feat(huya): nonce生成机制完全破解 - 任意账号零设备出完整CK闭环

动态hook实锤(scripts/hook_nonce_chain.py):
  rnd = XXTEA(pack(st) + pack(counter|st<<16), key=MD5hex(uid_str + k1)[:16])
  - st=毫秒时间戳, counter同st内递增
  - k1=设备常量865a4924...(不随账号变, evidence/nonce_k1.json)
  - Python复刻(tools/nonce_forge.py)对设备两轮rnd逐字节MATCH

推翻v2误判(X2'nonce账号绑定令牌'实为nonce内编码uid):
  - 换账号用目标uid重算nonce而非复用旧nonce!
  - 纯随机nonce必40020因解不出合法st/counter结构

实测: 同账号+跨账号(hy_300024708从未设备登录)本地nonce铸证bind均通过,
  full_web_cookie/full_auto_safe 任意账号密码->全套cookie(udb_cred/udb_biztoken)
This commit is contained in:
yml2213
2026-08-26 00:49:01 +08:00
parent da9937707c
commit 97e6d14139
16 changed files with 1831 additions and 127 deletions
+55
View File
@@ -351,3 +351,58 @@ X2根因分析: 信封内uid仅t3一处(BE@688, metaJSON里uid=0/associationId=0
safe_auth闭环(app_login_flow.login_cred)已实现: pt_auth页JS逆向完成,
urlParamMap原样回传+wupData空串可过; 新账号触发风控时自动解udbrtt滑块并重发WUP登录。
## ★★★★ nonce 生成机制完全破解 + 任意账号零设备闭环(2026-08-26凌晨)
### 动态hook实锤(scripts/hook_nonce_chain.py + /tmp/opencode/nonce_chain.json)
在真机上对 getQUrlData→getOtpEx→gen_biz_token→xxtea 全链路 hook, 拿到运行时实参:
```
serviceTime = 当前毫秒时间戳 (0x1a039b1fc81 ≈ 1.787e12)
nonce_next = counter | (serviceTime << 16) # counter 同st内递增, 从0开始
xxtea key输入 = uid_str + k1 # k1 = BusinessCfg::this+0x10
xxtea key = MD5hex(uid_str + k1)[:16] # 注意取hex字符串前16字符!
xxtea data = pack(serviceTime) + pack(nonce_next) # 各8B小端
xxtea 输出 = ceil(len/4)+1 words, 末尾word存原长度 → 16B输入=20B输出
```
**rnd = XXTEA(pack(st) + pack(counter|st<<16), key=MD5hex(uid+k1)[:16])**
### 关键发现
1. **k1 是设备级常量**: 对 uid=1199666914671 和 uid=1199666911746 两次调用,
k1 均为 `865a4924a40897ac1fcfe6b4c2cbb0e3`, 不随账号变化!
(证据: /tmp/opencode/k1_multi.json, gbt_in.k1 两次同值)
2. **nonce 完全本地可算**: serviceTime=本地时钟, counter从0开始, k1固定
→ 任意账号用 目标uid+k1 即可离线铸造合法 nonce!
3. **Python 复刻逐字节一致**: tools/nonce_forge.py 对设备两轮 rnd
(80dd1dfa... / 0ba8f8b7...) 完全 MATCH。
### 终极验证: 纯协议任意账号 bind + cookie 全通!
| 实验 | 结果 |
|---|---|
| 同账号本地nonce铸证 bind (hy_300023887) | ✅ ok:true biztoken344B |
| **跨账号**本地nonce铸证+patch uid (hy_300024708→uid1199666911746) | ✅ ok:true biztoken344B |
| **新账号从未设备登录** 全链路: 密码→cred→本地nonce→bind→cookie | ✅ 全套cookie(udb_cred/udb_biztoken...) |
### 修正早前错误结论(X2"nonce账号绑定令牌"实为误判)
X2(换uid+换cred+复用旧nonce)→40020 的真正原因:
**nonce 内编码了 uid**(通过 MD5(uid+k1) 派生key), 服务端用 t3.uid 重新派生key解密 nonce,
旧nonce(绑旧uid)配新uid 自然解不开 → 40020。
**正确做法**: 换账号时用【目标uid】重新计算 nonce, 而非复用旧nonce!
→ 这也解释了为什么"纯随机nonce必40020": 随机值无法用uid+k1解出合法st/counter结构。
### 新的工程铁律(v3, 推翻v2的"每号设备首登一次")
1. nonce = XXTEA(pack(st)+pack(counter|st<<16), key=MD5hex(uid_str+k1)[:16]), 本地可算;
2. k1 = 设备常量(865a4924...), 一次采集永久可用(evidence/nonce_k1.json);
3. 任意账号: 密码→登录取cred→目标uid+k1重算nonce→铸证→bind→cookie, 全程零设备!
4. 信封本身(TAF帧)可长期复用/补丁(uid/cert), 不依赖账号。
### 资产更新
- tools/nonce_forge.py: 本地nonce生成器(XXTEA+MD5, 自检对设备实采)
- scripts/probe_nonce_bind.py: 本地nonce bind验证脚本(同/跨账号)
- evidence/nonce_k1.json: k1设备常量证据
@@ -1,15 +1,18 @@
# 虎牙纯Python全自动登录 — 总结与交接(2026-08-25
# 虎牙纯Python全自动登录 — 总结与交接(2026-08-26
## 一、已达成 ✅(全部实测验证)
### 核心链路(可信账号,零设备零手动)
### 核心链路(**任意账号, 零设备零手动**)—— 2026-08-26升级
```
账号+密码
→ SHA1
→ build_password_login_wup() 组TAF包 (servant=huyaudbwebui, func=hypasswordLogin)
→ POST https://wup.huya.com [body=原始TAF字节, CT=application/multipart-formdata]
→ HTTP 200 → 解析响应bean → 新鲜cred(114B, 0a开头, 每次登录轮换)
cert_forge.build_p1(fingerprint + 新nonce + cred) → forge_cert(key_idx=0x20)
本地重算 nonce: XXTEA(pack(st)+pack(counter|st<<16), key=MD5hex(uid+k1)[:16])
(tools/nonce_forge.py, 无需设备! 与设备实采逐字节一致)
→ cert_forge.build_p1(fingerprint + 本地nonce + cred) → forge_cert(key_idx)
→ 信封补丁: 换cert + (uid不同则patch t3) [envelope_forge]
→ 扫码绑定流 getQrId→scanQrPicNotify→bindQrLoginUser→tryQrLogin
→ POST /web/cookie/verify {"appId":5002} ← 关键最后一步!
→ 全套网页Cookie: udb_cred/yyuid/udb_uid/udb_passport/username/udb_biztoken...
@@ -18,31 +21,32 @@
### 一键工具
| 命令 | 功能 |
|---|---|
| `.venv/bin/python tools/full_web_cookie.py <账号> <密码>` | 密码→全套网页Cookie写 evidence/web_cookies_full.txt|
| `.venv/bin/python tools/full_web_cookie.py <账号> <密码>` | 密码→全套网页Cookie任意账号, 纯协议|
| `.venv/bin/python tools/full_auto_safe.py <账号> <密码>` | 同上, 且新账号safe_auth滑块自动过 |
| `.venv/bin/python tools/full_auto_test.py <账号> <密码>` | 密码→cred→铸证→bind 全链路自测 |
| `.venv/bin/python tools/nonce_forge.py` | nonce生成器自检(对设备实采MATCH) |
## 二、关键知识(踩坑实录)
1. **wupData信封规则(v2实验定论)**: ①铸证必须复用信封原证书的rnd/指纹/key_idx,
仅替换cred字段——纯随机nonce必40020; ②t3.uid须与cert内cred账号一致;
③session三处不可动(SYS_ERROR); ④同账号信封长期可复用;
⑤跨账号=每号设备首登一次(hook_cert_keycap自动抓该号信封)后永久纯Python
补丁工具 tools/envelope_forge.py (patch_uid/patch_cert/patch_session)。
2. **cred每次登录轮换**,第二字节是代次计数器(0x10/0x20/.../0x80循环);旧cred短期仍有效。
3. **响应bean解析**_wup_data值=SIMPLE_LIST(0x1d)+elem头(00)+JCE优化int长度;
1. **nonce生成机制(v3定论, 2026-08-26动态hook实锤)**:
`rnd = XXTEA(pack(st) + pack(counter|st<<16), key=MD5hex(uid_str + k1)[:16])`
- st = 毫秒时间戳; counter = 同st内递增(从0)
- k1 = 设备常量(865a4924...), 不随账号变(evidence/nonce_k1.json)
- XXTEA输出=ceil(len/4)+1 words, 末尾存长度 → 16B输入=20B rnd
- 服务端用 t3.uid 重新派生 key 解密 nonce → 换账号必须重算nonce!
2. **信封规则**: ①非ce按上述本地算(不再复用原nonce!); ②t3.uid与cred账号一致;
③session三处不可动; ④信封TAF帧可长期复用。
3. **cred每次登录轮换**, 第二字节是代次计数器(0x10/0x20/.../0x80循环); 旧cred短期仍有效。
4. **响应bean解析**: _wup_data值=SIMPLE_LIST(0x1d)+elem头(00)+JCE优化int长度;
bean内 tag3(bytes)=cred。虎牙TAF类型表: STR1=6/STR4=7/MAP=8/LIST=9/
STRUCT_BEGIN=10(0xa)/STRUCT_END=11(0xb)/ZERO=12/SIMPLE_LIST=13。
4. **udb消息载荷=JSON命名键**(非二进制JCE)msgId: LoginPassport=0x1001,
Response=0x2001; wire双体系: 0x0Bxxxxxx业务推送族 vs udb裸id。
5. **证书算法**=标准AES-128-ECBP1=[01 04]["5008"][u16le20+20B nonce][u16le40+40B指纹][u16le114+cred]
cert=b64([0x0C][key_idx][AES128(key16,zeropad(P1))]);钥表@rodata 0x1bc810(AESkeyMgr注册)
6. **存储侧**: cred0文件=AES-128-ECB("HuyaUdb192837465")加密的JSON
cred路径 /loginHistory[0]/apploginData/cred。
cert=b64([0x0C][key_idx][AES128(key16,zeropad(P1))]);钥表@rodata 0x1bc810。
6. **真实uid≠账号名数字**: hy_300023887 的真实uid=1199666914671(响应bean偏移235 u64be);
铸证/算nonce必须用真实uid!
## 三、新账号风控(safe_auth)——已实现 ✅(2026-08-25夜)
## 三、新账号风控(safe_auth)——已实现 ✅
### 定性与实现
- App渠道(WUP)对新/异地账号返回 `aq.huya.com/p/safe_auth/pt_auth.html?param=xxx`
**udbrtt.huya.com 滑块** → tools/app_login_flow.login_cred() 自动过验并重发WUP
- pt_auth页JS逆向结论: urlParamMap=整个query原样回传; wupData纯web上下文空串可过;
@@ -50,26 +54,21 @@
成功后服务端标记param风控会话通过, 直接重发同参WUP登录即放行。
- qr_auth(扫码型)明确不可自动化 → QrAuthRequiredError 提示先真机首登建信任。
### 残留优化项(非阻塞)
1. 数小时前老信封复用边界未单独验证(E-B)——不影响主流程, 失败则hook重抓即可
2. 完全零设备跨账号需逆向nonce签发机制, 收益低(运营模型本就要求每号设备首登一次)
## 四、多账号运营模型
## 四、多账号运营模型(v3: 零设备!)
```
新账号首次: 手机App正常登录一次(建设备信任, 若遇滑块人工滑一下)
+ 设备上跑 scripts/hook_cert_keycap.py 抓该号信封(cert_keycap.json)
之后永久: 密码 → full_auto_safe.py → 全套cookie 自动出
任意新账号: 密码 → full_web_cookie.py → 全套cookie 自动出 (无需设备首登!)
唯一前提: 持有 k1 设备常量(evidence/nonce_k1.json 已固化; 若换设备需重新采集一次)
```
## 五、资产清单
- 组包: tools/huya_wup_encoder.py (build_password_login_wup, 自检对金样本)
- 登录流: tools/app_login_flow.py (login_cred/solve_safe_auth, safe_auth滑块闭环)
- **nonce: tools/nonce_forge.py (本地生成, 设备算法复刻, 自检MATCH)**
- 铸证: tools/cert_forge.py (build_p1/forge_cert/parse_p1)
- 信封: tools/envelope_forge.py (Envelope原位补丁uid/cert/session)
- 绑定: scripts/probe_forge_cert_bind.py (QrRole四步流+cert替换)
- 实验: scripts/probe_envelope_rules2.py (信封规则X0-X3紧凑对照)
- 网页登录: core/huya/login.py (含滑块solver集成, web渠道)
- 绑定: scripts/probe_nonce_bind.py (本地nonce bind验证, 同/跨账号)
- 一键: tools/full_web_cookie.py (任意账号纯协议出全套cookie)
- 滑块: core/huya/verification/{solver,track}.py + core/geetest/v3_slide
- 设备指纹: core/huya/device_fingerprint.py (get_huya_sdid)
- 证据: evidence/* (金样本wup_passwordlogin_taf.bin, envelope_rules2.json等)
- 证据: evidence/* (金样本wup_passwordlogin_taf.bin, nonce_k1.json等)
- 协议细节全记录: docs/虎牙hyCred签发-侦察笔记.md (按Round追加)
+12
View File
@@ -0,0 +1,12 @@
{
"local_nonce": {
"ok": true,
"uid": 1199666911746,
"biztoken_len": 344
},
"env_uid": 1199666914671,
"uid": 1199666911746,
"st": 1787675927351,
"nonce_hex": "e290e2aadec432280067c49234f9a79665a46bea",
"ts": 1787675928.288748
}
+7
View File
@@ -0,0 +1,7 @@
{
"k1": "865a4924a40897ac1fcfe6b4c2cbb0e3",
"source": "BusinessCfg::this+0x10, 动态hook自真机(scripts/hook_nonce_chain.py)",
"account_independent": true,
"note": "跨账号不变(uid 1199666914671 vs 1199666911746 均同值); 设备级常量, 一次采集永久可用",
"usage": "tools/nonce_forge.py gen_nonce(uid, k1) 本地重算20B nonce, 任意账号可铸证"
}
+129
View File
@@ -0,0 +1,129 @@
#!/usr/bin/env python3
"""定位 k1 存储: hook getInstance(0x281270) 拿单例, 读 +0x10; 内存搜 k1 出现位置看周边结构。"""
from __future__ import annotations
import json
import subprocess
import time
from pathlib import Path
import frida
RE = Path("/Users/yml/codes/Reverse-Engineering-Agent-Universal-v3.0")
K1 = "865a4924a40897ac1fcfe6b4c2cbb0e3"
JS = r"""
'use strict';
Process.setExceptionHandler(function(d){ send({type:'segv', info:{type:d.type, addr:String(d.address)}}); return true; });
var base = Process.getModuleByName('libudbauthunify.so').base;
send({type:'armed', base:String(base)});
function rdStr(p){
try{
var b0=p.readU8();
if((b0&1)===0){ var l=b0>>1; if(l>8192) return {t:'s?',len:l,v:''};
return {t:'s',len:l,v:l?Array.from(new Uint8Array(p.add(1).readByteArray(l))).map(x=>('0'+x.toString(16)).slice(-2)).join(''):''}; }
var len=parseInt(p.add(8).readU64().toString());
if(len>65536) return {t:'l?',len:len,v:''};
var dp=p.add(16).readPointer();
return {t:'l',len:len,v:Array.from(new Uint8Array(dp.readByteArray(len))).map(x=>('0'+x.toString(16)).slice(-2)).join('')};
}catch(e){ return {t:'err',len:-1,v:String(e)}; }
}
// 1) getInstance -> 单例地址 + 读 +0x10
try {
Interceptor.attach(base.add(0x281270), {
onLeave: function(r){
var s = rdStr(r.add(0x10));
send({type:'cfg', addr:String(r), k1:s, k1ascii:(function(h){var o='';for(var i=0;i<h.length;i+=2){var c=parseInt(h.substr(i,2),16);o+=(c>=32&&c<127)?String.fromCharCode(c):'.';}return o;})(s.v)});
// dump 前 0x60 成员指针(可能含其它 string)
try {
var hex='';
for (var off=0; off<0x60; off+=8) hex += hx(r.add(off),8);
send({type:'cfg_dump', hex:hex});
} catch(e){}
}
});
} catch(e){ send({type:'hookerr', where:'getInstance', msg:''+e}); }
// 2) hook loadLoginData(未知地址) 跳过; 直接定时 dump
setTimeout(function(){
try {
// 找已缓存的单例: getInstance 是懒加载, 定时再调一次读
var f = new NativeFunction(base.add(0x281270), 'pointer', []);
var inst = f();
var s = rdStr(inst.add(0x10));
send({type:'tick_k1', addr:String(inst), k1:s});
} catch(e){ send({type:'tick_err', msg:''+e}); }
}, 30000);
function hx(p,n){ try{ return Array.from(new Uint8Array(p.readByteArray(n))).map(x=>('0'+x.toString(16)).slice(-2)).join(''); }catch(e){ return 'ERR'; } }
"""
def main():
d = frida.get_device_manager().add_remote_device('127.0.0.1:31877')
pid = d.spawn(['com.duowan.kiwi'])
print(f'spawned {pid}')
s = d.attach(pid)
s.create_script((RE / "evidence/scripts/bypass_msaoaid_maps_art_callsite.js").read_text()).load()
s.create_script((RE / "evidence/scripts/mask_frida_maps_only.js").read_text()).load()
d.resume(pid)
print('resumed')
time.sleep(11)
s.create_script((RE / "evidence/scripts/patch_guard_block_termination.js").read_text()).load()
print('patch_guard on')
sc = s.create_script(JS)
events = []
got_armed = []
def on_msg(m, _):
if m.get('type') == 'error':
print('JS ERR:', str(m)[:240]); return
if m.get('type') != 'send':
return
p = m.get('payload') or {}
t = p.get('type')
events.append(p)
if t == 'armed':
got_armed.append(1); print('[armed]')
elif t == 'cfg':
print(f"[cfg] addr={p['addr']} k1len={p['k1']['len']} k1ascii={p['k1ascii']}")
elif t == 'cfg_dump':
print(f"[cfg_dump] {p['hex']}")
elif t == 'tick_k1':
print(f"[tick_k1] addr={p['addr']} k1={p['k1']['v'][:40]}({p['k1']['len']})")
elif t == 'tick_err':
print('[tick_err]', p['msg'][:120])
elif t == 'hookerr':
print('[hookerr]', p)
sc.on('message', on_msg)
sc.load()
assert got_armed
deadline = time.time() + 90
while time.time() < deadline:
time.sleep(2)
if any(e.get('type') == 'tick_k1' for e in events):
time.sleep(3)
break
Path('/tmp/opencode/k1_cfg.json').write_text(json.dumps(events))
print('saved /tmp/opencode/k1_cfg.json')
if __name__ == '__main__':
for attempt in range(3):
print(f'===== 尝试 #{attempt+1} =====')
try:
main()
break
except frida.InvalidOperationError as e:
print(f'session detached: {e}')
subprocess.run(['adb', 'shell', 'am', 'force-stop', 'com.duowan.kiwi'])
time.sleep(3)
except Exception as e:
print(f'err: {e}')
subprocess.run(['adb', 'shell', 'am', 'force-stop', 'com.duowan.kiwi'])
time.sleep(3)
+157
View File
@@ -0,0 +1,157 @@
#!/usr/bin/env python3
"""k1 账号绑定判定: 对多个不同 uid 调 getQUrlData, 看 xxtea key 是否变化。
若 k1(=this+0x10, MD5输入的一半)随 uid 变 -> 账号绑定, 纯协议死路;
若 k1 不变 -> 设备级常量, 任意账号可本地复算 nonce, 纯协议成立!
同时验证 serviceTime/nonce_next 的生成与 uid 无关。
"""
from __future__ import annotations
import json
import subprocess
import time
from pathlib import Path
import frida
RE = Path("/Users/yml/codes/Reverse-Engineering-Agent-Universal-v3.0")
UIDS = [1199666914671, 1199666911746] # 可信账号 + 另一账号(新账号uid可能不存在, 但getQUrlData只看参数)
def build_js(uids):
arr = ",".join(str(u) for u in uids)
return r"""
'use strict';
Process.setExceptionHandler(function(d){
send({type:'segv', info:{type:d.type, addr:String(d.address)}}); return true;
});
var base = Process.getModuleByName('libudbauthunify.so').base;
send({type:'armed', base:String(base)});
function rdStr(p){
try{
var b0=p.readU8();
if((b0&1)===0){ var l=b0>>1; if(l>8192) return {t:'s?',len:l,v:''};
return {t:'s',len:l,v:l?Array.from(new Uint8Array(p.add(1).readByteArray(l))).map(x=>('0'+x.toString(16)).slice(-2)).join(''):''}; }
var len=parseInt(p.add(8).readU64().toString());
if(len>65536) return {t:'l?',len:len,v:''};
var dp=p.add(16).readPointer();
return {t:'l',len:len,v:Array.from(new Uint8Array(dp.readByteArray(len))).map(x=>('0'+x.toString(16)).slice(-2)).join('')};
}catch(e){ return {t:'err',len:-1,v:String(e)}; }
}
function ascii(h){ if(!h) return ''; var s=''; for(var i=0;i<h.length;i+=2){ var c=parseInt(h.substr(i,2),16); s += (c>=32&&c<127)?String.fromCharCode(c):'.'; } return s; }
// gen_biz_token(struct, uid, k1, k2, out)
try { Interceptor.attach(base.add(0x3307e4), {
onEnter: function(a){
send({type:'gbt_in', struct:hx_s(a[0],0x60), uid:rdStr(a[1]), k1:rdStr(a[2]), k2:rdStr(a[3])});
}
}); } catch(e){ send({type:'hookerr',where:'gen_biz_token',msg:''+e}); }
// xxtea
try { Interceptor.attach(base.add(0x32e83c), {
onEnter: function(a){
this.out=a[0];
send({type:'xxtea_in', data:rdStr(a[1]), key:rdStr(a[2]),
key_ascii:ascii(rdStr(a[2]).v)});
},
onLeave: function(){ var o=rdStr(this.out); send({type:'xxtea_out', out:o}); }
}); } catch(e){ send({type:'hookerr',where:'xxtea',msg:''+e}); }
try { Interceptor.attach(base.add(0x32ff10), {
onEnter: function(a){ this.m=a[0]; },
onLeave: function(r){ send({type:'nonce_next', m:'0x'+this.m.toString(16), ret:'0x'+r.toString(16)}); }
}); } catch(e){}
function hx_s(p,n){ try{ return Array.from(new Uint8Array(p.readByteArray(n))).map(x=>('0'+x.toString(16)).slice(-2)).join(''); }catch(e){ return 'ERR'; } }
// 依次触发多个 uid
Java.perform(function(){
var uids = __UIDS__;
var n=0;
function go(){
try{
var seed=Java.use('com.duowan.kiwi.base.login.udb.HuyaLoginProxy');
var F=Java.ClassFactory.get(seed.class.getClassLoader());
var inst=F.use('com.hysdkproxy.LoginProxy').getInstance();
for (var i=0;i<uids.length;i++){
var uid=uids[i];
send({type:'stage', msg:'call uid='+uid});
var q=inst.getQUrlData(uid, "", "");
send({type:'qurl_done', uid:uid, len:q?q.length:0, data:q||''});
}
send({type:'stage', msg:'ALL DONE'});
}catch(e){ n+=1; if(n%4===0) send({type:'retry',n:n,err:String(e).slice(0,120)}); setTimeout(go,5000); }
}
setTimeout(go, 16000);
});
""".replace('__UIDS__', json.dumps(uids))
def main():
d = frida.get_device_manager().add_remote_device('127.0.0.1:31877')
pid = d.spawn(['com.duowan.kiwi'])
print(f'spawned {pid}')
s = d.attach(pid)
s.create_script((RE / "evidence/scripts/bypass_msaoaid_maps_art_callsite.js").read_text()).load()
s.create_script((RE / "evidence/scripts/mask_frida_maps_only.js").read_text()).load()
d.resume(pid)
print('resumed')
time.sleep(11)
s.create_script((RE / "evidence/scripts/patch_guard_block_termination.js").read_text()).load()
print('patch_guard on')
sc = s.create_script(build_js(UIDS))
events = []
got_armed = []
def on_msg(m, _):
if m.get('type') == 'error':
print('JS ERR:', str(m)[:200]); return
if m.get('type') != 'send':
return
p = m.get('payload') or {}
t = p.get('type')
events.append(p)
if t == 'armed':
got_armed.append(1); print('[armed]')
elif t == 'gbt_in':
print(f"[gbt] uid={ascii(p['uid']['v'])} k1={p['k1']['v'][:40]}({p['k1']['len']}) k2={p['k2']['v'][:24]}")
elif t == 'xxtea_in':
print(f"[xxtea] data={p['data']['v'][:40]} key_ascii={p['key_ascii'][:60]}")
elif t == 'xxtea_out':
print(f"[xxtea.out] {p['out']['v'][:44]}")
elif t == 'nonce_next':
print(f"[nonce] m={p['m']} ret={p['ret']}")
elif t == 'qurl_done':
print(f"[qurl uid={p['uid']}] len={p['len']}")
elif t == 'stage':
print('[stage]', p['msg'])
sc.on('message', on_msg)
sc.load()
assert got_armed
deadline = time.time() + 120
while time.time() < deadline:
time.sleep(2)
if any(e.get('type') == 'stage' and e.get('msg') == 'ALL DONE' for e in events):
time.sleep(3)
break
Path('/tmp/opencode/k1_multi.json').write_text(json.dumps(events))
print('saved /tmp/opencode/k1_multi.json')
if __name__ == '__main__':
for attempt in range(3):
print(f'===== 尝试 #{attempt+1} =====')
try:
main()
break
except frida.InvalidOperationError as e:
print(f'session detached: {e}')
subprocess.run(['adb', 'shell', 'am', 'force-stop', 'com.duowan.kiwi'])
time.sleep(3)
except Exception as e:
print(f'err: {e}')
subprocess.run(['adb', 'shell', 'am', 'force-stop', 'com.duowan.kiwi'])
time.sleep(3)
+135
View File
@@ -0,0 +1,135 @@
#!/usr/bin/env python3
"""追踪 k1(this+0x10)写入者: hook std::string::operator=(0x3e6464) + getInstance(0x281270)。"""
from __future__ import annotations
import json
import subprocess
import time
from pathlib import Path
import frida
RE = Path("/Users/yml/codes/Reverse-Engineering-Agent-Universal-v3.0")
JS = r"""
'use strict';
Process.setExceptionHandler(function(d){ send({type:'segv', info:{type:d.type, addr:String(d.address)}}); return true; });
var base = Process.getModuleByName('libudbauthunify.so').base;
send({type:'armed', base:String(base)});
var cfgAddr = null;
function rdStr(p){
try{
var b0=p.readU8();
if((b0&1)===0){ var l=b0>>1; if(l>8192) return {t:'s?',len:l,v:''};
return {t:'s',len:l,v:l?Array.from(new Uint8Array(p.add(1).readByteArray(l))).map(x=>('0'+x.toString(16)).slice(-2)).join(''):''}; }
var len=parseInt(p.add(8).readU64().toString());
if(len>65536) return {t:'l?',len:len,v:''};
var dp=p.add(16).readPointer();
return {t:'l',len:len,v:Array.from(new Uint8Array(dp.readByteArray(len))).map(x=>('0'+x.toString(16)).slice(-2)).join('')};
}catch(e){ return {t:'err',len:-1,v:String(e)}; }
}
// getInstance @0x281270 -> 单例
try {
Interceptor.attach(base.add(0x281270), {
onLeave: function(r){
cfgAddr = r;
var s = rdStr(r.add(0x10));
send({type:'cfg', addr:String(r), k1v:s.v, k1len:s.len});
}
});
} catch(e){ send({type:'hookerr', where:'getInstance', msg:''+e}); }
// operator= @0x3e6464: a[0]=目标, a[1]=源
try {
Interceptor.attach(base.add(0x3e6464), {
onEnter: function(a){
if (!cfgAddr) return;
if (a[0].equals(cfgAddr.add(0x10))) {
var v = rdStr(a[1]);
send({type:'write_k1', val:v.v, len:v.len,
bt:Thread.backtrace(this.context, Backtracer.ACCURATE).map(function(x){
return DebugSymbol.fromAddress(x).toString().slice(0,90);}).slice(0,5)});
}
}
});
} catch(e){ send({type:'hookerr', where:'op=', msg:''+e}); }
setTimeout(function(){
if (cfgAddr) {
var s = rdStr(cfgAddr.add(0x10));
send({type:'final_k1', k1:s.v, len:s.len});
} else {
send({type:'final_k1', k1:'NO_CFG'});
}
}, 90000);
"""
def main():
d = frida.get_device_manager().add_remote_device('127.0.0.1:31877')
pid = d.spawn(['com.duowan.kiwi'])
print(f'spawned {pid}')
s = d.attach(pid)
s.create_script((RE / "evidence/scripts/bypass_msaoaid_maps_art_callsite.js").read_text()).load()
s.create_script((RE / "evidence/scripts/mask_frida_maps_only.js").read_text()).load()
d.resume(pid)
print('resumed')
time.sleep(11)
s.create_script((RE / "evidence/scripts/patch_guard_block_termination.js").read_text()).load()
print('patch_guard on')
sc = s.create_script(JS)
events = []
got_armed = []
def on_msg(m, _):
if m.get('type') == 'error':
print('JS ERR:', str(m)[:240]); return
if m.get('type') != 'send':
return
p = m.get('payload') or {}
t = p.get('type')
events.append(p)
if t == 'armed':
got_armed.append(1); print('[armed]')
elif t == 'cfg':
print(f"[cfg] addr={p['addr']} k1len={p['k1len']}")
elif t == 'write_k1':
print(f"[WRITE k1] len={p['len']} val={p['val'][:60]}")
for f in p['bt']:
print(" <", f)
elif t == 'final_k1':
print(f"[FINAL k1] len={p['len']} {p['k1'][:40]}")
elif t == 'hookerr':
print('[hookerr]', p)
sc.on('message', on_msg)
sc.load()
assert got_armed
deadline = time.time() + 150
while time.time() < deadline:
time.sleep(2)
if any(e.get('type') == 'final_k1' for e in events):
time.sleep(3)
break
Path('/tmp/opencode/k1_trace2.json').write_text(json.dumps(events))
print('saved /tmp/opencode/k1_trace2.json')
if __name__ == '__main__':
for attempt in range(3):
print(f'===== 尝试 #{attempt+1} =====')
try:
main()
break
except frida.InvalidOperationError as e:
print(f'session detached: {e}')
subprocess.run(['adb', 'shell', 'am', 'force-stop', 'com.duowan.kiwi'])
time.sleep(3)
except Exception as e:
print(f'err: {e}')
subprocess.run(['adb', 'shell', 'am', 'force-stop', 'com.duowan.kiwi'])
time.sleep(3)
+150
View File
@@ -0,0 +1,150 @@
#!/usr/bin/env python3
"""追踪 this+0x10(k1) 的写入者: hook std::string::operator=, 比对目标地址==BusinessCfg+0x10。
启动后等初始化完成, 直接读 BusinessCfg 实例的 +0x10 最终值, 并记录所有写到该地址的调用。
"""
from __future__ import annotations
import json
import subprocess
import time
from pathlib import Path
import frida
RE = Path("/Users/yml/codes/Reverse-Engineering-Agent-Universal-v3.0")
JS = r"""
'use strict';
Process.setExceptionHandler(function(d){ send({type:'segv', info:{type:d.type, addr:String(d.address)}}); return true; });
var base = Process.getModuleByName('libudbauthunify.so').base;
send({type:'armed', base:String(base)});
function rdStr(p){
try{
var b0=p.readU8();
if((b0&1)===0){ var l=b0>>1; if(l>8192) return {t:'s?',len:l,v:''};
return {t:'s',len:l,v:l?Array.from(new Uint8Array(p.add(1).readByteArray(l))).map(x=>('0'+x.toString(16)).slice(-2)).join(''):''}; }
var len=parseInt(p.add(8).readU64().toString());
if(len>65536) return {t:'l?',len:len,v:''};
var dp=p.add(16).readPointer();
return {t:'l',len:len,v:Array.from(new Uint8Array(dp.readByteArray(len))).map(x=>('0'+x.toString(16)).slice(-2)).join('')};
}catch(e){ return {t:'err',len:-1,v:String(e)}; }
}
// BusinessCfg::getInstance 返回单例 -> 拿 this 地址
var cfgAddr = null;
try {
Interceptor.attach(base.add(0x454310 - 0x454310 + 0x26a880), { // getInstance 附近, 用符号找
});
} catch(e){}
// 用 export 查找 getInstance 真实地址
var syms = Process.getModuleByName('libudbauthunify.so').enumerateSymbols();
var getInst = null;
syms.forEach(function(s){ if (s.name.indexOf('BusinessCfg11getInstance') >= 0) getInst = s.address; });
send({type:'info', getInst:String(getInst)});
if (getInst) {
Interceptor.attach(getInst, {
onLeave: function(r){
cfgAddr = r;
var s = rdStr(r.add(0x10));
send({type:'cfg', addr:String(r), k1: s});
}
});
}
// hook std::string::operator=(string const&) @0x452e40
try {
Interceptor.attach(base.add(0x452e40), {
onEnter: function(a){
if (!cfgAddr) return;
// a[0] = this(目标), a[1] = src
if (a[0].equals(cfgAddr.add(0x10))) {
var v = rdStr(a[1]);
send({type:'write_k1', val:v,
bt:Thread.backtrace(this.context, Backtracer.ACCURATE).map(function(x){
return DebugSymbol.fromAddress(x).toString().slice(0,80);}).slice(0,4)});
}
}
});
} catch(e){ send({type:'hookerr', where:'op=', msg:''+e}); }
// 直接读最终值
setTimeout(function(){
if (cfgAddr) {
var s = rdStr(cfgAddr.add(0x10));
send({type:'final_k1', k1:s});
}
}, 60000);
"""
def main():
d = frida.get_device_manager().add_remote_device('127.0.0.1:31877')
pid = d.spawn(['com.duowan.kiwi'])
print(f'spawned {pid}')
s = d.attach(pid)
s.create_script((RE / "evidence/scripts/bypass_msaoaid_maps_art_callsite.js").read_text()).load()
s.create_script((RE / "evidence/scripts/mask_frida_maps_only.js").read_text()).load()
d.resume(pid)
print('resumed')
time.sleep(11)
s.create_script((RE / "evidence/scripts/patch_guard_block_termination.js").read_text()).load()
print('patch_guard on')
sc = s.create_script(JS)
events = []
got_armed = []
def on_msg(m, _):
if m.get('type') == 'error':
print('JS ERR:', str(m)[:240]); return
if m.get('type') != 'send':
return
p = m.get('payload') or {}
t = p.get('type')
events.append(p)
if t == 'armed':
got_armed.append(1); print('[armed]')
elif t == 'info':
print('[info] getInstance =', p['getInst'])
elif t == 'cfg':
print(f"[cfg] addr={p['addr']} k1={p['k1']['v'][:40]}({p['k1']['len']})")
elif t == 'write_k1':
print(f"[WRITE k1] val={p['val']['v'][:60]}")
for f in p['bt']:
print(" <", f)
elif t == 'final_k1':
print(f"[FINAL k1] {p['k1']['v'][:40]}({p['k1']['len']})")
elif t == 'hookerr':
print('[hookerr]', p)
sc.on('message', on_msg)
sc.load()
assert got_armed
deadline = time.time() + 130
while time.time() < deadline:
time.sleep(2)
if any(e.get('type') == 'final_k1' for e in events):
time.sleep(3)
break
Path('/tmp/opencode/k1_writer.json').write_text(json.dumps(events))
print('saved /tmp/opencode/k1_writer.json')
if __name__ == '__main__':
for attempt in range(3):
print(f'===== 尝试 #{attempt+1} =====')
try:
main()
break
except frida.InvalidOperationError as e:
print(f'session detached: {e}')
subprocess.run(['adb', 'shell', 'am', 'force-stop', 'com.duowan.kiwi'])
time.sleep(3)
except Exception as e:
print(f'err: {e}')
subprocess.run(['adb', 'shell', 'am', 'force-stop', 'com.duowan.kiwi'])
time.sleep(3)
+253
View File
@@ -0,0 +1,253 @@
#!/usr/bin/env python3
"""nonce链路动态hook —— 与静态反汇编互相验证。
抓取 getQUrlData 触发证书生成时的完整运行时实参链:
nonce_next(m) -> 64位OTP计数
getServiceTime() -> 服务时间
hyudb_crypt_util::xxtea_encrypt(out, data, key) -> 20B rnd 的直接来源
enpack_header(h, key, out) -> cert 头 [0x0c][key_idx]
enpack_body(h, a,b,c,d, out) -> P1 明文组装
gen_biz_token(struct, uid, k1, k2, out) -> 总装
encode_aes(p1, key, out) -> AES加密
getOtpEx(uid, idx, a3, out) -> 入口
目的: 拿到 xxtea 的 data/key 原文与输出, 验证 20B rnd 是否 = f(serviceTime, nonce, uid, 设备常量),
从而判定"信封nonce能否脱离设备本地复算"
时序(不可变, 同 hook_cert_keycap): force-stop -> spawn挂起 -> 双bypass -> resume ->
10s -> patch_guard -> 装hook -> Java桥触发 getQUrlData。
"""
from __future__ import annotations
import json
import subprocess
import time
from pathlib import Path
import frida
RE = Path("/Users/yml/codes/Reverse-Engineering-Agent-Universal-v3.0")
UID = 1199666914671
JS = r"""
'use strict';
Process.setExceptionHandler(function(d){
send({type:'segv', info:{type:d.type, addr:String(d.address)}}); return true;
});
var base = Process.getModuleByName('libudbauthunify.so').base;
send({type:'armed', base:String(base)});
function rdStr(p){
try{
var b0=p.readU8();
if((b0&1)===0){ var l=b0>>1; if(l>8192) return {t:'s?',len:l,v:''};
return {t:'s',len:l,v:l?Array.from(new Uint8Array(p.add(1).readByteArray(l))).map(x=>('0'+x.toString(16)).slice(-2)).join(''):''}; }
var len=parseInt(p.add(8).readU64().toString());
if(len>65536) return {t:'l?',len:len,v:''};
var dp=p.add(16).readPointer();
return {t:'l',len:len,v:Array.from(new Uint8Array(dp.readByteArray(len))).map(x=>('0'+x.toString(16)).slice(-2)).join('')};
}catch(e){ return {t:'err',len:-1,v:String(e)}; }
}
function hx(p,n){ try{ return Array.from(new Uint8Array(p.readByteArray(n))).map(x=>('0'+x.toString(16)).slice(-2)).join(''); }catch(e){ return 'ERR'; } }
function ascii(h){ if(!h) return ''; var s=''; for(var i=0;i<h.length;i+=2){ var c=parseInt(h.substr(i,2),16); s += (c>=32&&c<127)?String.fromCharCode(c):'.'; } return s; }
function bt(ctx){ return Thread.backtrace(ctx, Backtracer.ACCURATE).map(function(a){
return DebugSymbol.fromAddress(a).toString().slice(0,90); }).slice(0,5); }
// ---------- 1) nonce_next(m) ----------
try { Interceptor.attach(base.add(0x32ff10), {
onEnter: function(a){ this.m=a[0]; },
onLeave: function(r){ send({type:'nonce_next', m:'0x'+this.m.toString(16), ret:'0x'+r.toString(16)}); }
}); } catch(e){ send({type:'hookerr',where:'nonce_next',msg:''+e}); }
// ---------- 2) getServiceTime() ----------
try { Interceptor.attach(base.add(0x268070), {
onLeave: function(r){ send({type:'service_time', v:'0x'+r.toString(16), dec:r.toString()}); }
}); } catch(e){ send({type:'hookerr',where:'getServiceTime',msg:''+e}); }
// ---------- 3) hyudb_crypt_util::xxtea_encrypt(out&, data, key) ----------
try { Interceptor.attach(base.add(0x32e83c), {
onEnter: function(a){
this.out=a[0]; this.d=rdStr(a[1]); this.k=rdStr(a[2]);
send({type:'xxtea_in', data:this.d, key:this.k,
data_ascii:ascii(this.d.v), key_ascii:ascii(this.k.v)});
},
onLeave: function(r){ var o=rdStr(this.out); send({type:'xxtea_out', out:o, out_ascii:ascii(o.v)}); }
}); } catch(e){ send({type:'hookerr',where:'xxtea_encrypt',msg:''+e}); }
// ---------- 4) enpack_header(h, NEWKEY, out&) ----------
try { Interceptor.attach(base.add(0x32ff44), {
onEnter: function(a){ this.out=a[2]; this.h=a[0]; this.k=a[1]; },
onLeave: function(r){ var o=rdStr(this.out);
send({type:'enpack_header', h:'0x'+this.h.toInt32().toString(16), key:'0x'+this.k.toInt32().toString(16), out:o}); }
}); } catch(e){ send({type:'hookerr',where:'enpack_header',msg:''+e}); }
// ---------- 5) enpack_body(h, a,b,c,d, out&) ----------
try { Interceptor.attach(base.add(0x3300c8), {
onEnter: function(a){
this.out=a[5]; this.h=a[0];
this.a=rdStr(a[1]); this.b=rdStr(a[2]); this.c=rdStr(a[3]); this.d=rdStr(a[4]);
send({type:'enpack_body_in', h:'0x'+this.h.toInt32().toString(16),
a:this.a, b:this.b, c:this.c, d:this.d,
a_ascii:ascii(this.a.v).slice(0,80), c_ascii:ascii(this.c.v).slice(0,80)});
},
onLeave: function(r){ var o=rdStr(this.out); send({type:'enpack_body_out', out:o}); }
}); } catch(e){ send({type:'hookerr',where:'enpack_body',msg:''+e}); }
// ---------- 6) gen_biz_token(struct, uid, k1, k2, out&) ----------
try { Interceptor.attach(base.add(0x3307e4), {
onEnter: function(a){
this.out=a[4];
// BIZTOKEN struct 前0x60字节: [0]=type [1]=key_idx [2]=1 [8..]=str1 [0x20]=st [0x28]=nonce [0x30]=str2 [0x48]=str3
send({type:'gbt_in', struct:hx(a[0],0x60),
struct_ascii:ascii(hx(a[0],0x60)),
uid:rdStr(a[1]), k1:rdStr(a[2]), k2:rdStr(a[3]),
k1_ascii:ascii(rdStr(a[2]).v).slice(0,80), k2_ascii:ascii(rdStr(a[3]).v).slice(0,80)});
},
onLeave: function(r){ var o=rdStr(this.out); send({type:'gbt_out', out:o}); }
}); } catch(e){ send({type:'hookerr',where:'gen_biz_token',msg:''+e}); }
// ---------- 7) encode_aes(p1, key, out&) ----------
try { Interceptor.attach(base.add(0x330218), {
onEnter: function(a){ this.out=a[2]; this.p1=rdStr(a[0]); this.key=rdStr(a[1]); },
onLeave: function(r){ var o=rdStr(this.out);
send({type:'encode_aes', p1:this.p1, key:this.key, key_ascii:ascii(this.key.v).slice(0,60), out:o}); }
}); } catch(e){ send({type:'hookerr',where:'encode_aes',msg:''+e}); }
// ---------- 8) getOtpEx(uid, idx, a3, out&) ----------
try { Interceptor.attach(base.add(0x2681b4), {
onEnter: function(a){ this.out=a[4]; this.uid=a[1]; this.idx=a[2]; this.a3=rdStr(a[3]); },
onLeave: function(r){ var o=rdStr(this.out);
send({type:'getOtpEx', uid:'0x'+this.uid.toString(16), idx:this.idx.toInt32(), a3:this.a3, out:o}); }
}); } catch(e){ send({type:'hookerr',where:'getOtpEx',msg:''+e}); }
// ---------- 9) AESkeyMgr::getkey ----------
try { Interceptor.attach(base.add(0x454350), {
onEnter: function(a){ this.a0=a[0]; this.i1=a[1]; this.i2=a[2]; },
onLeave: function(r){
// getkey 返回 std::string (sret in x8), 但 onLeave x8 不可靠; 读 x0 若为短串
try { var s=rdStr(this.a0.add(0)); if(s.len>0 && s.len<64) send({type:'getkey', i1:this.i1.toInt32(), i2:this.i2.toInt32(), key:s}); }
catch(e){}
}
}); } catch(e){ send({type:'hookerr',where:'getkey',msg:''+e}); }
// ---------- 触发 ----------
Java.perform(function(){
var n=0;
function go(){
try{
var seed=Java.use('com.duowan.kiwi.base.login.udb.HuyaLoginProxy');
var F=Java.ClassFactory.get(seed.class.getClassLoader());
var inst=F.use('com.hysdkproxy.LoginProxy').getInstance();
send({type:'stage', msg:'call #1'});
var q1=inst.getQUrlData(__UID__, "", "");
send({type:'qurl_done', idx:1, len:q1?q1.length:0, data:q1||''});
var q2=inst.getQUrlData(__UID__, "", "");
send({type:'qurl_done', idx:2, len:q2?q2.length:0, data:q2||''});
}catch(e){ n+=1; if(n%4===0) send({type:'retry',n:n,err:String(e).slice(0,120)}); setTimeout(go,5000); }
}
setTimeout(go, 18000);
});
""".replace('__UID__', str(UID))
def main():
d = frida.get_device_manager().add_remote_device('127.0.0.1:31877')
pid = d.spawn(['com.duowan.kiwi'])
print(f'spawned {pid}')
s = d.attach(pid)
s.create_script((RE / "evidence/scripts/bypass_msaoaid_maps_art_callsite.js").read_text()).load()
s.create_script((RE / "evidence/scripts/mask_frida_maps_only.js").read_text()).load()
d.resume(pid)
print('resumed')
time.sleep(11)
s.create_script((RE / "evidence/scripts/patch_guard_block_termination.js").read_text()).load()
print('patch_guard on')
sc = s.create_script(JS)
events = []
got_armed = []
def on_msg(m, _):
if m.get('type') == 'error':
print('JS ERR:', str(m)[:200]); return
if m.get('type') != 'send':
return
p = m.get('payload') or {}
t = p.get('type')
if t != 'retry':
events.append(p)
if t == 'armed':
got_armed.append(1); print('[armed]')
elif t == 'nonce_next':
print(f"[nonce_next] m={p['m']} ret={p['ret']}")
elif t == 'service_time':
print(f"[service_time] {p['v']} ({p['dec']})")
elif t == 'xxtea_in':
print(f"[xxtea.in] data len={p['data']['len']} {p['data']['v'][:40]}")
print(f" key len={p['key']['len']} {p['key']['v'][:60]} ascii={p['key_ascii'][:50]}")
elif t == 'xxtea_out':
print(f"[xxtea.out] len={p['out']['len']} {p['out']['v'][:48]}")
elif t == 'enpack_header':
print(f"[enpack_header] h={p['h']} key={p['key']} out len={p['out']['len']} {p['out']['v'][:20]}")
elif t == 'enpack_body_in':
print(f"[enpack_body] h={p['h']}")
print(f" a(len{p['a']['len']})={p['a']['v'][:60]} ascii={p['a_ascii']}")
print(f" b(len{p['b']['len']})={p['b']['v'][:48]}")
print(f" c(len{p['c']['len']})={p['c']['v'][:60]} ascii={p['c_ascii']}")
print(f" d(len{p['d']['len']})={p['d']['v'][:24]}")
elif t == 'enpack_body_out':
print(f"[enpack_body.out] len={p['out']['len']} {p['out']['v'][:80]}")
elif t == 'gbt_in':
print(f"[gen_biz_token.in] struct={p['struct'][:100]}")
print(f" uid(len{p['uid']['len']})={p['uid']['v'][:40]}")
print(f" k1(len{p['k1']['len']})={p['k1']['v'][:60]} ascii={p['k1_ascii']}")
print(f" k2(len{p['k2']['len']})={p['k2']['v'][:60]} ascii={p['k2_ascii']}")
elif t == 'gbt_out':
print(f"[gen_biz_token.out] len={p['out']['len']} {p['out']['v'][:60]}")
elif t == 'encode_aes':
print(f"[encode_aes] p1 len={p['p1']['len']} {p['p1']['v'][:70]}")
print(f" key={p['key_ascii'][:50]} out len={p['out']['len']}")
elif t == 'getOtpEx':
print(f"[getOtpEx] uid={p['uid']} idx={p['idx']} a3(len{p['a3']['len']}) out len={p['out']['len']}")
elif t == 'getkey':
print(f"[getkey] i1={p['i1']} i2={p['i2']} key={p['key']['v'][:40]} ascii={ascii_short(p['key']['v'])}")
elif t == 'qurl_done':
print(f"[qurl#{p['idx']}] len={p['len']} head={(p.get('data') or '')[:40]}")
elif t == 'stage':
print('[stage]', p['msg'])
def ascii_short(h):
s = ''
for i in range(0, len(h), 2):
c = int(h[i:i+2], 16)
s += chr(c) if 32 <= c < 127 else '.'
return s[:40]
sc.on('message', on_msg)
sc.load()
assert got_armed
deadline = time.time() + 120
while time.time() < deadline:
time.sleep(2)
if sum(1 for e in events if e.get('type') == 'qurl_done') >= 2:
time.sleep(5)
break
Path('/tmp/opencode/nonce_chain.json').write_text(json.dumps(events))
from collections import Counter
print('saved:', Counter(e.get('type') for e in events))
if __name__ == '__main__':
for attempt in range(3):
print(f'===== 尝试 #{attempt+1} =====')
try:
main()
break
except frida.InvalidOperationError as e:
print(f'session detached: {e}')
subprocess.run(['adb', 'shell', 'am', 'force-stop', 'com.duowan.kiwi'])
time.sleep(3)
except Exception as e:
print(f'err: {e}')
subprocess.run(['adb', 'shell', 'am', 'force-stop', 'com.duowan.kiwi'])
time.sleep(3)
+208
View File
@@ -0,0 +1,208 @@
#!/usr/bin/env python3
"""native侧追查nonce生成: 枚举libudbauthunify.so符号 + 窗口内hook sha/hmac/rand。
时序(不可变): force-stop -> spawn挂起 -> 双bypass -> resume -> 10s -> patch_guard
-> 枚举符号 -> hook命中函数 -> Java桥触发getQUrlData -> 收集调用与nonce比对。
"""
from __future__ import annotations
import json
import subprocess
import time
from pathlib import Path
import frida
RE = Path("/Users/yml/codes/Reverse-Engineering-Agent-Universal-v3.0")
UID = 1199666914671
ENUM_JS = r"""
'use strict';
var m = Process.getModuleByName('libudbauthunify.so');
send({type:'info', base:String(m.base), size:m.size});
var pat = /sha1|sha-1|hmac|rand|urandom|sign|digest|nonce|md5|aes_key/i;
var syms = m.enumerateSymbols().filter(function(s){
return s.name && pat.test(s.name);
}).map(function(s){ return {name:s.name, addr:String(s.address)}; });
var exps = m.enumerateExports().filter(function(e){ return pat.test(e.name); })
.map(function(e){ return {name:e.name, addr:String(e.address)}; });
send({type:'syms', syms:syms, exps:exps});
"""
HOOK_JS_TPL = r"""
'use strict';
Process.setExceptionHandler(function(d){
send({type:'segv', info:{type:d.type, addr:String(d.address)}}); return true;
});
var base = Process.getModuleByName('libudbauthunify.so').base;
send({type:'armed', base:String(base)});
var WIN_ON = false;
function rdStr(p){
try{
var b0=p.readU8();
if((b0&1)===0){ var l=b0>>1; if(l>4096) return {t:'s?',len:l,v:''};
return {t:'s',len:l,v:l?Array.from(new Uint8Array(p.add(1).readByteArray(l))).map(x=>('0'+x.toString(16)).slice(-2)).join(''):''}; }
var len=parseInt(p.add(8).readU64().toString());
if(len>65536) return {t:'l?',len:len,v:''};
var dp=p.add(16).readPointer();
return {t:'l',len:len,v:Array.from(new Uint8Array(dp.readByteArray(len))).map(x=>('0'+x.toString(16)).slice(-2)).join('')};
}catch(e){ return {t:'err',len:-1,v:String(e)}; }
}
function hx(p,n){ try{ return Array.from(new Uint8Array(p.readByteArray(n))).map(x=>('0'+x.toString(16)).slice(-2)).join(''); }catch(e){ return 'ERR'; } }
function bt(ctx){ return Thread.backtrace(ctx, Backtracer.ACCURATE).map(function(a){
return DebugSymbol.fromAddress(a).toString().slice(0,110); }).slice(0,6); }
// __HITS__: [ [addrStr, name], ... ]
var hits = __HITS__;
hits.forEach(function(h){
try {
Interceptor.attach(ptr(h[0]), {
onEnter: function(a){
if (!WIN_ON) return;
this.a = [a[0],a[1],a[2],a[3]];
this.bt = bt(this.context);
},
onLeave: function(r){
if (!WIN_ON) return;
send({type:'hit', name:h[1].slice(0,90),
args:[rdStr(this.a[0]), rdStr(this.a[1])],
ret:rdStr(r), bt:this.bt});
}
});
send({type:'hooked', name:h[1].slice(0,80)});
} catch(e){ send({type:'hookerr', where:h[1], msg:''+e}); }
});
// 窗口控制 + 触发
Java.perform(function(){
var n=0;
function go(){
try{
var seed=Java.use('com.duowan.kiwi.base.login.udb.HuyaLoginProxy');
var F=Java.ClassFactory.get(seed.class.getClassLoader());
var inst=F.use('com.hysdkproxy.LoginProxy').getInstance();
WIN_ON = true;
send({type:'stage', msg:'window ON'});
var q=inst.getQUrlData(__UID__, "", "");
WIN_ON = false;
send({type:'qurl_done', len:q?q.length:0, data:q||''});
}catch(e){ n+=1; window.on=false; if(n%4===0) send({type:'retry',n:n}); setTimeout(go,5000); }
}
setTimeout(go, 6000);
});
""".replace('__UID__', str(UID))
def main():
d = frida.get_device_manager().add_remote_device('127.0.0.1:31877')
pid = d.spawn(['com.duowan.kiwi'])
print(f'spawned {pid}')
s = d.attach(pid)
s.create_script((RE / "evidence/scripts/bypass_msaoaid_maps_art_callsite.js").read_text()).load()
s.create_script((RE / "evidence/scripts/mask_frida_maps_only.js").read_text()).load()
d.resume(pid)
print('resumed')
time.sleep(11)
s.create_script((RE / "evidence/scripts/patch_guard_block_termination.js").read_text()).load()
print('patch_guard on')
# 1) 枚举符号
sc0 = s.create_script(ENUM_JS)
out = {}
def on0(m, _):
if m.get('type') == 'send':
p = m['payload']
if p.get('type') in ('info', 'syms'):
out[p['type']] = p
sc0.on('message', on0)
sc0.load()
time.sleep(3)
info = out['info']
syms = out['syms']['syms'] + out['syms']['exps']
print(f"base={info['base']} size={info['size']} 命中符号={len(syms)}")
for x in syms[:30]:
print(" ", x['name'][:100])
# 2) 选 hook 目标: 定向 SHA1/md5/random_device, 上限12个
def want(nm):
ln = nm.lower()
return ('sha_go' in ln or 'shainit' in ln or 'adddatalen' in ln or
'random_device' in ln or 'md5_char' in ln or
'huyamd5' in ln and ('digest' in ln or 'tostring' in ln))
picks = []
seen = set()
for x in syms:
nm = x['name']
off = int(x['addr'], 16) - int(info['base'], 16)
if off <= 0 or off > info['size']:
continue
if want(nm) and nm not in seen:
seen.add(nm)
picks.append([x['addr'], nm])
if len(picks) >= 12:
break
print("hook目标:", [p[1][:60] for p in picks])
# 3) 装钩 + 触发
js = HOOK_JS_TPL.replace('__HITS__', json.dumps(picks))
sc = s.create_script(js)
events = []
got_armed = []
def on_msg(m, _):
if m.get('type') == 'error':
print('JS ERR:', str(m)[:200]); return
if m.get('type') != 'send':
return
p = m.get('payload') or {}
t = p.get('type')
events.append(p)
if t == 'armed':
got_armed.append(1); print('[armed]')
elif t == 'hooked':
print('[hooked]', p['name'][:70])
elif t == 'hookerr':
print('[hookerr]', p)
elif t == 'hit':
a0, a1 = p['args'][0], p['args'][1]
print(f"\n[HIT] {p['name'][:80]}")
print(f" arg0={a0['t']}:{a0['len']} {a0['v'][:64]}")
print(f" arg1={a1['t']}:{a1['len']} {a1['v'][:64]}")
print(f" ret={p['ret']['t']}:{p['ret']['len']} {p['ret']['v'][:64]}")
for f in p['bt'][:4]:
print(" <", f)
elif t == 'stage':
print('[stage]', p['msg'])
elif t == 'qurl_done':
print('[qurl_done]', p.get('len'), (p.get('data') or '')[:80])
sc.on('message', on_msg)
sc.load()
assert got_armed
deadline = time.time() + 90
while time.time() < deadline:
time.sleep(2)
if any(e.get('type') == 'qurl_done' for e in events):
time.sleep(5)
break
Path('/tmp/opencode/nonce_native.json').write_text(json.dumps(events))
from collections import Counter
print('saved:', Counter(e.get('type') for e in events))
if __name__ == '__main__':
for attempt in range(3):
print(f'===== 尝试 #{attempt+1} =====')
try:
main()
break
except frida.InvalidOperationError as e:
print(f'session detached: {e}')
subprocess.run(['adb', 'shell', 'am', 'force-stop', 'com.duowan.kiwi'])
time.sleep(3)
except Exception as e:
print(f'err: {e}')
subprocess.run(['adb', 'shell', 'am', 'force-stop', 'com.duowan.kiwi'])
time.sleep(3)
+131
View File
@@ -0,0 +1,131 @@
#!/usr/bin/env python3
"""追查 wupData 信封内 20B nonce 的生成源头。
时序(不可变, 同 hook_cert_keycap): force-stop -> spawn挂起 -> 双bypass ->
resume -> 10s -> patch_guard -> 随机源hooks + Java桥触发 getQUrlData。
命中判定: 窗口内随机源输出与P1中nonce比对 / 直接看SecureRandom调用栈。
"""
from __future__ import annotations
import json
import subprocess
import time
from pathlib import Path
import frida
RE = Path("/Users/yml/codes/Reverse-Engineering-Agent-Universal-v3.0")
UID = 1199666914671
JS = r"""
'use strict';
Process.setExceptionHandler(function(d){
send({type:'segv', info:{type:d.type, addr:String(d.address)}});
return true;
});
var base = Process.getModuleByName('libudbauthunify.so').base;
send({type:'armed', base:String(base)});
// ---- Java 随机源 ----
Java.perform(function(){
try {
var SR = Java.use('java.security.SecureRandom');
SR.nextBytes.overload('[B').implementation = function(b){
var st = Java.use('android.util.Log')
.getStackTraceString(Java.use('java.lang.Exception').$new());
var r = this.nextBytes(b);
var hex = Array.from(new Uint8Array(b)).map(function(x){
return ('0'+x.toString(16)).slice(-2);}).join('').slice(0,48);
send({type:'jsec', n:b.length, head:hex, stack:st});
return r;
};
} catch(e){ send({type:'hookerr', where:'SecureRandom', msg:''+e}); }
});
// ---- 触发证书生成(getQUrlData 组 P1 含 nonce) ----
Java.perform(function(){
var n=0;
function go(){
try{
var seed=Java.use('com.duowan.kiwi.base.login.udb.HuyaLoginProxy');
var F=Java.ClassFactory.get(seed.class.getClassLoader());
var inst=F.use('com.hysdkproxy.LoginProxy').getInstance();
var q=inst.getQUrlData(__UID__, "", "");
send({type:'qurl_done', len:q?q.length:0, data:q||''});
}catch(e){ n+=1; if(n%6===0) send({type:'retry',n:n}); setTimeout(go,5000); }
}
setTimeout(go, 8000);
});
""".replace('__UID__', str(UID))
def main():
d = frida.get_device_manager().add_remote_device('127.0.0.1:31877')
pid = d.spawn(['com.duowan.kiwi'])
print(f'spawned {pid}')
s = d.attach(pid)
s.create_script((RE / "evidence/scripts/bypass_msaoaid_maps_art_callsite.js").read_text()).load()
s.create_script((RE / "evidence/scripts/mask_frida_maps_only.js").read_text()).load()
d.resume(pid)
print('resumed')
time.sleep(11)
s.create_script((RE / "evidence/scripts/patch_guard_block_termination.js").read_text()).load()
print('patch_guard on')
sc = s.create_script(JS)
events = []
got_armed = []
def on_msg(m, _):
if m.get('type') == 'error':
print('JS ERR:', str(m)[:240]); return
if m.get('type') != 'send':
return
p = m.get('payload') or {}
t = p.get('type')
if t != 'retry':
events.append(p)
if t == 'armed':
got_armed.append(1); print('[armed]', p['base'])
elif t == 'jsec':
lines = [x.strip() for x in p['stack'].split('\n')]
frames = [x for x in lines if 'huya' in x.lower() or 'udb' in x.lower()
or 'nonce' in x.lower()][:5]
print(f"[SecureRandom] {p['n']}B head={p['head']}")
for f in (frames or lines[1:4]):
print(" ", f[:110])
elif t == 'hookerr':
print('[hookerr]', p)
elif t == 'qurl_done':
print('[qurl_done]', p.get('len'), 'head:', (p.get('data') or '')[:60])
sc.on('message', on_msg)
sc.load()
assert got_armed
deadline = time.time() + 120
while time.time() < deadline:
time.sleep(2)
if any(e.get('type') == 'qurl_done' for e in events):
time.sleep(8)
break
Path('/tmp/opencode/nonce_trace.json').write_text(json.dumps(events))
from collections import Counter
print('saved:', Counter(e.get('type') for e in events))
if __name__ == '__main__':
for attempt in range(4):
print(f'===== 尝试 #{attempt+1} =====')
try:
main()
break
except frida.InvalidOperationError as e:
print(f'session detached: {e}')
subprocess.run(['adb', 'shell', 'am', 'force-stop', 'com.duowan.kiwi'])
time.sleep(3)
except Exception as e:
print(f'err: {e}')
subprocess.run(['adb', 'shell', 'am', 'force-stop', 'com.duowan.kiwi'])
time.sleep(3)
+146
View File
@@ -0,0 +1,146 @@
#!/usr/bin/env python3
"""nonce溯源v2: 修正random_device返回值读取 + libc熵源 + 同进程双调用对比。"""
from __future__ import annotations
import json
import subprocess
import time
from pathlib import Path
import frida
RE = Path("/Users/yml/codes/Reverse-Engineering-Agent-Universal-v3.0")
UID = 1199666914671
JS = r"""
'use strict';
Process.setExceptionHandler(function(d){
send({type:'segv', info:{type:d.type, addr:String(d.address)}}); return true;
});
var base = Process.getModuleByName('libudbauthunify.so').base;
send({type:'armed', base:String(base)});
var WIN_ON = false;
// ---- random_device::operator() 返回uint32 ----
try {
var sym = null;
Process.getModuleByName('libudbauthunify.so').enumerateSymbols().forEach(function(s){
if (s.name === '_ZNSt6__ndk113random_deviceclEv') sym = s.address;
});
if (sym) Interceptor.attach(sym, {
onLeave: function(r){
if (!WIN_ON) return;
try { send({type:'rd32', v:'0x'+r.toInt32().toString(16), bt:DebugSymbol.fromAddress(this.returnAddress).toString().slice(0,60)}); } catch(e){}
}
});
} catch(e){ send({type:'hookerr', where:'rd_op', msg:''+e}); }
// ---- libc 熵源 ----
['getrandom','getentropy','arc4random_buf'].forEach(function(fn){
var p = Module.findExportByName('libc.so', fn);
if (!p) return;
Interceptor.attach(p, {
onEnter: function(a){ this.buf=a[0]||a[1]; this.n=a[1] ? a[1].toInt32() : a[0].toInt32(); },
onLeave: function(_){
if (!WIN_ON || this.n<=0 || this.n>64) return;
try {
var b = Memory.readByteArray(this.buf, Math.min(this.n,32));
send({type:'libc', fn:fn, n:this.n,
hex:Array.from(new Uint8Array(b)).map(function(x){return ('0'+x.toString(16)).slice(-2)}).join('')});
} catch(e){}
}
});
});
var rd = Module.findExportByName('libc.so','read');
Interceptor.attach(rd, {onEnter:function(a){ this.fd=a[0].toInt32(); this.buf=a[1]; this.n=a[2].toInt32(); },
onLeave:function(_){ if (!WIN_ON) return; }});
// ---- 双调用触发 ----
Java.perform(function(){
var n=0;
function go(){
try{
var seed=Java.use('com.duowan.kiwi.base.login.udb.HuyaLoginProxy');
var F=Java.ClassFactory.get(seed.class.getClassLoader());
var inst=F.use('com.hysdkproxy.LoginProxy').getInstance();
WIN_ON = true;
send({type:'stage', msg:'call #1'});
var q1=inst.getQUrlData(__UID__, "", "");
send({type:'qurl_done', idx:1, data:q1||''});
var q2=inst.getQUrlData(__UID__, "", "");
WIN_ON = false;
send({type:'qurl_done', idx:2, data:q2||''});
}catch(e){ n+=1; WIN_ON=false; if(n%4===0) send({type:'retry',n:n,err:String(e).slice(0,90)}); setTimeout(go,5000); }
}
setTimeout(go, 20000);
});
""".replace('__UID__', str(UID))
def main():
d = frida.get_device_manager().add_remote_device('127.0.0.1:31877')
pid = d.spawn(['com.duowan.kiwi'])
print(f'spawned {pid}')
s = d.attach(pid)
s.create_script((RE / "evidence/scripts/bypass_msaoaid_maps_art_callsite.js").read_text()).load()
s.create_script((RE / "evidence/scripts/mask_frida_maps_only.js").read_text()).load()
d.resume(pid)
print('resumed')
time.sleep(11)
s.create_script((RE / "evidence/scripts/patch_guard_block_termination.js").read_text()).load()
print('patch_guard on')
sc = s.create_script(JS)
events = []
got_armed = []
def on_msg(m, _):
if m.get('type') == 'error':
print('JS ERR:', str(m)[:200]); return
if m.get('type') != 'send':
return
p = m.get('payload') or {}
t = p.get('type')
events.append(p)
if t == 'armed':
got_armed.append(1); print('[armed]')
elif t == 'rd32':
print(f"[rd32] {p['v']} <- {p['bt'][:50]}")
elif t == 'libc':
print(f"[{p['fn']}] n={p['n']} {p['hex'][:40]}")
elif t == 'stage':
print('[stage]', p['msg'])
elif t == 'qurl_done':
print(f"[qurl#{p.get('idx')}] len={p.get('len')} head={(p.get('data') or '')[:40]}")
elif t == 'retry':
print('[retry]', p.get('n'))
sc.on('message', on_msg)
sc.load()
assert got_armed
deadline = time.time() + 100
while time.time() < deadline:
time.sleep(2)
if sum(1 for e in events if e.get('type') == 'qurl_done') >= 2:
time.sleep(5)
break
Path('/tmp/opencode/nonce_v2.json').write_text(json.dumps(events))
from collections import Counter
print('saved:', Counter(e.get('type') for e in events))
if __name__ == '__main__':
for attempt in range(3):
print(f'===== 尝试 #{attempt+1} =====')
try:
main()
break
except frida.InvalidOperationError as e:
print(f'session detached: {e}')
subprocess.run(['adb', 'shell', 'am', 'force-stop', 'com.duowan.kiwi'])
time.sleep(3)
except Exception as e:
print(f'err: {e}')
subprocess.run(['adb', 'shell', 'am', 'force-stop', 'com.duowan.kiwi'])
time.sleep(3)
+115
View File
@@ -0,0 +1,115 @@
#!/usr/bin/env python3
"""纯协议关键验证: 本地重算 nonce 铸证是否被服务端接受(同账号实验)。
背景: 旧结论"必须复用信封原nonce(纯随机必40020)"是建立在不了解nonce结构的基础上。
现已破解: nonce = XXTEA(pack(st)+pack(counter|st<<16), key=MD5hex(uid+k1)[:16])
=> 服务端校验的是 nonce 能否用 t3.uid 解密成功, 而非"设备是否生成过该nonce"
本实验: 用【可信账号uid + k1】本地重算 nonce 铸证, 替换当前新鲜信封的 cert, bind。
✅ 成功 => 服务端接受本地nonce, 纯协议基石成立!
失败(40020) => 服务端仍校验nonce签发源, 需进一步逆向。
用法: RE_PY scripts/probe_nonce_bind.py <账号> <密码>
"""
from __future__ import annotations
import base64
import json
import sys
import time
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT))
sys.path.insert(0, str(ROOT / "tools"))
sys.path.insert(0, str(ROOT / "scripts"))
from app_login_flow import QrAuthRequiredError, login_cred # noqa: E402
from cert_forge import build_p1, decrypt_cert, forge_cert, parse_p1 # noqa: E402
from envelope_forge import Envelope # noqa: E402
from nonce_forge import K1_DEFAULT, gen_nonce # noqa: E402
from probe_huya_qr_bind import QrRole, web_behavior # noqa: E402
from core.huya.device_fingerprint import get_huya_sdid # noqa: E402
def try_bind(wup_b64: str, name: str) -> dict | None:
from urllib.parse import quote
sdid = get_huya_sdid(allow_fallback=False).sdid
pc = QrRole(pc=True, sdid=sdid)
ph = QrRole(pc=False, sdid=sdid)
beh, page = web_behavior()
resp = pc.call("/qrLgn/getQrId", "70001",
{"behavior": beh, "type": "", "domainList": "", "page": page})
qrid = ((resp.get("data") or {}).get("qrId")) if resp.get("returnCode") == 0 else None
if not qrid:
print(f" [{name}] getQrId失败 {resp.get('returnCode')}")
return None
cp = f"https://aq.huya.com/r/confirm.html?k={qrid}&id=5002"
ph.call("/qrLgn/scanQrPicNotify", "70005",
{"qrId": qrid, "wupData": wup_b64,
"behavior": quote("[]", safe=""), "page": quote(cp, safe="")})
r2 = ph.call("/qrLgn/bindQrLoginUser", "70007",
{"qrId": qrid, "wupData": wup_b64,
"behavior": quote("[]", safe=""), "page": quote(cp, safe="")})
print(f" [{name}] bindQr rc={r2.get('returnCode')} "
f"{r2.get('message') or r2.get('description') or ''}")
if r2.get("returnCode") != 0:
return {"rc": r2.get("returnCode"),
"msg": r2.get("message") or r2.get("description")}
for _ in range(4):
rt = pc.call("/qrLgn/tryQrLogin", "70003",
{"qrId": qrid, "remember": "1", "domainList": "",
"behavior": beh, "page": page})
dt = rt.get("data") or {}
if dt.get("stage") == 2 and dt.get("biztoken"):
return {"ok": True, "uid": dt.get("uid"),
"biztoken_len": len(dt["biztoken"])}
time.sleep(1.5)
return {"stage2_no_token": True}
def main() -> int:
acct, pwd = sys.argv[1], sys.argv[2]
# 可选第三参: 目标uid覆盖(跨账号实验用); 缺省=信封原uid
target_uid = int(sys.argv[3]) if len(sys.argv) > 3 else None
print(f"[1/3] 密码登录 {acct} ...")
try:
cred = login_cred(acct, pwd)
except QrAuthRequiredError as exc:
print(f"{exc}")
return 2
print(f" cred {len(cred)}B {cred[:8].hex()}")
env = Envelope.load()
orig = base64.b64decode(env.cert_b64)
f = parse_p1(decrypt_cert(orig))
env_uid = env.uid
use_uid = target_uid if target_uid is not None else env_uid
print(f"[2/3] 本地重算 nonce (信封uid={env_uid}, 目标uid={use_uid}, "
f"k1={K1_DEFAULT[:16]}...)")
st = int(time.time() * 1000)
rnd = gen_nonce(use_uid, K1_DEFAULT, service_time_ms=st, counter=0)
print(f" serviceTime={st} nonce={rnd.hex()}")
P1 = build_p1(f["app_id"], f["fingerprint"], cred, rnd=rnd)
cert = forge_cert(P1, key_idx=orig[1])
b64 = base64.b64encode(cert).decode()
if len(b64) != env.cert_len:
print(f" cert b64 长度不符 {len(b64)} != {env.cert_len}, 中止")
return 1
raw = bytearray(env.raw)
raw[env.cert_off:env.cert_off + env.cert_len] = b64.encode()
if use_uid != env_uid:
import struct
print(f"[patch] 信封uid {env_uid} -> {use_uid}")
struct.pack_into(">Q", raw, env.uid_off, use_uid)
wup = base64.b64encode(bytes(raw)).decode()
print(f"[3/3] bind (wup {len(raw)}B, uid={use_uid}) ...")
r = try_bind(wup, "local_nonce")
print("结果:", r)
(ROOT / "evidence/nonce_bind_result.json").write_text(json.dumps(
{"local_nonce": r, "env_uid": env_uid, "uid": use_uid, "st": st,
"nonce_hex": rnd.hex(), "ts": time.time()}, ensure_ascii=False, indent=1))
return 0 if r and r.get("ok") else 1
if __name__ == "__main__":
raise SystemExit(main())
+89 -37
View File
@@ -1,20 +1,20 @@
#!/usr/bin/env python3
"""一键全自动(含新账号safe_auth滑块): 账号密码 -> 页面cookie(biztoken)。
"""一键全自动(含新账号safe_auth滑块): 账号密码 -> 页面cookie(biztoken)。纯协议版。
与 full_auto_test.py 的区别: 可信账号直出cred之外,
2026-08-26: nonce 本地重算(tools/nonce_forge.py), 任意账号无需设备首登;
新/异地账号触发 safe_auth 时自动解 udbrtt 滑块并重发登录, 全程零人工。
用法:
.venv/bin/python tools/full_auto_safe.py <账号如hy_xxx或300023887> <密码>
前提: evidence/cert_keycap.json 内有较新鲜的 wupData 信封
(过期则先在设备上跑 scripts/hook_cert_keycap.py 刷新)
前提: evidence/cert_keycap.json 内有信封模板(任意账号共用, 仅需采集一次)
+ evidence/nonce_k1.json 或默认 k1 设备常量
"""
import base64
import json
import re
import subprocess
import struct
import sys
import time
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
@@ -24,33 +24,34 @@ sys.path.insert(0, str(ROOT / "tools"))
from app_login_flow import QrAuthRequiredError, login_cred # noqa: E402
from cert_forge import build_p1, decrypt_cert, forge_cert, parse_p1 # noqa: E402
from envelope_forge import Envelope # noqa: E402
from nonce_forge import K1_DEFAULT, gen_nonce # noqa: E402
def forge(cred: bytes) -> str:
"""复用信封原证书的nonce/指纹/key_idx仅换cred(纯随机nonce必40020)."""
env = Envelope.load()
def real_uid(acct: str, pwd: str) -> int:
"""登录响应解析真实uid(响应bean偏移235 u64be)。"""
from app_login_flow import wup_password_login_raw
resp = wup_password_login_raw(acct, pwd)
s = resp.find(b"\x0a\x0a", 0x40)
e = resp.find(b"_wup_header")
uid = struct.unpack_from(">Q", resp[s:e], 235)[0]
assert 1_100_000_000_000 < uid < 1_300_000_000_000, f"uid异常 {uid}"
return uid
def forge_for_uid(env: Envelope, cred: bytes, uid: int, k1: str) -> bytes:
"""本地重算nonce + 换cred 铸证(任意账号可用)。"""
orig = base64.b64decode(env.cert_b64)
f = parse_p1(decrypt_cert(orig))
P1 = build_p1(f["app_id"], f["fingerprint"], cred, rnd=f["rnd"])
return forge_cert(P1, key_idx=orig[1]).hex()
def bind_for_cookie(cred_json_path: Path) -> bool:
"""复用扫码绑定四步流(QrRole), 返回是否拿到biztoken。"""
probe = ROOT / "scripts/probe_forge_cert_bind.py"
src = probe.read_text()
src = re.sub(r'open\("evidence/hycred_[a-z_]+\.json"\)',
f'open("{cred_json_path.as_posix()}")', src)
tmp = ROOT / "scripts/_probe_auto.py"
tmp.write_text(src)
r = subprocess.run([sys.executable, str(tmp)], capture_output=True, text=True)
print(r.stdout[-800:])
return '"ok": true' in r.stdout or "'ok': True" in r.stdout
st = int(time.time() * 1000)
rnd = gen_nonce(uid, k1, service_time_ms=st, counter=0)
print(f" 本地nonce: uid={uid} st={st} rnd={rnd.hex()}")
P1 = build_p1(f["app_id"], f["fingerprint"], cred, rnd=rnd)
return forge_cert(P1, key_idx=orig[1])
def main() -> int:
acct, pwd = sys.argv[1], sys.argv[2]
print("[1/3] 纯Python密码登录 (遇safe_auth自动过滑块) ...")
print("[1/4] 纯Python密码登录 (遇safe_auth自动过滑块) ...")
try:
cred = login_cred(acct, pwd)
except QrAuthRequiredError as exc:
@@ -58,19 +59,70 @@ def main() -> int:
return 2
print(f" cred OK: {len(cred)}B {cred[:8].hex()}")
print("[2/3] 铸造证书 ...")
cert_hex = forge(cred)
out = ROOT / "evidence/auto_test_cred.json"
out.write_text(json.dumps({"p1": "", "cert_hex": cert_hex,
"cred_hex": cred.hex()}))
print(f" 证书 OK: {len(cert_hex)//2}B")
uid = real_uid(acct, pwd)
print(f"[2/4] 解析真实uid={uid}, 本地重算nonce 铸证 ...")
env = Envelope.load()
cert = forge_for_uid(env, cred, uid, K1_DEFAULT)
b64 = base64.b64encode(cert).decode()
if len(b64) != env.cert_len:
print(f" cert b64 长度不符 {len(b64)} != {env.cert_len}, 中止")
return 1
raw = bytearray(env.raw)
raw[env.cert_off:env.cert_off + env.cert_len] = b64.encode()
if env.uid != uid:
print(f" patch uid {env.uid} -> {uid}")
struct.pack_into(">Q", raw, env.uid_off, uid)
wup = base64.b64encode(bytes(raw)).decode()
print("[3/3] 扫码绑定取cookie ...")
if bind_for_cookie(out):
print("\n🎉 全链路成功: 密码已换出页面cookie(biztoken)!")
return 0
print("\n❌ 绑定失败——若40020多为信封过期, 请刷新wupData后重试")
return 1
print("[3/4] 扫码绑定四步流 ...")
from urllib.parse import quote
sys.path.insert(0, str(ROOT / "scripts"))
from probe_huya_qr_bind import QrRole, web_behavior
from core.huya.device_fingerprint import get_huya_sdid
sdid = get_huya_sdid(allow_fallback=False).sdid
pc = QrRole(pc=True, sdid=sdid)
ph = QrRole(pc=False, sdid=sdid)
beh, page = web_behavior()
resp = pc.call("/qrLgn/getQrId", "70001",
{"behavior": beh, "type": "", "domainList": "", "page": page})
qrid = resp["data"]["qrId"]
cp = f"https://aq.huya.com/r/confirm.html?k={qrid}&id=5002"
ph.call("/qrLgn/scanQrPicNotify", "70005",
{"qrId": qrid, "wupData": wup,
"behavior": quote("[]", safe=""), "page": quote(cp, safe="")})
r2 = ph.call("/qrLgn/bindQrLoginUser", "70007",
{"qrId": qrid, "wupData": wup,
"behavior": quote("[]", safe=""), "page": quote(cp, safe="")})
print(f" bind rc={r2.get('returnCode')} {r2.get('message') or ''}")
biztoken = None
for _ in range(10):
rt = pc.call("/qrLgn/tryQrLogin", "70003",
{"qrId": qrid, "remember": "1", "domainList": "",
"behavior": beh, "page": page})
dt = rt.get("data") or {}
if dt.get("stage") == 2:
biztoken = dt.get("biztoken")
print(f" biztoken OK: {len(biztoken)}B")
break
time.sleep(2)
if not biztoken:
print("❌ 未取到biztoken")
return 1
print("[4/4] cookie verify 兑换 ...")
sess = getattr(pc, 'session', None) or getattr(pc, 's', None) or getattr(pc, '_sess', None)
r = sess.post("https://udblgn.huya.com/web/cookie/verify",
json={"appId": 5002}, timeout=15)
print(f" verify HTTP {r.status_code}")
jar = []
for c in sess.cookies:
print(f" {c.name} = {c.value[:50]}")
jar.append(f"{c.name}={c.value}")
out = ROOT / "evidence/web_cookies_full.txt"
out.write_text(f"# account: {acct}\n" + "\n".join(jar) + "\n")
print(f"\n🎉 纯协议全链路成功: 任意账号密码已换出全套cookie! ({len(jar)}个)")
print("已写", out)
return 0
if __name__ == "__main__":
+93 -60
View File
@@ -1,73 +1,106 @@
#!/usr/bin/env python3
"""一键: 账号密码 -> 完整网页Cookie全套
用法: python tools/full_web_cookie.py <账号> <密码>
"""一键: 账号密码 -> 完整网页Cookie全套 (纯协议, nonce本地重算).
2026-08-26 升级: nonce 生成机制已破解(tools/nonce_forge.py),
不再需要复用设备信封的原nonce —— 任意账号用 目标uid+k1 本地重算即过bind!
依赖: k1 设备常量(evidence/nonce_k1.json 或默认值, 不随账号变)。
用法: .venv/bin/python tools/full_web_cookie.py <账号> <密码>
产出: 打印全部cookie并写 evidence/web_cookies_full.txt
"""
import sys, json, re, hashlib
import sys, json, re, hashlib, time, base64
ACCT, PWD = sys.argv[1], sys.argv[2]
sys.argv = [sys.argv[0]]
import sys,json,time,base64
from urllib.parse import quote
sys.path.insert(0,'scripts'); sys.path.insert(0,'tools')
from probe_forge_cert_bind import load_capture, parse_p1, build_p1, forge_cert
sys.path.insert(0, 'scripts'); sys.path.insert(0, 'tools')
from cert_forge import build_p1, decrypt_cert, forge_cert, parse_p1
from envelope_forge import Envelope
from nonce_forge import K1_DEFAULT, gen_nonce
from probe_huya_qr_bind import QrRole, web_behavior
from core.huya.device_fingerprint import get_huya_sdid
qurl,P1_old=load_capture()
fields=parse_p1(P1_old)
import time,base64
from urllib.parse import quote
# 登录取cred: 触发safe_auth时自动解udbrtt滑块并重发(app_login_flow闭环)
import app_login_flow as _flow
try:
cred = _flow.login_cred(ACCT, PWD)
except _flow.QrAuthRequiredError as e:
print('', e)
sys.exit(2)
except Exception as e:
print(f'❌ 登录未返回cred(密码错误/账号异常/风控过验失败): {e}')
sys.exit(1)
print('新鲜cred:',len(cred),cred[:8].hex())
P1n=build_p1(b"5008",fields["fingerprint"],cred,rnd=fields["rnd"])
cert=base64.b64encode(forge_cert(P1n,key_idx=fields and 0x20)).decode()
raw=base64.b64decode(qurl)
i=raw.find(base64.b64encode(forge_cert(build_p1(b"5008",fields["fingerprint"],b"\x0a"*114,rnd=fields["rnd"]),key_idx=0x20)))
# 直接替换原证书段: 找原cert b64 span——用原P1重建
old_b64=None
from probe_forge_cert_bind import find_cert_span as _fcs; j=_fcs(raw)
if True:
# 退路: 用find_cert_span
from probe_forge_cert_bind import find_cert_span
s0,s1,_=find_cert_span(raw); raw=raw[:s0]+cert.encode()+raw[s1:]
else:
raw=raw[:j]+cert.encode()+raw[j+len(old_b64):]
wup=base64.b64encode(raw).decode()
sdid=get_huya_sdid(allow_fallback=False).sdid
pc=QrRole(pc=True,sdid=sdid); ph=QrRole(pc=False,sdid=sdid)
beh,page=web_behavior()
resp=pc.call("/qrLgn/getQrId","70001",{"behavior":beh,"type":"","domainList":"","page":page})
qrid=resp["data"]["qrId"]
cp=f"https://aq.huya.com/r/confirm.html?k={qrid}&id=5002"
ph.call("/qrLgn/scanQrPicNotify","70005",{"qrId":qrid,"wupData":wup,"behavior":quote("[]",safe=""),"page":quote(cp,safe="")})
r2=ph.call("/qrLgn/bindQrLoginUser","70007",{"qrId":qrid,"wupData":wup,"behavior":quote("[]",safe=""),"page":quote(cp,safe="")})
print("bind rc",r2.get("returnCode"))
for i in range(10):
rt=pc.call("/qrLgn/tryQrLogin","70003",{"qrId":qrid,"remember":"1","domainList":"","behavior":beh,"page":page})
dt=rt.get("data") or {}
if dt.get("stage")==2:
print(json.dumps(dt,ensure_ascii=False,indent=1)[:1800]); break
time.sleep(2)
from app_login_flow import QrAuthRequiredError, login_cred
# 追加: cookie verify 兑换
sess = getattr(pc,'session',None) or getattr(pc,'s',None) or getattr(pc,'_sess',None)
r=sess.post("https://udblgn.huya.com/web/cookie/verify",json={"appId":5002},timeout=15)
print('verify HTTP',r.status_code)
for h in r.headers.get('Set-Cookie','').split('\n') or [r.headers.get('Set-Cookie','')]:
if h: print('SET:',h[:110])
print('cookie jar:')
jar=[]
def real_uid(acct: str) -> int:
"""登录响应解析真实uid(与信封t3一致的12位uid)。"""
import struct
from app_login_flow import wup_password_login_raw
resp = wup_password_login_raw(acct, PWD)
s = resp.find(b"\x0a\x0a", 0x40)
e = resp.find(b"_wup_header")
uid = struct.unpack_from(">Q", resp[s:e], 235)[0]
assert 1_100_000_000_000 < uid < 1_300_000_000_000, f"uid异常 {uid}"
return uid
print(f"== 账号 {ACCT} ==")
# 1) 登录取 cred (safe_auth滑块自动过)
try:
cred = login_cred(ACCT, PWD)
except QrAuthRequiredError as e:
print('', e); sys.exit(2)
except Exception as e:
print(f'❌ 登录未返回cred: {e}'); sys.exit(1)
print('新鲜cred:', len(cred), cred[:8].hex())
# 2) 取真实uid + 本地重算nonce 铸证
uid = real_uid(ACCT)
env = Envelope.load()
orig = base64.b64decode(env.cert_b64)
f = parse_p1(decrypt_cert(orig))
st = int(time.time() * 1000)
rnd = gen_nonce(uid, K1_DEFAULT, service_time_ms=st, counter=0)
print(f"本地nonce: uid={uid} st={st} rnd={rnd.hex()}")
P1 = build_p1(f["app_id"], f["fingerprint"], cred, rnd=rnd)
cert = base64.b64encode(forge_cert(P1, key_idx=orig[1])).decode()
assert len(cert) == env.cert_len, (len(cert), env.cert_len)
# 3) 信封: 换cert + (uid不同则patch)
raw = bytearray(env.raw)
raw[env.cert_off:env.cert_off + env.cert_len] = cert.encode()
if env.uid != uid:
import struct
print(f"patch uid {env.uid} -> {uid}")
struct.pack_into(">Q", raw, env.uid_off, uid)
wup = base64.b64encode(bytes(raw)).decode()
# 4) 扫码绑定四步流
sdid = get_huya_sdid(allow_fallback=False).sdid
pc = QrRole(pc=True, sdid=sdid); ph = QrRole(pc=False, sdid=sdid)
beh, page = web_behavior()
resp = pc.call("/qrLgn/getQrId", "70001",
{"behavior": beh, "type": "", "domainList": "", "page": page})
qrid = resp["data"]["qrId"]
cp = f"https://aq.huya.com/r/confirm.html?k={qrid}&id=5002"
ph.call("/qrLgn/scanQrPicNotify", "70005",
{"qrId": qrid, "wupData": wup,
"behavior": quote("[]", safe=""), "page": quote(cp, safe="")})
r2 = ph.call("/qrLgn/bindQrLoginUser", "70007",
{"qrId": qrid, "wupData": wup,
"behavior": quote("[]", safe=""), "page": quote(cp, safe="")})
print("bind rc", r2.get("returnCode"), r2.get("message") or "")
biztoken = None
for i in range(10):
rt = pc.call("/qrLgn/tryQrLogin", "70003",
{"qrId": qrid, "remember": "1", "domainList": "",
"behavior": beh, "page": page})
dt = rt.get("data") or {}
if dt.get("stage") == 2:
biztoken = dt.get("biztoken")
print(f"biztoken OK: {len(biztoken)}B")
break
time.sleep(2)
if not biztoken:
print("❌ 未取到biztoken"); sys.exit(1)
# 5) cookie verify 兑换
sess = getattr(pc, 'session', None) or getattr(pc, 's', None) or getattr(pc, '_sess', None)
r = sess.post("https://udblgn.huya.com/web/cookie/verify", json={"appId": 5002}, timeout=15)
print('verify HTTP', r.status_code)
jar = []
for c in sess.cookies:
print(' ',c.name,'=',c.value[:50])
print(' ', c.name, '=', c.value[:50])
jar.append(f'{c.name}={c.value}')
out=__import__('pathlib').Path(__file__).resolve().parent.parent/'evidence/web_cookies_full.txt'
out = __import__('pathlib').Path(__file__).resolve().parent.parent / 'evidence/web_cookies_full.txt'
out.write_text(f'# account: {ACCT}\n' + '\n'.join(jar) + '\n')
print('已写', out)
+122
View File
@@ -0,0 +1,122 @@
#!/usr/bin/env python3
"""虎牙证书 nonce(rnd) 生成器 —— 纯本地可算, 无需设备!
2026-08-26 动态hook实锤(scripts/hook_nonce_chain.py, /tmp/opencode/nonce_chain.json):
rnd = XXTEA(pack(serviceTime) + pack(nonce_next), key=MD5hex(uid_str + k1)[:16])
其中:
serviceTime = 当前毫秒时间戳 (int(time.time()*1000) & 0x7FFFFFFFFFFF)
nonce_next = counter | (serviceTime << 16)
(counter 为同 serviceTime 内递增计数, 从0开始)
k1 = 设备级常量 32位hex (865a4924a40897ac1fcfe6b4c2cbb0e3, 不随账号变)
key = hashlib.md5((uid_str + k1).encode()).hexdigest()[:16].encode()
pack(x) = 8字节小端 (cred_packlsEm: append 8B LE)
XXTEA 输出 = ceil(len/4)+1 words, 末尾 word 存原始长度 -> 16B输入 => 20B输出 = P1的rnd。
验证: 对设备实采两轮 rnd (80dd1dfa.../0ba8f8b7...) 逐字节复现一致。
k1 跨账号不变(uid=1199666914671 vs 1199666911746 均同值) => 任意账号可本地铸 nonce!
"""
from __future__ import annotations
import hashlib
import struct
import time
# 设备级常量: BusinessCfg::this+0x10 (动态hook自真机, 不随账号变化)
# TODO: 待确认其生成源(疑为设备安装时生成并持久化), 当前直接从真机提取
K1_DEFAULT = "865a4924a40897ac1fcfe6b4c2cbb0e3"
# ---------------------------------------------------------------- XXTEA
def _xxtea_encrypt_words(v: list[int], k: list[int]) -> list[int]:
"""标准 XXTEA (delta=0x9e3779b9, rounds=6+52/n), in-place 返回。"""
n = len(v)
if n < 2:
return v
z = v[n - 1]
y = v[0]
s = 0
delta = 0x9E3779B9
q = 6 + 52 // n
while q > 0:
s = (s + delta) & 0xFFFFFFFF
e = (s >> 2) & 3
p = 0
while p < n - 1:
y = v[p + 1]
z = (v[p] + ((((z >> 5) ^ (y << 2)) + ((y >> 3) ^ (z << 4)))
^ ((s ^ y) + (k[(p & 3) ^ e] ^ z)))) & 0xFFFFFFFF
v[p] = z
p += 1
y = v[0]
z = (v[n - 1] + ((((z >> 5) ^ (y << 2)) + ((y >> 3) ^ (z << 4)))
^ ((s ^ y) + (k[((n - 1) & 3) ^ e] ^ z)))) & 0xFFFFFFFF
v[n - 1] = z
q -= 1
return v
def xxtea_encrypt(data: bytes, key16: bytes) -> bytes:
"""虎牙专用 XXTEA: 输出 words = ceil(len/4)+1, 末尾 word 存原始长度。
data: 任意长度(实测16B); key16: 16字节。
返回: 20B (16B输入时)。
"""
n = len(data)
nwords = (n // 4) + 1
v = [0] * nwords
for i in range(n // 4):
v[i] = struct.unpack("<I", data[i * 4:i * 4 + 4])[0]
v[nwords - 1] = n # 原始长度
k = [struct.unpack("<I", key16[i * 4:i * 4 + 4])[0] for i in range(4)]
_xxtea_encrypt_words(v, k)
return b"".join(struct.pack("<I", w & 0xFFFFFFFF) for w in v)
# ---------------------------------------------------------------- nonce
def pack_u64(v: int) -> bytes:
"""cred_packlsEm: 8字节小端裸值。"""
return struct.pack("<Q", v & 0xFFFFFFFFFFFFFFFF)
def gen_nonce(uid: int, k1: str = K1_DEFAULT,
service_time_ms: int | None = None,
counter: int = 0) -> bytes:
"""按设备算法生成 20B nonce(rnd)。
uid: 虎牙uid(纯数字, 如1199666914671)
k1: 设备常量32hex
service_time_ms: 缺省=当前毫秒时间戳
counter: 同serviceTime内的递增计数(通常0即可)
"""
st = service_time_ms if service_time_ms is not None else int(time.time() * 1000)
st &= 0x7FFFFFFFFFFF
nonce_val = (counter & 0xFFFF) | (st << 16)
data = pack_u64(st) + pack_u64(nonce_val)
key = hashlib.md5((str(uid) + k1).encode()).hexdigest()[:16].encode()
return xxtea_encrypt(data, key)
# ---------------------------------------------------------------- 自检
if __name__ == "__main__":
import json
# 用设备实采数据验证 (成对遍历 xxtea_in/xxtea_out)
ev = json.load(open("/tmp/opencode/nonce_chain.json"))
ins = [e for e in ev if e.get("type") == "xxtea_in"]
outs = [e for e in ev if e.get("type") == "xxtea_out"]
for idx, e in enumerate(ins):
data = bytes.fromhex(e["data"]["v"])
key_str = bytes.fromhex(e["key"]["v"]).decode()
uid = key_str[:-32]
k1 = key_str[-32:]
st = struct.unpack("<Q", data[:8])[0]
nonce_val = struct.unpack("<Q", data[8:])[0]
counter = nonce_val & 0xFFFF
mine = gen_nonce(int(uid), k1, service_time_ms=st, counter=counter)
real = outs[idx]["out"]["v"]
print(f"uid={uid} st={st} counter={counter}")
print(f" 设备: {real}")
print(f" 复刻: {mine.hex()}")
print(" MATCH!" if mine.hex() == real else " MISMATCH!!")