☰
PNG隐写全解析:从LSB到IDAT的CTF实战指南
2026/10/1 14:13:52 网站建设 项目流程

1. 从赛题视角看PNG隐写:为什么偏偏是它

1.1 Misc方向里PNG为什么是常客

CTF的Misc方向题目五花八门,流量分析、日志审计、取证、社工、音频波形、压缩包密码……各种奇怪载体都出现过,但要说出镜率最高的,PNG图片隐写绝对排进前三。你会发现不管是新手入门题还是比赛里的中档题,出题人都特别喜欢把PNG丢给你,然后让你在像素、数据块、文件结构里翻来翻去找东西。

原因不复杂。PNG是典型的无损压缩格式,意味着我在像素值上做一点细微改动,压缩后依然能完整保留这些改动,解压回来不会丢失。JPEG那种有损压缩在保存时就会抹掉一部分高频细节,想在里面藏信息得先承受压缩失真,稳定性远不如PNG。再加上PNG格式的块结构本身就有很多“合法冗余空间”,附加一段数据到文件尾部完全不影响图片显示,这让出题人藏东西的成本变得极低。

另一个原因是PNG的复杂性介于“好做”和“有坑”之间。它没有GIF那么老,也没有BMP那么一根筋——BMP几乎就是裸像素,藏了LSB一眼就能从文件大小不对劲里发现;而PNG有IDAT压缩流、有IHDR关键块、有各种辅助块,任何一个字段被改都可能导致图片打不开或者显示不完整,解题时自然就有了一层层往下剥的感觉。这种“剥洋葱”的过程正好符合Misc题考核分析思路的目的。

如果你刷过几道BUUCTF的图片题,应该能感觉到,PNG隐写题几乎没有重样的:有的考文件拼接,有的考宽高被改,有的考LSB,有的甚至把另一个文件塞进IDAT压缩流里。所以把这一个载体吃透,Misc方向至少三分之一的基础题就稳了。

1.2 一张PNG藏东西的七个位置

既然要系统学PNG隐写,第一步不是学工具,而是建立一张“藏匿点地图”。先搞清楚一张PNG里哪些位置理论上能放额外数据,后面做题才不会被工具牵着走。

位置原理典型识别信号
IEND块之后PNG解析到IEND就结束,尾部多余数据不影响显示binwalk扫出zip/rar,文件尾部有PK头
IHDR字段篡改width/height/bit depth等字段被改,图片显示不完整图片高度异常、CRC报错
IDAT压缩流压缩像素数据中混入其他文件内容,或单独改IDAT块pngcheck提示IDAT长度异常,zlib解出非图像数据
tEXt/zTXt/iTXt文本块在元数据块里写字符串strings直接看到可疑英文/Base64
像素最低有效位LSB修改对人眼不可见通道噪声异常、StegSolve逐位查看有规律图案
调色板PLTE索引色图片调色板颜色值低位置换使用索引色PNG,zsteg能扫出数据
像素整体规律偏移所有像素的RGB值整体加/减固定值与正常图片对比,直方图整体平移

这七个位置按“由外到内”的顺序排查是最好的做法。先看文件尾部有没有拼接内容,再看元数据块和关键字段,最后才深入到像素级和IDAT压缩流。为什么这么排序?因为越往外的检查成本越低,binwalk扫一下、strings拉一遍,几秒钟就能排除一半的可能;而像素级的分析相对耗时,放在后面能为前面的低级判断留出验证时间。

2. 文件结构层:先把“外部垃圾”清理干净

2.1 扫描三件套的用法与局限

拿到一张PNG,我习惯的第一动作永远是先跑一遍常规扫描,而不是直接打开看。这里的“常规扫描”指的是三样东西:binwalk、foremost、strings。

binwalk擅长做文件签名扫描,它能识别PNG尾部是否拼接了ZIP、RAR、7z这些常见文件。使用方式很简单:

binwalk challenge.png

