diff --git a/debug-server/tng_mmp_mitm_addon.py b/debug-server/tng_mmp_mitm_addon.py new file mode 100644 index 0000000..d43cfcb --- /dev/null +++ b/debug-server/tng_mmp_mitm_addon.py @@ -0,0 +1,137 @@ +#!/usr/bin/env python3 +""" +mitmproxy 插件:自动保存 TNG Money Packet 含 receiverList 的 API 响应。 + +用法: + mitmdump -s debug-server/tng_mmp_mitm_addon.py -p 8888 + 或 + mitmweb -s debug-server/tng_mmp_mitm_addon.py -p 8888 + +手机 WiFi 代理 -> PC_IP:8888,安装 mitmproxy CA 后打开 TNG 红包 Leaderboard。 +命中响应会打印到终端,并写入 reverse/dumps/mitm_mmp/ +""" + +from __future__ import annotations + +import json +import os +import re +from datetime import datetime + +from mitmproxy import ctx, http + +OUTPUT_DIR = os.path.join( + os.path.dirname(os.path.dirname(os.path.abspath(__file__))), + "reverse", + "dumps", + "mitm_mmp", +) + +MMP_HINTS = ( + "receiverlist", + "claimedamount", + "mmpreceiver", + "moneypacket", + "merchantmoneypacket", +) + +HOST_HINTS = ( + "ebuckler.com", + "tngdigital.com", + "alipaydev.com", +) + + +def _ensure_dir() -> None: + os.makedirs(OUTPUT_DIR, exist_ok=True) + + +def _looks_like_mmp(body: str) -> bool: + lower = body.lower() + if any(h in lower for h in MMP_HINTS): + return True + return "mmp" in lower and "amount" in lower + + +def _extract_receiver_list(obj): + """递归找 receiverList 并格式化为 [(nickname, amount), ...]""" + rows = [] + + def walk(node): + if isinstance(node, dict): + if "receiverList" in node and isinstance(node["receiverList"], list): + for item in node["receiverList"]: + if not isinstance(item, dict): + continue + name = ( + item.get("nickName") + or item.get("displayName") + or item.get("userName") + or item.get("receiverName") + or item.get("name") + ) + amount = ( + item.get("claimedAmount") + or item.get("receiveAmount") + or item.get("amount") + ) + if name and amount is not None: + rows.append((str(name), str(amount))) + for v in node.values(): + walk(v) + elif isinstance(node, list): + for v in node: + walk(v) + + walk(obj) + return rows + + +class TngMmpCapture: + def __init__(self) -> None: + _ensure_dir() + self.count = 0 + ctx.log.info(f"TNG MMP capture -> {OUTPUT_DIR}") + + def response(self, flow: http.HTTPFlow) -> None: + if flow.response is None or not flow.response.content: + return + host = (flow.request.host or "").lower() + if not any(h in host for h in HOST_HINTS): + return + try: + text = flow.response.get_text(strict=False) + except Exception: + return + if not text or not _looks_like_mmp(text): + return + + self.count += 1 + ts = datetime.now().strftime("%Y%m%d_%H%M%S") + safe_host = re.sub(r"[^\w.-]", "_", host)[:40] + path = os.path.join(OUTPUT_DIR, f"mmp_{ts}_{self.count}_{safe_host}.json") + + summary_lines = [ + f"[TNG-MMP #{self.count}] {flow.request.method} {flow.request.url}", + ] + try: + data = json.loads(text) + with open(path, "w", encoding="utf-8") as f: + json.dump(data, f, ensure_ascii=False, indent=2) + rows = _extract_receiver_list(data) + if rows: + summary_lines.append(f" receiverList ({len(rows)} 条):") + for name, amount in rows: + summary_lines.append(f" {name} -> {amount}") + else: + summary_lines.append(" (JSON 已保存,未解析到 receiverList)") + except json.JSONDecodeError: + with open(path, "w", encoding="utf-8") as f: + f.write(text) + summary_lines.append(" (非 JSON,已保存原文)") + + summary_lines.append(f" saved: {path}") + ctx.log.info("\n".join(summary_lines)) + + +addons = [TngMmpCapture()] diff --git a/magisk-modules/tng_exit_guard/jni/main.cpp b/magisk-modules/tng_exit_guard/jni/main.cpp index 7ad86e5..7eafe8a 100644 --- a/magisk-modules/tng_exit_guard/jni/main.cpp +++ b/magisk-modules/tng_exit_guard/jni/main.cpp @@ -6,6 +6,7 @@ * pc==lr 循环 SEGV 也 skip;libc++abi __cxa_guard_acquire → SIGABRT 吞掉。 */ #include +#include #include #include #include @@ -174,11 +175,20 @@ static void fatal_skip_handler(int sig, siginfo_t *info, void *ctx) { freeze_forever(); } int n = ++g_abrt_swallow; - if (n <= 5 || n % 50 == 0) { - LOGI("ABRT pc+4 tid=%d pc=%lx lr=%lx streak=%d", (int)tid, - (unsigned long)pc, (unsigned long)lr, streak); + /* 主线程:abort 后 _exit 被 seccomp 拦 → abort 内部死循环(还复位 handler)→ + * 主线程永久卡死 → 黑屏/ANR。主线程 ABRT 时跳回 lr(Looper pollOnce)恢复; + * 仅当 lr 距 pc 远(不在 abort 内部)才跳,否则仍在 abort epilogue 内跳 LR 会再 abort。 */ + uintptr_t target = pc + 4; + if (tid == g_main_tid.load() && lr != 0 && pc != 0 + && (lr < pc - 0x1000 || lr > pc + 0x1000)) { + target = lr; } - uc->uc_mcontext.pc = pc + 4; + if (n <= 5 || n % 50 == 0) { + LOGI("ABRT tid=%d pc=%lx lr=%lx streak=%d -> %lx", (int)tid, + (unsigned long)pc, (unsigned long)lr, streak, + (unsigned long)target); + } + uc->uc_mcontext.pc = target; return; } @@ -236,14 +246,23 @@ static void install_soft_signals() { ((struct sock_filter){(unsigned short)(code), (jt), (jf), (unsigned int)(k)}) static int install_seccomp_exit_group_only() { + /* exit_group 拦 + 精准拦 Promon 自杀 kill(SIGABRT)(SI_USER)。 + * 只拦 kill()(nr=129):Promon 自杀是 kill(pid,SIGABRT) → si_code=SI_USER。 + * 不拦 tgkill/tkill:ART 的 abort() 用 tgkill(self) → si_code=SI_TKILL, + * 拦它会让 libart 状态错乱 → 进程崩(之前验证)。 */ struct sock_filter filter[] = { + // 0: arch _BPFI(BPF_LD | BPF_W | BPF_ABS, 0, 0, offsetof(struct seccomp_data, arch)), _BPFI(BPF_JMP | BPF_JEQ | BPF_K, 1, 0, AUDIT_ARCH_AARCH64), - _BPFI(BPF_RET | BPF_K, 0, 0, SC_RET_ALLOW), + _BPFI(BPF_RET | BPF_K, 0, 0, SC_RET_ALLOW), // 2: not aarch64 _BPFI(BPF_LD | BPF_W | BPF_ABS, 0, 0, offsetof(struct seccomp_data, nr)), - _BPFI(BPF_JMP | BPF_JEQ | BPF_K, 0, 1, 94), - _BPFI(BPF_RET | BPF_K, 0, 0, SC_RET_ERRNO_EPERM), - _BPFI(BPF_RET | BPF_K, 0, 0, SC_RET_ALLOW), + _BPFI(BPF_JMP | BPF_JEQ | BPF_K, 0, 1, __NR_exit_group), // 4: exit_group? true→5 + _BPFI(BPF_RET | BPF_K, 0, 0, SC_RET_ERRNO_EPERM), // 5: EPERM exit_group + _BPFI(BPF_JMP | BPF_JEQ | BPF_K, 0, 3, __NR_kill), // 6: kill? true→7 + _BPFI(BPF_LD | BPF_W | BPF_ABS, 0, 0, offsetof(struct seccomp_data, args) + 8), // 7: args[1]=sig + _BPFI(BPF_JMP | BPF_JEQ | BPF_K, 0, 1, SIGABRT), // 8: sig==SIGABRT? true→9 + _BPFI(BPF_RET | BPF_K, 0, 0, SC_RET_ERRNO_EPERM), // 9: EPERM kill ABRT + _BPFI(BPF_RET | BPF_K, 0, 0, SC_RET_ALLOW), // 10: allow }; struct sock_fprog prog = { .len = (unsigned short)(sizeof(filter) / sizeof(filter[0])), @@ -258,9 +277,9 @@ static int install_seccomp_exit_group_only() { LOGE("seccomp failed errno=%d", errno); return -1; } - LOGI("seccomp exit_group via prctl"); + LOGI("seccomp exit_group+kill-ABRT via prctl"); } else { - LOGI("seccomp exit_group via TSYNC"); + LOGI("seccomp exit_group+kill-ABRT via TSYNC"); } g_seccomp_ok.store(1); return 0; @@ -277,10 +296,31 @@ using pthread_kill_fn = int (*)(pthread_t, int); using cxa_guard_acquire_fn = int (*)(void *); using cxa_guard_abort_fn = void (*)(); using dlopen_fn = void *(*)(const char *, int); +using android_dlopen_ext_fn = void *(*)(const char *, int, const void *); +using sphal_load_fn = void *(*)(const char *, int); +using open_passthrough_hal_fn = void *(*)(const char *, const char *, int); static cxa_guard_acquire_fn orig_cxa_guard_acquire = nullptr; static cxa_guard_abort_fn orig_cxa_guard_abort = nullptr; static dlopen_fn orig_dlopen = nullptr; +static android_dlopen_ext_fn orig_android_dlopen_ext = nullptr; +static sphal_load_fn orig_sphal_load = nullptr; +static open_passthrough_hal_fn orig_open_passthrough_hal = nullptr; +static void *g_libandroid_handle = nullptr; +static void *g_mapper_pixel_handle = nullptr; + +#ifndef RTLD_NOW +#define RTLD_NOW 2 +#endif +#ifndef RTLD_GLOBAL +#define RTLD_GLOBAL 0x100 +#endif +#ifndef RTLD_NOLOAD +#define RTLD_NOLOAD 0x4 +#endif +#ifndef RTLD_DEFAULT +#define RTLD_DEFAULT reinterpret_cast(static_cast(-1)) +#endif static exit_fn orig_exit = nullptr; static exit_fn orig__exit = nullptr; @@ -359,31 +399,186 @@ static void hooked_cxa_guard_abort() { LOGI("blocked __cxa_guard_abort tid=%d depth=%d", (int)gettid(), tl_cxa_depth); } +static void *try_dlopen_noload(const char *name) { + if (!orig_dlopen || name == nullptr) { + return nullptr; + } + void *h = orig_dlopen(name, RTLD_NOW | RTLD_NOLOAD); + if (h != nullptr) { + return h; + } + const char *base = strrchr(name, '/'); + if (base != nullptr && base[1] != '\0') { + h = orig_dlopen(base + 1, RTLD_NOW | RTLD_NOLOAD); + } + return h; +} + +/** 校验 dlopen 句柄:拒绝空/明显毒化指针,并用 dlsym 探活。 */ +static bool libandroid_handle_ok(void *h) { + if (h == nullptr) return false; + uintptr_t p = reinterpret_cast(h); + // 用户态典型映射;排除明显垃圾(如 0x...c5c5 / 高熵毒化) + if (p < 0x10000UL) return false; + if ((p & 0xffffUL) == 0xc5c5UL) return false; + void *sym = dlsym(h, "ANativeWindow_fromSurface"); + if (sym == nullptr) { + sym = dlsym(h, "AAssetManager_fromJava"); + } + return sym != nullptr; +} + +static void preload_hwui_libs() { + if (!orig_dlopen) { + // PLT 尚未拿到 orig 时,用 libc 直调 + orig_dlopen = reinterpret_cast(dlsym(RTLD_DEFAULT, "dlopen")); + } + if (!orig_sphal_load) { + orig_sphal_load = reinterpret_cast( + dlsym(RTLD_DEFAULT, "android_load_sphal_library")); + } + if (g_libandroid_handle != nullptr && !libandroid_handle_ok(g_libandroid_handle)) { + LOGI("drop invalid cached libandroid %p", g_libandroid_handle); + g_libandroid_handle = nullptr; + } + if (g_libandroid_handle == nullptr && orig_dlopen) { + static const char *kPaths[] = { + "libandroid.so", + "/system/lib64/libandroid.so", + "/apex/com.android.runtime/lib64/libandroid.so", + }; + for (const char *path : kPaths) { + void *h = orig_dlopen(path, RTLD_NOW | RTLD_GLOBAL); + if (h == nullptr) { + h = try_dlopen_noload(path); + } + if (libandroid_handle_ok(h)) { + g_libandroid_handle = h; + LOGI("preload libandroid ok path=%s -> %p", path, h); + break; + } + if (h != nullptr) { + LOGI("preload libandroid reject path=%s -> %p", path, h); + } + } + if (g_libandroid_handle == nullptr) { + LOGI("preload libandroid FAILED"); + } + } + if (g_mapper_pixel_handle == nullptr) { + if (orig_sphal_load) { + g_mapper_pixel_handle = orig_sphal_load("mapper.pixel.so", RTLD_NOW); + LOGI("preload mapper via sphal -> %p", g_mapper_pixel_handle); + } + if (g_mapper_pixel_handle == nullptr && orig_dlopen) { + g_mapper_pixel_handle = orig_dlopen( + "/vendor/lib64/hw/mapper.pixel.so", RTLD_NOW | RTLD_GLOBAL); + LOGI("preload mapper via path -> %p", g_mapper_pixel_handle); + } + } +} + static void *hooked_dlopen(const char *name, int flags) { void *handle = orig_dlopen ? orig_dlopen(name, flags) : nullptr; - if (handle != nullptr || name == nullptr) { + if (name == nullptr) { return handle; } - // Compose/HWUI 依赖 libandroid + gralloc mapper;sphal 失败时尝试绝对路径兜底 - if (strstr(name, "libandroid.so") != nullptr - || strstr(name, "mapper.pixel") != nullptr - || strstr(name, "android.hardware.graphics.mapper") != nullptr) { - static const char *kFallbacks[] = { + if (handle != nullptr) { + // 命名空间下偶发返回毒化非空句柄,HWUI 随后 FATAL + if (strstr(name, "libandroid.so") != nullptr && !libandroid_handle_ok(handle)) { + LOGI("dlopen got bad handle %p for %s — recover", handle, name); + handle = nullptr; + } else { + if (strstr(name, "libandroid.so") != nullptr) { + g_libandroid_handle = handle; + } + return handle; + } + } + // 已映射库:命名空间下按名 dlopen 会失败,RTLD_NOLOAD 可取回句柄 + handle = try_dlopen_noload(name); + if (handle != nullptr) { + if (strstr(name, "libandroid.so") == nullptr || libandroid_handle_ok(handle)) { + LOGI("dlopen NOLOAD hit name=%s -> %p", name, handle); + if (strstr(name, "libandroid.so") != nullptr) { + g_libandroid_handle = handle; + } + return handle; + } + } + if (strstr(name, "libandroid.so") != nullptr) { + if (g_libandroid_handle != nullptr && !libandroid_handle_ok(g_libandroid_handle)) { + LOGI("drop bad cached libandroid %p", g_libandroid_handle); + g_libandroid_handle = nullptr; + } + if (g_libandroid_handle != nullptr) { + LOGI("dlopen return cached libandroid %p (from %s)", + g_libandroid_handle, name); + return g_libandroid_handle; + } + static const char *kAndroidPaths[] = { "/system/lib64/libandroid.so", - "/system/lib/libandroid.so", - "/vendor/lib64/hw/mapper.pixel.so", - "/vendor/lib/hw/mapper.pixel.so", "libandroid.so", - "mapper.pixel.so", }; - for (const char *path : kFallbacks) { - handle = orig_dlopen ? orig_dlopen(path, flags) : nullptr; - if (handle != nullptr) { + LOGI("dlopen miss name=%s flags=0x%x tid=%d — try fallback", + name, flags, (int)gettid()); + for (const char *path : kAndroidPaths) { + handle = try_dlopen_noload(path); + if (handle == nullptr && orig_dlopen) { + handle = orig_dlopen(path, flags | RTLD_GLOBAL); + } + if (libandroid_handle_ok(handle)) { + g_libandroid_handle = handle; LOGI("dlopen fallback %s -> %p (from %s)", path, handle, name); return handle; } + if (handle != nullptr) { + LOGI("dlopen fallback reject %s -> %p", path, handle); + } } LOGI("dlopen fallback failed name=%s tid=%d", name, (int)gettid()); + return nullptr; + } + if (strstr(name, "mapper.pixel") != nullptr + || strstr(name, "mapper.") != nullptr) { + if (g_mapper_pixel_handle != nullptr) { + LOGI("dlopen return cached mapper %p (from %s)", + g_mapper_pixel_handle, name); + return g_mapper_pixel_handle; + } + if (orig_sphal_load) { + handle = orig_sphal_load("mapper.pixel.so", RTLD_NOW); + if (handle != nullptr) { + g_mapper_pixel_handle = handle; + LOGI("dlopen sphal mapper -> %p (from %s)", handle, name); + return handle; + } + } + if (orig_dlopen) { + handle = orig_dlopen("/vendor/lib64/hw/mapper.pixel.so", + flags | RTLD_GLOBAL); + if (handle != nullptr) { + g_mapper_pixel_handle = handle; + LOGI("dlopen path mapper -> %p (from %s)", handle, name); + return handle; + } + } + LOGI("dlopen mapper failed name=%s tid=%d", name, (int)gettid()); + } + return handle; +} + +static void *hooked_android_dlopen_ext(const char *name, int flags, const void *extinfo) { + void *handle = orig_android_dlopen_ext + ? orig_android_dlopen_ext(name, flags, extinfo) + : nullptr; + if (handle != nullptr || name == nullptr) { + return handle; + } + if (strstr(name, "libandroid.so") != nullptr + || strstr(name, "mapper") != nullptr) { + LOGI("android_dlopen_ext miss name=%s — try dlopen fallback", name); + return hooked_dlopen(name, flags); } return handle; } @@ -402,7 +597,7 @@ static bool find_lib_match(const char *suffix, const char *contains, unsigned long inode = 0; char path[512] = {}; int n = sscanf(line, "%lx-%lx %7s %llx %31s %lu %511[^\n]", - &start, &end, perms, &offset, &deststr, &inode, path); + &start, &end, perms, &offset, deststr, &inode, path); if (n < 7 || inode == 0) continue; char *p = path; while (*p == ' ') ++p; @@ -438,12 +633,160 @@ static bool find_libc(dev_t *dev, ino_t *ino) { return find_lib_by_suffix("libc.so", dev, ino); } +/** 收集 maps 里所有匹配后缀的已加载库(去重)。Zygisk commit 前必须覆盖全部副本, + * 否则 libc++ 多副本(/system、/vendor、/apex)时只 hook 一份,调用点仍走原生实现。 */ +struct lib_devino { + dev_t dev; + ino_t ino; +}; + +static int find_all_lib_by_suffix(const char *suffix, lib_devino *out, int max) { + FILE *fp = fopen("/proc/self/maps", "r"); + if (!fp) return 0; + char line[1024]; + int n = 0; + while (fgets(line, sizeof(line), fp)) { + uintptr_t start = 0, end = 0; + char perms[8] = {}; + unsigned long long offset = 0; + char deststr[32] = {}; + unsigned long inode = 0; + char path[512] = {}; + int got = sscanf(line, "%lx-%lx %7s %llx %31s %lu %511[^\n]", + &start, &end, perms, &offset, deststr, &inode, path); + if (got < 7 || inode == 0) continue; + char *p = path; + while (*p == ' ') ++p; + size_t plen = strlen(p); + size_t slen = strlen(suffix); + if (plen < slen || strcmp(p + plen - slen, suffix) != 0) continue; + unsigned maj = 0, min = 0; + if (sscanf(deststr, "%x:%x", &maj, &min) != 2) continue; + dev_t d = makedev(maj, min); + ino_t in = inode; + bool dup = false; + for (int i = 0; i < n; i++) { + if (out[i].dev == d && out[i].ino == in) { + dup = true; + break; + } + } + if (dup) continue; + if (n < max) { + out[n].dev = d; + out[n].ino = in; + n++; + } + } + fclose(fp); + return n; +} + static void register_plt(zygisk::Api *api, dev_t dev, ino_t ino, const char *sym, void *hook, void **orig) { if (!api || dev == 0 || ino == 0) return; api->pltHookRegister(dev, ino, sym, hook, orig); } +static void *hooked_sphal_load(const char *name, int flags) { + void *handle = orig_sphal_load ? orig_sphal_load(name, flags) : nullptr; + if (handle != nullptr || name == nullptr) { + return handle; + } + LOGI("sphal miss name=%s flags=0x%x — try cache/path", name, flags); + if (strstr(name, "mapper") != nullptr) { + if (g_mapper_pixel_handle != nullptr) { + LOGI("sphal return cached mapper %p", g_mapper_pixel_handle); + return g_mapper_pixel_handle; + } + if (orig_dlopen) { + handle = orig_dlopen("/vendor/lib64/hw/mapper.pixel.so", + RTLD_NOW | RTLD_GLOBAL); + if (handle != nullptr) { + g_mapper_pixel_handle = handle; + LOGI("sphal path mapper -> %p", handle); + return handle; + } + handle = orig_dlopen("mapper.pixel.so", RTLD_NOW | RTLD_GLOBAL); + if (handle != nullptr) { + g_mapper_pixel_handle = handle; + LOGI("sphal name mapper -> %p", handle); + return handle; + } + } + } + return nullptr; +} + +static void *hooked_open_passthrough_hal(const char *interface, const char *instance, + int dlopen_flags) { + void *handle = orig_open_passthrough_hal + ? orig_open_passthrough_hal(interface, instance, dlopen_flags) + : nullptr; + if (handle != nullptr) { + return handle; + } + LOGI("passthroughHal miss iface=%s inst=%s — try mapper path", + interface ? interface : "?", instance ? instance : "?"); + if ((interface && strstr(interface, "mapper") != nullptr) + || (instance && strstr(instance, "pixel") != nullptr)) { + if (g_mapper_pixel_handle != nullptr) { + return g_mapper_pixel_handle; + } + if (orig_sphal_load) { + handle = orig_sphal_load("mapper.pixel.so", RTLD_NOW); + } + if (handle == nullptr && orig_dlopen) { + handle = orig_dlopen("/vendor/lib64/hw/mapper.pixel.so", + RTLD_NOW | RTLD_GLOBAL); + } + if (handle != nullptr) { + g_mapper_pixel_handle = handle; + LOGI("passthroughHal mapper recovered -> %p", handle); + } + } + return handle; +} + +static void register_dlopen_on_lib(zygisk::Api *api, const char *suffix) { + dev_t dev = 0; + ino_t ino = 0; + if (!find_lib_by_suffix(suffix, &dev, &ino)) { + return; + } + // 必须 hook 调用方 PLT(libhwui/libui),只 hook libc 拦不到 HWUI 的 dlopen + register_plt(api, dev, ino, "dlopen", + (void *)hooked_dlopen, (void **)&orig_dlopen); + register_plt(api, dev, ino, "android_dlopen_ext", + (void *)hooked_android_dlopen_ext, (void **)&orig_android_dlopen_ext); + register_plt(api, dev, ino, "android_load_sphal_library", + (void *)hooked_sphal_load, (void **)&orig_sphal_load); + register_plt(api, dev, ino, "AServiceManager_openDeclaredPassthroughHal", + (void *)hooked_open_passthrough_hal, + (void **)&orig_open_passthrough_hal); + LOGI("dlopen PLT on %s", suffix); +} + +/** 注册所有已加载 libc++ 副本的 __cxa_guard_acquire/abort。返回注册的副本数。 */ +static int register_cxx_guard_hooks(zygisk::Api *api) { + if (!api) return 0; + lib_devino libs[8]; + int n = find_all_lib_by_suffix("libc++.so", libs, 8); + if (n == 0) { + n = find_all_lib_by_suffix("libc++_shared.so", libs, 8); + } + for (int i = 0; i < n; i++) { + register_plt(api, libs[i].dev, libs[i].ino, "__cxa_guard_acquire", + (void *)hooked_cxa_guard_acquire, (void **)&orig_cxa_guard_acquire); + register_plt(api, libs[i].dev, libs[i].ino, "__cxa_guard_abort", + (void *)hooked_cxa_guard_abort, (void **)&orig_cxa_guard_abort); + } + if (n > 0) { + LOGI("cxx guard target libc++ copies=%d", n); + } + return n; +} + static void install_plt(zygisk::Api *api) { if (!api) return; dev_t dev = 0; @@ -460,9 +803,25 @@ static void install_plt(zygisk::Api *api) { register_plt(api, dev, ino, "pthread_kill", (void *)hooked_pthread_kill, (void **)&orig_pthread_kill); register_plt(api, dev, ino, "dlopen", (void *)hooked_dlopen, (void **)&orig_dlopen); + register_plt(api, dev, ino, "android_dlopen_ext", + (void *)hooked_android_dlopen_ext, (void **)&orig_android_dlopen_ext); } + // HWUI / libui 直接 PLT→linker,必须单独挂 + register_dlopen_on_lib(api, "libhwui.so"); + register_dlopen_on_lib(api, "libui.so"); + register_dlopen_on_lib(api, "libandroid_runtime.so"); + register_dlopen_on_lib(api, "libbinder_ndk.so"); + register_dlopen_on_lib(api, "libvndksupport.so"); + // libc++ cxa guard 必须在首次 commit 前注册:Zygisk pltHookCommit 二次调用会失败, + // 导致 __cxa_guard_acquire 递归 abort 保护从未生效(主线程反复 SIGABRT → 黑屏)。 + int cxx = register_cxx_guard_hooks(api); bool ok = api->pltHookCommit(); - LOGI("PLT commit=%d", ok ? 1 : 0); + LOGI("PLT commit=%d cxx_guard_copies=%d", ok ? 1 : 0, cxx); + if (ok && cxx > 0) { + g_cxx_plt.store(1); + } + // commit 后立刻预加载,抢在 Promon/命名空间收紧之前拿到句柄 + preload_hwui_libs(); } static void try_install_cxx_guard_plt() { @@ -470,17 +829,7 @@ static void try_install_cxx_guard_plt() { dev_t dev = 0; ino_t ino = 0; bool any = false; - // libc++:必须走 orig acquire(否则 gralloc 静态初始化被跳过 → mapper missing 黑屏)。 - // 仅在同 guard 递归时 return 1;abort 仍拦截。 - if (find_lib_by_suffix("libc++.so", &dev, &ino) - || find_lib_by_suffix("libc++_shared.so", &dev, &ino)) { - register_plt(g_api, dev, ino, "__cxa_guard_acquire", - (void *)hooked_cxa_guard_acquire, (void **)&orig_cxa_guard_acquire); - register_plt(g_api, dev, ino, "__cxa_guard_abort", - (void *)hooked_cxa_guard_abort, (void **)&orig_cxa_guard_abort); - any = true; - LOGI("cxx guard target libc++"); - } + // libc++ 副本已由首次 PLT commit 覆盖;此处仅补 libtngdigital_ewallet.so 自身 PLT。 if (find_lib_by_suffix("libtngdigital_ewallet.so", &dev, &ino)) { register_plt(g_api, dev, ino, "__cxa_guard_acquire", (void *)hooked_cxa_guard_acquire, (void **)&orig_cxa_guard_acquire); @@ -490,7 +839,9 @@ static void try_install_cxx_guard_plt() { LOGI("cxx guard target libtngdigital_ewallet"); } if (!any) return; - if (g_api->pltHookCommit()) { + bool ok = g_api->pltHookCommit(); + LOGI("cxx guard commit=%d", ok ? 1 : 0); + if (ok) { g_cxx_plt.store(1); LOGI("PLT cxx guards committed (orig acquire + recursive skip)"); } @@ -498,9 +849,13 @@ static void try_install_cxx_guard_plt() { static void *phase_thread(void *) { install_promon_segv_handler(); + /* Promon 用 SVC exit_group 绕过 PLT;必须 seccomp。延迟 400ms 避开最早的 fork/getprop。 */ usleep(400 * 1000); - install_seccomp_exit_group_only(); - LOGI("seccomp armed @400ms ok=%d", g_seccomp_ok.load()); + if (install_seccomp_exit_group_only() == 0) { + LOGI("seccomp exit_group armed @400ms"); + } else { + LOGE("seccomp install failed"); + } for (int i = 0; i < 40; i++) { usleep(1000 * 1000); install_soft_signals(); @@ -516,7 +871,7 @@ static void *phase_thread(void *) { static void install_all(zygisk::Api *api) { g_api = api; g_main_tid.store(gettid()); - LOGI("install pid=%d main_tid=%d (PLT+cxx-guard+ABRT-pc+4+SEGV-skip+exit_group@400ms)", + LOGI("install pid=%d main_tid=%d (PLT+cxx-guard+ABRT-pc+4+SEGV-skip+seccomp@400ms)", getpid(), (int)g_main_tid.load()); install_fatal_skip_handlers(); install_soft_signals(); diff --git a/magisk-modules/tng_exit_guard/zygisk/arm64-v8a.so b/magisk-modules/tng_exit_guard/zygisk/arm64-v8a.so index 8d6635b..209e785 100644 Binary files a/magisk-modules/tng_exit_guard/zygisk/arm64-v8a.so and b/magisk-modules/tng_exit_guard/zygisk/arm64-v8a.so differ diff --git a/scripts/start-tng-mitm.ps1 b/scripts/start-tng-mitm.ps1 new file mode 100644 index 0000000..3b91d8b --- /dev/null +++ b/scripts/start-tng-mitm.ps1 @@ -0,0 +1,41 @@ +# 启动 mitmproxy 抓 TNG Money Packet API(需先: pip install mitmproxy) +$ErrorActionPreference = "Stop" +$ProjectRoot = Split-Path -Parent $PSScriptRoot +$Addon = Join-Path $ProjectRoot "debug-server\tng_mmp_mitm_addon.py" + +function Get-LanIp { + $ip = Get-NetIPAddress -AddressFamily IPv4 | + Where-Object { $_.IPAddress -notlike "127.*" -and $_.InterfaceAlias -notlike "*Loopback*" } | + Select-Object -First 1 -ExpandProperty IPAddress + if ($ip) { return $ip } + return "127.0.0.1" +} + +$mitm = Get-Command mitmweb -ErrorAction SilentlyContinue +if (-not $mitm) { + $mitm = Get-Command mitmdump -ErrorAction SilentlyContinue +} +if (-not $mitm) { + Write-Host "未找到 mitmproxy。请先安装:" -ForegroundColor Yellow + Write-Host " pip install mitmproxy" -ForegroundColor Cyan + exit 1 +} + +$pcIp = Get-LanIp +Write-Host "" +Write-Host "=== TNG Money Packet 抓包 ===" -ForegroundColor Cyan +Write-Host "1. 手机与 PC 同一 WiFi" -ForegroundColor White +Write-Host "2. 手机 WiFi 代理: 手动 $pcIp 端口 8888" -ForegroundColor Green +Write-Host "3. 手机浏览器打开 http://mitm.it 安装证书 (Android)" -ForegroundColor White +Write-Host "4. TNG 登录 -> 进群 -> 打开红包 Leaderboard" -ForegroundColor White +Write-Host "5. 命中响应保存到 reverse/dumps/mitm_mmp/" -ForegroundColor White +Write-Host "" +Write-Host "若 TNG 报网络错误,可能是证书 pinning,见 docs 说明。" -ForegroundColor Yellow +Write-Host "" + +if ($mitm.Name -eq "mitmweb") { + Write-Host "Web UI: http://127.0.0.1:8081" -ForegroundColor Cyan + & mitmweb -s $Addon -p 8888 --web-host 127.0.0.1 +} else { + & mitmdump -s $Addon -p 8888 +} diff --git a/xposed-module/src/main/java/com/miraclegarden/smsmessage/xposed/HookBridge.java b/xposed-module/src/main/java/com/miraclegarden/smsmessage/xposed/HookBridge.java index b3a3cfc..4827c0f 100644 --- a/xposed-module/src/main/java/com/miraclegarden/smsmessage/xposed/HookBridge.java +++ b/xposed-module/src/main/java/com/miraclegarden/smsmessage/xposed/HookBridge.java @@ -18,6 +18,7 @@ public final class HookBridge { public static final String SOURCE_XPOSED_SUNCORP_NOTIFY = "xposed_suncorp_notify"; public static final String SOURCE_XPOSED_UBANK = "xposed_ubank"; public static final String SOURCE_XPOSED_UBANK_NOTIFY = "xposed_ubank_notify"; + public static final String SOURCE_XPOSED_TNG_MMP = "xposed_tng_mmp"; private HookBridge() { } diff --git a/xposed-module/src/main/java/com/miraclegarden/smsmessage/xposed/MainHook.java b/xposed-module/src/main/java/com/miraclegarden/smsmessage/xposed/MainHook.java index fa72915..4c67500 100644 --- a/xposed-module/src/main/java/com/miraclegarden/smsmessage/xposed/MainHook.java +++ b/xposed-module/src/main/java/com/miraclegarden/smsmessage/xposed/MainHook.java @@ -3,6 +3,7 @@ package com.miraclegarden.smsmessage.xposed; import com.miraclegarden.smsmessage.xposed.hook.MariBankRootBypassHook; import com.miraclegarden.smsmessage.xposed.hook.MariBankShpsNativeHook; import com.miraclegarden.smsmessage.xposed.hook.SuncorpBankMessageHook; +import com.miraclegarden.smsmessage.xposed.hook.TngMoneyPacketHook; import com.miraclegarden.smsmessage.xposed.hook.TngRootBypassHook; import com.miraclegarden.smsmessage.xposed.hook.SqliteMessageHook; import com.miraclegarden.smsmessage.xposed.hook.TelegramMessageHook; @@ -66,6 +67,7 @@ public class MainHook implements IXposedHookLoadPackage { if (TngRootBypassHook.isTargetPackage(lpparam.packageName)) { TngRootBypassHook.install(lpparam); + TngMoneyPacketHook.install(lpparam); return; } diff --git a/xposed-module/src/main/java/com/miraclegarden/smsmessage/xposed/hook/TngMoneyPacketHook.java b/xposed-module/src/main/java/com/miraclegarden/smsmessage/xposed/hook/TngMoneyPacketHook.java new file mode 100644 index 0000000..240dd19 --- /dev/null +++ b/xposed-module/src/main/java/com/miraclegarden/smsmessage/xposed/hook/TngMoneyPacketHook.java @@ -0,0 +1,579 @@ +package com.miraclegarden.smsmessage.xposed.hook; + +import android.content.Context; +import android.text.TextUtils; + +import com.miraclegarden.smsmessage.xposed.HookBridge; +import com.miraclegarden.smsmessage.xposed.HookForwarder; + +import org.json.JSONArray; +import org.json.JSONObject; + +import java.lang.reflect.Array; +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.Iterator; +import java.util.List; +import java.util.Locale; +import java.util.Set; + +import de.robv.android.xposed.XC_MethodHook; +import de.robv.android.xposed.XposedBridge; +import de.robv.android.xposed.XposedHelpers; +import de.robv.android.xposed.callbacks.XC_LoadPackage; + +/** + * TNG eWallet Money Packet(群红包)领取统计。 + * 从 HTTP 响应 JSON / Gson 反序列化结果中提取 receiverList(昵称 + 金额),转发到 notiMessage。 + */ +public final class TngMoneyPacketHook { + + private static final String TAG = "notiMessageHook/TngMmp"; + private static final String PACKAGE = TngRootBypassHook.PACKAGE; + private static final int DEDUP_SIZE = 256; + + private static final ArrayDeque RECENT_KEYS = new ArrayDeque<>(); + private static final HashSet RECENT_SET = new HashSet<>(); + private static final ThreadLocal CURRENT_REQUEST_URL = new ThreadLocal<>(); + + private TngMoneyPacketHook() { + } + + public static void install(XC_LoadPackage.LoadPackageParam lpparam) { + if (!TngRootBypassHook.isTargetPackage(lpparam.packageName)) { + return; + } + hookOkHttpUrl(lpparam); + hookResponseBody(lpparam); + hookGsonFromJson(lpparam); + XposedBridge.log(TAG + " installed for " + lpparam.packageName); + } + + private static void hookOkHttpUrl(XC_LoadPackage.LoadPackageParam lpparam) { + try { + XposedHelpers.findAndHookMethod( + "okhttp3.Request$Builder", + lpparam.classLoader, + "build", + new XC_MethodHook() { + @Override + protected void afterHookedMethod(MethodHookParam param) { + try { + Object url = XposedHelpers.callMethod(param.getResult(), "url"); + if (url != null) { + CURRENT_REQUEST_URL.set(String.valueOf(url)); + } + } catch (Throwable ignored) { + } + } + }); + } catch (Throwable t) { + XposedBridge.log(TAG + " Request.Builder.build hook failed: " + t.getMessage()); + } + } + + private static void hookResponseBody(XC_LoadPackage.LoadPackageParam lpparam) { + try { + XposedHelpers.findAndHookMethod( + "okhttp3.ResponseBody", + lpparam.classLoader, + "string", + new XC_MethodHook() { + @Override + protected void afterHookedMethod(MethodHookParam param) { + String body = (String) param.getResult(); + String url = CURRENT_REQUEST_URL.get(); + CURRENT_REQUEST_URL.remove(); + if (!isMmpPayload(url, body)) { + return; + } + forwardParsedJson(body, url, "http"); + } + }); + } catch (Throwable t) { + XposedBridge.log(TAG + " ResponseBody.string hook failed: " + t.getMessage()); + } + } + + private static void hookGsonFromJson(XC_LoadPackage.LoadPackageParam lpparam) { + XC_MethodHook hook = new XC_MethodHook() { + @Override + protected void afterHookedMethod(MethodHookParam param) { + Object result = param.getResult(); + if (result == null) { + return; + } + String className = result.getClass().getName(); + if (!isMmpModelClass(className)) { + return; + } + try { + String json = buildJsonFromObject(result); + if (TextUtils.isEmpty(json)) { + return; + } + forwardParsedJson(json, className, "gson"); + } catch (Throwable t) { + XposedBridge.log(TAG + " gson capture failed: " + t.getMessage()); + } + } + }; + try { + XposedHelpers.findAndHookMethod( + "com.google.gson.Gson", + lpparam.classLoader, + "fromJson", + String.class, + Class.class, + hook); + } catch (Throwable t) { + XposedBridge.log(TAG + " Gson.fromJson(Class) hook failed: " + t.getMessage()); + } + try { + XposedHelpers.findAndHookMethod( + "com.google.gson.Gson", + lpparam.classLoader, + "fromJson", + String.class, + java.lang.reflect.Type.class, + hook); + } catch (Throwable ignored) { + } + } + + private static boolean isMmpPayload(String url, String body) { + if (TextUtils.isEmpty(body) || body.length() < 24) { + return false; + } + String lower = body.toLowerCase(Locale.US); + boolean jsonHit = lower.contains("receiverlist") + || lower.contains("\"claimedamount\"") + || lower.contains("mmpreceiver") + || (lower.contains("nick") && lower.contains("amount") && lower.contains("mmp")); + if (jsonHit) { + return true; + } + if (TextUtils.isEmpty(url)) { + return false; + } + String urlLower = url.toLowerCase(Locale.US); + return urlLower.contains("mmp") || urlLower.contains("moneypacket"); + } + + private static boolean isMmpModelClass(String className) { + if (TextUtils.isEmpty(className)) { + return false; + } + return className.contains("MmpDetailResult") + || className.contains("MmpClaimQueryResult") + || className.contains("MmpClaimResult") + || className.contains("MmpReceiver") + || className.contains("MmpDetailLeaderboard"); + } + + private static void forwardParsedJson(String json, String sourceHint, String channel) { + MmpSnapshot snapshot = parseSnapshot(json); + if (snapshot == null || snapshot.claims.isEmpty()) { + return; + } + String dedupKey = snapshot.dedupKey(); + if (!remember(dedupKey)) { + return; + } + Context context = getContext(); + if (context == null) { + return; + } + String title = TextUtils.isEmpty(snapshot.title) ? "TNG Money Packet" : snapshot.title; + String content = snapshot.formatForForward(channel, sourceHint); + HookForwarder.forward(context, PACKAGE, title, content, HookBridge.SOURCE_XPOSED_TNG_MMP); + XposedBridge.log(TAG + " captured packet=" + snapshot.packetId + + " claims=" + snapshot.claims.size() + " via " + channel); + } + + private static MmpSnapshot parseSnapshot(String json) { + try { + JSONObject root = new JSONObject(json.trim()); + return parseSnapshot(root, null); + } catch (Throwable t) { + return null; + } + } + + private static MmpSnapshot parseSnapshot(JSONObject root, MmpSnapshot base) { + MmpSnapshot snapshot = base != null ? base : new MmpSnapshot(); + fillMeta(root, snapshot); + + JSONArray receiverList = findReceiverList(root); + if (receiverList != null) { + for (int i = 0; i < receiverList.length(); i++) { + JSONObject item = receiverList.optJSONObject(i); + if (item == null) { + continue; + } + ClaimLine line = parseClaimLine(item); + if (line != null) { + snapshot.claims.add(line); + } + } + } + + Iterator keys = root.keys(); + while (keys.hasNext()) { + String key = keys.next(); + Object val = root.opt(key); + if (val instanceof JSONObject) { + parseSnapshot((JSONObject) val, snapshot); + } else if (val instanceof JSONArray) { + JSONArray arr = (JSONArray) val; + for (int i = 0; i < arr.length(); i++) { + Object elem = arr.opt(i); + if (elem instanceof JSONObject) { + ClaimLine line = parseClaimLine((JSONObject) elem); + if (line != null && looksLikeClaimRow((JSONObject) elem)) { + snapshot.claims.add(line); + } + parseSnapshot((JSONObject) elem, snapshot); + } + } + } + } + return snapshot; + } + + private static JSONArray findReceiverList(JSONObject obj) { + Iterator keys = obj.keys(); + while (keys.hasNext()) { + String key = keys.next(); + Object val = obj.opt(key); + if ("receiverList".equalsIgnoreCase(key) && val instanceof JSONArray) { + return (JSONArray) val; + } + if (val instanceof JSONObject) { + JSONArray nested = findReceiverList((JSONObject) val); + if (nested != null) { + return nested; + } + } + } + return null; + } + + private static void fillMeta(JSONObject obj, MmpSnapshot snapshot) { + putIfPresent(obj, snapshot, "packetId", "mmpId", "moneyPacketId", "id"); + putIfPresent(obj, snapshot, "groupId", "chatId", "conversationId"); + putIfPresent(obj, snapshot, "groupName", "chatName", "conversationName"); + putIfPresent(obj, snapshot, "senderName", "senderNickName", "operatorName"); + putIfPresent(obj, snapshot, "totalAmount", "packetAmount", "amount"); + if (TextUtils.isEmpty(snapshot.title)) { + String merchant = firstNonEmpty( + obj.optString("merchantName", null), + obj.optString("shopName", null)); + if (!TextUtils.isEmpty(merchant)) { + snapshot.title = merchant; + } + } + } + + private static void putIfPresent(JSONObject obj, MmpSnapshot snapshot, String... keys) { + for (String key : keys) { + if (!obj.has(key)) { + continue; + } + String val = obj.optString(key, null); + if (TextUtils.isEmpty(val) || "null".equalsIgnoreCase(val)) { + continue; + } + if (key.toLowerCase(Locale.US).contains("packet") || key.equals("mmpId") || key.equals("id")) { + if (TextUtils.isEmpty(snapshot.packetId)) { + snapshot.packetId = val; + } + } else if (key.toLowerCase(Locale.US).contains("group")) { + if (TextUtils.isEmpty(snapshot.groupId)) { + snapshot.groupId = val; + } + } else if (key.toLowerCase(Locale.US).contains("name") && key.toLowerCase(Locale.US).contains("group")) { + snapshot.title = val; + } else if (key.toLowerCase(Locale.US).contains("sender") + || key.toLowerCase(Locale.US).contains("operator")) { + if (TextUtils.isEmpty(snapshot.senderName)) { + snapshot.senderName = val; + } + } else if (key.toLowerCase(Locale.US).contains("total") || key.equals("amount")) { + if (TextUtils.isEmpty(snapshot.totalAmount)) { + snapshot.totalAmount = val; + } + } + } + } + + private static boolean looksLikeClaimRow(JSONObject obj) { + String name = firstNonEmpty( + obj.optString("nickName", null), + obj.optString("displayName", null), + obj.optString("userName", null), + obj.optString("receiverName", null)); + String amount = firstNonEmpty( + obj.optString("claimedAmount", null), + obj.optString("receiveAmount", null), + obj.optString("amount", null)); + return !TextUtils.isEmpty(name) && !TextUtils.isEmpty(amount); + } + + private static ClaimLine parseClaimLine(JSONObject obj) { + String name = firstNonEmpty( + obj.optString("nickName", null), + obj.optString("displayName", null), + obj.optString("userName", null), + obj.optString("receiverName", null), + obj.optString("name", null)); + String amount = firstNonEmpty( + obj.optString("claimedAmount", null), + obj.optString("receiveAmount", null), + obj.optString("amount", null)); + if (TextUtils.isEmpty(name) || TextUtils.isEmpty(amount)) { + return null; + } + ClaimLine line = new ClaimLine(); + line.nickname = name; + line.amount = amount; + line.claimTime = firstNonEmpty( + obj.optString("claimTime", null), + obj.optString("claimedTime", null), + obj.optString("receiveTime", null)); + return line; + } + + private static String buildJsonFromObject(Object root) { + JSONObject json = objectToJson(root, new HashSet(), 0); + return json != null ? json.toString() : null; + } + + private static JSONObject objectToJson(Object obj, Set visited, int depth) { + if (obj == null || depth > 6) { + return null; + } + if (obj instanceof String || obj instanceof Number || obj instanceof Boolean) { + JSONObject wrap = new JSONObject(); + try { + wrap.put("value", obj); + } catch (Throwable ignored) { + } + return wrap; + } + int identity = System.identityHashCode(obj); + if (visited.contains(identity)) { + return null; + } + visited.add(identity); + + JSONObject out = new JSONObject(); + Class clazz = obj.getClass(); + if (clazz.isArray()) { + JSONArray arr = new JSONArray(); + int len = Array.getLength(obj); + for (int i = 0; i < len; i++) { + Object elem = Array.get(obj, i); + JSONObject child = objectToJson(elem, visited, depth + 1); + if (child != null) { + arr.put(child); + } + } + try { + out.put("array", arr); + } catch (Throwable ignored) { + } + return out; + } + if (obj instanceof Iterable) { + JSONArray arr = new JSONArray(); + for (Object elem : (Iterable) obj) { + JSONObject child = objectToJson(elem, visited, depth + 1); + if (child != null) { + arr.put(child); + } + } + try { + out.put("receiverList", arr); + } catch (Throwable ignored) { + } + return out; + } + + for (Field field : clazz.getDeclaredFields()) { + String fname = field.getName(); + if (fname.contains("$") || fname.startsWith("CREATOR")) { + continue; + } + try { + field.setAccessible(true); + Object val = field.get(obj); + if (val == null) { + continue; + } + if (isPrimitiveLike(val)) { + out.put(fname, String.valueOf(val)); + } else if (val instanceof Iterable || val.getClass().isArray()) { + JSONArray arr = new JSONArray(); + if (val instanceof Iterable) { + for (Object elem : (Iterable) val) { + putJsonValue(arr, elem, visited, depth + 1); + } + } else { + int len = Array.getLength(val); + for (int i = 0; i < len; i++) { + putJsonValue(arr, Array.get(val, i), visited, depth + 1); + } + } + out.put(fname, arr); + } else if (val.getClass().getName().startsWith("my.com.tngdigital") + || val.getClass().getName().contains("Mmp")) { + JSONObject child = objectToJson(val, visited, depth + 1); + if (child != null) { + out.put(fname, child); + } + } else if (isPrimitiveLikeViaGetter(obj, fname)) { + out.put(fname, String.valueOf(val)); + } + } catch (Throwable ignored) { + } + } + return out.length() > 0 ? out : null; + } + + private static void putJsonValue(JSONArray arr, Object elem, Set visited, int depth) { + if (elem == null) { + return; + } + if (isPrimitiveLike(elem)) { + arr.put(String.valueOf(elem)); + return; + } + JSONObject child = objectToJson(elem, visited, depth); + if (child != null) { + arr.put(child); + } + } + + private static boolean isPrimitiveLike(Object val) { + return val instanceof String + || val instanceof Number + || val instanceof Boolean + || val instanceof Character; + } + + private static boolean isPrimitiveLikeViaGetter(Object obj, String fieldName) { + try { + String suffix = fieldName.substring(0, 1).toUpperCase(Locale.US) + fieldName.substring(1); + for (String prefix : new String[]{"get", "is"}) { + try { + Method m = obj.getClass().getMethod(prefix + suffix); + Class rt = m.getReturnType(); + return rt == String.class || Number.class.isAssignableFrom(rt) + || rt == boolean.class || rt == Boolean.class; + } catch (Throwable ignored) { + } + } + } catch (Throwable ignored) { + } + return false; + } + + private static boolean remember(String key) { + if (TextUtils.isEmpty(key)) { + return false; + } + if (RECENT_SET.contains(key)) { + return false; + } + RECENT_SET.add(key); + RECENT_KEYS.addLast(key); + while (RECENT_KEYS.size() > DEDUP_SIZE) { + String old = RECENT_KEYS.removeFirst(); + RECENT_SET.remove(old); + } + return true; + } + + private static String firstNonEmpty(String... values) { + if (values == null) { + return null; + } + for (String v : values) { + if (!TextUtils.isEmpty(v) && !"null".equalsIgnoreCase(v)) { + return v; + } + } + return null; + } + + private static Context getContext() { + try { + Class activityThread = XposedHelpers.findClass("android.app.ActivityThread", null); + Object app = XposedHelpers.callStaticMethod(activityThread, "currentApplication"); + if (app instanceof Context) { + return (Context) app; + } + } catch (Throwable ignored) { + } + return null; + } + + private static final class MmpSnapshot { + String packetId; + String groupId; + String title; + String senderName; + String totalAmount; + final List claims = new ArrayList<>(); + + String dedupKey() { + StringBuilder sb = new StringBuilder(); + sb.append(TextUtils.isEmpty(packetId) ? "?" : packetId).append('|'); + for (ClaimLine line : claims) { + sb.append(line.nickname).append('=').append(line.amount).append(';'); + } + return sb.toString(); + } + + String formatForForward(String channel, String sourceHint) { + StringBuilder sb = new StringBuilder(); + sb.append("[MMP统计] "); + if (!TextUtils.isEmpty(packetId)) { + sb.append("packet=").append(packetId).append(' '); + } + if (!TextUtils.isEmpty(groupId)) { + sb.append("group=").append(groupId).append(' '); + } + if (!TextUtils.isEmpty(senderName)) { + sb.append("sender=").append(senderName).append(' '); + } + if (!TextUtils.isEmpty(totalAmount)) { + sb.append("total=").append(totalAmount).append(' '); + } + sb.append("via=").append(channel); + if (!TextUtils.isEmpty(sourceHint)) { + sb.append(" src=").append(sourceHint.length() > 120 + ? sourceHint.substring(0, 120) + "..." : sourceHint); + } + sb.append("\n"); + for (ClaimLine line : claims) { + sb.append(line.nickname).append(" -> ").append(line.amount); + if (!TextUtils.isEmpty(line.claimTime)) { + sb.append(" (").append(line.claimTime).append(')'); + } + sb.append('\n'); + } + return sb.toString().trim(); + } + } + + private static final class ClaimLine { + String nickname; + String amount; + String claimTime; + } +} diff --git a/xposed-module/src/main/java/com/miraclegarden/smsmessage/xposed/hook/TngRootBypassHook.java b/xposed-module/src/main/java/com/miraclegarden/smsmessage/xposed/hook/TngRootBypassHook.java index b78c1c0..4515549 100644 --- a/xposed-module/src/main/java/com/miraclegarden/smsmessage/xposed/hook/TngRootBypassHook.java +++ b/xposed-module/src/main/java/com/miraclegarden/smsmessage/xposed/hook/TngRootBypassHook.java @@ -45,6 +45,12 @@ public final class TngRootBypassHook { /** Promon 混淆包名:1.9.10 为 vhvlnqgy,旧版为 xwwqazamx。 */ private static final String[] PROMON_PKG_PREFIXES = {"vhvlnqgy", "xwwqazamx"}; + /** + * 登录优先:少 Hook、少短路 native-bridge,避免 JNI DeleteLocalRef 损坏 → Runtime abort。 + * 能进 UserLogin 后再逐步打开诊断 Hook。 + */ + private static final boolean LOGIN_FIRST_MINIMAL = true; + private static final String SECURITY_ERROR_ACTIVITY = "my.com.tngdigital.common.security.ui.SecurityErrorActivity"; @@ -230,11 +236,15 @@ public final class TngRootBypassHook { } XposedBridge.log(TAG + " install for " + lpparam.packageName + " pid=" + Process.myPid() - + " proc=" + getProcessName()); + + " proc=" + getProcessName() + + " minimal=" + LOGIN_FIRST_MINIMAL); - // Splash 常卡死;Instrumentation 强拉即可。双通道会抢跑导致 Login 重载/卡死。 hookEarlyAttachLog(lpparam); hookConscryptStatsLogGuard(); + if (LOGIN_FIRST_MINIMAL) { + installLoginFirstMinimal(lpparam); + return; + } hookRegistrationFlowGuard(lpparam); hookSplashForceLogin(lpparam); hookLoginDismissSplash(lpparam); @@ -277,6 +287,43 @@ public final class TngRootBypassHook { hookWebViewErrorDiag(lpparam); } + /** 最小集:藏 root + 拦自杀 Java 链 + bl#a/b,不碰 native-bridge 探测短路。 */ + private static void installLoginFirstMinimal(XC_LoadPackage.LoadPackageParam lpparam) { + hookSplashForceLogin(lpparam); + hookLoginDismissSplash(lpparam); + // 点区号会走 onCountryClick → i7.l loading;真 show → HWUI setName/gralloc abort + hookLoadingDialogSkip(); + // setName 一律 noop(A16+Zygisk 下 native setName→dlopen libandroid 易 ART abort)。 + // 区号 Compose 必须 HW:软件绘制会 IllegalArgumentException(hardware bitmaps)。 + hookHardwareRendererSetNameNoop(); + hookCallingCodeAllowHwSurface(lpparam); + + RootBypassHelper.hookFileExists(lpparam); + RootBypassHelper.hookRuntimeExec(lpparam); + RootBypassHelper.hookSystemGetProperty(lpparam); + ProcMapsFilterHook.install(lpparam); + + hookAntiSuicide(); + hookPromonSuicideUpstream(lpparam); + hookUncaughtPromonException(lpparam); + hookKillApplicationHandler(lpparam); + hookBlockSecurityErrorLaunch(lpparam); + hookPromonNativeGuard(lpparam); + hookPromonLifecycle(lpparam); + hookActivityThreadExit(lpparam); + hookFinishAllActivityAndKillApp(lpparam); + hookSecurityUrlOpeners(lpparam); + hookJailBroken(lpparam); + hookJailBrokenRpc(lpparam); + hookAppSecurityManager(lpparam); + hookAppSecurityCallbacks(lpparam); + hookSecurityErrorActivity(lpparam); + // seccomp 开着时必须 stub TigerTally init,否则 fork getprop 永不退出 → App.onCreate ANR + hookTigerTally(lpparam); + hookTigerTallyAppWrappers(lpparam); + XposedBridge.log(TAG + " login-first minimal hooks armed"); + } + /** * Zygisk/命名空间下 android.util.StatsLog native 常 UnsatisfiedLinkError, * Conscrypt TLS 指标线程一写就炸 → ART fatal。直接 noop 指标写入。 @@ -1066,30 +1113,130 @@ public final class TngRootBypassHook { } /** - * 非区号:清 HW flag + 拦 enableHW(成对,防半开黑屏); - * 区号 Compose / BottomSelect:完整 HW(软件绘制 Compose 必黑)。 - * Dialog.show 窗口期可拦 setName。 + * 一律跳过 HardwareRenderer.setName。 + * Pixel/A16 + Zygisk 命名空间下 native setName → dlopen("libandroid.so") 失败会 ART abort。 */ - private static void hookHwuiBySurfacePolicy(XC_LoadPackage.LoadPackageParam lpparam) { + private static void hookHardwareRendererSetNameNoop() { try { XposedHelpers.findAndHookMethod( "android.graphics.HardwareRenderer", null, "setName", String.class, new XC_MethodHook() { @Override protected void beforeHookedMethod(MethodHookParam param) { - if (Looper.myLooper() != Looper.getMainLooper()) { - return; - } - if (!shouldBlockSetNameOnMain()) { - return; - } param.setResult(null); } }); - XposedBridge.log(TAG + " hooked HardwareRenderer.setName (dialog-only block)"); + XposedBridge.log(TAG + " hooked HardwareRenderer.setName (always noop)"); } catch (Throwable t) { - XposedBridge.log(TAG + " HardwareRenderer.setName hook failed: " + t.getMessage()); + XposedBridge.log(TAG + " HardwareRenderer.setName noop failed: " + t.getMessage()); } + } + + /** + * 区号 Compose:标记 surface + 保证 HW flag(勿清、勿拦 enableHW)。 + * 软件绘制会崩:Software rendering doesn't support hardware bitmaps。 + */ + private static void hookCallingCodeAllowHwSurface(XC_LoadPackage.LoadPackageParam lpparam) { + XC_MethodHook markOn = new XC_MethodHook() { + @Override + protected void beforeHookedMethod(MethodHookParam param) { + Intent intent = extractIntent(param.args); + if (isCallingCodeIntent(intent)) { + callingCodeSurfaceActive = true; + XposedBridge.log(TAG + " callingCode surface ON (HW path)"); + } + } + }; + try { + XposedHelpers.findAndHookMethod( + Activity.class, "startActivity", Intent.class, markOn); + XposedHelpers.findAndHookMethod( + Activity.class, "startActivity", Intent.class, Bundle.class, markOn); + } catch (Throwable t) { + XposedBridge.log(TAG + " startActivity callingCode mark failed: " + t.getMessage()); + } + try { + XposedHelpers.findAndHookMethod( + Instrumentation.class, + "execStartActivity", + Context.class, + android.os.IBinder.class, + android.os.IBinder.class, + Activity.class, + Intent.class, + int.class, + Bundle.class, + new XC_MethodHook() { + @Override + protected void beforeHookedMethod(MethodHookParam param) { + Intent intent = (Intent) param.args[4]; + if (isCallingCodeIntent(intent)) { + callingCodeSurfaceActive = true; + XposedBridge.log(TAG + + " callingCode surface ON (execStart HW)"); + } + } + }); + } catch (Throwable t) { + XposedBridge.log(TAG + " execStart callingCode mark failed: " + t.getMessage()); + } + try { + XposedHelpers.findAndHookMethod( + Instrumentation.class, + "callActivityOnCreate", + Activity.class, + Bundle.class, + new XC_MethodHook() { + @Override + protected void beforeHookedMethod(MethodHookParam param) { + Activity activity = (Activity) param.args[0]; + if (activity == null) { + return; + } + if (isCallingCodeActivity(activity.getClass().getName())) { + callingCodeSurfaceActive = true; + try { + Window window = activity.getWindow(); + if (window != null) { + window.addFlags( + WindowManager.LayoutParams.FLAG_HARDWARE_ACCELERATED); + } + } catch (Throwable ignored) { + } + XposedBridge.log(TAG + + " callingCode onCreate — keep HW for Compose"); + } + } + }); + XposedHelpers.findAndHookMethod( + Instrumentation.class, + "callActivityOnDestroy", + Activity.class, + new XC_MethodHook() { + @Override + protected void afterHookedMethod(MethodHookParam param) { + Activity activity = (Activity) param.args[0]; + if (activity != null + && isCallingCodeActivity(activity.getClass().getName())) { + callingCodeSurfaceActive = false; + XposedBridge.log(TAG + " callingCode surface OFF"); + } + } + }); + } catch (Throwable t) { + XposedBridge.log(TAG + " callingCode lifecycle mark failed: " + t.getMessage()); + } + XposedBridge.log(TAG + " callingCode allow-HW surface armed"); + } + + /** + * 非区号:清 HW flag + 拦 enableHW(成对,防半开黑屏); + * 区号 Compose / BottomSelect:完整 HW(软件绘制 Compose 必黑)。 + * Dialog.show 窗口期可拦 setName。 + */ + private static void hookHwuiBySurfacePolicy(XC_LoadPackage.LoadPackageParam lpparam) { + // setName 一律 noop(A16 Zygisk 下 native setName 会 abort) + hookHardwareRendererSetNameNoop(); try { Class vri = XposedHelpers.findClass("android.view.ViewRootImpl", null); @@ -1976,7 +2123,9 @@ public final class TngRootBypassHook { } /** - * Promon lifecycle:1.9.10 用 vhvlnqgy.u,旧版用 w。只吞异常,不全拦。 + * Promon lifecycle:1.9.10 用 vhvlnqgy.u,旧版用 w。 + * 实测 afterHook 仍跑 native → DeleteLocalRef 损坏 → ART Runtime abort (signal 6)。 + * 登录优先:onActivity* / onApplication* 全部 noop,不调原 native。 */ private static void hookPromonLifecycle(XC_LoadPackage.LoadPackageParam lpparam) { Class promonExc = findPromonClass(lpparam.classLoader, "W"); @@ -2005,25 +2154,37 @@ public final class TngRootBypassHook { if (!name.startsWith("onActivity") && !name.startsWith("onApplication")) { continue; } - XposedBridge.hookMethod(method, new XC_MethodHook() { - @Override - protected void afterHookedMethod(MethodHookParam param) { - if (!param.hasThrowable()) { - return; + if (LOGIN_FIRST_MINIMAL) { + // 不调原 native:避免 JNI DeleteLocalRef → ART abort + XposedBridge.hookMethod(method, new XC_MethodHook() { + @Override + protected void beforeHookedMethod(MethodHookParam param) { + setSafeHookResult(param, method); } - Throwable t = param.getThrowable(); - if (isPromonThrowable(t, promonExc)) { - XposedBridge.log(TAG + " swallowed " + t.getClass().getSimpleName() - + " in " + lifecycleName + "#" + method.getName()); - param.setThrowable(null); + }); + } else { + XposedBridge.hookMethod(method, new XC_MethodHook() { + @Override + protected void afterHookedMethod(MethodHookParam param) { + if (!param.hasThrowable()) { + return; + } + Throwable t = param.getThrowable(); + if (isPromonThrowable(t, promonExc)) { + XposedBridge.log(TAG + " swallowed " + t.getClass().getSimpleName() + + " in " + lifecycleName + "#" + method.getName()); + param.setThrowable(null); + } } - } - }); + }); + } hooked++; } if (hooked > 0) { - XposedBridge.log(TAG + " hooked " + hooked - + " " + lifecycleName + " lifecycle method(s) (afterHook only)"); + XposedBridge.log(TAG + " hooked " + hooked + " " + lifecycleName + + " lifecycle (" + + (LOGIN_FIRST_MINIMAL ? "noop (login-first)" : "afterHook only") + + ")"); } } catch (Throwable t) { XposedBridge.log(TAG + " " + lifecycleName + " lifecycle hook failed: " @@ -2061,8 +2222,8 @@ public final class TngRootBypassHook { } /** - * bl/R 全方法 short-circuit:a/b 之外的方法仍会跑 native,~40s 后 stack_chk/SEGV。 - * a.run 是 bl#b 后台 Runnable,必须 beforeHook 直接 return。 + * 登录优先:只 short-circuit bl#a / bl#b(文档有效路径)。全拦 bl/R 六个方法会破坏 + * u.onActivityCreated JNI → ART abort;a.run 仍单独 noop。 */ private static void hookPromonBlSwallowExceptions(XC_LoadPackage.LoadPackageParam lpparam) { for (String simple : new String[]{"bl", "R"}) { @@ -2079,6 +2240,10 @@ public final class TngRootBypassHook { try { int hooked = 0; for (Method method : clazz.getDeclaredMethods()) { + String methodName = method.getName(); + if (!"a".equals(methodName) && !"b".equals(methodName)) { + continue; + } XposedBridge.hookMethod(method, new XC_MethodHook() { @Override protected void beforeHookedMethod(MethodHookParam param) { @@ -2089,7 +2254,7 @@ public final class TngRootBypassHook { } if (hooked > 0) { XposedBridge.log(TAG + " hooked " + hooked + " " + clazz.getName() - + " method(s), all short-circuit"); + + " method(s), a/b short-circuit only"); } } catch (Throwable t) { XposedBridge.log(TAG + " " + clazz.getName() + " short-circuit failed: " @@ -2423,18 +2588,56 @@ public final class TngRootBypassHook { } /** - * TigerTally:init/initCommon + genericNt1 必须短路。 - * 只短路 genericNt1 而放行 init 会导致 encodeUid 缺失(cloudauth 403)、JNI 损坏、主线程 ABRT 无响应。 + * seccomp 开着时:必须 stub init/initCommon/genericNt1,否则 fork getprop 卡死。 + * seccomp 关且非登录优先:只 stub genericNt1 探测。 */ private static boolean isTigerShortCircuit(String className, String methodName) { if ("com.aliyun.TigerTally.TigerTallyAPI".equals(className) - && ("init".equals(methodName) || "initCommon".equals(methodName))) { - return true; + || "com.aliyun.TigerTally.t.B".equals(className) + || "com.aliyun.TigerTally.t.C".equals(className)) { + if ("init".equals(methodName) || "initCommon".equals(methodName) + || "genericNt1".equals(methodName) || "initialize".equals(methodName)) { + return true; + } } return "com.aliyun.TigerTally.t.B".equals(className) && "genericNt1".equals(methodName); } + /** TNG 封装层:CaptchaInitializer / TigerTallyApiWrapper 直接 noop,避免 Startup latch 卡住。 */ + private static void hookTigerTallyAppWrappers(XC_LoadPackage.LoadPackageParam lpparam) { + String[] classes = { + "my.com.tngdigital.captcha.TigerTallyApiWrapper", + "my.com.tngdigital.app.launcher.initializer.CaptchaInitializer", + }; + for (String className : classes) { + try { + Class clazz = XposedHelpers.findClass(className, lpparam.classLoader); + int n = 0; + for (Method method : clazz.getDeclaredMethods()) { + String name = method.getName(); + if (!"initialize".equals(name) && !"init".equals(name) + && !"create".equals(name)) { + continue; + } + XposedBridge.hookMethod(method, new XC_MethodHook() { + @Override + protected void beforeHookedMethod(MethodHookParam param) { + XposedBridge.log(TAG + " TigerTally wrapper SC " + + className + "#" + method.getName()); + setSafeHookResult(param, method); + } + }); + n++; + } + XposedBridge.log(TAG + " hooked TigerTally wrapper " + className + " n=" + n); + } catch (Throwable t) { + XposedBridge.log(TAG + " TigerTally wrapper " + className + + " skipped: " + t.getMessage()); + } + } + } + private static Object safeJnicReturn(int cmd, Object[] args) { if (args != null) { for (Object arg : args) {