起因 手里有 .dzip 文件需要解压。unzip、7z、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 字节签名开头:
本地头的字段布局(签名之后 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 ..............te00000020 : 7374 2e74 7874 2b 49 ad28 0100 5541 0708 st.txt+I.(..UA..00000030 : c7a7 8b 3b 0600 0000 0400 0000 5541 0102 ...;........UA..00000040 : 3303 1400 0e08 0800 0000 0000 c7a7 8b 3b 3. .............;... 00000090 : 7 d1a 851f ead6 7 c32 c9d1 5541 0506 0000 }.....|2. .UA....000000 a0: 0000 0100 0100 5e00 0000 3 c00 0000 0000 ......^...<.....000000b 0: 7e5 b 44f 4 42f e 80 aa 6973 ea9d 2141 b0e1 ~[D.B...is..!A..000000 c0: 45 ed 3e7 b a68f 0b ba 3e20 a758 3 a8d c5b9 E.>{....> .X:...000000 d0: 79f f 595b 81 c8 f943 856b 60 d0 97f 4 4 ae7 y.Y[...C.k`...J.
第一行开头 55 41 03 04,也就是 UA\x03\x04。这不就是前面提到的 ZIP 本地文件头签名 PK\x03\x04(50 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\x0414 00 版本 20 (2.0 )0 e 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 2 e 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'
到这里不难看出,dzip 就是 ZIP,只是把签名 PK 改成了 UA。
不过有两个小坑值得记一下,省得后面栽跟头。
坑一:尾部多了一段 64 字节 EOCD 的 comment_len 明明是 0,但文件末尾还拖了 64 字节看着像随机数据的尾巴(上面 0xb0 之后那一段)。一开始我写 EOCD 查找的时候死板地要求 EOCD偏移 + 22 + comment_len == 文件大小,结果 “not a dzip”。
这 64 字节后来证明是加密层追加的,暂时不用考虑。
中央目录和本地头里的 extra 字段长度可能不一样。算载荷起始偏移时必须用本地头自己的 extra_len,不然偏移就错了。这种细节标准 ZIP 解压器都处理了,自己手写 parser 时容易漏。
写一个最小的解析器 既然格式清楚了,先写个能解非加密 dzip 的 parser。核心是把四种记录的结构体定义出来:
1 2 3 4 5 6 7 8 9 10 import struct, zlib, binascii, osSIG_LOCAL = b"UA\x03\x04" SIG_CENTRAL = b"UA\x01\x02" SIG_EOCD = b"UA\x05\x06" LFH = struct.Struct("<HHHHHIIIHH" ) CDFH = struct.Struct("<HHHHHHIIIHHHHHII" ) EOCD = struct.Struct("<HHHHIIH" )
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 ] 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 if method == 8 : return zlib.decompress(raw, -zlib.MAX_WBITS) 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 ..............te00000020 : 7374 2e74 7874 0199 0700 0100 4145 0308 st.txt......AE..00000030 : 008f 7330 97 a0 b4c0 7 a21 5788 22 c1 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 2 e 74 78 74 "test.txt"
紧跟的 11 字节 extra 就是 0x9901 AES 字段,原始字节为 01 99 07 00 01 00 41 45 03 08 00,按下表拆解:
数据从偏移 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 7 a 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 56auth (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.txt7z 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, structfrom Crypto.Cipher import AESfrom Crypto.Util import Counterdk = hashlib.pbkdf2_hmac("sha1" , b"zyzy" , salt, 1000 , 2 *32 + 2 ) enc_key, mac_key = dk[:32 ], dk[32 :64 ] assert dk[64 :66 ] == verifyassert hmac.new(mac_key, ct, hashlib.sha1).digest()[:10 ] == authctr = 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 () 'cb48cdc9c9d729cf2fca490100'
由加密前的明文,keystream:
1 2 3 4 pt = bytes .fromhex("cb48cdc9c9d729cf2fca490100" ) ct = bytes .fromhex("9630388dac879df34f69697856" ) ks = bytes (a ^ b for a, b in zip (pt, ct))
在一个大文件(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: ... 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 拿到的。关键逻辑肯定在 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) { mz_crypt_hmac_create(&v28); mz_crypt_hmac_create(&v27); mz_crypt_hmac_create(&v26); mz_crypt_hmac_set_algorithm(v28, 20 ); mz_crypt_hmac_set_algorithm(v27, 20 ); mz_crypt_hmac_set_algorithm(v26, 20 ); mz_crypt_hmac_init(v28, password, pw_len); mz_crypt_hmac_init(v27, password, pw_len); mz_crypt_hmac_update(v27, salt, salt_len); 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 ); 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 ; }
满眼的 20、mz_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 ); func_0x0009daa0(&uStack_80, 0x10 ); uStack_b0 = CONCAT44(uStack_b0._4_4_, 1 ); iVar3 = func_0x00096150(); uStack_b0 = CONCAT44(100 , uStack_b0); uStack_c0._4_4_ = iVar3 % 0xf1 + 1 ; func_0x0009c9c0(&uStack_98); if (password == 0 ) { iVar5 = 0 ; } else { iVar5 = strlen (password); iVar5 = malloc (iVar5 + 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); } func_0x0009d730(archive, &uStack_90, &uStack_c0); return 0 ; }
破案了:
1 2 3 uStack_c0._4_4_ = iVar3 % 0xf1 + 1 ; *(iVar5 + strlen (password)) = uStack_c0._4_4_; mz_zip_set_password(handle, iVar5);
这编码器在派生 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, binasciifrom Crypto.Cipher import AESfrom Crypto.Util import Counterdef 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: continue if hmac.new(dk[32 :64 ], ct, hashlib.sha1).digest()[:10 ] != auth: 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] auth_code = stored[-AES_AUTH_LEN:] 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) if not _hmac.compare_digest( dk[2 *key_size:2 *key_size + AES_PW_VERIFY], pw_verify): continue 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 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" ) 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 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" ) 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 ) 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 了,狗不狗*,好不好看另说