虎牙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辅助函数
- 新增证据金标准 + 协议文档
This commit is contained in:
yml2213
2026-08-25 02:59:20 +08:00
parent 06a0a1e7d3
commit ff99c036f8
14 changed files with 2185 additions and 0 deletions
+397
View File
@@ -0,0 +1,397 @@
"""虎牙 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 : 硬件设备 ID(32 位 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)} 字节,与金标准逐字节一致")