微信 4.x 图片 dat 加密全解析:一把账号级密钥,离线解密聊天记录与朋友圈图片 - UpXuu's blog
发布于
前排提醒:本文仅讨论备份和恢复自己的微信数据,所有样本均来自本人账号。 未经他人同意获取、解密、传播他人微信数据属于违法行为(《个人信息保护法》),请勿用于任何非法用途。
最近在写自己的微信聊天记录备份工具,卡在了一个所有人都卡住的地方:图片。
聊天记录的数据库是 SQLCipher 加密的,社区研究得很透彻,密钥提取方案已经很成熟。但图片是另一套体系——微信把它们打散成 .dat 文件扔在各个角落,加密方式迭代了三代,而且密钥根本不落在磁盘上。网上能搜到的方案大多停在”打开微信扫内存碰运气”的阶段。
这篇文章把我实测踩通的完整链路讲清楚:文件在哪、加密格式长什么样、密钥怎么拿、消息和图片文件之间怎么精确对上号。结论先行——不用碰微信进程,一把离线派生的账号级密钥就能解密全部图片,包括朋友圈。实测微信 4.1.13.63,聊天图片 15/15、朋友圈图片 40/40 全部解密成功。
一、图片到底存在哪
微信 4.x 的媒体文件按”容器”分散落盘,路径根目录可能是 C:\Users\<用户>\xwechat_files、D:\xwechat_files 或 Documents 下(多账号多实例并存):
| 媒体 | 路径 | 命名 |
|---|---|---|
| 聊天图片 | msg/attach/<目录1>/<目录2>/Img/*.dat | 内容 md5,缩略图带 _h/_t 后缀 |
| 朋友圈图片(浏览缓存) | cache/<YYYY-MM>/Sns/Img/<两位hex>/<md5> | 内容 md5,无扩展名 |
| 朋友圈背景/自己发的 | business/sns/{bkg,publish}/... | md5 |
| 聊天视频 | msg/video/... | md5 |
朋友圈的浏览缓存藏得最深——它不在 business/sns,而在按月份滚动的 cache/2026-09/Sns/Img/ 里。你在朋友圈刷到的每一张图,都会立刻以 md5 文件名落在这里。
二、三代加密:V0 / V1 / V2
按文件头 6 字节签名区分:
| 版本 | 头签名 | 方案 | 密钥 |
|---|---|---|---|
| V0 | 无签名 | 整文件单字节 XOR | 自动检测(明文 JPEG 首字节恒为 0xFF,key = 首字节 ^ 0xFF) |
| V1 | \x07\x08V1\x08\x07 | AES-128-ECB + XOR | 固定 key(社区已破解) |
| V2 | \x07\x08V2\x08\x07 | AES-128-ECB 头部 + 单字节 XOR 尾部 | 账号级密钥(本文主角) |
实测 4.1.13.63:朋友圈缓存 184/184 全部是 V2;聊天图片抽样 400 个中 367 个是 V2。V2 是绝对主流。
三、V2 文件格式逐字节拆解
拿一张真实文件做解剖(所有字段均实测校验):
偏移 长度 内容
0 6 魔数 \x07\x08V2\x08\x07
6 4 AES 段长度(LE u32) 实测 0x400 = 1024
10 4 XOR 段长度(LE u32)
14 1 标志字节 实测恒为 0x01
15 N AES-128-ECB 加密的图像头部 N = AES 段长度
15+N 16 分隔尾(不参与解密,直接跳过)
15+N+16 M 单字节 XOR 混淆的图像剩余部分
总长公式精确成立:file_size == 15 + aes_size + 16 + xor_size。我用三张不同大小的图验证,字节级吻合。
解密就是两段拼接:
pt = AES.new(key, AES.MODE_ECB).decrypt(data[15:15+aes_size]) \
+ bytes(b ^ xor_key for b in data[15+aes_size+16 : 15+aes_size+16+xor_size])
一个坑:15+N 处的 16 字节,网上老资料说是全局常量 56fbf4...。实测 4.1.13 已经变了(本机是另一组值),它只是个分隔符,解析时按长度跳过,千万不要硬编码。
四、密钥从哪来:MMKV 离线派生(核心发现)
V2 的密钥是账号级的——同账号所有图片共用一把。而它就躺在本机 MMKV 的统计文件名里:
C:\Users\<用户>\AppData\Roaming\Tencent\xwechat\net\kvcomm\key_<code>_<...>.statistic
文件名第一个字段就是 code(正则 key_(\d+)_ 提取;同目录下还有 ilink/kvcomm、旧版 Tencent/WeChat/*/kvcomm 可以一起扫)。
拿到 code 后,配合清洗过的 wxid(去掉 _数字 账号后缀,如 wxid_xxx_6409 → wxid_xxx):
import hashlib
code = 123456789 # 示例,从 kvcomm 文件名提取
wxid = "wxid_xxxx" # 清洗后缀后的自己的 wxid
aes_key = hashlib.md5(f"{code}{wxid}".encode()).hexdigest()[:16].encode()
xor_key = code & 0xFF
两个魔鬼细节:
aes_key是 hexdigest 的前 16 个 ASCII 字符直接当 16 字节密钥用,不是 hex 解码后的 8 字节——我一开始想当然解码,浪费了半小时;- wxid 必须去掉后缀,否则派生出来的 key 解不开任何文件。
派生出的 key 怎么确认是对的?拿任一 V2 文件的 15:31 十六字节做 ECB 解密,结果命中图像魔数(FF D8 FF / 89 50 4E 47 / GIF8 / RIFF / wxgf)即为命中——一次 AES 单块运算,微秒级。
五、三个质量档:同一个会话的图片有三份
解析聊天图片时最先撞上的坑:按消息里的 md5 去找文件,经常找不到。原因是我一开始没搞清质量档的设计——同一个会话的图片,微信在 msg/attach/<md5(会话username)>/<年-月>/Img/ 下保留了最多三份:
| 文件 | 质量 | 什么时候存在 |
|---|---|---|
<图片md5>.dat | 聊天显示版(聊天窗口里看到的) | 收到消息即下载 |
<图片md5>_h.dat | 高清原图(2MB 级) | 你点开过大图才下载 |
<图片md5>_t.dat | 小缩略图 | 始终有 |
两个关键规律(实测确认):
- 会话目录名 =
md5(会话username)——和缓存目录cache/<月>/Message/的哈希目录是同一个算法,知道聊天对象就能直接算出目录; - 消息 XML 里的
md5属性就是图片文件名的 md5(CDN md5),直接拼文件名即可命中,不依赖 hardlink 数据库。
所以”图片显示成缩略图”很多时候不是解密问题,而是微信压根没下载过高清原图——只有你点开过大图的那些,_h.dat 才存在。
六、消息怎么对上图片文件:Bubble 缓存映射破解
更刁钻的问题:有些消息连显示版都没落到 msg/attach,但微信明明显示出来了——图片在气泡缓存里:
cache/<YYYY-MM>/Message/<md5(会话username)>/Bubble/<md5>_b.dat
这个 <md5> 是什么?翻遍 hardlink 表都对不上。最后答案是:就在消息行自己的 packed_info_data 字段里——一个迷你 protobuf,内嵌一段 32 位 hex ASCII 字符串,正是气泡缓存文件名。实测逐字节对应(连文件修改时间都能和”我在微信里打开这张图的时间”对上)。
提取只要一个正则:
import re
bubble_md5 = re.search(rb"[0-9a-f]{32}", packed_info_data).group().decode()
于是图片解析链变成四级,全部用同一把账号级密钥:
1. msg/attach 按名直查 # 显示版 / _h 高清 / _t 缩略
2. hardlink 数据库 # 兜底映射
3. Bubble 气泡缓存 # packed_info 精确映射,微信展示的同款
4. Thumb 缩略图 # 明文 JPEG,最后防线
七、wxgf:微信自研格式怎么转码
用账号级密钥解出来的图片,很多不是 JPEG 而是 wxgf(WeChat Graphics Format,微信自研封装)。它不是简单改个头的 GIF,社区没有纯 Python 解码器。
但解码器微信自己就带了——安装目录下的 VoipEngine.dll,导出函数 wxam_dec_wxam2pic_5,ctypes 直接调:
import ctypes, os
def convert_wxgf(data: bytes, dll_path: str) -> bytes | None:
os.add_dll_directory(os.path.dirname(dll_path)) # DLL 依赖同目录组件
voip = ctypes.WinDLL(dll_path)
fn = voip.wxam_dec_wxam2pic_5
fn.argtypes = [ctypes.c_int64, ctypes.c_int, ctypes.c_int64,
ctypes.POINTER(ctypes.c_int), ctypes.c_int64]
fn.restype = ctypes.c_int64
out_buf = ctypes.create_string_buffer(52 * 1024 * 1024)
out_sz = ctypes.c_int(len(out_buf))
in_buf = ctypes.create_string_buffer(data, len(data))
ret = fn(ctypes.addressof(in_buf), len(data),
ctypes.addressof(out_buf), ctypes.byref(out_sz), None)
return out_buf.raw[:out_sz.value] if ret == 0 else None
两个实战细节:
- DLL 就在微信安装目录(
C:\Program Files\Tencent\Weixin\<版本>\VoipEngine.dll),按版本号倒序取最新; - Web 后端里必须做 DLL 单例 + 调用加锁——浏览器并发加载图片时重复
LoadLibrary会互相踩崩,表现为”图片全部 404”,而 curl 串行测试一切正常,非常迷惑人。
八、实测结果
| 实验 | 结果 |
|---|---|
| 朋友圈图片(Sns/Img 最近 40 张) | 40/40 解密成功 |
| 聊天图片(msg/attach 随机 15 张 V2) | 15/15 解密成功(同一把 key) |
高清原图(_h.dat,2.1MB 密文) | 解密成功,1080×2340 全分辨率 |
| JPEG 完整性 | FFD8 头 / FFD9 尾,图像可正常打开 |
全程没有打开微信进程、没有扫描任何内存、没有全量解密——密钥是纯离线派生的,图片按需解密、产物放内存缓存(程序关闭即释放)。
九、那”扫内存抓密钥”还有用吗
社区常见方案(各类 harvest 工具)是扫描微信进程内存抓密钥:找到内存里的 V2 魔数,对 ±256 字节窗口做滑动 16 字节尝试,或者正则捞 32 位 hex 串逐个试。
我实测了一轮:图片关闭后,密钥随即从内存释放——764MB 内存里只剩 1 处 V2 魔数、3.2 万个 hex 候选,无一命中。这类方案强依赖”图片正被查看”的时机,适合做兜底,不适合做主力。
正确的架构是:
1. 密钥库缓存 # 派生结果存下来,秒回
2. MMKV 离线派生 # 主力,无需微信在线
3. 内存收割 # 兜底:账号状态异常时,引导用户浏览图片后抓取
4. 按需解密 + 产物缓存 # 永远不全量解密(聊天媒体轻松几个 GB)
十、完整可运行的核心代码
import hashlib
import struct
from pathlib import Path
from Crypto.Cipher import AES
V2_MAGIC = b"\x07\x08V2\x08\x07"
def clean_wxid(wxid: str) -> str:
parts = wxid.split("_")
return "_".join(parts[:2]) if wxid.startswith("wxid_") and len(parts) >= 3 else wxid
def derive_key(code: int, wxid: str) -> tuple[bytes, int]:
wx = clean_wxid(wxid)
aes_key = hashlib.md5(f"{code}{wx}".encode()).hexdigest()[:16].encode()
return aes_key, code & 0xFF
def decrypt_v2(path: str, aes_key: bytes, xor_key: int) -> bytes:
data = Path(path).read_bytes()
assert data[:6] == V2_MAGIC, "不是 V2 格式"
aes_size = struct.unpack("<I", data[6:10])[0]
xor_size = struct.unpack("<I", data[10:14])[0]
head = AES.new(aes_key, AES.MODE_ECB).decrypt(data[15:15 + aes_size])
tail = bytes(b ^ xor_key for b in data[15 + aes_size + 16: 15 + aes_size + 16 + xor_size])
return head + tail
if __name__ == "__main__":
key, xk = derive_key(123456789, "wxid_xxxx_1234")
img = decrypt_v2(r"D:\xwechat_files\wxid_xxxx_1234\cache\2026-09\Sns\Img\ab\cd", key, xk)
Path("out.jpg").write_bytes(img)
依赖只有一个 pycryptodome。把 code、wxid、文件路径换成你自己的即可。
十一、写在最后
微信 4.x 的媒体加密思路其实很克制:格式本身不难,难的是三个信息差——密钥在 MMKV 文件名里、会话目录是 username 的 md5、气泡缓存文件名藏在消息行的 protobuf 里。把这三个拼图凑齐,整个图片体系就透明了。
踩坑过程中还留下了几个待研究项:msg/attach 里少量未知签名的 .dat(疑似视频变体)、视频文件的加密方式、MMKV code 在换号后的行为。这些等我的备份工具做到对应模块时再填坑。
**再次强调:本文方法仅限备份自己的数据。**工具是把双刃剑,请务必守法。