549 lines
17 KiB
Python
549 lines
17 KiB
Python
#!/usr/bin/env python3
|
||
"""bd2_dump.py — mitmproxy 抓包插件:**全抓**,能解的就解开。
|
||
|
||
## 启动
|
||
|
||
mitmdump -s bd2_dump.py --listen-port 8080 --set stream_large_bodies=1m
|
||
|
||
`stream_large_bodies=1m` **必须加** —— 资源包单个最大 270MB,不流式的话
|
||
mitmproxy 会把它们全缓冲进内存。加了之后超过 1MB 的响应不缓冲,
|
||
`raw_content` 为 None,脚本只记大小。
|
||
|
||
让客户端走代理的三种办法:
|
||
|
||
a) 系统代理 → 127.0.0.1:8080(最省事,但别的程序也会走)
|
||
b) hosts 把目标域名指到本机 + 监听 443
|
||
c) 只给游戏进程设 HTTP_PROXY 环境变量
|
||
|
||
装根证书(HTTPS 才解得开):
|
||
|
||
Windows: certutil -user -addstore Root %USERPROFILE%\\.mitmproxy\\mitmproxy-ca-cert.cer
|
||
Kali: cp ~/.mitmproxy/mitmproxy-ca-cert.pem /usr/local/share/ca-certificates/
|
||
update-ca-certificates
|
||
|
||
## 抓什么
|
||
|
||
**所有经过代理的流量都记**,一条不落。每条带一个 `game` 标记:
|
||
|
||
- `game=true` —— 命中 BD2 相关域名,或 UA 是 Unity/游戏的
|
||
- `game=false` —— 其它(浏览器、系统更新……)
|
||
|
||
`game=true` 的会在控制台实时打印;**全部**写进 jsonl。
|
||
|
||
## 解开什么
|
||
|
||
我们在服务端逆向时摸清的**三层编码**:
|
||
|
||
1. **外层**:JSON 信封 `{"errorType":0,"data":"<base64>","length":N,...}`
|
||
2. **AES 层**:`base64(AES-256-CBC(PKCS7(明文)))`,IV = 16 个 0 字节
|
||
3. **内层**:明文本身又是 `base64(protobuf)` —— **两层 base64**,
|
||
少一层客户端就报「不是合法的 Base-64 字符串」
|
||
|
||
批量另有花样:`PUT /BatchRequest` 的 body 是 `base64(AES(JSON数组))`,
|
||
**只有一层 base64**(数组里每个 `requestData` 才各自 base64)。
|
||
|
||
## 密钥
|
||
|
||
- `LoginUser` / `JoinUser` 固定 `abcdefghijkrstuv024680wxyzlmnopq`
|
||
- 之后用登录响应里下发的 `user_key`(32 位十六进制),脚本会自动认出来并接管
|
||
|
||
## 输出
|
||
|
||
- 控制台:`game=true` 的实时打印
|
||
- `capture/2.35.10/<时间戳>/bd2_dump.jsonl`:全部流量,每行一条
|
||
- `capture/2.35.10/<时间戳>/bd2_dump.log`:便于人工检查的文本日志
|
||
- `capture/2.35.10/<时间戳>/bodies/`:`game=true` 的原始 body 原样存一份
|
||
(服务端实现要按 schema 解,原字节比文本更值钱)
|
||
|
||
可用环境变量 `BD2_CAPTURE_DIR` 覆盖输出根目录。
|
||
|
||
只观察,不修改任何流量。
|
||
"""
|
||
|
||
import base64
|
||
import json
|
||
import os
|
||
import re
|
||
import time
|
||
|
||
from mitmproxy import http, ctx
|
||
|
||
# ---------------------------------------------------------------- 加密
|
||
|
||
FIX_KEY = b"abcdefghijkrstuv024680wxyzlmnopq"
|
||
ZERO_IV = b"\x00" * 16
|
||
|
||
SESS = None
|
||
TAG = time.strftime("%Y%m%d-%H%M%S")
|
||
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
|
||
PROJECT_DIR = os.path.dirname(os.path.dirname(SCRIPT_DIR))
|
||
CAPTURE_ROOT = os.path.abspath(
|
||
os.environ.get("BD2_CAPTURE_DIR", os.path.join(PROJECT_DIR, "data", "capture", "2.35.10"))
|
||
)
|
||
RUN_DIR = os.path.join(CAPTURE_ROOT, TAG)
|
||
LOG_PATH = os.path.join(RUN_DIR, "bd2_dump.jsonl")
|
||
TEXT_PATH = os.path.join(RUN_DIR, "bd2_dump.log")
|
||
BODY_DIR = os.path.join(RUN_DIR, "bodies")
|
||
|
||
_AES = None
|
||
_AES_IMPL = ""
|
||
|
||
|
||
def _load_aes():
|
||
global _AES, _AES_IMPL
|
||
if _AES is not None:
|
||
return _AES
|
||
try:
|
||
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
||
|
||
def dec(key, data):
|
||
c = Cipher(algorithms.AES(key), modes.CBC(ZERO_IV)).decryptor()
|
||
return c.update(data) + c.finalize()
|
||
|
||
_AES, _AES_IMPL = dec, "cryptography"
|
||
return _AES
|
||
except Exception:
|
||
pass
|
||
try:
|
||
from Crypto.Cipher import AES as _C
|
||
|
||
def dec(key, data):
|
||
return _C.new(key, _C.MODE_CBC, ZERO_IV).decrypt(data)
|
||
|
||
_AES, _AES_IMPL = dec, "pycryptodome"
|
||
return _AES
|
||
except Exception:
|
||
_AES_IMPL = ""
|
||
return None
|
||
|
||
|
||
def strip_pkcs7(b: bytes) -> bytes:
|
||
if not b:
|
||
return b
|
||
n = b[-1]
|
||
if n == 0 or n > 16 or n > len(b):
|
||
return b
|
||
if b[-n:] != bytes([n]) * n:
|
||
return b
|
||
return b[:-n]
|
||
|
||
|
||
def aes_unwrap(s: str, key: bytes):
|
||
"""base64 -> AES -> 去 PKCS7。解不开返回 None。"""
|
||
dec = _load_aes()
|
||
if dec is None or not s:
|
||
return None
|
||
try:
|
||
raw = base64.b64decode(s, validate=False)
|
||
except Exception:
|
||
return None
|
||
if not raw or len(raw) % 16:
|
||
return None
|
||
try:
|
||
return strip_pkcs7(dec(key, raw))
|
||
except Exception:
|
||
return None
|
||
|
||
|
||
# ---------------------------------------------------------------- protobuf 转储
|
||
|
||
PRINTABLE = re.compile(rb"^[\x20-\x7e\n\r\t]*$")
|
||
|
||
|
||
def pb_dump(b: bytes, indent: int = 0, depth: int = 0, limit: int = 60):
|
||
"""没有 schema 也能看:按 varint 拆字段号 + 值,嵌套递归一层层往下。"""
|
||
out = []
|
||
i, n = 0, len(b)
|
||
pad = " " * indent
|
||
while i < n and depth < 6 and len(out) < limit:
|
||
tag = shift = 0
|
||
while i < n:
|
||
c = b[i]
|
||
i += 1
|
||
tag |= (c & 0x7F) << shift
|
||
shift += 7
|
||
if not c & 0x80:
|
||
break
|
||
else:
|
||
break
|
||
fn, wt = tag >> 3, tag & 7
|
||
if fn == 0:
|
||
break
|
||
if wt == 0:
|
||
v = shift = 0
|
||
while i < n:
|
||
c = b[i]
|
||
i += 1
|
||
v |= (c & 0x7F) << shift
|
||
shift += 7
|
||
if not c & 0x80:
|
||
break
|
||
out.append("%s#%d varint %d" % (pad, fn, v))
|
||
elif wt == 2:
|
||
ln = shift = 0
|
||
while i < n:
|
||
c = b[i]
|
||
i += 1
|
||
ln |= (c & 0x7F) << shift
|
||
shift += 7
|
||
if not c & 0x80:
|
||
break
|
||
if i + ln > n:
|
||
break
|
||
chunk = b[i : i + ln]
|
||
i += ln
|
||
if chunk and PRINTABLE.match(chunk):
|
||
try:
|
||
out.append("%s#%d str %r" % (pad, fn, chunk.decode("utf-8")))
|
||
except Exception:
|
||
out.append("%s#%d bytes(%d)" % (pad, fn, ln))
|
||
else:
|
||
sub = pb_dump(chunk, indent + 1, depth + 1, limit - len(out))
|
||
if sub:
|
||
out.append("%s#%d 嵌套 {" % (pad, fn))
|
||
out.extend(sub)
|
||
out.append("%s}" % pad)
|
||
else:
|
||
out.append("%s#%d bytes(%d)" % (pad, fn, ln))
|
||
elif wt == 5:
|
||
i += 4
|
||
out.append("%s#%d fixed32" % (pad, fn))
|
||
elif wt == 1:
|
||
i += 8
|
||
out.append("%s#%d fixed64" % (pad, fn))
|
||
else:
|
||
break
|
||
if len(out) >= limit:
|
||
out.append("%s…(截断)" % pad)
|
||
return out
|
||
|
||
|
||
# ---------------------------------------------------------------- 输出
|
||
|
||
_fh = None
|
||
_tf = None
|
||
_body_seq = [0]
|
||
|
||
|
||
def _ensure_fh():
|
||
global _fh, _tf
|
||
os.makedirs(RUN_DIR, exist_ok=True)
|
||
if _fh is None:
|
||
_fh = open(LOG_PATH, "a", encoding="utf-8")
|
||
if _tf is None:
|
||
_tf = open(TEXT_PATH, "a", encoding="utf-8")
|
||
return _fh
|
||
|
||
|
||
def render_text(rec: dict) -> str:
|
||
"""把一条记录渲染成人能读的多行文本。proto 字段缩进展开,不塞成一行。"""
|
||
L = []
|
||
ts = rec.get("ts", "")
|
||
ev = rec.get("ev", "?")
|
||
|
||
if ev == "session":
|
||
return "\n[%s] ★ 会话密钥 user_key=%s\n" % (ts, rec.get("user_key"))
|
||
if ev == "set_cookie":
|
||
return "[%s] Set-Cookie: %s\n" % (ts, rec.get("value"))
|
||
|
||
flag = "" if rec.get("game") else " (非游戏)"
|
||
if ev == "req":
|
||
L.append("\n%s[%s] >>> %s %s%s" % ("", ts, rec.get("method"), rec.get("path"), flag))
|
||
if rec.get("shape"):
|
||
L.append(" 形状=%s raw=%dB" % (rec["shape"], rec.get("len", 0)))
|
||
if rec.get("cookie"):
|
||
L.append(" cookie=%s" % rec["cookie"])
|
||
if rec.get("raw_file"):
|
||
L.append(" 原始=%s" % rec["raw_file"])
|
||
if rec.get("batch_count"):
|
||
L.append(" 批量 %d 条:" % rec["batch_count"])
|
||
for e in rec.get("batch", []):
|
||
L.append(" · %-32s req=%dB" % (e.get("path"), e.get("req_len", 0)))
|
||
for line in e.get("pb", []):
|
||
L.append(" " + line)
|
||
for line in rec.get("pb", []):
|
||
L.append(" " + line)
|
||
else:
|
||
L.append("\n[%s] <<< %s HTTP %s%s" % (ts, rec.get("path"), rec.get("status"), flag))
|
||
if rec.get("errorType") is not None:
|
||
L.append(" errorType=%s length=%s %s" % (
|
||
rec.get("errorType"), rec.get("length"),
|
||
("msg=" + rec["errorMessage"]) if rec.get("errorMessage") else ""))
|
||
if rec.get("raw_file"):
|
||
L.append(" 原始=%s" % rec["raw_file"])
|
||
for line in rec.get("pb", []):
|
||
L.append(" " + line)
|
||
return "\n".join(L) + "\n"
|
||
|
||
|
||
def save_body(kind: str, path: str, raw: bytes) -> str:
|
||
"""原字节存一份 —— 后面要按 schema 解,字符比文本值钱。"""
|
||
if not raw:
|
||
return ""
|
||
os.makedirs(BODY_DIR, exist_ok=True)
|
||
_body_seq[0] += 1
|
||
safe = re.sub(r"[^A-Za-z0-9_.-]", "_", path.strip("/"))[:60] or "root"
|
||
fn = os.path.join(BODY_DIR, "%05d_%s_%s.bin" % (_body_seq[0], kind, safe))
|
||
with open(fn, "wb") as f:
|
||
f.write(raw)
|
||
return os.path.relpath(fn, RUN_DIR)
|
||
|
||
|
||
def header_pairs(headers) -> list[list[str]]:
|
||
"""保留重复头部(尤其 Set-Cookie),便于后续按真实响应回放。"""
|
||
try:
|
||
return [[str(k), str(v)] for k, v in headers.items(multi=True)]
|
||
except TypeError:
|
||
return [[str(k), str(v)] for k, v in headers.items()]
|
||
|
||
|
||
def emit(rec: dict, console: bool):
|
||
rec["ts"] = time.strftime("%H:%M:%S")
|
||
_ensure_fh()
|
||
|
||
# JSONL:给脚本用的,字段完整
|
||
_fh.write(json.dumps(rec, ensure_ascii=False) + "\n")
|
||
_fh.flush()
|
||
|
||
# 文本日志:给人看的,proto 逐字段展开
|
||
_tf.write(render_text(rec))
|
||
_tf.flush()
|
||
|
||
if console:
|
||
# 控制台只打一行摘要,细节都在文本日志里 —— 否则 proto dump 会把屏幕刷爆
|
||
if rec.get("ev") == "req":
|
||
head = ">>> %s %s [%s]" % (rec.get("method"), rec.get("path"), rec.get("shape", ""))
|
||
if rec.get("batch_count"):
|
||
head += " 批量%d条" % rec["batch_count"]
|
||
elif rec.get("ev") == "resp":
|
||
head = "<<< %s HTTP %s errorType=%s" % (
|
||
rec.get("path"), rec.get("status"), rec.get("errorType"))
|
||
else:
|
||
head = json.dumps(rec, ensure_ascii=False)
|
||
ctx.log.info(head)
|
||
|
||
|
||
# ---------------------------------------------------------------- 会话
|
||
|
||
USER_KEY = [None]
|
||
|
||
|
||
def sniff_user_key(plain: bytes):
|
||
if USER_KEY[0] or not plain:
|
||
return
|
||
m = re.search(rb"[0-9a-f]{32}", plain)
|
||
if m:
|
||
USER_KEY[0] = m.group(0).decode()
|
||
emit({"ev": "session", "user_key": USER_KEY[0],
|
||
"note": "从登录响应认出 user_key,后续包用它解密"}, True)
|
||
|
||
|
||
def cur_key() -> bytes:
|
||
return USER_KEY[0].encode() if USER_KEY[0] else FIX_KEY
|
||
|
||
|
||
# ---------------------------------------------------------------- 判定
|
||
|
||
GAME_HOSTS = (
|
||
"bd2.pmang.cloud",
|
||
"akamaized.net",
|
||
"neonapi.com",
|
||
"neowizplay.com",
|
||
"pmangplus",
|
||
"neon-file",
|
||
"browndust",
|
||
"gamfs",
|
||
)
|
||
|
||
# 资源包域名:只记元信息,不存 body
|
||
RESOURCE_HOSTS = ("dl.bd2.pmang.cloud", "cdn.bd2.pmang.cloud", "bd2-cdn.akamaized.net")
|
||
|
||
|
||
def is_game(host: str, ua: str) -> bool:
|
||
h, u = host.lower(), (ua or "").lower()
|
||
if any(g in h for g in GAME_HOSTS):
|
||
return True
|
||
return "unity" in u
|
||
|
||
|
||
def is_resource(host: str) -> bool:
|
||
return any(r in host.lower() for r in RESOURCE_HOSTS)
|
||
|
||
|
||
# ---------------------------------------------------------------- 解码
|
||
|
||
BATCH_KEYS = ("path", "requestData")
|
||
|
||
|
||
def looks_like_batch(b: bytes) -> bool:
|
||
try:
|
||
arr = json.loads(b)
|
||
except Exception:
|
||
return False
|
||
return (
|
||
isinstance(arr, list)
|
||
and bool(arr)
|
||
and isinstance(arr[0], dict)
|
||
and all(k in arr[0] for k in BATCH_KEYS)
|
||
)
|
||
|
||
|
||
def decode_request(body: bytes):
|
||
if not body:
|
||
return "空", b"", None
|
||
if looks_like_batch(body):
|
||
try:
|
||
return "批量(明文JSON)", body, json.loads(body)
|
||
except Exception:
|
||
return "批量(明文JSON)", body, None
|
||
|
||
txt = body.decode("ascii", "ignore").strip()
|
||
plain = aes_unwrap(txt, cur_key())
|
||
if plain is not None:
|
||
if looks_like_batch(plain):
|
||
try:
|
||
return "批量(加密JSON)", plain, json.loads(plain)
|
||
except Exception:
|
||
return "批量(加密JSON)", plain, None
|
||
try:
|
||
return "加密包", base64.b64decode(plain, validate=False), None
|
||
except Exception:
|
||
return "加密包(内层非base64)", plain, None
|
||
|
||
try:
|
||
return "明文包", base64.b64decode(body, validate=False), None
|
||
except Exception:
|
||
return "未识别", body, None
|
||
|
||
|
||
def decode_response(body: bytes):
|
||
try:
|
||
env = json.loads(body)
|
||
except Exception:
|
||
return None, None
|
||
if not isinstance(env, dict) or "errorType" not in env:
|
||
return None, None
|
||
data = env.get("data") or ""
|
||
if not data:
|
||
return env, b""
|
||
plain = aes_unwrap(data, cur_key())
|
||
if plain is not None:
|
||
try:
|
||
return env, base64.b64decode(plain, validate=False)
|
||
except Exception:
|
||
return env, plain
|
||
try:
|
||
return env, base64.b64decode(data, validate=False)
|
||
except Exception:
|
||
return env, b""
|
||
|
||
|
||
# ---------------------------------------------------------------- 钩子
|
||
|
||
|
||
def running():
|
||
_load_aes()
|
||
_ensure_fh()
|
||
ctx.log.info("━" * 60)
|
||
ctx.log.info("bd2_dump 已加载 AES=%s" % (_AES_IMPL or "不可用(只dump不解)"))
|
||
ctx.log.info("文本日志(看这个): %s" % TEXT_PATH)
|
||
ctx.log.info("JSONL(喂脚本): %s" % LOG_PATH)
|
||
ctx.log.info("原始 body: %s/" % BODY_DIR)
|
||
ctx.log.info("━" * 60)
|
||
|
||
|
||
def request(flow: http.HTTPFlow):
|
||
host = flow.request.pretty_host
|
||
ua = flow.request.headers.get("user-agent", "")
|
||
game = is_game(host, ua)
|
||
resource = is_resource(host)
|
||
|
||
if resource:
|
||
emit({"ev": "req", "flow_id": flow.id,
|
||
"game": True, "resource": True,
|
||
"method": flow.request.method, "host": host,
|
||
"path": flow.request.path, "url": flow.request.pretty_url,
|
||
"headers": header_pairs(flow.request.headers),
|
||
"len": len(flow.request.raw_content or b"")}, False)
|
||
return
|
||
|
||
body = flow.request.raw_content or b""
|
||
rec = {"ev": "req", "flow_id": flow.id,
|
||
"game": game, "method": flow.request.method,
|
||
"host": host, "path": flow.request.path,
|
||
"url": flow.request.pretty_url,
|
||
"headers": header_pairs(flow.request.headers), "len": len(body)}
|
||
if not game:
|
||
emit(rec, False)
|
||
return
|
||
|
||
rec["ua"] = ua[:80]
|
||
ck = flow.request.headers.get("cookie")
|
||
if ck:
|
||
rec["cookie"] = ck
|
||
|
||
shape, plain, batch = decode_request(body)
|
||
rec["shape"] = shape
|
||
rec["raw_file"] = save_body("req", flow.request.path, body)
|
||
|
||
if batch:
|
||
rec["batch_count"] = len(batch)
|
||
rec["batch"] = [
|
||
{"path": e.get("path"),
|
||
"req_len": len(base64.b64decode(e.get("requestData") or "") or b""),
|
||
"pb": pb_dump(base64.b64decode(e.get("requestData") or "") or b"")}
|
||
for e in batch[:100]
|
||
]
|
||
elif plain:
|
||
rec["pb"] = pb_dump(plain)
|
||
|
||
emit(rec, True)
|
||
|
||
|
||
def response(flow: http.HTTPFlow):
|
||
host = flow.request.pretty_host
|
||
resource = is_resource(host)
|
||
body = flow.response.raw_content or b""
|
||
|
||
if resource:
|
||
emit({"ev": "resp", "flow_id": flow.id,
|
||
"game": True, "resource": True,
|
||
"method": flow.request.method, "host": host,
|
||
"path": flow.request.path,
|
||
"url": flow.request.pretty_url,
|
||
"status": flow.response.status_code,
|
||
"headers": header_pairs(flow.response.headers),
|
||
"len": len(body),
|
||
"streamed": flow.response.raw_content is None}, False)
|
||
return
|
||
|
||
ua = flow.request.headers.get("user-agent", "")
|
||
game = is_game(host, ua)
|
||
rec = {"ev": "resp", "flow_id": flow.id,
|
||
"game": game, "method": flow.request.method,
|
||
"host": host, "path": flow.request.path,
|
||
"url": flow.request.pretty_url,
|
||
"status": flow.response.status_code,
|
||
"headers": header_pairs(flow.response.headers), "len": len(body)}
|
||
if not game:
|
||
emit(rec, False)
|
||
return
|
||
|
||
sc = flow.response.headers.get("set-cookie")
|
||
if sc:
|
||
emit({"ev": "set_cookie", "value": sc}, True)
|
||
|
||
env, plain = decode_response(body)
|
||
if env is not None:
|
||
rec["errorType"] = env.get("errorType")
|
||
rec["length"] = env.get("length")
|
||
if env.get("errorMessage"):
|
||
rec["errorMessage"] = env["errorMessage"]
|
||
rec["raw_file"] = save_body("resp", flow.request.path, body)
|
||
if plain:
|
||
if re.search(r"LoginUser|JoinUser", flow.request.path):
|
||
sniff_user_key(plain)
|
||
rec["pb"] = pb_dump(plain)
|
||
|
||
emit(rec, True)
|