# 微信图片如何加密，我们怎样一步步还原

当你开始用程序读微信消息时，文字通常比较容易处理。读到一张图片，事情就多了一步：微信里明明能显示它，把本地缓存拿出来，识图程序却打不开。

我们这次验证的 macOS 微信 4.1.9 V2 缓存，在图片字节外面还做了一层加密。要让程序使用这张图片，就得先撤掉这层处理；如果电脑上连缓存都没有，还要根据消息里的资源描述去下载。

下面拿一张自制测试图，把这件事从头走一遍。先复现已验证的缓存处理规则，看看图片字节怎样被分段、加密、组装；再拿同一份缓存，按相反的方向恢复。把这条路走通之后，再看怎样选择并下载消息中的高清资源。

正向部分是按已验证的格式做的合成复现，并非微信完整写入过程的录像。演示图、账号、UIN、密钥和摘要都属于合成实验；真实下载的两份样本会分别说明能证明什么。

配套视频共9分04秒。章节：00:00 从消息里的图片讲起；03:37 反向字节还原；06:11 本地没有文件时下载资源。

::video{src=https://img.leeguoo.com/media/83a28d3b-b510-4cdf-978c-c42587521695/wechat-image-principles-lesson.mp4 poster=https://img.leeguoo.com/media/41aef7ec-bd12-4464-a1a0-aceb755e0e27/wechat-image-guided-cover.png title="微信图片如何加密，我们怎样一步步还原"}

文末附可运行的合成实验，使用自制图片和合成账号，不读取微信。

## 图片、编码、加密分别是什么

屏幕上看见的是像素。PNG、JPEG 等文件，保存的是按某种格式编码后的字节。编码或压缩决定这些字节如何表达画面；加密决定在没有对应密钥时，如何阻止读取者直接还原这些字节。

所以，一份图片可以先编码，再被加密保存。把文件扩展名从 `.dat` 改成 `.jpg`，不会改变内部字节，自然不能替代解密。

我们还遇到以 `wxgf` 开头的载荷。在当前支持的静态样本里，它包含 HEVC 图像码流，需要再解码为 PNG。**解密后拿到文件字节，解码后才得到可以显示的画面。**

这次研究涉及两套不同的参数：

| 参数 | 从哪里来 | 用在哪里 |
| --- | --- | --- |
| 本地缓存密钥 | 已验证账号路径中的 UIN 候选与账号名称派生 | 还原本地 V2 DAT |
| 下载资源参数 | 对应消息 XML 的资源标识、AES 参数、长度等 | 微信原生下载链路 |

有本地缓存，我们先把它还原；没有文件，就根据消息去下载。两条路径最后得到所选资源的文件字节，核对后再按需要解码。

## 正向看：一份 PNG 怎样变成 DAT

我们生成了一份 320 × 240 的测试图。PNG 文件有 **9,086 字节**。下面所有计算都对这份文件实际执行过；反向还原的输出与输入逐字节相等。

### 1. 派生本地缓存密钥

合成实验采用：

```text
UIN = 123456
account_name = demo_account
```

按已验证的派生规则，UIN 先变成十进制文本，放在前面；账号名接在后面，中间不加分隔符：

```text
"123456" + "demo_account"
    ↓ MD5，输出小写十六进制文本
7f104a1508c17d1b446fca4b9b336c72
    ↓ 取前16个字符的ASCII字节
7f104a1508c17d1b
```

有了这串字符，才轮到 AES。我们把它们的 16 个 ASCII 字节作为 AES-128 的密钥输入。一个容易写错的地方是：**它不是 MD5 的原始 16 字节摘要。**

这个例子里，摘要的原始首字节是 `0x7f`；密钥首字符却是字符 `7`，其 ASCII 字节为 `0x37`。两种取法得到的密钥完全不同。16 字节是 AES 输入长度，也不等于这套派生方式提供了 128 位随机熵。

这里的账号名必须与缓存所属账号对应，不能直接把整个带附加后缀的目录名当成账号名。读取本地统计文件名称能够获得候选 UIN，但候选仍需要用目标文件验证。

尾部使用的 XOR 值则是：

```text
xor_key = UIN & 0xff
        = 123456 & 255
        = 64
        = 0x40
```

### 2. 把载荷分成前段和尾部

密钥准备好了，再看它处理文件的哪些位置。这份合成复现取前 1,024 字节作为前段，其余为尾部：

```text
PNG载荷：9,086 B
├── 前段：1,024 B
└── 尾部：8,062 B
```

这用于复现当前已验证样本中的常见布局，不是说每个 DAT 的前段都可以固定写成 1,024。反向解析时，边界必须从文件长度和头部字段求得。

### 3. 前段填充，再逐块 AES 加密

AES 的块大小为 16 字节。这里采用的 ECB 模式，对每个块使用同一把密钥独立处理。模式定义可以对照 [NIST SP 800-38A](https://csrc.nist.gov/pubs/sp/800/38/a/final)。

前段先按 PKCS#7 规则补齐。即使输入长度正好是 16 的倍数，也会补一个完整的块。这个例子中：

```text
1,024 B图片前段
+ 16个0x10填充字节
= 1,040 B待加密数据
= 65个16 B块
```

填充规则详见 [RFC 5652 第6.3节](https://www.rfc-editor.org/rfc/rfc5652.html#section-6.3)。不是所有填充都长 16 字节；这里之所以补 16，是因为这份前段恰好已经对齐。

使用前面派生的密钥，演示文件的第一个块实际发生了这样的变化：

```text
明文：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`：

```text
0xf0  = 11110000
0x40  = 01000000
XOR   = 10110000 = 0xb0
```

XOR 把同一个位置的两位比较：相同得 0，不同得 1。这种单字节异或很容易逆转，不能视为与 AES 相同强度的保护。

### 5. 加入文件头，组成 V2 DAT

把 15 字节文件头、AES 密文区和 XOR 尾部依次连接：

```text
15 B头部 + 1,040 B AES密文 + 8,062 B XOR尾部
= 9,117 B DAT
```

这份合成文件的头部是：

```text
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`。然后计算：

```text
aes_size = file_size - 15 - xor_size
         = 9,117 - 15 - 8,062
         = 1,040
```

这个例子的实际范围是：

```text
[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 值还原尾部

相同的数异或两次会相互抵消：

```text
A XOR K XOR K = A

0xb0 XOR 0x40 = 0xf0
```

把还原后的尾部接到已经去掉填充的前段后面。文件头留在外面，不拼进去。最终重新得到 9,086 字节的 PNG。

### 5. 用完整字节证明复现正确

合成实验的输入和输出可以直接逐字节比较：

```text
输入MD5：6d44c8898709f06cada73859dbc53dc6
输出MD5：6d44c8898709f06cada73859dbc53dc6
字节比较：完全一致
```

为了说明填充为什么不能忽略，我们还保留了一个错误拼接的结果：它的 MD5 为 `9b932acd943687fd30514b62f2d64ac7`，与输入不同。

真实微信缓存处理时，我们把还原载荷与同一消息、同一资源版本的 XML 摘要核对。摘要匹配用于确认已有描述下的内容一致性，不代表它提供了数字签名或来源认证；[RFC 6151](https://www.rfc-editor.org/rfc/rfc6151.html) 说明了 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。

两个文件分别保存：

```text
原始载荷 ── 与XML完整MD5比较 ── 保存
    │
    └── 必要的格式解码 ── PNG ── 查看
```

转换后的 PNG 与原始 WXGF 的字节不同，不能要求 PNG 也匹配原载荷的 MD5。

我们有两类独立实证：一份高清参数对照生成了 **204,763 B、1300 × 1030 的 PNG**，完整 XML MD5 一致；但它已存在缓存，因此只算参数验证。另一次未命中已检查缓存的新普通样本，自动得到 **95,942 B 的 WXGF**，完整摘要一致，再导出为 **832 × 1280 的 PNG**。这两次实验支持不同结论，不能合并成一次高清冷下载证明。

## 两条入口，汇合到同一份载荷

```text
已有V2 DAT
  → 识别布局 → 派生并验证密钥 → AES解密/去填充 + XOR还原
  → 原始图片载荷

本地没有图片
  → 精确消息资源 → 对应下载参数 → 微信原生异步下载
  → 原始图片载荷

原始图片载荷
  → 完整文件校验与保存
  → 必要的图片格式解码
  → 给人查看或给后续工具使用
```

把每一层的输入、处理和输出对应起来，才知道自己还原的是缓存、取得的是哪个资源版本，以及导出的 PNG 与原始文件有什么关系。当前实现与验证范围仍限定在所述版本和已支持的普通资源路径，其他业务类型和构建需要另行确认。

## 自己跑一遍

把下面的 Python 代码保存成 `reproduce.py`，再把合成测试图下载到同一目录。需要 Python 3 和 OpenSSL。整个实验只读写当前目录中的测试文件，不访问微信。

```bash
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`，最后断言恢复结果与输入逐字节相同。

```python
"""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))
```
