Awesome
Project KuimiVM
你好,这里是 Kilio Kuara。
由于各方面的原因,我们最终还是将此项目开源出来了。 我们知道此项目今后可能已经没有意义了,但我们依旧希望在 QQ Bot 的历史中留下属于我们的一笔。
抛开 QQ Bot 不谈,此项目所实现的核心逻辑也是值得开源的。所以,我们选择公开此技术。
<div align="right"> <i>乐章的第二篇已经落幕,乐章的第三篇又在何处
</i> </div>Structure
/src
KuimiVM 核心/packer
打包模块/tencent
magic-signer server/struct-define
stub of AndroidQQ.apk
magic-signer-guide
此项目用于解决各种 QQ 机器人框架的 sso sign 和 tlv 加密问题。
该项目为 RPC 服务后端,并提供 HTTP API,这意味着你可以根据框架的需求实现不同 RPC 客户端。
由于该项目扮演的角色比较特殊,为了保证客户端与服务端的通信安全,需要先进行认证,才可执行业务操作。
本项目(docker 镜像 kiliokuara/vivo50
,以下相同)在可控范围内<sup>(1)</sup>不会持久化 QQ 机器人的以下信息:
- 登录凭证(token,cookie 等)
- 需要加密的 tlv 数据
- 需要签名的 sso 包数据
为优化业务逻辑,本项目会持久化 QQ 机器人的以下信息:
- 设备信息
- 由 libfekit 产生的账号相关的 Key-Value 键值对。
强烈建议自行部署 RPC 服务端,避免使用他人部署的开放的 RPC 服务端。
(1) 可控范围指 RPC 服务端的所有代码。由于项目使用到了外置库 libfekit,我们无法得知 libfekit 会持久化的信息。
支持的 QQ 版本
Android 8.9.58.11170
使用方法
通过 docker 部署 RPC 服务端
$ docker pull kiliokuara/vivo50:latest
$ docker run -d --restart=always \
-e SERVER_IDENTITY_KEY=vivo50 \
-e AUTH_KEY=kfc \
-e PORT=8888 \
-p 8888:8888 \
--log-opt mode=non-blocking --log-opt max-buffer-size=4m \
-v /home/vivo50/serverData:/app/serverData \
-v /home/vivo50/testbot:/app/testbot \
--name vivo50 \
--memory 200M \
kiliokuara/vivo50
环境变量说明:
SERVER_IDENTITY_KEY
:RPC 服务端身份密钥,用于客户端确认服务端身份。AUTH_KEY
:RPC 客户端验证密钥,用于服务端确认客户端身份。PORT
:服务端口,默认8888
。MEMORY_MONITOR
:内存监控器,默认关闭,值为定时器循环周期,单位为秒。
更多详情请参考 https://docs.docker.com/engine/reference/commandline/run/
内存使用参考
登录机器人前
2023-07-27 16:29:46 [DEBUG] [Vivo45#1] MemoryDumper - committed | init | used | max
2023-07-27 16:29:46 [DEBUG] [Vivo45#1] MemoryDumper - Heap Memory: 200.0MB | 200.0MB | 18.47MB | 200.0MB
2023-07-27 16:29:46 [DEBUG] [Vivo45#1] MemoryDumper - Non-Heap Memory: 25.25MB | 7.31MB | 23.27MB | -1
2023-07-27 16:29:51 [DEBUG] [Vivo45#2] MemoryDumper - committed | init | used | max
2023-07-27 16:29:51 [DEBUG] [Vivo45#2] MemoryDumper - Heap Memory: 44.0MB | 200.0MB | 12.08MB | 200.0MB
2023-07-27 16:29:51 [DEBUG] [Vivo45#2] MemoryDumper - Non-Heap Memory: 25.25MB | 7.31MB | 22.17MB | -1
2023-07-27 16:29:56 [DEBUG] [Vivo45#1] MemoryDumper - committed | init | used | max
2023-07-27 16:29:56 [DEBUG] [Vivo45#1] MemoryDumper - Heap Memory: 44.0MB | 200.0MB | 11.08MB | 200.0MB
2023-07-27 16:29:56 [DEBUG] [Vivo45#1] MemoryDumper - Non-Heap Memory: 25.25MB | 7.31MB | 21.62MB | -1
登录一个机器人后
2023-07-27 16:30:41 [DEBUG] [Vivo45#3] MemoryDumper - committed | init | used | max
2023-07-27 16:30:41 [DEBUG] [Vivo45#3] MemoryDumper - Heap Memory: 52.0MB | 200.0MB | 33.13MB | 200.0MB
2023-07-27 16:30:41 [DEBUG] [Vivo45#3] MemoryDumper - Non-Heap Memory: 44.56MB | 7.31MB | 41.21MB | -1
2023-07-27 16:30:46 [DEBUG] [Vivo45#3] MemoryDumper - committed | init | used | max
2023-07-27 16:30:46 [DEBUG] [Vivo45#3] MemoryDumper - Heap Memory: 52.0MB | 200.0MB | 28.15MB | 200.0MB
2023-07-27 16:30:46 [DEBUG] [Vivo45#3] MemoryDumper - Non-Heap Memory: 44.56MB | 7.31MB | 40.68MB | -1
认证流程
1. 获取 RPC 服务端信息,并验证服务端的身份
首先调用 API GET /service/rpc/handshake/config
,获取回应如下:
{
"publicKey": "", // RSA 公钥,用于 客户端验证服务端身份 和下一步的 加密握手信息。
"timeout": 10000, // 会话过期时间(单位:毫秒)
"keySignature": "" // 服务端公钥签名,用于 客户端验证服务端身份
}
为了防止 MITM Attack(中间人攻击),客户端需要验证服务端的身份。通过如下计算:
$clientKeySignature = $sha1(
$sha1( ($SERVER_IDENTITY_KEY + $publicKey).getBytes() ).hex() + $SERVER_IDENTITY_KEY
).hex()
将 clientKeySignature
与 API 返回的 keySignature
比对即可验证服务端身份。
以 Kotlin 为例:
fun ByteArray.sha1(): ByteArray {
return MessageDigest.getInstance("SHA1").digest(this)
}
fun ByteArray.hex(): String {
return HexFormat.of().withLowerCase().formatHex(this)
}
val serverIdentityKey: String = ""; // 服务端 SERVER_IDENTITY_KEY
val publicKey: String = ""; // API 返回的 publicKey 字符串,该字符串是 base64 编码的 RSA 公钥。
val serverKeySignature: String = ""; // API 返回的 keySignature 字符串,该字符串是服务端计算签名。
val pKeyRsaSha1 = (serverIdentityKey + publicKey).toByteArray().sha1()
val clientKeySignature = (pKeyRsaSha1.hex() + serverIdentityKey).toByteArray().sha1().hex()
if (!clientKeySignature.equals(serverKeySignature)) {
throw IllegalStateException("client calculated key signature doesn't match the server provides.")
}
2. 与服务端握手
在与服务端握手之前,需要客户端生成一个 16-byte AES 密钥和 4096-bit RSA 密钥对。
- AES 密钥用于加解密握手成功之后的 WebSocket 业务通信。
- RSA 密钥对用于防止使用 Replay Attacks(重放攻击)再次建立相同的 WebSocket 连接。
生成密钥后,调用 API POST /service/rpc/handshake/handshake
,请求体如下:
{
"clientRsa": "", // 客户端生成的 RSA 密钥对中的公钥,使用 base64 编码。
"secret": "....", // 握手信息,使用上一步 “获取 RPC 服务端信息” 中的 publicKey,采用 RSA/ECB/PKCS1Padding 套件加密。
}
// 握手信息如下
{
"authorizationKey": "", // 服务端的 AUTH_KEY
"sharedKey": "", // AES 密钥
"botid": 1234567890, // Bot QQ 号
}
回应如下:
{
"status": 200, // 200 = 握手成功,403 = 握手失败
"reason": "Authorization code is invalid.", // 握手失败的原因,仅握手失败会有此属性。
"token": "", // WebSocket 通信 token,使用 base64 编码,仅握手成功会有此属性。
}
将 token
进行 base64 解码,至此握手过程已结束,接下来进行 WebSocket 通信。
3. 开启 WebSocket 会话
访问 API WEBSOCKET /service/rpc/session
,请求需要添加以下 headers:
Authorization: $token_decoded <--- base64 解码后的 token
X-SEC-Time: $timestamp_millis <--- 当前时间戳,毫秒
X-SEC-Signature: $timestamp_sign <--- 时间戳签名,使用客户端 RSA 密钥,采用 SHA256withRSA 算法签名,使用 base64 编码。
WebSocket 会话开启后,即可进行业务通信。
4. 查询 WebSocket 会话状态
WebSocket 每次发送 C2S 包之前,建议验证当前 WebSocket 会话的状态。
访问 API GET service/rpc/session/check
,请求添加同 3. 开启 WebSocket 会话 的 headers。
响应状态码如下:
- 204:会话有效
- 403:验证失败,需要检查 headers。
- 404:会话不存在
4. 中断 WebSocket 会话
在任务已经完成后(例如机器人下线,机器人框架关闭等),需要主动中断 WebSocket 会话。
访问 API DELETE /service/rpc/session
,请求添加同 3. 开启 WebSocket 会话 的 headers。
响应状态码如下:
- 204:会话已中断
- 403:验证失败,需要检查 headers。
- 404:会话不存在
WebSocket 通信格式
通用规范
-
C2S(client to server) 和 S2C(server to client)的所有的包均使用客户端 AES 密钥加密为 byte array。
-
S2C 包通用格式如下:
{
"packetId": "", // 独一无二的包 ID
"packetType": "", // 包类型,对应为业务操作。
..., // 具体包类型的其他属性
}
packetId
有如下两种情况:
- C2S 包包含
packetId
,此 C2S 包需要服务端回应,则该 S2C 包为此 C2S 包的回应包,packetId
为此 C2S 包的packetId
。- 若该 S2C 包的
packetType
为rpc.service.send
,表示此 S2C 包需要客户端回应,需要 C2S 包包含该 S2C 包的packetId
。
- 业务遇到错误的 S2C 包格式如下:
{
"packetId": .......,
"packetType": "service.error",
"message": "",
}
服务端不会主动发送业务错误的包,该 packetId
一定与 S2C 包的 packetId
对应。
业务场景
会话中断
S2C
{
"packetType": "service.interrupt",
"reason": "Interrupted by session invalidate",
}
客户端收到此包意味着当前 WebSocket session 已失效,需要重新握手获取新的 session。
初始化签名和加密服务
C2S
{
"packetId": "",
"packetType": "rpc.initialize",
"extArgs": {
"KEY_QIMEI36": "", // qimei 36
"BOT_PROTOCOL": {
"protocolValue": {
"ver": "8.9.58",
}
}
},
"device": { // 除特殊标记,参数均为 value.toByteArray().hexString()
"display": "",
"product": "",
"device": "",
"board": "",
"brand": "",
"model": "",
"bootloader": "",
"fingerprint": "",
"bootId": "", // raw string
"procVersion": "",
"baseBand": "",
"version": {
"incremental": "",
"release": "",
"codename": "",
"sdk": 0 // int
},
"simInfo": "",
"osType": "",
"macAddress": "",
"wifiBSSID": "",
"wifiSSID": "",
"imsiMd5": "",
"imei": "", // raw string
"apn": "",
"androidId": "",
"guid": ""
},
},
S2C response
{
"packetId": "",
"packetType": "rpc.initialize"
}
初始化服务后才能进行 tlv 加密。
初始化过程中服务端会发送 rpc.service.send
包,详见服务端需要通过机器人框架发送包。
获取 sso 签名白名单
C2S
{
"packetId": "",
"packetType": "rpc.get_cmd_white_list"
}
S2C response
{
"packetId": "",
"packetType": "rpc.get_cmd_white_list",
"response": [
"wtlogin.login",
...,
]
}
获取需要进行 sso 签名的包名单,帮助机器人框架判断机器人框架的网络包是否需要签名。
服务端需要通过机器人框架发送包
S2C
{
"packetId": "server-...",
"packetType": "rpc.service.send",
"remark": "msf.security", // sso 包标记,可忽略
"command": "trpc.o3.ecdh_access.EcdhAccess.SsoEstablishShareKey", // sso 包指令
"botUin": 1234567890, // bot id
"data": "" // RPC 服务端需要发送的包内容 bytes.hexString()
}
C2S response
{
"packetId": "server-...",
"packetType": "rpc.service.send",
"command": "trpc.o3.ecdh_access.EcdhAccess.SsoEstablishShareKey",
"data": "" // QQ 服务器包响应的内容 bytes.hexString()
}
客户端收到 rpc.service.send
后,需要将 data
包进行 sso 包装,通过机器人框架的网络层发送到 QQ 服务器。
QQ 服务器返回后,只需简单解析包的 command 等信息,将剩余内容传入 C2S response 包的 data
。
需要注意的是,服务端需要通过机器人框架发送包全部需要 sso 签名,所以请收到 rpc.service.send
包后调用 sso 签名对包装后的网络包进行签名。
sso 签名
C2S
{
"packetId": "",
"packetType": "rpc.sign",
"seqId": 33782, // sso 包的 sequence id
"command": "wtlogin.login", // sso 包指令
"extArgs": {}, // 额外参数,为空
"content": "" // sso 包内容 bytes.hexString()
}
S2C response
{
"packetId": "",
"packetType": "rpc.sign",
"response": {
"sign": "",
"extra": "",
"token": ""
}
}
tlv 加密
C2S
{
"packetId": "",
"packetType": "rpc.tlv",
"tlvType": 1348, // 0x544
"extArgs": {
"KEY_COMMAND_STR": "810_a"
},
"content": "" // t544 内容 bytes.hexString()
}
S2C response
{
"packetId": "",
"packetType": "rpc.tlv",
"response": "" // 加密结果 bytes.hexString()
}