你有没有遇到过这种情况:一道CTF杂项题,下载下来是个WAV文件,打开听了一耳朵,全是沙沙的噪音,键盘敲了半小时毫无头绪。其实这类"噪音题"恰恰是音频隐写里最常见的套路,而破解它的钥匙,就是Python脚本加上对WAV底层结构的理解。这篇博文就围绕WAV隐藏Flag这件事,把我实际做题时用到的思路、工具和脚本完整拆给你看,适合刚接触CTF杂项、或者想在音频隐写上再往前走一步的人。
1. WAV在CTF杂项里的特殊地位:格式越简单,藏法越夸张
1.1 WAV的结构就四个字:裸数据块
WAV在CTF里出镜率太高,根本原因在于它的数据是无压缩的PCM采样。你看到的RIFF、fmt、data这些块,说白了就是一层很薄的包装,里面装的是实实在在的振幅整数序列。
一个标准的WAV文件由三部分组成:
- RIFF头:告诉解析器这是WAVE格式,文件总大小是多少。
- fmt块:记录音频格式(通常是PCM)、声道数、采样率、位深、字节率、块对齐。
- data块:真正的采样数据,按声道交织排列。
以最常见的16bit单声道WAV为例,data块里每一个采样点就是一个int16整数,取值在-32768到32767之间。采样率决定时间轴上每秒有多少个点,位深决定每个点的取值范围。这样一个"数值序列"意味着你可以对每一个采样点做任意数学操作——改最低位、反转波形、切分频段、叠加信号,而且因为是无损格式,改动后被人耳察觉的难度会低很多。
相比之下,MP3、AAC经过有损压缩后,很多高频细节和低位数据会被直接抹掉,出题人想精确控制每个采样点就没那么容易。所以WAV天然成了隐写题的"藏宝图"。
1.2 常见"噪音题"的种类地图
我按实际做题经验,把WAV噪音题大致分成四类:
| 藏法 | 听感特征 | 适合的排查手段 |
|---|---|---|
| 频谱图隐写 | 刺耳、规律性高频,或整段像怪异的机械声 | 频谱图、滤波 |
| LSB隐写 | 听起来像底噪,但静音段可能残留细微波动 | 位平面提取、Python逐位分析 |
| 摩斯/双音多频/慢扫描电视 | 滴答声、电话按键声、类似传真机的节奏声 | 波形包络、频谱图、专门解码器 |
| 倒放/声道隐藏 | 听起来像倒着说话,或某一耳有明显的非对称感 | 波形反转、声道分离 |
这些类别不是互斥的,很多题会混合使用。但拿到题后,第一步如果直接写脚本去提取,大概率会在错误的目录上浪费大量时间。正确的做法是先做一轮"情报收集"。
2. 先靠耳朵和传统工具做情报收集,再决定用哪种Python方案
2.1 听感和波形特征对照表
耳朵其实是最快的"初筛器"。我每次拿到WAV文件,都会先完整听一遍,然后对着Audacity的波形图再扫一遍。听和看的特征能帮我快速锁定方向:
- 如果听到的是规律的滴答声,重点查摩斯电码,波形图上会显示出一段段短促的脉冲。
- 如果听到的是电话拨号音,那是DTMF双音多频,用Audacity看频谱能看到双频叠加的峰值。
- 如果声音倒着读有明显语言感,把波形反转再听一次。
- 如果整段就是均匀的白噪音,频谱图里有文字轮廓的概率非常高。
- 如果某个声道的音量明显比另一个低,或者某个频段异常突出,那就需要做声道分离和频段放大。
听觉主观性很强,所以一定要配合波形和频谱来印证。很多新手一上来就把文件丢给脚本,结果脚本跑了半天,提取出来的东西毫无规律,就是因为没做这一步。
2.2 strings、binwalk、foremost三板斧
在开始音频分析之前,先用文件分析三件套扫一遍,成本最低,收益却可能最高:
file noise.wav strings noise.wav | grep -i "flag" binwalk noise.wav foremost noise.wav -o extractedfile看真实格式,防止出题人把PNG或者ZIP改了扩展名再传上来。strings直接扫描文件里的可打印字符串,很多时候Flag就在文件尾部,连隐写都算不上。binwalk和foremost用来识别并分离隐藏在WAV文件末尾的压缩包、图片等其他文件。
这个"附加文件"手法比你想的多得多。出题人经常用WAV做壳,在data块结束后直接拼接一个ZIP或PNG,利用的是大多数播放器会忽略尾部多余数据的特性。strings如果发现了PK开头的内容,直接binwalk分离就行。
2.3 Audacity看频谱的快速操作
如果文件确实是一个"正经"WAV,排除掉附加文件后,就要进入音频本体分析。Audacity里最常用的操作是:
- 导入文件后,选中整个音轨。
- 点击左侧音频轨道名旁边的倒三角,切换视图为"频谱图"。
- 调整频谱图的亮度/对比度,直到文字或图形轮廓清晰。
这不需要任何脚本,几秒钟就能发现频谱图隐写。很多题目到这里就已经结束了,剩下的是截图提交Flag。但如果频谱图干干净净,或者只有一团模糊,那就该轮到Python登场了。
3. Python读WAV的核心操作:从帧数据到numpy数组
3.1 wave模块先看参数
Python标准库自带的wave模块是了解文件参数的最轻量方式:
import wave with wave.open('noise.wav', 'rb') as wav: print('声道数:', wav.getnchannels()) print('采样宽度(字节):', wav.getsampwidth()) print('采样率:', wav.getframerate()) print('帧数:', wav.getnframes()) print('参数:', wav.getparams())输出里最关键的是声道数、采样率、位深。双声道意味着后面做LSB提取时要分别处理左右声道;采样率可以被出题人改成奇怪的值,导致频谱图比例异常;位深如果是8bit,最低位对音量的影响会比16bit明显得多,更容易被听出来。
readframes()返回的是原始字节,如果你直接看这些字节,会是一堆难以解读的ASCII字符。必须转换成整数序列,才能做数学运算。
3.2 scipy.io.wavfile和numpy配合更顺手
我日常用得最多的其实是scipy.io.wavfile.read,它会直接返回一个numpy数组:
from scipy.io import wavfile import numpy as np sr, data = wavfile.read('noise.wav') print('采样率:', sr) print('数据类型:', data.dtype) print('形状:', data.shape)如果文件是单声道,data是一维数组;双声道则是二维数组,每一列对应一个声道。拿到numpy数组后,可以非常方便地做切片、转置、逐位运算、傅里叶变换,这些都是后续所有隐写提取的基础。
实际操作中,我通常会先做一次归一化或者取绝对值,观察波形的包络。如果某段区域一直有规律的小幅波动,但听感上又接近静音,那就值得怀疑是隐写数据了。
3.3 为什么位操作必须靠Python而不是Audacity
Audacity擅长看波形、频段编辑、降噪,但它不擅长逐位操作。LSB隐写这种东西,人眼很难在波形图上看出名堂,因为每个采样点只改了一个二进制位,振幅变化微乎其微。而Python可以精确提取某一位平面,把所有采样点的第0位拼成字节,再转换为ASCII字符串。这种"逐位拆解"能力,是图形界面工具给不了的。
另外,Python的自动化能力还体现在批量处理上:如果一段WAV里每隔N个采样才藏一个字节,手工在Audacity里测量间隔几乎不可能,但在Python里做一次循环就能解决。
4. 藏在频谱里的Flag:从噪音中"画"出一行字
4.1 频谱图隐写的原理
频谱图隐写的基本思路是:把一段文字或图片的像素信息,转化为特定时间点上的频率峰值。每一个字符的边缘、笔画、颜色深浅,对应着音频在不同频率上的能量差异。最终的音频听起来就像一组缺乏音乐感的"鸣叫声"或者"电子噪音",因为正常音乐不会出现如此规则、持续的窄带频率组合。
比如要隐藏一个"Flag"字样,可以在时间轴上把字母拆成像素列,每一列对应一个时间片,再用某几个固定频率的组合来代表该列的亮暗。解码时只需要绘制频谱图,人眼就能直接读出文字。
这属于"面向人眼的隐写":信息编码在频域,只要可视化就能还原。
4.2 用Python绘制清晰频谱图
当你怀疑某个WAV是频谱图隐写时,可以这样快速生成频谱图:
import numpy as np import matplotlib.pyplot as plt from scipy.io import wavfile from scipy.signal import spectrogram sr, data = wavfile.read('noise.wav') if data.ndim > 1: data = data.mean(axis=1) data = data.astype(np.float64) data /= np.max(np.abs(data)) + 1e-8 f, t, Sxx = spectrogram( data, fs=sr, window='hann', nperseg=1024, noverlap=512, mode='magnitude' ) plt.figure(figsize=(14, 6)) plt.pcolormesh(t, f, 10 * np.log10(Sxx + 1e-10), shading='auto', cmap='inferno') plt.ylim(0, min(sr // 2, 12000)) plt.xlabel('Time (s)') plt.ylabel('Frequency (Hz)') plt.savefig('spectrogram.png', dpi=200)这里有几个直接影响成图的参数:
nperseg:FFT窗口大小。窗口越大,频率分辨率越高,但时间分辨率越低。如果频谱图看不清字体的横向边缘,适当减小窗口试试。noverlap:窗口重叠量。重叠越多,时间轴越平滑,减少文字断裂感。cmap:颜色映射。同一条数据,inferno和gray看到的对比度不同,我习惯多换几个配色对比。ylim:限制频率范围,可以去掉无用低频和高频噪声的干扰。
4.3 文字不明显时怎么办
有时候第一次生成的频谱图只能看到一个模糊轮廓,这时候别急着否定。常见的增强手段是:
- 先做带通滤波,只保留目标频段。比如文字集中在4000-6000Hz,先用numpy的FFT把其他频段置零再还原,噪声会大幅减少。
- 调整动态范围。频谱图默认会把很宽的能量范围全映射到颜色,导致弱信号被淹没。把数值限制在某个分贝窗口内再绘图,效果往往天差地别。
- 提高DPI、放大局部区域。很多解不出来的情况只是因为图太小、肉眼看不清楚。
我在实际做题时会写一个非常简单的循环,自动生成多组nperseg、cmap和ylim组合的频谱图,然后快速翻阅。这种方法看起来笨,但对"噪音题"非常有效,因为出题人往往只觉得人能听出来就行,没考虑对比度问题。
5. 藏在采样最低位的Flag:LSB提取脚本与调参思路
5.1 LSB隐写为什么"听不出来"
LSB是Least Significant Bit的缩写,即最低有效位。对16bit音频来说,每个采样点的取值范围是-32768到32767,改动最低位,最多只改变振幅的1/65536,约等于0.0015%。
用一个直观类比:把一张1000元面值的钞票改掉小额零头的一角,在快速流动的市场里几乎没人会发现。LSB隐写就是这样,把要隐藏的旗标字符串按bit拆分,逐位替换采样值的最低位,原始音频和修改后的音频听感几乎完全一样。
但要说明一点,如果出题人不只改最低位,而是把每个采样的低8位全部替换成ASCII码,那产生的噪声会明显一些,甚至波形图上会出现锯齿状的小幅跳动。即便如此,在正常音量下依然具有欺骗性。
5.2 通用LSB提取脚本
以下这个脚本我一直在用,特点是支持调位平面、调声道和字节序,能满足大部分音频LSB题目:
import numpy as np from scipy.io import wavfile def extract_lsb(path, channel=0, bit_plane=0, reverse_bits=False): sr, data = wavfile.read(path) if data.ndim > 1: if data.shape[1] <= channel: raise ValueError('channel index out of range') samples = data[:, channel] else: samples = data if np.issubdtype(samples.dtype, np.signedinteger): samples = samples.astype(np.int64) else: samples = samples.astype(np.int64) bits = ((samples >> bit_plane) & 1).astype(np.uint8) if reverse_bits: bits = 1 - bits bit_str = ''.join(bits.astype(str).tolist()) output = [] for i in range(0, len(bit_str) - 7, 8): byte = bit_str[i:i + 8] val = int(byte, 2) if 32 <= val <= 126: output.append(chr(val)) else: output.append('.') return ''.join(output) if __name__ == '__main__': for ch in range(2): for bp in range(8): result = extract_lsb('noise.wav', channel=ch, bit_plane=bp) if 'flag' in result.lower() or 'ctf' in result.lower(): print(f'channel={ch}, bit_plane={bp}:') print(result)脚本的调试思路是:如果直接提取某个位平面就出现一段连续可读的字符串,那基本就是Flag了。如果全是一些不可读字符,再考虑其他位平面、其他声道,或者调整位顺序。
5.3 输出乱码时的三步排查
我第一次写LSB提取脚本时,提取出来全是乱码,卡了很久。后来总结出三步排查法:
- 查位序:有的题目是低位在前(LSB-first),有的题目是按字节MSB优先方式存储的隐性字符串。也就是说,bits拼接成字节的顺序可能是反的。可以尝试把整个bit串顺序反转,再按每8位分割。这不是玄学,而是不同的出题脚本在
bin(i)补零时方向不同所致。 - 查位平面:LSB不一定只藏第0位,有些题为了缩短数据长度,会使用第1位甚至第2位。如果第0位提取出来是随机噪声,而第1位提取出来有规律,说明出题人把bit位置偏移了。
- 查声道:双声道文件只处理默认的左声道很容易漏掉线索。我提到过,先对两个声道分别提取,再用结果里的可读字符串做判断。
上面脚本已经内置了声道、位平面、位序倒置三个参数,跑一遍就能覆盖大部分情况。
6. 三条差点让我放弃的隐蔽暗坑
6.1 字节序和位序错位:提取出来全是乱码
LSB提取中最常见的"看起来像对了但实际完全不对"的情形,就是位序问题。你提取出一串01000011,按二进制转十进制是67,对应大写字母C;但如果出题人的脚本是按从左到右直接拼成的8位二进制,而这个8位二进制本身是倒着存的,那么实际数据可能是11000010,对应194,超出了常见ASCII的可见范围。
解决方案是写出一个"双模式"脚本:把每个采样取出的bit,既按顺序拼,也把每个采样内部的位序反转后再拼,对比哪个输出更可读。我在实际做题时,通常直接用前面的脚本跑两轮,一轮reverse_bits=False,一轮reverse_bits=True,看哪个里面出现flag{。
6.2 Flag藏在右声道,我只看了左声道
有一次我在一道题上浪费了将近一小时。波形、频谱、LSB全查了一遍,左声道提取出来的数据完全是噪声。最后抱着试试看的心态,把channel参数从0改成1,结果第一行输出就是flag{right_channel_is_important}。
双声道文件在读取时,先按帧交织存储,每一帧内先左后右。用wavfile.read读到的是二维数组,第0列是左声道,第1列是右声道。如果出题人把信息只放在右声道,或者在左右声道各放一半信息,只分析单声道就废了。
所以建议每次拿到双声道WAV,先分别用Python画两个声道的波形和频谱,肉眼对比一下差异。如果差异明显,信息大概率藏在其中一个声道里。
6.3 伪装成噪音的摩斯电码:用包络判断高低电平
摩斯电码并不是一定以"滴答"的形式出现,有些题会刻意用噪音来掩盖这一节奏。听感上是一团规律的"沙沙",但波形图里其实藏着明显的高低振幅段。把音频包络提取出来,再用阈值判断"有信号段"和"无信号段"的时长,就能还原摩斯电码。
import numpy as np from scipy.io import wavfile sr, data = wavfile.read('noise.wav') data = data.astype(np.float64) envelope = np.abs(data) window = int(sr * 0.01) kernel = np.ones(window) / window smoothed = np.convolve(envelope, kernel, mode='same') threshold = smoothed.mean() * 1.2 binary_signal = smoothed > threshold拿到这个二值序列后,再统计每段连续高电平和连续低电平的长度。根据常识设定时间单位:最短的高电平段记为"点",三倍于最短高电平段记为"划"。最后把点、划和间隔翻译成字母,就是Flag了。
这个思路的本质,是把一个"听起来像噪音"的问题,降维成"二进制时序序列"问题。一旦完成降维,剩下的就是最基础的编码翻译。
6.4 扩展名和真实格式不一致
最后再提一个小坑:file noise.wav输出如果是JPEG image data,那压根不用做音频分析。把扩展名改成.jpg直接打开就行。这种情况在入门题里非常常见,算是一个"基础中的基础"陷阱。真要踩进去,不是技术问题,而是习惯问题。
写在最后的实操习惯
音频隐写题做多了之后,我养成了一个固定流程:拿到文件先备份,再用file、strings、binwalk各扫一遍,然后听音频、看波形、看频谱,确认不是附加文件和频谱图隐写后,才进入Python脚本阶段。脚本会同时覆盖声道、位平面和位序三个变量。这套流程帮我节省了大量试错时间。
如果你目前在CTF杂项上还处于"拿到WAV不知从哪下手"的状态,建议先别急着背脚本。找一个简单的频谱图隐写样例,用Audacity看清楚原理,再找一个LSB隐写样例,用Python脚本跑通闭环。两种最常见的藏法认熟之后,再遇到"噪音题",你的第一反应就不再是"好吵",而是"这里头有东西"。