资讯动态

CTF音频杂项出题脚本:从隐写到验题的自动化指南

发布时间:2026/10/14 23:05:22 来源:尧图企业网站定制
简介面向CTF选手与安全爱好者这是一份音频杂项方向的出题与练习资源包含WAV音频样本及Python脚本makeflag.py。压缩包为zip格式共3个文件2个wav音频文件ha-gs4.wav、ha-d4.wav和1个py脚本makeflag.py整体仅140KB轻量易用已有1931人学习下载。资源核心价值在于通过真实出题脚本与对应音频展示音频隐写与flag生成逻辑脚本中涉及音频读取、信号处理、傅里叶变换等操作解题者需结合频谱分析、倒频谱或时域分析提取隐藏信息同时可借助wave、numpy、scipy等库理解数据变换过程。适合作为CTF杂项训练样例或出题参考既能练习音频隐写分析也能学习如何用Python构建音频类flag生成流程。1. 音频杂项出题脚本把“听不出”变成“看得出”的自动化题面工厂CTF 音频杂项的题面有个反直觉的规律大部分题不是用耳朵解出来的而是用眼睛解出来的。选手拿到一个 WAV 文件第一件事永远是拖进 Audacity 看频谱图、看波形、看通道差异而不是点播放键。这意味着出题人真正在做的事是把 flag 藏在某个“肉眼可见但人耳无感”的维度里。手工用 Audacity 一点一点拼出这种题面既慢又容易翻车于是就有了专门干这件事的出题脚本。本文要讲的就是这类脚本怎么设计、怎么嵌入、怎么验题以及我踩过的那些坑。2. 出题脚本解决什么问题从手工拼接到脚本化生成的选型逻辑2.1 音频杂项题型的分类与自动化优先级先给题型做个归类后面所有脚本设计都围绕这张表展开。按 CTF 里音频杂项的常见套路大致能分成五类频谱隐写flag 呈现在 spectrogram 上、LSB 隐写藏在采样值最低位、音调序列摩斯电码、DTMF 双音多频、音频变形倒放、变速、变调、拼接、信号处理题滤波、相位、回声、双通道差异。前四类占了实际赛题里的绝大多数也是脚本化收益最高的部分。题型难度区间脚本化程度人工介入点频谱隐写入门-进阶高参数化后基本全自动频谱图对比度需目检LSB 隐写入门高纯脚本即可载体音频需预先准备摩斯/DTMF入门-进阶高码表和节奏可调辨别速度需人耳确认音频变形入门-困难中变速/倒放简单变调需要额外库变形后音质需试听信号处理题进阶-困难低依赖具体掩蔽手法需按题目单独调参把自动化优先级排出来之后脚本的边界就清楚了脚本负责生成“载体 嵌入 基础验证”而“这题听起来/看起来是否合理”留给人来判断。常见的错误做法是试图把一切都自动化结果生成出来的频谱图糊成一片或者摩斯速度快得连出题人自己都听不出来。2.2 为什么选 WAV 做载体格式与编码的取舍出题脚本的载体格式几乎只能选 WAV这不是喜好问题是编码层面的必然。WAV 用 PCM 编码存储原始采样值每个样本的数值可以被精确读取和修改而 MP3、AAC、OGG 都属于有损压缩解码后得到的采样值已经和编码前不一致LSB 这类依赖精确位操作的隐写在有损格式上直接失效。频谱隐写虽然能在压缩后幸存一部分但高频细节会先被编码器砍掉亮线的边缘会发虚。所以我的选型原则是脚本默认输出 WAV如果赛题要求给 MP3那必须用专门的 MP3 隐写工具比如针对 MP3 位元的方案不能拿 PCM 脚本硬套。文件体积的问题不用太担心单声道 16bit、44.1kHz 采样率下一分钟音频大约 5.3MB作为附件题完全可接受。采样率和位深也要在脚本里显式固定不能依赖环境默认值。脚本里我统一用 44100Hz、16bit、单声道理由有三个这是 WAV 最常见到几乎不会引发兼容性问题的参数单声道避免左右声道差异类题目被误伤差分题可以后续再特殊处理16bit 的样本范围足够承载 LSB 嵌入而不产生可听噪声。2.3 脚本的总体架构容器生成、隐写嵌入、验题闭环整个出题脚本的架构我按“三段式”来组织。第一段是容器生成器负责产出干净的载体音频可能是一段纯音、一段白噪声、或者一段预置的音乐采样第二段是嵌入器把 flag 转成比特流按题型写入载体第三段是验题器用独立的提取逻辑反向解出 flag同时渲染频谱图 PNG 供人工目检。这三段必须分开写不能揉成一个函数。原因是出题脚本最大的风险不是“生成不出来”而是“生成出来没人能解”——嵌入和提取逻辑如果写在同一处等于自己验自己很容易把错误的编码方式当成标准答案。市面上一些现成脚本把这三件事耦合在一起用起来是省事但踩坑之后排查极其痛苦属于典型的黑匣子。3. 搭建出题脚本四种嵌入方式的核心实现与参数说明3.1 频谱隐写把 flag 画成声谱图上的亮线频谱隐写是最常见的音频杂项题型选手在 Audacity 里切到频谱图视图会看到一段亮暗交替的条纹按亮暗解出二进制再转 ASCII 就是 flag。这种题的实现核心是在指定频点上用正弦波画“亮”不加信号只留背景噪声画“暗”。import numpy as np import wave def flag_to_bits(flag: str) - list[int]: # 先转字节再按 MSB 顺序逐 bit 展开 raw flag.encode(utf-8) bits [] for b in raw: for i in range(8): bits.append((b (7 - i)) 1) return bits def synth_spectrogram_wav(flag: str, sr: int 44100, carrier_freq: float 5000.0, block_len: float 0.4, noise_amp: float 0.02, signal_amp: float 0.5, output: str spectrogram_flag.wav): bits flag_to_bits(flag) n int(sr * block_len) t np.arange(n) / sr carrier np.sin(2 * np.pi * carrier_freq * t) # 每个 bit 对应一个时间块1 有载波、0 无载波 parts [] rng np.random.default_rng(20240101) # 局部随机源见第 4 章 for b in bits: noise rng.normal(0, noise_amp, n) if b 1: parts.append(signal_amp * carrier noise) else: parts.append(noise) data np.concatenate(parts) pcm (data / np.max(np.abs(data)) * 32767).astype(np.int16) with wave.open(output, wb) as wf: wf.setnchannels(1) wf.setsampwidth(2) wf.setframerate(sr) wf.writeframes(pcm.tobytes())这段代码里值得调的参数是block_len、carrier_freq和noise_amp / signal_amp的比值。block_len决定每个 bit 在时间轴上的宽度0.4 秒会让亮线清晰可数短到 0.1 秒时频谱图上的条纹会挤在一起适合做进阶题carrier_freq决定亮线在频谱图上的纵向位置5kHz 处在 Audacity 默认视图的中部视野舒服2kHz 以下容易被低频环境噪声盖住8kHz 以上对部分笔记本扬声器播放敏感但频谱图读取不受影响noise_amp与signal_amp的对比就是亮暗对比度0.02 对 0.5 约 28dB 的动态差在频谱图上非常明显。生成之后必须做一次反向目检。我习惯把生成的 WAV 渲染成频谱图 PNG确认亮暗条纹能够被肉眼区分。渲染用 scipy 的 spectrogram 即可import matplotlib matplotlib.use(Agg) import matplotlib.pyplot as plt from scipy import signal import wave import numpy as np def render_spectrogram(wav_path: str, out_png: str): with wave.open(wav_path, rb) as wf: sr wf.getframerate() data np.frombuffer(wf.readframes(wf.getnframes()), dtypenp.int16) f, t, Sxx signal.spectrogram(data, sr, nperseg1024, noverlap512) plt.figure(figsize(12, 4)) plt.pcolormesh(t, f, 10 * np.log10(Sxx 1e-12), shadinggouraud) plt.ylim(0, 12000) plt.xlabel(time (s)) plt.ylabel(Frequency (Hz)) plt.savefig(out_png, dpi120, bbox_inchestight)nperseg1024是频谱分辨率和时间分辨率的折中点。窗口越大频率分得越细但时间轴会变糊1024 在 44.1kHz 采样率下大约对应 43Hz 的频率分辨率5kHz 附近的亮线有绝对宽度足够辨认。渲染时ylim裁剪到 12kHz是因为人耳和常见播放设备的高频上限就在这个范围附近裁掉无用频段能让条纹更醒目。3.2 LSB 隐写把 flag 写进采样值的最低有效位LSB 隐写是唯一完全“看不见也听不见”的题型选手要用脚本逐位提取。WAV 的 PCM 采样值在 16bit 模式下是范围 [-32768, 32767] 的整数把最低位根据 flag 比特置 0 或置 1人耳完全无法察觉但可以对每个样本精确还原。import wave import numpy as np def embed_lsb(cover_path: str, flag: str, output: str): with wave.open(cover_path, rb) as wf: params wf.getparams() frames wf.readframes(wf.getnframes()) samples np.frombuffer(frames, dtypenp.int16).copy() flag_bytes flag.encode(utf-8) # 头部写 1 字节长度限制 flag 不超过 255 字节 header_bits [] for i in range(8): header_bits.append((len(flag_bytes) (7 - i)) 1) flag_bits flag_to_bits(flag) all_bits header_bits flag_bits if len(all_bits) len(samples): raise ValueError(载体音频样本数不足请换更长的音频) for i, b in enumerate(all_bits): # int16 是最低位Python 对负数做按位与/或时按补码处理结果正确 samples[i] (samples[i] ~1) | b with wave.open(output, wb) as wf: wf.setparams(params) wf.writeframes(samples.tobytes())这题的坑集中在int16的有符号处理上。提取时如果先把样本转成 float 再取整最低位信息就丢了正确做法是直接从 WAV 帧数据里用np.frombuffer(..., dtypenp.int16)读成整数数组。flag_to_bits复用了上面频谱题的函数这样全脚本的比特序保持一致不会出现这边 MSB 那边 LSB 的灾难。载体音频的选择对 LSB 题影响很小因为改最低位几乎不影响听感但载体时长决定了容量44.1kHz 单声道 16bit WAV每秒有 44100 个样本一个 20 字节的 flag 只需要 168 个样本随便一段 3 秒的音频就够。常见的失败场景是用了特别短的音效文件当载体生成时没报错但选手提取时发现样本不足——所以我在脚本里加了显式长度校验。3.3 摩斯电码与 DTMF把 flag 变成人耳可辨的音调序列摩斯电码题在 CTF 里属于“入门但极其经典”的存在选手听出点划节奏后查表解 flag。脚本要做的核心事情有两件把 flag 字符映射成点划序列再按时间参数合成音调。MORSE_MAP { A: .-, B: -..., C: -.-., D: -.., E: ., F: ..-., G: --., H: ...., I: .., J: .---, K: -.-, L: .-.., M: --, N: -., O: ---, P: .--., Q: --.-, R: .-., S: ..., T: -, U: ..-, V: ...-, W: .--, X: -..-, Y: -.--, Z: --.., 0: -----, 1: .----, 2: ..---, 3: ...--, 4: ....-, 5: ....., 6: -...., 7: --..., 8: ---.., 9: ----. } def synth_morse_wav(flag: str, dot_ms: int 60, dash_ms: int 180, gap_ms: int 120, tone_freq: float 800.0, sr: int 44100, output: str morse_flag.wav): seq [] for ch in flag.upper(): if ch : seq.append((gap, gap_ms * 3)) continue code MORSE_MAP.get(ch, ) for sym in code: seq.append((dot if sym . else dash, dot_ms if sym . else dash_ms)) seq.append((gap, gap_ms)) seq.append((gap, gap_ms * 2)) samples [] n_dot int(sr * dot_ms / 1000) t_dot np.arange(n_dot) / sr tone np.sin(2 * np.pi * tone_freq * t_dot) for kind, dur_ms in seq: n int(sr * dur_ms / 1000) samples.append(tone[:n] if kind ! gap else np.zeros(n)) data np.concatenate(samples) pcm (data / np.max(np.abs(data)) * 32767 * 0.8).astype(np.int16) with wave.open(output, wb) as wf: wf.setnchannels(1) wf.setsampwidth(2) wf.setframerate(sr) wf.writeframes(pcm.tobytes())摩斯的黄金参数是dot_ms60, dash_ms180, gap_ms120即点 60ms、划 180ms、符号间间隔 120ms。这是业余无线电标准速度的区间人耳容易识别初学者也不用反复听。想要加难度就把dot_ms缩到 40ms 以下或者混入相同频段的噪声。另一个技巧是不要用纯正弦音可以在 800Hz 载波上叠加 1200Hz 的弱泛音让音色不那么生硬这属于出题体验优化不影响解题。DTMF 和摩斯的差别在于每个符号是双音叠加而非单音。DTMF 的标准频率表只有八个频率对应十六个按键脚本实现时把两个正弦波相加即可。DTMF 题适合把 flag 的数字部分编成电话号码我记得有一次出练习卷把 flag 的数字段拉长到 11 位选手解出来发现是个手机号解完还顺手拨了一下——这种细节往往能让整道题的体验分拉满但要注意不要诱导任何真实的联系方式出现只当音频信号处理即可。3.4 变形题倒放、变速、拼接与频段滤波的简单实现变形题的核心是“把 flag 藏在被处理的语音/音乐里”。倒放是最常见的处理方式选手把音频反转过来就能听到正常语音。倒放的实现极其简单一行samples[::-1]就能完成。def reverse_wav(input_path: str, output_path: str): with wave.open(input_path, rb) as wf: params wf.getparams() frames wf.readframes(wf.getnframes()) samples np.frombuffer(frames, dtypenp.int16).copy() reversed_samples samples[::-1] with wave.open(output_path, wb) as wf: wf.setparams(params) wf.writeframes(reversed_samples.tobytes()) def slow_down_wav(input_path: str, output_path: str, ratio: float 0.7): # ratio 1 表示变慢这里用重采样实现音高会随速度变化 from scipy.signal import resample_poly with wave.open(input_path, rb) as wf: params wf.getparams() sr wf.getframerate() frames wf.readframes(wf.getnframes()) samples np.frombuffer(frames, dtypenp.int16) up, down 10, int(10 * ratio) # 保证 up/down 互为整数比例 slowed resample_poly(samples, up, down) slowed_pcm slowed.astype(np.int16) with wave.open(output_path, wb) as wf: wf.setparams((params.channels, params.sampwidth, int(sr * ratio), len(slowed_pcm), params.comptype, params.compname)) wf.writeframes(slowed_pcm.tobytes())变速这里有一个常见的概念混淆改变播放速度有两种做法一种直接重采样音高跟着变像磁带机另一种是时间拉伸音高不变像变速齿轮。直接重采样用resample_poly就够了时间拉伸需要额外依赖音频处理库。CTF 里大多数“慢放题”用的是重采样效果因为 Audacity 的“改变速度”功能就是这么干的选手在 Audacity 里还原时也方便。频段滤波题相对冷门做法是用带通滤波器把一句话限制在某个频段内听起来像隔着墙说话还原方式是用 Audacity 的均衡器反向放大被压制的频段。这类题脚本化收益低因为每道题要选的频段和目标语音都不一样我通常只在定制题里手动调参不会写进批量出题脚本里。4. 做成一条出题流水线参数配置、随机化与验题闭环4.1 用配置文件管理难度档位把散落的参数收拢进一个配置结构是脚本从“能跑”到“能用”的关键一步。我用 Python 的 dataclass 组织所有参数一个配置对象贯穿生成、嵌入、验证三个阶段。from dataclasses import dataclass, field dataclass class ChallengeConfig: style: str spectrogram # spectrogram / lsb / morse / reverse flag: str flag{demo_flag} seed: int 20240401 sr: int 44100 # 频谱题参数 carrier_freq: float 5000.0 block_len: float 0.4 signal_amp: float 0.5 noise_amp: float 0.02 # 摩斯题参数 dot_ms: int 60 dash_ms: int 180 gap_ms: int 120 tone_freq: float 800.0 # 文件输出 output_dir: str ./challenge render_preview: bool True配置驱动的好处是批量出题时可以通过循环改一个字段生成一组难度递进的题目比如只改block_len从 0.6 缩到 0.15就能做出“入门清晰版”到“地狱密集版”的同源题目。这里要提醒一点flag字段不能带引号之外的隐藏字符出题脚本里建议加一条assert flag.startswith(flag{)的校验防止直接 f-string 拼接时把花括号吃掉。4.2 确定性随机种子、场景与防碰撞随机性是出题脚本里最容易翻车的地方。我见过某位同行写批量生成脚本循环里用np.random.seed()固定随机数结果生成出来的多道题背景噪声完全相同——选手解出一道就能批量解出全部。这个问题的根源是全局随机种子在循环里被反复重置导致每道题拿到的是同一段噪声。正确的做法是使用局部随机生成器并显式注入种子派生class ChallengeGenerator: def __init__(self, config: ChallengeConfig): self.config config self.rng np.random.Generator(np.random.PCG64(config.seed)) def _make_noise(self, n: int): # 每次调用拿到的都是独立的噪声 return self.rng.normal(0, self.config.noise_amp, n) def generate(self): if self.config.style spectrogram: # 传入 flag使用 self.rng 产生噪声 ... elif self.config.style morse: ...np.random.Generator是 NumPy 较新推荐的方式它和旧的np.random.seed全局状态完全隔离。当你需要“同 seed 可复现”时只要同一个配置对象生成两次结果就完全一致当你需要批量不同题时循环外层给每道题一个递增的 seed 子串比如seed i生成结果互不干扰。这里有个细节不要让每道题的 seed 直接等于输入序号防止选手猜出规律常见做法是seed base_seed i * 1000003加个质数步长让序列看起来随机。4.3 验题脚本自动确认 flag 可提取、频谱图可目检验题闭环是整套脚本里我最坚持的部分。生成题面之后必须跑一个独立于嵌入逻辑的提取验证确认“用选手的视角能解出来”。嵌入器和提取器成对出现但提取器要从头单独实现不能用嵌入器的逆过程蒙混过关。def extract_spectrogram(wav_path: str) - str: from scipy import signal with wave.open(wav_path, rb) as wf: sr wf.getframerate() data np.frombuffer(wf.readframes(wf.getnframes()), dtypenp.int16) f, t, Sxx signal.spectrogram(data, sr, nperseg1024, noverlap512) # 找 5kHz 附近的能量峰值序列按时间块判断 1/0 freq_idx np.argmin(np.abs(f - 5000)) power Sxx[freq_idx, :] threshold np.median(power) * 1.5 # 有载波的块能量显著高于无载波 bits (power threshold).astype(int) # 按 block_len 聚合还原比特串再拼字节 ... def verify(wav_path: str, config: ChallengeConfig) - bool: if config.style spectrogram: extracted extract_spectrogram(wav_path) ok extracted.rstrip(\x00) config.flag return ok # lsb / morse 同理验题脚本还应该做三件附加检查一是文件时长正确性用wave.getnframes() / framerate算出实际时长防止block_len设置导致整段音频短得离谱二是文件大小在可接受范围比如超过 20MB 的附件在线上比赛里可能触发附件下载限制三是渲染频谱图 PNG 存档方便人工复核。5. 出题脚本的5个常见坑与排查路径5.1 频谱图上一片糊flag 线看不见现象生成的 WAV 在 Audacity 里看频谱图整片都是均匀的亮色亮暗条纹完全无法分辨。原因通常有两个一是signal_amp与noise_amp的差距不够大载波被噪声淹没二是render_spectrogram里 log 变换后的动态范围被压平暗块也显示成了中等亮度。解决先把noise_amp降到 0.005 以下试一次确认条纹出现如果渲染图还是平检查是不是用了线性色标而没有加10 * np.log10线性色标会把小能量差异全部压缩到同一个颜色区间。5.2 LSB 题被 MP3 转换毁掉现象出题时生成了 WAV 的 LSB 题提交前为了减小附件体积转成了 MP3选手用提取脚本解出来一堆乱码。原因MP3 的有损编码会把最低几位的高频抖动全部抹掉LSB 数据在编码阶段就丢失了任何提取逻辑都不可能还原。解决LSB 题禁止转有损格式附件就上 WAV如果平台强制限制附件大小那就缩小载体时长而不是转格式。这个坑属于不可逆损失没有后悔药唯一的预防手段是在脚本里对输出文件加一道格式校验用wave.open确认文件头是 PCM WAV不是伪装成.wav后缀的 MP3 内容。5.3 摩斯题速度快到连出题人都听不清现象dot_ms设为 30ms、间隔 60ms生成之后自己多听几遍都吃不准点划但要提交了才意识到。原因耳朵对短时音调的辨别能力有限30ms 的点在常见播放器里几乎成为一声咔嗒。解决摩斯题出完必须先给自己听一遍再打包。我个人的底线参数是dot_ms 40低于这个值除非题目特意要求结合频谱图看否则就是在为难选手而不是考察解码能力。想加难度优先降gap_ms而不是降dot_ms间隔小了容易连读但单音可辨性还保留着。5.4 验证脚本能提取选手却提不出来现象自己的验题脚本提取 flag 完全正确发给几个选手试解有人说提取出来是乱的。原因验题脚本里假设了某种工具链参数比如选手用scipy.io.wavfile.read读出来的是 float 数组与脚本里的 int16 数组直接按位运算结果自然是错的。解决在题目描述或提示里明确写出提取时的关键参数采样率、位深、单双声道并且在验题脚本里兼容两种读取方式。具体做法是提取前先判断数组 dtype如果是 float 就先还原成 int16这放在脚本里也就是三行的事但能救回一整个赛队的体验。5.5 固定 seed 后仍然“翻车”全局随机源冲突现象同一次调用两次生成频谱图的噪声背景完全一致但换一台机器或者换一个 Python 会话再跑结果变了。原因代码里可能混用了np.random.seed()全局状态和random模块的全局随机源或者某些第三方库内部使用了全局随机。全局随机源一旦被任何库代码污染固定 seed 就失效了。解决脚本从入口到内部函数一律使用局部np.random.Generator实例禁止调用全局np.random.seed。出题脚本应当是纯函数式的相同的配置对象输入产出 byte 级一致的 WAV 文件这是可复现出题的基本要求。6. 进阶技巧难度曲线、批量出题与人工复核习惯6.1 难度档位映射到参数区间给批量出题做难度梯度时不需要为每道题重写逻辑只要做一张参数映射表难度题型关键参数预期解题时间入门频谱block_len0.6, noise_amp0.0053-5 分钟进阶频谱噪声block_len0.2, noise_amp0.0510-15 分钟入门摩斯dot_ms80, 无混音5 分钟进阶摩斯白噪dot_ms40, SNR≈10dB15 分钟进阶倒放直接反转3 分钟困难倒放降采样3/4 倍速再反转10-20 分钟这套映射的核心原则是简单题给足信噪比和时长难题只加一个维度的干扰不要同时叠加频段滤波、变速、混噪、加密编码四个变量。一次只加一个干扰选手定位问题的难度会线性增长叠加太多变量会导致题目容错率极低解不出来时选手和出题人都很难复盘是哪里出了问题。6.2 批量出题与 flag 去重检查批量生成多套赛题时脚本还需要注意 flag 唯一性。循环生成时如果从同一个 flag 模板替换数字位要确保替换后的 flag 不与任何历史题目重复。我习惯在脚本里维护一个简单去重集合生成前先检查生成后把 flag 写入 sidecar JSON 文件。文件名也建议带难度和题型前缀比如spec_easy_01.wav、morse_hard_03.wav这样赛后复盘时一眼就能对应上参数配置。6.3 出题后的30秒人工复核清单脚本再完善我也不会跳过最后的人工复核。生成完一批题目我会按这个顺序过一遍先播放前 5 秒确认没有爆音或异常的直流偏置再看频谱图 PNG确认亮暗对比度和位置符合预期然后跑一次验题脚本确认 flag 可提取最后用另一个工具比如 Audacity 或 ffmpeg重新打开文件确认文件头正常、时长和声道数与配置一致。这套流程在一次自己出练习卷的批量生成里救过我有个批次因为噪声振幅写死导致所有题的频谱暗块被抬到接近亮块预览图一眼就抓出来了如果跳过复核直接交卷一道题会毁掉整场练习。建议每个用过这套脚本的人都养成这个习惯——脚本负责生成人负责把关希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