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
This commit is contained in:
yml2213
2026-10-26 11:23:29 +08:00
parent 6fc453017c
commit 03bc0818dd
9 changed files with 1188 additions and 156 deletions
+16 -4
View File
@@ -1178,11 +1178,13 @@ def parse_points_center_gifts(payload: dict | None) -> list[dict]:
→ tag4 分类名
→ tag5 图标
→ tag9/tag10 金额(分,不是积分)
→ tag13 库存
→ tag14 剩余库存(tag17[0].tag3 兜底)
→ tag13 累计兑换/销量
→ tag75.tag0.tag2 积分
→ tag34.tag26 积分(重复字段,兜底)
注意:``tag13`` 在这里是库存,不是 ``ActPrizeItem.price`` 的那个 tag13。
注意:2026-10-25 抓包里 ``tag14`` 才是剩余库存,和 SKU ``tag17[0].tag3``
一致;``tag13`` 是累计兑换/销量。旧抓包没有 ``tag14`` 时才回退 ``tag13``。
"""
if not isinstance(payload, dict):
return []
@@ -1234,14 +1236,24 @@ def parse_points_center_gifts(payload: dict | None) -> list[dict]:
else None
)
stock = _as_int(basic.get("tag13"))
# 2026-10-25 抓包:tag13 是累计兑换/销量,tag14 才是剩余库存,
# 且与 SKU 列表 tag17[0].tag3 一致;旧抓包没有 tag14 时回退 tag13。
claimed = _as_int(basic.get("tag13"))
stock = _as_int(basic.get("tag14"))
if stock is None:
skus = basic.get("tag17")
if isinstance(skus, list) and skus and isinstance(skus[0], dict):
stock = _as_int(skus[0].get("tag3"))
if stock is None:
stock = claimed
gifts.append(
{
"product_id": spu,
"name": name[:256],
"price": points,
"remain_text": f"剩{stock}" if stock is not None else "",
"remain_text": str(stock) if stock is not None else "",
"stock": stock,
"claimed": claimed,
"score": points,
"category_name": (_as_text(basic.get("tag4")) or category_name)[
:64
+153 -112
View File
@@ -18,8 +18,6 @@
import asyncio
import random
import re
import struct
from collections import deque
from collections.abc import Callable
from typing import Any, cast
@@ -41,8 +39,6 @@ ACTIVITY_WS_HOST = "d35bf373-ws.va.huya.com"
# wsLaunch tReq: lUid=0, sGuid="", sUA="webh5&0.0.1&websocket&&h5_/index.html"
SHOP_BASEINFO = "DBYAJiV3ZWJoNSYwLjAuMSZ3ZWJzb2NrZXQmJmg1Xy9pbmRleC5odG1sNgxIVVlBJlpIJjIwNTJGAFYAbHYAhgCWAKgM"
TAIL_BYTES = bytes.fromhex("2c36004c5c6600")
BIG_TAIL_SUFFIX = bytes.fromhex("4c5c6600")
WSS_BIZ_UA = "web&1.0.0&huya"
WSS_COOKIE_UA = "webh5&0.0.1&websocket&&diypc_52775"
WSS_SHOP_SCENE = 4
@@ -114,33 +110,81 @@ POINTS_ORDER_METHODS = frozenset({"createOrderV5", "payOrderSubmitV5"})
class WssCommand:
RPC_REQUEST = 0x03
RPC_RESPONSE = 0x04
HEARTBEAT = 0x05
HEARTBEAT_ACK = 0x06
AUTH = 0x0A
SERVER_PUSH1 = 0x0B
HEARTBEAT_SEND = 0x10
HEARTBEAT_RECV = 0x11
CONFIRM_SEND = 0x17
SERVER_PUSH2 = 0x18
REGISTER = 0x21
AUTH_RESPONSE = 0x0B
HEARTBEAT_SEND = 0x10 # EWSCmdC2S_RegisterGroupReq
HEARTBEAT_RECV = 0x11 # EWSCmdS2C_RegisterGroupRsp
CONFIRM_SEND = 0x17 # EWSCmdC2S_UpdateUserExpsReq
CONFIRM_RECV = 0x18
PING = 0x14 # EWSCmdC2S_HeartBeatReq
PING_ACK = 0x15 # EWSCmdS2C_HeartBeatRsp
REGISTER = 0x21 # EWSCmdC2S_UpdateUserInfoReq
class WssMessage:
def __init__(self, command: int, sequence: int, body: bytes):
"""浏览器 ``HUYA.WebSocketCommand``(TAF/JCE 结构,2026-10-25 对齐 huya-go)。
结构字段:
0 iCmdType(int32) 1 vData(bytes) 2 lRequestId(int64)
3 traceId(string) 4 iEncryptType(int32) 5 lTime(int64) 6 sMD5(string)
``sequence`` 保留旧命名,实际承载 ``lRequestId``;``body`` 即 ``vData``。
"""
def __init__(
self,
command: int = 0,
sequence: int = 0,
body: bytes = b"",
*,
trace_id: str = "",
encrypt_type: int = 0,
timestamp: int = 0,
md5: str = "",
):
self.command = command
self.sequence = sequence
self.body = body
self.trace_id = trace_id
self.encrypt_type = encrypt_type
self.timestamp = timestamp
self.md5 = md5
def encode(self) -> bytes:
header = struct.pack(">BBI", 0x00, self.command, self.sequence)
return header + self.body
os = TafOutputStream()
os.write_int32(0, int(self.command))
os.write_bytes(1, self.body or b"")
os.write_int64(2, int(self.sequence))
os.write_string(3, self.trace_id)
os.write_int32(4, int(self.encrypt_type))
os.write_int64(5, int(self.timestamp))
os.write_string(6, self.md5)
return os.get_bytes()
@classmethod
def decode(cls, data: bytes) -> "WssMessage":
if len(data) < 6:
if len(data) < 2:
raise ValueError(f"消息太短: {len(data)} bytes")
_version, command = struct.unpack(">BB", data[0:2])
sequence = struct.unpack(">I", data[2:6])[0]
body = data[6:]
return cls(command=command, sequence=sequence, body=body)
ins = TafInputStream(data)
# 必须按结构字段顺序读取:tag0 命令 → tag1 数据 → tag2 requestId → ...
command = ins.read_int32(0)
body = ins.read_bytes(1)
request_id = ins.read_int64(2)
trace_id = ins.read_string(3)
encrypt_type = ins.read_int32(4)
timestamp = ins.read_int64(5)
md5 = ins.read_string(6)
return cls(
command=command,
sequence=request_id,
body=body,
trace_id=trace_id,
encrypt_type=encrypt_type,
timestamp=timestamp,
md5=md5,
)
class HuyaWssClient:
@@ -153,7 +197,9 @@ class HuyaWssClient:
self.ws: Any = None
self._biz_seq = SEQ_BUSINESS # 业务 RPC 递增用
self._wup_req_id = 8
self.rpc_queue = deque()
# 按 WUP iRequestId 匹配响应(浏览器/HAR 实测:服务端回包 requestID 与请求一致)
self._pending: dict[int, asyncio.Future] = {}
self.logger = logger or print
self._recv_task = None
self._heartbeat_task = None
@@ -183,49 +229,24 @@ class HuyaWssClient:
return req_id
@staticmethod
def _make_big_tail() -> bytes:
"""生成 WSS 大包尾部 trace(格式与浏览器 HAR 一致)"""
def _new_trace_id() -> str:
"""浏览器帧 traceId: ``<hex>:<hex>:0:<enableTrace>``。"""
trace = f"{random.getrandbits(64):016x}"
return f",6%{trace}:{trace}:0:3".encode("ascii") + BIG_TAIL_SUFFIX
return f"{trace}:{trace}:0:0"
@staticmethod
def _encode_rpc_body(wup_data: bytes) -> bytes:
"""按浏览器规则封装 WSS RPC body
"""业务 RPC 的 vData 就是 WUP 原包(长度前缀 + WUP 结构)。
小包: [4B WUP长度][WUP body] + TAIL_BYTES
大包: [1B len低字节][4B WUP body长度][裸WUP body] + trace尾部
2026-10-25 对齐 huya-go:浏览器把 ``Wup.encode()`` 的结果原样放进
``WebSocketCommand.vData``,不再拼 trace/尾部字节。
"""
if len(wup_data) < 4:
return wup_data + TAIL_BYTES
declared_len = int.from_bytes(wup_data[:4], "big")
if declared_len == len(wup_data):
wup_body = wup_data[4:]
else:
wup_body = wup_data
if len(wup_body) <= 0xFF:
return wup_data + TAIL_BYTES
# 大包头里的长度使用原始 WUP 包长度(含 4B 长度前缀),但正文只放裸 WUP body;
# 浏览器随后用 trace 尾部的前 4 字节补足该长度窗口。
packet_len = len(wup_data)
return (
bytes([packet_len & 0xFF])
+ packet_len.to_bytes(4, "big")
+ wup_body
+ HuyaWssClient._make_big_tail()
)
return wup_data
@staticmethod
def _encode_activity_launch_body(wup_data: bytes) -> bytes:
"""活动 wsLaunch 使用浏览器的固定大包头,即使包体小于 255B。"""
if len(wup_data) < 4:
return wup_data + TAIL_BYTES
packet_len = len(wup_data)
return (
bytes([packet_len & 0xFF])
+ packet_len.to_bytes(4, "big")
+ wup_data[4:]
+ TAIL_BYTES
)
"""wsLaunch 同样直接使用 WUP 原包。"""
return wup_data
@staticmethod
def _normalize_biz_cookie(cookie: str) -> str:
@@ -402,42 +423,65 @@ class HuyaWssClient:
except Exception as e: # noqa: BLE001 外部接口与任务边界需要保留宽泛异常兜底
self.logger(f"[WSS] 接收循环异常: {type(e).__name__}: {e}")
@staticmethod
def _wup_request_id(body: bytes) -> int | None:
"""从 WSS 回包 vData 里解出 WUP iRequestId(失败返回 None)。"""
try:
wup = WupResponse()
wup.decode(HuyaWssClient._extract_wup(body))
return int(wup.iRequestId)
except Exception: # noqa: BLE001 外部响应可能不完整,按未匹配处理
return None
async def _handle_message(self, msg: WssMessage):
if msg.command == WssCommand.RPC_RESPONSE:
if self.rpc_queue:
future = self.rpc_queue.popleft()
if not future.done():
future.set_result(msg.body)
request_id = self._wup_request_id(msg.body)
future = (
self._pending.pop(request_id, None) if request_id is not None else None
)
if future is None and len(self._pending) == 1:
# 兜底:服务端偶尔不回原始 requestId,只剩一个在途请求时直接认领
fallback_id, fallback = next(iter(self._pending.items()))
self._pending.pop(fallback_id, None)
future = fallback
if future is not None and not future.done():
future.set_result(msg.body)
else:
self.logger(f"[WSS] 未匹配的 RPC 响应 seq={msg.sequence}")
elif msg.command == WssCommand.HEARTBEAT_RECV:
pass # 心跳已在上层 _recv_loop 打过日志
elif msg.command in (WssCommand.SERVER_PUSH1, WssCommand.SERVER_PUSH2):
pass # 推送已在上层打过日志
else:
pass # 未知消息已在上层打过日志
self.logger(f"[WSS] 未匹配的 RPC 响应 requestId={request_id}")
elif msg.command in (
WssCommand.PING_ACK,
WssCommand.HEARTBEAT_ACK,
WssCommand.HEARTBEAT_RECV,
WssCommand.AUTH_RESPONSE,
WssCommand.CONFIRM_RECV,
):
pass # 控制类响应无需处理,日志已在 _recv_loop 打出
async def _heartbeat_loop(self):
try:
while True:
await asyncio.sleep(8)
await self.send_heartbeat()
await self.send_ping()
except asyncio.CancelledError:
pass
except Exception as e: # noqa: BLE001 外部接口与任务边界需要保留宽泛异常兜底
self.logger(f"[WSS] 心跳循环异常: {e}")
async def send_ping(self):
"""EWSCmdC2S_HeartBeatReq(20),vData 为空(对齐 huya-go)。"""
msg = WssMessage(command=WssCommand.PING, sequence=0, body=b"")
await self.ws.send(msg.encode())
async def send_heartbeat(self):
# body = LIST(tag0,count=1) + 元素{tag0="live:0", tag1=""} + TAIL
"""EWSCmdC2S_RegisterGroupReq(16):vData = list{"live:0"} + token。"""
os = TafOutputStream()
os.write_head(0, TafType.LIST) # 09
os.write_int32(0, 1) # 00 01 (count=1)
os.write_string(0, "live:0") # 06 06 live:0
os.buf.write(b"\x16\x00") # 16 00 (tag1 "")
body = os.get_bytes() + TAIL_BYTES
msg = WssMessage(
command=WssCommand.HEARTBEAT_SEND, sequence=SEQ_HEARTBEAT, body=body
)
os.write_string(1, "") # 16 00
body = os.get_bytes()
msg = WssMessage(command=WssCommand.HEARTBEAT_SEND, sequence=0, body=body)
self.logger(
format_wss_log(body, WssCommand.HEARTBEAT_SEND, SEQ_HEARTBEAT, "发")
)
@@ -530,18 +574,15 @@ class HuyaWssClient:
wup.iTimeout = 0
wup.newdata["tReq"] = os.get_bytes()
body = self._encode_activity_launch_body(wup.encode())
msg = WssMessage(WssCommand.RPC_REQUEST, SEQ_ACTIVITY_WSLAUNCH, body)
msg = WssMessage(WssCommand.RPC_REQUEST, 0, body)
future = asyncio.Future()
self.rpc_queue.append(future)
self.logger(
format_wss_log(body, WssCommand.RPC_REQUEST, SEQ_ACTIVITY_WSLAUNCH, "发")
)
self._pending[-1] = future
self.logger(format_wss_log(body, WssCommand.RPC_REQUEST, -1, "发"))
await self.ws.send(msg.encode())
try:
response = await asyncio.wait_for(future, timeout)
except TimeoutError:
if future in self.rpc_queue:
self.rpc_queue.remove(future)
self._pending.pop(-1, None)
return None
self.logger(
format_wss_log(
@@ -601,18 +642,16 @@ class HuyaWssClient:
wup_data = wup.encode()
body = self._encode_rpc_body(wup_data)
seq = SEQ_ACTIVITY_WSLAUNCH
msg = WssMessage(command=WssCommand.RPC_REQUEST, sequence=seq, body=body)
msg = WssMessage(command=WssCommand.RPC_REQUEST, sequence=0, body=body)
future = asyncio.Future()
self.rpc_queue.append(future)
self.logger(format_wss_log(body, WssCommand.RPC_REQUEST, seq, "发"))
self._pending[-1] = future
self.logger(format_wss_log(body, WssCommand.RPC_REQUEST, -1, "发"))
await self.ws.send(msg.encode())
try:
body = await asyncio.wait_for(future, timeout)
except TimeoutError:
if future in self.rpc_queue:
self.rpc_queue.remove(future)
self._pending.pop(-1, None)
self.logger("[✗] wsLaunch 超时")
return None
@@ -716,8 +755,6 @@ class HuyaWssClient:
``ua`` 可覆盖:积分中心页面为 ``h5_/points.html``(2026-10-13 抓包)。
"""
os = TafOutputStream()
if self._activity_mode or self._points_mode:
os.write_head(3, TafType.ZERO)
os.write_int64(0, int(uid or 0))
os.write_string(1, ua or WSS_COOKIE_UA)
os.write_string(2, HuyaWssClient._normalize_biz_cookie(cookie))
@@ -728,13 +765,12 @@ class HuyaWssClient:
or self._launch_guid
)
os.write_string(3, auth_guid)
os.write_int8(4, 1)
os.write_int32(4, 1) # AutoRegisterUID
os.write_string(5, "HUYA&ZH&2052")
os.write_string(6, "")
auth_body = os.get_bytes() + TAIL_BYTES
auth_body = os.get_bytes()
msg = WssMessage(
command=WssCommand.AUTH,
sequence=sequence,
sequence=0,
body=auth_body,
)
await self.ws.send(msg.encode())
@@ -796,8 +832,8 @@ class HuyaWssClient:
os.write_struct_end()
os.write_map(7, {})
os.write_int32(8, 0)
body = os.get_bytes() + TAIL_BYTES
msg = WssMessage(command=WssCommand.REGISTER, sequence=SEQ_REGISTER, body=body)
body = os.get_bytes()
msg = WssMessage(command=WssCommand.REGISTER, sequence=0, body=body)
self.logger(format_wss_log(body, WssCommand.REGISTER, SEQ_REGISTER, "发"))
await self.ws.send(msg.encode())
@@ -809,10 +845,8 @@ class HuyaWssClient:
"""
os = TafOutputStream()
os.write_map(0, version_map or {})
body = os.get_bytes() + TAIL_BYTES
msg = WssMessage(
command=WssCommand.CONFIRM_SEND, sequence=SEQ_CONFIRM, body=body
)
body = os.get_bytes()
msg = WssMessage(command=WssCommand.CONFIRM_SEND, sequence=0, body=body)
self.logger(format_wss_log(body, WssCommand.CONFIRM_SEND, SEQ_CONFIRM, "发"))
await self.ws.send(msg.encode())
@@ -880,28 +914,32 @@ class HuyaWssClient:
rsp_class=None,
timeout: float = 10.0,
):
seq = self._next_biz_seq(method in POINTS_ORDER_METHODS)
wup = WupRequest()
wup.setServant(service)
wup.setFunc(method)
wup.setRequestId(self._next_wup_req_id())
req_id = self._next_wup_req_id()
wup.setRequestId(req_id)
wup.iTimeout = 0
wup.writeStruct("tReq", req_struct)
wup_data = wup.encode()
body = self._encode_rpc_body(wup_data)
msg = WssMessage(command=WssCommand.RPC_REQUEST, sequence=seq, body=body)
# 浏览器帧的 lRequestId 固定 0,区分请求靠 traceId + WUP iRequestId
msg = WssMessage(
command=WssCommand.RPC_REQUEST,
sequence=0,
body=body,
trace_id=self._new_trace_id(),
)
future = asyncio.Future()
self.rpc_queue.append(future)
self.logger(format_wss_log(body, WssCommand.RPC_REQUEST, seq, "发"))
self._pending[req_id] = future
self.logger(format_wss_log(body, WssCommand.RPC_REQUEST, req_id, "发"))
await self.ws.send(msg.encode())
try:
body = await asyncio.wait_for(future, timeout)
except TimeoutError:
if future in self.rpc_queue:
self.rpc_queue.remove(future)
self._pending.pop(req_id, None)
self.logger(f"[✗] {service}.{method} 超时")
return None
@@ -1302,28 +1340,31 @@ class HuyaWssClient:
os.write_struct_end()
seq = self._next_biz_seq(True)
wup = WupRequest()
wup.setServant("shopMiddleUI")
wup.setFunc("payOrderSubmitV5")
wup.setRequestId(self._next_wup_req_id())
req_id = self._next_wup_req_id()
wup.setRequestId(req_id)
wup.iTimeout = 0
wup.newdata["tReq"] = os.get_bytes()
wup_data = wup.encode()
body = self._encode_rpc_body(wup_data)
msg = WssMessage(command=WssCommand.RPC_REQUEST, sequence=seq, body=body)
msg = WssMessage(
command=WssCommand.RPC_REQUEST,
sequence=0,
body=body,
trace_id=self._new_trace_id(),
)
future = asyncio.Future()
self.rpc_queue.append(future)
self.logger(format_wss_log(body, WssCommand.RPC_REQUEST, seq, "发"))
self._pending[req_id] = future
self.logger(format_wss_log(body, WssCommand.RPC_REQUEST, req_id, "发"))
await self.ws.send(msg.encode())
try:
body = await asyncio.wait_for(future, 15.0)
except TimeoutError:
if future in self.rpc_queue:
self.rpc_queue.remove(future)
self._pending.pop(req_id, None)
self.logger("[✗] payOrderSubmitV5 超时")
return None
+127
View File
@@ -0,0 +1,127 @@
# 精英宝典 HAR:完整 CK 与积分中心商品
> 抓包:`/Users/yml/Desktop/抓包/hy/10.25/精英宝典日常皮肤-1.har`
> 时间:2026-10-25 · 页面:`m-shop.huya.com/points.html?pagecode=heping_baodian_credit_center_page&sourceId=yellowcarlist`
## 一、结论
- 这是 **Web H5 抓包**(WebInspector,117 条请求),不是 App 抓包。
- 请求里的 `PlayMallUI.queryAllocation` 带 **完整浏览器 CK**:
- 原始 `tReq.tag0.tag4` 有 79 个键(含重复),去重后 **43 个字段 / 2338B**;
- 包含 `udb_cred`、`udb_biztoken`、`udb_deviceid`、`udb_guiddata`、`udb_anobiztoken`;
- 9 个 WSS 必填字段**全齐**,可以直接走 WSS。
- 账号:`hy_302081117` / UID `1199668650719`。
- 当前积分中心返回 **18 个「精英专享」商品**;现有 `parse_points_center_gifts()` 解析结果与抓包一致,核心解析代码不需要改。
- 赛季:配置 `scoreCenterConfigId=15`,活动 ID `3203`,`精英宝典福利季丨Q4`,有效期 **2026-10-09 00:00 → 2026-12-31 23:59:59(UTC+8)**。
## 二、HAR 里哪些请求有用
真正有业务数据的只有 7 条 `cdnws.api.huya.com` 的 WUP 请求:
| 函数 | 作用 |
|---|---|
| `launch.wsLaunch` | 建立 WSS 会话,拿服务端地址 |
| `launch.pingSignal` | 心跳 |
| `huyauserui.getUserProfile` | 用户资料 |
| `wupui.getUserFollowWeChatOfficialStatus` | 微信关注状态 |
| `PlayMallUI.queryAllocation` | **积分中心商品/配置(核心)** |
| `launch.queryHttpDns` | HTTP DNS |
其余是静态资源、埋点 `ylog/metric/statwup`、商城图片等。
## 三、完整 CK 在哪
`queryAllocation` 请求解码后:
```text
tReq.tag0.tag0 = 1199668650719 # UID
tReq.tag0.tag4 = <完整 document.cookie>
tReq.tag2 = heping_baodian_credit_center_page
tReq.tag1.tag6 = yellowcarlist
```
注意:HAR 里的原始 Cookie 有大量重复键(79 个),必须先按 key 去重、保留最后一次出现的值,得到 43 个字段。直接把原始 4090B 字符串塞进系统会带旧值。
脚本 `scripts/huya_har_ck.py` 会自动做这一步。
```bash
# 摘要 + 18 个商品
.venv/bin/python scripts/huya_har_ck.py \
"/Users/yml/Desktop/抓包/hy/10.25/精英宝典日常皮肤-1.har" --goods
# 落盘为 `账号----ck`(权限 0600)
.venv/bin/python scripts/huya_har_ck.py \
"/Users/yml/Desktop/抓包/hy/10.25/精英宝典日常皮肤-1.har" \
--out /tmp/huya_har_ck.txt
# JSON(默认不含完整 CK;加 --print 才包含)
.venv/bin/python scripts/huya_har_ck.py xxx.har --json
```
本次实测已生成 `/tmp/huya_har_ck.txt`:
```text
hy_302081117----<完整 CK>
```
## 四、18 个精英专享商品(2026-10-25 抓包快照)
| # | SPU | 名称 | 积分 | 库存 |
|---|---|---|---|---|
| 01 | `hy-8328509` | 套装-雪山精英 | 600 | 5054 |
| 02 | `hy-8347511` | 套装-青涩年华 | 488 | 6920 |
| 03 | `hy-8327363` | 套装-暗影哥特 | 888 | 3293 |
| 04 | `hy-8349003` | 套装-浪漫波比 | 1280 | 2207 |
| 05 | `hy-8315965` | 套装-浪漫天命 | 360 | 3298 |
| 06 | `hy-8348009` | 套装-牛仔情诗 | 550 | 454 |
| 07 | `hy-8335113` | 黑色高级特训官上衣 | 360 | 800 |
| 08 | `hy-8315982` | 幸运币礼包(小) | 20 | 9235 |
| 09 | `hy-8344526` | 荣耀勋章礼包(小) | 20 | 1951 |
| 10 | `hy-8338110` | 套装-甜星爱神 | 1280 | 246 |
| 11 | `hy-8335118` | 荣耀勋章礼包(中) | 300 | 448 |
| 12 | `hy-8329225` | 荣耀勋章礼包(大) | 900 | 549 |
| 13 | `hy-8329234` | 幸运币礼包(中) | 300 | 2301 |
| 14 | `hy-8328508` | 幸运币礼包(大) | 900 | 6647 |
| 15 | `hy-8348012` | 套装-甜心信使 | 1280 | 197 |
| 16 | `hy-8331702` | 浪漫波比头盔 | 1680 | 28 |
| 17 | `hy-8346046` | 浪漫波比背包 | 1680 | 442 |
| 18 | `hy-8324889` | 蜘蛛突变者大礼包 | 5500 | 78 |
> 积分取 `tag34.tag26`(600/488/888...)。同一商品里的 `tag9/tag10` 是 6000/4880/8880,别误当成积分;现有解析器用的就是 `tag34.tag26`,实测与页面一致。
>
> 库存取 `tag14`(与 SKU `tag17[0].tag3` 一致)。`tag13` 在本次抓包里是累计兑换/销量(例如雪山精英 `tag13=43194`、`tag14=5054`),不能当剩余库存;旧抓包无 `tag14` 时解析器回退 `tag13`。
## 五、赛季与日常任务
`queryAllocation` 响应 `tag2[1].tag2_2`:
- `tag0`:配置 `15`、名称 `精英宝典福利季`、副标题 `精英宝典福利季丨Q4`、活动 `3203`;
- `tag10/tag11`:`1791475200000` / `1798732799000`;
- `tag4`:`{8000, 0, 0, 1, 0}`(页面侧积分/状态快照)。
日常任务(`tag1`):
1. 开通宝典并订阅「和平精英福利提醒」→ **5 积分**;
2. 开通宝典并每天观看和平精英直播 10 分钟 → **2 积分**。
任务和商品库存都会变,实际执行前以实时 `queryAllocation` 为准。
## 六、验证
用 HAR 提取的完整 CK 实时调用现有 HTTP 通道:
```text
play_mall_query_allocation(uid=1199668650719, deep=True)
→ code=200
→ parse_points_center_gifts → 18 个商品
→ 第一个:套装-雪山精英 600 积分
```
本次抓包的 CK 已通过该验证;由于 `udb_cred` 存在,也可以走 WSS。
## 七、注意事项
- `/tmp/huya_har_ck.txt` 是登录凭据,文件权限已 `0600`;导入系统后请删除。
- HAR 本身也含完整 Cookie,不要提交 Git、不要外发。
- 商品库存是抓包瞬间的值,正式下单前必须重新查询。
- `scripts/*` 被 `.gitignore` 忽略;脚本是本地工具,若要入库需 `git add -f scripts/huya_har_ck.py`。
+190
View File
@@ -0,0 +1,190 @@
# 小X分身虎牙 CK 提取(Android / root)
> 2026-10-25 实测设备:OnePlus PHK110,Android 16,SukiSU Ultra v4.2.1。
> 结论:**root 后读每个分身的 WebView Cookies SQLite 即可拿到可用 CK**;不需要改 App,也不需要 Frida。
## 一、设备上的分身
小X分身并不是把多个账号塞进同一个进程,而是给每个分身分配独立的包/UID:
| 实例 | 进程(示例) | UID | 数据目录 |
|---|---|---|---|
| 主分身 1 | `com.bly.dkplat:p0` 内的 `com.duowan.kiwi` | 10363 | `/data/user/0/com.bly.dkplat/chaos/data/user/1/com.duowan.kiwi` |
| 主分身 2 | `com.bly.dkplat:p3` 内的 `com.duowan.kiwi` | 10363 | `/data/user/0/com.bly.dkplat/chaos/data/user/2/com.duowan.kiwi` |
| 独立分身 A | `dkplugin.duq.lls:p0` 内的 `com.duowan.kiwi` | 10365 | `/data/user/0/dkplugin.duq.lls/virtual/data/user/0/com.duowan.kiwi` |
| 独立分身 B | `dkplugin.zoc.bcn:p0` 内的 `com.duowan.kiwi` | 10366 | `/data/user/0/dkplugin.zoc.bcn/virtual/data/user/0/com.duowan.kiwi` |
| 真身(可选) | `com.duowan.kiwi` | 10364 | `/data/user/0/com.duowan.kiwi` |
每个分身真正登录态落在:
```text
<数据目录>/app_webview/Default/Cookies
```
这是 WebView 的 SQLite Cookie 库,`value` 是明文(当前设备上 `encrypted_value` 全为 0)。
## 二、为什么必须 root
无 root 时以下路径都走不通(已逐项实测):
- `run-as`:分身和真身都 `not debuggable`
- `am dumpheap`:`Process not debuggable`
- `content query`:`CFileProvider` / `DKFileProvider` 均未导出
- `/sdcard/Android/data/...`:只有 cache / 日志,没有 Cookie
- WebView 调试:`/proc/net/unix` 没有 `webview_devtools_remote`
- `adb backup`:包没有 `ALLOW_BACKUP`,且 Android 16 对 targetSdk 33+ 的限制
root 后直接 `cat` 上面的 `Cookies` 即可,不需要停 App。
## 三、一键提取
脚本:`scripts/huya_clone_ck.py`
```bash
# 只提 4 个分身,终端看脱敏摘要
.venv/bin/python scripts/huya_clone_ck.py
# 连真身一起提,落盘为 `账号----ck`(文件权限 0600)
.venv/bin/python scripts/huya_clone_ck.py --include-real --out /tmp/huya_cks.txt
# 需要直接看完整 CK 时(会打印到终端,注意不要留 shell 历史)
.venv/bin/python scripts/huya_clone_ck.py --include-real --print
# 用活动积分接口验证(sid 换成当前活动;活动结束返回 603 也算登录态被接受)
.venv/bin/python scripts/huya_clone_ck.py --include-real --verify-sid 2203
```
脚本行为:
1. `adb shell su -c id` 必须返回 `uid=0`,否则直接报错并提示去 SukiSU 授权;
2. 自动发现 `dkplugin.*` 的 virtual 数据目录,以及 `com.bly.dkplat/chaos/data/user/*/com.duowan.kiwi`;
3. `cat` 每个分身的 `Cookies` 到临时文件,用 SQLite 读取 `host_key like '%huya.com'` 的非空 Cookie;
4. 默认调用现有 `fill_web_cookie_fields()` 补齐网页设备态,并把 `udb_appid` 修正为 `5002`;
5. 按 `(udb_uid, udb_passport)` 去重,避免真身/分身重复账号;
6. 可用 `--out` 输出 `账号----ck`,直接贴进协作台账的「贴 / 换 CK」。
> 脚本默认**不打印完整 CK**,只打印实例、UID、账号、字段数、WSS 是否可用和验证结果;必须显式 `--print` 或 `--out` 才拿明文。
## 四、实测结果(2026-10-25)
| 实例 | UID | 账号 | Cookie 字段 | 接口验证 |
|---|---|---|---|---|
| `clone:dkplat/1` | 1199668927843 | `hy_302407019` | 38 | 603 活动已结束 |
| `clone:dkplat/2` | 1199668931394 | `hy_302409741` | 38 | 603 活动已结束 |
| `clone:duq.lls` | 1199668932116 | `hy_302407828` | 38 | 603 活动已结束 |
| `clone:zoc.bcn` | 1199668932128 | `hy_302407842` | 38 | 603 活动已结束 |
| `real:huya` | 1199668930633 | `hy_302408724` | 38 | 603 活动已结束 |
`query_user_score(sid=2203)` 返回 `603 活动已结束` 说明请求已经带着有效登录态进入了业务层;同一接口用伪造 Cookie 调用时没有 `tRsp` 数据,可直接区分。
## 五、已知限制:WSS 字段不全
虎牙 App 的 `udb_cred` 不在 WebView Cookie 库里,而在:
```text
<数据目录>/files/huyaudb/cred0
```
`cred0` 是加密落盘(`UDB_SHAREAPP_DATA` / `credAnonymous` 同样加密)。**2026-10-25 已实现离线解密,见第八节**;下面三条是解密之前的历史结论:
- **HTTP cdnws 通道可用**:实测 `getUserScore` 能正常进入业务层;
- ~~**WSS 通道不可用**~~:当时缺 `udb_cred` / `udb_deviceid` / `udb_guiddata` / `udb_anobiztoken`,会回退 HTTP;现已由 cred0 + 匿名设备态协议补齐;
- ~~需要 Frida hook 或重新签发 cred~~:不需要,直接 AES-128-ECB 解密即可。
- 另一种拿到完整 WSS CK 的方式是从已登录的 H5 抓包提取,见 `docs/虎牙/HAR提取CK与积分中心.md`;注意 `udb_cred` 是账号级凭据,不能从别的账号挪用。
## 六、注意事项
- CK 就是登录凭据,`--out` 生成的文件权限已设为 `0600`;不要提交到 Git、不要发群。
- 不要对分身执行 `pm clear` / 卸载 / 清数据,否则 Cookie 和登录态一起没了。
- 虎牙 App 更新后 Cookie 库结构一般不变;如果 `Cookies` 变成 `encrypted_value`,脚本只会在备注里提示,不会输出错误 CK。
- root 授权被回收后,`su: inaccessible`,重新去 SukiSU 给 Shell 授权即可。
## 七、手机 CK 转网页 CK 实测(2026-10-25)
用一个小X分身账号做了完整链路验证,测试过程中不输出 CK 值:
| 步骤 | 结果 |
|---|---|
| WebView 原始 CK | 17 字段;无 `udb_cred` / `guid` / `udb_deviceid` / `udb_guiddata` / `udb_anobiztoken` |
| `fill_web_cookie_fields()` 补齐后 | 38 字段;仍缺 `udb_cred` / `udb_deviceid` / `udb_guiddata` / `udb_anobiztoken` |
| `checkLogin`(原始 CK) | HTTP 200,`isLogined=true`,UID 匹配 |
| `POST /web/cookie/verify` | HTTP 200,`returnCode=0`,`data=null`,**无 Set-Cookie** |
| 打开积分中心 H5 | HTTP 200,**无 Set-Cookie**,Cookie 数量不变 |
| `PlayMallUI.queryAllocation`(原始 CK) | `code=200`,`msg=成功`,18 个商品 |
| `PlayMallUI.queryAllocation`(38 字段 CK) | `code=200`,`msg=成功`,18 个商品 |
| 网页请求后的 CK 再查询 | `code=200`,`msg=成功`,18 个商品 |
结论:
1. **手机 WebView 的 CK 本身已经能作为虎牙网页 HTTP CK 使用**,可以直接调用积分中心等 H5/HTTP 活动接口。
2. 虎牙**没有**提供一个“提交手机 CK、由服务端下发完整网页 CK”的兑换接口。`web/cookie/verify` 成功时不回 `Set-Cookie`,积分中心页面也不会补发登录 Cookie。
3. 本地 `fill_web_cookie_fields()` 只补齐 `guid`、QIMEI、页面统计等网页运行字段,不会生成或取得 `udb_cred`。
4. 因此手机 CK 可以稳定支持 **HTTP 活动查询**;依赖完整 9 字段的 WSS、部分领取/兑换类动作仍不可用,除非另外解密 `cred0` 或从已登录 H5 抓包取得 `udb_cred`。
5. 本次只验证了只读活动查询,没有执行积分领取、兑换或支付。
## 八、cred0 离线解密:手机 CK 直接升级为完整 WSS CK(2026-10-25)
### 8.1 结论
`files/huyaudb/cred0` 是 **AES-128-ECB + 零填充(zero padding)** 加密的登录历史 JSON,
密钥是虎牙 UDB 主密钥的前 16 字节:
```text
HuyaUdb1928374650qwertyuiop[:16] → "HuyaUdb192837465"
```
解密后结构(`loginHistory[0].apploginData`):
| 字段 | 含义 |
|---|---|
| `cred` | 152 字符 base64url,**就是 `udb_cred`**(114 字节 hyCred) |
| `uid` / `passport` | 账号 UID 与 `hy_xxxx` 账号 |
| `cookie.biztoken` | 网页 `udb_biztoken`(344 字符) |
| `cookie.status` / `cookie.version` | `udb_status` / `udb_version` |
| `biztoken_vec[0].biztoken` | App 通道 216 字符 biztoken(与网页不同) |
同一套密钥也能解 `credAnonymous`(匿名态)和 `UDB_SHAREAPP_DATA`。
### 8.2 脚本支持
```bash
# 默认:WebView Cookie + cred0 凭据 + 网页设备态补齐(39 字段)
.venv/bin/python scripts/huya_clone_ck.py --serial <serial> --json
# 完整 WSS CK:再加匿名设备态四字段(43 字段)
.venv/bin/python scripts/huya_clone_ck.py --serial <serial> --anon-device --out /tmp/cks.txt
```
新增参数:
- `--no-cred0`:只取 WebView Cookie,不解密 cred0;
- `--anon-device`:额外走 `core/huya/anon_device.py` 的 middle + anonymousLogin,
补齐 `udb_deviceid` / `udb_guiddata` / `udb_anobiztoken` / `udb_anouid`(需要外网)。
JSON 摘要新增 `wss_ready`(登录态字段齐全)与 `missing_device`(待匿名设备态补齐的字段)。
### 8.3 实测(2026-10-25,四个分身)
| 实例 | 账号 | 无 `--anon-device` | 加 `--anon-device` | WSS `PlayMallUI.queryAllocation` |
|---|---|---|---|---|
| `clone:dkplat/1` | `hy_302407019` | 39 字段,有 `udb_cred` | 43 字段 | `code=200`,18 个商品 |
| `clone:dkplat/2` | `hy_302409741` | 39 | 43 | 未单独跑(同链路) |
| `clone:duq.lls` | `hy_302407828` | 39 | 43 | `code=200`,18 个商品 |
| `clone:zoc.bcn` | `hy_302407842` | 39 | 43 | 未单独跑(同链路) |
对照:同一个 CK 在 Go 参考实现(`~/codes/huya-go`)里调用
`webActUI.getActPrizeList(sid=2277)` 也是 `status=200 / 18 项`,两边商品一致。
### 8.4 顺带修掉的 WSS 帧层 Bug
原先 `core/huya/wss_client.py` 用「6 字节自定义头 + 尾部字节」拼帧,服务端对
`launch.wsLaunch` 一直不回包(日志 `wsLaunch 无响应`)。按 `huya-go` 的实现改为浏览器真正的
`HUYA.WebSocketCommand`(TAF/JCE 结构:`iCmdType` / `vData` / `lRequestId` / `traceId` /
`iEncryptType` / `lTime` / `sMD5`)后:
- `vData` 直接放 `Wup.encode()` 原包,不再拼 `TAIL_BYTES`;
- 响应按 WUP `iRequestId` 匹配(不再 FIFO);
- 初始化顺序对齐:`RegisterGroup(16)` → `wsLaunch(-1)` → `VerifyCookie(10)` →
`getConfig` → `UpdateUserExps(23)` → `RegisterGroup(16)` → `HeartBeat(20)`。
修改后 `tests/ -k huya` 全绿(394 passed),并用真号 CK 实测 WSS 返回 18 个商品。
+622
View File
@@ -0,0 +1,622 @@
"""从小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
+31 -2
View File
@@ -795,6 +795,7 @@ def points_center_blob(
product_id: str = "hy-1001",
points: int = 200,
stock: int = 39,
remaining: int | None = None,
category: str = "礼包",
) -> bytes:
"""构造 queryAllocation 的 ``tag5`` 最小商品列表(TAF LIST)。"""
@@ -817,6 +818,13 @@ def points_center_blob(
os.write_string(5, "")
os.write_int64(9, points * 10)
os.write_int64(13, stock)
if remaining is not None:
os.write_int64(14, remaining)
os.write_head(17, TafType.LIST)
os.write_int32(0, 1)
os.write_head(0, TafType.STRUCT_BEGIN)
os.write_int64(3, remaining)
os.write_struct_end()
# 积分 tag75.tag0.tag2
os.write_head(75, TafType.STRUCT_BEGIN)
os.write_head(0, TafType.STRUCT_BEGIN)
@@ -1295,8 +1303,9 @@ class TestCollabCkPreview(TestCollabBase):
"product_id": "hy-1001",
"name": "不朽伊人大礼包",
"price": 200,
"remain_text": "剩39",
"remain_text": "39",
"stock": 39,
"claimed": 39,
"score": 200,
"category_name": "礼包",
"icon": "",
@@ -1392,8 +1401,9 @@ class TestCollabGifts(TestCollabBase):
"product_id": "hy-1001",
"name": "不朽伊人大礼包",
"price": 200,
"remain_text": "剩39",
"remain_text": "39",
"stock": 39,
"claimed": 39,
"score": 200,
"category_name": "礼包",
"icon": "",
@@ -1440,6 +1450,25 @@ class TestCollabGifts(TestCollabBase):
assert gifts[0]["score"] == 888
assert gifts[0]["stock"] == 7
def test_points_center_parser_prefers_remaining_stock(self):
"""2026-10-25 抓包:tag13 是累计兑换,tag14 才是剩余库存。"""
from core.huya.shop_structs import parse_points_center_gifts
gifts = parse_points_center_gifts(
{
"tag2": [
{
"tag5": points_center_blob(
points=600, stock=43194, remaining=5054
)
}
]
}
)
assert gifts[0]["claimed"] == 43194
assert gifts[0]["stock"] == 5054
assert gifts[0]["remain_text"] == "5054"
def test_resolve_gifts_sid_prefers_workbench_config(self):
self.db.add(HuyaConfig(sid="2277"))
self.db.commit()
+7 -16
View File
@@ -11,7 +11,7 @@ from core.huya.shop_structs import (
ShopCodeRsp,
)
from core.huya.taf_protocol import TafInputStream, TafOutputStream, TafType
from core.huya.wss_client import TAIL_BYTES, HuyaWssClient, WssCommand, WssMessage
from core.huya.wss_client import HuyaWssClient, WssCommand, WssMessage
from web.backend.services.huya_runner_core import HuyaBatchRunnerCore
@@ -108,9 +108,8 @@ def test_wss_requires_web_device_cookie_fields():
def test_activity_auth_frame_matches_latest_capture_layout():
"""对齐 9.1 活动 AUTH(828 连接)逐字段结构。
9.1: tag3 ZERO 首字段 -> tag0 uid -> tag1 UA -> tag2 cookie(STRING4)
-> tag3 STRING1 guid(32hex) -> tag4 INT8=1 -> tag5 "HUYA&ZH&2052"
-> tag6 "" -> TAIL_BYTES
2026-10-25 对齐 huya-go:tag0 uid -> tag1 UA -> tag2 cookie(STRING4)
-> tag3 STRING1 guid(32hex) -> tag4 INT32=1 -> tag5 "HUYA&ZH&2052"
"""
async def build_frame():
@@ -126,12 +125,9 @@ def test_activity_auth_frame_matches_latest_capture_layout():
message = WssMessage.decode(asyncio.run(build_frame()))
assert message.command == WssCommand.AUTH
assert message.sequence == 0x1D000109
assert message.body.endswith(TAIL_BYTES)
assert message.sequence == 0
stream = TafInputStream(message.body[: -len(TAIL_BYTES)])
# 首字段:tag3 ZERO(9.1 活动帧的占位字段)
assert stream.read_head() == (3, TafType.ZERO)
stream = TafInputStream(message.body)
assert stream.read_int64(0) == 1199664135026
assert stream.read_string(1) == "webh5&0.0.1&websocket&&diypc_52775"
assert stream.read_head() == (2, TafType.STRING4)
@@ -141,12 +137,8 @@ def test_activity_auth_frame_matches_latest_capture_layout():
# tag3 = STRING1 guid(9.1 中为 Cookie 里的 32hex guid)
guid = stream.read_string(3)
assert guid == "0a" * 16
assert stream.read_int8(4) == 1
assert stream.read_int32(4) == 1
assert stream.read_string(5) == "HUYA&ZH&2052"
assert stream.read_string(6) == ""
# body 必须与 9.1 828 AUTH 同样以 TAIL_BYTES 收尾
assert message.body[-len(TAIL_BYTES) :] == TAIL_BYTES
def test_activity_cookie_guid_is_replaced_by_launch_guid():
@@ -173,8 +165,7 @@ def test_activity_auth_accepts_explicit_launch_guid_over_cookie_guid():
return client.ws.send.call_args.args[0]
message = WssMessage.decode(asyncio.run(build_frame()))
stream = TafInputStream(message.body[: -len(TAIL_BYTES)])
assert stream.read_head() == (3, TafType.ZERO)
stream = TafInputStream(message.body)
stream.read_int64(0)
stream.read_string(1)
assert stream.read_head() == (2, TafType.STRING4)
+15 -7
View File
@@ -11,10 +11,11 @@
import asyncio
from types import SimpleNamespace
from core.huya.frame_decoder import _decode_wup_body, decode_struct_bytes
from core.huya.frame_decoder import _decode_wup_body, _extract_wup, decode_struct_bytes
from core.huya.http_client import HuyaHttpClient
from core.huya.shop_structs import ShopRawRsp
from core.huya.wss_client import HuyaWssClient, WssMessage
from core.huya.wss_client import HuyaWssClient, WssCommand, WssMessage
from core.huya.wup_protocol import WupResponse
from web.backend.models import HuyaAccount
from web.backend.services.huya_runner import HuyaBatchRunner
@@ -41,10 +42,17 @@ class _FakeWs:
async def send(self, data: bytes):
self.sent.append(data)
while self.client.rpc_queue:
future = self.client.rpc_queue.popleft()
if not future.done():
future.set_result(b"")
message = WssMessage.decode(data)
if message.command != WssCommand.RPC_REQUEST:
return
try:
wup = WupResponse()
wup.decode(_extract_wup(message.body))
except Exception: # noqa: BLE001 测试替身:无法解析的帧不兑现
return
future = self.client._pending.pop(wup.iRequestId, None)
if future is not None and not future.done():
future.set_result(b"")
def last_request(self) -> dict:
return _decode_wup_body(WssMessage.decode(self.sent[-1]).body)
@@ -111,7 +119,7 @@ def test_wss_redeem_need_goods_request_matches_real_call():
message = WssMessage.decode(client.ws.sent[-1])
# 积分中心业务帧统一 0x1d000107(只有下单/支付用 0x1d000108)
assert message.sequence == 0x1D000107
assert message.sequence == 0
decoded = client.ws.last_request()
assert (decoded["servant"], decoded["func"]) == (
+27 -15
View File
@@ -29,12 +29,13 @@ COOKIE = f"udb_uid={UID}; yyuid={UID}; guid={GUID}"
PID = 1199650619883
# 抓包字面值(不要引用实现常量,否则断言会与实现一起漂移)
POINTS_UA_LITERAL = "webh5&0.0.1&websocket&&h5_/points.html"
# 旧版本在 vData 末尾拼的尾部字节;JCE 帧不需要,保留常量用于确认已移除
TAIL_LITERAL = bytes.fromhex("2c36004c5c6600")
BIZ_PARAMS_LITERAL = '{ "jifencenter": 1 }'
# 抓包实测:RegisterGroup(live:0) 与 UpdateUserExps(版本 map) 的 body 逐字节一致
HEARTBEAT_BODY = bytes.fromhex("09000106066c6976653a3016002c36004c5c6600")
UPDATE_EXPS_BODY = bytes.fromhex("0800010605353836363416063130373236332c36004c5c6600")
HEARTBEAT_BODY = bytes.fromhex("09000106066c6976653a301600")
UPDATE_EXPS_BODY = bytes.fromhex("080001060535383636341606313037323633")
CONFIG_VERSIONS = {"58664": "107263"}
@@ -48,10 +49,17 @@ class _FakeWs:
async def send(self, data: bytes):
self.sent.append(data)
while self.client.rpc_queue:
future = self.client.rpc_queue.popleft()
if not future.done():
future.set_result(self.response)
message = WssMessage.decode(data)
if message.command != WssCommand.RPC_REQUEST:
return
try:
wup = WupResponse()
wup.decode(_extract_wup(message.body))
except Exception: # noqa: BLE001 测试替身:无法解析的帧不兑现
return
future = self.client._pending.pop(wup.iRequestId, None)
if future is not None and not future.done():
future.set_result(self.response)
def last_body(self) -> bytes:
return WssMessage.decode(self.sent[-1]).body
@@ -106,8 +114,9 @@ def test_register_group_frame_matches_capture():
await client.send_heartbeat()
message = WssMessage.decode(client.ws.sent[-1])
assert message.command == WssCommand.HEARTBEAT_SEND
assert message.sequence == 0x1D00000D
assert message.sequence == 0
assert message.body == HEARTBEAT_BODY
assert not message.body.endswith(TAIL_LITERAL)
asyncio.run(run())
@@ -119,8 +128,8 @@ def test_shop_ws_launch_carries_real_identity_and_points_ua():
message = WssMessage.decode(client.ws.sent[-1])
assert message.command == WssCommand.RPC_REQUEST
# 抓包:wsLaunch 帧 seq=0x1d000100,iRequestId=-1
assert message.sequence == 0x1D000100
# JCE 帧 lRequestId 固定 0,真正的请求号是 WUP iRequestId=-1
assert message.sequence == 0
wup = WupResponse()
wup.decode(_extract_wup(message.body))
assert wup.iRequestId == -1
@@ -182,8 +191,7 @@ def test_create_order_v5_matches_points_capture():
)
message = WssMessage.decode(client.ws.sent[-1])
# 抓包:下单帧用 0x1d000108(其余业务帧 0x1d000107)
assert message.sequence == 0x1D000108
assert message.command == WssCommand.RPC_REQUEST
decoded = _decode_wup_body(message.body)
assert (decoded["servant"], decoded["func"]) == (
@@ -223,7 +231,7 @@ def test_pay_order_submit_v5_callback_points_page():
)
message = WssMessage.decode(client.ws.sent[-1])
assert message.sequence == 0x1D000108
assert message.command == WssCommand.RPC_REQUEST
decoded = _decode_wup_body(message.body)
assert (decoded["servant"], decoded["func"]) == (
@@ -338,11 +346,15 @@ def test_new_points_rpcs_match_capture():
asyncio.run(run())
def test_confirm_frame_ends_with_capture_tail():
def test_confirm_frame_carries_version_map_without_legacy_tail():
async def run():
client = _points_client()
await client.send_confirm({})
assert client.ws.last_body().endswith(TAIL_LITERAL)
await client.send_confirm(CONFIG_VERSIONS)
message = WssMessage.decode(client.ws.sent[-1])
assert message.command == WssCommand.CONFIRM_SEND
assert message.sequence == 0
assert message.body == UPDATE_EXPS_BODY
assert not message.body.endswith(TAIL_LITERAL)
asyncio.run(run())