起因

手里有 .dzip 文件需要解压。unzip7z、Python 的 zipfile 全都不认这个扩展名,file 也只报 data。一番搜索才知道,这个是一个名叫解压专家的 APP 的专用格式,但除了这一个 APP,就找不到其他工具了。

唯一搜到的只是一个名叫 dzip 的仓库,但这只是一个标准 zip 子集的实现,和我手里的 .dzip 不能说是一模一样,只能说是毫不相关。

但好在现如今有了 AI 帮忙,这种活可以直接丢给 AI 了,本次逆向由 GLM-5.2 协助完成。

TL;DR

.dzip 就是把 ZIP 四个签名里的 PK 换成了 UA,其余完全是标准 ZIP;加密层是 WinZip AES,但原生库在派生密钥前往用户密码末尾追加了一个随机字节(X % 241) + 1,每包一个),所以拿原始密码去 PBKDF2 永远对不上。爆破这个字节(1–255)即可。完整实现请查看@V-Conet/undzip.

前置知识:ZIP 和 WinZip AES 速览

后面的分析需要你大概知道这两件事,在这里快速过一下关键字段,免得看到 hex 发懵。

ZIP 的四种记录签名

一个 ZIP 文件由四种记录拼成,每种都以一个 4 字节签名开头:

记录 签名 含义
Local File Header PK\x03\x04 (50 4B 03 04) 每个文件数据前的本地头
Data Descriptor PK\x07\x08 (50 4B 07 08) 数据描述符(可选,跟在数据后)
Central Directory Header PK\x01\x02 (50 4B 01 02) 中央目录,每文件一条
End of Central Directory PK\x05\x06 (50 4B 05 06) EOCD,文件尾

本地头的字段布局(签名之后 26 字节):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
偏移  长度  含义
0 4 签名
4 2 版本
6 2 通用位标志 (GP flag)
8 2 压缩方法 (0=Stored, 8=Deflate, 99=WinZip AES)
10 2 修改时间
12 2 修改日期
14 4 CRC-32
18 4 压缩后大小
22 4 原始大小
26 2 文件名长度 n
28 2 extra 字段长度 m
30 n 文件名
30+n m extra 字段
30+n+m ... 压缩数据

WinZip AES

method = 99 时,走的是 WinZip AES。每条加密条目会带一个 0x9901 extra 字段,说明 AES 参数:

1
2
3
4
5
0x9901 extra 字段 (签名后 7 字节):
2 vendor 版本 (1=AE-1 保留真实 CRC, 2=AE-2 CRC 置零)
2 vendor ID ("AE")
1 强度 (1=AES-128, 2=AES-192, 3=AES-256)
2 实际压缩方法 (数据用 deflate 压缩后再加密, 这里通常是 8)

加密载荷的布局是 salt + password_verify(2) + ciphertext + auth_code(10),其中 salt 长度随强度变(AES-256 是 16 字节)。密钥派生用 PBKDF2-HMAC-SHA1,迭代 1000 次(WinZip 规范固定),派生出 enc_key + mac_key + verify(2)。加密用 AES-CTR,尾部 10 字节是 HMAC-SHA1(mac_key, ciphertext) 的前 10 字节做鉴权。

这套东西的标准实现到处都是,关键是要记住:verify 是 16 位的,只能当初筛;HMAC 是 80 位的,才能下结论。 这个区别后面会反复用到。

第一眼:这东西长得太像 ZIP 了

拿最小的样本 test.dzip(240 字节,单个 test.txt)扔进 xxd

