☰
CTF图片音频隐写全攻略:从LSB到频谱图手把手找Flag
2026/10/11 17:42:42 网站建设 项目流程

CTF Misc模块系列分享(三):图片/音频隐写术!手把手教你挖隐藏Flag

我一直觉得,Misc(Miscellaneous)才是CTF里最有"侦探感"的板块——题目不会直接告诉你漏洞在哪,而是给你一张看似正常的图片、一段莫名其妙的音频,让你像做刑侦一样从像素缝隙和频谱波纹里找线索。前两篇聊了流量包分析和压缩包伪装,这次集中讲讲平时出镜率最高、也最适合新手入门练手的两类载体:图片隐写和音频隐写。

先说点实际的:图片和音频隐写之所以在CTF里这么常见,是因为它们的文件结构天然适合"藏东西"。图片的冗余字节多,压缩算法会留下大量可插入数据的空隙;音频更是把信息藏在时域、频域、甚至左右声道差值里,肉眼和耳朵都很难察觉。这篇文章不搞花架子,直接从工具链、原理、实战题目拆解到隐写检测思路,按我平时做题的流程一步步来,争取让没接触过隐写的人也能在赛后自己复现出Flag。

1. 入坑前先摆平工具链:这六个工具够用90%的题

1.1 图片隐写题的基础工具与安装要点

做图片隐写,你第一个要装的不是什么高端神器,而是binwalk和strings。strings在大多数Linux发行版里自带,作用就是扫描文件里的可打印字符串,很多出题人偷懒,把Flag直接以明文藏进图片的元数据或者追加字节里,一条strings命令下去原形毕露。binwalk则用来检测文件里是否嵌入了其他文件,它靠的是文件签名特征识别,比如JPEG的FF D8 FF、PNG的89 50 4E 47、ZIP的50 4B 03 04。

# 检测图片里是否藏了其他文件 binwalk flag.png # 提取嵌入的文件 binwalk -e flag.png

这里有个新手常踩的坑:binwalk -e有时候提取出来的文件不完整,尤其是遇到压缩包嵌套的时候。我的习惯是先用binwalk看签名偏移量,再用dd手动切割,比如偏移量是0x1A2B3C,就可以用以下命令精确切出目标文件:

dd if=flag.png of=hidden.zip bs=1 skip=$((0x1A2B3C))

提示:二进制的偏移量计算建议直接开Python交互式环境算,0x前缀的十六进制数和十进制的对应关系容易算错,不值得。

接下来是zsteg,这个工具专门处理PNG和BMP的LSB隐写,支持逐通道、逐bit扫描,还能自动检测ZIP、PNG等文件签名。安装方式很简单,是Ruby写的:

gem install zsteg

1.2 音频隐写题的工具准备

音频题的核心工具是三件套:Audacity、Sonic Visualiser、dtmf2num(或multiduplex)。Audacity做频谱分析和波形查看最方便,跨平台且免费;Sonic Visualiser的频谱图渲染更精细,适合找那种藏在特定频段的摩斯码;dtmf2num用来解码DTMF拨号音,音频题里偶尔会出现。

安装方面没什么玄学,直接去官网下载对应系统版本就行。我个人的建议是不要只装一个音频编辑器,因为不同的工具对频谱图的渲染算法有差异,有些信号在Audacity里看不出来,换Sonic Visualiser一渲染就清清楚楚。

2. 图片隐写三板斧:LSB、文件拼接与像素通道

2.1 LSB隐写为什么能藏得住信息

LSB是Least Significant Bit的缩写,通俗讲就是"最低有效位"。一张8位深度的PNG图片,每个像素的RGB三个通道各占8个bit,取值范围从0到255。人眼对颜色变化的感知有极限,你把每个通道的最后1个bit改成其他值,图像整体的颜色变化只有1/255,肉眼完全看不出来。

