← 返回首页

微信 4.x 图片 dat 加密全解析:一把账号级密钥,离线解密聊天记录与朋友圈图片 - UpXuu's blog

发布于

前排提醒:本文仅讨论备份和恢复自己的微信数据,所有样本均来自本人账号。 未经他人同意获取、解密、传播他人微信数据属于违法行为(《个人信息保护法》),请勿用于任何非法用途。

最近在写自己的微信聊天记录备份工具,卡在了一个所有人都卡住的地方:图片

聊天记录的数据库是 SQLCipher 加密的,社区研究得很透彻,密钥提取方案已经很成熟。但图片是另一套体系——微信把它们打散成 .dat 文件扔在各个角落,加密方式迭代了三代,而且密钥根本不落在磁盘上。网上能搜到的方案大多停在”打开微信扫内存碰运气”的阶段。

这篇文章把我实测踩通的完整链路讲清楚:文件在哪、加密格式长什么样、密钥怎么拿、消息和图片文件之间怎么精确对上号。结论先行——不用碰微信进程,一把离线派生的账号级密钥就能解密全部图片,包括朋友圈。实测微信 4.1.13.63,聊天图片 15/15、朋友圈图片 40/40 全部解密成功。

一、图片到底存在哪

微信 4.x 的媒体文件按”容器”分散落盘,路径根目录可能是 C:\Users\<用户>\xwechat_filesD:\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 首字节恒为 0xFFkey = 首字节 ^ 0xFF
V1\x07\x08V1\x08\x07AES-128-ECB + XOR固定 key(社区已破解)
V2\x07\x08V2\x08\x07AES-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_6409wxid_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

两个魔鬼细节:

  1. aes_keyhexdigest 的前 16 个 ASCII 字符直接当 16 字节密钥用,不是 hex 解码后的 8 字节——我一开始想当然解码,浪费了半小时;
  2. 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小缩略图始终有

两个关键规律(实测确认):

  1. 会话目录名 = md5(会话username)——和缓存目录 cache/<月>/Message/ 的哈希目录是同一个算法,知道聊天对象就能直接算出目录;
  2. 消息 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

两个实战细节:

  1. DLL 就在微信安装目录(C:\Program Files\Tencent\Weixin\<版本>\VoipEngine.dll),按版本号倒序取最新;
  2. 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。把 codewxid、文件路径换成你自己的即可。

十一、写在最后

微信 4.x 的媒体加密思路其实很克制:格式本身不难,难的是三个信息差——密钥在 MMKV 文件名里、会话目录是 username 的 md5、气泡缓存文件名藏在消息行的 protobuf 里。把这三个拼图凑齐,整个图片体系就透明了。

踩坑过程中还留下了几个待研究项:msg/attach 里少量未知签名的 .dat(疑似视频变体)、视频文件的加密方式、MMKV code 在换号后的行为。这些等我的备份工具做到对应模块时再填坑。

**再次强调:本文方法仅限备份自己的数据。**工具是把双刃剑,请务必守法。


前往 upxuu.com 阅读完整版本(含交互功能)