Files
live-hub-py/tools/huya_wup_encoder.py
T
yml2213 ff99c036f8 虎牙App密码登录: 逆向WUP协议并实现纯Python登录编码器
- hook WUP序列化层抓取TLS加密前明文,确认密码登录走
  servant=huyaudbwebui / func=hypasswordLogin (msgType=0x1001)
- 逆向JCE RequestPacket帧格式(1015字节TAF二进制),定稿三层结构:
  WUP header(tag1-10) -> sBuffer Map -> _wup_data业务struct
- 澄清两个疑点字节: 0x66/0x7d 是TAF字段头(非分隔符)
- 实现 tools/huya_wup_encoder.py,逐字节复现金标准
- 实测纯Python动态构造登录成功(返回uid+token),TLS指纹不阻断
- userAction随机坐标/时间戳同样通过,内置make_user_action辅助函数
- 新增证据金标准 + 协议文档
2026-08-25 02:59:20 +08:00

398 lines
15 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.
"""虎牙 App 密码登录 WUP(TAF) 请求帧编码器。
逆向自真实抓包金标准 `evidence/wup_passwordlogin_taf.bin`1015 字节,
POST https://udblgn.huya.com/open/hy/passwordLogin 的 base64 解码后 body)。
帧结构(偏移从 0 开始,含 4 字节长度前缀):
[0:4] 长度 = 1015 (big-endian uint32)
WUP headerTAF 字段,tag 1..10:
tag1 iVersion = 3 (INT8)
tag2 cPacketType = 0 (ZERO)
tag3 iMessageType = 0 (ZERO)
tag4 iRequestId = session (INT32)
tag5 sServantName = "huyaudbwebui" (STRING1)
tag6 sFuncName = "hypasswordLogin" (STRING1)
tag7 sBuffer = bytes (SIMPLE_LIST, 961 字节)
tag8 iTimeout = 0 (ZERO)
tag9 context = {} (空 MAP)
tag10 status = {} (空 MAP)
sBuffer = Map<str, bytes> 共 2 项:
"_wup_data" -> 915 字节 TAF struct(业务数据,见下)
"wupudbrequest_v0" -> 5 字节 = INT32(session) ← 裸 INT32 字段,非 struct
_wup_data struct915 字节,STRUCT_BEGIN 包裹,字段 t0..t8:
t0 (struct, 请求"头"):
t0 = 0 (ZERO)
t1 = "1.0"
t2 = 元数据 JSON (compact, 固定键序)
t3 = bizAppid "5008"
t4 = 3 (INT8)
t5 = safedeviceid
t6 = ""
t7 = ""
t8 = user_action_json
t9 = ""
t1 (struct, 设备信息):
t0 = hdid
t1 = app_version "13.4.22"
t2 = sdk_version "1.0.80138"
t3 = ""
t4 = ip "127.0.0.1"
t5 = vendor "xiaomi"
t6 = ""
t2 (struct, 屏幕/设备特征,注意 t5 缺失):
t0 = 1 (INT8)
t1 = model "M2102J2SC"
t2 = fingerprint "02df3987..."
t3 = os "android"
t4 = screen "M2102J2SC,30,11"
t6 = width "1080"
t7 = height "2120"
t8 = device_id "7c5387e0..."
t3 = name "hy_<uid_str>"
t4 = sha1_password
t5 = LIST[1] = ["5008"]
t6 = 1 (INT8)
t7 = {} (空 MAP)
t8 = 空 SIMPLE_LIST (bytes)
两个疑点字节的最终结论:
- 偏移 27 的 0x66 ('f'): 并非字面 'f',而是 TAF head 字节 (tag6<<4 | STRING1=6)
= sFuncName 字段的 STRING1 头。ASCII 'f' 纯属巧合。
- 偏移 44 的 0x7d ('}'): 并非字面 '}',而是 TAF head 字节 (tag7<<4 | SIMPLE_LIST=13)
= sBuffer 字段的 SIMPLE_LIST(byte[]) 头。
偏移 45..51 的 7 字节 = SIMPLE_LIST 元素类型头(00) + 长度(01 03 c1 = 961)
+ sBuffer 内层 Map 头(08) + Map 长度(00 02 = 2)。
"""
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) # 元素类型固定 INT8
self.int32(0, len(b)) # 长度(int32 优化编码)
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(固定键序,compact)。"""
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。
实测(2025-08 抓包 + 重放): 服务端对该字段不做严格校验,
随机坐标/时间戳与旧轨迹一样能通过登录(返回 uid+token)。
参数:
now_ms: 基准毫秒时间戳;缺省取当前时间。
"""
if now_ms is None:
now_ms = int(_time.time() * 1000)
t1 = now_ms
t2 = now_ms + random.randint(200, 900) # 两次点击间隔 200~900ms
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:
"""编码 915 字节的 _wup_data struct。"""
meta_json = _build_meta_json(session, trace_id)
name = _make_name(uid_str)
# ---- _wup_data struct(即"外层" struct----
w.struct_begin(0) # _wup_data struct (0x0a)
# -- t0: 请求"头" struct --
w.struct_begin(0) # t0 (0x0a)
w.int8(0, 0) # t0.t0 = 0 -> ZERO (0x0c)
w.string(1, "1.0") # t0.t1 = "1.0"
w.string(2, meta_json) # t0.t2 = 元数据 JSON
w.string(3, "5008") # t0.t3 = bizAppid
w.int8(4, 3) # t0.t4 = 3 -> INT8
w.string(5, safedeviceid) # t0.t5
w.string(6, "") # t0.t6
w.string(7, "") # t0.t7
w.string(8, user_action_json) # t0.t8
w.string(9, "") # t0.t9
w.struct_end() # 0x0b
# -- t1: 设备信息 struct --
di = device_info
w.struct_begin(1) # t1 (0x1a)
w.string(0, hdid) # t1.t0 = hdid
w.string(1, di["app_version"]) # t1.t1 = "13.4.22"
w.string(2, di["sdk_version"]) # t1.t2 = "1.0.80138"
w.string(3, "") # t1.t3
w.string(4, di["ip"]) # t1.t4 = "127.0.0.1"
w.string(5, di["vendor"]) # t1.t5 = "xiaomi"
w.string(6, "") # t1.t6
w.struct_end() # 0x0b
# -- t2: 屏幕/设备特征 struct(注意 t5 缺省)--
w.struct_begin(2) # t2 (0x2a)
w.int8(0, 1) # t2.t0 = 1 -> INT8
w.string(1, di["model"]) # t2.t1 = "M2102J2SC"
w.string(2, di["fingerprint"]) # t2.t2 = 40 位指纹
w.string(3, di["os"]) # t2.t3 = "android"
w.string(4, di["screen"]) # t2.t4 = "M2102J2SC,30,11"
# (t5 缺省)
w.string(6, di["width"]) # t2.t6 = "1080"
w.string(7, di["height"]) # t2.t7 = "2120"
w.string(8, di["device_id"]) # t2.t8 = 40 位设备ID
w.struct_end() # 0x0b
# -- 登录字段 --
w.string(3, name) # t3 = "hy_300023887"
w.string(4, sha1_password) # t4 = SHA1 hex
w.list_begin(5, 1) # t5 = LIST[1]
w.string(0, "5008") # item = "5008"
w.int8(6, 1) # t6 = 1 -> INT8
w.map_begin(7, 0) # t7 = 空 MAP
w.bytes(8, b"") # t8 = 空 bytes (8d 00 0c)
w.struct_end() # 0x0b (_wup_data struct 结束)
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 请求体(1015 字节那种,未 base64)。
参数:
uid_str : 虎牙号(如 "300023887"),登录名 = "hy_" + uid_str。
若已带 "hy_" 前缀则原样使用。
sha1_password : SHA1(明文密码) 的 40 位 hex 字符串。
safedeviceid : 设备安全 ID(长 base64 串)。
hdid : 硬件设备 ID32 位 hex)。
session : 会话/请求 id(同时写入 iRequestId、元数据 session、
"wupudbrequest_v0" 值)。
trace_id : 元数据 traceId。
user_action_json : 用户行为 JSON 字符串(curl/furl/user_action 等)。
device_info : dict,需包含键:
app_version, sdk_version, vendor, model, os,
fingerprint, screen, width, height, device_id, ip。
返回:
1015 字节 TAF 二进制(HTTP body 为 base64 编码后发送)。
"""
# 1. _wup_data struct
wd = _Writer()
_build_wup_data(wd, uid_str, sha1_password, safedeviceid, hdid,
session, trace_id, user_action_json, device_info)
wup_data = wd.get()
# 2. "wupudbrequest_v0" 值 = 裸 INT32(session)(非 struct
req = _Writer()
req.int32(0, session)
wupdbreq_v0 = req.get()
# 3. sBuffer = Map<str, bytes>2 项
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()
# 4. WUP header
w = _Writer()
w.int16(1, 3) # iVersion = 3 -> INT8
w.int8(2, 0) # cPacketType = 0 -> ZERO
w.int8(3, 0) # iMessageType = 0 -> ZERO
w.int32(4, session) # iRequestId -> INT32
w.string(5, "huyaudbwebui") # sServantName
w.string(6, "hypasswordLogin") # sFuncName
w.bytes(7, s_buffer) # sBuffer
w.int32(8, 0) # iTimeout = 0 -> ZERO
w.map_begin(9, 0) # context = 空 MAP
w.map_begin(10, 0) # status = 空 MAP
wup_body = w.get()
# 5. 长度前缀
return struct.pack('>I', 4 + len(wup_body)) + wup_body
# ---------------------------------------------------------------------------
# 自检
# ---------------------------------------------------------------------------
if __name__ == "__main__":
import os
here = os.path.dirname(os.path.abspath(__file__))
golden_path = os.path.join(here, "..", "evidence", "wup_passwordlogin_taf.bin")
golden = open(golden_path, "rb").read()
device_info = {
"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",
}
out = build_password_login_wup(
uid_str="300023887",
sha1_password="772ed992b0e161276f44ec63671e60155c506294",
safedeviceid=("PQwemAN9NHkZKoMqVTFUZBIypqMTaQEOrmXr37xQVhQZqrL/gUKEQ11xvE0ju48V8O/"
"t9UBGSp27m4+6bP4IiAEnpaR5Rj1kHEfN2SPLPqYZW9vroxUSoAvjJn6ezTP9jWGxxlRDCbt"
"Py4Rd6MencYT/pNImVIWK+YbNKZt1O05bHUFhqHf3"),
hdid="ed0db8334cadd236c00cadf7e11ab5a5",
session=3251699,
trace_id="0b8f098ff64a5bdc-23473-94783833787595172451",
user_action_json=('{"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"}]}'),
device_info=device_info,
)
assert out == golden, "编码结果与金标准不一致!"
print("编码器验证通过")
print(f" 输出长度 = {len(out)} 字节,与金标准逐字节一致")