Files
workphone-sdk/sdk/agent/hook/frida_manager.py

435 lines
17 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
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.617.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 spawnspawn 失败会在 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 pidofRemote/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-11frida 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_loopFS-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,
}