feat: publish workphone SDK deployment and API docs
This commit is contained in:
434
sdk/agent/hook/frida_manager.py
Normal file
434
sdk/agent/hook/frida_manager.py
Normal file
@@ -0,0 +1,434 @@
|
||||
"""
|
||||
Frida Manager — 管理 Frida 与微信进程的连接生命周期
|
||||
|
||||
职责:
|
||||
- 连接设备(USB / remote / gadget 三种模式)
|
||||
- attach 到微信进程
|
||||
- 加载 JS 脚本
|
||||
- 维护 rpc.exports 代理
|
||||
- 自动重连
|
||||
|
||||
连接模式:
|
||||
- usb: 需要 Root,通过 frida-server 连接(传统方式)
|
||||
- gadget: 无需 Root,通过注入到 APK 中的 frida-gadget 连接
|
||||
- remote: 通过 TCP 连接远程 frida-server
|
||||
|
||||
技术栈:Frida 16.5.6(17.x 在 Android 11 上 Java 桥失效)
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
import logging
|
||||
import threading
|
||||
import subprocess
|
||||
from typing import Optional, Callable, Dict, Any, Literal
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
DEFAULT_SCRIPT = os.path.join(SCRIPT_DIR, "wechat_hook_v2.js")
|
||||
WECHAT_PACKAGE = "com.tencent.mm"
|
||||
import random as _random
|
||||
_ANTI_DETECT_PORT_RANGE = (10000, 60000)
|
||||
GADGET_PORT = _random.randint(*_ANTI_DETECT_PORT_RANGE)
|
||||
|
||||
ConnectionMode = Literal["usb", "gadget", "remote"]
|
||||
|
||||
_SNAKE_RE = re.compile(r'(?<=[a-z0-9])([A-Z])')
|
||||
|
||||
|
||||
def _random_port() -> int:
|
||||
return _random.randint(*_ANTI_DETECT_PORT_RANGE)
|
||||
|
||||
|
||||
class FridaManager:
|
||||
"""Frida 会话管理器,支持 USB / Gadget / Remote 三种连接模式"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
device_serial: Optional[str] = None,
|
||||
script_path: str = DEFAULT_SCRIPT,
|
||||
on_event: Optional[Callable[[Dict[str, Any]], None]] = None,
|
||||
on_detach: Optional[Callable[[str], None]] = None,
|
||||
auto_reconnect: bool = True,
|
||||
reconnect_interval: float = 5.0,
|
||||
mode: ConnectionMode = "gadget",
|
||||
gadget_host: str = "127.0.0.1",
|
||||
gadget_port: int = 0,
|
||||
use_adb_forward: bool = True,
|
||||
):
|
||||
if gadget_port == 0:
|
||||
gadget_port = _random_port()
|
||||
self.device_serial = device_serial
|
||||
self.script_path = script_path
|
||||
self.on_event = on_event
|
||||
self.on_detach = on_detach
|
||||
self.auto_reconnect = auto_reconnect
|
||||
self.reconnect_interval = reconnect_interval
|
||||
self.mode = mode
|
||||
self.gadget_host = gadget_host
|
||||
self.gadget_port = gadget_port
|
||||
self.use_adb_forward = use_adb_forward
|
||||
|
||||
self._device = None
|
||||
self._session = None
|
||||
self._script = None
|
||||
self._rpc = None
|
||||
self._running = False
|
||||
self._detaching = False
|
||||
self._lock = threading.Lock()
|
||||
|
||||
@property
|
||||
def connected(self) -> bool:
|
||||
return self._session is not None and self._script is not None
|
||||
|
||||
@property
|
||||
def rpc(self):
|
||||
return self._rpc
|
||||
|
||||
def start(self) -> bool:
|
||||
try:
|
||||
import frida
|
||||
except ImportError:
|
||||
logger.error("frida 未安装,请执行: pip install frida-tools")
|
||||
return False
|
||||
|
||||
with self._lock:
|
||||
if self.connected:
|
||||
return True
|
||||
return self._do_attach()
|
||||
|
||||
def _setup_gadget_forward(self):
|
||||
"""为 Gadget/Remote 模式设置 ADB 端口转发(手机本机 Agent 连 127.0.0.1 时跳过)"""
|
||||
if not self.use_adb_forward:
|
||||
logger.info("跳过 ADB forward(设备本机 Frida remote)")
|
||||
return
|
||||
try:
|
||||
cmd = ["adb"]
|
||||
if self.device_serial:
|
||||
cmd += ["-s", self.device_serial]
|
||||
cmd += ["forward", f"tcp:{self.gadget_port}", f"tcp:{self.gadget_port}"]
|
||||
subprocess.run(cmd, capture_output=True, timeout=5)
|
||||
logger.info(f"ADB 端口转发已设置: tcp:{self.gadget_port}")
|
||||
except Exception as e:
|
||||
logger.warning(f"端口转发设置失败: {e}")
|
||||
|
||||
def _do_attach(self) -> bool:
|
||||
import frida
|
||||
|
||||
try:
|
||||
if self.mode == "gadget":
|
||||
self._setup_gadget_forward()
|
||||
addr = f"{self.gadget_host}:{self.gadget_port}"
|
||||
mgr = frida.get_device_manager()
|
||||
self._device = mgr.add_remote_device(addr)
|
||||
logger.info(f"Frida Gadget 已连接: {addr}")
|
||||
|
||||
self._session = self._device.attach("Gadget")
|
||||
self._session.on("detached", self._on_session_detached)
|
||||
logger.info("已 attach 到 Gadget 进程(无 Root 模式)")
|
||||
|
||||
elif self.mode == "remote":
|
||||
self._setup_gadget_forward()
|
||||
addr = f"{self.gadget_host}:{self.gadget_port}"
|
||||
mgr = frida.get_device_manager()
|
||||
self._device = mgr.add_remote_device(addr)
|
||||
logger.info(f"Frida Remote 已连接: {addr}")
|
||||
|
||||
pid = self._find_wechat_pid()
|
||||
if pid is None:
|
||||
# WX-11 修复:禁用 frida spawn(spawn 失败会在 ActivityManager 留下
|
||||
# (null)pid 幽灵进程记录,阻塞微信后续启动需 reboot)。改为 am start
|
||||
# 温和拉起 + 轮询 attach;仍失败则本轮放弃,交重连循环稍后重试。
|
||||
logger.warning("微信未运行,am start 温和拉起(禁用 frida spawn 防 AM 幽灵进程 WX-11)")
|
||||
pid = self._launch_wechat_gently()
|
||||
if pid is None:
|
||||
logger.warning("微信仍未运行,本轮放弃 attach(交重连循环重试 am start,不 frida-spawn)")
|
||||
return False
|
||||
self._session = self._device.attach(pid)
|
||||
self._session.on("detached", self._on_session_detached)
|
||||
logger.info(f"已 attach 到微信进程 pid={pid}")
|
||||
|
||||
else: # usb (需要 Root)
|
||||
if self.device_serial:
|
||||
self._device = frida.get_device(self.device_serial)
|
||||
else:
|
||||
self._device = frida.get_usb_device(timeout=10)
|
||||
logger.info(f"Frida USB 已连接设备: {self._device.name}")
|
||||
|
||||
pid = self._find_wechat_pid()
|
||||
if pid is None:
|
||||
# WX-11 修复:USB 模式同样禁用 frida spawn,改 am start 温和拉起。
|
||||
logger.warning("微信未运行,am start 温和拉起(禁用 frida spawn 防 AM 幽灵进程 WX-11)")
|
||||
pid = self._launch_wechat_gently()
|
||||
if pid is None:
|
||||
logger.warning("微信仍未运行,本轮放弃 attach(不 frida-spawn)")
|
||||
return False
|
||||
self._session = self._device.attach(pid)
|
||||
self._session.on("detached", self._on_session_detached)
|
||||
logger.info(f"已 attach 到微信进程 pid={pid}")
|
||||
|
||||
self._load_script()
|
||||
self._running = True
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Frida attach 失败 [{self.mode}]: {e}")
|
||||
self._do_cleanup()
|
||||
return False
|
||||
|
||||
def _find_wechat_pid(self) -> Optional[int]:
|
||||
# 手机本机:shell pidof,无需 adb
|
||||
if not self.use_adb_forward:
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["pidof", WECHAT_PACKAGE],
|
||||
capture_output=True, text=True, timeout=5,
|
||||
)
|
||||
out = (result.stdout or "").strip()
|
||||
if out:
|
||||
return int(out.split()[0])
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["sh", "-c", f"pidof {WECHAT_PACKAGE}"],
|
||||
capture_output=True, text=True, timeout=5,
|
||||
)
|
||||
out = (result.stdout or "").strip()
|
||||
if out:
|
||||
return int(out.split()[0])
|
||||
except Exception:
|
||||
pass
|
||||
# 主机 ADB pidof(Remote/Gadget 经端口转发时 enumerate 常漏进程)
|
||||
if self.device_serial and self.use_adb_forward:
|
||||
for attempt in range(3):
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["adb", "-s", self.device_serial, "shell", "pidof", WECHAT_PACKAGE],
|
||||
capture_output=True, text=True, timeout=8,
|
||||
)
|
||||
out = (result.stdout or "").strip()
|
||||
if out:
|
||||
return int(out.split()[0])
|
||||
except Exception:
|
||||
pass
|
||||
if attempt < 2:
|
||||
time.sleep(2)
|
||||
try:
|
||||
for proc in self._device.enumerate_processes():
|
||||
if proc.name in (WECHAT_PACKAGE, "WeChat") or getattr(proc, 'identifier', '') == WECHAT_PACKAGE:
|
||||
return proc.pid
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
for app in self._device.enumerate_applications():
|
||||
if app.identifier == WECHAT_PACKAGE and app.pid > 0:
|
||||
return app.pid
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["adb", "-s", self.device_serial or "", "shell", "pidof", WECHAT_PACKAGE],
|
||||
capture_output=True, text=True, timeout=5,
|
||||
)
|
||||
if result.stdout.strip():
|
||||
return int(result.stdout.strip().split()[0])
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
def _launch_wechat_gently(self, max_wait: int = 18) -> Optional[int]:
|
||||
"""温和拉起微信:仅 am start + 轮询 pid;绝不用 frida spawn。
|
||||
|
||||
WX-11:frida spawn 失败会在 ActivityManager 留下 (null)pid 幽灵进程记录,
|
||||
使微信被视为"正在启动"而拒绝后续 am start,最终需 reboot 才能恢复。
|
||||
故统一改用 am start 温和拉起并轮询,失败返回 None 交重连循环稍后重试。
|
||||
"""
|
||||
try:
|
||||
cmd = ["am", "start", "-n", f"{WECHAT_PACKAGE}/.ui.LauncherUI"]
|
||||
if self.device_serial:
|
||||
cmd = ["adb", "-s", self.device_serial, "shell"] + cmd
|
||||
subprocess.run(cmd, capture_output=True, timeout=15)
|
||||
except Exception as e:
|
||||
logger.warning(f"am start 拉起微信失败: {e}")
|
||||
deadline = time.time() + max_wait
|
||||
while time.time() < deadline:
|
||||
pid = self._find_wechat_pid()
|
||||
if pid:
|
||||
logger.info(f"微信已温和拉起 pid={pid}")
|
||||
return pid
|
||||
time.sleep(2)
|
||||
return None
|
||||
|
||||
def _load_script(self):
|
||||
path = self.script_path
|
||||
if not os.path.exists(path):
|
||||
fallback = os.path.join(SCRIPT_DIR, "wechat_hook_v1.js")
|
||||
if os.path.exists(fallback):
|
||||
path = fallback
|
||||
else:
|
||||
raise FileNotFoundError(f"Hook 脚本不存在: {path}")
|
||||
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
source = f.read()
|
||||
|
||||
self._script = self._session.create_script(source)
|
||||
self._script.on("message", self._on_message)
|
||||
self._script.load()
|
||||
# 注意:禁止调用 script.eternalize()。Frida 语义为「eternalize 后即与脚本断开交互」,
|
||||
# 调用后 exports_sync 失效,所有 RPC 报 "script has been destroyed"。
|
||||
# 保活改由 _on_session_detached → _reconnect_loop(FS-3 detach 重连守护)实现。
|
||||
self._rpc = self._script.exports_sync
|
||||
logger.info(f"Hook 脚本已加载: {os.path.basename(path)}")
|
||||
|
||||
try:
|
||||
pong = self._rpc.ping()
|
||||
logger.info(f"RPC ping: {pong}")
|
||||
except Exception as e:
|
||||
logger.warning(f"RPC ping 失败: {e}")
|
||||
|
||||
def _on_message(self, message: dict, data):
|
||||
if message.get("type") == "send":
|
||||
payload = message.get("payload", {})
|
||||
msg_type = payload.get("type", "")
|
||||
|
||||
if msg_type == "hook_event" and self.on_event:
|
||||
self.on_event(payload)
|
||||
elif msg_type == "log":
|
||||
level = payload.get("level", "info")
|
||||
tag = payload.get("tag", "hook")
|
||||
text = payload.get("message", "")
|
||||
getattr(logger, level, logger.info)(f"[{tag}] {text}")
|
||||
elif message.get("type") == "error":
|
||||
logger.error(f"Frida 脚本错误: {message.get('description', '')}")
|
||||
|
||||
def _on_session_detached(self, reason: str):
|
||||
"""Frida 内部线程回调 — 只清理引用,不重复 detach/unload,不获取 _lock(避免死锁)"""
|
||||
logger.warning(f"Frida 会话断开: {reason}")
|
||||
self._detaching = True
|
||||
self._nullify_refs()
|
||||
self._detaching = False
|
||||
|
||||
if self.on_detach:
|
||||
try:
|
||||
self.on_detach(reason)
|
||||
except Exception as e:
|
||||
logger.warning(f"on_detach 回调异常: {e}")
|
||||
|
||||
if self.auto_reconnect and self._running and reason != "application-requested":
|
||||
threading.Thread(target=self._reconnect_loop, daemon=True).start()
|
||||
|
||||
def _reconnect_loop(self):
|
||||
while self._running and not self.connected:
|
||||
logger.info(f"尝试重连 Frida({self.reconnect_interval}s 后)...")
|
||||
time.sleep(self.reconnect_interval)
|
||||
with self._lock:
|
||||
if self.connected:
|
||||
break
|
||||
try:
|
||||
if self._do_attach():
|
||||
logger.info("Frida 重连成功")
|
||||
break
|
||||
except Exception as e:
|
||||
logger.warning(f"重连失败: {e}")
|
||||
|
||||
@staticmethod
|
||||
def _to_snake(name: str) -> str:
|
||||
return _SNAKE_RE.sub(r'_\1', name).lower()
|
||||
|
||||
def call_rpc(self, method: str, params: dict = None) -> Dict[str, Any]:
|
||||
with self._lock:
|
||||
if not self.connected or not self._rpc:
|
||||
return {"success": False, "error": "Frida 未连接"}
|
||||
rpc = self._rpc
|
||||
try:
|
||||
snake = self._to_snake(method)
|
||||
fn = getattr(rpc, snake, None) or getattr(rpc, method, None)
|
||||
if fn is None:
|
||||
return {"success": False, "error": f"RPC 方法不存在: {method} ({snake})"}
|
||||
result = fn(params) if params else fn({})
|
||||
if isinstance(result, dict):
|
||||
result.setdefault("success", True)
|
||||
return result
|
||||
return {"success": True, "data": result}
|
||||
except Exception as e:
|
||||
logger.error(f"RPC 调用失败 [{method}]: {e}")
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
def reload_script(self) -> Dict[str, Any]:
|
||||
"""重新加载当前 Hook 脚本,不重启微信进程。"""
|
||||
with self._lock:
|
||||
try:
|
||||
if not self._session:
|
||||
ok = self._do_attach()
|
||||
return {
|
||||
"success": ok,
|
||||
"reloaded": ok,
|
||||
"script_path": self.script_path,
|
||||
"method": "attach_and_load",
|
||||
}
|
||||
if self._script:
|
||||
try:
|
||||
self._script.unload()
|
||||
except Exception as e:
|
||||
logger.warning(f"Hook 脚本卸载失败,继续重载: {e}")
|
||||
self._script = None
|
||||
self._rpc = None
|
||||
self._load_script()
|
||||
return {
|
||||
"success": True,
|
||||
"reloaded": True,
|
||||
"script_path": self.script_path,
|
||||
"method": "reload_script",
|
||||
}
|
||||
except Exception as e:
|
||||
self._script = None
|
||||
self._rpc = None
|
||||
logger.error(f"Hook 脚本重载失败: {e}")
|
||||
return {
|
||||
"success": False,
|
||||
"reloaded": False,
|
||||
"script_path": self.script_path,
|
||||
"error": str(e),
|
||||
}
|
||||
|
||||
def stop(self):
|
||||
self._running = False
|
||||
with self._lock:
|
||||
self._do_cleanup()
|
||||
logger.info("FridaManager 已停止")
|
||||
|
||||
def _nullify_refs(self):
|
||||
"""清空引用(不执行 unload/detach),用于 detach 回调内"""
|
||||
self._script = None
|
||||
self._session = None
|
||||
self._rpc = None
|
||||
|
||||
def _do_cleanup(self):
|
||||
"""主动断开时完整清理(unload 脚本 + detach 会话)"""
|
||||
if self._detaching:
|
||||
return
|
||||
try:
|
||||
if self._script:
|
||||
self._script.unload()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
if self._session:
|
||||
self._session.detach()
|
||||
except Exception:
|
||||
pass
|
||||
self._nullify_refs()
|
||||
|
||||
def get_status(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"connected": self.connected,
|
||||
"mode": self.mode,
|
||||
"device": self._device.name if self._device else None,
|
||||
"device_serial": self.device_serial,
|
||||
"script": os.path.basename(self.script_path),
|
||||
"running": self._running,
|
||||
"gadget_endpoint": f"{self.gadget_host}:{self.gadget_port}" if self.mode == "gadget" else None,
|
||||
}
|
||||
Reference in New Issue
Block a user