这就是LSB隐写能成立的基础。出题人把Flag的二进制位拆开,依次替换掉图片某些像素的LSB,图片看起来毫无变化,但信息已经嵌进去了。最常见的做法是连续替换,从图片第一个像素开始,按照R、G、B的顺序逐个bit填充,提取的时候反向操作就行。

用zsteg可以直接检测并提取:

zsteg -a flag.png

-a参数表示所有通道和bit位都扫一遍,多试几次,输出里如果出现b1,rgb,lsb,xy之类的描述,再配合输出的ASCII字符串就能定位Flag位置。这里要注意:不是所有LSB隐写都从第一个像素开始,有些题目会设置偏移量,比如前100个像素不变,从第101个像素开始藏,这时候就需要自己写Python脚本了。

一个能应付大部分情况的提取脚本如下:

from PIL import Image img = Image.open('flag.png') pixels = list(img.getdata()) binary = '' for pixel in pixels: for channel in range(3): # RGB binary += str(pixel[channel] & 1) # 从二进制串里找ASCII可打印字符 for i in range(0, len(binary), 8): byte = binary[i:i+8] if len(byte) == 8: char = chr(int(byte, 2)) if 32 <= ord(char) <= 126: print(char, end='') else: print('')

2.2 文件拼接与CRC校验:两种常见的"藏头"玩法

文件拼接是另一种入门级隐写:出题人把Flag所在的文件(通常是ZIP或另一张图)直接追加到一张正常图片的末尾。图片查看器只会读取自己识别的文件头数据,后面的附加数据会被忽略,所以图片照样能打开,但binwalk一眼就能识别出文件签名。

处理这种题的思路很直接:

  1. binwalk看签名偏移量;
  2. 用dd切出隐藏文件;
  3. 如果是ZIP且有密码,再去做压缩包爆破或伪加密分析。

CRC校验的坑我在第二篇提过,这里再强调一遍:PNG的IHDR块里存着CRC32校验值,如果把图片的宽高改了但没改CRC,图片就会报错打不开。反过来,如果你发现一张PNG能正常显示但尺寸明显不对(比如长宽比很怪),可以用pngcheck查看CRC和实际宽高:

pngcheck -v flag.png

更常见的题目是"CRC碰撞":出题人改了宽高,CRC校验值没改,但图片本身还是能打开,只是显示不全,这时候可以用crc32逆向爆破原始宽高。有一个专门的工具叫pngcrc,脚本思路是用Python遍历可能的宽高组合,直到CRC32匹配:

import struct import zlib def check_crc(data, width, height): ihdr = data[12:29] new_ihdr = ihdr[:4] + struct.pack('>II', width, height) + ihdr[8:] crc = zlib.crc32(new_ihdr) & 0xffffffff return crc # 读文件,遍历 width 和 height,比对已知的 CRC

2.3 像素通道分离与颜色差异提取

这一招在Misc题里不算高频,但遇到了就是送分题。原理是出题人把Flag藏在某个颜色通道里,比如把R通道全部置为255,G和B通道保留原始信息,或者把Flag以白色像素写在纯色背景上,直接用stegsolve分离通道就能看到。

stegsolve是个Java写的小工具,打开图片后可以逐个通道、逐个bit查看,还能做XOR、ADD等像素运算,对初学者极其友好。做题流程是:打开图片 → 逐个点击RGB通道的每个bit位 → 观察哪个bit位出现异常图案 → 截图恢复Flag。

这里有个实操技巧:看通道分离结果时,不要只盯着灰色图看,要重点看那些"噪点"分布有规律的位平面。正常图片的高位平面图像轮廓清晰,低位平面看起来像随机噪点;如果发现某个低位平面突然出现清晰的文字轮廓或某种规律条纹,那基本就是暗号所在。

3. 音频隐写实战:频谱、波形与DTMF拨号

3.1 频谱图里藏摩斯码:看到比听到更重要

音频隐写里最经典的一类,是把摩斯码调制到音频的特定频段上。直接听音频,你听到的可能是正常的音乐或者噪音,但打开频谱图,会看到一串明显的点划信号——短的竖线是"点"(.),长的竖线是"划"(-)。这时候要做的就是两件事:识别信号、转成文本。

