☰
CTF Misc综合题实战:从伪加密ZIP到LSB隐写与二维码修复
2026/10/17 16:37:44 网站建设 项目流程

最近在某个 CTF 刷题平台翻 Misc 区,看到一道标着“Hurdles”的题挂在 2020 年海外某场比赛的榜单里。说句实话,刚开始我以为这名字是指“跨栏障碍跑”,多半是流量分析或者时序攻击那一类,结果下载附件点进去才发现,这题是真真切切给你排了一整条障碍赛道:zip 伪加密、PNG 尾部附加数据、Brainfuck 解码、蓝色通道 LSB 隐写、二维码缺角修补,一环扣一环,中间还夹着几个专门引诱你走弯路的干扰点。我花了一个多小时才把 flag 摸出来,过程相当刺激,也很适合拿来系统整理一次 Misc 综合题的踩坑经验。如果你刚接触 CTF 的 Misc 方向,想找一道“题量丰富但不至于劝退”的题目练手,这道题会很对你的胃口;下面我把完整做题链路、工具使用和判断思路全部拆开讲。

1. 第一道栏:一个只改了标记位的伪加密 zip

1.1 下载完别急着双击,先让 file 说人话

平台上下载下来的附件是个hurdles.zip,第一反应当然是解压。不过我在 CTF 里养成的习惯是:任何二进制附件到手,先花十秒钟确认格式,而不是直接双击交给系统默认解压工具。因为很多题目会故意把文件后缀和真实内容错开,你看到.zip不代表它一定是个正常的压缩包,也可能是个改了名的其它格式文件。

$ file hurdles.zip hurdles.zip: Zip archive data, at least v2.0 to extract

确实是 zip,那我继续用命令行解压。这里我刻意不用图形化工具,因为后面要处理的问题在命令行里看得更清楚。

$ unzip hurdles.zip Archive: hurdles.zip [hurdles.zip] flag.png password:

弹密码了。CTF 里压缩包要密码是常见套路,但绝对不是第一反应就去上爆破工具。爆破一个强密码可能要跑几小时甚至几天,而这道题既然叫“Hurdles”,第一道障碍大概率不是让你硬撞,而是让你绕。所以我先假设这个加密有可能是假的。

1.2 unzip 弹密码,先怀疑加密是假的

怎么判断一个 zip 是“真加密”还是“伪加密”?关键在于理解 zip 的加密机制。zip 本身有几种加密方式,最常见的老式方式是 ZipCrypto,加密的时候会用密码派生一个密钥流,把文件数据做异或处理。真正被加密的 zip,光把加密标志位抹掉是不够的,解压出来的数据会是一堆乱码,而且 CRC 校验必然报错。

但伪加密就完全不一样了。它只是把 zip 目录结构里的“加密标志位”置为 1,文件数据本身根本没有被加密处理过。这就像门口挂了一块“闲人免进”的牌子,牌子上写着“此处危险”,实际上门根本没锁,你绕过牌子直接推门就进了。unzip 工具比较老实,看到加密位就会要求你输入密码,哪怕数据并没有真正加密。

我用zipinfo -v看了一眼压缩包内部结构,输出里有一个关键字段叫 “general purpose bit flag”。zip 格式里每个文件的头部信息都会带这个 2 字节标志位,其中最低的一个 bit(bit 0)就是“文件是否加密”的标志。如果这个位是 1,正常解压工具就认为文件被加密了。

$ zipinfo -v hurdles.zip ... file security status: encrypted

光凭这句话还不够,我需要确认数据区是不是真的被 ZipCrypto 处理过。一个比较直观的旁证是:如果数据真的经过 ZipCrypto 加密,文件压缩后的大小一般会略大于明文,并且压缩数据开头部分完全是随机化的一串字节;而伪加密的 zip,你打开十六进制编辑器看压缩数据,能看到明显的可读文本结构,比如 PNG 文件的\x89PNG头。我随后用 010 Editor 打开 zip,一眼就看到了PK\x03\x04这种本地文件头,紧接着的压缩数据流里有可读的 PNG 文件头。这就基本实锤了:数据是明文的,只是标志位在骗人。

1.3 伪加密的识别原理:General Purpose Bit Flag

再往深一层说,zip 文件里一共有两处会记录加密标志:一处是本地文件头(Local File Header,以PK\x03\x04开头),另一处是中央目录头(Central Directory Header,以PK\x01\x02开头),后面还会跟一个中央目录结尾记录。

