正值 2024 年的最后一天,群友突发奇想,该做群聊年终总结了。而众所周知,自从 QQ 全平台更新到了 NT 架构,原有的聊天记录导出方法就全都失效了。本文记录一下根据网上现有方案导出 Android 版 NTQQ (版本 9.1.5)聊天记录的过程。
我准备了一台 root 过的小米 15,以及一台 Linux PC。
导出数据库
其实由于各平台使用了统一的 NT 架构进行核心的消息处理,各个平台导出的数据库大同小异,但获取密钥的方式差异比较大。PC 版 QQ 需要使用 IDA 进行反编译调试,试了几次并没有成功,Android 设备没有 root 的读者可以尝试使用 PC 版 QQ 的方法,在此不详述。而 root 后的 Android 手机可以通过 adb shell 运行 su
切换为 root 用户,这样能够方便地进入 QQ 的私有目录浏览所需的文件。
dada:/ $ su
dada:/ # whoami
root
1
2
3
|
dada:/ $ su
dada:/ # whoami
root
|
密钥由 uid
和 rand
两个参数生成。其中 uid
并不是用户的 QQ 号,而是形如 u_xxxxxxx
的一串字符串;rand
嵌在数据库文件中。
首先获取 uid
。进入 /data/data/com.tencent.mobileqq/files/uid/
目录,其中的文件名记录了 QQ 号和 uid
的对应关系。
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
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
可以在 页面顶部的小工具中计算得到。
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
......
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
将其保存到电脑上:
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)
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
将其找出,或使用其他类似工具读取二进制文件:
$ strings nt_msg.db | grep --context 3 "QQ_NT"
SQLite header 3
QQ_NT DB
Z68aUxXX
1.0.0.1" HMAC_SHA1
\[`-
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
。
解密数据库
先计算数据库口令,在 顶部的小工具中可以计算。看起来腾讯在数据库的头上加了些元数据,先把它截掉:
tail -c +1025 nt_msg.db > nt_msg.clean.db
1
|
tail -c +1025 nt_msg.db > nt_msg.clean.db
|
之后用 sqlcipher 打开,输入以下内容,将你的口令替换进去,尝试解密:
$ 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
......
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
......
|
如果能像上面这样看到表名,那么到这一步都是正确的。解密时,若按照 中的教程操作可能出现“database disk image is malformed”,这可能并没有出问题。这时候可以先 dump 下来,然后再导入未加密的 SQLite 数据库:
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
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 很抽象,其中一列甚至是二进制。好在网络上已经有了数据库字段含义 和二进制 Protobuf payload 定义,对于提取文字消息而言是非常够用的。
首先将该链接内的 Protobuf 定义编译为 Python 类:
protoc --python_out=. message.proto
1
|
protoc --python_out=. message.proto
|
我让 Claude 帮我写了一份代码,用于把特定群组的文字消息导出为 JSON,包含发送方和发送时间,效果还行,搭配上述 Python 类食用即可,可以选择发送时间:
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()
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()
|
用法:
$ 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)
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)
|
最后祝各位新年快乐!