jhs_raw_codec_client.py 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591
  1. #!/usr/bin/env python3
  2. # -*- coding: utf-8 -*-
  3. """
  4. Parameter-based client for jhs_raw_codec_rpc.js
  5. Usage (import-based):
  6. from raw_codec_rpc.jhs_raw_codec_client import call_codec
  7. enc = call_codec({
  8. "op": "enc",
  9. "url": "https://api.jihuanshe.com/api/market/auction-products?sorting=completed&page=2&token=..."
  10. })
  11. dec = call_codec({
  12. "op": "dec",
  13. "request_url": "https://api.jihuanshe.com/api/market/banners?raw_data=...&token=...",
  14. "response_raw_data": "BASE64_CIPHER"
  15. })
  16. """
  17. from pathlib import Path
  18. from typing import Any, Dict, Optional
  19. import time
  20. import json
  21. import os
  22. import queue
  23. import subprocess
  24. import tempfile
  25. import threading
  26. import concurrent.futures
  27. from urllib.parse import unquote
  28. import frida
  29. PKG = "com.jihuanshe"
  30. SCRIPT_PATH = Path(__file__).with_name("jhs_raw_codec_rpc.js")
  31. ENV_DEVICE_ID = "FRIDA_DEVICE_ID"
  32. ENV_DEBUG = "JHS_CODEC_DEBUG"
  33. ENV_CLI_TARGET_SEC = "FRIDA_CLI_TARGET_SEC"
  34. ENV_RPC_TIMEOUT_SEC = "FRIDA_RPC_TIMEOUT_SEC"
  35. class JhsRawCodecClient:
  36. RETRYABLE_FRIDA_ERROR_MARKERS = (
  37. "unable to connect to remote frida-server: closed",
  38. "server is not running",
  39. "the connection is closed",
  40. "connection is closed",
  41. "connection terminated",
  42. "session is detached",
  43. "script is destroyed",
  44. "transport error",
  45. "device lost",
  46. )
  47. def _on_script_message(self, message, data) -> None:
  48. msg_type = message.get("type")
  49. if msg_type == "log":
  50. level = message.get("level", "log")
  51. payload = message.get("payload", "")
  52. print(f"[frida:{level}] {payload}")
  53. return
  54. if msg_type == "error":
  55. desc = message.get("description", "script error")
  56. stack = message.get("stack")
  57. print(f"[frida:error] {desc}")
  58. if stack:
  59. print(stack)
  60. return
  61. print(f"[frida:{msg_type}] {message}")
  62. def _resolve_device(self, device_id: Optional[str]):
  63. """
  64. 解析并返回 Frida 设备对象。
  65. 优先使用显式传入的 device_id;若为空则回退到 USB 设备。
  66. Args:
  67. device_id: 目标设备 ID,例如 emulator-5554。
  68. Returns:
  69. frida.core.Device: 已连接设备对象。
  70. """
  71. if device_id:
  72. return frida.get_device_manager().get_device(device_id, timeout=5)
  73. return frida.get_usb_device(timeout=5)
  74. def _log(self, msg: str) -> None:
  75. if self.debug:
  76. print(f"[JhsRawCodecClient] {msg}")
  77. def _is_retryable_frida_error(self, exc: Exception) -> bool:
  78. msg = str(exc).strip().lower()
  79. return any(marker in msg for marker in self.RETRYABLE_FRIDA_ERROR_MARKERS)
  80. def _find_pid_by_identifier(self) -> int:
  81. """
  82. 通过应用标识符(package name)查找目标应用的进程 PID。
  83. Returns:
  84. int: 找到则返回 PID,未找到返回 0。
  85. """
  86. # Prefer app identifier lookup; attach("name") in frida-python matches process name.
  87. try:
  88. for app in self.device.enumerate_applications():
  89. if app.identifier == self.package and app.pid:
  90. return int(app.pid)
  91. except Exception:
  92. pass
  93. return 0
  94. def _find_pid_by_process(self) -> int:
  95. """
  96. 通过进程名查找目标进程 PID(作为 identifier 查找的兜底方案)。
  97. Returns:
  98. int: 找到则返回 PID,未找到返回 0。
  99. """
  100. try:
  101. for p in self.device.enumerate_processes():
  102. if p.name == self.package:
  103. return int(p.pid)
  104. except Exception:
  105. pass
  106. return 0
  107. def _attach_session(self) -> None:
  108. last_err = None
  109. self.device = self._resolve_device(self.device_id)
  110. for _ in range(6):
  111. try:
  112. pid = self._find_pid_by_identifier() or self._find_pid_by_process()
  113. if pid:
  114. self.session = self.device.attach(pid)
  115. return
  116. last_err = frida.ProcessNotFoundError(
  117. f"unable to find running app/process for '{self.package}'"
  118. )
  119. time.sleep(1.0)
  120. except frida.ProcessNotFoundError as e:
  121. last_err = e
  122. time.sleep(1.0)
  123. raise RuntimeError(
  124. f"unable to attach '{self.package}', please open app and keep it running"
  125. ) from last_err
  126. def _load_script(self) -> None:
  127. code = SCRIPT_PATH.read_text(encoding="utf-8")
  128. self.script = self.session.create_script(code)
  129. self.script.on("message", self._on_script_message)
  130. self.script.load()
  131. def _connect(self) -> None:
  132. self.session = None
  133. self.script = None
  134. self._attach_session()
  135. self._load_script()
  136. def reconnect(self) -> None:
  137. self._log("reconnecting frida session")
  138. self.close()
  139. self._connect()
  140. def __init__(
  141. self,
  142. package: str = PKG,
  143. device_id: Optional[str] = None,
  144. cli_target_sec: Optional[int] = None,
  145. rpc_timeout_sec: Optional[int] = None,
  146. ):
  147. """
  148. 初始化客户端并附加到目标 App 进程,随后加载 RPC 脚本。
  149. Args:
  150. package: 目标应用包名,默认使用常量 PKG。
  151. device_id: Frida 设备 ID。未传时读取环境变量 FRIDA_DEVICE_ID,
  152. 若仍为空则使用默认 USB 设备选择逻辑。
  153. cli_target_sec: CLI 兜底模式的 frida `-t` 秒数。未传时读取
  154. 环境变量 FRIDA_CLI_TARGET_SEC,默认 3 秒。
  155. rpc_timeout_sec: Python RPC 同步调用的超时秒数(frida-python 的
  156. `exports_sync` 本身无超时,靠外层 future 兜底)。未传时读取
  157. 环境变量 FRIDA_RPC_TIMEOUT_SEC,默认 20 秒;最小 3 秒。
  158. Raises:
  159. RuntimeError: 多次重试后仍无法附加到目标进程。
  160. """
  161. self.package = package
  162. self.device_id = device_id or os.getenv(ENV_DEVICE_ID)
  163. self.debug = os.getenv(ENV_DEBUG, "").strip().lower() in {"1", "true", "yes", "on"}
  164. target_sec = cli_target_sec
  165. if target_sec is None:
  166. target_sec = int(os.getenv(ENV_CLI_TARGET_SEC, "3"))
  167. self.cli_target_sec = max(1, int(target_sec))
  168. rpc_ts = rpc_timeout_sec
  169. if rpc_ts is None:
  170. rpc_ts = int(os.getenv(ENV_RPC_TIMEOUT_SEC, "20"))
  171. self.rpc_timeout_sec = max(3, int(rpc_ts))
  172. self.device = None
  173. self.session = None
  174. self.script = None
  175. self._prefer_cli = False
  176. # 单线程 executor 专门用来给同步 RPC 加超时;到点若卡死则丢弃换新的
  177. self._executor = concurrent.futures.ThreadPoolExecutor(
  178. max_workers=1, thread_name_prefix="jhs-rpc"
  179. )
  180. self._connect()
  181. def __enter__(self):
  182. """上下文管理器入口,返回当前客户端实例。"""
  183. return self
  184. def __exit__(self, exc_type, exc, tb):
  185. """上下文管理器退出时释放 Frida 资源。"""
  186. self.close()
  187. return False
  188. def close(self) -> None:
  189. """关闭并清理资源:卸载脚本、断开会话连接、关闭 executor。"""
  190. try:
  191. if self.script is not None:
  192. self.script.unload()
  193. except Exception:
  194. pass
  195. try:
  196. if self.session is not None:
  197. self.session.detach()
  198. except Exception:
  199. pass
  200. self.script = None
  201. self.session = None
  202. # executor 里可能残留卡死的调用线程,不 join,让守护线程随进程结束
  203. try:
  204. self._executor.shutdown(wait=False, cancel_futures=True)
  205. except Exception:
  206. pass
  207. def _sync_rpc_call(self, params: Dict[str, Any]) -> Dict[str, Any]:
  208. """
  209. 为 `script.exports_sync.call` 增加超时保护的包装。
  210. frida-python 的 `exports_sync` 是纯阻塞调用、没有 timeout 参数;
  211. 目标 app 冷启动时相关 Java 类可能还未加载,脚本 hook 排队后不返回,
  212. Python 端会永远阻塞。这里通过后台 future + `result(timeout=...)` 兜底。
  213. Args:
  214. params (Dict[str, Any]): RPC 参数字典。
  215. Returns:
  216. Dict[str, Any]: JS 侧返回的结果。
  217. Raises:
  218. TimeoutError: 超过 `self.rpc_timeout_sec` 仍未返回;抛出前会尝试
  219. detach session 让底层阻塞线程报错退出,并重建 executor。
  220. """
  221. fut = self._executor.submit(self.script.exports_sync.call, params)
  222. try:
  223. return fut.result(timeout=self.rpc_timeout_sec)
  224. except concurrent.futures.TimeoutError:
  225. self._log(f"python rpc timeout after {self.rpc_timeout_sec}s, detaching session")
  226. # 强断会话,通常会让阻塞的 frida 线程抛异常退出(否则它就常驻卡着)
  227. try:
  228. if self.session is not None:
  229. self.session.detach()
  230. except Exception:
  231. pass
  232. # 旧 executor 内的线程可能还在阻塞,直接丢弃、开新的
  233. try:
  234. self._executor.shutdown(wait=False, cancel_futures=True)
  235. except Exception:
  236. pass
  237. self._executor = concurrent.futures.ThreadPoolExecutor(
  238. max_workers=1, thread_name_prefix="jhs-rpc"
  239. )
  240. raise TimeoutError(
  241. f"frida python rpc call timeout after {self.rpc_timeout_sec}s"
  242. )
  243. def encrypt(self, url: str) -> Dict[str, Any]:
  244. """
  245. 调用 JS RPC 的 encrypt 方法,对请求 URL 进行加密处理。
  246. Args:
  247. url: 原始请求 URL。
  248. Returns:
  249. Dict[str, Any]: JS 侧返回的加密结果字典。
  250. """
  251. return self.script.exports_sync.encrypt(url)
  252. def decrypt(self, request_url_with_raw_data: str, response_raw_data: str) -> Dict[str, Any]:
  253. """
  254. 调用 JS RPC 的 decrypt 方法,对响应中的 raw_data 进行解密。
  255. Args:
  256. request_url_with_raw_data: 包含 raw_data 参数的请求 URL。
  257. response_raw_data: 响应中的加密 raw_data 字符串。
  258. Returns:
  259. Dict[str, Any]: JS 侧返回的解密结果字典。
  260. """
  261. return self.script.exports_sync.decrypt(request_url_with_raw_data, response_raw_data)
  262. def call(self, params: Dict[str, Any]) -> Dict[str, Any]:
  263. """
  264. 统一调用入口,转发到 JS RPC 的 call 方法。
  265. 当 Python 会话环境缺失 Java bridge(如部分 Gadget 场景)时,
  266. 自动降级为 CLI 注入方式调用。
  267. Args:
  268. params: RPC 调用参数字典。
  269. Returns:
  270. Dict[str, Any]: RPC 返回结果。
  271. """
  272. if self._prefer_cli:
  273. self._log("call path: cli(preferred)")
  274. return self._call_via_cli(params)
  275. try:
  276. self._log("call path: python rpc")
  277. return self._sync_rpc_call(params)
  278. except TimeoutError as e:
  279. # 已经在 _sync_rpc_call 内 detach 过;这里直接重连,让上层重试
  280. self._log(f"python rpc timeout, reconnecting session: {e}")
  281. try:
  282. self.reconnect()
  283. except Exception as re:
  284. self._log(f"reconnect after timeout failed: {re}")
  285. raise
  286. except Exception as e:
  287. msg = str(e)
  288. # In some embedded Gadget setups, Python session scripts miss Java bridge.
  289. if "Java is not defined" in msg or "ReferenceError: 'Java' is not defined" in msg:
  290. # Once detected, skip the slow exception path on later calls.
  291. self._prefer_cli = True
  292. self._log("python rpc missing Java bridge, switch to cli fallback")
  293. return self._call_via_cli(params)
  294. if self._is_retryable_frida_error(e):
  295. self._log(f"python rpc failed with retryable frida error: {e}")
  296. self.reconnect()
  297. return self._sync_rpc_call(params)
  298. raise
  299. def _call_via_cli(self, params: Dict[str, Any]) -> Dict[str, Any]:
  300. """
  301. 使用 frida CLI 作为兜底方案执行一次 RPC 调用。
  302. 实现流程:
  303. 1) 拼接临时 JS(注入参数并调用 rpc.exports.call)
  304. 2) 通过 frida 命令行注入目标进程执行
  305. 3) 从标准输出解析约定的结果前缀
  306. Args:
  307. params: RPC 调用参数字典。
  308. Returns:
  309. Dict[str, Any]: RPC 返回结果。
  310. Raises:
  311. RuntimeError: CLI 调用失败或未解析到结果行。
  312. """
  313. js_src = SCRIPT_PATH.read_text(encoding="utf-8")
  314. params_json = json.dumps(params, ensure_ascii=False)
  315. wrapper = (
  316. "const __PARAMS = " + params_json + ";\n"
  317. + js_src
  318. + "\nsetImmediate(function(){\n"
  319. " rpc.exports.call(__PARAMS)\n"
  320. " .then(function(r){ console.log('[CODEC-RESULT-URI]' + encodeURIComponent(JSON.stringify(r))); })\n"
  321. " .catch(function(e){ console.log('[CODEC-ERROR]' + e); });\n"
  322. "});\n"
  323. )
  324. fd, tmp_path = tempfile.mkstemp(prefix="jhs_codec_", suffix=".js")
  325. os.close(fd)
  326. proc = None
  327. try:
  328. Path(tmp_path).write_text(wrapper, encoding="utf-8")
  329. cli_cmd = ["frida"]
  330. if self.device_id:
  331. cli_cmd.extend(["-D", self.device_id])
  332. else:
  333. cli_cmd.append("-U")
  334. cli_cmd.extend(["-N", self.package, "-l", tmp_path, "-q", "-t", str(self.cli_target_sec)])
  335. self._log("spawn cli: " + " ".join(cli_cmd))
  336. proc = subprocess.Popen(
  337. cli_cmd,
  338. stdout=subprocess.PIPE,
  339. stderr=subprocess.PIPE,
  340. text=True,
  341. encoding="utf-8",
  342. errors="replace",
  343. )
  344. deadline_total = max(10, self.cli_target_sec + 8)
  345. deadline = time.time() + deadline_total
  346. out_lines = []
  347. err_lines = []
  348. # 后台线程把 stdout / stderr 读到 queue 里,主循环用 queue.get(timeout=...)
  349. # 拿数据,从根本上避免 readline() 阻塞导致 deadline 失效
  350. io_q: "queue.Queue" = queue.Queue()
  351. def _pump(stream, tag):
  352. try:
  353. for line in iter(stream.readline, ""):
  354. io_q.put((tag, line))
  355. finally:
  356. try:
  357. stream.close()
  358. except Exception:
  359. pass
  360. io_q.put((tag, None)) # EOF 标记
  361. t_out = threading.Thread(target=_pump, args=(proc.stdout, "out"), daemon=True)
  362. t_err = threading.Thread(target=_pump, args=(proc.stderr, "err"), daemon=True)
  363. t_out.start()
  364. t_err.start()
  365. out_eof = False
  366. err_eof = False
  367. while True:
  368. remaining = deadline - time.time()
  369. if remaining <= 0:
  370. break
  371. try:
  372. tag, line = io_q.get(timeout=min(0.5, remaining))
  373. except queue.Empty:
  374. # 进程已结束且两路输出都到 EOF 就退出,否则继续等
  375. if proc.poll() is not None and out_eof and err_eof:
  376. break
  377. continue
  378. if line is None:
  379. if tag == "out":
  380. out_eof = True
  381. else:
  382. err_eof = True
  383. if out_eof and err_eof and proc.poll() is not None:
  384. break
  385. continue
  386. line = line.rstrip("\r\n")
  387. if tag == "err":
  388. err_lines.append(line)
  389. continue
  390. out_lines.append(line)
  391. if line.startswith("[CODEC-RESULT-URI]"):
  392. payload = unquote(line[len("[CODEC-RESULT-URI]"):])
  393. result = json.loads(payload)
  394. self._terminate_proc(proc)
  395. return result
  396. if line.startswith("[CODEC-RESULT]"):
  397. result = json.loads(line[len("[CODEC-RESULT]"):])
  398. self._terminate_proc(proc)
  399. return result
  400. if line.startswith("[CODEC-ERROR]"):
  401. self._terminate_proc(proc)
  402. raise RuntimeError(line)
  403. # 到点未拿到结果:强杀子进程,抛 TimeoutError 让上层重试
  404. self._terminate_proc(proc)
  405. # 抓一下 err 里剩下的(可能已经通过 queue 收了大部分,这里兜底)
  406. drained_out, drained_err = self._drain_queue(io_q)
  407. out_lines.extend(drained_out)
  408. err_lines.extend(drained_err)
  409. out = "\n".join(out_lines)
  410. err = "\n".join(err_lines)
  411. raise TimeoutError(
  412. f"cli codec call timeout after {deadline_total}s, no result line\n"
  413. + "stdout:\n" + out + "\n"
  414. + "stderr:\n" + err
  415. )
  416. finally:
  417. self._terminate_proc(proc)
  418. try:
  419. os.remove(tmp_path)
  420. except Exception:
  421. pass
  422. @staticmethod
  423. def _drain_queue(io_q: "queue.Queue"):
  424. """把 queue 里现存的数据一次性抽干,用于超时后收拾残余输出。
  425. Args:
  426. io_q (queue.Queue): _call_via_cli 中用来收集 stdout/stderr 的队列。
  427. Returns:
  428. tuple[list[str], list[str]]: (stdout 行列表, stderr 行列表)。
  429. """
  430. outs, errs = [], []
  431. while True:
  432. try:
  433. tag, line = io_q.get_nowait()
  434. except queue.Empty:
  435. break
  436. if line is None:
  437. continue
  438. (outs if tag == "out" else errs).append(line.rstrip("\r\n"))
  439. return outs, errs
  440. @staticmethod
  441. def _terminate_proc(proc) -> None:
  442. """尽力结束 frida CLI 子进程,先 terminate 再 kill。
  443. Args:
  444. proc: `subprocess.Popen` 对象,允许为 None。
  445. """
  446. if proc is None:
  447. return
  448. if proc.poll() is not None:
  449. return
  450. try:
  451. proc.terminate()
  452. except Exception:
  453. pass
  454. try:
  455. proc.wait(timeout=2)
  456. except Exception:
  457. try:
  458. proc.kill()
  459. except Exception:
  460. pass
  461. def encrypt_url(url: str, package: str = PKG, device_id: Optional[str] = None) -> Dict[str, Any]:
  462. """
  463. 便捷函数:创建临时客户端,执行 URL 加密并自动释放资源。
  464. Args:
  465. url: 原始请求 URL。
  466. package: 目标应用包名。
  467. device_id: Frida 设备 ID(如 emulator-5554),可不传。
  468. Returns:
  469. Dict[str, Any]: 加密结果字典。
  470. """
  471. with JhsRawCodecClient(package=package, device_id=device_id) as client:
  472. return client.encrypt(url)
  473. def decrypt_raw_data(
  474. request_url: str,
  475. response_raw_data: str,
  476. package: str = PKG,
  477. device_id: Optional[str] = None,
  478. ) -> Dict[str, Any]:
  479. """
  480. 便捷函数:创建临时客户端,执行 raw_data 解密并自动释放资源。
  481. Args:
  482. request_url: 包含 raw_data 参数的请求 URL。
  483. response_raw_data: 响应中的加密 raw_data。
  484. package: 目标应用包名。
  485. device_id: Frida 设备 ID(如 emulator-5554),可不传。
  486. Returns:
  487. Dict[str, Any]: 解密结果字典。
  488. """
  489. with JhsRawCodecClient(package=package, device_id=device_id) as client:
  490. return client.decrypt(request_url, response_raw_data)
  491. def call_codec(
  492. params: Dict[str, Any],
  493. package: str = PKG,
  494. device_id: Optional[str] = None,
  495. ) -> Dict[str, Any]:
  496. """
  497. 对外统一调用入口:根据 params["op"] 执行 enc/dec。
  498. Args:
  499. params: 调用参数字典,支持两种格式:
  500. enc: {"op": "enc", "url": "..."}
  501. dec: {"op": "dec", "request_url": "...", "response_raw_data": "..."}
  502. package: 目标应用包名。
  503. device_id: Frida 设备 ID(如 emulator-5554),可不传。
  504. 也可通过环境变量 FRIDA_DEVICE_ID 指定。
  505. Returns:
  506. Dict[str, Any]: 编解码结果字典。
  507. Raises:
  508. TypeError: 当 params 不是 dict 时抛出。
  509. """
  510. if not isinstance(params, dict):
  511. raise TypeError("params must be a dict")
  512. with JhsRawCodecClient(package=package, device_id=device_id) as client:
  513. return client.call(params)