feat(huya): hyCred签发链侦察 - saveLoginData落盘捕获+长连接消息族定位
- 证实cred长寿命(晨间cred午后仍有效), 每账号一次采集即可 - 密码登录响应不含cred; 签发走cdn.wup长连接(MsgRequestLgnCred族) - getCred纯本地读; 落盘链: saveLoginData->JSON->AES->writeFileEx - 三路线评估: A混合采集(可用)/B wsapi复刻/C长连接复刻, 下次从笔记抓手继续
This commit is contained in:
@@ -0,0 +1,49 @@
|
|||||||
|
# hyCred 签发协议 — 侦察笔记(2026-08-25 傍晚)
|
||||||
|
|
||||||
|
> 目标:任意账号取得有效 hyCred(114B),配合 tools/cert_forge 实现全自动多账号。
|
||||||
|
> 当前状态:**铸证已闭环**(见《虎牙证书算法闭环-差分实证.md》),本文记录签发链侦察结果。
|
||||||
|
|
||||||
|
## 已确认事实
|
||||||
|
|
||||||
|
1. **cred 是长寿命凭证**:早上抓的 hyCred 在下午(且中间用户重新登录过一次后)
|
||||||
|
依然能通过 bind 铸证(实验C)。=> 每账号只需采集一次,非会话级短时效。
|
||||||
|
2. **cred 每次登录轮换值但旧的仍有效**:今晨 `0a80a762...` ≠ 午后 `0a50ec12...`,两者均可用。
|
||||||
|
3. **密码登录响应不含 cred**:`evidence/wup_response_login.bin`(1514B) 无 114B/152b64 结构。
|
||||||
|
4. **cred 存于本地登录态文件**:`BusinessCfg::saveLoginData` (@0x265fb0 / 0x265a6c)
|
||||||
|
-> JSON(含"cred"字段) -> UdbAESUtil AES 加密 -> `UdbFileUtil::writeFileEx` 写盘。
|
||||||
|
`BusinessCfg::getCred(uid)` @0x265524 纯本地读(UdbLock+日志),无网络。
|
||||||
|
5. **签发走 cdn.wup.huya.com 长连接隧道**:HAR 里只有 CONNECT,载荷不可见;
|
||||||
|
相关消息族符号齐全:
|
||||||
|
- `MsgRequestLgnCred` / `MsgResponseLgnCred`(请求/响应)
|
||||||
|
- `HandlerRequestLoginCred`(@0x3a1820) / `HandlerResponseLoginCred`(@0x3a182c)
|
||||||
|
- 已知方法: `handlerCredLoginOverTimeMsg` @0x3a00d0
|
||||||
|
- `UdbObjCreator_Msg{Request,Response}LgnCred`、`AppLgnCredentialLoginReq/Resp`(wup JCE)
|
||||||
|
- `MsgGetCred`/`HandlerGetCred`、Anony 变体、`MsgResponseUpdateCred`
|
||||||
|
6. **dckey/check ≠ hyCred**:udbdf.huya.com/dckey/check 返回设备钥 dckey
|
||||||
|
(固定前缀 AAAAAMC1eP4iV43WYoI57ZOu0),与账号 cred 不同物。
|
||||||
|
|
||||||
|
## 本次抓取(evidence/cred_issue_trace.json)
|
||||||
|
|
||||||
|
用户重登时捕获 saveLoginData bean 内存头:
|
||||||
|
`..."hy_300023887"...len前缀"1199666914671"..."4C5fozan"...`
|
||||||
|
(bean 为 BusBeansLoginData 结构体,cred 字段在更深偏移,待下次 dump 全量)
|
||||||
|
|
||||||
|
vtable 探测结论:slot2/3 是析构/通用槽(打中 MsgLoop::doMSG/sendMessage),
|
||||||
|
真正 onHandler 未命中 —— 下次应挂 `ResponseMsgHandler` 派生 onHandler 或
|
||||||
|
直接挂 `hyudb_packet_util::cred_pack/cred_unpack`(@0x333170) 收发两端。
|
||||||
|
|
||||||
|
## 三条候选路线(按性价比)
|
||||||
|
|
||||||
|
| 路线 | 做法 | 成本 | 备注 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| A 混合采集(立即可用) | 手机登目标号一次,Frida 自动抓 cred 入库;此后该号永久全自动铸证 | 低 | 已验证可行,脚本齐备 |
|
||||||
|
| B wsapi 短连接复刻 | 若 MsgRequestLgnCred 可经 huyaudbwebui servant 走 wsapi.huya.com HTTPS | 中 | 需 msgId+JCE 结构+OTP加密链(已有) 组包试验 |
|
||||||
|
| C 长连接完整复刻 | 实现 cdn.wup.huya.com WSP 握手/心跳/加密隧道 | 高 | 最后手段 |
|
||||||
|
|
||||||
|
## 下次继续的抓手
|
||||||
|
|
||||||
|
1. 重跑 hook_cred_issue.py 增强版:saveLD_bean dump 640B 定位 cred 字段偏移 +
|
||||||
|
backtrace 10 帧(找推送来源 handler)+ 挂 `cred_unpack`@0x333170 抓网络侧明文。
|
||||||
|
2. 静态:`MsgRequestLgnCred::getClassName`@0x2a37d0 / 各消息 getClassName 取类名串,
|
||||||
|
在 rodata 找 类名→msgId 表(参考 MsgGetH5InfoEx=184549387 的命名规律)。
|
||||||
|
3. 若走 B:用 tools/huya_wup_encoder.py 的信封 + OTP 加密链组 RequestLgnCred 发 wsapi 试探。
|
||||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,137 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""抓 hyCred 签发链: 挂 saveLoginData(落盘) + LoginCred handler(vtable探测)。
|
||||||
|
|
||||||
|
用法: 脚本跑起来后, 在手机上 手动退出登录 -> 重新登录 一次。
|
||||||
|
捕获: 登录态JSON明文(含cred字段) / saveLoginData 入参 / LoginCred handler 触发。
|
||||||
|
"""
|
||||||
|
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:{t:d.type,a:String(d.address)}}); return true; });
|
||||||
|
var base = Process.getModuleByName('libudbauthunify.so').base;
|
||||||
|
send({type:'armed'});
|
||||||
|
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 rdStr(p){
|
||||||
|
try{
|
||||||
|
var b0=p.readU8();
|
||||||
|
if((b0&1)===0){ var l=b0>>1; if(l>262144) return {t:'s?',len:l,v:''};
|
||||||
|
return {t:'s',len:l,v:l?hx(p.add(1),Math.min(l,4096)):''}; }
|
||||||
|
var len=parseInt(p.add(8).readU64().toString());
|
||||||
|
if(len>1048576) return {t:'l?',len:len,v:''};
|
||||||
|
return {t:'l',len:len,v:hx(p.add(16).readPointer(),Math.min(len,4096))};
|
||||||
|
}catch(e){ return {t:'err',len:-1,v:String(e)}; }
|
||||||
|
}
|
||||||
|
function ra(ctx){ try{ return DebugSymbol.fromAddress(ctx.returnAddress).toString().slice(0,70);}catch(e){return '?';} }
|
||||||
|
|
||||||
|
// 登录态JSON的AES加密(含cred字段) —— 已知走 UdbAESUtil::encrypt
|
||||||
|
Interceptor.attach(base.add(0x250038), {
|
||||||
|
onEnter: function(a){ this.x1=rdStr(a[1]); this.x2=rdStr(a[2]); },
|
||||||
|
onLeave: function(){
|
||||||
|
if(this.x1.len>800||this.x2.len>800)
|
||||||
|
send({type:'bigaes', x1:{len:this.x1.len}, x2:this.x2, ra:ra(this)});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// saveLoginData(string const&, BusBeansLoginData&, bool) 与 (BusBeansLoginData&, bool)
|
||||||
|
Interceptor.attach(base.add(0x265fb0), {
|
||||||
|
onEnter: function(a){
|
||||||
|
send({type:'saveLD_str', str:rdStr(a[1]), bean_head:hx(a[2],128), ra:ra(this)});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
Interceptor.attach(base.add(0x265a6c), {
|
||||||
|
onEnter: function(a){
|
||||||
|
send({type:'saveLD_bean', bean_head:hx(a[1],160), b:rdStr(a[1]), flag:a[2].toInt32(), ra:ra(this)});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// HandlerRequestLoginCred / HandlerResponseLoginCred vtable 探测:
|
||||||
|
// vtable+0x10 起为虚函数槽, 逐个attach前3个槽, 打印触发与backtrace首帧
|
||||||
|
function probeVtable(name, vtAddr){
|
||||||
|
for(var slot=0; slot<4; slot++){
|
||||||
|
(function(slot){
|
||||||
|
try{
|
||||||
|
var fp = vtAddr.add(0x10 + slot*8).readPointer();
|
||||||
|
if(fp.compare(base) < 0 || fp.compare(base.add(0x480000)) > 0) return;
|
||||||
|
Interceptor.attach(fp, {
|
||||||
|
onEnter: function(a){
|
||||||
|
send({type:'vtable', name:name, slot:slot,
|
||||||
|
a0:a[0].toString(), a1:rdStr(a[1]), a2:rdStr(a[2]),
|
||||||
|
bt:Thread.backtrace(this.context, Backtracer.FUZZY).slice(0,3)
|
||||||
|
.map(function(x){return DebugSymbol.fromAddress(x).toString().slice(0,60);})});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
send({type:'vt_attached', name:name, slot:slot, fp:String(fp.sub(base))});
|
||||||
|
}catch(e){}
|
||||||
|
})(slot);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
probeVtable('ReqLoginCred', base.add(0x477f80));
|
||||||
|
probeVtable('RespLoginCred', base.add(0x478018));
|
||||||
|
probeVtable('GetCred', base.add(0x477000)); // 占位: 若无效仅跳过
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
d = frida.get_device_manager().add_remote_device('127.0.0.1:31877')
|
||||||
|
pid = d.spawn(['com.duowan.kiwi'])
|
||||||
|
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)
|
||||||
|
time.sleep(11)
|
||||||
|
s.create_script((RE / "evidence/scripts/patch_guard_block_termination.js").read_text()).load()
|
||||||
|
print('armed. >>> 请在手机上退出登录并重新登录 <<<')
|
||||||
|
|
||||||
|
sc = s.create_script(JS)
|
||||||
|
events = []
|
||||||
|
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 == 'vt_attached':
|
||||||
|
print(f"[vt] {p['name']} slot{p['slot']} @+{p['fp']}")
|
||||||
|
elif t == 'saveLD_str':
|
||||||
|
print(f"[saveLD_str] str={p['str']['t']}:{p['str']['len']}:{p['str']['v'][:80]}")
|
||||||
|
elif t == 'saveLD_bean':
|
||||||
|
print(f"[saveLD_bean] flag={p['flag']} bean={p['bean_head'][:96]}")
|
||||||
|
print(f" asStr={p['b']}")
|
||||||
|
elif t == 'bigaes':
|
||||||
|
print(f"[bigaes] len={p['x2']['len']} head={p['x2']['v'][:120]}")
|
||||||
|
elif t == 'vtable':
|
||||||
|
print(f"[VT!] {p['name']}#{p['slot']} a1={p['a1']} bt={p['bt']}")
|
||||||
|
sc.on('message', on_msg)
|
||||||
|
sc.load()
|
||||||
|
deadline = time.time() + 300
|
||||||
|
while time.time() < deadline:
|
||||||
|
time.sleep(3)
|
||||||
|
if any(e.get('type') == 'saveLD_bean' for e in events):
|
||||||
|
time.sleep(10)
|
||||||
|
break
|
||||||
|
Path('/Users/yml/codes/douyu_login_py/evidence/cred_issue_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):
|
||||||
|
try:
|
||||||
|
main()
|
||||||
|
break
|
||||||
|
except frida.InvalidOperationError as e:
|
||||||
|
print('detached:', e)
|
||||||
|
subprocess.run(['adb', 'shell', 'am', 'force-stop', 'com.duowan.kiwi'])
|
||||||
|
time.sleep(3)
|
||||||
Reference in New Issue
Block a user