记导出 Android 版 NTQQ 聊天记录

正值 2024 年的最后一天,群友突发奇想,该做群聊年终总结了。而众所周知,自从 QQ 全平台更新到了 NT 架构,原有的聊天记录导出方法就全都失效了。本文记录一下根据网上现有方案导出 Android 版 NTQQ (版本 9.1.5)聊天记录的过程。

我准备了一台 root 过的小米 15,以及一台 Linux PC。

导出数据库1

其实由于各平台使用了统一的 NT 架构进行核心的消息处理,各个平台导出的数据库大同小异,但获取密钥的方式差异比较大。PC 版 QQ 需要使用 IDA 进行反编译调试,试了几次并没有成功,Android 设备没有 root 的读者可以尝试使用 PC 版 QQ 的方法,在此不详述。而 root 后的 Android 手机可以通过 adb shell 运行 su 切换为 root 用户,这样能够方便地进入 QQ 的私有目录浏览所需的文件。

shell
1
2
3
dada:/ $ su
dada:/ # whoami
root

密钥由 uidrand 两个参数生成。其中 uid 并不是用户的 QQ 号,而是形如 u_xxxxxxx 的一串字符串;rand 嵌在数据库文件中。

首先获取 uid。进入 /data/data/com.tencent.mobileqq/files/uid/ 目录,其中的文件名记录了 QQ 号和 uid 的对应关系。

shell
1
2
3
dada:/ # ls -l /data/user/0/com.tencent.mobileqq/files/uid/                                                                              
total 0
-rw------- 1 u0_a327 u0_a327 0 2023-12-22 18:49 2660000000###u_U6vwQunVqPOUxxxxxxxxxx

然后获取数据库。这个时候强烈建议暂时关闭 QQ 的 自启动和后台运行权限,以防 QQ 运行时修改数据库,从而影响校验和计算和比对。其数据库保存在 /data/user/0/com.tencent.mobileqq/databases/nt_db/nt_qq_<QQ_path_hash>/nt_msg.db 中,其中 QQ_path_hash 可以在 1 页面顶部的小工具中计算得到。

shell
1
2
3
4
5
6
7
8
9
dada:/ # ls -lh /data/user/0/com.tencent.mobileqq/databases/nt_db/nt_qq_5937760ce9717f9d58748067ce3a5e3b                                                            
total 3.5M
......
-rw------- 1 u0_a327 u0_a327 2.3G 2024-12-31 18:20 nt_msg.db
-rw------- 1 u0_a327 u0_a327  16K 2024-12-31 15:56 nt_msg.db-first.material
-rw------- 1 u0_a327 u0_a327  16K 2024-12-31 15:48 nt_msg.db-last.material
-rw------- 1 u0_a327 u0_a327  32K 2024-12-31 18:21 nt_msg.db-shm
-rw------- 1 u0_a327 u0_a327 499K 2024-12-31 18:21 nt_msg.db-wal
......

先将数据库复制到一个普通用户可以获取的位置,然后使用 adb pull 将其保存到电脑上:

shell
1
2
3
4
5
dada:/ # cp /data/user/0/com.tencent.mobileqq/databases/nt_db/nt_qq_5937760ce9717f9d58748067ce3a5e3b/nt_msg.db /sdcard/

# 这之后都在本机 shell 中运行
$ adb pull sdcard/nt_msg.db ./
sdcard/nt_msg.db: 1 file pulled, 0 skipped. 171.7 MB/s (2487477248 bytes in 13.817s)

提取字符串后,建议在手机和 PC 上分别计算校验和,确保数据完整性。 rand 字段在数据库二进制文件的 QQ_NT 字段附近。使用 strings 提取数据库中的可读字符串,然后使用 grep 将其找出,或使用其他类似工具读取二进制文件:

shell
1
2
3
4
5
6
$ strings nt_msg.db | grep --context 3 "QQ_NT"
SQLite header 3
QQ_NT DB
Z68aUxXX
1.0.0.1" HMAC_SHA1
\[`-

由上述信息,得到 rand 字符串为 Z68aUxXX

解密数据库2

先计算数据库口令,在 1 顶部的小工具中可以计算。看起来腾讯在数据库的头上加了些元数据,先把它截掉:

shell
1
tail -c +1025 nt_msg.db > nt_msg.clean.db

之后用 sqlcipher 打开,输入以下内容,将你的口令替换进去,尝试解密:

shell
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
$ sqlcipher nt_msg.clean.db
SQLite version 3.45.3 2024-04-15 13:34:05 (SQLCipher 4.6.0 community)
Enter ".help" for usage hints.
sqlite> PRAGMA key = '<your-key>';
PRAGMA kdf_iter = 4000;
PRAGMA cipher_hmac_algorithm = HMAC_SHA1;
ok
sqlite> SELECT name FROM sqlite_master WHERE type='table';
c2c_msg_table
c2c_msg_flow_table
group_msg_table
group_msg_flow_table
c2c_temp_msg_table
c2c_temp_msg_flow_table
......

如果能像上面这样看到表名,那么到这一步都是正确的。解密时,若按照 2 中的教程操作可能出现“database disk image is malformed”,这可能并没有出问题。这时候可以先 dump 下来,然后再导入未加密的 SQLite 数据库3

shell
1
2
3
4
sqlite> .output nt_msg.sql
sqlite> .dump
sqlite> .exit
$ cat nt_msg.sql | sed -e 's|^ROLLBACK;\( -- due to errors\)*$|COMMIT;|g' | sqlite3 nt_msg.decrypt.db

现在得到的 nt_msg.decrypt.db 就是解密后的数据库了,可以直接打开查看。

导出聊天记录

数据库的 schema 很抽象,其中一列甚至是二进制。好在网络上已经有了数据库字段含义 4 和二进制 Protobuf payload 定义5,对于提取文字消息而言是非常够用的。

首先将该链接内的 Protobuf 定义编译为 Python 类:

shell
1
protoc --python_out=. message.proto

我让 Claude 帮我写了一份代码,用于把特定群组的文字消息导出为 JSON,包含发送方和发送时间,效果还行,搭配上述 Python 类食用即可,可以选择发送时间:

python
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
import sqlite3
import base64
import json
from datetime import datetime
import argparse
from message_pb2 import Message

def timestamp_to_datetime(timestamp):
    """将时间戳转换为可读的日期时间格式"""
    return datetime.fromtimestamp(timestamp).strftime('%Y-%m-%d %H:%M:%S')

def decode_message(blob_content):
    """解码BLOB数据并解析protobuf消息"""
    try:
        message = Message()
        message.ParseFromString(blob_content)
        return message
    except Exception as e:
        print(f"Error decoding message: {e}")
        return None


def extract_text_messages(message):
    """从消息中提取文字内容"""
    messages = []
    for single_msg in message.messages:
        if single_msg.messageType == 1:  # 文字消息
            print("Debug info for message:")
            print(f"Raw sendTimestamp: {single_msg.sendTimestamp}")
            print(f"Raw senderUid: {single_msg.senderUid}")
            print(f"Raw messageText: {single_msg.messageText}")
            print("Message full content:")
            print(single_msg)
            print("------------------------")
            
            messages.append({
                'time': timestamp_to_datetime(single_msg.sendTimestamp),
                'sender_id': single_msg.senderUid,
                'content': single_msg.messageText
            })
    return messages


def fetch_messages(db_path, group_id, start_time=None, end_time=None):
    """从数据库中获取消息"""
    conn = sqlite3.connect(db_path)
    cursor = conn.cursor()
    
    query = 'SELECT "40050", "40033", "40800" FROM group_msg_table WHERE "40027" = ?'
    params = [group_id]
    
    if start_time:
        query += ' AND "40050" >= ?'
        params.append(start_time)
    if end_time:
        query += ' AND "40050" <= ?'
        params.append(end_time)
    
    query += ' ORDER BY "40050" ASC'
    
    try:
        cursor.execute(query, params)
        results = cursor.fetchall()
        
        all_messages = []
        for timestamp, sender_id, content in results:
            if content:  # 确保内容不为空
                message = decode_message(content)
                if message:
                    # 从protobuf中只提取消息文本
                    for single_msg in message.messages:
                        if single_msg.messageType == 1:  # 文字消息
                            all_messages.append({
                                'timestamp': timestamp_to_datetime(timestamp),
                                'sender_id': sender_id,
                                'content': single_msg.messageText
                           python extract.py --help                                                                                                                      20:21:06   
usage: extract.py [-h] [--start START] [--end END] [--output OUTPUT] db_path group_id

Extract QQ group chat messages

positional arguments:
  db_path          Path to the SQLite database file
  group_id         Group ID to extract messages from

options:
  -h, --help       show this help message and exit
  --start START    Start timestamp (optional)
  --end END        End timestamp (optional)
  --output OUTPUT  Output JSON file path (optional)

        conn.close()


def main():
    parser = argparse.ArgumentParser(description='Extract QQ group chat messages')
    parser.add_argument('db_path', help='Path to the SQLite database file')
    parser.add_argument('group_id', type=int, help='Group ID to extract messages from')
    parser.add_argument('--start', type=int, help='Start timestamp (optional)')
    parser.add_argument('--end', type=int, help='End timestamp (optional)')
    parser.add_argument('--output', help='Output JSON file path (optional)')
    
    args = parser.parse_args()
    
    messages = fetch_messages(args.db_path, args.group_id, args.start, args.end)
    
    # 将结果转换为JSON
    output = {
        'group_id': args.group_id,
        'messages': messages
    }
    
    # 输出结果
    if args.output:
        with open(args.output, 'w', encoding='utf-8') as f:
            json.dump(output, f, ensure_ascii=False, indent=2)
    else:
        print(json.dumps(output, ensure_ascii=False, indent=2))

if __name__ == "__main__":
    main()

用法:

shell
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
$ python extract.py --help
usage: extract.py [-h] [--start START] [--end END] [--output OUTPUT] db_path group_id

Extract QQ group chat messages

positional arguments:
  db_path          Path to the SQLite database file
  group_id         Group ID to extract messages from

options:
  -h, --help       show this help message and exit
  --start START    Start timestamp (optional)
  --end END        End timestamp (optional)
  --output OUTPUT  Output JSON file path (optional)

最后祝各位新年快乐!

许可证:CC BY-SA 4.0
最后更新于 2024 年 12 月 31 日 20:23