输出里如果出现类似ZIP archive data之类的信息,说明图片后面有东西,直接binwalk -e提取就行。不过要注意,binwalk对PNG的识别是基于特征码的,有时候遇到伪装的、加密的、或者被截断的附件会失效。遇到binwalk没结果但是文件大小明显不合理(一个纯色PNG却有几MB),就要考虑是不是有加密或混淆过的拼接数据。

foremost是另一种思路,它不依赖binwalk那样按偏移识别,而是按文件类型分块提取,适合拼接内容被分散的情况。但foremost会把整张PNG的区块也一起切出来,输出目录里会有一堆碎片,反而容易找错重点。

strings命令则是拉取所有可打印字符串。这个工具简单得不能再简单,却常常直接给你答案:

strings challenge.png | head -50

如果在输出里看到flag{或者疑似Base64的字符串,就直接结束了。很多新手过于迷信图形化工具,反而忽略了这种最朴素的检查方式。我的建议是:不管后面要做什么,先跑一遍这三样,成本低、收益高,而且能帮你确认文件“大面上”没有藏着东西。

2.2 人工核对Hex:文件头尾和块序列

工具扫完之后,第二步就是打开Hex编辑器看结构。这一步的意义不是让你一行行读完整个文件,而是核对几个关键位置。

PNG文件头是固定的8字节签名:89 50 4E 47 0D 0A 1A 0A。在010 Editor或者HxD里打开,第一眼就要确认这个签名是完整的。如果是做题时题目给的图片被改过签名,常见的坑是文件头被改成89 50 4E 47 0D 0A 1A 0B或者其他文件类型(JPG的FF D8 FF E0)的签名,导致图片无法识别或打开方式错误。这种题考验的就是你对文件头的敏感度。

接下来看块结构。PNG由若干chunk组成,每个chunk的格式是:4字节长度 + 4字节类型 + 数据 + 4字节CRC32。按顺序应该依次是IHDR、PLTE(索引色才有)、IDAT(可能有多块)、IEND。IEND是最后一个块,正常情况它的数据长度是0,类型是49 45 4E 44,CRC是AE 42 60 82。

需要重点关注的地方是IEND之后。如果IEND后面还有内容,那几乎可以断定是拼接文件。还有一种情况是文件里出现两个IEND块——第一个IEND后面还跟着真正的IDAT块和另一个IEND。这种结构意味着出题人在正常图片还没结束时插入了一个伪造的结束标记,再用第二段IDAT藏数据,新手一看后面有IEND就以为文件结束了,实际上一半信息在更深处。

手动核对Hex的好处是能让你获得对文件整体的掌控感。工具告诉你“这有个ZIP包”的时候,你可能不知道它藏在哪个offset;自己看过一遍,你会知道“哦,这个ZIP从0xC8A1开始,前面前面是图片数据”,这种理解对后续手动切割文件很有帮助。

2.3 元数据块里的“暗语”

PNG的元数据辅助块是经常被忽略的藏匿点。tEXt块里可以存纯文本,zTXt块里可以存压缩文本,iTXt块则支持国际化文本。出题人很喜欢在这些块里塞提示信息,有时候是直接可读的,有时候是Base64编码后的密文。

检查这几个块最方便的方式是pngcheck:

pngcheck -v challenge.png

它会列出每个chunk的类型、长度、CRC校验情况。如果看到tEXt、zTXt块,用strings或者010 Editor提取出来查看内容。有的题会在tEXt块里写上类似key: admin123的提示,这个key后面可能就是解开LSB数据的钥匙。

需要注意的一点是,zTXt块里的文本是经过zlib压缩的,strings直接拉可能拉不出内容,需要先解压再读。处理方式很简单:

python3 -c "import zlib,sys; data=open('challenge.png','rb').read(); idx=data.find(b'zTXt'); import struct; ln=struct.unpack('>I',data[idx-4:idx])[0]; print(zlib.decompress(data[idx+9:idx+8+ln-5]))"

代码比较粗糙,但能应急。实际做题时,如果你看到zTXt块,直接优先处理它肯定是没错的,因为出题人不会无缘无故用压缩文本块存一个无关字符串。

3. LSB隐写:像素最低有效位里的另一个世界

3.1 为什么改最低位人眼看不出来

从这一节开始,才是PNG隐写里技术含量比较高的一层。LSB(Least Significant Bit)隐写的原理极其简单:把一个8位像素值的最低一位改变,数值上的变化最多只有1。比如原本是RGB(128, 200, 30),把最低位改掉后可能是(129, 200, 30)或(128, 201, 30),这个差异在屏幕上显示出来几乎等于没有变化。

但就是这一点点变化,就能承载数据。举个例子,想要隐藏字符“A”(ASCII码65,二进制01000001),我可以把它拆成8个bit,分别写入8个像素的R通道最低位。接收方只需要逐个读取每个像素的某一通道的最后一位,再按顺序拼起来,就能还原出“A”。

这个思路真正的关键是:PNG是无损压缩,保存再多次,像素值依然是保存时的值,不会像JPEG那样被二次量化破坏。所以只要隐写后不要再把图片另存为JPG,数据就一直在。做题时遇到一张看起来普通的PNG,如果文件大小和图像内容有明显不匹配(比如一张纯白底的图却有800KB),就要高度怀疑是LSB隐写。

为了更直观地理解“最低位”这个概念,我给你拆解一下:RGB每个通道8位,位权从高到低分别是128、64、32、16、8、4、2、1。最高位(第7位)一变就是128,肉眼可见颜色突变;最低位(第0位)一变只有1,人眼根本没感觉。所以LSB隐写牺牲的视觉质量最小,还能做到完全不改变文件结构,这也是它成为最经典PNG隐写方式的原因。

3.2 StegSolve和zsteg的组合拳

分析LSB隐写,老牌的StegSolve依然是新手的首选,因为它提供了一个非常直观的“逐通道逐位查看”功能。打开图片后,StegSolve的Analyse菜单里有“Data Extract”选项,可以分别选择R、G、B通道,再选择Bit 0到Bit 7的某一层位平面,勾选“Preview”就能看到该位平面是否出现规律图案。

正常的照片在每个位平面上看起来都是随机噪声,但如果是LSB隐写,最低位平面上会出现明显的文字轮廓或者另一个图像的轮廓。你只要用StegSolve把R、G、B三个通道的Bit 0都翻一遍,大概率能看到东西。

我见过不少新人卡在StegSolve上,原因不是找不到隐写数据,而是不会导出来。正确操作是:在Data Extract面板里,勾选对应通道的对应位,下方Bit Order选LSB First,Bit Plane Order选RGB,然后Save Text保存。这个操作会把选中的位平面数据直接导出为一个文件,常被导出一个ZIP或者PNG。

StegSolve虽然是图形化神器,但它对需要“按字节序组合多个通道”的场景不够灵活,这时候就该zsteg上场了:

zsteg -a challenge.png

zsteg会自动尝试RGB三个通道、各种位序组合、各种提取方式,直接输出可能隐藏的信息。它还有个好处是能检测调色板隐写和某些扩展的隐写方式。-a参数表示全部检测,虽然慢一点,但能最大程度避免漏报。

3.3 别把LSB想得太死:变体玩法要心里有数

LSB隐写还有一个常见变体,就是不用最低位而用倒数第二位、倒数第三位,或者只提取R通道、只提取G/B通道的组合。有的出题人觉得标准LSB太简单,就把数据写在Bit 1层,这种时候zsteg默认扫描可能扫不出来,需要手动指定:

zsteg challenge.png --bits 1

另一个容易忽略的点是位序。同样是取最低位,数据可以从最低位开始写入,也可以从最高位开始写入,提取时如果Bit Order选错了,出来的数据就是乱码。StegSolve和zsteg都会尝试这些组合,但如果你在写自定义脚本,务必要把LSB First和MSB First都试一遍。

调色板PNG的隐写思路又不一样。索引色PNG的像素值存的是调色板索引号,而不是直接存RGB。这种情况下,LSB隐写可以作用在索引值上,也可以作用在调色板颜色值的RGB分量上。zsteg对这类情况有专门支持,所以遇到color type=3的PNG,优先用zsteg扫。

我实战中最大的教训是:LSB隐写不一定产出肉眼可辨的图片,有时候是压缩包、有时候是文本、有时候是另一个PNG的字节流。拿到提取结果后不要急着在文本编辑器里看,先看看文件头是什么类型。脚本里加一句xxd result.bin | head,比盲目用记事本打开高效得多。

4. IDAT块与宽高篡改:藏在“解不开”的图片里

4.1 CRC32校验码:篡改宽高的突破口

比LSB再深一层的是IDAT压缩流分析。PNG的像素数据全部经过zlib压缩后放在IDAT块里,而这个压缩流的尺寸和图片宽高、位深、色彩类型直接相关。出题人常用的一个手法是:把IHDR块里的height字段改小,让图片只显示上半部分,真正的flag藏在图片下半部分。

为什么改height之后图片能正常打开却不完整?因为PNG解码时按IHDR声明的宽高来分配像素缓冲区,height被改小了,解码器只解码压缩流中前面的一部分像素数据,后面的数据被当作多余的忽略掉。而正常情况下PNG文件的IDAT长度与IHDR声明的尺寸是匹配的,一旦不匹配,pngcheck就会报错。

这里的核心判断依据是CRC32。IHDR块的数据部分包括width、height、bit depth、color type、compression method、filter method、interlace method,这些字段共同参与CRC32计算。当你看到一张PNG图片能正常打开但内容显示不全、或者pngcheck提示CRC error in chunk IHDR,基本就可以确定IHDR被改过了。CRC32校验失败说明文件有过人为修改,而最能藏信息又最合理的修改目标就是width和height。

修复思路有两种。一种是直接把height改大,看图片是否能恢复出更多内容;另一种是用已知的CRC32值反推原始宽高,虽然CRC32不可逆,但可以利用IDAT数据长度来验证候选值。

4.2 Python手撕IDAT恢复宽高

下面这段脚本是我在实战中常用的,核心思路是:枚举可能的width和height组合,计算对应的IDAT解压后长度是否匹配,匹配的那组就是原始宽高。

import struct import zlib def chunk_data(data, off): length = struct.unpack('>I', data[off:off+4])[0] ctype = data[off+4:off+8] cdata = data[off+8:off+8+length] return ctype, cdata, off + 12 + length with open('challenge.png', 'rb') as f: data = f.read() off = 8 ihdr_data = None idat_chunks = [] while off < len(data): ctype, cdata, off = chunk_data(data, off) if ctype == b'IHDR': ihdr_data = cdata elif ctype == b'IDAT': idat_chunks.append(cdata) elif ctype == b'IEND': break width = struct.unpack('>I', ihdr_data[0:4])[0] height = struct.unpack('>I', ihdr_data[4:8])[0] bit_depth = ihdr_data[8] color_type = ihdr_data[9] compressed = b''.join(idat_chunks) try: raw = zlib.decompress(compressed) except Exception as e: print('zlib decompress error:', e) exit() # 像素数据每行前面有1字节filter type row_size = width * (3 if color_type == 2 else (1 if color_type == 0 else 4)) # 判断实际数据长度是否为 当前width下 height行的行数 for h_guess in range(height, 10000): if len(raw) == (row_size + 1) * h_guess: print(f'possible height: {h_guess}') break

脚本逻辑不复杂,就是反推height。如果height被改小,那IDAT解压出的像素流长度其实是按原始宽高来的,我们只需要找到一个能整除(row_size + 1)的height数即可。

不过在真正做题时,width也可能被修改,比如把width改小导致图片被裁成细条。这种情况稍微麻烦一点,因为width决定每行的字节数,改小了之后每一行的数据是错位的,直接按行解出来的画面是撕裂的。此时需要枚举width和height的组合,判断解压数据按某个行列划分后,每一行的filter type是否合法(0-4之间)。合法组合大概率就是原始尺寸。

4.3 IDAT压缩流里藏另一份文件

宽高篡改只是IDAT层的一种玩法,更进阶的是直接在IDAT压缩流里塞其他内容。因为IDAT数据本身是经过zlib压缩的,你可以在压缩前准备好一段数据流,其中一部分是像素数据,另一部分是隐藏文件内容,然后整体压缩。解析时,zlib解压出来的数据前面是图像像素,后面可能藏着一个完整的ZIP或另一个文件。

遇到这种情况,先用4.2节的方法把整个IDAT解压出来,然后用binwalk对解压后的原始数据再扫一遍。如果binwalk能在解压流里识别出ZIP文件头,直接提取即可。

还有一类骚操作是把一个Python的pyc文件拆成多个字节段,分别写入PNG的多个辅助块里,或者把整个pyc塞在IDAT尾部。这种题做得多了你会发现,万变不离其宗:先把PNG的每一层都拆开、解压、扫描,不要因为图片能正常显示就觉得“里面没问题”。压缩流是PNG内容的最大容器,也是最容易被粗心者跳过的地方。

5. 实战排错:工具扫不出来时该怀疑什么

5.1 数据被加密或变换:从“没结果”到“有结果”

做Misc题最焦虑的时刻不是图太复杂,而是工具全跑了一遍,结果全是空的。这时候先别灰心,回想一下:如果你是一个出题人,你会让你想藏的flag这么容易被扫出来吗?显然不会。所以“工具扫不出”往往意味着数据被套了一层“变换”。

常见的变换包括:

  • 对隐藏数据做了异或运算,需要先猜key才能还原
  • 对隐藏数据做了字节反转,头部特征全被打乱
  • 数据被Base64编码后藏在像素里,提取出来是Base64串而不是明文
  • 数据被压缩后再隐藏,提取出来是zlib等压缩流,需要再次解压

我的习惯是:zsteg扫出疑似数据但显示乱码时,把提取结果保存成文件,然后分别试试xxd、file、binwalk对结果文件做二次分析。很多时候乱码只是因为格式没有被识别,加上文件头分析后就能找到方向。

还有一种情况是zsteg扫出了数据但只有前半段能看懂,后半段是乱码。这往往说明数据不是从位平面起点开始写的,而是跳过了一段固定区域。比如出题人先把一些无意义的填充位写在前面,再把有效数据写进去。处理方法是把提取结果按不同的偏移切片,逐个查看每个切片中间是否有可读内容。

5.2 四件容易翻车的小事

做题翻车的场景高度重复,我把最常见的几件列在这里:

第一,把图片另存为了JPEG。有些人习惯把题目图片拖进画图软件里看一眼,顺手保存成jpg,结果LSB数据全被有损压缩干掉了。修复方法是每次都从原始文件重新解压,不要在分析过程中用“另存为”污染原始数据。

第二,没有备份原始文件。有的脚本可能改写原始文件,比如修复宽高时直接往原文件里写数据,改坏了想回退都难。现在分析任何文件,第一步永远是复制一份,分析副本。

第三,忽略了通道顺序。LSB提取时R/G/B通道的顺序和写入时不一样,导致提取出来的数据是乱码。遇到这种情况,把通道顺序换成BGR、RBG、GBR等常见组合再试一遍。

第四,对宽高的判断依赖肉眼而非计算。有时候图片看起来正常,不代表width和height没被改过。比如两个高度只相差1的图片肉眼分辨不出来,但用pngcheck一查CRC就露馅了。所以凡是怀疑IHDR有问题的,别靠眼睛判断,要看CRC。

5.3 区分正常噪声与隐写噪声

看到这里你可能会担心:如果每张PNG的位平面都有噪声,我怎么确定哪一层有隐写?我的经验是看“规律性”。自然图像的LSB位平面虽然看起来是噪声,但你放大观察,会发现它们是随机的、无结构的;而隐写数据的位平面往往因为编码格式(比如PNG文件的签名、ZIP文件的PK头)而带有明显的局部规律,甚至直接形成可辨别的字符轮廓。

StegSolve里有一个操作很实用:把图片的Bit 0和Bit 7分别做成两个图层,切换频率比较。如果Bit 7是正常图像图案,Bit 0看起来却像一张完整的小图或者一行规则文字,那基本可以判定为隐写。

另外看直方图也能辅助判断。正常PNG的RGB直方图是平滑连续的,如果某个通道的直方图出现“阶梯状”分布,说明像素值的最低几位被“使用”过,存在隐写的可能。

6. 一道题的正确解体姿势:从拿到文件到出结果的完整流程

6.1 先建档,再动手

很多新手做题是拿到文件就打开看,看到没发现异常就卡住了。正确的方式是先做信息收集。

面对一张PNG,先记录基础信息:文件大小、尺寸、位深、色彩类型、是否索引色、是否隔行扫描。这些信息用identify -verbose或者pngcheck都能拿到。文件大小尤其关键,一张1024x1024的RGB PNG,理论未压缩大小为3MB左右,压完之后一般在几百KB到1MB。如果一张纯色图片却有2MB,信息冗余度异常高,大概率藏了东西。

然后查看文件尾部:IEND之前有没有多余块、IEND之后有没有拼接。接着查看所有辅助块,尤其tEXt、zTXt。这一套流程走完大约需要两分钟,但能帮你排除一半以上的可能性。

6.2 我的排查顺序:由外到内、由浅入深

下面这个顺序是我在实践中固定下来的一个SOP,每次做PNG隐写题都按这个来,效率最高:

步骤操作预期发现
1binwalk扫描文件整体尾部拼接文件、嵌套文件
2strings拉取可打印字符串明文flag、提示key、Base64密文
3pngcheck -v查看块结构CRC错误、异常块、IEND位置
4手动查看Hex,重点看IHDR和IEND附近修改过的字段、多余数据
5zsteg -a自动扫描像素层LSB/MSB隐写、调色板隐写
6StegSolve逐通道逐位目检位平面上肉眼可见的规律图案
7解出IDAT数据流,对原始解压流执行1、2步压缩流中嵌套的隐藏文件

这套顺序不是绝对的,但有一个核心原则:先做成本低的检查,再做成本高的分析。binwalk和strings是秒出结果,pngcheck也不会超过一秒钟,但StegSolve和IDAT解压需要人工判断。把快速检查放在前面,能在早期就拿到线索,避免陷入深度分析后才发现最外层藏着个ZIP的尴尬。

6.3 几个经过多次实战验证的小习惯

最后分享几个对做题成功率影响很大的习惯。

一是给每个工具的输出单独保存。binwalk的结果、pngcheck的输出、zsteg扫出来的所有疑似数据,分别存成文件并命名。因为有些数据不是一次就能提取完整的,后面可能需要基于这些中间结果做进一步处理,重新跑一遍反而浪费时间。

二是保留原始文件名和路径。题目给的图片只要改过一次名,做题时看到flag里包含文件名提示的场景就会对不上。比如有的题目flag就是图片文件名加一段密文还原出来的,改名会直接造成信息丢失。

三是不要排斥写脚本。Misc题刷到后面,所有现成工具都只能帮你定位问题,真正解决问题基本靠Python。对binascii、struct、zlib这三个库的操作要熟练,PNG隐写分析百分之八九十的脚本都绕不开它们。

踩过几次坑之后我的体会是,PNG隐写分析的难度不在于某个工具有多难用,而在于你能不能把一个文件看成“容器+像素数据+压缩流”的三层结构。有了这个结构感,工具只是验证假设的手段,而不是你找flag的唯一指望。

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

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

立即咨询