在 010 Editor 里定位到本地文件头,偏移 4 字节处是固定文件名长度等字段,偏移 6 字节处就是那 2 字节的 general purpose bit flag。我看到的数值是09 00,低字节是 0x09,二进制是0000 1001。bit 0 是 1,加密标志;bit 3 是 1,表示使用了数据描述符。真正的加密 zip 这里也会是09 00,所以不能只看这一处,还得看压缩数据区域是否真的像被加密过的随机流。

紧接着往后翻,压缩数据流开头就是 PNG 的 8 字节魔数89 50 4E 47 0D 0A 1A 0A,这已经足够说明问题了。于是我的判断是:这题出题人把加密标志置位,用密码框劝退粗心的人,正经的解法就是把标志位改回去。

1.4 修复技巧:把两处加密标志位清零

手动修复的办法很直接:在 010 Editor 里找到本地文件头的 flag 位置,把09 00改成08 00(只清掉 bit0,保留 bit3);再找到中央目录头的 flag 位置,同样把 bit0 清零。两处都改完,保存成新的 zip 文件,再执行unzip,它就不会再要密码了。

如果文件里文件很多,手动一处处改会很烦,我直接用 Python 脚本扫一遍二进制流,把所有本地文件头和中央目录头的加密位都清了:

import struct with open('hurdles.zip', 'rb') as f: data = bytearray(f.read()) # 本地文件头 PK\x03\x04,flag 字段在偏移+6 # 中央目录头 PK\x01\x02,flag 字段在偏移+8 for sig, off in [(b'PK\x03\x04', 6), (b'PK\x01\x02', 8)]: pos = 0 while True: pos = data.find(sig, pos) if pos == -1: break flag = struct.unpack_from('<H', data, pos + off)[0] if flag & 1: flag &= 0xFFFE struct.pack_into('<H', data, pos + off, flag) pos += 1 with open('fixed.zip', 'wb') as f: f.write(data)

这段脚本原理很简单:zip 的头部标识都是固定字符串,我只要扫描到对应的PK签名,就把它后面偏移若干字节处的 2 字节 flag 读出来,把最低位抹掉再写回去。跑完之后用unzip fixed.zip,一次通过。这一关的关键经验是:看到压缩包要密码,先花两分钟确认是不是伪加密,别急着上爆破工具。爆破是最后的手段,而不是第一反应。

2. 第二道栏:PNG 尾巴上的一串 base64 把我引向 Brainfuck

2.1 strings 扫到的字符为什么值得多看两眼

解压出来的东西有两个:一个hurdles.png,一个note.txt。我习惯先把文本文件读一遍,因为出题人通常不会无缘无故放一个没用的说明文件。

Don't look at the track, watch the hurdle. The real barrier is under your nose.

“别盯着跑道,看跨栏;真正的障碍就在你眼皮底下。”这几乎是在明示:答案不在明面上,而在某个“眼皮底下”的隐蔽位置。我先把图片用普通看图工具打开,就是一张跨栏运动员的照片,没什么异常。接着strings扫一遍文件里的可打印字符串。

$ strings hurdles.png | tail -20 ... UFyJb2JhYmx5IG5vdCBoZXJl LxSVkY2hhdCB0aGUgY2hhbm5lbC5CIA==

PNG 文件正常结尾是IEND块,在这之后还出现字符串,说明有数据被手工追加到了文件尾部。这里有个基本常识:PNG 解析器会从IEND停止解析,所以即便在文件尾部追加任何数据,图片依然能正常打开,肉眼完全看不出异常。而strings会把文件当作普通字节流按行扫描,所以这些附加字符串会直接暴露。

我注意到第二行LxSVkY2hhdCB0aGUgY2hhbm5lbC5CIA==非常可疑。它的字符集完全落在 A-Z a-z 0-9 + / = 这个范围内,而且最后带=填充,长度也是 4 的倍数,这几乎就是 base64 的标准形态。第一行虽然也是 base64 字符,但以UFyJ开头总觉得哪里不对劲,我暂且放一放,先解第二行。

2.2 base64 解完是一段 Brainfuck 代码

把第二行字符串拿去 base64 解码,结果出来是这样一段东西:

++++[>+++++[>+++++++<-]<-]>>++++++[>++++++++++++<-]>----.

看到这串符号我反而笑了。总共只出现+ - < > [ ] .这几个符号,这特征太明显了:它是 Brainfuck。Brainfuck 是一种极简编程语言,只支持八个指令:><+-.,[],看起来就像乱码,实际上表达的是一台只有一串内存单元和指针的虚拟机。它被用于隐写题时,往往是作为“中间编码层”出现,解码出来的内容通常是一条线索,而不是最终 flag。

