资讯动态

CTF音频杂项出题脚本:频谱、LSB与相位隐藏的自动生成

发布时间:2026/10/14 5:23:33 来源:尧图企业网站定制
简介面向CTF选手与网络安全学习者的音频杂项出题脚本包聚焦WAV音频隐写信息的构造与提取既适合参赛者练手也适合出题人参考出题思路。压缩包共3个文件两个WAV音频样本作为隐写载体一个Python脚本负责从音频数据中生成或还原flag整体仅140KB部署轻量。脚本常见逻辑涉及wave、numpy等音频处理库包含傅里叶变换、滤波或字符串加密等步骤读者需要结合Audacity、SoX等工具对音频做频谱与波形分析再跟踪脚本中的数据流与输出结果理解隐藏信息如何嵌入和还原。通过亲手运行脚本并对比音频样本可以完整掌握音频杂项题从出题到解密的闭环思路并沉淀出可复用的音频隐写模板与Python代码审计技巧。已有1931人学习适合具备基础Python编程和音频处理概念的参赛者提升实战能力。1. CTF音频杂项出题脚本为什么出题比解题更容易翻车作为CTF出题人很多人觉得音频杂项最好出找段BGM、塞个flag、传上去就行。这个想法害人不浅。真正出过题的人都有这种经历题目生成时自己挺满意开赛后却发现解出的字符串与预期不一致甚至同一个附件用两套工具能解出两种不同结果。CTF音频杂项出题脚本就是把频谱图题、LSB隐写题、倒放题、相位题这几类高频音频题目的生成流程固化为一套可复制的代码工具输入一个flag和题型自动输出WAV、控制隐写参数、留下解码线索并在发布前自动反向解一遍验证可解性。适合三类人给赛事出题的技术负责人、安全团队的内部赛出题人以及想拿音频隐写当教学案例的培训讲师。下文按题型逐类拆开从合成原理、生成脚本到参数边界一次讲清。2. 频谱图隐写与摩斯码生成脚本从文本到信号摩斯题是音频杂项里最好的入门题。出题逻辑一句话就能说清把flag转成摩斯码再把“点”“划”翻译成一段纯音的时有时无参赛者用频谱图看到明暗条纹后解码。生成脚本的核心是三件事构造一个频率稳定的正弦波、按点/划时长切分、用静音段隔开字符。下面这段代码是我平时生成摩斯题的最小脚本参数都摆在文件顶部。import numpy as np import wave # 摩斯码映射表A-Z、0-9空格映射为停顿 MORSE { 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: ----. } # 出题参数单位时长、载波频率、采样率 DOT 0.06 # 一个“点”的时长秒 DASH DOT * 3 # 一个“划”的时长秒 SYM_GAP DOT # 点划之间的间隙 CHAR_GAP DOT * 3 # 字符之间的停顿 WORD_GAP DOT * 7 # 单词之间的停顿 FREQ 880.0 # 载波频率Hz SAMPLE_RATE 44100 # 采样率 def beep(duration): t np.linspace(0, duration, int(SAMPLE_RATE * duration), endpointFalse) env np.ones_like(t) ramp int(SAMPLE_RATE * 0.005) # 5ms 淡入淡出避免首尾咔哒声 env[:ramp] np.linspace(0, 1, ramp) env[-ramp:] np.linspace(1, 0, ramp) return (np.sin(2 * np.pi * FREQ * t) * env * 0.6).astype(np.float32) def text_to_audio(text, outfile): parts [] for c in text.upper(): if c : # 空格使用七倍点长作为单词级停顿 parts.append(np.zeros(int(SAMPLE_RATE * WORD_GAP), dtypenp.float32)) continue code MORSE[c] for i, sym in enumerate(code): if i ! 0: # 同一字符内部点划之间只留一个点长 parts.append(np.zeros(int(SAMPLE_RATE * SYM_GAP), dtypenp.float32)) parts.append(beep(DOT if sym . else DASH)) # 每个字符结束后留三个点长停顿 parts.append(np.zeros(int(SAMPLE_RATE * CHAR_GAP), dtypenp.float32)) audio np.concatenate(parts) # 浮点转16位PCM才能写入标准WAV pcm (audio * 32767).astype(np.int16) with wave.open(outfile, wb) as wf: wf.setnchannels(1) wf.setsampwidth(2) wf.setframerate(SAMPLE_RATE) wf.writeframes(pcm.tobytes()) if __name__ __main__: text_to_audio(FLAG{MORSE_IS_FUN}, morse.wav)代码逻辑分三段看。第一段是摩斯映射表注意空格被映射成单词级停顿这样解码结果能保留单词分隔。第二段beep函数生成带淡入淡出包络的正弦波直接输出连续正弦波会在首尾产生“咔哒”声在频谱图上表现为额外的竖条纹干扰解码加上5毫秒斜坡后波形干净得多。第三段是拼接逻辑字符内点划用SYM_GAP隔开字符间用CHAR_GAP文本里的空格用WORD_GAP。最后把浮点数乘32767并转int16写入PCM WAV。这里最影响题目成败的参数是DOT。它决定一切相对时长划长、字符停顿、单词停顿都从它推导。建议放在0.05到0.08秒之间。小于0.04秒时点信号过短频谱图上看到的只是一段短线解码器很容易把点误判成噪声大于0.15秒时题目稳是稳了但一个20字符的flag会生成十几秒信号体验明显变差。FREQ选880赫兹或1000赫兹附近都可以太低会和环境低频噪声混在一起太高了多数便携设备的扬声器衰减明显。2.1 写一盘能听的摩斯码节点时长与字符分隔的计算出题时最容易忽略的是“节点时长”这个概念。摩斯码不是简单地把点设成0.06秒就完事它要求一个符号流里所有时间关系都来自同一个基准。常见做法是让DASH DOT * 3、SYM_GAP DOT、CHAR_GAP DOT * 3这同时也是大多数解码器默认的“初学者速度”预设。如果你用固定秒数去分别定义点、划、间隙比如点0.06、划0.15、间隙0.08解码器在速度判定上就会出现误差点与划的比例关系不再整齐最终的解码结果可能莫名其妙多一个字符或少一个字符。字符之间的停顿特别值得单独拿出来说。很多出题脚本会在字符之间只留一个点长这样解码出来的字符串会全部粘连成一个长串无法区分字母边界。我一般会把CHAR_GAP设成三倍点长这样字符串里每个字符的边界都很清晰。如果flag里有下划线或花括号这类无法直接对应摩斯码的符号就在映射表里补上对应编码或者干脆用单词级停顿加“斜杠”来分隔。不要让这些特殊字符映射成空白停顿否则解码工具会把它们丢掉输出和原始flag对不上。2.2 频谱题的三组必调参数载波、音量与FFT窗口生成脚本里还有一组参数容易被忽略载波幅度、包络斜坡长度、以及参赛者打开频谱图时的FFT窗口大小。第一组是我的常用值直接给出一张参数速查表。参数推荐值设得太小设得太大点长 DOT0.05–0.08秒点被当成噪声或毛刺音频拖长节奏感差载波频率400–1500Hz与低频环境噪声混合高频衰减后谱线变淡峰值振幅0.4–0.6频谱图对比度低削波产生谐波干扰包络斜坡3–8ms首尾出现咔哒声点信号能量被吃掉FFT窗口1024/2048频率分辨率差时间分辨率差FFT窗口是出题人自己要在本地验证的不要指望参赛者会去调。窗口取512时同一根频率线在频谱图上会糊成一块宽带点划边界不清窗口取4096时时间分辨率下降快速的点会被平滑掉看起来像一条连续亮带。我一般用1024到2048之间的窗口做最终校验能清晰看到点、划和停顿三个状态就算合格。2.3 摩斯码题为什么容易误判噪声底与解码阈值摩斯解码工具本质是对信号包络取阈值样本能量高于阈值的片段记为“有信号”低于阈值记为“无信号”。这意味着你生成的音频不能只有正弦波本身干净还要保证“无信号”段足够安静。一个常见翻车点是淡入淡出斜坡太长或整段加了混响导致静音段残留能量一直挂在阈值附近解码工具把这些残留判成点输出一个几十个字符的乱码串。另一个隐蔽问题是振幅不统一。beep函数生成的波形峰值都一样但如果音频后面又做过响度归一化或加了一点背景音解码器对“有信号/无信号”的判定会受到背景噪声影响。我的做法是在生成摩斯题时全程不让任何噪声进入信道不加BGM、不加混响、只保留纯正弦和静音。先保证这道题在解码器眼里边界分明再考虑用BGM增加“伪装感”。竞赛题优先保证可解性伪装有价值但不能以牺牲可解性为代价。3. LSB隐写与载波音频生成把字符串写进低比特位音频LSB隐写在杂项里属于“考脚本能力”的题。16位PCM采样的范围是-32768到32767最低位只贡献1的振幅差人耳几乎听不出变化。把payload按位拆开逐采样点替换最低位改动后的音频在听感上和原文件基本一致但用脚本提取最低位就能还原隐藏数据。这个题型拼的不是信号知识而是能不能写出一个干净的存取脚本并正确处理采样位深和载荷长度。3.1 用Python把payload写进16位采样写入与反向提取下面是我经常用的LSB写入脚本。关键设计是把“载荷长度”作为前两个字节写进开头这样提取时就能知道在哪里停止。import wave import numpy as np def text_to_bits(s): # 转成utf-8字节流前2字节记录原始长度避免多解 raw s.encode(utf-8) length len(raw).to_bytes(2, big) payload length raw bits .join(f{b:08b} for b in payload) return [int(x) for x in bits] def embed_lsb(carrier_file, secret, out_file): with wave.open(carrier_file, rb) as wf: params wf.getparams() frames wf.readframes(wf.getnframes()) # 原始字节转成int16采样数组 samples np.frombuffer(frames, dtypenp.int16).copy() bits text_to_bits(secret) if len(bits) len(samples): raise ValueError(载体太短放不下这段秘密信息) # 把每个采样点的最低位替换为1个bit先清0再置1 for i, bit in enumerate(bits): samples[i] (samples[i] ~1) | bit with wave.open(out_file, wb) as wf: wf.setparams(params) wf.writeframes(samples.tobytes()) def extract_lsb(stego_file): with wave.open(stego_file, rb) as wf: frames wf.readframes(wf.getnframes()) samples np.frombuffer(frames, dtypenp.int16) # 提取最低位并每8个bit拼回1个字节 bits [s 1 for s in samples] byts [] for i in range(0, len(bits) - 7, 8): b 0 for j in range(8): b (b 1) | bits[i j] byts.append(b) # 先读长度再按长度取payload length (byts[0] 8) | byts[1] payload bytes(byts[2:2 length]) return payload.decode(utf-8)写入那段代码每个采样点只改最低位改动幅度最大是1所以听感无差别。samples[i] ~1先把最低位清零然后用| bit写入当前bit。这里有个性能上的细节用Python的for循环逐点写bit一个100万采样点的WAV大概需要一到两秒对出题来说完全够用。如果你要做批量生成可以改成numpy的位运算批量操作速度能快几十倍但逻辑不够直白不建议新手一开始就上向量化写法。提取脚本从样本最低位取回bit流再按8个bit拼字节。前两个字节是长度字段所以正确解析出原始字符串。这里有个很重要的坑如果直接把整段音频的所有最低位都解出来并打印你会发现尾部有一大堆随机bit组成的“垃圾字符”因为载体音频只有前几百个采样点存了payload。这就是为什么长度字段必须写进去——它让提取有明确的终止边界也避免了“解出不同结果”的多解争议。3.2 载体音频的选型与预处理响度、位深与压缩留白不是随便一个WAV都能当LSB载体。我用过几个不同来源的载体踩出的规律值得记录。纯正弦波、纯静音段占比过高的音频不能用因为最低位会呈现规律性的0/1模式用统计工具一眼就能识别出“这个文件被动过手脚”。一段正常音乐即便不做任何隐写其最低位的分布也近乎随机只有把payload嵌入后前几百个bit才会出现非随机性。要让这道题有迷惑性需要载体本身有足够的动态范围。载体属性推荐条件说明位深16位或24位8位音频只有256级改最低位占比太大声道单声道/双声道均可双声道隐写容量翻倍但要注意左右声道一致性响度峰值0.5左右不要用已经削波的素材有效时长至少10秒每秒只能存约5.5KB单声道16位来源未压缩WAV用过MP3转WAV的素材最低位全是被压缩算法污染的数据预处理通常只需要两步一是用sox或Python把峰值归一化到0.5左右二是确认文件是16位PCM。如果原素材是24位np.frombuffer的dtype要改成np.int32处理逻辑不变但sample_width会不一样。我见过有脚本把24位文件按16位读结果整个采样数组错位嵌入位置全部偏移反向提取自然失败。这是一个很值得写进出题自检清单的细节。3.3 反向提取校验防止出题时自己先翻车每次生成完LSB题目都要跑一次反向提取而且不要只跑成功路径。我习惯把提取结果打出来再和原始flag做一个逐字符比对不一致就立刻调整参数。常见的不一致有两种。第一种是头部长度字段读错导致后半段payload被截断或越界这种通常是写入时用的编码和提取时不一致比如写入用UTF-8提取用ASCII解码中文就报错。第二种是载体音频自带静音开头隐写数据从第0个采样点写入后播放器或二次转码把这些头部静音剪掉参赛者拿到的文件前几百个采样点位移了一个点整个bit流全部错位。为避免第二种情况我会在嵌写之前先把载体音频“预处理成不含静音的截断版本”或者从音频的第2000个采样之后开始写并把这个偏移量写进题目提示里。也有一种更稳妥的做法在payload末尾加一组固定结束标记提取时搜索到标记就停止这样即使长度字段被某种格式转换破坏也能靠结束标记找回边界。出题脚本里加这两个保险LSB题基本不会翻车。4. 倒放与相位隐藏题目两招需要“听”的进阶玩法音频倒放和相位隐藏是比频谱图和LSB更难一档的题型因为它们要求参赛者不只是“看频谱”或“跑脚本”还要对音频的时间和空间结构有概念。倒放题考的是时间轴反转相位题考的是左右声道的关系。这两类题的出题脚本同样可以写成标准工具并且有非常值得注意的边界坑。4.1 倒放题怎么出整帧翻转脚本与提示音设计倒放题的做法是把一段完整的语音或旋律从尾到头反转隐藏的flag在反转播放时才听得清。常见出题套路是给参赛者一个看似正常的句子但播放方向反了再放一段“正常版本”做对照提示他们用音频编辑器的反转功能处理。生成脚本很简单但要小心帧边界问题。import wave import numpy as np def reverse_audio(infile, outfile): with wave.open(infile, rb) as wf: params wf.getparams() raw wf.readframes(wf.getnframes()) # 注意必须转成int16采样数组再反转不能对bytes直接[::-1] samples np.frombuffer(raw, dtypenp.int16) samples samples[::-1].copy() with wave.open(outfile, wb) as wf: wf.setparams(params) wf.writeframes(samples.tobytes())这里最大的坑是raw[::-1]。直接在字节串上反转会把每个16位采样点的低字节和高字节对调播放出来是一段刺耳的噪声而不是“反着播的语音”。正确做法是先用np.frombuffer(raw, dtypenp.int16)把字节流解释成采样点数组再在采样点维度反转。反转后要.copy()因为[::-1]返回的是原数组的反向视图直接写writeframes会因非连续内存报出奇怪的格式错误。倒放题的关键在于提示设计。如果只有一个反转音频很多参赛者根本意识不到要倒放。我一般会在题目描述里放一句“试试从结尾开始听”或者把附件里的语音做成“前半段正着读一句话后半段倒着读另一句话”让参赛者产生对比。还有一个小技巧把倒放后的音频淡入淡出做得平滑一点避免开头和结尾出现突兀的截断噪声这种噪声会在听感上干扰对语音内容的判断。4.2 相位差分隐藏让flag藏在左右声道的和差里相位题稍微冷门原理却非常清晰。立体声文件里有左声道L和右声道R中置声道M和侧边声道S分别定义为M (L R) / 2和S (L - R) / 2。如果某个声音在左右声道完全同相它会在M里增强、在S里抵消如果完全反相则正好反过来。出题人可以把payload编码成“同相/反相”两种状态参赛者用音频编辑器把立体声分别合并为M和S就能在其中一个里看到隐藏的信号波形。import wave import numpy as np def phase_embed(secret, outfile, freq880.0, sr44100, bit_len512): # 把字符串编码为bit流 raw secret.encode(utf-8) length len(raw).to_bytes(2, big) payload length raw bits [int(x) for x in .join(f{b:08b} for b in payload)] # 每个bit占512个采样点载波用880Hz正弦 t np.arange(bit_len) / sr carrier np.sin(2 * np.pi * freq * t) * 0.5 left_chunks, right_chunks [], [] for bit in bits: if bit 1: # 左右反相信息落在侧边声道 left_chunks.append(carrier) right_chunks.append(-carrier) else: # 左右同相信息落在中置声道 left_chunks.append(carrier) right_chunks.append(carrier) L np.concatenate(left_chunks).astype(np.float32) R np.concatenate(right_chunks).astype(np.float32) # 双声道16位WAV左右各自转成PCM frame np.stack([L, R], axis1) pcm (frame * 32767).astype(np.int16) with wave.open(outfile, wb) as wf: wf.setnchannels(2) wf.setsampwidth(2) wf.setframerate(sr) wf.writeframes(pcm.tobytes())这段代码用同一段正弦波构造左右声道bit为1时右声道反相bit为0时右声道同相。如果参赛者做L - R反相的部分会增强同相的部分会抵消正好还原出bit流如果做L R则会看到互补的另一份数据。这里有个细节同相/反相这两种状态在听感上区别很小直接用耳机几乎听不出差异所以这道题在竞赛里通常被归为“分析题”而不是“听力题”。出题提示要明确引导参赛者去查看声道的和差信号否则很多人会卡在“不知道看哪”这一步。相位题的难度主要来自参数选择。bit_len决定每个bit占多少采样点512个采样点是44.1kHz下约11.6毫秒人耳能隐约听到一连串“嗡嗡”的节奏如果设成128每个bit只有约3毫秒提取时相位判断的容错率会变低。我给赛题用的经验值是bit_len在256到1024之间太少提取容易错太多音频被拉得很长。解码时用的也是同样的窗口长度两边不匹配就会出现一个bit错位后全部错乱的问题。4.3 进阶题的难度校准一道题应该让多少人解出来一道合格的音频题难度应该落在“大部分参赛者经过工具学习后能解但解出时间有梯度”这个区间。我的校准方法是拿四类题型做横向对比然后按赛事定位选择组合。题型预估难度主要考点适合轮次频谱图摩斯入门频谱查看、摩斯解码新手赛、预赛LSB隐写入门到进阶脚本提取、隐写分析工具预赛到决赛音频倒放进阶时间轴反转、听感辨音决赛、练习赛相位差分进阶声道运算、和中置侧边决赛、高阶题出题人容易犯的错误是把相位题和LSB题混在同一场比赛里导致两道题解题思路大量重叠。比如参赛者刚用L - R解出相位题转头又发现LSB题的载体也是立体声就会尝试用同样手法分析浪费很多时间。我一般会在一个比赛里只安排两种音频题且保证题型差异明显一个偏信号观察一个偏数据提取。这样既覆盖了基础知识又不会让音频题占据过多解题时长。5. 音频出题避坑清单五条高频翻车与排查记录这一章不做空泛的“注意事项”直接列我反复遇到的五类翻车。每一条都是“现象→原因→解决”的结构照着自己检查一遍能省掉开赛后的紧急公告。5.1 翻车一题目出完自己反向解却解不出来现象用生成脚本产出WAV后拿解码脚本反向提取输出是一段乱码或者摩斯解码结果和原始flag差一个字符。原因写入时使用了A编码提取时用了B编码最常见的是字符串长度字段写错位数比如flag超过255个字节但长度字段只留了1个字节。解决所有生成脚本和反向校验脚本放在同一个仓库里共用同一个text_to_bits函数生成后立刻跑一遍“解出→字符串比对”比对失败立刻停止发布。5.2 翻车二频谱图被压缩糊掉flag永远看不清现象出题时WAV在本地看频谱清清楚楚传上平台后变成MP3参赛者打开频谱图看到一片模糊亮带点和划完全分不开。原因平台上传做了有损压缩短时瞬态和静音段被抹平。解决分发附件一律用WAV或FLAC并在题目说明里写清楚“附件为无损音频请勿转码”。如果平台强制转码就放弃频谱题改成LSB题。压缩后重采样还会导致字节流错位对LSB题是致命损伤对摩斯这类“看频谱”的题则是信号糊掉。5.3 翻车三音量太小听不见或者响度拉满直接削波现象倒放题用了很轻的配音参赛者把系统音量开到最大也听不清隐藏句子另一次测试把正弦波峰值设到0.99播放时出现明显毛刺。原因出题环境用的是监听音箱参赛者用的是普通耳机动态范围不一样峰值过高时后续做响度标准化会触顶削波。解决语音类音频先做响度归一化峰值压在0.6左右正弦波信号峰值压在0.4到0.5。题库里可以专门放一段“参考文件”让参赛者校准音量但不要依赖这个校准。5.4 翻车四多解问题同一个附件解出两种答案现象开赛后有人用工具A解出flag{helloworld}有人用工具B解出flag{hello_world}两边都觉得自己是对的。原因摩斯码的字符间距不符合标准不同解码器的自动纠错路径不同LSB题没有写长度字段提取脚本把尾部噪声也当成payload。解决生成时固定使用标准间距比例字符间用三倍点长payload开头写长度同时用FLAG{和}作为包裹标记提取时先找包裹再做字符串切分避免尾部垃圾数据混入。5.5 翻车五文件流转后隐写丢失参赛者拿到的是坏文件现象同一道LSB题出题人本地解出来正常参赛者下载后却是乱码甚至文件打不开。原因分发系统对音频做了重编码16位变8位、立体声变单声道、44.1kHz变16kHz最低位信息被全部破坏。解决发布前写一个分发前自检脚本读取文件参数并断言getnchannels()、getsampwidth()、getframerate()和生成时一致。还有一道防线把附件打包成压缩包并附上哈希值让参赛者核对文件完整性。数字水印类的隐写题最怕链路转换这是最不值得翻车的地方。6. 脚本化进阶一键生成整套音频题并自动校验多道题型都跑通后下一步是把它们收进一个流程里。我自己的脚本结构很简单一个主入口接收flag和题目类型调用对应生成函数然后统一执行“反向校验”。生成目录里还会附带一份参数记录把随机种子、载波频率、隐写偏移量全部写进文件方便赛后复盘或补发提示。def gen_all(flag, out_dir, seed202403): rng np.random.default_rng(seed) # 把同一份flag分别生成为四种题型 gen_morse(flag, f{out_dir}/morse.wav) gen_lsb(flag, f{out_dir}/carrier.wav, f{out_dir}/lsb.wav) gen_reverse(flag, f{out_dir}/reverse.wav) gen_phase(flag, f{out_dir}/phase.wav, rngrng) # 自动校验每种题型都反向解出答案并比对 assert verify_morse(f{out_dir}/morse.wav) flag assert extract_lsb(f{out_dir}/lsb.wav) flag assert verify_phase(f{out_dir}/phase.wav) flag随机种子是个容易被忽略的细节。生成相位题时如果用到随机化的载波频率或噪声固定种子能让整道题可复现——同一份题目无论哪次生成参数都一致。遇到开赛后需要出题组提供“官方解法”的场景这套参数记录就是最可靠的依据。校验函数不能只返回“能解出”还要检查解出的字符串是否和flag完全相等我至少会做一次整字节比对而不是人工目测。最后讲一个我坚持的习惯自动化校验跑完我还会用播放器从头到尾听一遍成品音频。脚本只能保证数据层面可解但听感上的奇怪噪声、音量失衡、开头结尾的爆音只能靠耳朵发现。这些年靠这个习惯拦下过好几道差点上线的烂题。音频题终究是要给人类选手做的机器说“通过”不等于人听着舒服。希望这个习惯和这套脚本能帮到你也帮你的赛题少一点开赛后的紧急补丁。本文还有配套的精品资源点击获取

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

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

免费获取报价 →
↑