From e80f7f908c983d5a1bcb791e235c45ccca9dd7d3 Mon Sep 17 00:00:00 2001 From: mars <3361409208@qq.com> Date: Tue, 4 Aug 2026 14:10:24 +0800 Subject: [PATCH] =?UTF-8?q?fix(tng):=20Splash=20=E5=8D=A1=E4=BD=8F?= =?UTF-8?q?=E6=89=8D=E6=95=91=E6=8F=B4=EF=BC=8C=E5=B9=B6=E8=B7=B3=E8=BF=87?= =?UTF-8?q?=E9=A6=96=E9=A1=B5=E5=BC=BA=E5=88=B6=20eKYC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 避免已登录冷启动被强拉回登录页;HomeEkycVerify 测试期 finish + canBypass/enforce stub。 --- docs/TNG_开放问题与抓包能力.md | 52 +++ .../xposed/hook/TngRootBypassHook.java | 373 ++++++++++++++++-- 2 files changed, 388 insertions(+), 37 deletions(-) create mode 100644 docs/TNG_开放问题与抓包能力.md diff --git a/docs/TNG_开放问题与抓包能力.md b/docs/TNG_开放问题与抓包能力.md new file mode 100644 index 0000000..82bd77f --- /dev/null +++ b/docs/TNG_开放问题与抓包能力.md @@ -0,0 +1,52 @@ +# TNG 开放问题与抓包能力(2026-08-04) + +## 待办:冷启动假掉登录(先不改) + +**现象:** 登录成功后退出再进,界面像未登录,需重新登录。 + +**根因判断(已基本确认):** +旧逻辑:`SplashActivity.onCreate` **无条件** 2s 强拉 `UserLoginActivity`。 + +**已修复(2026-08-04):** 改为 4s **卡住救援**——已自行跳到 PIN/首页则取消;仅仍停在 Splash 时救援(有本地会话优先 `UserPinActivity`,否则 `UserLoginActivity`)。 + +--- + +## Money Packet 领取统计 — 网络/明文获取能力 + +目标:群红包 Leaderboard 的 **昵称 + 已领金额**(字段预期 `receiverList` / `claimedAmount` 等)。 + +### 路线对比 + +| 路线 | 状态 | 说明 | +|------|------|------| +| 外部 mitm(Charles / mitmproxy) | **不可行(当前)** | TNG API(如 `mpaasgw.tngdigital.com.my`)有 **证书 pinning**,只能看到域名,解不开 HTTPS 正文。`reverse/dumps/mitm_mmp/` 里曾落盘的多为官网 HTML/新闻,**不是** 红包 API。 | +| 进程内 Xposed(`TngMoneyPacketHook`) | **代码已接,运行时未实证** | 在 TLS 之后读明文:Hook `OkHttp ResponseBody.string` + `Gson.fromJson`,匹配 `receiverList` / `claimedAmount` / `Mmp*` 模型,经 `HookForwarder` 转发。`MainHook` 已 `install`。 | +| 纯日志/UI 自动化 | 兜底 | 无 API 时可读界面,不稳定,不作主路径。 | + +### 当前缺口 + +1. **尚未在真机打开 Money Packet Leaderboard 做过一次捕获验证** → logcat 里暂无 `TngMmp captured …`。 +2. TNG 主业务多为 **mPaaS / Quake RPC**,若响应不走 `ResponseBody.string()` / 目标 Gson 类名不符,现有 Hook 会漏;需补 **RPC invoke 返回值 / 其它 JSON 入口**。 +3. Splash 强拉登录不影响「登录后进群点红包」时的抓包,但影响复测效率。 + +### 建议验证步骤(下次动手) + +1. 保持登录态,进群 → 打开红包详情 / Leaderboard。 +2. 看 LSPosed:`notiMessageHook/TngMmp installed` 与 `captured packet=… claims=N`。 +3. 若无:对同一次操作抓 logcat 里 URL / 类名,补 Hook 点(Quake `RpcInvocationHandler` 等)。 +4. 确认 notiMessage / debug-server 是否收到转发内容。 + +### eKYC「验证您的帐户」强制页(2026-08-04) + +`HomeEkycVerifyActivity` 挡首页。测试期:`hookHomeEkycVerifySkip` — finish 该页 + 拦 Intent + `canBypassEkyc`/`enforceEkyc` stub。 +**注意:** 服务端仍可能在部分功能(转账/红包)二次校验 eKYC,首页跳过不等于全功能可用。 + + +```text +packetId / title +receiverList[]: + - nickname / displayName + - claimedAmount(或 amount) +``` + +按昵称聚合 `claimedAmount` 即可做领取排行。 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 57f5a9e..0ac6c65 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 @@ -18,6 +18,7 @@ import android.view.WindowManager; import android.os.Message; +import java.io.File; import java.lang.reflect.Constructor; import java.lang.reflect.Method; import java.lang.reflect.Modifier; @@ -131,6 +132,8 @@ public final class TngRootBypassHook { hookAppSecurityManager(lpparam); hookAppSecurityCallbacks(lpparam); hookSecurityErrorActivity(lpparam); + // 首页强制 eKYC「验证您的帐户」— 测试期直接跳过 + hookHomeEkycVerifySkip(lpparam); // seccomp 开着时必须 stub TigerTally init,否则 fork getprop 永不退出 → App.onCreate ANR hookTigerTally(lpparam); hookTigerTallyAppWrappers(lpparam); @@ -156,51 +159,54 @@ public final class TngRootBypassHook { } } - /** Splash 优先生效;新 schedule 会取消旧 Runnable(Application 兜底 vs Splash 2000ms)。 */ + /** Splash 卡住救援;新 schedule 会取消旧 Runnable。 */ private static final Handler FORCE_LOGIN_HANDLER = new Handler(Looper.getMainLooper()); private static Runnable pendingForceLoginRunnable; + /** Splash 已离开(进 PIN/首页等)则取消救援。 */ + private static volatile boolean splashNavigationDone = false; - private static void scheduleForceLoginToUserLogin( + /** + * 仅当 Splash 超时仍停在自身时才救援,避免已登录冷启动被强拉回 UserLogin。 + * 有本地会话痕迹 → 优先 UserPin;否则 → UserLogin。 + */ + private static void scheduleSplashStuckRescue( final Context appCtx, final Activity splashAct, final String reason, final long delayMs) { if (appCtx == null) { return; } + splashNavigationDone = false; if (pendingForceLoginRunnable != null) { FORCE_LOGIN_HANDLER.removeCallbacks(pendingForceLoginRunnable); } - final String login = "my.com.tngdigital.user.view.UserLoginActivity"; pendingForceLoginRunnable = new Runnable() { @Override public void run() { pendingForceLoginRunnable = null; try { - if (isTopActivityRegistrationFlow(appCtx)) { - XposedBridge.log(TAG + " skip force login (" + reason + ", registration flow)"); + if (splashNavigationDone) { + XposedBridge.log(TAG + " skip splash rescue (" + reason + ", already left)"); return; } - ActivityManager am = - (ActivityManager) appCtx.getSystemService(Context.ACTIVITY_SERVICE); - if (am != null) { - for (ActivityManager.AppTask task : am.getAppTasks()) { - ActivityManager.RecentTaskInfo info = task.getTaskInfo(); - if (info == null || info.topActivity == null) { - continue; - } - String top = info.topActivity.getClassName(); - if (top.endsWith(".UserLoginActivity") - || top.endsWith(".UserPinActivity") - || isRegistrationFlowActivity(top)) { - XposedBridge.log(TAG + " skip force login (" + reason + ", on " + top + ")"); - return; - } - } + if (isTopActivityRegistrationFlow(appCtx)) { + XposedBridge.log(TAG + " skip splash rescue (" + reason + ", registration flow)"); + return; + } + String top = getTopActivityClassName(appCtx); + if (top != null && !isSplashActivityName(top)) { + XposedBridge.log(TAG + " skip splash rescue (" + reason + ", on " + top + ")"); + splashNavigationDone = true; + return; } if (!isUiVisibleForForceLogin(appCtx)) { - XposedBridge.log(TAG + " skip force login (" + reason + ", no visible UI / BAL)"); + XposedBridge.log(TAG + " skip splash rescue (" + reason + ", no visible UI / BAL)"); return; } + boolean hasSession = hasLocalLoginSession(appCtx); + String target = hasSession + ? "my.com.tngdigital.user.view.UserPinActivity" + : "my.com.tngdigital.user.view.UserLoginActivity"; Intent intent = new Intent(); - intent.setClassName(PACKAGE, login); + intent.setClassName(PACKAGE, target); intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP | Intent.FLAG_ACTIVITY_SINGLE_TOP); if (splashAct != null && !splashAct.isFinishing()) { @@ -209,20 +215,141 @@ public final class TngRootBypassHook { splashAct.finish(); } catch (Throwable ignored) { } - XposedBridge.log(TAG + " forced → UserLogin (" + reason + ", from Splash)"); + XposedBridge.log(TAG + " splash stuck → " + shortActivityName(target) + + " (" + reason + ", session=" + hasSession + ", from Splash)"); } else { intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); appCtx.startActivity(intent); - XposedBridge.log(TAG + " forced → UserLogin (" + reason + ", from AppCtx)"); + XposedBridge.log(TAG + " splash stuck → " + shortActivityName(target) + + " (" + reason + ", session=" + hasSession + ", from AppCtx)"); } + splashNavigationDone = true; } catch (Throwable t) { - XposedBridge.log(TAG + " force login failed (" + reason + "): " + t.getMessage()); + XposedBridge.log(TAG + " splash rescue failed (" + reason + "): " + t.getMessage()); } } }; FORCE_LOGIN_HANDLER.postDelayed(pendingForceLoginRunnable, delayMs); } + private static String shortActivityName(String className) { + if (className == null) { + return "?"; + } + int dot = className.lastIndexOf('.'); + return dot >= 0 ? className.substring(dot + 1) : className; + } + + private static boolean isSplashActivityName(String className) { + return className != null + && (className.endsWith(".SplashActivity") || className.contains(".SplashActivity")); + } + + private static String getTopActivityClassName(Context ctx) { + try { + ActivityManager am = (ActivityManager) ctx.getSystemService(Context.ACTIVITY_SERVICE); + if (am == null) { + return null; + } + for (ActivityManager.AppTask task : am.getAppTasks()) { + ActivityManager.RecentTaskInfo info = task.getTaskInfo(); + if (info == null || info.topActivity == null) { + continue; + } + if (PACKAGE.equals(info.topActivity.getPackageName())) { + return info.topActivity.getClassName(); + } + } + } catch (Throwable ignored) { + } + return null; + } + + /** + * 粗判本地是否已有登录痕迹(有则冷启动应走 PIN,而不是登录页)。 + * SharedPreferences 文件名/键含 session、token、user、pin、login 等即视为已登录。 + */ + private static boolean hasLocalLoginSession(Context ctx) { + try { + File prefsDir = new File(ctx.getApplicationInfo().dataDir, "shared_prefs"); + if (!prefsDir.isDirectory()) { + return false; + } + File[] files = prefsDir.listFiles(); + if (files == null) { + return false; + } + for (File f : files) { + String name = f.getName().toLowerCase(Locale.US); + if (!name.endsWith(".xml")) { + continue; + } + if (name.contains("session") || name.contains("token") || name.contains("user") + || name.contains("login") || name.contains("account") + || name.contains("auth") || name.contains("pin") + || name.contains("credential") || name.contains("wallet")) { + if (f.length() > 64) { + return true; + } + } + // 扫键名:任意 prefs 里出现登录相关 key + if (prefsXmlLooksLikeLoggedIn(f)) { + return true; + } + } + } catch (Throwable t) { + XposedBridge.log(TAG + " hasLocalLoginSession failed: " + t.getMessage()); + } + return false; + } + + private static boolean prefsXmlLooksLikeLoggedIn(File xmlFile) { + java.io.BufferedReader reader = null; + try { + reader = new java.io.BufferedReader(new java.io.FileReader(xmlFile)); + String line; + int lines = 0; + while ((line = reader.readLine()) != null && lines < 200) { + lines++; + String lower = line.toLowerCase(Locale.US); + if ((lower.contains("name=\"") || lower.contains("name='")) + && (lower.contains("token") || lower.contains("session") + || lower.contains("userid") || lower.contains("user_id") + || lower.contains("loginid") || lower.contains("mobile") + || lower.contains("phonenumber") || lower.contains("islogin") + || lower.contains("logged") || lower.contains("access_token"))) { + // 排除空值 + if (lower.contains(">true<") || lower.contains("value=\"true\"") + || (lower.contains("value=\"") && !lower.contains("value=\"\"") + && !lower.contains("value=\"0\"") && !lower.contains("value=\"false\"")) + || (lower.contains(">") && lower.contains("") + && !lower.contains("><"))) { + return true; + } + if (lower.contains("")) { + int a = lower.indexOf('>'); + int b = lower.lastIndexOf(""); + if (a >= 0 && b > a + 1 && (b - a) > 8) { + return true; + } + } + if (lower.contains(" clazz = XposedHelpers.findClass(className, lpparam.classLoader); + int hooked = 0; + for (Method method : clazz.getDeclaredMethods()) { + if (method.getReturnType() != boolean.class && method.getReturnType() != Boolean.class) { + continue; + } + String n = method.getName(); + boolean match = false; + for (String hint : nameHints) { + if (n.equals(hint) || n.toLowerCase(Locale.US).contains(hint.toLowerCase(Locale.US))) { + match = true; + break; + } + } + if (!match) { + continue; + } + final boolean ret = result; + XposedBridge.hookMethod(method, new XC_MethodHook() { + @Override + protected void beforeHookedMethod(MethodHookParam param) { + param.setResult(ret); + } + }); + hooked++; + } + XposedBridge.log(TAG + " eKYC bool stub " + className + " n=" + hooked + " -> " + result); + } catch (Throwable t) { + XposedBridge.log(TAG + " eKYC bool stub skip " + className + ": " + t.getMessage()); + } + } + + private static void hookVoidMethodsByName( + XC_LoadPackage.LoadPackageParam lpparam, + String className, + String... nameHints) { + try { + Class clazz = XposedHelpers.findClass(className, lpparam.classLoader); + int hooked = 0; + for (Method method : clazz.getDeclaredMethods()) { + if (method.getReturnType() != void.class) { + continue; + } + String n = method.getName(); + boolean match = false; + for (String hint : nameHints) { + if (n.equals(hint) || n.startsWith(hint)) { + match = true; + break; + } + } + if (!match) { + continue; + } + XposedBridge.hookMethod(method, new XC_MethodHook() { + @Override + protected void beforeHookedMethod(MethodHookParam param) { + param.setResult(null); + } + }); + hooked++; + } + if (hooked > 0) { + XposedBridge.log(TAG + " eKYC void noop " + className + " n=" + hooked); + } + } catch (Throwable t) { + XposedBridge.log(TAG + " eKYC void noop skip " + className + ": " + t.getMessage()); + } + } + private static void hookReturnFalse( XC_LoadPackage.LoadPackageParam lpparam, String className, String methodName) { try {