Files
First/src/engine.py
T
2026-04-02 21:38:29 +08:00

506 lines
20 KiB
Python

"""
DebugEngine - wraps debug_server, proxy_server, frida_server into a reusable class.
"""
import asyncio
import json
import random
import re
import sys
from collections import Counter
from pathlib import Path
import frida
import websockets
import websockets.server
import websockets.exceptions
from .codex import wrap_debug_message_data, unwrap_debug_message_data
from .third_party import wmpf_debug_pb2 as proto
from .userscript import (
build_cdp_enable_page_command,
build_cdp_add_script_command,
build_injection_wrapper,
)
class DebugMessageBus:
"""Central event bus bridging debug server and CDP proxy server."""
def __init__(self):
self._cdp_callbacks = []
self._proxy_callbacks = []
def on_cdp_message(self, callback):
self._cdp_callbacks.append(callback)
def on_proxy_message(self, callback):
self._proxy_callbacks.append(callback)
def emit_cdp_message(self, message: str):
for cb in self._cdp_callbacks:
cb(message)
def emit_proxy_message(self, message: str):
for cb in self._proxy_callbacks:
cb(message)
def buffer_to_hex_string(data: bytes) -> str:
return data.hex()
def _build_protobuf_cdp_message(cdp_json: str, seq: int) -> bytes:
"""Wrap a CDP JSON command into protobuf binary for sending to the miniapp runtime."""
raw_payload = {
"jscontext_id": "",
"op_id": round(100 * random.random()),
"payload": cdp_json,
}
wrapped = wrap_debug_message_data(raw_payload, "chromeDevtools", 0)
out_msg = proto.WARemoteDebug_DebugMessage()
out_msg.seq = seq
out_msg.category = "chromeDevtools"
out_msg.data = wrapped["buffer"]
out_msg.compressAlgo = 0
out_msg.originalSize = wrapped["originalSize"]
return out_msg.SerializeToString()
class DebugEngine:
"""Wraps all three subsystems: debug server, proxy server, frida injection."""
def __init__(self, options, logger, userscripts=None):
self.options = options
self.logger = logger
self.bus = DebugMessageBus()
self.userscripts = userscripts or []
self.debug_srv = None
self.proxy_srv = None
self.frida_session = None
self.frida_script = None
self.miniapp_clients = set()
self.devtools_clients = set()
self.message_counter = 0
self._pending_responses = {}
self._cmd_counter = 80000
self._status_callbacks = []
self._event_listeners = {} # method -> [callback, ...]
self.status = {"frida": False, "miniapp": False, "devtools": False}
def on_status_change(self, callback):
self._status_callbacks.append(callback)
def _notify_status(self, key, value):
self.status[key] = value
for cb in self._status_callbacks:
try:
cb(dict(self.status))
except Exception:
pass
def _next_cmd_id(self):
self._cmd_counter += 1
return self._cmd_counter
async def start(self):
"""Start all three subsystems."""
self.debug_srv = await self._start_debug_server()
self.proxy_srv = await self._start_proxy_server()
try:
self.frida_session, self.frida_script = await self._start_frida()
self._notify_status("frida", True)
except Exception as e:
self.logger.error(str(e))
raise
async def stop(self):
"""Graceful shutdown."""
if self.debug_srv:
self.debug_srv.close()
await self.debug_srv.wait_closed()
self.debug_srv = None
if self.proxy_srv:
self.proxy_srv.close()
await self.proxy_srv.wait_closed()
self.proxy_srv = None
if self.frida_script:
try:
self.frida_script.unload()
except Exception:
pass
self.frida_script = None
if self.frida_session:
try:
self.frida_session.detach()
except Exception:
pass
self.frida_session = None
self._notify_status("frida", False)
self._notify_status("miniapp", False)
self._notify_status("devtools", False)
self.miniapp_clients.clear()
self.devtools_clients.clear()
self.message_counter = 0
self._pending_responses.clear()
self.logger.info("[server] engine stopped")
async def evaluate_js(self, expression, timeout=5.0):
"""Send Runtime.evaluate via CDP to the miniapp, return result dict."""
if not self.miniapp_clients:
raise RuntimeError("No miniapp client connected")
cmd_id = self._next_cmd_id()
cdp_cmd = json.dumps({
"id": cmd_id,
"method": "Runtime.evaluate",
"params": {"expression": expression, "returnByValue": True}
})
loop = asyncio.get_event_loop()
future = loop.create_future()
self._pending_responses[cmd_id] = future
self.bus.emit_proxy_message(cdp_cmd)
try:
return await asyncio.wait_for(future, timeout=timeout)
except asyncio.TimeoutError:
self._pending_responses.pop(cmd_id, None)
raise
async def send_cdp_command(self, method, params=None, timeout=5.0):
"""Send an arbitrary CDP command and return the response."""
if not self.miniapp_clients:
raise RuntimeError("No miniapp client connected")
cmd_id = self._next_cmd_id()
cdp_cmd = json.dumps({"id": cmd_id, "method": method, "params": params or {}})
loop = asyncio.get_event_loop()
future = loop.create_future()
self._pending_responses[cmd_id] = future
self.bus.emit_proxy_message(cdp_cmd)
try:
return await asyncio.wait_for(future, timeout=timeout)
except asyncio.TimeoutError:
self._pending_responses.pop(cmd_id, None)
raise
async def set_extra_headers(self, headers: dict):
"""Enable Network domain and set extra HTTP headers via CDP."""
await self.send_cdp_command("Network.enable")
await self.send_cdp_command("Network.setExtraHTTPHeaders",
{"headers": headers})
def on_cdp_event(self, method, callback):
"""Subscribe to a CDP event by method name (e.g. 'Debugger.scriptParsed')."""
self._event_listeners.setdefault(method, []).append(callback)
def off_cdp_event(self, method, callback):
"""Unsubscribe from a CDP event."""
cbs = self._event_listeners.get(method, [])
if callback in cbs:
cbs.remove(callback)
def _handle_cdp_response(self, message_str):
"""Check if a CDP response matches a pending evaluate_js call, or dispatch events."""
try:
data = json.loads(message_str)
except (json.JSONDecodeError, TypeError):
return
msg_id = data.get("id")
if msg_id is not None and msg_id in self._pending_responses:
future = self._pending_responses.pop(msg_id)
if not future.done():
future.set_result(data)
# Dispatch CDP events (messages with "method" but no "id")
method = data.get("method")
if method and method in self._event_listeners:
for cb in self._event_listeners[method]:
try:
cb(data)
except Exception:
pass
# ── Debug Server ──
async def _start_debug_server(self):
engine = self
logger = self.logger
bus = self.bus
userscripts = self.userscripts
scripts_injected = False
def on_proxy_message(message: str):
engine.message_counter += 1
raw_payload = {
"jscontext_id": "",
"op_id": round(100 * random.random()),
"payload": str(message),
}
logger.main_debug(raw_payload)
wrapped = wrap_debug_message_data(raw_payload, "chromeDevtools", 0)
out_msg = proto.WARemoteDebug_DebugMessage()
out_msg.seq = engine.message_counter
out_msg.category = "chromeDevtools"
out_msg.data = wrapped["buffer"]
out_msg.compressAlgo = 0
out_msg.originalSize = wrapped["originalSize"]
encoded = out_msg.SerializeToString()
clients_snapshot = list(engine.miniapp_clients)
for ws in clients_snapshot:
try:
task = asyncio.ensure_future(ws.send(encoded))
task.add_done_callback(lambda t: t.exception() if t.done() and not t.cancelled() and t.exception() else None)
except Exception:
pass
bus.on_proxy_message(on_proxy_message)
async def handler(websocket):
nonlocal scripts_injected
engine.miniapp_clients.add(websocket)
engine._notify_status("miniapp", True)
logger.info("[miniapp] miniapp client connected")
if userscripts and not scripts_injected:
scripts_injected = True
logger.info("[userscript] registering scripts immediately on connection...")
try:
seq = engine.message_counter
cmd_id = 90000
seq += 1
await websocket.send(_build_protobuf_cdp_message(
build_cdp_enable_page_command(cmd_id), seq
))
cmd_id += 1
seq += 1
await websocket.send(_build_protobuf_cdp_message(
json.dumps({"id": cmd_id, "method": "Debugger.enable", "params": {}}), seq
))
cmd_id += 1
logger.info("[anti-debug] Debugger.enable sent")
seq += 1
await websocket.send(_build_protobuf_cdp_message(
json.dumps({"id": cmd_id, "method": "Debugger.setSkipAllPauses", "params": {"skip": True}}), seq
))
cmd_id += 1
logger.info("[anti-debug] Debugger.setSkipAllPauses(true) sent")
for script in userscripts:
seq += 1
await websocket.send(_build_protobuf_cdp_message(
build_cdp_add_script_command(script, cmd_id), seq
))
cmd_id += 1
logger.info(f"[userscript] registered (persistent): {script.name}")
engine.message_counter = seq
logger.info("[userscript] registration done, scripts will run on page load")
except Exception as e:
logger.error(f"[userscript] registration error: {e}")
immediate_done = False
try:
async for message in websocket:
if isinstance(message, str):
message = message.encode("utf-8")
msg_category = ""
try:
decoded_msg = proto.WARemoteDebug_DebugMessage()
decoded_msg.ParseFromString(message)
msg_category = decoded_msg.category
except Exception:
pass
self._process_miniapp_message(message)
if msg_category == "setupContext" and userscripts and not immediate_done:
immediate_done = True
await asyncio.sleep(0.5)
try:
seq = engine.message_counter
cmd_id = 91000
seq += 1
await websocket.send(_build_protobuf_cdp_message(
json.dumps({"id": cmd_id, "method": "Debugger.enable", "params": {}}), seq
))
cmd_id += 1
seq += 1
await websocket.send(_build_protobuf_cdp_message(
json.dumps({"id": cmd_id, "method": "Debugger.setSkipAllPauses", "params": {"skip": True}}), seq
))
cmd_id += 1
logger.info("[anti-debug] Debugger.setSkipAllPauses re-sent after setupContext")
for script in userscripts:
wrapped = build_injection_wrapper(script)
seq += 1
cdp_cmd = json.dumps({
"id": cmd_id,
"method": "Runtime.evaluate",
"params": {
"expression": wrapped,
"includeCommandLineAPI": True,
"silent": False,
},
})
await websocket.send(_build_protobuf_cdp_message(cdp_cmd, seq))
cmd_id += 1
logger.info(f"[userscript] immediate inject (Runtime.evaluate): {script.name}")
engine.message_counter = seq
except Exception as e:
logger.error(f"[userscript] immediate inject error: {e}")
except websockets.exceptions.ConnectionClosed:
pass
except Exception as e:
logger.error(f"[miniapp] miniapp client err: {e}")
finally:
engine.miniapp_clients.discard(websocket)
if not engine.miniapp_clients:
engine._notify_status("miniapp", False)
logger.info("[miniapp] miniapp client disconnected")
server = await websockets.server.serve(
handler, "0.0.0.0", self.options.debug_port,
max_size=None,
)
logger.info(f"[server] debug server running on ws://localhost:{self.options.debug_port}")
logger.info("[server] debug server waiting for miniapp to connect...")
return server
def _process_miniapp_message(self, message: bytes):
self.logger.main_debug(
f"[miniapp] client received raw message (hex): {buffer_to_hex_string(message)}"
)
unwrapped_data = None
try:
decoded = proto.WARemoteDebug_DebugMessage()
decoded.ParseFromString(message)
unwrapped_data = unwrap_debug_message_data(decoded)
self.logger.main_debug("[miniapp] [DEBUG] decoded data:")
self.logger.main_debug(unwrapped_data)
except Exception as e:
self.logger.error(f"[miniapp] miniapp client err: {e}")
if unwrapped_data is None:
return
if unwrapped_data.get("category") == "chromeDevtoolsResult":
payload = unwrapped_data["data"].get("payload", "")
self.bus.emit_cdp_message(payload)
# Also check for pending evaluate_js responses
self._handle_cdp_response(payload)
# ── Proxy Server ──
async def _start_proxy_server(self):
engine = self
logger = self.logger
bus = self.bus
def on_cdp_message(message: str):
clients_snapshot = list(engine.devtools_clients)
for ws in clients_snapshot:
try:
task = asyncio.ensure_future(ws.send(message))
task.add_done_callback(lambda t: t.exception() if t.done() and not t.cancelled() and t.exception() else None)
except Exception:
pass
bus.on_cdp_message(on_cdp_message)
async def handler(websocket):
engine.devtools_clients.add(websocket)
engine._notify_status("devtools", True)
logger.info("[cdp] CDP client connected")
try:
async for message in websocket:
if isinstance(message, bytes):
message = message.decode("utf-8")
bus.emit_proxy_message(message)
except websockets.exceptions.ConnectionClosed:
pass
except Exception as e:
logger.error(f"[cdp] CDP client err: {e}")
finally:
engine.devtools_clients.discard(websocket)
if not engine.devtools_clients:
engine._notify_status("devtools", False)
logger.info("[cdp] CDP client disconnected")
server = await websockets.server.serve(
handler, "0.0.0.0", self.options.cdp_port,
max_size=None,
)
logger.info(f"[server] proxy server running on ws://localhost:{self.options.cdp_port}")
logger.info(f"[server] link: devtools://devtools/bundled/inspector.html?ws=127.0.0.1:{self.options.cdp_port}")
return server
# ── Frida ──
async def _start_frida(self):
logger = self.logger
device = frida.get_local_device()
processes = device.enumerate_processes(scope="metadata")
wmpf_processes = [p for p in processes if p.name == "WeChatAppEx.exe"]
if not wmpf_processes:
raise RuntimeError("[frida] WeChatAppEx.exe process not found")
wmpf_ppids = []
for p in wmpf_processes:
ppid = p.parameters.get("ppid", 0)
wmpf_ppids.append(ppid if ppid else 0)
if not wmpf_ppids:
raise RuntimeError("[frida] WeChatAppEx.exe process not found")
pid_counts = Counter(wmpf_ppids)
wmpf_pid = pid_counts.most_common(1)[0][0]
if wmpf_pid == 0:
raise RuntimeError("[frida] WeChatAppEx.exe process not found")
wmpf_process = None
for p in processes:
if p.pid == wmpf_pid:
wmpf_process = p
break
if wmpf_process is None:
raise RuntimeError("[frida] Could not find main WMPF process")
wmpf_process_path = wmpf_process.parameters.get("path", "")
version_matches = re.findall(r"\d+", wmpf_process_path)
if not version_matches:
raise RuntimeError("[frida] error in find wmpf version")
wmpf_version = int(version_matches[-1])
if wmpf_version == 0:
raise RuntimeError("[frida] error in find wmpf version")
session = device.attach(wmpf_pid)
if getattr(sys, 'frozen', False):
# PyInstaller onefile: resources in _MEIPASS; onedir: next to exe
project_root = Path(getattr(sys, '_MEIPASS', Path(sys.executable).parent)).resolve()
else:
project_root = Path(__file__).parent.parent.resolve()
hook_path = project_root / "frida" / "hook.js"
if not hook_path.exists():
raise RuntimeError("[frida] hook script not found")
script_content = hook_path.read_text(encoding="utf-8")
config_path = project_root / "frida" / "config" / f"addresses.{wmpf_version}.json"
if not config_path.exists():
raise RuntimeError(f"[frida] version config not found: {wmpf_version}")
config_content = config_path.read_text(encoding="utf-8")
config_content = json.dumps(json.loads(config_content))
final_script = script_content.replace("@@CONFIG@@", config_content)
script = session.create_script(final_script)
def on_message(message, data):
if message.get("type") == "error":
logger.error("[frida client]", message)
return
logger.frida_debug("[frida client]", message.get("payload", ""))
script.on("message", on_message)
script.load()
logger.info(f"[frida] script loaded, WMPF version: {wmpf_version}, pid: {wmpf_pid}")
logger.info("[frida] you can now open any miniapps")
return session, script