diff --git a/.gitignore b/.gitignore index 8f998c1..5667af5 100644 --- a/.gitignore +++ b/.gitignore @@ -20,6 +20,11 @@ bg.jpg fullbg.jpg slice.jpg +# 虎牙登录抓包证据(含有效登录 token / 手机号掩码,勿提交) +evidence/huya_token.txt +evidence/wup_response_login.bin +evidence/wup_response_new_session.bin + # Node 依赖 **/node_modules/ web/frontend/dist/ diff --git a/docs/虎牙App密码登录-WUP明文抓包.md b/docs/虎牙App密码登录-WUP明文抓包.md new file mode 100644 index 0000000..dcc448c --- /dev/null +++ b/docs/虎牙App密码登录-WUP明文抓包.md @@ -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` 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 登录被风控阻断时的替代路径 diff --git a/docs/虎牙App密码登录协议-抓包实证.md b/docs/虎牙App密码登录协议-抓包实证.md new file mode 100644 index 0000000..c3d1931 --- /dev/null +++ b/docs/虎牙App密码登录协议-抓包实证.md @@ -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/时间戳等字段。 diff --git a/docs/虎牙App登录-APK静态分析.md b/docs/虎牙App登录-APK静态分析.md new file mode 100644 index 0000000..c8738c6 --- /dev/null +++ b/docs/虎牙App登录-APK静态分析.md @@ -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>) | +| `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 + ├─ 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 | diff --git a/docs/虎牙App登录-加密算法动态验证.md b/docs/虎牙App登录-加密算法动态验证.md new file mode 100644 index 0000000..a539e28 --- /dev/null +++ b/docs/虎牙App登录-加密算法动态验证.md @@ -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(" 关键坑: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 值。 diff --git a/docs/虎牙App登录逆向笔记.md b/docs/虎牙App登录逆向笔记.md new file mode 100644 index 0000000..a51d6bc --- /dev/null +++ b/docs/虎牙App登录逆向笔记.md @@ -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": ""}}}` —— 按 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=` + +--- + +## 四、加密体分析汇总 + +| 加密体 | 位置 | 结构 | 猜测算法 | +|---|---|---|---| +| 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 +``` diff --git a/evidence/wup_passwordlogin_base64.bin b/evidence/wup_passwordlogin_base64.bin new file mode 100644 index 0000000..89f81a6 --- /dev/null +++ b/evidence/wup_passwordlogin_base64.bin @@ -0,0 +1 @@ +AAAD9xADLDxCADGd81YMaHV5YXVkYndlYnVpZg9oeXBhc3N3b3JkTG9naW59AAEDwQgAAgYJX3d1cF9kYXRhHQABA5MKCgwWAzEuMCbPeyJhc3NvY2lhdGlvbklkIjo4MTkzLCJmdW5jTmFtZSI6Imh5cGFzc3dvcmRMb2dpbiIsImdyb3VwIjoxLCJpZCI6NDA5Nywic2Vzc2lvbiI6MzI1MTY5OSwic3RlcCI6MSwic3RpbGxMb2dpbiI6ZmFsc2UsInRyYWNlSWQiOiIwYjhmMDk4ZmY2NGE1YmRjLTIzNDczLTk0NzgzODMzNzg3NTk1MTcyNDUxIiwidHlwZSI6MywidWlkIjowLCJ1c2VyQ29udGV4dCI6IiJ9NgQ1MDA4QANWtFBRd2VtQU45TkhrWktvTXFWVEZVWkJJeXBxTVRhUUVPcm1YcjM3eFFWaFFacXJML2dVS0VRMTF4dkUwanU0OFY4Ty90OVVCR1NwMjdtNCs2YlA0SWlBRW5wYVI1Umoxa0hFZk4yU1BMUHFZWlc5dnJveFVTb0F2akpuNmV6VFA5aldHeHhsUkRDYnRQeTRSZDZNZW5jWVQvcE5JbVZJV0srWWJOS1p0MU8wNWJIVUZocUhmM2YAdgCG0nsiY3VybCI6IueZu+W9lemhtSIsImZ1cmwiOiLmiJHnmoQiLCJsYXRpdHVkZSI6Ii0xLjAiLCJsb25naXR1ZGUiOiItMS4wIiwic3NpZCI6IiIsInVzZXJfYWN0aW9uIjpbeyJpZCI6IjI0IiwidGltZSI6IjE3ODc1OTUxNzE5NTAiLCJ4IjoiMjc3IiwieSI6IjEwNTcifSx7ImlkIjoiMTEiLCJ0aW1lIjoiMTc4NzU5NTE3MjQ0NCIsIngiOiIyOTYiLCJ5IjoiOTUyIn1dfZYACxoGIGVkMGRiODMzNGNhZGQyMzZjMDBjYWRmN2UxMWFiNWE1FgcxMy40LjIyJgkxLjAuODAxMzg2AEYJMTI3LjAuMC4xVgZ4aWFvbWlmAAsqAAEWCU0yMTAySjJTQyYoMDJkZjM5ODc5NzQzMmVhZGVmY2MxMjc2NzExOWFkNWU4MDk5OTM4OTYHYW5kcm9pZEYPTTIxMDJKMlNDLDMwLDExZgQxMDgwdgQyMTIwhig3YzUzODdlMDUzOWMwMjNjMzFjNGZmMGU4MDdlNzI1NjExNzM4NWVlCzYMaHlfMzAwMDIzODg3Rig3NzJlZDk5MmIwZTE2MTI3NmY0NGVjNjM2NzFlNjAxNTVjNTA2Mjk0WQABBgQ1MDA4YAF4DI0ADAsGEHd1cHVkYnJlcXVlc3RfdjAdAAAFAgAxnfOMmAyoDA== \ No newline at end of file diff --git a/evidence/wup_passwordlogin_taf.bin b/evidence/wup_passwordlogin_taf.bin new file mode 100644 index 0000000..04e2331 Binary files /dev/null and b/evidence/wup_passwordlogin_taf.bin differ diff --git a/evidence/wup_response_badreq.bin b/evidence/wup_response_badreq.bin new file mode 100644 index 0000000..7a43f28 Binary files /dev/null and b/evidence/wup_response_badreq.bin differ diff --git a/tools/huya_app_crypto.py b/tools/huya_app_crypto.py new file mode 100644 index 0000000..bd9cb1c --- /dev/null +++ b/tools/huya_app_crypto.py @@ -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(" 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(" 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(" 共 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_" + 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,格式 `--<毫秒时间戳>`(与服务端生成规则一致)。""" + 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,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)} 字节,与金标准逐字节一致") diff --git a/tools/hy_har_parse.py b/tools/hy_har_parse.py new file mode 100644 index 0000000..40552ae --- /dev/null +++ b/tools/hy_har_parse.py @@ -0,0 +1,157 @@ +"""解析虎牙 App HAR:解码 WUP(TAF) 全字段 + dckey/check 分析。 + +用法: + .venv/bin/python tools/hy_har_parse.py [--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,返回 {key: bytes}。""" + if not sbuf: + return {} + t = Taf(sbuf) + out = {} + try: + tag, dt = t.read_head() + if not (tag == 0 and dt == MAP): + return {"": 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"" + _, 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[""] = 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) diff --git a/tools/taf_decode.py b/tools/taf_decode.py new file mode 100644 index 0000000..04aaa24 --- /dev/null +++ b/tools/taf_decode.py @@ -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"" + 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"" + cnt += 1 + return out diff --git a/tools/taf_dump.py b/tools/taf_dump.py new file mode 100644 index 0000000..aceb791 --- /dev/null +++ b/tools/taf_dump.py @@ -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()