赛程过半,我在题目列表里刷到"ISCTF2025 - 美丽的风景照"。看到这个名字的瞬间,脑子里其实已经开始自动分类了:带图片附件、名字又是"风景照"这种描述性词汇,九成是隐写题(Steganography),剩下的一成可能是社工题或者对抗样本题——但附件类型会很快告诉我答案。我甚至能预见到接下来几个小时的状态:下载附件、跑文件分析、翻通道、解压缩,直到最后在某个不起眼的角落抽出一串flag。
这篇文章更适合两类人看:一是刚接触CTF、想从图片隐写入门的新手;二是主攻Web或逆向、Misc题打得比较少,想在赛前把工具链补齐的选手。我会以这道风景照题为引子,把"拿到图片附件之后应该按什么顺序做什么"完整捋一遍。很多人在图片题上卡住,不是因为工具不会用,而是因为顺序不对——一上来就开StegSolve翻通道,反而错过了文件尾部附加数据这种最常见的藏法。下文涉及的命令和脚本都可以直接抄,但更重要的是理解每一步背后的判断逻辑。
1. 打开图片之前:信息收集阶段的三板斧
1.1 文件类型与尺寸:先确认附件的"表面身份"
拿到附件的第一步,不是双击打开看风景,而是先让文件自己说话。在终端里跑一句:
$ file scene.jpg scene.jpg: JPEG image data, JFIF standard 1.01, resolution (DPI), 1920x1280file输出很短,信息量却不小:格式是JPEG,尺寸1920x1280,这说明它至少是一张"正常照片"级别的图。紧接着看一眼文件大小:
$ ls -l scene.jpg -rw-r--r-- 1 user staff 2.4M scene.jpg2.4MB 对应一张1920x1280的JPEG照片,完全正常。但如果出现"1920x1280的图只有几十KB"或者"文件尺寸很大但压缩比离谱"这种反常组合,就要立刻提高警惕。很多时候出题人会直接在图片后面追加压缩包,或者干脆把信息写进PNG的IDAT块之外的自定义块里——这些都会让文件大小与正常图片产生偏差。
接下来我会用十六进制编辑器快速看一眼文件头和文件尾。JPEG 以FF D8 FF开头,以FF D9结尾;PNG 以89 50 4E 47 0D 0A 1A 0A开头,以IEND收尾。这个动作花不到30秒,却能避免后面很多误判。比如你以为是PNG题,结果附件是伪造扩展名的JPEG,直接套PNG工具链就会卡住。
1.2 strings与binwalk:先摘低垂果实
第二板斧是strings,这是隐写题里性价比最高的命令。它会把文件里的可打印字符串全部拉出来,配合 grep 过滤,能快速发现明文线索:
$ strings scene.jpg | grep -iE "flag|ctf|isctf|password|key|hint"有些新手不爱跑这个命令,觉得"出题人怎么可能把字符串明文放文件里"。但事实上,出题人也会失误,或者故意在图片的注释块里塞半截提示。我在一道BMP题里就遇到过,strings直接吐出一段Base64,解出来是后半段提示。对于"美丽的风景照"这种题,直接出flag的概率不大,但顺手跑一次没有任何成本。
更关键的是binwalk。它的原理是扫描文件里的已知文件签名,也就是 magic bytes:
$ binwalk scene.jpg DECIMAL HEXADECIMAL DESCRIPTION -------------------------------------------------------------------------------- 0 0x0 JPEG image data, JFIF standard 1.01 812345 0xC64B9 ZIP archive data, at least v2.0 to extract看到 ZIP 出现在 JPEG 中段或末尾,基本可以断定题目结构是"图片 + 附加压缩包"。不过binwalk的误报率不算低,看到输出别急着兴奋,要结合偏移量和文件头特征判断。比如在0xC64B9这个位置,如果十六进制里能看到50 4B 03 04(ZIP本地文件头),那基本就坐实了。
1.3 EXIF与注释区:图片自带的小纸条
第三板斧是exiftool,用来读取图像的元数据:
$ exiftool scene.jpg ... ImageDescription : 2025 spring Artist : ISCTF Comment : reach_for_the_???出题人特别喜欢在EXIF字段里藏线索,常见的藏匿点有Comment、ImageDescription、Artist、Software、GPS Position等。一个很实用的经验是:如果EXIF里出现明显不属于相机自动生成的字段——比如一句英文短句、一串疑似密钥的字符串,或者一个路径——几乎可以断定它是线索。
有些题还会把提示拆成两段,一段在EXIF里,另一段藏在图片本身的视觉区域。这就是很经典的"提示联动"设计。信息收集阶段跑完这三板斧,你对题目已经有了初步判断:文件结构是否干净、字符串区有没有异常、元数据里有没有线索。接下来才进入真正的像素级分析。
2. 风景照为什么适合做隐写载体:像素层面的攻防逻辑
2.1 照片的"噪声富矿"与LSB隐写原理
在深入通道分析之前,有必要把LSB(Least Significant Bit)的原理讲透。一张彩色图片的每个像素通常由R、G、B三个通道组成,每个通道的取值范围是0到255,落到二进制就是8个比特位。所谓最低有效位,就是指每个字节最右边的那个bit——它从0变成1,RGB值只变化1,反映到人眼上几乎等于没有变化。
把要隐藏的信息转成二进制,然后逐个覆盖图片像素的最低bit位,这就是LSB隐写最基本的做法。为什么风景照是重灾区?因为照片天然包含大量高频细节:树叶的纹理、水面的波纹、天空的云层渐变。这些区域的像素值变化本来就剧烈,LSB的改动会被直接淹没在噪声里。反过来,如果载体是一张纯色背景图,LSB嵌入后会产生肉眼可见的色斑,或者至少能被RS分析等统计检测工具抓出来。
这里还有一个概念叫"隐写容量"。一张1920x1280的图有大约245万个像素,每个像素有RGB三个通道,每个通道能塞1bit信息,理论容量接近92万字节——但实际上不可能全塞满,因为完全塞满会破坏图片统计特征。出题人一般只嵌入几百字节的flag,对这张图来说只是九牛一毛。
2.2 工具选型:StegSolve、zsteg与手工交叉验证
工具层面,老牌的 StegSolve 依然是首选。把图片拖进去,打开Analyse菜单下的Frame Browser或Bit Planes,就能逐通道、逐bit位查看图像。当你在R、G、B三个通道的第0位平面上看到明显非随机的图案——比如字符轮廓、二维码、可读的文字排列——说明信息就藏在通道低位里。
zsteg 则更适合快速批量检测PNG和BMP:
$ zsteg -a scene.png [?] 1920x1280 pixels b1,rgb,lsb,xy .. text: "flag{...}"这里要特别强调一个原则:工具输出只算线索,不能直接当作最终答案。zsteg报出来的一段文字有可能是完整的flag,但更常见的情况是它给出一段半截提示,或者把同一段字符串重复输出了很多次。我在处理这种输出时,一般会再用脚本手工提取一次LSB数据,确认数据的开头结尾、字节序和提取方向,再做下一步判断。
| 工具 | 适用场景 | 输出形式 | 注意点 |
|---|---|---|---|
| StegSolve | JPEG/PNG/BMP逐通道查看 | 可视化平面图 | 适合手工确认,不适合批量 |
| zsteg | PNG/BMP快速LSB检测 | 文本/二进制 | 交叉验证后再信 |
| binwalk | 所有文件的签名扫描 | 偏移量与文件类型 | 有误报,需看十六进制确认 |
| exiftool | 元数据字段审查 | 键值对文本 | 关注非相机自生成的字段 |
2.3 "美丽"本身就是提示:图像细节与嵌入方式的关系
题目叫"美丽的风景照",这个命名本身就给出了线索。如果我是出题人,要在这样一张细节丰富的照片里塞信息,肯定会优先选择纹理最复杂的区域——比如树林、草地、水面交界处——或者干脆用全图低位嵌入。做题时对应的思路是:当你在某个通道平面里看到一团规律性异常,先别急着还原整张图,而是先定位异常区域在画面中的坐标,再去原图对比那个区域长什么样。
一个反直觉的规律是:如果一张风景照里有一大片干净的纯色天空,但天空区域的LSB平面却出现了规律性噪点,那几乎可以肯定是脚本定向写入的结果。这也是为什么有些人用StegSolve翻完一遍没发现东西,直接放弃——他忽略了一个问题:这类题不一定要全图嵌入,可能只动了某个坐标区块。遇到这种情况,更需要结合图像内容去"哪里最乱看哪里"。
3. 从疑似路径到真正Flag:一次完整的提取复盘
3.1 定位附加数据:binwalk输出与手动分离
回到信息收集阶段的binwalk输出,假设它在0xC64B9附近报出了ZIP压缩包。此时最稳妥的做法不是直接迷信binwalk -e,而是先用dd手动抠出来看看:
$ dd if=scene.jpg of=hidden.zip bs=1 skip=$((0xC64B9))这样能确认ZIP是否从正确位置开始。如果直接用binwalk -e自动提取,有时会把JPEG尾部几字节也带进压缩包,导致解压时报CRC错误。手动分离之后,立刻验证完整性:
$ unzip -t hidden.zip或者用7z t hidden.zip。只要这里输出没有 error,说明压缩包结构是完整的,可以放心往下走。
3.2 加密压缩包的密码:线索不在天上掉
解压到一半,压缩包弹出了密码提示。这时候回看信息收集阶段的产物——EXIF的Comment字段、strings输出的可疑字符串、图片本身某个角落的文字——它们往往就是密码线索的组成部分。
比较典型的出题方式是:提示写着"密码是图片里那座桥的名字,拼音小写",然后你在照片右下角发现一行极小、低对比度的字。肉眼看不清不要紧,把图片放大,或者在图像编辑软件里拉一下色阶,文字就会从背景里浮出来。还有另一种常见情况:某段线索字符串不是直接拿来当密码的,而是先做了简单编码——Base64、凯撒位移、MD5前八位等。我当时对着一串看起来不像密码的字符试了好几次,后来才反应过来要先解码再当密码用。
这类题的共同点是什么?线索不是孤立的。你从EXIF里拿到的半截字符串,和从图片视觉区读到的另半截,拼起来才构成完整的密码。做题时一定要做好线索记录,每发现一个可疑字符串就单独记一行,免得后面来回翻终端历史。
3.3 二次隐写:被改过宽高的PNG与CRC爆破
解压出来的文件是一个flag.png,但用图片查看器打开时,要么直接报错,要么显示成一张被裁切过、上下比例怪异的图。遇到这种"能打开但不对"的情况,先别怀疑文件损坏,优先怀疑IHDR宽高被篡改。
用pngcheck看一眼:
$ pngcheck -v flag.png ... chunk IHDR at offset 0x0000C, length 13 1920 x 1154 image, 24-bit RGB ...如果图片实际内容是1920x1280的正常尺寸,但IHDR里写的是1920x1154,那么解码器只会按1154的高度解析像素,下面的部分就被截掉了。修复思路基于一个事实:IHDR块带CRC32校验,我们只需要枚举可能的高度值,计算篡改后的IHDR CRC,直到与文件现有CRC匹配。
import struct import zlib data = open('flag.png', 'rb').read() width = struct.unpack('>I', data[16:20])[0] crc = struct.unpack('>I', data[29:33])[0] for h in range(1, 4000): ihdr = data[12:16] + struct.pack('>II', width, h) + data[24:29] if zlib.crc32(ihdr) & 0xffffffff == crc: print(f'找到匹配高度: {h}') break找到正确高度后,用十六进制编辑器改回IHDR对应字节,图片立刻能正常显示。真正的flag要么直接显示在图片上,要么还需要再对修复后的图片做一次LSB提取。这就是"二次隐写"的典型节奏——第一层藏在文件结构里,第二层藏在像素里。
4. 这类题目最容易翻车的三个地方
4.1 只测LSB,忽略文件结构
见过太多人在一道BMP题上卡了几个小时,一直在StegSolve里翻通道平面,结果最后发现文件尾部直接塞了一整个RAR压缩包。这类教训反复出现,说明很多人对隐藏位置的优先级判断是错的。我的经验是,按这个顺序排查:文件结构 > 头尾附加数据 > 元数据字段 > 像素通道。先确认有没有拼接文件、有没有附加压缩包、有没有隐藏在文件头尾的字符串,再去做像素级分析。顺序反了,浪费的是最宝贵的比赛时间。
4.2 提取之后不校验,解出来全是乱码
用binwalk -e自动分离出来的文件不一定是完整的。我之前遇到过一次压缩包少了几字节,没做完整性校验就直接去爆破密码,结果解出来的文件全部损坏,打不开也分析不了,最后只能回到原始附件重新手动提取。所以任何时候得到压缩包,先跑一遍unzip -t或者7z t,确认哪个文件报CRC错误、缺多少字节。如果只是头部多了几个字节,用dd从正确偏移重新切一次就行;如果是中间缺了数据段,那就要考虑是不是JPEG和ZIP之间还藏了一层别的结构。
4.3 修改宽高的边界条件:不只是高度一个数
CRC爆破PNG宽高时有几个容易忽略的细节。第一,枚举范围不要太保守,有的题目会把高度改成原图的十分之一,范围从1跑到原高度的两倍比较稳妥。第二,宽度也可以被篡改,所以更完整的做法是宽高联合枚举。第三,如果CRC匹配了但图片依然不对,可能是出题人连压缩算法或filter类型都改过,那就需要pngdefruiter这类工具介入,而不能只停留在改IHDR的层面。爆破出多个候选高度时也不要慌,用图片查看器逐个验证,选那个显示内容最自然的。
5. 从做题到出题:风景照还能怎么变形
5.1 变体一:双图差分,两张"一模一样"的照片
如果题目附件给了两张看起来几乎一样的风景照,别纠结看哪张更好看,直接用差分工具逐像素对比。两张图的尺寸相同、肉眼观感相同,但如果有隐写,必然在像素层面存在差异。用Python处理非常简单:
from PIL import Image, ImageChops img1 = Image.open('scene_a.png').convert('RGB') img2 = Image.open('scene_b.png').convert('RGB') diff = ImageChops.difference(img1, img2) diff.save('diff.png')差分图里,完全相同的区域是黑色,有差异的区域会高亮出来。如果差异区域恰好拼成一段文字或一个二维码,那答案就直接摆在了眼前。如果两张图尺寸不一致,先resize到相同尺寸再比较,不然差分结果全是噪点。
5.2 变体二:动态图与多帧隐藏
风景照不一定只有JPG和PNG两种形态,也有可能是GIF或者APNG。多帧图片的信息隐藏方式更多样:某一帧单独嵌入了LSB信息,或者利用帧间隔时间做"一闪而过"的提示。逐帧提取用ffmpeg就行:
$ ffmpeg -i scene.gif -vsync 0 frame_%03d.png提取之后把每一帧都当独立图片跑一遍信息收集流程。帧数不多时,手工扫一遍也不费事;帧数上百时,优先挑画面内容差异最大的关键帧看。反正这类题的目的就是让你"逐帧看",别嫌麻烦。
5.3 变体三:频域隐写与盲水印
进阶一点的风景照题会用DCT变换或小波变换,把信息隐藏进频域,这就是常说的盲水印。肉眼和LSB检测工具都发现不了,需要把图片转换到傅里叶频谱里观察是否存在规律性点阵。检测时可以用Python的NumPy做FFT,也可以用图像软件查看频谱图。这类题通常会在文件名或题面里留一个"watermark"之类的词,不会让新手凭空猜。我个人的建议是:这类变体可以作为知识储备,不必作为优先排查方向——频率隐写在CTF里出现的频率远低于LSB和文件拼接,先解决大概率,再去赌小概率。
最后再分享一点个人体会。隐写题最值钱的从来不是某个具体工具,而是"按优先级排查"的思维。我看过太多新手在通道平面里翻到天荒地老,却不肯先跑一遍binwalk,也见过有人解出压缩包之后不校验就硬着头皮往下走,白白浪费半小时。每次比赛开始前,把binwalk、exiftool、zsteg、StegSolve、pngcheck这些工具在本地跑通一遍,确认版本可用,比临时上网找教程高效得多。遇到"美丽的风景照"这种题目名,先笑纳——它至少明确告诉了你,战场在图片里,接下来就按顺序打,别跳步。