Files
live-hub-py/scripts/huya_clone_ck.py
T
yml2213 03bc0818dd feat(huya): 分身 CK 支持 cred0 解密补全,WSS 帧改为 JCE WebSocketCommand
- scripts/huya_clone_ck.py: root 读取小X分身/真身 WebView Cookie,默认
  用 AES-128-ECB(UDB 主密钥前 16B) 解 cred0 注入 udb_cred,新增
  --anon-device 补匿名设备字段、--no-cred0 退回旧行为
- core/huya/wss_client.py: 帧层改用浏览器 HUYA.WebSocketCommand(JCE)
  结构,vData 直接放 Wup 原包,按 WUP iRequestId 匹配响应,初始化顺序
  对齐 RegisterGroup→wsLaunch→VerifyCookie→getConfig→UpdateUserExps
- tests: 帧级断言改为新结构,假 WS 按 requestId 兑现
- docs: 新增分身 CK 提取、HAR 提取 CK 与积分中心说明

实测:四个分身 cred0 解出 udb_cred(39/43 字段),WSS
PlayMallUI.queryAllocation 返回 code=200、18 个商品;tests -k huya 394 passed
2026-10-26 11:23:29 +08:00

623 lines
22 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""从小X分身/虎牙 App 的 WebView Cookie 库提取虎牙 CK。
前提:手机已 root(SukiSU/KernelSU/Magisk),且 `adb shell su -c id` 返回 uid=0。
脚本只读设备数据,不修改 App 数据;默认只输出脱敏摘要,避免 CK 直接落到终端。
典型用法:
.venv/bin/python scripts/huya_clone_ck.py
.venv/bin/python scripts/huya_clone_ck.py --include-real --out /tmp/huya_cks.txt
.venv/bin/python scripts/huya_clone_ck.py --verify-sid 2203
输出文件格式为 `账号----ck`,可直接贴入现有虎牙协作台账的「贴 / 换 CK」。
"""
from __future__ import annotations
import argparse
import json
import os
import shlex
import sqlite3
import subprocess
import sys
import tempfile
from collections.abc import Mapping
from dataclasses import dataclass, field
from datetime import UTC, datetime
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parents[1]
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
from core.huya.cookie_utils import cookie_pairs, cookie_value, normalize_cookie_pairs
from core.huya.web_cookie_fields import fill_web_cookie_fields
HUYA_PACKAGE = "com.duowan.kiwi"
DKPLAT_PACKAGE = "com.bly.dkplat"
COOKIE_REL = "app_webview/Default/Cookies"
CRED0_REL = "files/huyaudb/cred0"
# 虎牙 App 本地登录态文件 cred0 的 AES-128-ECB 密钥(UDB 主密钥前 16 字节)
UDB_MASTER_KEY = b"HuyaUdb1928374650qwertyuiop"[:16]
# WSS 登录态字段:App 登录 + cred0 就能给全
LOGIN_REQUIRED = ("udb_cred", "udb_biztoken", "udb_uid", "yyuid", "sdid")
# 匿名设备态字段:由匿名设备态协议签发(--anon-device / 后端自动补)
DEVICE_REQUIRED = ("udb_deviceid", "udb_guiddata", "udb_anobiztoken")
WSS_REQUIRED = LOGIN_REQUIRED + DEVICE_REQUIRED
class CliError(RuntimeError):
"""可预期的命令行错误。"""
@dataclass(frozen=True)
class CloneSource:
label: str
kind: str # clone / real
package: str
data_dir: str
cookies_path: str = ""
@dataclass
class Extracted:
source: CloneSource
uid: int
passport: str
cookie: str
raw_cookie: str
has_cred: bool
missing_wss: tuple[str, ...]
notes: list[str] = field(default_factory=list)
verify: dict[str, object] | None = None
@property
def account(self) -> str:
return self.passport or f"uid_{self.uid}"
@property
def wss_ready(self) -> bool:
"""登录态字段齐全即可走 WSS(匿名设备字段可另补)。"""
present = {key for key, value in cookie_pairs(self.cookie) if value}
return all(key in present for key in LOGIN_REQUIRED)
class AdbDevice:
def __init__(self, adb: str, serial: str | None) -> None:
self.adb = adb
self.serial = serial
def _invoke(
self, mode: str, command: str, *, root: bool
) -> subprocess.CompletedProcess[bytes]:
args = [self.adb]
if self.serial:
args.extend(["-s", self.serial])
args.append(mode)
args.append(f"su -c {shlex.quote(command)}" if root else command)
try:
return subprocess.run(args, capture_output=True, check=False)
except FileNotFoundError as exc:
raise CliError(f"找不到 adb 可执行文件: {self.adb}") from exc
def shell_text(self, command: str, *, root: bool = False) -> str:
proc = self._invoke("shell", command, root=root)
if proc.returncode != 0:
stderr = proc.stderr.decode("utf-8", "replace").strip()
raise CliError(f"adb shell 失败({command}):{stderr or proc.returncode}")
return proc.stdout.decode("utf-8", "replace")
def exec_out(self, command: str, *, root: bool = False) -> bytes:
proc = self._invoke("exec-out", command, root=root)
if proc.returncode != 0 or not proc.stdout:
stderr = proc.stderr.decode("utf-8", "replace").strip()
raise CliError(f"adb exec-out 失败({command}):{stderr or '无输出'}")
return proc.stdout
def ensure_root(self) -> None:
proc = self._invoke("shell", "id", root=True)
output = (proc.stdout + proc.stderr).decode("utf-8", "replace")
if proc.returncode != 0 or "uid=0" not in output:
raise CliError(
"adb shell 没有 root。请先在 SukiSU/KernelSU 里给 Shell 授权,"
"确认 `adb shell su -c id` 返回 uid=0。"
)
def pick_serial(adb: str, requested: str | None) -> str | None:
if requested:
return requested
try:
proc = subprocess.run([adb, "devices"], capture_output=True, check=False)
except FileNotFoundError as exc:
raise CliError(f"找不到 adb 可执行文件: {adb}") from exc
serials = [
line.split()[0]
for line in proc.stdout.decode("utf-8", "replace").splitlines()
if "\tdevice" in line and line.split()
]
if not serials:
raise CliError("没有已连接的 adb 设备。")
if len(serials) > 1:
raise CliError(
"连接了多个 adb 设备,请用 --serial 指定目标:" + ", ".join(serials)
)
return serials[0]
def discover_sources(device: AdbDevice, *, include_real: bool) -> list[CloneSource]:
sources: list[CloneSource] = []
packages = device.shell_text("pm list packages -3")
for line in packages.splitlines():
package = line.strip().removeprefix("package:")
if not package.startswith("dkplugin."):
continue
sources.append(
CloneSource(
label=f"clone:{package.removeprefix('dkplugin.')}",
kind="clone",
package=package,
data_dir=f"/data/user/0/{package}/virtual/data/user/0/{HUYA_PACKAGE}",
)
)
# 小X分身自己的「主分身」数据在 com.bly.dkplat/chaos/data/user/<id>/ 下。
glob_cmd = (
f"ls -d /data/user/0/{DKPLAT_PACKAGE}/chaos/data/user/*/{HUYA_PACKAGE} "
"2>/dev/null || true"
)
for raw_path in device.shell_text(glob_cmd, root=True).splitlines():
path = raw_path.strip()
if not path:
continue
marker = f"/{DKPLAT_PACKAGE}/chaos/data/user/"
instance = path.split(marker, 1)[1].split("/", 1)[0] if marker in path else "?"
sources.append(
CloneSource(
label=f"clone:dkplat/{instance}",
kind="clone",
package=DKPLAT_PACKAGE,
data_dir=path,
)
)
if include_real:
sources.append(
CloneSource(
label="real:huya",
kind="real",
package=HUYA_PACKAGE,
data_dir=f"/data/user/0/{HUYA_PACKAGE}",
)
)
found: list[CloneSource] = []
for source in sources:
cookies_path = f"{source.data_dir}/{COOKIE_REL}"
probe = device.shell_text(
f"test -f {cookies_path} && echo yes || echo no", root=True
).strip()
if probe.endswith("yes"):
found.append(
CloneSource(
label=source.label,
kind=source.kind,
package=source.package,
data_dir=source.data_dir,
cookies_path=cookies_path,
)
)
return sorted(found, key=lambda item: item.label)
def read_cookies(blob: bytes) -> tuple[str, list[str]]:
"""把 WebView Cookies SQLite 转成 Cookie 字符串。"""
with tempfile.NamedTemporaryFile(suffix=".db") as handle:
handle.write(blob)
handle.flush()
connection = sqlite3.connect(f"file:{handle.name}?mode=ro", uri=True)
try:
integrity = connection.execute("PRAGMA integrity_check").fetchone()
if not integrity or integrity[0] != "ok":
raise CliError(f"Cookie 数据库完整性检查失败: {integrity}")
rows = list(
connection.execute(
"SELECT host_key, name, value, encrypted_value "
"FROM cookies ORDER BY rowid"
)
)
finally:
connection.close()
pairs: list[tuple[str, str]] = []
encrypted: list[str] = []
for host_key, name, value, encrypted_value in rows:
host = str(host_key or "")
key = str(name or "")
if not key or not (host == ".huya.com" or host.endswith("huya.com")):
continue
if value:
pairs.append((key, str(value)))
elif encrypted_value:
encrypted.append(key)
if not pairs:
raise CliError("Cookie 库里没有非空的 huya.com Cookie。")
return normalize_cookie_pairs(pairs), encrypted
def decrypt_cred0(blob: bytes) -> dict[str, object] | None:
"""解密 ``files/huyaudb/cred0``,取回 App 登录态里的 cred/uid/passport。
cred0 是 AES-128-ECB(零填充) 加密的登录历史 JSON;``apploginData.cred``
就是 ``udb_cred``(152 字符 base64url),WebView Cookie 里永远没有它。
解密失败只返回 None,不影响 Cookie 提取。
"""
if not blob or len(blob) % 16:
return None
try:
from Crypto.Cipher import AES
plain = AES.new(UDB_MASTER_KEY, AES.MODE_ECB).decrypt(blob).rstrip(b"\x00")
payload = json.loads(plain)
history = payload.get("loginHistory") or []
if not history:
return None
login = history[0].get("apploginData") or {}
cred = str(login.get("cred") or "")
if not cred:
return None
cookie = login.get("cookie") or {}
return {
"cred": cred,
"uid": int(login.get("uid") or cookie.get("uid") or 0),
"passport": str(login.get("passport") or cookie.get("passport") or ""),
"biztoken": str(cookie.get("biztoken") or ""),
"status": str(cookie.get("status") or ""),
"version": str(cookie.get("version") or ""),
}
except Exception: # noqa: BLE001 - 解密失败按「没有 cred0」处理
return None
def apply_credential(raw_cookie: str, login: Mapping[str, object]) -> str:
"""把 cred0 里的登录凭据写进 Cookie(覆盖 WebView 里的旧值)。"""
overrides = {
"udb_cred": login.get("cred"),
"udb_uid": login.get("uid"),
"yyuid": login.get("uid"),
"udb_passport": login.get("passport"),
"username": login.get("passport"),
"udb_biztoken": login.get("biztoken"),
"udb_status": login.get("status"),
"udb_version": login.get("version"),
}
pairs = [
(key, value) for key, value in cookie_pairs(raw_cookie) if key not in overrides
]
for key, value in overrides.items():
text = "" if value in (None, 0) else str(value)
if text:
pairs.append((key, text))
return "; ".join(f"{key}={value}" for key, value in pairs)
def build_web_cookie(raw_cookie: str, uid: int) -> str:
"""补齐网页设备态,并把渠道 appId 修正为网页的 5002。"""
filled = fill_web_cookie_fields(raw_cookie, guid="", uid=uid, qimei_feature=None)
pairs = dict(cookie_pairs(filled))
pairs["udb_appid"] = "5002"
return "; ".join(f"{key}={value}" for key, value in pairs.items())
def verify_cookie(cookie: str, uid: int, sid: int, timeout: float) -> dict[str, object]:
from core.huya.http_client import HuyaHttpClient
try:
response = HuyaHttpClient(logger=lambda _message: None).query_user_score(
uid, cookie, sid=sid, timeout=timeout
)
except Exception as exc: # noqa: BLE001 - 验证失败要写进报告而不是中断提取
return {"ok": False, "error": f"{type(exc).__name__}: {exc}"}
return {
"ok": True,
"status": getattr(response, "status", None),
"msg": getattr(response, "msg", None),
}
def extract_one(
device: AdbDevice,
source: CloneSource,
*,
fill_web: bool,
verify_sid: int | None,
timeout: float,
use_cred0: bool = True,
anon_device: bool = False,
) -> Extracted:
blob = device.exec_out(f"cat {source.cookies_path}", root=True)
raw_cookie, encrypted = read_cookies(blob)
uid = int(cookie_value(raw_cookie, "udb_uid") or 0)
if not uid:
uid = int(cookie_value(raw_cookie, "yyuid") or 0)
notes: list[str] = []
if encrypted:
notes.append(
f"有 {len(encrypted)} 个 Cookie 使用 encrypted_value(当前脚本不解密)"
)
# WebView Cookie 只有登录态字段,udb_cred 只存在于 App 本地登录态 cred0。
login: dict[str, object] | None = None
if use_cred0:
cred_path = f"{source.data_dir}/{CRED0_REL}"
probe = device.shell_text(
f"test -f {cred_path} && echo yes || echo no", root=True
).strip()
if probe.endswith("yes"):
login = decrypt_cred0(device.exec_out(f"cat {cred_path}", root=True))
if login:
notes.append("已从 cred0 解出 App 登录凭据(udb_cred)")
if not uid and login.get("uid"):
uid = int(login["uid"]) # type: ignore[arg-type]
else:
notes.append("cred0 存在但解密失败,CK 不含 udb_cred")
else:
notes.append("没有 cred0(该分身可能只有匿名态)")
if not uid:
raise CliError(f"{source.label} 的 Cookie 里没有 udb_uid/yyuid。")
base_cookie = apply_credential(raw_cookie, login) if login else raw_cookie
try:
cookie = build_web_cookie(base_cookie, uid) if fill_web else base_cookie
except Exception as exc: # noqa: BLE001 - 补齐失败时保留原始 Cookie
notes.append(
f"网页设备态补齐失败,退回原始 Cookie:{type(exc).__name__}: {exc}"
)
cookie = base_cookie
if anon_device:
try:
from core.huya.anon_device import fetch_anonymous_device_state
anon = fetch_anonymous_device_state()
missing = {
key: value
for key, value in anon.items()
if value and not cookie_value(cookie, key)
}
if missing:
cookie = "; ".join(
[cookie] + [f"{key}={value}" for key, value in missing.items()]
)
notes.append("已用匿名设备态补齐: " + ", ".join(sorted(missing)))
except Exception as exc: # noqa: BLE001 - 匿名设备态失败不阻断提取
notes.append(f"匿名设备态获取失败: {type(exc).__name__}: {exc}")
missing_login = tuple(
key for key in LOGIN_REQUIRED if not cookie_value(cookie, key)
)
missing_device = tuple(
key for key in DEVICE_REQUIRED if not cookie_value(cookie, key)
)
missing_wss = missing_login + missing_device
if missing_login:
notes.append(
"缺少 WSS 登录字段: "
+ ", ".join(missing_login)
+ ";活动/商城只能走 HTTP 通道"
)
elif missing_device:
notes.append(
"缺少匿名设备字段: "
+ ", ".join(missing_device)
+ "(加 --anon-device 可由协议补齐)"
)
verify = (
verify_cookie(cookie, uid, verify_sid, timeout)
if verify_sid is not None
else None
)
return Extracted(
source=source,
uid=uid,
passport=cookie_value(raw_cookie, "udb_passport")
or cookie_value(raw_cookie, "username"),
cookie=cookie,
raw_cookie=raw_cookie,
has_cred=bool(cookie_value(cookie, "udb_cred")),
missing_wss=missing_wss,
notes=notes,
verify=verify,
)
def public_record(item: Extracted, *, reveal: bool) -> dict[str, object]:
record: dict[str, object] = {
"label": item.source.label,
"kind": item.source.kind,
"package": item.source.package,
"uid": item.uid,
"passport": item.passport,
"account": item.account,
"cookie_fields": len(cookie_pairs(item.cookie)),
"cookie_bytes": len(item.cookie.encode("utf-8")),
"has_cred": item.has_cred,
"wss_ready": item.wss_ready,
"missing_device": [k for k in DEVICE_REQUIRED if k in item.missing_wss],
"missing_wss": list(item.missing_wss),
"notes": item.notes,
}
if reveal:
record["cookie"] = item.cookie
if item.verify is not None:
record["verify"] = item.verify
return record
def print_table(items: list[Extracted]) -> None:
if not items:
print("没有发现可提取的虎牙 Cookie。")
return
headers = ("实例", "UID", "账号", "字段", "WSS", "验证", "说明")
rows = []
for item in items:
verify = "-"
if item.verify is not None:
if item.verify.get("ok"):
verify = f"{item.verify.get('status')} {item.verify.get('msg') or ''}".strip()
else:
verify = str(item.verify.get("error") or "失败")
rows.append(
(
item.source.label,
str(item.uid),
item.account,
str(len(cookie_pairs(item.cookie))),
"OK" if item.wss_ready else "HTTP",
verify,
";".join(item.notes) or "-",
)
)
widths = [
max(len(headers[index]), *(len(row[index]) for row in rows))
for index in range(len(headers))
]
print(
" ".join(headers[index].ljust(widths[index]) for index in range(len(headers)))
)
for row in rows:
print(
" ".join(row[index].ljust(widths[index]) for index in range(len(headers)))
)
def write_cookie_file(path: Path, items: list[Extracted]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
lines = [f"{item.account}----{item.cookie}" for item in items]
path.write_text("\n".join(lines) + "\n", encoding="utf-8")
os.chmod(path, 0o600)
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description="从已 root 的安卓设备提取小X分身/虎牙 App 的 CK(只读)"
)
parser.add_argument("--adb", default="adb", help="adb 可执行文件(默认: adb)")
parser.add_argument("--serial", help="目标设备序列号(多设备时必填)")
parser.add_argument(
"--include-real",
action="store_true",
help="同时提取真身 com.duowan.kiwi(默认只提分身)",
)
parser.add_argument(
"--raw",
action="store_true",
help="不补齐网页设备字段(仍会注入 cred0 解出的 udb_cred)",
)
parser.add_argument(
"--no-cred0",
action="store_true",
help="不解密 files/huyaudb/cred0(默认会解密并补上 udb_cred)",
)
parser.add_argument(
"--anon-device",
action="store_true",
help="额外走匿名设备态协议补齐 udb_deviceid/guiddata/anobiztoken(需要外网)",
)
parser.add_argument(
"--out",
type=Path,
help="把完整 CK 写成 `账号----ck` 文件(权限 0600)",
)
parser.add_argument(
"--print",
action="store_true",
help="在终端打印完整 `账号----ck`(默认只打印脱敏摘要)",
)
parser.add_argument("--json", action="store_true", help="输出 JSON 摘要")
parser.add_argument(
"--verify-sid",
type=int,
help="可选:用活动积分接口验证 CK(例如 2203;活动已结束也会返回 603)",
)
parser.add_argument("--timeout", type=float, default=15.0, help="验证超时秒数")
return parser
def main(argv: list[str] | None = None) -> int:
args = build_parser().parse_args(argv)
serial = pick_serial(args.adb, args.serial)
device = AdbDevice(args.adb, serial)
device.ensure_root()
sources = discover_sources(device, include_real=args.include_real)
if not sources:
raise CliError(
"没有发现虎牙分身。请确认小X分身已安装虎牙、分身至少启动过一次。"
)
items: list[Extracted] = []
seen: dict[tuple[int, str], str] = {}
for source in sources:
try:
item = extract_one(
device,
source,
fill_web=not args.raw,
verify_sid=args.verify_sid,
timeout=args.timeout,
use_cred0=not args.no_cred0,
anon_device=args.anon_device,
)
except CliError as exc:
print(f"[跳过] {source.label}: {exc}", file=sys.stderr)
continue
key = (item.uid, item.passport)
if key in seen:
print(
f"[去重] {source.label} 与 {seen[key]} 是同一账号 {item.account}",
file=sys.stderr,
)
continue
seen[key] = source.label
items.append(item)
if args.out:
write_cookie_file(args.out, items)
if args.print:
for item in items:
print(f"{item.account}----{item.cookie}")
if args.json:
print(
json.dumps(
[public_record(item, reveal=args.print) for item in items],
ensure_ascii=False,
indent=2,
)
)
elif not args.print:
print_table(items)
if args.out:
print(
f"已写入 {len(items)} 个 CK: {args.out}({datetime.now(UTC).isoformat()})",
file=sys.stderr,
)
if not args.print and not args.out and not args.json:
print("提示:加 --out 落盘,或加 --print 在终端输出完整 CK。")
return 0
if __name__ == "__main__":
try:
raise SystemExit(main())
except CliError as exc:
print(f"错误:{exc}", file=sys.stderr)
raise SystemExit(2) from exc