吾爱破解 - 52pojie.cn

 找回密码
 注册[Register]

QQ登录

只需一步,快速开始

查看: 573|回复: 6
上一主题 下一主题
收起左侧

[原创] 某IM4.x 魔改 SQLCipher 的完整逆向笔记,电脑版聊天记录导出实录

  [复制链接]
跳转到指定楼层
楼主
dog6507371 发表于 2026-11-5 14:35 回帖奖励

对象:某 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 解密,一个都不对。

复盘后原因有两个:

  1. 命中的 salt 大多出现在数据库封装层的表结构/模式字符串附近,是文件头副本,不是加解密上下文;
  2. 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

放弃暴力搜索后,改成结构体定位:

  1. 在内存中找到文件 salt;
  2. 搜索指向这块 salt 的 8 字节指针;
  3. 指针命中处向前回溯,校验加解密上下文的字段:
+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。图片、语音、视频、文件以占位符保留标题,媒体文件本体仍在原数据目录。

十、踩坑清单(按痛苦程度排序)

  1. 掩码:内存里的 key 是"真 key XOR 32 字节 mask",不反推 mask,所有 AES 尝试都是徒劳。
  2. WAL 里的 page1:磁盘 page1 可能是旧版本,比对前先看 WAL。
  3. 页格式偏移 16 字节:page1 解密结果从 SQLite 页头偏移 16 开始,不是魔数开头。
  4. 每库独立 key:不要拿一个库的 key 去套所有库。
  5. salt 邻域不可靠:盐和 key 是分开的堆块,必须走结构体指针。
  6. PBKDF2 参数是 256000,但客户端不走口令派生:每库是随机 raw key,PBKDF2 只用于派生 HMAC key(2 次迭代)。
  7. 复制数据库要连 -wal:否则会少最新消息。
  8. 运行中持续写库:读一致性快照前让它落定,或退出客户端。

十一、参考

fig10_result.png (120.02 KB, 下载次数: 0)

fig10_result.png

免费评分

参与人数 4吾爱币 +4 热心值 +4 收起 理由
yueshuang98 + 1 + 1 用心讨论,共获提升!
buluo533 + 1 + 1 用心讨论,共获提升!
helian147 + 1 + 1 热心回复!
yikepinguo + 1 + 1 我很赞同!

查看全部评分

发帖前要善用【论坛搜索】功能,那里可能会有你要找的答案或者已经有人发布过相同内容了,请勿重复发帖。

沙发
陪初雪去看雪 发表于 2026-11-5 17:45
牛的牛的,大佬学习到了 hhh
3#
ABOI 发表于 2026-11-5 17:55
4#
wchy056 发表于 2026-11-5 18:46
5#
monica75 发表于 2026-11-5 20:24
看不懂,但感谢分享!
6#
Sean021 发表于 2026-11-5 22:15
大牛啊 很详细了   太仔细了
7#
zzyy0093 发表于 2026-11-6 01:32
牛的牛的,学习到了
您需要登录后才可以回帖 登录 | 注册[Register]

本版积分规则

返回列表

RSS订阅|小黑屋|处罚记录|联系我们|吾爱破解 - 52pojie.cn ( 京ICP备16042023号 | 京公网安备 11010502030087号 )

GMT+8, 2026-11-6 01:43

Powered by Discuz!

Copyright © 2001-2020, Tencent Cloud.

快速回复 返回顶部 返回列表