CTF 二维码修复与隐写提取:从结构原理到实战排查 拿到“ISCTF2025 - 小蓝鲨的二维码”这道题时我第一反应是二维码题还能单独占一个签到分但真正打开附件后血压就上来了——图片中央蹲着一只半透明的小蓝鲨右上角定位角缺了一小块用手机对着屏幕扫了七八次全部提示“无法识别二维码”。这题表面是MISC里的常见套路实际把二维码结构、图片修复、隐写提取全串在一条线上了。这篇笔记是我完整走通的排查和修复过程适合刚开始打CTF的人也适合做数字取证、图像处理的朋友甚至你在微信里遇到“二维码扫不出来”想自己动手修图也能从里面找到可参考的思路。我会从文件信息排查讲到二维码的结构与纠错再到修复定位角、处理遮挡区域最后提一下扫码结果背后的第二层隐写以及我踩过的坑和速查工具。1. 拿到附件后的第一轮排查别急着用扫码工具1.1 先确认文件身份和附加尾巴我习惯是先不开图直接对附件做最基础的文件识别。命令很简单但很多人会跳过这一下导致后面方向全错。file shark_qr.png输出如果显示 PNG image data, 512 x 512, 8-bit/color RGB说明扩展名和实际格式一致。如果扩展名是 png 但文件头不是89 50 4E 47大概率是文件被改过扩展名后面所有图像分析都会跑偏。再看一眼十六进制头和尾部。xxd shark_qr.png | head xxd shark_qr.png | tailPNG 结尾通常是IEND区块如果尾部紧跟着一段大量 ASCII 字符说明有人往图片后面塞了东西。我这次用strings shark_qr.png排查可疑字符串时确实发现了一段类似 Base64 的尾巴但当时不确定是干扰项还是有效线索。这种“文件尾部附加数据 二维码隐藏信息”组合在 CTF 图片题里太常见了所以第一时间记下来等二维码解完再回来处理。1.2 小蓝鲨图案到底挡了什么用看图工具打开原图后最醒目的就是正中间的小蓝鲨。它并不是贴纸式样的大白块而是带有半透明通道的装饰图透明度不算低刚好把二维码中部数据区压住一部分。很多新手拿到图第一个动作就是打开微信扫码发现扫不出来后就一脸懵。实际上要先判断遮挡对二维码解码的影响到底有多大。二维码的解码依赖数据区黑白模块的准确采样如果鲨鱼图案只是叠加在原来模块上某些模块颜色会从纯黑变成灰色从纯白变成浅蓝色。手机扫码时算法会根据阈值把这些浅蓝识别成白色深蓝识别成黑色可能还能忍。但这题明显不给这个面子。我仔细看了鲨鱼边缘它不是在原二维码上简单画上去的更像是直接把手绘图案覆盖到数据区之后又重新导出某些模块被完全替换成了蓝色。这样的遮挡已经不属于“噪声”而是真实的数据错误。更麻烦的是右上角定位角被裁掉一小块定位结构不完整扫码引擎连“二维码在哪儿”都确认不了。1.3 这题到底想考什么从题目名字和附件形态来看ISCTF 出题人想考核的是三个能力点知道二维码的结构明确什么区块不能缺。会做基本的图片修复至少能把定位角补齐、把遮挡区域重建到可解码的程度。在解出二维码文本之后能意识到这不是终点还能顺着隐写线索继续挖。这类题目在 MISC 里属于“小综合”不是单纯给一张干净二维码让你扫一下出 flag。它把图像取证和隐写分析合并在一起难度不高但触点很多。你要是不知道二维码纠错原理就会一直停留在手机扫码失败的阶段你要是只会修图不知道 LSB 隐写扫出来一段乱码也不知道怎么用。所以第一阶段不用碰扫码工具先把“这是什么文件、有什么附加内容、图案覆盖了什么区域”这三件事搞清楚。2. 二维码为什么扫不出来先弄懂结构再动手2.1 QR 码的基本分区要修复二维码先得知道一张 QR 码里哪些地方是命脉。我把一张标准二维码按功能拆开看大概分成几块位置探测图形通常出现在左上、右上、左下三个角每个都是“3×3 黑点 一圈白边 黑外框”的嵌套方块。扫码引擎靠它定位方向和边界一旦不完整整个图形几乎扫描失败。校正图形散布在数据区里的一些小方块用来修正透视畸变。版本越高数量越多V4 以上会比较明显。时序图形位于第二行和第二列上的黑白交替线相当于坐标尺帮助确定模块位置。格式信息与版本信息记录纠错级别、掩码规则、版本号这组二进制位一旦出错解码器会不知道按什么规则读取数据区。数据区与纠错码真正存放内容的地方周围和内部会按照规则交织排布纠错码字。在“小蓝鲨的二维码”里鲨鱼压着数据区右上角定位角损坏。这相当于一个仓库的坐标尺和正门同时出问题里面货物再好也运不出来。因此修复顺序必须是先保证定位角完整再尽量恢复数据区最后才谈扫描。2.2 纠错级别不是无限容错QR 码在生成时可以指定纠错级别常见的四种是 L、M、Q、H对应的可纠正码字比例大约是 7%、15%、25%、30%。纠错级别约可恢复的错误比例适用场景L7%空间紧张内容优先M15%默认折中方案Q25%需要一定鲁棒性H30%二维码可能被遮挡或污损时很多人以为 H 级能扛 30% 面积遮挡实际上二维码是二维排列对局部破损特别敏感。如果一个鲨鱼图案正好把某个区域的数据码字刷掉一大片即使总量不超过 30%也可能因为码字分布太集中而无法纠错因为这 30% 是对码字的错误率不是简单地对面积占比。这题的关键在于如果给的是高纠错版本中间鲨鱼遮挡可能仍在理论容错范围内但右上角定位角被破坏以后所有纠错都没有意义因为解码器压根找不到定位基准。所以修复优先级里定位角永远是第一位。2.3 扫码失败的几个常见原因在真正动手修图前可以快速判断一下扫不出来的具体类型。同样是扫码失败症状不同病灶也不同。反色二维码是白底黑块如果变成黑底白块很多扫码引擎不会自动适配。定位角缺失或变形最常见手机无法确定边界和方向。透视畸变斜着拍、旋转拍摄二维码变形严重需要先做透视校正。模块模糊图片被压缩或缩放后黑白边界发灰采样时出现误判。留白不足二维码外圈没有保留安静区quiet zone边缘信息干扰严重。数据区遮挡中间被图案盖住超出纠错能力。我用手机扫小蓝鲨二维码时报错是“未发现二维码”说明没有收到有效定位信息用命令行工具 zbarimg 扫输出 Empty也能印证这一点。这时候就不要反复换扫码 App 了先回图片本身找原因。3. 实操记录先把二维码恢复到能扫3.1 直接扫描的判断我先把附件传给手机用微信和第三方扫码工具都试了一次结果是“无法识别”。然后用 ZBar 命令行再确认一次zbarimg shark_qr.png输出Empty QR code。这个结果不太意外因为定位角缺失会让引擎认为整张图不是二维码。还有另一种情况是引擎能定位但解不出数据报“invalid block type”那问题可能出在数据区错误太多或格式信息损坏。小蓝鲨这题属于前者。遇到“Empty”时我不建议闷头修图而是先用 OpenCV 看一眼二值化后的轮廓。二维码在二值化后应该呈现清晰的网格我可以快速看到哪个角缺了一块、哪个区域被鲨鱼完全破坏。import cv2 img cv2.imread(shark_qr.png, cv2.IMREAD_GRAYSCALE) _, thresh cv2.threshold(img, 180, 255, cv2.THRESH_BINARY) cv2.imwrite(shark_qr_bin.png, thresh)这步阈值不严谨只是为了视觉检查。如果鲨鱼区域把原来的黑白模块完全改变阈值分割后会看到那里出现一块连成片的蓝色深浅过渡而不是正常的方块纹理。3.2 先做二值化和反色判断修复二维码的第一步是把它转成“干净的黑白图”。二维码本身是二值图像但照片、PNG 导出过程中可能混入灰度渐变。我直接把原图转灰度用固定阈值 180 切一刀低于阈值的算黑高于阈值的算白。阈值选择不能随便拍脑袋。511× 511 像素的图每个模块通常由整数个像素组成。如果阈值太低原本接近白色但被蓝色阴影污染的模块会被误判成黑色模块膨胀阈值太高原本浅蓝的干扰会被误判成白色模块缩小。我这次试了 160、180、200 三种阈值最后 180 的时候黑白方块比例最接近正常二维码的视觉感受。如果很不幸原图是反色区块可以先对二值图求反inv cv2.bitwise_not(thresh) cv2.imwrite(shark_qr_inv.png, inv)扫描时把反色图也试一下有些扫码引擎对反色支持很差但 ZBar 和部分手机 App 反而能处理。修图阶段多生成几个变体没有成本后面都丢给解码器筛选。3.3 补全缺掉的右上角定位角这是整个修复过程中最关键的一步。定位角是一个 7×7 模块的正方形结构内层 3×3 黑点、外部一圈白边加上外围黑框整体正好是 7×7 模块标准实现通常是三个角各有一个。小蓝鲨二维码的右上角被裁掉一小块我不能凭空画一个最稳妥的办法是拿左下角或者左上角的完整定位角做模板复制过去再旋转。具体操作我建议这样先确定模块像素数512 像素的图二维码版本大概是 V4一个模块可能是 8 像素或 16 像素。用画图工具量一下两个相邻网格点之间的距离即可。如果有完整角从原图截取完整的 7×7 模块区域。把模板旋转到需要补充的位置左下角模板顺时针转 90 度可以补右上角左上角模板旋转 90 度可以补右上角根据缺失角的相对位置判断。贴图时注意和原图逐像素对齐别只靠肉眼。这里有一个很容易翻车的点QR 码的三个定位角本身完全一样但贴到不同位置后方向错了会导致扫码引擎判断的“原点”错位。二维码的数据读取顺序依赖左下角、左上角、右上角的方向关系。如果我把一个完整角贴到了右上角但没有旋转到正确朝向读取方向就反了结果依然无法解码。我这次是从左下角完整定位角取模板顺时针旋转 90 度贴到右上角空缺处。贴完后用 OpenCV 画轮廓做对比确认三个角的嵌套方块结构一致。3.4 处理鲨鱼遮挡区域定位角补完扫码引擎已经可以定位了但数据区仍有一块被鲨鱼破坏。我试过用 OpenCV 的 inpainting 功能自动修补鲨鱼区域mask cv2.imread(shark_mask.png, cv2.IMREAD_GRAYSCALE) img_color cv2.imread(shark_qr.png) result cv2.inpaint(img_color, mask, 3, cv2.INPAINT_TELEA) cv2.imwrite(shark_qr_inpainted.png, result)因为是补某些小孔洞效果还可以但鲨鱼区域太大了修补算法会用周围像素猜出一堆灰色渐变根本恢复不了原本的方块。所以最终还得靠“按模块重绘”。我的做法是用一张同版本、同纠错级别的空白二维码做参照先识别出鲨鱼遮挡区域内有哪些完整模块再把被破坏的模块按照周围模块的网格对齐手动填充成黑或白。听起来麻烦实际操作时只需要看遮挡区域边缘的网格线顺着网格线把每一个交叉点的黑白状态补出来。不必完美补齐整个鲨鱼区域只需要保证这些被补的模块在逻辑上符合 QR 码的格式。因为 QR 码有纠错你可以只补一部分剩下的错误交给 H 级纠错去兜底。这里要记住一个原则宁可多留一点白边也不要让模块边缘变灰。扫码引擎对模块区域的中心点进行采样只要中心点颜色正确边缘模糊一点不影响结果。我重绘时每个方块按 16×16 像素填纯白或纯黑扫描的成功率一下子提上来了。3.5 掩码与格式信息问题如果修复后扫码结果是乱码而不是空结果很可能是格式信息或掩码信息坏了。QR 码生成时会对数据区应用一种掩码Mask Pattern让黑白分布更均匀。解码器必须从格式信息位读出当前使用的是哪种掩码才能正常取码字。正常情况下二维码的格式信息会复制两份分别放在左上角定位角旁边和右上/左下角附近。如果这些区域也被鲨鱼挡住解码器可能会选错掩码导致解出来一串毫无意义的字符。这类问题不在小蓝鲨这道题的主线上但如果以后你修复一张损坏程度更高的二维码就要用到。可以用一些现成工具比如尝试用不同 Mask Pattern 去解析同一张图很多 CTF 选手会直接写 Python 脚本调 QR 解码库遍历所有掩码。真正实战里我会优先在图像层面恢复格式信息区域而不是暴力遍历掩码。4. 第二层隐写扫出来的不是 flag 而是线索4.1 从二维码里解出的关键字符当我把修复后的图片重新丢给 zbarimgzbarimg shark_qr_fixed.png这次没有 Empty而是输出了一段文本。具体内容在不同 fork 版本里会有差异但这类题的通常套路是二维码正文本身不是最终 flag而是一个钥匙、密码或者压缩包名称。我拿到的结果是l3ft_f1sh_b0ne!这样一段类似密码的字符串后面正好用在解压隐藏文件上。如果你看到二维码解码结果先是提示“QR-Code: ...”后面跟着的内容就是该用的。不要直接把它当成完整答案先复制到文本文件里存好。接下来要回到原始附件因为出题人通常还会在图片文件的像素层或者尾部附加数据里再藏一层信息。4.2 用最低有效位来找藏起来的东西二维码修复完毕我回到原始shark_qr.png做隐写分析。最常见的图像隐写是 LSB也就是通过修改每个像素 RGB 分量的最低位来隐藏数据。图片看起来完全相同但最低位里可能藏着一个压缩包或者另一张图。我习惯先用 zsteg 扫一遍所有通道和 bit planezsteg -a shark_qr.png输出里如果出现类似b8,rgb,lsb,xy的通道描述后面跟着可读字符串那基本就是隐藏信息了。也可以用 StegSolve 手动查看 RGB 三个通道的最低位平面这种方式更直观把 R、G、B 的 bit 0 单独显示如果最低位里有另一幅图案屏幕上会看到本来是黑白噪声的区域呈现出规律轮廓。在小蓝鲨这题里zsteg 检测到最低位平面有一串 ASCII 文本内容是一段“从偏移 0x14520 处提取数据”的提示。这种“扫码结果 LSB 提示”的组合在 CTF 里很常见。4.3 文件尾附加数据与压缩包分离前面strings命令发现的异常尾巴到这里派上用场了。用 binwalk 检查整个文件binwalk shark_qr.png如果尾部或数据段中藏着一个 ZIP/RARbinwalk 会报告类似ZIP archive data, at least offset 0x14520。分离方式可以用命令一键完成foremost shark_qr.png -o output分离出的文件可能是压缩包。打开压缩包时需要密码密码正是二维码里扫出来的那串字符或者它的某种变形。这类设计把两条线索绑在一起不做二维码修复就拿不到密码不检查隐写和文件尾部就不知道还有一个压缩包。4.4 从二维码到 flag 的完整链路这道题的整体解题线可以归纳成四条原始图片PNG 格式正常二维码被小蓝鲨遮挡且定位角损坏。修复二维码补右上角定位角重建鲨鱼遮挡区域使二维码能解码。解码结果得到一段密码字符串先保存后面解压用。隐写分离检查 LSB 或文件尾部隐藏数据分离出压缩包再用密码解压得到最终 flag。如果缺少任意一步就算直接扫出一个看似很像 flag 的字串也未必是真正答案。所以在最终提交前我会把扫码结果、LSB 提取结果和 binwalk 分离结果全部列出来最后再做交叉验证。5. 常见问题与避坑记录5.1 修复完还是扫不出按这个清单检查如果你按上面的步骤补完定位角重绘完遮挡区域却依然扫码失败我建议按下面顺序排查。第一查 quiet zone。二维码外圈至少要有 4 个模块宽的白边。有些修复图边缘贴得很紧扫码引擎会把外圈物体当成黑块干扰。我通常用画图工具把画布四周各扩展 64 像素按 16 像素一个模块算正好 4 个模块并填充成纯白。第二查模块数量。修复过程中如果缩放图片模块宽度可能变成非整数像素扫码引擎采样时对齐失败。要在原始分辨率下操作不要随便 resize。第三查反色。可以把处理好的图bitwise_not之后和原版本一起测试。很多工具只认其中一种。第四查定位角是否完整。有时候你贴的定位角边缘多出几像素的残余像素影响轮廓识别可以先把定位角区域用纯白填充再贴模板。5.2 定位角方向与模块对齐细节补定位角最容易出错的是方向。QR 码的三个定位角可以看作一个左下角、一个左上角、一个右上角数据读取顺序依赖它们的位置关系。从右下角方向看三个角本身旋转对称所以不能用“看起来像”去贴。最简单的方式是观察相邻的时序图形方向然后把模板按照该方向旋转。模块对齐也一样。二维码是网格结构所有方块必须严格落在网格点上。放大图片后如果发现鲨鱼区域的阴影把两条相邻网格线之间的边界都淹没了那就手动标出网格线再逐个方块填色。这一步别只追求视觉美观要保证每个方块的几何中心颜色正确。5.3 zbarimg 和手机扫码结果不一致有些场景下 zbarimg 识别失败但手机扫码能成功或者反过来。原因是各类扫码引擎对图像预处理、纠错策略、反色支持不一样。这不算 bug是正常现象。我的习惯是至少用三个工具交叉验证工具用途常见场景zbarimg命令行快速扫描批量测试修复结果微信/支付宝扫码移动端识别判断修复图是否符合日常使用Barcode Generator / ZXing 库脚本可控环境下扫描处理特殊掩码、大数据内容如果命令行一直报错可以先把修复图发给手机扫。如果手机也失败大概率是图像本身问题不是工具差异。5.4 常用工具速查表我顺手整理了一张在二维码 MISC 题里高频用到的工具清单给后面做题省点时间。功能工具常用命令或操作文件识别file / binwalkfile a.png/binwalk a.png二维码扫描zbarimgzbarimg a.png图像修复OpenCV / GIMP / Photoshop阈值、inpaint、模板贴图LSB 隐写提取zsteg / StegSolvezsteg -a a.png文件分离foremost / binwalkforemost a.png -o out/binwalk -e a.png数据反转ImageMagick / OpenCVconvert a.png -negate a_neg.png工具不在多关键是知道每步该用哪个。很多选手平时只准备一个扫码器遇到二维码损坏就卡住因为没有备用的检查维度。5.5 几条真正有用的实操习惯最后说几个我踩坑换来的习惯。第一每一步处理都另存副本。修图过程不可逆你永远不知道哪一步操作把原本还能识别的图像搞坏了。保存原始哈希处理后文件用_bin、_fixed、_inpaint后缀区分。第二别急着追求完美。修复数据区时可以先修补最小必要区域然后反复扫描逐步扩大修补范围。如果你一开始就把鲨鱼区域全涂成白色可能已经严重破坏了二维码的数据码字分布反而更难恢复。第三扫码结果不一定是人类可读的完整语言它可能只是几个字符片段。遇到奇异字符串不要立刻跳过去它是密码还是 Base64先存下来再结合其他线索判断。第四处理二维码图片时不要做任何有损压缩尤其不要转成 JPG。JPG 会让黑白模块边缘出现大量伪影严重影响扫码稳定。尽量保留 PNG必要时用纯色重绘模块。我自己在修这张“小蓝鲨的二维码”时最经典的瞬间是补完右上角定位角后先用手机扫居然还是失败于是又花二十分钟修鲨鱼区域结果发现只是留白不够。后来调整 quiet zone一次就扫出来了。二维码题跟其他隐写题一样思路比蛮力重要排查顺序对了很快就能看见的结果。