CTF图片音频隐写全攻略:从LSB到频谱图手把手找Flag
CTF Misc模块系列分享三图片/音频隐写术手把手教你挖隐藏Flag我一直觉得MiscMiscellaneous才是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 ifflag.png ofhidden.zip bs1 skip$((0x1A2B3C))提示二进制的偏移量计算建议直接开Python交互式环境算0x前缀的十六进制数和十进制的对应关系容易算错不值得。接下来是zsteg这个工具专门处理PNG和BMP的LSB隐写支持逐通道、逐bit扫描还能自动检测ZIP、PNG等文件签名。安装方式很简单是Ruby写的gem install zsteg1.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:i8] 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一眼就能识别出文件签名。处理这种题的思路很直接binwalk看签名偏移量用dd切出隐藏文件如果是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比对已知的 CRC2.3 像素通道分离与颜色差异提取这一招在Misc题里不算高频但遇到了就是送分题。原理是出题人把Flag藏在某个颜色通道里比如把R通道全部置为255G和B通道保留原始信息或者把Flag以白色像素写在纯色背景上直接用stegsolve分离通道就能看到。stegsolve是个Java写的小工具打开图片后可以逐个通道、逐个bit查看还能做XOR、ADD等像素运算对初学者极其友好。做题流程是打开图片 → 逐个点击RGB通道的每个bit位 → 观察哪个bit位出现异常图案 → 截图恢复Flag。这里有个实操技巧看通道分离结果时不要只盯着灰色图看要重点看那些噪点分布有规律的位平面。正常图片的高位平面图像轮廓清晰低位平面看起来像随机噪点如果发现某个低位平面突然出现清晰的文字轮廓或某种规律条纹那基本就是暗号所在。3. 音频隐写实战频谱、波形与DTMF拨号3.1 频谱图里藏摩斯码看到比听到更重要音频隐写里最经典的一类是把摩斯码调制到音频的特定频段上。直接听音频你听到的可能是正常的音乐或者噪音但打开频谱图会看到一串明显的点划信号——短的竖线是点.长的竖线是划-。这时候要做的就是两件事识别信号、转成文本。我的做法是用Audacity打开音频菜单里选择频谱图视图Spectrogram把视图范围调到主要信号所在的频段通常看色带最亮的部分截图或直接观察把点划序列记录下来用摩斯码表解码格式一般是.-. ...- . .-. ... .这一类。实际操作中最怕的是信号宽度比较模糊。这时候可以调整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 -20IHDR块从第16字节开始前4字节是宽度接着4字节是高度。我看到宽度是0x000003F11009高度是0x000003F11009但图片内容的视觉比例明显不符合这个宽高比。当时我尝试把高度改成0x0000047A1146图片打开后果然多出来一行像素而这一行像素里就是被隐藏的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 自建题目的价值以出题人视角反推排查点我在学隐写时有一个特别有效的方法试着自己出一道题再拿自己的题去考别人。这不是闲得慌而是逼自己去理解出题人会设置哪些排查点。比如我设计一道三阶段的图片隐写题第一阶段用strings能直接看到提示字符串但提示不是Flag本身第二阶段把一段密文用LSB藏在图片的G通道里并且从第500个像素才开始嵌入第三阶段把真正的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.pngfile命令显示它是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 建立自己的排查清单做了大量隐写题之后我给自己整理了一份排查清单每次拿到题目都按这个顺序走一遍能覆盖大部分出题套路查看文件类型、大小、字符串信息binwalk扫描文件签名和偏移查看图片尺寸是否异常尝试修复宽高zsteg全通道扫描LSBstegsolve逐通道逐bit观察对音频查看频谱图、声道分离、倒放变速如果以上都无效回到题目描述里找隐藏线索。这份清单不是死的但它的核心逻辑是从最显眼的信息开始逐步深入到文件内部结构最后再反推题目意图。新手最容易犯的错是拿到题直接开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:i8] 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题的魅力也正在于此它不是单纯的技术竞赛更像一场安静的寻宝——你永远不知道下一个线索藏在哪个不起眼的像素里而找到它的那一刻确实是这个模块最上头的瞬间。