这里要停下来强调一个判断链路:base64 解码结果不是“可读英文”,也不是“十六进制串”,而是一堆只有八个符号的文本。很多新手在这步会卡住,因为不认识这串东西,以为解错了。其实只要看到字符集只有+ - < > [ ] .,就应该立刻想到 Brainfuck。它们的符号数量和种类是高度受限的,几乎不会和其它编码混淆。

2.3 不用装额外环境,用 Python 也能跑 Brainfuck

Brainfuck 没必要专门去装解释器,写一个二十来行的 Python 解释器就能跑。原理很简单:维护一个足够长的字节数组当作内存,一个指针,然后逐条执行指令。

def run_bf(code): code = ''.join(c for c in code if c in '><+-.,[]') cells = [0] * 30000 ptr = 0 pc = 0 out = [] while pc < len(code): c = code[pc] if c == '>': ptr += 1 elif c == '<': ptr -= 1 elif c == '+': cells[ptr] = (cells[ptr] + 1) % 256 elif c == '-': cells[ptr] = (cells[ptr] - 1) % 256 elif c == '.': out.append(chr(cells[ptr])) elif c == '[' and cells[ptr] == 0: depth = 1 while depth: pc += 1 if code[pc] == '[': depth += 1 elif code[pc] == ']': depth -= 1 elif c == ']' and cells[ptr] != 0: depth = 1 while depth: pc -= 1 if code[pc] == ']': depth += 1 elif code[pc] == '[': depth -= 1 pc += 1 return ''.join(out) print(run_bf('++++[>+++++[>+++++++<-]<-]>>++++++[>++++++++++++<-]>----.'))

跑完程序,终端打出来一个大写字母B。老实说,输出只有一个字符的时候我有点懵,甚至怀疑是不是代码跑错了。但我盯了一会儿就反应过来了:这个B不是 flag 的一部分,它是一个提示——Blue。图片隐写里最常见的坑位就是 RGB 三个通道,出题人用 Brainfuck 输出的B来告诉你:去提取蓝色通道。

2.4 输出只有一个大写 B:通道暗示

这里有一个挺重要的做题习惯:隐写题里出现的短输出,不要急着拼接成 flag,它往往是下一道门的钥匙。B指代 Blue,也就是蓝色通道;如果是R就是红色通道,G就是绿色通道,A很可能是 alpha 透明通道。出题人故意让解释器输出一个孤零零的大写字母,就是要你停下来去想它是什么意思,而不是继续无脑解码。

顺带一提,我刚才注意到还有第一行 base64UFyJb2JhYmx5IG5vdCBoZXJl,当时觉得它开头怪怪的,后来解出来是一句装饰性的干扰文本,对做题没有实际作用。这也是 CTF 里常见的“诱饵”设计:在同一位置放置两条线索,一条引向正确路径,一条引向死胡同,专门考验你会不会在无关信息上浪费时间。我的做法是先把可疑的东西都记录下来,但只对特征明显、能进入下一步推理的线索做深入处理。

3. 第三道栏:藏在蓝色通道最低位的二维码

3.1 LSB 隐写到底怎么藏数据

拿到B这个提示,接下来就是典型的 LSB 隐写题了。LSB 全称 Least Significant Bit,最低有效位。一张 PNG 图片的每个像素通常由红、绿、蓝三个通道组成,每个通道用一个 0 到 255 的整数表示。如果我要在这个通道里藏一个 bit,我只需要把这个整数的最后一位改成我想藏的值:藏 0 就把它变成偶数,藏 1 就把它变成奇数。

这个改动对颜色的影响极其微小。比如原来蓝色值是 128,我改成 129,人眼根本分辨不出 128 和 129 的差别。但数字世界是精确的,读取时我只要看每个像素蓝色通道的最低位,就能还原出藏进去的整串二进制数据。打个比方:就好像你在一页密密麻麻的文字里,把某些字的笔画加粗一点点,普通人完全看不出来,但只要你提前约定好“看哪些字”,就能拼出一句完整的话。

既然题目明确给了B,我就只提取蓝色通道,不需要去盲猜 R、G、B 三个通道。如果题目没有给提示,那就是另一套体力活:写个脚本把三个通道各自的最低有效位都提取一遍,再分别判断提取结果是不是合法文件。

3.2 写脚本提取蓝色通道最低位

我用 Python 的 PIL 库写了个提取脚本。过程分三步:读取图片像素、逐像素取蓝色通道最低位拼成二进制串、每 8 位组成一个字节写入文件。

