- docs/HUYA_APP_OVERVIEW.md: 整体流程/三形态hdid区分/登录入口盘点/随机化矩阵/风险清单/真机Frida现状 - core/huya 各模块 docstring 加风险标记与 hdid 名称混淆提示, 指向总览 - tools/app_login_flow 标注已过时(未接注册链), tools/huya_device_profile 标注与 core 策略关系 - scripts/phone_stable_capture.py + diag_phone_lifecycle.py: 真机 spawn+art_callsite 稳定抓帧/四组诊断 - evidence/diag_phone/: 真机基线/attach/稳定抓帧实验证据 (90s 存活无 EGL 崩)
236 lines
9.7 KiB
Python
236 lines
9.7 KiB
Python
#!/usr/bin/env python3
|
|
"""虎牙App渠道纯Python登录流 —— 含新账号 safe_auth 滑块自动过验。
|
|
|
|
⚠ 已过时:本工具仍读取画像里的固定 safedeviceid 并在缺失时回退金样本,**未接
|
|
``core/huya/dfp_register`` 新设备注册链**。生产请用 ``core/huya/app_login.py``
|
|
(每次登录前注册链动态签发 safedeviceid/device_id)。本文件仅作历史参考。
|
|
|
|
链路(全部实测/逆向自 pt_auth 页JS, 见 work/safe_auth/pt_auth.pretty.js):
|
|
账号密码 -> WUP hypasswordLogin (POST wup.huya.com)
|
|
|- 正常: 响应bean直出新鲜cred(114B)
|
|
'- 风控(safe_auth): 响应内嵌 https://aq.huya.com/p/safe_auth/pt_auth.html?param=...
|
|
-> udbrtt config3/gurl/vurl 滑块闭环(与 web 渠道 solver 同源)
|
|
-> 重发 WUP 登录 -> cred 直出
|
|
|
|
pt_auth 页 JS 逆向结论(2026-08-25):
|
|
1. oUrl = parseQueryString(location.search) 即整个query串 =>
|
|
config3/gurl/vurl 的 data.urlParamMap = {param, sceneType, appId, mobile,
|
|
allowRefresh, hideShareButton, ...} 原样回传。
|
|
2. wupData: App WebView 里来自原生桥(HYUDBMSDKQUrlCommon/HYUDBMSDKCommon),
|
|
纯web上下文为 "" 也能走通(config3接受空)。
|
|
3. POST https://udbrtt.huya.com/auth/client/config3
|
|
{appId, data:{urlParamMap, wupData:"", page:<pt_auth完整URL>,
|
|
behavior:<URI编码JSON事件数组>, info:"csid_"+32位hex, cTuIndex:0}}
|
|
=> data{gurl, vurl, js(UdbCipher), key}
|
|
4. gurl取图 {.., ver:5} => {code, dtuStr, puzzleStr, segment, location_y};
|
|
segment = UdbCipher.decrypt(segment, key) 的 b64 JSON .ord 为乱序还原表。
|
|
5. vurl提交 act=UdbCipher.encrypt(btoa(encodeURI(JSON{
|
|
point:(slideBlockLeft/imgareaWidth)*390, travel:"x,y,t;...",
|
|
code, sceneType, endTime})), key), ver:5 => data.authId。
|
|
6. 成功后页面把 authId 回传给端上(JSBridge quit / HYUDBVerifySDKCallback);
|
|
纯Python侧直接重发同参WUP登录即可放行(param风控会话已被服务端标记通过)。
|
|
|
|
对外入口:
|
|
login_cred(account, password) 账号密码 -> 新鲜cred(自动过safe_auth滑块)
|
|
solve_safe_auth(risk_url) 单独解一个 pt_auth/qr_auth 链接
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import json
|
|
import re
|
|
import sys
|
|
import time
|
|
from pathlib import Path
|
|
from urllib.parse import parse_qs, urlparse
|
|
|
|
ROOT = Path(__file__).resolve().parent.parent
|
|
if str(ROOT) not in sys.path:
|
|
sys.path.insert(0, str(ROOT))
|
|
sys.path.insert(0, str(ROOT / "tools"))
|
|
|
|
import requests
|
|
|
|
from huya_wup_encoder import build_password_login_wup
|
|
|
|
# 与 core/huya/login.py 的 APP_UA_MOBILE 一致(App WebView UA, 设备画像自洽)
|
|
APP_UA_MOBILE = (
|
|
"Mozilla/5.0 (Linux; Android 11; M2102J2SC Build/RKQ1.200826.002; wv) "
|
|
"AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 "
|
|
"Chrome/149.0.7827.159 Mobile Safari/537.36 huya adr/13.4.22/xiaomi/30"
|
|
)
|
|
|
|
WUP_URL = "https://wup.huya.com"
|
|
|
|
# TAF响应里内嵌的风控页URL(pt_auth=滑块可自动化; qr_auth=扫码需人工一次)
|
|
RISK_URL_RE = re.compile(rb"https://aq\.huya\.com/p/safe_auth/[^\x00-\x20\"'\\<>]+")
|
|
_URL_TAIL_KEEP = set(
|
|
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~:/?#[]@!$&'()*+,;=%"
|
|
)
|
|
|
|
|
|
class AppLoginError(RuntimeError):
|
|
"""App渠道纯Python登录失败。"""
|
|
|
|
|
|
class QrAuthRequiredError(AppLoginError):
|
|
"""风控要求扫码(qr_auth), 需该账号在真机App正常登录一次建立信任。"""
|
|
|
|
|
|
def _golden_assets() -> tuple[dict, str, str]:
|
|
"""从金样本提取 session/traceId、ua JSON 与 sd。"""
|
|
raw = (ROOT / "evidence/wup_passwordlogin_taf.bin").read_bytes()
|
|
mj = json.loads(re.search(rb"(\{\"associationId.*?\})", raw).group(1))
|
|
ua = re.search(rb"(\{\"curl\".*?\"user_action\":\[.*?\]\})", raw).group().decode()
|
|
sd = re.search(rb"PQwemAN9[A-Za-z0-9+/=]+", raw).group().decode()
|
|
return mj, ua, sd
|
|
|
|
|
|
_DEV = {
|
|
"app_version": "13.4.22", "sdk_version": "1.0.80138",
|
|
"vendor": "xiaomi", "model": "M2102J2SC", "os": "android",
|
|
"ip": "127.0.0.1",
|
|
"fingerprint": "02df398797432eadefcc12767119ad5e80999389",
|
|
"screen": "M2102J2SC,30,11", "width": "1080", "height": "2120",
|
|
"device_id": "7c5387e0539c023c31c4ff0e807e7256117385ee",
|
|
}
|
|
|
|
|
|
def wup_password_login_raw(account: str, password: str, timeout: int = 15,
|
|
device_info: dict | None = None,
|
|
safedeviceid: str | None = None,
|
|
hdid: str | None = None) -> bytes:
|
|
"""发送WUP密码登录, 返回原始响应字节(不解析)。
|
|
|
|
device_info: 每账号设备画像 (tools/huya_device_profile.get_profile);
|
|
缺省用金样本设备 _DEV。换 fingerprint/device_id/机型 会触发
|
|
safe_auth 滑块 (自动可过); hdid/app_version 是设备注册硬锚, 不可换
|
|
(实测换之 → APP_SIGN_NOT_MATCH)。
|
|
"""
|
|
uid = account[3:] if account.startswith("hy_") else account
|
|
mj, ua, sd = _golden_assets()
|
|
dev = device_info or _DEV
|
|
pkt = build_password_login_wup(
|
|
uid, hashlib.sha1(password.encode()).hexdigest(),
|
|
safedeviceid or dev.get("safedeviceid") or sd,
|
|
hdid or dev.get("hdid") or "ed0db8334cadd236c00cadf7e11ab5a5",
|
|
mj["session"], mj["traceId"], ua, dev)
|
|
r = requests.post(
|
|
WUP_URL, data=pkt,
|
|
headers={"Content-Type": "application/multipart-formdata; charset=UTF-8",
|
|
"User-Agent": "Dalvik/2.1.0 (Linux; U; Android 11)",
|
|
"Accept-Encoding": "gzip"}, timeout=timeout)
|
|
if r.status_code != 200:
|
|
raise AppLoginError(f"登录HTTP {r.status_code}")
|
|
return r.content
|
|
|
|
|
|
def parse_cred(resp: bytes) -> bytes | None:
|
|
"""从WUP登录响应切片cred(tag3 SIMPLE_LIST); 无则None。"""
|
|
s = resp.find(b"\x0a\x0a", 0x40)
|
|
e = resp.find(b"_wup_header")
|
|
if s < 0 or e < 0:
|
|
return None
|
|
d = resp[s:e - 6]
|
|
m = re.search(rb"\x3d\x00([\x00-\x03])(.)", d) # tag3, INT8 len
|
|
if not m:
|
|
return None
|
|
ln = m.group(2)[0]
|
|
st = m.start() + 4
|
|
cred = d[st:st + ln]
|
|
if len(cred) == 114 and cred[:1] == b"\x0a":
|
|
return cred
|
|
return None
|
|
|
|
|
|
def parse_risk_url(resp: bytes) -> str | None:
|
|
"""提取safe_auth风控页URL; 优先pt_auth(滑块)。返回None表示无风控。"""
|
|
urls = []
|
|
for m in RISK_URL_RE.finditer(resp):
|
|
u = m.group().decode("utf-8", "ignore")
|
|
while u and u[-1] not in _URL_TAIL_KEEP:
|
|
u = u[:-1]
|
|
urls.append(u)
|
|
if not urls:
|
|
return None
|
|
pt = [u for u in urls if "pt_auth" in u]
|
|
return (pt or urls)[0]
|
|
|
|
|
|
def solve_safe_auth(risk_url: str, proxies=None, max_retry: int = 2) -> dict:
|
|
"""解一个safe_auth风控链接(pt_auth滑块), 返回vurl响应data(含authId)。
|
|
|
|
复现 pt_auth 页行为:
|
|
- urlParamMap = 整个query串原样回传
|
|
- page = pt_auth 完整URL(location.href)
|
|
- appId 取自query; info 用 csid_ 前缀会话id(getSdid格式)
|
|
- App WebView 场景用 touch 事件画像(use_touch_events=True)
|
|
"""
|
|
from core.huya.verification.solver import (
|
|
HuyaQrAuthRequiredError,
|
|
HuyaVerificationSolver,
|
|
)
|
|
|
|
q = {k: v[0] for k, v in
|
|
parse_qs(urlparse(risk_url).query, keep_blank_values=True).items()}
|
|
app_id = str(q.get("appId") or "5002")
|
|
last_err: Exception | None = None
|
|
for attempt in range(max_retry):
|
|
solver = HuyaVerificationSolver(
|
|
ua=APP_UA_MOBILE,
|
|
proxies=proxies,
|
|
app_id=app_id,
|
|
page_url=risk_url,
|
|
use_touch_events=True,
|
|
)
|
|
try:
|
|
result = solver.solve(risk_url)
|
|
except HuyaQrAuthRequiredError:
|
|
raise # qr_auth 明确不可自动化, 直接上抛
|
|
except Exception as exc: # noqa: BLE001
|
|
last_err = exc
|
|
print(f" [safe_auth] 第{attempt + 1}次过验异常: {exc}")
|
|
time.sleep(1.0)
|
|
continue
|
|
auth_id = str((result or {}).get("authId") or "")
|
|
if auth_id:
|
|
print(f" [safe_auth] 滑块通过 authId={auth_id[:24]}...")
|
|
return result
|
|
print(f" [safe_auth] 未返回authId: {str(result)[:160]}")
|
|
raise AppLoginError(f"safe_auth 过验失败: {last_err or '无authId'}")
|
|
|
|
|
|
def login_cred(account: str, password: str, max_rounds: int = 3,
|
|
device_info: dict | None = None) -> bytes:
|
|
"""账号密码 -> 新鲜cred。触发safe_auth时自动解滑块并重发登录。
|
|
|
|
device_info: 每账号独立设备画像 (tools/huya_device_profile.get_profile),
|
|
多账号时让每个账号呈现不同设备指纹/机型, 避免统一画像被风控关联。
|
|
"""
|
|
for rnd in range(max_rounds):
|
|
resp = wup_password_login_raw(account, password, device_info=device_info)
|
|
cred = parse_cred(resp)
|
|
if cred:
|
|
return cred
|
|
risk_url = parse_risk_url(resp)
|
|
if risk_url:
|
|
kind = "pt_auth(滑块)" if "pt_auth" in risk_url else \
|
|
("qr_auth(扫码)" if "qr_auth" in risk_url else "未知")
|
|
print(f"[risk] 第{rnd + 1}轮触发安全验证: {kind}")
|
|
if "qr_auth" in risk_url:
|
|
raise QrAuthRequiredError(
|
|
"该账号App渠道要求扫码验证(qr_auth), 请先在手机虎牙App上"
|
|
f"正常登录一次建立设备信任。\n 风控页: {risk_url[:120]}")
|
|
solve_safe_auth(risk_url)
|
|
print("[risk] 过验完成, 重发WUP登录 ...")
|
|
continue
|
|
raise AppLoginError(
|
|
f"登录未返回cred也无风控URL(密码错误或账号异常), 响应{len(resp)}B")
|
|
raise AppLoginError(f"{max_rounds}轮内未取得cred")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
acct, pwd = sys.argv[1], sys.argv[2]
|
|
cred = login_cred(acct, pwd)
|
|
print(f"cred OK: {len(cred)}B {cred[:8].hex()}")
|