这次想解决的是一个具体问题:读取自己 Mac 上的微信聊天数据库,并按日期导出一段会话。在 Mac Studio 上取得了 20 个数据库的逐库密钥,取钥完成时 20/20 只读验证通过,从指定会话导出了当天 31 条消息,保存为 JSONL。之后复验一度变为 19/20;针对新建的资源库重新取钥后恢复为 20/20。下面保留这次变化和修复过程。
先说明证据边界:本机是 macOS 26.5、微信 4.1.15,操作前 SIP 已经关闭。所以这篇记录不能作为“我在 SIP 开启时实测取钥成功”的证据。参考的 社区 Gist 报告了另一套环境(Apple Silicon、macOS 15.6.1、微信 4.1.15)中保持 SIP 开启的验证结果。两者应分别看待。
本文中的账号目录、会话名和 ID、聊天正文、数据库密钥、截图与导出文件均未公开。数量、软件版本和不含秘密的命令结构保留下来,便于别人复核方法。本文是本地实践记录,未向任何会话发送测试消息。
先厘清:为什么过去有人会关闭 SIP?
SQLCipher 的密钥不是由 SIP 生成或保存的。真正的问题是:调试器能否读取正在运行的微信进程。macOS 的代码签名、Hardened Runtime、调试 entitlement 和系统保护策略会影响 task_for_pid 与 LLDB 附加。Apple 的调试器 entitlement 文档明确说明,给调试器增加权限也不能任意取得没有 get-task-allow 的受保护进程任务端口;公证说明也解释了发行版通常会移除 get-task-allow。因此,sudo 不是可靠的万能开关。社区方案的本机诊断还记录了对原版微信直接申请任务端口返回权限错误。
一些旧做法通过关闭 SIP,配合修改原版应用签名或降低调试限制来附加原进程。这里采用的思路不同:不去附加原版进程,而是复制应用、只重签临时副本,再让 LLDB 启动这个副本。副本仍访问同一账号的数据目录;原版应用的签名保持原样。这样改变的是被调试目标的签名和启动方式,不是解密算法,也不是从磁盘“破解”密钥。该方法的适用性仍取决于具体 macOS、微信版本和本机策略。Gist 的取钥说明
密钥是怎样被捕获并验证的?
1 | 正常退出原版微信 |
这套流程依据的是 Gist 的 bootstrap_keys.py 和 capture_keys.py。提取器只处理匹配目标数据库盐值的调用,并对候选密钥做首页 HMAC 校验;它不是把所有经过断点的数据都当成 key。SQLCipher 文档说明数据库盐通常位于文件前 16 字节,并描述 PBKDF2、HMAC 和页面大小的配置。Gist 当前实现只覆盖已验证的 arm64、特定 PBKDF 参数和 4096 字节 SQLCipher 4 页面布局,不能把这一实现直接当作所有旧版微信的通用取钥器。
临时副本和原版在打开同一账号、同一批数据库时,使用的是这些数据库实际需要的密钥。但“同一个 key”容易引起误解:结果是逐数据库映射,不能假定所有分库共用一把。若切换账号、重建数据库或密钥发生变化,先重新验证,不要沿用旧结果。Gist 的 KEY_GUIDE.md
本次实际做了什么
- 在原版微信正常退出后,按 Gist 的临时副本思路重签副本,用 LLDB 观察副本打开本地数据库时的密钥派生调用。登录确认由本人在手机上完成。
- 对捕获结果逐库校验,将密钥保存到仅当前用户可读的私有文件。目录权限为
0700,密钥文件为0600;本文不包含密钥内容。 - 用 SQLCipher 只读打开本地数据库并查询表结构,取钥完成时 20 个库全部通过。之后按会话的内部 ID 读取指定日期的消息,导出 31 行 JSONL。导出文件也设为
0600,其中真实正文没有进入本文。
撰文时复验出现了一个新的边界:19/20 通过;message_resource.db 使用已保存密钥打开时返回错误 26(SQLITE_NOTADB),第一页 HMAC 校验也不通过。它是图片、视频等附件资源信息的索引库;两个消息分库当时仍能读取。SQLite 错误码文档、Gist 媒体代码
继续只读检查发现:旧密钥文件保存于当天 18:57,而当前 message_resource.db 文件创建于 20:19;旧文件中的 20 把 key 没有一把能通过新库首页 HMAC。这说明旧密钥映射已不适用于当前资源库。文件为何被重新创建,这次没有进一步确定,不能仅凭时间戳断言是微信主动轮换了全部数据库密钥。
修复时先正常退出原版微信,只针对这个新资源库启动一次临时副本和 LLDB。候选 key 通过新库首页 HMAC 后,先与旧清单合并到独立候选文件,只读验证 20/20;随后在私有目录以 0600 权限备份旧 key.json,原子替换正式文件,再次只读验证 20/20。资源库的 MessageResourceInfo、MessageResourceDetail 等表可查询,临时副本已清理。其余 19 个库的 key 未变;没有修改微信数据库。Gist 的取钥与验证实现
本次实际验证的是“临时副本取钥 → 原库只读验证 → 历史消息导出”。Gist 还实现了借助 ScreenCaptureKit、OCR、模拟鼠标键盘输入的界面发送,以及发送后的数据库核验;本次没有实测发送。它不是拦截微信发送 API,也不通过向数据库 INSERT 来发消息。Gist 的发送说明与代码
给后来人的复现入口
先阅读并审查 Gist 的 QUICKSTART.md、KEY_GUIDE.md 和源码。我查看时本地克隆对应的提交是 c98dbb10cf23c30e83d5d744905101b0880887a0;Gist 后续可能变化。已有有效密钥时应先验证,不必因为微信重启就重新取钥。首次取钥需要保存草稿、正常退出原版微信,并准备在临时副本中完成手机登录确认。
下面只展示脱敏后的命令形状;<账号目录>、<会话 ID> 和私有目录必须由使用者在本人设备上确定,不能照抄别人的账号 ID:
1 | WX_DB="$HOME/Library/Containers/com.tencent.xinWeChat/Data/Documents/xwechat_files/<账号目录>/db_storage" |
read 默认只给出最近一批消息,并有单次条数上限;本次按日期导出全部 31 条使用了本机另写的跨分库日期查询脚本,不能把上面的 read --limit 20 误认成完整日期导出。JSONL 是“一行一个 JSON 对象”,这里只给字段示意,不是真实消息:
1 | {"created_at":"<时间>","sender_id":"<已脱敏>","from_self":false,"message_type":1,"text":"<示例内容>"} |
数据库可能启用 WAL。已提交的新记录可以仍在 -wal 文件中,只复制 .db 可能漏消息;本次通过 SQLCipher 只读连接读取当前库及其 WAL。SQLite 官方 WAL 文档说明了读者与 WAL/SHM 的关系。这里的“只读”指数据库连接使用 SQLITE_OPEN_READONLY 并设置 PRAGMA query_only=ON,不等于操作系统绝不会触碰辅助文件。Gist 的 sqlcipher_probe.py
适用范围与隐私边界
- 本机结果限于上面的 macOS 和微信版本;SIP 开启时能否成功应以对应设备的实测为准。Intel、Rosetta、旧版微信或更改了 SQLCipher 参数的版本不能直接套用。
- 当前数据库中的历史消息通常可用当前数据库的有效密钥读取;不需要安装当年产生消息时的旧客户端。对旧客户端本身重新取钥,则要重新验证调用路径和数据库格式。
- 密钥文件、JSONL、截图与日志都可能包含隐私或附件凭据。不要把它们提交到仓库、贴进 issue 或发给他人;公开求助时只提供脱敏的系统/微信版本、错误类型和验证数量。
- 取钥成功不等于某个聊天已完整同步到本机;
text: null也不等于空消息,可能是媒体或尚未解析的载荷。涉及重要记录时要检查会话范围、时间区间和导出行数。Gist 的读取说明
资料与溯源
- acmerfight 的 macOS 微信 4.x 本地工具 Gist:本次临时副本、LLDB 捕获、逐库验证及读取方案的直接来源;重点看
KEY_GUIDE.md、QUICKSTART.md、bootstrap_keys.py、capture_keys.py、sqlcipher_probe.py。本地核对提交:c98dbb10cf23c30e83d5d744905101b0880887a0。 - Apple:Debugging tool entitlement、Apple:Resolving common notarization issues:调试任务端口与
get-task-allow的权限背景。 - Zetetic:SQLCipher API:数据库盐、PBKDF2、页面 HMAC 和配置参数。
- SQLite:Write-Ahead Logging、Result and Error Codes:WAL 读写、只读打开条件与错误码 26。