sniff_live_v4.py 8.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241
  1. """sniff_live_v4 — 全端口自动发现式抓包(2026-09-26)。
  2. 背景: v3 按 8001-8099 端口过滤, 但本次真客户端会话零命中(历史端口假设失效)。
  3. v4 策略: 去掉端口/接口假设, 靠协议自身特征发现游戏流:
  4. - encode6 每输出字节都在 [0x3C,0x7B] → 48B 帧头 = 48 连中, 误报率 2^-96
  5. - 全接口抓 (en1 默认路由 + lo0 本地代理 + utun 隧道)
  6. - 非 TCP(IP/UDP) 只记首包 flow 元数据(拓扑发现), TCP 流自动帧同步
  7. 输出 JSONL, 每行 kind:
  8. frame: 完整游戏帧(frame_hex 可重放, 含 48B 头+body)
  9. syn: TCP SYN(新连接发现)
  10. flow: 每条流首包元数据(端口/接口/方向)
  11. 用法(root):
  12. sudo protocol_bot/.venv/bin/python protocol_bot/docs/sniff_live_v4.py \
  13. --out /tmp/cap_guide4.jsonl --duration 1800
  14. """
  15. from __future__ import annotations
  16. import argparse
  17. import json
  18. import struct
  19. import subprocess
  20. import time
  21. from pathlib import Path
  22. from typing import TYPE_CHECKING, Final
  23. from pbot.codec.bitbuf import decode6
  24. if TYPE_CHECKING:
  25. from collections.abc import Callable
  26. from typing import TextIO
  27. HDR_LEN: Final[int] = 48
  28. MAX_BODY: Final[int] = 60000
  29. ENC_LO: Final[int] = 0x3C # encode6 输出偏置
  30. ENC_HI: Final[int] = 0x7B # +63
  31. SYNC_SCAN_CAP: Final[int] = 8192 # 未同步流最多扫描前 8KB
  32. INERT_CAP: Final[int] = 65536 # 未同步流缓冲超 64KB → 放弃该流
  33. _ALPHABET: Final[bytes] = bytes(range(ENC_LO, ENC_HI + 1))
  34. def _is_encoded_header(chunk: bytes) -> bool:
  35. """48B 全部落在 encode6 字母表 → 候选帧头。"""
  36. return all(ENC_LO <= b <= ENC_HI for b in chunk)
  37. def try_sync(buf: bytes) -> int:
  38. """在 buf 里找第一个有效帧偏移。返回 >=0 偏移, -1 未找到。"""
  39. if len(buf) < HDR_LEN:
  40. return -1
  41. scan_end = min(len(buf), SYNC_SCAN_CAP) - HDR_LEN
  42. for i in range(scan_end + 1):
  43. chunk = buf[i : i + HDR_LEN]
  44. if not _is_encoded_header(chunk):
  45. continue
  46. h = struct.unpack("<9i", decode6(chunk))
  47. blen = h[7]
  48. if 0 <= blen <= MAX_BODY:
  49. return i
  50. return -1
  51. def split_frames(buf: bytes) -> tuple[list[tuple[list[int], bytes]], bytes]:
  52. """从已同步流缓冲切帧: 返回 ([(9int头, body)...], 剩余字节)。"""
  53. out: list[tuple[list[int], bytes]] = []
  54. pos = 0
  55. while len(buf) - pos >= HDR_LEN:
  56. chunk = buf[pos : pos + HDR_LEN]
  57. if not _is_encoded_header(chunk):
  58. break
  59. h = list(struct.unpack("<9i", decode6(chunk)))
  60. blen = h[7]
  61. if blen < 0 or blen > MAX_BODY or len(buf) - pos < HDR_LEN + blen:
  62. break
  63. out.append((h, buf[pos + HDR_LEN : pos + HDR_LEN + blen]))
  64. pos += HDR_LEN + blen
  65. return out, buf[pos:]
  66. def body_utf8(body: bytes) -> str:
  67. """尽力解码引导串; 不可解码部分以 ? 替换。"""
  68. return body.decode("utf-8", errors="replace").replace("\x00", "")
  69. def _reencode_header_hex(h: list[int]) -> str:
  70. from pbot.codec.bitbuf import encode6
  71. return encode6(struct.pack("<9i", *h)).hex()
  72. class FlowState:
  73. """单条 TCP 流的切帧状态机。"""
  74. __slots__ = ("buf", "frames", "inert", "synced")
  75. def __init__(self) -> None: # noqa: D107 简单状态容器
  76. self.buf = b""
  77. self.synced = False
  78. self.inert = False
  79. self.frames = 0
  80. def feed(self, payload: bytes) -> list[tuple[list[int], bytes]]:
  81. """喂入流数据, 返回切出的完整帧列表。"""
  82. if self.inert:
  83. return []
  84. self.buf += payload
  85. if not self.synced:
  86. off = try_sync(self.buf)
  87. if off < 0:
  88. if len(self.buf) > INERT_CAP:
  89. self.inert = True # 永久放弃: TLS/图片/视频流
  90. self.buf = b""
  91. return []
  92. self.buf = self.buf[off:]
  93. self.synced = True
  94. frames, rest = split_frames(self.buf)
  95. if not frames and len(rest) == len(self.buf) and len(rest) >= HDR_LEN:
  96. # 已同步态却整包解析失败 → 流边界错位, 回退重新同步
  97. self.synced = False
  98. off = try_sync(rest)
  99. if off >= 0:
  100. self.buf = rest[off:]
  101. self.synced = True
  102. frames, rest = split_frames(self.buf)
  103. else:
  104. self.buf = rest
  105. return frames
  106. self.buf = rest
  107. self.frames += len(frames)
  108. return frames
  109. def make_handler(
  110. fh: TextIO, t0: float | None = None
  111. ) -> tuple[Callable[[object], None], dict[str, int]]:
  112. """构造 scapy prn 回调。返回 (handler, 计数器)。"""
  113. flows: dict[tuple[str, int, str, int], FlowState] = {}
  114. seen_flows: set[tuple[str, int, str, int]] = set()
  115. local_ips = {
  116. s.strip()
  117. for s in subprocess.run( # noqa: PLW1510 本机 ifconfig, 无外部输入
  118. ["/sbin/ifconfig"], capture_output=True, text=True
  119. ).stdout.splitlines()
  120. if "inet " in s
  121. }
  122. local_ips = {ln.split()[1] for ln in local_ips}
  123. stats = {"frames": 0, "pkts": 0, "syn": 0}
  124. start = t0 if t0 is not None else time.time()
  125. def emit(rec: dict[str, object]) -> None:
  126. fh.write(json.dumps(rec, ensure_ascii=False) + "\n")
  127. def on_pkt(pkt: object) -> None:
  128. from scapy.all import IP as SC_IP
  129. from scapy.all import TCP as SC_TCP
  130. from scapy.all import Raw as SC_Raw
  131. try:
  132. ip = pkt[SC_IP] # pyright: ignore[reportIndexIssue]
  133. tcp = pkt[SC_TCP] # pyright: ignore[reportIndexIssue]
  134. except (KeyError, IndexError, TypeError):
  135. return
  136. stats["pkts"] += 1
  137. src, dst = str(ip.src), str(ip.dst)
  138. now = round(time.time() - start, 2)
  139. sport, dport = int(tcp.sport), int(tcp.dport)
  140. key = (src, sport, dst, dport)
  141. flags = int(tcp.flags)
  142. if flags & 0x02 and not flags & 0x10: # SYN 无 ACK
  143. stats["syn"] += 1
  144. emit({
  145. "kind": "syn", "t": now, "flow": f"{src}:{sport}>{dst}:{dport}",
  146. })
  147. if key not in seen_flows:
  148. seen_flows.add(key)
  149. emit({
  150. "kind": "flow", "t": now,
  151. "flow": f"{src}:{sport}>{dst}:{dport}",
  152. })
  153. if SC_Raw not in pkt: # pyright: ignore[reportOperatorIssue]
  154. return
  155. payload = bytes(pkt[SC_Raw].load) # pyright: ignore[reportIndexIssue]
  156. if not payload:
  157. return
  158. st = flows.setdefault(key, FlowState())
  159. # 方向: 本机地址作源 = 客户端→服务器 = CS
  160. local_side = src in local_ips or src.startswith("127.")
  161. for h, body in st.feed(payload):
  162. emit({
  163. "kind": "frame", "t": now,
  164. "dir": "CS" if local_side else "SC",
  165. "flow": f"{src}:{sport}>{dst}:{dport}",
  166. "frame_hex": _reencode_header_hex(h) + body.hex(),
  167. "pre": h[0], "msg": h[1], "recog": h[2], "p1": h[3],
  168. "p2": h[4], "p3": h[5], "p6": h[6], "blen": h[7],
  169. "tail": h[8],
  170. "body_hex": body.hex(),
  171. "body_utf8": body_utf8(body),
  172. })
  173. stats["frames"] += 1
  174. return on_pkt, stats
  175. def main() -> None:
  176. """主入口: 全接口抓包(需 root)。"""
  177. ap = argparse.ArgumentParser()
  178. ap.add_argument("--out", default="/tmp/cap_guide4.jsonl") # noqa: S108
  179. ap.add_argument("--duration", type=int, default=0, help="秒, 0=直到 Ctrl-C")
  180. ap.add_argument("--ifaces", default="en1,lo0,utun0",
  181. help="逗号分隔抓包接口")
  182. a = ap.parse_args()
  183. from scapy.all import sniff
  184. ifaces = [s.strip() for s in a.ifaces.split(",") if s.strip()]
  185. fh = Path(a.out).open("w", buffering=1) # noqa: SIM115 行缓冲实时落盘
  186. handler, stats = make_handler(fh)
  187. print(f"[sniff_v4] ifaces={ifaces} all-ports → {a.out}", flush=True) # noqa: T201
  188. try:
  189. sniff(
  190. iface=ifaces,
  191. filter="ip",
  192. prn=handler,
  193. store=False,
  194. timeout=a.duration or None,
  195. )
  196. except KeyboardInterrupt:
  197. pass
  198. finally:
  199. fh.close()
  200. print( # noqa: T201
  201. f"[done] {stats['frames']} frames / {stats['syn']} syn / "
  202. f"{stats['pkts']} pkts → {a.out}"
  203. )
  204. if __name__ == "__main__":
  205. main()