@Yukimeepo #12
当然有风险,用别怕,怕别用
找个mac mini之类的长期开机来做,不要用VPS
我目前的方案:
不接入微信的网络协议,也不依赖任何官方收发 API。直接读取微信在本地
的 SQLCipher 加密消息数据库,再用游标增量轮询把新行交给上层处理。
所谓"实时",本质是:以固定短间隔对已解密的本地库做基于游标的增量查询。
延迟 ≈ 轮询间隔 + 上层节流,而不是服务器推送。
三层结构:
[注入层] 进程内 dylib —— 抓取 SQLCipher 主密钥(并可关闭防截屏)
│ 注入进一份固定版本、已重签名的客户端
▼
[解密读取层] 只读解密器 + 读取脚本 —— 用密钥解密、跑只读 SQL、解码正文、反查发送者
▼
[桥接轮询层] follow 循环 —— 持游标循环 fetch(since=cursor),逐行吐 NDJSON
│ 每个会话一个子进程
▼
[消费层] reader 适配器 + 房间循环 —— 解析 NDJSON、看门狗、持久化游标、退避重启
▼
事件与游标同事务落库 → 上层观察/直答流水线
为什么是"读库"而不是"调 API"
- 现代微信桌面端是 Qt/C++ 架构,消息落在加密的 SQLCipher 库里,
没有可稳定 hook 的应用内消息层,也没有可用的收发接口。
- 因此唯一稳的抓取点是:拿到 SQLCipher 主密钥 → 只读解密本地库 → 增量读新行。
- 主密钥由客户端启动时经系统的密钥派生函数(
CCKeyDerivationPBKDF,来自
CommonCrypto)生成;在这个系统符号上做拦截把密钥取出来 —— 相对跨版本稳定。
逐层详解
1. 注入层:拿到解密密钥
在客户端进程内加载一个小 dylib,用 fishhook 重绑定系统的
CCKeyDerivationPBKDF。这样每次客户端启动、派生 SQLCipher 主密钥时,
就能在函数返回路径上把那把 32 字节密钥截获并落到一个本地临时位置,供读取层取用。
注入方式是给客户端可执行文件追加一条 LC_LOAD_DYLIB 加载命令并重签名
(自签 / Developer-ID),让 dylib 随进程启动自动加载。可选地再注入一个 dylib
swizzle 掉窗口的防截屏属性,便于可视化/发送路径。
工程前提:让 bot 运行一份被冻结、已打补丁并重签名的固定版本客户端,
而不是会自动升级的那份。固定版本 = 抓取契约稳定,这是整套机制稳定性的第一来源。
挂载、补丁完整性、进程存活、密钥可用性应由一个看门狗持续自检并可自愈。
2. 解密读取层:只读解密 + 结构化输出
解密器用捕获到的密钥以口令模式(sqlite3_key(),复刻客户端做法)打开
SQLCipher 库,只执行单条只读 SQL。加固要点:
- 三重只读强制:
query_only PRAGMA + SQLite authorizer + sqlite3_stmt_readonly(),
从根上杜绝任何写操作。
- 数据库路径、密钥、SQL 都经长度前缀的 stdin 私有协议传入,不走命令行参数
(避免密钥出现在 argv / 进程列表 / 诊断里)。
- 错误只回固定错误码,不回显路径、密钥或 SQL。
- 客户端运行中可并发只读,无需退出客户端。
读取脚本在其上做业务解码:定位所有数字消息分片、解码正文(明文或 zstd 压缩)、
通过 id↔用户名映射表反查发送者、输出结构化行。常见消息类型:
1=文本、3=图片、43=视频、47=表情、10000=系统。
关于密钥派生的通用事实:主密钥 32 字节,PBKDF2-HMAC-SHA512,rounds=256000
(SQLCipher 4 默认)。这些参数一旦被客户端改动,解密即失效(见末节)。
3. 桥接轮询层:游标驱动的 follow
工具自带的"跟随最新"命令每次启动都从最新消息开始,会漏消息。因此桥接层改成
接受一个显式游标,由消费层持久化、重启后续读:
loop:
刷新分片列表(不重启即可拾取新生成的分片)
fetch(since = cursor, forward_limit = N) # 只取游标之后的新行
for row in rows:
清洗/降级(见"健壮性")
emit(row) # 一行一个 JSON
cursor = max(cursor, row.local_id)
emit({control: "poll"}) # 一轮结束的心跳
sleep(interval) # 固定短间隔
输入(会话 id / 起始游标 / 间隔 / 初始同步模式)同样走 stdin 私有请求,不落 argv。
输出协议(每行一个 JSON 对象):
形态 |
含义 |
|---|
数据行 {local_id, type, sender, time, text} |
一条消息(已清洗) |
{control: "poll"} |
一轮轮询结束的心跳(供健康统计与卡死看门狗喂狗) |
{control: "baseline", local_id: N} |
延迟基线已确立(见"初始同步") |
4. 消费层:适配器 + 会话循环
- reader 适配器:启动
follow 子进程,逐行 readline 解析,校验行大小、JSON 合法性、
control 类型,把数据行上交业务。内置卡死看门狗:只要 stdout 有数据(包含每轮的
poll 心跳)就重置计时;超过"命令超时 + 轮询间隔"仍无动静即判定卡死并杀掉子进程。
因此 poll 心跳不能省 —— 它是"进程还活着"的唯一证据。子进程以独立进程组启动,
退出时整组回收,避免留下孤儿。
- 会话循环:每个会话一个循环 / 一个
follow 子进程。取持久化游标 → 收到的每行
经业务摄取 → 出错则置退避状态并按退避间隔重启。
游标与初始同步语义
游标是"已消费到的最大 local_id",单调不减(存储层强制 MAX(...))。
initial_sync 决定首次基线:
- 从零开始:游标从 0 起,
follow 从头读(会回放历史)。
- 从最新开始:基线取当前最新
local_id,只读之后的新消息。
- 若首次游标为 0,
follow 进入"等待基线"态:先不吐历史,等库里出现消息后把
游标钉到当时最新值,再发 baseline 心跳。
- 意义:防止一份迟到同步的数据库在启动瞬间把历史当"新消息"倒灌进来。
崩溃边界:一条新消息的事件落库与游标推进必须在同一事务提交 —— 要么都成,
要么都不成。重复出现的库行凭稳定事件身份幂等,不会被重复处理。
健壮性设计(改动前务必理解)
- 毒行绝不能钉死游标:解不出 / 超限 / 含非法字段的行,要降级成"最小可推进游标"
的 tombstone(空 sender/text),而不是抛异常。否则 reader 崩溃重启后游标停在毒行上
→ 死循环。原则是"宁可丢这一行的内容,也要让游标继续前进"。
- 大小上限:单条正文与单行输出都设硬上限,超限即降级为 tombstone,防止异常大行
撑爆管道或内存。
- 内容无关的日志:控制/错误输出只含类型码,不泄露路径、密钥、正文。
- 分片热更新:每轮重新枚举消息分片,新分片无需重启即被拾取。
- 看门狗分层:进程内卡死看门狗(卡死即杀)+ 会话级退避重启 + 环境级看门狗
(挂载 / 补丁 / 密钥 / 一次真正的解密探针作为端到端 golden signal,可自动修复)。
对客户端版本的敏感度(升级即可能失效)
按脆弱程度排序,便于排障定位:
环节 |
抗更新 |
说明 |
|---|
密钥抓取(hook 系统密钥派生函数) |
强 |
hook 的是系统符号,跨版本稳定 |
SQLCipher 参数(哈希 / rounds / cipher) |
中 |
客户端改 KDF 或加密参数则解不开 |
数据库 schema(消息表、id↔用户名映射、媒体索引、联系人库) |
弱 |
表/字段漂移直接让查询失败 |
加密媒体容器格式 |
弱 |
通常按固定版本适配,格式变了要重做 |
注入(加载命令 + 重签名) |
中 |
客户端改签名/加固方式时,重注入流程可能要改 |
核心策略:靠"钉死版本 + 看门狗自愈"回避大多数冲击。 真需要迁到新版本时:
- 优先保持不升级那份冻结拷贝(除非旧版无法登录或被服务端淘汰)。
- 在新二进制上重建注入链:重新注入 + 重签名,先确认密钥仍能被抓到。
- 用只读 fixture 离线校验 SQLCipher 参数、消息表/映射表/媒体索引 schema、
媒体容器格式,全绿再切生产。
- 切换采用 drain-first reload(读进程继续、游标保留),避免升级窗口丢消息。
一句话总结
抓密钥(hook 系统符号,稳)→ 只读解密本地库(严格只读 + 私有协议)→
游标增量轮询(单调游标 + 事务落库 + 毒行降级)→ 多层看门狗兜底。
稳定性来自钉死一个已打补丁的客户端版本;真正需要人工跟进的,只有
schema 与媒体格式的版本漂移,而这些都能用 fixture 在切生产前先验证。