feat(huya): safe_auth滑块自动过验闭环 - 新账号密码登录也全自动

This commit is contained in:
yml2213
2026-08-25 21:22:19 +08:00
parent 0019e5f7a1
commit 44b50bb503
6 changed files with 315 additions and 27 deletions
+1 -1
View File
@@ -1 +1 @@
{"p1": "", "cert_hex": "0c20c7ff8cf3674630822e627816a71bc19e97b43de7dae589f13fd1b2327e3be2d52cbaa481870772fe2a4df9a94fdb8a0e41312514470c77cbfa586558f87309be8229a3b618c3d68c33a364eb7810a71b8dfef6c9f5243c7ff2437c1c542ca99db749c296d58c65146f6c1849a1c1b6fbb48d7ff04fc2b18994285a47d348c0c8f8b93714d24fbefbe2f21656a5609169a773e2d8568f1820ff20c6880825b54884bfdeb549b839e7c99a69a274f9b1bcc4cec852c74856f9e8c0520d1d02a309", "cred_hex": "0a20a6e307aa15df2b09999394144eacd1016b73a356e39501dd049dca3130b3fc5fe187ddce5c31ff8f326ff160f593b92ceb09c2745c594edfbfe9d818e96077e5fd580261f243fb95e6bcc1430bc70123ad9b96f76534a22c499dcf8b0a239712f6b3d5bf1461158ca682afb60fa24133"}
{"p1": "", "cert_hex": "0c201a13953ffe33c7229dca02bbe6caec3dd38624f9ab5083301a165843b2372ab92cbaa481870772fe2a4df9a94fdb8a0e41312514470c77cbfa586558f87309bef720eed88e558cfaaff4033070459c4f0437d9af6981f2f0bb088aa9f9e8a15829fa90c7945a5183d07f3f17723b79bff98276257ddea8ed331a796c2b107170a26a6030de1b7096a6d1405a0c91e687a2f1fd4029923eeb10b17a838f276681d4afbf2261860fcbefbeaf2db3c6f16e293807e5ef76563025b5d7cb418a09fd", "cred_hex": "0a70e6d4c19cb5d4984c9e428c1a92c11744a85dd5c8f2f67ef7ef9c75aff6bffdaffcbe7cdacc8c3c7b1d20e1e3c549be02cce015af48ffdae1505d6b98fb86ac40dfebe356b7ef810898aeaaf67fce72792c8aea3629aa6ddf62c34ba92dd5383314697459dca6a1e7179fd1b525a623e7"}
File diff suppressed because one or more lines are too long
+3 -3
View File
@@ -4,9 +4,9 @@
"ok": true,
"uid": 1199666914671,
"biztoken_len": 344,
"biztoken": "AQA4y9MKq4Qmt6_gGeXThZEnv3QkPIbY_YEbvuIJFuRdzSJVE_1gPSvor9FLSVh9ASmgtE5FhBI7NIwygC3W0rpO3AOjkh2W5NzxYJFLp4P8XER2kF7SXCKZQFUguSkkIrlZFQy6Fg4jsiafM6bWPyRfzOn59e98B-wTS-hxxKT4rzfNCfjkdkVTNfToF8RJy2Gu78hJTLjWfzbkTI2phwW50bpXf55OVXZr-GdXvwHLcrEF1IKpPaxpLWX5ulfk-vR_HlPlhREPQS5WvqS7QJ3EB2MJFeYCRJdvDx3TC_M3Nw5OA3Bqrft8K-n_cjC11Mi86Zi462gK6g_EAGAFP0-v"
"biztoken": "AQCMqxiPqae2tZebsGe0fV3QrGKT0e7nARXgDSHt5_JKINIhDchOyo02v311rg1ApT89J_GLyEf8Mxm5ZbtiZ48nBnCDaGRjHsMda9NNaWV1Dd3lLbqfp4pmkW6dX5Q5A5CP8XF9lf5hUvqv-X5VuB0Via0f8FyVP6EcOn_h4fYSDV-qV9GMm9QJIfWtFNWjJnNeyv33Vk0hocJ7U8zP20WbHha_kH3HIQqrKLJVnGSAmhIPnyZVkh3yar6a5YcpFrnD6_7OAara-jz11jRHv6XTMM9YDOr-66T8NZYv4aklYgAQ3O-dg3Cu2fRrtLdGrxhV6x1N10MBzhQ3zNAGc2K9"
}
},
"p1_new_hex": "010400353030381400fd951db27f62fad1580eb969f4e921146552ba3e28003032646633393837393734333265616465666363313237363731313961643565383039393933383972000a20a6e307aa15df2b09999394144eacd1016b73a356e39501dd049dca3130b3fc5fe187ddce5c31ff8f326ff160f593b92ceb09c2745c594edfbfe9d818e96077e5fd580261f243fb95e6bcc1430bc70123ad9b96f76534a22c499dcf8b0a239712f6b3d5bf1461158ca682afb60fa24133",
"ts": 1787656476.736854
"p1_new_hex": "0104003530303814008db679c3751852831bb96c87814eb02130c6261f28003032646633393837393734333265616465666363313237363731313961643565383039393933383972000a70e6d4c19cb5d4984c9e428c1a92c11744a85dd5c8f2f67ef7ef9c75aff6bffdaffcbe7cdacc8c3c7b1d20e1e3c549be02cce015af48ffdae1505d6b98fb86ac40dfebe356b7ef810898aeaaf67fce72792c8aea3629aa6ddf62c34ba92dd5383314697459dca6a1e7179fd1b525a623e7",
"ts": 1787663026.148772
}
+215
View File
@@ -0,0 +1,215 @@
#!/usr/bin/env python3
"""虎牙App渠道纯Python登录流 —— 含新账号 safe_auth 滑块自动过验。
链路(全部实测/逆向自 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) -> bytes:
"""发送WUP密码登录, 返回原始响应字节(不解析)。"""
uid = account[3:] if account.startswith("hy_") else account
mj, ua, sd = _golden_assets()
pkt = build_password_login_wup(
uid, hashlib.sha1(password.encode()).hexdigest(), sd,
"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) -> bytes:
"""账号密码 -> 新鲜cred。触发safe_auth时自动解滑块并重发登录。"""
for rnd in range(max_rounds):
resp = wup_password_login_raw(account, password)
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()}")
+77
View File
@@ -0,0 +1,77 @@
#!/usr/bin/env python3
"""一键全自动(含新账号safe_auth滑块): 账号密码 -> 页面cookie(biztoken)。
与 full_auto_test.py 的区别: 可信账号直出cred之外,
新/异地账号触发 safe_auth 时自动解 udbrtt 滑块并重发登录, 全程零人工。
用法:
.venv/bin/python tools/full_auto_safe.py <账号如hy_xxx或300023887> <密码>
前提: evidence/cert_keycap.json 内有较新鲜的 wupData 信封
(过期则先在设备上跑 scripts/hook_cert_keycap.py 刷新)。
"""
import base64
import json
import re
import subprocess
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT))
sys.path.insert(0, str(ROOT / "tools"))
from app_login_flow import QrAuthRequiredError, login_cred # noqa: E402
from cert_forge import build_p1, decrypt_cert, forge_cert, parse_p1 # noqa: E402
from envelope_forge import Envelope # noqa: E402
def forge(cred: bytes) -> str:
"""复用信封原证书的nonce/指纹/key_idx仅换cred(纯随机nonce必40020)."""
env = Envelope.load()
orig = base64.b64decode(env.cert_b64)
f = parse_p1(decrypt_cert(orig))
P1 = build_p1(f["app_id"], f["fingerprint"], cred, rnd=f["rnd"])
return forge_cert(P1, key_idx=orig[1]).hex()
def bind_for_cookie(cred_json_path: Path) -> bool:
"""复用扫码绑定四步流(QrRole), 返回是否拿到biztoken。"""
probe = ROOT / "scripts/probe_forge_cert_bind.py"
src = probe.read_text()
src = re.sub(r'open\("evidence/hycred_[a-z_]+\.json"\)',
f'open("{cred_json_path.as_posix()}")', src)
tmp = ROOT / "scripts/_probe_auto.py"
tmp.write_text(src)
r = subprocess.run([sys.executable, str(tmp)], capture_output=True, text=True)
print(r.stdout[-800:])
return '"ok": true' in r.stdout or "'ok': True" in r.stdout
def main() -> int:
acct, pwd = sys.argv[1], sys.argv[2]
print("[1/3] 纯Python密码登录 (遇safe_auth自动过滑块) ...")
try:
cred = login_cred(acct, pwd)
except QrAuthRequiredError as exc:
print(f"{exc}")
return 2
print(f" cred OK: {len(cred)}B {cred[:8].hex()}")
print("[2/3] 铸造证书 ...")
cert_hex = forge(cred)
out = ROOT / "evidence/auto_test_cred.json"
out.write_text(json.dumps({"p1": "", "cert_hex": cert_hex,
"cred_hex": cred.hex()}))
print(f" 证书 OK: {len(cert_hex)//2}B")
print("[3/3] 扫码绑定取cookie ...")
if bind_for_cookie(out):
print("\n🎉 全链路成功: 密码已换出页面cookie(biztoken)!")
return 0
print("\n❌ 绑定失败——若40020多为信封过期, 请刷新wupData后重试")
return 1
if __name__ == "__main__":
raise SystemExit(main())
+18 -22
View File
@@ -14,28 +14,18 @@ from probe_huya_qr_bind import QrRole, web_behavior
from core.huya.device_fingerprint import get_huya_sdid
qurl,P1_old=load_capture()
fields=parse_p1(P1_old)
import hashlib,requests as _rq
from huya_wup_encoder import build_password_login_wup
_raw=(__import__('pathlib').Path(__file__).resolve().parent.parent/'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()
_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"}
_pkt=build_password_login_wup(ACCT[3:] if ACCT.startswith("hy_") else ACCT, hashlib.sha1(PWD.encode()).hexdigest(),_sd,"ed0db8334cadd236c00cadf7e11ab5a5",_mj["session"],_mj["traceId"],_ua,_dev)
_r=_rq.post("https://wup.huya.com",data=_pkt,headers={"Content-Type":"application/multipart-formdata; charset=UTF-8","User-Agent":"Dalvik/2.1.0"},timeout=15)
_b=_r.content; _s=_b.find(b'\x0a\x0a',0x40); _e=_b.find(b'_wup_header'); _d=_b[_s:_e-6]
_m=re.search(rb'\x3d\x00\x00.',_d)
if not _m:
_u=re.search(rb'https://aq\.huya\.com[^\x00\x20]{20,}',_d)
if _u:
print('❌ 该账号触发了安全验证(safe_auth), 无法直接取cred')
print(' 解决: 先用该账号在手机虎牙App上正常登录一次(建立设备信任),')
print(' 之后本脚本即可全自动。验证页:', _u.group()[:100].decode())
else:
print('❌ 登录未返回cred(密码错误或账号异常), 响应',len(_b),'B')
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)
_l=_m.group()[3]
cred=_d[_m.start()+4:_m.start()+4+_ln]
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()
@@ -74,4 +64,10 @@ print('verify HTTP',r.status_code)
for h in r.headers.get('Set-Cookie','').split('\n') or [r.headers.get('Set-Cookie','')]:
if h: print('SET:',h[:110])
print('cookie jar:')
for c in sess.cookies: print(' ',c.name,'=',c.value[:50])
jar=[]
for c in sess.cookies:
print(' ',c.name,'=',c.value[:50])
jar.append(f'{c.name}={c.value}')
out=__import__('pathlib').Path(__file__).resolve().parent.parent/'evidence/web_cookies_full.txt'
out.write_text(f'# account: {ACCT}\n' + '\n'.join(jar) + '\n')
print('已写', out)