feat(huya): wupData信封规则定论 - 原nonce必带/t3-cred一致/session不可动 + 交接文档更新
This commit is contained in:
@@ -0,0 +1,139 @@
|
||||
#!/usr/bin/env python3
|
||||
"""信封规则测定实验 —— 回答"wupData能否彻底脱离设备"。
|
||||
|
||||
E-B 老信封时效: 数小时前的模板信封(git_d29290a) + 可信账号新cred铸证
|
||||
-> 通过 => 信封无短TTL, 可长期复用
|
||||
E-C session篡改: 新鲜模板 + 随机session(iRequestId/meta JSON/v0) + 可信cred
|
||||
-> 通过 => session字段服务端不校验
|
||||
E-A 换账号(uid补丁): 模板uid改成新账号 + 新账号cred铸证
|
||||
-> 通过 => 零设备跨账号bind成立!
|
||||
|
||||
每次实验: 现登录取新鲜cred(新账号自动过safe_auth滑块) -> cert_forge离线铸证
|
||||
-> Envelope补丁 -> QrRole四步流bind。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import random
|
||||
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 login_cred, wup_password_login_raw, parse_cred # noqa: E402
|
||||
from cert_forge import build_p1, forge_cert # noqa: E402
|
||||
from envelope_forge import Envelope # noqa: E402
|
||||
from probe_huya_qr_bind import QrRole, web_behavior # noqa: E402
|
||||
from core.huya.device_fingerprint import get_huya_sdid # noqa: E402
|
||||
|
||||
TRUSTED = ("hy_300023887", "aa778899")
|
||||
NEWACCT = ("hy_300024708", "aa778899")
|
||||
|
||||
FINGERPRINT = b"02df398797432eadefcc12767119ad5e80999389"
|
||||
|
||||
|
||||
def fresh_rnd() -> bytes:
|
||||
t = int(time.time() * 1000) & ((1 << 48) - 1)
|
||||
return (t.to_bytes(6, "big") + b"\x00\x00"
|
||||
+ ((t << 16) & ((1 << 64) - 1)).to_bytes(8, "big")[2:] + bytes(8))[:20]
|
||||
|
||||
|
||||
def forge(cred: bytes) -> bytes:
|
||||
return forge_cert(build_p1(b"5008", FINGERPRINT, cred, rnd=fresh_rnd()),
|
||||
key_idx=0x20)
|
||||
|
||||
|
||||
def login_uid_cred(account: str, password: str) -> tuple[int, bytes]:
|
||||
"""登录并解析 (uid, cred)。uid在响应bean偏移235(u64be)。"""
|
||||
import struct
|
||||
resp = wup_password_login_raw(account, password)
|
||||
s = resp.find(b"\x0a\x0a", 0x40)
|
||||
e = resp.find(b"_wup_header")
|
||||
body = resp[s:e]
|
||||
uid = struct.unpack_from(">Q", body, 235)[0]
|
||||
assert 1_100_000_000_000 < uid < 1_300_000_000_000, f"uid异常 {uid}"
|
||||
cred = parse_cred(resp)
|
||||
if not cred:
|
||||
raise RuntimeError("未取到cred")
|
||||
return uid, cred
|
||||
|
||||
|
||||
def try_bind(wup_b64: str, name: str) -> dict | None:
|
||||
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失败 rc={resp.get('returnCode')}")
|
||||
return None
|
||||
from urllib.parse import quote
|
||||
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="")})
|
||||
d2 = r2.get("data") or {}
|
||||
print(f" [{name}] bindQr rc={r2.get('returnCode')} "
|
||||
f"msg={r2.get('message') or r2.get('description')}")
|
||||
if r2.get("returnCode") != 0 or d2.get("stage") != 2:
|
||||
return 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 and dt.get("biztoken"):
|
||||
return {"ok": True, "uid": dt.get("uid"),
|
||||
"biztoken_len": len(dt["biztoken"])}
|
||||
time.sleep(2)
|
||||
return {"stage2_no_token": True}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
results = {}
|
||||
|
||||
# ---- E-B: 老信封时效 ----
|
||||
print("\n===== E-B 老信封(数小时前git_d29290a) + 可信账号新cred =====")
|
||||
uid_t, cred_t = login_uid_cred(*TRUSTED)
|
||||
env = Envelope.load(ROOT / "work/envelope/keycap_d29290a.json")
|
||||
print(f" 模板uid={env.uid} (保持不变={uid_t}), 信封大小{len(env.raw)}")
|
||||
r = try_bind(env.patch_cert(forge(cred_t)).wup_b64(), "E-B")
|
||||
results["E-B_old_envelope"] = r
|
||||
|
||||
# ---- E-C: session篡改 ----
|
||||
print("\n===== E-C 新鲜模板 + 随机session + 可信cred =====")
|
||||
env = Envelope.load()
|
||||
sess = random.randint(1_000_000, 4_999_999)
|
||||
print(f" 注入随机session={sess}")
|
||||
r = try_bind(env.patch_session(sess).patch_cert(forge(cred_t)).wup_b64(), "E-C")
|
||||
results["E-C_random_session"] = r
|
||||
|
||||
# ---- E-A: uid补丁换账号 ----
|
||||
print("\n===== E-A 模板uid补丁成新账号 + 新账号cred =====")
|
||||
uid_n, cred_n = login_uid_cred(*NEWACCT)
|
||||
print(f" 新账号uid={uid_n}, cred {len(cred_n)}B {cred_n[:8].hex()}")
|
||||
env = Envelope.load()
|
||||
r = try_bind(env.patch_uid(uid_n).patch_cert(forge(cred_n)).wup_b64(), "E-A")
|
||||
results["E-A_cross_account"] = r
|
||||
|
||||
print("\n===== 结论 =====")
|
||||
for k, v in results.items():
|
||||
ok = bool(v and (v.get("ok") or v.get("stage2_no_token")))
|
||||
print(f" {k}: {'✅' if ok else '❌'} {v}")
|
||||
out = ROOT / "evidence/envelope_rules.json"
|
||||
out.write_text(__import__("json").dumps(results, ensure_ascii=False, indent=1))
|
||||
print("已写", out)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,210 @@
|
||||
#!/usr/bin/env python3
|
||||
"""信封规则测定 v2 —— 单次新鲜信封, 紧凑窗口内连续对照。
|
||||
|
||||
流程: 设备hook刷新信封(~60s) -> 立刻登录两账号取cred -> 窗口内连续4次bind:
|
||||
X0 对照: 原uid + 信封原nonce+可信cred铸证 (期望✅)
|
||||
X1 错配: 原uid(t3不动) + 新账号cred铸证 (测 t3与cert内cred的交叉校验)
|
||||
X2 换账号: t3补丁成新uid + 新账号cred铸证 (零设备关键判定)
|
||||
X3 改session: 三处session一致随机化 + 可信cred (测session是否校验)
|
||||
|
||||
铸造铁律(probe_forge_cert_bind已证): 服务端校验证书内nonce字段内容,
|
||||
纯随机nonce必40020 => 必须【复用信封原证书的rnd/指纹/key_idx】, 仅替换cred。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
import random
|
||||
import re
|
||||
import struct
|
||||
import subprocess
|
||||
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 wup_password_login_raw, parse_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 probe_huya_qr_bind import QrRole, web_behavior # noqa: E402
|
||||
from core.huya.device_fingerprint import get_huya_sdid # noqa: E402
|
||||
|
||||
TRUSTED = ("hy_300023887", "aa778899")
|
||||
NEWACCT = ("hy_300024708", "aa778899")
|
||||
KEYCAP = ROOT / "evidence/cert_keycap.json"
|
||||
|
||||
|
||||
def env_forge(env: Envelope, cred: bytes) -> bytes:
|
||||
"""复用信封原证书的 rnd/指纹/key_idx, 仅替换 cred 字段重铸。"""
|
||||
orig = base64.b64decode(env.cert_b64)
|
||||
f = parse_p1(decrypt_cert(orig))
|
||||
return forge_cert(build_p1(f["app_id"], f["fingerprint"], cred, rnd=f["rnd"]),
|
||||
key_idx=orig[1])
|
||||
|
||||
|
||||
def login_uid_cred(account: str, password: str) -> tuple[int, bytes]:
|
||||
resp = wup_password_login_raw(account, password)
|
||||
s = resp.find(b"\x0a\x0a", 0x40)
|
||||
e = resp.find(b"_wup_header")
|
||||
body = resp[s:e]
|
||||
uid = struct.unpack_from(">Q", body, 235)[0]
|
||||
cred = parse_cred(resp)
|
||||
assert cred, "无cred"
|
||||
return uid, cred
|
||||
|
||||
|
||||
def refresh_envelope_from_device(max_age: float = 600.0) -> None:
|
||||
"""信封够新鲜(<max_age秒, 实测TTL约15~20分钟)直接用; 否则后台hook重抓。"""
|
||||
age = time.time() - KEYCAP.stat().st_mtime
|
||||
if age < max_age:
|
||||
print(f"[hook] 信封仅{age:.0f}s新, 跳过设备采集")
|
||||
return
|
||||
mtime0 = KEYCAP.stat().st_mtime
|
||||
py = Path.home() / "codes/Reverse-Engineering-Agent-Universal-v3.0/.venv/bin/python"
|
||||
proc = subprocess.Popen(
|
||||
[str(py), str(ROOT / "scripts/hook_cert_keycap.py")],
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
while time.time() - mtime0 < 120 and KEYCAP.stat().st_mtime == mtime0:
|
||||
time.sleep(1)
|
||||
if KEYCAP.stat().st_mtime == mtime0:
|
||||
proc.kill()
|
||||
raise RuntimeError("120s 未等到信封更新")
|
||||
time.sleep(1) # 等文件写完整
|
||||
print(f"[hook] 信封已刷新 (+{time.time()-mtime0:.0f}s)")
|
||||
|
||||
|
||||
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 None
|
||||
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")}
|
||||
time.sleep(1.5)
|
||||
return None
|
||||
|
||||
|
||||
def patch_session_consistent(env: Envelope, session: int) -> Envelope:
|
||||
"""三处session等宽一致改写, 并回读校验。"""
|
||||
raw = bytearray(env.raw)
|
||||
# iRequestId @[8:12]
|
||||
struct.pack_into(">I", raw, 8, session & 0xFFFFFFFF)
|
||||
# meta JSON 内等宽替换
|
||||
s, e = env.meta_json_span
|
||||
js = bytes(raw[s:e]).decode()
|
||||
old = re.search(r'"session":(\d+)', js).group(1)
|
||||
new = str(session)[-len(old):] if len(str(session)) >= len(old) else \
|
||||
str(session).rjust(len(old), "0")
|
||||
js2 = js.replace(f'"session":{old}', f'"session":{new}', 1)
|
||||
assert len(js2.encode()) == e - s
|
||||
raw[s:e] = js2.encode()
|
||||
# v0 blob: 定位key串后的完整值段, 解析其头后整体替换为同构5B
|
||||
i = bytes(raw).find(b"wupudbrequest_v0")
|
||||
klen_off = i - 1 # STRING1长度字节
|
||||
vhead = klen_off + 1 + raw[klen_off] # value字段头(0x1d)
|
||||
assert raw[vhead] & 0x0F == 0x0D
|
||||
p = vhead + 1 # 元素类型头
|
||||
dt_len = raw[p] & 0x0F
|
||||
if dt_len == 0x00:
|
||||
vn = raw[p + 1]; val = p + 2
|
||||
elif dt_len == 0x01:
|
||||
vn = struct.unpack_from(">h", raw, p + 1)[0]; val = p + 3
|
||||
elif dt_len == 0x03:
|
||||
vn = struct.unpack_from(">i", raw, p + 1)[0]; val = p + 5
|
||||
else:
|
||||
raise ValueError(f"v0长度编码{dt_len:#x}")
|
||||
# 保持首字节(head)不动, 只换其后4字节数值
|
||||
struct.pack_into(">I", raw, val + 1, session & 0xFFFFFFFF)
|
||||
env.raw = raw
|
||||
return env
|
||||
|
||||
|
||||
def main() -> int:
|
||||
results = {}
|
||||
|
||||
# 并行: 登录(不依赖信封) 与 设备hook同时跑
|
||||
import threading
|
||||
cred_box: dict = {}
|
||||
|
||||
def _login_all():
|
||||
uid_t, cred_t = login_uid_cred(*TRUSTED)
|
||||
uid_n, cred_n = login_uid_cred(*NEWACCT)
|
||||
cred_box.update(uid_t=uid_t, cred_t=cred_t, uid_n=uid_n, cred_n=cred_n)
|
||||
|
||||
th = threading.Thread(target=_login_all, daemon=True)
|
||||
t0 = time.time()
|
||||
th.start()
|
||||
refresh_envelope_from_device()
|
||||
th.join(timeout=30)
|
||||
t_refresh = KEYCAP.stat().st_mtime # 以信封采集时刻为窗口原点
|
||||
print(f"[login] 可信uid={cred_box['uid_t']} 新账号uid={cred_box['uid_n']} "
|
||||
f"(准备耗时{time.time()-t0:.0f}s) —— 开始紧凑窗口")
|
||||
uid_t, cred_t = cred_box["uid_t"], cred_box["cred_t"]
|
||||
uid_n, cred_n = cred_box["uid_n"], cred_box["cred_n"]
|
||||
|
||||
def elapsed() -> float:
|
||||
return f"+{time.time()-t_refresh:.0f}s"
|
||||
|
||||
# X0 对照
|
||||
env = Envelope.load()
|
||||
assert env.uid == uid_t
|
||||
r = try_bind(env.patch_cert(env_forge(env, cred_t)).wup_b64(),
|
||||
f"X0对照 {elapsed()}")
|
||||
results["X0_control"] = r
|
||||
|
||||
# X1 uid错配
|
||||
env = Envelope.load()
|
||||
r = try_bind(env.patch_cert(env_forge(env, cred_n)).wup_b64(),
|
||||
f"X1错配 {elapsed()}")
|
||||
results["X1_uid_mismatch"] = r
|
||||
|
||||
# X2 换账号
|
||||
env = Envelope.load()
|
||||
r = try_bind(env.patch_uid(uid_n).patch_cert(env_forge(env, cred_n)).wup_b64(),
|
||||
f"X2换号 {elapsed()}")
|
||||
results["X2_cross_account"] = r
|
||||
|
||||
# X3 session一致随机
|
||||
env = Envelope.load()
|
||||
sess = 3_900_000 + random.randint(1, 99_999)
|
||||
r = try_bind(patch_session_consistent(env, sess).patch_cert(
|
||||
env_forge(env, cred_t)).wup_b64(), f"X3改session {elapsed()}")
|
||||
results["X3_random_session"] = r
|
||||
|
||||
print("\n===== 结论 =====")
|
||||
for k, v in results.items():
|
||||
print(f" {k}: {'✅ 通过' if v else '❌ 拒绝'} {v or ''}")
|
||||
(ROOT / "evidence/envelope_rules2.json").write_text(
|
||||
json.dumps(results, ensure_ascii=False, indent=1))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user