逆向工程
把微信 4.x 的聊天记录导出来:LLDB 抓 passphrase、PBKDF2 解密与 wxecho 导出
聊天的截图是最贵的一种「省事」:一屏只能装二十来条消息,上下文被裁掉一半,结论常常建立在被裁掉的那一半上。要做任何真正靠谱的分析,第一步都不是「怎么回」,而是把整段聊天变成一份可以检索、可以统计的文本。这篇文章拆开 pickled-fish 里的导出管线 skills/wechat-mac-export:密钥从进程内存里怎么取、passphrase 为什么还要过一遍 PBKDF2、db_storage 里到底躺着什么,以及为什么导出成 chat.txt 才算真正「能看懂」。
为什么先解决「导出」这件事#
先说清楚 pickled-fish(泡菜鱼)是什么。它是跑在 Cursor Agent 上的技能包,仓库里写着三块已经落地的能力:把本人 Mac 微信的聊天导出成可读的 chat.txt、对整段聊天做时间线与话轮分析、再按问题从本地知识包里挑理论和话术。它的口号是「你听得到吗 · 先看懂,再开口」——注意顺序,是先看懂。
在这个顺序里,导出是绕不过去的第一环。理由很实际:
- 截图是有损压缩。你想问的是「这段时间她为什么变淡了」,截图给你的却只有最后二十条消息。冷场发生在第 300 条之后还是第 3000 条之后,截图看不出来。
- 截图不能统计。话轮数、回复间隔、谁先开口、哪一天之后语气变了——这些都是需要遍历全部消息才能算出来的量。
- 截图不能检索。「上次她提过的那件事」,如果没有可搜索的文本,就只能靠翻聊天记录翻到手酸。
所以导出管线不是附属功能,而是整个技能包的地基:没有 chat.txt,后面的分析都只能退化成「看着几张图猜」。
版本前提:WeChat 4.1.10+ 与 db_storage#
微信从 4.x 开始,macOS 版的本地数据被组织进一个叫 db_storage 的目录,消息按业务拆成多个 SQLite 数据库,仓库 README 里写的就是 db_storage/*.db,其中消息主库是 db_storage/message_*.db。
关键点在于:这些 .db 不是能直接双击打开的明文库。它们经过加密,用普通的 SQLite 客户端打开只会得到一句「file is not a database」。这也是整条管线为什么要和密钥打交道的全部原因。
| 层次 | 是什么 | 为什么重要 |
|---|---|---|
| 版本前提 | WeChat 4.1.10+(macOS) | 目录结构与加密方式在 4.x 上才成立,低版本走的是另一套路径 |
| 数据载体 | db_storage/*.db | 加密后的 SQLite 库,消息在 message_*.db |
| 密钥材料 | 进程内存里的 passphrase | 微信运行时持有它,并在其基础上派生数据库密钥 |
| 输出 | ~/Downloads/wxecho/<联系人>/chat.txt | 人能读、程序能搜、能被后面的分析管线消费 |
先确认版本,再动手。这条规则在逆向相关的工作里几乎总是成立:同一个思路换个版本号,成功率可能从「十分钟搞定」变成「一整天白干」。仓库里明确标注的支持范围是 WeChat 4.1.10+,超出这个范围就不是「照着做」的问题了。
密钥从哪来:用 LLDB 抓 passphrase#
数据库是加密的,那就得有密钥;密钥不能靠猜——一个正常强度的 passphrase 没有任何爆破价值。剩下的路只有一条:微信自己在运行时会用到它,那就去它用到的地方拿。
这正是 wcdb-key-tool 做的事。它是一个独立的小工具(不在 pickled-fish 仓库里,属于外部依赖),思路是:用 LLDB 这个调试器附加到微信进程上,在数据库密钥被派生出来的位置把 passphrase 读出来。工具名里的 WCDB 指的正是微信那套基于 SQLite 的数据库组件——加密就发生在这一层。
为什么是 LLDB 而不是别的?因为 macOS 上要观察一个正在运行的进程内部状态,调试器是最直接的手段:可以附加进程、可以下断点、可以在断点命中时读取寄存器和内存里的值。整个过程不需要修改微信的任何文件,只是「在旁边看着它把密钥算出来」。
| 做法 | 可行性 | 说明 |
|---|---|---|
| 爆破 passphrase | 不可行 | 搜索空间过大,纯属浪费电 |
| 从磁盘文件里找 | 不可行 | 密钥不以明文形式躺在数据目录里 |
| LLDB 附加进程读取 | 本管线的做法 | 在密钥派生处取值,拿到的就是微信自己用的那一份 |
需要提醒的是,这一步涉及系统层面的调试权限,具体需要在系统里开哪些设置、工具需要什么参数,以 wcdb-key-tool 自己的说明为准——本文只讲它在整条管线里的位置,不复述另一份会过期的文档。
passphrase 不是 key:PBKDF2 在中间做了什么#
拿到 passphrase 就以为万事大吉,是这条管线里最容易犯的错。真实情况是:passphrase 是原料,不是密钥。数据库解密需要的是从 passphrase 派生出来的 key,中间的派生函数就是 PBKDF2。
为什么要多这一步?因为人选的 passphrase 通常短、熵低。如果直接把 passphrase 当密钥用,攻击者每秒钟可以试几百万次。PBKDF2 这类密钥派生函数(KDF)的设计目标正好相反:让每一次尝试都变得昂贵——通过加盐(同样的 passphrase 在不同场景派生出不同 key)和大量迭代(把一次尝试的成本抬高几个数量级),把「每秒几百万次」压到「每秒几次」。
| 概念 | 它是什么 | 在本管线里的角色 |
|---|---|---|
| passphrase | 微信持有的原始口令材料 | LLDB 抓取的目标,属于中间产物 |
| KDF(PBKDF2) | 把口令材料扩展成密钥的函数 | 必须按同一套参数重算,才能得到相同的 key |
| key | 真正参与数据库加解密的值 | 交给解密步骤,用来打开 db_storage/*.db |
这个区分在实际操作中有个很直接的后果:只抄走 passphrase 而不复现派生参数,是打不开库的。加盐值、迭代次数、输出长度,任何一个对不上,派生出来的 key 就是另一串字节。所以这部分工作最好交给已经封装好的工具去做,而不是自己手搓一遍——手搓的版本会在微信升级后静默失效,而且失效时表现出的症状(「库打不开」)和密钥错误完全一样,非常难排查。
解密 db_storage/*.db#
派生出的 key 交给解密步骤,逐个打开 db_storage 下的库,得到可以直接用 SQLite 查询的副本。这一段的工程要点不是算法,而是纪律:
- 只读、不动原库。解密应该产出新的文件,而不是原地改写。原始数据是唯一的一份,一旦写坏就没有第二份可对照。
- 先退微信再操作。库正在被写入时复制,很容易拿到一个字段不完整的快照;顺序上应当先让微信安静下来。
- 把密钥当密码对待。仓库的边界说明里专门写了一句:密钥、
chat.txt和自定义隐私包都不要提交到公开仓库。这句话比任何技术细节都重要。
解出来的库就是标准的 SQLite:联系人、会话、消息各在自己的表里,用任意客户端都能查。到这一步,「可检索」已经实现了一半,但它仍然不方便给人看——你不可能指望自己每次都用 SQL 去回忆一段对话。所以还需要最后一步。
导出成 chat.txt#
最后一步由 @walkerch/wxecho 完成。仓库里给出的用法就一条命令:
wxecho export "昵称"
执行之后,会在下载目录下按联系人分文件夹写出结果:
~/Downloads/wxecho/<联系人>/chat.txt
除了纯文本 chat.txt,管线里也支持 json 与 csv 形式——文本适合人和 Agent 直接读,结构化格式适合自己写脚本做统计。子命令和参数以工具自身的帮助信息为准,这里只固定这条最短路径。
把整条链路连起来看,数据是这样流动的:
Mac WeChat 4.x
db_storage/message_*.db
│ wechat-mac-export
│ (wcdb-key-tool → 明文库 → wxecho)
▼
chat.txt / json / csv
│ local-chat-pipeline
│ (时间线 · 话轮 · 转折)
▼
kb-rag 读 REGISTRY.md
│ 选 ≤2 个 pack
▼
1–2 个关键点 + 可发送原文或「先不发」
packs_used: [...]
注意这条链路里有一个容易被忽略的设计:导出与分析是分开的两件事。导出只负责把数据还原成人能读的样子,不掺杂任何判断;判断留给后面的分析环节。这样做的好处是,当分析结论可疑时,你可以回到原文核对,而不是怀疑「是不是导出的时候就丢东西了」。
导出来之后:整段分析,而不是截图分析#
pickled-fish 的中间一环是 skills/qingsheng/references/local-chat-pipeline.md。它处理的对象是「上万行导出文本」,做的是时间线、话轮、冷场与转折点的统计。这一步的价值,用一句话概括就是:把「我感觉最近不太对」变成「第 N 天开始,回复间隔的中位数变了」。
举几个只有整段文本才能回答的问题:
- 是谁先开的口?这个比例在这段时间里有没有变化?
- 最长的一次冷场出现在哪里,前后发生了什么?
- 话题是被谁终止的?有多少次是「对方回了一个字之后就没有下文」?
- 转折点是渐变的,还是某一件具体的事之后突然出现的?
分析完再进入知识库环节:kb-rag 先读 knowledge/REGISTRY.md 这张路由表,选出不超过两个 pack,然后才展开具体内容,并在回答里强制标注 packs_used。也就是说,最终给你的不是「一堆理论」,而是一到两个关键点,外加一段可以直接发送的原文,或者一句「先不发」。
这套流程里最反直觉、但最值得学的一点是:它把「不下结论」也当成一个合法输出。「先不发」是可以被交付的答案之一——这在情感场景里比在技术场景里更需要被显式支持。
把它装起来#
整包装进 Cursor 只要两步:
git clone https://github.com/lnuxe/pickled-fish.git ~/.cursor/skills/pickled-fish
bash ~/.cursor/skills/pickled-fish/scripts/install-skills.sh
install-skills.sh 会把四个入口软链到 ~/.cursor/skills/:pickled-fish、wechat-mac-export、qingsheng、kb-rag。仓库的目录结构大致是这样:
pickled-fish/
├── SKILL.md # 总控:导出 → 选库 → 分析
├── knowledge/
│ ├── REGISTRY.md # 路由表(意图 → pack id)
│ ├── index.md # 分类索引
│ ├── packs/ # 已捆绑的 MIT 知识包
│ └── custom/ # 你的私有包(默认不进公开 git)
├── skills/
│ ├── wechat-mac-export/ # macOS 微信解密与导出
│ ├── kb-rag/ # 选包与 progressive disclosure
│ └── qingsheng/ # 中文恋爱实操 + local-chat 管线
└── scripts/
├── install-skills.sh # 软链到 ~/.cursor/skills/
└── add-pack.sh # git/本地目录装进 custom/
导出那一步的本机依赖有三个:@walkerch/wxecho、wcdb-key-tool,以及 Python 3。前两个负责解密和导出,Python 负责把库里的内容整理成文本。想换一套自己的笔记,用 scripts/add-pack.sh 把它装进 knowledge/custom/<id> 即可。
边界:这件事能做什么,不能做什么#
仓库里有一份 DISCLAIMER.md,内容很短,但三条都必须先读:仅限本人数据、非诊疗、禁止骚扰。把它翻译成工程语言,就是下面三条约束:
| 约束 | 含义 | 落地做法 |
|---|---|---|
| 仅限本人数据 | 导出的对象必须是自己的账号数据 | 不导出、不转发他人的聊天记录 |
| 非诊疗 | 它给的是沟通建议,不是心理诊断 | 不把分析结论当成医学判断使用 |
| 禁止骚扰 | 对方明确拒绝时,任何话术都失效 | 「停联系」优先于任何 pack 里的技巧 |
工程纪律
还有一条工程纪律:密钥、chat.txt、自定义隐私包都不要提交公开仓库。knowledge/custom/ 之所以默认不进公开 git,就是因为它存放的是私有的东西——设计上已经替你把这条线划好了,别自己把它越过去。
小结#
这条导出管线的全部复杂度,都集中在「拿到能用的密钥」这一步:用 LLDB 从进程里取 passphrase,再按同一套参数走 PBKDF2 派生 key,最后逐个解密 db_storage/*.db,交给 wxecho 写出 chat.txt。密钥之后的每一步,其实都是普通的工程活。
如果只带走一句话,我希望是这句:任何严肃的分析都必须建立在完整的、可检索的原始文本之上。截图不是数据,截图是数据的缩略图——而缩略图上的判断,通常比没有判断更危险。下一篇会接着讲导出之后的那一半:为什么给个人知识库配一套文件系统检索,比接一个向量库更划算。