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索引色图片调色板颜色值低位置换使用索引色PNGzsteg能扫出数据像素整体规律偏移所有像素的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 44CRC是AE 42 60 82。需要重点关注的地方是IEND之后。如果IEND后面还有内容那几乎可以断定是拼接文件。还有一种情况是文件里出现两个IEND块——第一个IEND后面还跟着真正的IDAT块和另一个IEND。这种结构意味着出题人在正常图片还没结束时插入了一个伪造的结束标记再用第二段IDAT藏数据新手一看后面有IEND就以为文件结束了实际上一半信息在更深处。手动核对Hex的好处是能让你获得对文件整体的掌控感。工具告诉你“这有个ZIP包”的时候你可能不知道它藏在哪个offset自己看过一遍你会知道“哦这个ZIP从0xC8A1开始前面前面是图片数据”这种理解对后续手动切割文件很有帮助。2.3 元数据块里的“暗语”PNG的元数据辅助块是经常被忽略的藏匿点。tEXt块里可以存纯文本zTXt块里可以存压缩文本iTXt块则支持国际化文本。出题人很喜欢在这些块里塞提示信息有时候是直接可读的有时候是Base64编码后的密文。检查这几个块最方便的方式是pngcheckpngcheck -v challenge.png它会列出每个chunk的类型、长度、CRC校验情况。如果看到tEXt、zTXt块用strings或者010 Editor提取出来查看内容。有的题会在tEXt块里写上类似key: admin123的提示这个key后面可能就是解开LSB数据的钥匙。需要注意的一点是zTXt块里的文本是经过zlib压缩的strings直接拉可能拉不出内容需要先解压再读。处理方式很简单python3 -c import zlib,sys; dataopen(challenge.png,rb).read(); idxdata.find(bzTXt); import struct; lnstruct.unpack(I,data[idx-4:idx])[0]; print(zlib.decompress(data[idx9:idx8ln-5]))代码比较粗糙但能应急。实际做题时如果你看到zTXt块直接优先处理它肯定是没错的因为出题人不会无缘无故用压缩文本块存一个无关字符串。3. LSB隐写像素最低有效位里的另一个世界3.1 为什么改最低位人眼看不出来从这一节开始才是PNG隐写里技术含量比较高的一层。LSBLeast 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 FirstBit Plane Order选RGB然后Save Text保存。这个操作会把选中的位平面数据直接导出为一个文件常被导出一个ZIP或者PNG。StegSolve虽然是图形化神器但它对需要“按字节序组合多个通道”的场景不够灵活这时候就该zsteg上场了zsteg -a challenge.pngzsteg会自动尝试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 type3的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:off4])[0] ctype data[off4:off8] cdata data[off8:off8length] 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 bIHDR: ihdr_data cdata elif ctype bIDAT: idat_chunks.append(cdata) elif ctype bIEND: 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(fpossible 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的唯一指望。