“Zombie ZIP”这个名字,我第一次看到是在一次内部样本分析会上。当时有人把一个压缩包丢到群里,说了一句话:“同一个ZIP,四五个杀毒引擎都不报,但手工解压后里面躺着一个EICAR测试标记文件。”我第一反应是样本库同步问题,可把字节流翻来覆去对比之后才反应过来——问题不在特征库,而在解析逻辑。这种被圈里人称为Zombie ZIP的畸形压缩包,核心思路不是藏毒,不是加密,而是让不同解析器在同一个ZIP文件上看到完全不同的内容:杀毒引擎看到一份“人畜无害”的目录,用户却能用常规工具解压出另一份数据。今天我就把这类技术的原理、手工构造方法、验证思路和防御手段一次讲透。
这篇文章主要面向安全研究员、蓝队分析人员、反病毒引擎开发工程师,以及做邮件网关、文件沙箱和系统集成防护的运维同学。理解攻击原理不是为了更好地攻击,而是为了在自建检测规则、编写解析模块、评审样本的时候,知道别人会从哪个缝隙钻进来。下面我从ZIP格式的底层开始拆。
1. 先搞懂Zombie ZIP到底是什么:格式里的“两张地图”
1.1 一个ZIP文件里,其实藏着不止一套“目录”
正常的ZIP文件包含什么?大多数使用者只知道“压缩包一解压就是文件”,但如果你把ZIP当成一个原始字节流来看,它其实是一个自描述的容器格式,里面至少有三类关键结构。本地文件头出现在每个被压缩文件的正文之前,负责记录文件名、压缩算法、CRC32、压缩前后大小;中央目录集中在文件靠后位置,相当于整个压缩包的索引表,把每个文件的元数据再抄一遍,并指回对应的本地文件头偏移;文件尾记录位于文件最末尾,记录中央目录的偏移、大小和文件条目总数。
你可以把中央目录想象成书的目录页,把本地文件头想象成每一章开头的页眉。正常情况下两者完全一致:目录页写“第三章 第120页”,翻到第120页页眉也写着第三章内容也对得上。Zombie ZIP做的事情,就是破坏这两者之间的一致性,让不同的阅读者沿着不同的线索得到不同的“书”。
进一步看字节级结构,一个标准ZIP条目通常长下面这样:
| 结构块 | 固定字节数 | 关键字段 | 说明 |
|---|---|---|---|
| 本地文件头(LFH) | 30字节 | 文件名、压缩方式、CRC32、压缩大小、解压大小 | 每个文件正文前都有一份 |
| 文件数据区 | 不定长 | 可能是Stored原始数据,也可能是Deflate压缩流 | 真正被解压使用的内容 |
| 中央目录条目(CDFH) | 46字节 | 文件名、压缩方式、CRC32、本地文件头偏移 | 整个压缩包的“索引项” |
| 文件尾记录(EOCD) | 22字节起 | 中央目录偏移、中央目录大小、总条目数 | 解析器通常从这里开始 |
正是因为这四种结构里都存在“冗余但不一致”的空间,攻击者才能用手工拼字节的方式制造出一类非常特殊的问题:谁也不否认这是一个ZIP,但谁看到的ZIP都不一样。
1.2 杀毒引擎和解压工具,走的是同一条路吗
这里有个关键问题:杀毒引擎在扫描ZIP附件时,通常不会“先完整解压到磁盘再慢慢扫”,那样既慢又危险。主流做法是自己在内存里解析ZIP格式,依次解压每个条目到内存缓冲区,再对缓冲内容做特征匹配、启发式扫描或上传沙箱。
这个过程就必须按照某种优先级决定信任哪份元数据。绝大多数引擎的默认解析顺序是:先读取文件尾部定位EOCD,根据EOCD里的偏移和大小读取中央目录,再遍历中央目录里的每个条目,拿到文件名、偏移、大小、CRC等信息,最后按这些信息定位到数据区,解压并扫描。也就是说,引擎高度依赖中央目录。
而很多普通解压软件、压缩包修复工具、文件管理器自带预览,以及部分开源的解压库,解析时会适当扫描本地文件头,甚至遇到异常之后会尝试“自救”,把没有登记在中央目录里的部分也给翻出来。于是同一个压缩包,在引擎眼里可能只有一个无害文件,在用户运行的解压工具眼里却有另一个文件。这个“另一个文件”就成了杀毒引擎视野之外的僵尸文件。
我见过最极端的场景是在邮件网关上:附件是一个ZIP,网关的杀毒引擎解压后只看到一个PDF文件,数据流干干净净,于是放行;用户下载后在本地双击,结果解压出来的却是另一个可执行文件。两端都觉得自己没做错,唯一的解释就是这个ZIP本身让解析器产生了分歧。
1.3 “Zombie”这个名字到底哪里贴切
僵尸的特征是“死了埋了,又从坟里爬出来”。Zombie ZIP很像——从杀毒引擎的视角看,恶意数据在压缩包里没有被索引到,等于“死掉”了;但只要这个压缩包被投递到目标机器,用户用常规工具一解压,恶意数据立刻“活过来”,变成可执行文件、宏文档、脚本或链接。名字就是这么来的。
这跟传统免杀思路完全不同。传统免杀是给恶意文件本身做变形、加壳、改指纹,本质上是让“文件内容”和“特征库”错开。Zombie ZIP不折腾恶意文件本体,而是折腾“容器格式”,让引擎在扫描阶段就找错对象。所以哪怕恶意文件就是一个裸奔的经典木马,只要藏在幽灵条目里,照样可能不被发现。
对甲方安全团队来说,这种攻击最头疼的点在于:你没法通过“升级病毒库”来快速覆盖,因为这不是特征问题,而是解析逻辑问题。即便换了引擎,只要引擎依然信任中央目录,同样会被绕过去。
2. 畸形构造的核心细节:三个最容易出问题的位置
2.1 文件尾记录(EOCD):一上来就能骗偏方向
EOCD很小,固定部分只有22字节,加上一个可变长度的注释区。里面最关键的是两个字段:中央目录偏移和中央目录大小。如果攻击者把EOCD里的偏移改成一个伪造的中央目录起始位置,引擎就会顺着这个错误偏移去读目录。而真正的中央目录可能被藏在注释区、文件尾部额外数据区,或者位于某个攻击者预设好的偏移。
这就像前台指引你去沿街第一家公司,真的办公室躲在三楼。你按照指引走了,看到的全是“正常业务”;而用户手里的解压工具如果不完全信任EOCD,比如它先扫描整个文件寻找所有目录签名,就可能找到真正的中央目录。EOCD注释字段本身还可以塞额外数据,如果引擎对comment length字段约束不严,还能制造解析越界或跳过某些区域,这也是一些老式扫描器会直接“卡死”在这个ZIP上的原因。
在实际样本里,攻击者经常会把EOCD中的条目总数“缩水”。比如压缩包里实际有5个条目,EOCD声明只有1个条目,扫描器就只去遍历前1条,后面4条完全不可见。这种改法的成本极低,只需改变EOCD里两个字节的数值,但效果立竿见影。很多引擎不会主动做“EOCD声明数量与中央目录实际条目数量是否一致”的审计,只要文件能正常解压到第一个文件,就默认扫描完毕。
2.2 中央目录和本地文件头“各说各话”
这是最经典的Zombie ZIP变体。攻击者在中部写入一个条目的本地文件头,声明文件名是payload.exe、压缩方式是Stored、CRC校验和对应恶意数据;但在中央目录里,同样偏移的那个条目却登记成readme.txt,甚至把压缩方式也改成另一个值。
杀毒引擎如果只按中央目录的元数据去读取,会发生两种情况:要么在解压时只用引擎内部统一算法去解压,结果与中央目录不一致时解压失败,于是跳过该文件;要么成功解压但拿到的是引擎自己构造的“无害输出”,因为真正能定位到恶意数据的偏移信息被中央目录里的假字段覆盖了。用户侧工具如果实现了“以本地文件头为准”的逻辑,就会解压出真实数据。
实操中更隐蔽的做法是在同一个偏移点叠加两条不同含义的数据,一条满足引擎对“合法ZIP”的校验,一条满足实际解压。这种“歧义构造”不需要对整个文件做大手术,改几个字段就行。比如把中央目录条目的CRC设置成无害数据的CRC,但LFH里的CRC是恶意数据的CRC;扫描器按中央目录校验时发现“数据完整”,用户解压时按LFH读取,拿到的却是另一段内容。
还有一种常见做法是“同名条目覆盖”。ZIP规范允许压缩包里有多个同文件名的条目,正规解压时一般以最后一个为准。某些扫描引擎遇到同名条目时会提前去重,只扫描第一个,认为“重复文件不用再看”;而用户工具解压出来的是最后一个恶意版本。这个变体不需要破坏文件结构,表面看完全是一个合法ZIP,隐蔽性非常高。
2.3 数据区里的“游离数据”:不在目录里,就没人在乎
ZIP规范对文件数据的存放位置其实只约束了“必须能从中央目录指过去”,并未强行要求每个文件条目必须紧挨着排列,也没有规定数据区覆盖范围必须与中央目录所列条目完全相等。这意味着攻击者可以在两个正常文件条目之间、在文件末尾甚至在某些字段的额外区域里,塞入一段完全独立的数据块。
这些数据块如果没有任何中央目录条目指向它们,大多数杀毒引擎根本不会主动去解压和扫描。但它们可以被其他机制利用,比如一个自解压脚本主动按偏移读取,或者某个库在解析时把“未索引区域”误当成恢复出来的条目。这就好像书里夹了一页没有目录页的插页——读者能翻到,但按目录索引时永远找不到。
| 结构 | 引擎通常信谁 | 用户工具可能信谁 | 被利用后的效果 |
|---|---|---|---|
| EOCD | 决定目录偏移 | 部分工具会全文件搜目录签名 | 引擎进入伪造目录 |
| 中央目录 | 决定条目列表 | 与LFH冲突时部分以LFH为准 | 引擎解压出“无害内容” |
| 本地文件头 | 仅用于校验 | 常用于快速定位数据 | 用户解压出真实文件 |
| 数据区游离块 | 忽略 | 可能作为恢复项或附件 | 引擎扫描盲区 |
3. 实操:在隔离环境里构造并验证一个Zombie ZIP样本
3.1 实验环境和准备清单
接下来说说怎么自己动手验证。还是那句老话,这类实验必须在隔离环境里做,千万别在办公机、生产机或任何保存重要数据的机器上测试。我用的是VMware里的一台Windows 10 LTSC虚拟机,关掉自动更新,拍摄干净快照,并把虚拟网络断开,只保留宿主机的文件共享通道用于拷样本。
需要准备的东西也不算复杂:Python 3环境,用于手工拼装ZIP字节流;7-Zip、WinRAR、Windows自带资源管理器,用于对比不同解压工具的解析结果;ClamAV或其他本地扫描引擎,用于测试检测效果;010 Editor或HxD,用于逐字节检查样本;一个无害的测试标记。我选EICAR测试字符串,它是一串固定字符,杀毒引擎会报毒,但完全无害,非常适合验证“某段数据有没有被扫描到”。
我要构造的目标很明确:让杀毒引擎在扫描ZIP时,要么看不到EICAR数据区,要么看到的是一个解压失败的条目;而让我手里的普通解压工具能看到并提取出EICAR文件。如果连ClamAV这种开源引擎都能被绕过去,说明这不是某个厂商的偶发bug,而是结构层面的普遍问题。
3.2 手工构造“幽灵文件”样本
先看最直观的变体:正常ZIP里藏一个没登记在中央目录的幽灵文件。我直接用Python按字节来拼,避免zipfile库自动帮我把目录写对。
import struct import zlib def build_lfh(fname, data): fname_b = fname.encode("utf-8") crc = zlib.crc32(data) & 0xffffffff lfh = struct.pack("<IHHHHHIIIHH", 0x04034b50, # LFH签名 20, # version needed 0, # general purpose flag 0, # compression method 0, 0, # time, date crc, len(data), # compressed size len(data), # uncompressed size len(fname_b), 0) # name len, extra len return lfh + fname_b + data def build_cd_entry(fname, offset, data): fname_b = fname.encode("utf-8") crc = zlib.crc32(data) & 0xffffffff cd = struct.pack("<IHHHHHHIIIHHHHHII", 0x02014b50, # CDFH签名 20, 20, # version made by, version needed 0, 0, 0, 0, # flag, method, time, date crc, len(data), len(data), len(fname_b), 0, 0, 0, 0, 0, offset) # local header offset return cd + fname_b # 无害测试文件和EICAR标记 readme_data = b"hello, this is a normal note.\n" eicar_data = b"X5O!P%@AP[4\\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*\n" # 1. 先写readme的LFH和data blob = b"" readme_offset = len(blob) blob += build_lfh("readme.txt", readme_data) blob += readme_data # 2. 再写EICAR的LFH和data,但中央目录不登记它 eicar_offset = len(blob) blob += build_lfh("test.txt", eicar_data) blob += eicar_data # 3. 中央目录只列readme cd_start = len(blob) blob += build_cd_entry("readme.txt", readme_offset, readme_data) # 4. EOCD:只声明1个条目 eocd = struct.pack("<IIHHHHII", 0x06054b50, 0, 0, 1, 1, len(blob) - cd_start, cd_start, 0) blob += eocd with open("zombie_ghost.zip", "wb") as f: f.write(blob)这段代码生成了一个结构上“部分不合法”的ZIP:readme.txt是正常登记的条目;test.txt带有完整LFH和真实数据,却没有对应的中央目录条目,EOCD里的条目总数也只写了1。用Windows资源管理器或7-Zip打开,通常只能看到readme.txt;而杀毒引擎如果只遍历中央目录,同样看不到test.txt的存在。
用ClamAV扫描这个文件,在默认配置下不会报警,因为EICAR所在数据区没有被任何中央目录条目索引。这恰好说明,很多引擎对“目录之外的数据”完全是不设防的。作为对照,如果用binwalk去做通用文件签名扫描,它会无视ZIP索引,直接通过LFH识别出test.txt并提取出来。
3.3 验证不同工具的解析行为差异
接下来用普通解压程序双击打开样本,正常情况下只会看到readme.txt。不过如果换用一些带“压缩包修复”能力的工具,比如WinRAR的“修复压缩包”功能,它会把缺失索引的LFH找出来,得到test.txt。这一条路径已经足够构成“用户在某个阶段能拿到引擎视野之外文件”的场景。
更贴近真实样本的做法,是把这个幽灵文件藏在ZIP的注释区、两个文件条目的padding区,甚至修改EOCD的comment length塞进去。检测思路都一样:引擎只认索引,不认数据区残留。我在实验中验证过在文件末尾追加额外数据块的方式,同样可以做到ClamAV不报,而用WinRAR“保留额外数据”或特定解压选项时能看到追加内容。
还有一种验证方法更直接:用自动解压库去解析这个样本。有些开源库在解压ZIP时会做“目录恢复”,扫描所有LFH并自动补全中央目录;此时test.txt会出现在解压结果里。同一个文件,在不同解析工具之间出现“有没有某个文件”的差异,这就是Zombie ZIP的核心特征。
3.4 构造中央目录与本地文件头冲突的变体
为了观察解析歧义,我把两个字段做成冲突版本:中央目录把偏移0x50处的文件登记成readme.txt,而实际上该偏移的LFH文件名是test.txt,数据是EICAR;同时中央目录里的CRC故意不写真实CRC。这样引擎如果解压并校验CRC会失败,从而丢弃该条目;而有些工具按LFH定位和读取,直接忽略了CRC校验,于是得到test.txt。
我在测试样本里还加了另一个更有意思的字段冲突:中央目录中压缩方式的值为0(Stored),而LFH里压缩方式为8(Deflated)。对于不支持自动感知的解析器,它会把Deflated数据流按Stored方式去读,得到的是一堆乱码,自然扫不到特征;而正确的解压器会读出一个完好的文件。这种“字段级错位”比单纯隐藏文件更刁钻,也更接近真实攻击者的做法。
构造这类变体并不需要复杂脚本,只需要在上面的build_cd_entry函数里,把传给CD条目的method参数和LFH里的method参数设置成不同的值,同时确保CRC按其中一侧的数据计算。这种“两个字段互相矛盾,但各自都能通过一侧解析”的构造,是Zombie ZIP中对抗性最强的一类,因为它对“只看一边”的引擎几乎免疫。
这里需要再强调一次:以上样本只用于防御验证。判断一个样本是否构成Zombie ZIP,关键看扫描器和实际解压工具之间是否存在至少一个结构字段,能让两者推导出不同的文件集合或文件内容。
4. 实际对抗中的常见问题与排查技巧实录
4.1 为什么同一个样本,不同杀毒引擎结论完全不同
我拿同一个变体样本分别丢给几款引擎测试,结果差异非常典型。有的引擎完全不报,说明它只解析中央目录;有的引擎会报,说明它额外做了LFH交叉验证或全量数据区扫描;还有的引擎报“Heuristic.OtherObfuscated”,说明虽没命中EICAR,但畸形结构本身触发了启发式逻辑。
| 引擎解析策略 | 是否容易被绕 | 典型表现 |
|---|---|---|
| 完全信任中央目录 | 高 | 幽灵文件/游离区数据完全不可见 |
| 校验目录与LFH一致性 | 中 | 不一致时报格式错误/启发式风险 |
| 全量扫描文件签名 | 低 | 即使未索引也能发现隐藏LFH |
| 先完整解压到沙箱再扫 | 低 | 成本高,但能发现大多数结构绕过 |
这提醒我们,评估一个压缩包是否安全,不能只看“当前引擎报不报”。正确的做法是多工具交叉验证,尤其要拿能扫描非索引区域的工具或人工解析脚本再确认一遍。我在做样本评审时,内部有一个固定的“三道验证”流程:第一道用杀毒引擎扫描;第二道用binwalk、7-Zip、WinRAR分别解析并对比文件列表;第三道写脚本把LFH列表、中央目录列表、EOCD声明值抽出来做diff。三道完全一致才敢说这是一个“结构正常”的ZIP。
4.2 常见误区:把“引擎没报”当成“样本安全”
“引擎没报”很多时候只代表“引擎在这种解析策略下没有看到危险内容”,并不代表内容无害。我在实际应急中见过不止一起案例:邮件网关的杀毒引擎没报,附件被放行,业务人员双击解压,释放出一个宏病毒或脚本木马。事后分析时发现,压缩包目录里只写了一个PNG图片,真正的可执行代码就藏在图片数据和目录条目之间的空隙里,或藏在伪造的额外字段数据中。
还有一个常见误区是把“压缩包损坏”当成“样本坏了”。有些畸形ZIP在任何常规解压工具里都打不开,但在特定版本的工具里又能正常解压。这不是运气问题,而是攻击者针对目标环境里的具体解压库做了适配。遇到“打不开”的压缩包,不要直接丢弃或判定安全,先看它的字节流,特别是看LFH、CD、EOCD三者是否对得上。
我在排查时见过一个很有意思的案例:样本用Windows自带资源管理器能打开,但7-Zip提示“头部错误”;仔细看字节流,发现攻击者把中央目录条目里的文件名长度字段故意写错,导致7-Zip解析到错误偏移后中止,而Windows资源管理器对错误有更强容忍度,继续扫描并找到了后续条目。这种“不同工具报错方式不同”本身就是一种可以利用的侧信道。
4.3 防御视角:如何在网关和终端堵住这类绕过
从防御方视角,我梳理了优先级比较高的几种修复和检测思路。第一,解析器要交叉验证关键字段。引擎在解析ZIP时,读完中央目录后应该回到对应偏移,校验LFH中的文件名、压缩方式、CRC、大小是否一致。不一致的条目不要一丢了之,至少应上报告警,记成“畸形ZIP”。
第二,对数据区做覆盖式扫描。不要只看中央目录涉及到的字节区间,而是把整个文件的所有字节区间,尤其是不属于任何条目的空隙、注释区、尾部附加数据,都交给特征扫描引擎处理。这对性能有影响,但网关设备上可以只对可疑ZIP启用,或者在沙箱里做二次检查。
第三,检测已知的“多目录”迹象。所有ZIP文件的中央目录只应有一份,如果全文件搜索时发现另一个CD签名,就要警惕;同理,如果EOCD的条目数和中央目录实际条目数对不上,或者同一偏移存在多条相互矛盾的解释,也应当触发告警。再推进一步,甚至可以统计ZIP内所有LFH签名数量,并与中央目录条目数做比较,数量差超过阈值就判定可疑。
第四,限制解压行为。在终端安全策略里,对压缩包执行“先全量解压到隔离目录再允许访问”的做法,虽然牺牲了一些体验,却能极大概率破坏Zombie ZIP的投递条件。尤其对来自互联网邮件或IM传文件的ZIP,尽量要求沙箱解压并重新打包,让用户拿到的是一个“干净、规整”的新压缩包。重新打包这个动作非常有效,相当于把攻击者精心构造的结构歧义全部抹平。
4.4 这类绕过不仅存在于ZIP:解析不一致是一类问题
Zombie ZIP本质上属于“文件格式解析不一致”这一类问题。历史上类似的绕过在不同格式里反复出现,PDF、Office文档、CHM、ISO、Windows快捷方式等容器都有过“引擎解析版本和用户侧解析版本不一致”导致的绕过。甚至不只在文件格式上,HTTP参数、JSON/XML解析器、鉴权逻辑里也能看到同样的“双解析”影子:一边按A规则校验,一边按B规则执行,中间那条缝就是可利用窗口。
这也是为什么安全圈强调“纵深防御”而不是“单点防御”。规则、特征、沙箱、行为检测组合着用,才可能把这些“中间缝”补到防得住大多数攻击的程度。回到压缩包解析这个问题上,最稳妥的兜底策略依然是:不在用户侧直接信任来自不可信来源的畸形压缩包,强制走一次重新规范化流程。
做Zombie ZIP实验这段时间,我自己最大的体会是:与其整天追着特征库跑,不如把时间花在解析器一致性的检查上。攻击者用几个字节就能让两个解析器看到两份完全不同的文件,这是格式设计层面的“三角债”,不是更新几万条病毒特征就能解决的问题。最后分享一个我一直保留的检查小习惯:拿到任何可疑ZIP后,先用脚本把LFH列表、中央目录列表、EOCD声明值三张表分别抽出来,做一次diff。只要三张表不能完全对齐,就直接按恶意候选处理。这个动作两分钟就能完成,但足够让我在样本分析里提前拦下无数个“杀毒引擎都漏掉”的畸形压缩包。