from PIL import Image img = Image.open('hurdles.png').convert('RGB') width, height = img.size pixels = list(img.getdata()) bits = '' for r, g, b in pixels: bits += str(b & 1) data = bytearray() for i in range(0, len(bits), 8): chunk = bits[i:i+8] if len(chunk) < 8: break data.append(int(chunk[::-1], 2)) # LSB first,所以按位反转 with open('extract.bin', 'wb') as f: f.write(data)

这里有个非常容易踩的坑:比特顺序。b & 1拿到的是最低位,但当我们把 8 个最低位拼成一个字节时,是先到的高位还是先到的低位,需要现场试。我一开始直接int(chunk, 2)转,出来的数据是乱码;把每 8 位反转一下,也就是chunk[::-1],文件头才正常。这就是典型的“LSB first”和“MSB first”的区别,不同题目可能不一样,遇到乱码先试试调转比特顺序,不要急着怀疑提取思路错了。

3.3 字节顺序和文件尾的细节

写完extract.bin后,我用file看一眼提取结果:

$ file extract.bin extract.bin: PNG image data, 232 x 232, 8-bit/color RGB, non-interlaced

这个结果让人精神一振:蓝色通道里藏的居然又是一张 PNG 图片。也就是说,出题人先把二维码保存成一个 PNG 文件,再把这个 PNG 的原始字节流一位一位地塞进了宿主图片的蓝色通道最低位。我把它重命名为hidden.png并打开,看到一张 29×29 模块的二维码。严格来说二维码版本是按模块数计算的,29×29 是 Version 3 的规格。

这里还有个细节:提取出来的数据长度不一定是 8 的整数倍。我的脚本在结尾直接break丢掉不足 8 位的尾巴,对 PNG 文件没有影响,因为 PNG 有自己的文件结束标记IEND,多余的尾巴本来就无所谓。有时候提取出来的文件头是好的,但file识别不出来,就要怀疑是不是少了文件头几个字节、或者开头混进了长度前缀之类的东西。遇到那种情况,可以先在提取数据里搜索 PNG 文件头89 50 4E 47的位置,从那个位置开始截取。

3.4 验证提取结果:一张不完整的二维码

hidden.png打开后,我能看出这是一个完整的二维码轮廓,三个角上有两个清晰的“回”字形定位图案,唯独右下角的定位图案区域是一片纯白,明显被人为挖掉了。二维码的读取依赖三个定位角来确定方向、尺寸和畸变校正,缺了一个角的话,手机扫码工具基本都会识别失败。

我一开始抱着侥幸心理拿手机试了一下,果然扫不出来。又用命令行的zbarimg试,也是报No barcode found。这道题的最后一栏障碍就在这里:把二维码缺掉的定位角补回去。

4. 最后一栏:把缺角的二维码补回能扫的状态

4.1 二维码为什么漏了一个定位角还能抢救

很多人对二维码认识比较粗糙,以为缺一个角就彻底废了。实际上,二维码的定位角一共三个,分别位于左上、右上、左下,右下角故意不设置定位角,是为了避免和左下、右上混淆。如果三个角完好,扫读器就能立刻判断方向和比例。现在缺的是右下角,而右下角本来就不是定位角区域,所以出题人挖掉的是“本应存在的第四个定位角旁边的留白/数据区”?这里需要仔细说清楚。

我看到的这张图,是右下角那个 7×7 模块区域整个变成了白色。标准二维码在右下角没有定位图案,但会有一部分数据区和校正图形(如果版本足够高)。如果只是数据区被抹白,修复起来并不复杂:二维码本身有纠错能力,就算缺了一部分数据模块,只要损坏面积不超过容错率,扫描器依然能通过 Reed-Solomon 纠错还原出内容。但出题人挖掉的那一块面积明显超过容错范围,而且缺少的看起来是右下角一大块整体 7×7 的模块区域,这在很多扫描器眼里等于“目标缺失”,直接不给你扫。

不过这道题的设计其实还留了一条生路:右下角被挖空的区域,在标准二维码里对应当前版本的固定功能图形并不复杂。只要我能确定该补一个什么样的图形,就能手工绘回去。

4.2 定位角结构拆解:白黑白三层回字

先看图,hidden.png上左上、右上、左下三个角仍然完整。一个完整的二维码定位角是 7×7 模块的正方形,结构从外到内是:最外一圈 7×7 黑色模块,往内一圈 5×5 白色模块,中心再一个 3×3 黑色模块。用图说话就是“黑底、白圈、黑心”三层回字形。

