拿到“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%。
| 纠错级别 | 约可恢复的错误比例 | 适用场景 |
|---|---|---|
| L | 7% | 空间紧张,内容优先 |
| M | 15% | 默认折中方案 |
| Q | 25% | 需要一定鲁棒性 |
| H | 30% | 二维码可能被遮挡或污损时 |
很多人以为 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 从二维码里解出的关键字符
当我把修复后的图片重新丢给 zbarimg:
zbarimg shark_qr_fixed.png这次没有 Empty,而是输出了一段文本。具体内容在不同 fork 版本里会有差异,但这类题的通常套路是:二维码正文本身不是最终 flag,而是一个钥匙、密码或者压缩包名称。我拿到的结果是l3ft_f1sh_b0ne!这样一段类似密码的字符串,后面正好用在解压隐藏文件上。
如果你看到二维码解码结果先是提示“QR-Code: ...”,后面跟着的内容就是该用的。不要直接把它当成完整答案,先复制到文本文件里存好。接下来要回到原始附件,因为出题人通常还会在图片文件的像素层或者尾部附加数据里再藏一层信息。
4.2 用最低有效位来找藏起来的东西
二维码修复完毕,我回到原始shark_qr.png做隐写分析。最常见的图像隐写是 LSB,也就是通过修改每个像素 RGB 分量的最低位来隐藏数据。图片看起来完全相同,但最低位里可能藏着一个压缩包或者另一张图。
我习惯先用 zsteg 扫一遍所有通道和 bit plane:
zsteg -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/RAR,binwalk 会报告类似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 / binwalk | file a.png/binwalk a.png |
| 二维码扫描 | zbarimg | zbarimg a.png |
| 图像修复 | OpenCV / GIMP / Photoshop | 阈值、inpaint、模板贴图 |
| LSB 隐写提取 | zsteg / StegSolve | zsteg -a a.png |
| 文件分离 | foremost / binwalk | foremost a.png -o out/binwalk -e a.png |
| 数据反转 | ImageMagick / OpenCV | convert a.png -negate a_neg.png |
工具不在多,关键是知道每步该用哪个。很多选手平时只准备一个扫码器,遇到二维码损坏就卡住,因为没有备用的检查维度。
5.5 几条真正有用的实操习惯
最后说几个我踩坑换来的习惯。
第一,每一步处理都另存副本。修图过程不可逆,你永远不知道哪一步操作把原本还能识别的图像搞坏了。保存原始哈希,处理后文件用_bin、_fixed、_inpaint后缀区分。
第二,别急着追求完美。修复数据区时可以先修补最小必要区域,然后反复扫描,逐步扩大修补范围。如果你一开始就把鲨鱼区域全涂成白色,可能已经严重破坏了二维码的数据码字分布,反而更难恢复。
第三,扫码结果不一定是人类可读的完整语言,它可能只是几个字符片段。遇到奇异字符串不要立刻跳过去,它是密码还是 Base64?先存下来,再结合其他线索判断。
第四,处理二维码图片时不要做任何有损压缩,尤其不要转成 JPG。JPG 会让黑白模块边缘出现大量伪影,严重影响扫码稳定。尽量保留 PNG,必要时用纯色重绘模块。
我自己在修这张“小蓝鲨的二维码”时,最经典的瞬间是:补完右上角定位角后先用手机扫,居然还是失败,于是又花二十分钟修鲨鱼区域,结果发现只是留白不够。后来调整 quiet zone,一次就扫出来了。二维码题跟其他隐写题一样,思路比蛮力重要,排查顺序对了,很快就能看见的结果。