
图片隐写里性价比最高的一类题就是PNG宽高被人动过手脚的那一种。它不需要你懂密码学、不用爆破、不用啃算法一张看起来干干净净的图只要把文件头里某个四字节整数改回正常值下半截被裁掉的隐藏文字就自己露出来了。这类题在CTF的misc方向出现频率极高属于入门必刷、老手也常拿它当热身题的典型。我见过不少刚接触图片隐写的朋友卡在这种题上不是因为不会改字节而是压根没想到这张图被截断过这个方向。下面我按自己实际做题的顺序把从判断题型、拆文件结构、定位字段、写脚本改值、修校验码到排查各种玄学现象的全过程完整写一遍中间会给出可以直接抄的Python脚本和几条踩坑经验照着走基本能独立复现。1. 这类题到底在考什么1.1 图片看起来正常才是最危险的信号CTF的misc图片题入口往往朴素得让人放松警惕一张图双击能打开画质正常没有花屏没有报错肉眼扫过去就是一风景照或者一张截图。很多人的第一反应是丢进Stegsolve翻颜色通道、看LSB最低位、试盲水印翻了一圈没收获就卡住了。这时候真正该怀疑的位置其实是图片的元数据而不是像素——一张PNG如果被人用十六进制编辑器把高度字段改小剩下的像素数据依然完整地躺在数据块里只是解码器读到新的高度值就停止绘制了。你在屏幕上看到的那一截画面本身完全正常异常发生在它没有被画出来的那部分。我最早接触这类题的时候把图拖进好几个工具里轮着试甚至怀疑过数据被加密了。后来顺手用命令行校验工具扫了一眼输出里提示数据流的解压长度和文件头声明的高度对不上。这个对不上就是整道题唯一的破绽也是所有后续操作的起点。所以判断题型的第一步不是动手改而是先验证文件自洽不自洽——一张原生导出的PNG它的压缩数据解压后长度应该刚好等于高度 × 每行字节数只要这个等式不成立八成就是宽高被动了。1.2 被裁掉的那部分其实一直在文件里要理解为什么改高度能出字得先接受一个前提PNG的像素数据是按行组织、再整体做无损压缩的。宽度决定每一行有多长高度决定一共画多少行两者共同描述这块画布多大。而压缩后的数据流本身并不知道画布多大它只是一段连续的字节。解码器的工作方式是拿到宽度算出每行字节数拿到高度决定循环读多少行然后从数据流里一行一行往外取。这里的关键在于改小高度并不会删掉数据。原本一张高400像素的图被人把高度改成200文件体积几乎不变因为压缩流一个字节都没动变的只是文件头里那四个字节的整数。解码器老老实实读200行就收工剩下200行的数据静静躺在文件末尾谁也不去碰它。反过来你把高度改回400解码器继续往后读被藏起来的下半部分就画出来了——很多时候那里写着一行字、一个flag格式的字符串或者一个二维码的下半截。这个机制的巧妙之处在于隐藏得非常自然文件大小正常、能正常打开、不报错普通的图片查看习惯完全发现不了。所以做这类题养成一个习惯比什么技巧都重要——拿到PNG先看文件头声明的尺寸再看压缩数据的实际容量两者对不上就先怀疑裁剪。1.3 这类题的典型外在特征从经验上看被裁剪过的PNG通常有几个可观察的线索。第一是文件体积与视觉尺寸不匹配一张显示为800×200的图文件却有几百KB按常规压缩率算明显偏大多出来的数据就有嫌疑。第二是画面内容收得太突然比如一张图底部正好切在文字中间、切在人脸上、切在一个方框的边缘这种构图在正常图片里很少见。第三是用十六进制编辑器打开时末尾的数据块长度远超图像本身所需。也有反过来的变体出题人把高度改大让画面下方多出一大片纯黑或者灰色区域真正的隐藏内容被压在正常画面之上需要你把高度改小才看得见。这两种方向的本质一样都是让文件头声明的尺寸和真实数据不匹配处理手法完全一致只是改大改小的方向不同。做题时两个方向都试一遍成本很低。2. 把PNG拆开看宽高到底存在哪几个字节2.1 签名与数据块的通用骨架PNG的文件结构非常规整值得花几分钟记牢因为后面所有操作都建立在这个结构上。文件最开头是固定的8字节签名十六进制写作89 50 4E 47 0D 0A 1A 0A其中50 4E 47正好是ASCII的PNG。这8字节的作用是让程序快速判断这是不是PNG所有合法的PNG文件都必须以它开头做文件头修复的题目也经常在这里动手脚。签名之后是一个接一个的数据块每个块的结构完全统一先是4字节的长度字段大端序只统计数据部分的字节数不含长度本身、类型和校验码接着是4字节的块类型ASCII字符比如IHDR、IDAT、IEND、tEXt然后是长度字段指定字节数的数据最后是4字节的循环冗余校验码。整个块的开销固定是12字节加上数据长度。第一个块必须是IHDR最后一个块必须是IEND中间可以有任意多个其他块。记住这个骨架的实际价值在于你打开十六进制编辑器时能立刻看出每个块的边界在哪而不会对着满屏数字发懵。比如看到00 00 00 0D后面跟着49 48 44 52就知道这是IHDR块长度13字节数据从第17个字节开始偏移从0算到第29字节结束紧接着4字节校验码。这个定位能力比任何工具都可靠因为工具可能装不上、可能版本不兼容但结构是不会变的。2.2 IHDR那13个字节各自管什么IHDR是整张图的总说明书数据部分固定13字节字段顺序和含义如下表。这张表我建议直接记住偏移量做题时能省掉大量翻文档的时间。相对IHDR数据起点的偏移长度字段名说明04Width图像宽度大端序无符号整数44Height图像高度大端序无符号整数81Bit depth位深常见1/2/4/8/1691Color type颜色类型0灰度、2真彩、3索引、4灰度加alpha、6真彩加alpha101Compression压缩方法规范里只有0111Filter滤波方法规范里只有0121Interlace隔行方式0不隔行、1为Adam7换算成文件的绝对偏移更好用签名占0到7IHDR块的长度字段在8到11类型字段在12到15所以数据从偏移16开始宽度占16到19高度占20到23位深在24颜色类型在25压缩在26滤波在27隔行在28校验码占29到32。整个IHDR块从偏移8到32共25字节。做题目时最常动的就是20到23这四个字节。这里有个必须刻进肌肉记忆的点PNG所有多字节整数都是大端序也就是高位在前。想把高度从80改成400400的十六进制是00 00 01 90就按这个顺序原样写进去千万别写成90 01 00 00写反了得到的会是一个荒谬的巨大值某些查看器会直接崩掉或者卡死。2.3 行结构为什么每行多一个字节是解题命门这一节是理解整类题的核心也是最容易算错的地方。PNG在压缩之前会把像素按行展开成一维字节流而每一行的最前面要额外加一个字节用来标记这一行用了哪种滤波方式取值0到4。这个字节不参与显示纯粹是给解码器还原像素用的。所以每行实际占用的字节数是每行字节数 1 向上取整(宽度 × 位深 × 通道数 ÷ 8)通道数由颜色类型决定灰度是1真彩是3索引是1灰度加alpha是2真彩加alpha是4。举个常见的例子一张真彩加alpha颜色类型6、位深8的图宽度800那么通道数是4每像素4字节每行就是800×43200字节加上滤波字节共3201字节。如果高度是400解压后的数据总长应该是 3201×400 1280400 字节。这个公式有两个用处。一是验证解压数据块后拿总长度除以每行字节数得到的商就是这张图真实能画多少行。二是反推如果算出来的行数明显大于文件头声明的高度差额就是被裁掉的部分。理解了每行多一个滤波字节你也就明白后面为什么强烈建议改高度而不是改宽度——宽度一变每行的长度跟着变所有行的滤波字节位置全部错位画面会撕裂成斜条纹。3. 实操两种改法从手工到脚本3.1 开改之前先做三件确认动手之前有三件事必须先确认否则很容易白忙一场。第一确认文件真的是PNG。不要相信文件后缀名用命令行看一眼真实类型输出里应该明确写着PNG image data 以及它的宽高和位深这个输出本身就已经把文件头声明的尺寸告诉你了很多时候一眼就能看出尺寸可疑。file hidden.png第二备份原文件。改字节是不可逆操作尤其是手工改的时候手滑多改一位整张图就废了虽然理论上能重下但比赛环境里往往没有第二次机会。养成cp hidden.png hidden.bak的习惯成本几秒钟能救命。第三先校验一遍再改。用命令行工具扫一下看看有没有现成的报错。如果有校验和错误的提示说明这道题可能不止改了宽高还顺手把校验码破坏了如果提示数据流长度异常那就基本确认是裁剪题。这个前置检查能帮你判断后面需不需要额外修校验码。pngcheck -v hidden.png3.2 用十六进制编辑器手工改一遍手工改一遍的意义不在于效率而在于让你对字节位置形成肌肉记忆考试环境里没有脚本的时候不至于抓瞎。打开十六进制编辑器按偏移20跳过去你会看到四个字节比如00 00 00 C8这代表高度200。想改成400就把这四位改成00 00 01 90保存。改完之后有两个选择一是直接打开看效果二是先把校验码修好。直接打开通常也能看因为不少看图软件对校验码并不严格但为了稳妥建议顺手把校验码重算一遍。IHDR的校验码在偏移29到32计算对象是类型字段加数据字段共17个字节也就是偏移12到28的内容算法是标准的CRC-32。需要注意的是别用文本编辑器改。十六进制字节流里存在大量不可打印字符和换行符文本编辑器会自动做编码转换、补BOM、改行尾一次保存就能把整个文件搞坏。必须用专门的十六进制工具或者干脆用脚本以二进制模式读写。另一个常见的手滑是改错了位置把偏移16到19的宽度当成了高度或者把长度字段的00 00 00 0D给改了。改长度字段的后果非常严重解码器会按错误的长度去读数据整个块边界全部错位文件直接打不开。所以改之前先在纸上或者注释里写清楚偏移204字节高度改完再核对一遍。3.3 脚本化改写并顺手修好校验码实际做题我更推荐脚本一次写好之后遇到同类题直接改参数复用。下面这段用Python标准库就能跑不需要装任何第三方包。它会解析所有块、打印出每个块的类型、长度、原始校验码和重算校验码方便你一眼看出哪个块被动过。import struct import zlib import sys def walk_chunks(path): with open(path, rb) as f: data f.read() if data[:8] ! b\x89PNG\r\n\x1a\n: print(not a png) return off 8 while off len(data): length struct.unpack(I, data[off:off 4])[0] ctype data[off 4:off 8] body data[off 8:off 8 length] old_crc struct.unpack(I, data[off 8 length:off 12 length])[0] new_crc zlib.crc32(ctype body) 0xffffffff flag OK if old_crc new_crc else BAD print(ctype.decode(latin1), len, length, crc, hex(old_crc), flag) off 12 length if ctype bIEND: break if __name__ __main__: walk_chunks(sys.argv[1])改高度并重算校验码的脚本更短核心就是改四个字节、重算一次CRC、写回四个字节。注意IHDR数据从偏移16开始共13字节类型字段在12到15所以校验对象是data[12:29]。import struct import zlib import sys def set_size(src, dst, widthNone, heightNone): with open(src, rb) as f: data bytearray(f.read()) assert data[:8] b\x89PNG\r\n\x1a\n, not a png assert data[12:16] bIHDR, first chunk is not IHDR old_w, old_h struct.unpack(II, data[16:24]) new_w old_w if width is None else width new_h old_h if height is None else height data[16:20] struct.pack(I, new_w) data[20:24] struct.pack(I, new_h) crc zlib.crc32(bytes(data[12:29])) 0xffffffff data[29:33] struct.pack(I, crc) with open(dst, wb) as f: f.write(data) print(size: %dx%d - %dx%d, new crc%s % (old_w, old_h, new_w, new_h, hex(crc))) if __name__ __main__: # 用法: python fix.py in.png out.png 400 set_size(sys.argv[1], sys.argv[2], heightint(sys.argv[3]))跑完之后直接打开输出文件。如果隐藏的字出现在下半部分这道题就结束了。如果下面出现的是大片纯黑或者灰色说明高度改得超过了数据支撑的范围需要往下走一步用数据长度反推真实高度。4. 高度到底该改成多少用数据流真实长度反推4.1 先算清楚每行占多少字节盲目试高度是最浪费时间的做法。与其从100试到10000不如直接把每行字节数算出来一步到位。计算公式前面提过这里给出可直接运行的版本从IHDR里读出宽度、位深、颜色类型查表拿到通道数然后套公式。import struct CHANNELS {0: 1, 2: 3, 3: 1, 4: 2, 6: 4} def row_bytes(width, bit_depth, color_type): ch CHANNELS[color_type] return 1 (width * bit_depth * ch 7) // 8这里的向上取整用的是(a 7) // 8这个整数技巧避免浮点误差。为什么要小心因为位深为1、2、4的图一行不一定刚好占整数个字节。比如位深1、宽度13的灰度图13位只占2个字节补到16位而不是1.625字节。如果这一步算错后面反推出来的高度全会偏你会在错误的方向上试很久。索引色图颜色类型3尤其容易被忽略它每个像素只占1个索引值位深通常是8或更低算出每行很小很容易算出个离谱的大高度。4.2 解压数据流数出真实行数拿到每行字节数之后把所有IDAT块的数据拼起来用zlib解压得到原始扫描线字节流长度除以每行字节数就是真实行数。一段图可能被切成多个IDAT块必须全部拼上漏掉任何一个都会算少。import struct import zlib import sys CHANNELS {0: 1, 2: 3, 3: 1, 4: 2, 6: 4} def real_height(path): with open(path, rb) as f: data f.read() off 8 idat b w h bd ct 0 while off len(data): length struct.unpack(I, data[off:off 4])[0] ctype data[off 4:off 8] body data[off 8:off 8 length] if ctype bIHDR: w, h, bd, ct struct.unpack(IIBB, body[:10]) elif ctype bIDAT: idat body elif ctype bIEND: break off 12 length raw zlib.decompress(idat) rb 1 (w * bd * CHANNELS[ct] 7) // 8 print(declared: %dx%d bit%d color%d % (w, h, bd, ct)) print(raw bytes%d row bytes%d % (len(raw), rb)) print(max rows%d remainder%d % (len(raw) // rb, len(raw) % rb)) return len(raw) // rb if __name__ __main__: real_height(sys.argv[1])输出的三个数字信息量很大。max rows是这段数据最多能画出多少行把它填回高度字段基本就是答案。remainder是除不尽的余数正常情况下应该是0如果不是0说明每行字节数算错了或者这张图的数据被改过不止一处也可能是这张图用了隔行方式需要另作处理。这里还有个容易被忽略的细节zlib解压本身可能失败。如果解压直接报错说明数据流被破坏过不是单纯的裁剪题得换个方向排查比如数据块被截断、被追加了额外字节、或者压缩流中间被塞了东西。解压失败和长度不匹配是两种完全不同的故障处理路径也不同。4.3 为什么优先改高度而不是宽度很多人第一次做这类题直觉是宽度也可能被改了于是两个都试一遍。我的建议是先只改高度宽度不要碰原因在于行结构。前面说过每行开头有一个滤波字节一行之内的像素还原依赖这个字节。宽度决定了每行多少像素、总共多少字节一旦你改了宽度解码器算出的每行长度就变了它会把原本属于第二行的滤波字节当成像素数据把原本的像素当成滤波字节后面所有行全部错位累积。视觉上的结果就是画面被拉成斜向的条纹颜色一块一块地斜着错开。这种撕裂画面里隐藏内容有时候还能勉强辨认但更多时候完全糊掉你会误判成改错了方向继续瞎试。而改高度只影响读多少行行的内部结构完全不变只要不超出行数上限每一行都能被正确还原画面下部是干净的、完整的。这个差别在实际做题中非常关键我见过太多人在宽度上反复折腾其实只是把简单题做复杂了。顺带说一个例外如果这张图用的是Adam7隔行方式IHDR第12字节为1改高度的影响会被放大因为隔行把图像拆成7个不同分辨率的通道每个通道的行数都和总高度挂钩改高度会导致各通道行数重新计算画面错乱得更厉害。遇到隔行图稳妥做法是先把那个字节改成0让它变成不隔行再调整高度。5. 校验码修复与解码器兼容性5.1 校验码算错会发生什么PNG每个块后面那4字节CRC计算对象是块类型块数据算法是标准CRC-32初值和异或输出都是常规配置Python的zlib直接提供。这个值的作用是让解码器判断数据有没有被破坏。改了宽高却不更新CRC解码器一读就能发现说明书和校验对不上。后果取决于你用哪个软件打开差异非常大这也是最容易让人误判的地方。严格遵循规范的库会直接拒绝解码报一个校验失败的错误表现就是图片完全打不开或者只显示一个破图标。宽松的实现则干脆忽略CRC照常渲染出完整画面隐藏内容照样看得见。浏览器通常属于后者所以很多人养成一个很实用的习惯改完的图先拖进浏览器看能出东西就先截图再去修CRC。所以CRC到底要不要修我的做法是都修。修的成本很低一个函数调用的事不修的代价是你在不同环境里得到不一致的结果排查起来极其费劲——在A软件里能看在B软件里报错你会怀疑是不是自己改错了字节其实是软件严格程度不同。5.2 验证与多解码器交叉确认改完之后的验证环节建议至少用两个不同来源的工具看一遍。一个用命令行工具校验结构是否合法一个用图形界面看实际渲染效果。命令行工具输出干净每个块都标OK说明文件结构没问题图形界面能看到隐藏内容说明改的方向对了。两边都过一遍才算真的改对。pngcheck -v fixed.png还有个小技巧如果图形工具打开后显示的内容和预期不一致换一个渲染引擎再看一次。同一个文件在不同解码器下可能表现出截断补灰报错三种完全不同的状态多试两个能帮你快速区分是我的文件坏了还是是这个软件太严格。另外提醒一句某些系统的图片预览有缓存改完同名文件后打开可能还是旧图。遇到明明改了却毫无变化的情况先改个文件名再打开能排除掉一大半假故障。6. 常见问题与排查实录6.1 现象、原因、处理速查表下面这张表是我这些年攒下来的基本覆盖了这类题九成以上的异常表现。遇到问题先对号入座比盲目重试高效得多。现象最可能的原因处理办法校验工具提示IHDR校验失败改了宽高没重算CRC用脚本重算并写回偏移29到32图片打不开、显示损坏误改了长度字段或块类型字段换回备份只改偏移20到23能打开但下半部分全是黑高度改得超过数据支撑范围解压数据流算出真实行数再填画面撕裂成斜条纹改了宽度导致行对齐错位宽度改回原值只调高度解压数据流直接报错数据流被破坏或截断换方向查附加数据、查多块拼接换软件才看得见不同解码器对CRC容忍度不同以能看见的为准随后补修CRC改了完全没变化图片预览缓存了旧文件重命名后再打开下方有内容但残缺隔行方式导致通道行数错位把隔行标志改成0重试6.2 改完还是没看到字往哪找如果高度改对了、画面完整了下半部分却是一片纯色什么都没有那基本可以判定隐藏内容不在像素里。这时候要及时止损换方向不要在宽高上继续耗。可以按这个顺序往下排查先看文件末尾有没有在结束块之后追加数据很多题会把flag直接贴在文件尾部再看有没有文本类数据块有些题把内容写进描述字段里然后是像素级的最低有效位这类需要工具辅助最后才考虑压缩流里手工插入的额外数据。反过来还有一种情况值得单独说有些题把高度改小的同时把多出来的数据并不是简单留着而是重新压缩了一遍导致解压长度刚好等于声明高度。这种题光靠长度对不上是发现不了的得靠视觉线索——画面底部被切得太生硬、图像长宽比不符合常见拍摄比例、某些元素明显只露出了一半。遇到这种直接把高度往上翻倍试一次成本极低。提示改高度时不要一次加太多。某些解码器对超大尺寸会直接拒绝渲染或者分配巨量内存导致卡死。建议先按一倍、两倍递增每次都在备份上操作。7. 同类变体与顺手扩展7.1 JPG也存在同样的切入点虽然这道题的主角是PNG但同一思路在JPG上同样成立值得一起记住。JPG没有CRC校验它的尺寸信息存在帧起始段里常见的是标记FF C0或FF C2段内结构是标记2字节、段长2字节、精度1字节、高度2字节、宽度2字节然后才是分量信息。注意这里是2字节而非4字节最大值只能到65535改的时候别按4字节写。改JPG高度的难点在于它的压缩方式。JPG不按行独立压缩而是把整幅图切成8×8的块做变换改高度后解码器会按新的块行数去解析数据不够时通常在后半段填充灰色或者直接报错。所以JPG改尺寸的效果远不如PNG干净经常只能露出部分内容。实践中的做法是把高度改大一点看能不能多解出几行能看清就用看不清再想别的办法。7.2 把对不上当成一种通用检查手段这类题给我的最大启发是文件声明的属性和文件实际的容量之间应该存在严格对应关系任何不匹配都是可疑点。这个思路可以迁移到很多地方压缩包声明的文件列表和实际解压出的数量不一致、文档声明的页数和实际内容长度不一致、音频声明的时长和数据量不一致。养成先算账、再验账的习惯比记住一百个工具快捷键有用得多。具体到图片隐写我现在的固定流程是看真实文件类型、看声明尺寸、算每行字节、解压数据流、比对行数。这五步跑完绝大多数裁剪类题目就现原形了剩下的才是考虑别的隐写手段。流程化的好处是不依赖灵感和运气即使换了出题人、换了文件格式这套检查逻辑依然成立。最后分享一个我自己的小习惯每做完一道这类题我会把改尺寸的脚本存一份参数改成通用形式文件名带上题目特征。两年下来攒了一个小脚本库新题进来先跑一遍能省掉大量重复劳动。这行的经验很多时候不是知道更多原理而是把验证过的东西留下来复用。