CTF里的隐写题,看着永远是最“无害”的一类——不考漏洞利用、不考内网渗透,但做起来经常让人对着一个图片发呆半小时,最后发现线索就在眼皮底下。这道BugkuCTF“赛博朋克”就是典型代表:题目名字看起来很赛博,压缩包解出来也是一张赛博朋克风格的图片,但核心考点其实只有两个词——ZIP文件头协议和LSB隐写。
这两个知识点本身都不复杂,但组合在一道题里,刚好能串起一整条隐写题的解题链路:先判断文件真实类型,再修复文件头,解压出真正的载体,最后从图像像素的最低有效位里把flag捞出来。不管你是刚入坑CTF的萌新,还是只会对着题解“照作业”的玩家,把这条链路吃透,以后遇到七八成隐写题都能自己往下走了。这篇我就以这道题为例,把ZIP文件头的结构、伪加密的识别修复、LSB的原理和提取脚本整个过一遍,附带我实际踩过的一些坑。
1. 出题思路拆解:为什么是ZIP文件头加LSB隐写
很多人拿到隐写题的第一反应是打开看图、用StegSolve扫,扫不出来就开始焦虑。这个顺序其实错了一半。真正高效的思路是:先搞清楚手头这个文件到底是什么,再考虑“里面能藏什么”。而这道题选ZIP文件头协议和LSB隐写这两个点,本身就代表了CTF隐写题最常见的两类出题手法。
1.1 组合题的出题逻辑
CTF隐写题里,纯单点考察越来越少,出题人更喜欢“套娃”:把一个信息经过多重隐藏,做成一条解题链。ZIP加图片的组合尤其常见,原因很实际。
ZIP文件头是可修改的。文件头协议里那几个魔数字节不是不可变的,改掉之后,系统就不会再把它当成ZIP文件,但里面的压缩数据仍然完好。这天然适合做成第一层障碍:下载下来一个文件,file命令识别不出来,直接双击也打不开,你得靠对二进制结构的熟悉程度把它“救回来”。
LSB隐写则适合做第二层障碍。它不需要额外生成文件,直接把信息埋在图片像素里,视觉上和原图几乎没有任何差异。尤其是赛博朋克风格的高饱和霓虹图片,暗部、噪点、半透明光效特别多,像素值本身就比较杂,埋点东西进去肉眼完全看不出异常。这就是为什么载体偏偏选赛博朋克风格的图片——不是为了好看,是为了让LSB痕迹更难被察觉。
所以这道题整体链路一般是:拿到一个无扩展名或伪装过的文件 → 修复ZIP文件头 → 解压得到一张图片 → 图片里有LSB隐写 → 提取出flag。理解了这条链路,你就知道每一步该干什么了。
1.2 第一步该做什么:文件类型识别三板斧
拿到任何未知文件,我建议先执行三个命令,成本极低,但能避免后面走弯路:
| 工具 | 作用 | 典型命令 |
|---|---|---|
| file | 根据文件头识别真实类型 | file cyberpunk |
| hexdump | 查看文件开头十六进制字节 | hexdump -C cyberpunk | head -20 |
| binwalk | 扫描文件里是否嵌入了其他文件 | binwalk cyberpunk |
这里要强调一下,file命令识别的依据就是文件头内容。如果出题人把文件头前几个字节篡改了,file命令会给出错误结果,但hexdump能让你看到原始字节。我个人的习惯是:file结果和十六进制开头对照着看,如果发现“file说是JPEG,但开头却是50 4B”,那说明文件头被改过或者文件被拼接了,接下来就要按真正的类型去处理。
另外,先备份原文件再动手,这是血泪教训。修复文件头、改标志位这种操作是对二进制直接写入,写错了原文件就废了,从头再来还要重新下载。
2. ZIP文件头协议:从“PK”到伪加密
ZIP文件头协议是这道题绕不开的第一个技术点。很多人对ZIP的认知只有“能解压的压缩包”和“文件头是PK”,但做题时需要看得更细一些,至少要知道文件头里哪些字节决定了解压行为。
一个标准ZIP文件会包含三种关键签名,分别出现在文件不同位置:
| 签名 | 十六进制 | 位置作用 |
|---|---|---|
| 局部文件头 | 50 4B 03 04 | 每个压缩文件条目开始处 |
| 中央目录 | 50 4B 01 02 | 文件末尾区域的目录信息区 |
| 中央目录结尾 | 50 4B 05 06 | 整个ZIP文件的末尾标记 |
hexdump打开一个正常ZIP,通常能看到类似这样的结构:
00000000 50 4b 03 04 14 00 00 00 08 00 21 07 5a 7f 62 60 |PK........!.Z.bb| 00000010 b3 2f 3d 8f 4d 30 cb 0a 33 00 00 00 34 00 00 00 |./=.M0..3...4...|开头的50 4b 03 04就是局部文件头签名,也就是网上常说的“PK”头部。紧跟着的字段里,有几个值得重点关注:
- 偏移4-5字节(14 00):解压所需版本,做题时基本不用管。
- 偏移6-7字节(00 00):通用标志位,这就是伪加密判断的核心,后面细说。
- 偏移8-9字节(08 00):压缩算法。08表示Deflate,是ZIP最常见的压缩方式;00表示不压缩(Stored)。
- 偏移18-21字节(33 00 00 00):压缩后大小。
- 偏移22-25字节(34 00 00 00):原始大小。
做题时主要用的就是前10个字节。尤其是通用标志位,伪加密题百分之百靠它做文章。
2.1 伪加密的识别与修复
伪加密,英文叫Fake Encryption,意思是压缩数据本身并没有真正加密,只是把文件头里的加密标志位置成了1。当你尝试解压时,压缩软件看到加密标志就会要求输入密码,但实际上根本没有加密,把标志位改回去就能正常解压。
通用标志位具体怎么读?在局部文件头中,第6、7字节是十六进制值,它的bit0(最低位,0x01)代表“加密”,bit3(0x08)代表“数据描述符”。伪加密题的特征就是:bit0为1,但数据没有加密。
举一个真实常见的例子。用hexdump看到文件头是:
50 4b 03 04 14 00 09 00 08 00这中间第6-7字节是09 00,二进制写作0000 1001。拆开看,bit0是1,bit3也是1。如果你尝试解压,会弹窗要密码,但你心里得打个问号:这密码是不是出题人故意的?此时验证方法很简单——不去爆破密码,直接改标志位。
用010 Editor或者HxD这类十六进制编辑器打开文件,定位到文件偏移6的位置(因为0-3是签名,4-5是版本,6-7是标志),把09 00改成00 00,保存后再解压。如果没有任何密码提示直接解出内容,那它就是伪加密。
这里要提醒一下:改的时候最好只动通用标志位那2个字节,后面的中央目录(中央目录里的对应标志位)如果主解压工具仍报错,可以一并改掉,大多数情况改局部文件头就够了。另外,7-Zip对伪加密的容忍度比WinRAR高,用7-Zip试一下也许直接就能打开。第二种判断方法是运行那个经典命令:
ZipCenOp.jar -r flag.zip国外CTF圈子老工具ZipCenOp可以对ZIP的加密标志做批量重置,但它的使用环境依赖Java老版本,现在很多新机器跑不起来。所以我现在的首选方案仍是:先试7-Zip,再手动改字节,这两个方法基本覆盖90%的伪加密场景。
2.2 文件头被篡改时的修复技巧
除了伪加密,还有一种常见操作是把文件头整个换掉。比如题目文件的开头被改成50 4B 03 05或者干脆是一串和ZIP无关的字节,导致file命令识别为Data,Windows里也会直接报“文件已损坏”。
修复思路其实很简单:只要认出数据主体是ZIP结构,把开头改回50 4B 03 04即可。但前提是你要能从杂物里认出ZIP结构。我的判断顺序是:
- 用hexdump看前16字节。
- 如果是无扩展名文件,搜索整个文件里有没有
50 4b 03 04或50 4b 01 02的重复出现。 - 用binwalk扫描,看有没有提示
Zip archive data。
如果binwalk提示有ZIP数据但头部被破坏,修复时不要只改前4字节。要注意局部文件头和中央目录是否都完整。最稳的操作是:把文件头和中央目录签名都恢复成标准值,然后保存为新文件,再试解压。
常见的文件头魔数我也顺手整理了一份,遇到类似“改文件头”的题可以用到:
| 文件类型 | 十六进制魔数 | ASCII文本 |
|---|---|---|
| ZIP | 50 4B 03 04 | PK.. |
| PNG | 89 50 4E 47 | .PNG |
| JPEG | FF D8 FF E0 | .... |
| GIF | 47 49 46 38 | GIF8 |
| 25 50 44 46 | ||
| BMP | 42 4D | BM |
修复脚本可以用Python写,几行就够。下面这个示例假设已知文件偏移0处应该修成ZIP头:
# fix_zip_header.py with open('cyberpunk', 'rb') as f: data = bytearray(f.read()) # 如果开头不是标准ZIP局部文件头,按标准字节修正 if data[:4] not in (b'PK\x03\x04',): data[:4] = b'PK\x03\x04' with open('fixed.zip', 'wb') as f: f.write(data) print('done -> fixed.zip')更保险的做法是不覆盖原文件,始终输出到新文件。等你熟练了可以直接用十六进制编辑器改,但对新手来说,保留原始文件、另存修复结果能避免很多误操作。
3. LSB隐写:藏在像素最低位里的信息
解决完ZIP文件头,解压出来的图片才是第二层战场。如果打开图片肉眼看不到任何异常,别急着在脑门上写“扫不出来”,先想想LSB。
3.1 LSB原理:为什么肉眼看不出来
LSB全称Least Significant Bit,最低有效位。数字图像中每个像素由红、绿、蓝三个通道组成,每个通道通常用8位二进制表示,取值范围0到255。
把255写成二进制是1111 1111,把254写成1111 1110,两者只差最后一位。而这一位恰恰对颜色数值影响最小——最多让亮度变化1/255,人眼几乎无法察觉。LSB隐写干的事情,就是把要隐藏的信息的二进制位,逐个替换掉像素值的最低一位。
比如要隐藏字符“A”,它的ASCII码是65,二进制是01000001。假如某个像素的红色分量是10001010,把最后一位替换成0,它变成10001010不变;如果下一位需要1,但对应的绿色分量最后一位正好是0,就把它改成1。这样逐位替换,所有像素的RGB三个通道就能承载任意二进制数据。一行字符、一张小图片,甚至一个压缩包,只要体积允许,都可以塞进去。
用生活化的例子来理解:这就像在整面纯色墙纸上,用颜色差别只有1%的涂料写了一段文字。你站在三米外看,墙纸就是纯色的;凑到跟前对比色号,才能看出有些地方颜色微微不一样。LSB隐写就是这个思路,只不过“涂料的色号差别”体现在二进制最后一位。
之所以图片载体喜欢选PNG、BMP,是因为它们是无损格式,像素值原样保存;如果是JPEG这种有损压缩格式,压缩过程中像素值会被重新量化,LSB里嵌入的信息大概率会被破坏。所以做题时看到题目给的是PNG图片,LSB几乎是必查项。
3.2 用StegSolve和zsteg提取
提取LSB最老牌的工具是StegSolve,一个Java写的GUI小工具,适合手动查看和逐通道提取。打开图片后,操作路径是:
- 菜单栏选
Analyse。 - 选择
Data Extract。 - 面板里有R、G、B三个通道,每个通道有0到7共8个位平面。
- 只勾选R、G、B的0位(也就是每个通道的最低位),点
Extract或Preview。
如果看到提取出来的左侧预览区出现一串ASCII字符或者明显的文件头,那基本就是flag了。把结果保存成文本或者文件即可。
命令行选手更推荐zsteg,一条命令扫完所有常见隐写组合:
zsteg -a cyberpunk.png-a参数会遍历所有通道、所有位平面组合,输出可能的隐藏内容。如果flag是明文,屏幕上直接就能看到;如果隐藏内容是一张图片,zsteg会提示检测到了PNG/JPEG之类的东西。
我实际使用中,两个工具配合最有效:先用zsteg快速出结果,如果没出,再开StegSolve手动调整通道组合。因为有些题目会只隐藏在一个通道里,或者需要按特定顺序读取,zsteg的默认组合不一定覆盖到,StegSolve的手动勾选反而更灵活。
3.3 手写一个Python提取脚本
工具再好用,面试时、比赛断网时,手写脚本的能力就是底牌。用Python配合PIL库,提取LSB其实非常短。
下面这个脚本按“先红通道、再绿通道、再蓝通道,从上到下逐行扫描”的顺序,取出每个像素三个通道的最低有效位,拼成二进制串,再转成ASCII字符串:
# extract_lsb.py from PIL import Image def extract_text_from_lsb(image_path): img = Image.open(image_path) pixels = img.load() width, height = img.size bits = [] # 按顺序遍历像素:每个像素取R、G、B通道的最低位 for y in range(height): for x in range(width): r, g, b = pixels[x, y][:3] bits.append(r & 1) bits.append(g & 1) bits.append(b & 1) # 每8位拼成一个字节,再转字符 chars = [] for i in range(0, len(bits) - 7, 8): byte = 0 for j in range(8): byte = (byte << 1) | bits[i + j] chars.append(chr(byte)) # 提取结果是完整文本 + 大量空字节噪声,只输出可读部分 text = ''.join(chars) printable = ''.join(c for c in text if 32 <= ord(c) <= 126) return printable if __name__ == '__main__': print(extract_text_from_lsb('cyberpunk.png'))运行后输出的可读字符串里如果能看到flag{...},那就成了。如果输出是乱码,不要慌,大概率是位序或读取顺序不对。常见的变体包括:
- 字节内位序是反的,需要从第7位往第0位读,而不是从第0位往第7位。
- 只取某一个通道,比如先全图取R通道,再全图取G通道,而不是每个像素交替取三通道。
- 隐藏的不是文本,而是另一张图片或ZIP文件。那提取出来的二进制串转成字节后,前几个字节会显示出PNG或PK的文件头,此时需要把整个二进制流按字节保存成文件,再用file命令识别。
提取二进制转文件的核心代码,本质上就是一个“比特收集器”加一个bytes()拼接:
# 假设bits已经收集好 byte_values = [] for i in range(0, len(bits) - 7, 8): byte = 0 for j in range(8): byte = (byte << 1) | bits[i + j] byte_values.append(byte) data = bytes(byte_values) with open('hidden_out', 'wb') as f: f.write(data)保存之后用file hidden_out查看真实类型,再按类型做后续处理。这一步在CTF里很常见,可能一个图里藏了另一个小图,小图里还有一层。
4. 模拟解题流程:神秘文件变成flag的完整过程
前面原理讲了一堆,接下来我用一个模拟题场景,把从拿到文件到拿到flag的完整流程走一遍。为了符合“赛博朋克”这道题的背景,我假设题目下载下来一个没有扩展名的文件,文件名就叫cyberpunk。
4.1 初始分析:file、binwalk、hexdump
拿到文件后,我的习惯顺序是:
ls -la cyberpunk file cyberpunk binwalk cyberpunk hexdump -C cyberpunk | head -20假设file输出的是data,说明系统没识别出真实类型。binwalk扫描结果可能显示ZIP archive data,只是头部信息不对。再用hexdump看前20个字节,此时看到的可能是:
00000000 50 4b 03 05 14 00 00 00 08 00 21 07 5a 7f 62 60 |PK........!.Z.bb|50 4b 03 05和标准ZIP的50 4b 03 04就差最后一个字节,这是出题人故意“改坏”的典型手法。认出这一点,修复方向就明确了。
4.2 修复ZIP头并破解伪加密
这里有两种路径,取决于binwalk能不能直接提取。如果binwalk已经把里面的ZIP部分分离出来了,那直接处理提取出的文件;如果binwalk只提示ZIP结构存在但没分离,就手动修复。
修复步骤:
- 复制一份:
cp cyberpunk cyberpunk.bak。 - 用010 Editor打开
cyberpunk,把偏移0处的50 4b 03 05改成50 4b 03 04。 - 保存为
cyberpunk_fixed.zip。 - 尝试
: 7z x cyberpunk_fixed.zip,或者直接双击。
此时如果出现“需要密码”的提示,就怀疑伪加密。重复前面说的:hexdump查看偏移6-7字节的通用标志位,发现是09 00或01 00之类的加密标志,直接改成00 00再保存。
伪加密修复之后,解压得到cyberpunk.png。图片是赛博朋克风格的高饱和霓虹夜景图,肉眼完全看不出异常。不要光看,直接进入LSB步骤。
4.3 在解压出的图片里提取LSB内容
拿到cyberpunk.png,先跑zsteg:
zsteg -a cyberpunk.png如果运气好,输出里直接有flag{...}。但为了演示手写脚本的用法,也可以把自己写的extract_lsb.py跑一下:
python3 extract_lsb.py输出里能看到一串可读字符,其中有类似flag{zip_lsb_...}的字段。到这里,这道题就算解完了。
4.4 解题流程总览
把上面整个过程简单总结成一张表,方便以后遇到类似题目时对照:
| 阶段 | 动作 | 工具 | 产出 |
|---|---|---|---|
| 识别类型 | file / binwalk / hexdump | Kali自带命令 | 确认文件实际是ZIP |
| 修复文件头 | 把错误魔数改回50 4B 03 04 | 010 Editor / Python | 可被识别的ZIP文件 |
| 处理伪加密 | 通用标志位bit0清零 | 十六进制编辑器 / 7-Zip | 可免密解压的ZIP |
| 解压 | 解压出图片 | 7z / unzip | 赛博朋克风PNG图片 |
| 提取隐写内容 | 扫描LSB位并还原数据 | zsteg / StegSolve / Python | flag字符串 |
这个流程不是只对“赛博朋克”这一道题有效。换成任何“ZIP+图片”的隐写组合题,只要把文件名和图片风格换掉,解题路径几乎是一致的。
5. 常见问题与避坑经验
这一章我把实际做隐写题过程中踩过的坑整理一下,按“症状→原因→解法”的方式列出来,方便大家真的卡住的时候有据可查。
5.1 排查问题速查表
| 症状 | 可能原因 | 排查方向 |
|---|---|---|
| file命令报data,打不开 | 文件头被篡改 | hexdump看前16字节,对照文件头魔数表 |
| 解压ZIP时报“需要密码” | 伪加密 | 查看偏移6-7字节的flag位,bit0为1则改为0 |
| 7-Zip打开提示“文件头错误” | 头部损坏或文件被拼接 | 用binwalk下一层分离,或搜索全文找PK签名 |
| 解压后得到图片,肉眼无异常 | 隐写不在可见区域 | 跑LSB提取扫描,不要用眼睛判断 |
| zsteg无输出 | 位序/通道顺序特殊 | StegSolve手动勾选不同位平面组合 |
| 提取出来是乱码 | 读取顺序或编码不一致 | 尝试交换逐像素通道顺序、反转字节内位序 |
| 提取出来是文件头而非文本 | 隐藏的是文件而不是字符串 | 把二进制流保存为文件,用file识别类型 |
| strings看不到flag | flag做了编码或反向 | 注意看提取结果是否base64、十六进制,多试几次方向 |
5.2 实际操作中的几点心得
第一个经验:看到什么类型,不要只信file命令的输出。file是拿文件头比对魔数库,文件头被改了它就“撒谎”。比赛里最容易被骗的就是这一步。每次我都用hexdump再确认一下前4个字节,最多10秒钟,能省后面半小时。
第二个经验:解压ZIP报错时,别急着上密码爆破工具。很多新手一看到压缩包有密码就去跑字典,但伪加密题的密码根本不存在,跑几小时也是白费。判断ZIP是否为伪加密,其实只要看通用标志位这一个字段。先花30秒做静态检查,比闷头爆破高效太多了。
第三个经验:LSB提取没有固定顺序。不同的隐藏脚本可能按“每个像素RGB”“先R后G后B”,也可能按“全图R通道扫完再G通道”,甚至可能是MSB顺序。工具扫不出来的时候,我建议手写脚本多试几个排列组合,比反复开GUI点鼠标快。这也是为什么我推荐大家至少会手写一个提取脚本,不是为了炫技,而是排查问题时它是最可控的手段。
第四个经验:培养“备份原文件”的肌肉记忆。修文件头、改标志位都是直接对二进制动手,写错一个字节文件就是废的。我现在只要对原始文件做修改,一定先复制一份.bak,改坏了可以用原文件重来。这习惯让我少交了很多份“重做作业”。