CTF杂项WAV音频隐写全解析:从LSB到频谱图的Python实战指南

发布时间:2026/9/15 13:47:01
CTF杂项WAV音频隐写全解析:从LSB到频谱图的Python实战指南
你有没有遇到过这种情况一道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而不是AudacityAudacity擅长看波形、频段编辑、降噪但它不擅长逐位操作。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(axis1) data data.astype(np.float64) data / np.max(np.abs(data)) 1e-8 f, t, Sxx spectrogram( data, fssr, windowhann, nperseg1024, noverlap512, modemagnitude ) plt.figure(figsize(14, 6)) plt.pcolormesh(t, f, 10 * np.log10(Sxx 1e-10), shadingauto, cmapinferno) plt.ylim(0, min(sr // 2, 12000)) plt.xlabel(Time (s)) plt.ylabel(Frequency (Hz)) plt.savefig(spectrogram.png, dpi200)这里有几个直接影响成图的参数npersegFFT窗口大小。窗口越大频率分辨率越高但时间分辨率越低。如果频谱图看不清字体的横向边缘适当减小窗口试试。noverlap窗口重叠量。重叠越多时间轴越平滑减少文字断裂感。cmap颜色映射。同一条数据inferno和gray看到的对比度不同我习惯多换几个配色对比。ylim限制频率范围可以去掉无用低频和高频噪声的干扰。4.3 文字不明显时怎么办有时候第一次生成的频谱图只能看到一个模糊轮廓这时候别急着否定。常见的增强手段是先做带通滤波只保留目标频段。比如文字集中在4000-6000Hz先用numpy的FFT把其他频段置零再还原噪声会大幅减少。调整动态范围。频谱图默认会把很宽的能量范围全映射到颜色导致弱信号被淹没。把数值限制在某个分贝窗口内再绘图效果往往天差地别。提高DPI、放大局部区域。很多解不出来的情况只是因为图太小、肉眼看不清楚。我在实际做题时会写一个非常简单的循环自动生成多组nperseg、cmap和ylim组合的频谱图然后快速翻阅。这种方法看起来笨但对噪音题非常有效因为出题人往往只觉得人能听出来就行没考虑对比度问题。5. 藏在采样最低位的FlagLSB提取脚本与调参思路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, channel0, bit_plane0, reverse_bitsFalse): 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, channelch, bit_planebp) if flag in result.lower() or ctf in result.lower(): print(fchannel{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_bitsFalse一轮reverse_bitsTrue看哪个里面出现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, modesame) 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脚本跑通闭环。两种最常见的藏法认熟之后再遇到噪音题你的第一反应就不再是好吵而是这里头有东西。