1
2
3
4
5
6
7
8
9
10
11
00000000: 5541 0304 1400 0e08 0800 0000 0000 0000  UA..............
00000010: 0000 0000 0000 0400 0000 0800 0000 7465 ..............te
00000020: 7374 2e74 7874 2b49 ad28 0100 5541 0708 st.txt+I.(..UA..
00000030: c7a7 8b3b 0600 0000 0400 0000 5541 0102 ...;........UA..
00000040: 3303 1400 0e08 0800 0000 0000 c7a7 8b3b 3..............;
...
00000090: 7d1a 851f ead6 7c32 c9d1 5541 0506 0000 }.....|2..UA....
000000a0: 0000 0100 0100 5e00 0000 3c00 0000 0000 ......^...<.....
000000b0: 7e5b 44f4 42fe 80aa 6973 ea9d 2141 b0e1 ~[D.B...is..!A..
000000c0: 45ed 3e7b a68f 0bba 3e20 a758 3a8d c5b9 E.>{....> .X:...
000000d0: 79ff 595b 81c8 f943 856b 60d0 97f4 4ae7 y.Y[...C.k`...J.

第一行开头 55 41 03 04,也就是 UA\x03\x04。这不就是前面提到的 ZIP 本地文件头签名 PK\x03\x0450 4B 03 04)吗。往后看,UA\x07\x08(数据描述符)、UA\x01\x02(中央目录)、UA\x05\x06(EOCD)一一对应。

也就是说,这个 dzip 就是把 ZIP 所有记录签名的前两字节 PK 换成了 UA,其余字段、小端长度、deflate 流、中央目录结构原封不动。

按本地头布局手工拆一下前 30 字节:

1
2
3
4
5
6
7
8
9
10
11
55 41 03 04   签名 UA\x03\x04
14 00 版本 20 (2.0)
0e 08 GP flag = 0x080e (bit1,2,3,11)
08 00 方法 8 (Deflate)
00 00 00 00 修改时间/日期
00 00 00 00 CRC = 0 (用了数据描述符, bit3 置位)
00 00 00 00 压缩大小 = 0 (同上)
04 00 00 00 原始大小 = 4 ("text")
08 00 文件名长度 8
00 00 extra 长度 0
74 65 73 74 2e 74 78 74 "test.txt"

数据从偏移 30+8=38 (0x26) 开始:2b 49 ad 28 01 00,6 字节,正好是 text 的 raw deflate 流。Python 验证一下:

1
2
3
4
5
>>> import zlib, binascii
>>> zlib.decompress(bytes([0x2b,0x49,0xad,0x28,0x01,0x00]), -15)
b'text'
>>> hex(binascii.crc32(b'text') & 0xffffffff)
'0x3b8ba7c7' # 和数据描述符里的 c7a78b3b (小端) 一致

到这里不难看出,dzip 就是 ZIP,只是把签名 PK 改成了 UA

不过有两个小坑值得记一下,省得后面栽跟头。

坑一:尾部多了一段 64 字节

EOCD 的 comment_len 明明是 0,但文件末尾还拖了 64 字节看着像随机数据的尾巴(上面 0xb0 之后那一段)。一开始我写 EOCD 查找的时候死板地要求 EOCD偏移 + 22 + comment_len == 文件大小,结果 “not a dzip”。

这 64 字节后来证明是加密层追加的,暂时不用考虑。

坑二:本地头的 extra 长度要按本地头自己读

中央目录和本地头里的 extra 字段长度可能不一样。算载荷起始偏移时必须用本地头自己的 extra_len,不然偏移就错了。这种细节标准 ZIP 解压器都处理了,自己手写 parser 时容易漏。

写一个最小的解析器

既然格式清楚了,先写个能解非加密 dzip 的 parser。核心是把四种记录的结构体定义出来:

1
2
3
4
5
6
7
8
9
10
import struct, zlib, binascii, os

SIG_LOCAL = b"UA\x03\x04"
SIG_CENTRAL = b"UA\x01\x02"
SIG_EOCD = b"UA\x05\x06"

# 签名之后的字段
LFH = struct.Struct("<HHHHHIIIHH") # 版本,flag,方法,时间,日期,crc,comp,uncomp,nlen,elen
CDFH = struct.Struct("<HHHHHHIIIHHHHHII") # 多了 vmade, commentlen, disk, iattr, eattr, loff
EOCD = struct.Struct("<HHHHIIH") # disk,cd_disk,n_disk,n_total,cd_size,cd_off,comment_len

EOCD 的查找要容忍尾部 footer:

1
2
3
4
5
6
7
8
9
10
11
12
13
def find_eocd(data):
"""dzip 在 EOCD 后追加了 64 字节 footer (comment_len==0),
所以不能要求 EOCD 正好落在文件尾。改成: 倒着扫签名, 找到第一个
其声称的中央目录偏移确实指向 UA\x01\x02 的候选。"""
start = max(0, len(data) - (22 + 0xFFFF))
pos = data.rfind(SIG_EOCD, start)
while pos != -1:
if pos + 22 <= len(data):
cd_off = EOCD.unpack(data[pos+4:pos+22])[5]
if 0 <= cd_off < pos and data[cd_off:cd_off+4] == SIG_CENTRAL:
return pos
pos = data.rfind(SIG_EOCD, start, pos)
raise ValueError("EOCD not found")

读中央目录拿条目列表,再按每条的本地偏移去读本地头算数据起点(用本地头自己的 nlen/elen):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
def read_entries(data):
p = find_eocd(data)
_, _, _, n_total, _, cd_off, _ = EOCD.unpack(data[p+4:p+22])
entries = []
q = cd_off
for _ in range(n_total):
f = CDFH.unpack(data[q+4:q+46])
method, crc, comp, uncomp, nlen, elen, loff = f[3], f[6], f[7], f[8], f[9], f[10], f[15]
name = data[q+46:q+46+nlen].decode("utf-8", "replace")
entries.append(dict(name=name, method=method, crc=crc, comp=comp,
uncomp=uncomp, loff=loff))
q += 46 + nlen + elen + f[11] # f[11] = comment_len
return entries

def read_payload(data, e):
"""用本地头自己的 nlen/elen 算数据起点; 长度用中央目录的 comp。"""
p = e["loff"]
nlen, elen = LFH.unpack(data[p+4:p+30])[8:10]
start = p + 30 + nlen + elen
return data[start:start + e["comp"]]

解压:

1
2
3
4
def decompress(raw, method):
if method == 0: return raw # Stored
if method == 8: return zlib.decompress(raw, -zlib.MAX_WBITS) # raw deflate
raise ValueError(f"unsupported method {method}")

使用下面的目录测试:

1
2
3
4
5
6
7
8
9
10
11
12
.
├── dir_a
│   ├── Markdown 文档.txt
│   └── Microsoft PowerPoint 幻灯片.pptx
├── dir_b
│   ├── 欢迎使用文档应用.docx
│   └── 平面示意图.pdf
├── h.ogg
├── HOYO-MiX - 如歌的秘数 Choir's Cypher [mqms2].ogg
└── test.txt

3 directories, 7 files

用 APP 压缩成 dzip,使用我们自己写的 parser 解压,结果和原文件逐字节一致,至此,无密码 dzip 的解压已解决,之后就是如何处理加密 dzip。

破解加密

先看一个最小的加密样本 test.dzip(297 字节,里面就一个 test.txt,内容 hello,world):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
00000000: 5541 0304 3300 0908 6300 0000 0000 0000  UA..3...c.......
00000010: 0000 0000 0000 0b00 0000 0800 0b00 7465 ..............te
00000020: 7374 2e74 7874 0199 0700 0100 4145 0308 st.txt......AE..
00000030: 008f 7330 97a0 b4c0 7a21 5788 22c1 a6f6 ..s0....z!W."...
00000040: 8b19 ef96 3038 8dac 879d f34f 6969 7856 ....08.....OiixV
00000050: 366c de4c a894 1998 4914 5541 0708 fed1 6l.L....I.UA....
00000060: 887a 2900 0000 0b00 0000 5541 0102 3303 .z).......UA..3.
00000070: 3300 0908 6300 0000 0000 fed1 887a 2900 3...c........z).
00000080: 0000 0b00 0000 0800 3300 0000 0000 0000 ........3.......
00000090: 8000 b081 0000 0000 7465 7374 2e74 7874 ........test.txt
000000a0: 0199 0700 0100 4145 0308 0051 1a24 0017 ......AE...Q.$..
000000b0: 0020 0077 df26 3f49 1233 56d2 8a4a 8715 . .w.&?I.3V..J..
000000c0: d25b f5b9 80be eeb5 03ca b46e a61a c9f3 .[.........n....
000000d0: 320e da55 4105 0600 0000 0001 0001 0069 2..UA..........i
000000e0: 0000 006a 0000 0000 00c1 76ec 436f 1bfb ...j......v.Co..
000000f0: ed8a fe2b bece 534c 916b e11d 724b 0139 ...+..SL.k..rK.9
00000100: 8985 d6c3 cc23 2023 e402 01b0 be3c 99af .....# #.....<..
00000110: a5d5 2ee7 6ed6 c541 019a 45b6 5b65 6ded ....n..A..E.[em.
00000120: 2e11 e56b 0ed7 ffdd 5c ...k....\

拆本地头:

1
2
3
4
5
6
7
8
9
10
11
55 41 03 04   签名
33 00 版本 51 (5.1)
09 08 GP flag = 0x0809 -> bit0(加密) + bit3(数据描述符) + bit11(UTF-8)
63 00 方法 99 (WinZip AES)
00 00 00 00 时间/日期
00 00 00 00 CRC = 0 (数据描述符)
00 00 00 00 压缩大小 = 0 (数据描述符)
0b 00 00 00 原始大小 = 11 ("hello,world")
08 00 文件名长度 8
0b 00 extra 长度 11
74 65 73 74 2e 74 78 74 "test.txt"

紧跟的 11 字节 extra 就是 0x9901 AES 字段,原始字节为 01 99 07 00 01 00 41 45 03 08 00,按下表拆解:

偏移 字节 字段 值 / 含义
0–1 01 99 头 ID 0x9901(WinZip AES)
2–3 07 00 数据长度 7
4–5 01 00 vendor 版本 1(AE-1,保留真实 CRC)
6–7 41 45 vendor ID "AE"
8 03 强度 3(AES-256)
9–10 08 00 实际压缩方法 8(Deflate)

数据从偏移 30+8+11=49 (0x31) 开始,41 字节,按 WinZip AES 布局 salt(16)+verify(2)+ct+auth(10) 拆:

1
2
3
4
salt (16): 8f 73 30 97 a0 b4 c0 7a 21 57 88 22 c1 a6 f6 8b
verify(2): 19 ef
ct (13): 96 30 38 8d ac 87 9d f3 4f 69 69 78 56
auth (10): 36 6c de 4c a8 94 19 98 49 14

13 字节 ct 正好等于 hello,world 在 level 5 下的 raw deflate 长度(13 字节)。ct_len == pt_len,说明是流密码。后面跟数据描述符 UA\x07\x08 + crc(7a88d1fe) + comp(29=41) + uncomp(0b=11),CRC 是真实的(AE-1)。

怎么看都是标准 WinZip AES。那按标准来不就完了?

标注样本测试

在测试 dzip 之前,我用 7z 造了一个标准 WinZip AES 的 zip,拿已知密码去解,看看是否能解密:

1
2
printf 'Hello WinZip AES test vector! Secret message.' > secret.txt
7z a -tzip -mem=AES256 -pzyzy std.zip secret.txt

然后写个独立的小测试,把标准 PBKDF2 + AES-CTR + HMAC 跑一遍,看 verify/HMAC/keystream 是不是都对:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import hashlib, hmac, struct
from Crypto.Cipher import AES
from Crypto.Util import Counter

# 解析 std.zip 拿到 salt/verify/ct/auth (省略, 同前)
dk = hashlib.pbkdf2_hmac("sha1", b"zyzy", salt, 1000, 2*32 + 2)
enc_key, mac_key = dk[:32], dk[32:64]

# verify
assert dk[64:66] == verify
# HMAC 鉴权
assert hmac.new(mac_key, ct, hashlib.sha1).digest()[:10] == auth
# 解密
ctr = Counter.new(128, initial_value=1, little_endian=True)
pt = AES.new(enc_key, AES.MODE_CTR, counter=ctr).decrypt(ct)
assert pt == open("secret.txt","rb").read()

全过。说明代码没问题。

顺带在这里踩了个 CTR 的坑。cryptography 库的 modes.CTR 只支持大端计数器,而 WinZip AES 用的是小端计数器从 1 开始。我手动用 ECB + XOR 测了 big/little × start 0/1 四种组合,只有 little endian, start=1 能解出明文。所以后面改用 pycryptodome 的 Counter.new(128, initial_value=1, little_endian=True)。这点如果搞错,verify 和 HMAC 都会过(它们跟 counter 无关),但解出来全是乱码,非常迷惑人。

dzip 的 verify 对不上

把代码套到 dzip 上结果发现 verify 对不上:

1
2
3
4
5
>>> dk = hashlib.pbkdf2_hmac("sha1", b"123456789", salt, 1000, 66)
>>> dk[64:66].hex()
'03a7'
>>> verify.hex() # 存的是
'19ef'

密码明明是对的,结构也是标准的 WinZip AES,但密钥派生对不上。只能假设这编码器在 KDF 上动了手脚。

已知明文是个好东西

于是我手动造了一个已知明文的 test.dzip,里面仅有一个 test.txt,内容为 hello,world

由上文,压缩是标准的 zlib(我用未加密的样本验证过,等级 1/3/6/9 的 deflate 字节和 Python zlib 完全一致):

1
2
3
>>> import zlib
>>> zlib.compress(b'hello,world', 5)[2:-4].hex() # 去掉 2 字节 zlib 头和 4 字节 adler
'cb48cdc9c9d729cf2fca490100' # 13 字节, 和 ct 长度一致

由加密前的明文,keystream:

1
2
3
4
pt = bytes.fromhex("cb48cdc9c9d729cf2fca490100")
ct = bytes.fromhex("9630388dac879df34f69697856")
ks = bytes(a ^ b for a, b in zip(pt, ct))
# 5d78f5446550b43c60a3207956 (13 字节)

在一个大文件(deflate 后 5528 字节)上还能拿到完整的 16 字节 block 0:

1
2
Markdown  salt = c17210e177c78f80e981fe798af8c360
keystream[:16] = 67e4df77744c257d9f1a6a212e4a09c1

一通乱试

接下来,我就交给 AI 去暴力搜索了,AI 尝试了多种组合,但都失败了:

  • PBKDF2-HMAC-SHA1/SHA256/SHA512,salt 用 salt16 / 0x1a51 字段里的 32 字节 / 空 / 拼接,迭代次数从粗扫到细扫 1–20000,外加规整值和 0x00200017

  • scrypt、EVP_BytesToKey、HKDF、PBKDF1、SP800_108、bcrypt(cost 12–23)、迭代 SHA-256

  • Key-wrap:用 PBKDF2 派生 KEK,再 AES-ECB/CBC 解包那 32 字节(KEK 迭代 1–10000 细扫)

  • XOR 掩码密钥、原始密码直接当 key、32 字节直接当 key、全局密码 key

  • 算法:AES-CTR(大/小端、计数器 0–32、salt+N、12 字节 nonce)、OFB/CFB、ChaCha20、Salsa20、RC4、哈希流密码

  • 偏移 0/2/16(怀疑 verify 也被一起加密了)

  • 密码编码 UTF-8 / UTF-16-LE

    中间有个小插曲差点把 AI 带沟里。用 16 位 verify 当判据细扫迭代次数时,在 6241 处命中了:

1
2
3
4
5
>>> for it in range(1, 10001):
... dk = hashlib.pbkdf2_hmac("sha1", b"123456789", salt16, it, 66)
... if dk[64:66] == verify: # verify = 19ef
... print(it)
6241

我激动了一下——难道是 PBKDF2 迭代 6241 轮?结果 HMAC 和 keystream 匹配不上。6241 是纯纯的 16 位巧合。

0x1a51 字段成谜

还有个可疑的地方:每条加密条目的中央目录里都带一个自定义 0x1a51 extra 字段。在 test.dzip 的中央目录里:

1
2
3
4
5
01 99 07 00 01 00 41 45 03 08 00    <- 0x9901 AES 字段 (11 字节)
51 1a 24 00 <- hid 0x1a51, size 0x24=36
17 00 20 00 <- 固定前缀 (0x17=23, 0x20=32)
77 df 26 3f 49 12 33 56 d2 8a 4a 87 15 d2 5b f5 <- 32 字节, 每文件不同
b9 80 be ee b5 03 ca b4 6e a6 1a c9 f3 32 0e

17=23、20=32,这俩数字我试过当迭代次数、当 bcrypt cost、当 scrypt 的 log2(N),全不对;那 32 字节试过当 salt、当 key、当 IV、当包装密钥(AES-ECB/CBC 解包,KEK 迭代 1–10000 细扫),也全不对。它几乎肯定是关键,但我就是摸不透它的用法。

到这一步,纯靠样本分析已经走不动了。我需要看代码。

逆向

实在没招了,只能逆向那个 APP 了,好在 APP 的 native 库里就有这么一个 libDZIP.so,省去了找库的麻烦。

1
2
$ file decode/libDZIP.so
decode/libDZIP.so: ELF 64-bit LSB shared object, ARM aarch64, ... for Android 23, built by NDK r21e, stripped

符号表

先看看有没有有用的符号 nm -D

1
2
3
4
5
6
7
8
9
10
11
12
13
T mz_crypt_pbkdf2            # minizip 的 PBKDF2
T mz_zip_extrafield_write # extra 字段读写
T mz_zip_extrafield_find
T mz_zip_extrafield_read
T dzip_minizip_add # 压缩入口
T dzip_minizip_extract # 解压入口
T dzip_minizip_extract_password_cb
T Java_com_unzip_unzipmasterjar_DZIPUnzipZipApi_executeAdd # JNI 入口
T Java_com_unzip_unzipmasterjar_DZIPUnzipZipApi_executeExtract
T AES_wrap_key / AES_unwrap_key
T EVP_BytesToKey
T EVP_PBE_scrypt
...一堆 OpenSSL 符号

底层是 minizip(带 OpenSSL)。而且 JNI 入口和 dzip_minizip_add/extract 都没被去掉符号名,省了大事。

Java 桥接

将 APK 丢给 Jadex 反编译,找到 DZIPUnzipZipApi.java

1
2
3
4
5
6
7
8
9
10
11
12
13
public class DZIPUnzipZipApi {
public static native int executeAdd(String[] strArr, String str, String str2,
int i, long j, long j2,
ExtractCallback extractCallback, Context context);
public static native int executeExtract(String str, String str2, long j,
ExtractCallback extractCallback, Context context);
static { System.loadLibrary("DZIP"); }
}

public interface DZIPExtractCallback {
void callBackForExtractProgress(long j, long j2);
String callBackForGetPassword(); // 密码是从 Java 回调拿的
}

密码是原生层回调 Java 拿到的。关键逻辑肯定在 dzip_minizip_add(创建/加密)和 dzip_minizip_extract(解密)里。

反编译

先反编译 mz_crypt_pbkdf2,确认它就是个手写的 PBKDF2-HMAC-SHA1:

mz_crypt_pbkdf2 的反编译

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
__int64 mz_crypt_pbkdf2(__int64 password, unsigned int pw_len,
__int64 salt, unsigned int salt_len,
int iter_count, void *out, int out_len)
{
// 三个 HMAC 上下文, 都设成 SHA-1 (digest=20)
mz_crypt_hmac_create(&v28);
mz_crypt_hmac_create(&v27);
mz_crypt_hmac_create(&v26);
mz_crypt_hmac_set_algorithm(v28, 20); // <- 20 = SHA-1
mz_crypt_hmac_set_algorithm(v27, 20);
mz_crypt_hmac_set_algorithm(v26, 20);

mz_crypt_hmac_init(v28, password, pw_len); // 内层: HMAC(pw, ...)
mz_crypt_hmac_init(v27, password, pw_len);
mz_crypt_hmac_update(v27, salt, salt_len); // U1 = HMAC(pw, salt || INT(1))

// 输出块数 = ceil(out_len / 20), 用 0x66666667 那套除以 20 的魔数算的
n_blocks = (out_len - 1) / 20 + 1;

for (block = 0; block < n_blocks; block++) {
mz_crypt_hmac_copy(v27, v26);
v31 = INT32_big(block + 1); // 大端块序号
// 迭代 iter_count 次: U_{i+1} = HMAC(pw, U_i)
for (i = 0; i < iter_count; i++) {
mz_crypt_hmac_update(v26, v31, 4);
mz_crypt_hmac_end(v26, v31, 20);
for (j = 0; j < 20; j++) v29[j] ^= v31[j]; // 累加异或
mz_crypt_hmac_copy(v28, v26);
}
memcpy(out + block*20, v29, min(20, out_len - block*20));
}
return 0;
}

满眼的 20mz_crypt_hmac_set_algorithm(ctx, 20),迭代次数是调用方传进来的参数 iter_count。和标准 minizip 一致,这里没动手脚。

那手脚在哪儿?接着看 dzip_minizip_add

dzip_minizip_add 的反编译

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
uint64_t dzip_minizip_add(int64_t archive, int64_t password, int64_t opts,
int64_t n_files, int64_t files, int64_t ctx)
{
// ...
func_0x0009daa0(&uStack_90, 0x10); // 生成 16 字节随机 (后面写进 footer)
func_0x0009daa0(&uStack_80, 0x10);
// ...
uStack_b0 = CONCAT44(uStack_b0._4_4_, 1); // 某个结构体字段 = 1
iVar3 = func_0x00096150(); // 取个值 (时间/随机)
uStack_b0 = CONCAT44(100, uStack_b0); // 另一字段 = 100
uStack_c0._4_4_ = iVar3 % 0xf1 + 1; // **追加字节 = (X % 241) + 1
func_0x0009c9c0(&uStack_98); // 初始化 zip 句柄

if (password == 0) {
iVar5 = 0;
} else {
iVar5 = strlen(password);
iVar5 = malloc(iVar5 + 2); // 多分配 2 字节
iVar6 = strlen(password);
memset(iVar5, 0, iVar6 + 2);
memcpy(iVar5, password, strlen(password)); // 拷贝用户密码
iVar6 = strlen(password);
*(iVar5 + iVar6) = uStack_c0._4_4_; // **末尾追加那个字节!
mz_zip_set_password(uStack_98, iVar5); // 用 "密码+字节" 当 zip 密码
}
// ... 设置压缩等级、回调, 循环添加文件 ...
func_0x0009d730(archive, &uStack_90, &uStack_c0);// mz_zip_writer_protect_aes, 写 footer
return 0;
}

破案了:

1
2
3
uStack_c0._4_4_ = iVar3 % 0xf1 + 1;            // 字节 = (X % 241) + 1, 范围 [1, 241]
*(iVar5 + strlen(password)) = uStack_c0._4_4_; // 追加到密码末尾
mz_zip_set_password(handle, iVar5); // 之后走标准 minizip WinZip AES

这编码器在派生 AES 密钥之前,往用户密码末尾追加了一个随机字节(值在 1–241 之间,每个压缩包一个,同包内所有条目共享)。之后才走标准 minizip 的 WinZip AES 流程:PBKDF2-HMAC-SHA1、1000 轮、salt16、AES-CTR 小端计数器从 1 开始。

这就完美解释了之前所有的失败——我一直拿原始密码 123456789 去 PBKDF2,而实际用的是 123456789 + 某个字节。那个 0x1a51 字段和 64 字节 footer 大概是存这个字节和相关校验信息的,但我根本不需要去搞清楚它存在哪儿——直接爆破这个字节就行了,反正只有 1–255 这么点范围。

0x1a51 字段里的 17 00 20 00 可能是对密码/字节做的某种校验签名。但目前的爆破也够用了,留个坑给有兴趣的人。

爆破找回追加字节

思路很简单:对每个字节 b in 1..255,用 password + bytes([b]) 派生密钥,先过 16 位 verify 初筛,再用 80 位 HMAC 终判。命中即用。因为字节是每包一个,找到后缓存起来,同包后续条目直接复用,不用每次都爆破。

完整脚本:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import struct, zlib, hashlib, hmac, binascii
from Crypto.Cipher import AES
from Crypto.Util import Counter

def find_byte_and_decrypt(stored, base_pw):
"""stored = salt(16)+verify(2)+ct+auth(10). 返回明文 deflate 流。"""
salt, verify = stored[:16], stored[16:18]
auth, ct = stored[-10:], stored[18:-10]
for b in range(1, 256):
pw = base_pw + bytes([b])
dk = hashlib.pbkdf2_hmac("sha1", pw, salt, 1000, 2*32 + 2)
if dk[64:66] != verify: # 16 位初筛
continue
if hmac.new(dk[32:64], ct, hashlib.sha1).digest()[:10] != auth: # 80 位终判
continue
ctr = Counter.new(128, initial_value=1, little_endian=True)
return AES.new(dk[:32], AES.MODE_CTR, counter=ctr).decrypt(ct)
raise ValueError("byte not in 1..255")

test.txt 一试:

1
2
3
byte=110 (0x6e) verify=OK mac=True ks=True
password = b'123456789' + b'n' -> b'123456789n'
decrypted: b'hello,world' crc ok

123456789 + 0x6e(也就是字符 'n')。完美命中,HMAC 和 CRC 都过。

完整实现

最终实现在 undzip.py (依赖 pycryptodome),在此给出部分代码,完整代码请前往 @V-Conet/undzip

AES 解密函数,把“候选密码列表”作为参数,第一个能过 verify+HMAC 的就用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
def decrypt_aes(stored, candidate_passwords, aes, name):
"""stored = salt || verify(2) || ciphertext || auth(10)。
依次试候选密码, 第一个 verify(16位)+HMAC(80位) 都过的用于解密。
返回 (明文压缩流, 命中的密码)。dzip 会往密码末尾追加 1 字节,
所以调用方应传入 [原始密码, 原始密码+byte 1..255]。"""
import hashlib
key_size, salt_size, _actual, _vver = aes
salt = stored[:salt_size]
pw_verify = stored[salt_size:salt_size + AES_PW_VERIFY] # 2
auth_code = stored[-AES_AUTH_LEN:] # 10
ciphertext = stored[salt_size + AES_PW_VERIFY:-AES_AUTH_LEN]
dklen = 2 * key_size + AES_PW_VERIFY

AES, Counter = _load_crypto()
for pw in candidate_passwords:
dk = hashlib.pbkdf2_hmac("sha1", pw, salt, AES_ITERATIONS, dklen) # 1000 轮
# 16 位 verify 初筛
if not _hmac.compare_digest(
dk[2*key_size:2*key_size + AES_PW_VERIFY], pw_verify):
continue
# 80 位 HMAC 终判 (算的是密文, 截前 10 字节)
mac = _hmac.new(dk[key_size:2*key_size], ciphertext, hashlib.sha1).digest()
if not _hmac.compare_digest(mac[:AES_AUTH_LEN], auth_code):
continue
# AES-CTR, 小端计数器从 1 开始
ctr = Counter.new(128, initial_value=1, little_endian=True)
plain = AES.new(dk[:key_size], AES.MODE_CTR, counter=ctr).decrypt(ciphertext)
return plain, pw
raise DzipError(f"{name}: AES password verification failed "
"(wrong password, or appended byte not in 1..255)")

调用方构造候选密码时,把缓存的命中密码放最前,其次是原始密码,再是 原始密码 + bytes([b])

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
def extract(data, entries, out_dir, password=None):
os.makedirs(out_dir, exist_ok=True)
deferred_dir_modes = []
aes_pw_cache = None # 每包命中的 "密码+字节", 复用
pw_bytes = password.encode("utf-8") if password else None

for e in entries:
target = safe_join(out_dir, e.name) # 路径穿越防护
if e.is_dir:
os.makedirs(target, exist_ok=True)
if e.host_unix:
deferred_dir_modes.append((target, e.unix_mode & 0o7777))
continue

os.makedirs(os.path.dirname(target), exist_ok=True)
raw = read_local_data(data, e)

if e.is_aes:
if pw_bytes is None:
raise DzipError(f"{e.name}: AES-encrypted; supply -p")
# 候选: 缓存命中 -> 原始密码 -> 原始密码+字节 1..255
cands = []
if aes_pw_cache is not None:
cands.append(aes_pw_cache)
cands.append(pw_bytes)
cands.extend(pw_bytes + bytes((b,)) for b in range(1, 256))
raw, used = decrypt_aes(raw, cands, e.aes, e.name)
aes_pw_cache = used # 缓存, 同包后续条目直接命中
method = e.effective_method # AES 条目的真实方法在 0x9901 里
elif e.is_encrypted:
raise DzipError(f"{e.name}: traditional ZipCrypto not supported")
else:
method = e.method

payload = decompress(raw, method, e.name)

if len(payload) != e.uncomp_size:
raise DzipError(f"{e.name}: size mismatch")
# AE-2 会把 CRC 置零; 此时 HMAC 已鉴权, 跳过 CRC
if not (e.is_aes and e.aes[3] == 2 and e.crc == 0):
actual_crc = binascii.crc32(payload) & 0xFFFFFFFF
if actual_crc != e.crc:
raise DzipError(f"{e.name}: CRC mismatch")
with open(target, "wb") as fh:
fh.write(payload)
if e.host_unix:
os.chmod(target, e.unix_mode & 0o7777)

# 目录权限最后设, 免得 restrictive mode 卡住中间写入
for target, mode in deferred_dir_modes:
os.chmod(target, mode)

CLI:

1
2
3
4
python3 undzip.py archive.dzip                    # 解压(无密码)
python3 undzip.py -p 123456789 archive.dzip # 解压(带密码)
python3 undzip.py -p zyzy -o outdir archive.dzip # 指定输出目录
python3 undzip.py -l archive.dzip # 只列出内容

功能上:支持 Stored/Deflate、数据描述符、目录与 Unix 权限还原、非 ASCII 文件名、CRC 校验、HMAC 鉴权、路径穿越防护;加密支持 WinZip AES-128/192/256(含这种追加字节的变体),不支持 ZipCrypto。

Closing

这次在 AI 的帮助下,整个逆向顺利完成了,不得不感叹现在 AI 的能力。
另外,做封面真的好麻烦,交给 ChatGPT Image2 了,狗不狗*,好不好看另说