从 0 到 1 写一个微信聊天记录提取工具
作者:leap
日期:2026-08-28
个人博客:https://vue2.xyz
前言
电脑版微信的聊天记录都封在本地数据库里,官方不提供导出,想备份或检索只能干瞪眼。数据库本身是 SQLCipher 加密的,直接打开是一堆乱码,密钥还藏在微信进程内存里。于是我做了一个叫 wxco 的工具,从密钥提取、数据库解密,到桌面 GUI 和 Agent 的 MCP 接入,一套全打通。这篇文章把整个过程按开发顺序记录下来,包括踩过的坑。
第一步:摸清微信的数据结构
1.1 数据藏在哪里
电脑版微信 4.x 登录后,聊天数据都在一个叫 db_storage 的目录下,里面按模块分了库:
session/session.db:会话列表message/msg_*.db:按时间分库的消息表contact/contact.db:联系人message/media_0.db:语音等媒体索引
每个库都是 SQLCipher 加密的,不是标准 SQLite。密码学的部分其实不复杂——SQLCipher 用 AES-256-CBC 按页加密,4096 字节一页。麻烦的是密钥:每个数据库的加密密钥都写在微信进程的内存里,磁盘上找不到。
1.2 页结构里的关键信息
读第一个页(page 1)能拿到三样东西:
PAGE_SZ = 4096 # 一页大小
SALT_SZ = 16 # salt 长度(页首)
RESERVE_SZ = 80 # 页尾保留区:IV(16) + HMAC-SHA512(64)
salt = db_page1[:SALT_SZ] # 页首 16 字节是 salt
iv = db_page1[PAGE_SZ - 80: PAGE_SZ - 80 + 16] # 页尾偏移处的 IV
原理:SQLCipher 的每个库都有独立的 salt,解密密钥就是 enc_key + salt 派生的。salt 可以从磁盘库直接读到,所以只要拿到内存里的 enc_key,就能解开。
优势:把「破解数据库」的问题,转化成了「从进程内存里找密钥」的问题——后者有迹可循。
第二步:提取密钥——最难啃的骨头
2.1 定位 Config.Cipher 对象
微信进程里有一个叫 com.Tencent.WCDB.Config.Cipher 的全局配置对象,数据库密钥都放在它的一个 blob 字段里。思路是先在进程内存里找到这个特征字符串,再顺着指针找到对象本体。
流程是这样的:
flowchart TD
A[打开微信进程] --> B[VirtualQueryEx 枚举可读内存区域]
B --> C[ReadProcessMemory 逐块读取]
C --> D{找到 Config.Cipher 特征串?}
D -->|否| C
D -->|是| E[顺着对象指针读 blob]
E --> F[用固定 XOR mask 解码]
F --> G[正则提取 key+salt 对]
G --> H{用 salt 匹配磁盘库?}
H -->|是| I[PBKDF2+HMAC 验证密钥]
I --> J[写入密钥缓存]
H -->|否| G
核心代码大概是这个套路:
def extract_db_keys(db_dir, pid=None):
h = kernel32.OpenProcess(0x0010 | 0x0400, False, pid)
# 1. 枚举所有已提交的可读内存区域
regions = _enum_regions(h)
# 2. 找到 Config.Cipher 特征字符串的地址
addrs = _find_bytes(h, regions, CONFIG_CIPHER_NAME)
# 3. 顺着对象指针找到 blob,XOR 解码
decoded = _xor_repeat(blob, CONFIG_XOR_MASK)
# 4. 正则提取 x'<64位key><32位salt>' 形式的密钥对
for key_hex, salt_hex in _parse_config_blob(decoded):
_record_matching_keys(db_files, key_map, remaining, key_hex, salt_hex)
原理:VirtualQueryEx 枚举进程内存区域,ReadProcessMemory 读出来,按特征字符串和指针结构逐步定位到加密密钥的 blob。解码后的 blob 里以 x'<64位key><32位salt>' 的形式内嵌所有库的密钥。
优势:不用爆破,一次内存扫描直接还原全部库密钥;拿到 salt 后还能反过来和磁盘库逐一配对、验证。
2.2 密钥验证:不能拿到什么就用什么
内存里扫出来的候选密钥必须验证过才敢信——用 HMAC 校验页面完整性:
def verify_enc_key(enc_key, db_page1):
salt = db_page1[:SALT_SZ]
mac_salt = bytes(b ^ 0x3A for b in salt) # mac_salt 由 salt 异或 0x3A 得到
mac_key = hashlib.pbkdf2_hmac("sha512", enc_key, mac_salt, 2, dklen=32)
hmac_data = db_page1[SALT_SZ: PAGE_SZ - RESERVE_SZ + 16]
stored_hmac = db_page1[PAGE_SZ - 64: PAGE_SZ]
hm = hmac_mod.new(mac_key, hmac_data, hashlib.sha512)
return hm.digest() == stored_hmac
原理:SQLCipher 在页尾存了 HMAC,用 enc_key + mac_salt 派生 mac_key 重新算一遍,比对一致才算密钥正确。
优势:杜绝了「内存里搜到一段看似密钥的字符串、实际不对」的误判,扫出来的密钥能直接落盘用。
2.3 图片 V2 密钥是个例外
数据库密钥搞定后,发现图片是另一套体系——V2 图片用单独的 AES 密钥加密,这个密钥不在 Config.Cipher 里,只在你看过图片后驻留内存。所以提取图片密钥得单独扫一遍内存,用图片文件的密文头去试解,命中 JPEG/PNG 魔数才算数。
这个"时效性"坑我放到踩坑记录里细说。
第三步:解密数据库
拿到密钥,解密就是纯粹的密码学活了。每个库按页解密,page 1 特殊处理(要保留 SQLite 文件头):
def decrypt_page(enc_key, page_data, pgno):
iv = page_data[PAGE_SZ - RESERVE_SZ: PAGE_SZ - RESERVE_SZ + 16]
if pgno == 1:
encrypted = page_data[SALT_SZ: PAGE_SZ - RESERVE_SZ]
cipher = AES.new(enc_key, AES.MODE_CBC, iv)
return SQLITE_HDR + cipher.decrypt(encrypted) + b"\x00" * RESERVE_SZ
encrypted = page_data[:PAGE_SZ - RESERVE_SZ]
cipher = AES.new(enc_key, AES.MODE_CBC, iv)
return cipher.decrypt(encrypted) + b"\x00" * RESERVE_SZ
原理:页首 16 字节是 salt(仅 page 1),页尾 80 字节是 IV + HMAC 保留区,中间部分是密文。用对应 IV 做 AES-CBC 解密,page 1 再拼回 SQLite format 3 文件头。
优势:解密后的库就是标准 SQLite,可以直接用 sqlite3 查询,所有现成工具都能接。
解密是 IO 密集活,大库首次解密要几秒。我加了个 DBCache:按 mtime 检测源库是否变化,没变就复用 %TEMP% 下的解密副本,跨会话也生效。
第四步:解析消息
解密后的 msg_*.db 里,消息的 message_content 是 zstd 压缩 + 二进制序列化 的,直接读是一堆字节:
def decompress_content(content, ct):
if ct == 0:
return content.decode("utf-8", errors="replace")
# ct=4 表示 zstd 压缩
if ct == 4 and content:
try:
return zstd.ZstdDecompressor().decompress(content).decode("utf-8", errors="replace")
except Exception:
return None
return None
原理:WCDB_CT_message_content 列标记压缩类型,0 表示未压缩,4 表示 zstd。解压后按 local_type 分发解析:文本、图片、语音、系统消息各有各的格式。
优势:解压逻辑集中一处,消息类型分发、发送人解析、媒体路径提取都复用同一份内容。
发送人解析靠 Name2Id 表:把 real_sender_id 映射回用户名,再查 contact.db 联系人表换成显示名。群聊里还要区分是群主自己发的还是成员发的。
第五步:媒体解码
文本搞定后是媒体。图片和语音是两块硬骨头。
5.1 图片:V2 加密格式
微信 4.x 的图片 *.dat 是 V2 格式,头部 6 字节魔数后跟着 AES 密文区 + XOR 区:
sig = data[:6]
if sig == b"\x07\x08V2\x08\x07":
aes_size, xor_size = struct.unpack_from("<LL", data, 6)
aes_data = data[15:15 + aligned] # AES 区(ECB 解密)
xor_data = data[raw_end:] # 尾部 XOR 区(异或 0x88)
dec = cipher.decrypt(aes_data)
total = dec + raw_data + bytes(b ^ 0x88 for b in xor_data)
原理:V2 图片是「AES-ECB 加密区 + 明文区 + XOR 尾部」三段的组合,解密后按文件魔数识别成 jpg/png/webp。
优势:一套逻辑同时处理 V1/V2,未知格式不硬猜,返回 bin 让上层决定。
5.2 语音:silk → wav
微信语音是 silkwave 格式,主流播放器不认。标准做法是转成 wav。silk 解码本身有成熟实现,但 Python 3.14 没有现成的 wheel(pilk 只编到旧版本)。我直接用官方预编译的 silk-decoder.exe,用 subprocess 调:
result = subprocess.run([decoder, silk_path, pcm_path], capture_output=True, timeout=60)
with open(pcm_path, "rb") as f:
pcm = f.read()
write_wav(pcm, wav_path, rate=24000) # 微信语音 24kHz,补 WAV 头
原理:silk 解码器输出裸 PCM,手动补一个 44 字节的 WAV 头就是标准 wav 文件。
优势:绕开 Python 生态缺 wheel 的问题,用编译好的二进制,稳定可靠。
第六步:三种使用形态
核心库做好后,我给了它三种「脸」:命令行、桌面 GUI、MCP。
6.1 命令行(CLI)
六条命令覆盖全部功能:init(提取密钥)、sessions(会话)、history(记录)、search(搜索)、export(导出)、media(媒体)。命令行是核心库的第一层验证,逻辑最薄。
6.2 桌面 GUI
用 customtkinter 做了暗色界面,五个页面:首次引导(提权提取密钥)、会话列表、聊天记录、导出向导、设置。GUI 踩了个大坑——首次解密大库会卡死界面,后来改成后台线程 + 主线程 after() 轮询的模式才解决,这个放踩坑记录。
6.3 MCP:让 Agent 也能查微信
最让我兴奋的是 MCP 形态。用 fastmcp 把核心能力包成 10 个工具,Agent(比如 Claude Code)接上后,直接打字就能查微信:
from fastmcp import FastMCP
mcp = FastMCP("wxco-mcp")
@mcp.tool()
def get_history(chat: str, limit: int = 50, offset: int = 0):
"""查看指定聊天记录。"""
...
@mcp.tool()
def transcribe_voice(chat: str, msg_id: int) -> str:
"""识别单条语音消息的文字(可选 ASR)。"""
...
原理:MCP 用 stdio 和宿主通信,工具定义就是普通 Python 函数,Agent 通过标准协议发现和调用。
优势:接入后你只需要说「搜索微信里'截止日期'的消息」,Agent 自己就会去调工具查,不用再打开微信翻聊天记录。
整体架构是这样的:
flowchart LR
subgraph 使用侧
GUI[桌面 GUI]
CLI[命令行]
AGENT[Claude Code / Agent]
end
subgraph wxco 核心
CORE[core 核心库]
MCP[fastmcp 工具层]
end
subgraph 数据侧
DB[微信加密数据库]
KEYS[进程内存密钥]
end
GUI --> CORE
CLI --> CORE
AGENT --> MCP --> CORE
CORE --> DB
CORE --> KEYS
第七步:可选 ASR 与打包发布
7.1 语音转文字(ASR)
语音能导出 wav 后,顺手加了语音转文字。选型上试过 faster-whisper(依赖 CTranslate2,Python 3.14 没 wheel),最后用 sherpa-onnx——zipformer-ctc 中文模型,int8 版才几十 MB,本地离线识别,模型首次使用时自动下载:
recognizer = sherpa_onnx.OfflineRecognizer.from_zipformer_ctc(
model=model_dir + "/model.int8.onnx",
tokens=model_dir + "/tokens.txt",
num_threads=2, sample_rate=16000,
)
stream = recognizer.create_stream()
stream.accept_waveform(sample_rate=16000, waveform=samples)
recognizer.decode_stream(stream)
text = stream.result.text
原理:silk 转出的 24kHz wav 先线性重采样到 16kHz,喂给 zipformer-ctc 离线识别。
优势:全程本地跑,语音不上传,隐私友好;模型按需下载,默认关闭不拖累核心。
7.2 打包:PyInstaller 单文件 exe
最后用 PyInstaller 打成单文件 exe,普通用户双击就能用,不需要装 Python:
pyinstaller --onefile --windowed --name wxco \
--add-binary "src/wxco/bin/silk-decoder-x64.exe;bin/silk-decoder-x64.exe" \
--collect-all customtkinter src/wxco/gui/app.py
原理:--onefile 打成一个 exe,--add-binary 把 silk 解码器塞进包里,--collect-all 收集 customtkinter 的主题资源。
优势:产物约 44MB 的单文件,发出去对方双击就能用。
踩坑记录
1. 图片 V2 密钥有时效性
问题:wxco init 后数据库都能解,但图片解密总是失败,报密钥错误。
原因:图片 V2 的 AES 密钥不像数据库密钥常驻内存,它只在微信里打开过图片之后才驻留。没看过图,内存里就扫不到图片密钥。
解决:在文档和 GUI 里都写明——图片密钥提取不到时,先在微信里打开几张图再提取。这是微信 4.x 的机制,绕不开,只能让用户配合。
2. 图片 dat 是近似映射
问题:按消息里的 md5 去找对应的 *.dat 文件,经常找不到。
原因:微信 4.x 的 dat 文件名是本地内容哈希,和消息 XML 里的 md5 没有关联,只能按「会话 + 时间」近似匹配目录。
解决:接受近似映射,文档里明确标注「图片定位是近似,可能不准」。语音则是精确映射(按 local_id),不受影响。
3. Python 3.14 一堆包没 wheel
问题:pyaudio、pilk、PySide6 在 Python 3.14 下都没有预编译 wheel,pip 装不上。
原因:3.14 太新,很多包还没跟上编译。
解决:优先找预编译二进制(silk 用官方 exe),坚决不源码编译;需要原生绑定的组件评估后换实现。
4. fastmcp 和 mcp 的版本冲突
问题:装上 fastmcp 最新版后,import 直接报错,整个 MCP server 起不来。
原因:fastmcp 3.x 的元数据要求 mcp<2.0,而 pip 默认装了 mcp 2.x。
解决:把版本写死为 fastmcp>=3.0 + mcp>=1.24.0,<2.0 的兼容组合,写进 pyproject 的 extra。
5. GUI 首次加载卡死
问题:打开会话列表,界面卡住好几秒,鼠标都动不了。
原因:解密大库是 IO 密集操作,直接在 GUI 主线程里同步跑,阻塞了事件循环。
解决:改成后台线程加载 + 主线程 after() 轮询,再用 token 机制丢弃过期请求——用户快速切换会话时,旧线程的结果不会覆盖新界面。
6. 打包后提权提取密钥失败
问题:exe 打包后,GUI 里点「提取密钥」没反应。
原因:提权命令用的是 wxco init,依赖系统 PATH 里有 wxco 命令;打包分发的机器上根本没装,自然找不到。
解决:让 exe 支持 wxco.exe init 参数——启动时判断 argv 含 init 就走 CLI 逻辑,提权命令改为运行自身 exe,不再依赖 PATH。
总结
做一个本地数据提取工具,最怕的不是算法,而是逆向推理链路长——密钥 → 解密 → 解析 → 媒体,每一环都卡壳就全完。整个过程踩了不少坑,几条最值钱的经验:
- 先验证再落盘:内存里扫出来的密钥必须用 HMAC 验证过才信,防止误判。
- 优先预编译二进制:Python 3.14 缺 wheel 时,官方编译好的 exe 是最稳的绕行方案。
- 版本冲突要写死:fastmcp/mcp 这类强耦合依赖,锁版本比靠 pip 自动解析可靠。
- GUI 别碰阻塞 IO:后台线程 + 轮询是 tkinter 系 UI 的标准解,token 防竞态。
- 打包要考虑自定位:单文件 exe 的提权操作不能依赖外部 PATH,入口做成双模式。
- 文档要诚实:图片 dat 近似映射这种机制性限制,标清楚比藏着让用户猜强。
工具本身已经开源(Python,仅 Windows),桌面 GUI、命令行、MCP 三条路径都能用。以后想给 Agent 接微信数据,或者想导出一份自己的聊天记录备份,都可以直接上手。
文档版本:v1.0
最后更新:2026-08-28
作者:leap
个人博客:https://vue2.xyz
