CTF中的zip(上)

原创文章,未经许可严禁转载

Author:ex@m1ne2

zip文件格式

.zip 文件格式由菲尔·卡茨(Phil Katz)发明,属于当下主流的压缩格式之一

.zip 的竞争者包括 .rar 格式和开放源码的 .7z 格式

从性能上比较,.rar.7z.zip 格式压缩率更高,而且 .7z 由于提供了免费的压缩工具而在更多领域得到应用

对于一个 .zip 文件来说,它由3部分组成,而每部分都有对应的文件头标记

  • 压缩源文件数据区(简称数据区),文件头为 50 4B 03 04
  • 压缩源文件目录区(简称目录区),文件头为 50 4B 01 02
  • 压缩源文件目录结束标志(简称目录结束标志),文件头为 50 4B 05 06

其中,所有文件头都有 50 4B,其对应的ASCII码为 PK,纪念 .zip 的发明人Phil Katz先生


我们首先来自制几个 .zip 文件

创建文本文件 1.txt2.txt3.txt,里面分别只有一句话:"First Text"、"Second Text"、"Third Text"(无回车符)

然后通过Bandizip或其它压缩软件,将3个 .txt 压缩成 temp.zip(无需密码)

我们用十六进制编辑器打开 temp.zip

由于我们在 temp.zip 中放置了3.txt 文件,因此可以看到有3个表示数据区的文件头 50 4B 03 04

数据区

数据区又可以划分为3部分:

  • File Header (文件头)
  • File Data (文件数据)
  • Data Descriptor (数据描述符)

File Header用于标识该文件的开始,它的结构如下:

