当你开始用程序读微信消息时,文字通常比较容易处理。读到一张图片,事情就多了一步:微信里明明能显示它,把本地缓存拿出来,识图程序却打不开。
我们这次验证的 macOS 微信 4.1.9 V2 缓存,在图片字节外面还做了一层加密。要让程序使用这张图片,就得先撤掉这层处理;如果电脑上连缓存都没有,还要根据消息里的资源描述去下载。
下面拿一张自制测试图,把这件事从头走一遍。先复现已验证的缓存处理规则,看看图片字节怎样被分段、加密、组装;再拿同一份缓存,按相反的方向恢复。把这条路走通之后,再看怎样选择并下载消息中的高清资源。
正向部分是按已验证的格式做的合成复现,并非微信完整写入过程的录像。演示图、账号、UIN、密钥和摘要都属于合成实验;真实下载的两份样本会分别说明能证明什么。
配套视频共9分04秒。章节:00:00 从消息里的图片讲起;03:37 反向字节还原;06:11 本地没有文件时下载资源。
文末附可运行的合成实验,使用自制图片和合成账号,不读取微信。
图片、编码、加密分别是什么
屏幕上看见的是像素。PNG、JPEG 等文件,保存的是按某种格式编码后的字节。编码或压缩决定这些字节如何表达画面;加密决定在没有对应密钥时,如何阻止读取者直接还原这些字节。
所以,一份图片可以先编码,再被加密保存。把文件扩展名从 .dat 改成 .jpg,不会改变内部字节,自然不能替代解密。
我们还遇到以 wxgf 开头的载荷。在当前支持的静态样本里,它包含 HEVC 图像码流,需要再解码为 PNG。解密后拿到文件字节,解码后才得到可以显示的画面。
这次研究涉及两套不同的参数:
| 参数 | 从哪里来 | 用在哪里 |
|---|---|---|
| 本地缓存密钥 | 已验证账号路径中的 UIN 候选与账号名称派生 | 还原本地 V2 DAT |
| 下载资源参数 | 对应消息 XML 的资源标识、AES 参数、长度等 | 微信原生下载链路 |
有本地缓存,我们先把它还原;没有文件,就根据消息去下载。两条路径最后得到所选资源的文件字节,核对后再按需要解码。
正向看:一份 PNG 怎样变成 DAT
我们生成了一份 320 × 240 的测试图。PNG 文件有 9,086 字节。下面所有计算都对这份文件实际执行过;反向还原的输出与输入逐字节相等。
1. 派生本地缓存密钥
合成实验采用:
UIN = 123456
account_name = demo_account
按已验证的派生规则,UIN 先变成十进制文本,放在前面;账号名接在后面,中间不加分隔符:
"123456" + "demo_account"
↓ MD5,输出小写十六进制文本
7f104a1508c17d1b446fca4b9b336c72
↓ 取前16个字符的ASCII字节
7f104a1508c17d1b
有了这串字符,才轮到 AES。我们把它们的 16 个 ASCII 字节作为 AES-128 的密钥输入。一个容易写错的地方是:它不是 MD5 的原始 16 字节摘要。
这个例子里,摘要的原始首字节是 0x7f;密钥首字符却是字符 7,其 ASCII 字节为 0x37。两种取法得到的密钥完全不同。16 字节是 AES 输入长度,也不等于这套派生方式提供了 128 位随机熵。
这里的账号名必须与缓存所属账号对应,不能直接把整个带附加后缀的目录名当成账号名。读取本地统计文件名称能够获得候选 UIN,但候选仍需要用目标文件验证。
尾部使用的 XOR 值则是:
xor_key = UIN & 0xff
= 123456 & 255
= 64
= 0x40
2. 把载荷分成前段和尾部
密钥准备好了,再看它处理文件的哪些位置。这份合成复现取前 1,024 字节作为前段,其余为尾部:
PNG载荷:9,086 B
├── 前段:1,024 B
└── 尾部:8,062 B
这用于复现当前已验证样本中的常见布局,不是说每个 DAT 的前段都可以固定写成 1,024。反向解析时,边界必须从文件长度和头部字段求得。
3. 前段填充,再逐块 AES 加密
AES 的块大小为 16 字节。这里采用的 ECB 模式,对每个块使用同一把密钥独立处理。模式定义可以对照 NIST SP 800-38A。
前段先按 PKCS#7 规则补齐。即使输入长度正好是 16 的倍数,也会补一个完整的块。这个例子中:
1,024 B图片前段
+ 16个0x10填充字节
= 1,040 B待加密数据
= 65个16 B块
填充规则详见 RFC 5652 第6.3节。不是所有填充都长 16 字节;这里之所以补 16,是因为这份前段恰好已经对齐。
使用前面派生的密钥,演示文件的第一个块实际发生了这样的变化:
明文:89 50 4e 47 0d 0a 1a 0a 00 00 00 0d 49 48 44 52
密文:4f 0b 81 51 39 76 da 89 f2 b4 0b d7 93 b1 dc de
输入以 PNG 文件签名开头;加密后的块不再直接露出这个签名。其他块按相同方式分别处理。这里不需要给 ECB 额外构造 IV。
4. 尾部逐字节异或
尾部的每个字节都与 0x40 异或。例如,这份文件尾部的第一个字节实际为 0xf0:
0xf0 = 11110000
0x40 = 01000000
XOR = 10110000 = 0xb0
XOR 把同一个位置的两位比较:相同得 0,不同得 1。这种单字节异或很容易逆转,不能视为与 AES 相同强度的保护。
5. 加入文件头,组成 V2 DAT
把 15 字节文件头、AES 密文区和 XOR 尾部依次连接:
15 B头部 + 1,040 B AES密文 + 8,062 B XOR尾部
= 9,117 B DAT
这份合成文件的头部是:
07 08 56 32 08 07 00 04 00 00 7e 1f 00 00 01
| 偏移,含首尾 | 在本例中的内容 | 解释 |
|---|---|---|
| 0–5 | 07 08 56 32 08 07 | 样本格式签名,56 32为ASCII的V2 |
| 6–9 | 00 04 00 00 | 已观察常见值0x400;不把它当成AES密文长度 |
| 10–13 | 7e 1f 00 00 | 小端尾长,数值为8,062 |
| 14 | 01 | 样本头部中的字节,不进入图片载荷 |
前 6 字节签名用于说明当前样本格式;实际解码器还有长度、分段、载荷等检查,不能仅凭一个签名就认为任意输入都有效。
反向看:我们怎样把 DAT 还原
反向过程不是“对整个文件跑一次 AES”。文件头、AES 区、XOR 区的处理方式不同,顺序也不能错。
1. 从布局计算边界
先识别 V2 格式,再从偏移 10–13 的四个字节按小端序读取 xor_size。然后计算:
aes_size = file_size - 15 - xor_size
= 9,117 - 15 - 8,062
= 1,040
这个例子的实际范围是:
[0, 15) 文件头
[15, 1055) AES密文,共1,040 B
[1055, 9117) XOR尾部,共8,062 B
右端不包含在区间内。实现还会拒绝尾长越界、空 AES 区或不是 16 字节倍数的 AES 区。这比把头部的 0x400 直接当成密文长度可靠,因为填充也属于密文。
2. 派生并确认目标密钥
按照相同的 UIN 与账号名规则派生候选密钥。先解密第一个块,检查结果是否像 PNG、JPEG 或其他受支持载荷的开头,用来减少候选范围。
但“像图片头”还不是最终答案。我们的目标选钥实现继续还原完整文件,只有输出的完整 MD5 与目标消息对应资源的摘要一致,才接受这把候选密钥。
因此,不能拿一个无关缩略图的成功结果,就认为它选出的密钥必然适用于当前目标。
3. AES 解密后,先检查并移除填充
逐块解密得到 1,040 字节。后面的 16 个 0x10 还在,它们不是图片内容。
读取最后一个字节的数值 n,要求它在 1 到 16 之间,并且末尾连续 n 个字节全部等于 n,再删除这段填充。本例恢复为 1,024 字节前段。
如果直接把 1,040 字节全部与尾部拼接,填充会夹在图片中间。开头可能仍是合法图片签名,解码器也可能容忍一部分损坏,但完整文件已经不对了。
4. 用同一个 XOR 值还原尾部
相同的数异或两次会相互抵消:
A XOR K XOR K = A
0xb0 XOR 0x40 = 0xf0
把还原后的尾部接到已经去掉填充的前段后面。文件头留在外面,不拼进去。最终重新得到 9,086 字节的 PNG。
5. 用完整字节证明复现正确
合成实验的输入和输出可以直接逐字节比较:
输入MD5:6d44c8898709f06cada73859dbc53dc6
输出MD5:6d44c8898709f06cada73859dbc53dc6
字节比较:完全一致
为了说明填充为什么不能忽略,我们还保留了一个错误拼接的结果:它的 MD5 为 9b932acd943687fd30514b62f2d64ac7,与输入不同。
真实微信缓存处理时,我们把还原载荷与同一消息、同一资源版本的 XML 摘要核对。摘要匹配用于确认已有描述下的内容一致性,不代表它提供了数字签名或来源认证;RFC 6151 说明了 MD5 的安全限制。
高清原图怎样下载
前面的逆过程解决的是“本地已经有 DAT”。如果对应图片尚未保存在本地,就要先取得消息描述的资源。此时使用的是消息中的资源参数,不是前面派生的 DAT 缓存密钥。
1. 从同一消息选出同一份资源
一条图片消息可能同时描述缩略图、完整普通图和独立高清资源。我们的已验证原生入口区分:
| 资源选择 | 资源标识字段 | 字节长度字段 | 其他绑定 |
|---|---|---|---|
独立高清 original_hd | cdnbigimgurl | hdlength | 同资源的aeskey、md5 |
完整普通图 ordinary_full | cdnmidimgurl | length | 同资源的aeskey、md5 |
这里的字段名并不保证它能直接作为浏览器 GET 地址使用。它是客户端资源描述的一部分,需要走对应的原生下载语义。
没有独立高清描述时,完整普通图片也可以是用户实际发送的完整资源;它不等于聊天列表缩略图。但工具不能凭空承诺还存在一份更高分辨率的相机原文件。
2. 按微信的构造和选择逻辑创建请求
我们追踪微信自身的字段读取、对象构造和选择器,确认哪些值会进入实际下载对象。然后使用对应原生工厂创建对象,填入来自同一资源的标识、AES 参数和长度。
业务上的图片版本、底层任务选择、格式选项分别处理。不能只改一个类型数字,就认为资源标识和密钥也自动换成了高清那一组。XML 中的完整 MD5 在这条已验证路径中保留给输出校验。
这是 REA 和 Hopper 在此次研究中的作用:帮助确认实际调用和字段来源,再用原始字节、运行记录与最终文件核对。某个字段名字看起来像“摘要”,不等于它就是目标 XML 的 MD5。
3. 由原生链路下载并处理资源
请求交给微信自身的下载链路,由它处理网络资源和对应的资源解密,再向指定位置写出原始图片载荷。我们没有独立重写全部网络加密协议,也没有把本地 AES-ECB 缓存布局推广到网络报文。
原生调用提交任务后,客户端继续运行,异步接收和文件写入才会推进。提交返回成功,与完整文件落盘,是不同的时刻。 工具要等待原任务的输出,再检查长度和完整摘要,才能把结果交出去。
4. 载荷校验后,再决定怎样显示
如果原生输出已是 PNG 或 JPEG,就按对应格式读取。如果拿到的是 WXGF,我们当前支持的静态路径会检查并定位 HEVC Annex-B 码流,再用解码器生成 PNG。
两个文件分别保存:
原始载荷 ── 与XML完整MD5比较 ── 保存
│
└── 必要的格式解码 ── PNG ── 查看
转换后的 PNG 与原始 WXGF 的字节不同,不能要求 PNG 也匹配原载荷的 MD5。
我们有两类独立实证:一份高清参数对照生成了 204,763 B、1300 × 1030 的 PNG,完整 XML MD5 一致;但它已存在缓存,因此只算参数验证。另一次未命中已检查缓存的新普通样本,自动得到 95,942 B 的 WXGF,完整摘要一致,再导出为 832 × 1280 的 PNG。这两次实验支持不同结论,不能合并成一次高清冷下载证明。
两条入口,汇合到同一份载荷
已有V2 DAT
→ 识别布局 → 派生并验证密钥 → AES解密/去填充 + XOR还原
→ 原始图片载荷
本地没有图片
→ 精确消息资源 → 对应下载参数 → 微信原生异步下载
→ 原始图片载荷
原始图片载荷
→ 完整文件校验与保存
→ 必要的图片格式解码
→ 给人查看或给后续工具使用
把每一层的输入、处理和输出对应起来,才知道自己还原的是缓存、取得的是哪个资源版本,以及导出的 PNG 与原始文件有什么关系。当前实现与验证范围仍限定在所述版本和已支持的普通资源路径,其他业务类型和构建需要另行确认。
自己跑一遍
把下面的 Python 代码保存成 reproduce.py,再把合成测试图下载到同一目录。需要 Python 3 和 OpenSSL。整个实验只读写当前目录中的测试文件,不访问微信。
curl -fL -H 'Accept: image/png' \
'https://img.leeguoo.com/media/a7c3f889-c71b-4b8b-8215-8db163cb8164/wechat-v2-synthetic-source.png' \
-o source.png
python3 reproduce.py
固定下载 PNG 原始字节,是为了让长度、摘要和文中的演算一致。脚本会先核对测试图,再生成 synthetic-v2.dat 和 recovered.png,最后断言恢复结果与输入逐字节相同。
"""Synthetic demonstration of the observed local DAT layout. No WeChat access."""
from pathlib import Path
import hashlib,json,struct,subprocess
p=Path(__file__).parent
original=(p/'source.png').read_bytes()
assert hashlib.md5(original).hexdigest() == '6d44c8898709f06cada73859dbc53dc6', '测试图字节不匹配'
uin=123456;account='demo_account';joined=f'{uin}{account}'
h=hashlib.md5(joined.encode()).hexdigest();key=h[:16].encode();xor=uin&255
prefix,tail=original[:1024],original[1024:]
assert len(tail)>0
pad=16-len(prefix)%16
padded=prefix+bytes([pad])*pad
def aes(data,decrypt=False):
argv=['openssl','enc','-aes-128-ecb','-nosalt','-nopad','-K',key.hex()]
if decrypt:argv.append('-d')
return subprocess.run(argv,input=data,capture_output=True,check=True).stdout
cipher=aes(padded);xtail=bytes(x^xor for x in tail)
header=b'\x07\x08V2\x08\x07'+struct.pack('<II',1024,len(tail))+b'\x01'
dat=header+cipher+xtail;(p/'synthetic-v2.dat').write_bytes(dat)
# Decode from observed fields rather than assuming the cipher length.
xor_size=int.from_bytes(dat[10:14],'little');aes_size=len(dat)-15-xor_size
assert aes_size>0 and aes_size%16==0
plain=aes(dat[15:15+aes_size],True);n=plain[-1]
assert 1<=n<=16 and plain[-n:]==bytes([n])*n
recovered=plain[:-n]+bytes(x^xor for x in dat[15+aes_size:]);(p/'recovered.png').write_bytes(recovered)
assert recovered==original
md5=lambda b:hashlib.md5(b).hexdigest()
info={'schema':'synthetic-wechat-v2-reconstruction/v1','synthetic':True,'account':account,'uin':uin,'joined':joined,'md5Text':h,'aesKeyAscii':key.decode(),'aesKeyHex':key.hex(),'xorKey':xor,'plainBytes':len(original),'prefixBytes':len(prefix),'padCount':pad,'cipherBytes':len(cipher),'tailBytes':len(tail),'headerBytes':15,'datBytes':len(dat),'headerHex':header.hex(' '),'plainBlockHex':prefix[:16].hex(' '),'cipherBlockHex':cipher[:16].hex(' '),'xorExample':{'plain':tail[0],'key':xor,'cipher':xtail[0],'restored':xtail[0]^xor},'originalMd5':md5(original),'recoveredMd5':md5(recovered),'byteForByteEqual':True,'badJoinMd5':md5(plain+tail),'pngDimensions':[320,240],'sourceBasis':'Self-generated ffmpeg testsrc2 PNG; observed layout reproduced with OpenSSL, not a trace of WeChat writer.'}
(p/'demo.json').write_text(json.dumps(info,ensure_ascii=False,indent=2)+'\n')
print(json.dumps(info,ensure_ascii=False))

微信
支付宝
评论
评论发布后会立即公开,如触发规则可能被审核下架。