1. 先分清两类数据源

导 QQ 记录前,第一件事是判断手上的东西属于哪一类。走错路会白忙。

数据源形态能否离线解密
桌面上导出的备份<QQ号>的<N>个聊天记录.bak不能。库头部的 hex 是 key_meta(兑换凭证),真密钥要登录时向服务器换
本机 QQ 数据目录nt_msg.db / group_info.db / profile_info.db能。密钥就在已登录的 QQ 进程内存里

判断依据:解压 .bak 后看 db/* 文件头部偏移 32 的魔数——

  • MSG0 = 备份库(备份时单独生成的密钥,账号密钥也开不了)
  • QQ_NT DB = 本机在线库

两者 key_meta 必然不同。只要 .bak 和本机是同一个账号,本机 nt_msg.db 里通常已有同样的聊天内容,直接解本机库比导入 .bak 快得多。

.bak 结构(本质是不压缩的 ZIP):

db/<32位hex>        # 4 个 SQLCipher 库:消息本体 + group/profile 信息
files/<N>/<40位hex>  # 加密的媒体 blob,离线无解

要拿到 .bak 里的内容,唯一低成本路径是官方导入:QQ 登录对应账号 → 导入聊天记录 → 消息合并进本机 nt_msg.db → 再走下面流程。

2. 本机数据在哪

%USERPROFILE%\Documents\Tencent Files\<QQ号>\nt_qq\nt_db\
    nt_msg.db        # 消息本体(c2c_msg_table / group_msg_table)
    group_info.db    # 群列表、群成员、群昵称
    profile_info.db  # 好友资料
%USERPROFILE%\Documents\Tencent Files\<QQ号>\nt_qq\nt_data\
    Pic\<年-月>\Ori\<md5>          # 图片原图
    Video\<年-月>\Ori\<md5>.mp4    # 视频
    Ptt\<年-月>\Ori\<md5>.amr      # 语音
    File\Ori\<uuid>\<原文件名>      # 聊天里的文件

3. 抓数据库密钥(Windows)

工具:QQBackup/qq-win-db-key → scripts/windows/ntqq/windows_ntqq_get_key.ps1

# 先静态验证工具能否识别当前 QQ(不动 QQ,安全)
powershell -ExecutionPolicy Bypass -File .\windows_ntqq_get_key.ps1 -NoDebugForKey
# 抓密钥(会重启 QQ)
powershell -ExecutionPolicy Bypass -File .\windows_ntqq_get_key.ps1

原理:脚本自动定位 wrapper.node(版本目录 versions\<ver>\resources\app\wrapper.node),静态分析出 nt_sqlite3_key_v2 字符串的引用位置,然后 CreateProcessW + DEBUG_ONLY_THIS_PROCESS 拉起 QQ,在 nt_sqlite3_key_v2 下 int3 断点,登录时读出 16 字符密钥,随即 TerminateProcess。

要点:

  • 必须先关掉正在跑的 QQ,否则新实例起不来(QQ 单实例)。
  • 密钥是 16 个可打印 ASCII(如 qE[qsJAQK67X]EN4);hook 到 20 长度的 key 属于 login.db,是误报,脚本已过滤。
  • 只抓第一把,抓到就杀进程。多账号登录时抓到的可能不是目标号——用目标库做一次解密验证即可确认。
  • 脚本顶部有 UTF-8 自动重载逻辑,PowerShell 5.1 下会跑两遍:内层抓到密钥后 exit $LASTEXITCODE 抛异常被外层 catch,外层继续执行会再拉起一次 QQ。看到密钥后要把后台任务杀掉。

4. 解密(SQLCipher)

坑:每个库文件前面有 1024 字节自定义头,必须剥掉才是真正的 SQLCipher 库。

with open(src,'rb') as f:
    f.read(1024)          # 丢掉头部
    data = f.read()
open(clear,'wb').write(data)
import sqlcipher3
con = sqlcipher3.connect(clear)
cur = con.cursor()
cur.execute("PRAGMA cipher_page_size = 4096;")          # 必须在 key 之前
cur.execute(f"PRAGMA key = '{KEY}';")
cur.execute("PRAGMA kdf_iter = 4000;")                  # 以下必须在 key 之后
cur.execute("PRAGMA cipher_hmac_algorithm = HMAC_SHA1;")
cur.execute("PRAGMA cipher_kdf_algorithm = PBKDF2_HMAC_SHA512;")
cur.execute("SELECT count(*) FROM sqlite_master;")       # 能过就是密钥对了

PRAGMA 顺序是硬约束,顺序错会报 file is not a database。同一把 key 可开同一账号的所有库(每个库盐不同,PBKDF2 派生结果自然不同)。

导出成普通 SQLite:

cur.execute(f"ATTACH DATABASE '{out}' AS p KEY '';")
cur.execute("SELECT sqlcipher_export('p');")
cur.execute("DETACH DATABASE p;")

安装:pip install sqlcipher3。sqlcipher3 有 cp311 的 win_amd64 轮子,直接装;sqlcipher3-binary 只有 manylinux,Windows 上装不了,别在这上面浪费时间。

5. 消息表结构(列名是数字,SQL 里必须加引号)

c2c_msg_table(私聊)/ group_msg_table(群聊):

列含义
40001消息 id
40003序号(同一时间戳排序用)
40011消息类型(1=blank 空占位, 2=text, 3=file, 5=image, 6=voice, 7=video, 8=system, 9=reply, 10=card, 11=emoji, 16=xml)
40020发送者 uid(u_xxx)
40021会话 uid / 群号字符串
40027群号(群消息按这个分组)
40030对端 QQ 号(私聊按这个分组)/ 群号
40033发送者 QQ 号
40050时间戳(秒)
40090发送者群昵称
40093发送者备注/昵称
40800消息体 protobuf

消息体 40800 是嵌套 protobuf,元素字段:

字段含义
45002元素类型(同 40011)
45101文本
45402文件名
45405大小
45503文件元素的 <md5>_<uuid>,取 _ 后段即 File/Ori/<uuid> 目录名
45923语音转文字

空占位行:40011=1、40033=0、40050=0、body 为空的行是垃圾行(真实案例里占 9%),导出时要滤掉。

6. 媒体文件映射

数据库里不存本地路径,只存 md5 / uuid,要靠命名规则去 nt_data 里找:

类型库里给的磁盘位置
图片file_name = <MD5大写>.<ext>Pic/<年-月>/Ori/<md5小写>(无扩展名)
视频<md5>.mp4Video/<年-月>/Ori/<md5>.mp4
语音file_path 有时直接给全路径Ptt/<年-月>/Ori/<md5>.amr
文件45503 的 uuid 段File/Ori/<uuid>/<原文件名>

拇指图在 Thumb/ 下,命名 <md5>_750.jpg。原图缺失时可退回 Thumb。

7. 验证方法

  1. 解密后先试 SELECT count(*) FROM sqlite_master;,能返回数字就是密钥对了。
  2. 导出后用普通 sqlite3 打开明文库,SELECT name FROM sqlite_master 看表名。
  3. 导出完做一次引用一致性检查:txt 里引用的附件名集合 == 附件目录里的文件名集合(缺失 0、多余 0)。

8. 常见坑

症状原因
file is not a databasePRAGMA 顺序错;或密钥不对;或没剥 1024 字节头
解压 .bak 后库打不开MSG0 头 = 备份专用密钥,离线无解,必须官方导入
脚本报 UnicodeEncodeError(如 \u2062)中文 Windows 控制台是 GBK。设 PYTHONIOENCODING=utf-8 和 PYTHONUTF8=1
Python 写的 /tmp 找不到Python 的 /tmp = C:\tmp,和 Git Bash 的 /tmp(%LOCALAPPDATA%\Temp)不是一回事
附件名变成 X.jpg.jpg生成名时把 file_name 的扩展名和源文件后缀都拼上了
用 importlib 加载脚本报 dataclass 错要先 sys.modules[name] = module 再 exec_module
导出全是 [无法解码]空占位行(40011=1),按第 5 节滤掉

9. 边界与风险

  • 脚本要调试器注入 QQ 进程,仓库作者明言可能破坏数据或导致封号。密钥等于账号所有聊天库的钥匙,勿外传。
  • 抓密钥会杀掉一次 QQ。抓完记得把 QQ 重新打开。
  • 解出来的明文库、媒体文件含全部隐私与好友关系链,本地处理,不要上传任何云或仓库。
  • Windows 抓的密钥开不了同账号 macOS / 手机的库,密钥按设备独立。
  • 只用于导出自己的聊天记录。

10. 现成的参考实现

项目用途
QQBackup/qq-win-db-keyWindows 抓密钥
QQBackup/QQDecrypt分平台教程与数据库解析文档
QQBackup/nt_msg_db_utilnt_msg.db 剥头、解密、导出
fnyfree/qqnt-chat-decrypt.bak 格式分析(docs/bak-format.md 写得最清楚)
zywstudy123/qq-bak-chat-history-exportprotobuf 解码 + txt/json/csv 导出,可直接复用其 decode_message_blob

相关:QQ 群聊风格蒸馏与调侃人格提示词 (Distilling a Group-Chat Persona from NapCat Logs)、NapCat 多实例部署与 AstrBot 入群门槛审核 (NapCat Multi-Instance & AstrBot Join Moderation)


本文归档于个人知识库 · 领域:CS & AI · 模块:04 Project_CS_AI/AI apps