拿到一个WAV音频文件,先别急着戴上耳机去听。CTF杂项题里的音频,尤其是带“噪音”标签的那种,你戴着耳机循环半小时,听到的依然只是滋滋啦啦的白噪音,但恼人的是,Flag就藏在这段噪音里。我见过不少新手卡在这一步——听不出来,就不知道下一步往哪儿走。这篇内容就围绕这个场景展开:用Python脚本对WAV文件做体检、看频谱、拆采样点,一步步把藏在“噪音”里的Flag提取出来。说白了,这就是一套针对WAV隐写题的完整解题思路和实操脚本。
这篇文章适合准备打CTF入门赛的新人,尤其是杂项(Misc)方向遇到音频题不知道从何下手的;对做数字取证、玩信号处理的同学来说,也算是一份能直接套用的周边参考。你不用把信号与系统学完,能装Python库、会看频谱图就够了。下面我按自己平时解题的习惯,把整条流程拆开讲,你拿到题目直接对照着做。
1. 项目概述:为什么WAV成了“藏Flag的重灾区”
1.1 WAV文件结构其实很朴素
WAV(Waveform Audio File Format)是微软和IBM联合定义的无损音频封装格式,底层存的是裸的PCM采样数据。它不像MP3有感知编码压缩,也不像AAC有复杂的频域编码,整体结构就是“RIFF头 + fmt子块 + data子块”三个部分。RIFF头声明文件类型,fmt子块里写采样率、位深、声道数,data子块里就是实实在在的采样点字节流。
学习的时候把WAV当成一个可以逐字节读取的容器就好,很多隐写操作就是直接改这些裸数据。因为无损,WAV里的任何改动都有可能被原样保留下来,这给隐写提供了绝佳土壤。最常见的两种隐藏思路,一个藏在频域,一个藏在时域:
- 频域隐藏:利用人耳对高频信号不敏感的特点,把一个文字、图案或字符串直接画在频谱图上,人耳听到的只是“嘶嘶”的高频噪音。
- 时域隐藏:修改每个采样点的最低有效位(LSB),一次只改一两个bit,听感差异微乎其微。
大多数“噪音题”的本质就是频域隐藏,你用频谱图一看,文字直接显形;而LSB隐藏则必须靠脚本提取,这正是Python的强项。
1.2 为什么“听”是最没效率的第一步
纯靠耳朵听音频题,是新手最常走的弯路。因为出题人压根没想让你听出语义信息,他要藏的东西要么在人耳听觉阈值之外的高频段,要么在低bit位的微观差异里,还有一种是直接在波形间距上编码成摩斯电码。听感上有时候只有“有点刺耳”“好像有电流声”这种模模糊糊的差异,根本构不成有效信息。
我的习惯是“可视化优先,脚本兜底”。不管题目描述得多玄乎,先对文件做一次客观体检:用file和binwalk看文件类型和尾部结构,用十六进制编辑器看RIFF头,用Python读采样率位深声道数,用Audacity或matplotlib看波形和频谱图。这套组合拳打完,大部分隐藏方式就能定位了。
1.3 准备工作:环境与工具清单
工欲善其事,必先利其器。不用装太多东西,下面这套是够用的:
| 工具 | 用途 | 安装方式 |
|---|---|---|
| Python 3.8+ | 运行提取脚本 | 官网下载或系统包管理器 |
| numpy | 处理采样数组 | pip install numpy |
| scipy | 频谱分析中的STFT计算 | pip install scipy |
| matplotlib | 绘制频谱图 | pip install matplotlib |
| Audacity | 快速可视化波形和频谱 | 官网安装包 |
| binwalk / file | 文件体检、尾部扫描 | 系统包管理器或pip install binwalk |
Python建议直接装最新稳定版,装的时候勾选“Add Python to PATH”,不然后面跑pip命令会遇到“不是内部或外部命令”之类的报错。库的安装也很简单,一条pip install numpy scipy matplotlib就全带上了。
2. 从“噪音”里找线索:四种常见WAV隐写原理
2.1 频谱图隐藏:把“画”写进频率轴
频谱图(Spectrogram)是由短时傅里叶变换(STFT)计算出来的,横轴是时间,纵轴是频率,颜色深浅代表该时刻该频率上的能量强度。出题人会通过音频编辑软件或脚本,把Flag文字直接叠加在某段频率范围上,比如3kHz到6kHz之间画出一行白字,再把这段信号混进原始音频里。
人耳听起来,这段叠加信号就是“高频嘶嘶声”,完全分辨不出内容。但一旦用频谱图查看,文字和图案会非常直观地显形。这种题不用写复杂算法,视觉确认后把图案里的字符读出来就行。难点在于有些题目会把文字做得很淡,需要调整频谱图的动态范围和频率轴缩放,否则白茫茫一片看不清。
2.2 LSB隐写:采样点里塞比特
LSB全称Least Significant Bit,最低有效位。以16bit采样值为例,每个采样点是一个从-32768到32767的整数,二进制表示下最低位改变,对幅值的影响只有1/32768,人耳完全无法分辨。出题人会把Flag的每个bit依次写入每个采样点的最低位,或者稍微变种,写在次低位(bit 1)甚至更高的位。
一个WAV文件如果采样率是44.1kHz,单声道,那么一秒钟就有44100个采样点,能塞下44100个bit,约5500字节。存一个40字节的Flag,只需要320个采样点,连0.01秒的音频量都不到。换句话说,从听感上找差异是不可能的,必须跑脚本。
2.3 摩斯电码与信号时长隐藏
还有一些题不走频率覆盖,也不走bit替换,而是用“短音”和“长音”编码信息。比如一段音频里只有“嘀”和“嗒”两种声音,“嘀”是短音,“嗒”是长音,声音之间的停顿也有长短之分。这本质上是摩斯电码,只是把电信号换成了音频脉冲。
这种类型的音频,人耳其实是能分辨出节奏的,只是慢速播放或看波形图会更清楚。脚本层面可以计算音频的包络能量,划分出“有声段”和“无声段”,再统计每个声段的时长比例,把“短”和“长”映射成点和划,最后转成摩斯码字符。
2.4 文件尾部追加与注释块隐藏
不需要多高深的信号处理,直接在WAV文件末尾追加一段ASCII文本,或者追加一个压缩包,也是常见玩法。原因很简单,WAV的data块长度在RIFF头里写明后,播放器通常不关心文件实际大小比声明的长多少,追加内容不影响播放。
这类题用binwalk扫一下就能发现“有东西”,或者直接用xxd noise.wav | tail看文件尾部。如果追加的是RAR或ZIP头,binwalk能直接识别并帮你拆出来。这种算是送分题,但也别跳过,因为在复杂的综合题里,尾部隐藏往往是多步流程中的一环。
3. 实操全流程:一步步从“噪音”WAV中挖出Flag
3.1 第一步:文件体检,先看RIFF头和统计信息
拿到文件第一件事,不要听,先体检。我用一套固定命令组合排查:
file noise.wav binwalk noise.wav xxd noise.wav | head -20 xxd noise.wav | tail -20file会告诉你文件真实的格式、采样率、位深、声道数。binwalk会扫描文件内嵌的其他文件,如果尾部藏了压缩包,这里基本会现形。xxd看头部和尾部,头部能确认是不是标准RIFF/WAVE头,尾部能直接看到明文追加内容。
看完命令行,再用Python按结构读取一遍,因为在后面的提取脚本里也需要这些参数:
import wave w = wave.open('noise.wav', 'rb') n_channels = w.getnchannels() sampwidth = w.getsampwidth() framerate = w.getframerate() n_frames = w.getnframes() print(f'声道数: {n_channels}') print(f'采样宽度: {sampwidth} 字节') # 2表示16bit,1表示8bit print(f'采样率: {framerate} Hz') print(f'总帧数: {n_frames}') data = w.readframes(n_frames) w.close() print(f'原始数据字节数: {len(data)}')如果file显示正常,binwalk没扫出内嵌文件,尾部也没有明文,就进入下一步可视化分析。
3.2 第二步:波形与频谱图,看人耳听不出的信息
波形图看的是时域包络。如果音频里有明显的短音和长音交替,波形图上会出现“高能量段”和“低能量段”交替排列的规律,这是摩斯电码的典型特征。如果波形看起来就是一片均匀噪音,没有任何结构,基本可以排除摩斯,转向频谱分析。
频谱图用Python画也很简单,核心代码如下:
import wave import numpy as np import matplotlib.pyplot as plt from scipy.signal import spectrogram def load_wav(filename): w = wave.open(filename, 'rb') n_channels = w.getnchannels() n_frames = w.getnframes() data = np.frombuffer(w.readframes(n_frames), dtype=np.int16) w.close() if n_channels == 2: data = data.reshape(-1, 2) data = data.mean(axis=1).astype(np.int16) return data, w.getframerate() data, fs = load_wav('noise.wav') f, t, Sxx = spectrogram(data, fs, nperseg=1024, noverlap=512) plt.figure(figsize=(14, 6)) plt.pcolormesh(t, f, 10 * np.log10(Sxx + 1e-10), shading='gouraud', cmap='viridis') plt.xlabel('时间 (s)') plt.ylabel('频率 (Hz)') plt.ylim(0, fs // 2) plt.colorbar(label='能量 (dB)') plt.show()这里有几个细节要注意。nperseg控制窗口大小,窗口越大,频率分辨率越高,但时间分辨率越低;窗口越小,时间分辨越细,但频率越模糊。如果看不清目标频率区域,可以调整nperseg的取值,比如512或2048。
10 * np.log10(Sxx + 1e-10)是转成dB单位,因为原始STFT值的动态范围极大,直接画颜色会集中在一小段区间,什么细节都看不到。加一个极小值1e-10是为了防止log(0)导致无穷大。
3.3 第三步:频谱文字还原Flag
当频谱图弹出来,眼睛扫一遍,如果看到一段清晰的白字或者图案,比如在3kHz到6kHz之间有一行字符,恭喜,这就是出题人画上去的Flag。
这一步最简单的解法是:肉眼读。缩放图片、调整ylim范围把目标频段放大,再调整plt的clim(颜色范围)把对比度拉高,通常能直接读出字符。
plt.pcolormesh(t, f, 10 * np.log10(Sxx + 1e-10), shading='gouraud', cmap='gray') plt.ylim(2000, 8000) plt.clim(-80, -30)单一颜色cmap='gray'对文字图案反而更清晰。如果字太小,可以适当降低ylim上限只保留目标频段,或者把图像输出成PNG再人工放大。
极少数情况下,文字图案会被噪声淹没。这时可以尝试对频谱做一次“差分化”处理:把整段信号分成两半,一半是有信息的,一半是纯噪声,然后做频谱相减。不过实际做题时这种操作很少见,先调clim基本够用。
3.4 第四步:写LSB提取脚本,把比特重新拼成字节
如果频谱图干干净净,没有文字图案,那大概率是LSB隐写。提取LSB的思路并不复杂:把所有采样点的最低位取出来,拼成一个bit流,然后每8个bit拼成一个字节,最后在字节流里搜索可打印字符串。
先看基础版脚本:
import wave import numpy as np import re def extract_lsb_bytes(samples, bit=0): bits = '' for s in samples: bits += str((int(s) >> bit) & 1) bytes_list = [] for i in range(0, len(bits) - 7, 8): b = bits[i:i+8] bytes_list.append(int(b, 2)) return bytes(bytes_list) w = wave.open('noise.wav', 'rb') n_channels = w.getnchannels() n_frames = w.getnframes() samples = np.frombuffer(w.readframes(n_frames), dtype=np.int16) w.close() if n_channels == 2: samples = samples.reshape(-1, 2) # 双声道先取左声道试 channel_data = samples[:, 0] if n_channels == 2 else samples data = extract_lsb_bytes(channel_data, bit=0) print(data[:200]) # 查找可打印字符串 strings = re.findall(rb'[ -~]{6,}', data) print(strings)这个脚本把每个16bit采样点的最低1bit取出,拼成bit流再按字节切分。int(s) >> bit) & 1是把第bit位的值提取出来,>> 0就是最低位,>> 1就是次低位。
跑完看输出,如果发现strings里出现CTF{...},直接收工。如果出现一串可打印的乱码,初看不像Flag,先别急着放弃,下面第4章会讲怎么处理。
再补充一个稍快的向量化版本,数据量大时更高效:
def extract_lsb_byte_smart(samples, bit=0, channel=0): if samples.ndim == 2: samples = samples[:, channel] bit_vals = (samples.astype(np.uint16) >> bit) & 1 bit_vals = bit_vals[:len(bit_vals) - (len(bit_vals) % 8)] bit_matrix = bit_vals.reshape(-1, 8) byte_vals = np.packbits(bit_matrix, axis=1) return byte_vals[:, 0].tobytes()用np.packbits可以替代手写字节拼接,效率更高,数据量大时优势明显。
3.5 第五步:摩斯电码解析与波形特征排查
如果波形图呈现出明显的“能量块”规律,比如“短-短-短-长-长-短”,那基本是摩斯电码。解析思路是先把信号转成能量包络,再按阈值分成“有音”和“无音”,最后统计每个声段的时长。
下面是一份简化但能跑的包络检测脚本:
import wave import numpy as np w = wave.open('noise.wav', 'rb') n_frames = w.getnframes() samples = np.frombuffer(w.readframes(n_frames), dtype=np.int16) fs = w.getframerate() w.close() frame_len = int(fs * 0.01) # 10ms滑动窗口 energy = [] for i in range(0, len(samples) - frame_len, frame_len): seg = samples[i:i+frame_len] rms = np.sqrt(np.mean(seg.astype(np.float64) ** 2)) energy.append(rms) energy = np.array(energy) threshold = (energy.max() + energy.min()) / 2 on_off = energy > threshold # 统计连续1(有声)和连续0(无声)的帧数 durations = [] prev = on_off[0] count = 1 for v in on_off[1:]: if v == prev: count += 1 else: durations.append((prev, count)) prev = v count = 1 durations.append((prev, count)) # 打印有声段的时长,人工比对长短音 for is_sound, length in durations: if is_sound: print(f'有声: {length * 0.01:.2f}s')跑完之后看打印的时长,如果短音大约0.1秒,长音大约0.3秒,就能确定单位时间长度是0.1秒。然后把每个有声段映射成.或-,无声段按间隔短中长分成“字符内间隔”“字符间间隔”“单词间间隔”。这一步我一般是脚本输出时长,再对照摩斯码表人工转换,因为自动识别容易受噪声干扰,反而不如人快。
4. 我踩过的坑:常见问题与排查技巧实录
4.1 频谱图白茫茫一片,什么也看不清
这是我遇到最多的情况。频谱图画出来全是均匀的高频噪点,没有任何文字图案。原因往往是信息文字的能量太低,动态范围被其他频率段盖住了。
解决办法分几步:
- 把颜色范围
clim的范围收紧,比如从(-80, -30)开始试,如果还是一团白,就继续缩到(-60, -40),直到背景变暗、目标变亮。 - 把垂直坐标限制在可能藏信息的频段,比如高频题目喜欢藏在4kHz到10kHz,你直接
plt.ylim(4000, 10000)放大看。 - 切换
nperseg调整频率分辨率。nperseg越大,频率越细,适合找窄带文字;nperseg越小,时间越细,适合找短促信号。 - 换成灰度
cmap='gray',文字边缘更锐利。
我实际碰到过最夸张的一次,把clim调到(-65, -55)才看清那行字,早期的图在默认范围里就是一坨白。
4.2 提取出来的字符串是乱码
LSB脚本跑出来的内容如果是一串乱码,先别急,大概率是下面几个原因:
- bit位取错了。出题人可能把信息写在bit 0,也可能写在bit 1或bit 2。脚本里
bit=0的循环改成bit=1、bit=2再跑一遍。 - 声道没选对。双声道文件,左右声道交织存放,信息可能只藏在其中一个声道。脚本里
channel_data = samples[:, 1]换成右声道试试。 - 字节方向反了。bit流拼字节时,从MSB到LSB和从LSB到MSB结果是完全不同的。如果
int(b, 2)解出来是乱码,可以尝试把8bit整体反转再解析。 - 整体顺序反了。有时候Flag的字节顺序被反着写入,处理办法是把最终的
data反向输出一次。 - 有加密或编码层。比如提取出的内容是Base64字符串,需要再解一层;甚至可能是XOR加密,需要找密钥。不过CTF题库里直接XOR的不多,先优先排查前四项。
这几个变量组合起来其实有几十种可能。我的操作是写一个简单的循环,把bit=0/1/2、左右声道、正反方向全部组合跑一遍,再对所有结果搜索CTF、flag、key等关键字,省去反复手动测试的时间。
4.3 数据长度与真相不符,可能还有其他层
提取出的LSB内容长度不对,比如全零字节、或者开头一段有效后面全是垃圾,这意味着真正的信息可能只占前面一小部分,或者写入时按某种规则跳着采样点写。
常见的一种是“每隔N个采样点取一个bit”,有的题目会在每个采样点写多个bit(比如利用16bit采样值的8个低位),等于一隐藏8bit信息。此时脚本里range(0, len(samples), step)之类的间隔参数就需要调整。另一个常见现象是信息只存在于前半段,后半段全是清空的零bit,只需要截取前几KB内容看。
另外,我在一次做题时发现LSB提取结果前半段是PNG文件头89 50 4E 47,后半段是可打印字符串。这说明出题人在这段音频里既藏了图片又藏了文本。遇到这种情况,把提取出的字节流用write落盘成文件,再用file工具识别,常常能拆出额外线索。
4.4 多层隐写叠加怎么办
音频题很少只考单一手法,高频操作是“频谱图藏提示 + LSB藏压缩包 + 压缩包加密密码藏在摩斯电码里”这种多级串联。遇到这种题,处理顺序非常重要。
我给自己定了一套固定流程:
- 先跑
binwalk和尾部查看,处理最简单的明文隐藏。 - 再画波形图,看有没有摩斯节奏;有的话先解摩斯。
- 再画频谱图,看有没有文字图案;有的话记下来。
- 最后跑LSB脚本,结合前面拿到的线索处理压缩包密码或下一步文件。
这个顺序的核心逻辑是“由易到难、由外到内”。如果一上来就死磕LSB,可能解出一堆无意义字节,反而忽略了频谱图里的明文提示。
最后再分享一个习惯
我个人的经验是,音频隐写题最忌“凭感觉找”。感觉音频有点奇怪、感觉这里有点刺耳,这些都是主观信号,很难推导出确定答案。反倒是客观工具的输出——file的类型识别、binwalk的扫描结果、频谱图的视觉图案、LSB脚本的提取字节——每一步都不依赖听感,可复现、可验证、可交叉比对。把流程固定下来,按部就班跑一遍,大多数WAV隐写题都能有突破口。
小技巧方面,建议把上面的频谱绘制脚本和LSB提取脚本整理成自己常用的工具函数,保存成一个wav_stego.py,遇到音频题直接import。每次查完频谱图,也顺手把图保存成PNG,后续在题目讨论区复盘时方便对照。另外,即使在频谱图里已经看到了Flag字样,也建议再用脚本把目标频段单独滤出来听一下,这不仅帮你确认信息位置,还能积累“那段频率大约长什么样”的经验,下次遇到类似噪音题,一眼就能定位。