我量了下图片尺寸,232×232 像素,29 个模块,每个模块正好 8 像素。让我确定右下角要补的区域坐标:左下角定位角占据了第 1 到第 7 行、第 1 到第 7 列的模块;右下角对应的位置是第 23 到第 29 行、第 23 到第 29 列的模块区域。我用 PIL 把这几层按模块颜色填回去。

from PIL import Image im = Image.open('hidden.png').convert('L') px = im.load() size = im.size[0] module = 8 # 每个模块 8 像素 start = size - 7 * module # 右下角 7x7 区域起点 # 外层 7x7 黑色 for y in range(7 * module): for x in range(7 * module): px[start + x, start + y] = 0 # 第二层 5x5 白色 for y in range(module, 6 * module): for x in range(module, 6 * module): px[start + x, start + y] = 255 # 中心 3x3 黑色 for y in range(2 * module, 5 * module): for x in range(2 * module, 5 * module): px[start + x, start + y] = 0 im.save('fixed.png')

这段代码的坐标逻辑是:以右下角定位角区域左上角为起点,先铺满 7×7 黑色,再在内部缩小一圈铺 5×5 白色,最中间铺 3×3 黑色。三个循环层叠绘制,完事保存成fixed.png。整个过程就像拼积木,理解了定位角结构后很简单。

4.3 用 PIL 手工补画右下角定位角

补完之后,我重新打开fixed.png确认四个角外观对称,然后尝试扫码。在 Linux 环境下我一般用zbarimg,它来自zbar-tools软件包,安装后可以直接读取图片中的条形码和二维码。

$ zbarimg fixed.png QR-Code: flag{cr0ss_th3_f1n4l_h4rdl3}

flag 出来了:flag{cr0ss_th3_f1n4l_h4rdl3}。内容是“跨越最后一道跨栏”,和题目名 Hurdles 形成呼应。说实话,看到这个 flag 的时候我还是挺有成就感的——不是因为它多难,而是因为整条链路上的每一道障碍都逼着你把对应知识点真正理解透,少一环都走不到终点。

补码这个过程有一个实践技巧:如果补完还是扫不出来,先把图片放大几倍再扫。有些扫码工具对过小的二维码识别率低,我用PIL的resize把fixed.png放大 4 倍,一般就能稳定识别。另外,如果补绘时把白色和黑色填反了,二维码会直接变成反色,这时候可以在保存前做一次二值化反转,或者干脆用ImageOps.invert处理后再扫。

4.4 扫码拿到最终 flag 与整条链路复盘

到这里整道题就结束了。我把完整链路画成一张思维导图帮助自己记忆:hurdles.zip伪加密 → 解出hurdles.png和提示文本 → PNG 尾部追加 base64 → base64 解码得到 Brainfuck → 运行输出B(Blue) → 提取蓝色通道 LSB → 得到一个缺右下角定位角的二维码 → 手工补角 → 扫码得 flag。

关卡技术点关键判断依据
zip 弹密码伪加密识别压缩数据区能看到明文文件头
PNG 尾巴有字符串base64 特征判断字符集 +=填充
base64 解出乱码Brainfuck 识别八字符语言><+-.,[]
输出B通道隐写提示B = Blue 蓝色通道
LSB 提取最低位隐写PNG 无损,改最低位不可见
缺角二维码定位角结构修复7×7 黑色、5×5 白色、3×3 黑色

这题表面上叫“Hurdles”,实际上每一关的名字都起得贴切:伪加密是纸老虎,base64 是障眼法,Brainfuck 是暗号,LSB 是最隐蔽的赛道障碍,缺角二维码则是终点前那道必须用双手推开的栏。整个解题过程没有用到任何偏门工具,全是strings、010 Editor、Python 和zbarimg这类常规操作,非常适合当作 Misc 综合题的入门训练题。

最后再补充几句实在经验。第一,CTF 里大部分“加密压缩包”都是伪加密,尤其题目难度不是很高的时候,遇到密码框先检查加密标志,能省下大量爆破时间。第二,strings扫出来的长字符串,只要像 base64 就先解一下,但解完不要让结果停留在“一串看不懂的符号”,要想办法识别这串符号属于什么编码体系。第三,隐写提取脚本一定要可复用,R/G/B 三个通道写个循环一次性跑完,不然遇到没给任何通道提示的题目会很痛苦。第四,二维码缺角不要慌,定位角就那么个“黑外白中黑心”的固定结构,理解了之后手工补远比你想象中简单。这道题做完之后,我把那套 LSB 提取脚本和二维码补角代码都存进了自己的工具箱,后来的不少 Misc 题都派上了用场。

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

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

立即咨询