虎牙App密码登录: 逆向WUP协议并实现纯Python登录编码器
- hook WUP序列化层抓取TLS加密前明文,确认密码登录走 servant=huyaudbwebui / func=hypasswordLogin (msgType=0x1001) - 逆向JCE RequestPacket帧格式(1015字节TAF二进制),定稿三层结构: WUP header(tag1-10) -> sBuffer Map -> _wup_data业务struct - 澄清两个疑点字节: 0x66/0x7d 是TAF字段头(非分隔符) - 实现 tools/huya_wup_encoder.py,逐字节复现金标准 - 实测纯Python动态构造登录成功(返回uid+token),TLS指纹不阻断 - userAction随机坐标/时间戳同样通过,内置make_user_action辅助函数 - 新增证据金标准 + 协议文档
This commit is contained in:
@@ -20,6 +20,11 @@ bg.jpg
|
|||||||
fullbg.jpg
|
fullbg.jpg
|
||||||
slice.jpg
|
slice.jpg
|
||||||
|
|
||||||
|
# 虎牙登录抓包证据(含有效登录 token / 手机号掩码,勿提交)
|
||||||
|
evidence/huya_token.txt
|
||||||
|
evidence/wup_response_login.bin
|
||||||
|
evidence/wup_response_new_session.bin
|
||||||
|
|
||||||
# Node 依赖
|
# Node 依赖
|
||||||
**/node_modules/
|
**/node_modules/
|
||||||
web/frontend/dist/
|
web/frontend/dist/
|
||||||
|
|||||||
@@ -0,0 +1,309 @@
|
|||||||
|
# 虎牙 App 密码登录 — WUP 明文抓包实证(最终)
|
||||||
|
|
||||||
|
> 状态:**密码登录完整 WUP 请求已抓到(TLS 加密前明文)**
|
||||||
|
> 证据:`/Users/yml/codes/Reverse-Engineering-Agent-Universal-v3.0/evidence/derived/huya-wup-full3.jsonl`
|
||||||
|
> 方法:hook `createWupRequestData<AppLgnPasswordLoginReq>` onLeave 拿到序列化后的 WUP 字节
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、密码登录 WUP 请求(核心结论)
|
||||||
|
|
||||||
|
| 项 | 值 |
|
||||||
|
|---|---|
|
||||||
|
| **servant** | `huyaudbwebui` |
|
||||||
|
| **func** | `hypasswordLogin` |
|
||||||
|
| **协议** | WUP / TAF(JCE) 二进制序列化 |
|
||||||
|
| **msgType** | 4097 (0x1001) = `MsgLogin.mMsgId` |
|
||||||
|
| **请求大小** | 1356 字节 |
|
||||||
|
| **密码** | SHA1(明文) hex,无盐(`772ed992b0e161276f44ec63671e60155c506294`) |
|
||||||
|
| **name** | `hy_300023887`(`hy_` + 虎牙号) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、WUP 帧完整结构(已 100% 逆向定稿)
|
||||||
|
|
||||||
|
> 金标准文件:`evidence/wup_passwordlogin_taf.bin`(1015 字节 TAF 二进制)+ `evidence/wup_passwordlogin_base64.bin`(1356 字节 base64,即 HTTP body)
|
||||||
|
> HTTP:`POST https://udblgn.huya.com/open/hy/passwordLogin`,`Content-Type: application/x-wup`
|
||||||
|
|
||||||
|
### 2.1 外层:WUP RequestPacket(标准 JCE/TAF 序列化)
|
||||||
|
|
||||||
|
```
|
||||||
|
[0:4] uint32 BE 总长 = 1015
|
||||||
|
|
||||||
|
JCE RequestPacket 字段(head = (tag<<4)|type):
|
||||||
|
tag1 iVersion = 3 (INT8: 10 03)
|
||||||
|
tag2 cPacketType = 0 (ZERO: 2c)
|
||||||
|
tag3 iMessageType = 0 (ZERO: 3c)
|
||||||
|
tag4 iRequestId = session(3251699) (INT32: 42 00 31 9d f3)
|
||||||
|
tag5 sServantName = "huyaudbwebui" (STR1: 56 0c + 12B)
|
||||||
|
tag6 sFuncName = "hypasswordLogin" (STR1: 66 0f + 15B)
|
||||||
|
tag7 sBuffer = MAP{...} (SIMPLE_LIST: 7d 00 01 03 c1 + 961B)
|
||||||
|
tag8 iTimeout = 0 (ZERO: 8c)
|
||||||
|
tag9 context = {} (空map) (MAP: 98 0c)
|
||||||
|
tag10 status = {} (空map) (MAP: a8 0c)
|
||||||
|
```
|
||||||
|
|
||||||
|
> 关键澄清:之前误认为 `'f'(0x66)` 和 `'}' (0x7d)` 是分隔符,实为 JCE 字段头:
|
||||||
|
> - `0x56` = 字段5(sServantName)+STRING1
|
||||||
|
> - `0x66` = 字段6(sFuncName)+STRING1
|
||||||
|
> - `0x7d` = 字段7(sBuffer)+SIMPLE_LIST
|
||||||
|
|
||||||
|
### 2.2 中层:sBuffer(961 字节)是一个 MAP
|
||||||
|
|
||||||
|
```
|
||||||
|
MAP (2条目):
|
||||||
|
"_wup_data" → 915 字节 (内层登录请求 struct)
|
||||||
|
"wupudbrequest_v0" → 5 字节 = 02 00 31 9d f3 (版本2 + session INT32)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.3 内层:_wup_data(915 字节)登录请求 struct(完整字段映射)
|
||||||
|
|
||||||
|
```
|
||||||
|
_wup_data = STRUCT {
|
||||||
|
field0 = STRUCT { # 元数据+风控
|
||||||
|
tag0 = 0
|
||||||
|
tag1 = "1.0" # 协议版本
|
||||||
|
tag2 = 207B JSON (associationId/funcName/group/id/session/step/stillLogin/traceId/type/uid/userContext)
|
||||||
|
tag3 = "5008" # bizAppid
|
||||||
|
tag4 = 3 (INT8) # = type
|
||||||
|
tag5 = 180B safedeviceid # 设备指纹(base64)
|
||||||
|
tag6 = ""
|
||||||
|
tag7 = ""
|
||||||
|
tag8 = 210B userAction JSON # 风控行为轨迹
|
||||||
|
tag9 = ""
|
||||||
|
}
|
||||||
|
field1 = STRUCT { # 设备基础信息
|
||||||
|
tag0 = "ed0db8334cadd236c00cadf7e11ab5a5" # hdid (32 hex)
|
||||||
|
tag1 = "13.4.22" # App版本
|
||||||
|
tag2 = "1.0.80138" # SDK版本
|
||||||
|
tag3 = ""
|
||||||
|
tag4 = "127.0.0.1" # 占位
|
||||||
|
tag5 = "xiaomi" # 厂商
|
||||||
|
tag6 = ""
|
||||||
|
}
|
||||||
|
field2 = STRUCT { # 设备详情
|
||||||
|
tag0 = 1 (INT8)
|
||||||
|
tag1 = "M2102J2SC" # 型号 thyme
|
||||||
|
tag2 = "02df398797432eadefcc12767119ad5e80999389" # 设备唯一ID(40 hex)
|
||||||
|
tag3 = "android"
|
||||||
|
tag4 = "M2102J2SC,30,11" # 型号,SDK,版本
|
||||||
|
tag6 = "1080" # 屏宽 (tag5缺失)
|
||||||
|
tag7 = "2120" # 屏高
|
||||||
|
tag8 = "7c5387e0539c023c31c4ff0e807e7256117385ee" # 另一设备ID(40 hex)
|
||||||
|
}
|
||||||
|
field3 = STRUCT { # 登录凭证
|
||||||
|
tag3 = "hy_300023887" # name (hy_虎牙号)
|
||||||
|
tag4 = "772ed992b0e161276f44ec63671e60155c506294" # password = SHA1(明文)
|
||||||
|
tag5 = LIST["5008"] # appid列表
|
||||||
|
tag6 = 1 (INT8)
|
||||||
|
tag7 = {} (空map)
|
||||||
|
tag8 = [] (空simplelist)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.4 原始可读字符串(完整)
|
||||||
|
|
||||||
|
TAF 序列化,可读字符串全量提取如下(关键字段用换行标出):
|
||||||
|
|
||||||
|
```
|
||||||
|
AAAD9xADLDxCADGd81YMaHV5YXVkYndlYnVpZg9oeXBhc3N3b3JkTG9naW59... ← WUP 头
|
||||||
|
└─ "huyaudbwebui" ← servant
|
||||||
|
└─ "hypasswordLogin" ← func
|
||||||
|
|
||||||
|
_wup_data 字段(关联元数据 JSON):
|
||||||
|
{"associationId":8193,
|
||||||
|
"funcName":"hypasswordLogin",
|
||||||
|
"group":1,
|
||||||
|
"id":4097, ← msgType
|
||||||
|
"session":3251699,
|
||||||
|
"step":1,
|
||||||
|
"stillLogin":false,
|
||||||
|
"traceId":"0b8f098ff64a5bdc-23473-94783833787595172451",
|
||||||
|
"type":3,
|
||||||
|
"uid":0,
|
||||||
|
"userContext":""}
|
||||||
|
|
||||||
|
业务数据:
|
||||||
|
5008 ← bizAppid (appId)
|
||||||
|
PQwemAN9NHkZKoMqVT... ← safedeviceid (设备指纹, base64)
|
||||||
|
|
||||||
|
userAction JSON:
|
||||||
|
{"curl":"登录页",
|
||||||
|
"furl":"我的",
|
||||||
|
"latitude":"-1.0",
|
||||||
|
"longitude":"-1.0",
|
||||||
|
"ssid":"",
|
||||||
|
"user_action":[
|
||||||
|
{"id":"24","time":"1787595171950","x":"277","y":"1057"},
|
||||||
|
{"id":"11","time":"1787595172444","x":"296","y":"952"}]}
|
||||||
|
|
||||||
|
设备信息字段:
|
||||||
|
ed0db8334cadd236c00cadf7e11ab5a5 ← hdid (硬件ID)
|
||||||
|
13.4.22 ← App 版本
|
||||||
|
1.0.801386 ← 某版本号
|
||||||
|
127.0.0.1 ← 回环地址(占位)
|
||||||
|
xiaomi ← 厂商
|
||||||
|
M2102J2SC ← 设备型号 (thyme)
|
||||||
|
M2102J2SC,30,11 ← 型号,SDK版本,版本
|
||||||
|
android ← 系统
|
||||||
|
1080 / 2120 ← 分辨率
|
||||||
|
7c5387e0539c023c31c4ff0e807e7256117385ee ← 另一个设备ID
|
||||||
|
|
||||||
|
登录凭证:
|
||||||
|
hy_300023887 ← name
|
||||||
|
772ed992b0e161276f44ec63671e60155c506294 ← password (SHA1)
|
||||||
|
|
||||||
|
结尾:
|
||||||
|
wupudbrequest_v0 ← WUP 请求类型标记
|
||||||
|
AAFAgAxnfOMmAyoDA== ← base64 尾巴(签名/校验)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、WUP 请求的三层结构
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────────────────────────────────┐
|
||||||
|
│ 第1层: WUP/TAF 外层 │
|
||||||
|
│ servant = "huyaudbwebui" │
|
||||||
|
│ func = "hypasswordLogin" │
|
||||||
|
│ _wup_data = { associationId, funcName, group, id, │
|
||||||
|
│ session, step, stillLogin, traceId, │
|
||||||
|
│ type, uid, userContext } │
|
||||||
|
├──────────────────────────────────────────────────────┤
|
||||||
|
│ 第2层: 业务数据 (AppLgnPasswordLoginReq) │
|
||||||
|
│ bizAppid = 5008 │
|
||||||
|
│ name = "hy_300023887" │
|
||||||
|
│ password = SHA1(明文) │
|
||||||
|
│ userAction = 风控行为 JSON │
|
||||||
|
│ safedeviceid / hdid / 设备信息 │
|
||||||
|
├──────────────────────────────────────────────────────┤
|
||||||
|
│ 第3层: 网络传输 │
|
||||||
|
│ TLS 1.3 → 116.162.209.x:443 (udblgn.huya.com) │
|
||||||
|
│ (自定义 BoringSSL / hyssl) │
|
||||||
|
└──────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、网络层(实证)
|
||||||
|
|
||||||
|
- **密码登录走 TLS 443**(不是之前看到的 80 端口图片 CDN)
|
||||||
|
- 目标 IP:`116.162.209.83-86`(`udblgn.huya.com`,kunluncan CDN)
|
||||||
|
- TLS 层:libhycrypto.so = 定制 BoringSSL(hyssl),符号已 strip
|
||||||
|
- 连接池:App 启动时预建立,登录时复用
|
||||||
|
- **HTTP body = 原始 TAF 二进制(1015字节),NOT base64**(base64 是 hook 层返回值的表示形式,发送前已解码)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四·五、纯 Python 重放验证(✅ 成功)
|
||||||
|
|
||||||
|
**关键结论:纯 Python 可以复刻密码登录请求,服务端返回完整登录响应。**
|
||||||
|
|
||||||
|
```
|
||||||
|
POST https://udblgn.huya.com/open/hy/passwordLogin
|
||||||
|
Content-Type: application/x-wup
|
||||||
|
Body: 原始 TAF 二进制 (1015字节, 非base64)
|
||||||
|
```
|
||||||
|
|
||||||
|
重放金标准请求(`wup_passwordlogin_taf.bin`)后,服务端返回 **1514 字节登录响应**:
|
||||||
|
|
||||||
|
| 字段 | 值 |
|
||||||
|
|---|---|
|
||||||
|
| **uid** | `1199666914671` |
|
||||||
|
| **name** | `hy_300023887` |
|
||||||
|
| **手机号掩码** | `001******7524` |
|
||||||
|
| **token** | 346 字节 base64(已存 `evidence/huya_token.txt`) |
|
||||||
|
| **uc_states** | `1`(成功标记) |
|
||||||
|
|
||||||
|
这证明:
|
||||||
|
1. ✅ 请求格式 100% 正确(服务端无 BAD_REQUEST)
|
||||||
|
2. ✅ 设备指纹(safedeviceid/hdid)可复用(重放旧值仍通过)
|
||||||
|
3. ✅ userAction 可复用(旧轨迹未被风控拒绝)
|
||||||
|
4. ✅ TLS 指纹不阻断
|
||||||
|
|
||||||
|
> 注:此验证是「重放」,即复用已抓到的完整请求。要批量登录新账号,还需编码器动态构造新请求(替换 name/password/session/traceId/userAction)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四·六、编码器 + 动态构造验证(✅ 完整成功)
|
||||||
|
|
||||||
|
**编码器已实现,逐字节复现金标准,且能动态构造全新请求。**
|
||||||
|
|
||||||
|
1. **`tools/huya_wup_encoder.py`** — 自检通过:用已知字段构造,输出与金标准逐字节一致(1015 字节)
|
||||||
|
2. **动态构造测试** — 用新 session(3251799)、新 traceId 构造全新请求(1009 字节),发送后返回:
|
||||||
|
|
||||||
|
| 字段 | 值 |
|
||||||
|
|---|---|
|
||||||
|
| **uid** | `1199666914671` |
|
||||||
|
| **name** | `hy_300023887` |
|
||||||
|
| **手机号掩码** | `001******7524` |
|
||||||
|
| **token** | 全新 token(`XAQBRJ3H...`) |
|
||||||
|
|
||||||
|
这证明 **纯 Python 可以完整复刻虎牙密码登录**:
|
||||||
|
1. ✅ 帧格式(JCE/WUP)逆向定稿
|
||||||
|
2. ✅ 编码器逐字节复现
|
||||||
|
3. ✅ session/traceId 可自由生成(服务端接受新值)
|
||||||
|
4. ✅ 设备指纹可复用
|
||||||
|
5. ✅ 动态构造请求返回有效登录响应(uid + token)
|
||||||
|
|
||||||
|
### userAction 随机化(✅ 已实测)
|
||||||
|
|
||||||
|
`userAction` 只是请求里的一个普通 JSON 字符串字段,编码器已暴露 `user_action_json` 参数。实测**随机坐标 + 随机时间戳**同样能通过登录(返回 uid + token),服务端对该字段不做严格校验。
|
||||||
|
|
||||||
|
编码器提供辅助函数:
|
||||||
|
- `make_user_action(now_ms=None)` — 生成随机 userAction JSON(坐标/时间戳/点击间隔随机)
|
||||||
|
- `make_trace_id(pid=0)` — 生成 traceId
|
||||||
|
|
||||||
|
> 未实测定论:批量高频登录时,服务端是否按**账号/设备/IP 维度**限流或风控。这需要实测才能确认,与 userAction 无关。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、对「纯 Python 复刻」的启示(已实证更新)
|
||||||
|
|
||||||
|
**TLS 指纹已实测:不阻断。** 普通 Python `urllib` 直连 `udblgn.huya.com` 能正常收到业务响应。
|
||||||
|
|
||||||
|
要脱离 App 用 Python 发密码登录请求,需要:
|
||||||
|
|
||||||
|
1. ✅ **密码**:`sha1(明文)` —— 已破解,纯 Python 可做
|
||||||
|
2. ✅ **name**:`hy_虎牙号` —— 已知
|
||||||
|
3. ✅ **servant/func**:`huyaudbwebui` / `hypasswordLogin` —— 已知
|
||||||
|
4. ✅ **WUP 帧格式**:标准 JCE RequestPacket,已 100% 定稿(见第二节)
|
||||||
|
5. ✅ **Content-Type**:`application/x-wup`,body = 原始 TAF 二进制
|
||||||
|
6. ✅ **重放验证**:服务端返回 uid + token(纯 Python 可行)
|
||||||
|
7. ✅ **TAF/JCE 编码器**:`tools/huya_wup_encoder.py`(自检通过,逐字节复现)
|
||||||
|
8. ✅ **_wup_data 元数据**:`session`/`traceId` 可自由生成(服务端接受新值)
|
||||||
|
9. ✅ **设备指纹(safedeviceid/hdid)**:可复用旧值(缓存一次即可)
|
||||||
|
10. ✅ **userAction**:随机坐标/时间戳即可通过(编码器内置 `make_user_action()`)
|
||||||
|
|
||||||
|
> 唯一待实测项:批量高频登录时的账号/设备/IP 维度限流(与 userAction 无关)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 六、关联证据文件
|
||||||
|
|
||||||
|
- `huya-wup-plain.jsonl` — 第一次明文抓包(sendMsg JSON 层,305 事件)
|
||||||
|
- `huya-wup-full3.jsonl` — 完整 WUP 序列化(含本次密码登录 1356 字节)
|
||||||
|
- `wup_passwordlogin_taf.bin` — 金标准:1015 字节 TAF 二进制(= HTTP body,编码器自检依赖)
|
||||||
|
- `wup_passwordlogin_base64.bin` — 金标准:1356 字节 base64(hook 返回值)
|
||||||
|
- `wup_response_badreq.bin` — 服务端响应帧(383 字节,returnCode=104)
|
||||||
|
- `tools/huya_wup_encoder.py` — WUP 帧编码器(自检通过,逐字节复现金标准,含 `make_user_action`/`make_trace_id`)
|
||||||
|
- hook 脚本:`hook_huya_wup_full.js`
|
||||||
|
- runner:`run_huya_netcapture.py`
|
||||||
|
|
||||||
|
> 含有效登录 token 的响应文件(`wup_response_login.bin`、`wup_response_new_session.bin`、`huya_token.txt`)已加入 `.gitignore`,不提交。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 七、结论与下一步
|
||||||
|
|
||||||
|
已完成并验证:
|
||||||
|
1. ✅ **TLS 指纹**:实测不阻断(Python urllib 正常收到业务响应)
|
||||||
|
2. ✅ **TAF 编码器**:`tools/huya_wup_encoder.py`,逐字节复现金标准
|
||||||
|
3. ✅ **纯 Python 登录**:重放 + 动态构造均返回 uid + token
|
||||||
|
4. ✅ **userAction 随机化**:随机坐标/时间戳通过
|
||||||
|
|
||||||
|
待实测(唯一未定论项):
|
||||||
|
1. **批量高频登录的限流/风控**:需用编码器连续发请求,观察账号/设备/IP 维度是否触发限制(错误码、频率阈值)
|
||||||
|
2. **集成进 `core/huya/login.py`**:把 APP 密码登录(`/open/hy/passwordLogin`)作为 Web 登录被风控阻断时的替代路径
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
# 虎牙 App 密码登录协议 — Frida 抓包实证
|
||||||
|
|
||||||
|
> 状态:**密码登录完整链路已抓包实证**
|
||||||
|
> 方法:spawn + libmsaoaidsec 绕过 + hook sendMsg/jbyteArray2str/str2jbyteArray(TLS 加密前的明文层)
|
||||||
|
> 证据文件:`/Users/yml/codes/Reverse-Engineering-Agent-Universal-v3.0/evidence/derived/huya-wup-plain.jsonl`
|
||||||
|
> Java 反编译:`/tmp/hy_jadx5/sources/com/huyaudbunify/`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、密码登录请求(msgType = 0x1001 = 4097)
|
||||||
|
|
||||||
|
`MsgLogin.mMsgId = 4097 (0x1001)`,Java 层 `HuyaAuth.login()` → `sendMessage(4097, json)` → native `sendMsg(JNIEnv, jclass, jlong, jbyteArray)`。
|
||||||
|
|
||||||
|
**请求 JSON**(TLS 加密前明文,抓包实证):
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"bizAppids": [],
|
||||||
|
"isAuthLogin": false,
|
||||||
|
"lgnExtParam": {},
|
||||||
|
"name": "hy_300023887",
|
||||||
|
"password": "772ed992b0e161276f44ec63671e60155c506294",
|
||||||
|
"userAction": "{\"curl\":\"登录页\",\"furl\":\"我的\",\"latitude\":\"-1.0\",\"longitude\":\"-1.0\",\"ssid\":\"\",\"user_action\":[{\"id\":\"24\",\"time\":\"1787593636012\",\"x\":\"253\",\"y\":\"1573\"},...]}"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| 字段 | 值/规则 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| `name` | `"hy_" + 虎牙号` | 虎牙号 = 纯数字账号 |
|
||||||
|
| `password` | **标准 SHA1(明文密码) hex,无盐** | 40 字符小写 hex |
|
||||||
|
| `isAuthLogin` | false | 普通密码登录 |
|
||||||
|
| `bizAppids` | [] | 目标业务 appId 列表(空=主 app) |
|
||||||
|
| `lgnExtParam` | {} | 扩展参数 |
|
||||||
|
| `userAction` | 风控行为 JSON | 点击轨迹、时间戳、坐标、curl/furl、经纬度 |
|
||||||
|
|
||||||
|
**密码哈希**(用户确认 + 抓包实证):
|
||||||
|
```
|
||||||
|
password = sha1(明文密码).hexdigest()
|
||||||
|
例如 sha1("某密码") = "772ed992b0e161276f44ec63671e60155c506294"
|
||||||
|
```
|
||||||
|
> 无盐、无 HMAC、无 RSA,就是裸 SHA1。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、登录响应(msgType=0x1001 返回)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"lastLgnTime": 1787593640035,
|
||||||
|
"token": "9QDzAAEDAAAAb2W4URcBAAABAGNI4jSgAQQANTAwOCgAMDJkZjM5ODc5NzQzMmVhZGVmY2MxMjc2NzExOWFkNWU4MDk5OTM4OQQANTAwOKIAAhKJiMHWd/m1LAKSxxpg1cjDxJEIG+ooj8BGSC5NBXb23Trpcz85MOqpZxhLmSeQ9c2MGzBk0oU4iR2PR0Ak6/t50Ej9sdzHjDLa2wcDiRofNyEZzuU1KcIQqrGDwwwfouEwuagTPI0ZrP5vAFJe8uccW5BKRVoqDDsXc09nIW7HX5Yp+9cT0nr8t/8+/3IdTXhFOZYmDN6gHaQI+ToQivXIAAAAAA==",
|
||||||
|
"tokenType": 2,
|
||||||
|
"uid": 1199666914671
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| 字段 | 说明 |
|
||||||
|
|---|---|
|
||||||
|
| `token` | Base64,内含 TAF 二进制(`9QDzAAED...` 头),是后续凭据登录/接口调用的 token |
|
||||||
|
| `tokenType` | 2(密码登录产生的 token 类型) |
|
||||||
|
| `uid` | 1199666914671(该账号 uid) |
|
||||||
|
| `lastLgnTime` | 上次登录时间戳(ms) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、完整登录调用链(Java → native → 网络)
|
||||||
|
|
||||||
|
```
|
||||||
|
Java: HuyaAuth.login(name, password, isAuthLogin, bizAppids, callback)
|
||||||
|
└─ MsgLogin (mMsgId=4097=0x1001)
|
||||||
|
└─ ReqLogin.toString() → JSON(含 SHA1 密码)
|
||||||
|
└─ HuyaAuthMgr.sendMessage(4097, jsonBytes)
|
||||||
|
└─ HuyaAuthCore.sendMsg(jlong, byte[]) [native JNI]
|
||||||
|
└─ sendMsg(JNIEnv, jclass, jlong msgType, jbyteArray)
|
||||||
|
└─ jbyteArray2str → 明文 JSON
|
||||||
|
└─ UdbHandlerMgr.getPlatFormMsgHandler() 处理
|
||||||
|
└─ TAF/WUP 包装(servant + func)
|
||||||
|
└─ TLS (libhycrypto 自带 BoringSSL)
|
||||||
|
└─ 目标: udblgn.huya.com / udb3lgn.huya.com (443)
|
||||||
|
```
|
||||||
|
|
||||||
|
**关键符号地址**(libudbauthunify.so):
|
||||||
|
| 函数 | 地址 | 作用 |
|
||||||
|
|---|---|---|
|
||||||
|
| `sendMsg` | 0x26244c | Java→native 消息入口(msgType + jbyteArray) |
|
||||||
|
| `jbyteArray2str` | 0x262b00 | jbyteArray→std::string(请求明文) |
|
||||||
|
| `str2jbyteArray` | 0x262be8 | std::string→jbyteArray(响应明文) |
|
||||||
|
| `hyudb_otp_encrypt` | 0x32fa24 | OTP/dckey 加密(XXTEA+AES) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、msgType 观察(本次抓包 305 事件)
|
||||||
|
|
||||||
|
| msgType | 次数 | 含义 |
|
||||||
|
|---|---|---|
|
||||||
|
| `0xb000003` | 92 | getLoginData(读取登录态/token),高频轮询 |
|
||||||
|
| `0xb000021` | 4 | setDeviceInfo(hdid + safedeviceid) |
|
||||||
|
| **`0x1001` (4097)** | 1 | **密码登录**(MsgLogin) |
|
||||||
|
| `0xb000001` | 1 | 登录后 getLoginData |
|
||||||
|
| `0xb000024` | 1 | 登录后获取数据 |
|
||||||
|
| `0xb000013` | 1 | getConfig(TAF 二进制,servant=mobileuif) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、与 OTP 加密链的关系
|
||||||
|
|
||||||
|
密码登录时序(抓包时间线):
|
||||||
|
```
|
||||||
|
1787593639831 sendMsg msgType=0x1001 ← 密码登录 JSON 提交
|
||||||
|
1787593639988 crypto-otp b=2 ← 紧接着触发 OTP/dckey 加密(AESkeyMgr b=2)
|
||||||
|
1787593639996 sendMsg msgType=0xb000001 ← 登录成功后的凭据流程
|
||||||
|
```
|
||||||
|
|
||||||
|
即:**密码登录本身用 SHA1 密码 + WUP 明文 JSON;登录成功后,native 层用 OTP 加密链(XXTEA+AES)生成 dckey 凭据,供后续业务接口(token/cred)使用**。
|
||||||
|
|
||||||
|
之前破解的 OTP 加密链(`tools/huya_app_crypto.py`)对应的是**登录后的凭据续期/设备绑定**,不是密码本身。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 六、servant / URL
|
||||||
|
|
||||||
|
- 密码登录 CGI:`/{servantName}/hypasswordLogin`
|
||||||
|
- URL:`https://udblgn.huya.com/open/hy/passwordLogin`(国内版)
|
||||||
|
- servant 名运行时传入,登录类走 `huyaudb`/`huyaudbwebui`
|
||||||
|
- 设备信息:`getCgi()` → `/{servantName}/setDeviceInfo`(hdid + safedeviceid)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 七、待确认项(剩余,影响较小)
|
||||||
|
|
||||||
|
1. **userAction 是否必填/可复用**:风控行为采集(点击轨迹),批量登录时能否固定/复用同一份,还是每次都要真实模拟。
|
||||||
|
2. **登录接口是否校验 OTP dckey**:密码登录请求本身是否需要先拿到 dckey(本次抓包 OTP 在密码登录之后触发,说明密码登录可能不依赖 dckey)。
|
||||||
|
3. **token 的 TAF 结构解析**:响应 token 的 Base64 内是 TAF 二进制(`9QDzAAED...`),需解析出 uid/appid/时间戳等字段。
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
# 虎牙 App 登录 — APK 静态逆向分析(libudbauthunify.so)
|
||||||
|
|
||||||
|
> 目标:`apks/huya-13.4.22-115315-live.apk`(158MB,versionCode=115315)
|
||||||
|
> 分析对象:`lib/arm64-v8a/libudbauthunify.so`(4.7MB,UDB 认证统一库)
|
||||||
|
> 方法:`nm -D` 导出符号 + `objdump` 反汇编 + `strings` 字符串提取(纯静态,无需 hook)
|
||||||
|
> 结论:**App 登录 = WUP 长连接 + XXTEA/AES 双层加密**,全部加密函数与 servant 已定位。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、关键结论(一句话)
|
||||||
|
|
||||||
|
虎牙 App 登录的**设备风控加密**由 `libudbauthunify.so` 实现,算法链是:
|
||||||
|
|
||||||
|
```
|
||||||
|
明文 → cred_pack 打包 → XXTEA 加密 → cred_pack 拼装 → MD5 派生 key → AES-128-ECB 加密 → 密文
|
||||||
|
```
|
||||||
|
|
||||||
|
`dckey` = **Login Credential(登录凭据)**,通过 `MsgGetCred` / `MsgRequestLgnCred` 获取,加密后经 `udbdf.huya.com/dckey/check` 上传。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、关键 native 库定位
|
||||||
|
|
||||||
|
| so 库 | 大小 | 作用 |
|
||||||
|
|---|---|---|
|
||||||
|
| **`libudbauthunify.so`** | 4.7MB | **UDB 认证统一库(核心)**,含全部登录/加密逻辑 |
|
||||||
|
| `libhycrypto.so` | 1.9MB | BoringSSL(AES/RSA 标准实现) |
|
||||||
|
| `libhydeviceid.so` | 4.1MB | 设备 ID 生成 |
|
||||||
|
| `libtscsdk.so` | 1.5MB | 数美(TrustKernel)设备指纹 SDK(对应 zztfly 请求) |
|
||||||
|
| `libtea.so` | 409KB | XXTEA 算法 |
|
||||||
|
| `libmtpencrypt.so` | 809KB | MTP 加密 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、加密体系(已 100% 破解,来自反汇编)
|
||||||
|
|
||||||
|
### 3.1 加密函数清单
|
||||||
|
|
||||||
|
| 函数 | 地址 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| `hyudb_crypt_util::xxtea_encrypt` | 0x32e83c | **XXTEA 加密**(第一层) |
|
||||||
|
| `hyudb_crypt_util::xxtea_decrypt` | 0x32ece8 | XXTEA 解密 |
|
||||||
|
| `hyudb_crypt_util::md5_char16` | 0x32e71c | **MD5 派生 16 字节 key** |
|
||||||
|
| `hyudb_crypt_util::md5_char32` | 0x32e7ac | MD5 派生 32 字节 |
|
||||||
|
| `UdbAESUtil::encrypt` | 0x250038 | **AES-128-ECB 加密**(第二层) |
|
||||||
|
| `UdbAESUtil::decrypt` | 0x2501a4 | AES 解密 |
|
||||||
|
| `AESkeyMgr::getkey` | 0x26871c | **AES key 缓存管理**(map<int,map<int,string>>) |
|
||||||
|
| `hyudb_packet_util::cred_pack` | 0x45xxxx | **cred(dckey) 二进制打包** |
|
||||||
|
| `hyudb_packet_util::cred_unpack` | — | cred 解包 |
|
||||||
|
| `encode_xxtea` / `decode_xxtea` | 0x330058/0x3306cc | XXTEA 编解码封装 |
|
||||||
|
|
||||||
|
### 3.2 加密模式确认(反汇编证据)
|
||||||
|
|
||||||
|
**AES 是 ECB 模式 + ZeroPadding**,证据来自 `UdbAESUtil::encrypt`(0x250038)反汇编:
|
||||||
|
|
||||||
|
```asm
|
||||||
|
; 计算长度 & 0xf,非 16 倍数则 push_back(0) 补齐(ZeroPadding)
|
||||||
|
and w11, w11, #0xf
|
||||||
|
...
|
||||||
|
bl push_back(0) ; 补零
|
||||||
|
; 每 16 字节独立调用 _encrypt(单块),无 IV 参与 → ECB 模式
|
||||||
|
loop:
|
||||||
|
bl UdbAESUtil::_encrypt(ptr16, out) ; 逐块 AES
|
||||||
|
add x24, x24, #0x10 ; +16
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.3 完整加密调用链(`hyudb_otp_encrypt` 0x32fa24 还原)
|
||||||
|
|
||||||
|
```
|
||||||
|
hyudb_otp_encrypt(x0=key, w1=2, w2=len, x3..x5=字段, w6=4, x7=buf, out)
|
||||||
|
├─ cred_pack(sp+0xb0, x7) # 打包第一个字段
|
||||||
|
├─ xxtea_encrypt(sp+0xc8, sp+0xe0, x24) # XXTEA,key = x0 参数
|
||||||
|
├─ cred_pack(sp+0x98, <header字段>) # 打包 header
|
||||||
|
├─ cred_pack(sp+0x98, sp+0xc8) # 打包 XXTEA 密文
|
||||||
|
├─ cred_pack(sp+0x98, x23) # 打包第5参数字段
|
||||||
|
├─ AESkeyMgr::getkey(i, i) # 取 AES key 字符串
|
||||||
|
├─ string::insert(...) # key 拼接
|
||||||
|
├─ md5_char16(key_str) # MD5 派生 16 字节 key
|
||||||
|
├─ UdbAESUtil::C1(md5key) # 用 MD5 结果构造 AES
|
||||||
|
└─ UdbAESUtil::encrypt(packed, md5key) # AES-128-ECB 加密
|
||||||
|
```
|
||||||
|
|
||||||
|
调用方:`BusinessCfg::getOtp(uid, otp, ...)`(0x268bbc~0x268ca0)→ `hyudb_otp_encrypt(...)`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、硬编码 key 候选(rodata 段)
|
||||||
|
|
||||||
|
| 字符串 | 长度 | 备注 |
|
||||||
|
|---|---|---|
|
||||||
|
| **`HuyaUdb1928374650qwertyuiop`** | 27B | **最强候选**("HuyaUdb" + 数字 + 字母键盘序) |
|
||||||
|
| `275d0ff676c0d65114acdefbd2ad87a3` | 16B | 32 hex,正好 AES-128 key 长度 |
|
||||||
|
| `0123456789ABCDEF` / `0123456789abcdef` | 16B | 常见默认 key |
|
||||||
|
|
||||||
|
> XXTEA key 与 AES key 源需 hook 最终确认(hook `xxtea_encrypt` 第3参、`md5_char16` 输入)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、dckey = Login Credential
|
||||||
|
|
||||||
|
符号证据链:
|
||||||
|
|
||||||
|
```
|
||||||
|
MsgGetCred / MsgRequestLgnCred / MsgResponseLgnCred
|
||||||
|
HandlerGetCred / HandlerRequestLoginCred / HandlerResponseLoginCred
|
||||||
|
AutoCredLogin(自动凭据登录)
|
||||||
|
BusinessCfg::getCred / getCredEx / setSafeDeviceId
|
||||||
|
AUTO_CRED_LOGIN_TIMER
|
||||||
|
```
|
||||||
|
|
||||||
|
即:**dckey 就是「登录凭据(credential)」**,App 启动后用设备指纹换取,登录时作为风控凭证提交。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 六、完整登录 servant 清单(TAF/WUP 结构名)
|
||||||
|
|
||||||
|
从 so 的 rodata 提取全部 `wup.*` 结构名:
|
||||||
|
|
||||||
|
### 登录类(核心)
|
||||||
|
| servant/结构 | 用途 |
|
||||||
|
|---|---|
|
||||||
|
| **`AppLgnPasswordLoginReq/Resp`** | **App 密码登录** |
|
||||||
|
| **`AppLgnOtpLoginReq/Resp`** | **App 一键(OTP)登录** |
|
||||||
|
| **`AppLgnCredentialLoginReq/Resp`** | **dckey 凭据登录** |
|
||||||
|
| **`AppLgnSmscodeLoginReq/Resp`** | 短信验证码登录 |
|
||||||
|
| `AppLgnMobileSendSmsReq/Resp` | 手机号发短信 |
|
||||||
|
| `AppLgnSessionSendSmsReq/Resp` | 会话发短信 |
|
||||||
|
| `AppLgnSecondAuthLoginReq/Resp` | 二次认证 |
|
||||||
|
| `AppLgnAntiViolentLoginReq/Resp` | 反暴力登录 |
|
||||||
|
| `AppLgnRefreshPicReq/Resp` | 刷新验证码图 |
|
||||||
|
| `AppLgnThirdLoginReq/Resp` | 第三方登录 |
|
||||||
|
|
||||||
|
### 密码/注册类
|
||||||
|
```
|
||||||
|
AppPasswordLoginReq/Resp # 密码登录(另一组)
|
||||||
|
AppCredentialLoginReq # 凭据登录
|
||||||
|
AppAnonymousLoginReq/Resp # 匿名登录
|
||||||
|
AppMobileRegisterReq # 手机注册
|
||||||
|
AppRegSendSms / AppRegCheckMobile / AppRegCheckUser
|
||||||
|
AppRegSmscodeRegister / AppRegSmscodeModifyPassword / AppRegSmscodeResetPassword
|
||||||
|
AppRegTokenRegister / AppRegTokenModifyPassword / AppRegTokenResetPassword
|
||||||
|
AppRegUidVerifySmscode
|
||||||
|
AppBindLoginMobile / AppBindNewMobile(绑定手机)
|
||||||
|
AppBypassRouteReq/Resp # 旁路路由
|
||||||
|
```
|
||||||
|
|
||||||
|
### servant 名(TAF 服务名)
|
||||||
|
```
|
||||||
|
huyaudb # UDB 主服务
|
||||||
|
huyaudbwebui # 抓包里 wsapi.huya.com 的 servant
|
||||||
|
huyaudbnimoui # NIM UI 服务
|
||||||
|
passport # 护照服务
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 七、其他关键 token/字段
|
||||||
|
|
||||||
|
```
|
||||||
|
violentToken / sessionToken # 反暴力 token / 会话 token
|
||||||
|
enpack_header / depack_header # header 打包/解包(含 NEWKEY)
|
||||||
|
key_version[%d] / key_id[%d] # key 版本/ID 管理
|
||||||
|
lgnExtParam / lgnProtect # 登录扩展参数 / 登录保护
|
||||||
|
loginAppId / loginAppName / loginDeviceName / loginStrategy / loginType
|
||||||
|
```
|
||||||
|
|
||||||
|
`enpack_header` + `NEWKEY` 解释了抓包里 dckey body 的 **100 字节固定头**:那是打包的 header(key 版本、key_id、固定字段),加密只作用于变长数据体。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 八、给 hook 的精确落点(静态分析直接给出)
|
||||||
|
|
||||||
|
| 目标函数 | hook 什么 | 得到什么 |
|
||||||
|
|---|---|---|
|
||||||
|
| `hyudb_crypt_util::xxtea_encrypt` | 第3参(XXTEA key) + 第2参(明文) + 返回(密文) | **XXTEA key + 明文结构** |
|
||||||
|
| `hyudb_crypt_util::md5_char16` | 输入 string + 输出 16B | **AES key 源**(MD5 前明文) |
|
||||||
|
| `AESkeyMgr::getkey` | 返回值 string | **AES key 字符串** |
|
||||||
|
| `UdbAESUtil::encrypt` | 入参(明文) + 返回(密文) | 验证 AES 模式/填充 |
|
||||||
|
| `hyudb_packet_util::cred_pack` | 各字段 | **dckey 二进制结构** |
|
||||||
|
| `BusinessCfg::getOtp` | 入参 | OTP 生成逻辑 |
|
||||||
|
| `MsgRequestLgnCred` 序列化前 | WUP 结构 | **登录凭据请求明文** |
|
||||||
|
| OkHttp `CertificatePinner.check` | — | SSL unpin,解 cdn.wup 长连接 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 九、抓包 ↔ so 对应关系(验证闭环)
|
||||||
|
|
||||||
|
| 抓包现象 | so 证据 |
|
||||||
|
|---|---|
|
||||||
|
| dckey body 前 100B 固定 | `enpack_header`/`cred_pack` 打包的固定 header |
|
||||||
|
| dckey body 后段变长 | XXTEA+AES 加密的数据体 |
|
||||||
|
| dfpReport t2 魔数 `571882cf664bb39401ee` | cred_pack 的固定标识 |
|
||||||
|
| selectOperator.t5 / dfpReport.t2 同前缀 13B | 同一固定 header(非 RSA,是打包结构头) |
|
||||||
|
| 登录走 cdn.wup.huya.com 抓不到 | App 登录 servant 走 WUP 长连接 + SSL Pinning |
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
# 虎牙 App 登录 — 加密算法动态验证(Frida 最终结论)
|
||||||
|
|
||||||
|
> 状态:**算法链已 100% 破解并双向验证;arg7/appKey/XXTEA key 源已确认**
|
||||||
|
> 实现:`tools/huya_app_crypto.py`(自检通过)
|
||||||
|
> 方法:Frida spawn + libmsaoaidsec 绕过 + hook libudbauthunify.so 导出符号
|
||||||
|
> 样本:com.duowan.kiwi 13.4.22(versionCode 115315)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、最终算法链(一句话)
|
||||||
|
|
||||||
|
```
|
||||||
|
OTP 加密 = AES-128-ECB( cred_pack([0x02][xxtea密文][设备指纹]) , key )
|
||||||
|
其中 key = md5("865a4924a40897ac1fcfe6b4c2cbb0e3" + AESKEYMGR_KEYS[b])[4:12].hex()
|
||||||
|
xxtea密文 = XXTEA( arg7(8字节) + 4字节长度 , key=md5(uid)[:16]hex )
|
||||||
|
```
|
||||||
|
|
||||||
|
两层加密,均已用 hook 到的「明文↔密文」样本双向验证通过。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、AES 层(已验证)
|
||||||
|
|
||||||
|
| 项 | 值 |
|
||||||
|
|---|---|
|
||||||
|
| 模式 | AES-128-ECB + ZeroPadding |
|
||||||
|
| key 派生 | `md5(otp_input).digest()[4:12].hex()` → 16 个 ASCII hex 字符 |
|
||||||
|
| otp_input | `sessHex32 + AESKEYMGR_KEYS[b]` |
|
||||||
|
|
||||||
|
验证样本:
|
||||||
|
```
|
||||||
|
otp_input = "865a4924a40897ac1fcfe6b4c2cbb0e3" + "MKDKeridjing7avnsasdSDHI"
|
||||||
|
aes_key = "312334e88d8c35cb"
|
||||||
|
明文 131 字节 → 补 13 字节零 → 144 字节密文,加密/解密双向一致。
|
||||||
|
```
|
||||||
|
|
||||||
|
> 关键坑:`md5_char16` 内部是 `HuyaMd5::toString16()`,输出的是 digest **第 5~12 字节**(跳过前 4 字节)转 hex,不是前 16 字符,也不是 16 字节二进制。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、XXTEA 层(已验证)
|
||||||
|
|
||||||
|
| 项 | 值 |
|
||||||
|
|---|---|
|
||||||
|
| 算法 | 标准 XXTEA(DELTA=0x9e3779b9,rounds=6+52/n) |
|
||||||
|
| key | `md5(uid字符串).hexdigest()[:16]` 的 16 个 ASCII 字符(4 个小端 u32) |
|
||||||
|
| 输入 | `arg7(8字节小端) + struct.pack("<I", len(arg7))`(追加原始长度) |
|
||||||
|
|
||||||
|
验证样本:
|
||||||
|
```
|
||||||
|
uid = "1471238907296"
|
||||||
|
xxtea_key = "4d20d5c7222d5858" (md5(uid) 前 16 hex 字符)
|
||||||
|
输入 = 0000ca5ead34a001 + 08000000 (arg7=0x01a034ad5eca0000, 长度8)
|
||||||
|
输出 = d24e6493d0ee53c395790d67
|
||||||
|
```
|
||||||
|
|
||||||
|
> 关键坑:XXTEA key 是 md5 hex 字符串**前 16 个 ASCII 字符**,不是 16 字节 digest。反汇编证据:`ldr q0` 只读 16 字节,无 hex 解码。
|
||||||
|
> padding:不是简单补 0,而是「补 0 到 4 对齐 + 追加 4 字节原始长度」。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、AESkeyMgr 硬编码 key 表(rodata)
|
||||||
|
|
||||||
|
`AESkeyMgr::getkey(a=1, b)` 返回的 key,b=2..5 已 hook 动态确认:
|
||||||
|
|
||||||
|
| b | key | 确认方式 |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 | `owNMiaCgcHmqoTr3iRamFuHj` | rodata 推断 |
|
||||||
|
| **2** | `MKDKeridjing7avnsasdSDHI` | ✅ hook |
|
||||||
|
| **3** | `nskdI7MDGKSDJsnadjdoonvs` | ✅ hook |
|
||||||
|
| **4** | `whdFNGFdfJHMJbdfg6jhdsFG` | ✅ hook |
|
||||||
|
| **5** | `SHBfgytjtoikooruhogji7ER` | ✅ hook |
|
||||||
|
| 6 | `KNSDNjfohweeromnmkladj3g` | rodata 推断 |
|
||||||
|
| 7 | `KJNzmZaxUd9kGLoAbtefNzCv` | rodata 推断 |
|
||||||
|
| 8 | `YnzbiMPNXaGuxxuoNRd9Lzyj` | rodata 推断 |
|
||||||
|
|
||||||
|
`sessHex32 = "865a4924a40897ac1fcfe6b4c2cbb0e3"` 固定(多次 hook 一致,非 so 内字符串,运行时生成)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、OTP 数据结构(cred_pack)
|
||||||
|
|
||||||
|
`cred_pack` 打包格式(反汇编 `push_varstr`/`cred_packlsE*` 确认):
|
||||||
|
|
||||||
|
| 类型 | 编码 |
|
||||||
|
|---|---|
|
||||||
|
| uchar | 1 字节 |
|
||||||
|
| ulong | 8 字节(小端) |
|
||||||
|
| string | `[u16_le 长度][数据]` |
|
||||||
|
|
||||||
|
`hyudb_otp_encrypt` 输出的 AES 明文结构:
|
||||||
|
```
|
||||||
|
[0x02] # 固定 header(arg1=2)
|
||||||
|
[u16_le len][xxtea密文] # arg7 加密结果(8字节→12字节)
|
||||||
|
[u16_le len][设备指纹] # arg5(114字节左右)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 六、函数参数映射(hook 实测)
|
||||||
|
|
||||||
|
`hyudb_otp_encrypt(x0..x7, 栈上out)`:
|
||||||
|
|
||||||
|
| 参数 | 含义 | 实测值 |
|
||||||
|
|---|---|---|
|
||||||
|
| x0 (string) | XXTEA key 源 = **uid 字符串** | "1471238907296" |
|
||||||
|
| x1 (uchar) | 固定 header | 2 |
|
||||||
|
| x2 (uchar) | **AESkeyMgr b 索引** | 2/3/4/5 |
|
||||||
|
| x3 (string) | 空 | "" |
|
||||||
|
| x4 (string) | **sessHex32** | "865a4924a40897ac1fcfe6b4c2cbb0e3" |
|
||||||
|
| x5 (string) | **设备指纹**(二进制) | 0a80... |
|
||||||
|
| x6 (uchar) | 固定 | 4 |
|
||||||
|
| x7 (ulong) | **XXTEA 输入值** | 0x01a034ad5eca0000 |
|
||||||
|
|
||||||
|
调用方:`BusinessCfg::getOtp(uid, ...)` → `hyudb_otp_encrypt`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 七、Hook 落点(已验证可用)
|
||||||
|
|
||||||
|
| 函数 | 符号 needle | 地址偏移 |
|
||||||
|
|---|---|---|
|
||||||
|
| OTP 加密 | `hyudb_otp_encrypt` | 0x32fa24 |
|
||||||
|
| XXTEA 包装 | `crypt_util13xxtea_encrypt` | 0x32e83c |
|
||||||
|
| XXTEA 核心 | `hyudbxxt13xxtea_encryptEPjj` | 0x252700 |
|
||||||
|
| MD5 派生 | `md5_char16` | 0x32e71c |
|
||||||
|
| AES 加密 | `UdbAESUtil7encrypt` | 0x250038 |
|
||||||
|
| AES key 管理 | `AESkeyMgr6getkey` | 0x26871c |
|
||||||
|
| OTP 入口 | `BusinessCfg6getOtp` | 0x26916c |
|
||||||
|
|
||||||
|
Hook 脚本:`/Users/yml/codes/Reverse-Engineering-Agent-Universal-v3.0/evidence/scripts/hook_huya_crypto.js`
|
||||||
|
Runner:`/Users/yml/codes/Reverse-Engineering-Agent-Universal-v3.0/scripts/run_huya_crypto.py`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 八、sessHex32 = appKey(已确认)
|
||||||
|
|
||||||
|
`sessHex32 = "865a4924a40897ac1fcfe6b4c2cbb0e3"` 就是 **appId=5008 的 appKey**。
|
||||||
|
|
||||||
|
验证(appSign 算法 `md5(f"{appId}{versionCode}{appKey}")[:8]`):
|
||||||
|
```
|
||||||
|
md5("5008" + "115315" + "865a4924a40897ac1fcfe6b4c2cbb0e3")[:8] = "60576904" ✓
|
||||||
|
```
|
||||||
|
与已知 appSign(5008) 完全一致。该值不在 so/dex 硬编码,是运行时由上层传入(App 密钥)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 九、arg7 = 时间戳 << 16(已确认)
|
||||||
|
|
||||||
|
`arg7` 是 XXTEA 的输入(8 字节 ulong),规律:
|
||||||
|
```
|
||||||
|
arg7 = System.currentTimeMillis() << 16 # 低 16 位恒为 0
|
||||||
|
```
|
||||||
|
Frida 实测 5 个样本,`arg7 >> 16` 与事件时间戳差值 <600ms(hook 传播延迟)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 十、XXTEA key 源 = uid 字符串(已确认)
|
||||||
|
|
||||||
|
`hyudb_otp_encrypt` 的 x0(XXTEA key 源)= **uid 十进制字符串**:
|
||||||
|
- 登录后:`"1471238907296"`(用户 uid)→ `md5(uid)[:16]hex` 作 XXTEA key
|
||||||
|
- 匿名(未登录):空字符串 → `md5("")[:16]hex`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 十一、设备指纹(arg5)= 数美 SDK 黑盒(需缓存/设备获取)
|
||||||
|
|
||||||
|
设备指纹是数美(TrustKernel)SDK 生成的黑盒二进制(非标准 JCE),同一设备跨调用稳定:
|
||||||
|
|
||||||
|
| b(OTP 类型) | 指纹开头 | 长度 | 说明 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 2, 4 | `0a80ee5642...` | 114B | 缓存同值(设备指纹 A) |
|
||||||
|
| 3 | 空 | 0 | 无设备指纹 |
|
||||||
|
| 5 | `0a005e3748...` | 114B | 另一指纹类型(设备指纹 B) |
|
||||||
|
|
||||||
|
**无法在纯 Python 复刻**(依赖设备硬件信息 + 数美算法)。两种可行方案:
|
||||||
|
1. **缓存**:对同一设备,hook 一次拿到指纹后持久化复用;
|
||||||
|
2. **设备获取**:在设备上通过 hook `createWupDeviceInfo` / 数美 SDK 实时生成。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 十二、待确认项(剩余)
|
||||||
|
|
||||||
|
1. **b=1,6,7,8 的 key 映射**:rodata 推断,需 hook 对应场景确认(影响较小,登录主流程走 b=2)。
|
||||||
|
2. **设备指纹的稳定周期**:同一设备指纹是否长期不变,还是含时间戳会刷新(跨调用已稳定,跨天待验证)。
|
||||||
|
3. **App 登录主流程走哪个 b**:观察到的 b=2/3/4/5 是启动期匿名 getOtp 的不同类型,真正的密码登录提交需在登录动作时确认 b 值。
|
||||||
@@ -0,0 +1,212 @@
|
|||||||
|
# 虎牙 App 登录逆向笔记
|
||||||
|
|
||||||
|
> 抓包来源:`/Users/yml/Desktop/抓包/hy/8.23/`(登录-1.har / 登录-5.har)
|
||||||
|
> 分析工具:`tools/taf_decode.py`(TAF/JCE 纯字节流解码)、`tools/hy_har_parse.py`(HAR 批量解析)
|
||||||
|
> 状态:App 登录**前置风控链路**已完整解明;**登录提交(最后一跳)走 cdn.wup.huya.com 长连接 + SSL Pinning,尚未解出明文**。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、结论速览
|
||||||
|
|
||||||
|
1. **App 登录 ≠ Web 登录**,是两套完全不同的协议栈:
|
||||||
|
- **Web 登录(旧,appId=5002)**:`udblgn.huya.com/web/v2/passwordLogin` + `udbrtt.huya.com/auth/pt/*` 滑块 + `df.huya.com/web/df/token|collect`。已在 `core/huya/login.py`、`e语言/huya/登录/虎牙操作.txt` 实现。
|
||||||
|
- **App 登录(新,appId=5008)**:`wsapi.huya.com`(servant=`huyaudbwebui`)走 `getDfpConfig → selectOperator → dfpReport` 设备指纹,`udbdf.huya.com/dckey/check` 拿 dckey,运营商取号,最后登录提交进 `cdn.wup.huya.com` 长连接。
|
||||||
|
|
||||||
|
2. **Web 登录"风控加强"的本质**:Web 纯脚本无法伪造 App 侧的**设备态**(dckey + dfp 指纹 + appId=5008),被风控拦下。登录接口本身没变,是**前置设备校验变严**。
|
||||||
|
|
||||||
|
3. **抓包抓不到登录的原因**(两层锁):
|
||||||
|
- TLS 层:`cdn.wup.huya.com` 与裸 IP(`211.91.243.102` 等)有 SSL Pinning,Reqable 中间人证书被拒,只留 CONNECT 隧道。
|
||||||
|
- 应用层:`statwup.huya.com` 的部分 body 是 `877de5c4...` 纯二进制(应用层加密),即使破 TLS 也还要再解一层。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、App 登录完整链路(已解明的普通 HTTP 部分)
|
||||||
|
|
||||||
|
按时间顺序(登录-5.har):
|
||||||
|
|
||||||
|
```
|
||||||
|
① 数美安全 SDK 初始化(App 启动)
|
||||||
|
cfgc.zztfly.com/v6/gcf · cdn-api-auth.zztfly.com/api/initSecCdn · upc.flysleep.cn/v6/gcl
|
||||||
|
② 设备指纹配置
|
||||||
|
wsapi.huya.com huyaudbwebui.getDfpConfig ← 只传 appId=5008,返回 80 字节加密配置
|
||||||
|
③ 选运营商(一键登录前置)
|
||||||
|
wsapi.huya.com huyaudbwebui.selectOperator ← 传设备信息 + RSA 加密段,返回运营商索引
|
||||||
|
④ 设备指纹上报 ×4(循环)
|
||||||
|
wsapi.huya.com huyaudbwebui.dfpReport ← 上报加密设备指纹,返回 32 位指纹 + action
|
||||||
|
udbdf.huya.com/dckey/check ← 加密 body,返回 dckey
|
||||||
|
⑤ 运营商取号(联通)
|
||||||
|
auth.wosms.cn/dro/netm/v1.0/qc
|
||||||
|
nisportal.10010.com:9001/api?appid=2074469246116
|
||||||
|
⑥ 登录提交(❌ 未抓取)
|
||||||
|
→ 进入 cdn.wup.huya.com / 裸 IP 长连接(被 SSL Pinning 挡住)
|
||||||
|
```
|
||||||
|
|
||||||
|
`wup.huya.com` 上看到的 `mobileui.getDynamicConfigHotFixUpdateInfo` / `reportMobileUpdateResult` 只是 **App 热更配置**,与登录无关。`statwup.huya.com` 上 `metric.reportDetailV2` 是埋点。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、各接口字段级结构
|
||||||
|
|
||||||
|
### 1. getDfpConfig(wsapi,servant=huyaudbwebui)
|
||||||
|
|
||||||
|
- **请求 tReq**:`{"t0": {"t0": "5008"}}` —— 只传 appId。
|
||||||
|
- **响应 tRsp**:`{"t0": {"t0": {"5008": "<base64>"}}}` —— 按 appId 返回配置。
|
||||||
|
- **base64 配置解码后 = 80 字节**(64+16 结构,疑似密钥材料):
|
||||||
|
|
||||||
|
```
|
||||||
|
hex: 34b946b716f1481d8584980b79cf756b962e79c999d752016904c1fc07d689
|
||||||
|
694a148ef872206826745498088e61be3f062f7d8d1a0276e29c0048062ac8e2
|
||||||
|
b58fe0d1ee376c412472d8b9124d0bbf7d
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. selectOperator(wsapi,servant=huyaudbwebui)
|
||||||
|
|
||||||
|
请求 key 是 `_wup_data`(注意不是 tReq),三层嵌套结构:
|
||||||
|
|
||||||
|
```
|
||||||
|
_wup_data.t0.t0 = { # 会话上下文
|
||||||
|
t0: 0,
|
||||||
|
t1: "1.0", # 协议版本
|
||||||
|
t2: "hyudb_1787583730402", # 会话 id = "hyudb_" + 毫秒时间戳
|
||||||
|
t3: "5008", # appId
|
||||||
|
t4: 0,
|
||||||
|
t5: "PQwemAN9NHkZKoMqV...", # RSA 公钥加密段(与 dfpReport 响应 t2 同前缀)
|
||||||
|
t6:"", t7:"", t8:"", t9:""
|
||||||
|
}
|
||||||
|
_wup_data.t1 = { # SDK 信息
|
||||||
|
t0:"", t1:"13.4.22"(App版本), t2:"2.0.8"(SDK版本), t3:"",t4:"",t5:"",t6:""
|
||||||
|
}
|
||||||
|
_wup_data.t2 = { # 设备信息
|
||||||
|
t0: 1,
|
||||||
|
t1: "M2102J2SC", # 机型
|
||||||
|
t2: "02df398797432eadefcc12767119ad5e80999389", # 40 hex = SHA1 设备指纹
|
||||||
|
t3: "android",
|
||||||
|
t4: "M2102J2SC,30,11", # 机型,Android版本,SDK版本
|
||||||
|
t6:"", t7:""
|
||||||
|
}
|
||||||
|
_wup_data.t3 = [0, 1, 3] # 运营商列表
|
||||||
|
```
|
||||||
|
|
||||||
|
- **响应**:`_wup_data.t0.t1 = 1`(选中运营商索引 1)。
|
||||||
|
|
||||||
|
### 3. dfpReport(wsapi,servant=huyaudbwebui)
|
||||||
|
|
||||||
|
- **请求 tReq**:
|
||||||
|
|
||||||
|
```
|
||||||
|
{t0: {t0:"", t1:"android", t2:"<加密体>", t3:"", t4:12}}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `t2` 加密体结构:**10 字节固定魔数 + 变长加密数据**
|
||||||
|
- 魔数(跨 6 次请求完全一致):`571882cf664bb39401ee`
|
||||||
|
- 加密数据长度 3300~4100 字节(每次递增,说明是**累积式采集上报**,每次追加新采集数据)
|
||||||
|
|
||||||
|
- **响应 tRsp**:
|
||||||
|
|
||||||
|
```
|
||||||
|
{t0: {
|
||||||
|
t0: "10",
|
||||||
|
t1: "865a4924a40897ac1fcfe6b4c2cbb045", # 32 hex = 稳定设备指纹(4 次响应一致)
|
||||||
|
t2: "PQwemAN9NHkZKoMqV...", # base64 = RSA 公钥加密的 action(每次变)
|
||||||
|
t3: 43200, # 过期时间(秒)=12h
|
||||||
|
t4: "action",
|
||||||
|
t5: "7c5387e0539c023c31c4ff0e807e7256117385ee" # 16 hex = 稳定 session hash(4 次一致)
|
||||||
|
}}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. dckey/check(udbdf.huya.com,okhttp/3.14.9,application/octet-stream)
|
||||||
|
|
||||||
|
- **请求 body 结构**:**100 字节固定头 + 变长加密体**(总长 543B 或 547B,两种长度差 4 字节)
|
||||||
|
- 100 字节固定头(跨 5 个样本、两个会话完全一致):
|
||||||
|
|
||||||
|
```
|
||||||
|
32f1990eb8f46c503c3b5db81be58ae84dd3e961b991dd9e9c593aba8c78881c
|
||||||
|
f1281c555198710a1009f1ec897c46be8b26d81e8eab98f27242de3444315cab8
|
||||||
|
d617eae189d597e296099f0e5bc817a7554456c1af99515254ab7665a2a3f861a
|
||||||
|
7d941b
|
||||||
|
```
|
||||||
|
|
||||||
|
- 变长加密体(543/547B 减 100B = 443/447B)每次变化。
|
||||||
|
- 同一长度下,末尾 32 字节可复现(同设备+同 session 重复调用时)。
|
||||||
|
|
||||||
|
- **响应**:`{"dckey":"AAAAAMC1eP4iV43WYoI57ZOu0...","status":"success"}`
|
||||||
|
- dckey 固定前缀 `AAAAAMC1eP4iV43WYoI57ZOu0`(base64 前 16 字节),后续每次变。
|
||||||
|
|
||||||
|
### 5. 运营商取号(联通一键登录)
|
||||||
|
|
||||||
|
- `POST auth.wosms.cn/dro/netm/v1.0/qc`
|
||||||
|
- `POST nisportal.10010.com:9001/api?appid=2074469246116&ret_url=<base64>`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、加密体分析汇总
|
||||||
|
|
||||||
|
| 加密体 | 位置 | 结构 | 猜测算法 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| dfpReport t2 | wsapi | 10B 魔数 `571882cf664bb39401ee` + 变长加密 | 自定义格式,魔数疑似协议版本/类型标识 |
|
||||||
|
| dckey/check body | udbdf | 100B 固定头 + 443/447B 变长 | 固定头疑似 RSA/ECDH 公钥或设备态加密快照 |
|
||||||
|
| getDfpConfig 配置 | wsapi 响应 | 80B = 64B+16B | 疑似 dfpReport 加密密钥 + IV/nonce |
|
||||||
|
| selectOperator t5 / dfpReport t2(action) | wsapi | base64,同前缀 `PQwemAN9NHkZKoMqV` | **RSA 公钥加密**(同公钥 → 密文前缀相同) |
|
||||||
|
| statwup #7 | statwup | 400B 纯二进制 | 应用层加密(session-key) |
|
||||||
|
|
||||||
|
**关键判断**:`selectOperator.t5` 和 `dfpReport 响应 t2` 前缀相同 → **同一个 RSA 公钥**加密,明文大概率是「设备信息 + 会话 key」的协商包。拿到这个公钥(APK 内嵌)就能解密。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、稳定字段清单(可直接复用的常量)
|
||||||
|
|
||||||
|
| 字段 | 值 | 用途 |
|
||||||
|
|---|---|---|
|
||||||
|
| appId(App 场景) | `5008` | 替代 web 的 5002 |
|
||||||
|
| App 版本 | `13.4.22` | versionCode=115315 |
|
||||||
|
| SDK 版本 | `2.0.8` | selectOperator.t1.t2 |
|
||||||
|
| dfpReport 魔数 | `571882cf664bb39401ee` | t2 加密体前缀 |
|
||||||
|
| dckey 固定头 | 100B(见上) | dckey/check body 前缀 |
|
||||||
|
| dckey 响应前缀 | `AAAAAMC1eP4iV43WYoI57ZOu0` | dckey 前 16B |
|
||||||
|
| 设备指纹(selectOperator) | `02df398797432eadefcc12767119ad5e80999389` | 40 hex = SHA1 |
|
||||||
|
| 设备指纹(dfpReport) | `865a4924a40897ac1fcfe6b4c2cbb045` | 32 hex,服务端下发 |
|
||||||
|
| session hash(dfpReport) | `7c5387e0539c023c31c4ff0e807e7256117385ee` | t5 |
|
||||||
|
| 会话 id 格式 | `hyudb_<毫秒时间戳>` | selectOperator.t2 |
|
||||||
|
| appSign | `60576904`(md5 取前 8,见 login.py 注释) | App 场景签名 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 六、与现有 Web 登录(appId=5002)的差异
|
||||||
|
|
||||||
|
| 维度 | Web(5002) | App(5008) |
|
||||||
|
|---|---|---|
|
||||||
|
| 登录入口 | udblgn.huya.com/web/v2/passwordLogin | cdn.wup.huya.com 长连接 |
|
||||||
|
| 设备指纹 | df.huya.com/web/df/token+collect | wsapi getDfpConfig+dfpReport + dckey/check |
|
||||||
|
| 验证码 | udbrtt pt/get3+verify3(滑块/点选) | 运营商取号 + (未解明) |
|
||||||
|
| 传输 | JSON over HTTP | TAF/WUP + 部分应用层加密 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 七、待破解项(按优先级)
|
||||||
|
|
||||||
|
1. **[最硬] dckey/check 的加密算法**:100B 固定头 + 变长体。需 hook APK 里 dckey 生成方法。
|
||||||
|
2. **[核心] dfpReport t2 加密体**:魔数 + 累积式采集数据。需 hook dfp 采集模块。
|
||||||
|
3. **[核心] 登录提交的 WUP servant/func + 参数**:被 SSL Pinning 挡住,需 Frida unpin 或 hook 登录方法。
|
||||||
|
4. **[可解] RSA 公钥**:APK 内嵌,解出 selectOperator.t5 / dfpReport action 明文。
|
||||||
|
5. **[可解] getDfpConfig 80B 配置**:确定是否 dfpReport 加密密钥。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 八、Frida hook 点建议(供 hook 阶段参考)
|
||||||
|
|
||||||
|
1. **dckey 生成**:hook `udbdf` 请求发出前,dump 加密前明文。搜 `com/duowan/kiwi` 下 dckey / dcKey 相关类。
|
||||||
|
2. **dfp 采集**:hook dfpReport 的 t2 字节流写入点(`writeTo`/`toByteArray`)。
|
||||||
|
3. **登录提交**:hook `com.duowan.kiwi.base.login.UdbNetHelper`(appSign 已逆向自此类),以及登录 servant 的 `writeStruct("tReq", ...)` 前的参数。
|
||||||
|
4. **SSL unpin**:hook OkHttp `CertificatePinner.check` + conscrypt `TrustManagerImpl.verifyChain`,让 Reqable 能解 cdn.wup.huya.com。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 九、工具使用
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 解码某个 HAR 里的所有 WUP(TAF) 请求/响应字段
|
||||||
|
.venv/bin/python tools/hy_har_parse.py "/Users/yml/Desktop/抓包/hy/8.23/登录-5.har"
|
||||||
|
|
||||||
|
# 底层 TAF 解码器(可单独 import)
|
||||||
|
from tools.taf_decode import Taf, decode_struct_bytes
|
||||||
|
```
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
AAAD9xADLDxCADGd81YMaHV5YXVkYndlYnVpZg9oeXBhc3N3b3JkTG9naW59AAEDwQgAAgYJX3d1cF9kYXRhHQABA5MKCgwWAzEuMCbPeyJhc3NvY2lhdGlvbklkIjo4MTkzLCJmdW5jTmFtZSI6Imh5cGFzc3dvcmRMb2dpbiIsImdyb3VwIjoxLCJpZCI6NDA5Nywic2Vzc2lvbiI6MzI1MTY5OSwic3RlcCI6MSwic3RpbGxMb2dpbiI6ZmFsc2UsInRyYWNlSWQiOiIwYjhmMDk4ZmY2NGE1YmRjLTIzNDczLTk0NzgzODMzNzg3NTk1MTcyNDUxIiwidHlwZSI6MywidWlkIjowLCJ1c2VyQ29udGV4dCI6IiJ9NgQ1MDA4QANWtFBRd2VtQU45TkhrWktvTXFWVEZVWkJJeXBxTVRhUUVPcm1YcjM3eFFWaFFacXJML2dVS0VRMTF4dkUwanU0OFY4Ty90OVVCR1NwMjdtNCs2YlA0SWlBRW5wYVI1Umoxa0hFZk4yU1BMUHFZWlc5dnJveFVTb0F2akpuNmV6VFA5aldHeHhsUkRDYnRQeTRSZDZNZW5jWVQvcE5JbVZJV0srWWJOS1p0MU8wNWJIVUZocUhmM2YAdgCG0nsiY3VybCI6IueZu+W9lemhtSIsImZ1cmwiOiLmiJHnmoQiLCJsYXRpdHVkZSI6Ii0xLjAiLCJsb25naXR1ZGUiOiItMS4wIiwic3NpZCI6IiIsInVzZXJfYWN0aW9uIjpbeyJpZCI6IjI0IiwidGltZSI6IjE3ODc1OTUxNzE5NTAiLCJ4IjoiMjc3IiwieSI6IjEwNTcifSx7ImlkIjoiMTEiLCJ0aW1lIjoiMTc4NzU5NTE3MjQ0NCIsIngiOiIyOTYiLCJ5IjoiOTUyIn1dfZYACxoGIGVkMGRiODMzNGNhZGQyMzZjMDBjYWRmN2UxMWFiNWE1FgcxMy40LjIyJgkxLjAuODAxMzg2AEYJMTI3LjAuMC4xVgZ4aWFvbWlmAAsqAAEWCU0yMTAySjJTQyYoMDJkZjM5ODc5NzQzMmVhZGVmY2MxMjc2NzExOWFkNWU4MDk5OTM4OTYHYW5kcm9pZEYPTTIxMDJKMlNDLDMwLDExZgQxMDgwdgQyMTIwhig3YzUzODdlMDUzOWMwMjNjMzFjNGZmMGU4MDdlNzI1NjExNzM4NWVlCzYMaHlfMzAwMDIzODg3Rig3NzJlZDk5MmIwZTE2MTI3NmY0NGVjNjM2NzFlNjAxNTVjNTA2Mjk0WQABBgQ1MDA4YAF4DI0ADAsGEHd1cHVkYnJlcXVlc3RfdjAdAAAFAgAxnfOMmAyoDA==
|
||||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,238 @@
|
|||||||
|
"""
|
||||||
|
虎牙 App 登录 — OTP/dckey 加密算法(已通过 Frida 动态双向验证)
|
||||||
|
|
||||||
|
完整加密链(libudbauthunify.so,com.duowan.kiwi 13.4.22 / versionCode 115315):
|
||||||
|
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ OTP 加密(hyudb_otp_encrypt) │
|
||||||
|
│ │
|
||||||
|
│ 1. XXTEA: xxtea_key = md5(uid字符串).hexdigest()[:16] │
|
||||||
|
│ xxtea_in = arg7(8字节小端) + 4字节原始长度 │
|
||||||
|
│ (标准 XXTEA, rounds = 6 + 52/n, DELTA=0x9e3779b9)│
|
||||||
|
│ │
|
||||||
|
│ 2. cred_pack 拼装: │
|
||||||
|
│ [0x02] # 固定 header │
|
||||||
|
│ [u16_le len][xxtea密文] # string 打包 │
|
||||||
|
│ [u16_le len][设备指纹] # string 打包 │
|
||||||
|
│ │
|
||||||
|
│ 3. AES-128-ECB + ZeroPadding: │
|
||||||
|
│ aes_key = md5(sessHex32 + AESKEYMGR_KEY[b])[4:12].hex() │
|
||||||
|
│ cipher = AES_ECB(拼装数据 + 补零) │
|
||||||
|
└─────────────────────────────────────────────────────────────┘
|
||||||
|
|
||||||
|
动态验证样本(两次 hook,两个独立 PID,结果一致):
|
||||||
|
uid = "1471238907296"
|
||||||
|
sessHex32 = "865a4924a40897ac1fcfe6b4c2cbb0e3" (固定)
|
||||||
|
AESKEYMGR = "MKDKeridjing7avnsasdSDHI" (b=2)
|
||||||
|
aes_key = "312334e88d8c35cb"
|
||||||
|
XXTEA 双向、AES 加密方向均已验证通过。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import struct
|
||||||
|
from binascii import unhexlify
|
||||||
|
|
||||||
|
from Crypto.Cipher import AES
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# AESkeyMgr 硬编码 key 表(按 b 参数索引,1-based)
|
||||||
|
# b=2..5 已通过 Frida hook AESkeyMgr::getkey(a=1, b) 动态确认;
|
||||||
|
# 其余为 rodata 相邻字符串推断,待最终确认顺序。
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
AESKEYMGR_KEYS: dict[int, str] = {
|
||||||
|
1: "owNMiaCgcHmqoTr3iRamFuHj",
|
||||||
|
2: "MKDKeridjing7avnsasdSDHI", # 已验证
|
||||||
|
3: "nskdI7MDGKSDJsnadjdoonvs", # 已验证
|
||||||
|
4: "whdFNGFdfJHMJbdfg6jhdsFG", # 已验证
|
||||||
|
5: "SHBfgytjtoikooruhogji7ER", # 已验证
|
||||||
|
6: "KNSDNjfohweeromnmkladj3g",
|
||||||
|
7: "KJNzmZaxUd9kGLoAbtefNzCv",
|
||||||
|
8: "YnzbiMPNXaGuxxuoNRd9Lzyj",
|
||||||
|
}
|
||||||
|
|
||||||
|
# 固定 32 位 hex 会话值(OTP AES key 源的第一半)
|
||||||
|
SESS_HEX32 = "865a4924a40897ac1fcfe6b4c2cbb0e3"
|
||||||
|
|
||||||
|
DELTA = 0x9E3779B9
|
||||||
|
|
||||||
|
|
||||||
|
def huya_arg7(now_ms: int | None = None) -> int:
|
||||||
|
"""arg7 = System.currentTimeMillis() << 16(低 16 位恒为 0)。
|
||||||
|
|
||||||
|
Frida 实测:arg7 >> 16 ≈ 事件时间戳(ms),误差为 hook 传播延迟(<600ms)。
|
||||||
|
"""
|
||||||
|
import time
|
||||||
|
if now_ms is None:
|
||||||
|
now_ms = int(time.time() * 1000)
|
||||||
|
return now_ms << 16
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# XXTEA(标准实现,已验证)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
def _xxtea_mx(z, y, total, key, p, e):
|
||||||
|
return (((z >> 5 ^ y << 2) + (y >> 3 ^ z << 4))
|
||||||
|
^ ((total ^ y) + (key[(p & 3) ^ e] ^ z))) & 0xFFFFFFFF
|
||||||
|
|
||||||
|
|
||||||
|
def _xxtea_rounds(n: int) -> int:
|
||||||
|
return 6 + 52 // n
|
||||||
|
|
||||||
|
|
||||||
|
def xxtea_encrypt_u32(v: list[int], key: list[int]) -> list[int]:
|
||||||
|
n = len(v)
|
||||||
|
if n < 2:
|
||||||
|
return v
|
||||||
|
total = 0
|
||||||
|
z = v[n - 1]
|
||||||
|
for _ in range(_xxtea_rounds(n)):
|
||||||
|
total = (total + DELTA) & 0xFFFFFFFF
|
||||||
|
e = (total >> 2) & 3
|
||||||
|
for p in range(n):
|
||||||
|
y = v[(p + 1) % n]
|
||||||
|
v[p] = (v[p] + _xxtea_mx(z, y, total, key, p, e)) & 0xFFFFFFFF
|
||||||
|
z = v[p]
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
def xxtea_decrypt_u32(v: list[int], key: list[int]) -> list[int]:
|
||||||
|
n = len(v)
|
||||||
|
if n < 2:
|
||||||
|
return v
|
||||||
|
rounds = _xxtea_rounds(n)
|
||||||
|
total = (rounds * DELTA) & 0xFFFFFFFF
|
||||||
|
y = v[0]
|
||||||
|
while total != 0:
|
||||||
|
e = (total >> 2) & 3
|
||||||
|
for p in range(n - 1, 0, -1):
|
||||||
|
z = v[p - 1]
|
||||||
|
v[p] = (v[p] - _xxtea_mx(z, y, total, key, p, e)) & 0xFFFFFFFF
|
||||||
|
y = v[p]
|
||||||
|
z = v[n - 1]
|
||||||
|
v[0] = (v[0] - _xxtea_mx(z, y, total, key, 0, e)) & 0xFFFFFFFF
|
||||||
|
y = v[0]
|
||||||
|
total = (total - DELTA) & 0xFFFFFFFF
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
def xxtea_key_from_uid(uid: str) -> list[int]:
|
||||||
|
"""XXTEA key = md5(uid字符串).hexdigest()[:16] 的 16 个 ASCII 字节(4 个小端 u32)。"""
|
||||||
|
h = hashlib.md5(uid.encode("ascii")).hexdigest()[:16]
|
||||||
|
return list(struct.unpack("<4I", h.encode("ascii")))
|
||||||
|
|
||||||
|
|
||||||
|
def huya_xxtea_encrypt(data: bytes, uid: str) -> bytes:
|
||||||
|
"""XXTEA 加密:数据 + 4字节原始长度 padding,再标准 XXTEA。"""
|
||||||
|
key = xxtea_key_from_uid(uid)
|
||||||
|
pad = (-len(data)) % 4
|
||||||
|
buf = data + b"\x00" * pad + struct.pack("<I", len(data))
|
||||||
|
v = list(struct.unpack("<%dI" % (len(buf) // 4), buf))
|
||||||
|
enc = xxtea_encrypt_u32(v, key)
|
||||||
|
return b"".join(struct.pack("<I", x) for x in enc)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# AES(已验证)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
def huya_aes_key(otp_input: bytes) -> bytes:
|
||||||
|
"""AES-128 key = md5(otp_input) digest 第 5~12 字节转 hex(16 ASCII 字符)。"""
|
||||||
|
d = hashlib.md5(otp_input).digest()
|
||||||
|
return d[4:12].hex().encode("ascii")
|
||||||
|
|
||||||
|
|
||||||
|
def huya_aes_encrypt(plain: bytes, otp_input: bytes) -> bytes:
|
||||||
|
key = huya_aes_key(otp_input)
|
||||||
|
pad = (-len(plain)) % 16
|
||||||
|
return AES.new(key, AES.MODE_ECB).encrypt(plain + b"\x00" * pad)
|
||||||
|
|
||||||
|
|
||||||
|
def huya_aes_decrypt(cipher: bytes, otp_input: bytes) -> bytes:
|
||||||
|
key = huya_aes_key(otp_input)
|
||||||
|
return AES.new(key, AES.MODE_ECB).decrypt(cipher).rstrip(b"\x00")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# cred_pack 打包
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
def cred_pack_string(data: bytes) -> bytes:
|
||||||
|
"""string = [2字节小端长度][数据](cred_pack::push_varstr)。"""
|
||||||
|
return struct.pack("<H", len(data)) + data
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 完整 OTP 加密(对应 hyudb_otp_encrypt)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
def huya_password_sha1(password: str) -> str:
|
||||||
|
"""密码登录的 password 字段 = 标准 SHA1(明文密码) 的 hex,无盐。
|
||||||
|
|
||||||
|
Frida 抓包证据(msgType=0x1001 密码登录请求):
|
||||||
|
password = "772ed992b0e161276f44ec63671e60155c506294" (40 位 hex)
|
||||||
|
"""
|
||||||
|
return hashlib.sha1(password.encode("utf-8")).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def huya_name(hyname: str) -> str:
|
||||||
|
"""登录 name 字段 = "hy_" + 虎牙号(纯数字账号)。"""
|
||||||
|
return f"hy_{hyname}"
|
||||||
|
|
||||||
|
|
||||||
|
def huya_otp_encrypt(
|
||||||
|
uid: str,
|
||||||
|
arg7: int,
|
||||||
|
device_fingerprint: bytes,
|
||||||
|
b: int = 2,
|
||||||
|
sess_hex32: str = SESS_HEX32,
|
||||||
|
) -> bytes:
|
||||||
|
"""完整 OTP 加密,返回 AES 密文。
|
||||||
|
|
||||||
|
uid : 账号 uid 的十进制字符串(XXTEA key 源)
|
||||||
|
arg7 : 8 字节 ulong(时间戳/计数器)
|
||||||
|
device_fingerprint : 设备指纹二进制(arg5)
|
||||||
|
b : AESkeyMgr key 表索引(1..8)
|
||||||
|
sess_hex32 : 固定 32 位 hex 会话值
|
||||||
|
"""
|
||||||
|
# 1. XXTEA 加密 arg7
|
||||||
|
xxtea_ct = huya_xxtea_encrypt(struct.pack("<Q", arg7), uid)
|
||||||
|
|
||||||
|
# 2. cred_pack 拼装
|
||||||
|
packed = b"\x02" + cred_pack_string(xxtea_ct) + cred_pack_string(device_fingerprint)
|
||||||
|
|
||||||
|
# 3. AES-128-ECB
|
||||||
|
otp_input = (sess_hex32 + AESKEYMGR_KEYS[b]).encode("ascii")
|
||||||
|
return huya_aes_encrypt(packed, otp_input)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
# 自检 1: AES
|
||||||
|
otp_input = (SESS_HEX32 + AESKEYMGR_KEYS[2]).encode("ascii")
|
||||||
|
plain = unhexlify(
|
||||||
|
"020c00cf8b207ddeb2ec4ec2a9344772000a70686484dee8e70dd7649fa14e82"
|
||||||
|
"86caef42e5ddb9c380aef2485f87170507881b3e11b04c8dc976b8d056dab02"
|
||||||
|
"45672e3b5595f73d1803c123672fb3525bb3640b986a94d4c00349995e904a1"
|
||||||
|
"0b27b6d9d0078b1a0c85abdcd1747e588831370d553cf3bd9e3032a9537e30f"
|
||||||
|
"29dbb4866"
|
||||||
|
)
|
||||||
|
expected = unhexlify(
|
||||||
|
"b2f83ab1970e3d343935b502874a63e3e4a674c60827347a8b81186e11bfb44"
|
||||||
|
"3a95cd1252536822859daa76685317aab55ca6f50530b98b72122b61f774d6a"
|
||||||
|
"9bc515930d7a88f387efc6dac90379ee26b346a5a08e42546950b268a8a7f5c"
|
||||||
|
"ecfe8b93f2b1271c2c40ccddc8f805b2732e2d16e3e79fd526666b58a5fc3d0"
|
||||||
|
"af366cebfbeaa91089188cb5d7ed48661cbd"
|
||||||
|
)
|
||||||
|
assert huya_aes_key(otp_input) == b"312334e88d8c35cb", "AES key 派生失败"
|
||||||
|
assert huya_aes_encrypt(plain, otp_input) == expected, "AES 加密失败"
|
||||||
|
assert huya_aes_decrypt(expected, otp_input) == plain, "AES 解密失败"
|
||||||
|
|
||||||
|
# 自检 2: XXTEA
|
||||||
|
uid = "1471238907296"
|
||||||
|
xxtea_ct = huya_xxtea_encrypt(struct.pack("<Q", 0x01A034AD5ECA0000), uid)
|
||||||
|
assert xxtea_ct.hex() == "d24e6493d0ee53c395790d67", f"XXTEA 失败: {xxtea_ct.hex()}"
|
||||||
|
|
||||||
|
# 自检 3: 密码 SHA1(无盐)
|
||||||
|
assert huya_password_sha1("password123") == hashlib.sha1(b"password123").hexdigest()
|
||||||
|
|
||||||
|
print("自检全部通过")
|
||||||
|
print(" AES key =", huya_aes_key(otp_input).decode())
|
||||||
|
print(" XXTEA =", huya_xxtea_encrypt(struct.pack('<Q', 0x01A034AD5ECA0000), uid).hex())
|
||||||
@@ -0,0 +1,397 @@
|
|||||||
|
"""虎牙 App 密码登录 WUP(TAF) 请求帧编码器。
|
||||||
|
|
||||||
|
逆向自真实抓包金标准 `evidence/wup_passwordlogin_taf.bin`(1015 字节,
|
||||||
|
POST https://udblgn.huya.com/open/hy/passwordLogin 的 base64 解码后 body)。
|
||||||
|
|
||||||
|
帧结构(偏移从 0 开始,含 4 字节长度前缀):
|
||||||
|
[0:4] 长度 = 1015 (big-endian uint32)
|
||||||
|
WUP header(TAF 字段,tag 1..10):
|
||||||
|
tag1 iVersion = 3 (INT8)
|
||||||
|
tag2 cPacketType = 0 (ZERO)
|
||||||
|
tag3 iMessageType = 0 (ZERO)
|
||||||
|
tag4 iRequestId = session (INT32)
|
||||||
|
tag5 sServantName = "huyaudbwebui" (STRING1)
|
||||||
|
tag6 sFuncName = "hypasswordLogin" (STRING1)
|
||||||
|
tag7 sBuffer = bytes (SIMPLE_LIST, 961 字节)
|
||||||
|
tag8 iTimeout = 0 (ZERO)
|
||||||
|
tag9 context = {} (空 MAP)
|
||||||
|
tag10 status = {} (空 MAP)
|
||||||
|
|
||||||
|
sBuffer = Map<str, bytes> 共 2 项:
|
||||||
|
"_wup_data" -> 915 字节 TAF struct(业务数据,见下)
|
||||||
|
"wupudbrequest_v0" -> 5 字节 = INT32(session) ← 裸 INT32 字段,非 struct
|
||||||
|
|
||||||
|
_wup_data struct(915 字节,STRUCT_BEGIN 包裹,字段 t0..t8):
|
||||||
|
t0 (struct, 请求"头"):
|
||||||
|
t0 = 0 (ZERO)
|
||||||
|
t1 = "1.0"
|
||||||
|
t2 = 元数据 JSON (compact, 固定键序)
|
||||||
|
t3 = bizAppid "5008"
|
||||||
|
t4 = 3 (INT8)
|
||||||
|
t5 = safedeviceid
|
||||||
|
t6 = ""
|
||||||
|
t7 = ""
|
||||||
|
t8 = user_action_json
|
||||||
|
t9 = ""
|
||||||
|
t1 (struct, 设备信息):
|
||||||
|
t0 = hdid
|
||||||
|
t1 = app_version "13.4.22"
|
||||||
|
t2 = sdk_version "1.0.80138"
|
||||||
|
t3 = ""
|
||||||
|
t4 = ip "127.0.0.1"
|
||||||
|
t5 = vendor "xiaomi"
|
||||||
|
t6 = ""
|
||||||
|
t2 (struct, 屏幕/设备特征,注意 t5 缺失):
|
||||||
|
t0 = 1 (INT8)
|
||||||
|
t1 = model "M2102J2SC"
|
||||||
|
t2 = fingerprint "02df3987..."
|
||||||
|
t3 = os "android"
|
||||||
|
t4 = screen "M2102J2SC,30,11"
|
||||||
|
t6 = width "1080"
|
||||||
|
t7 = height "2120"
|
||||||
|
t8 = device_id "7c5387e0..."
|
||||||
|
t3 = name "hy_<uid_str>"
|
||||||
|
t4 = sha1_password
|
||||||
|
t5 = LIST[1] = ["5008"]
|
||||||
|
t6 = 1 (INT8)
|
||||||
|
t7 = {} (空 MAP)
|
||||||
|
t8 = 空 SIMPLE_LIST (bytes)
|
||||||
|
|
||||||
|
两个疑点字节的最终结论:
|
||||||
|
- 偏移 27 的 0x66 ('f'): 并非字面 'f',而是 TAF head 字节 (tag6<<4 | STRING1=6)
|
||||||
|
= sFuncName 字段的 STRING1 头。ASCII 'f' 纯属巧合。
|
||||||
|
- 偏移 44 的 0x7d ('}'): 并非字面 '}',而是 TAF head 字节 (tag7<<4 | SIMPLE_LIST=13)
|
||||||
|
= sBuffer 字段的 SIMPLE_LIST(byte[]) 头。
|
||||||
|
偏移 45..51 的 7 字节 = SIMPLE_LIST 元素类型头(00) + 长度(01 03 c1 = 961)
|
||||||
|
+ sBuffer 内层 Map 头(08) + Map 长度(00 02 = 2)。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import random
|
||||||
|
import struct
|
||||||
|
import time as _time
|
||||||
|
from typing import Any, Dict
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# TAF 类型标签
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
INT8, INT16, INT32, INT64 = 0x00, 0x01, 0x02, 0x03
|
||||||
|
STRING1, STRING4 = 0x06, 0x07
|
||||||
|
MAP, LIST = 0x08, 0x09
|
||||||
|
STRUCT_BEGIN, STRUCT_END = 0x0A, 0x0B
|
||||||
|
ZERO, SIMPLE_LIST = 0x0C, 0x0D
|
||||||
|
|
||||||
|
|
||||||
|
class _Writer:
|
||||||
|
"""极简 TAF 输出流(仅够构造本请求帧)。"""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.buf = bytearray()
|
||||||
|
|
||||||
|
def get(self) -> bytes:
|
||||||
|
return bytes(self.buf)
|
||||||
|
|
||||||
|
def head(self, tag: int, dtype: int) -> None:
|
||||||
|
if tag < 15:
|
||||||
|
self.buf.append((tag << 4) | dtype)
|
||||||
|
else:
|
||||||
|
self.buf.append(0xF0 | dtype)
|
||||||
|
self.buf.append(tag)
|
||||||
|
|
||||||
|
def int8(self, tag: int, v: int) -> None:
|
||||||
|
if v == 0:
|
||||||
|
self.head(tag, ZERO)
|
||||||
|
else:
|
||||||
|
self.head(tag, INT8)
|
||||||
|
self.buf += struct.pack('b', v)
|
||||||
|
|
||||||
|
def int16(self, tag: int, v: int) -> None:
|
||||||
|
if -128 <= v <= 127:
|
||||||
|
self.int8(tag, v)
|
||||||
|
else:
|
||||||
|
self.head(tag, INT16)
|
||||||
|
self.buf += struct.pack('>h', v)
|
||||||
|
|
||||||
|
def int32(self, tag: int, v: int) -> None:
|
||||||
|
if -32768 <= v <= 32767:
|
||||||
|
self.int16(tag, v)
|
||||||
|
else:
|
||||||
|
self.head(tag, INT32)
|
||||||
|
self.buf += struct.pack('>i', v)
|
||||||
|
|
||||||
|
def int64(self, tag: int, v: int) -> None:
|
||||||
|
if -2147483648 <= v <= 2147483647:
|
||||||
|
self.int32(tag, v)
|
||||||
|
else:
|
||||||
|
self.head(tag, INT64)
|
||||||
|
self.buf += struct.pack('>q', v)
|
||||||
|
|
||||||
|
def string(self, tag: int, s: str) -> None:
|
||||||
|
b = s.encode('utf-8')
|
||||||
|
if len(b) > 255:
|
||||||
|
self.head(tag, STRING4)
|
||||||
|
self.buf += struct.pack('>I', len(b))
|
||||||
|
else:
|
||||||
|
self.head(tag, STRING1)
|
||||||
|
self.buf += struct.pack('B', len(b))
|
||||||
|
self.buf += b
|
||||||
|
|
||||||
|
def bytes(self, tag: int, b: bytes) -> None:
|
||||||
|
"""SIMPLE_LIST (byte[])。"""
|
||||||
|
self.head(tag, SIMPLE_LIST)
|
||||||
|
self.head(0, INT8) # 元素类型固定 INT8
|
||||||
|
self.int32(0, len(b)) # 长度(int32 优化编码)
|
||||||
|
self.buf += b
|
||||||
|
|
||||||
|
def struct_begin(self, tag: int) -> None:
|
||||||
|
self.head(tag, STRUCT_BEGIN)
|
||||||
|
|
||||||
|
def struct_end(self) -> None:
|
||||||
|
self.head(0, STRUCT_END)
|
||||||
|
|
||||||
|
def list_begin(self, tag: int, n: int) -> None:
|
||||||
|
self.head(tag, LIST)
|
||||||
|
self.int32(0, n)
|
||||||
|
|
||||||
|
def map_begin(self, tag: int, n: int) -> None:
|
||||||
|
self.head(tag, MAP)
|
||||||
|
self.int32(0, n)
|
||||||
|
|
||||||
|
|
||||||
|
def _build_meta_json(session: int, trace_id: str) -> str:
|
||||||
|
"""构造 _wup_data.t0.t2 元数据 JSON(固定键序,compact)。"""
|
||||||
|
meta: Dict[str, Any] = {
|
||||||
|
"associationId": 8193,
|
||||||
|
"funcName": "hypasswordLogin",
|
||||||
|
"group": 1,
|
||||||
|
"id": 4097,
|
||||||
|
"session": session,
|
||||||
|
"step": 1,
|
||||||
|
"stillLogin": False,
|
||||||
|
"traceId": trace_id,
|
||||||
|
"type": 3,
|
||||||
|
"uid": 0,
|
||||||
|
"userContext": "",
|
||||||
|
}
|
||||||
|
return json.dumps(meta, ensure_ascii=False, separators=(',', ':'))
|
||||||
|
|
||||||
|
|
||||||
|
def _make_name(uid_str: str) -> str:
|
||||||
|
"""登录名 = "hy_" + 虎牙号(若已带前缀则原样返回)。"""
|
||||||
|
if uid_str.startswith("hy_"):
|
||||||
|
return uid_str
|
||||||
|
return "hy_" + uid_str
|
||||||
|
|
||||||
|
|
||||||
|
def make_user_action(now_ms: int | None = None) -> str:
|
||||||
|
"""生成一条随机的 userAction 风控行为 JSON。
|
||||||
|
|
||||||
|
实测(2025-08 抓包 + 重放): 服务端对该字段不做严格校验,
|
||||||
|
随机坐标/时间戳与旧轨迹一样能通过登录(返回 uid+token)。
|
||||||
|
|
||||||
|
参数:
|
||||||
|
now_ms: 基准毫秒时间戳;缺省取当前时间。
|
||||||
|
"""
|
||||||
|
if now_ms is None:
|
||||||
|
now_ms = int(_time.time() * 1000)
|
||||||
|
t1 = now_ms
|
||||||
|
t2 = now_ms + random.randint(200, 900) # 两次点击间隔 200~900ms
|
||||||
|
return json.dumps(
|
||||||
|
{
|
||||||
|
"curl": "登录页",
|
||||||
|
"furl": "我的",
|
||||||
|
"latitude": "-1.0",
|
||||||
|
"longitude": "-1.0",
|
||||||
|
"ssid": "",
|
||||||
|
"user_action": [
|
||||||
|
{"id": "24", "time": str(t1),
|
||||||
|
"x": str(random.randint(150, 900)),
|
||||||
|
"y": str(random.randint(800, 1600))},
|
||||||
|
{"id": "11", "time": str(t2),
|
||||||
|
"x": str(random.randint(150, 900)),
|
||||||
|
"y": str(random.randint(800, 1600))},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
ensure_ascii=False,
|
||||||
|
separators=(",", ":"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def make_trace_id(pid: int = 0) -> str:
|
||||||
|
"""生成 traceId,格式 `<hex16>-<pid>-<毫秒时间戳>`(与服务端生成规则一致)。"""
|
||||||
|
return f"{random.getrandbits(64):016x}-{pid}-{int(_time.time() * 1000)}"
|
||||||
|
|
||||||
|
|
||||||
|
def _build_wup_data(w: _Writer, uid_str: str, sha1_password: str,
|
||||||
|
safedeviceid: str, hdid: str, session: int,
|
||||||
|
trace_id: str, user_action_json: str,
|
||||||
|
device_info: Dict[str, str]) -> None:
|
||||||
|
"""编码 915 字节的 _wup_data struct。"""
|
||||||
|
meta_json = _build_meta_json(session, trace_id)
|
||||||
|
name = _make_name(uid_str)
|
||||||
|
|
||||||
|
# ---- _wup_data struct(即"外层" struct)----
|
||||||
|
w.struct_begin(0) # _wup_data struct (0x0a)
|
||||||
|
|
||||||
|
# -- t0: 请求"头" struct --
|
||||||
|
w.struct_begin(0) # t0 (0x0a)
|
||||||
|
w.int8(0, 0) # t0.t0 = 0 -> ZERO (0x0c)
|
||||||
|
w.string(1, "1.0") # t0.t1 = "1.0"
|
||||||
|
w.string(2, meta_json) # t0.t2 = 元数据 JSON
|
||||||
|
w.string(3, "5008") # t0.t3 = bizAppid
|
||||||
|
w.int8(4, 3) # t0.t4 = 3 -> INT8
|
||||||
|
w.string(5, safedeviceid) # t0.t5
|
||||||
|
w.string(6, "") # t0.t6
|
||||||
|
w.string(7, "") # t0.t7
|
||||||
|
w.string(8, user_action_json) # t0.t8
|
||||||
|
w.string(9, "") # t0.t9
|
||||||
|
w.struct_end() # 0x0b
|
||||||
|
|
||||||
|
# -- t1: 设备信息 struct --
|
||||||
|
di = device_info
|
||||||
|
w.struct_begin(1) # t1 (0x1a)
|
||||||
|
w.string(0, hdid) # t1.t0 = hdid
|
||||||
|
w.string(1, di["app_version"]) # t1.t1 = "13.4.22"
|
||||||
|
w.string(2, di["sdk_version"]) # t1.t2 = "1.0.80138"
|
||||||
|
w.string(3, "") # t1.t3
|
||||||
|
w.string(4, di["ip"]) # t1.t4 = "127.0.0.1"
|
||||||
|
w.string(5, di["vendor"]) # t1.t5 = "xiaomi"
|
||||||
|
w.string(6, "") # t1.t6
|
||||||
|
w.struct_end() # 0x0b
|
||||||
|
|
||||||
|
# -- t2: 屏幕/设备特征 struct(注意 t5 缺省)--
|
||||||
|
w.struct_begin(2) # t2 (0x2a)
|
||||||
|
w.int8(0, 1) # t2.t0 = 1 -> INT8
|
||||||
|
w.string(1, di["model"]) # t2.t1 = "M2102J2SC"
|
||||||
|
w.string(2, di["fingerprint"]) # t2.t2 = 40 位指纹
|
||||||
|
w.string(3, di["os"]) # t2.t3 = "android"
|
||||||
|
w.string(4, di["screen"]) # t2.t4 = "M2102J2SC,30,11"
|
||||||
|
# (t5 缺省)
|
||||||
|
w.string(6, di["width"]) # t2.t6 = "1080"
|
||||||
|
w.string(7, di["height"]) # t2.t7 = "2120"
|
||||||
|
w.string(8, di["device_id"]) # t2.t8 = 40 位设备ID
|
||||||
|
w.struct_end() # 0x0b
|
||||||
|
|
||||||
|
# -- 登录字段 --
|
||||||
|
w.string(3, name) # t3 = "hy_300023887"
|
||||||
|
w.string(4, sha1_password) # t4 = SHA1 hex
|
||||||
|
w.list_begin(5, 1) # t5 = LIST[1]
|
||||||
|
w.string(0, "5008") # item = "5008"
|
||||||
|
w.int8(6, 1) # t6 = 1 -> INT8
|
||||||
|
w.map_begin(7, 0) # t7 = 空 MAP
|
||||||
|
w.bytes(8, b"") # t8 = 空 bytes (8d 00 0c)
|
||||||
|
|
||||||
|
w.struct_end() # 0x0b (_wup_data struct 结束)
|
||||||
|
|
||||||
|
|
||||||
|
def build_password_login_wup(
|
||||||
|
uid_str: str,
|
||||||
|
sha1_password: str,
|
||||||
|
safedeviceid: str,
|
||||||
|
hdid: str,
|
||||||
|
session: int,
|
||||||
|
trace_id: str,
|
||||||
|
user_action_json: str,
|
||||||
|
device_info: Dict[str, str],
|
||||||
|
) -> bytes:
|
||||||
|
"""构造密码登录的 WUP TAF 请求体(1015 字节那种,未 base64)。
|
||||||
|
|
||||||
|
参数:
|
||||||
|
uid_str : 虎牙号(如 "300023887"),登录名 = "hy_" + uid_str。
|
||||||
|
若已带 "hy_" 前缀则原样使用。
|
||||||
|
sha1_password : SHA1(明文密码) 的 40 位 hex 字符串。
|
||||||
|
safedeviceid : 设备安全 ID(长 base64 串)。
|
||||||
|
hdid : 硬件设备 ID(32 位 hex)。
|
||||||
|
session : 会话/请求 id(同时写入 iRequestId、元数据 session、
|
||||||
|
"wupudbrequest_v0" 值)。
|
||||||
|
trace_id : 元数据 traceId。
|
||||||
|
user_action_json : 用户行为 JSON 字符串(curl/furl/user_action 等)。
|
||||||
|
device_info : dict,需包含键:
|
||||||
|
app_version, sdk_version, vendor, model, os,
|
||||||
|
fingerprint, screen, width, height, device_id, ip。
|
||||||
|
|
||||||
|
返回:
|
||||||
|
1015 字节 TAF 二进制(HTTP body 为 base64 编码后发送)。
|
||||||
|
"""
|
||||||
|
# 1. _wup_data struct
|
||||||
|
wd = _Writer()
|
||||||
|
_build_wup_data(wd, uid_str, sha1_password, safedeviceid, hdid,
|
||||||
|
session, trace_id, user_action_json, device_info)
|
||||||
|
wup_data = wd.get()
|
||||||
|
|
||||||
|
# 2. "wupudbrequest_v0" 值 = 裸 INT32(session)(非 struct)
|
||||||
|
req = _Writer()
|
||||||
|
req.int32(0, session)
|
||||||
|
wupdbreq_v0 = req.get()
|
||||||
|
|
||||||
|
# 3. sBuffer = Map<str, bytes>,2 项
|
||||||
|
sb = _Writer()
|
||||||
|
sb.map_begin(0, 2)
|
||||||
|
sb.string(0, "_wup_data")
|
||||||
|
sb.bytes(1, wup_data)
|
||||||
|
sb.string(0, "wupudbrequest_v0")
|
||||||
|
sb.bytes(1, wupdbreq_v0)
|
||||||
|
s_buffer = sb.get()
|
||||||
|
|
||||||
|
# 4. WUP header
|
||||||
|
w = _Writer()
|
||||||
|
w.int16(1, 3) # iVersion = 3 -> INT8
|
||||||
|
w.int8(2, 0) # cPacketType = 0 -> ZERO
|
||||||
|
w.int8(3, 0) # iMessageType = 0 -> ZERO
|
||||||
|
w.int32(4, session) # iRequestId -> INT32
|
||||||
|
w.string(5, "huyaudbwebui") # sServantName
|
||||||
|
w.string(6, "hypasswordLogin") # sFuncName
|
||||||
|
w.bytes(7, s_buffer) # sBuffer
|
||||||
|
w.int32(8, 0) # iTimeout = 0 -> ZERO
|
||||||
|
w.map_begin(9, 0) # context = 空 MAP
|
||||||
|
w.map_begin(10, 0) # status = 空 MAP
|
||||||
|
wup_body = w.get()
|
||||||
|
|
||||||
|
# 5. 长度前缀
|
||||||
|
return struct.pack('>I', 4 + len(wup_body)) + wup_body
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 自检
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import os
|
||||||
|
|
||||||
|
here = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
golden_path = os.path.join(here, "..", "evidence", "wup_passwordlogin_taf.bin")
|
||||||
|
golden = open(golden_path, "rb").read()
|
||||||
|
|
||||||
|
device_info = {
|
||||||
|
"app_version": "13.4.22",
|
||||||
|
"sdk_version": "1.0.80138",
|
||||||
|
"vendor": "xiaomi",
|
||||||
|
"model": "M2102J2SC",
|
||||||
|
"os": "android",
|
||||||
|
"ip": "127.0.0.1",
|
||||||
|
"fingerprint": "02df398797432eadefcc12767119ad5e80999389",
|
||||||
|
"screen": "M2102J2SC,30,11",
|
||||||
|
"width": "1080",
|
||||||
|
"height": "2120",
|
||||||
|
"device_id": "7c5387e0539c023c31c4ff0e807e7256117385ee",
|
||||||
|
}
|
||||||
|
|
||||||
|
out = build_password_login_wup(
|
||||||
|
uid_str="300023887",
|
||||||
|
sha1_password="772ed992b0e161276f44ec63671e60155c506294",
|
||||||
|
safedeviceid=("PQwemAN9NHkZKoMqVTFUZBIypqMTaQEOrmXr37xQVhQZqrL/gUKEQ11xvE0ju48V8O/"
|
||||||
|
"t9UBGSp27m4+6bP4IiAEnpaR5Rj1kHEfN2SPLPqYZW9vroxUSoAvjJn6ezTP9jWGxxlRDCbt"
|
||||||
|
"Py4Rd6MencYT/pNImVIWK+YbNKZt1O05bHUFhqHf3"),
|
||||||
|
hdid="ed0db8334cadd236c00cadf7e11ab5a5",
|
||||||
|
session=3251699,
|
||||||
|
trace_id="0b8f098ff64a5bdc-23473-94783833787595172451",
|
||||||
|
user_action_json=('{"curl":"登录页","furl":"我的","latitude":"-1.0","longitude":"-1.0",'
|
||||||
|
'"ssid":"","user_action":[{"id":"24","time":"1787595171950","x":"277","y":"1057"},'
|
||||||
|
'{"id":"11","time":"1787595172444","x":"296","y":"952"}]}'),
|
||||||
|
device_info=device_info,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert out == golden, "编码结果与金标准不一致!"
|
||||||
|
print("编码器验证通过")
|
||||||
|
print(f" 输出长度 = {len(out)} 字节,与金标准逐字节一致")
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
"""解析虎牙 App HAR:解码 WUP(TAF) 全字段 + dckey/check 分析。
|
||||||
|
|
||||||
|
用法:
|
||||||
|
.venv/bin/python tools/hy_har_parse.py <file.har> [--req] [--resp]
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
|
||||||
|
sys.path.insert(0, ".")
|
||||||
|
|
||||||
|
from tools.taf_decode import ( # noqa: E402
|
||||||
|
Taf, STRUCT_END, MAP, SIMPLE_LIST, decode_struct_bytes,
|
||||||
|
)
|
||||||
|
|
||||||
|
STR = (6, 7) # STRING1, STRING4
|
||||||
|
SIMPLE = 13
|
||||||
|
|
||||||
|
|
||||||
|
def norm_payload(data: bytes) -> bytes:
|
||||||
|
"""去掉可选 4 字节 WUP 长度前缀。"""
|
||||||
|
if len(data) < 4:
|
||||||
|
return data
|
||||||
|
declared = int.from_bytes(data[0:4], "big")
|
||||||
|
if declared in (len(data), len(data) - 4):
|
||||||
|
return data[4:]
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def decode_wup_header(data: bytes):
|
||||||
|
"""返回 (servant, func, sBuffer_bytes)。"""
|
||||||
|
t = Taf(norm_payload(data))
|
||||||
|
servant = ""
|
||||||
|
func = ""
|
||||||
|
sbuf = b""
|
||||||
|
while not t.eof():
|
||||||
|
try:
|
||||||
|
tag, dt = t.read_head()
|
||||||
|
except EOFError:
|
||||||
|
break
|
||||||
|
if tag > 10:
|
||||||
|
break
|
||||||
|
if tag == 5 and dt in STR:
|
||||||
|
servant = t.read_string_value(dt)
|
||||||
|
elif tag == 6 and dt in STR:
|
||||||
|
func = t.read_string_value(dt)
|
||||||
|
elif tag == 7 and dt == SIMPLE:
|
||||||
|
t.read_head()
|
||||||
|
n = t.read_len()
|
||||||
|
sbuf = t.d[t.p:t.p + n]
|
||||||
|
t.p += n
|
||||||
|
else:
|
||||||
|
t.skip_value(dt)
|
||||||
|
return servant, func, sbuf
|
||||||
|
|
||||||
|
|
||||||
|
def decode_sbuffer_map(sbuf: bytes):
|
||||||
|
"""sBuffer 是一个 Map<str, bytes>,返回 {key: bytes}。"""
|
||||||
|
if not sbuf:
|
||||||
|
return {}
|
||||||
|
t = Taf(sbuf)
|
||||||
|
out = {}
|
||||||
|
try:
|
||||||
|
tag, dt = t.read_head()
|
||||||
|
if not (tag == 0 and dt == MAP):
|
||||||
|
return {"<raw>": sbuf}
|
||||||
|
n = t.read_len()
|
||||||
|
for _ in range(min(n, 20)):
|
||||||
|
_, kt = t.read_head()
|
||||||
|
k = t.read_string_value(kt) if kt in STR else f"<k{kt}>"
|
||||||
|
_, vt = t.read_head()
|
||||||
|
if vt == SIMPLE:
|
||||||
|
t.read_head()
|
||||||
|
ln = t.read_len()
|
||||||
|
out[k] = t.d[t.p:t.p + ln]; t.p += ln
|
||||||
|
else:
|
||||||
|
t.skip_value(vt)
|
||||||
|
except Exception as e:
|
||||||
|
out["<err>"] = str(e).encode()
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def dump_har(path: str, show_req: bool = True, show_resp: bool = True):
|
||||||
|
with open(path, encoding="utf-8", errors="ignore") as fp:
|
||||||
|
h = json.load(fp)
|
||||||
|
ents = h["log"]["entries"]
|
||||||
|
|
||||||
|
targets = ("wup.huya.com", "statwup.huya.com", "wsapi.huya.com",
|
||||||
|
"udbdf.huya.com", "udblgn.huya.com", "udbreg.huya.com")
|
||||||
|
|
||||||
|
for i, e in enumerate(ents):
|
||||||
|
req = e["request"]
|
||||||
|
u = req["url"]
|
||||||
|
if req["method"] != "POST" or not any(x in u for x in targets):
|
||||||
|
continue
|
||||||
|
pd = req.get("postData", {})
|
||||||
|
txt = pd.get("text", "")
|
||||||
|
mime = pd.get("mimeType", "")
|
||||||
|
print("=" * 80)
|
||||||
|
print(f"#{i} {u[:110]}")
|
||||||
|
print(f" mime={mime} textLen={len(txt)}")
|
||||||
|
|
||||||
|
# ---- 请求 ----
|
||||||
|
if txt:
|
||||||
|
try:
|
||||||
|
raw = base64.b64decode(txt)
|
||||||
|
except Exception:
|
||||||
|
raw = txt.encode("latin1")
|
||||||
|
if len(raw) >= 6 and raw[4:6] == b"\x10\x03" and show_req:
|
||||||
|
servant, func, sbuf = decode_wup_header(raw)
|
||||||
|
print(f" [REQ WUP] {servant}.{func}")
|
||||||
|
for k, v in decode_sbuffer_map(sbuf).items():
|
||||||
|
print(f" {k}: {json.dumps(decode_struct_bytes(v), ensure_ascii=False)[:700]}")
|
||||||
|
else:
|
||||||
|
# 加密 binary(如 dckey/check、statwup 应用层加密)
|
||||||
|
print(f" [REQ BINARY] {len(raw)}B hex={raw[:32].hex()}")
|
||||||
|
analyze_binary(raw)
|
||||||
|
|
||||||
|
# ---- 响应 ----
|
||||||
|
resp = e.get("response", {})
|
||||||
|
c = resp.get("content", {})
|
||||||
|
rtext = c.get("text", "")
|
||||||
|
if rtext and show_resp:
|
||||||
|
if rtext.lstrip().startswith("{"):
|
||||||
|
print(f" [RESP JSON] {rtext[:220]}")
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
rraw = base64.b64decode(rtext)
|
||||||
|
except Exception:
|
||||||
|
rraw = rtext.encode("latin1")
|
||||||
|
if len(rraw) >= 6 and rraw[4:6] == b"\x10\x03":
|
||||||
|
servant, func, sbuf = decode_wup_header(rraw)
|
||||||
|
print(f" [RESP WUP] {servant}.{func}")
|
||||||
|
for k, v in decode_sbuffer_map(sbuf).items():
|
||||||
|
print(f" {k}: {json.dumps(decode_struct_bytes(v), ensure_ascii=False)[:700]}")
|
||||||
|
else:
|
||||||
|
print(f" [RESP BINARY] {len(rraw)}B hex={rraw[:32].hex()}")
|
||||||
|
elif rtext:
|
||||||
|
print(f" [RESP raw] {rtext[:200]!r}")
|
||||||
|
|
||||||
|
|
||||||
|
def analyze_binary(raw: bytes):
|
||||||
|
"""对加密 body 做基础分析:长度/对齐/头部熵。"""
|
||||||
|
print(f" len={len(raw)} mod16={len(raw) % 16} mod64={len(raw) % 64}")
|
||||||
|
# 头部是否像有固定结构(前几字节恒定)
|
||||||
|
print(f" head32={raw[:32].hex()}")
|
||||||
|
print(f" tail16={raw[-16:].hex()}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
args = [a for a in sys.argv[1:] if not a.startswith("--")]
|
||||||
|
path = args[0] if args else "-"
|
||||||
|
show_req = "--req" not in sys.argv or True
|
||||||
|
show_resp = "--resp" not in sys.argv or True
|
||||||
|
dump_har(path)
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
"""虎牙 TAF(JCE) 纯字节流递归解码器 — 不依赖项目 read_* 高层 API。
|
||||||
|
|
||||||
|
独立实现 head/value 解析,直接按 dtype 读取 value(已消费 head 的前提下),
|
||||||
|
避免与 TafInputStream.read_string 等"内部再读 head"的方法冲突。
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import struct
|
||||||
|
|
||||||
|
# 类型标签(与 core/huya/taf_protocol.py TafType 一致)
|
||||||
|
INT8, INT16, INT32, INT64 = 0x00, 0x01, 0x02, 0x03
|
||||||
|
FLOAT, DOUBLE = 0x04, 0x05
|
||||||
|
STRING1, STRING4 = 0x06, 0x07
|
||||||
|
MAP, LIST = 0x08, 0x09
|
||||||
|
STRUCT_BEGIN, STRUCT_END = 0x0A, 0x0B
|
||||||
|
ZERO, SIMPLE_LIST = 0x0C, 0x0D
|
||||||
|
|
||||||
|
_STR = (STRING1, STRING4)
|
||||||
|
_INTS = (INT8, INT16, INT32, INT64)
|
||||||
|
|
||||||
|
|
||||||
|
class Taf:
|
||||||
|
"""带游标的字节读取器"""
|
||||||
|
|
||||||
|
def __init__(self, data: bytes):
|
||||||
|
self.d = data
|
||||||
|
self.p = 0
|
||||||
|
|
||||||
|
def eof(self) -> bool:
|
||||||
|
return self.p >= len(self.d)
|
||||||
|
|
||||||
|
def peek_head(self):
|
||||||
|
if self.eof():
|
||||||
|
raise EOFError
|
||||||
|
b = self.d[self.p]
|
||||||
|
tag = (b >> 4) & 0x0F
|
||||||
|
dt = b & 0x0F
|
||||||
|
if tag == 15:
|
||||||
|
tag = self.d[self.p + 1]
|
||||||
|
return tag, dt
|
||||||
|
|
||||||
|
def read_head(self):
|
||||||
|
tag, dt = self.peek_head()
|
||||||
|
self.p += 1
|
||||||
|
if (self.d[self.p - 1] >> 4) & 0x0F == 15:
|
||||||
|
self.p += 1
|
||||||
|
return tag, dt
|
||||||
|
|
||||||
|
def read_len(self) -> int:
|
||||||
|
"""读 map/list 长度(int32 优化编码)"""
|
||||||
|
tag, dt = self.read_head()
|
||||||
|
return self.read_int_value(dt)
|
||||||
|
|
||||||
|
def read_int_value(self, dt: int) -> int:
|
||||||
|
if dt == ZERO:
|
||||||
|
return 0
|
||||||
|
if dt == INT8:
|
||||||
|
v = struct.unpack('b', self.d[self.p:self.p + 1])[0]; self.p += 1; return v
|
||||||
|
if dt == INT16:
|
||||||
|
v = struct.unpack('>h', self.d[self.p:self.p + 2])[0]; self.p += 2; return v
|
||||||
|
if dt == INT32:
|
||||||
|
v = struct.unpack('>i', self.d[self.p:self.p + 4])[0]; self.p += 4; return v
|
||||||
|
if dt == INT64:
|
||||||
|
v = struct.unpack('>q', self.d[self.p:self.p + 8])[0]; self.p += 8; return v
|
||||||
|
raise ValueError(f"期望整数, 实际 0x{dt:02x} @{self.p}")
|
||||||
|
|
||||||
|
def read_string_value(self, dt: int) -> str:
|
||||||
|
if dt == STRING1:
|
||||||
|
n = self.d[self.p]; self.p += 1
|
||||||
|
elif dt == STRING4:
|
||||||
|
n = struct.unpack('>I', self.d[self.p:self.p + 4])[0]; self.p += 4
|
||||||
|
else:
|
||||||
|
raise ValueError(f"期望 string, 实际 0x{dt:02x} @{self.p}")
|
||||||
|
v = self.d[self.p:self.p + n].decode('utf-8', errors='replace'); self.p += n
|
||||||
|
return v
|
||||||
|
|
||||||
|
def skip_value(self, dt: int):
|
||||||
|
if dt == ZERO:
|
||||||
|
return
|
||||||
|
if dt in _INTS:
|
||||||
|
self.p += {INT8: 1, INT16: 2, INT32: 4, INT64: 8}[dt]
|
||||||
|
elif dt in (FLOAT,):
|
||||||
|
self.p += 4
|
||||||
|
elif dt in (DOUBLE,):
|
||||||
|
self.p += 8
|
||||||
|
elif dt == STRING1:
|
||||||
|
self.p += 1 + self.d[self.p]
|
||||||
|
elif dt == STRING4:
|
||||||
|
self.p += 4 + struct.unpack('>I', self.d[self.p:self.p + 4])[0]
|
||||||
|
elif dt == SIMPLE_LIST:
|
||||||
|
self.read_head() # 元素类型
|
||||||
|
n = self.read_len()
|
||||||
|
self.p += n
|
||||||
|
elif dt == MAP:
|
||||||
|
n = self.read_len()
|
||||||
|
for _ in range(n):
|
||||||
|
_, kt = self.read_head(); self.skip_value(kt)
|
||||||
|
_, vt = self.read_head(); self.skip_value(vt)
|
||||||
|
elif dt == LIST:
|
||||||
|
n = self.read_len()
|
||||||
|
for _ in range(n):
|
||||||
|
_, it = self.read_head(); self.skip_value(it)
|
||||||
|
elif dt == STRUCT_BEGIN:
|
||||||
|
while True:
|
||||||
|
_, t = self.read_head()
|
||||||
|
if t == STRUCT_END:
|
||||||
|
break
|
||||||
|
self.skip_value(t)
|
||||||
|
# STRUCT_END 不需要处理
|
||||||
|
|
||||||
|
|
||||||
|
def decode_value(t: Taf, dt: int, depth: int = 0, max_depth: int = 6, max_items: int = 50):
|
||||||
|
"""给定已消费 head 的 dtype,递归读 value。"""
|
||||||
|
if dt == ZERO:
|
||||||
|
return 0
|
||||||
|
if dt in _INTS:
|
||||||
|
return t.read_int_value(dt)
|
||||||
|
if dt == FLOAT:
|
||||||
|
v = struct.unpack('>f', t.d[t.p:t.p + 4])[0]; t.p += 4; return round(v, 6)
|
||||||
|
if dt == DOUBLE:
|
||||||
|
v = struct.unpack('>d', t.d[t.p:t.p + 8])[0]; t.p += 8; return round(v, 6)
|
||||||
|
if dt in _STR:
|
||||||
|
return t.read_string_value(dt)
|
||||||
|
if dt == SIMPLE_LIST:
|
||||||
|
t.read_head() # 元素类型
|
||||||
|
n = t.read_len()
|
||||||
|
raw = t.d[t.p:t.p + n]; t.p += n
|
||||||
|
# 尝试 utf8 可读
|
||||||
|
try:
|
||||||
|
s = raw.decode('utf-8')
|
||||||
|
if s.isprintable() and n < 500:
|
||||||
|
return s
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return {"_bytes": n, "hex": raw[:48].hex()}
|
||||||
|
if dt == MAP:
|
||||||
|
n = t.read_len()
|
||||||
|
m = {}
|
||||||
|
for _ in range(min(n, max_items)):
|
||||||
|
kt_tag, kdt = t.read_head()
|
||||||
|
k = decode_value(t, kdt, depth + 1) if kdt != STRUCT_BEGIN else _decode_struct(t, depth + 1)
|
||||||
|
vt_tag, vdt = t.read_head()
|
||||||
|
v = decode_value(t, vdt, depth + 1) if depth + 1 < max_depth else _skip_str(t, vdt)
|
||||||
|
m[str(k)] = v
|
||||||
|
return m
|
||||||
|
if dt == LIST:
|
||||||
|
n = t.read_len()
|
||||||
|
items = []
|
||||||
|
for _ in range(min(n, max_items)):
|
||||||
|
it_tag, idt = t.read_head()
|
||||||
|
items.append(decode_value(t, idt, depth + 1) if depth + 1 < max_depth else _skip_str(t, idt))
|
||||||
|
return items
|
||||||
|
if dt == STRUCT_BEGIN:
|
||||||
|
return _decode_struct(t, depth + 1)
|
||||||
|
return f"<0x{dt:02x}>"
|
||||||
|
|
||||||
|
|
||||||
|
def _skip_str(t: Taf, dt: int):
|
||||||
|
t.skip_value(dt)
|
||||||
|
return f"<0x{dt:02x}>"
|
||||||
|
|
||||||
|
|
||||||
|
def _decode_struct(t: Taf, depth: int = 0):
|
||||||
|
"""已消费 STRUCT_BEGIN,读到 STRUCT_END。"""
|
||||||
|
out = {}
|
||||||
|
cnt = 0
|
||||||
|
while not t.eof() and cnt < 100:
|
||||||
|
try:
|
||||||
|
tag, dt = t.read_head()
|
||||||
|
except EOFError:
|
||||||
|
break
|
||||||
|
if dt == STRUCT_END:
|
||||||
|
break
|
||||||
|
key = f"t{tag}"
|
||||||
|
if depth < 6:
|
||||||
|
try:
|
||||||
|
out[key] = decode_value(t, dt, depth)
|
||||||
|
except Exception as e:
|
||||||
|
t.skip_value(dt)
|
||||||
|
out[key] = f"<err {e}>"
|
||||||
|
else:
|
||||||
|
t.skip_value(dt)
|
||||||
|
out[key] = f"<0x{dt:02x}>"
|
||||||
|
cnt += 1
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def decode_struct_bytes(data: bytes):
|
||||||
|
"""解析一个 TAF struct 字节串(可含或不含 STRUCT_BEGIN 头)。"""
|
||||||
|
t = Taf(data)
|
||||||
|
out = {}
|
||||||
|
cnt = 0
|
||||||
|
while not t.eof() and cnt < 100:
|
||||||
|
try:
|
||||||
|
tag, dt = t.read_head()
|
||||||
|
except EOFError:
|
||||||
|
break
|
||||||
|
if dt == STRUCT_END:
|
||||||
|
break
|
||||||
|
key = f"t{tag}"
|
||||||
|
try:
|
||||||
|
out[key] = decode_value(t, dt, 0)
|
||||||
|
except Exception as e:
|
||||||
|
t.skip_value(dt)
|
||||||
|
out[key] = f"<err {e}>"
|
||||||
|
cnt += 1
|
||||||
|
return out
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
"""通用 Taf/JCE 递归解析 dump 工具(调试用)。"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
|
||||||
|
sys.path.insert(0, ".")
|
||||||
|
|
||||||
|
from core.huya.taf_protocol import TafInputStream, TafType # noqa: E402
|
||||||
|
|
||||||
|
STR = (TafType.STRING1, TafType.STRING4)
|
||||||
|
INTS = (TafType.INT8, TafType.INT16, TafType.INT32, TafType.INT64)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_struct(i: TafInputStream, depth: int = 0):
|
||||||
|
"""解析到 STRUCT_END 或数据结束, 返回 dict。"""
|
||||||
|
out = {}
|
||||||
|
pad = " " * depth
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
tag, dtype = i.peek_head()
|
||||||
|
except Exception:
|
||||||
|
break
|
||||||
|
if dtype == TafType.STRUCT_END:
|
||||||
|
i.read_head()
|
||||||
|
break
|
||||||
|
key = f"tag{tag}"
|
||||||
|
if dtype == TafType.STRUCT_BEGIN:
|
||||||
|
i.read_head()
|
||||||
|
print(f"{pad}{key}: {{")
|
||||||
|
out[key] = parse_struct(i, depth + 1)
|
||||||
|
print(f"{pad}}}")
|
||||||
|
elif dtype in STR:
|
||||||
|
val = i.read_string(tag)
|
||||||
|
show = val if len(val) <= 300 else val[:300] + f"...({len(val)})"
|
||||||
|
print(f"{pad}{key}: STR {show!r}")
|
||||||
|
out[key] = val
|
||||||
|
elif dtype in INTS:
|
||||||
|
val = {TafType.INT8: i.read_int8, TafType.INT16: i.read_int16,
|
||||||
|
TafType.INT32: i.read_int32, TafType.INT64: i.read_int64}[dtype](tag)
|
||||||
|
print(f"{pad}{key}: INT {val}")
|
||||||
|
out[key] = val
|
||||||
|
elif dtype == TafType.ZERO:
|
||||||
|
i.read_head()
|
||||||
|
print(f"{pad}{key}: 0")
|
||||||
|
out[key] = 0
|
||||||
|
elif dtype == TafType.MAP:
|
||||||
|
n = i.read_int32(0)
|
||||||
|
print(f"{pad}{key}: MAP[{n}]")
|
||||||
|
m = {}
|
||||||
|
for _ in range(n):
|
||||||
|
kt, kdt = i.peek_head()
|
||||||
|
if kdt in STR:
|
||||||
|
kv = i.read_string(kt)
|
||||||
|
elif kdt in INTS:
|
||||||
|
kv = str({TafType.INT8: i.read_int8, TafType.INT16: i.read_int16,
|
||||||
|
TafType.INT32: i.read_int32, TafType.INT64: i.read_int64}[kdt](kt))
|
||||||
|
else:
|
||||||
|
break
|
||||||
|
vt, vdt = i.peek_head()
|
||||||
|
if vdt == TafType.STRUCT_BEGIN:
|
||||||
|
i.read_head()
|
||||||
|
print(f"{pad} [{kv}]: {{")
|
||||||
|
m[str(kv)] = parse_struct(i, depth + 2)
|
||||||
|
print(f"{pad} }}")
|
||||||
|
elif vdt == TafType.SIMPLE_LIST:
|
||||||
|
sv = i.read_bytes(vt)
|
||||||
|
m[str(kv)] = sv.hex()
|
||||||
|
print(f"{pad} [{kv}] BYTES[{len(sv)}] {sv[:40].hex()}")
|
||||||
|
elif vdt in STR:
|
||||||
|
sv = i.read_string(vt); m[str(kv)] = sv
|
||||||
|
print(f"{pad} [{kv}] STR {sv[:120]!r}")
|
||||||
|
else:
|
||||||
|
i.read_head(); i.skip_field(vdt)
|
||||||
|
out[key] = m
|
||||||
|
elif dtype == TafType.LIST:
|
||||||
|
n = i.read_int32(0)
|
||||||
|
print(f"{pad}{key}: LIST[{n}]")
|
||||||
|
items = []
|
||||||
|
for _ in range(n):
|
||||||
|
it, idt = i.read_head()
|
||||||
|
if idt in STR:
|
||||||
|
items.append(i.read_string(it))
|
||||||
|
elif idt in INTS:
|
||||||
|
items.append({TafType.INT8: i.read_int8, TafType.INT16: i.read_int16,
|
||||||
|
TafType.INT32: i.read_int32, TafType.INT64: i.read_int64}[idt](it))
|
||||||
|
else:
|
||||||
|
i.skip_field(idt)
|
||||||
|
out[key] = items
|
||||||
|
elif dtype == TafType.SIMPLE_LIST:
|
||||||
|
i.read_head() # 元素类型 INT8
|
||||||
|
n = i.read_int32(0)
|
||||||
|
raw = (getattr(i, 'stream', None) or getattr(i, 'buf')).read(n)
|
||||||
|
print(f"{pad}{key}: BYTES[{n}] {raw[:60].hex()}")
|
||||||
|
out[key] = raw.hex()
|
||||||
|
else:
|
||||||
|
print(f"{pad}{key}: ?type={hex(dtype)}")
|
||||||
|
try:
|
||||||
|
i.skip_field(dtype)
|
||||||
|
except Exception:
|
||||||
|
break
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def dump_packet(data: bytes, label: str):
|
||||||
|
print("=" * 70)
|
||||||
|
print(label, f"({len(data)} bytes)")
|
||||||
|
print("=" * 70)
|
||||||
|
from core.huya.wup_protocol import WupResponse
|
||||||
|
r = WupResponse()
|
||||||
|
try:
|
||||||
|
r.decode(data)
|
||||||
|
print(f"iVersion={r.iVersion} servant={r.sServantName!r} func={r.sFuncName!r}")
|
||||||
|
for k, v in r.newdata.items():
|
||||||
|
print(f"--- newdata[{k!r}] len={len(v)}")
|
||||||
|
if not k or len(v) < 8:
|
||||||
|
print(" ", v.hex())
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
parse_struct(TafInputStream(v))
|
||||||
|
except Exception as e:
|
||||||
|
print(" parse err:", e, "| hex:", v.hex()[:200])
|
||||||
|
except Exception as e:
|
||||||
|
# 可能没有长度前缀, 直接当 body
|
||||||
|
print("decode with prefix failed:", e, "-> try raw")
|
||||||
|
parse_struct(TafInputStream(data))
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
import argparse
|
||||||
|
ap = argparse.ArgumentParser()
|
||||||
|
ap.add_argument("path")
|
||||||
|
ap.add_argument("--b64", action="store_true", help="输入是 base64")
|
||||||
|
args = ap.parse_args()
|
||||||
|
if args.path == "-":
|
||||||
|
raw = sys.stdin.buffer.read()
|
||||||
|
else:
|
||||||
|
raw = open(args.path, "rb").read()
|
||||||
|
if args.b64:
|
||||||
|
data = base64.b64decode(raw)
|
||||||
|
else:
|
||||||
|
data = raw
|
||||||
|
dump_packet(data, args.path)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Reference in New Issue
Block a user