|
|
@@ -24,8 +24,12 @@ from typing import Any, Dict, Optional
|
|
|
import time
|
|
|
import json
|
|
|
import os
|
|
|
+import queue
|
|
|
import subprocess
|
|
|
import tempfile
|
|
|
+import threading
|
|
|
+import concurrent.futures
|
|
|
+from urllib.parse import unquote
|
|
|
|
|
|
import frida
|
|
|
|
|
|
@@ -35,9 +39,22 @@ SCRIPT_PATH = Path(__file__).with_name("jhs_raw_codec_rpc.js")
|
|
|
ENV_DEVICE_ID = "FRIDA_DEVICE_ID"
|
|
|
ENV_DEBUG = "JHS_CODEC_DEBUG"
|
|
|
ENV_CLI_TARGET_SEC = "FRIDA_CLI_TARGET_SEC"
|
|
|
+ENV_RPC_TIMEOUT_SEC = "FRIDA_RPC_TIMEOUT_SEC"
|
|
|
|
|
|
|
|
|
class JhsRawCodecClient:
|
|
|
+ RETRYABLE_FRIDA_ERROR_MARKERS = (
|
|
|
+ "unable to connect to remote frida-server: closed",
|
|
|
+ "server is not running",
|
|
|
+ "the connection is closed",
|
|
|
+ "connection is closed",
|
|
|
+ "connection terminated",
|
|
|
+ "session is detached",
|
|
|
+ "script is destroyed",
|
|
|
+ "transport error",
|
|
|
+ "device lost",
|
|
|
+ )
|
|
|
+
|
|
|
def _on_script_message(self, message, data) -> None:
|
|
|
msg_type = message.get("type")
|
|
|
if msg_type == "log":
|
|
|
@@ -74,6 +91,10 @@ class JhsRawCodecClient:
|
|
|
if self.debug:
|
|
|
print(f"[JhsRawCodecClient] {msg}")
|
|
|
|
|
|
+ def _is_retryable_frida_error(self, exc: Exception) -> bool:
|
|
|
+ msg = str(exc).strip().lower()
|
|
|
+ return any(marker in msg for marker in self.RETRYABLE_FRIDA_ERROR_MARKERS)
|
|
|
+
|
|
|
def _find_pid_by_identifier(self) -> int:
|
|
|
"""
|
|
|
通过应用标识符(package name)查找目标应用的进程 PID。
|
|
|
@@ -105,11 +126,49 @@ class JhsRawCodecClient:
|
|
|
pass
|
|
|
return 0
|
|
|
|
|
|
+ def _attach_session(self) -> None:
|
|
|
+ last_err = None
|
|
|
+ self.device = self._resolve_device(self.device_id)
|
|
|
+ for _ in range(6):
|
|
|
+ try:
|
|
|
+ pid = self._find_pid_by_identifier() or self._find_pid_by_process()
|
|
|
+ if pid:
|
|
|
+ self.session = self.device.attach(pid)
|
|
|
+ return
|
|
|
+ last_err = frida.ProcessNotFoundError(
|
|
|
+ f"unable to find running app/process for '{self.package}'"
|
|
|
+ )
|
|
|
+ time.sleep(1.0)
|
|
|
+ except frida.ProcessNotFoundError as e:
|
|
|
+ last_err = e
|
|
|
+ time.sleep(1.0)
|
|
|
+ raise RuntimeError(
|
|
|
+ f"unable to attach '{self.package}', please open app and keep it running"
|
|
|
+ ) from last_err
|
|
|
+
|
|
|
+ def _load_script(self) -> None:
|
|
|
+ code = SCRIPT_PATH.read_text(encoding="utf-8")
|
|
|
+ self.script = self.session.create_script(code)
|
|
|
+ self.script.on("message", self._on_script_message)
|
|
|
+ self.script.load()
|
|
|
+
|
|
|
+ def _connect(self) -> None:
|
|
|
+ self.session = None
|
|
|
+ self.script = None
|
|
|
+ self._attach_session()
|
|
|
+ self._load_script()
|
|
|
+
|
|
|
+ def reconnect(self) -> None:
|
|
|
+ self._log("reconnecting frida session")
|
|
|
+ self.close()
|
|
|
+ self._connect()
|
|
|
+
|
|
|
def __init__(
|
|
|
self,
|
|
|
package: str = PKG,
|
|
|
device_id: Optional[str] = None,
|
|
|
cli_target_sec: Optional[int] = None,
|
|
|
+ rpc_timeout_sec: Optional[int] = None,
|
|
|
):
|
|
|
"""
|
|
|
初始化客户端并附加到目标 App 进程,随后加载 RPC 脚本。
|
|
|
@@ -120,6 +179,9 @@ class JhsRawCodecClient:
|
|
|
若仍为空则使用默认 USB 设备选择逻辑。
|
|
|
cli_target_sec: CLI 兜底模式的 frida `-t` 秒数。未传时读取
|
|
|
环境变量 FRIDA_CLI_TARGET_SEC,默认 3 秒。
|
|
|
+ rpc_timeout_sec: Python RPC 同步调用的超时秒数(frida-python 的
|
|
|
+ `exports_sync` 本身无超时,靠外层 future 兜底)。未传时读取
|
|
|
+ 环境变量 FRIDA_RPC_TIMEOUT_SEC,默认 20 秒;最小 3 秒。
|
|
|
|
|
|
Raises:
|
|
|
RuntimeError: 多次重试后仍无法附加到目标进程。
|
|
|
@@ -131,31 +193,19 @@ class JhsRawCodecClient:
|
|
|
if target_sec is None:
|
|
|
target_sec = int(os.getenv(ENV_CLI_TARGET_SEC, "3"))
|
|
|
self.cli_target_sec = max(1, int(target_sec))
|
|
|
- self.device = self._resolve_device(self.device_id)
|
|
|
+ rpc_ts = rpc_timeout_sec
|
|
|
+ if rpc_ts is None:
|
|
|
+ rpc_ts = int(os.getenv(ENV_RPC_TIMEOUT_SEC, "20"))
|
|
|
+ self.rpc_timeout_sec = max(3, int(rpc_ts))
|
|
|
+ self.device = None
|
|
|
self.session = None
|
|
|
+ self.script = None
|
|
|
self._prefer_cli = False
|
|
|
- last_err = None
|
|
|
- for _ in range(6):
|
|
|
- try:
|
|
|
- pid = self._find_pid_by_identifier() or self._find_pid_by_process()
|
|
|
- if pid:
|
|
|
- self.session = self.device.attach(pid)
|
|
|
- break
|
|
|
- last_err = frida.ProcessNotFoundError(
|
|
|
- f"unable to find running app/process for '{self.package}'"
|
|
|
- )
|
|
|
- time.sleep(1.0)
|
|
|
- except frida.ProcessNotFoundError as e:
|
|
|
- last_err = e
|
|
|
- time.sleep(1.0)
|
|
|
- if self.session is None:
|
|
|
- raise RuntimeError(
|
|
|
- f"unable to attach '{self.package}', please open app and keep it running"
|
|
|
- ) from last_err
|
|
|
- code = SCRIPT_PATH.read_text(encoding="utf-8")
|
|
|
- self.script = self.session.create_script(code)
|
|
|
- self.script.on("message", self._on_script_message)
|
|
|
- self.script.load()
|
|
|
+ # 单线程 executor 专门用来给同步 RPC 加超时;到点若卡死则丢弃换新的
|
|
|
+ self._executor = concurrent.futures.ThreadPoolExecutor(
|
|
|
+ max_workers=1, thread_name_prefix="jhs-rpc"
|
|
|
+ )
|
|
|
+ self._connect()
|
|
|
|
|
|
def __enter__(self):
|
|
|
"""上下文管理器入口,返回当前客户端实例。"""
|
|
|
@@ -167,16 +217,66 @@ class JhsRawCodecClient:
|
|
|
return False
|
|
|
|
|
|
def close(self) -> None:
|
|
|
- """关闭并清理资源:卸载脚本、断开会话连接。"""
|
|
|
+ """关闭并清理资源:卸载脚本、断开会话连接、关闭 executor。"""
|
|
|
+ try:
|
|
|
+ if self.script is not None:
|
|
|
+ self.script.unload()
|
|
|
+ except Exception:
|
|
|
+ pass
|
|
|
try:
|
|
|
- self.script.unload()
|
|
|
+ if self.session is not None:
|
|
|
+ self.session.detach()
|
|
|
except Exception:
|
|
|
pass
|
|
|
+ self.script = None
|
|
|
+ self.session = None
|
|
|
+ # executor 里可能残留卡死的调用线程,不 join,让守护线程随进程结束
|
|
|
try:
|
|
|
- self.session.detach()
|
|
|
+ self._executor.shutdown(wait=False, cancel_futures=True)
|
|
|
except Exception:
|
|
|
pass
|
|
|
|
|
|
+ def _sync_rpc_call(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
|
|
+ """
|
|
|
+ 为 `script.exports_sync.call` 增加超时保护的包装。
|
|
|
+
|
|
|
+ frida-python 的 `exports_sync` 是纯阻塞调用、没有 timeout 参数;
|
|
|
+ 目标 app 冷启动时相关 Java 类可能还未加载,脚本 hook 排队后不返回,
|
|
|
+ Python 端会永远阻塞。这里通过后台 future + `result(timeout=...)` 兜底。
|
|
|
+
|
|
|
+ Args:
|
|
|
+ params (Dict[str, Any]): RPC 参数字典。
|
|
|
+
|
|
|
+ Returns:
|
|
|
+ Dict[str, Any]: JS 侧返回的结果。
|
|
|
+
|
|
|
+ Raises:
|
|
|
+ TimeoutError: 超过 `self.rpc_timeout_sec` 仍未返回;抛出前会尝试
|
|
|
+ detach session 让底层阻塞线程报错退出,并重建 executor。
|
|
|
+ """
|
|
|
+ fut = self._executor.submit(self.script.exports_sync.call, params)
|
|
|
+ try:
|
|
|
+ return fut.result(timeout=self.rpc_timeout_sec)
|
|
|
+ except concurrent.futures.TimeoutError:
|
|
|
+ self._log(f"python rpc timeout after {self.rpc_timeout_sec}s, detaching session")
|
|
|
+ # 强断会话,通常会让阻塞的 frida 线程抛异常退出(否则它就常驻卡着)
|
|
|
+ try:
|
|
|
+ if self.session is not None:
|
|
|
+ self.session.detach()
|
|
|
+ except Exception:
|
|
|
+ pass
|
|
|
+ # 旧 executor 内的线程可能还在阻塞,直接丢弃、开新的
|
|
|
+ try:
|
|
|
+ self._executor.shutdown(wait=False, cancel_futures=True)
|
|
|
+ except Exception:
|
|
|
+ pass
|
|
|
+ self._executor = concurrent.futures.ThreadPoolExecutor(
|
|
|
+ max_workers=1, thread_name_prefix="jhs-rpc"
|
|
|
+ )
|
|
|
+ raise TimeoutError(
|
|
|
+ f"frida python rpc call timeout after {self.rpc_timeout_sec}s"
|
|
|
+ )
|
|
|
+
|
|
|
def encrypt(self, url: str) -> Dict[str, Any]:
|
|
|
"""
|
|
|
调用 JS RPC 的 encrypt 方法,对请求 URL 进行加密处理。
|
|
|
@@ -220,7 +320,15 @@ class JhsRawCodecClient:
|
|
|
return self._call_via_cli(params)
|
|
|
try:
|
|
|
self._log("call path: python rpc")
|
|
|
- return self.script.exports_sync.call(params)
|
|
|
+ return self._sync_rpc_call(params)
|
|
|
+ except TimeoutError as e:
|
|
|
+ # 已经在 _sync_rpc_call 内 detach 过;这里直接重连,让上层重试
|
|
|
+ self._log(f"python rpc timeout, reconnecting session: {e}")
|
|
|
+ try:
|
|
|
+ self.reconnect()
|
|
|
+ except Exception as re:
|
|
|
+ self._log(f"reconnect after timeout failed: {re}")
|
|
|
+ raise
|
|
|
except Exception as e:
|
|
|
msg = str(e)
|
|
|
# In some embedded Gadget setups, Python session scripts miss Java bridge.
|
|
|
@@ -229,6 +337,10 @@ class JhsRawCodecClient:
|
|
|
self._prefer_cli = True
|
|
|
self._log("python rpc missing Java bridge, switch to cli fallback")
|
|
|
return self._call_via_cli(params)
|
|
|
+ if self._is_retryable_frida_error(e):
|
|
|
+ self._log(f"python rpc failed with retryable frida error: {e}")
|
|
|
+ self.reconnect()
|
|
|
+ return self._sync_rpc_call(params)
|
|
|
raise
|
|
|
|
|
|
def _call_via_cli(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
|
|
@@ -256,7 +368,7 @@ class JhsRawCodecClient:
|
|
|
+ js_src
|
|
|
+ "\nsetImmediate(function(){\n"
|
|
|
" rpc.exports.call(__PARAMS)\n"
|
|
|
- " .then(function(r){ console.log('[CODEC-RESULT]' + JSON.stringify(r)); })\n"
|
|
|
+ " .then(function(r){ console.log('[CODEC-RESULT-URI]' + encodeURIComponent(JSON.stringify(r))); })\n"
|
|
|
" .catch(function(e){ console.log('[CODEC-ERROR]' + e); });\n"
|
|
|
"});\n"
|
|
|
)
|
|
|
@@ -280,52 +392,138 @@ class JhsRawCodecClient:
|
|
|
encoding="utf-8",
|
|
|
errors="replace",
|
|
|
)
|
|
|
- deadline = time.time() + max(10, self.cli_target_sec + 8)
|
|
|
+ deadline_total = max(10, self.cli_target_sec + 8)
|
|
|
+ deadline = time.time() + deadline_total
|
|
|
out_lines = []
|
|
|
err_lines = []
|
|
|
|
|
|
- while time.time() < deadline:
|
|
|
- if proc.stdout is None:
|
|
|
+ # 后台线程把 stdout / stderr 读到 queue 里,主循环用 queue.get(timeout=...)
|
|
|
+ # 拿数据,从根本上避免 readline() 阻塞导致 deadline 失效
|
|
|
+ io_q: "queue.Queue" = queue.Queue()
|
|
|
+
|
|
|
+ def _pump(stream, tag):
|
|
|
+ try:
|
|
|
+ for line in iter(stream.readline, ""):
|
|
|
+ io_q.put((tag, line))
|
|
|
+ finally:
|
|
|
+ try:
|
|
|
+ stream.close()
|
|
|
+ except Exception:
|
|
|
+ pass
|
|
|
+ io_q.put((tag, None)) # EOF 标记
|
|
|
+
|
|
|
+ t_out = threading.Thread(target=_pump, args=(proc.stdout, "out"), daemon=True)
|
|
|
+ t_err = threading.Thread(target=_pump, args=(proc.stderr, "err"), daemon=True)
|
|
|
+ t_out.start()
|
|
|
+ t_err.start()
|
|
|
+
|
|
|
+ out_eof = False
|
|
|
+ err_eof = False
|
|
|
+
|
|
|
+ while True:
|
|
|
+ remaining = deadline - time.time()
|
|
|
+ if remaining <= 0:
|
|
|
break
|
|
|
- line = proc.stdout.readline()
|
|
|
- if line:
|
|
|
- line = line.rstrip("\r\n")
|
|
|
- out_lines.append(line)
|
|
|
- if line.startswith("[CODEC-RESULT]"):
|
|
|
- result = json.loads(line[len("[CODEC-RESULT]"):])
|
|
|
- if proc.poll() is None:
|
|
|
- proc.terminate()
|
|
|
- return result
|
|
|
- if line.startswith("[CODEC-ERROR]"):
|
|
|
- if proc.poll() is None:
|
|
|
- proc.terminate()
|
|
|
- raise RuntimeError(line)
|
|
|
+ try:
|
|
|
+ tag, line = io_q.get(timeout=min(0.5, remaining))
|
|
|
+ except queue.Empty:
|
|
|
+ # 进程已结束且两路输出都到 EOF 就退出,否则继续等
|
|
|
+ if proc.poll() is not None and out_eof and err_eof:
|
|
|
+ break
|
|
|
continue
|
|
|
|
|
|
- if proc.poll() is not None:
|
|
|
- break
|
|
|
+ if line is None:
|
|
|
+ if tag == "out":
|
|
|
+ out_eof = True
|
|
|
+ else:
|
|
|
+ err_eof = True
|
|
|
+ if out_eof and err_eof and proc.poll() is not None:
|
|
|
+ break
|
|
|
+ continue
|
|
|
|
|
|
- if proc.stderr is not None:
|
|
|
- err_lines.extend(proc.stderr.read().splitlines())
|
|
|
+ line = line.rstrip("\r\n")
|
|
|
+ if tag == "err":
|
|
|
+ err_lines.append(line)
|
|
|
+ continue
|
|
|
|
|
|
+ out_lines.append(line)
|
|
|
+ if line.startswith("[CODEC-RESULT-URI]"):
|
|
|
+ payload = unquote(line[len("[CODEC-RESULT-URI]"):])
|
|
|
+ result = json.loads(payload)
|
|
|
+ self._terminate_proc(proc)
|
|
|
+ return result
|
|
|
+ if line.startswith("[CODEC-RESULT]"):
|
|
|
+ result = json.loads(line[len("[CODEC-RESULT]"):])
|
|
|
+ self._terminate_proc(proc)
|
|
|
+ return result
|
|
|
+ if line.startswith("[CODEC-ERROR]"):
|
|
|
+ self._terminate_proc(proc)
|
|
|
+ raise RuntimeError(line)
|
|
|
+
|
|
|
+ # 到点未拿到结果:强杀子进程,抛 TimeoutError 让上层重试
|
|
|
+ self._terminate_proc(proc)
|
|
|
+ # 抓一下 err 里剩下的(可能已经通过 queue 收了大部分,这里兜底)
|
|
|
+ drained_out, drained_err = self._drain_queue(io_q)
|
|
|
+ out_lines.extend(drained_out)
|
|
|
+ err_lines.extend(drained_err)
|
|
|
out = "\n".join(out_lines)
|
|
|
err = "\n".join(err_lines)
|
|
|
- raise RuntimeError(
|
|
|
- "cli codec call failed: no result line\n"
|
|
|
+ raise TimeoutError(
|
|
|
+ f"cli codec call timeout after {deadline_total}s, no result line\n"
|
|
|
+ "stdout:\n" + out + "\n"
|
|
|
+ "stderr:\n" + err
|
|
|
)
|
|
|
finally:
|
|
|
- if proc is not None and proc.poll() is None:
|
|
|
- try:
|
|
|
- proc.terminate()
|
|
|
- except Exception:
|
|
|
- pass
|
|
|
+ self._terminate_proc(proc)
|
|
|
try:
|
|
|
os.remove(tmp_path)
|
|
|
except Exception:
|
|
|
pass
|
|
|
|
|
|
+ @staticmethod
|
|
|
+ def _drain_queue(io_q: "queue.Queue"):
|
|
|
+ """把 queue 里现存的数据一次性抽干,用于超时后收拾残余输出。
|
|
|
+
|
|
|
+ Args:
|
|
|
+ io_q (queue.Queue): _call_via_cli 中用来收集 stdout/stderr 的队列。
|
|
|
+
|
|
|
+ Returns:
|
|
|
+ tuple[list[str], list[str]]: (stdout 行列表, stderr 行列表)。
|
|
|
+ """
|
|
|
+ outs, errs = [], []
|
|
|
+ while True:
|
|
|
+ try:
|
|
|
+ tag, line = io_q.get_nowait()
|
|
|
+ except queue.Empty:
|
|
|
+ break
|
|
|
+ if line is None:
|
|
|
+ continue
|
|
|
+ (outs if tag == "out" else errs).append(line.rstrip("\r\n"))
|
|
|
+ return outs, errs
|
|
|
+
|
|
|
+ @staticmethod
|
|
|
+ def _terminate_proc(proc) -> None:
|
|
|
+ """尽力结束 frida CLI 子进程,先 terminate 再 kill。
|
|
|
+
|
|
|
+ Args:
|
|
|
+ proc: `subprocess.Popen` 对象,允许为 None。
|
|
|
+ """
|
|
|
+ if proc is None:
|
|
|
+ return
|
|
|
+ if proc.poll() is not None:
|
|
|
+ return
|
|
|
+ try:
|
|
|
+ proc.terminate()
|
|
|
+ except Exception:
|
|
|
+ pass
|
|
|
+ try:
|
|
|
+ proc.wait(timeout=2)
|
|
|
+ except Exception:
|
|
|
+ try:
|
|
|
+ proc.kill()
|
|
|
+ except Exception:
|
|
|
+ pass
|
|
|
+
|
|
|
|
|
|
def encrypt_url(url: str, package: str = PKG, device_id: Optional[str] = None) -> Dict[str, Any]:
|
|
|
"""
|