710 lines
27 KiB
Python
710 lines
27 KiB
Python
# -*- coding: utf-8 -*-
|
||||
|
|
"""
|
|||
|
|
Ananta 注入器(只挂保龄球瓶 + 气球)—— 共享基础设施
|
|||
|
|
|
|||
|
|
★ 名字说明:这一份**不是**「统一加载器 AnantaUnifiedLoader」。
|
|||
|
|
那份是全通道诊断版(A/B/C/D/E/F 都能开),在
|
|||
|
|
C:\\Game\\Ananta_mainleakflow_keitaro_gg\\AnantaUnifiedLoader;
|
|||
|
|
本目录是它的**最小副本**,日常跑服只挂 bowling + balloon 两个模块。
|
|||
|
|
|
|||
|
|
★ 由 AnantaTrainSeatLoader v5.0.0.4 的 loader_common.py 改写。保留它踩过的所有坑:
|
|||
|
|
· 优雅停机三阶段(先让 agent 停手 → 等在途 RPC → 卸脚本)
|
|||
|
|
· 卸载超时**绝不 detach**(半拆的会话会把游戏拖死)
|
|||
|
|
· attach 走子线程 + 超时 detach(attach 本身可能永久阻塞)
|
|||
|
|
· 单次 RPC 失败**不拆会话**,交给主循环打印真实原因 + 退避重连
|
|||
|
|
|
|||
|
|
★ 与单脚本版的唯一结构性差别:**一个 frida 会话里开多个 script**。
|
|||
|
|
为什么必须这样(不是合并成一个大 JS):
|
|||
|
|
- 每个 agent.js 顶层都是 `var Il2Cpp2;` + 一堆 IIFE,合并会撞全局命名空间;
|
|||
|
|
- 更致命的是 world_mod.js / gadget_world.js / train_agent.js 都写
|
|||
|
|
`rpc.exports = { ... }`(**整体覆盖**),合并后只有最后一个模块的导出会活下来;
|
|||
|
|
- 分成多个 script:各自独立 realm,导出互不覆盖,且仍是**同一个会话**
|
|||
|
|
(满足「同一时刻只能有一个 frida 会话」这条硬约束)。
|
|||
|
|
"""
|
|||
|
|
import sys, os, time, json, subprocess, atexit, ctypes, threading, re
|
|||
|
|
|
|||
|
|
for _s in (sys.stdout, sys.stderr):
|
|||
|
|
try:
|
|||
|
|
_s.reconfigure(encoding="utf-8", errors="replace")
|
|||
|
|
except Exception:
|
|||
|
|
pass
|
|||
|
|
|
|||
|
|
_builtin_print = print
|
|||
|
|
|
|||
|
|
|
|||
|
|
def print(*a, **k):
|
|||
|
|
k["flush"] = True
|
|||
|
|
try:
|
|||
|
|
_builtin_print(*a, **k)
|
|||
|
|
except Exception:
|
|||
|
|
pass
|
|||
|
|
|
|||
|
|
|
|||
|
|
STOP = {"ask": False, "done": False}
|
|||
|
|
|
|||
|
|
RPC_TIMEOUT = 25.0
|
|||
|
|
ATTACH_TIMEOUT = 60.0
|
|||
|
|
|
|||
|
|
# ---- 超时治理(2026-09-29)----
|
|||
|
|
# ⛑ frida 的同步 RPC 一旦卡住,Python 侧**没有任何办法**让那个调用返回:
|
|||
|
|
# t.join(timeout) 只是「放弃等待」,线程仍卡在 _frida 的阻塞调用里,
|
|||
|
|
# daemon 线程要到进程退出才回收 ⇒ 主循环每超时一次就永久多一个线程。
|
|||
|
|
# 治理靠三层,见 Mod._run_guarded 的注释。
|
|||
|
|
_STUCK_LOCK = threading.Lock()
|
|||
|
|
_STUCK_THREADS = [] # 只保留**还活着**的卡住线程(每次统计前先清理)
|
|||
|
|
# 同一个 script 连续超时这么多次 ⇒ 判定该 script 彻底废了,快速失败不再堆新调用
|
|||
|
|
STUCK_MAX_CONSECUTIVE = 3
|
|||
|
|
|
|||
|
|
|
|||
|
|
def stuck_thread_count():
|
|||
|
|
"""当前卡在 frida 同步调用里、join 不回来的线程数(供上层观测/自检)。"""
|
|||
|
|
with _STUCK_LOCK:
|
|||
|
|
_STUCK_THREADS[:] = [x for x in _STUCK_THREADS if x.is_alive()]
|
|||
|
|
return len(_STUCK_THREADS)
|
|||
|
|
|
|||
|
|
# 这些是「别的独立加载器 exe」。本统一加载器一开,它们就必须全部关闭,
|
|||
|
|
# 否则同一个游戏进程上会有两个 frida 会话 —— 实测会把游戏带崩。
|
|||
|
|
OTHER_EXES = [
|
|||
|
|
"AnantaGymLoader.exe",
|
|||
|
|
"AnantaTrainSeatLoader.exe",
|
|||
|
|
"AnantaBowlingLoader.exe",
|
|||
|
|
"AnantaBalloonLoader.exe",
|
|||
|
|
"AnantaGadgetLoader.exe",
|
|||
|
|
"AnantaWorldMod.exe",
|
|||
|
|
"AnantaPropScanner.exe",
|
|||
|
|
]
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _install_ctrl_handler():
|
|||
|
|
if os.name != "nt":
|
|||
|
|
return False
|
|||
|
|
try:
|
|||
|
|
k32 = ctypes.windll.kernel32
|
|||
|
|
PROT = ctypes.WINFUNCTYPE(ctypes.c_bool, ctypes.c_uint)
|
|||
|
|
|
|||
|
|
def handler(ev):
|
|||
|
|
if ev not in (0, 1, 2, 5, 6):
|
|||
|
|
return False
|
|||
|
|
STOP["ask"] = True
|
|||
|
|
try:
|
|||
|
|
_atexit_close()
|
|||
|
|
except Exception:
|
|||
|
|
pass
|
|||
|
|
for _ in range(280):
|
|||
|
|
if STOP["done"]:
|
|||
|
|
break
|
|||
|
|
k32.Sleep(50)
|
|||
|
|
return True
|
|||
|
|
|
|||
|
|
cb = PROT(handler)
|
|||
|
|
handler._cb = cb
|
|||
|
|
k32.SetConsoleCtrlHandler(cb, True)
|
|||
|
|
return True
|
|||
|
|
except Exception:
|
|||
|
|
return False
|
|||
|
|
|
|||
|
|
|
|||
|
|
HAS_CTRL = _install_ctrl_handler()
|
|||
|
|
|
|||
|
|
|
|||
|
|
def sleep_slice(sec):
|
|||
|
|
end = time.time() + sec
|
|||
|
|
while time.time() < end:
|
|||
|
|
if STOP["ask"]:
|
|||
|
|
return
|
|||
|
|
time.sleep(min(0.2, max(0.0, end - time.time())))
|
|||
|
|
|
|||
|
|
|
|||
|
|
def ts():
|
|||
|
|
return time.strftime("%H:%M:%S")
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _tasklist_rows():
|
|||
|
|
"""全量 tasklist 一次,返回 [(进程名小写, pid)]。
|
|||
|
|
|
|||
|
|
⛑ 为什么不用 `tasklist /FI "IMAGENAME eq xxx"`:
|
|||
|
|
实测(2026-09-28)游戏是提权启动的,`/FI` 过滤会直接回
|
|||
|
|
「没有运行的任务匹配指定标准」,而**不带 filter 的全量列表里明明有**。
|
|||
|
|
所以一律走全量解析,filter 那条路不可信。
|
|||
|
|
"""
|
|||
|
|
rows = []
|
|||
|
|
try:
|
|||
|
|
out = subprocess.check_output(
|
|||
|
|
'tasklist /FO CSV /NH', shell=True,
|
|||
|
|
text=True, encoding="mbcs", errors="replace")
|
|||
|
|
except Exception:
|
|||
|
|
return rows
|
|||
|
|
for line in out.splitlines():
|
|||
|
|
parts = [p.strip().strip('"') for p in line.split('","')]
|
|||
|
|
if len(parts) < 2:
|
|||
|
|
continue
|
|||
|
|
try:
|
|||
|
|
rows.append((parts[0].lower(), int(parts[1].replace(",", ""))))
|
|||
|
|
except ValueError:
|
|||
|
|
continue
|
|||
|
|
return rows
|
|||
|
|
|
|||
|
|
|
|||
|
|
def resolve_pid():
|
|||
|
|
"""找 Ananta.exe 的 pid:全量 tasklist → frida 枚举(两道保险)。"""
|
|||
|
|
for name, pid in _tasklist_rows():
|
|||
|
|
if name in ("ananta.exe", "ananta"):
|
|||
|
|
return pid
|
|||
|
|
# 回退:连 tasklist 都看不到时(更严格的权限隔离),用 frida 自己枚举
|
|||
|
|
try:
|
|||
|
|
import frida
|
|||
|
|
dev = frida.get_local_device()
|
|||
|
|
for p in dev.enumerate_processes():
|
|||
|
|
if p.name.lower() in ("ananta.exe", "ananta"):
|
|||
|
|
return p.pid
|
|||
|
|
except Exception:
|
|||
|
|
pass
|
|||
|
|
return None
|
|||
|
|
|
|||
|
|
|
|||
|
|
def count_exe(name):
|
|||
|
|
n = name.lower()
|
|||
|
|
return len([1 for nm, _ in _tasklist_rows() if nm == n])
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _pid_alive_win(pid):
|
|||
|
|
"""OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION)。
|
|||
|
|
|
|||
|
|
⛑ 为什么不用 tasklist 做唯一判据:
|
|||
|
|
tasklist 在某些环境(受限 shell / 沙箱)会返回**空列表**,
|
|||
|
|
那时 pid_alive 恒 False ⇒ 互斥锁形同虚设(两份加载器照样起得来)。
|
|||
|
|
PROCESS_QUERY_LIMITED_INFORMATION(0x1000) 自 Vista 起对**任何**进程都放行
|
|||
|
|
(含提权进程、含别的会话),只要 pid 存在就拿得到句柄 —— 是最可靠的存在性判据。
|
|||
|
|
"""
|
|||
|
|
try:
|
|||
|
|
k32 = ctypes.windll.kernel32
|
|||
|
|
k32.OpenProcess.restype = ctypes.c_void_p
|
|||
|
|
k32.OpenProcess.argtypes = [ctypes.c_uint32, ctypes.c_bool, ctypes.c_uint32]
|
|||
|
|
h = k32.OpenProcess(0x1000, False, int(pid))
|
|||
|
|
if not h:
|
|||
|
|
return False
|
|||
|
|
try:
|
|||
|
|
# ⛑ 光拿到句柄**不能**算活着:只要还有人握着该进程的句柄,
|
|||
|
|
# 进程对象就不会销毁,OpenProcess 照样成功(实测踩到)。
|
|||
|
|
# 退出码 != STILL_ACTIVE(259) ⇒ 它已经死了。
|
|||
|
|
code = ctypes.c_uint32()
|
|||
|
|
ok = k32.GetExitCodeProcess(ctypes.c_void_p(h), ctypes.byref(code))
|
|||
|
|
return bool(ok) and code.value == 259
|
|||
|
|
finally:
|
|||
|
|
k32.CloseHandle(ctypes.c_void_p(h))
|
|||
|
|
except Exception:
|
|||
|
|
pass
|
|||
|
|
return False
|
|||
|
|
|
|||
|
|
|
|||
|
|
def pid_alive(pid):
|
|||
|
|
try:
|
|||
|
|
pid = int(pid)
|
|||
|
|
except Exception:
|
|||
|
|
return False
|
|||
|
|
if _pid_alive_win(pid):
|
|||
|
|
return True
|
|||
|
|
for _, p in _tasklist_rows(): # 兜底:万一 OpenProcess 被拦
|
|||
|
|
if p == pid:
|
|||
|
|
return True
|
|||
|
|
return False
|
|||
|
|
|
|||
|
|
|
|||
|
|
# 常驻加载器的互斥锁。⛑ 为什么必须有(2026-09-29):
|
|||
|
|
# 两个 frida 会话同时挂在 Ananta.exe 上 = 游戏必崩(历史踩过 3 次)。
|
|||
|
|
# 以前加载器是手动开的,人不容易开两份;现在改成「一键启动」随游戏自动起,
|
|||
|
|
# 重复双击的概率大幅上升,所以必须由程序自己挡住。
|
|||
|
|
# ⛑ 锁文件里存 pid 而不是单纯「文件存在」—— 崩溃退出会留下僵尸锁,
|
|||
|
|
# 只判存在会永久卡死启动,必须校验 pid 是否真的活着。
|
|||
|
|
LOCK_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), ".loader.lock")
|
|||
|
|
|
|||
|
|
|
|||
|
|
def acquire_instance_lock():
|
|||
|
|
"""拿常驻锁。返回 None = 拿到;返回占用者 pid = 已经有一份在跑,调用方应立刻退出。"""
|
|||
|
|
try:
|
|||
|
|
if os.path.exists(LOCK_PATH):
|
|||
|
|
try:
|
|||
|
|
old = int((open(LOCK_PATH, encoding="utf-8").read() or "0").strip() or 0)
|
|||
|
|
except Exception:
|
|||
|
|
old = 0
|
|||
|
|
if old and pid_alive(old) and old != os.getpid():
|
|||
|
|
return old
|
|||
|
|
except Exception:
|
|||
|
|
pass
|
|||
|
|
try:
|
|||
|
|
with open(LOCK_PATH, "w", encoding="utf-8") as fh:
|
|||
|
|
fh.write(str(os.getpid()))
|
|||
|
|
except Exception:
|
|||
|
|
pass
|
|||
|
|
atexit.register(release_instance_lock)
|
|||
|
|
return None
|
|||
|
|
|
|||
|
|
|
|||
|
|
def release_instance_lock():
|
|||
|
|
"""只删**自己**写的锁,别把别人的删了。"""
|
|||
|
|
try:
|
|||
|
|
if not os.path.exists(LOCK_PATH):
|
|||
|
|
return
|
|||
|
|
try:
|
|||
|
|
cur = int((open(LOCK_PATH, encoding="utf-8").read() or "0").strip() or 0)
|
|||
|
|
except Exception:
|
|||
|
|
cur = 0
|
|||
|
|
if cur == os.getpid():
|
|||
|
|
os.remove(LOCK_PATH)
|
|||
|
|
except Exception:
|
|||
|
|
pass
|
|||
|
|
|
|||
|
|
|
|||
|
|
def other_loader_running():
|
|||
|
|
"""有没有别的加载器在跑?返回名字,没有返回 None。必须**每一轮**复查。"""
|
|||
|
|
for e in OTHER_EXES:
|
|||
|
|
if count_exe(e) > 0:
|
|||
|
|
return e
|
|||
|
|
return None
|
|||
|
|
|
|||
|
|
|
|||
|
|
def apply_placeholders(src, root):
|
|||
|
|
"""把 agent JS 里「由 Python 端注入」的占位符替换掉。
|
|||
|
|
|
|||
|
|
⛑ 2026-09-28 才发现的大坑:`modules/world_mod.js:3376` 是
|
|||
|
|
var CELLS_RAW = __CELLS_JSON__;
|
|||
|
|
这是一个**原始占位符**,原版靠 `new-worldmod/inject.py` 在注入前
|
|||
|
|
`src.replace("__CELLS_JSON__", json.dumps(cells))` 换掉。
|
|||
|
|
我们这里是直接读文件 ⇒ 未定义的标识符 ⇒ 顶层 ReferenceError
|
|||
|
|
⇒ **整个脚本加载即死**,连它自己的 `Il2Cpp.perform(...)` 引导都跑不到。
|
|||
|
|
实测:所有历史会话里 `[worldmod]` 的 console 输出 **0 次** —— 通道 B 一直是死的。
|
|||
|
|
"""
|
|||
|
|
if "__CELLS_JSON__" not in src:
|
|||
|
|
return src
|
|||
|
|
p = os.path.join(root, "cells.json")
|
|||
|
|
if not os.path.exists(p):
|
|||
|
|
raise RuntimeError("JS 需要 __CELLS_JSON__,但找不到 %s" % p)
|
|||
|
|
data = json.loads(open(p, encoding="utf-8").read())
|
|||
|
|
rows = data.get("cells", data if isinstance(data, list) else [])
|
|||
|
|
if not rows:
|
|||
|
|
raise RuntimeError("%s 里没有 cells" % p)
|
|||
|
|
payload = json.dumps(rows, ensure_ascii=False, separators=(",", ":"))
|
|||
|
|
return src.replace("__CELLS_JSON__", payload)
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ★★ 气球 side-channel 路径(2026-09-29 修)
|
|||
|
|
#
|
|||
|
|
# 病根:`modules/balloon_fix.js:3417` 的默认值是作者自己开发机上的硬编码
|
|||
|
|
# var PUSH_PATHS = ['D:\\Ananta\\gym_tools\\_balloon_push.json'];
|
|||
|
|
# 该文件顶部 R146 注释写得很清楚:**「exe 加载器会把这一行整个改写成运行期算出来的
|
|||
|
|
# 本机多路径」**。原版 `AnantaBalloonLoader.exe` 确实这么干(实机日志里有
|
|||
|
|
# `agent PUSH_PATHS -> ["C:\\Users\\…\\AnantaBalloon\\balloon_push.json", …]`),
|
|||
|
|
# 而我们这里是把 js 原样读进来 ⇒ 没有重写 ⇒ agent 永远去读 D:\Ananta\…
|
|||
|
|
# ⇒ pushN 恒 0 ⇒ 一颗气球都不会被伪造出来。
|
|||
|
|
#
|
|||
|
|
# 服务端(GameRouter.Balloon.cs:1140-1159)按顺序写这三条,这里按**同一顺序**给候选:
|
|||
|
|
# ① %LOCALAPPDATA%\AnantaBalloon\balloon_push.json ← 与安装位置无关,主用
|
|||
|
|
# ② <服务端 base 目录>\config\balloon_push.json
|
|||
|
|
# ③ D:\Ananta\gym_tools\_balloon_push.json ← 作者旧位置,兜底
|
|||
|
|
_BALLOON_PUSH_RE = re.compile(r"var\s+PUSH_PATHS\s*=\s*\[[^\]]*\]\s*;")
|
|||
|
|
|
|||
|
|
|
|||
|
|
def apply_balloon_paths(src, root):
|
|||
|
|
"""把 balloon_fix.js 里写死的 PUSH_PATHS 换成本机真实候选列表。"""
|
|||
|
|
if "PUSH_PATHS" not in src:
|
|||
|
|
return src
|
|||
|
|
shared = os.path.join(
|
|||
|
|
os.environ.get("LOCALAPPDATA") or "", "AnantaBalloon", "balloon_push.json")
|
|||
|
|
cands = []
|
|||
|
|
for p in (
|
|||
|
|
shared,
|
|||
|
|
os.path.join(root, "config", "balloon_push.json"),
|
|||
|
|
os.path.join(root, "Drmk.Server", "Drmk.App", "bin", "Debug", "net8.0",
|
|||
|
|
"config", "balloon_push.json"),
|
|||
|
|
r"D:\Ananta\gym_tools\_balloon_push.json",
|
|||
|
|
):
|
|||
|
|
if not p:
|
|||
|
|
continue
|
|||
|
|
# 统一成反斜杠(Windows File API 两种都吃,但 JS 字符串里反斜杠是转义符,
|
|||
|
|
# 必须成对出现 —— 下面 _js_str 会再翻倍一次)
|
|||
|
|
p = os.path.normpath(p).replace("/", "\\")
|
|||
|
|
if p not in cands:
|
|||
|
|
cands.append(p)
|
|||
|
|
|
|||
|
|
def _js_str(s):
|
|||
|
|
# ⛑ 不能用 json.dumps 直接塞:`"C:\Users\..."` 里的 \U / \b 会被 JS 当转义吃
|
|||
|
|
# 掉(实测 node 打印成 C:UsersNoiro...)。反斜杠必须翻倍。
|
|||
|
|
return '"' + s.replace("\\", "\\\\") + '"'
|
|||
|
|
|
|||
|
|
arr = "[" + ", ".join(_js_str(p) for p in cands) + "]"
|
|||
|
|
# ⛑⛑ 替换值**必须用函数**给:re.subn 的字符串 repl 会再做一次反斜杠转义处理,
|
|||
|
|
# 把 `C:\\Users` 压回 `C:\Users`(实测踩到,JS 侧就又变成转义符了)。
|
|||
|
|
new, n = _BALLOON_PUSH_RE.subn(lambda _m: "var PUSH_PATHS = %s;" % arr, src, count=1)
|
|||
|
|
if n == 0:
|
|||
|
|
return src
|
|||
|
|
return new
|
|||
|
|
|
|||
|
|
|
|||
|
|
class Mod(object):
|
|||
|
|
"""一个模块 = 一个 frida script。"""
|
|||
|
|
|
|||
|
|
def __init__(self, name, path):
|
|||
|
|
self.name = name
|
|||
|
|
self.path = path
|
|||
|
|
self.script = None
|
|||
|
|
self.ready = False
|
|||
|
|
self.note = ""
|
|||
|
|
self.dead = False # 客户端崩了/重启后,旧 script 会永久失效
|
|||
|
|
self.timeouts = 0 # 本 script 连续超时次数(load() 成功时清零)
|
|||
|
|
|
|||
|
|
def load(self, session, quiet=True):
|
|||
|
|
try:
|
|||
|
|
src = open(self.path, encoding="utf-8", errors="replace").read()
|
|||
|
|
except Exception as e:
|
|||
|
|
self.note = "读文件失败: %s" % e
|
|||
|
|
return False
|
|||
|
|
# ★ 占位符替换(__CELLS_JSON__ 等)—— 见 apply_placeholders 的说明
|
|||
|
|
try:
|
|||
|
|
src = apply_placeholders(
|
|||
|
|
src, os.path.dirname(os.path.dirname(os.path.abspath(self.path))))
|
|||
|
|
except Exception as e:
|
|||
|
|
self.note = "占位符替换失败: %s" % str(e)[:160]
|
|||
|
|
return False
|
|||
|
|
# ★ 气球 side-channel:js 里的 PUSH_PATHS 是作者机器的硬编码,必须按本机改写
|
|||
|
|
# root = 服务端根目录(…/DRMK-PS_09_HyNB-v0.3):
|
|||
|
|
# <root>/tools/AnantaInjector/modules/balloon_fix.js → 往上 4 层
|
|||
|
|
try:
|
|||
|
|
src = apply_balloon_paths(
|
|||
|
|
src, os.path.dirname(os.path.dirname(os.path.dirname(
|
|||
|
|
os.path.dirname(os.path.abspath(self.path))))))
|
|||
|
|
except Exception as e:
|
|||
|
|
self.note = "气球路径改写失败: %s" % str(e)[:160]
|
|||
|
|
return False
|
|||
|
|
try:
|
|||
|
|
# runtime="v8" 是原加载器的写法(frida 17.18 仍接受该参数)。
|
|||
|
|
# 万一某个 frida 版本把它拿掉了,退回默认运行时——别因为这一个参数整个模块挂掉。
|
|||
|
|
try:
|
|||
|
|
sc = session.create_script(src, runtime="v8")
|
|||
|
|
except TypeError:
|
|||
|
|
sc = session.create_script(src)
|
|||
|
|
except Exception as e:
|
|||
|
|
self.note = "create_script 失败: %s" % str(e)[:120]
|
|||
|
|
return False
|
|||
|
|
|
|||
|
|
box = {"ready": False}
|
|||
|
|
|
|||
|
|
def on_message(msg, data):
|
|||
|
|
try:
|
|||
|
|
if msg.get("type") == "send":
|
|||
|
|
p = msg.get("payload")
|
|||
|
|
if isinstance(p, str):
|
|||
|
|
try:
|
|||
|
|
p = json.loads(p)
|
|||
|
|
except Exception:
|
|||
|
|
pass
|
|||
|
|
if isinstance(p, dict) and (p.get("ready") or p.get("abReady")):
|
|||
|
|
box["ready"] = True
|
|||
|
|
elif not quiet:
|
|||
|
|
print(" [%s] %s" % (self.name, str(p)[:200]))
|
|||
|
|
except Exception:
|
|||
|
|
pass
|
|||
|
|
try:
|
|||
|
|
if msg.get("type") == "error":
|
|||
|
|
self.note = "script error: %s" % str(msg.get("description"))[:160]
|
|||
|
|
if not quiet:
|
|||
|
|
print(" [!] %s 脚本报错: %s" % (self.name, self.note))
|
|||
|
|
except Exception:
|
|||
|
|
pass
|
|||
|
|
|
|||
|
|
try:
|
|||
|
|
sc.on("message", on_message)
|
|||
|
|
sc.load()
|
|||
|
|
except Exception as e:
|
|||
|
|
self.note = "load 失败: %s" % str(e)[:160]
|
|||
|
|
return False
|
|||
|
|
|
|||
|
|
# 有些模块不发 ready,给 3 秒宽限后也放行(它们可能只是没实现握手)
|
|||
|
|
for _ in range(30):
|
|||
|
|
if box["ready"]:
|
|||
|
|
break
|
|||
|
|
time.sleep(0.1)
|
|||
|
|
self.script = sc
|
|||
|
|
self.ready = True
|
|||
|
|
self.dead = False # 重新加载 = 新 script,旧失效标记要清掉
|
|||
|
|
self.timeouts = 0 # 同上:连续超时计数也归零
|
|||
|
|
return True
|
|||
|
|
|
|||
|
|
def call3(self, name, arg=None, arg2=None, arg3=None, timeout=RPC_TIMEOUT):
|
|||
|
|
"""三参 RPC(如 gadget_loader 的 findobj <关键字> <maxNodes> <maxDepth>)。
|
|||
|
|
|
|||
|
|
不想把 arg2/arg3 混进 call() 的可选语义里(None 与「不传」会分不清),
|
|||
|
|
所以单独开一个方法,语义明确。
|
|||
|
|
"""
|
|||
|
|
if self.script is None:
|
|||
|
|
raise RuntimeError("模块 %s 未加载" % self.name)
|
|||
|
|
E = self.script.exports_sync
|
|||
|
|
fn = getattr(E, name)
|
|||
|
|
box = {}
|
|||
|
|
|
|||
|
|
def run():
|
|||
|
|
try:
|
|||
|
|
box["r"] = fn(arg, arg2, arg3)
|
|||
|
|
except Exception as e:
|
|||
|
|
box["e"] = e
|
|||
|
|
|
|||
|
|
return self._run_guarded(run, box, name, timeout)
|
|||
|
|
|
|||
|
|
def _run_guarded(self, run, box, name, timeout):
|
|||
|
|
"""起线程跑 RPC + 收尾:超时判定 + 识别「客户端崩了」。
|
|||
|
|
|
|||
|
|
⛑ 客户端崩掉/重启后旧 script 会变成 'script has been destroyed',
|
|||
|
|
不识别的话上层会一直假死在旧 session 上。
|
|||
|
|
⛑ 线程在这里起(不在调用方),免得哪天又漏掉 t 的定义。
|
|||
|
|
|
|||
|
|
⛑⛑ 2026-09-29 修「超时线程泄漏」(原实现只 join、不回收):
|
|||
|
|
Python 杀不掉线程,t.join(timeout) 超时后那个调用**仍然卡在 _frida 里**
|
|||
|
|
⇒ 主循环每超时一次就永久多一个线程,跑久了线程数单调增长。
|
|||
|
|
三层治理,从强到弱:
|
|||
|
|
① **best-effort 真取消**:子线程里挂 frida.Cancellable(),超时后 cancel.cancel()。
|
|||
|
|
只对 frida 内部会检查 cancellable 的同步调用生效(attach/load 这类稳生效,
|
|||
|
|
exports_sync 的 RPC 不一定);**不生效也无害**,纯增强。
|
|||
|
|
② **超时即判该 script 已废**:置 self.dead ⇒ 上层 `ctx.dead` 为真 ⇒ close()
|
|||
|
|
→ script.unload() → 卡在等待中的 RPC 抛错 → 线程自然退出。
|
|||
|
|
**这才是真正的回收路径**,也是原来漏掉的一环(原来超时不置 dead,
|
|||
|
|
于是下一轮继续往同一个废 script 上发调用,每次再留一个线程)。
|
|||
|
|
⛑ 本副本还有 armedGuard 兜底(bfStatus 连续 60s 拿不到就主动重挂),
|
|||
|
|
不置 dead 也不会无限假死 —— 但要白等 60 秒,且这 60 秒里每次探测都再留
|
|||
|
|
一个线程。置 dead 之后走 `unified_loader.py` 的 `ctx.dead` 分支,**立刻**重挂。
|
|||
|
|
③ **快速失败**:连续超时达 STUCK_MAX_CONSECUTIVE ⇒ 抛错让上层重建,
|
|||
|
|
不再往废 script 上堆新调用。
|
|||
|
|
⛑ 日志按规则 110 —— 不打「只打前 N 条」,每次超时都打(量本来就小)。
|
|||
|
|
"""
|
|||
|
|
state = {}
|
|||
|
|
|
|||
|
|
def runner():
|
|||
|
|
# cancellable 是线程局部的,必须在**调用线程**里 push 才有效。
|
|||
|
|
cancel = None
|
|||
|
|
try:
|
|||
|
|
import frida as _frida
|
|||
|
|
cancel = _frida.Cancellable()
|
|||
|
|
except Exception:
|
|||
|
|
cancel = None
|
|||
|
|
if cancel is None:
|
|||
|
|
run()
|
|||
|
|
return
|
|||
|
|
state["cancel"] = cancel
|
|||
|
|
try:
|
|||
|
|
with cancel:
|
|||
|
|
run()
|
|||
|
|
except Exception as e:
|
|||
|
|
if "e" not in box:
|
|||
|
|
box["e"] = e
|
|||
|
|
|
|||
|
|
t = threading.Thread(target=runner)
|
|||
|
|
t.daemon = True
|
|||
|
|
with _STUCK_LOCK:
|
|||
|
|
_STUCK_THREADS.append(t)
|
|||
|
|
t.start()
|
|||
|
|
t.join(timeout)
|
|||
|
|
|
|||
|
|
if t.is_alive():
|
|||
|
|
cancel = state.get("cancel")
|
|||
|
|
if cancel is not None:
|
|||
|
|
try:
|
|||
|
|
cancel.cancel()
|
|||
|
|
except Exception:
|
|||
|
|
pass
|
|||
|
|
self.timeouts += 1
|
|||
|
|
self.dead = True # ② 交给上层 close() 回收
|
|||
|
|
n = stuck_thread_count()
|
|||
|
|
print("[%s] [!] rpc %s.%s 超时(>%ss)—— 判定该 script 已废,上层会重建;"
|
|||
|
|
"卡住线程=%d 连续超时=%d" % (ts(), self.name, name, timeout, n, self.timeouts))
|
|||
|
|
if self.timeouts >= STUCK_MAX_CONSECUTIVE:
|
|||
|
|
raise RuntimeError("rpc %s.%s 连续超时 %d 次,放弃该 script,等待重建"
|
|||
|
|
% (self.name, name, self.timeouts))
|
|||
|
|
raise RuntimeError("rpc %s.%s 超时(>%ss)" % (self.name, name, timeout))
|
|||
|
|
|
|||
|
|
# 正常返回:线程已结束,从账本里摘掉
|
|||
|
|
self.timeouts = 0
|
|||
|
|
with _STUCK_LOCK:
|
|||
|
|
if t in _STUCK_THREADS:
|
|||
|
|
_STUCK_THREADS.remove(t)
|
|||
|
|
if "e" in box:
|
|||
|
|
msg = str(box["e"])
|
|||
|
|
if ("has been destroyed" in msg or "is detached" in msg
|
|||
|
|
or "invalid session" in msg or "Session is detached" in msg):
|
|||
|
|
self.dead = True
|
|||
|
|
raise RuntimeError("rpc %s.%s 失败: %s" % (self.name, name, msg[:160]))
|
|||
|
|
return box.get("r")
|
|||
|
|
|
|||
|
|
def call(self, name, arg=None, arg2=None, timeout=RPC_TIMEOUT):
|
|||
|
|
"""arg2 用于双参 RPC(如 addbtn 的 abTick(半径, 批量))。"""
|
|||
|
|
if self.script is None:
|
|||
|
|
raise RuntimeError("模块 %s 未加载" % self.name)
|
|||
|
|
E = self.script.exports_sync
|
|||
|
|
fn = getattr(E, name)
|
|||
|
|
box = {}
|
|||
|
|
|
|||
|
|
def run():
|
|||
|
|
try:
|
|||
|
|
if arg2 is not None:
|
|||
|
|
box["r"] = fn(arg, arg2)
|
|||
|
|
elif arg is not None:
|
|||
|
|
box["r"] = fn(arg)
|
|||
|
|
else:
|
|||
|
|
box["r"] = fn()
|
|||
|
|
except Exception as e:
|
|||
|
|
box["e"] = e
|
|||
|
|
|
|||
|
|
return self._run_guarded(run, box, name, timeout)
|
|||
|
|
|
|||
|
|
|
|||
|
|
class Ctx(object):
|
|||
|
|
def __init__(self, modules):
|
|||
|
|
self.modules = modules # [Mod]
|
|||
|
|
self.session = None
|
|||
|
|
self.pid = None
|
|||
|
|
self.closing = False
|
|||
|
|
self.fail = 0
|
|||
|
|
|
|||
|
|
def attach(self):
|
|||
|
|
box = {}
|
|||
|
|
|
|||
|
|
def run():
|
|||
|
|
try:
|
|||
|
|
box["r"] = self._attach_impl(box)
|
|||
|
|
except Exception as e:
|
|||
|
|
box["e"] = e
|
|||
|
|
|
|||
|
|
t = threading.Thread(target=run)
|
|||
|
|
t.daemon = True
|
|||
|
|
t.start()
|
|||
|
|
t.join(ATTACH_TIMEOUT)
|
|||
|
|
if t.is_alive():
|
|||
|
|
sess = box.get("sess")
|
|||
|
|
if sess is not None:
|
|||
|
|
def _kill():
|
|||
|
|
try:
|
|||
|
|
sess.detach()
|
|||
|
|
except Exception:
|
|||
|
|
pass
|
|||
|
|
k = threading.Thread(target=_kill)
|
|||
|
|
k.daemon = True
|
|||
|
|
k.start()
|
|||
|
|
k.join(6)
|
|||
|
|
return False
|
|||
|
|
if "e" in box:
|
|||
|
|
print("[%s] attach 异常:%s" % (ts(), str(box["e"])[:200]))
|
|||
|
|
return False
|
|||
|
|
return box.get("r", False)
|
|||
|
|
|
|||
|
|
def _attach_impl(self, box=None):
|
|||
|
|
import frida
|
|||
|
|
pid = resolve_pid()
|
|||
|
|
if pid is None:
|
|||
|
|
return False
|
|||
|
|
self.pid = pid
|
|||
|
|
session = frida.attach(pid)
|
|||
|
|
if box is not None:
|
|||
|
|
box["sess"] = session
|
|||
|
|
ok = 0
|
|||
|
|
for m in self.modules:
|
|||
|
|
if m.load(session):
|
|||
|
|
ok += 1
|
|||
|
|
print("[%s] ✓ %s" % (ts(), m.name))
|
|||
|
|
else:
|
|||
|
|
print("[%s] ✗ %s —— %s" % (ts(), m.name, m.note or "未知原因"))
|
|||
|
|
if ok == 0:
|
|||
|
|
try:
|
|||
|
|
session.detach()
|
|||
|
|
except Exception:
|
|||
|
|
pass
|
|||
|
|
return False
|
|||
|
|
self.session = session
|
|||
|
|
self.closing = False
|
|||
|
|
self.fail = 0
|
|||
|
|
return True
|
|||
|
|
|
|||
|
|
@property
|
|||
|
|
def dead(self):
|
|||
|
|
"""客户端崩了/重启 ⇒ 任一模块的 script 失效,或 session 已断。"""
|
|||
|
|
if self.session is None:
|
|||
|
|
return True
|
|||
|
|
for m in self.modules:
|
|||
|
|
if m.script is not None and m.dead:
|
|||
|
|
return True
|
|||
|
|
return False
|
|||
|
|
|
|||
|
|
def mod(self, name):
|
|||
|
|
for m in self.modules:
|
|||
|
|
if m.name == name and m.script is not None:
|
|||
|
|
return m
|
|||
|
|
return None
|
|||
|
|
|
|||
|
|
def close(self):
|
|||
|
|
"""优雅停机:① 请各 agent 停手 ② 卸脚本 ③ detach。卸载超时绝不 detach。"""
|
|||
|
|
if self.closing or self.session is None:
|
|||
|
|
STOP["done"] = True
|
|||
|
|
return
|
|||
|
|
self.closing = True
|
|||
|
|
t0 = time.time()
|
|||
|
|
|
|||
|
|
for m in self.modules:
|
|||
|
|
# ⛑ 2026-09-29:dead 的 script 再发 stop 只会再超时一次、再留一个卡住的线程
|
|||
|
|
# (它的 RPC 通道已经废了,发什么都是白发)⇒ 直接跳过,交给 unload 回收。
|
|||
|
|
if m.script is None or m.dead:
|
|||
|
|
continue
|
|||
|
|
# "stop" 是 AnantaGadgetLoader 的停机 RPC(gadget_world.py 的 finally 里调它)
|
|||
|
|
for stop_name in ("shutdown", "bowlShutdown", "abShutdown", "stop"):
|
|||
|
|
try:
|
|||
|
|
m.call(stop_name, timeout=4)
|
|||
|
|
break
|
|||
|
|
except Exception:
|
|||
|
|
continue
|
|||
|
|
|
|||
|
|
time.sleep(0.3)
|
|||
|
|
sess = self.session
|
|||
|
|
scrs = [m.script for m in self.modules if m.script is not None]
|
|||
|
|
self.session = None
|
|||
|
|
for m in self.modules:
|
|||
|
|
m.script = None
|
|||
|
|
|
|||
|
|
def _unload():
|
|||
|
|
for sc in scrs:
|
|||
|
|
try:
|
|||
|
|
sc.unload()
|
|||
|
|
except Exception:
|
|||
|
|
pass
|
|||
|
|
|
|||
|
|
t1 = threading.Thread(target=_unload)
|
|||
|
|
t1.daemon = True
|
|||
|
|
t1.start()
|
|||
|
|
t1.join(8.0)
|
|||
|
|
if t1.is_alive():
|
|||
|
|
print("[!] 脚本卸载没在 8 秒内完成 —— 已跳过强制断开(避免把游戏拖死)。")
|
|||
|
|
print(" 各 agent 已停机、不再做任何事;重启游戏即可彻底清掉。")
|
|||
|
|
STOP["done"] = True
|
|||
|
|
return
|
|||
|
|
|
|||
|
|
def _det():
|
|||
|
|
try:
|
|||
|
|
if sess:
|
|||
|
|
sess.detach()
|
|||
|
|
except Exception:
|
|||
|
|
pass
|
|||
|
|
|
|||
|
|
t2 = threading.Thread(target=_det)
|
|||
|
|
t2.daemon = True
|
|||
|
|
t2.start()
|
|||
|
|
t2.join(3.0)
|
|||
|
|
print("[%s] 注入已安全卸载,用时 %.1fs" % (ts(), time.time() - t0))
|
|||
|
|
STOP["done"] = True
|
|||
|
|
|
|||
|
|
|
|||
|
|
_LIVE = []
|
|||
|
|
_CLOSE_LOCK = threading.Lock()
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _atexit_close():
|
|||
|
|
with _CLOSE_LOCK:
|
|||
|
|
for c in list(_LIVE):
|
|||
|
|
try:
|
|||
|
|
c.close()
|
|||
|
|
except Exception:
|
|||
|
|
pass
|
|||
|
|
STOP["done"] = True
|