项目实战:从 0 到 1 写一个微信聊天记录提取工具

2026年8月28日 兴趣使然 20 分钟阅读 3 次阅读
📖 文章摘要

微信聊天记录封在本地 SQLCipher 数据库里,官方不提供导出。文章记录我从密钥提取、数据库解密到 GUI/MCP/打包的完整开发过程,包含图片密钥时效、Python 3.14 依赖缺失等多个踩坑与解决。

从 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_contentzstd 压缩 + 二进制序列化 的,直接读是一堆字节:

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

问题pyaudiopilkPySide6 在 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。


总结

做一个本地数据提取工具,最怕的不是算法,而是逆向推理链路长——密钥 → 解密 → 解析 → 媒体,每一环都卡壳就全完。整个过程踩了不少坑,几条最值钱的经验:

  1. 先验证再落盘:内存里扫出来的密钥必须用 HMAC 验证过才信,防止误判。
  2. 优先预编译二进制:Python 3.14 缺 wheel 时,官方编译好的 exe 是最稳的绕行方案。
  3. 版本冲突要写死:fastmcp/mcp 这类强耦合依赖,锁版本比靠 pip 自动解析可靠。
  4. GUI 别碰阻塞 IO:后台线程 + 轮询是 tkinter 系 UI 的标准解,token 防竞态。
  5. 打包要考虑自定位:单文件 exe 的提权操作不能依赖外部 PATH,入口做成双模式。
  6. 文档要诚实:图片 dat 近似映射这种机制性限制,标清楚比藏着让用户猜强。

工具本身已经开源(Python,仅 Windows),桌面 GUI、命令行、MCP 三条路径都能用。以后想给 Agent 接微信数据,或者想导出一份自己的聊天记录备份,都可以直接上手。


文档版本:v1.0
最后更新:2026-08-28
作者:leap
个人博客https://vue2.xyz

文章创建于:2026年8月28日CC BY-NC-SA 4.0

评论

暂无评论,来写第一条吧

别老叽霸扫描爆破后台了,个人博客能存啥有价值的东西,有这时间不如去扫俩放片的网站