对象:某 Windows 桌面即时通讯客户端(4.1.x 版本)
环境:Windows 10 / Python 3.10
结果:9 万条消息、322 个会话、1 万联系人、432 个群,导出为 Excel
一、背景
社区里几个常用的聊天记录导出项目相继因合规原因下架后,想备份自己的数据就只能自己动手。
旧版本的思路很统一:数据库文件头 16 字节是 salt;在客户端进程内存里找到 salt 之后紧跟的 32 字节密钥;再用 PBKDF2 派生 SQLCipher 的 key,按标准页格式解密。
这套方法在新版客户端上完全失效。前后折腾了几天,最后定位到根因:目标客户端用的是 SQLCipher 4.10 的魔改版,标准参数(AES-256-CBC、page 4096、reserve 80、HMAC-SHA512)都不变,但内存中的密钥被 32 字节随机掩码保护,直接扫到的"key"永远解不开数据库。
本文把完整的排查过程、关键代码和踩过的坑记录下来。
二、环境与现象
数据目录下有 28 个 .db:
- 5 个消息分片库(按时间/容量分片,单个最大约 46 MB);
- 1 个全文索引库、1 个媒体资源索引库;
- 联系人、会话、收藏、朋友圈、表情等若干附属库。
共同特征:
- 每个文件前 16 字节是随机 salt,其余全是高熵密文;
- 文件大小均为 4096 的整数倍;
-wal 头里 page size = 4096,说明是 WAL 模式;
- 用 SQLite 直接打开一律报
file is not a database;
- 在运行时的 Chromium 组件里能搜到
SQLCipher 4.10.0 community、sqlcipher_extra_shutdown、cipher_hmac_pgno 等字符串。
一开始以为只是 SQLCipher 4 的标准加密,后来发现远不止。
图1:文件头 16 字节 salt,其余为高熵密文;直接打开报 file is not a database。
三、第一轮失败:key 不在 salt 旁边
最流行的方案是"盐邻域找 key"。我写脚本枚举所有库的 salt,在客户端进程内存中扫描,命中约 57 处。
但把这些位置前后 96 字节内所有 32 字节窗口都拿去试 AES 解密,一个都不对。
复盘后原因有两个:
- 命中的 salt 大多出现在数据库封装层的表结构/模式字符串附近,是文件头副本,不是加解密上下文;
- SQLCipher 的
salt、key、hmac_key 是三块独立分配的堆内存,结构体里只存指针,它们并不会在内存里相邻。
结论:必须顺着结构体指针找 key,不能靠"盐附近"猜。
图2:命中的 salt 多是文件头副本;真正的 salt / key / hmac 是三块独立堆内存。
四、第二轮失败:page1 的偏移算错 16 字节
为了验证算法,我用 SQLCipher 自己生成了一个已知口令的测试库,逐字节对照,才发现一个非常隐蔽的问题:
SQLCipher 加密第 1 页时,文件第 0..16 字节是明文 salt;密文从文件偏移 16 开始。解密出来的明文不是从 SQLite format 3 开头,而是从 SQLite 页头偏移 16 开始——第一个字段就是 page size。
iv = page[4016:4032]
ct = page[16:4016] # page1 特殊:跳过前 16 字节 salt
pt = AES_CBC_decrypt(key, iv, ct)
# pt[0:2] == b"\x10\x00",pt[2:4] 是读写版本
我早期校验写成了 pt[16:18] == 10 00,偏移完全反了,导致大量正确候选被误杀。这个小错误直接让我浪费了大半天。
顺带确认了本机数据库的页头特征是 10 00 02 02 50 40 20 20:
- page size 4096;
- 读写版本 2/2(WAL 模式,旧资料里常见的 1/1 是非 WAL);
- reserved = 0x50 = 80;
- payload 分数 64/32/32。
图3:page1 的 salt 明文存放;解密结果对应页头偏移 16 起;reserve=80。
五、第三轮失败:最新的 page1 在 WAL 里
本机是 WAL 模式。磁盘上的主库文件可能停留在几小时前的检查点,最新的 page1 其实在 -wal 里。
我拿磁盘 page1 和内存里的明文页做逐块比对,一直对不上,一度怀疑 key 找错了。后来解析 WAL 结构才明白:
WAL 头 32 字节 + 每帧 [24 字节帧头 + 4096 字节页数据]
帧头里的 pgno 是明文,可以直接读
page1 的最新版本往往就在某个 WAL 帧里。任何"磁盘页 ↔ 内存明文页"的校验,都必须先考虑 WAL。
还有一个坑:本机 SQLCipher 会对 WAL 头部的 salt/校验和做特殊处理,手工解析时帧盐与文件头盐可能对不上,导致校验和算法怎么算都不匹配。最稳的办法是让 SQLCipher 自己读(连同 -wal 一起打开),或者干脆退出客户端,让它把 WAL 合并回主库。
图4:最新的 page1 往往在 WAL 帧里;帧头 pgno 为明文。
六、第四轮:顺着指针找到 codec_ctx
放弃暴力搜索后,改成结构体定位:
- 在内存中找到文件 salt;
- 搜索指向这块 salt 的 8 字节指针;
- 指针命中处向前回溯,校验加解密上下文的字段:
+0 store_pass +4 kdf_iter (256000) +8 fast_kdf_iter (2)
+12 kdf_salt_sz (16) +16 key_sz (32) +20 iv_sz (16)
+24 block_sz (16) +28 page_sz (4096) +32 keyspec_sz (99)
+36 reserve_sz (80) +40 hmac_sz (64)
+72 kdf_salt 指针 +80 hmac_kdf_salt 指针
+104 read_ctx 指针 +112 write_ctx 指针
其中 keyspec_sz = 99 = (32+16)*2+3 是非常强的特征,能把误报基本过滤干净。
沿着 read_ctx 再走一层,里面还有:
+8 key 指针
+16 hmac_key 指针
到这里终于拿到了"看起来像 key"的东西:
- 每个库各不相同;
- 读、写上下文一致;
- 同一进程内长时间不变;
- 换一台机器/换一个账号完全不同。
并且是每库独立 key,不是旧版本那种"全库共用一个 key"。
但——它解不开数据库。
图5:codec_ctx 字段与两层指针;keyspec_sz=99 是很强的特征值。
七、真正的答案:32 字节掩码
7.1 三个相互矛盾的现象
- 拿到的 key 去解 page1:失败;
- 拿 hmac_key 去验 HMAC:失败;
- key 与 hmac_key 之间不满足 SQLCipher 的派生关系
H = PBKDF2(K, salt^0x3a, 2)。
如果 key 是明文,派生关系应该成立;如果它只在加解密瞬间还原成明文,那么高频采样应该能看到缓冲区"翻转"。我写了个采样器,30 秒内读取这些 key 缓冲区约 4.7 万轮,结果一次翻转都没有。
这三个现象指向同一个结论:常驻内存的值被某种变换保护着,而且还原发生在其他临时副本上,不在原缓冲区。
7.2 源码里的答案
翻 SQLCipher 4.10 源码 src/sqlcipher.c,找到 sqlcipher_shield:
static size_t sqlcipher_shield_mask_sz = 32;
static u8* sqlcipher_shield_mask = NULL; // 进程级随机量,首次使用时生成
static void sqlcipher_shield(unsigned char *in, int sz) {
for (int i = 0; i < sz; i++)
in[i] ^= sqlcipher_shield_mask[i % sqlcipher_shield_mask_sz];
}
密钥派生完成后:
sqlcipher_shield(c_ctx->key, ctx->key_sz);
sqlcipher_shield(c_ctx->hmac_key, ctx->key_sz);
每次加解密前再 shield 一次(异或还原),用完再 shield 一次(重新掩码)。
也就是说:
内存里的 key = 真 key XOR mask
内存里的 hmac_key = 真 hmac_key XOR mask
掩码是进程级生成的 32 字节随机数,同一进程内所有库共用同一条 mask。重启客户端,掩码就会变。
图6:内存里的 key = 真 key XOR 32 字节 mask;使用前后各 shield 一次。
7.3 用 SQLCipher 自己的关系反推掩码
掩码本身是 32 字节随机数,单看没有任何特征。但它必须让所有库的 key 同时满足派生关系:
K_true = K_masked XOR mask
H_true = H_masked XOR mask
H_true = PBKDF2-SHA512(K_true, salt ^ 0x3a, 2)
于是可以把"内存中所有指针指向的 32 字节块"当作 mask 候选,逐个代入验证:
X = lambda a, b: bytes(x ^ y for x, y in zip(a, b))
for cand in mask_candidates:
K = X(K_masked, cand)
H = X(H_masked, cand)
if hashlib.pbkdf2_hmac("sha512", K, salt_xor_3a, 2, 32) == H:
mask = cand # 命中
break
本机枚举了约 105 万个指针目标,在第 486,166 个候选上命中。命中后,还原出的 key 立刻能解开 page1,并且与 HMAC 完全自洽。
图7:用 H = PBKDF2-SHA512(K, salt^0x3a, 2) 从候选里筛出 mask。
7.4 一个佐证
掩码命中的内存地址位于客户端的 Chromium 运行时模块中,该模块里同样有 SQLCipher 4.10.0 community 和 "failed to generate requisite random mask data" 字符串,说明数据库层用的就是这版 SQLCipher 代码。
八、解密实现
拿到每库真 key 之后,剩下的相对简单。
8.1 手工解密页
def decrypt_page(page, key, pgno):
iv = page[4016:4032]
if pgno == 1:
pt = AES_CBC_decrypt(key, iv, page[16:4016])
return b"SQLite format 3\x00" + pt + b"\x00" * 80
pt = AES_CBC_decrypt(key, iv, page[0:4016])
return pt + b"\x00" * 80
8.2 用现成 SQLCipher 打开(推荐)
c = sqlcipher3.connect(db_copy) # 复制时连同 -wal 一起
c.execute("PRAGMA key = \"x'<64位hex>'\"")
c.execute("select count(*) from sqlite_master").fetchone()
raw key 形式会跳过 PBKDF2,和客户端的用法一致。WAL 由 SQLite 自动应用,省去手工处理。
8.3 WAL 的两个注意点
- 必须连同
-wal(以及可能存在的 -shm)一起复制,否则会少最新消息;
- 客户端运行中会持续写库,可能出现"文件头和帧盐不是同一代"的撕裂状态;导出前最好让它落定,或直接退出客户端再读。
图8:交给 SQLCipher 或手工逐页解密,结果都是明文 SQLite。
九、数据模型
9.1 消息表
- 每个会话一张表,表名由固定前缀和会话 ID 的 MD5 拼接;
- 同一会话可能分散在多个消息分片库中,需要按会话聚合;
- 核心字段:
local_id、server_id、local_type、real_sender_id、create_time、message_content、compress_content、sort_seq。
9.2 发送者与方向
- 发送者:
real_sender_id → 一张 rowid 到用户名的映射表 → 用户名;
- 自己的账号不在映射表里,可以用"1 对 1 会话的两个发送者取交集"推断每个分片的 self id(本机各分片为 1/6/6/4/1);
- 发送者 ID 等于 self id 即为"发送",否则为"收到"。
9.3 内容与类型
local_type & 0xFFFFFFFF 是基础类型:1 文本、3 图片、34 语音、43 视频、47 表情、49 应用消息、10000 系统、10002 撤回;
- 49 应用消息是 XML,按
<type> 区分链接、文件、引用、小程序、转账、红包等;
message_content 常见 zstd 压缩(魔数 28 b5 2f fd),解压后才是文本;
- 联系人显示名优先级:备注 > 昵称 > 用户名;
- 群成员关系另有一张 room/member 映射表,结合 contact 库可以还原成员昵称。
图9:会话分表 → 发送者映射 / 内容解析 → 可读记录与 Excel。
9.4 导出结果
| 项目 |
数量 |
| 消息 |
90,926 条 |
| 会话 |
322 个 |
| 联系人 |
10,668 |
| 群组 |
432 |
| 群成员关系 |
9,241 |
| 时间范围 |
2023-04 ~ 2026-11 |
导出两个 Excel:一个总表(说明 / 联系人 / 群组 / 群成员 / 会话汇总 / 全部消息),一个按会话分 sheet。图片、语音、视频、文件以占位符保留标题,媒体文件本体仍在原数据目录。
十、踩坑清单(按痛苦程度排序)
- 掩码:内存里的 key 是"真 key XOR 32 字节 mask",不反推 mask,所有 AES 尝试都是徒劳。
- WAL 里的 page1:磁盘 page1 可能是旧版本,比对前先看 WAL。
- 页格式偏移 16 字节:page1 解密结果从 SQLite 页头偏移 16 开始,不是魔数开头。
- 每库独立 key:不要拿一个库的 key 去套所有库。
- salt 邻域不可靠:盐和 key 是分开的堆块,必须走结构体指针。
- PBKDF2 参数是 256000,但客户端不走口令派生:每库是随机 raw key,PBKDF2 只用于派生 HMAC key(2 次迭代)。
- 复制数据库要连
-wal:否则会少最新消息。
- 运行中持续写库:读一致性快照前让它落定,或退出客户端。
十一、参考