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:
yml2213
2026-08-26 15:51:30 +08:00
parent 4eec01568f
commit da2cf5a61d
22 changed files with 2572 additions and 11 deletions
+25
View File
@@ -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",
+531
View File
@@ -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()
+90
View File
@@ -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)
+99
View File
@@ -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
+260
View File
@@ -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()
+78
View File
@@ -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)
+35
View File
@@ -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")
+256
View File
@@ -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
+81
View File
@@ -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()
+215
View File
@@ -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()
+211
View File
@@ -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()
+259
View File
@@ -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()
+151
View File
@@ -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
View File
@@ -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
View File
@@ -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):
+5
View File
@@ -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) =>
+8
View File
@@ -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 {
+54 -5
View File
@@ -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}