偏移量(Offset) 长度(Length) 内容(Contents)
0 4 bytes 文件头(50 4B 03 04)
4 2 bytes 解压需要的pkware最低版本
6 2 bytes 通用位标记
8 2 bytes 压缩方式
10 2 bytes 文件最后修改时间
12 2 bytes 文件最后修改日期
14 4 bytes CRC-32校验码
18 4 bytes 压缩后大小
22 4 bytes 未压缩大小
26 2 bytes 文件名长度(假设为n
28 2 bytes 扩展区长度(假设为m
30 n bytes 文件名
30+n m bytes 扩展区

File Data实际存储着被压缩文件的数据

Data Descriptor,它只在文件被加密的情况下才会出现,就未加密的情况而言,它为空


我们的 temp.zip 压缩着 1.txt2.txt3.txt,分别对应3数据区,我们来详细看看第一个数据区

依次分析为:

  • 红色方框50 4b 03 04 代表数据区的文件头标识

  • 14 00 表示pkware的最低版本、00 00 表示通用位标识、08 00 表示压缩方式、72 bd4d 50 分别表示最后的修改时间和修改日期

  • 03 c3 94 58CRC-32校验码

  • 两个绿色方块分别表示压缩后、压缩前的文件体积大小 0c 00 00 000a 00 00 00

    值得一提的是,.zip 在存储文件大小的时候,使用的是小端序

    字节序

    计算机在存放字节数据的时候有2种顺序——大端序小端序

    大端序指高位字节在前、低位字节在后;小端序相反,低位字节在前、高位字节在后

    举例来说,十六进制数值 0x1234567大端序01 23 45 67小端序67 45 23 01(注意计算机以8 bits(1 byte)为基本单位,不足8 bits补齐)

    我们人类习惯读写大端序,而在计算机电路中,优先处理低位字节效率会比较高,因而采用小端序存储

    计算机在处理字节时,并不知道什么是大端序什么是小端序,它只会按顺序读取字节,先读第一个字节,再读第二个

    (事实上,.zip 存放大多数数据都采用小端序

    所以,按小端序存储的 0c 00 00 00 实际上表示的数值是 0xc,对应十进制 13;同理,0a 00 00 00 对应的数值是 0xa,十进制为 10

    我们打开 1.txt 的详细信息,里面记载着 1.txt 的大小:10字节,恰好就是第二个绿色方框显示的数值(压缩前文件体积)

    为什么压缩后,文件大小反而由 10 增加到了 13

    这里涉及压缩的原理

    假如文件中有大量重复的数据,如 ABABABABABABCD,如果我们把 AB 替换成 X,就成为了 XXXXXXCD,再在后面补充上 X=AB,这样会简洁很多

    我们把"在后面补充上 X=AB "称之为增加控制信息

    增加控制信息对应压缩来说是必须的步骤,但是如果压缩前文件信息本身就很紧凑,根本不用压缩,那么增加的冗余控制信息反倒会使体积变大

回到这张图:

  • 05 00 表示文件名长度、00 00 表示扩展区长度

    很显然,.zip 对它们的存储也是通过小端序

  • 蓝色方框表示文件名,它占用的字节数由前面的 05 00 决定,31 2e 74 78 74 对应的ASCII码就是 1.txt

由于扩展区长度为 00 00,所以截止至文件名,就是数据区File Header部分了

黑色方框对应的是File Data部分,随后就进入下一个数据区 50 4B 03 04 了(因为该 .zip 未加密,所以Data Descriptor部分为空)

可以通过Python的zlib模块实现模拟 .zip 的压缩和解压

还记得我们的 1.txt 的数据吗?——"First Text"

zlib对这段字符串进行压缩的Python 3代码为:

1
2
3
import zlib
print(zlib.compress(b"First Text"))
# b'x\x9cs\xcb,*.Q\x08I\xad(\x01\x00\x14g\x03\xce'

然后我们将 .zip 中的黑色方框部分的数据显示出来:

1
2
3
s = b"\x73\xcb\x2c\x2a\x2e\x51\x08\x49\xad\x28\x01\x00"
print(s)
# b's\xcb,*.Q\x08I\xad(\x01\x00'

对比两次输出结果,我们发现,黑色方框中提取出来的数据只是"First Text"经过压缩的一部分,分别缺少了前面的 b'x\x9c' 和后面的 b'\x14g\x03\xce'

个人理解是,

.zip 中的 73 cb 2c 2a 2e 51 08 49 ad 28 01 00 才是 1.txt 真正的压缩数据,而首尾多出来的信息应该是属于压缩过程中添加的控制信息

2.txt 来测试下,发现File Data的显示信息为 b'\x0bNM\xce\xcfKQ\x08I\xad(\x01\x00',而"Second Text"经zlib压缩得到 b'x\x9c\x0bNM\xce\xcfKQ\x08I\xad(\x01\x00\x18^\x04"'

可以看到,同样增加了前面的 b'x\x9c' 和后面的 b'\x18^\x04"'

我们用 zlib.decompress() 去直接解压 .zip 中的File Data,会发现报错:zlib.error: Error -3 while decompressing data: incorrect header check(错误的文件头检测)

因此猜测,额外添加的控制信息中还有确保解压正确的信息


理清楚了一个数据区中的部分数据含义后,我们再来看看之前被我们忽略的:

仍然是 1.txttemp.zip数据区部分,这次我们讲点其它的

蓝色部分分别是最后修改时间最后修改日期,两者都采用的是MS-DOS格式(因为 .zip 程序是在MS-DOS上发明的)

  • MS-DOS时间

    将"时"、"分"、"秒"转换成16 bits格式(即2 bytes),转换过程为:

    内容
    0 - 4 小时(24小时制,0 - 23)
    5 - 10 分钟(0 - 59)
    11 - 15 秒除以2

    就拿上面的 72 bd 举例,由于采用小端序,因此实际存储的数值为 0xbd72

    我们查看 1.txt 的最后修改时间(我是通过exiftool),得到 2020:02:13 23:43:35

    如何由时间 23:43:35 得到 bd72 呢?

    我们用Python将 bd72 转化成16位二进制字符串:

    1
    2
    3
    n = "bd72"
    n = bin(int(n,16))[2:].zfill(16)
    # n = '1011110101110010'

    按照上面表格的截取,得到

    1
    2
    3
    n1 = n[0:5] # '10111'
    n2 = n[5:11] # '101011'
    n3 = n[11:] # '10010'

    最后分别由二进制转成十进制:

    1
    2
    3
    print(int(n1,2)) # 23
    print(int(n2,2)) # 43
    print(int(n3,2)) # 18

    n1n2n3 分别得到 23 43 18,对比时间 23:43:35,将乘以$2$后,整个时间只有$1$秒的误差!

  • MS-DOS日期

    此表存疑

    内容
    0 - 4 年份
    5 - 8 月份
    9 - 15

    我没能在网上找到MS-DOS日期和时间转16 bits值的公式

    但是依照上表进行解析,似乎发现了一点规律:

    • 我在 2020/02/13 新建了一个 .txt,压缩为 .zip 后查看日期为 4d 50,根据上表进行解析,得到 10 0 77
    • 我在后一天 2020/02/14 进行了同样的操作,得到结果 10 0 78

    感觉MS-DOS日期是以某年某天为参考系的...

回到这张图:

红色方框存储着CRC-32校验码

在学习 .png 格式的过程中,就已经了解过CRC-32是一种生成8 bytes(32 bits)固定长度字符串的哈希算法

.zipCRC-32校验只对源文件的内容进行运算,例如,1.txt 的文件内容为"First Text",用Python 2快速计算下:

1
2
3
4
import zlib
temp = hex(zlib.crc32("First Text") & 0xffffffff)
print temp
# 0x5894c303L

因为是小端序,所以可看到输出结果与图中的 03 c3 94 58 一致


CRC碰撞

在CTF,可能给你一个加密了的 .zip 文件,并且密码很长,不太可能通过暴力破解得到密码;但是里面只包含一个小小的 .txt 文件,记录着长度不到30的flag

我们留意到CRC-32校验码只对源数据进行运算,如果我们无法爆破 .zip 的密码,那么不妨直接爆破里面的flag

HBCTF中的一道题:

链接:https://pan.baidu.com/s/10fWciZ0OLhYxa_ZVBmp6tg
提取码:rfnb

解答

打开 .zip,看到

提示我们flag为6位数,并且根据原始大小 6 猜测文件 flag6位数 中只包含为flag的那6位数;又知道了这6位数的CRC-32校验码,写脚本对这6位数进行爆破:

1
2
3
4
5
# Python 2
import zlib
for i in range(100000, 1000000):
if hex(zlib.crc32(str(i)) & 0xffffffff)[2:-1] == "9c4d9a5d":
print i

最后得到的输出结果 954288 就是flag了

限于CPU能力,CRC碰撞只能用于压缩文件较小的情况


CRC碰撞脚本

CRC碰撞有时是极耗费时间的,所以不妨借助一些经过优化的工具来加速碰撞

  • 文件字节数$\leq$5时(可以自己写脚本爆破)

    我在网络上找到一个适用于字节数$\leq$5的Python 3的CRC碰撞脚本 crcak.py

    链接:https://pan.baidu.com/s/1ucUlL8twOFFS1g6e0P1kjw
    提取码:jjl6

    使用方法:python crcak.py temp.zip,脚本会自动判别字节数

    (实测字节数等于6也可以,但是非常慢)

  • 文件字节数等于6时,借助Github上的脚本 crc32.py

    将脚本clone到本地,可以使用 -h 参数查看用法:

    单就CRC碰撞来讲,它的用法为:python crc32.py reverse 0x8位校验码

    长度为6 bytes文本的CRC校验码已经有可能出现重复了:

    该脚本会快速罗列出所有有可能的字符串

    值得一提的是,打开 crc32.py 可以看到它的字符集:

    image-20201011110050411

    很显然它缺少一些特殊字符,有可能导致无法碰撞出正确结果;自行添加上去即可

  • 文本长度$\geq$7的就不推荐使用CRC碰撞


到这里,数据区的分析就告一段落

注意,数据区的名字虽然是"数据",但它的定义是记录数据区中每一个压缩的源文件/目录都是一条记录

真正存储数据的地方是后面的目录区


目录区

目录区在官方文档中的定义是核心目录(Central Directory)数据区中的每一条记录都对应目录区中的一条数据

就结构而言,目录区数据区还是很相似的

我们用数据区来对比,查看 1.txttemp.zip 中的目录区数据:

↑ 数据区截图

数据区的文件头标识为 50 4B 03 04,而目录区则是 50 4B 01 02,对应上图的黑色方框

数据区,原本文件头标识后面就是2 bytes的pkware版本,而在目录区,它被扩展为4 bytes,前2 bytes仍然是新出现的压缩所用的pkware版本,后2 bytes则是原先的解压所需的pkware最低版本

后面的基本与数据区相同,00 00 表示通用位标记,08 00 表示压缩方法,72 bd 4d 50 分别表示最后修改时间和日期,03 c3 94 58 表示源数据的CRC-32校验码,0c 00 00 000a 00 00 00 分别表示压缩后文件大小、压缩前文件大小,05 00 表示文件名长度,24 00 表示扩展区长度

目录区,扩展区开始不为空

图中,随后的5蓝色方框都是相比较数据区,新出现的东西

然而这些都不在我们的主要讨论范围内,看一下就够了

首先的 00 00 表示"文件注释长度",之后的 00 00 表示"文件开始位置的磁盘编号",再之后的 00 00 表示"内部文件属性"

20 00 00 00 表示"外部文件属性",00 00 00 00 表示"本地文件头的相对位移"

跳过这些后,到了表示文件名31 2e 74 78 74

目录区不像数据区由①File Header;②File Data;③Data Descriptor组成,所以文件名结束后,就到了拓展区文件注释内容,它们分别由之前的长度确定


请看这个:

它是我们一直没怎么在意的通用位标记,它同时存在于数据区目录区,但只在目录区发挥效果

因为数据区只是记录

我们把绿色方框中的2 bytes数据称为通用位标记(General Purpose Bit Flag),实际上它与 .zip加密有关,这2 bytes对应的16 bits也被称之为加密位

我们重复创建 temp.zip 的操作——将 1.txt2.txt3.txt 压缩成 .zip 文件,但是这次设置了密码 abcd,得到 secret.zip

在Bandizip中分别打开 temp.zipsecret.zip,可以看到:标注了 * 号为加密文件:

使用十六进制编辑器打开 secret.zip,查看数据区

与原先未加密进行对比,变动的主要是上图各色方框的位置

关于 .zip 的加密算法

.zip 是允许多种无损压缩算法的,但最常用的算法为Deflate,以及Deflate的微型改进版——Deflate64

zip压缩软件在得到用户设置的密码后,会根据密码动态修改自身的压缩算法(其实是改变了压缩算法中的个别参数)

正因为如此,通过密码而动态改变的压缩算法本质上起到了加密算法的作用

【参考】:https://bbs.csdn.net/topics/10315681

我们首先看加密后的红色方框黑色方框部分,可以看到,经过ZIP加密后,加密后的 1.txt 文件体积增加到了 0x18 bytes,而下方的File Data区域也经过了加密,变得不可读

数据区目录区的记录,因此目录区也发生了相同的变化:

1.txt 的真实压缩数据位于数据区File Data,而目录区没有,因此加密后目录区的变化少于数据区

......

重点在于绿色方框中的数据

我们参考pkware的官方文档,在general purpose bit flag一栏中能够看到详细的解释:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
general purpose bit flag: (2 bytes)

Bit 0: If set, indicates that the file is encrypted.

(For Method 6 - Imploding)
Bit 1: If the compression method used was type 6,
Imploding, then this bit, if set, indicates
an 8K sliding dictionary was used. If clear,
then a 4K sliding dictionary was used.
Bit 2: If the compression method used was type 6,
Imploding, then this bit, if set, indicates
3 Shannon-Fano trees were used to encode the
sliding dictionary output. If clear, then 2
Shannon-Fano trees were used.
......

加密位2 bytes展开成16 bits,这16个二进制位,每一位都相当于一个开关;当某一位上的开关被打开(设置为 1,set),那么就会启动相应的功能

我们看文档中的 Bit 0,它强调:如果开启,则表明该文件已加密

也就是说,16 bits中的最低位如果为 1,那么 .zip 会认为这是一个已经经过加密的文件;倘若人为地修改未加密文件的加密位的最低位为 1,那么哪怕没有设置密码,.zip 也会判定它是一个已加密文件

这也就引申出CTF中的一类题目——zip伪加密


zip伪加密

人为地修改目录区中,加密位的最低位为 1,ZIP压缩软件将判定 .zip 经过加密;解压时,会在没有设置密码的情况下,要求输入密码,否则解压失败

位运算

在学习位运算的时候,就有过一道经典的题目:快速判断一个数是否是奇数

常规做法是,if (n % 2 == 1) 通过取模运算

而在位运算中,更快的做法是 if (n & 1 == 1)

理由是,所有的数如果都转换成二进制表示,那么每个数都可以表示成:$a_{n} × 2^n + a_{n-1} × 2^{n-1} + a_{n-2} × 2^{n-2} + ... + a_1 × 2^1 + a_0 × 2^0$

($a_0$到$a_n$是各二进制比特位上的取值)

因此,一个数的奇偶性就取决于最后一位$a_0$的取值

ZIP压缩软件会将加密位最低位为 1 视为加密,也就是说,加密位上的数值被修改为奇数,那么都将实现伪加密


实验一下,十六进制编辑器打开 temp.zip,定位到 1.txt目录区的加密位上,手动把它更改为 07 00

Bandizip打开 temp.zip,发现:

可以看到,在没有设置密码的情况下,解压 1.txt 需要输入密码

这种情况一看就是zip伪加密,正常的压缩包加密都是把里面所有的文件都加密的,不可能出现单个文件加密、其余未加密的情况

所以通常将每个文件的加密位都做手脚,让zip伪加密更难被发现


由于zip伪加密仅仅只是修改了加密位的最后一位,使得ZIP压缩软件错误进行了"索要密码"的行为,除此之外,对 .zip 没有任何修改

这也意味着zip伪加密是脆弱的

  • binwalk -e 能够无视伪加密,直接解压出里面的文件
  • 在Mac OS以及部分Linux(如Kali)中,可以直接打开伪加密.zip 压缩包

当压缩文件较多,或者我们不想逐个字节的去找到加密位的时候,可以借助一些工具——检测zip伪加密的Java小工具 ZipCenOp.jar

链接:https://pan.baidu.com/s/1SokyYdAW8C8Pfs1kRTm5Dw
提取码:e48i

使用方法为:打开CMD窗口,键入

1
2
java -jar ZipCenOp.jar r temp.zip # 解密
java -jar ZipCenOp.jar e temp.zip # 加密

解密时,会显示 success x flag(s) found,这时再次打开 .zip,会发现表示加密的 * 已经消失了,能够直接打开

注意

亲测, ZipCenOp.zip 对正常加密的 .zip 压缩包会直接修改所有加密位为偶数,虽然会让ZIP压缩软件显示"未加密",但往往 .zip 文件也损坏了,得到"CRC校验错误"的结果

所以如果拿到加密了的 .zip 文件,还是建议用十六进制编辑器打开,将加密位手动改回偶数;如果发现损坏了,则证明不是伪加密,再把加密位改回,尝试其它办法

可以比对数据区目录区加密位伪加密往往只会修改目录区加密位(因为这样就能起到效果),而正常的加密会同时修改两者的加密位

如果出题人把两处的加密位都修改了,那只好一一尝试了

此外,我在之前认为,一个压缩包中的文件要么不加密、要么全加密,不可能出现部分文件加密、部分文件不加密的情况,这种情况的出现只能是伪加密导致的;然而经过测试,真的可以做到这样

以Bandizip压缩软件为例:

  • 先将 temp1.txt 压缩为 try1.zip,然后用Bandizip打开 try1.zip,把 temp2.txt 拖入,加入压缩包中;这时可以选择为 temp2.txt 添加密码
  • 或者先将 temp1.txt 加密压缩为 try2.zip,然后拖入 temp2.txt 时选择不加密

经过测试,.zip.rar 都允许这样的操作

更有甚者,把 temp1.txt 加密压缩时选择一个密码,把 temp2.txt 拖入时选择另外一个密码,这样在解压时,需要输入两个密码Orz

所以这个不能再作为是否是伪加密的依据了


目录结束标志

我们已经大致浏览过了文件头为 50 4B 03 04数据区、文件头为 50 4B 01 02目录区,那么对于一个 .zip 文件,最后的部分就是文件头为 50 4B 05 06目录结束标志

这部分在CTF中没什么应用,适当忽略,在这里仅作记录

我们查看 temp.zip目录结束标志部分:

50 4b 05 06文件头00 00 表示当前磁盘编号, 00 00 表示目录区开始磁盘编号,03 00 表示本磁盘上记录总数,03 00 表示目录区记录总数,05 01 00 00 表示目录区尺寸大小,8e 00 00 00 表示目录区对第一张磁盘的偏移量,00 00 表示ZIP文件的注释长度