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 cyberpunkhexdump查看文件开头十六进制字节hexdump -C cyberpunk | head -20binwalk扫描文件里是否嵌入了其他文件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代表“加密”bit30x08代表“数据描述符”。伪加密题的特征就是bit0为1但数据没有加密。举一个真实常见的例子。用hexdump看到文件头是50 4b 03 04 14 00 09 00 08 00这中间第6-7字节是09 00二进制写作0000 1001。拆开看bit0是1bit3也是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命令识别为DataWindows里也会直接报“文件已损坏”。修复思路其实很简单只要认出数据主体是ZIP结构把开头改回50 4B 03 04即可。但前提是你要能从杂物里认出ZIP结构。我的判断顺序是用hexdump看前16字节。如果是无扩展名文件搜索整个文件里有没有50 4b 03 04或50 4b 01 02的重复出现。用binwalk扫描看有没有提示Zip archive data。如果binwalk提示有ZIP数据但头部被破坏修复时不要只改前4字节。要注意局部文件头和中央目录是否都完整。最稳的操作是把文件头和中央目录签名都恢复成标准值然后保存为新文件再试解压。常见的文件头魔数我也顺手整理了一份遇到类似“改文件头”的题可以用到文件类型十六进制魔数ASCII文本ZIP50 4B 03 04PK..PNG89 50 4E 47.PNGJPEGFF D8 FF E0....GIF47 49 46 38GIF8PDF25 50 44 46%PDFBMP42 4DBM修复脚本可以用Python写几行就够。下面这个示例假设已知文件偏移0处应该修成ZIP头# fix_zip_header.py with open(cyberpunk, rb) as f: data bytearray(f.read()) # 如果开头不是标准ZIP局部文件头按标准字节修正 if data[:4] not in (bPK\x03\x04,): data[:4] bPK\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先跑zstegzsteg -a cyberpunk.png如果运气好输出里直接有flag{...}。但为了演示手写脚本的用法也可以把自己写的extract_lsb.py跑一下python3 extract_lsb.py输出里能看到一串可读字符其中有类似flag{zip_lsb_...}的字段。到这里这道题就算解完了。4.4 解题流程总览把上面整个过程简单总结成一张表方便以后遇到类似题目时对照阶段动作工具产出识别类型file / binwalk / hexdumpKali自带命令确认文件实际是ZIP修复文件头把错误魔数改回50 4B 03 04010 Editor / Python可被识别的ZIP文件处理伪加密通用标志位bit0清零十六进制编辑器 / 7-Zip可免密解压的ZIP解压解压出图片7z / unzip赛博朋克风PNG图片提取隐写内容扫描LSB位并还原数据zsteg / StegSolve / Pythonflag字符串这个流程不是只对“赛博朋克”这一道题有效。换成任何“ZIP图片”的隐写组合题只要把文件名和图片风格换掉解题路径几乎是一致的。5. 常见问题与避坑经验这一章我把实际做隐写题过程中踩过的坑整理一下按“症状→原因→解法”的方式列出来方便大家真的卡住的时候有据可查。5.1 排查问题速查表症状可能原因排查方向file命令报data打不开文件头被篡改hexdump看前16字节对照文件头魔数表解压ZIP时报“需要密码”伪加密查看偏移6-7字节的flag位bit0为1则改为07-Zip打开提示“文件头错误”头部损坏或文件被拼接用binwalk下一层分离或搜索全文找PK签名解压后得到图片肉眼无异常隐写不在可见区域跑LSB提取扫描不要用眼睛判断zsteg无输出位序/通道顺序特殊StegSolve手动勾选不同位平面组合提取出来是乱码读取顺序或编码不一致尝试交换逐像素通道顺序、反转字节内位序提取出来是文件头而非文本隐藏的是文件而不是字符串把二进制流保存为文件用file识别类型strings看不到flagflag做了编码或反向注意看提取结果是否base64、十六进制多试几次方向5.2 实际操作中的几点心得第一个经验看到什么类型不要只信file命令的输出。file是拿文件头比对魔数库文件头被改了它就“撒谎”。比赛里最容易被骗的就是这一步。每次我都用hexdump再确认一下前4个字节最多10秒钟能省后面半小时。第二个经验解压ZIP报错时别急着上密码爆破工具。很多新手一看到压缩包有密码就去跑字典但伪加密题的密码根本不存在跑几小时也是白费。判断ZIP是否为伪加密其实只要看通用标志位这一个字段。先花30秒做静态检查比闷头爆破高效太多了。第三个经验LSB提取没有固定顺序。不同的隐藏脚本可能按“每个像素RGB”“先R后G后B”也可能按“全图R通道扫完再G通道”甚至可能是MSB顺序。工具扫不出来的时候我建议手写脚本多试几个排列组合比反复开GUI点鼠标快。这也是为什么我推荐大家至少会手写一个提取脚本不是为了炫技而是排查问题时它是最可控的手段。第四个经验培养“备份原文件”的肌肉记忆。修文件头、改标志位都是直接对二进制动手写错一个字节文件就是废的。我现在只要对原始文件做修改一定先复制一份.bak改坏了可以用原文件重来。这习惯让我少交了很多份“重做作业”。