feat(huya): 整合 App 协议登录获取 Cookie 全链路并与 Web 登录解耦
- core/huya: 新增 app_login, wup_encoder, nonce_forge, cert_forge, envelope_forge, device_profile, udb_aes - core/huya/__init__.py: 导出 login_huya_app_password 与 HuyaAppPasswordLogin - web/backend: 新增 /accounts/app-password-login 与 /accounts/app-password-login/selected 路由及 Schema,与原 Web 密码登录独立分开 - web/frontend: 增加 App 登录 API 与前端界面“App 登录选中”操作,弹窗结果明确区分 - tests: 新增 test_huya_app_login.py 单元测试覆盖全链路
This commit is contained in:
@@ -12,11 +12,15 @@ __all__ = [
|
||||
"HuyaLoginError",
|
||||
"HuyaLoginResult",
|
||||
"HuyaPasswordLogin",
|
||||
"HuyaAppLoginError",
|
||||
"HuyaAppQrAuthRequiredError",
|
||||
"HuyaAppPasswordLogin",
|
||||
"HuyaSmsCodeResult",
|
||||
"HuyaSmsLogin",
|
||||
"HuyaVerificationError",
|
||||
"HuyaVerificationSolver",
|
||||
"login_huya_password",
|
||||
"login_huya_app_password",
|
||||
"login_huya_sms",
|
||||
"send_huya_sms_code",
|
||||
"solve_huya_verification",
|
||||
@@ -57,6 +61,27 @@ def __getattr__(name: str):
|
||||
}
|
||||
globals().update(values)
|
||||
return values[name]
|
||||
if name in {
|
||||
"HuyaAppLoginError",
|
||||
"HuyaAppQrAuthRequiredError",
|
||||
"HuyaAppPasswordLogin",
|
||||
"login_huya_app_password",
|
||||
}:
|
||||
from .app_login import (
|
||||
HuyaAppLoginError,
|
||||
HuyaAppQrAuthRequiredError,
|
||||
HuyaAppPasswordLogin,
|
||||
login_huya_app_password,
|
||||
)
|
||||
|
||||
values = {
|
||||
"HuyaAppLoginError": HuyaAppLoginError,
|
||||
"HuyaAppQrAuthRequiredError": HuyaAppQrAuthRequiredError,
|
||||
"HuyaAppPasswordLogin": HuyaAppPasswordLogin,
|
||||
"login_huya_app_password": login_huya_app_password,
|
||||
}
|
||||
globals().update(values)
|
||||
return values[name]
|
||||
if name in {
|
||||
"HuyaSmsCodeResult",
|
||||
"HuyaSmsLogin",
|
||||
|
||||
@@ -0,0 +1,531 @@
|
||||
"""虎牙 App 渠道协议登录模块。
|
||||
|
||||
流程:
|
||||
1. 账号+密码 -> 独立设备画像 -> WUP 密码登录 (POST wup.huya.com)
|
||||
2. safe_auth 滑块自动过验 -> 提取 fresh cred 与 真实 uid
|
||||
3. 本地 XXTEA 算 nonce -> 铸造登录证书 (cert_forge) -> 补丁 WUP 信封 (envelope_forge)
|
||||
4. 模拟扫码绑定四步流 (getQrId -> scanQrPicNotify -> bindQrLoginUser -> tryQrLogin) 获取 biztoken
|
||||
5. POST /web/cookie/verify 兑换获取全套网页 Cookie
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
import random
|
||||
import re
|
||||
import struct
|
||||
import time
|
||||
import uuid
|
||||
from typing import Mapping
|
||||
from urllib.parse import parse_qs, quote, urlparse
|
||||
|
||||
import requests
|
||||
from loguru import logger
|
||||
|
||||
from .cert_forge import build_p1, decrypt_cert, forge_cert, parse_p1
|
||||
from .cookie_utils import normalize_huya_cookie
|
||||
from .device_fingerprint import get_huya_sdid
|
||||
from .device_profile import get_profile
|
||||
from .envelope_forge import Envelope
|
||||
from .login import HuyaCredentialError, HuyaLoginError, HuyaLoginResult
|
||||
from .nonce_forge import K1_DEFAULT, gen_nonce
|
||||
from .wup_encoder import build_password_login_wup
|
||||
|
||||
WUP_URL = "https://wup.huya.com"
|
||||
UDB_BASE = "https://udblgn.huya.com"
|
||||
|
||||
APP_SIGN_WEB = "1ce3bf682483d03f146f58232ec10635"
|
||||
APP_SIGN_H5 = "0ba67962ab9e12387648efeae2750777"
|
||||
|
||||
UA_PC = (
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 "
|
||||
"(KHTML, like Gecko) Chrome/151.0.0.0 Safari/537.36"
|
||||
)
|
||||
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"
|
||||
)
|
||||
|
||||
RISK_URL_RE = re.compile(rb"https://aq\.huya\.com/p/safe_auth/[^\x00-\x20\"'\\<>]+")
|
||||
_URL_TAIL_KEEP = set(
|
||||
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~:/?#[]@!$&'()*+,;=%"
|
||||
)
|
||||
|
||||
DEFAULT_GOLDEN_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",
|
||||
"hdid": "ed0db8334cadd236c00cadf7e11ab5a5",
|
||||
"safedeviceid": (
|
||||
"PQwemAN9NHkZKoMqVTFUZBIypqMTaQEOrmXr37xQVhQZqrL/gUKEQ11xvE0ju48V8O/"
|
||||
"t9UBGSp27m4+6bP4IiAEnpaR5Rj1kHEfN2SPLPqYZW9vroxUSoAvjJn6ezTP9jWGxxlRDCbt"
|
||||
"Py4Rd6MencYT/pNImVIWK+YbNKZt1O05bHUFhqHf3"
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
class HuyaAppLoginError(HuyaLoginError):
|
||||
"""虎牙 App 登录失败。"""
|
||||
|
||||
|
||||
class HuyaAppQrAuthRequiredError(HuyaAppLoginError):
|
||||
"""风控要求扫码验证 (qr_auth)。"""
|
||||
|
||||
|
||||
def _golden_session_assets() -> tuple[dict, str, str]:
|
||||
mj = {
|
||||
"associationId": 8193,
|
||||
"funcName": "hypasswordLogin",
|
||||
"group": 1,
|
||||
"id": 4097,
|
||||
"session": 3251699,
|
||||
"step": 1,
|
||||
"stillLogin": False,
|
||||
"traceId": "0b8f098ff64a5bdc-23473-94783833787595172451",
|
||||
"type": 3,
|
||||
"uid": 0,
|
||||
"userContext": "",
|
||||
}
|
||||
ua = (
|
||||
'{"curl":"登录页","furl":"我的","latitude":"-1.0","longitude":"-1.0","ssid":"",'
|
||||
'"user_action":[{"id":"24","time":"1787595171950","x":"277","y":"1057"},'
|
||||
'{"id":"11","time":"1787595172444","x":"296","y":"952"}]}'
|
||||
)
|
||||
sd = (
|
||||
"PQwemAN9NHkZKoMqVTFUZBIypqMTaQEOrmXr37xQVhQZqrL/gUKEQ11xvE0ju48V8O/"
|
||||
"t9UBGSp27m4+6bP4IiAEnpaR5Rj1kHEfN2SPLPqYZW9vroxUSoAvjJn6ezTP9jWGxxlRDCbt"
|
||||
"Py4Rd6MencYT/pNImVIWK+YbNKZt1O05bHUFhqHf3"
|
||||
)
|
||||
return mj, ua, sd
|
||||
|
||||
|
||||
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,
|
||||
proxies: dict | None = None,
|
||||
) -> bytes:
|
||||
"""发送 WUP 密码登录,返回原始响应字节。"""
|
||||
uid_str = account[3:] if account.startswith("hy_") else account
|
||||
mj, ua, sd = _golden_session_assets()
|
||||
dev = device_info or DEFAULT_GOLDEN_DEV
|
||||
pkt = build_password_login_wup(
|
||||
uid_str,
|
||||
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",
|
||||
},
|
||||
proxies=proxies,
|
||||
timeout=timeout,
|
||||
)
|
||||
if r.status_code != 200:
|
||||
raise HuyaAppLoginError(f"登录 HTTP 状态异常: {r.status_code}")
|
||||
return r.content
|
||||
|
||||
|
||||
def parse_cred(resp: bytes) -> bytes | None:
|
||||
"""从 WUP 响应中提取 114 字节的 cred。"""
|
||||
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)
|
||||
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。"""
|
||||
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 = 3) -> dict:
|
||||
"""解 safe_auth 风控滑块。"""
|
||||
from .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
|
||||
except Exception as exc:
|
||||
last_err = exc
|
||||
logger.warning(f"[safe_auth] 第 {attempt + 1} 次过验异常: {exc}")
|
||||
time.sleep(1.0)
|
||||
continue
|
||||
auth_id = str((result or {}).get("authId") or "")
|
||||
if auth_id:
|
||||
logger.info(f"[safe_auth] 滑块通过 authId={auth_id[:24]}...")
|
||||
return result
|
||||
raise HuyaAppLoginError(f"safe_auth 滑块过验失败: {last_err or '未返回 authId'}")
|
||||
|
||||
|
||||
def parse_real_uid(resp: bytes) -> int:
|
||||
"""从登录响应中提取 12 位真实 UID。"""
|
||||
s = resp.find(b"\x0a\x0a", 0x40)
|
||||
e = resp.find(b"_wup_header")
|
||||
if s >= 0 and e >= 0:
|
||||
chunk = resp[s:e]
|
||||
if len(chunk) > 243:
|
||||
uid = struct.unpack_from(">Q", chunk, 235)[0]
|
||||
if 1_100_000_000_000 < uid < 1_300_000_000_000:
|
||||
return uid
|
||||
for off in range(len(chunk) - 8):
|
||||
u = struct.unpack_from(">Q", chunk, off)[0]
|
||||
if 1_100_000_000_000 < u < 1_300_000_000_000:
|
||||
return u
|
||||
raise HuyaAppLoginError("无法从登录响应解析真实 UID")
|
||||
|
||||
|
||||
def login_cred_with_flow(
|
||||
account: str,
|
||||
password: str,
|
||||
max_rounds: int = 3,
|
||||
device_info: dict | None = None,
|
||||
proxies: dict | None = None,
|
||||
) -> tuple[bytes, int]:
|
||||
"""账号密码 -> (新鲜cred, 真实uid)。自动过 safe_auth 滑块。"""
|
||||
for rnd in range(max_rounds):
|
||||
resp = wup_password_login_raw(account, password, device_info=device_info, proxies=proxies)
|
||||
cred = parse_cred(resp)
|
||||
if cred:
|
||||
uid = parse_real_uid(resp)
|
||||
return cred, uid
|
||||
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 "未知")
|
||||
logger.info(f"[huya-app] 第 {rnd + 1} 轮触发安全验证: {kind}")
|
||||
if "qr_auth" in risk_url:
|
||||
raise HuyaAppQrAuthRequiredError(
|
||||
f"该账号 App 渠道要求扫码验证(qr_auth),请先在手机虎牙 App 上正常登录一次建立设备信任。"
|
||||
)
|
||||
solve_safe_auth(risk_url, proxies=proxies)
|
||||
logger.info("[huya-app] safe_auth 滑块过验成功,重发 WUP 登录...")
|
||||
continue
|
||||
raise HuyaAppLoginError("登录未返回凭据也无风控URL(密码错误或账号状态异常)")
|
||||
raise HuyaAppLoginError(f"{max_rounds} 轮内未取得登录凭据")
|
||||
|
||||
|
||||
class QrRole:
|
||||
"""udblgn.huya.com/qrLgn/* 的 JSON 协议封装。"""
|
||||
|
||||
def __init__(self, pc: bool, sdid: str, proxies: dict | None = None):
|
||||
self.pc = pc
|
||||
self.sdid = sdid
|
||||
self.s = requests.Session()
|
||||
self.s.trust_env = False
|
||||
if proxies:
|
||||
self.s.proxies.update(proxies)
|
||||
ctx_hex = uuid.uuid4().hex
|
||||
tail = "CBC9F93DBEB000011DB11EC02A401BAD-" if pc else uuid.uuid4().hex.upper()
|
||||
tail = tail if tail.endswith("-") else tail + "-"
|
||||
prefix = "WB" if pc else "H5"
|
||||
self.context = f"{prefix}-{ctx_hex}-{tail}"
|
||||
self.page_id = random.randint(40_000_000, 41_000_000)
|
||||
self.req_counter = random.randint(40_000_000, 41_000_000)
|
||||
self.s.headers.update({
|
||||
"User-Agent": UA_PC if pc else APP_UA_MOBILE,
|
||||
"Origin": UDB_BASE,
|
||||
"content-type": "application/json;charset=UTF-8",
|
||||
"Accept": "*/*",
|
||||
})
|
||||
|
||||
def _headers(self, uri: str) -> dict:
|
||||
mid = "2.6" if self.pc else "2.5"
|
||||
return {
|
||||
"context": self.context,
|
||||
"uri": uri,
|
||||
"reqid": str(self.req_counter),
|
||||
"lcid": "2052",
|
||||
"Referer": f"{UDB_BASE}/web/middle/{mid}/{self.page_id}/https/{self.context.split('-')[1]}",
|
||||
}
|
||||
|
||||
def call(self, path: str, uri: str, data: dict, cookies: dict | None = None) -> dict:
|
||||
envelope = {
|
||||
"uri": uri,
|
||||
"version": "2.6" if self.pc else "2.5",
|
||||
"context": self.context,
|
||||
"appId": "5002" if self.pc else "5131",
|
||||
"appSign": APP_SIGN_WEB if self.pc else APP_SIGN_H5,
|
||||
"authId": "",
|
||||
"sdid": self.sdid,
|
||||
"lcid": "2052",
|
||||
"byPass": "3",
|
||||
"requestId": str(self.req_counter),
|
||||
"data": data,
|
||||
}
|
||||
self.req_counter += random.randint(120, 400)
|
||||
r = self.s.post(
|
||||
f"{UDB_BASE}{path}",
|
||||
json=envelope,
|
||||
headers=self._headers(uri),
|
||||
cookies=cookies,
|
||||
timeout=20,
|
||||
)
|
||||
return r.json()
|
||||
|
||||
|
||||
def web_behavior(page: str = "https://www.huya.com/g") -> tuple[str, str]:
|
||||
now = int(time.time() * 1000) - random.randint(3000, 10000)
|
||||
acts = []
|
||||
d = random.randint(800, 1800)
|
||||
for a in ("7", "7", "8", "8"):
|
||||
now += random.randint(600, 2000)
|
||||
d += random.randint(600, 2000)
|
||||
acts.append({"id": a, "d": d, "time": now})
|
||||
val = {"furl": page, "curl": page, "user_action": acts}
|
||||
beh = quote(json.dumps(val, separators=(",", ":")), safe="~()*!.'")
|
||||
return beh, quote(page, safe="")
|
||||
|
||||
|
||||
class HuyaAppPasswordLogin:
|
||||
"""虎牙 App 渠道纯协议密码登录器。"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
username: str,
|
||||
password: str,
|
||||
proxies: Mapping[str, str] | None = None,
|
||||
timeout: tuple[float, float] | None = None,
|
||||
force_new_device: bool = False,
|
||||
device_info: dict | None = None,
|
||||
):
|
||||
self.username = username.strip()
|
||||
self.password = password
|
||||
self.proxies = dict(proxies) if proxies else None
|
||||
self.timeout = timeout or (10.0, 25.0)
|
||||
self.force_new_device = force_new_device
|
||||
self.device_info = device_info or get_profile(self.username, force_new=force_new_device)
|
||||
|
||||
def login(self) -> HuyaLoginResult:
|
||||
"""执行完整 App 登录获取 Cookie 流程。"""
|
||||
acct = self.username
|
||||
logger.info(f"[huya-app] 开始登录账号 {acct} (机型: {self.device_info.get('model')})...")
|
||||
|
||||
# 1) 获取新鲜 cred 与 真实 uid (自动过 safe_auth 滑块)
|
||||
try:
|
||||
cred, uid = login_cred_with_flow(
|
||||
acct,
|
||||
self.password,
|
||||
device_info=self.device_info,
|
||||
proxies=self.proxies,
|
||||
)
|
||||
except HuyaAppQrAuthRequiredError as exc:
|
||||
return HuyaLoginResult(
|
||||
success=False,
|
||||
message=str(exc),
|
||||
code="QR_AUTH_REQUIRED",
|
||||
)
|
||||
except Exception as exc:
|
||||
return HuyaLoginResult(
|
||||
success=False,
|
||||
message=f"App 登录凭证获取失败: {exc}",
|
||||
code="LOGIN_FAILED",
|
||||
)
|
||||
|
||||
logger.info(f"[huya-app] 成功获取 cred ({len(cred)}B), uid={uid}")
|
||||
|
||||
# 2) 本地生成 nonce 铸造证书 (P1 指纹与该账号设备画像一致)
|
||||
try:
|
||||
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)
|
||||
fp_bytes = self.device_info["fingerprint"].encode("ascii")
|
||||
p1 = build_p1(f["app_id"], fp_bytes, cred, rnd=rnd)
|
||||
cert = base64.b64encode(forge_cert(p1, key_idx=orig[1])).decode()
|
||||
|
||||
# 3) 信封补丁
|
||||
raw = bytearray(env.raw)
|
||||
raw[env.cert_off:env.cert_off + env.cert_len] = cert.encode("ascii")
|
||||
if env.uid != uid:
|
||||
struct.pack_into(">Q", raw, env.uid_off, uid)
|
||||
wup = base64.b64encode(bytes(raw)).decode("ascii")
|
||||
except Exception as exc:
|
||||
return HuyaLoginResult(
|
||||
success=False,
|
||||
message=f"证书铸造/信封补丁失败: {exc}",
|
||||
code="CERT_FORGE_FAILED",
|
||||
)
|
||||
|
||||
# 4) 模拟扫码绑定四步流获取 biztoken
|
||||
try:
|
||||
sdid_obj = get_huya_sdid(allow_fallback=True)
|
||||
sdid = sdid_obj.sdid if sdid_obj else ""
|
||||
pc = QrRole(pc=True, sdid=sdid, proxies=self.proxies)
|
||||
ph = QrRole(pc=False, sdid=sdid, proxies=self.proxies)
|
||||
beh, page = web_behavior()
|
||||
|
||||
# 4.1 获取 qrId
|
||||
resp = pc.call(
|
||||
"/qrLgn/getQrId",
|
||||
"70001",
|
||||
{"behavior": beh, "type": "", "domainList": "", "page": page},
|
||||
)
|
||||
qrid = (resp.get("data") or {}).get("qrId")
|
||||
if not qrid:
|
||||
return HuyaLoginResult(
|
||||
success=False,
|
||||
message=f"获取 qrId 失败: {resp.get('message') or resp}",
|
||||
code="QR_ID_FAILED",
|
||||
)
|
||||
|
||||
# 4.2 扫码与绑定
|
||||
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=""),
|
||||
},
|
||||
)
|
||||
if r2.get("returnCode") not in (0, "0", None) and r2.get("returnCode") != 0:
|
||||
logger.warning(f"[huya-app] bind 返回码: {r2.get('returnCode')} msg: {r2.get('message')}")
|
||||
|
||||
# 4.3 轮询 tryQrLogin
|
||||
biztoken = None
|
||||
for _ in range(12):
|
||||
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")
|
||||
break
|
||||
time.sleep(1.5)
|
||||
|
||||
if not biztoken:
|
||||
return HuyaLoginResult(
|
||||
success=False,
|
||||
message="未能在轮询时间内获取到 biztoken (绑定超时或失败)",
|
||||
code="BIZTOKEN_TIMEOUT",
|
||||
)
|
||||
|
||||
# 5) POST /web/cookie/verify 兑换 Cookie
|
||||
verify_resp = pc.s.post(
|
||||
"https://udblgn.huya.com/web/cookie/verify",
|
||||
json={"appId": 5002},
|
||||
timeout=15,
|
||||
)
|
||||
if verify_resp.status_code != 200:
|
||||
return HuyaLoginResult(
|
||||
success=False,
|
||||
message=f"Cookie verify 兑换 HTTP 失败: {verify_resp.status_code}",
|
||||
code="VERIFY_FAILED",
|
||||
)
|
||||
|
||||
cookie_str = normalize_huya_cookie(pc.s.cookies)
|
||||
if "udb_cred" not in cookie_str and "yyuid" not in cookie_str:
|
||||
return HuyaLoginResult(
|
||||
success=False,
|
||||
message="Cookie 兑换完成但缺失关键凭据 (udb_cred/yyuid)",
|
||||
code="COOKIE_INCOMPLETE",
|
||||
)
|
||||
|
||||
logger.info(f"[huya-app] 账号 {acct} 登录成功,获取完整 Cookie ({len(cookie_str)}B)")
|
||||
return HuyaLoginResult(
|
||||
success=True,
|
||||
cookie=cookie_str,
|
||||
message="App协议登录成功",
|
||||
sdid=sdid,
|
||||
context=pc.context,
|
||||
)
|
||||
except Exception as exc:
|
||||
return HuyaLoginResult(
|
||||
success=False,
|
||||
message=f"扫码绑定兑换 Cookie 失败: {exc}",
|
||||
code="BIND_FLOW_FAILED",
|
||||
)
|
||||
|
||||
|
||||
def login_huya_app_password(
|
||||
username: str,
|
||||
password: str,
|
||||
proxies: Mapping[str, str] | None = None,
|
||||
timeout: tuple[float, float] | None = None,
|
||||
force_new_device: bool = False,
|
||||
) -> HuyaLoginResult:
|
||||
"""函数式入口:使用 App 协议执行虎牙密码登录并获取全套 Cookie。"""
|
||||
return HuyaAppPasswordLogin(
|
||||
username=username,
|
||||
password=password,
|
||||
proxies=proxies,
|
||||
timeout=timeout,
|
||||
force_new_device=force_new_device,
|
||||
).login()
|
||||
@@ -0,0 +1,90 @@
|
||||
"""虎牙 udb 登录证书 (biz_token) 铸造与解析工具。
|
||||
|
||||
证书格式:base64( [0x0c][key_idx][AES-128-ECB(key16, zeropad(P1))] )
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import os
|
||||
import struct
|
||||
|
||||
from .udb_aes import udb_decrypt, udb_encrypt
|
||||
|
||||
KEY_TABLE_RAW = [
|
||||
"4VYcPdvKKqjBHZtCmbroRXHk",
|
||||
"xXEDWqiKLGwEZ6HubEiswCqK",
|
||||
"3FMHubdKosFrhmXNLHTNHZwe",
|
||||
]
|
||||
DEFAULT_KEY16 = KEY_TABLE_RAW[0][:16].encode()
|
||||
|
||||
|
||||
def build_p1(
|
||||
app_id: bytes,
|
||||
fingerprint: bytes,
|
||||
cred: bytes,
|
||||
rnd: bytes | None = None,
|
||||
) -> bytes:
|
||||
"""按样本结构组装 P1 明文。
|
||||
|
||||
- app_id: b'5008' 等 4 字节
|
||||
- fingerprint: 40 字节 ASCII hex
|
||||
- cred: 114 字节 hyCred
|
||||
- rnd: 20 字节 nonce,缺省用 os.urandom(20)
|
||||
"""
|
||||
assert len(fingerprint) == 40, f"指纹应为40B, 实际为 {len(fingerprint)}"
|
||||
assert len(cred) == 114, f"cred应为114B, 实际为 {len(cred)}"
|
||||
r = os.urandom(20) if rnd is None else rnd
|
||||
p1 = b"\x01\x04\x00" + app_id
|
||||
p1 += struct.pack("<H", len(r)) + r
|
||||
p1 += struct.pack("<H", len(fingerprint)) + fingerprint
|
||||
p1 += struct.pack("<H", len(cred)) + cred
|
||||
assert len(p1) == 187, f"P1长度应为187,实际为 {len(p1)}"
|
||||
return p1
|
||||
|
||||
|
||||
def parse_p1(data: bytes) -> dict:
|
||||
"""解析 P1 结构。"""
|
||||
o = 0
|
||||
|
||||
def tk(n: int) -> bytes:
|
||||
nonlocal o
|
||||
b = data[o:o + n]
|
||||
o += n
|
||||
return b
|
||||
|
||||
out = {
|
||||
"head": tk(3).hex(),
|
||||
"app_id": tk(4),
|
||||
}
|
||||
n = struct.unpack("<H", tk(2))[0]
|
||||
out["rnd"] = tk(n)
|
||||
n = struct.unpack("<H", tk(2))[0]
|
||||
out["fingerprint"] = tk(n)
|
||||
n = struct.unpack("<H", tk(2))[0]
|
||||
out["cred"] = tk(n)
|
||||
out["trailing"] = data[o:]
|
||||
return out
|
||||
|
||||
|
||||
def forge_cert(
|
||||
p1: bytes,
|
||||
key16: bytes = DEFAULT_KEY16,
|
||||
key_idx: int = 0x20,
|
||||
type_byte: int = 0x0C,
|
||||
) -> bytes:
|
||||
"""P1 -> 二进制证书 [type][key_idx][192B AES 密文]。"""
|
||||
ct = udb_encrypt(key16, p1)
|
||||
assert len(ct) == 192, len(ct)
|
||||
return bytes([type_byte, key_idx]) + ct
|
||||
|
||||
|
||||
def parse_cert(blob: bytes) -> tuple[int, int, bytes]:
|
||||
"""[0x0c][idx][192B] -> (type, idx, ct)。"""
|
||||
assert blob[0] == 0x0C and len(blob) >= 194, "非证书结构"
|
||||
return blob[0], blob[1], blob[2:194]
|
||||
|
||||
|
||||
def decrypt_cert(blob: bytes, key16: bytes = DEFAULT_KEY16) -> bytes:
|
||||
"""解密二进制证书为 P1 明文。"""
|
||||
_, _, ct = parse_cert(blob)
|
||||
return udb_decrypt(key16, ct)
|
||||
@@ -0,0 +1,99 @@
|
||||
"""虎牙多账号设备画像生成与管理。
|
||||
|
||||
为每个账号生成并持久化独立的设备身份画像(机型、屏幕、指纹、设备ID等)。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import random
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent.parent
|
||||
DATA_DIR = ROOT / "data"
|
||||
PRIMARY_PROFILE_DB = DATA_DIR / "huya_device_profiles.json"
|
||||
FALLBACK_PROFILE_DB = ROOT / "evidence" / "device_profiles.json"
|
||||
|
||||
REAL_MODELS = [
|
||||
("xiaomi", "M2102J2SC", "M2102J2SC,30,11", (1080, 2120)),
|
||||
("vivo", "V2370A", "V2370A,34,13", (1080, 2412)),
|
||||
("oppo", "PFFM20", "PFFM20,34,13", (1080, 2412)),
|
||||
("honor", "SDY-AN00", "SDY-AN00,31,12", (1080, 2400)),
|
||||
("samsung", "SM-G9910", "SM-G9910,31,12", (1440, 3200)),
|
||||
("oneplus", "PGZ110", "PGZ110,34,13", (1080, 2412)),
|
||||
("redmi", "23049PCD8G", "23049PCD8G,34,13", (1080, 2400)),
|
||||
("realme", "RMX3366", "RMX3366,34,13", (1080, 2412)),
|
||||
("iqoo", "V2183A", "V2183A,34,13", (1080, 2400)),
|
||||
("nubia", "NX729J", "NX729J,33,13", (1080, 2400)),
|
||||
("motorola", "XT2301-5", "XT2301-5,33,13", (1080, 2400)),
|
||||
("gionee", "GN9013", "GN9013,29,10", (720, 1560)),
|
||||
]
|
||||
|
||||
HDID = "ed0db8334cadd236c00cadf7e11ab5a5"
|
||||
APP_VERSION = "13.4.22"
|
||||
SDK_VERSION = "1.0.80138"
|
||||
SAFEDEVICEID_DEFAULT = (
|
||||
"PQwemAN9NHkZKoMqVTFUZBIypqMTaQEOrmXr37xQVhQZqrL/gUKEQ11xvE0ju48V8O/"
|
||||
"t9UBGSp27m4+6bP4IiAEnpaR5Rj1kHEfN2SPLPqYZW9vroxUSoAvjJn6ezTP9jWGxxlRDCbt"
|
||||
"Py4Rd6MencYT/pNImVIWK+YbNKZt1O05bHUFhqHf3"
|
||||
)
|
||||
|
||||
|
||||
def _rand_sha1_hex() -> str:
|
||||
return hashlib.sha1(os.urandom(20)).hexdigest()
|
||||
|
||||
|
||||
def generate_profile(model_pick=None) -> dict:
|
||||
"""生成一套随机设备画像。"""
|
||||
vendor, model, screen, (w, h) = (
|
||||
random.choice(REAL_MODELS) if model_pick is None else model_pick
|
||||
)
|
||||
return {
|
||||
"app_version": APP_VERSION,
|
||||
"sdk_version": SDK_VERSION,
|
||||
"vendor": vendor,
|
||||
"model": model,
|
||||
"os": "android",
|
||||
"ip": "127.0.0.1",
|
||||
"fingerprint": _rand_sha1_hex(),
|
||||
"screen": screen,
|
||||
"width": str(w),
|
||||
"height": str(h),
|
||||
"device_id": _rand_sha1_hex(),
|
||||
"hdid": HDID,
|
||||
"safedeviceid": SAFEDEVICEID_DEFAULT,
|
||||
}
|
||||
|
||||
|
||||
def _load_db() -> dict:
|
||||
if PRIMARY_PROFILE_DB.exists():
|
||||
try:
|
||||
return json.loads(PRIMARY_PROFILE_DB.read_text("utf-8"))
|
||||
except Exception:
|
||||
pass
|
||||
if FALLBACK_PROFILE_DB.exists():
|
||||
try:
|
||||
return json.loads(FALLBACK_PROFILE_DB.read_text("utf-8"))
|
||||
except Exception:
|
||||
pass
|
||||
return {}
|
||||
|
||||
|
||||
def _save_db(db: dict) -> None:
|
||||
try:
|
||||
PRIMARY_PROFILE_DB.parent.mkdir(parents=True, exist_ok=True)
|
||||
PRIMARY_PROFILE_DB.write_text(json.dumps(db, indent=2, ensure_ascii=False), encoding="utf-8")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def get_profile(account: str, force_new: bool = False) -> dict:
|
||||
"""按账号获取或创建画像(幂等:同账号复用同一套)。"""
|
||||
db = _load_db()
|
||||
if not force_new and account in db:
|
||||
return db[account]
|
||||
p = generate_profile()
|
||||
db[account] = p
|
||||
_save_db(db)
|
||||
return p
|
||||
@@ -0,0 +1,260 @@
|
||||
"""wupData 信封构造与补丁工具。
|
||||
|
||||
解析与改写 WUP 信封中的 cert、uid、session 等字段。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
import struct
|
||||
from pathlib import Path
|
||||
|
||||
INT8, INT16, INT32, INT64 = 0x00, 0x01, 0x02, 0x03
|
||||
STRING1, STRING4 = 0x06, 0x07
|
||||
MAP, LIST = 0x08, 0x09
|
||||
STRUCT_BEGIN, STRUCT_END = 0x0A, 0x0B
|
||||
ZERO, SIMPLE_LIST = 0x0C, 0x0D
|
||||
|
||||
DEFAULT_QURL_B64 = (
|
||||
"AAAD5hADLDxCAFpBBVYMaHV5YXVkYndlYnVpZgdkZWZhdWx0fQABA7gIAAIGCV93dXBfZGF0YR0AAQOKCgoMFgMxLjAm"
|
||||
"ynsiYXNzb2NpYXRpb25JZCI6MTg0NTQ5MzkyLCJmdW5jTmFtZSI6IiIsImdyb3VwIjowLCJpZCI6MTg0NTQ5MzkyLCJz"
|
||||
"ZXNzaW9uIjo1OTE0ODg1LCJzdGVwIjowLCJzdGlsbExvZ2luIjpmYWxzZSwidHJhY2VJZCI6IjBiOGYwOThmZjY0YTVi"
|
||||
"ZGMtMjY2OTItODI2MzkzOTQ3ODc2NjM1NTEzNjUiLCJ0eXBlIjoyLCJ1aWQiOjAsInVzZXJDb250ZXh0IjoiIn02BDUw"
|
||||
"MDhAA1a0UFF3ZW1BTjlOSGtaS29NcVdNUW5WUkl5cHFNVGFRRU9ybVhyMzd4UVZoUVpxclA1aVVLRVExMXh2RTB2cE1n"
|
||||
"a2xlajIzbEpGYmFHVW5LUEFhYnhWaUF0TnZyTkxBbXhBQzJyRGp6Qy9JSU0vSWFQeTdTcytvQXZqSmltR2pBS2RnVmpr"
|
||||
"bUhWV2Q2bEw4cUZScU4zWkJMamt4c2xUQjczaXNvallWcjlrSFhWdUh3SkxoM3YzZgB2AIYAlgALGgYgZWQwZGI4MzM0"
|
||||
"Y2FkZDIzNmMwMGNhZGY3ZTExYWI1YTUWBzEzLjQuMjImCTEuMC44MDEzODYARgkxMjcuMC4wLjFWBnhpYW9taWYACyoA"
|
||||
"ARYJTTIxMDJKMlNDJigwMmRmMzk4Nzk3NDMyZWFkZWZjYzEyNzY3MTE5YWQ1ZTgwOTk5Mzg5NgdhbmRyb2lkRg9NMjEw"
|
||||
"MkoyU0MsMzAsMTFmBDEwODB2BDIxMjCGKDdjNTM4N2UwNTM5YzAyM2MzMWM0ZmYwZTgwN2U3MjU2MTE3Mzg1ZWULMwAA"
|
||||
"ARdRuGVvRwAAAQREQ0JHY0QyN0liSWEvZnZ0UHhNT2xZdGJUY0M3bWRaUlJ3YzIyc2NnQkFyRTJ5eTZwSUdIQjNMK0tr"
|
||||
"MzVxVS9iaWc1Qk1TVVVSd3gzeS9wWVpWajRjd20rWEg1dnNrakR2SzNhUlhudEJGcURDQUUvaUVIbGs4ZXJ3VUJZdmJM"
|
||||
"aXlIb0YrSytQam5GTGJRMmlzSHVhcUtqTHAvWmRETDlxSit3VEVSb3h0ZjFuUzlTZ0l2N3lCaVIyMjd4N3F3RjllUTVu"
|
||||
"ckNaRitnRnczelVZb2N6Uk9jbHE1aXZDclhRZTVSZ3hOYkp5aWQ3ZkZqTVhYYlNQdHBIZ2p0TVJtdWp6RVRvPVYAZgAL"
|
||||
"BhB3dXB1ZGJyZXF1ZXN0X3YwHQAABQIAWkEFjJgMqAw="
|
||||
)
|
||||
|
||||
|
||||
def _read_len_int(d: bytes, p: int) -> tuple[int, int]:
|
||||
dt = d[p] & 0x0F
|
||||
if dt == ZERO:
|
||||
return 0, p + 1
|
||||
if dt == INT8:
|
||||
return struct.unpack_from(">b", d, p + 1)[0], p + 2
|
||||
if dt == INT16:
|
||||
return struct.unpack_from(">h", d, p + 1)[0], p + 3
|
||||
if dt == INT32:
|
||||
return struct.unpack_from(">i", d, p + 1)[0], p + 5
|
||||
raise ValueError(f"长度int类型异常 {dt:#x}@{p}")
|
||||
|
||||
|
||||
def _skip_value(d: bytes, p: int, dt: int) -> int:
|
||||
if dt == ZERO:
|
||||
return p
|
||||
if dt == INT8:
|
||||
return p + 1
|
||||
if dt == INT16:
|
||||
return p + 2
|
||||
if dt == INT32:
|
||||
return p + 4
|
||||
if dt == INT64:
|
||||
return p + 8
|
||||
if dt == STRING1:
|
||||
return p + 1 + d[p]
|
||||
if dt == STRING4:
|
||||
return p + 4 + struct.unpack_from(">i", d, p)[0]
|
||||
if dt == SIMPLE_LIST:
|
||||
p += 1
|
||||
n, p = _read_len_int(d, p)
|
||||
return p + n
|
||||
if dt == MAP:
|
||||
n, p = _read_len_int(d, p)
|
||||
for _ in range(n):
|
||||
h = d[p]
|
||||
p += 1
|
||||
kd = h & 0x0F
|
||||
if kd == STRING1:
|
||||
p += 1 + d[p]
|
||||
elif kd == STRING4:
|
||||
p += 4 + struct.unpack_from(">i", d, p)[0]
|
||||
else:
|
||||
raise ValueError(f"map key 类型 {kd:#x}")
|
||||
vh = d[p]
|
||||
p += 1
|
||||
p = _skip_value(d, p, vh & 0x0F)
|
||||
return p
|
||||
if dt == LIST:
|
||||
n, p = _read_len_int(d, p)
|
||||
eh = d[p]
|
||||
p += 1
|
||||
edt = eh & 0x0F
|
||||
for _ in range(n):
|
||||
p = _skip_value(d, p, edt)
|
||||
return p
|
||||
if dt == STRUCT_BEGIN:
|
||||
while True:
|
||||
h = d[p]
|
||||
p += 1
|
||||
sdt = h & 0x0F
|
||||
if sdt == STRUCT_END:
|
||||
break
|
||||
p = _skip_value(d, p, sdt)
|
||||
return p
|
||||
raise ValueError(f"未知类型 {dt:#x}@{p}")
|
||||
|
||||
|
||||
class Envelope:
|
||||
"""WUP 请求信封结构解析器与补丁器。"""
|
||||
|
||||
def __init__(self, raw_bytes: bytes):
|
||||
self.raw = bytearray(raw_bytes)
|
||||
self.tag4_span: tuple[int, int] | None = None
|
||||
self.meta_json_span: tuple[int, int] | None = None
|
||||
self.uid_off: int | None = None
|
||||
self.cert_off: int | None = None
|
||||
self.cert_len: int = 260
|
||||
self._parse()
|
||||
|
||||
@classmethod
|
||||
def load(cls, path: str | Path | None = None) -> "Envelope":
|
||||
"""加载信封模板,支持从文件加载或使用内嵌金样本。"""
|
||||
if path:
|
||||
p = Path(path)
|
||||
if p.exists():
|
||||
return cls._load_from_path(p)
|
||||
# 尝试查找 evidence/cert_keycap.json
|
||||
candidate = Path(__file__).resolve().parent.parent.parent / "evidence" / "cert_keycap.json"
|
||||
if candidate.exists():
|
||||
try:
|
||||
return cls._load_from_path(candidate)
|
||||
except Exception:
|
||||
pass
|
||||
return cls(base64.b64decode(DEFAULT_QURL_B64))
|
||||
|
||||
@classmethod
|
||||
def _load_from_path(cls, p: Path) -> "Envelope":
|
||||
if p.suffix == ".json":
|
||||
j = json.loads(p.read_text("utf-8"))
|
||||
q = next(e["data"] for e in j if e.get("type") == "qurl_done" and e.get("data"))
|
||||
return cls(base64.b64decode(q))
|
||||
return cls(p.read_bytes())
|
||||
|
||||
def _parse(self) -> None:
|
||||
d = self.raw
|
||||
p = 4
|
||||
# WUP header
|
||||
while p < len(d):
|
||||
h = d[p]
|
||||
p += 1
|
||||
tag, dt = (h >> 4) & 0x0F, h & 0x0F
|
||||
if tag == 4 and dt == INT32:
|
||||
self.tag4_span = (p, p + 4)
|
||||
p += 4
|
||||
elif tag == 7 and dt == SIMPLE_LIST:
|
||||
p += 1 # 元素类型头
|
||||
n, p = _read_len_int(d, p)
|
||||
self._parse_sbuffer(p, n)
|
||||
break
|
||||
else:
|
||||
p = _skip_value(d, p, dt)
|
||||
|
||||
def _parse_sbuffer(self, start: int, ln: int) -> None:
|
||||
d = self.raw
|
||||
p = start
|
||||
h = d[p]
|
||||
p += 1
|
||||
assert (h & 0x0F) == MAP
|
||||
cnt, p = _read_len_int(d, p)
|
||||
for _ in range(cnt):
|
||||
kh = d[p]
|
||||
p += 1
|
||||
kln = d[p]
|
||||
p += 1
|
||||
k = bytes(d[p:p + kln])
|
||||
p += kln
|
||||
vh = d[p]
|
||||
p += 1
|
||||
if k == b"_wup_data":
|
||||
assert (vh & 0x0F) == SIMPLE_LIST
|
||||
p += 1
|
||||
wup_data_len, p = _read_len_int(d, p)
|
||||
self._parse_wup_data_struct(p, wup_data_len)
|
||||
p += wup_data_len
|
||||
else:
|
||||
p = _skip_value(d, p, vh & 0x0F)
|
||||
|
||||
def _parse_wup_data_struct(self, start: int, ln: int) -> None:
|
||||
d = self.raw
|
||||
q = start
|
||||
assert (d[q] & 0x0F) == STRUCT_BEGIN
|
||||
q += 1
|
||||
while q < start + ln:
|
||||
hh = d[q]
|
||||
q += 1
|
||||
tag, dt = (hh >> 4) & 0x0F, hh & 0x0F
|
||||
if tag == 15:
|
||||
tag = d[q]
|
||||
q += 1
|
||||
if dt == STRUCT_END:
|
||||
break
|
||||
if tag == 0 and dt == STRUCT_BEGIN:
|
||||
while True:
|
||||
h2 = d[q]
|
||||
q += 1
|
||||
tg2, dt2 = (h2 >> 4) & 0x0F, h2 & 0x0F
|
||||
if tg2 == 15:
|
||||
tg2 = d[q]
|
||||
q += 1
|
||||
if dt2 == STRUCT_END:
|
||||
break
|
||||
vs = q
|
||||
q = _skip_value(d, q, dt2)
|
||||
if tg2 == 2 and dt2 in (STRING1, STRING4):
|
||||
off = vs + (4 if dt2 == STRING4 else 1)
|
||||
self.meta_json_span = (off, q)
|
||||
elif tag == 3 and dt == INT64:
|
||||
self.uid_off = q
|
||||
q += 8
|
||||
elif tag == 4 and dt == STRING4:
|
||||
ln_c = struct.unpack_from(">i", d, q)[0]
|
||||
self.cert_off = q + 4
|
||||
self.cert_len = ln_c
|
||||
q = self.cert_off + ln_c
|
||||
else:
|
||||
q = _skip_value(d, q, dt)
|
||||
if self.uid_off is None or self.cert_off is None:
|
||||
raise ValueError("未在信封中定位到 t3(uid) 或 t4(cert)")
|
||||
|
||||
@property
|
||||
def uid(self) -> int:
|
||||
return struct.unpack_from(">Q", self.raw, self.uid_off)[0]
|
||||
|
||||
def patch_uid(self, uid: int) -> "Envelope":
|
||||
struct.pack_into(">Q", self.raw, self.uid_off, uid)
|
||||
return self
|
||||
|
||||
@property
|
||||
def cert_b64(self) -> bytes:
|
||||
return bytes(self.raw[self.cert_off:self.cert_off + self.cert_len])
|
||||
|
||||
def patch_cert(self, cert: bytes) -> "Envelope":
|
||||
b64 = base64.b64encode(cert)
|
||||
if len(b64) != self.cert_len:
|
||||
raise ValueError(
|
||||
f"证书b64长度不符: {len(b64)} != 模板 {self.cert_len} (cert {len(cert)}B)"
|
||||
)
|
||||
self.raw[self.cert_off:self.cert_off + self.cert_len] = b64
|
||||
return self
|
||||
|
||||
def patch_session(self, session: int) -> "Envelope":
|
||||
if self.tag4_span:
|
||||
struct.pack_into(">I", self.raw, self.tag4_span[0], session & 0xFFFFFFFF)
|
||||
d = self.raw
|
||||
i = d.find(b"wupudbrequest_v0")
|
||||
if i >= 0:
|
||||
j = i + len(b"wupudbrequest_v0") + 4
|
||||
if j + 4 <= len(d):
|
||||
struct.pack_into(">I", d, j, session & 0xFFFFFFFF)
|
||||
return self
|
||||
|
||||
def wup_b64(self) -> str:
|
||||
return base64.b64encode(bytes(self.raw)).decode()
|
||||
@@ -0,0 +1,78 @@
|
||||
"""虎牙证书 nonce(rnd) 本地生成器。
|
||||
|
||||
基于 XXTEA 算法与 uid + k1 派生密钥。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import struct
|
||||
import time
|
||||
|
||||
K1_DEFAULT = "865a4924a40897ac1fcfe6b4c2cbb0e3"
|
||||
|
||||
|
||||
def _xxtea_encrypt_words(v: list[int], k: list[int]) -> list[int]:
|
||||
"""标准 XXTEA (delta=0x9e3779b9, rounds=6+52/n)。"""
|
||||
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 存原始长度。"""
|
||||
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)
|
||||
|
||||
|
||||
def pack_u64(v: int) -> bytes:
|
||||
"""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: 计数器(通常为 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)
|
||||
@@ -0,0 +1,35 @@
|
||||
"""虎牙 Udb AES 加解密工具。
|
||||
|
||||
基于 AES-128-ECB 与 0 填充。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from Crypto.Cipher import AES
|
||||
|
||||
MASTER_KEY_STR = b"HuyaUdb1928374650qwertyuiop"
|
||||
|
||||
|
||||
def encrypt_block(key16: bytes, block16: bytes) -> bytes:
|
||||
return AES.new(key16, AES.MODE_ECB).encrypt(block16)
|
||||
|
||||
|
||||
def decrypt_block(key16: bytes, block16: bytes) -> bytes:
|
||||
return AES.new(key16, AES.MODE_ECB).decrypt(block16)
|
||||
|
||||
|
||||
def zero_pad(data: bytes) -> bytes:
|
||||
"""UdbAESUtil::encrypt 填充:补 '\\0' 到 16 的倍数。"""
|
||||
if len(data) % 16 == 0:
|
||||
return data
|
||||
return data + b"\x00" * (16 - len(data) % 16)
|
||||
|
||||
|
||||
def udb_encrypt(key16: bytes, plain: bytes) -> bytes:
|
||||
"""AES-128-ECB 加密,带零填充。"""
|
||||
p = zero_pad(plain)
|
||||
return AES.new(key16, AES.MODE_ECB).encrypt(p)
|
||||
|
||||
|
||||
def udb_decrypt(key16: bytes, ct: bytes) -> bytes:
|
||||
"""AES-128-ECB 解密,去除尾部零填充。"""
|
||||
return AES.new(key16, AES.MODE_ECB).decrypt(ct).rstrip(b"\x00")
|
||||
@@ -0,0 +1,256 @@
|
||||
"""虎牙 App 密码登录 WUP(TAF) 请求帧编码器。
|
||||
|
||||
纯 Python 构造符合 TAF/WUP 协议的请求二进制帧。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import random
|
||||
import struct
|
||||
import time as _time
|
||||
from typing import Any, Dict
|
||||
|
||||
# TAF 类型标签
|
||||
INT8, INT16, INT32, INT64 = 0x00, 0x01, 0x02, 0x03
|
||||
STRING1, STRING4 = 0x06, 0x07
|
||||
MAP, LIST = 0x08, 0x09
|
||||
STRUCT_BEGIN, STRUCT_END = 0x0A, 0x0B
|
||||
ZERO, SIMPLE_LIST = 0x0C, 0x0D
|
||||
|
||||
|
||||
class _Writer:
|
||||
"""TAF 输出流。"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.buf = bytearray()
|
||||
|
||||
def get(self) -> bytes:
|
||||
return bytes(self.buf)
|
||||
|
||||
def head(self, tag: int, dtype: int) -> None:
|
||||
if tag < 15:
|
||||
self.buf.append((tag << 4) | dtype)
|
||||
else:
|
||||
self.buf.append(0xF0 | dtype)
|
||||
self.buf.append(tag)
|
||||
|
||||
def int8(self, tag: int, v: int) -> None:
|
||||
if v == 0:
|
||||
self.head(tag, ZERO)
|
||||
else:
|
||||
self.head(tag, INT8)
|
||||
self.buf += struct.pack("b", v)
|
||||
|
||||
def int16(self, tag: int, v: int) -> None:
|
||||
if -128 <= v <= 127:
|
||||
self.int8(tag, v)
|
||||
else:
|
||||
self.head(tag, INT16)
|
||||
self.buf += struct.pack(">h", v)
|
||||
|
||||
def int32(self, tag: int, v: int) -> None:
|
||||
if -32768 <= v <= 32767:
|
||||
self.int16(tag, v)
|
||||
else:
|
||||
self.head(tag, INT32)
|
||||
self.buf += struct.pack(">i", v)
|
||||
|
||||
def int64(self, tag: int, v: int) -> None:
|
||||
if -2147483648 <= v <= 2147483647:
|
||||
self.int32(tag, v)
|
||||
else:
|
||||
self.head(tag, INT64)
|
||||
self.buf += struct.pack(">q", v)
|
||||
|
||||
def string(self, tag: int, s: str) -> None:
|
||||
b = s.encode("utf-8")
|
||||
if len(b) > 255:
|
||||
self.head(tag, STRING4)
|
||||
self.buf += struct.pack(">I", len(b))
|
||||
else:
|
||||
self.head(tag, STRING1)
|
||||
self.buf += struct.pack("B", len(b))
|
||||
self.buf += b
|
||||
|
||||
def bytes(self, tag: int, b: bytes) -> None:
|
||||
"""SIMPLE_LIST (byte[])。"""
|
||||
self.head(tag, SIMPLE_LIST)
|
||||
self.head(0, INT8)
|
||||
self.int32(0, len(b))
|
||||
self.buf += b
|
||||
|
||||
def struct_begin(self, tag: int) -> None:
|
||||
self.head(tag, STRUCT_BEGIN)
|
||||
|
||||
def struct_end(self) -> None:
|
||||
self.head(0, STRUCT_END)
|
||||
|
||||
def list_begin(self, tag: int, n: int) -> None:
|
||||
self.head(tag, LIST)
|
||||
self.int32(0, n)
|
||||
|
||||
def map_begin(self, tag: int, n: int) -> None:
|
||||
self.head(tag, MAP)
|
||||
self.int32(0, n)
|
||||
|
||||
|
||||
def _build_meta_json(session: int, trace_id: str) -> str:
|
||||
"""构造 _wup_data.t0.t2 元数据 JSON。"""
|
||||
meta: Dict[str, Any] = {
|
||||
"associationId": 8193,
|
||||
"funcName": "hypasswordLogin",
|
||||
"group": 1,
|
||||
"id": 4097,
|
||||
"session": session,
|
||||
"step": 1,
|
||||
"stillLogin": False,
|
||||
"traceId": trace_id,
|
||||
"type": 3,
|
||||
"uid": 0,
|
||||
"userContext": "",
|
||||
}
|
||||
return json.dumps(meta, ensure_ascii=False, separators=(",", ":"))
|
||||
|
||||
|
||||
def _make_name(uid_str: str) -> str:
|
||||
"""登录名 = "hy_" + 虎牙号。"""
|
||||
if uid_str.startswith("hy_"):
|
||||
return uid_str
|
||||
return "hy_" + uid_str
|
||||
|
||||
|
||||
def make_user_action(now_ms: int | None = None) -> str:
|
||||
"""生成一条随机的 userAction 风控行为 JSON。"""
|
||||
if now_ms is None:
|
||||
now_ms = int(_time.time() * 1000)
|
||||
t1 = now_ms
|
||||
t2 = now_ms + random.randint(200, 900)
|
||||
return json.dumps(
|
||||
{
|
||||
"curl": "登录页",
|
||||
"furl": "我的",
|
||||
"latitude": "-1.0",
|
||||
"longitude": "-1.0",
|
||||
"ssid": "",
|
||||
"user_action": [
|
||||
{"id": "24", "time": str(t1),
|
||||
"x": str(random.randint(150, 900)),
|
||||
"y": str(random.randint(800, 1600))},
|
||||
{"id": "11", "time": str(t2),
|
||||
"x": str(random.randint(150, 900)),
|
||||
"y": str(random.randint(800, 1600))},
|
||||
],
|
||||
},
|
||||
ensure_ascii=False,
|
||||
separators=(",", ":"),
|
||||
)
|
||||
|
||||
|
||||
def make_trace_id(pid: int = 0) -> str:
|
||||
"""生成 traceId,格式 `<hex16>-<pid>-<毫秒时间戳>`。"""
|
||||
return f"{random.getrandbits(64):016x}-{pid}-{int(_time.time() * 1000)}"
|
||||
|
||||
|
||||
def _build_wup_data(w: _Writer, uid_str: str, sha1_password: str,
|
||||
safedeviceid: str, hdid: str, session: int,
|
||||
trace_id: str, user_action_json: str,
|
||||
device_info: Dict[str, str]) -> None:
|
||||
"""编码 _wup_data struct。"""
|
||||
meta_json = _build_meta_json(session, trace_id)
|
||||
name = _make_name(uid_str)
|
||||
|
||||
w.struct_begin(0)
|
||||
|
||||
# -- t0: 请求头 struct --
|
||||
w.struct_begin(0)
|
||||
w.int8(0, 0)
|
||||
w.string(1, "1.0")
|
||||
w.string(2, meta_json)
|
||||
w.string(3, "5008")
|
||||
w.int8(4, 3)
|
||||
w.string(5, safedeviceid)
|
||||
w.string(6, "")
|
||||
w.string(7, "")
|
||||
w.string(8, user_action_json)
|
||||
w.string(9, "")
|
||||
w.struct_end()
|
||||
|
||||
# -- t1: 设备信息 struct --
|
||||
di = device_info
|
||||
w.struct_begin(1)
|
||||
w.string(0, hdid)
|
||||
w.string(1, di.get("app_version", "13.4.22"))
|
||||
w.string(2, di.get("sdk_version", "1.0.80138"))
|
||||
w.string(3, "")
|
||||
w.string(4, di.get("ip", "127.0.0.1"))
|
||||
w.string(5, di.get("vendor", "xiaomi"))
|
||||
w.string(6, "")
|
||||
w.struct_end()
|
||||
|
||||
# -- t2: 屏幕/设备特征 struct --
|
||||
w.struct_begin(2)
|
||||
w.int8(0, 1)
|
||||
w.string(1, di.get("model", "M2102J2SC"))
|
||||
w.string(2, di.get("fingerprint", ""))
|
||||
w.string(3, di.get("os", "android"))
|
||||
w.string(4, di.get("screen", "M2102J2SC,30,11"))
|
||||
w.string(6, str(di.get("width", "1080")))
|
||||
w.string(7, str(di.get("height", "2120")))
|
||||
w.string(8, di.get("device_id", ""))
|
||||
w.struct_end()
|
||||
|
||||
# -- 登录字段 --
|
||||
w.string(3, name)
|
||||
w.string(4, sha1_password)
|
||||
w.list_begin(5, 1)
|
||||
w.string(0, "5008")
|
||||
w.int8(6, 1)
|
||||
w.map_begin(7, 0)
|
||||
w.bytes(8, b"")
|
||||
|
||||
w.struct_end()
|
||||
|
||||
|
||||
def build_password_login_wup(
|
||||
uid_str: str,
|
||||
sha1_password: str,
|
||||
safedeviceid: str,
|
||||
hdid: str,
|
||||
session: int,
|
||||
trace_id: str,
|
||||
user_action_json: str,
|
||||
device_info: Dict[str, str],
|
||||
) -> bytes:
|
||||
"""构造密码登录的 WUP TAF 请求体。"""
|
||||
wd = _Writer()
|
||||
_build_wup_data(wd, uid_str, sha1_password, safedeviceid, hdid,
|
||||
session, trace_id, user_action_json, device_info)
|
||||
wup_data = wd.get()
|
||||
|
||||
req = _Writer()
|
||||
req.int32(0, session)
|
||||
wupdbreq_v0 = req.get()
|
||||
|
||||
sb = _Writer()
|
||||
sb.map_begin(0, 2)
|
||||
sb.string(0, "_wup_data")
|
||||
sb.bytes(1, wup_data)
|
||||
sb.string(0, "wupudbrequest_v0")
|
||||
sb.bytes(1, wupdbreq_v0)
|
||||
s_buffer = sb.get()
|
||||
|
||||
w = _Writer()
|
||||
w.int16(1, 3)
|
||||
w.int8(2, 0)
|
||||
w.int8(3, 0)
|
||||
w.int32(4, session)
|
||||
w.string(5, "huyaudbwebui")
|
||||
w.string(6, "hypasswordLogin")
|
||||
w.bytes(7, s_buffer)
|
||||
w.int32(8, 0)
|
||||
w.map_begin(9, 0)
|
||||
w.map_begin(10, 0)
|
||||
wup_body = w.get()
|
||||
|
||||
return struct.pack(">I", 4 + len(wup_body)) + wup_body
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,81 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
dfpReport 同步 pair 分析器
|
||||
- 从 evidence/dfp_pair_sync.json 提取每个 wire 的 tag2 密文 (去掉 10B 魔数 + 9B 尾部字段)
|
||||
- 结合 plains 内存缓冲 (JSON 头 + 二进制尾) 计算 keystream
|
||||
- 验证输入布局假设: input = [JSON 554B][binary L-554B]
|
||||
"""
|
||||
import json, binascii, sys
|
||||
|
||||
MAGIC = bytes.fromhex('571882cf664bb39401ee')
|
||||
TRAILER = bytes.fromhex('00400c0b8c980ca80c')
|
||||
EVID = 'evidence/dfp_pair_sync.json'
|
||||
|
||||
def load_pairs():
|
||||
d = json.load(open(EVID))
|
||||
pairs = []
|
||||
for i in range(0, len(d), 2):
|
||||
w, p = d[i], d[i+1]
|
||||
raw = binascii.unhexlify(w['hex'])
|
||||
idx = raw.find(b'\r\n\r\n')
|
||||
body = raw[idx+4:]
|
||||
mp = body.find(MAGIC)
|
||||
assert body.endswith(TRAILER), f"pair{i//2} trailer mismatch"
|
||||
tag2 = body[mp+10 : len(body)-len(TRAILER)]
|
||||
plains = []
|
||||
for pl in p['plains']:
|
||||
if isinstance(pl, dict) and pl.get('hex'):
|
||||
plains.append({'addr': pl['addr'], 'buf': binascii.unhexlify(pl['hex'])})
|
||||
pairs.append({'t': w['t'], 'tag2': tag2, 'plains': plains})
|
||||
return pairs
|
||||
|
||||
def find_json_end(buf):
|
||||
"""locate the end of the 554B JSON string at buf head; return index after closing brace + boundaries of following non-zero region"""
|
||||
# JSON starts with '{' at offset 0. Find the matching close at known length 554
|
||||
if buf[:1] != b'{':
|
||||
return None
|
||||
# standard JSON serialized 554B for this device
|
||||
json_bytes = buf[:554]
|
||||
assert json_bytes.startswith(b'{"appId":"5008"'), json_bytes[:40]
|
||||
return 554
|
||||
|
||||
def nonzero_ranges(buf, start):
|
||||
"""return list of (start,end) runs of non-zero bytes in buf[start:]"""
|
||||
runs = []
|
||||
in_run = False
|
||||
for i in range(start, len(buf)):
|
||||
if buf[i] != 0 and not in_run:
|
||||
s = i; in_run = True
|
||||
elif buf[i] == 0 and in_run:
|
||||
runs.append((s, i)); in_run = False
|
||||
if in_run:
|
||||
runs.append((s, len(buf)))
|
||||
return runs
|
||||
|
||||
def main():
|
||||
pairs = load_pairs()
|
||||
print(f"{len(pairs)} pairs loaded")
|
||||
for k, pr in enumerate(pairs):
|
||||
t2 = pr['tag2']
|
||||
print(f"\n===== pair{k} t={pr['t']} tag2_len={len(t2)} (data={len(t2)}) =====")
|
||||
for pi, pl in enumerate(pr['plains']):
|
||||
buf = pl['buf']
|
||||
je = find_json_end(buf)
|
||||
runs = nonzero_ranges(buf, 0) if je else []
|
||||
# focus: runs after JSON end
|
||||
post = [r for r in runs if r[0] >= 554]
|
||||
print(f" plain{pi} addr={pl['addr']} buflen={len(buf)} json@0..{je if je else '?'}")
|
||||
# show a compact view: for each 256B block 0..8192 whether zero or nonzero
|
||||
blocks = []
|
||||
for b in range(0, len(buf), 256):
|
||||
chunk = buf[b:b+256]
|
||||
nz = sum(1 for x in chunk if x != 0)
|
||||
blocks.append(f"{b//256}:{nz}")
|
||||
print(" nz/256B:", ' '.join(blocks))
|
||||
# print runs summary first 6
|
||||
for r in post[:6]:
|
||||
print(f" nonzero {r[0]}..{r[1]} (len {r[1]-r[0]}) head={buf[r[0]:r[0]+32].hex()}")
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,215 @@
|
||||
#!/usr/bin/env python3
|
||||
"""定位 dfpReport t2 (魔数+密文) 原生组装函数 —— 轻量版。
|
||||
|
||||
v1 教训: 全量 memcpy hook (24B~20KB 都插桩) 把 App 拖到 ANR 被杀。
|
||||
本版只插桩大拷贝 (2500~20000B):
|
||||
- src/dst 命中 10B 魔数 -> 深捕获 + 回溯 (稀有, 不拖慢)
|
||||
- 其余大拷贝只压轻量环形 (size/src/dst), 供 SSL_write 时与输出缓冲关联
|
||||
流程: spawn+三件套 bypass -> 自动 dfpReport -> 命中即持久化 (实时写文件)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import frida
|
||||
|
||||
PACKAGE = "com.duowan.kiwi"
|
||||
RE = Path("/Users/yml/codes/Reverse-Engineering-Agent-Universal-v3.0")
|
||||
OUT = Path("/Users/yml/codes/douyu_login_py/evidence/dfp_builder2.json")
|
||||
|
||||
JS = r"""
|
||||
'use strict';
|
||||
Process.setExceptionHandler(function(d){ return true; });
|
||||
send({type:'armed'});
|
||||
|
||||
var MAGIC = [0x57,0x18,0x82,0xcf,0x66,0x4b,0xb3,0x94,0x01,0xee];
|
||||
var processStart = Date.now();
|
||||
|
||||
function hexb(p, n){
|
||||
try{
|
||||
var arr = Array.from(new Uint8Array(p.readByteArray(n)));
|
||||
var s = '';
|
||||
for (var i=0;i<arr.length;i++) s += ('0'+arr[i].toString(16)).slice(-2);
|
||||
return s;
|
||||
}catch(e){ return null; }
|
||||
}
|
||||
function startsWith(p, arr){
|
||||
try{
|
||||
var b = new Uint8Array(p.readByteArray(arr.length));
|
||||
for (var i=0;i<arr.length;i++) if (b[i]!==arr[i]) return false;
|
||||
return true;
|
||||
}catch(e){ return false; }
|
||||
}
|
||||
function bt(){
|
||||
var out = [];
|
||||
try{
|
||||
var list = Thread.backtrace(this.context, Backtracer.ACCURATE);
|
||||
for (var i=0;i<list.length && i<40;i++){
|
||||
var a = list[i];
|
||||
var m = Process.findModuleByAddress(a);
|
||||
if (m){
|
||||
out.push(m.name + '!' + a.sub(m.base).toString());
|
||||
}
|
||||
}
|
||||
}catch(e){}
|
||||
return out;
|
||||
}
|
||||
|
||||
var ring = [];
|
||||
function onCopy(fn, dst, src, size){
|
||||
var now = Date.now() - processStart;
|
||||
if (size >= 2500 && size <= 20000){
|
||||
ring.push({t: now, fn: fn, size: size, dst: String(dst), src: String(src)});
|
||||
if (ring.length > 200) ring.shift();
|
||||
var dm = startsWith(dst, MAGIC);
|
||||
var sm = startsWith(src, MAGIC);
|
||||
if (dm || sm){
|
||||
var cap = {t: now, fn: fn, size: size, dst: String(dst), src: String(src),
|
||||
dmagic: dm, smagic: sm,
|
||||
srchead: hexb(src, 64), dsthead: hexb(dst, 64),
|
||||
stack: bt.call(this)};
|
||||
ring.push({t: now, fn: 'CAP:'+fn, size: size, dst: String(dst), src: String(src),
|
||||
stack: cap.stack});
|
||||
send({type:'capture', cap: cap});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function hookLibc(){
|
||||
['memcpy','memmove'].forEach(function(fn){
|
||||
try{
|
||||
var p = Module.findExportByName('libc.so', fn);
|
||||
if (!p) return;
|
||||
Interceptor.attach(p, {
|
||||
onEnter: function(a){
|
||||
try{ onCopy.call(this, fn, a[0], a[1], a[2].toInt32()); }catch(e){}
|
||||
}
|
||||
});
|
||||
send({type:'hooked_copy', fn: fn, at: String(p)});
|
||||
}catch(e){ send({type:'hook_fail', fn: fn, e: String(e)}); }
|
||||
});
|
||||
}
|
||||
hookLibc();
|
||||
|
||||
function scanMagic(){
|
||||
var hits = [];
|
||||
try{
|
||||
Process.enumerateRanges('r--').forEach(function(r){
|
||||
if (r.size > 1024*1024*256) return;
|
||||
try{
|
||||
var m = Memory.scanSync(r.base, r.size, '57 18 82 cf 66 4b b3 94 01 ee');
|
||||
m.slice(0, 14).forEach(function(x){
|
||||
var mm = Process.findModuleByAddress(x.address);
|
||||
hits.push({addr: String(x.address), mod: mm ? mm.name : 'anon',
|
||||
pre: hexb(x.address.sub(64), 64), post: hexb(x.address.add(10), 48)});
|
||||
});
|
||||
}catch(e){}
|
||||
});
|
||||
}catch(e){}
|
||||
return hits;
|
||||
}
|
||||
|
||||
try{
|
||||
var r = new ApiResolver('module');
|
||||
r.enumerateMatchesSync('exports:*!SSL_write').forEach(function(m){
|
||||
Interceptor.attach(m.address, {
|
||||
onEnter: function(a){
|
||||
var len = a[2].toInt32();
|
||||
if (len < 100 || len > 20000) return;
|
||||
var head = '';
|
||||
try{ head = a[1].readCString(Math.min(len, 2000)); }catch(e){ return; }
|
||||
if (head.indexOf('dfpReport') < 0) return;
|
||||
send({type:'wire', len:len, hex:hexb(a[1], len), t:Date.now(),
|
||||
ring: ring.slice(-150), magics: scanMagic()});
|
||||
}
|
||||
});
|
||||
});
|
||||
send({type:'ssl_hooked'});
|
||||
}catch(e){ send({type:'ssl_err', e:String(e)}); }
|
||||
"""
|
||||
|
||||
|
||||
def main():
|
||||
device = frida.get_device_manager().add_remote_device("127.0.0.1:31877")
|
||||
pid = device.spawn([PACKAGE])
|
||||
print(f"[*] spawned {PACKAGE} pid={pid}", flush=True)
|
||||
session = device.attach(pid)
|
||||
|
||||
events = []
|
||||
|
||||
def on_message(message, data):
|
||||
if message.get("type") == "error":
|
||||
print("[JS-ERR]", str(message)[:300], flush=True)
|
||||
return
|
||||
if message.get("type") != "send":
|
||||
return
|
||||
p = message.get("payload") or {}
|
||||
t = p.get("type")
|
||||
if t == "armed":
|
||||
print("[*] JS armed", flush=True)
|
||||
elif t == "hooked_copy":
|
||||
print(f"[hook] {p.get('fn')} @ {p.get('at')}", flush=True)
|
||||
elif t == "ssl_hooked":
|
||||
print("[*] SSL_write hooked", flush=True)
|
||||
elif t == "capture":
|
||||
c = p.get('cap') or {}
|
||||
print(f"[CAPTURE] {c.get('fn')} size={c.get('size')} dmagic={c.get('dmagic')} smagic={c.get('smagic')}", flush=True)
|
||||
print(f" src={c.get('src')} -> dst={c.get('dst')}", flush=True)
|
||||
print(f" srchead={c.get('srchead','')[:40]} dsthead={c.get('dsthead','')[:40]}", flush=True)
|
||||
print(f" stack: {(c.get('stack') or [])[:16]}", flush=True)
|
||||
events.append({"type": "capture", "cap": c})
|
||||
OUT.write_text(json.dumps(events))
|
||||
elif t == "wire":
|
||||
print(f"[wire] len={p.get('len')}", flush=True)
|
||||
ev = {"type": "wire", "len": p.get('len'), "t": p.get('t'), "hex": p.get('hex'),
|
||||
"ring": p.get('ring'), "magics": p.get('magics')}
|
||||
events.append(ev)
|
||||
OUT.write_text(json.dumps(events))
|
||||
mg = p.get('magics') or []
|
||||
print(f" magics: {len(mg)}", flush=True)
|
||||
for mm in mg[:8]:
|
||||
print(f" {mm.get('addr')} mod={mm.get('mod')} pre={mm.get('pre','')[-32:]} post={mm.get('post','')[:20]}", flush=True)
|
||||
# ring entries that wrote near magic addresses
|
||||
if mg:
|
||||
for mm in mg[:3]:
|
||||
try:
|
||||
target = int(mm.get('addr'), 16)
|
||||
for e in (p.get('ring') or []):
|
||||
try:
|
||||
d = int(e.get('dst'), 16)
|
||||
s = int(e.get('dst'), 16) + (e.get('size') or 0)
|
||||
if d <= target <= s:
|
||||
print(f" ring-write-to-magic: {e}", flush=True)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
script = session.create_script(JS)
|
||||
script.on("message", on_message)
|
||||
script.load()
|
||||
print("[*] 主JS 已装载, 加载 bypass + resume", flush=True)
|
||||
session.create_script(
|
||||
(RE / "evidence/scripts/bypass_msaoaid_maps_art_callsite.js").read_text()
|
||||
).load()
|
||||
session.create_script(
|
||||
(RE / "evidence/scripts/mask_frida_maps_only.js").read_text()
|
||||
).load()
|
||||
device.resume(pid)
|
||||
time.sleep(11)
|
||||
session.create_script(
|
||||
(RE / "evidence/scripts/patch_guard_block_termination.js").read_text()
|
||||
).load()
|
||||
print("[*] 已 resume。等待自动 dfpReport", flush=True)
|
||||
try:
|
||||
while True:
|
||||
time.sleep(5)
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
print(f"[*] 结束 -> {OUT}", flush=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,211 @@
|
||||
#!/usr/bin/env python3
|
||||
"""hook libhydeviceid.so!0x91ce8: t2 [魔数+密文] 拷贝点 -> 抓完整加密输入。
|
||||
|
||||
0x91ce8 是 t2 组装中的 memcpy 调用点 (src 已含 魔数+密文, size~3867-3986)。
|
||||
onEnter:
|
||||
1) 从 x0/x1/x2/sp 定位 [魔数+密文] 输出缓冲, dump 4096B
|
||||
2) 全内存扫 {"appId":"5008 找加密输入 (JSON+二进制) 缓冲, dump 4096B
|
||||
3) SSL_write 抓 wire (密文基准)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import frida
|
||||
|
||||
PACKAGE = "com.duowan.kiwi"
|
||||
RE = Path("/Users/yml/codes/Reverse-Engineering-Agent-Universal-v3.0")
|
||||
OUT = Path("/Users/yml/codes/douyu_login_py/evidence/dfp_input_capture.json")
|
||||
|
||||
JS = r"""
|
||||
'use strict';
|
||||
Process.setExceptionHandler(function(d){ return true; });
|
||||
send({type:'armed'});
|
||||
|
||||
var MAGIC_STR = '57 18 82 cf 66 4b b3 94 01 ee';
|
||||
var JSON_STR = '7b 22 61 70 70 49 64 22 3a 22 35 30 30 38'; // {"appId":"5008
|
||||
|
||||
function hexb(p, n){
|
||||
try{
|
||||
var arr = Array.from(new Uint8Array(p.readByteArray(n)));
|
||||
var s = '';
|
||||
for (var i=0;i<arr.length;i++) s += ('0'+arr[i].toString(16)).slice(-2);
|
||||
return s;
|
||||
}catch(e){ return null; }
|
||||
}
|
||||
function startsWithHex(p, hexstr){
|
||||
try{
|
||||
var want = hexstr.split(' ').map(function(x){return parseInt(x,16);});
|
||||
var b = new Uint8Array(p.readByteArray(want.length));
|
||||
for (var i=0;i<want.length;i++) if (b[i]!==want[i]) return false;
|
||||
return true;
|
||||
}catch(e){ return false; }
|
||||
}
|
||||
function scanPattern(pattern, cap){
|
||||
var hits = [];
|
||||
try{
|
||||
Process.enumerateRanges('r--').forEach(function(r){
|
||||
if (r.size > 1024*1024*256) return;
|
||||
if (hits.length >= cap) return;
|
||||
try{
|
||||
var m = Memory.scanSync(r.base, r.size, pattern);
|
||||
m.slice(0, cap - hits.length).forEach(function(x){
|
||||
hits.push({addr: String(x.address),
|
||||
mod: (function(){ var mm=Process.findModuleByAddress(x.address); return mm? mm.name : 'anon'; })()});
|
||||
});
|
||||
}catch(e){}
|
||||
});
|
||||
}catch(e){}
|
||||
return hits;
|
||||
}
|
||||
function dumpAround(addr, pre, post){
|
||||
try{
|
||||
var p = addr.sub(pre);
|
||||
var d = hexb(p, pre + post);
|
||||
return {base: String(p), pre: pre, post: post, hex: d};
|
||||
}catch(e){ return null; }
|
||||
}
|
||||
|
||||
// 找到含魔数的缓冲 (扫描参数指向的内存)
|
||||
function findMagicBuf(ctx){
|
||||
var cands = [];
|
||||
[ctx.x0, ctx.x1, ctx.x2, ctx.x3, ctx.x4].forEach(function(p, i){
|
||||
try{
|
||||
if (p.isNull()) return;
|
||||
if (startsWithHex(p, MAGIC_STR)) cands.push({reg: 'x'+i, addr: String(p)});
|
||||
// 也看看指针对应的数据是否在 sp 附近 (std::string 结构: ptr 在偏移8/16)
|
||||
var q = p.readPointer();
|
||||
if (!q.isNull() && startsWithHex(q, MAGIC_STR)) cands.push({reg: 'x'+i+'_ptr', addr: String(q)});
|
||||
}catch(e){}
|
||||
});
|
||||
// 扫最近的匿名内存找 [魔数+密文] (限 512MB 内, 快)
|
||||
if (!cands.length){
|
||||
try{
|
||||
var m = Memory.scanSync(ctx.sp, 64*1024*1024, MAGIC_STR);
|
||||
m.slice(0, 3).forEach(function(x){ cands.push({reg: 'sp_scan', addr: String(x.address)}); });
|
||||
}catch(e){}
|
||||
}
|
||||
return cands;
|
||||
}
|
||||
|
||||
var nCalls = 0;
|
||||
var hooked91 = false;
|
||||
function tryHook91(){
|
||||
if (hooked91) return;
|
||||
try{
|
||||
var base = Process.getModuleByName('libhydeviceid.so').base;
|
||||
Interceptor.attach(base.add(0x91ce8), {
|
||||
onEnter: function(a){
|
||||
nCalls++;
|
||||
var ctx = this.context;
|
||||
var ev = {type:'t2copy', n:nCalls, x0:String(ctx.x0), x1:String(ctx.x1),
|
||||
x2:String(ctx.x2), x3:String(ctx.x3), x4:String(ctx.x4)};
|
||||
var mbs = findMagicBuf(ctx);
|
||||
ev.magicbufs = mbs;
|
||||
for (var i=0;i<mbs.length && i<2;i++){
|
||||
var d = dumpAround(ptr(mbs[i].addr), 0, 4096);
|
||||
ev['magicdump'+i] = d;
|
||||
}
|
||||
// 全内存扫明文 JSON (加密输入)
|
||||
var jh = scanPattern(JSON_STR, 6);
|
||||
ev.jsonhits = jh;
|
||||
for (var j=0;j<jh.length && j<4;j++){
|
||||
var d = dumpAround(ptr(jh[j].addr), 0, 4096);
|
||||
ev['jsondump'+j] = d;
|
||||
}
|
||||
send({type:'t2copy', ev: ev});
|
||||
}
|
||||
});
|
||||
hooked91 = true;
|
||||
send({type:'hooked_91ce8', base:String(base)});
|
||||
}catch(e){
|
||||
setTimeout(tryHook91, 1000);
|
||||
}
|
||||
}
|
||||
setTimeout(tryHook91, 500);
|
||||
|
||||
try{
|
||||
var r = new ApiResolver('module');
|
||||
r.enumerateMatchesSync('exports:*!SSL_write').forEach(function(m){
|
||||
Interceptor.attach(m.address, {
|
||||
onEnter: function(a){
|
||||
var len = a[2].toInt32();
|
||||
if (len < 100 || len > 20000) return;
|
||||
var head = '';
|
||||
try{ head = a[1].readCString(Math.min(len, 2000)); }catch(e){ return; }
|
||||
if (head.indexOf('dfpReport') < 0) return;
|
||||
send({type:'wire', len:len, hex:hexb(a[1], len), t:Date.now()});
|
||||
}
|
||||
});
|
||||
});
|
||||
send({type:'ssl_hooked'});
|
||||
}catch(e){ send({type:'ssl_err', e:String(e)}); }
|
||||
"""
|
||||
|
||||
|
||||
def main():
|
||||
device = frida.get_device_manager().add_remote_device("127.0.0.1:31877")
|
||||
pid = device.spawn([PACKAGE])
|
||||
print(f"[*] spawned {PACKAGE} pid={pid}", flush=True)
|
||||
session = device.attach(pid)
|
||||
|
||||
events = []
|
||||
|
||||
def on_message(message, data):
|
||||
if message.get("type") == "error":
|
||||
print("[JS-ERR]", str(message)[:300], flush=True)
|
||||
return
|
||||
if message.get("type") != "send":
|
||||
return
|
||||
p = message.get("payload") or {}
|
||||
t = p.get("type")
|
||||
if t == "armed":
|
||||
print("[*] JS armed", flush=True)
|
||||
elif t == "hooked_91ce8":
|
||||
print(f"[*] hooked 0x91ce8 @ {p.get('base')}", flush=True)
|
||||
elif t == "ssl_hooked":
|
||||
print("[*] SSL_write hooked", flush=True)
|
||||
elif t == "hook_err":
|
||||
print("[hook_err]", p.get('e'), flush=True)
|
||||
elif t == "t2copy":
|
||||
ev = p.get('ev') or {}
|
||||
print(f"[t2copy] n={ev.get('n')} x0={ev.get('x0')} x1={ev.get('x1')} x2={ev.get('x2')}", flush=True)
|
||||
for mb in (ev.get('magicbufs') or [])[:3]:
|
||||
print(f" magicbuf reg={mb.get('reg')} @{mb.get('addr')}", flush=True)
|
||||
for jh in (ev.get('jsonhits') or [])[:6]:
|
||||
print(f" jsonhit @{jh.get('addr')} mod={jh.get('mod')}", flush=True)
|
||||
events.append({"type": "t2copy", "ev": ev})
|
||||
OUT.write_text(json.dumps(events))
|
||||
elif t == "wire":
|
||||
print(f"[wire] len={p.get('len')}", flush=True)
|
||||
events.append({"type": "wire", "len": p.get('len'), "t": p.get('t'), "hex": p.get('hex')})
|
||||
OUT.write_text(json.dumps(events))
|
||||
|
||||
script = session.create_script(JS)
|
||||
script.on("message", on_message)
|
||||
script.load()
|
||||
print("[*] 主JS 已装载, 加载 bypass + resume", flush=True)
|
||||
session.create_script(
|
||||
(RE / "evidence/scripts/bypass_msaoaid_maps_art_callsite.js").read_text()
|
||||
).load()
|
||||
session.create_script(
|
||||
(RE / "evidence/scripts/mask_frida_maps_only.js").read_text()
|
||||
).load()
|
||||
device.resume(pid)
|
||||
time.sleep(11)
|
||||
session.create_script(
|
||||
(RE / "evidence/scripts/patch_guard_block_termination.js").read_text()
|
||||
).load()
|
||||
print("[*] 已 resume。等待自动 dfpReport (0x91ce8 命中)", flush=True)
|
||||
try:
|
||||
while True:
|
||||
time.sleep(5)
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
print(f"[*] 结束 -> {OUT}", flush=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,259 @@
|
||||
#!/usr/bin/env python3
|
||||
"""散弹枪 v2: 完整解码 dfpReport 管道参数 (指针数组 / (ptr,len) / std::string)。
|
||||
|
||||
挂载: topA_20e924, midA_5a938, cryptoA_5b1f4, copy_61098, pktA_7f744, pktB_92128
|
||||
对每个 onEnter 的 x0..x4:
|
||||
1) 直接缓冲探测 (JSON/MAGIC 前缀 -> full dump 6000B)
|
||||
2) (ptr,len) 解释: 读 16B -> 若两个 u64 第二个 < 100000 且第一个可读 -> dump
|
||||
3) 指针数组解释: 读 4x8B 指针, 对每个指针尝试 (2)
|
||||
4) std::string 解释: [size u64][data u64][cap u64] 或 [data][size][cap]
|
||||
所有事件带 t (ms since start)。SSL_write 抓 wire 对照。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import frida
|
||||
|
||||
PACKAGE = "com.duowan.kiwi"
|
||||
RE = Path("/Users/yml/codes/Reverse-Engineering-Agent-Universal-v3.0")
|
||||
OUT = Path("/Users/yml/codes/douyu_login_py/evidence/dfp_pipeline_v2.json")
|
||||
|
||||
JS = r"""
|
||||
'use strict';
|
||||
Process.setExceptionHandler(function(d){ return true; });
|
||||
send({type:'armed'});
|
||||
|
||||
var t0 = Date.now();
|
||||
var JSON_HDR = [0x7b,0x22,0x61,0x70,0x70,0x49,0x64,0x22,0x3a,0x22,0x35,0x30,0x30,0x38];
|
||||
var MAGIC_HDR = [0x57,0x18,0x82,0xcf,0x66,0x4b,0xb3,0x94,0x01,0xee];
|
||||
|
||||
function hexb(p, n){
|
||||
try{
|
||||
var arr = Array.from(new Uint8Array(p.readByteArray(n)));
|
||||
var s = '';
|
||||
for (var i=0;i<arr.length;i++) s += ('0'+arr[i].toString(16)).slice(-2);
|
||||
return s;
|
||||
}catch(e){ return null; }
|
||||
}
|
||||
function startsWith(p, arr){
|
||||
try{
|
||||
var b = new Uint8Array(p.readByteArray(arr.length));
|
||||
for (var i=0;i<arr.length;i++) if (b[i]!==arr[i]) return false;
|
||||
return true;
|
||||
}catch(e){ return false; }
|
||||
}
|
||||
function isReadable(p){
|
||||
try{ p.readU8(); return true; }catch(e){ return false; }
|
||||
}
|
||||
function u64(p){
|
||||
try{ return p.readU64().toString(10); }catch(e){ return null; }
|
||||
}
|
||||
function decodeArg(p, tag){
|
||||
var res = {reg: tag};
|
||||
try{
|
||||
if (!p || p.isNull()) return null;
|
||||
res.addr = String(p);
|
||||
// 1) direct buffer
|
||||
if (startsWith(p, JSON_HDR)){
|
||||
res.kind = 'JSON'; res.full = hexb(p, 6000); return res;
|
||||
}
|
||||
if (startsWith(p, MAGIC_HDR)){
|
||||
res.kind = 'MAGIC';
|
||||
var pre = '';
|
||||
try{ pre = hexb(p.sub(1536), 1536); }catch(e){}
|
||||
res.pre = pre;
|
||||
res.full = hexb(p, 9000);
|
||||
return res;
|
||||
}
|
||||
// 2) (ptr,len) pair: [ptr][len]
|
||||
var p0 = p.readPointer(); var l0 = p.add(8).readU64();
|
||||
if (!p0.isNull() && isReadable(p0) && l0 > 0 && l0 < 20000 && p0 != p){
|
||||
var b0 = hexb(p0, Math.min(Number(l0), 6000));
|
||||
var kind = 'seg';
|
||||
if (startsWith(p0, JSON_HDR)) kind = 'JSON';
|
||||
else if (startsWith(p0, MAGIC_HDR)) kind = 'MAGIC';
|
||||
res.kind = kind;
|
||||
res.ptrlen = {ptr: String(p0), len: Number(l0)};
|
||||
res.seg = b0;
|
||||
return res;
|
||||
}
|
||||
// 3) pointer array (4 x 8B)
|
||||
var ptrs = [];
|
||||
for (var i=0;i<4;i++){
|
||||
var q = p.add(i*8).readPointer();
|
||||
if (q.isNull() || !isReadable(q)) break;
|
||||
ptrs.push(String(q));
|
||||
}
|
||||
if (ptrs.length >= 2){
|
||||
res.kind = 'ptrs'; res.ptrs = ptrs;
|
||||
// decode each pointer as (ptr,len)
|
||||
var segs = [];
|
||||
for (var j=0;j<ptrs.length;j++){
|
||||
var qj = ptr(j);
|
||||
var q0 = qj.readPointer();
|
||||
var lj = qj.add(8).readU64();
|
||||
if (!q0.isNull() && lj > 0 && lj < 20000 && isReadable(q0)){
|
||||
segs.push({ptr: String(q0), len: Number(lj), head: hexb(q0, 48),
|
||||
kind: startsWith(q0, JSON_HDR) ? 'JSON' : (startsWith(q0, MAGIC_HDR) ? 'MAGIC' : 'raw')});
|
||||
}
|
||||
}
|
||||
res.segs = segs;
|
||||
return res;
|
||||
}
|
||||
// 4) raw
|
||||
res.kind = 'raw'; res.head = hexb(p, 32) || '';
|
||||
return res;
|
||||
}catch(e){ return null; }
|
||||
}
|
||||
|
||||
function hookFn(off, name){
|
||||
try{
|
||||
var base = Process.getModuleByName('libhydeviceid.so').base;
|
||||
Interceptor.attach(base.add(off), {
|
||||
onEnter: function(a){
|
||||
var ctx = this.context;
|
||||
var ev = {fn: name, phase: 'enter', t: Date.now() - t0};
|
||||
['x0','x1','x2','x3','x4'].forEach(function(r, i){
|
||||
var pr = decodeArg(ctx[r], r);
|
||||
if (pr) ev[r] = pr;
|
||||
});
|
||||
send({type:'pipe', ev: ev});
|
||||
}
|
||||
});
|
||||
send({type:'hooked', name: name});
|
||||
}catch(e){ send({type:'hook_fail', name: name, e: String(e)}); }
|
||||
}
|
||||
|
||||
var H = [
|
||||
[0x20e924, 'topA_20e924'],
|
||||
[0x5a938, 'midA_5a938'],
|
||||
[0x5b1f4, 'cryptoA_5b1f4'],
|
||||
[0x61098, 'copy_61098'],
|
||||
[0x7f744, 'pktA_7f744'],
|
||||
[0x92128, 'pktB_92128'],
|
||||
];
|
||||
var hookedN = 0;
|
||||
function attachOne(base, off, name){
|
||||
try{
|
||||
Interceptor.attach(base.add(off), {
|
||||
onEnter: function(a){
|
||||
var ctx = this.context;
|
||||
var ev = {fn: name, phase: 'enter', t: Date.now() - t0};
|
||||
['x0','x1','x2','x3','x4'].forEach(function(r, i){
|
||||
var pr = decodeArg(ctx[r], r);
|
||||
if (pr) ev[r] = pr;
|
||||
});
|
||||
send({type:'pipe', ev: ev});
|
||||
}
|
||||
});
|
||||
hookedN++;
|
||||
return true;
|
||||
}catch(e){ return false; }
|
||||
}
|
||||
function tryHookAll(){
|
||||
try{
|
||||
var m = Process.getModuleByName('libhydeviceid.so');
|
||||
for (var i=0;i<H.length;i++){
|
||||
if (attachOne(m.base, H[i][0], H[i][1])){}
|
||||
}
|
||||
send({type:'hooked_all', n: hookedN});
|
||||
}catch(e){ setTimeout(tryHookAll, 800); }
|
||||
}
|
||||
setTimeout(tryHookAll, 500);
|
||||
|
||||
try{
|
||||
var r = new ApiResolver('module');
|
||||
r.enumerateMatchesSync('exports:*!SSL_write').forEach(function(m){
|
||||
Interceptor.attach(m.address, {
|
||||
onEnter: function(a){
|
||||
var len = a[2].toInt32();
|
||||
if (len < 100 || len > 20000) return;
|
||||
var head = '';
|
||||
try{ head = a[1].readCString(Math.min(len, 2000)); }catch(e){ return; }
|
||||
if (head.indexOf('dfpReport') < 0) return;
|
||||
send({type:'wire', len:len, hex:hexb(a[1], len), t:Date.now()-t0});
|
||||
}
|
||||
});
|
||||
});
|
||||
send({type:'ssl_hooked'});
|
||||
}catch(e){ send({type:'ssl_err', e:String(e)}); }
|
||||
"""
|
||||
|
||||
|
||||
def main():
|
||||
device = frida.get_device_manager().add_remote_device("127.0.0.1:31877")
|
||||
pid = device.spawn([PACKAGE])
|
||||
print(f"[*] spawned {PACKAGE} pid={pid}", flush=True)
|
||||
session = device.attach(pid)
|
||||
|
||||
events = []
|
||||
|
||||
def on_message(message, data):
|
||||
if message.get("type") == "error":
|
||||
print("[JS-ERR]", str(message)[:300], flush=True)
|
||||
return
|
||||
if message.get("type") != "send":
|
||||
return
|
||||
p = message.get("payload") or {}
|
||||
t = p.get("type")
|
||||
if t == "armed":
|
||||
print("[*] JS armed", flush=True)
|
||||
elif t == "hooked_all":
|
||||
print(f"[*] hooked {p.get('n')} fns", flush=True)
|
||||
elif t == "ssl_hooked":
|
||||
print("[*] SSL_write hooked", flush=True)
|
||||
elif t == "pipe":
|
||||
ev = p.get('ev') or {}
|
||||
parts = []
|
||||
for r in ['x0','x1','x2','x3','x4']:
|
||||
pr = ev.get(r)
|
||||
if not pr or not isinstance(pr, dict):
|
||||
continue
|
||||
k = pr.get('kind')
|
||||
if k == 'JSON':
|
||||
parts.append(f"{r}=JSON full")
|
||||
elif k == 'MAGIC':
|
||||
parts.append(f"{r}=MAGIC full")
|
||||
elif k == 'seg':
|
||||
parts.append(f"{r}=seg({pr.get('ptrlen',{}).get('len')})@{pr.get('ptrlen',{}).get('ptr')}")
|
||||
elif k == 'ptrs':
|
||||
parts.append(f"{r}=ptrs {pr.get('ptrs')} segs={[(s.get('kind'), s.get('len')) for s in pr.get('segs') or []]}")
|
||||
elif k == 'raw':
|
||||
parts.append(f"{r}=raw {pr.get('addr')} {pr.get('head','')[:16]}")
|
||||
print(f"[{ev.get('t')}ms] {ev.get('fn')}::{ev.get('phase')} " + " ".join(parts), flush=True)
|
||||
events.append({"type": "pipe", "ev": ev})
|
||||
OUT.write_text(json.dumps(events))
|
||||
elif t == "wire":
|
||||
print(f"[{p.get('t')}ms] [wire] len={p.get('len')}", flush=True)
|
||||
events.append({"type": "wire", "len": p.get('len'), "t": p.get('t'), "hex": p.get('hex')})
|
||||
OUT.write_text(json.dumps(events))
|
||||
|
||||
script = session.create_script(JS)
|
||||
script.on("message", on_message)
|
||||
script.load()
|
||||
print("[*] 主JS 已装载, 加载 bypass + resume", flush=True)
|
||||
session.create_script(
|
||||
(RE / "evidence/scripts/bypass_msaoaid_maps_art_callsite.js").read_text()
|
||||
).load()
|
||||
session.create_script(
|
||||
(RE / "evidence/scripts/mask_frida_maps_only.js").read_text()
|
||||
).load()
|
||||
device.resume(pid)
|
||||
time.sleep(11)
|
||||
session.create_script(
|
||||
(RE / "evidence/scripts/patch_guard_block_termination.js").read_text()
|
||||
).load()
|
||||
print("[*] 已 resume。等待 dfpReport", flush=True)
|
||||
try:
|
||||
while True:
|
||||
time.sleep(5)
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
print(f"[*] 结束 -> {OUT}", flush=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,151 @@
|
||||
"""虎牙 App 密码登录及相关组件测试。"""
|
||||
|
||||
import base64
|
||||
import os
|
||||
import struct
|
||||
import unittest
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
os.environ.setdefault("DATABASE_URL", "sqlite://")
|
||||
os.environ.setdefault("APP_ENCRYPTION_KEY", "MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY=")
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from core.huya import (
|
||||
HuyaAppLoginError,
|
||||
HuyaAppPasswordLogin,
|
||||
HuyaAppQrAuthRequiredError,
|
||||
login_huya_app_password,
|
||||
login_huya_password,
|
||||
)
|
||||
from core.huya.cert_forge import build_p1, decrypt_cert, forge_cert, parse_p1
|
||||
from core.huya.device_profile import generate_profile, get_profile
|
||||
from core.huya.envelope_forge import Envelope
|
||||
from core.huya.login import HuyaLoginResult
|
||||
from core.huya.nonce_forge import K1_DEFAULT, gen_nonce
|
||||
from core.huya.udb_aes import udb_decrypt, udb_encrypt
|
||||
from core.huya.wup_encoder import build_password_login_wup
|
||||
|
||||
from web.backend.database import Base
|
||||
from web.backend.models import User, HuyaAccount
|
||||
from web.backend.schemas import HuyaAppPasswordLoginRequest, HuyaPasswordLoginSelectedRequest
|
||||
from web.backend.routers.huya import (
|
||||
app_password_login_account,
|
||||
app_password_login_selected_accounts,
|
||||
)
|
||||
|
||||
|
||||
class TestHuyaAppLogin(unittest.TestCase):
|
||||
|
||||
def setUp(self):
|
||||
self.engine = create_engine("sqlite://")
|
||||
Base.metadata.create_all(self.engine)
|
||||
self.session = sessionmaker(bind=self.engine)()
|
||||
self.admin = User(username="admin", password_hash="hash", role="super_admin")
|
||||
self.session.add(self.admin)
|
||||
self.session.commit()
|
||||
|
||||
def tearDown(self):
|
||||
self.session.close()
|
||||
Base.metadata.drop_all(self.engine)
|
||||
self.engine.dispose()
|
||||
|
||||
def test_udb_aes_roundtrip(self):
|
||||
key = b"4VYcPdvKKqjBHZtC"
|
||||
plain = b"Hello, Huya App Login Protocol!"
|
||||
enc = udb_encrypt(key, plain)
|
||||
dec = udb_decrypt(key, enc)
|
||||
self.assertEqual(dec, plain)
|
||||
|
||||
def test_cert_and_nonce_forge(self):
|
||||
uid = 1199666914671
|
||||
rnd = gen_nonce(uid, K1_DEFAULT)
|
||||
self.assertEqual(len(rnd), 20)
|
||||
|
||||
fp = b"02df398797432eadefcc12767119ad5e80999389"
|
||||
cred = b"\x0a" + os.urandom(113)
|
||||
p1 = build_p1(b"5008", fp, cred, rnd=rnd)
|
||||
self.assertEqual(len(p1), 187)
|
||||
|
||||
cert = forge_cert(p1)
|
||||
dec = decrypt_cert(cert)
|
||||
parsed = parse_p1(dec)
|
||||
self.assertEqual(parsed["rnd"], rnd)
|
||||
self.assertEqual(parsed["fingerprint"], fp)
|
||||
self.assertEqual(parsed["cred"], cred)
|
||||
|
||||
def test_envelope_patching(self):
|
||||
env = Envelope.load()
|
||||
self.assertGreater(env.uid, 0)
|
||||
self.assertEqual(len(env.cert_b64), 260)
|
||||
|
||||
new_uid = 1199666911746
|
||||
env.patch_uid(new_uid)
|
||||
self.assertEqual(env.uid, new_uid)
|
||||
|
||||
fake_cert = bytes([0x0C, 0x20]) + os.urandom(192)
|
||||
env.patch_cert(fake_cert)
|
||||
self.assertEqual(len(env.cert_b64), 260)
|
||||
|
||||
wup_b64 = env.wup_b64()
|
||||
self.assertGreater(len(wup_b64), 0)
|
||||
|
||||
def test_device_profile_generation(self):
|
||||
p1 = generate_profile()
|
||||
self.assertEqual(p1["os"], "android")
|
||||
self.assertEqual(len(p1["fingerprint"]), 40)
|
||||
self.assertEqual(len(p1["device_id"]), 40)
|
||||
self.assertEqual(p1["hdid"], "ed0db8334cadd236c00cadf7e11ab5a5")
|
||||
|
||||
p2 = get_profile("test_user_account_123")
|
||||
p3 = get_profile("test_user_account_123")
|
||||
self.assertEqual(p2["fingerprint"], p3["fingerprint"])
|
||||
|
||||
def test_wup_encoder_output(self):
|
||||
dev = generate_profile()
|
||||
pkt = build_password_login_wup(
|
||||
uid_str="300023887",
|
||||
sha1_password="772ed992b0e161276f44ec63671e60155c506294",
|
||||
safedeviceid=dev["safedeviceid"],
|
||||
hdid=dev["hdid"],
|
||||
session=3251699,
|
||||
trace_id="test-trace-id",
|
||||
user_action_json="{}",
|
||||
device_info=dev,
|
||||
)
|
||||
self.assertGreater(len(pkt), 500)
|
||||
total_len = struct.unpack(">I", pkt[:4])[0]
|
||||
self.assertEqual(total_len, len(pkt))
|
||||
|
||||
def test_router_functions(self):
|
||||
mock_res = HuyaLoginResult(
|
||||
success=True,
|
||||
cookie="udb_cred=mock_cred_123; yyuid=1199666914671; udb_uid=mock_uid; username=mock_test_huya_user",
|
||||
message="App协议登录成功",
|
||||
sdid="mock_sdid_123",
|
||||
)
|
||||
|
||||
with patch("web.backend.routers.huya.login_huya_app_password", return_value=mock_res):
|
||||
req = HuyaAppPasswordLoginRequest(
|
||||
username="mock_test_huya_user",
|
||||
password="mock_password_123",
|
||||
tag="test_tag",
|
||||
)
|
||||
resp = app_password_login_account(req=req, db=self.session, current=self.admin)
|
||||
self.assertTrue(resp["success"])
|
||||
self.assertIn("App", resp["message"])
|
||||
self.assertEqual(resp["account"].username, "mock_test_huya_user")
|
||||
|
||||
# Test batch app login router function
|
||||
acc = self.session.query(HuyaAccount).filter(HuyaAccount.username == "mock_test_huya_user").first()
|
||||
batch_req = HuyaPasswordLoginSelectedRequest(account_ids=[acc.id])
|
||||
batch_resp = app_password_login_selected_accounts(req=batch_req, db=self.session, current=self.admin)
|
||||
self.assertTrue(batch_resp["success"])
|
||||
self.assertEqual(batch_resp["count"], 1)
|
||||
self.assertEqual(len(batch_resp["results"]), 1)
|
||||
self.assertTrue(batch_resp["results"][0]["success"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
+200
-5
@@ -12,8 +12,11 @@ from sqlalchemy import func, or_
|
||||
from sqlalchemy.orm import Session, defer, joinedload
|
||||
|
||||
from core.huya import (
|
||||
HuyaAppLoginError,
|
||||
HuyaAppQrAuthRequiredError,
|
||||
HuyaCredentialError,
|
||||
HuyaLoginError,
|
||||
login_huya_app_password,
|
||||
login_huya_password,
|
||||
login_huya_sms,
|
||||
send_huya_sms_code,
|
||||
@@ -51,6 +54,7 @@ from ..schemas import (
|
||||
HuyaGoodsOut,
|
||||
HuyaPasswordAccountImport,
|
||||
HuyaPasswordLoginRequest,
|
||||
HuyaAppPasswordLoginRequest,
|
||||
HuyaPasswordLoginSelectedRequest,
|
||||
HuyaRechargeGoodsOut,
|
||||
HuyaRegisterSuccessLogOut,
|
||||
@@ -528,7 +532,7 @@ def password_login_account(
|
||||
db: Session = Depends(get_db),
|
||||
current: User = Depends(get_current_user),
|
||||
):
|
||||
"""使用账号密码登录虎牙,成功后保存 Cookie。"""
|
||||
"""使用 Web 方式账号密码登录虎牙(旧版),成功后保存 Cookie。"""
|
||||
_require_huya_perm(current, "huya:import")
|
||||
try:
|
||||
result = login_huya_password(
|
||||
@@ -541,17 +545,63 @@ def password_login_account(
|
||||
except HuyaLoginError as exc:
|
||||
raise HTTPException(status_code=502, detail=str(exc)) from exc
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=502, detail=f"虎牙密码登录失败: {exc}") from exc
|
||||
raise HTTPException(status_code=502, detail=f"虎牙 Web 密码登录失败: {exc}") from exc
|
||||
|
||||
if not result.success or not result.cookie:
|
||||
raise HTTPException(status_code=502, detail=result.message or "虎牙密码登录失败")
|
||||
raise HTTPException(status_code=502, detail=result.message or "虎牙 Web 密码登录失败")
|
||||
|
||||
try:
|
||||
account = upsert_huya_cookie(db, result.cookie, tag=req.tag, username_hint=req.username)
|
||||
if hasattr(account, "account_password") and req.password:
|
||||
account.account_password = req.password
|
||||
account.status = "active"
|
||||
db.commit()
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=502, detail=str(exc)) from exc
|
||||
return {
|
||||
"message": "登录成功,Cookie 已保存",
|
||||
"message": "Web 登录成功,Cookie 已保存",
|
||||
"success": True,
|
||||
"account": _account_out(account),
|
||||
"sdid": result.sdid,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/accounts/app-password-login")
|
||||
def app_password_login_account(
|
||||
req: HuyaAppPasswordLoginRequest,
|
||||
db: Session = Depends(get_db),
|
||||
current: User = Depends(get_current_user),
|
||||
):
|
||||
"""使用 App 协议密码登录虎牙(推荐),自动过滑块并保存 Cookie。"""
|
||||
_require_huya_perm(current, "huya:import")
|
||||
try:
|
||||
result = login_huya_app_password(
|
||||
username=req.username.strip(),
|
||||
password=req.password,
|
||||
force_new_device=req.force_new_device,
|
||||
)
|
||||
except HuyaAppQrAuthRequiredError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
except HuyaCredentialError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
except (HuyaAppLoginError, HuyaLoginError) as exc:
|
||||
raise HTTPException(status_code=502, detail=str(exc)) from exc
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=502, detail=f"虎牙 App 密码登录失败: {exc}") from exc
|
||||
|
||||
if not result.success or not result.cookie:
|
||||
raise HTTPException(status_code=502, detail=result.message or "虎牙 App 密码登录失败")
|
||||
|
||||
try:
|
||||
account = upsert_huya_cookie(db, result.cookie, tag=req.tag, username_hint=req.username)
|
||||
if hasattr(account, "account_password") and req.password:
|
||||
account.account_password = req.password
|
||||
account.status = "active"
|
||||
db.commit()
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=502, detail=str(exc)) from exc
|
||||
return {
|
||||
"message": "App 协议登录成功,Cookie 已保存",
|
||||
"success": True,
|
||||
"account": _account_out(account),
|
||||
"sdid": result.sdid,
|
||||
@@ -918,7 +968,152 @@ def password_login_selected_accounts(
|
||||
})
|
||||
|
||||
return {
|
||||
"message": f"登录完成:成功 {success_count} 条,失败 {failed_count} 条",
|
||||
"message": f"Web 登录完成:成功 {success_count} 条,失败 {failed_count} 条",
|
||||
"success": True,
|
||||
"count": success_count,
|
||||
"failed": failed_count,
|
||||
"results": results,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/accounts/app-password-login/selected")
|
||||
def app_password_login_selected_accounts(
|
||||
req: HuyaPasswordLoginSelectedRequest,
|
||||
db: Session = Depends(get_db),
|
||||
current: User = Depends(get_current_user),
|
||||
):
|
||||
"""对已导入的虎牙账号使用 App 协议批量执行密码登录(推荐)。"""
|
||||
_require_huya_perm(current, "huya:import")
|
||||
selected_ids = _selected_huya_account_ids(db, current, req)
|
||||
if not selected_ids:
|
||||
raise HTTPException(status_code=400, detail="请选择虎牙账号")
|
||||
|
||||
accounts = (
|
||||
_visible_huya_accounts_query(db, current)
|
||||
.filter(HuyaAccount.id.in_(selected_ids))
|
||||
.all()
|
||||
)
|
||||
account_map = {account.id: account for account in accounts}
|
||||
results = []
|
||||
success_count = 0
|
||||
failed_count = 0
|
||||
include_cookie = _can_view_huya_cookie(current)
|
||||
|
||||
for account_id in selected_ids:
|
||||
account = account_map.get(account_id)
|
||||
if account is None:
|
||||
failed_count += 1
|
||||
results.append({
|
||||
"line": account_id,
|
||||
"username": "",
|
||||
"success": False,
|
||||
"message": "账号不存在或无权登录",
|
||||
})
|
||||
continue
|
||||
|
||||
username = (account.username or "").strip()
|
||||
password = (account.account_password or "").strip()
|
||||
if not username or not password:
|
||||
failed_count += 1
|
||||
results.append({
|
||||
"line": account.id,
|
||||
"username": username,
|
||||
"success": False,
|
||||
"message": "该账号未导入密码",
|
||||
"account": _account_out(account, include_cookie=include_cookie),
|
||||
})
|
||||
continue
|
||||
|
||||
try:
|
||||
result = login_huya_app_password(
|
||||
username=username,
|
||||
password=password,
|
||||
force_new_device=req.force_new_device,
|
||||
)
|
||||
if not result.success or not result.cookie:
|
||||
account.status = "login_failed"
|
||||
account.updated_at = datetime.now(timezone.utc)
|
||||
db.commit()
|
||||
failed_count += 1
|
||||
results.append({
|
||||
"line": account.id,
|
||||
"username": username,
|
||||
"success": False,
|
||||
"message": result.message or "虎牙 App 密码登录失败",
|
||||
"account": _account_out(account, include_cookie=include_cookie),
|
||||
"sdid": result.sdid,
|
||||
})
|
||||
continue
|
||||
|
||||
saved = save_huya_login_cookie_to_account(
|
||||
db,
|
||||
account,
|
||||
result.cookie,
|
||||
tag=account.tag or "",
|
||||
username_hint=username,
|
||||
)
|
||||
saved.status = "active"
|
||||
db.commit()
|
||||
success_count += 1
|
||||
results.append({
|
||||
"line": account.id,
|
||||
"username": username,
|
||||
"success": True,
|
||||
"message": "App 登录成功,Cookie 已保存",
|
||||
"account": _account_out(saved, include_cookie=include_cookie),
|
||||
"sdid": result.sdid,
|
||||
})
|
||||
except HuyaAppQrAuthRequiredError as exc:
|
||||
account.status = "login_failed"
|
||||
account.updated_at = datetime.now(timezone.utc)
|
||||
db.commit()
|
||||
failed_count += 1
|
||||
results.append({
|
||||
"line": account.id,
|
||||
"username": username,
|
||||
"success": False,
|
||||
"message": str(exc),
|
||||
"account": _account_out(account, include_cookie=include_cookie),
|
||||
})
|
||||
except HuyaCredentialError as exc:
|
||||
account.status = "login_failed"
|
||||
account.updated_at = datetime.now(timezone.utc)
|
||||
db.commit()
|
||||
failed_count += 1
|
||||
results.append({
|
||||
"line": account.id,
|
||||
"username": username,
|
||||
"success": False,
|
||||
"message": str(exc),
|
||||
"account": _account_out(account, include_cookie=include_cookie),
|
||||
})
|
||||
except (HuyaAppLoginError, HuyaLoginError, ValueError) as exc:
|
||||
account.status = "login_failed"
|
||||
account.updated_at = datetime.now(timezone.utc)
|
||||
db.commit()
|
||||
failed_count += 1
|
||||
results.append({
|
||||
"line": account.id,
|
||||
"username": username,
|
||||
"success": False,
|
||||
"message": str(exc),
|
||||
"account": _account_out(account, include_cookie=include_cookie),
|
||||
})
|
||||
except Exception as exc:
|
||||
account.status = "login_failed"
|
||||
account.updated_at = datetime.now(timezone.utc)
|
||||
db.commit()
|
||||
failed_count += 1
|
||||
results.append({
|
||||
"line": account.id,
|
||||
"username": username,
|
||||
"success": False,
|
||||
"message": f"虎牙 App 密码登录失败: {exc}",
|
||||
"account": _account_out(account, include_cookie=include_cookie),
|
||||
})
|
||||
|
||||
return {
|
||||
"message": f"App 登录完成:成功 {success_count} 条,失败 {failed_count} 条",
|
||||
"success": True,
|
||||
"count": success_count,
|
||||
"failed": failed_count,
|
||||
|
||||
+10
-1
@@ -261,13 +261,21 @@ class HuyaCookieImport(BaseModel):
|
||||
|
||||
|
||||
class HuyaPasswordLoginRequest(BaseModel):
|
||||
"""虎牙账号密码登录并保存 Cookie。"""
|
||||
"""虎牙账号 Web 密码登录并保存 Cookie(旧版)。"""
|
||||
username: str = Field(..., min_length=1, max_length=128)
|
||||
password: str = Field(..., min_length=1, max_length=128)
|
||||
tag: str = ""
|
||||
cookie: str = ""
|
||||
|
||||
|
||||
class HuyaAppPasswordLoginRequest(BaseModel):
|
||||
"""虎牙账号 App 协议密码登录并保存 Cookie(推荐)。"""
|
||||
username: str = Field(..., min_length=1, max_length=128)
|
||||
password: str = Field(..., min_length=1, max_length=128)
|
||||
tag: str = ""
|
||||
force_new_device: bool = False
|
||||
|
||||
|
||||
class HuyaSmsCodeRequest(BaseModel):
|
||||
"""发送虎牙短信验证码。"""
|
||||
phone: str = Field(..., min_length=5, max_length=32)
|
||||
@@ -386,6 +394,7 @@ class HuyaPasswordLoginSelectedRequest(BaseModel):
|
||||
tag: str = ""
|
||||
assigned_only: bool = False
|
||||
has_cookie: bool = False
|
||||
force_new_device: bool = False
|
||||
|
||||
|
||||
class HuyaAccountOut(BaseModel):
|
||||
|
||||
@@ -15,6 +15,7 @@ import type {
|
||||
HuyaPasswordAccountImportResult,
|
||||
HuyaPasswordLoginBatchResult,
|
||||
HuyaPasswordLoginRequest,
|
||||
HuyaAppPasswordLoginRequest,
|
||||
HuyaPasswordLoginResult,
|
||||
HuyaPasswordLoginSelectedRequest,
|
||||
HuyaRechargeGoodsItem,
|
||||
@@ -49,6 +50,10 @@ export const huyaApi = {
|
||||
api.post<HuyaPasswordLoginResult, HuyaPasswordLoginResult>('/huya/accounts/password-login', data),
|
||||
passwordLoginSelected: (data: HuyaPasswordLoginSelectedRequest) =>
|
||||
api.post<HuyaPasswordLoginBatchResult, HuyaPasswordLoginBatchResult>('/huya/accounts/password-login/selected', data),
|
||||
appPasswordLogin: (data: HuyaAppPasswordLoginRequest) =>
|
||||
api.post<HuyaPasswordLoginResult, HuyaPasswordLoginResult>('/huya/accounts/app-password-login', data),
|
||||
appPasswordLoginSelected: (data: HuyaPasswordLoginSelectedRequest) =>
|
||||
api.post<HuyaPasswordLoginBatchResult, HuyaPasswordLoginBatchResult>('/huya/accounts/app-password-login/selected', data),
|
||||
smsCode: (data: HuyaSmsCodeRequest) =>
|
||||
api.post<HuyaSmsCodeResult, HuyaSmsCodeResult>('/huya/accounts/sms-code', data, { timeout: 120000 }),
|
||||
smsLogin: (data: HuyaSmsLoginRequest) =>
|
||||
|
||||
@@ -478,6 +478,13 @@ export interface HuyaPasswordLoginRequest {
|
||||
cookie?: string;
|
||||
}
|
||||
|
||||
export interface HuyaAppPasswordLoginRequest {
|
||||
username: string;
|
||||
password: string;
|
||||
tag?: string;
|
||||
force_new_device?: boolean;
|
||||
}
|
||||
|
||||
export interface HuyaPasswordLoginResult extends MessageResponse {
|
||||
account: HuyaAccountItem;
|
||||
sdid: string;
|
||||
@@ -615,6 +622,7 @@ export interface HuyaPasswordLoginSelectedRequest {
|
||||
tag?: string;
|
||||
assigned_only?: boolean;
|
||||
has_cookie?: boolean;
|
||||
force_new_device?: boolean;
|
||||
}
|
||||
|
||||
export interface HuyaCookieItem {
|
||||
|
||||
@@ -56,6 +56,8 @@ export default function HuyaAccountsPage() {
|
||||
const [passwordImporting, setPasswordImporting] = useState(false);
|
||||
const [passwordLoginResultOpen, setPasswordLoginResultOpen] = useState(false);
|
||||
const [passwordLogging, setPasswordLogging] = useState(false);
|
||||
const [appPasswordLogging, setAppPasswordLogging] = useState(false);
|
||||
const [loginModalTitle, setLoginModalTitle] = useState('虎牙 App 协议登录结果 (推荐)');
|
||||
const [passwordLoginResults, setPasswordLoginResults] = useState<HuyaPasswordLoginBatchItem[]>([]);
|
||||
const [smsLoginOpen, setSmsLoginOpen] = useState(false);
|
||||
const [smsPhone, setSmsPhone] = useState('');
|
||||
@@ -291,7 +293,35 @@ export default function HuyaAccountsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleLoginSelected = async () => {
|
||||
const handleAppLoginSelected = async () => {
|
||||
if (selectedCount === 0) {
|
||||
message.warning('请先选择虎牙账号');
|
||||
return;
|
||||
}
|
||||
setAppPasswordLogging(true);
|
||||
try {
|
||||
const result = await huyaApi.appPasswordLoginSelected({
|
||||
...selectionPayload,
|
||||
});
|
||||
setPasswordLoginResults(result.results || []);
|
||||
setLoginModalTitle('虎牙 App 协议登录结果 (推荐)');
|
||||
setPasswordLoginResultOpen(true);
|
||||
if (result.failed > 0) {
|
||||
message.warning(result.message);
|
||||
} else {
|
||||
message.success(result.message || 'App 登录成功,Cookie 已保存');
|
||||
}
|
||||
loadAccounts();
|
||||
loadTags();
|
||||
loadSummary();
|
||||
} catch (e: unknown) {
|
||||
message.error(getErrorMessage(e));
|
||||
} finally {
|
||||
setAppPasswordLogging(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleWebLoginSelected = async () => {
|
||||
if (selectedCount === 0) {
|
||||
message.warning('请先选择虎牙账号');
|
||||
return;
|
||||
@@ -302,11 +332,12 @@ export default function HuyaAccountsPage() {
|
||||
...selectionPayload,
|
||||
});
|
||||
setPasswordLoginResults(result.results || []);
|
||||
setLoginModalTitle('虎牙 Web 密码登录结果 (旧版)');
|
||||
setPasswordLoginResultOpen(true);
|
||||
if (result.failed > 0) {
|
||||
message.warning(result.message);
|
||||
} else {
|
||||
message.success(result.message || '登录成功,Cookie 已保存');
|
||||
message.success(result.message || 'Web 登录成功,Cookie 已保存');
|
||||
}
|
||||
loadAccounts();
|
||||
loadTags();
|
||||
@@ -574,14 +605,25 @@ export default function HuyaAccountsPage() {
|
||||
批量打标签
|
||||
</Button>
|
||||
)}
|
||||
{canImport && (
|
||||
<Button
|
||||
type="primary"
|
||||
disabled={selectedCount === 0}
|
||||
icon={<MobileOutlined />}
|
||||
loading={appPasswordLogging}
|
||||
onClick={handleAppLoginSelected}
|
||||
>
|
||||
App 登录选中 ({selectedCount})
|
||||
</Button>
|
||||
)}
|
||||
{canImport && (
|
||||
<Button
|
||||
disabled={selectedCount === 0}
|
||||
icon={<LoginOutlined />}
|
||||
loading={passwordLogging}
|
||||
onClick={handleLoginSelected}
|
||||
onClick={handleWebLoginSelected}
|
||||
>
|
||||
登录选中
|
||||
Web 登录 (旧版)
|
||||
</Button>
|
||||
)}
|
||||
{canDelete && selectedCount > 0 && (
|
||||
@@ -861,7 +903,7 @@ export default function HuyaAccountsPage() {
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title="虎牙登录结果"
|
||||
title={loginModalTitle}
|
||||
open={passwordLoginResultOpen}
|
||||
onCancel={() => setPasswordLoginResultOpen(false)}
|
||||
footer={[
|
||||
@@ -871,6 +913,13 @@ export default function HuyaAccountsPage() {
|
||||
]}
|
||||
width={860}
|
||||
>
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
style={{ marginBottom: 12 }}
|
||||
message="登录说明"
|
||||
description="使用 App 协议登录时,系统已自动识别并通过 safe_auth 乱序滑块;若个别新账号提示“要求扫码验证 (qr_auth)”,则需在手机虎牙 App 上正常登录一次以建立设备信任。"
|
||||
/>
|
||||
{passwordLoginResults.length > 0 && (
|
||||
<Table
|
||||
columns={passwordLoginResultColumns}
|
||||
|
||||
Reference in New Issue
Block a user