
CTF杂项里碰到“WAV音频Python提取Flag”这个组合几乎每个玩CTF入门的人都会遇到一次。上周帮朋友看一道题题目只给了一个WAV文件耳机里听上去从头到尾就是“沙沙”的噪音语音内容完全没有。很多人卡在这就放弃了其实这就是典型的音频隐写题文件里藏了一段不可听的信息需要写Python脚本把藏在WAV采样数据最底层的比特抠出来Flag就藏在里面。这篇文章我会把这类题的完整思路和实操脚本拆开讲从WAV文件结构、常见隐写手法到一步步用Python还原数据适合刚开始玩CTF、对音频隐写还没有系统概念的朋友。我自己第一次做音频隐写题也走了不少弯路最开始的脚本提取出来全是乱码后来才搞明白是字节顺序和声道组合的问题。所以这篇文章不只是贴代码还会把为什么这样做、出题人为什么这样出、排查时从哪里下手都写清楚。看完之后你再碰到“噪音音频找Flag”的题应该能有一套固定的解题流程而不是拿到文件先盲试。1. 先搞懂WAV藏在哪Flag能藏哪些地方1.1 CTF音频题为什么偏爱WAVCTF里的音频隐写题最常见的就是WAV格式偶尔有MP3、FLAC但占比远不如WAV。核心原因在于WAV格式几乎没有有损压缩它内部存的是PCM裸采样数据。出题人想改哪个采样点就直接在对应字节上动手脚不需要经过编码器重新压缩。反过来看MP3本身做了大量频域压缩和量化很多高频细节和低比特差异会在编码过程中被丢弃如果往里藏LSB信息播放器重新解码之后隐藏数据往往已经面目全非。WAV的另一个优势是结构简单用Python的wave模块就能读用struct也能手工解析不需要引入复杂的第三方解码库。这让出题人和做题人的门槛都低了很多。对于刚入门CTF的人来说音频隐写题属于“不卡脑洞但卡代码量”的题目类型只要你能把WAV数据读出来、理解隐写的位操作Flag基本就能还原出来。这类题还有一个特点题目音频通常“很难听”。你听到的可能是白噪音、沙沙声、电流声或者一段完全听不懂的语音片段。这不是出题人在折磨你而是在暗示你信息不在听觉层而在数据层或频域层。一旦意识到这一点解题方向就清楚了一半。1.2 WAV文件的内部结构从RIFF到采样数据要写脚本提取隐写数据先得知道WAV文件里面到底长什么样。WAV文件的主体是RIFF格式整体结构可以拆成三部分RIFF文件头、fmt格式块、data数据块。RIFF头的前四个字节是ASCII字符“RIFF”表示这是一个RIFF容器紧接着四个字节是文件总长度再四个字节是“WAVE”表示文件类型。fmt块描述音频编码格式包含音频格式代码、声道数、采样率、数据速率等信息data块才是真正的采样数据也是我们做隐写分析主要关心的部分。偏移长度字节内容说明0x004RIFFRIFF容器标识0x044文件长度从下一个字节开始到文件末尾的长度0x084WAVEWAV标识0x0C4fmt fmt块起始标识0x104fmt块长度通常为16或180x142音频编码格式1表示PCM3表示IEEE float0x162声道数1单声道2双声道0x184采样率如44100、220500x1C4字节速率每秒数据量0x202块对齐每个采样帧的字节数0x222位深度如16位、8位0x244datadata块起始标识0x284data长度采样数据的字节数0x2CN采样数据真正的PCM数据看到这个表格不要有压力绝大多数情况下我们用Python的wave模块就能拿到帧数和采样参数不需要真的手工按偏移解析。但如果遇到畸形的WAV文件或者文件里被塞了额外的自定义块手工解析还是很有用的后面我会在排查章节详细说。1.3 隐藏Flag的常见套路总览音频隐写题虽然形式变化多端但核心套路其实就那么几种。提前在脑子里建立一个“套路清单”做题时能大幅缩短判断时间。隐藏方式文件表现常见工具提取方式LSB隐写听感大多是白噪音文件较大Python、Stegsolve提取采样数据的最低位频谱隐写听感可以是任何东西常伴随沙沙声Audacity转到频谱图观察摩斯码有明显“滴答”声或频谱图显条纹Audacity、Python转文字解码元数据隐藏正常音频文件属性有信息strings、exiftool搜索字符串声道隐藏双声道一边正常一边异常Audacity、Python分离声道分析文件拼接文件尾部有额外数据binwalk提取尾部数据波形图倒置/拼接波形有不自然截断Audacity观察波形并处理这几种方式经常叠加出现比如一道题先用LSB隐藏了一个压缩包压缩包里又有一张图片图片里再用LSB隐藏真正的Flag。这就是为什么做Misc题时“组合拳”思维很重要不要看到一个方向就死磕到底。2. 核心细节拆解从“噪音”里分辨三种常见隐写2.1 LSB隐写藏在比特底部的秘密LSB全称Least Significant Bit最低有效位。16位PCM音频的每个采样点占两个字节例如采样值28108的十六进制是0x6DCC二进制是0110110111001100。其中最后一位改成0或者1采样值变成28108或28109对应的电压只变化一个最小单位人耳几乎不可能分辨出来。这就像在一本很厚的书里每一页最后一个字的右下角用铅笔轻轻画个小点只有知道“每一页最后一个字”这个规则的人才能把那些字连起来读出隐藏信息。音频LSB隐写也是一样的逻辑把Flag的每个ASCII字符拆成8个bit依次替换掉音频采样数据每个字节的最低位播放时完全听不出差别但脚本一跑数据就出来了。写提取脚本时最基本的做法是把data块里的采样数据全部读出来对每个字节取最低位每8个位拼成一个新字节再把这些字节写进文件。出来的可能是一段明文Flag字符串也可能是一个图片、压缩包或另一段音频需要进一步处理。这个逻辑通顺之后再看变体题目就会容易很多。值得多说一句LSB隐写不一定按“每个字节都取最低位”来藏。出题人可能改成每隔几个字节藏一位也可能先对数据做异或运算再藏甚至把数据藏在第二个声道的偶数帧里。所以在看到提取结果乱码或者文件头不对时不要急着放弃先调整“读取规则”。2.2 频谱隐写用Audacity把“噪音”画成图频谱隐写是另一种高频考法。它的原理是基于短时傅里叶变换将时域的声音信号按时间切片变换到频域横轴是时间、纵轴是频率颜色深浅表示能量强度。播放时听起来像噪音的东西在频谱图上可能直接显示出一串字母、一幅二维码或者摩斯码的明暗条纹。做题时我建议先用Audacity打开音频从波形视图切换到频谱图视图。菜单路径是“多视图”或“频谱图”不同版本略有差异。如果频谱图上出现清晰的字母、图案或者规则的条状间隔那基本可以确定是频谱隐写。有时候还需要调整频谱图的显示参数比如窗口大小Window size和着色方案默认参数看不清时可以多切换几种尝试。有个很容易被忽略的细节有些题目把白色图案画在黑色背景上但切换成灰度配色方案后会更明显。如果你在默认彩色模式下什么都看不到记得试试灰度或经典配色。这属于那种“没人告诉你就要卡很久”的小技巧。2.3 元数据隐写与声道信息不是所有隐藏都藏在数据层。有些题特别“善良”直接把Flag写在WAV文件的元数据里。用strings命令或exiftool扫一遍就能看到。虽然这种方式听起来简单粗暴但在比赛中很常见尤其是入门题。所以拿到音频文件之后先跑一遍strings再去做复杂的LSB分析永远是个好习惯。声道信息也是容易被忽略的点。WAV支持多声道一个双声道音频可能左声道是正常音乐右声道是一段经过变调的摩斯码。用Audacity把声道拆分出来单独播放或者用Python按声道分别提取采样点往往能发现问题。甚至有些题目会把两段音频叠加后再做相位反转让一段信息隐藏在差信号中这种情况需要把左声道和右声道相减才能还原。2.4 采样率、时长和波形畸变里的线索一个看起来正常的WAV文件如果采样率被从44100改成22050音频播放速度会变慢一半音调也变低。反过来如果采样率被调得很高原本很长的一段摩斯码会被压缩成一秒内的尖锐声。遇到这种情况直接听是听不出什么名堂的需要把采样率调回正常范围再听。有些题目还会把Flag藏在某个时间区段内。比如整段音频前20秒是正常音乐第21秒到第23秒插入了极短的一段摩斯码之后又是正常音乐。人耳很容易忽略这一两秒的细节但波形图上会有明显的不自然变化。用Audacity看波形时如果发现有“形状突变”的地方那段区域大概率有问题。还有一类题会在波形上直接画出图案比如把一个正弦波的包络调成“FLAG”的形状。这种题用眼睛看波形就能发现因为正常音乐或噪音的波形包络不会呈现出规整的字母形状。当你看到波形有强烈规律性异常时马上就该意识到这是故意画出来的。3. Python脚本实操一条龙提取Flag3.1 环境准备与基础读WAV做这类题Python 3.x 是基础环境。如果你电脑里还没装Python到官网下载安装包安装时记得勾选“Add Python to PATH”否则命令行里敲python找不到命令。WAV读取用标准库wave就够了完全不需要额外安装第三方库如果后面要做频谱分析再考虑装numpy、scipy、matplotlib。拿到一个WAV文件后先用wave模块读取基本参数。这一步的目的是确认文件的采样宽度、声道数、采样率、帧数这些信息帮助判断这是不是一个标准的PCM WAV文件。import wave wav_path noise.wav with wave.open(wav_path, rb) as w: channels w.getnchannels() sampwidth w.getsampwidth() framerate w.getframerate() n_frames w.getnframes() print(声道数:, channels) print(采样宽度(字节):, sampwidth) print(采样率:, framerate) print(帧数:, n_frames) print(总采样字节数:, n_frames * sampwidth * channels)如果这一步报错“unknown format”说明文件内部的音频编码格式不是标准PCM可能是IEEE float或其他格式。这种情况下wave模块默认读取方式会失败需要手工解析。但不用慌大多数CTF题还是PCM格式其他格式属于进阶玩法。3.2 写第一个脚本提取LSB并拼出可读字符串读取参数没问题之后直接写LSB提取脚本。整体思路把所有采样数据的原始字节读进来遍历每个字节取最低位再把每8个最低位拼成一个新字节写到一个输出文件中。import wave import re def extract_lsb(wav_path, output_path): with wave.open(wav_path, rb) as w: n_frames w.getnframes() raw_data w.readframes(n_frames) # 收集所有采样字节的最低位 bits [] for byte in raw_data: bits.append(byte 1) # 每8个bit组成一个字节 extracted bytearray() for i in range(0, len(bits) - 7, 8): value 0 for bit in bits[i:i 8]: value (value 1) | bit extracted.append(value) with open(output_path, wb) as f: f.write(extracted) # 尝试直接搜索flag字符串 text extracted.decode(latin-1, errorsignore) matches re.findall(rflag\{[^}]\}|FLAG\{[^}]\}|ctf\{[^}]\}, text, re.I) if matches: print(找到Flag:, matches) else: print(没有直接找到明文Flag检查输出文件的类型) print(输出文件头(Hex):, extracted[:16].hex()) extract_lsb(noise.wav, lsb_output.bin)这个脚本里最值得关注的是byte 1这个操作。Python中是按位与byte 1的结果就是最低位。如果最低位是1结果就是1如果最低位是0结果就是0。后面的内层循环把8个bit按顺序拼成一个字节这里的顺序是“第一个bit作为最高位第八个bit作为最低位”。如果出题人按相反顺序写入这里也要改成反向拼接。解码时用latin-1而不是utf-8是因为提取出来的字节不一定构成合法的UTF-8序列用latin-1可以让每个字节都映射到一个字符不会因为非法序列而中断搜索。这个细节在做二进制隐写提取时很实用。3.3 判断提取结果从文件头识别隐藏文件如果脚本运行后没有直接输出Flag你手里会多一个lsb_output.bin文件。这时候先别急着打开看一眼它的文件头。文件头是文件内容的前几个字节不同格式的文件有不同的固定标识俗称“魔数”。文件格式文件头HexPNG89 50 4E 47JPGFF D8 FF E0ZIP50 4B 03 04RAR52 61 72 21PDF25 50 44 46GIF47 49 46 38在Linux和macOS上直接执行file lsb_output.bin就能识别Windows上可以装一个第三方工具或者用Hex编辑器查看。如果文件头是50 4B 03 04说明提取出来的东西是个ZIP压缩包直接改后缀为.zip解压如果是89 50 4E 47说明是PNG图片改成.png就能打开。如果文件头是完全随机的字节没有匹配任何已知格式那你提取出来的可能是一段加密数据或者需要换其他的LSB提取规则。比如把“从每个字节取最低位”改成“从每个字节取最高位”或者尝试每隔一个字节取一次。比赛里最常见的两个变体就是把最低位改到其他位置以及调整字节拼接顺序。3.4 频谱隐写处理用Python直接画出频谱图很多人在这一步会选择Audacity图形界面操作这当然没问题。但如果你想用Python一条龙处理也可以用scipy和matplotlib把频谱图画出来。import numpy as np import wave import matplotlib.pyplot as plt from scipy.signal import spectrogram wav_path noise.wav with wave.open(wav_path, rb) as w: rate w.getframerate() data w.readframes(w.getnframes()) # 转成int16数组 audio np.frombuffer(data, dtypenp.int16) # 计算频谱 f, t, Sxx spectrogram(audio, rate, nperseg1024, noverlap512) # 显示灰度频谱 plt.figure(figsize(12, 4)) plt.pcolormesh(t, f, 10 * np.log10(Sxx 1e-10), shadingauto, cmapgray) plt.xlabel(Time (s)) plt.ylabel(Frequency (Hz)) plt.tight_layout() plt.show()这个脚本的原理是短时傅里叶变换。声音信号在极短的时间窗口内可以看作平稳信号spectrogram把整段音频切成很多小窗口对每个窗口做傅里叶变换最终得到“随时间变化的频谱”。人的耳朵对频率的感知有上限但数据里高频部分往往藏着肉眼可见的信息。nperseg是窗口大小数值越小时间分辨率越高频率分辨率越低数值越大频率分辨率越高但时间上会变模糊。碰到看不清的情况可以调这个参数。cmapgray用灰阶显示很多频谱隐写题的图案在灰阶下更清楚这也是Audacity里切换到频谱图之后建议试试不同配色的原因。3.5 摩斯码自动解码辅助处理如果频谱图或波形图中出现明显的点划条纹下一步就是摩斯码解码。常规做法是人眼观察把点和划抄下来然后对照摩斯码表翻译。但写一个Python小工具来自动翻译也不难尤其是当你已经能提取出一串类似“.... . .-.. .-.. ---”的点划文本时。morse_dict { .-: 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 morse_decode(morse_str): words morse_str.strip().split( / ) result [] for word in words: chars word.split() result.append(.join(morse_dict.get(c, ?) for c in chars)) return .join(result) print(morse_decode(.... . .-.. .-.. --- / .-- --- .-. .-.. -..))这段代码只是辅助实际做题时摩斯码往往要和音频波形结合着看。我的习惯是先用Audacity查看波形把明显的长音和短音标记成划和点空隔区分单词再拿这段脚本翻译。如果你能直接从频谱图或波形中看出点划序列手动翻译和脚本翻译都行。4. 常见问题与排查技巧实录4.1 运行脚本遇到的典型报错刚开始写音频隐写脚本的人最容易碰到几个报错这里按我实际遇到过的频率排个序。第一个是wave.Error: unknown format: 3。这个错误的意思是这个WAV文件内部不是标准PCM编码而是IEEE float格式也就是32位浮点采样。标准wave模块默认只支持PCM遇到这种文件可以直接用struct手工解析或者用soundfile库读取。CTF里这种格式不常见但如果比赛题目刻意刁钻就会用这个卡人。第二个是解码出来发现所有字节全是0x00或者全是0xFF。这通常意味着你读取数据的偏移不对。WAV文件标准的文件头是44字节但有些文件在RIFF头和data块之间还有额外的自定义块比如LIST块、fact块。如果盲写代码跳过固定44字节就取采样数据拿到的可能是“半个头半个数据”的混合内容LSB提取结果自然全是乱码。解决办法是先定位data字符串在文件中的位置再从这个位置之后开始读数据。第三个是提取出来的文件无法打开。如果文件头显示是ZIP但解压时提示“文件损坏”很可能是你提前了或者延后了几个字节才开始取数据。试着把输出文件从头偏移1个字节、2个字节再重新提取往往能解决问题。这就好比你从一段排列整齐的队伍里开始报数报数起点错了整个队伍的结果全部错位。4.2 提取出来全乱码怎么办提取出来的内容如果完全无法识别先不要怀疑脚本逻辑出错而是考虑数据组织方式被改动过。最常见的改动是位顺序被反转。标准LSB提取从每个字节的最低位开始每8个bit拼成一个字节时通常把第一个bit当作最高位。如果出题人反向写入你就得反向拼接。另外一个大坑是声道顺序。双声道WAV的采样数据是交错存储的也就是“左声道采样、右声道采样、左声道采样、右声道采样”这样排列。如果你把所有字节连着取最低位实际上相当于把左右声道的数据混在一起。有些题目只把信息藏在左声道这时你应该先把左声道单独取出来再做LSB提取。还有一类情况是提取结果被加密了。比如提取出一个ZIP包但输入密码才能打开密码可能藏在音频的频谱图里、元数据里或者题目描述里。遇到这种情况说明你已经完成了大半剩下的工作是找密码而不是继续分析提取数据本身。密码常见的藏匿位置是文件名、题目描述、音频注释、频谱图角落。4.3 快速排查流程速查表做音频隐写题时我的排查流程早就固化成了一套固定习惯按表操作能少走很多弯路。这张表不一定覆盖所有题目但覆盖了绝大多数入门到中级难度的Misc音频题。观察现象优先怀疑方向具体操作音频听感像噪音或沙沙声LSB隐写跑Python脚本提取最低位音频连续几秒有明显滴答声摩斯码Audacity波形图中读取点划波形无异常但频谱有图案频谱隐写频谱图切换配色观察strings搜不到任何可读内容数据被压缩或加密binwalk扫描拼接文件文件尾部有额外数据或特征头文件拼接binwalk分离尾部数据双声道一边正常一边异常声道隐藏分离声道分别分析完整音频中有一段特殊区域时间区段隐藏放大波形检查突变位置看到“噪音”两个字时尤其要记住一点噪音类WAV题目十有八九是LSB隐写因为LSB隐写会让音频听感接近白噪音刚好掩护了隐藏数据。而频谱隐写虽然也会让音频听起来发闷但通常会有明显的“节奏感”。做题时先听一遍、再看波形、再开频谱、最后跑脚本这个顺序最不容易漏细节。5. 写在最后这套思路的扩展用法音频隐写不只在CTF比赛中有用玩数字取证、研究信息隐藏、甚至日常检查一个来源不明的音频文件是否被改动过都需要类似的思路。我个人的体会是解题最关键的地方其实不在脚本本身而在于“先判断信息藏在哪个维度”。音频本身就是多维的有时域、有频域、有声道、有数据层每个维度都能藏东西。判断错了维度写再多脚本都是白搭。一个小技巧拿到WAV文件后先用strings扫一遍再把文件拖进Audacity看波形和频谱最后才写Python脚本。这个流程看起来简单但能筛掉一半以上的干扰项。还有一个经验是碰到提取结果是二进制文件时一定要养成“先看文件头”的习惯十六进制开头几个字节能直接告诉你接下来该解压、该看图片还是该继续跑脚本。如果你也想加深理解可以自己做一个实验拿一段WAV音频用文中的LSB脚本把你的名字藏进去再用提取脚本读出来。走一遍“隐藏-提取”的闭环之后再看网上那些弯弯绕绕的音频隐写题目思路会清晰很多。后续如果再碰到更复杂的变形比如多文件组合隐写、音频叠加隐写、基于相位的信息隐藏你也有能力顺着这个框架自己去推演了。