我的做法是:

  1. 用Audacity打开音频;
  2. 菜单里选择"频谱图"视图(Spectrogram);
  3. 把视图范围调到主要信号所在的频段(通常看色带最亮的部分);
  4. 截图或直接观察,把点划序列记录下来;
  5. 用摩斯码表解码,格式一般是.-. ...- . .-. ... .这一类。

实际操作中,最怕的是信号宽度比较模糊。这时候可以调整Audacity的频谱图设置:把"窗口化"调到Hann,把"缩放"调大,再对比不同时间点的切片。有个土办法是直接放大波形图,看振幅的包络——摩斯码信号的包络还是比较明显的,只是视觉上没有频谱图直观。

解出草稿后推荐用一个在线摩斯解码工具,或者写个Python脚本自动映射:

morse_map = { '.-': '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' } code = '... --- ...' chars = code.split(' ') for c in chars: if c in morse_map: print(morse_map[c], end='')

3.2 左右声道与倒放音频:别被耳朵骗了

音频隐写的第二类常见套路是利用声道差异。把Flag的信息藏在左声道或者右声道,或者把左右声道的差值作为信号。最简单的检测方式就是用Audacity的"分离立体声到单声道"功能,分别播放左右声道,或者查看各自的波形图。

倒放音频也是个经典的坑。你听半天听不出来,把音频翻转(效果 → 反向)再听,也许就出现了一段清晰的语音。这个手法不算隐写,更像"编码变换",但CTF里确实经常出现,而且和隐写题混在一起考。

处理这类题的核心思路就一句话:把能分离的都分离,把能变换的都变换。拿到一段音频,先看声道数,再看频谱图,再试试倒放和变速,这些操作成本都很低,但能覆盖大部分出题套路。

3.3 DTMF拨号音解码:老式电话的隐藏消息

DTMF(双音多频)信号是电话拨号时产生的频率组合,每个按键对应一个低频和一个高频的叠加。有些音频题会把Flag对应的数字序列转成DTMF音调,放在音频末尾或中间某一段。

用dtmf2num解码很方便:

dtmf2num flag.wav

如果工具识别不准,也可以直接在Audacity里看频谱图,找那些同时出现两根明显亮线的位置,对照DTMF频率表换算:低频697Hz对应第一行,高频1209Hz对应第一列,交叉点就是对应的数字键位。

DTMF题算是音频隐写里的"识别题",只要知道存在这种编码方式,剩下的就是查表,难度不大但挺有意思。

4. 从题目反推套路:一道模拟图片隐写题的完整拆解

4.1 拿到一个"看起来正常"的PNG,我的排查顺序

与其抽象讲理论,不如带大家走一遍完整的做题流程。下面这个例子是我根据常见出题思路构造的模拟题,不代表任何真实赛事,但结构很典型,你几乎可以把它当模板套用到多数入门级图片隐写题上。

假设比赛给了个memory.png,题目描述只有一句:"Somebody left a message."。我的处理顺序如下:

第一步,先看元数据。用strings扫一遍,顺手看一眼文件大小和尺寸是否合理:

strings memory.png | head -50 ls -lh memory.png file memory.png

如果strings输出里直接出现了flag{...}或者key{...},这题就结束了。没有的话进入下一步。

第二步,检测文件拼接。binwalk memory.png,看看有没有可疑的偏移量。如果出现了ZIP或RAR的签名,直接用binwalk -e提取。

第三步,检查LSB隐写。依次用zsteg扫:

zsteg -a memory.png

