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逆向完成, safe_auth闭环(app_login_flow.login_cred)已实现: pt_auth页JS逆向完成,
urlParamMap原样回传+wupData空串可过; 新账号触发风控时自动解udbrtt滑块并重发WUP登录。 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 → SHA1
→ build_password_login_wup() 组TAF包 (servant=huyaudbwebui, func=hypasswordLogin) → build_password_login_wup() 组TAF包 (servant=huyaudbwebui, func=hypasswordLogin)
→ POST https://wup.huya.com [body=原始TAF字节, CT=application/multipart-formdata] → POST https://wup.huya.com [body=原始TAF字节, CT=application/multipart-formdata]
→ HTTP 200 → 解析响应bean → 新鲜cred(114B, 0a开头, 每次登录轮换) → 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 → 扫码绑定流 getQrId→scanQrPicNotify→bindQrLoginUser→tryQrLogin
→ POST /web/cookie/verify {"appId":5002} ← 关键最后一步! → POST /web/cookie/verify {"appId":5002} ← 关键最后一步!
→ 全套网页Cookie: udb_cred/yyuid/udb_uid/udb_passport/username/udb_biztoken... → 全套网页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_safe.py <账号> <密码>` | 同上, 且新账号safe_auth滑块自动过 |
| `.venv/bin/python tools/full_auto_test.py <账号> <密码>` | 密码→cred→铸证→bind 全链路自测 | | `.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, 1. **nonce生成机制(v3定论, 2026-08-26动态hook实锤)**:
仅替换cred字段——纯随机nonce必40020; ②t3.uid须与cert内cred账号一致; `rnd = XXTEA(pack(st) + pack(counter|st<<16), key=MD5hex(uid_str + k1)[:16])`
③session三处不可动(SYS_ERROR); ④同账号信封长期可复用; - st = 毫秒时间戳; counter = 同st内递增(从0)
⑤跨账号=每号设备首登一次(hook_cert_keycap自动抓该号信封)后永久纯Python - k1 = 设备常量(865a4924...), 不随账号变(evidence/nonce_k1.json)
补丁工具 tools/envelope_forge.py (patch_uid/patch_cert/patch_session)。 - XXTEA输出=ceil(len/4)+1 words, 末尾存长度 → 16B输入=20B rnd
2. **cred每次登录轮换**,第二字节是代次计数器(0x10/0x20/.../0x80循环);旧cred短期仍有效。 - 服务端用 t3.uid 重新派生 key 解密 nonce → 换账号必须重算nonce!
3. **响应bean解析**_wup_data值=SIMPLE_LIST(0x1d)+elem头(00)+JCE优化int长度; 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/ 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。 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] 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注册) cert=b64([0x0C][key_idx][AES128(key16,zeropad(P1))]);钥表@rodata 0x1bc810。
6. **存储侧**: cred0文件=AES-128-ECB("HuyaUdb192837465")加密的JSON 6. **真实uid≠账号名数字**: hy_300023887 的真实uid=1199666914671(响应bean偏移235 u64be);
cred路径 /loginHistory[0]/apploginData/cred。 铸证/算nonce必须用真实uid!
## 三、新账号风控(safe_auth)——已实现 ✅(2026-08-25夜) ## 三、新账号风控(safe_auth)——已实现 ✅
### 定性与实现
- App渠道(WUP)对新/异地账号返回 `aq.huya.com/p/safe_auth/pt_auth.html?param=xxx` - App渠道(WUP)对新/异地账号返回 `aq.huya.com/p/safe_auth/pt_auth.html?param=xxx`
**udbrtt.huya.com 滑块** → tools/app_login_flow.login_cred() 自动过验并重发WUP **udbrtt.huya.com 滑块** → tools/app_login_flow.login_cred() 自动过验并重发WUP
- pt_auth页JS逆向结论: urlParamMap=整个query原样回传; wupData纯web上下文空串可过; - pt_auth页JS逆向结论: urlParamMap=整个query原样回传; wupData纯web上下文空串可过;
@@ -50,26 +54,21 @@
成功后服务端标记param风控会话通过, 直接重发同参WUP登录即放行。 成功后服务端标记param风控会话通过, 直接重发同参WUP登录即放行。
- qr_auth(扫码型)明确不可自动化 → QrAuthRequiredError 提示先真机首登建信任。 - qr_auth(扫码型)明确不可自动化 → QrAuthRequiredError 提示先真机首登建信任。
### 残留优化项(非阻塞) ## 四、多账号运营模型(v3: 零设备!)
1. 数小时前老信封复用边界未单独验证(E-B)——不影响主流程, 失败则hook重抓即可
2. 完全零设备跨账号需逆向nonce签发机制, 收益低(运营模型本就要求每号设备首登一次)
## 四、多账号运营模型
``` ```
新账号首次: 手机App正常登录一次(建设备信任, 若遇滑块人工滑一下) 任意新账号: 密码 → full_web_cookie.py → 全套cookie 自动出 (无需设备首登!)
+ 设备上跑 scripts/hook_cert_keycap.py 抓该号信封(cert_keycap.json) 唯一前提: 持有 k1 设备常量(evidence/nonce_k1.json 已固化; 若换设备需重新采集一次)
之后永久: 密码 → full_auto_safe.py → 全套cookie 自动出
``` ```
## 五、资产清单 ## 五、资产清单
- 组包: tools/huya_wup_encoder.py (build_password_login_wup, 自检对金样本) - 组包: tools/huya_wup_encoder.py (build_password_login_wup, 自检对金样本)
- 登录流: tools/app_login_flow.py (login_cred/solve_safe_auth, safe_auth滑块闭环) - 登录流: 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/cert_forge.py (build_p1/forge_cert/parse_p1)
- 信封: tools/envelope_forge.py (Envelope原位补丁uid/cert/session) - 信封: tools/envelope_forge.py (Envelope原位补丁uid/cert/session)
- 绑定: scripts/probe_forge_cert_bind.py (QrRole四步流+cert替换) - 绑定: scripts/probe_nonce_bind.py (本地nonce bind验证, 同/跨账号)
- 实验: scripts/probe_envelope_rules2.py (信封规则X0-X3紧凑对照) - 一键: tools/full_web_cookie.py (任意账号纯协议出全套cookie)
- 网页登录: core/huya/login.py (含滑块solver集成, web渠道)
- 滑块: core/huya/verification/{solver,track}.py + core/geetest/v3_slide - 滑块: core/huya/verification/{solver,track}.py + core/geetest/v3_slide
- 设备指纹: core/huya/device_fingerprint.py (get_huya_sdid) - 设备指纹: 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追加) - 协议细节全记录: 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 #!/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 滑块并重发登录, 全程零人工。 新/异地账号触发 safe_auth 时自动解 udbrtt 滑块并重发登录, 全程零人工。
用法: 用法:
.venv/bin/python tools/full_auto_safe.py <账号如hy_xxx或300023887> <密码> .venv/bin/python tools/full_auto_safe.py <账号如hy_xxx或300023887> <密码>
前提: evidence/cert_keycap.json 内有较新鲜的 wupData 信封 前提: evidence/cert_keycap.json 内有信封模板(任意账号共用, 仅需采集一次)
(过期则先在设备上跑 scripts/hook_cert_keycap.py 刷新) + evidence/nonce_k1.json 或默认 k1 设备常量
""" """
import base64 import base64
import json import json
import re import struct
import subprocess
import sys import sys
import time
from pathlib import Path from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent 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 app_login_flow import QrAuthRequiredError, login_cred # noqa: E402
from cert_forge import build_p1, decrypt_cert, forge_cert, parse_p1 # noqa: E402 from cert_forge import build_p1, decrypt_cert, forge_cert, parse_p1 # noqa: E402
from envelope_forge import Envelope # noqa: E402 from envelope_forge import Envelope # noqa: E402
from nonce_forge import K1_DEFAULT, gen_nonce # noqa: E402
def forge(cred: bytes) -> str: def real_uid(acct: str, pwd: str) -> int:
"""复用信封原证书的nonce/指纹/key_idx仅换cred(纯随机nonce必40020).""" """登录响应解析真实uid(响应bean偏移235 u64be)。"""
env = Envelope.load() 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) orig = base64.b64decode(env.cert_b64)
f = parse_p1(decrypt_cert(orig)) f = parse_p1(decrypt_cert(orig))
P1 = build_p1(f["app_id"], f["fingerprint"], cred, rnd=f["rnd"]) st = int(time.time() * 1000)
return forge_cert(P1, key_idx=orig[1]).hex() 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)
def bind_for_cookie(cred_json_path: Path) -> bool: return forge_cert(P1, key_idx=orig[1])
"""复用扫码绑定四步流(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
def main() -> int: def main() -> int:
acct, pwd = sys.argv[1], sys.argv[2] acct, pwd = sys.argv[1], sys.argv[2]
print("[1/3] 纯Python密码登录 (遇safe_auth自动过滑块) ...") print("[1/4] 纯Python密码登录 (遇safe_auth自动过滑块) ...")
try: try:
cred = login_cred(acct, pwd) cred = login_cred(acct, pwd)
except QrAuthRequiredError as exc: except QrAuthRequiredError as exc:
@@ -58,19 +59,70 @@ def main() -> int:
return 2 return 2
print(f" cred OK: {len(cred)}B {cred[:8].hex()}") print(f" cred OK: {len(cred)}B {cred[:8].hex()}")
print("[2/3] 铸造证书 ...") uid = real_uid(acct, pwd)
cert_hex = forge(cred) print(f"[2/4] 解析真实uid={uid}, 本地重算nonce 铸证 ...")
out = ROOT / "evidence/auto_test_cred.json" env = Envelope.load()
out.write_text(json.dumps({"p1": "", "cert_hex": cert_hex, cert = forge_for_uid(env, cred, uid, K1_DEFAULT)
"cred_hex": cred.hex()})) b64 = base64.b64encode(cert).decode()
print(f" 证书 OK: {len(cert_hex)//2}B") 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 ...") print("[3/4] 扫码绑定四步流 ...")
if bind_for_cookie(out): from urllib.parse import quote
print("\n🎉 全链路成功: 密码已换出页面cookie(biztoken)!") sys.path.insert(0, str(ROOT / "scripts"))
return 0 from probe_huya_qr_bind import QrRole, web_behavior
print("\n❌ 绑定失败——若40020多为信封过期, 请刷新wupData后重试") from core.huya.device_fingerprint import get_huya_sdid
return 1 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__": if __name__ == "__main__":
+93 -60
View File
@@ -1,73 +1,106 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""一键: 账号密码 -> 完整网页Cookie全套 """一键: 账号密码 -> 完整网页Cookie全套 (纯协议, nonce本地重算).
用法: python tools/full_web_cookie.py <账号> <密码>
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 产出: 打印全部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] ACCT, PWD = sys.argv[1], sys.argv[2]
sys.argv = [sys.argv[0]] sys.argv = [sys.argv[0]]
import sys,json,time,base64
from urllib.parse import quote from urllib.parse import quote
sys.path.insert(0,'scripts'); sys.path.insert(0,'tools') sys.path.insert(0, 'scripts'); sys.path.insert(0, 'tools')
from probe_forge_cert_bind import load_capture, parse_p1, build_p1, forge_cert 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 probe_huya_qr_bind import QrRole, web_behavior
from core.huya.device_fingerprint import get_huya_sdid from core.huya.device_fingerprint import get_huya_sdid
qurl,P1_old=load_capture() from app_login_flow import QrAuthRequiredError, login_cred
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)
# 追加: cookie verify 兑换
sess = getattr(pc,'session',None) or getattr(pc,'s',None) or getattr(pc,'_sess',None) def real_uid(acct: str) -> int:
r=sess.post("https://udblgn.huya.com/web/cookie/verify",json={"appId":5002},timeout=15) """登录响应解析真实uid(与信封t3一致的12位uid)。"""
print('verify HTTP',r.status_code) import struct
for h in r.headers.get('Set-Cookie','').split('\n') or [r.headers.get('Set-Cookie','')]: from app_login_flow import wup_password_login_raw
if h: print('SET:',h[:110]) resp = wup_password_login_raw(acct, PWD)
print('cookie jar:') s = resp.find(b"\x0a\x0a", 0x40)
jar=[] 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: for c in sess.cookies:
print(' ',c.name,'=',c.value[:50]) print(' ', c.name, '=', c.value[:50])
jar.append(f'{c.name}={c.value}') 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') out.write_text(f'# account: {ACCT}\n' + '\n'.join(jar) + '\n')
print('已写', out) 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!!")