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 {