如果zsteg扫出了ASCII字符串,看内容里有没有flag{这种格式的关键字。如果没有,再考虑是不是用了奇奇怪怪的通道顺序或者偏移。

第四步,做通道分离。用stegsolve逐通道观察,重点关注低位平面的噪声分布。

4.2 这一步我卡了半小时:偏移量藏在图片宽度里

前面那些步骤都过完后,我的zsteg和stegsolve都没扫出有效结果。这时候我意识到,题目可能没那么直接,于是回头重新审视图片本身。

打开图片看,它的宽高比很奇怪,明明内容是风景,却是一张接近正方形的图。我怀疑出题人改了宽高,导致图片内容显示不全,而真正的关键信息可能在被裁掉的那部分空间里。此时我先去查PNG的IHDR块数据:

xxd memory.png | head -20

IHDR块从第16字节开始,前4字节是宽度,接着4字节是高度。我看到宽度是0x000003F1(1009),高度是0x000003F1(1009),但图片内容的视觉比例明显不符合这个宽高比。当时我尝试把高度改成0x0000047A(1146),图片打开后果然多出来一行像素,而这一行像素里就是被隐藏的16进制字符串。

这一步的教训很实在:图片尺寸异常时,不要只盯着隐写工具的输出看,先看看图片本身显示是否完整。改宽高本身不是隐写,但很多题会把"改宽高"和"信息藏在裁掉区域"结合,只要增大画布高度,隐藏部分就露出来了。

4.3 藏了一半的音频:频谱中段的信息定位

还是这个模拟场景,假设图片部分解完之后,在备注栏提示"第二段线索在audio文件夹里"。打开clue.wav,刚开始播放是正常的白噪音,频谱图上大部分区域也是均匀的杂色。

但当你把频谱图的频率范围调到中段,比如4000Hz到6000Hz之间,会发现一小段明显的规律性亮斑。把这段截出来放大,能看到点划交替的波形,确认是摩斯码。抄下来解码,得到一串十六进制,再配合前面的图片线索,最终拼接出完整的Flag。

这个过程本身不复杂,但它提醒了我一件事:音频隐写题里,"把整个频谱都看一遍"比"听完整段音频"重要得多。很多信号就藏在你以为没有内容的频段里,光靠耳朵根本听不出来。

5. 从入门到进阶:如何自己造一个隐写题(以及怎么不被别人秒解)

5.1 手写LSB隐写脚本:把"隐藏Flag"变成"给Flag加密"

很多初学者学完提取之后会有一个错觉:隐写不就是把字符串塞进图片吗?真到自己动手写脚本时才发现,里面藏着不少细节。

以LSB为例,最简单的加密脚本长这样:

from PIL import Image def text_to_bits(text): result = [] for char in text: bits = bin(ord(char))[2:].rjust(8, '0') result.extend([int(b) for b in bits]) return result img = Image.open('cover.png') pixels = list(img.getdata()) bits = text_to_bits('flag{hidden_message}') # 检查容量是否够用 if len(bits) > len(pixels) * 3: raise ValueError('信息太长') new_pixels = [] bit_index = 0 for pixel in pixels: r, g, b = pixel[:3] if bit_index < len(bits): r = (r & 0xFE) | bits[bit_index] bit_index += 1 if bit_index < len(bits): g = (g & 0xFE) | bits[bit_index] bit_index += 1 if bit_index < len(bits): b = (b & 0xFE) | bits[bit_index] bit_index += 1 new_pixels.append((r, g, b, pixel[3] if len(pixel) == 4 else 255)) img.putdata(new_pixels) img.save('stego.png')

这段代码有几个关键点:

  • 改的是每个通道的最低有效位,x & 0xFE把最低位清零,| bits[i]把目标bit放进去;
  • 如果原图有Alpha通道,需要保留透明度,否则保存后图片可能出现黑边或异常效果;
  • 如果信息过长,会超出图片容量,但这个报错做得比较糙,实际使用时你会想要在信息前加入长度标识。

提取脚本和2.1节里那个差不多,但要处理"信息长度"的问题,不然会把整张图的LSB都解出来,得到一堆无意义字符。常见的做法是在信息开头加一个定长标识或长度段,比如固定前32个bit记录信息长度,提取时先读长度再读数据。

5.2 容量与鲁棒性:为什么真实隐写题没那么简单

写过一个隐写脚本后,你会发现一个矛盾:藏得越深,越容易被检测;藏得越隐蔽,容量越小。

LSB隐写虽然简单,但鲁棒性极差——图片稍微压缩一下,或者转个格式,LSB的信息就全毁了。所以现在很多CTF题目不会单纯用LSB,而是会把信息藏在DCT系数里(JPEG隐写),或者用冗余度更高的编码方式。这些内容对新手来说有点超前,但了解"为什么有这么多隐写算法"这件事本身,对做题是有帮助的——出题人选择哪种方案,取决于他想让这道题停留在"工具题"还是"原理题"。

5.3 自建题目的价值:以出题人视角反推排查点

我在学隐写时,有一个特别有效的方法:试着自己出一道题,再拿自己的题去考别人。这不是闲得慌,而是逼自己去理解出题人会设置哪些"排查点"。

比如我设计一道三阶段的图片隐写题:

  1. 第一阶段:用strings能直接看到提示字符串,但提示不是Flag本身;
  2. 第二阶段:把一段密文用LSB藏在图片的G通道里,并且从第500个像素才开始嵌入;
  3. 第三阶段:把真正的Flag以ZIP形式追加到图片末尾,ZIP用伪加密(改标志位)实现"看起来有密码但实际不需要密码",或者用弱密码加密。

出完题之后,我再以做题者的身份去解,就会发现自己挖的坑有多隐蔽:如果我没在题目描述里提示"G通道有信息",可能很多选手会直接卡死在第一阶段;如果我不把伪加密的细节处理好,binwalk提取出的ZIP很可能直接被工具自动解压,让第三阶段形同虚设。

这个过程让我深刻体会到一件事:CTF隐写题的本质,是出题人和做题人之间的"心理博弈"。出题人埋线索,做题人找线索,谁对文件格式和工具原理理解得更深,谁就能在博弈中占上风。

6. 排查与复盘:做题时常见的几个"伪Flag"陷阱

6.1 扫出了可见字符串,但解出来不是Flag怎么办

这是新手最常见的困惑。strings扫出了类似flag{This_is_not_the_flag}的文本,结果提交的时候平台提示错误。遇到这种情况,先别急,把它当成提示而不是答案。

出题人经常会在文件里塞"诱饵字符串",用来引导你进入下一个隐藏层。比如诱饵字符串提示"password is in the blue channel",这就是在告诉你去查蓝通道的LSB;再比如诱饵字符串是一串看起来像Base64的文本,但解码后是另一个文件名,这时候就要去文件系统里找那个文件。

处理这种题的唯一正确心态是:把每次输出都当成下一步的输入。隐写题很少会"一步到位",大多数都是环环相扣的链式谜题。

6.2 那张图片可能不是PNG:文件头伪装与扩展名欺骗

拿到flag.png,file命令显示它是JPEG,但扩展名是.png,这种情况在Misc里很常见。不要被扩展名骗了,所有工具应该基于真实文件格式运行:file命令会读文件头识别真实类型,binwalk会根据签名识别嵌入文件,stegsolve支持打开多种常见图片格式。

更进阶一点的是文件头被修改的场景,比如把PNG的文件头89 50 4E 47改成了89 50 4E 48,图片打不开,但只要你把第四个字节改回47就能恢复。这种题考察的是对文件格式细节的记忆力,也考察基本功。

6.3 音频时长和文件大小:一种容易被忽略的线索

音频隐写里我吃过一次亏:题目给了一个几十MB的WAV文件,播放时长却只有十几秒。按照正常采样率算,十几秒的44.1kHz 16bit双声道WAV应该在2MB左右,怎么可能有几十MB?

后来才发现,音频文件里被塞进了一张图片——具体来说是BMP格式,没压缩,所以体积巨大。用binwalk扫了一下,果然在音频数据段后面发现了BMP文件头,切出来就看到了清晰的信息。

这个案例告诉我们:文件大小和内容量的异常,本身就是最强的隐写线索。做题时养成习惯,ls -lh看一下文件大小,再对比正常情况下的估算值,异常越大越要仔细查。

7. 隐写检测思路总结:如何从零开始系统化做题

7.1 建立自己的"排查清单"

做了大量隐写题之后,我给自己整理了一份排查清单,每次拿到题目都按这个顺序走一遍,能覆盖大部分出题套路:

  1. 查看文件类型、大小、字符串信息;
  2. binwalk扫描文件签名和偏移;
  3. 查看图片尺寸是否异常,尝试修复宽高;
  4. zsteg全通道扫描LSB;
  5. stegsolve逐通道逐bit观察;
  6. 对音频查看频谱图、声道分离、倒放变速;
  7. 如果以上都无效,回到题目描述里找隐藏线索。

这份清单不是死的,但它的核心逻辑是:从最显眼的信息开始,逐步深入到文件内部结构,最后再反推题目意图。新手最容易犯的错是拿到题直接开zsteg,忽略最基础的strings和file,反而漏掉最简单的线索。

7.2 工具暴破参数配置参考

有些题目需要爆破,比如隐藏的ZIP密码是纯数字或纯小写字母,可以先用fcrackzip或john配合字典跑一轮。但对于CTF比赛,密码往往是英语单词或者flag{...}的某种变体,直接跑常见密码字典的效果通常比纯数字爆破好。

这里给个经验值:如果压缩包密码位数是4到6位纯数字,fcrackzip跑起来非常快,几乎秒开;如果是8位以上混合字符,纯爆破不现实,更可能藏在题目描述的提示里。

7.3 复盘比做题更重要:每次赛后重推一遍

最后说个看似"软性"但特别有用的习惯——赛后复盘。我每次打完比赛,不管有没有解开题目,都会把解题脚本和思路重新梳理一遍:哪一步卡住了,为什么卡住,是工具没用对还是思路没跟上。特别是别人解出的题目,我会把自己代入解题者的视角重新走一遍流程,看对方是在哪个环节产生了突破。

这种复盘做多了之后,你对隐写套路会形成一种"肌肉记忆"。看到文件大小异常,第一反应不再是用file看一眼完事,而是直接binwalk加ls -lh;听到音频背景里有规律性的"滴答"声,第一反应不再是忽略,而是打开频谱图找信号。这种直觉不是天生的,纯粹是靠题目喂出来的。

7.4 一处实操补充:自己写批量提取脚本

如果比赛中有多道类似的隐写题,或者需要批量处理几十张图片,手写一个批处理脚本会省很多时间。以批量检测LSB并输出可打印字符串为例:

import os from PIL import Image def extract_lsb_text(img_path): img = Image.open(img_path) pixels = list(img.getdata()) binary = '' for pixel in pixels: for channel in range(3): binary += str(pixel[channel] & 1) text = '' for i in range(0, len(binary), 8): byte = binary[i:i+8] if len(byte) == 8: char = chr(int(byte, 2)) if 32 <= ord(char) <= 126: text += char else: text += '\n' return text for fname in os.listdir('imgs'): if fname.endswith('.png'): text = extract_lsb_text(os.path.join('imgs', fname)) if 'flag' in text or 'key' in text: print(fname, text)

脚本本身简陋,但用来筛题足够了。CTF里速度很重要,能批处理的就别手工一张张开,尤其当题目数量上到两位数之后,时间差会非常明显。

写到这里,其实已经把我在图片/音频隐写这块的主要经验都倒出来了。工具在更新、题目在变难,但底层思路始终是那几条:看全、拆全、想全。看全是指把文件里能读的信息都读一遍,拆全是指把能分离的层都分离一遍,想全是说拿到线索别急着提交,先想想它是不是链式谜题中的一环。Misc题的魅力也正在于此,它不是单纯的"技术竞赛",更像一场安静的寻宝——你永远不知道下一个线索藏在哪个不起眼的像素里,而找到它的那一刻,确实是这个模块最上头的瞬间。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询