Compare commits

...

21 Commits

Author SHA1 Message Date
mars
3fe84a8dd3 fix(mmp): 停止反复强跳红包历史页,避免打断前台使用
领取台恢复改为仅拉起 TNG 前台;Hook 侧限次/冷却,已在历史页不再重复打开。
2026-08-06 16:45:53 +08:00
mars
ae65351d8e feat(mmp): Hook 无心跳时自动唤醒或强停重开 TNG
领取台检测开启时,进程在跑但心跳过期会先进历史页唤醒,仍无效则 force-stop 重建 Hook。
2026-08-05 17:29:36 +08:00
mars
abf41b9ab6 fix(mmp): 状态条标明调试台可选,避免误解为故障
调试台未连接不参与爆红判定,文案改为可选提示。
2026-08-05 17:18:16 +08:00
mars
b3f2b2b3c2 feat(mmp): 领取进度/过期状态,并展示红包与领取人ID
状态优先显示已全部领取或已领取N/M,未领完才附加已过期;列表与最高最低补全 activityId、receiverId。
2026-08-05 15:45:39 +08:00
mars
293979122d feat(mmp): 领取人ID/模块版本检测,并修正状态条误报红
展示 receiverId 与 activityPoolId;检测 Hook 是否最新;按真实时间排序;有心跳与定时拉取时不再因久无新数据爆红。
2026-08-05 15:09:51 +08:00
mars
a4aed01b29 docs(mmp): 功能说明补充 TNG 闪退与强制停止排障
说明多点重试、强停、清缓存、更新 Hook 后强停及作用域排查步骤。
2026-08-04 17:49:22 +08:00
mars
c8cbfdff82 feat(mmp): 独立红包领取台(手机+PC)、同步、筛选与功能说明
隔离通用消息台;支持设置/手气摘要/电脑同步;忽略临时逆向脚本与运行时 JSON。
2026-08-04 17:39:29 +08:00
mars
1517ff4671 feat(tng): 自动拉红包历史/详情,并展示发放与领取时间
从 ILoginStorage 与官方历史页缓存请求模板,级联拉详情;领取台列表用发放时间而非拉取时间。
2026-08-04 16:20:13 +08:00
mars
1e2021d8fd docs(tng): 补充 Money Packet 领取台说明,并同步自动拉详情 Hook
说明 activityId、手动缓存模板后再自动拉历史详情的流程,以及 /mmp 双通道与去重行为。
2026-08-04 15:39:44 +08:00
mars
88a2b319f8 feat(tng): 红包领取台双通道推送,并修复金额/按包分组
Hook Quake RPC 抓 Money Packet;解析 Money 对象金额;调试台按单个红包展示排行;支持 USB 本机与共享网段双地址转发。
2026-08-04 15:09:28 +08:00
mars
e80f7f908c fix(tng): Splash 卡住才救援,并跳过首页强制 eKYC
避免已登录冷启动被强拉回登录页;HomeEkycVerify 测试期 finish + canBypass/enforce stub。
2026-08-04 14:10:24 +08:00
mars
f9426d6b68 chore: 清理临时逆向脚本,保留 TNG 安装工具与 captcha 文档
删除未跟踪的一次性 _*.py 扫描脚本,并从仓库移除已过时辅助脚本。
2026-08-04 13:48:47 +08:00
mars
bc51fb35cc chore(tng): 清理死代码并停止跟踪 reverse/dumps
精简 login-first 不可达 Hook,忽略调试产物目录,从仓库移除已入库的 dumps 文件。
2026-08-04 13:32:57 +08:00
mars
5378a34f58 fix(tng): 修复区号页 HWUI 闪退并放行 Compose HW 绘制
拦截 HardwareRenderer.setName,校验/缓存 libandroid.so,避免软件绘制撞 hardware bitmap;附带 Money Packet hook 与 mitm 脚本。
2026-08-04 12:26:27 +08:00
mars
609635aba1 chore: 备份 TNG 注册/captcha 逆向与 MariBank SG bypass 进展
TngRootBypassHook 增强 captcha 诊断、TigerTally/JNIC 分层与 HWUI 策略;新增逆向脚本、Frida 工具与 UI dump;同步 MariBank SG hook 与 tng_exit_guard 更新。
2026-08-03 15:23:02 +08:00
mars
193c04a24b fix(tng): 修复地区选择黑屏并加固注册链 native abort 防护 2026-08-03 11:22:51 +08:00
mars
18ae42ec63 fix(tng): 适配 Promon 1.9.10 vhvlnqgy 包名并加固 Zygisk 信号跳过
1.9.10 将 xwwqazamx 重命名为 vhvlnqgy,Login 闪退为 vhvlnqgy.bd:16;Xposed 双包名解析并 short-circuit R/bl、拦截 bd 异常。Zygisk 扩展 non-promon SEGV pc+4 与 ABRT/TRAP 一律 pc+4,修复 Login 稳定;注册页 stack_chk abort 待续攻。
2026-08-03 11:02:46 +08:00
mars
818b2f4f51 fix(tng): 防止 SIGABRT 跳 LR 死循环,改 pc+4 与 streak freeze
吞 ABRT 时若跳回近距 LR 会瞬间再 abort 刷屏;改为优先 pc+4,同 tid+pc 超阈值后冻结工作线程,主线程仅 bail。
2026-08-03 10:26:48 +08:00
mars
13e407623b feat(tng): TNG eWallet Promon bypass、Zygisk 信号守卫与注册链保护
新增 TngRootBypassHook 与 tng_exit_guard 模块,修复 BAL 强拉、SIGABRT/pc==lr SEGV 崩溃,验证可进入注册页。
2026-07-31 17:55:13 +08:00
mars
5a232213f3 docs: 新增 Telegram 抓消息专文并更新交叉引用 2026-07-06 14:17:01 +08:00
mars
81119e0ff9 feat(maribank): PH 注册 OTP 突破、SG bypass 与中文文档
菲律宾 SeaBank 在 Root+LSPosed+Shamiko 下 register 已通过并触发 OTP;扩展 SG 包名、加密前 Hook、Magisk 设备伪装脚本,并将 docs 整理为中文操作与风控说明。
2026-07-06 14:01:49 +08:00
178 changed files with 24208 additions and 206 deletions

15
.gitignore vendored
View File

@@ -27,3 +27,18 @@ reverse/frida/*.log.err
reverse/frida/logcat_capture.txt
reverse/frida/*.out
reverse/frida/*.err
debug-server/__pycache__/
# 调试台运行时落盘(设置/红包镜像,本机生成)
debug-server/mmp_packets.json
debug-server/mmp_settings.json
magisk-modules/tng_exit_guard/obj/
magisk-modules/tng_exit_guard/libs/
# 调试抓包/截图/ANR/APK 产物,不入库
reverse/dumps/
reverse/scripts/__pycache__/
# 临时 MMP 逆向探测脚本(一次性 dump/scan不入库
reverse/scripts/_dump_mmp_*.py
reverse/scripts/_scan_mmp_*.py
reverse/scripts/_verify_mmp_*.py
reverse/scripts/_find_mmp_*.py
reverse/scripts/_dump_rpc_request.py

View File

@@ -2,7 +2,11 @@
Android 应用,监听通知栏消息并通过 **双通道**(通知监听 + Xposed Hook抓取内容支持 PC 本地调试台转发。
> **Hook 架构与扩展指南**:详见 [docs/HOOK_GUIDE.md](docs/HOOK_GUIDE.md)Telegram 实现、接入新 App 步骤
> **Hook 架构与扩展指南**:详见 [docs/Hook指南.md](docs/Hook指南.md)(含 **Xposed / LSPosed**、接入新 App
> **Telegram 抓消息专文**[docs/Telegram抓消息说明.md](docs/Telegram抓消息说明.md)
> **手机部署与银行 bypass 操作**:详见 [docs/手机操作手册.md](docs/手机操作手册.md)
> **MariBank 风控与 register 载荷**[docs/MariBank风控与载荷说明.md](docs/MariBank风控与载荷说明.md)
> **TNG Money Packet 领取台**[docs/TNG_MoneyPacket领取台.md](docs/TNG_MoneyPacket领取台.md)
## 项目结构
@@ -29,7 +33,9 @@ notiMessage/
├── scripts/ # build-debug.ps1, install-full.ps1, start-debug-server.ps1
├── library/ # 基础库模块 (com.miraclegarden.library)
│ └── MiracleGardenActivity<T> # ViewBinding 基类
├── docs/HOOK_GUIDE.md # Hook 架构与扩展文档
├── docs/Hook指南.md # Hook 架构、Xposed/LSPosed、扩展
├── docs/手机操作手册.md # Root 机部署与日常操作
├── docs/MariBank风控与载荷说明.md # register 字段与风控说明
├── build.gradle # 根构建文件
├── settings.gradle # 模块: app, library, xposed-module
└── gradle.properties # Gradle 配置

View File

@@ -74,6 +74,18 @@
<activity
android:name=".Activity.BankListActivity"
android:exported="false" />
<activity
android:name=".Activity.MmpClaimActivity"
android:exported="false"
android:theme="@style/Theme.SmsMessage1" />
<activity
android:name=".Activity.MmpDetailActivity"
android:exported="false"
android:theme="@style/Theme.SmsMessage1" />
<activity
android:name=".Activity.MmpHelpActivity"
android:exported="false"
android:theme="@style/Theme.SmsMessage1" />
<activity
android:name=".Activity.PermissionActivity"
android:exported="false" />
@@ -112,6 +124,7 @@
android:exported="true">
<intent-filter>
<action android:name="com.miraclegarden.smsmessage.action.HOOK_MESSAGE" />
<action android:name="com.miraclegarden.smsmessage.action.HOOK_STATUS" />
</intent-filter>
</receiver>

View File

@@ -72,6 +72,10 @@ public class MainActivity extends MiracleGardenActivity<ActivityMainBinding> {
startActivity(new Intent(this, BankListActivity.class));
});
binding.btnMmpClaim.setOnClickListener(v -> {
startActivity(new Intent(this, MmpClaimActivity.class));
});
binding.permissionNotificationAccess.setOnClickListener(v -> {
startActivity(new Intent(Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS));
});

View File

@@ -0,0 +1,661 @@
package com.miraclegarden.smsmessage.Activity;
import android.app.DatePickerDialog;
import android.content.Intent;
import android.graphics.Color;
import android.os.Bundle;
import android.os.Handler;
import android.os.Looper;
import android.text.Editable;
import android.text.TextUtils;
import android.text.TextWatcher;
import android.view.View;
import android.widget.TextView;
import android.widget.Toast;
import androidx.annotation.Nullable;
import androidx.appcompat.app.AlertDialog;
import androidx.recyclerview.widget.LinearLayoutManager;
import com.miraclegarden.library.app.MiracleGardenActivity;
import com.miraclegarden.smsmessage.R;
import com.miraclegarden.smsmessage.comm.CommonAdapter;
import com.miraclegarden.smsmessage.comm.ViewHolder;
import com.miraclegarden.smsmessage.databinding.ActivityMmpClaimBinding;
import com.miraclegarden.smsmessage.mmp.MmpHookStatus;
import com.miraclegarden.smsmessage.mmp.MmpLocalSettings;
import com.miraclegarden.smsmessage.mmp.MmpModuleInfo;
import com.miraclegarden.smsmessage.mmp.MmpPacket;
import com.miraclegarden.smsmessage.mmp.MmpPacketStore;
import com.miraclegarden.smsmessage.mmp.MmpSettingsClient;
import com.miraclegarden.smsmessage.mmp.MmpSyncClient;
import com.miraclegarden.smsmessage.mmp.TngProcessHelper;
import org.json.JSONObject;
import java.util.ArrayList;
import java.util.Calendar;
import java.util.List;
import java.util.Locale;
/**
* 手机端红包领取台(独立于通用消息/情况监听)。
*/
public class MmpClaimActivity extends MiracleGardenActivity<ActivityMmpClaimBinding>
implements MmpPacketStore.Listener {
private final List<MmpPacket> allPackets = new ArrayList<>();
private final List<MmpPacket> packets = new ArrayList<>();
private CommonAdapter<MmpPacket> adapter;
private final Handler handler = new Handler(Looper.getMainLooper());
private final Runnable refreshRunnable = this::reload;
/** 0全部 1领取中 2已领完 */
private int statusFilter = 0;
/** 0全部时间 1近1天 2近3天 3近7天 4自定义 */
private int timeMode = 0;
private long customFromMs;
private long customToMs;
private String keyword = "";
private boolean settingsOpen;
private boolean autoWatchWhileOpen = true;
private boolean autoLaunchTngIfKilled = true;
private long lastTngLaunchAt;
/** 0=未处理 1=已唤醒/进历史 2=已强停重开 */
private int hookRecoverStep;
private long lastHookRecoverAt;
private final Runnable watchRunnable = this::watchTngOnce;
/** null=检测中 true=通 false=不通 */
private Boolean debugServerOk;
private long lastDebugPingAt;
@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
JSONObject local = MmpLocalSettings.load(this);
autoWatchWhileOpen = local.optBoolean("autoWatchWhileOpen", true);
autoLaunchTngIfKilled = local.optBoolean("autoLaunchTngIfKilled", true);
initView();
reload();
loadSettingsQuiet();
}
@Override
protected void onResume() {
super.onResume();
MmpPacketStore.addListener(this);
reload();
MmpSyncClient.syncIfStale(this, 5000L);
handler.removeCallbacks(refreshRunnable);
handler.postDelayed(refreshRunnable, 2000);
scheduleWatch();
updateConnStatus();
pingDebugServer();
}
@Override
protected void onPause() {
super.onPause();
MmpPacketStore.removeListener(this);
handler.removeCallbacks(refreshRunnable);
handler.removeCallbacks(watchRunnable);
}
@Override
public void onMmpPacketsChanged() {
runOnUiThread(() -> {
reload();
updateConnStatus();
});
}
private void initView() {
binding.backIv.setOnClickListener(v -> finish());
binding.btnClear.setOnClickListener(v -> confirmClear());
binding.btnHelp.setOnClickListener(v ->
startActivity(new android.content.Intent(this, MmpHelpActivity.class)));
binding.btnSettings.setOnClickListener(v -> toggleSettings());
binding.btnCloseSettings.setOnClickListener(v -> {
settingsOpen = false;
binding.settingsPanel.setVisibility(View.GONE);
});
binding.btnSync.setOnClickListener(v -> syncNow());
binding.btnRefresh.setOnClickListener(v -> manualRefresh());
binding.chipAll.setOnClickListener(v -> setStatusFilter(0));
binding.chipOpen.setOnClickListener(v -> setStatusFilter(1));
binding.chipDone.setOnClickListener(v -> setStatusFilter(2));
binding.chipTimeAll.setOnClickListener(v -> setTimeMode(0));
binding.chipTime1.setOnClickListener(v -> setTimeMode(1));
binding.chipTime3.setOnClickListener(v -> setTimeMode(2));
binding.chipTime7.setOnClickListener(v -> setTimeMode(3));
binding.chipTimeCustom.setOnClickListener(v -> setTimeMode(4));
binding.btnDateFrom.setOnClickListener(v -> pickDate(true));
binding.btnDateTo.setOnClickListener(v -> pickDate(false));
binding.etFilter.addTextChangedListener(new TextWatcher() {
@Override
public void beforeTextChanged(CharSequence s, int start, int count, int after) {
}
@Override
public void onTextChanged(CharSequence s, int start, int before, int count) {
keyword = s != null ? s.toString() : "";
applyFilter();
}
@Override
public void afterTextChanged(Editable s) {
}
});
binding.btnPresetFast.setOnClickListener(v -> applyPreset("fast"));
binding.btnPresetNormal.setOnClickListener(v -> applyPreset("normal"));
binding.btnPresetSlow.setOnClickListener(v -> applyPreset("slow"));
binding.btnSaveSettings.setOnClickListener(v -> saveSettings());
binding.recyclerview.setLayoutManager(new LinearLayoutManager(this));
adapter = new CommonAdapter<MmpPacket>(this, R.layout.item_mmp_packet, packets) {
@Override
public void convert(ViewHolder holder, MmpPacket packet, int index) {
holder.setText(R.id.tv_title, packet.displayTitle());
holder.setText(R.id.tv_sum, formatMoney(packet.sumClaimed > 0
? packet.sumClaimed : parseDouble(packet.total)));
TextView idView = holder.getView(R.id.tv_id);
if (!TextUtils.isEmpty(packet.packetId)) {
idView.setVisibility(View.VISIBLE);
idView.setText("ID " + packet.packetId);
} else {
idView.setVisibility(View.GONE);
idView.setText("");
}
android.widget.TextView status = holder.getView(R.id.tv_status);
status.setText(packet.statusLabel());
if (packet.isFullyClaimed()) {
status.setTextColor(Color.parseColor("#047857"));
status.setBackgroundColor(Color.parseColor("#ECFDF5"));
} else if (packet.isExpired()) {
status.setTextColor(Color.parseColor("#6B7280"));
status.setBackgroundColor(Color.parseColor("#F3F4F6"));
} else {
status.setTextColor(Color.parseColor("#B45309"));
status.setBackgroundColor(Color.parseColor("#FFFBEB"));
}
String meta;
if (packet.effectiveTotalCount() > 0) {
meta = packet.effectiveClaimedCount() + "/" + packet.effectiveTotalCount() + "";
} else {
meta = packet.claimantCount + "";
}
if (packet.issuedAt != null && !packet.issuedAt.isEmpty()) {
meta += " · 发放 " + packet.issuedAt;
}
if (packet.expiresAt != null && !packet.expiresAt.isEmpty()) {
meta += " · 到期 " + packet.expiresAt;
}
holder.setText(R.id.tv_meta, meta);
String hi = packet.finished ? "最佳" : "最高";
String lo = packet.finished ? "最差" : "最低";
com.miraclegarden.smsmessage.mmp.MmpClaim best = packet.bestClaim();
com.miraclegarden.smsmessage.mmp.MmpClaim worst = packet.worstClaim();
holder.setText(R.id.tv_best, formatHiLoLine(hi, best));
if (worst != null && packet.claimantCount > 1) {
holder.setText(R.id.tv_worst, formatHiLoLine(lo, worst));
} else {
holder.setText(R.id.tv_worst, lo + " -");
}
holder.itemView.setOnClickListener(v -> openDetail(packet.packetId));
}
};
binding.recyclerview.setAdapter(adapter);
updateChipUi();
updateTimeChipUi();
}
private void reload() {
allPackets.clear();
allPackets.addAll(MmpPacketStore.getPackets(this));
applyFilter();
updateConnStatus();
handler.removeCallbacks(refreshRunnable);
handler.postDelayed(refreshRunnable, 2000);
}
private void applyFilter() {
packets.clear();
for (MmpPacket p : allPackets) {
if (p.matchesFilter(keyword, statusFilter, timeMode, customFromMs, customToMs)) {
packets.add(p);
}
}
adapter.notifyDataSetChanged();
binding.tvCount.setText(packets.size() + "/" + allPackets.size() + "");
binding.emptyLy.setVisibility(packets.isEmpty() ? View.VISIBLE : View.GONE);
}
private void setStatusFilter(int filter) {
statusFilter = filter;
updateChipUi();
applyFilter();
}
private void setTimeMode(int mode) {
timeMode = mode;
binding.customRangeLy.setVisibility(mode == 4 ? View.VISIBLE : View.GONE);
updateTimeChipUi();
applyFilter();
}
private void pickDate(boolean from) {
Calendar cal = Calendar.getInstance();
long base = from ? customFromMs : customToMs;
if (base > 0) {
cal.setTimeInMillis(base);
}
new DatePickerDialog(this, (view, y, m, d) -> {
Calendar c = Calendar.getInstance();
if (from) {
c.set(y, m, d, 0, 0, 0);
c.set(Calendar.MILLISECOND, 0);
customFromMs = c.getTimeInMillis();
binding.btnDateFrom.setText(String.format(Locale.US, "%04d-%02d-%02d", y, m + 1, d));
} else {
c.set(y, m, d, 23, 59, 59);
c.set(Calendar.MILLISECOND, 999);
customToMs = c.getTimeInMillis();
binding.btnDateTo.setText(String.format(Locale.US, "%04d-%02d-%02d", y, m + 1, d));
}
if (timeMode != 4) {
setTimeMode(4);
} else {
applyFilter();
}
}, cal.get(Calendar.YEAR), cal.get(Calendar.MONTH), cal.get(Calendar.DAY_OF_MONTH)).show();
}
private void updateChipUi() {
styleChip(binding.chipAll, statusFilter == 0, true);
styleChip(binding.chipOpen, statusFilter == 1, true);
styleChip(binding.chipDone, statusFilter == 2, true);
}
private void updateTimeChipUi() {
styleChip(binding.chipTimeAll, timeMode == 0, false);
styleChip(binding.chipTime1, timeMode == 1, false);
styleChip(binding.chipTime3, timeMode == 2, false);
styleChip(binding.chipTime7, timeMode == 3, false);
styleChip(binding.chipTimeCustom, timeMode == 4, false);
}
private static void styleChip(View chip, boolean active, boolean brand) {
if (!(chip instanceof android.widget.TextView)) {
return;
}
android.widget.TextView tv = (android.widget.TextView) chip;
if (active) {
tv.setBackgroundColor(Color.parseColor(brand ? "#C45C26" : "#1C1410"));
tv.setTextColor(Color.WHITE);
} else {
tv.setBackgroundColor(Color.parseColor("#EEEEEE"));
tv.setTextColor(Color.parseColor("#333333"));
}
}
private void syncNow() {
Toast.makeText(this, "正在同步到电脑…", Toast.LENGTH_SHORT).show();
MmpSyncClient.syncToPc(this, (ok, message) -> runOnUiThread(() ->
Toast.makeText(this, message != null ? message : (ok ? "同步成功" : "同步失败"),
Toast.LENGTH_SHORT).show()));
}
private void manualRefresh() {
reload();
MmpSyncClient.syncIfStale(this, 0L);
boolean alive = TngProcessHelper.isRunning(this);
String tip = "已刷新,当前 " + packets.size() + "/" + allPackets.size() + "";
if (!alive) {
tip += "TNG 未运行";
if (autoLaunchTngIfKilled) {
if (TngProcessHelper.launch(this)) {
lastTngLaunchAt = System.currentTimeMillis();
tip += ",已尝试打开";
} else {
tip += ",打开失败";
}
}
} else {
tip += "TNG 在跑";
}
Toast.makeText(this, tip, Toast.LENGTH_SHORT).show();
updateConnStatus();
pingDebugServer();
scheduleWatch();
}
private void scheduleWatch() {
handler.removeCallbacks(watchRunnable);
handler.postDelayed(watchRunnable, 3_000L);
}
private void watchTngOnce() {
if (isFinishing()) {
return;
}
boolean alive = TngProcessHelper.isRunning(this);
if (autoWatchWhileOpen && autoLaunchTngIfKilled) {
if (!alive) {
long now = System.currentTimeMillis();
if (now - lastTngLaunchAt > 60_000L) {
if (TngProcessHelper.launch(this)) {
lastTngLaunchAt = now;
Toast.makeText(this, "检测到 TNG 已退出,正在重新打开…", Toast.LENGTH_SHORT).show();
}
}
} else {
maybeRecoverStaleHook();
}
}
updateConnStatus();
if (System.currentTimeMillis() - lastDebugPingAt > 20_000L) {
pingDebugServer();
}
handler.removeCallbacks(watchRunnable);
handler.postDelayed(watchRunnable, 12_000L);
}
/**
* TNG 还在跑但 Hook 长时间无心跳:仅把 TNG 拉到前台(不强进历史页),
* 仍无效再强停重开。历史页跳转只由 Xposed 短 bounce会自动 finish避免一直把人拽进历史页。
*/
private void maybeRecoverStaleHook() {
long hookAt = MmpHookStatus.lastAliveAt(this);
long age = hookAt > 0 ? System.currentTimeMillis() - hookAt : Long.MAX_VALUE;
if (age <= 3 * 60_000L) {
hookRecoverStep = 0;
return;
}
long now = System.currentTimeMillis();
if (hookRecoverStep == 0) {
if (now - lastHookRecoverAt < 90_000L) {
return;
}
lastHookRecoverAt = now;
hookRecoverStep = 1;
// 不强进 MoneyPacketHistoryActivity用户已在 App 内时会被反复打断
boolean ok = TngProcessHelper.launch(this);
if (ok) {
Toast.makeText(this, "Hook 无心跳,已尝试唤醒 TNG 前台…", Toast.LENGTH_SHORT).show();
}
return;
}
if (hookRecoverStep == 1) {
if (now - lastHookRecoverAt < 2 * 60_000L) {
return;
}
// 唤醒后仍无心跳 → 强停重建
lastHookRecoverAt = now;
hookRecoverStep = 2;
Toast.makeText(this, "仍无心跳,强制重启 TNG…", Toast.LENGTH_SHORT).show();
new Thread(() -> {
boolean ok = TngProcessHelper.forceStopAndLaunch(this);
runOnUiThread(() -> {
if (ok) {
lastTngLaunchAt = System.currentTimeMillis();
} else {
Toast.makeText(this, "强制重启 TNG 失败", Toast.LENGTH_SHORT).show();
hookRecoverStep = 0;
}
});
}, "mmp-hook-recover").start();
return;
}
// step 2 后隔 3 分钟仍无心跳,再重试一轮
if (now - lastHookRecoverAt > 3 * 60_000L) {
hookRecoverStep = 0;
}
}
private void pingDebugServer() {
lastDebugPingAt = System.currentTimeMillis();
MmpSettingsClient.pingHealth(ok -> runOnUiThread(() -> {
debugServerOk = ok;
updateConnStatus();
}));
}
private void updateConnStatus() {
if (binding.tvConnStatus == null) {
return;
}
boolean tngAlive = TngProcessHelper.isRunning(this);
long lastIngest = MmpPacketStore.lastIngestAt(this);
long ageMs = lastIngest > 0 ? System.currentTimeMillis() - lastIngest : -1L;
long hookAge = MmpHookStatus.lastAliveAt(this) > 0
? System.currentTimeMillis() - MmpHookStatus.lastAliveAt(this) : -1L;
// 有近期心跳 = Hook 活着(定时拉取会报活);久无入库不等于故障
boolean hookAlive = hookAge >= 0 && hookAge <= 3 * 60_000L;
boolean modLatest = MmpHookStatus.liveVersionCode(this) >= 0
&& MmpHookStatus.isLiveLatest(this);
boolean modKnownOld = hookAlive
&& MmpHookStatus.liveVersionCode(this) > 0
&& MmpHookStatus.liveVersionCode(this) < MmpModuleInfo.EXPECTED_VERSION_CODE;
String tngPart = tngAlive ? "TNG运行中" : "TNG未运行";
String hookPart;
if (!tngAlive) {
hookPart = "Hook未连接TNG 未开)";
} else if (hookAlive && ageMs >= 0 && ageMs <= 120_000L) {
hookPart = "Hook正常" + formatAge(ageMs) + "前有数据)";
} else if (hookAlive) {
if (ageMs >= 0) {
hookPart = "Hook正常定时拉取中上次数据 " + formatAge(ageMs) + "前)";
} else {
hookPart = "Hook正常定时拉取中尚无入库";
}
} else if (ageMs >= 0 && ageMs <= 10 * 60_000L) {
hookPart = "Hook待确认" + formatAge(ageMs) + "前有数据,心跳偏旧)";
} else if (ageMs < 0) {
hookPart = "Hook待确认无心跳尚无入库";
} else {
hookPart = "Hook异常无心跳" + formatAge(ageMs) + "前曾有数据)";
}
String watchPart = autoWatchWhileOpen
? (autoLaunchTngIfKilled
? "检测:开(退出/无心跳会拉起)"
: "检测:开(不自动拉起)")
: "检测:关";
String debugPart;
if (debugServerOk == null) {
debugPart = "调试台:检测中(可选)";
} else if (debugServerOk) {
debugPart = "调试台:已连接(可选)";
} else {
debugPart = "调试台:未连(可选,不影响领取)";
}
String text = tngPart + " · " + hookPart + "\n"
+ watchPart + " · " + debugPart + "\n"
+ MmpHookStatus.describe(this);
binding.tvConnStatus.setText(text);
// 颜色只看 TNG / Hook 心跳 / 模块版本;调试台可有可无,绝不因此爆红
boolean okAll = tngAlive && hookAlive && modLatest;
boolean bad = !tngAlive || !hookAlive || modKnownOld;
if (okAll) {
binding.tvConnStatus.setBackgroundColor(Color.parseColor("#064E3B"));
binding.tvConnStatus.setTextColor(Color.parseColor("#D1FAE5"));
} else if (bad) {
binding.tvConnStatus.setBackgroundColor(Color.parseColor("#7F1D1D"));
binding.tvConnStatus.setTextColor(Color.parseColor("#FEE2E2"));
} else {
binding.tvConnStatus.setBackgroundColor(Color.parseColor("#78350F"));
binding.tvConnStatus.setTextColor(Color.parseColor("#FEF3C7"));
}
}
private static String formatAge(long ageMs) {
if (ageMs < 60_000L) {
return Math.max(1, ageMs / 1000) + "";
}
if (ageMs < 3600_000L) {
return (ageMs / 60_000L) + "分钟";
}
return (ageMs / 3600_000L) + "小时";
}
private void toggleSettings() {
settingsOpen = !settingsOpen;
binding.settingsPanel.setVisibility(settingsOpen ? View.VISIBLE : View.GONE);
if (settingsOpen) {
loadSettingsQuiet();
}
}
private void loadSettingsQuiet() {
MmpSettingsClient.load(this, (json, error) -> {
if (json != null) {
fillSettingsForm(json);
if (error != null && !error.isEmpty()) {
binding.tvSettingsStatus.setText(error);
} else {
binding.tvSettingsStatus.setText("设置已加载(本地优先,可离线保存)");
}
} else {
fillSettingsForm(MmpLocalSettings.load(this));
binding.tvSettingsStatus.setText(error != null ? error : "已用本地默认设置");
}
});
}
private void fillSettingsForm(JSONObject json) {
binding.cfgHistory.setText(String.valueOf(json.optInt("historyCooldownSec", 8)));
binding.cfgDetail.setText(String.valueOf(json.optInt("detailCooldownSec", 8)));
binding.cfgDedup.setText(String.valueOf(json.optInt("dedupSec", 3)));
binding.cfgGap.setText(String.valueOf(json.optInt("detailGapMs", 80)));
binding.cfgOpenHistory.setChecked(json.optBoolean("openHistoryIfNoTemplate", true));
binding.cfgBounceHistory.setChecked(json.optBoolean("autoBounceHistoryOnDisconnect", true));
binding.cfgAutoWatch.setChecked(json.optBoolean("autoWatchWhileOpen", true));
binding.cfgAutoLaunch.setChecked(json.optBoolean("autoLaunchTngIfKilled", true));
autoWatchWhileOpen = binding.cfgAutoWatch.isChecked();
autoLaunchTngIfKilled = binding.cfgAutoLaunch.isChecked();
}
private void applyPreset(String name) {
try {
JSONObject o = MmpLocalSettings.defaults();
if ("fast".equals(name)) {
o.put("historyCooldownSec", 5);
o.put("detailCooldownSec", 5);
o.put("dedupSec", 2);
o.put("detailGapMs", 50);
o.put("pagePollMs", 800);
} else if ("slow".equals(name)) {
o.put("historyCooldownSec", 30);
o.put("detailCooldownSec", 45);
o.put("dedupSec", 15);
o.put("detailGapMs", 200);
o.put("pagePollMs", 3000);
}
o.put("openHistoryIfNoTemplate", true);
o.put("autoBounceHistoryOnDisconnect", true);
o.put("autoWatchWhileOpen", true);
o.put("autoLaunchTngIfKilled", true);
fillSettingsForm(o);
binding.tvSettingsStatus.setText("已套用预设,点保存生效");
} catch (Exception e) {
binding.tvSettingsStatus.setText("预设失败");
}
}
private void saveSettings() {
try {
JSONObject body = new JSONObject();
body.put("historyCooldownSec", parseIntSafe(binding.cfgHistory.getText().toString(), 8));
body.put("detailCooldownSec", parseIntSafe(binding.cfgDetail.getText().toString(), 8));
body.put("dedupSec", parseIntSafe(binding.cfgDedup.getText().toString(), 3));
body.put("detailGapMs", parseIntSafe(binding.cfgGap.getText().toString(), 80));
body.put("pagePollMs", 1000);
body.put("openHistoryIfNoTemplate", binding.cfgOpenHistory.isChecked());
body.put("autoBounceHistoryOnDisconnect", binding.cfgBounceHistory.isChecked());
body.put("autoWatchWhileOpen", binding.cfgAutoWatch.isChecked());
body.put("autoLaunchTngIfKilled", binding.cfgAutoLaunch.isChecked());
autoWatchWhileOpen = binding.cfgAutoWatch.isChecked();
autoLaunchTngIfKilled = binding.cfgAutoLaunch.isChecked();
binding.tvSettingsStatus.setText("保存中…");
MmpSettingsClient.save(this, body, (json, error) -> {
if (json != null) {
fillSettingsForm(json);
String msg = error != null ? error : "已保存Hook 参数约 10 秒内生效(需调试台)";
binding.tvSettingsStatus.setText(msg);
Toast.makeText(this, "设置已保存", Toast.LENGTH_SHORT).show();
scheduleWatch();
} else {
binding.tvSettingsStatus.setText("保存失败:" + error);
Toast.makeText(this, "保存失败", Toast.LENGTH_SHORT).show();
}
});
} catch (Exception e) {
binding.tvSettingsStatus.setText("保存异常");
}
}
private void openDetail(String packetId) {
Intent intent = new Intent(this, MmpDetailActivity.class);
intent.putExtra(MmpDetailActivity.EXTRA_PACKET_ID, packetId);
startActivity(intent);
}
private static String formatHiLoLine(String prefix,
com.miraclegarden.smsmessage.mmp.MmpClaim claim) {
if (claim == null) {
return prefix + " -";
}
String nick = claim.nickname != null ? claim.nickname : "?";
String amt = claim.amountText != null ? claim.amountText : formatMoney(claim.amount);
if (!TextUtils.isEmpty(claim.userId)) {
return prefix + " " + nick + " " + amt + "\nID " + claim.userId;
}
return prefix + " " + nick + " " + amt;
}
private void confirmClear() {
new AlertDialog.Builder(this)
.setTitle("清空红包台")
.setMessage("仅清空本机红包领取记录,不影响通用消息台。")
.setPositiveButton("清空", (d, w) -> {
MmpPacketStore.clear(this);
Toast.makeText(this, "已清空", Toast.LENGTH_SHORT).show();
reload();
})
.setNegativeButton("取消", null)
.show();
}
private static String formatMoney(double v) {
return String.format(Locale.US, "%.2f", v);
}
private static double parseDouble(String s) {
try {
return s == null || s.isEmpty() ? 0 : Double.parseDouble(s);
} catch (NumberFormatException e) {
return 0;
}
}
private static int parseIntSafe(String s, int def) {
try {
return Integer.parseInt(s.trim());
} catch (Exception e) {
return def;
}
}
}

View File

@@ -0,0 +1,233 @@
package com.miraclegarden.smsmessage.Activity;
import android.graphics.Color;
import android.os.Bundle;
import android.os.Handler;
import android.os.Looper;
import android.text.TextUtils;
import android.view.View;
import android.widget.TextView;
import androidx.annotation.Nullable;
import androidx.recyclerview.widget.LinearLayoutManager;
import com.miraclegarden.library.app.MiracleGardenActivity;
import com.miraclegarden.smsmessage.R;
import com.miraclegarden.smsmessage.comm.CommonAdapter;
import com.miraclegarden.smsmessage.comm.ViewHolder;
import com.miraclegarden.smsmessage.databinding.ActivityMmpDetailBinding;
import com.miraclegarden.smsmessage.mmp.MmpClaim;
import com.miraclegarden.smsmessage.mmp.MmpPacket;
import com.miraclegarden.smsmessage.mmp.MmpPacketStore;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
/**
* 单个红包领取排行(数据优先展示)。
*/
public class MmpDetailActivity extends MiracleGardenActivity<ActivityMmpDetailBinding>
implements MmpPacketStore.Listener {
public static final String EXTRA_PACKET_ID = "packetId";
private String packetId;
private final List<MmpClaim> claims = new ArrayList<>();
private CommonAdapter<MmpClaim> adapter;
private final Handler handler = new Handler(Looper.getMainLooper());
private final Runnable refreshRunnable = this::reload;
private double bestAmount = -1;
private double worstAmount = -1;
@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
packetId = getIntent().getStringExtra(EXTRA_PACKET_ID);
if (TextUtils.isEmpty(packetId)) {
finish();
return;
}
initView();
reload();
}
@Override
protected void onResume() {
super.onResume();
MmpPacketStore.addListener(this);
reload();
}
@Override
protected void onPause() {
super.onPause();
MmpPacketStore.removeListener(this);
handler.removeCallbacks(refreshRunnable);
}
@Override
public void onMmpPacketsChanged() {
runOnUiThread(this::reload);
}
private void initView() {
binding.backIv.setOnClickListener(v -> finish());
binding.recyclerview.setLayoutManager(new LinearLayoutManager(this));
adapter = new CommonAdapter<MmpClaim>(this, R.layout.item_mmp_claim, claims) {
@Override
public void convert(ViewHolder holder, MmpClaim claim, int index) {
holder.setText(R.id.tv_rank, String.valueOf(claim.rank > 0 ? claim.rank : index + 1));
holder.setText(R.id.tv_nickname, claim.nickname != null ? claim.nickname : "?");
TextView userIdView = holder.getView(R.id.tv_user_id);
if (!TextUtils.isEmpty(claim.userId)) {
userIdView.setVisibility(View.VISIBLE);
userIdView.setText("用户ID " + claim.userId);
} else {
userIdView.setVisibility(View.GONE);
userIdView.setText("");
}
TextView poolIdView = holder.getView(R.id.tv_pool_id);
if (!TextUtils.isEmpty(claim.poolId)) {
poolIdView.setVisibility(View.VISIBLE);
poolIdView.setText("领取ID " + claim.poolId);
} else {
poolIdView.setVisibility(View.GONE);
poolIdView.setText("");
}
holder.setText(R.id.tv_amount, claim.amountText != null
? claim.amountText
: String.format(Locale.US, "%.2f", claim.amount));
holder.setText(R.id.tv_time,
TextUtils.isEmpty(claim.claimTime) ? "-" : claim.claimTime);
TextView tag = holder.getView(R.id.tv_tag);
boolean isBest = claims.size() > 1 && bestAmount >= 0
&& Math.abs(claim.amount - bestAmount) < 0.0001;
boolean isWorst = claims.size() > 1 && worstAmount >= 0
&& Math.abs(claim.amount - worstAmount) < 0.0001
&& Math.abs(bestAmount - worstAmount) > 0.0001;
if (isBest) {
tag.setVisibility(View.VISIBLE);
tag.setText("最高");
tag.setTextColor(Color.parseColor("#047857"));
holder.itemView.setBackgroundColor(Color.parseColor("#ECFDF5"));
} else if (isWorst) {
tag.setVisibility(View.VISIBLE);
tag.setText("最低");
tag.setTextColor(Color.parseColor("#B45309"));
holder.itemView.setBackgroundColor(Color.parseColor("#FFFBEB"));
} else {
tag.setVisibility(View.GONE);
holder.itemView.setBackgroundColor(Color.WHITE);
}
}
};
binding.recyclerview.setAdapter(adapter);
}
private void reload() {
MmpPacket packet = MmpPacketStore.getPacket(this, packetId);
if (packet == null) {
binding.tvTitle.setText("红包详情");
binding.tvHeaderMoney.setText("-");
binding.tvSub.setText("记录不存在或已清空");
binding.tvBestName.setText("-");
binding.tvBestAmt.setText("-");
binding.tvWorstName.setText("-");
binding.tvWorstAmt.setText("-");
claims.clear();
adapter.notifyDataSetChanged();
binding.emptyLy.setVisibility(View.VISIBLE);
binding.recyclerview.setVisibility(View.GONE);
return;
}
binding.tvTitle.setText(packet.displayTitle());
binding.tvHeaderMoney.setText(String.format(Locale.US, "%.2f",
packet.sumClaimed > 0 ? packet.sumClaimed : parseDouble(packet.total)));
StringBuilder sub = new StringBuilder();
if (!TextUtils.isEmpty(packet.packetId)) {
sub.append("ID ").append(packet.packetId);
}
if (!TextUtils.isEmpty(packet.issuedAt)) {
if (sub.length() > 0) {
sub.append(" · ");
}
sub.append("发放 ").append(packet.issuedAt);
}
if (!TextUtils.isEmpty(packet.expiresAt)) {
if (sub.length() > 0) {
sub.append(" · ");
}
sub.append("到期 ").append(packet.expiresAt);
}
if (sub.length() > 0) {
sub.append(" · ");
}
sub.append(packet.statusLabel());
binding.tvSub.setText(sub.toString());
binding.tvStatPeople.setText(String.valueOf(packet.claimantCount));
binding.tvStatSum.setText(String.format(Locale.US, "%.2f", packet.sumClaimed));
binding.tvStatTotal.setText(TextUtils.isEmpty(packet.total) ? "-" : packet.total);
String hi = packet.finished ? "手气最佳" : "目前最高";
String lo = packet.finished ? "手气最差" : "目前最低";
binding.tvBestLab.setText(hi);
binding.tvWorstLab.setText(lo);
MmpClaim best = packet.bestClaim();
MmpClaim worst = packet.worstClaim();
if (best != null) {
bestAmount = best.amount;
binding.tvBestName.setText(formatClaimName(best));
binding.tvBestAmt.setText(best.amountText != null ? best.amountText
: String.format(Locale.US, "%.2f", best.amount));
} else {
bestAmount = -1;
binding.tvBestName.setText("-");
binding.tvBestAmt.setText("-");
}
if (worst != null && packet.claimantCount > 1) {
worstAmount = worst.amount;
binding.tvWorstName.setText(formatClaimName(worst));
binding.tvWorstAmt.setText(worst.amountText != null ? worst.amountText
: String.format(Locale.US, "%.2f", worst.amount));
} else {
worstAmount = -1;
binding.tvWorstName.setText("-");
binding.tvWorstAmt.setText("-");
}
claims.clear();
if (packet.leaderboard != null) {
claims.addAll(packet.leaderboard);
}
adapter.notifyDataSetChanged();
boolean empty = claims.isEmpty();
binding.emptyLy.setVisibility(empty ? View.VISIBLE : View.GONE);
binding.recyclerview.setVisibility(empty ? View.GONE : View.VISIBLE);
handler.removeCallbacks(refreshRunnable);
handler.postDelayed(refreshRunnable, 2000);
}
private static String formatClaimName(MmpClaim claim) {
if (claim == null) {
return "-";
}
String nick = claim.nickname != null ? claim.nickname : "?";
if (!TextUtils.isEmpty(claim.userId)) {
return nick + "\nID " + claim.userId;
}
return nick;
}
private static double parseDouble(String s) {
try {
return s == null || s.isEmpty() ? 0 : Double.parseDouble(s);
} catch (NumberFormatException e) {
return 0;
}
}
}

View File

@@ -0,0 +1,20 @@
package com.miraclegarden.smsmessage.Activity;
import android.os.Bundle;
import androidx.annotation.Nullable;
import com.miraclegarden.library.app.MiracleGardenActivity;
import com.miraclegarden.smsmessage.databinding.ActivityMmpHelpBinding;
/**
* 红包领取台功能说明页。
*/
public class MmpHelpActivity extends MiracleGardenActivity<ActivityMmpHelpBinding> {
@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
binding.backIv.setOnClickListener(v -> finish());
}
}

View File

@@ -12,11 +12,18 @@ public final class AppConfig {
public static final boolean ENABLE_DEBUG_FORWARD = true;
/**
* 调试服务地址。
* 调试服务地址(可同时配置 USB 本机 + 局域网)
* USB + adb reverse: http://127.0.0.1:8765
* Wi-Fi: http://&lt;电脑局域网IP&gt;:8765
* 会向列表中每个地址各推送一次;不可达的静默失败。
*/
public static final String DEBUG_SERVER_URL = "http://127.0.0.1:8765";
public static final String[] DEBUG_SERVER_URLS = {
"http://127.0.0.1:8765",
"http://10.151.104.25:8765",
};
/** 兼容旧引用:取第一个调试地址 */
public static final String DEBUG_SERVER_URL = DEBUG_SERVER_URLS[0];
/** 是否定期唤醒监听列表中的 App保持进程 / Hook 加载) */
public static final boolean ENABLE_MONITORED_APP_KEEP_ALIVE = true;

View File

@@ -0,0 +1,33 @@
package com.miraclegarden.smsmessage.mmp;
/**
* 单个红包领取人。
*/
public class MmpClaim {
public String nickname;
/** TNG receiverId重名时用来区分 */
public String userId;
/** activityPoolId单次领取池条目 */
public String poolId;
public String amountText;
public double amount;
public String claimTime;
public int rank;
public MmpClaim() {
}
public MmpClaim(String nickname, double amount, String claimTime) {
this.nickname = nickname;
this.amount = amount;
this.amountText = String.format(java.util.Locale.US, "%.2f", amount);
this.claimTime = claimTime != null ? claimTime : "";
}
public String shortUserId() {
if (userId == null || userId.isEmpty()) {
return "";
}
return userId.length() <= 10 ? userId : userId.substring(0, 8) + "";
}
}

View File

@@ -0,0 +1,136 @@
package com.miraclegarden.smsmessage.mmp;
import android.content.Context;
import android.content.SharedPreferences;
import android.content.pm.PackageInfo;
import android.content.pm.PackageManager;
import android.text.TextUtils;
/**
* 记录 TNG 进程内 Hook 心跳,用于判断是否挂上最新模块。
*/
public final class MmpHookStatus {
private static final String PREF = "mmp_hook_status";
private static volatile int sLiveCode;
private static volatile String sLiveName = "";
private static volatile long sLiveAt;
private static volatile String sHostPkg = "";
private MmpHookStatus() {
}
public static void noteAlive(Context context, int versionCode, String versionName,
String hostPackage) {
sLiveCode = versionCode;
sLiveName = versionName != null ? versionName : "";
sLiveAt = System.currentTimeMillis();
sHostPkg = hostPackage != null ? hostPackage : "";
if (context == null) {
return;
}
prefs(context).edit()
.putInt("live_code", sLiveCode)
.putString("live_name", sLiveName)
.putLong("live_at", sLiveAt)
.putString("host_pkg", sHostPkg)
.apply();
}
public static void ensureLoaded(Context context) {
if (sLiveAt > 0 || context == null) {
return;
}
SharedPreferences sp = prefs(context);
sLiveCode = sp.getInt("live_code", 0);
sLiveName = sp.getString("live_name", "");
sLiveAt = sp.getLong("live_at", 0L);
sHostPkg = sp.getString("host_pkg", "");
}
public static long lastAliveAt(Context context) {
ensureLoaded(context);
return sLiveAt;
}
public static int liveVersionCode(Context context) {
ensureLoaded(context);
return sLiveCode;
}
public static String liveVersionName(Context context) {
ensureLoaded(context);
return sLiveName != null ? sLiveName : "";
}
public static int installedVersionCode(Context context) {
if (context == null) {
return 0;
}
try {
PackageInfo pi = context.getPackageManager()
.getPackageInfo(MmpModuleInfo.XPOSED_PACKAGE, 0);
return pi != null ? pi.versionCode : 0;
} catch (PackageManager.NameNotFoundException e) {
return 0;
}
}
public static String installedVersionName(Context context) {
if (context == null) {
return "";
}
try {
PackageInfo pi = context.getPackageManager()
.getPackageInfo(MmpModuleInfo.XPOSED_PACKAGE, 0);
return pi != null && pi.versionName != null ? pi.versionName : "";
} catch (PackageManager.NameNotFoundException e) {
return "";
}
}
/**
* @return 人类可读模块状态
*/
public static String describe(Context context) {
ensureLoaded(context);
int installed = installedVersionCode(context);
String installedName = installedVersionName(context);
if (installed <= 0) {
return "模块APK未安装";
}
String apkPart = "模块APK" + (TextUtils.isEmpty(installedName)
? String.valueOf(installed) : installedName);
boolean apkLatest = installed >= MmpModuleInfo.EXPECTED_VERSION_CODE;
if (!apkLatest) {
apkPart += "(旧,期望" + MmpModuleInfo.EXPECTED_VERSION_NAME + ")";
}
long age = sLiveAt > 0 ? System.currentTimeMillis() - sLiveAt : -1L;
String livePart;
if (age < 0) {
livePart = "运行中:未检测到(请强停再开 TNG";
} else if (age > 3 * 60_000L) {
livePart = "运行中:心跳偏旧(" + (age / 60_000L) + "分钟前 "
+ (TextUtils.isEmpty(sLiveName) ? sLiveCode : sLiveName) + ")";
} else if (sLiveCode >= MmpModuleInfo.EXPECTED_VERSION_CODE) {
livePart = "运行中:最新(" + (TextUtils.isEmpty(sLiveName) ? sLiveCode : sLiveName) + ")";
} else if (sLiveCode > 0) {
livePart = "运行中:旧版(" + (TextUtils.isEmpty(sLiveName) ? sLiveCode : sLiveName)
+ ",期望" + MmpModuleInfo.EXPECTED_VERSION_NAME + ")";
} else {
livePart = "运行中:未知";
}
return apkPart + " · " + livePart;
}
public static boolean isLiveLatest(Context context) {
ensureLoaded(context);
long age = sLiveAt > 0 ? System.currentTimeMillis() - sLiveAt : -1L;
return age >= 0 && age <= 3 * 60_000L && sLiveCode >= MmpModuleInfo.EXPECTED_VERSION_CODE;
}
private static SharedPreferences prefs(Context context) {
return context.getApplicationContext().getSharedPreferences(PREF, Context.MODE_PRIVATE);
}
}

View File

@@ -0,0 +1,85 @@
package com.miraclegarden.smsmessage.mmp;
import android.content.Context;
import android.content.SharedPreferences;
import android.text.TextUtils;
import org.json.JSONObject;
/**
* 领取台本地设置(不依赖电脑调试台也能读写)。
*/
public final class MmpLocalSettings {
private static final String PREF = "mmp_settings_local";
private MmpLocalSettings() {
}
public static JSONObject defaults() {
JSONObject o = new JSONObject();
try {
o.put("historyCooldownSec", 8);
o.put("detailCooldownSec", 8);
o.put("dedupSec", 3);
o.put("detailGapMs", 80);
o.put("pagePollMs", 1000);
o.put("openHistoryIfNoTemplate", true);
o.put("autoBounceHistoryOnDisconnect", true);
o.put("autoLaunchTngIfKilled", true);
o.put("autoWatchWhileOpen", true);
} catch (Exception ignored) {
}
return o;
}
public static JSONObject load(Context context) {
JSONObject out = defaults();
if (context == null) {
return out;
}
SharedPreferences sp = prefs(context);
try {
String raw = sp.getString("json", null);
if (!TextUtils.isEmpty(raw)) {
JSONObject saved = new JSONObject(raw);
mergeInto(out, saved);
}
} catch (Exception ignored) {
}
return out;
}
public static void save(Context context, JSONObject json) {
if (context == null || json == null) {
return;
}
JSONObject merged = defaults();
mergeInto(merged, json);
prefs(context).edit().putString("json", merged.toString()).apply();
}
public static void mergeInto(JSONObject target, JSONObject src) {
if (target == null || src == null) {
return;
}
String[] keys = {
"historyCooldownSec", "detailCooldownSec", "dedupSec", "detailGapMs", "pagePollMs",
"openHistoryIfNoTemplate", "autoBounceHistoryOnDisconnect",
"autoLaunchTngIfKilled", "autoWatchWhileOpen"
};
for (String k : keys) {
if (!src.has(k)) {
continue;
}
try {
target.put(k, src.get(k));
} catch (Exception ignored) {
}
}
}
private static SharedPreferences prefs(Context context) {
return context.getApplicationContext().getSharedPreferences(PREF, Context.MODE_PRIVATE);
}
}

View File

@@ -0,0 +1,13 @@
package com.miraclegarden.smsmessage.mmp;
/**
* Xposed 模块版本约定(与 xposed-module/build.gradle 同步)。
*/
public final class MmpModuleInfo {
public static final String XPOSED_PACKAGE = "com.miraclegarden.smsmessage.xposed";
public static final int EXPECTED_VERSION_CODE = 5;
public static final String EXPECTED_VERSION_NAME = "1.2.2";
private MmpModuleInfo() {
}
}

View File

@@ -0,0 +1,292 @@
package com.miraclegarden.smsmessage.mmp;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
/**
* 按 activityId 聚合的红包快照。
*/
public class MmpPacket {
public String packetId;
public String title;
public String sender;
public String group;
public String total;
public String via;
public String issuedAt;
public String expiresAt;
/** TNG activityStatusACTIVE / FINISHED / ENDED / CLOSED / CANCELLED … */
public String status;
public String updatedAt;
public boolean finished;
public int snapshots;
/** 已领人数(来自 TNG claimedCount缺省用名单人数 */
public int claimedCount;
/** 红包总份数TNG totalCount */
public int totalCount;
public int claimantCount;
public double sumClaimed;
public List<MmpClaim> leaderboard = new ArrayList<>();
public MmpPacket() {
}
public String displayTitle() {
if (sender != null && !sender.isEmpty()) {
return sender + " 的红包";
}
if (title != null && !title.isEmpty()) {
return title;
}
return "红包";
}
public String shortId() {
if (packetId == null || packetId.isEmpty()) {
return "-";
}
if (packetId.length() >= 8 && packetId.indexOf('-') > 0) {
return packetId.substring(0, 8);
}
return packetId.length() > 16 ? packetId.substring(0, 16) : packetId;
}
/** 手气最佳(金额最大);排行已按金额降序时取首位。 */
public MmpClaim bestClaim() {
if (leaderboard == null || leaderboard.isEmpty()) {
return null;
}
MmpClaim best = leaderboard.get(0);
for (MmpClaim c : leaderboard) {
if (c != null && c.amount > best.amount) {
best = c;
}
}
return best;
}
/** 手气最差(金额最小)。 */
public MmpClaim worstClaim() {
if (leaderboard == null || leaderboard.isEmpty()) {
return null;
}
MmpClaim worst = leaderboard.get(0);
for (MmpClaim c : leaderboard) {
if (c != null && c.amount < worst.amount) {
worst = c;
}
}
return worst;
}
public String luckSummaryLine() {
refreshClosedState();
MmpClaim best = bestClaim();
MmpClaim worst = worstClaim();
boolean closed = finished;
String hi = closed ? "手气最佳" : "目前最高";
String lo = closed ? "手气最差" : "目前最低";
if (best == null) {
return hi + "- " + lo + "-";
}
String bn = best.nickname != null ? best.nickname : "?";
String ba = best.amountText != null ? best.amountText
: String.format(Locale.US, "%.2f", best.amount);
if (worst == null || leaderboard.size() == 1) {
return hi + "" + bn + "" + ba + " " + lo + "-";
}
String wn = worst.nickname != null ? worst.nickname : "?";
String wa = worst.amountText != null ? worst.amountText
: String.format(Locale.US, "%.2f", worst.amount);
return hi + "" + bn + "" + ba + " " + lo + "" + wn + "" + wa + "";
}
/** 根据 expire/status/人数 刷新 finished过期未领完也会标结束 */
public void refreshClosedState() {
if (isFullyClaimed()) {
finished = true;
return;
}
if (finished) {
return;
}
if (isTerminalStatus(status) || isExpirePassed()) {
finished = true;
}
}
public boolean isExpired() {
if (isExpirePassed()) {
return true;
}
if (status == null) {
return false;
}
String s = status.toUpperCase(Locale.US);
return s.contains("EXPIRE") || s.contains("ENDED") || s.contains("CLOSED")
|| s.contains("CANCEL");
}
public boolean isFullyClaimed() {
int claimed = effectiveClaimedCount();
int total = effectiveTotalCount();
return total > 0 && claimed >= total;
}
public int effectiveClaimedCount() {
return Math.max(claimedCount, claimantCount);
}
public int effectiveTotalCount() {
return Math.max(0, totalCount);
}
/**
* 优先展示领取进度,过期放后面:
* 已全部领取 / 已领取 N/M 人 · 已过期
*/
public String statusLabel() {
refreshClosedState();
String progress;
if (isFullyClaimed()) {
progress = "已全部领取";
} else {
int claimed = effectiveClaimedCount();
int total = effectiveTotalCount();
if (total > 0) {
progress = "已领取 " + claimed + "/" + total + "";
} else if (claimed > 0) {
progress = "已领取 " + claimed + "";
} else {
progress = finished ? "未领取" : "领取中";
}
}
if (isExpired() && !isFullyClaimed()) {
return progress + " · 已过期";
}
return progress;
}
private boolean isExpirePassed() {
long ms = parseIssueMs(expiresAt);
return ms > 0 && System.currentTimeMillis() >= ms;
}
private static boolean isTerminalStatus(String status) {
if (status == null || status.isEmpty()) {
return false;
}
String s = status.toUpperCase(Locale.US);
return s.contains("FINISH") || s.contains("COMPLETE") || s.contains("EXPIRE")
|| s.contains("ENDED") || s.contains("CLOSED") || s.contains("CANCEL")
|| s.contains("DONE");
}
public boolean matchesFilter(String keyword, int statusFilter) {
return matchesFilter(keyword, statusFilter, 0, 0L, 0L);
}
/**
* @param timeMode 0全部 1近1天 2近3天 3近7天 4自定义
* @param customFromMs / customToMs 自定义起止(含当天),未用传 0
*/
public boolean matchesFilter(String keyword, int statusFilter,
int timeMode, long customFromMs, long customToMs) {
refreshClosedState();
if (statusFilter == 1 && finished) {
return false;
}
if (statusFilter == 2 && !finished) {
return false;
}
if (!matchesTime(timeMode, customFromMs, customToMs)) {
return false;
}
if (keyword == null || keyword.trim().isEmpty()) {
return true;
}
String q = keyword.trim().toLowerCase(Locale.US);
if (containsIgnoreCase(packetId, q) || containsIgnoreCase(sender, q)
|| containsIgnoreCase(title, q) || containsIgnoreCase(group, q)
|| containsIgnoreCase(total, q)) {
return true;
}
if (leaderboard != null) {
for (MmpClaim c : leaderboard) {
if (c != null && (containsIgnoreCase(c.nickname, q)
|| containsIgnoreCase(c.userId, q)
|| containsIgnoreCase(c.poolId, q))) {
return true;
}
}
}
return false;
}
private boolean matchesTime(int timeMode, long customFromMs, long customToMs) {
if (timeMode <= 0) {
return true;
}
long ms = parseIssueMs(issuedAt);
if (ms <= 0) {
ms = parseIssueMs(updatedAt);
}
if (ms <= 0) {
return false;
}
long now = System.currentTimeMillis();
if (timeMode == 1) {
return ms >= now - 1L * 24 * 3600 * 1000;
}
if (timeMode == 2) {
return ms >= now - 3L * 24 * 3600 * 1000;
}
if (timeMode == 3) {
return ms >= now - 7L * 24 * 3600 * 1000;
}
if (timeMode == 4) {
if (customFromMs > 0 && ms < customFromMs) {
return false;
}
if (customToMs > 0 && ms > customToMs) {
return false;
}
return true;
}
return true;
}
/** 解析发放时间13/07/2026 11:39:15 或 yyyy-MM-dd… */
public static long parseIssueMs(String text) {
if (text == null || text.trim().isEmpty()) {
return 0;
}
String s = text.trim();
try {
java.text.SimpleDateFormat[] formats = new java.text.SimpleDateFormat[]{
new java.text.SimpleDateFormat("dd/MM/yyyy HH:mm:ss", Locale.US),
new java.text.SimpleDateFormat("dd/MM/yyyy HH:mm", Locale.US),
new java.text.SimpleDateFormat("dd/MM/yyyy", Locale.US),
new java.text.SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US),
new java.text.SimpleDateFormat("yyyy-MM-dd", Locale.US),
};
for (java.text.SimpleDateFormat f : formats) {
f.setLenient(true);
try {
java.util.Date d = f.parse(s);
if (d != null) {
return d.getTime();
}
} catch (Exception ignored) {
}
}
} catch (Exception ignored) {
}
return 0;
}
private static boolean containsIgnoreCase(String hay, String needle) {
return hay != null && hay.toLowerCase(Locale.US).contains(needle);
}
}

View File

@@ -0,0 +1,805 @@
package com.miraclegarden.smsmessage.mmp;
import android.content.Context;
import android.content.SharedPreferences;
import android.text.TextUtils;
import android.util.Log;
import org.json.JSONArray;
import org.json.JSONObject;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* 本地红包领取台数据:解析 Hook 的 [MMP统计] 文本,按 activityId 持久化。
*/
public final class MmpPacketStore {
private static final String TAG = "MmpPacketStore";
private static final String PREF = "mmp_packets";
private static final String KEY_JSON = "packets_json";
private static final String KEY_LAST_INGEST = "last_ingest_at";
private static final int MAX_PACKETS = 200;
private static final Pattern AMOUNT_JSON = Pattern.compile(
"\"amount\"\\s*:\\s*\"?([0-9]+(?:\\.[0-9]+)?)\"?");
private static final Pattern AMOUNT_PLAIN = Pattern.compile("([0-9]+(?:\\.[0-9]+)?)");
public interface Listener {
void onMmpPacketsChanged();
}
private static final CopyOnWriteArrayList<Listener> LISTENERS = new CopyOnWriteArrayList<>();
private static volatile long sLastIngestAt;
private MmpPacketStore() {
}
public static void addListener(Listener listener) {
if (listener != null) {
LISTENERS.add(listener);
}
}
public static void removeListener(Listener listener) {
LISTENERS.remove(listener);
}
public static boolean isMmpContent(String content, String source) {
if (source != null && source.toLowerCase(Locale.US).contains("tng_mmp")) {
return true;
}
return content != null && content.contains("[MMP统计]");
}
/** 解析并写入;非 MMP 或无领取人则忽略。 */
public static boolean ingest(Context context, String title, String content, String source) {
if (context == null || TextUtils.isEmpty(content) || !isMmpContent(content, source)) {
return false;
}
ParseResult parsed = parse(content);
noteModuleFromMeta(context, parsed.meta);
String packetId = parsed.meta.get("packet");
if (TextUtils.isEmpty(packetId)) {
packetId = parsed.meta.get("packetId");
}
boolean hasRealPacketId = !TextUtils.isEmpty(packetId)
&& !"unknown".equals(packetId)
&& !"TNG 红包".equals(packetId)
&& !"TNG Money Packet".equals(packetId);
// 允许无人领取的新红包入库(只有 packet/metaclaims 为空)
if (parsed.claims.isEmpty() && !hasRealPacketId) {
return false;
}
if (!hasRealPacketId) {
packetId = "红包-" + claimsFingerprint(parsed.claims);
if (packetId.length() > 56) {
packetId = packetId.substring(0, 56);
}
}
MmpPacket incoming = new MmpPacket();
incoming.packetId = packetId;
incoming.title = TextUtils.isEmpty(title) ? "TNG 红包" : title;
incoming.sender = nullToEmpty(parsed.meta.get("sender"));
incoming.group = nullToEmpty(parsed.meta.get("group"));
incoming.total = nullToEmpty(parsed.meta.get("total"));
incoming.via = nullToEmpty(parsed.meta.get("via"));
incoming.issuedAt = firstNonEmpty(parsed.meta, "issued", "createTime", "issuedAt", "create");
incoming.expiresAt = firstNonEmpty(parsed.meta, "expire", "expireTime", "expiry", "expiresAt");
incoming.status = firstNonEmpty(parsed.meta, "status", "activityStatus");
parseCountsMeta(incoming, parsed.meta.get("counts"));
if (incoming.claimedCount <= 0) {
tryParseIntField(incoming, parsed.meta, "claimed", "claimedCount");
}
if (incoming.totalCount <= 0) {
tryParseIntField(incoming, parsed.meta, "slots", "totalCount", "quantity");
}
incoming.finished = "1".equals(parsed.meta.get("done"))
|| "true".equalsIgnoreCase(parsed.meta.get("done"))
|| isTerminalStatusMeta(incoming.status);
incoming.refreshClosedState();
incoming.updatedAt = nowText();
incoming.snapshots = 1;
List<MmpClaim> ranked = aggregateClaims(parsed.claims);
incoming.leaderboard = ranked;
incoming.claimantCount = ranked.size();
if (incoming.claimedCount <= 0) {
incoming.claimedCount = ranked.size();
}
if (incoming.totalCount > 0 && incoming.claimedCount >= incoming.totalCount) {
incoming.finished = true;
}
double sum = 0;
for (MmpClaim c : ranked) {
sum += c.amount;
}
incoming.sumClaimed = Math.round(sum * 10000.0) / 10000.0;
Double totalNum = normalizeMoney(incoming.total);
if (totalNum == null || (incoming.sumClaimed > 0
&& Math.abs(totalNum - incoming.sumClaimed) > 0.001
&& totalNum <= maxClaimAmount(ranked) + 1e-9)) {
incoming.total = String.format(Locale.US, "%.2f", incoming.sumClaimed);
} else {
incoming.total = String.format(Locale.US, "%.2f", totalNum);
}
synchronized (MmpPacketStore.class) {
Map<String, MmpPacket> map = loadMap(context);
MmpPacket existing = map.get(packetId);
if (existing != null) {
if (score(incoming) >= score(existing)) {
incoming.snapshots = existing.snapshots + 1;
if (TextUtils.isEmpty(incoming.issuedAt) && !TextUtils.isEmpty(existing.issuedAt)) {
incoming.issuedAt = existing.issuedAt;
}
if (TextUtils.isEmpty(incoming.expiresAt) && !TextUtils.isEmpty(existing.expiresAt)) {
incoming.expiresAt = existing.expiresAt;
}
if (TextUtils.isEmpty(incoming.status) && !TextUtils.isEmpty(existing.status)) {
incoming.status = existing.status;
}
if (incoming.totalCount <= 0 && existing.totalCount > 0) {
incoming.totalCount = existing.totalCount;
}
if (incoming.claimedCount < existing.claimedCount) {
// 保留更大的已领人数
incoming.claimedCount = existing.claimedCount;
}
if (!incoming.finished && existing.finished) {
incoming.finished = true;
}
incoming.refreshClosedState();
map.put(packetId, incoming);
} else {
existing.snapshots = existing.snapshots + 1;
if (TextUtils.isEmpty(existing.issuedAt) && !TextUtils.isEmpty(incoming.issuedAt)) {
existing.issuedAt = incoming.issuedAt;
}
existing.updatedAt = nowText();
}
} else {
map.put(packetId, incoming);
}
trimMap(map);
saveMap(context, map);
}
notifyListeners();
sLastIngestAt = System.currentTimeMillis();
try {
prefs(context).edit().putLong(KEY_LAST_INGEST, sLastIngestAt).apply();
} catch (Exception ignored) {
}
Log.i(TAG, "ingest packet=" + packetId + " claims=" + ranked.size());
try {
MmpSyncClient.syncIfStale(context.getApplicationContext(), 3000L);
} catch (Exception e) {
Log.w(TAG, "auto sync skip", e);
}
return true;
}
/** Hook 最近一次成功写入本地的时间戳0 表示尚无 */
public static long lastIngestAt(Context context) {
if (sLastIngestAt > 0) {
return sLastIngestAt;
}
if (context == null) {
return 0L;
}
try {
long v = prefs(context).getLong(KEY_LAST_INGEST, 0L);
if (v > 0) {
sLastIngestAt = v;
}
return v;
} catch (Exception e) {
return 0L;
}
}
public static List<MmpPacket> getPackets(Context context) {
synchronized (MmpPacketStore.class) {
List<MmpPacket> list = new ArrayList<>(loadMap(context).values());
Collections.sort(list, new Comparator<MmpPacket>() {
@Override
public int compare(MmpPacket a, MmpPacket b) {
// DD/MM/YYYY 不能按字符串比;按真实时间降序,最新在前
long ta = sortTimeMs(a);
long tb = sortTimeMs(b);
if (tb != ta) {
return Long.compare(tb, ta);
}
return nullToEmpty(b.packetId).compareTo(nullToEmpty(a.packetId));
}
});
for (MmpPacket p : list) {
if (p != null) {
p.refreshClosedState();
}
}
return list;
}
}
private static long sortTimeMs(MmpPacket p) {
if (p == null) {
return 0L;
}
long ms = MmpPacket.parseIssueMs(p.issuedAt);
if (ms <= 0) {
ms = MmpPacket.parseIssueMs(p.updatedAt);
}
return ms;
}
public static MmpPacket getPacket(Context context, String packetId) {
if (TextUtils.isEmpty(packetId)) {
return null;
}
synchronized (MmpPacketStore.class) {
MmpPacket p = loadMap(context).get(packetId);
if (p != null) {
p.refreshClosedState();
}
return p;
}
}
public static void clear(Context context) {
synchronized (MmpPacketStore.class) {
prefs(context).edit().remove(KEY_JSON).apply();
}
notifyListeners();
}
/** 导出给 PC /api/mmp/sync 的全量 JSON。 */
public static JSONArray exportJsonArray(Context context) {
JSONArray arr = new JSONArray();
synchronized (MmpPacketStore.class) {
for (MmpPacket p : loadMap(context).values()) {
try {
arr.put(toJson(p));
} catch (Exception e) {
Log.e(TAG, "export packet failed", e);
}
}
}
return arr;
}
// --- parse ---
/** 从 header 的 mod=1.2.0/3 更新 Hook 运行版本(兜底) */
private static void noteModuleFromMeta(Context context, Map<String, String> meta) {
if (context == null || meta == null) {
return;
}
String mod = meta.get("mod");
if (TextUtils.isEmpty(mod)) {
return;
}
int code = 0;
String name = mod.trim();
int slash = name.lastIndexOf('/');
if (slash >= 0 && slash + 1 < name.length()) {
String codePart = name.substring(slash + 1).trim();
name = name.substring(0, slash).trim();
try {
code = Integer.parseInt(codePart);
} catch (NumberFormatException ignored) {
code = 0;
}
}
MmpHookStatus.noteAlive(context.getApplicationContext(), code, name, "my.com.tngdigital.ewallet");
}
private static final class ParseResult {
final Map<String, String> meta = new LinkedHashMap<>();
final List<RawClaim> claims = new ArrayList<>();
}
private static final class RawClaim {
String nickname;
String userId;
String poolId;
double amount;
String claimTime;
}
private static ParseResult parse(String content) {
ParseResult out = new ParseResult();
String text = content.trim();
String[] lines = text.split("\\r?\\n");
if (lines.length == 0) {
return out;
}
String head = lines[0].trim();
if (head.startsWith("[MMP统计]")) {
head = head.substring("[MMP统计]".length()).trim();
}
List<String> parts;
if (head.contains(" | ")) {
parts = splitPipe(head);
} else {
parts = splitLegacy(head);
}
for (String part : parts) {
int eq = part.indexOf('=');
if (eq <= 0) {
continue;
}
out.meta.put(part.substring(0, eq).trim(), part.substring(eq + 1).trim());
}
// sender 被空格截断时尽量还原
String sender = out.meta.get("sender");
if (sender != null && head.contains("sender=")) {
String raw = head.substring(head.indexOf("sender=") + "sender=".length());
for (String stop : new String[]{" | ", " total=", " via=", " group=", " packet=", " src=", " issued="}) {
int i = raw.indexOf(stop);
if (i >= 0) {
raw = raw.substring(0, i);
break;
}
}
raw = raw.trim();
if (raw.length() > sender.length()) {
out.meta.put("sender", raw);
}
}
Double totalNum = normalizeMoney(out.meta.get("total"));
if (totalNum != null) {
out.meta.put("total", String.format(Locale.US, "%.2f", totalNum));
}
for (int i = 1; i < lines.length; i++) {
String line = lines[i].trim();
if (line.isEmpty() || !line.contains("->")) {
continue;
}
int arrow = line.indexOf("->");
String nick = line.substring(0, arrow).trim();
String right = line.substring(arrow + 2).trim();
String userId = "";
String poolId = "";
int ridAt = right.indexOf(" #rid=");
if (ridAt >= 0) {
String rest = right.substring(ridAt + 6).trim();
right = right.substring(0, ridAt).trim();
int sp = rest.indexOf(' ');
if (sp > 0) {
userId = rest.substring(0, sp).trim();
String more = rest.substring(sp + 1).trim();
if (more.startsWith("#pool=")) {
poolId = more.substring(6).trim();
}
} else {
userId = rest;
}
}
int poolAt = right.indexOf(" #pool=");
if (poolAt >= 0 && TextUtils.isEmpty(poolId)) {
poolId = right.substring(poolAt + 7).trim();
right = right.substring(0, poolAt).trim();
}
// 兼容 #rid= 后仍有 #pool=
int poolInId = userId.indexOf("#pool=");
if (poolInId >= 0) {
poolId = userId.substring(poolInId + 6).trim();
userId = userId.substring(0, poolInId).trim();
}
String claimTime = "";
String amountRaw = right;
if (right.endsWith(")") && right.contains("(") && !right.startsWith("{")) {
int lp = right.lastIndexOf('(');
amountRaw = right.substring(0, lp).trim();
claimTime = right.substring(lp + 1, right.length() - 1).trim();
}
Double amt = normalizeMoney(amountRaw);
if (!TextUtils.isEmpty(nick) || !TextUtils.isEmpty(userId)) {
RawClaim c = new RawClaim();
c.nickname = TextUtils.isEmpty(nick) ? "?" : nick;
c.userId = userId;
c.poolId = poolId;
c.amount = amt != null ? amt : 0;
c.claimTime = claimTime;
out.claims.add(c);
}
}
return out;
}
private static List<String> splitPipe(String head) {
String[] arr = head.split(" \\| ");
List<String> parts = new ArrayList<>();
for (String p : arr) {
if (!TextUtils.isEmpty(p.trim())) {
parts.add(p.trim());
}
}
return parts;
}
private static List<String> splitLegacy(String head) {
List<String> parts = new ArrayList<>();
String buf = "";
for (String part : head.split(" ")) {
if (!buf.isEmpty()) {
buf += " " + part;
if (countChar(buf, '{') <= countChar(buf, '}')) {
parts.add(buf);
buf = "";
}
continue;
}
if (part.contains("=")) {
String v = part.substring(part.indexOf('=') + 1);
if (v.startsWith("{") && countChar(part, '{') > countChar(part, '}')) {
buf = part;
continue;
}
}
parts.add(part);
}
if (!buf.isEmpty()) {
parts.add(buf);
}
return parts;
}
private static int countChar(String s, char c) {
int n = 0;
for (int i = 0; i < s.length(); i++) {
if (s.charAt(i) == c) {
n++;
}
}
return n;
}
private static List<MmpClaim> aggregateClaims(List<RawClaim> claims) {
LinkedHashMap<String, MmpClaim> buckets = new LinkedHashMap<>();
for (RawClaim c : claims) {
String nick = c.nickname != null ? c.nickname : "?";
String key = !TextUtils.isEmpty(c.userId) ? ("id:" + c.userId) : ("n:" + nick);
MmpClaim b = buckets.get(key);
if (b == null) {
MmpClaim created = new MmpClaim(nick, c.amount, c.claimTime);
created.userId = c.userId;
created.poolId = c.poolId;
buckets.put(key, created);
continue;
}
if (c.amount >= b.amount) {
b.amount = c.amount;
b.amountText = String.format(Locale.US, "%.2f", c.amount);
if (!TextUtils.isEmpty(c.claimTime)) {
b.claimTime = c.claimTime;
}
if (!TextUtils.isEmpty(c.userId)) {
b.userId = c.userId;
}
if (!TextUtils.isEmpty(c.poolId)) {
b.poolId = c.poolId;
}
if (!TextUtils.isEmpty(nick) && !"?".equals(nick)) {
b.nickname = nick;
}
} else {
if (TextUtils.isEmpty(b.claimTime) && !TextUtils.isEmpty(c.claimTime)) {
b.claimTime = c.claimTime;
}
if (TextUtils.isEmpty(b.userId) && !TextUtils.isEmpty(c.userId)) {
b.userId = c.userId;
}
if (TextUtils.isEmpty(b.poolId) && !TextUtils.isEmpty(c.poolId)) {
b.poolId = c.poolId;
}
}
}
List<MmpClaim> result = new ArrayList<>(buckets.values());
Collections.sort(result, new Comparator<MmpClaim>() {
@Override
public int compare(MmpClaim a, MmpClaim b) {
return Double.compare(b.amount, a.amount);
}
});
for (int i = 0; i < result.size(); i++) {
result.get(i).rank = i + 1;
}
return result;
}
private static String claimsFingerprint(List<RawClaim> claims) {
List<String> rows = new ArrayList<>();
for (RawClaim c : claims) {
String id = !TextUtils.isEmpty(c.userId) ? c.userId : (c.nickname != null ? c.nickname : "?");
rows.add(id + "=" + String.format(Locale.US, "%.2f", c.amount));
}
Collections.sort(rows);
StringBuilder sb = new StringBuilder();
for (String r : rows) {
if (sb.length() > 0) {
sb.append('|');
}
sb.append(r);
}
return sb.toString();
}
static Double normalizeMoney(String raw) {
if (TextUtils.isEmpty(raw)) {
return null;
}
String s = raw.trim();
if (s.startsWith("{")) {
Matcher m = AMOUNT_JSON.matcher(s);
if (m.find()) {
try {
return Double.parseDouble(m.group(1));
} catch (NumberFormatException ignored) {
}
}
}
Matcher m = AMOUNT_PLAIN.matcher(s.replace(",", ""));
if (m.find()) {
try {
return Double.parseDouble(m.group(1));
} catch (NumberFormatException ignored) {
}
}
return null;
}
private static double maxClaimAmount(List<MmpClaim> ranked) {
double max = 0;
for (MmpClaim c : ranked) {
if (c.amount > max) {
max = c.amount;
}
}
return max;
}
private static int score(MmpPacket p) {
int issued = TextUtils.isEmpty(p.issuedAt) ? 0 : 1;
int claims = p.leaderboard != null ? p.leaderboard.size() : 0;
return issued * 1000 + claims;
}
private static void trimMap(Map<String, MmpPacket> map) {
if (map.size() <= MAX_PACKETS) {
return;
}
List<MmpPacket> list = new ArrayList<>(map.values());
Collections.sort(list, new Comparator<MmpPacket>() {
@Override
public int compare(MmpPacket a, MmpPacket b) {
return nullToEmpty(a.updatedAt).compareTo(nullToEmpty(b.updatedAt));
}
});
int remove = map.size() - MAX_PACKETS;
for (int i = 0; i < remove; i++) {
map.remove(list.get(i).packetId);
}
}
// --- persistence ---
private static SharedPreferences prefs(Context context) {
return context.getApplicationContext().getSharedPreferences(PREF, Context.MODE_PRIVATE);
}
private static Map<String, MmpPacket> loadMap(Context context) {
Map<String, MmpPacket> map = new HashMap<>();
String raw = prefs(context).getString(KEY_JSON, null);
if (TextUtils.isEmpty(raw)) {
return map;
}
try {
JSONArray arr = new JSONArray(raw);
for (int i = 0; i < arr.length(); i++) {
MmpPacket p = fromJson(arr.getJSONObject(i));
if (p != null && !TextUtils.isEmpty(p.packetId)) {
map.put(p.packetId, p);
}
}
} catch (Exception e) {
Log.e(TAG, "load failed", e);
}
return map;
}
private static void saveMap(Context context, Map<String, MmpPacket> map) {
try {
JSONArray arr = new JSONArray();
for (MmpPacket p : map.values()) {
arr.put(toJson(p));
}
prefs(context).edit().putString(KEY_JSON, arr.toString()).apply();
} catch (Exception e) {
Log.e(TAG, "save failed", e);
}
}
private static JSONObject toJson(MmpPacket p) throws Exception {
JSONObject o = new JSONObject();
o.put("packetId", nullToEmpty(p.packetId));
o.put("title", nullToEmpty(p.title));
o.put("sender", nullToEmpty(p.sender));
o.put("group", nullToEmpty(p.group));
o.put("total", nullToEmpty(p.total));
o.put("via", nullToEmpty(p.via));
o.put("issuedAt", nullToEmpty(p.issuedAt));
o.put("expiresAt", nullToEmpty(p.expiresAt));
o.put("status", nullToEmpty(p.status));
o.put("updatedAt", nullToEmpty(p.updatedAt));
o.put("finished", p.finished);
o.put("snapshots", p.snapshots);
o.put("claimedCount", p.claimedCount);
o.put("totalCount", p.totalCount);
o.put("claimantCount", p.claimantCount);
o.put("sumClaimed", p.sumClaimed);
JSONArray board = new JSONArray();
if (p.leaderboard != null) {
for (MmpClaim c : p.leaderboard) {
JSONObject row = new JSONObject();
row.put("nickname", nullToEmpty(c.nickname));
row.put("userId", nullToEmpty(c.userId));
row.put("poolId", nullToEmpty(c.poolId));
row.put("amountText", nullToEmpty(c.amountText));
row.put("amount", c.amount);
row.put("claimTime", nullToEmpty(c.claimTime));
row.put("rank", c.rank);
board.put(row);
}
}
o.put("leaderboard", board);
return o;
}
private static MmpPacket fromJson(JSONObject o) throws Exception {
MmpPacket p = new MmpPacket();
p.packetId = o.optString("packetId", "");
p.title = o.optString("title", "");
p.sender = o.optString("sender", "");
p.group = o.optString("group", "");
p.total = o.optString("total", "");
p.via = o.optString("via", "");
p.issuedAt = o.optString("issuedAt", "");
p.expiresAt = o.optString("expiresAt", "");
p.status = o.optString("status", "");
p.updatedAt = o.optString("updatedAt", "");
p.finished = o.optBoolean("finished", false);
p.snapshots = o.optInt("snapshots", 1);
p.claimedCount = o.optInt("claimedCount", 0);
p.totalCount = o.optInt("totalCount", 0);
p.claimantCount = o.optInt("claimantCount", 0);
p.refreshClosedState();
p.snapshots = o.optInt("snapshots", 1);
p.claimantCount = o.optInt("claimantCount", 0);
p.sumClaimed = o.optDouble("sumClaimed", 0);
JSONArray board = o.optJSONArray("leaderboard");
if (board != null) {
for (int i = 0; i < board.length(); i++) {
JSONObject row = board.getJSONObject(i);
MmpClaim c = new MmpClaim();
c.nickname = row.optString("nickname", "");
c.userId = row.optString("userId", "");
c.poolId = row.optString("poolId", "");
c.amount = row.optDouble("amount", 0);
c.amountText = row.optString("amountText",
String.format(Locale.US, "%.2f", c.amount));
c.claimTime = row.optString("claimTime", "");
c.rank = row.optInt("rank", i + 1);
p.leaderboard.add(c);
}
}
if (p.claimantCount <= 0) {
p.claimantCount = p.leaderboard.size();
}
if (p.claimedCount <= 0) {
p.claimedCount = p.claimantCount;
}
p.refreshClosedState();
return p;
}
private static void notifyListeners() {
for (Listener l : LISTENERS) {
try {
l.onMmpPacketsChanged();
} catch (Exception e) {
Log.e(TAG, "listener error", e);
}
}
}
private static String nullToEmpty(String s) {
return s == null ? "" : s;
}
private static String firstNonEmpty(Map<String, String> meta, String... keys) {
for (String k : keys) {
String v = meta.get(k);
if (!TextUtils.isEmpty(v)) {
return v;
}
}
return "";
}
/** counts=1/5 */
private static void parseCountsMeta(MmpPacket packet, String counts) {
if (packet == null || TextUtils.isEmpty(counts)) {
return;
}
String raw = counts.trim();
int slash = raw.indexOf('/');
try {
if (slash >= 0) {
String left = raw.substring(0, slash).trim();
String right = raw.substring(slash + 1).trim();
if (!left.isEmpty()) {
packet.claimedCount = Integer.parseInt(left);
}
if (!right.isEmpty()) {
packet.totalCount = Integer.parseInt(right);
}
} else {
packet.claimedCount = Integer.parseInt(raw);
}
} catch (NumberFormatException ignored) {
}
}
private static void tryParseIntField(MmpPacket packet, Map<String, String> meta,
String... keys) {
if (packet == null || meta == null) {
return;
}
for (String k : keys) {
String v = meta.get(k);
if (TextUtils.isEmpty(v)) {
continue;
}
try {
int n = Integer.parseInt(v.trim());
if (n < 0) {
continue;
}
if ("claimed".equals(k) || "claimedCount".equals(k)) {
packet.claimedCount = n;
} else {
packet.totalCount = n;
}
return;
} catch (NumberFormatException ignored) {
}
}
}
private static boolean isTerminalStatusMeta(String status) {
if (TextUtils.isEmpty(status)) {
return false;
}
String s = status.toUpperCase(Locale.US);
return s.contains("FINISH") || s.contains("COMPLETE") || s.contains("EXPIRE")
|| s.contains("ENDED") || s.contains("CLOSED") || s.contains("CANCEL")
|| s.contains("DONE");
}
private static String nowText() {
return new java.text.SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault())
.format(new java.util.Date());
}
}

View File

@@ -0,0 +1,178 @@
package com.miraclegarden.smsmessage.mmp;
import android.content.Context;
import android.os.Handler;
import android.os.Looper;
import android.util.Log;
import com.miraclegarden.smsmessage.AppConfig;
import org.json.JSONObject;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import okhttp3.MediaType;
import okhttp3.OkHttpClient;
import okhttp3.Request;
import okhttp3.RequestBody;
import okhttp3.Response;
/**
* 领取台设置:本地 SharedPreferences 优先,能连上调试台时再同步 Hook 侧参数。
*/
public final class MmpSettingsClient {
private static final String TAG = "MmpSettingsClient";
private static final MediaType JSON = MediaType.parse("application/json; charset=utf-8");
private static final OkHttpClient CLIENT = new OkHttpClient.Builder()
.connectTimeout(4, TimeUnit.SECONDS)
.readTimeout(4, TimeUnit.SECONDS)
.writeTimeout(4, TimeUnit.SECONDS)
.build();
private static final ExecutorService EXEC = Executors.newSingleThreadExecutor();
private static final Handler MAIN = new Handler(Looper.getMainLooper());
public interface CallbackJson {
void onResult(JSONObject json, String error);
}
private MmpSettingsClient() {
}
/** 先本地再尝试合并调试台Hook 冷却等) */
public static void load(Context context, CallbackJson cb) {
final Context app = context != null ? context.getApplicationContext() : null;
EXEC.execute(() -> {
JSONObject local = MmpLocalSettings.load(app);
Exception last = null;
for (String base : AppConfig.DEBUG_SERVER_URLS) {
if (base == null || base.isEmpty()) {
continue;
}
try {
Request req = new Request.Builder()
.url(base.replaceAll("/$", "") + "/api/mmp/settings")
.get()
.build();
try (Response resp = CLIENT.newCall(req).execute()) {
if (resp.isSuccessful() && resp.body() != null) {
JSONObject remote = new JSONObject(resp.body().string());
// 远程覆盖 Hook 相关;手机专属开关保留本地
boolean launch = local.optBoolean("autoLaunchTngIfKilled", true);
boolean watch = local.optBoolean("autoWatchWhileOpen", true);
MmpLocalSettings.mergeInto(local, remote);
local.put("autoLaunchTngIfKilled", launch);
local.put("autoWatchWhileOpen", watch);
MmpLocalSettings.save(app, local);
JSONObject out = local;
MAIN.post(() -> cb.onResult(out, null));
return;
}
}
} catch (Exception e) {
last = e;
}
}
JSONObject out = local;
String tip = last != null
? ("已用本地设置(调试台未连上:" + last.getMessage() + "")
: null;
MAIN.post(() -> cb.onResult(out, tip));
});
}
/** 始终写本地;能连调试台则同步 Hook 参数 */
public static void save(Context context, JSONObject body, CallbackJson cb) {
final Context app = context != null ? context.getApplicationContext() : null;
EXEC.execute(() -> {
MmpLocalSettings.save(app, body);
JSONObject saved = MmpLocalSettings.load(app);
Exception last = null;
boolean remoteOk = false;
for (String base : AppConfig.DEBUG_SERVER_URLS) {
if (base == null || base.isEmpty()) {
continue;
}
try {
Request req = new Request.Builder()
.url(base.replaceAll("/$", "") + "/api/mmp/settings")
.post(RequestBody.create(saved.toString(), JSON))
.build();
try (Response resp = CLIENT.newCall(req).execute()) {
if (resp.isSuccessful() && resp.body() != null) {
JSONObject remote = new JSONObject(resp.body().string());
boolean launch = saved.optBoolean("autoLaunchTngIfKilled", true);
boolean watch = saved.optBoolean("autoWatchWhileOpen", true);
MmpLocalSettings.mergeInto(saved, remote);
saved.put("autoLaunchTngIfKilled", launch);
saved.put("autoWatchWhileOpen", watch);
MmpLocalSettings.save(app, saved);
remoteOk = true;
break;
}
}
} catch (Exception e) {
last = e;
}
}
final boolean okRemote = remoteOk;
final JSONObject out = saved;
final Exception err = last;
MAIN.post(() -> {
if (okRemote) {
cb.onResult(out, null);
} else {
// 本地已保存成功
cb.onResult(out, err != null
? ("已保存到手机;调试台未同步:" + err.getMessage())
: "已保存到手机");
}
});
});
}
public static void pingAsync() {
load(null, (json, error) -> {
if (error != null) {
Log.w(TAG, "settings ping: " + error);
}
});
}
public interface HealthCallback {
void onResult(boolean ok);
}
/** 探测调试台 /health 是否可达 */
public static void pingHealth(HealthCallback cb) {
EXEC.execute(() -> {
boolean ok = false;
for (String base : AppConfig.DEBUG_SERVER_URLS) {
if (base == null || base.isEmpty()) {
continue;
}
try {
Request req = new Request.Builder()
.url(base.replaceAll("/$", "") + "/health")
.get()
.build();
try (Response resp = CLIENT.newCall(req).execute()) {
if (resp.isSuccessful()) {
ok = true;
break;
}
}
} catch (Exception ignored) {
}
}
boolean result = ok;
MAIN.post(() -> {
if (cb != null) {
cb.onResult(result);
}
});
});
}
}

View File

@@ -0,0 +1,134 @@
package com.miraclegarden.smsmessage.mmp;
import android.content.Context;
import android.text.TextUtils;
import android.util.Log;
import com.miraclegarden.smsmessage.AppConfig;
import org.json.JSONArray;
import org.json.JSONObject;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import okhttp3.MediaType;
import okhttp3.OkHttpClient;
import okhttp3.Request;
import okhttp3.RequestBody;
import okhttp3.Response;
/**
* 把手机本地红包台全量同步到 PC 调试台,方便电脑打开 /mmp 查看。
*/
public final class MmpSyncClient {
private static final String TAG = "MmpSyncClient";
private static final MediaType JSON = MediaType.parse("application/json; charset=utf-8");
private static final OkHttpClient CLIENT = new OkHttpClient.Builder()
.connectTimeout(5, TimeUnit.SECONDS)
.writeTimeout(8, TimeUnit.SECONDS)
.readTimeout(8, TimeUnit.SECONDS)
.build();
private static final ExecutorService EXEC = Executors.newSingleThreadExecutor();
private static final AtomicBoolean IN_FLIGHT = new AtomicBoolean(false);
private static volatile long sLastSyncAt;
public interface Callback {
void onDone(boolean ok, String message);
}
private MmpSyncClient() {
}
public static void syncToPc(Context context) {
syncToPc(context, null);
}
public static void syncToPc(Context context, Callback callback) {
if (context == null || !AppConfig.ENABLE_DEBUG_FORWARD) {
if (callback != null) {
callback.onDone(false, "调试转发未开启");
}
return;
}
final Context app = context.getApplicationContext();
EXEC.execute(() -> {
if (!IN_FLIGHT.compareAndSet(false, true)) {
if (callback != null) {
callback.onDone(false, "同步进行中");
}
return;
}
try {
JSONArray packets = MmpPacketStore.exportJsonArray(app);
JSONObject body = new JSONObject();
body.put("packets", packets);
body.put("device", "android");
body.put("syncedAt", System.currentTimeMillis());
JSONObject hook = new JSONObject();
MmpHookStatus.ensureLoaded(app);
hook.put("liveCode", MmpHookStatus.liveVersionCode(app));
hook.put("liveName", MmpHookStatus.liveVersionName(app));
hook.put("liveAt", MmpHookStatus.lastAliveAt(app));
hook.put("installedCode", MmpHookStatus.installedVersionCode(app));
hook.put("installedName", MmpHookStatus.installedVersionName(app));
body.put("hookStatus", hook);
String payload = body.toString();
boolean anyOk = false;
String lastErr = "调试台不可达";
String lastOkMsg = "";
for (String base : AppConfig.DEBUG_SERVER_URLS) {
if (TextUtils.isEmpty(base)) {
continue;
}
try {
Request req = new Request.Builder()
.url(base.replaceAll("/$", "") + "/api/mmp/sync")
.post(RequestBody.create(payload, JSON))
.build();
try (Response resp = CLIENT.newCall(req).execute()) {
String respBody = resp.body() != null ? resp.body().string() : "";
if (resp.isSuccessful()) {
anyOk = true;
lastOkMsg = "已同步 " + packets.length() + " 个红包到电脑";
Log.i(TAG, "sync ok -> " + base + " " + respBody);
} else {
lastErr = "HTTP " + resp.code();
}
}
} catch (Exception e) {
lastErr = e.getMessage();
Log.w(TAG, "sync fail " + base + ": " + lastErr);
}
}
sLastSyncAt = System.currentTimeMillis();
if (callback != null) {
if (anyOk) {
callback.onDone(true, lastOkMsg);
} else {
callback.onDone(false, lastErr != null ? lastErr : "同步失败");
}
}
} catch (Exception e) {
Log.e(TAG, "sync error", e);
if (callback != null) {
callback.onDone(false, e.getMessage());
}
} finally {
IN_FLIGHT.set(false);
}
});
}
/** 距上次同步超过 cooldownMs 才再同步。 */
public static void syncIfStale(Context context, long cooldownMs) {
if (System.currentTimeMillis() - sLastSyncAt < cooldownMs) {
return;
}
syncToPc(context, null);
}
}

View File

@@ -0,0 +1,155 @@
package com.miraclegarden.smsmessage.mmp;
import android.app.ActivityManager;
import android.content.Context;
import android.content.Intent;
import android.content.pm.PackageManager;
import android.text.TextUtils;
import android.util.Log;
import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.concurrent.TimeUnit;
/**
* 检测 / 拉起 TNG供领取台「自动检测」「杀掉后自动打开」使用。
*/
public final class TngProcessHelper {
private static final String TAG = "TngProcessHelper";
public static final String TNG_PACKAGE = "my.com.tngdigital.ewallet";
private TngProcessHelper() {
}
public static boolean isRunning(Context context) {
if (context == null) {
return false;
}
if (pidofAlive()) {
return true;
}
try {
ActivityManager am = (ActivityManager) context.getSystemService(Context.ACTIVITY_SERVICE);
if (am == null) {
return false;
}
List<ActivityManager.RunningAppProcessInfo> list = am.getRunningAppProcesses();
if (list == null) {
return false;
}
for (ActivityManager.RunningAppProcessInfo info : list) {
if (info == null || TextUtils.isEmpty(info.processName)) {
continue;
}
if (info.processName.equals(TNG_PACKAGE)
|| info.processName.startsWith(TNG_PACKAGE + ":")) {
return true;
}
}
} catch (Exception e) {
Log.w(TAG, "running check fail", e);
}
return false;
}
private static boolean pidofAlive() {
String out = shell("pidof " + TNG_PACKAGE);
if (TextUtils.isEmpty(out)) {
out = shell("su -c pidof " + TNG_PACKAGE);
}
return !TextUtils.isEmpty(out) && out.trim().matches(".*\\d.*");
}
private static String shell(String cmd) {
Process p = null;
try {
p = Runtime.getRuntime().exec(new String[]{"sh", "-c", cmd});
if (!p.waitFor(2, TimeUnit.SECONDS)) {
p.destroy();
return "";
}
StringBuilder sb = new StringBuilder();
try (BufferedReader br = new BufferedReader(
new InputStreamReader(p.getInputStream(), StandardCharsets.UTF_8))) {
String line;
while ((line = br.readLine()) != null) {
sb.append(line).append('\n');
}
}
return sb.toString().trim();
} catch (Exception e) {
return "";
} finally {
if (p != null) {
p.destroy();
}
}
}
/** @return true 已发出打开 Intent */
public static boolean launch(Context context) {
if (context == null) {
return false;
}
try {
PackageManager pm = context.getPackageManager();
Intent intent = pm.getLaunchIntentForPackage(TNG_PACKAGE);
if (intent == null) {
Log.w(TAG, "no launch intent for TNG");
return false;
}
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK
| Intent.FLAG_ACTIVITY_RESET_TASK_IF_NEEDED);
context.startActivity(intent);
Log.i(TAG, "launched TNG");
return true;
} catch (Exception e) {
Log.e(TAG, "launch TNG fail", e);
return false;
}
}
/** 打开红包历史页,触发 Hook onResume / 自动拉取 */
public static boolean openMoneyPacketHistory(Context context) {
if (context == null) {
return false;
}
try {
Intent intent = new Intent();
intent.setClassName(TNG_PACKAGE,
"my.com.tngdigital.funding.moneypacket.create.ui.MoneyPacketHistoryActivity");
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK
| Intent.FLAG_ACTIVITY_NO_ANIMATION);
context.startActivity(intent);
Log.i(TAG, "opened MoneyPacketHistoryActivity");
return true;
} catch (Exception e) {
Log.w(TAG, "open history fail, fallback launch: " + e.getMessage());
return launch(context);
}
}
/** 强制停止 TNG优先 su */
public static boolean forceStop(Context context) {
String out = shell("su -c am force-stop " + TNG_PACKAGE);
if (TextUtils.isEmpty(out)) {
out = shell("am force-stop " + TNG_PACKAGE);
}
Log.i(TAG, "force-stop TNG done");
return true;
}
/** 强停后再打开,用于 Hook 假死重建 */
public static boolean forceStopAndLaunch(Context context) {
forceStop(context);
try {
Thread.sleep(900L);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
return launch(context);
}
}

View File

@@ -10,6 +10,7 @@ import com.miraclegarden.smsmessage.MessageInfo;
import org.json.JSONObject;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import okhttp3.Call;
import okhttp3.Callback;
@@ -21,6 +22,7 @@ import okhttp3.Response;
/**
* 将抓取到的消息转发到 PC 本地调试服务,便于浏览器查看。
* 支持同时向本机USB/adb reverse和局域网地址推送。
*/
public final class DebugForwarder {
@@ -51,6 +53,14 @@ public final class DebugForwarder {
return;
}
String[] urls = AppConfig.DEBUG_SERVER_URLS;
if (urls == null || urls.length == 0) {
if (onComplete != null) {
onComplete.run();
}
return;
}
try {
JSONObject json = new JSONObject();
json.put("source", source != null ? source : "unknown");
@@ -60,39 +70,49 @@ public final class DebugForwarder {
json.put("group", resolveGroup(title, messageInfo));
json.put("content", content != null ? content : "");
json.put("timestamp", timestamp);
final String body = json.toString();
final AtomicInteger pending = new AtomicInteger(urls.length);
Request request = new Request.Builder()
.url(AppConfig.DEBUG_SERVER_URL + "/api/debug/push")
.header("Content-Type", "application/json")
.post(RequestBody.create(json.toString(), JSON))
.build();
Log.d(TAG, "forwarding [" + source + "] " + messageInfo.getPackageName()
+ " -> " + AppConfig.DEBUG_SERVER_URL);
CLIENT.newCall(request).enqueue(new Callback() {
@Override
public void onFailure(Call call, java.io.IOException e) {
Log.w(TAG, "forward failed [" + source + "]: " + e.getMessage());
if (onComplete != null) {
onComplete.run();
}
for (String baseUrl : urls) {
if (TextUtils.isEmpty(baseUrl)) {
finishOne(pending, onComplete);
continue;
}
final String target = baseUrl.endsWith("/")
? baseUrl.substring(0, baseUrl.length() - 1)
: baseUrl;
Request request = new Request.Builder()
.url(target + "/api/debug/push")
.header("Content-Type", "application/json")
.post(RequestBody.create(body, JSON))
.build();
@Override
public void onResponse(Call call, Response response) {
int code = response.code();
response.close();
if (code >= 200 && code < 300) {
Log.d(TAG, "forward ok [" + source + "] " + title);
} else {
Log.w(TAG, "forward http " + code + " [" + source + "] " + title);
Log.d(TAG, "forwarding [" + source + "] " + messageInfo.getPackageName()
+ " -> " + target);
CLIENT.newCall(request).enqueue(new Callback() {
@Override
public void onFailure(Call call, java.io.IOException e) {
Log.w(TAG, "forward failed [" + source + "] -> " + target
+ ": " + e.getMessage());
finishOne(pending, onComplete);
}
if (onComplete != null) {
onComplete.run();
@Override
public void onResponse(Call call, Response response) {
int code = response.code();
response.close();
if (code >= 200 && code < 300) {
Log.d(TAG, "forward ok [" + source + "] -> " + target
+ " " + title);
} else {
Log.w(TAG, "forward http " + code + " [" + source + "] -> "
+ target + " " + title);
}
finishOne(pending, onComplete);
}
}
});
});
}
} catch (Exception e) {
Log.w(TAG, "forward build failed: " + e.getMessage());
if (onComplete != null) {
@@ -101,6 +121,12 @@ public final class DebugForwarder {
}
}
private static void finishOne(AtomicInteger pending, Runnable onComplete) {
if (pending.decrementAndGet() == 0 && onComplete != null) {
onComplete.run();
}
}
private static String resolveGroup(String title, MessageInfo messageInfo) {
if (!TextUtils.isEmpty(title)) {
return title.trim();

View File

@@ -10,6 +10,8 @@ import com.miraclegarden.smsmessage.Activity.NotificationActivity;
import com.miraclegarden.smsmessage.App;
import com.miraclegarden.smsmessage.AppConfig;
import com.miraclegarden.smsmessage.MessageInfo;
import com.miraclegarden.smsmessage.mmp.MmpHookStatus;
import com.miraclegarden.smsmessage.mmp.MmpPacketStore;
import com.miraclegarden.smsmessage.network.DebugForwarder;
/**
@@ -21,15 +23,30 @@ public class HookMessageReceiver extends BroadcastReceiver {
private static final String TAG = "HookMessageReceiver";
public static final String ACTION_HOOK_MESSAGE = "com.miraclegarden.smsmessage.action.HOOK_MESSAGE";
public static final String ACTION_HOOK_STATUS = "com.miraclegarden.smsmessage.action.HOOK_STATUS";
public static final String EXTRA_PACKAGE_NAME = "packageName";
public static final String EXTRA_TITLE = "title";
public static final String EXTRA_CONTENT = "content";
public static final String EXTRA_TIMESTAMP = "timestamp";
public static final String EXTRA_SOURCE = "source";
public static final String EXTRA_MODULE_VERSION_CODE = "moduleVersionCode";
public static final String EXTRA_MODULE_VERSION_NAME = "moduleVersionName";
public static final String EXTRA_HOST_PACKAGE = "hostPackage";
@Override
public void onReceive(Context context, Intent intent) {
if (intent == null || !ACTION_HOOK_MESSAGE.equals(intent.getAction())) {
if (intent == null || intent.getAction() == null) {
return;
}
if (ACTION_HOOK_STATUS.equals(intent.getAction())) {
int code = intent.getIntExtra(EXTRA_MODULE_VERSION_CODE, 0);
String name = intent.getStringExtra(EXTRA_MODULE_VERSION_NAME);
String host = intent.getStringExtra(EXTRA_HOST_PACKAGE);
MmpHookStatus.noteAlive(context.getApplicationContext(), code, name, host);
Log.i(TAG, "hook status host=" + host + " mod=" + name + "/" + code);
return;
}
if (!ACTION_HOOK_MESSAGE.equals(intent.getAction())) {
return;
}
@@ -58,6 +75,26 @@ public class HookMessageReceiver extends BroadcastReceiver {
Log.i(TAG, "hook received: pkg=" + packageName + " source=" + source + " title=" + title);
final String finalTitle = title;
final boolean isMmp = MmpPacketStore.isMmpContent(content, source);
// 红包统计:只进领取台,不混入「情况」监听台 / 正式上传
if (isMmp) {
try {
MmpPacketStore.ingest(context.getApplicationContext(), finalTitle, content, source);
} catch (Exception e) {
Log.e(TAG, "mmp ingest failed", e);
}
Context appContext = context.getApplicationContext();
PendingResult pendingResult = goAsync();
Runnable finish = pendingResult::finish;
if (AppConfig.ENABLE_DEBUG_FORWARD) {
DebugForwarder.forward(appContext, messageInfo, finalTitle, content, timestamp, source, finish);
} else {
finish.run();
}
return;
}
NotificationActivity.sendMessage("[Hook/" + source + "] " + finalTitle + " " + content);
Context appContext = context.getApplicationContext();

View File

@@ -92,6 +92,16 @@
android:backgroundTint="#FF3700B3" />
</LinearLayout>
<Button
android:id="@+id/btn_mmp_claim"
android:layout_width="match_parent"
android:layout_height="56dp"
android:layout_marginTop="12dp"
android:text="红包领取台(独立)"
android:textSize="16sp"
android:textColor="@color/white"
android:backgroundTint="#C45C26" />
<!-- 权限设置 -->
<TextView
android:layout_width="wrap_content"

View File

@@ -0,0 +1,633 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="#F6F1EA">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="#111827"
android:orientation="vertical"
android:paddingBottom="4dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="44dp"
android:gravity="center_vertical"
android:orientation="horizontal"
android:paddingStart="12dp"
android:paddingEnd="12dp">
<ImageView
android:id="@+id/back_iv"
android:layout_width="24dp"
android:layout_height="24dp"
android:contentDescription="返回"
android:src="@drawable/ic_action_back" />
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="12dp"
android:layout_weight="1"
android:ellipsize="end"
android:maxLines="1"
android:text="红包领取台"
android:textColor="#FFFFFF"
android:textSize="17sp"
android:textStyle="bold" />
<TextView
android:id="@+id/tv_count"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:maxLines="1"
android:text="0 个"
android:textColor="#FDBA74"
android:textSize="13sp" />
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="36dp"
android:gravity="center_vertical"
android:orientation="horizontal"
android:paddingStart="8dp"
android:paddingEnd="8dp">
<TextView
android:id="@+id/btn_refresh"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1"
android:gravity="center"
android:text="刷新"
android:textColor="#FFFFFF"
android:textSize="13sp" />
<TextView
android:id="@+id/btn_sync"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1"
android:gravity="center"
android:text="同步"
android:textColor="#FFFFFF"
android:textSize="13sp" />
<TextView
android:id="@+id/btn_help"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1"
android:gravity="center"
android:text="说明"
android:textColor="#FDBA74"
android:textSize="13sp" />
<TextView
android:id="@+id/btn_settings"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1"
android:gravity="center"
android:text="设置"
android:textColor="#FFFFFF"
android:textSize="13sp" />
<TextView
android:id="@+id/btn_clear"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1"
android:gravity="center"
android:text="清空"
android:textColor="#FECACA"
android:textSize="13sp" />
</LinearLayout>
</LinearLayout>
<TextView
android:id="@+id/tv_conn_status"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="#1F2937"
android:lineSpacingExtra="2dp"
android:paddingStart="12dp"
android:paddingTop="8dp"
android:paddingEnd="12dp"
android:paddingBottom="8dp"
android:text="连接检测中…"
android:textColor="#E5E7EB"
android:textSize="12sp" />
<!-- 筛选 -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@color/white"
android:orientation="vertical"
android:paddingStart="12dp"
android:paddingTop="8dp"
android:paddingEnd="12dp"
android:paddingBottom="8dp">
<EditText
android:id="@+id/et_filter"
android:layout_width="match_parent"
android:layout_height="40dp"
android:background="#F0F0F0"
android:hint="筛选:昵称 / 发送人 / ID"
android:imeOptions="actionSearch"
android:inputType="text"
android:paddingStart="12dp"
android:paddingEnd="12dp"
android:textColor="#222222"
android:textColorHint="#666666"
android:textSize="14sp" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:orientation="horizontal">
<TextView
android:id="@+id/chip_all"
android:layout_width="0dp"
android:layout_height="32dp"
android:layout_weight="1"
android:background="#C45C26"
android:gravity="center"
android:text="全部"
android:textColor="@color/white"
android:textSize="13sp" />
<TextView
android:id="@+id/chip_open"
android:layout_width="0dp"
android:layout_height="32dp"
android:layout_marginStart="8dp"
android:layout_weight="1"
android:background="#EEEEEE"
android:gravity="center"
android:text="领取中"
android:textColor="#333333"
android:textSize="13sp" />
<TextView
android:id="@+id/chip_done"
android:layout_width="0dp"
android:layout_height="32dp"
android:layout_marginStart="8dp"
android:layout_weight="1"
android:background="#EEEEEE"
android:gravity="center"
android:text="已领完"
android:textColor="#333333"
android:textSize="13sp" />
</LinearLayout>
<HorizontalScrollView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:scrollbars="none">
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="horizontal">
<TextView
android:id="@+id/chip_time_all"
android:layout_width="wrap_content"
android:layout_height="32dp"
android:background="#1C1410"
android:gravity="center"
android:paddingStart="12dp"
android:paddingEnd="12dp"
android:text="全部时间"
android:textColor="#FFFFFF"
android:textSize="12sp" />
<TextView
android:id="@+id/chip_time_1"
android:layout_width="wrap_content"
android:layout_height="32dp"
android:layout_marginStart="6dp"
android:background="#EEEEEE"
android:gravity="center"
android:paddingStart="12dp"
android:paddingEnd="12dp"
android:text="近1天"
android:textColor="#333333"
android:textSize="12sp" />
<TextView
android:id="@+id/chip_time_3"
android:layout_width="wrap_content"
android:layout_height="32dp"
android:layout_marginStart="6dp"
android:background="#EEEEEE"
android:gravity="center"
android:paddingStart="12dp"
android:paddingEnd="12dp"
android:text="近3天"
android:textColor="#333333"
android:textSize="12sp" />
<TextView
android:id="@+id/chip_time_7"
android:layout_width="wrap_content"
android:layout_height="32dp"
android:layout_marginStart="6dp"
android:background="#EEEEEE"
android:gravity="center"
android:paddingStart="12dp"
android:paddingEnd="12dp"
android:text="近7天"
android:textColor="#333333"
android:textSize="12sp" />
<TextView
android:id="@+id/chip_time_custom"
android:layout_width="wrap_content"
android:layout_height="32dp"
android:layout_marginStart="6dp"
android:background="#EEEEEE"
android:gravity="center"
android:paddingStart="12dp"
android:paddingEnd="12dp"
android:text="自定义"
android:textColor="#333333"
android:textSize="12sp" />
</LinearLayout>
</HorizontalScrollView>
<LinearLayout
android:id="@+id/custom_range_ly"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:orientation="horizontal"
android:visibility="gone">
<Button
android:id="@+id/btn_date_from"
android:layout_width="0dp"
android:layout_height="40dp"
android:layout_weight="1"
android:backgroundTint="#FFF3E0"
android:text="起始日期"
android:textColor="#4E342E"
android:textSize="12sp" />
<Button
android:id="@+id/btn_date_to"
android:layout_width="0dp"
android:layout_height="40dp"
android:layout_marginStart="8dp"
android:layout_weight="1"
android:backgroundTint="#FFF3E0"
android:text="结束日期"
android:textColor="#4E342E"
android:textSize="12sp" />
</LinearLayout>
</LinearLayout>
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recyclerview"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:clipToPadding="false"
android:padding="8dp" />
</LinearLayout>
<LinearLayout
android:id="@+id/empty_ly"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:gravity="center"
android:orientation="vertical"
android:visibility="gone">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="暂无红包数据"
android:textColor="#999999"
android:textSize="16sp" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:gravity="center"
android:paddingStart="32dp"
android:paddingEnd="32dp"
android:text="确认监听列表已勾选 TNG打开 TNG 登录后等待自动拉取全部历史与详情"
android:textColor="#FF9800"
android:textSize="13sp" />
</LinearLayout>
<!-- 设置全屏可滚动层:盖在列表之上,可滚到底,避免底部裁切 -->
<ScrollView
android:id="@+id/settings_panel"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_marginTop="84dp"
android:background="#FFF8F1"
android:clickable="true"
android:elevation="12dp"
android:fillViewport="true"
android:visibility="gone">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="12dp"
android:paddingBottom="48dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:orientation="horizontal">
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="拉取设置"
android:textColor="#1C1410"
android:textSize="16sp"
android:textStyle="bold" />
<TextView
android:id="@+id/btn_close_settings"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:padding="8dp"
android:text="关闭"
android:textColor="#C2410C"
android:textSize="14sp"
android:textStyle="bold" />
</LinearLayout>
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:text="控制 TNG 里多久自动扫一次红包列表/详情。改完点保存,约 10 秒后生效。不懂就用下面「标准」。"
android:textColor="#5D4037"
android:textSize="12sp" />
<!-- 历史冷却 -->
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:text="① 历史列表冷却(秒)"
android:textColor="#1C1410"
android:textSize="13sp"
android:textStyle="bold" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="多久再自动拉一次「红包历史列表」。越小越勤,越费电。"
android:textColor="#6D4C41"
android:textSize="11sp" />
<EditText
android:id="@+id/cfg_history"
android:layout_width="match_parent"
android:layout_height="40dp"
android:layout_marginTop="4dp"
android:background="#FFFFFF"
android:hint="例如 8"
android:inputType="number"
android:padding="8dp"
android:textColor="#222222"
android:textColorHint="#999999"
android:textSize="14sp" />
<!-- 详情冷却 -->
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
android:text="② 详情刷新冷却(秒)"
android:textColor="#1C1410"
android:textSize="13sp"
android:textStyle="bold" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="还在领的红包,多久再拉一次领取名单。已领完的会自动停刷。"
android:textColor="#6D4C41"
android:textSize="11sp" />
<EditText
android:id="@+id/cfg_detail"
android:layout_width="match_parent"
android:layout_height="40dp"
android:layout_marginTop="4dp"
android:background="#FFFFFF"
android:hint="例如 8"
android:inputType="number"
android:padding="8dp"
android:textColor="#222222"
android:textColorHint="#999999"
android:textSize="14sp" />
<!-- 去重 -->
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
android:text="③ 相同名单去重(秒)"
android:textColor="#1C1410"
android:textSize="13sp"
android:textStyle="bold" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="领取名单没变化时,多少秒内不重复推送到领取台(防刷屏)。"
android:textColor="#6D4C41"
android:textSize="11sp" />
<EditText
android:id="@+id/cfg_dedup"
android:layout_width="match_parent"
android:layout_height="40dp"
android:layout_marginTop="4dp"
android:background="#FFFFFF"
android:hint="例如 3"
android:inputType="number"
android:padding="8dp"
android:textColor="#222222"
android:textColorHint="#999999"
android:textSize="14sp" />
<!-- 间隔 -->
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
android:text="④ 详情请求间隔(毫秒)"
android:textColor="#1C1410"
android:textSize="13sp"
android:textStyle="bold" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="连续拉多个红包详情时每个之间停顿多久。1000 毫秒 = 1 秒。"
android:textColor="#6D4C41"
android:textSize="11sp" />
<EditText
android:id="@+id/cfg_gap"
android:layout_width="match_parent"
android:layout_height="40dp"
android:layout_marginTop="4dp"
android:background="#FFFFFF"
android:hint="例如 80"
android:inputType="number"
android:padding="8dp"
android:textColor="#222222"
android:textColorHint="#999999"
android:textSize="14sp" />
<CheckBox
android:id="@+id/cfg_open_history"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
android:buttonTint="#C45C26"
android:text="无请求模板时,自动打开 TNG 红包历史页(方便首次抓取)"
android:textColor="#222222"
android:textSize="13sp" />
<CheckBox
android:id="@+id/cfg_bounce_history"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:buttonTint="#C45C26"
android:checked="true"
android:text="Hook 断线时短进历史页再建连(约 2 秒后自动返回;已在历史页不会重复打开)"
android:textColor="#222222"
android:textSize="13sp" />
<CheckBox
android:id="@+id/cfg_auto_watch"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:buttonTint="#C45C26"
android:checked="true"
android:text="领取台打开时自动检测 TNG 是否在跑"
android:textColor="#222222"
android:textSize="13sp" />
<CheckBox
android:id="@+id/cfg_auto_launch"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:buttonTint="#C45C26"
android:checked="true"
android:text="检测到 TNG 被完全杀掉时,自动重新打开 TNG"
android:textColor="#222222"
android:textSize="13sp" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
android:text="快捷预设"
android:textColor="#1C1410"
android:textSize="13sp"
android:textStyle="bold" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="极速=更勤拉(费电) 标准=日常推荐 省流=少拉省电"
android:textColor="#6D4C41"
android:textSize="11sp" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="6dp"
android:orientation="horizontal">
<Button
android:id="@+id/btn_preset_fast"
android:layout_width="0dp"
android:layout_height="40dp"
android:layout_weight="1"
android:backgroundTint="#5D4037"
android:text="极速"
android:textColor="#FFFFFF"
android:textSize="12sp" />
<Button
android:id="@+id/btn_preset_normal"
android:layout_width="0dp"
android:layout_height="40dp"
android:layout_marginStart="6dp"
android:layout_weight="1"
android:backgroundTint="#5D4037"
android:text="标准"
android:textColor="#FFFFFF"
android:textSize="12sp" />
<Button
android:id="@+id/btn_preset_slow"
android:layout_width="0dp"
android:layout_height="40dp"
android:layout_marginStart="6dp"
android:layout_weight="1"
android:backgroundTint="#5D4037"
android:text="省流"
android:textColor="#FFFFFF"
android:textSize="12sp" />
<Button
android:id="@+id/btn_save_settings"
android:layout_width="0dp"
android:layout_height="40dp"
android:layout_marginStart="6dp"
android:layout_weight="1"
android:backgroundTint="#C45C26"
android:text="保存"
android:textColor="#FFFFFF"
android:textSize="12sp" />
</LinearLayout>
<TextView
android:id="@+id/tv_settings_status"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="6dp"
android:textColor="#4E342E"
android:textSize="12sp" />
</LinearLayout>
</ScrollView>
</FrameLayout>

View File

@@ -0,0 +1,324 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="#F3F4F6"
android:orientation="vertical">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="56dp"
android:background="#111827"
android:gravity="center_vertical"
android:orientation="horizontal"
android:paddingStart="16dp"
android:paddingEnd="16dp">
<ImageView
android:id="@+id/back_iv"
android:layout_width="24dp"
android:layout_height="24dp"
android:contentDescription="返回"
android:src="@drawable/ic_action_back" />
<TextView
android:id="@+id/tv_title"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_weight="1"
android:ellipsize="end"
android:maxLines="1"
android:text="红包详情"
android:textColor="#FFFFFF"
android:textSize="17sp"
android:textStyle="bold" />
<TextView
android:id="@+id/tv_header_money"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="0.00"
android:textColor="#FDBA74"
android:textSize="20sp"
android:textStyle="bold" />
</LinearLayout>
<ScrollView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:fillViewport="false">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="#FFFFFF"
android:orientation="vertical"
android:padding="14dp">
<TextView
android:id="@+id/tv_sub"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textColor="#6B7280"
android:textSize="12sp" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:orientation="horizontal">
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:background="#F9FAFB"
android:orientation="vertical"
android:padding="10dp">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="人数"
android:textColor="#6B7280"
android:textSize="11sp"
android:textStyle="bold" />
<TextView
android:id="@+id/tv_stat_people"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="0"
android:textColor="#111827"
android:textSize="20sp"
android:textStyle="bold" />
</LinearLayout>
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:layout_weight="1"
android:background="#FFF7ED"
android:orientation="vertical"
android:padding="10dp">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="合计"
android:textColor="#9A3412"
android:textSize="11sp"
android:textStyle="bold" />
<TextView
android:id="@+id/tv_stat_sum"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="0.00"
android:textColor="#C2410C"
android:textSize="20sp"
android:textStyle="bold" />
</LinearLayout>
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:layout_weight="1"
android:background="#FFF7ED"
android:orientation="vertical"
android:padding="10dp">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="总额"
android:textColor="#9A3412"
android:textSize="11sp"
android:textStyle="bold" />
<TextView
android:id="@+id/tv_stat_total"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="-"
android:textColor="#C2410C"
android:textSize="20sp"
android:textStyle="bold" />
</LinearLayout>
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
android:orientation="horizontal">
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:background="#ECFDF5"
android:orientation="vertical"
android:padding="12dp">
<TextView
android:id="@+id/tv_best_lab"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="手气最佳"
android:textColor="#047857"
android:textSize="12sp"
android:textStyle="bold" />
<TextView
android:id="@+id/tv_best_name"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:ellipsize="end"
android:maxLines="2"
android:text="-"
android:textColor="#047857"
android:textSize="16sp"
android:textStyle="bold" />
<TextView
android:id="@+id/tv_best_amt"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="-"
android:textColor="#047857"
android:textSize="24sp"
android:textStyle="bold" />
</LinearLayout>
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:layout_weight="1"
android:background="#FFFBEB"
android:orientation="vertical"
android:padding="12dp">
<TextView
android:id="@+id/tv_worst_lab"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="手气最差"
android:textColor="#B45309"
android:textSize="12sp"
android:textStyle="bold" />
<TextView
android:id="@+id/tv_worst_name"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:ellipsize="end"
android:maxLines="2"
android:text="-"
android:textColor="#B45309"
android:textSize="16sp"
android:textStyle="bold" />
<TextView
android:id="@+id/tv_worst_amt"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="-"
android:textColor="#B45309"
android:textSize="24sp"
android:textStyle="bold" />
</LinearLayout>
</LinearLayout>
<!-- keep old tv_luck hidden for binding safety if referenced - remove from activity instead -->
</LinearLayout>
</ScrollView>
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingStart="14dp"
android:paddingTop="10dp"
android:paddingEnd="14dp"
android:paddingBottom="6dp"
android:text="领取排行"
android:textColor="#6B7280"
android:textSize="12sp"
android:textStyle="bold" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="#F9FAFB"
android:orientation="horizontal"
android:paddingStart="14dp"
android:paddingTop="8dp"
android:paddingEnd="14dp"
android:paddingBottom="8dp">
<TextView
android:layout_width="36dp"
android:layout_height="wrap_content"
android:text="#"
android:textColor="#6B7280"
android:textSize="12sp"
android:textStyle="bold" />
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="昵称"
android:textColor="#6B7280"
android:textSize="12sp"
android:textStyle="bold" />
<TextView
android:layout_width="80dp"
android:layout_height="wrap_content"
android:gravity="end"
android:text="金额"
android:textColor="#6B7280"
android:textSize="12sp"
android:textStyle="bold" />
<TextView
android:layout_width="96dp"
android:layout_height="wrap_content"
android:gravity="end"
android:text="时间"
android:textColor="#6B7280"
android:textSize="12sp"
android:textStyle="bold" />
</LinearLayout>
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recyclerview"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:background="#FFFFFF" />
<LinearLayout
android:id="@+id/empty_ly"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:gravity="center"
android:orientation="vertical"
android:visibility="gone">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="暂无领取记录"
android:textColor="#9CA3AF"
android:textSize="14sp" />
</LinearLayout>
</LinearLayout>

View File

@@ -0,0 +1,209 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="#F6F1EA"
android:orientation="vertical">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="56dp"
android:background="#111827"
android:gravity="center_vertical"
android:orientation="horizontal"
android:paddingStart="16dp"
android:paddingEnd="16dp">
<ImageView
android:id="@+id/back_iv"
android:layout_width="24dp"
android:layout_height="24dp"
android:contentDescription="返回"
android:src="@drawable/ic_action_back" />
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_weight="1"
android:text="功能说明"
android:textColor="#FFFFFF"
android:textSize="18sp"
android:textStyle="bold" />
</LinearLayout>
<ScrollView
android:layout_width="match_parent"
android:layout_height="match_parent"
android:fillViewport="true">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp"
android:paddingBottom="32dp">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="红包领取台做什么"
android:textColor="#1C1410"
android:textSize="16sp"
android:textStyle="bold" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="6dp"
android:lineSpacingExtra="3dp"
android:text="独立统计 TNG Money Packet红包谁发的、谁领了、领了多少。数据只进本台不会进通用「情况」监听台也不会上传正式服务器。"
android:textColor="#4E342E"
android:textSize="14sp" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="18dp"
android:text="怎么用"
android:textColor="#1C1410"
android:textSize="16sp"
android:textStyle="bold" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="6dp"
android:lineSpacingExtra="4dp"
android:text="1. 主 App 监听列表勾选 TNG\n2. LSPosed 勾选本 Xposed 模块,作用域含 TNG\n3. 强制停止后再打开 TNG若闪退见下方「打开 TNG 闪退怎么办」\n4. 登录 / 输 PIN 后回到本页等待自动拉取(一般不必再进红包历史页)\n5. 要电脑看:电脑开调试台 + adb reverse再点「同步电脑」或打开本页自动同步"
android:textColor="#4E342E"
android:textSize="14sp" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="18dp"
android:text="列表怎么看"
android:textColor="#1C1410"
android:textSize="16sp"
android:textStyle="bold" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="6dp"
android:lineSpacingExtra="4dp"
android:text="• 右上角橙色数字:已领合计金额\n• 领取中 / 已领完:红包是否还在领\n• 最高 / 最低(领取中)或 最佳 / 最差(已领完):当前手气两端\n• 点进卡片:看完整领取排行\n• 清空:只清本台红包数据,不影响通用消息"
android:textColor="#4E342E"
android:textSize="14sp" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="18dp"
android:text="筛选"
android:textColor="#1C1410"
android:textSize="16sp"
android:textStyle="bold" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="6dp"
android:lineSpacingExtra="4dp"
android:text="• 关键词:昵称 / 发送人 / 红包 ID\n• 状态:全部、领取中、已领完\n• 时间:近 1 / 3 / 7 天,或自定义起止日期"
android:textColor="#4E342E"
android:textSize="14sp" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="18dp"
android:text="同步电脑"
android:textColor="#1C1410"
android:textSize="16sp"
android:textStyle="bold" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="6dp"
android:lineSpacingExtra="4dp"
android:text="手机是数据源。点「同步电脑」会把本地红包全量推到调试台(落盘),电脑打开 http://127.0.0.1:8765/mmp 即可看。手机浏览器也可访问USB reverse 或局域网 IP。"
android:textColor="#4E342E"
android:textSize="14sp" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="18dp"
android:text="设置里各项含义"
android:textColor="#1C1410"
android:textSize="16sp"
android:textStyle="bold" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="6dp"
android:lineSpacingExtra="4dp"
android:text="① 历史列表冷却:多久再扫一次红包历史列表\n② 详情刷新冷却:领取中的包多久再拉一次名单\n③ 相同名单去重:名单没变时,多少秒内不重复推送\n④ 详情请求间隔:连拉多个包之间停顿(毫秒)\n\n已领完的包会停刷详情。不懂就用预设「标准」再保存约 10 秒后对 Hook 生效。"
android:textColor="#4E342E"
android:textSize="14sp" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="18dp"
android:text="断线自动进历史页"
android:textColor="#1C1410"
android:textSize="16sp"
android:textStyle="bold" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="6dp"
android:lineSpacingExtra="4dp"
android:text="设置里可勾选:\n• 检测不到 Hook 连接 → 自动进历史页再返回\n• 领取台打开时自动检测 TNG 是否在跑\n• TNG 被完全杀掉时自动重新打开\n\n这些设置保存在手机本地不依赖电脑调试台。顶栏「刷新」可立刻重载列表并检测 TNG。\n\n注意自动打开 TNG 需要领取台在前台(或刚打开);完全退出本 App 后不会后台常驻拉起。"
android:textColor="#4E342E"
android:textSize="14sp" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="18dp"
android:text="打开 TNG 闪退怎么办"
android:textColor="#1C1410"
android:textSize="16sp"
android:textStyle="bold" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="6dp"
android:lineSpacingExtra="4dp"
android:text="装了 Xposed / 防护相关模块后TNG 偶发一打开就闪退,属常见现象,可按下面顺序试:\n\n1. 多点几次图标再打开(有时前几次会崩,后面就能进)\n2. 仍不行:系统设置 → 应用 → TNG → 强制停止,再重新打开\n3. 还不行:强制停止后清一下「缓存」(尽量别清「数据」,否则要重新登录)\n4. 推送/更新过 Hook 模块后:务必强制停止再开 TNG否则新逻辑可能不生效或更易闪\n5. 连续闪退:先关掉 LSPosed 里本模块对 TNG 的作用域试能否正常打开,确认后再勾回并强停重开\n\n能进首页并登录 / 输 PIN 后,再回本领取台等自动拉数据。"
android:textColor="#4E342E"
android:textSize="14sp" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="18dp"
android:text="没数据时先查这些"
android:textColor="#1C1410"
android:textSize="16sp"
android:textStyle="bold" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="6dp"
android:lineSpacingExtra="4dp"
android:text="• 监听列表是否勾选 TNG\n• Xposed 模块是否启用且作用域含 TNG\n• 是否按上面做过「强制停止再开」并完成登录\n• 电脑同步失败调试台是否在跑、8765 是否多开、adb reverse 是否做好\n\n更细的技术说明见仓库 docs/TNG_MoneyPacket领取台.md"
android:textColor="#4E342E"
android:textSize="14sp" />
</LinearLayout>
</ScrollView>
</LinearLayout>

View File

@@ -0,0 +1,89 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:orientation="horizontal"
android:paddingStart="14dp"
android:paddingTop="12dp"
android:paddingEnd="14dp"
android:paddingBottom="12dp">
<TextView
android:id="@+id/tv_rank"
android:layout_width="36dp"
android:layout_height="wrap_content"
android:text="1"
android:textColor="#EA580C"
android:textSize="16sp"
android:textStyle="bold" />
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:id="@+id/tv_nickname"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:ellipsize="end"
android:maxLines="1"
android:text="昵称"
android:textColor="#111827"
android:textSize="15sp"
android:textStyle="bold" />
<TextView
android:id="@+id/tv_user_id"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:ellipsize="middle"
android:maxLines="1"
android:text=""
android:textColor="#9CA3AF"
android:textSize="11sp"
android:visibility="gone" />
<TextView
android:id="@+id/tv_pool_id"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:ellipsize="middle"
android:maxLines="1"
android:text=""
android:textColor="#9CA3AF"
android:textSize="11sp"
android:visibility="gone" />
<TextView
android:id="@+id/tv_tag"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:text=""
android:textSize="11sp"
android:textStyle="bold"
android:visibility="gone" />
</LinearLayout>
<TextView
android:id="@+id/tv_amount"
android:layout_width="80dp"
android:layout_height="wrap_content"
android:gravity="end"
android:text="0.00"
android:textColor="#C2410C"
android:textSize="17sp"
android:textStyle="bold" />
<TextView
android:id="@+id/tv_time"
android:layout_width="96dp"
android:layout_height="wrap_content"
android:gravity="end"
android:text="-"
android:textColor="#9CA3AF"
android:textSize="11sp" />
</LinearLayout>

View File

@@ -0,0 +1,134 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="6dp"
android:background="#FFFFFF"
android:orientation="vertical"
android:paddingStart="10dp"
android:paddingTop="8dp"
android:paddingEnd="10dp"
android:paddingBottom="8dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:orientation="horizontal">
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:id="@+id/tv_title"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:ellipsize="end"
android:maxLines="1"
android:text="红包"
android:textColor="#111827"
android:textSize="14sp"
android:textStyle="bold" />
<TextView
android:id="@+id/tv_id"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:ellipsize="middle"
android:maxLines="1"
android:text=""
android:textColor="#9CA3AF"
android:textSize="11sp"
android:visibility="gone" />
</LinearLayout>
<TextView
android:id="@+id/tv_sum"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="6dp"
android:text="0.00"
android:textColor="#C2410C"
android:textSize="16sp"
android:textStyle="bold" />
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:gravity="center_vertical"
android:orientation="horizontal">
<TextView
android:id="@+id/tv_status"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="#ECFDF5"
android:paddingStart="5dp"
android:paddingTop="1dp"
android:paddingEnd="5dp"
android:paddingBottom="1dp"
android:text="已领完"
android:textColor="#047857"
android:textSize="11sp"
android:textStyle="bold" />
<TextView
android:id="@+id/tv_meta"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="6dp"
android:layout_weight="1"
android:ellipsize="end"
android:maxLines="1"
android:text="0 人"
android:textColor="#6B7280"
android:textSize="11sp" />
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="5dp"
android:orientation="horizontal">
<TextView
android:id="@+id/tv_best"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:background="#ECFDF5"
android:ellipsize="end"
android:maxLines="2"
android:paddingStart="6dp"
android:paddingTop="4dp"
android:paddingEnd="6dp"
android:paddingBottom="4dp"
android:text="最高 -"
android:textColor="#047857"
android:textSize="11sp"
android:textStyle="bold" />
<TextView
android:id="@+id/tv_worst"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="5dp"
android:layout_weight="1"
android:background="#FFFBEB"
android:ellipsize="end"
android:maxLines="2"
android:paddingStart="6dp"
android:paddingTop="4dp"
android:paddingEnd="6dp"
android:paddingBottom="4dp"
android:text="最低 -"
android:textColor="#B45309"
android:textSize="11sp"
android:textStyle="bold" />
</LinearLayout>
</LinearLayout>

147
debug-server/mmp_help.html Normal file
View File

@@ -0,0 +1,147 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>红包领取台 · 功能说明</title>
<style>
:root {
--bg: #f6f1ea; --panel: #fff; --ink: #1c1410; --muted: #5d4037;
--line: #e7e0d8; --accent: #c45c26; --nav: #111827;
}
* { box-sizing: border-box; }
body {
margin: 0; font-family: "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
background: var(--bg); color: var(--ink); line-height: 1.55;
}
header {
position: sticky; top: 0; z-index: 2;
display: flex; gap: 12px; align-items: center; flex-wrap: wrap;
padding: 12px 16px; background: var(--nav); color: #fff;
}
header h1 { margin: 0; font-size: 17px; font-weight: 700; }
a.nav { color: #fdba74; text-decoration: none; font-size: 13px; }
a.nav:hover { text-decoration: underline; }
main { max-width: 720px; margin: 0 auto; padding: 20px 16px 48px; }
section {
background: var(--panel); border: 1px solid var(--line);
border-radius: 10px; padding: 16px 18px; margin-bottom: 12px;
}
h2 { margin: 0 0 8px; font-size: 16px; }
p, li { margin: 0; color: var(--muted); font-size: 14px; }
p + p { margin-top: 8px; }
ol, ul { margin: 6px 0 0; padding-left: 1.25em; color: var(--muted); font-size: 14px; }
li + li { margin-top: 4px; }
code {
font-family: ui-monospace, Consolas, monospace; font-size: 12px;
background: #f3eee6; padding: 1px 5px; border-radius: 4px; color: #1c1410;
}
.tip {
margin-top: 10px; padding: 10px 12px; border-radius: 8px;
background: #fff7ed; border: 1px solid #fed7aa; color: #9a3412; font-size: 13px;
}
</style>
</head>
<body>
<header>
<h1>功能说明</h1>
<a class="nav" href="/mmp">← 返回领取台</a>
<a class="nav" href="/">通用消息台</a>
</header>
<main>
<section>
<h2>红包领取台做什么</h2>
<p>独立统计 TNG Money Packet红包谁发的、谁领了、领了多少。</p>
<p>数据只进本台,不进入通用「情况」监听台,也不走正式上传。</p>
</section>
<section>
<h2>怎么用</h2>
<ol>
<li>主 App 监听列表勾选 TNG</li>
<li>LSPosed 勾选本 Xposed 模块,作用域含 TNG</li>
<li>强制停止后再打开 TNG若闪退见下方「打开 TNG 闪退怎么办」</li>
<li>登录 / 输 PIN 后,打开手机「红包领取台」等待自动拉取(一般不必再进红包历史页)</li>
<li>电脑本页看数据:调试台运行 + <code>adb reverse tcp:8765 tcp:8765</code>,手机点「同步电脑」或打开领取台自动同步</li>
</ol>
</section>
<section>
<h2>断线自动进历史页</h2>
<p>设置可勾选「检测不到连接时:自动进历史页再返回」。</p>
<ul>
<li>session 丢失、历史 RPC 失败、或还没有请求模板时触发</li>
<li>短暂打开 TNG 红包历史页约 2 秒后自动返回,用来重建连接</li>
<li>约 45 秒最多一次,避免刷屏</li>
<li>仍需要 TNG 进程在跑;完全杀掉后不会自己拉起</li>
</ul>
</section>
<section>
<h2>打开 TNG 闪退怎么办</h2>
<p>装了 Xposed / 防护相关模块后TNG 偶发一打开就闪退,属常见现象,可按顺序试:</p>
<ol>
<li>多点几次图标再打开(有时前几次会崩,后面就能进)</li>
<li>仍不行:系统设置 → 应用 → TNG → <strong>强制停止</strong>,再重新打开</li>
<li>还不行:强制停止后清一下「缓存」(尽量别清「数据」,否则要重新登录)</li>
<li>推送 / 更新过 Hook 模块后:务必强制停止再开 TNG否则新逻辑可能不生效或更易闪</li>
<li>连续闪退:先关掉 LSPosed 里本模块对 TNG 的作用域试能否正常打开,确认后再勾回并强停重开</li>
</ol>
<div class="tip">能进首页并登录 / 输 PIN 后,再回领取台等自动拉数据。</div>
</section>
<section>
<h2>列表怎么看</h2>
<ul>
<li>右侧橙色金额:已领合计</li>
<li>领取中 / 已领完:红包是否还在领</li>
<li>最高/最低(领取中)或 最佳/最差(已领完):手气两端</li>
<li>点选左侧条目:右侧看完整领取排行</li>
<li>清空:只清本台红包数据</li>
</ul>
</section>
<section>
<h2>筛选</h2>
<ul>
<li>关键词:昵称 / 发送人 / 红包 ID</li>
<li>状态:全部、领取中、已领完</li>
<li>时间:近 1 / 3 / 7 天,或自定义起止</li>
</ul>
</section>
<section>
<h2>同步与电脑访问</h2>
<p>手机是数据源。同步后落盘到调试台,刷新本页即可。</p>
<ul>
<li>本机:<code>http://127.0.0.1:8765/mmp</code></li>
<li>局域网:<code>http://&lt;电脑IP&gt;:8765/mmp</code></li>
</ul>
<div class="tip">8765 不要多开多个调试台进程,否则同步会挂。</div>
</section>
<section>
<h2>设置含义</h2>
<ul>
<li>① 历史列表冷却:多久再扫一次红包历史列表</li>
<li>② 详情刷新冷却:领取中的包多久再拉名单</li>
<li>③ 相同名单去重:名单没变时防重复推送</li>
<li>④ 详情请求间隔:连拉多个包之间的停顿(毫秒)</li>
<li>页面轮询:电脑页多久向服务器拉一次列表</li>
</ul>
<p style="margin-top:8px">已领完的包会停刷详情。不懂就用「标准」预设再保存,约 10 秒后对手机 Hook 生效。</p>
</section>
<section>
<h2>没数据时先查</h2>
<ul>
<li>监听列表是否勾选 TNG</li>
<li>Xposed 是否启用且作用域含 TNG</li>
<li>是否按「打开 TNG 闪退怎么办」做过强制停止再开,并完成登录</li>
<li>同步失败调试台是否在跑、端口是否冲突、adb reverse 是否做好</li>
</ul>
<p style="margin-top:8px">更细的技术说明见仓库 <code>docs/TNG_MoneyPacket领取台.md</code></p>
</section>
</main>
</body>
</html>

542
debug-server/mmp_page.html Normal file
View File

@@ -0,0 +1,542 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>红包领取台</title>
<style>
:root {
--bg: #f3f4f6;
--panel: #ffffff;
--ink: #111827;
--muted: #6b7280;
--line: #e5e7eb;
--money: #c2410c;
--best: #047857;
--best-bg: #ecfdf5;
--worst: #b45309;
--worst-bg: #fffbeb;
--done: #047857;
--open: #b45309;
--rank: #ea580c;
}
* { box-sizing: border-box; }
html, body { height: 100%; margin: 0; }
body {
font-family: "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
background: var(--bg); color: var(--ink);
display: flex; flex-direction: column;
}
button, input { font: inherit; }
header {
background: var(--panel); border-bottom: 1px solid var(--line);
padding: 12px 16px; display: flex; flex-wrap: wrap; gap: 10px; align-items: center;
flex-shrink: 0;
}
h1 { margin: 0; font-size: 18px; font-weight: 700; margin-right: auto; }
.stat { color: var(--muted); font-size: 13px; }
a.nav { color: #2563eb; text-decoration: none; font-size: 13px; }
.btn {
border: 1px solid var(--line); background: #fff; color: var(--ink);
padding: 7px 12px; border-radius: 6px; cursor: pointer; font-size: 13px;
}
.btn.primary { background: #ea580c; border-color: #ea580c; color: #fff; font-weight: 600; }
.conn-bar {
background: #1f2937; color: #e5e7eb; padding: 8px 16px; font-size: 12px;
line-height: 1.45; flex-shrink: 0; border-bottom: 1px solid #111827;
}
.conn-bar.ok { background: #064e3b; color: #d1fae5; }
.conn-bar.bad { background: #7f1d1d; color: #fee2e2; }
.conn-bar.warn { background: #78350f; color: #fef3c7; }
.uid { display: block; font-size: 11px; color: var(--muted); font-weight: 500; margin-top: 2px; }
.toolbar {
background: var(--panel); border-bottom: 1px solid var(--line);
padding: 10px 16px; display: grid; gap: 8px; flex-shrink: 0;
}
.row { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
.search {
flex: 1; min-width: 180px; border: 1px solid var(--line); border-radius: 6px;
padding: 8px 10px; background: #fff;
}
.chip {
border: 1px solid var(--line); background: #fff; color: var(--muted);
padding: 6px 10px; border-radius: 6px; cursor: pointer; font-size: 13px;
}
.chip.active { background: #111827; color: #fff; border-color: #111827; }
.chip.tone.active { background: #ea580c; border-color: #ea580c; }
.range { display: none; gap: 8px; align-items: center; flex-wrap: wrap; }
.range.open { display: flex; }
.range input { border: 1px solid var(--line); border-radius: 6px; padding: 6px 8px; }
.settings-panel {
display: none; background: #fff; border-bottom: 1px solid var(--line);
padding: 12px 16px; flex-shrink: 0;
}
.settings-panel.open { display: block; }
.settings-grid {
display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 10px;
}
.settings-grid label { display: flex; flex-direction: column; gap: 4px; font-size: 12px; color: var(--muted); }
.settings-grid input[type=number] { border: 1px solid var(--line); border-radius: 6px; padding: 8px; }
.check { flex-direction: row !important; align-items: center; gap: 8px !important; }
.settings-actions { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 10px; align-items: center; }
.settings-hint { color: var(--muted); font-size: 12px; margin-top: 8px; }
.layout { display: flex; flex: 1; min-height: 0; }
.sidebar {
width: 340px; background: var(--panel); border-right: 1px solid var(--line);
overflow-y: auto; flex-shrink: 0;
}
.sidebar h2 {
margin: 0; padding: 10px 14px; font-size: 12px; color: var(--muted);
border-bottom: 1px solid var(--line); background: #fafafa; font-weight: 600;
}
.pkt {
padding: 8px 12px; border-bottom: 1px solid var(--line); cursor: pointer;
}
.pkt:hover { background: #f9fafb; }
.pkt.active { background: #fff7ed; box-shadow: inset 3px 0 0 #ea580c; }
.pkt-row { display: flex; justify-content: space-between; gap: 8px; align-items: center; }
.pkt-name { font-size: 13px; font-weight: 700; line-height: 1.25; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.pkt-uid { font-size: 11px; color: #9ca3af; font-weight: 500; margin-top: 2px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-family: ui-monospace, Consolas, monospace; }
.pkt-money {
font-size: 15px; font-weight: 800; color: var(--money);
font-variant-numeric: tabular-nums; white-space: nowrap;
}
.pkt-meta { margin-top: 3px; font-size: 11px; color: var(--muted); display: flex; gap: 5px; flex-wrap: nowrap; align-items: center; overflow: hidden; }
.pkt-meta span { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.badge {
display: inline-block; padding: 1px 5px; border-radius: 3px; font-size: 10px; font-weight: 700; flex-shrink: 0;
max-width: 100%; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.badge.done { background: var(--best-bg); color: var(--done); }
.badge.open { background: var(--worst-bg); color: var(--open); }
.badge.expired { background: #f3f4f6; color: #6b7280; }
.pkt-luck {
margin-top: 4px; display: grid; grid-template-columns: 1fr 1fr; gap: 4px;
}
.mini {
border-radius: 4px; padding: 3px 6px; font-size: 11px; line-height: 1.25;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-weight: 700;
}
.mini.best { background: var(--best-bg); color: var(--best); }
.mini.worst { background: var(--worst-bg); color: var(--worst); }
.pkt-id { margin-left: auto; flex-shrink: 0; font-size: 10px; color: #9ca3af; font-family: ui-monospace, Consolas, monospace; }
.main { flex: 1; overflow-y: auto; padding: 16px 18px; }
.empty { padding: 60px 20px; text-align: center; color: var(--muted); }
.title-row { display: flex; justify-content: space-between; gap: 12px; align-items: flex-start; margin-bottom: 6px; }
.title { margin: 0; font-size: 22px; font-weight: 800; }
.title-money {
font-size: 28px; font-weight: 900; color: var(--money);
font-variant-numeric: tabular-nums; line-height: 1;
}
.sub { color: var(--muted); font-size: 12px; margin-bottom: 14px; word-break: break-all; }
.kpi {
display: grid; grid-template-columns: repeat(4, minmax(0,1fr)); gap: 10px; margin-bottom: 12px;
}
.kpi .box {
background: var(--panel); border: 1px solid var(--line); border-radius: 8px; padding: 12px;
}
.kpi .k { font-size: 12px; color: var(--muted); font-weight: 600; }
.kpi .v {
margin-top: 4px; font-size: 22px; font-weight: 800;
font-variant-numeric: tabular-nums;
}
.kpi .v.money { color: var(--money); }
.luck-grid {
display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-bottom: 16px;
}
.luck-card {
border-radius: 8px; padding: 14px 16px; border: 1px solid transparent;
}
.luck-card.best { background: var(--best-bg); border-color: #a7f3d0; }
.luck-card.worst { background: var(--worst-bg); border-color: #fde68a; }
.luck-card .lab { font-size: 12px; font-weight: 700; opacity: .9; }
.luck-card .name { margin-top: 4px; font-size: 18px; font-weight: 800; }
.luck-card .amt {
margin-top: 2px; font-size: 24px; font-weight: 900;
font-variant-numeric: tabular-nums;
}
.luck-card.best .lab, .luck-card.best .name, .luck-card.best .amt { color: var(--best); }
.luck-card.worst .lab, .luck-card.worst .name, .luck-card.worst .amt { color: var(--worst); }
.section { font-size: 13px; font-weight: 700; color: var(--muted); margin: 0 0 8px; }
table {
width: 100%; border-collapse: collapse; background: var(--panel);
border: 1px solid var(--line); border-radius: 8px; overflow: hidden;
}
th, td { padding: 11px 12px; text-align: left; border-bottom: 1px solid var(--line); font-size: 13px; }
th { background: #f9fafb; color: var(--muted); font-weight: 700; font-size: 12px; }
tr:last-child td { border-bottom: 0; }
tr.row-best { background: #ecfdf5; }
tr.row-worst { background: #fffbeb; }
.rank { width: 48px; color: var(--rank); font-weight: 800; font-variant-numeric: tabular-nums; }
.nick { font-weight: 700; }
.amt { color: var(--money); font-weight: 800; font-variant-numeric: tabular-nums; font-size: 15px; }
.tag-inline {
margin-left: 6px; font-size: 11px; font-weight: 700; padding: 1px 5px; border-radius: 3px;
}
.tag-inline.best { background: #d1fae5; color: var(--best); }
.tag-inline.worst { background: #fef3c7; color: var(--worst); }
@media (max-width: 900px) {
.layout { flex-direction: column; }
.sidebar { width: 100%; max-height: 36vh; border-right: 0; border-bottom: 1px solid var(--line); }
.kpi, .luck-grid { grid-template-columns: 1fr 1fr; }
.title-money { font-size: 22px; }
}
</style>
</head>
<body>
<header>
<h1>红包领取台</h1>
<span class="stat" id="pktCount">0 个</span>
<span class="stat" id="updated">-</span>
<span class="stat" id="cfgHint">刷新: -</span>
<a class="nav" href="/mmp/help">功能说明</a>
<a class="nav" href="/">通用消息台</a>
<button class="btn" onclick="toggleSettings()">设置</button>
<button class="btn" onclick="loadData()">刷新</button>
<button class="btn primary" onclick="clearAll()">清空</button>
</header>
<div class="conn-bar warn" id="connBar">模块检测中…</div>
<div class="toolbar">
<div class="row">
<input class="search" type="search" id="filterQ" placeholder="搜索昵称 / 发送人 / ID" oninput="onFilterChange()" />
<button type="button" class="chip tone active" id="fAll" onclick="setStatus('all')">全部</button>
<button type="button" class="chip" id="fOpen" onclick="setStatus('open')">领取中</button>
<button type="button" class="chip" id="fDone" onclick="setStatus('done')">已领完</button>
</div>
<div class="row">
<button type="button" class="chip active" id="tAll" onclick="setTime('all')">全部时间</button>
<button type="button" class="chip" id="t1" onclick="setTime('1')">近1天</button>
<button type="button" class="chip" id="t3" onclick="setTime('3')">近3天</button>
<button type="button" class="chip" id="t7" onclick="setTime('7')">近7天</button>
<button type="button" class="chip" id="tCustom" onclick="setTime('custom')">自定义</button>
<div class="range" id="customRange">
<label><input type="date" id="dateFrom" onchange="onFilterChange()" /></label>
<label><input type="date" id="dateTo" onchange="onFilterChange()" /></label>
</div>
</div>
</div>
<div class="settings-panel" id="settingsPanel">
<div class="settings-grid">
<label>① 历史列表冷却(秒)— 多久扫一次红包列表
<input type="number" id="cfgHistory" min="1" max="300" step="1" />
</label>
<label>② 详情刷新冷却(秒)— 领取中的包多久再拉名单
<input type="number" id="cfgDetail" min="1" max="300" step="1" />
</label>
<label>③ 相同名单去重(秒)— 名单没变时防重复推送
<input type="number" id="cfgDedup" min="0" max="120" step="1" />
</label>
<label>④ 详情请求间隔(毫秒)— 连拉多个包之间的停顿
<input type="number" id="cfgGap" min="0" max="5000" step="10" />
</label>
<label>页面轮询(毫秒)
<input type="number" id="cfgPagePoll" min="500" max="30000" step="100" />
</label>
<label class="check"><input type="checkbox" id="cfgOpenHistory" /> 无模板时自动打开历史页</label>
<label class="check"><input type="checkbox" id="cfgBounceHistory" /> 检测不到连接时:自动进历史页再返回(重建连接)</label>
</div>
<div class="settings-actions">
<button type="button" class="chip" onclick="applyPreset('fast')">极速</button>
<button type="button" class="chip" onclick="applyPreset('normal')">标准</button>
<button type="button" class="chip" onclick="applyPreset('slow')">省流</button>
<button type="button" class="btn primary" onclick="saveSettings()">保存</button>
<span class="stat" id="cfgStatus"></span>
</div>
<div class="settings-hint">不懂就选「标准」再保存。约 10 秒后对手机 Hook 生效。</div>
</div>
<div class="layout">
<aside class="sidebar">
<h2>红包列表</h2>
<div id="list"></div>
</aside>
<main class="main">
<div id="empty" class="empty">暂无数据。打开手机红包领取台会自动同步到这里。</div>
<div id="panel" style="display:none">
<div class="title-row">
<h2 class="title" id="title">-</h2>
<div class="title-money" id="titleMoney">RM 0.00</div>
</div>
<div class="sub" id="sub">-</div>
<div class="kpi">
<div class="box"><div class="k">领取人数</div><div class="v" id="cPeople">0</div></div>
<div class="box"><div class="k">领取合计</div><div class="v money" id="cSum">0.00</div></div>
<div class="box"><div class="k">红包总额</div><div class="v money" id="cTotal">-</div></div>
<div class="box"><div class="k">状态</div><div class="v" id="cStatus">-</div></div>
</div>
<div class="luck-grid">
<div class="luck-card best">
<div class="lab" id="luckBestLab">手气最佳</div>
<div class="name" id="luckBestName">-</div>
<div class="amt" id="luckBestAmt">-</div>
</div>
<div class="luck-card worst">
<div class="lab" id="luckWorstLab">手气最差</div>
<div class="name" id="luckWorstName">-</div>
<div class="amt" id="luckWorstAmt">-</div>
</div>
</div>
<div class="section">领取排行</div>
<table>
<thead><tr><th class="rank">#</th><th>昵称 / ID</th><th>金额</th><th>时间</th></tr></thead>
<tbody id="board"></tbody>
</table>
</div>
</main>
</div>
<script>
let packets = [], activeId = null, pagePollTimer = null;
let statusFilter = 'all', timeFilter = 'all', keyword = '';
let settings = { historyCooldownSec: 8, detailCooldownSec: 8, dedupSec: 3, detailGapMs: 80, pagePollMs: 1000, openHistoryIfNoTemplate: true, autoBounceHistoryOnDisconnect: true };
function toggleSettings(){ document.getElementById('settingsPanel').classList.toggle('open'); }
function fillSettingsForm(){
cfgHistory.value = settings.historyCooldownSec; cfgDetail.value = settings.detailCooldownSec;
cfgDedup.value = settings.dedupSec; cfgGap.value = settings.detailGapMs;
cfgPagePoll.value = settings.pagePollMs; cfgOpenHistory.checked = !!settings.openHistoryIfNoTemplate;
cfgBounceHistory.checked = settings.autoBounceHistoryOnDisconnect !== false;
cfgHint.textContent = '刷新: 历史' + settings.historyCooldownSec + 's / 详情' + settings.detailCooldownSec + 's';
}
function readSettingsForm(){
return {
historyCooldownSec: Number(cfgHistory.value), detailCooldownSec: Number(cfgDetail.value),
dedupSec: Number(cfgDedup.value), detailGapMs: Number(cfgGap.value),
pagePollMs: Number(cfgPagePoll.value), openHistoryIfNoTemplate: cfgOpenHistory.checked,
autoBounceHistoryOnDisconnect: cfgBounceHistory.checked
};
}
function applyPreset(name){
if (name==='fast') settings={historyCooldownSec:5,detailCooldownSec:5,dedupSec:2,detailGapMs:50,pagePollMs:800,openHistoryIfNoTemplate:true,autoBounceHistoryOnDisconnect:true};
else if (name==='slow') settings={historyCooldownSec:30,detailCooldownSec:45,dedupSec:15,detailGapMs:200,pagePollMs:3000,openHistoryIfNoTemplate:true,autoBounceHistoryOnDisconnect:true};
else settings={historyCooldownSec:8,detailCooldownSec:8,dedupSec:3,detailGapMs:80,pagePollMs:1000,openHistoryIfNoTemplate:true,autoBounceHistoryOnDisconnect:true};
fillSettingsForm();
}
async function loadSettings(){ settings = await (await fetch('/api/mmp/settings')).json(); fillSettingsForm(); restartPagePoll(); }
async function saveSettings(){
settings = await (await fetch('/api/mmp/settings',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(readSettingsForm())})).json();
fillSettingsForm(); restartPagePoll(); cfgStatus.textContent = '已保存 ' + new Date().toLocaleTimeString();
}
function restartPagePoll(){ if(pagePollTimer) clearInterval(pagePollTimer); pagePollTimer=setInterval(loadData, Math.max(500, Number(settings.pagePollMs)||1000)); }
function setStatus(s){
statusFilter=s;
['fAll','fOpen','fDone'].forEach(id=>document.getElementById(id).classList.remove('active'));
document.getElementById(s==='all'?'fAll':s==='open'?'fOpen':'fDone').classList.add('active');
renderList();
}
function setTime(t){
timeFilter=t;
['tAll','t1','t3','t7','tCustom'].forEach(id=>document.getElementById(id).classList.remove('active'));
document.getElementById({all:'tAll','1':'t1','3':'t3','7':'t7',custom:'tCustom'}[t]).classList.add('active');
customRange.classList.toggle('open', t==='custom');
renderList();
}
function onFilterChange(){ keyword=(filterQ.value||'').trim().toLowerCase(); renderList(); }
function parseIssueMs(text){
const s=(text||'').trim(); if(!s) return null;
let m=s.match(/^(\d{2})\/(\d{2})\/(\d{4})(?:\s+(\d{2}):(\d{2})(?::(\d{2}))?)?/);
if(m) return new Date(+m[3],+m[2]-1,+m[1],+(m[4]||0),+(m[5]||0),+(m[6]||0)).getTime();
m=s.match(/^(\d{4})-(\d{2})-(\d{2})/);
if(m) return new Date(+m[1],+m[2]-1,+m[3]).getTime();
const t=Date.parse(s); return Number.isNaN(t)?null:t;
}
function inTimeRange(p){
if(timeFilter==='all') return true;
const ms=parseIssueMs(p.issuedAt||p.latestAt||p.updatedAt||p.fetchedAt||'');
if(ms==null) return false;
const now=Date.now();
if(timeFilter==='1'||timeFilter==='3'||timeFilter==='7') return ms>=now-Number(timeFilter)*86400000;
if(timeFilter==='custom'){
const from=dateFrom.value, to=dateTo.value;
if(from){ const fromMs=new Date(from+'T00:00:00').getTime(); if(ms<fromMs) return false; }
if(to){ const toMs=new Date(to+'T00:00:00').getTime()+86400000-1; if(ms>toMs) return false; }
}
return true;
}
function filteredPackets(){
return packets.filter(p=>{
if(statusFilter==='open'&&p.finished) return false;
if(statusFilter==='done'&&!p.finished) return false;
if(!inTimeRange(p)) return false;
if(!keyword) return true;
const blob=[p.packetId,p.sender,p.title,p.group,p.total,p.bestNick,p.worstNick,...(p.leaderboard||[]).map(r=>[r.nickname,r.userId,r.poolId].join(' '))].join(' ').toLowerCase();
return blob.indexOf(keyword)>=0;
});
}
function money(v){ const n=Number(v); return Number.isFinite(n)?n.toFixed(2):String(v??'-'); }
function shortId(id){ const s=String(id||''); return (s.length>=8&&s.indexOf('-')>0)?s.slice(0,8):(s.slice(0,16)||'-'); }
function formatHiLo(prefix, row){
if(!row) return prefix + ' -';
const nick = row.nickname || row.bestNick || '-';
const amt = row.amountText || row.bestAmount || (row.amount!=null ? money(row.amount) : '');
const uid = row.userId || '';
if(uid) return `${prefix} ${esc(nick)} ${esc(amt)}<div class="pkt-uid">ID ${esc(uid)}</div>`;
return `${prefix} ${esc(nick)} ${esc(amt)}`.trim();
}
function hiLo(p){
return {
hi: p&&p.finished?'手气最佳':'目前最高',
lo: p&&p.finished?'手气最差':'目前最低'
};
}
async function loadData(){
const [pktRes, stRes] = await Promise.all([
fetch('/api/mmp'),
fetch('/api/mmp/status')
]);
packets = await pktRes.json();
updated.textContent = '更新 ' + new Date().toLocaleTimeString();
try {
const st = await stRes.json();
renderConnBar(st);
} catch (e) {
renderConnBar({ text: '模块状态不可用', ok: false });
}
renderList();
}
function renderConnBar(st){
const el = document.getElementById('connBar');
if (!el) return;
el.textContent = (st && st.text) ? st.text : '模块状态未知';
el.className = 'conn-bar ' + (st && st.ok ? 'ok' : (st && st.liveAt ? 'bad' : 'warn'));
}
function renderList(){
const view=filteredPackets();
pktCount.textContent = view.length + '/' + packets.length + ' 个';
list.innerHTML='';
if(!view.length){ empty.style.display='block'; panel.style.display='none'; activeId=null; return; }
empty.style.display='none'; panel.style.display='block';
if(!activeId || !view.find(p=>p.packetId===activeId)) activeId=view[0].packetId;
for(const p of view){
const div=document.createElement('div');
div.className='pkt'+(p.packetId===activeId?' active':'');
div.onclick=()=>{ activeId=p.packetId; renderList(); };
const label = p.statusLabel || (p.finished ? '已领完' : '领取中');
const fully = label.indexOf('已全部领取') >= 0;
const expired = label.indexOf('已过期') >= 0;
const badgeClass = fully ? 'done' : (expired ? 'expired' : 'open');
const badge=`<span class="badge ${badgeClass}">${esc(label)}</span>`;
const hiShort = p.finished || fully ? '最佳' : '最高';
const loShort = p.finished || fully ? '最差' : '最低';
const best = (p.leaderboard && p.leaderboard[0]) || null;
const worst = (p.leaderboard && p.leaderboard.length > 1)
? p.leaderboard[p.leaderboard.length - 1] : null;
const bestLine = formatHiLo(hiShort, best || {nickname:p.bestNick, amountText:p.bestAmount, userId:p.bestUserId});
const worstLine = (p.claimantCount||0)<=1
? `${loShort} -`
: formatHiLo(loShort, worst || {nickname:p.worstNick, amountText:p.worstAmount, userId:p.worstUserId});
const timeLine = p.expiresAt
? `${esc(p.issuedAt||'时间未知')} · 到期 ${esc(p.expiresAt)}`
: esc(p.issuedAt||'时间未知');
const countLine = (p.totalCount > 0)
? `${p.claimedCount||p.claimantCount||0}/${p.totalCount}`
: `${p.claimantCount||0}`;
div.innerHTML=`
<div class="pkt-row">
<div style="min-width:0;flex:1">
<div class="pkt-name">${esc(p.sender||p.title||'红包')}</div>
${p.packetId ? `<div class="pkt-uid">ID ${esc(p.packetId)}</div>` : ''}
</div>
<div class="pkt-money">${money(p.sumClaimed??p.total??0)}</div>
</div>
<div class="pkt-meta">${badge}<span>${countLine} · ${timeLine}</span></div>
<div class="pkt-luck">
<div class="mini best">${bestLine}</div>
<div class="mini worst">${worstLine}</div>
</div>`;
list.appendChild(div);
}
renderActive();
}
function renderActive(){
const p=packets.find(x=>x.packetId===activeId); if(!p) return;
const labels=hiLo(p);
title.textContent = p.sender ? p.sender + ' 的红包' : (p.title || '红包详情');
titleMoney.textContent = 'RM ' + money(p.sumClaimed ?? p.total ?? 0);
const parts=[];
if(p.packetId) parts.push('ID '+p.packetId);
if(p.issuedAt) parts.push('发放 '+p.issuedAt);
if(p.expiresAt) parts.push('到期 '+p.expiresAt);
if(p.via) parts.push(p.via);
sub.textContent = parts.join(' · ');
cPeople.textContent = p.claimantCount || 0;
cSum.textContent = money(p.sumClaimed ?? 0);
cTotal.textContent = p.total || '-';
cStatus.innerHTML = (()=>{
const label = p.statusLabel || (p.finished ? '已领完' : '领取中');
const fully = label.indexOf('已全部领取') >= 0;
const expired = label.indexOf('已过期') >= 0;
const cls = fully ? 'done' : (expired ? 'expired' : 'open');
return `<span class="badge ${cls}">${esc(label)}</span>`;
})();
luckBestLab.textContent = labels.hi;
luckWorstLab.textContent = labels.lo;
const best = (p.leaderboard && p.leaderboard[0]) || null;
const worst = (p.leaderboard && p.leaderboard.length > 1)
? p.leaderboard[p.leaderboard.length - 1] : null;
if(best){
luckBestName.innerHTML = best.userId
? `${esc(best.nickname||'-')}<div class="pkt-uid">ID ${esc(best.userId)}</div>`
: esc(best.nickname||p.bestNick||'-');
luckBestAmt.textContent = best.amountText ? ('RM ' + best.amountText)
: (p.bestAmount ? ('RM ' + p.bestAmount) : '-');
} else {
luckBestName.textContent = p.bestNick || '-';
luckBestAmt.textContent = p.bestAmount ? ('RM ' + p.bestAmount) : '-';
}
if(worst && (p.claimantCount||0)>1){
luckWorstName.innerHTML = worst.userId
? `${esc(worst.nickname||'-')}<div class="pkt-uid">ID ${esc(worst.userId)}</div>`
: esc(worst.nickname||p.worstNick||'-');
luckWorstAmt.textContent = worst.amountText ? ('RM ' + worst.amountText)
: (p.worstAmount ? ('RM ' + p.worstAmount) : '-');
} else {
luckWorstName.textContent='-'; luckWorstAmt.textContent='-';
}
const rows=p.leaderboard||[];
board.innerHTML='';
if(!rows.length){
board.innerHTML='<tr><td colspan="4" style="text-align:center;color:#6b7280">暂无领取记录</td></tr>';
return;
}
const maxAmt = Math.max(...rows.map(r=>Number(r.amount)||0));
const minAmt = Math.min(...rows.map(r=>Number(r.amount)||0));
for(const row of rows){
const amt=Number(row.amount)||0;
const isBest=rows.length>1 && amt===maxAmt;
const isWorst=rows.length>1 && amt===minAmt && maxAmt!==minAmt;
const tr=document.createElement('tr');
if(isBest) tr.className='row-best';
if(isWorst) tr.className='row-worst';
let nick=esc(row.nickname);
if(isBest) nick += '<span class="tag-inline best">最高</span>';
if(isWorst) nick += '<span class="tag-inline worst">最低</span>';
let nickHtml=`<div>${nick}</div>`;
if(row.userId) nickHtml += `<span class="uid">用户ID ${esc(row.userId)}</span>`;
if(row.poolId) nickHtml += `<span class="uid">领取ID ${esc(row.poolId)}</span>`;
tr.innerHTML=`
<td class="rank">${row.rank}</td>
<td class="nick">${nickHtml}</td>
<td class="amt">${esc(row.amountText || money(row.amount))}</td>
<td>${esc(row.claimTime || '-')}</td>`;
board.appendChild(tr);
}
}
async function clearAll(){
if(!confirm('仅清空红包领取台数据?')) return;
await fetch('/api/mmp',{method:'DELETE'}); activeId=null; loadData();
}
function esc(s){ return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }
loadSettings().then(loadData);
</script>
</body>
</html>

View File

@@ -2,7 +2,10 @@
"""notiMessage 本地调试转发服务 — 在 PC 浏览器查看手机抓取的消息。"""
import json
import os
import re
import threading
import time
from collections import deque
from datetime import datetime
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
@@ -11,9 +14,96 @@ from urllib.parse import urlparse
HOST = "0.0.0.0"
PORT = 8765
MAX_MESSAGES = 500
SETTINGS_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "mmp_settings.json")
PACKETS_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "mmp_packets.json")
HOOK_STATUS_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "mmp_hook_status.json")
# 与 xposed-module / App MmpModuleInfo 同步
EXPECTED_MODULE_CODE = 5
EXPECTED_MODULE_NAME = "1.2.2"
_messages = deque(maxlen=MAX_MESSAGES)
_mmp_messages = deque(maxlen=MAX_MESSAGES)
_mmp_synced = {} # packetId -> packet dict手机全量同步落盘
_mmp_hook_status = {
"liveCode": 0,
"liveName": "",
"liveAt": 0, # epoch ms
"installedCode": 0,
"installedName": "",
"source": "",
}
_lock = threading.Lock()
_last_dedup = {"key": None, "ts": 0.0}
_mmp_last_dedup = {"key": None, "ts": 0.0}
_mmp_settings_lock = threading.Lock()
_DEFAULT_MMP_SETTINGS = {
"historyCooldownSec": 8,
"detailCooldownSec": 8,
"dedupSec": 3,
"detailGapMs": 80,
"pagePollMs": 1000,
"openHistoryIfNoTemplate": True,
"autoBounceHistoryOnDisconnect": True,
}
_mmp_settings = dict(_DEFAULT_MMP_SETTINGS)
def _load_mmp_settings():
global _mmp_settings
try:
if os.path.isfile(SETTINGS_PATH):
with open(SETTINGS_PATH, "r", encoding="utf-8") as f:
data = json.load(f)
if isinstance(data, dict):
merged = dict(_DEFAULT_MMP_SETTINGS)
merged.update(data)
_mmp_settings = _normalize_mmp_settings(merged)
except Exception as e:
print("load mmp settings failed:", e)
def _save_mmp_settings():
try:
with open(SETTINGS_PATH, "w", encoding="utf-8") as f:
json.dump(_mmp_settings, f, ensure_ascii=False, indent=2)
except Exception as e:
print("save mmp settings failed:", e)
def _normalize_mmp_settings(raw):
out = dict(_DEFAULT_MMP_SETTINGS)
if not isinstance(raw, dict):
return out
try:
out["historyCooldownSec"] = max(1, min(300, int(raw.get("historyCooldownSec", out["historyCooldownSec"]))))
out["detailCooldownSec"] = max(1, min(300, int(raw.get("detailCooldownSec", out["detailCooldownSec"]))))
out["dedupSec"] = max(0, min(120, int(raw.get("dedupSec", out["dedupSec"]))))
out["detailGapMs"] = max(0, min(5000, int(raw.get("detailGapMs", out["detailGapMs"]))))
out["pagePollMs"] = max(500, min(30000, int(raw.get("pagePollMs", out["pagePollMs"]))))
out["openHistoryIfNoTemplate"] = bool(raw.get(
"openHistoryIfNoTemplate", out["openHistoryIfNoTemplate"]))
out["autoBounceHistoryOnDisconnect"] = bool(raw.get(
"autoBounceHistoryOnDisconnect", out["autoBounceHistoryOnDisconnect"]))
except (TypeError, ValueError):
pass
return out
def _get_mmp_settings():
with _mmp_settings_lock:
return dict(_mmp_settings)
def _set_mmp_settings(raw):
global _mmp_settings
with _mmp_settings_lock:
_mmp_settings = _normalize_mmp_settings(raw)
_save_mmp_settings()
return dict(_mmp_settings)
_load_mmp_settings()
def _now_iso():
@@ -32,11 +122,42 @@ def _add_message(payload):
item = dict(payload)
item["group"] = _resolve_group(payload)
item["receivedAt"] = _now_iso()
dedup_key = "|".join([
str(payload.get("source") or ""),
str(payload.get("packageName") or ""),
str(payload.get("title") or ""),
str(payload.get("content") or ""),
])
now = time.time()
is_mmp = _is_mmp_message(item)
content_for_mod = ""
with _lock:
_messages.appendleft(item)
item["id"] = len(_messages)
print("[{0}] [{1}] [{2}] {3} | {4}".format(
if is_mmp:
if (_mmp_last_dedup["key"] == dedup_key
and now - float(_mmp_last_dedup["ts"]) < 3.0):
if _mmp_messages:
return _mmp_messages[0]
_mmp_last_dedup["key"] = dedup_key
_mmp_last_dedup["ts"] = now
_mmp_messages.appendleft(item)
item["id"] = len(_mmp_messages)
channel = "MMP"
content_for_mod = item.get("content") or ""
else:
if (_last_dedup["key"] == dedup_key
and now - float(_last_dedup["ts"]) < 3.0):
if _messages:
return _messages[0]
_last_dedup["key"] = dedup_key
_last_dedup["ts"] = now
_messages.appendleft(item)
item["id"] = len(_messages)
channel = "MSG"
if content_for_mod:
_note_module_from_content(content_for_mod)
print("[{0}] [{1}] [{2}] [{3}] {4} | {5}".format(
_now_iso(),
channel,
item["group"],
payload.get("source", "?"),
payload.get("appName", payload.get("packageName", "")),
@@ -45,6 +166,16 @@ def _add_message(payload):
return item
def _clear_mmp_messages():
with _lock:
_mmp_messages.clear()
_mmp_synced.clear()
# 兼容:顺带清掉旧版混入通用队列的 MMP
keep = [m for m in _messages if not _is_mmp_message(m)]
_messages.clear()
_messages.extend(keep)
_save_mmp_synced()
def _json_response(handler, status, data):
body = json.dumps(data, ensure_ascii=False).encode("utf-8")
handler.send_response(status)
@@ -58,6 +189,8 @@ def _json_response(handler, status, data):
def _group_messages(messages):
groups = {}
for msg in messages:
if _is_mmp_message(msg):
continue
key = msg.get("group") or _resolve_group(msg)
if key not in groups:
groups[key] = {
@@ -78,6 +211,751 @@ def _group_messages(messages):
return result
def _is_mmp_message(msg):
source = (msg.get("source") or "").lower()
content = msg.get("content") or ""
title = msg.get("title") or ""
if "tng_mmp" in source or "xposed_tng_mmp" in source:
return True
if "[MMP统计]" in content or "Money Packet" in title:
return True
lower = content.lower()
return "receiverlist" in lower or ("claimedamount" in lower and "mmp" in lower)
def _normalize_money(value):
"""把 1.23 / RM1.23 / {"amount":"0.71","cent":"71"} 统一成 float 或 None。"""
if value is None:
return None
text = str(value).strip()
if not text or text.lower() == "null":
return None
text = text.replace("RM", "").replace("rm", "").strip()
if text.startswith("{") and "amount" in text:
try:
obj = json.loads(text)
if isinstance(obj, dict):
if obj.get("amount") not in (None, ""):
return float(str(obj["amount"]).replace(",", ""))
if obj.get("cent") not in (None, ""):
return int(str(obj["cent"])) / 100.0
except Exception:
return None
try:
return float(text.replace(",", ""))
except ValueError:
return None
def _parse_mmp_content(content):
"""解析 TngMoneyPacketHook 转发文本 → {meta, claims[]}。"""
text = (content or "").strip()
meta = {}
claims = []
if not text:
return meta, claims
lines = text.splitlines()
head = lines[0] if lines else ""
if head.startswith("[MMP统计]"):
head = head[len("[MMP统计]"):].strip()
# 新格式用 " | " 分隔;旧格式兼容空格拆 token
if " | " in head:
parts = [p.strip() for p in head.split(" | ") if p.strip()]
else:
parts = []
buf = ""
for part in head.split():
if buf:
buf += " " + part
if buf.count("{") <= buf.count("}"):
parts.append(buf)
buf = ""
continue
if "=" in part and part.split("=", 1)[1].startswith("{") and part.count("{") > part.count("}"):
buf = part
continue
parts.append(part)
if buf:
parts.append(buf)
for part in parts:
if "=" not in part:
continue
k, v = part.split("=", 1)
meta[k.strip()] = v.strip()
# 旧报文 sender=LIAO RUICHAO 被空格截断时,尝试从原文还原
if meta.get("sender") and "sender=" in head:
raw_sender = head.split("sender=", 1)[1]
for stop in (" | ", " total=", " via=", " group=", " packet=", " src="):
if stop in raw_sender:
raw_sender = raw_sender.split(stop, 1)[0]
break
raw_sender = raw_sender.strip()
if raw_sender and len(raw_sender) > len(meta.get("sender") or ""):
meta["sender"] = raw_sender
if "total" in meta:
total_num = _normalize_money(meta["total"])
meta["total"] = ("{0:.2f}".format(total_num) if total_num is not None else meta["total"])
# 发放时间issued= / createTime= / issuedAt=
for key in ("issued", "createTime", "issuedAt", "create"):
if meta.get(key):
meta["issued"] = meta[key]
break
for line in lines[1:]:
line = line.strip()
if not line or "->" not in line:
continue
left, right = line.split("->", 1)
nick = left.strip()
right = right.strip()
user_id = ""
pool_id = ""
if " #rid=" in right:
before, _, rest = right.partition(" #rid=")
right = before.strip()
rid_part = rest.strip()
if " #pool=" in rid_part:
user_id, _, pool_rest = rid_part.partition(" #pool=")
user_id = user_id.strip()
pool_id = pool_rest.strip()
elif " " in rid_part:
user_id = rid_part.split(" ", 1)[0].strip()
else:
user_id = rid_part
if "#pool=" in user_id:
user_id, _, pool_id = user_id.partition("#pool=")
user_id = user_id.strip()
pool_id = pool_id.strip()
if " #pool=" in right and not pool_id:
before, _, pool_rest = right.partition(" #pool=")
right = before.strip()
pool_id = pool_rest.strip()
claim_time = ""
amount_raw = right
if "(" in right and ")" in right and not right.startswith("{"):
amt, _, rest = right.partition("(")
amount_raw = amt.strip()
claim_time = rest.split(")", 1)[0].strip()
amount_num = _normalize_money(amount_raw)
if nick or user_id:
claims.append({
"nickname": nick or "?",
"userId": user_id,
"poolId": pool_id,
"amount": ("{0:.2f}".format(amount_num) if amount_num is not None else "0.00"),
"amountValue": amount_num if amount_num is not None else 0.0,
"claimTime": claim_time,
})
return meta, claims
def _claims_fingerprint(claims):
rows = []
for c in claims:
key = c.get("userId") or c.get("nickname") or "?"
rows.append("{0}={1}".format(key, c.get("amount") or "0"))
rows.sort()
return "|".join(rows)
def _aggregate_claims(claims):
"""同一快照内按 userId无则昵称去重取较大金额禁止同人累加翻倍。"""
buckets = {}
order = []
for c in claims:
nick = c.get("nickname") or "?"
uid = (c.get("userId") or "").strip()
key = ("id:" + uid) if uid else ("n:" + nick)
try:
amt = float(c.get("amountValue") if c.get("amountValue") is not None
else _normalize_money(c.get("amount")) or 0.0)
except (TypeError, ValueError):
amt = 0.0
if key not in buckets:
buckets[key] = {
"nickname": nick,
"userId": uid,
"poolId": (c.get("poolId") or "").strip(),
"amount": amt,
"count": 1,
"claimTime": c.get("claimTime") or "",
}
order.append(key)
continue
b = buckets[key]
b["count"] += 1
if uid and not b.get("userId"):
b["userId"] = uid
if c.get("poolId") and not b.get("poolId"):
b["poolId"] = c.get("poolId") or ""
if amt >= b["amount"]:
b["amount"] = amt
if nick and nick != "?":
b["nickname"] = nick
if c.get("claimTime"):
b["claimTime"] = c.get("claimTime") or b["claimTime"]
if uid:
b["userId"] = uid
if c.get("poolId"):
b["poolId"] = c.get("poolId") or b.get("poolId") or ""
elif c.get("claimTime") and not b["claimTime"]:
b["claimTime"] = c.get("claimTime") or ""
result = []
for key in order:
b = buckets[key]
result.append({
"nickname": b["nickname"],
"userId": b.get("userId") or "",
"poolId": b.get("poolId") or "",
"amount": round(b["amount"], 4),
"amountText": "{0:.2f}".format(b["amount"]),
"claimCount": b["count"],
"claimTime": b.get("claimTime") or "",
})
result.sort(key=lambda x: x["amount"], reverse=True)
for i, row in enumerate(result, 1):
row["rank"] = i
return result
def _parse_mod_token(mod):
"""mod=1.2.0/3 → (name, code)"""
text = (mod or "").strip()
if not text:
return "", 0
name = text
code = 0
if "/" in text:
name, _, code_part = text.rpartition("/")
name = name.strip()
try:
code = int(code_part.strip())
except (TypeError, ValueError):
code = 0
return name, code
def _note_module_from_content(content):
if not content or "[MMP统计]" not in content:
return
head = content.strip().split("\n", 1)[0]
if "mod=" not in head:
return
raw = head.split("mod=", 1)[1]
for stop in (" | ", " src=", " "):
if stop in raw:
raw = raw.split(stop, 1)[0]
break
name, code = _parse_mod_token(raw)
_update_hook_status({
"liveCode": code,
"liveName": name,
"liveAt": int(time.time() * 1000),
"source": "message",
})
def _load_hook_status():
global _mmp_hook_status
try:
if not os.path.isfile(HOOK_STATUS_PATH):
return
with open(HOOK_STATUS_PATH, "r", encoding="utf-8") as f:
data = json.load(f)
if isinstance(data, dict):
with _lock:
_mmp_hook_status.update({
"liveCode": int(data.get("liveCode") or 0),
"liveName": str(data.get("liveName") or ""),
"liveAt": int(data.get("liveAt") or 0),
"installedCode": int(data.get("installedCode") or 0),
"installedName": str(data.get("installedName") or ""),
"source": str(data.get("source") or ""),
})
except Exception as e:
print("load mmp hook status failed:", e)
def _save_hook_status():
try:
with _lock:
data = dict(_mmp_hook_status)
with open(HOOK_STATUS_PATH, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=2)
except Exception as e:
print("save mmp hook status failed:", e)
def _update_hook_status(patch):
if not isinstance(patch, dict):
return
with _lock:
try:
incoming_live_at = int(patch.get("liveAt") or 0)
except (TypeError, ValueError):
incoming_live_at = 0
stale_live = (incoming_live_at > 0
and int(_mmp_hook_status.get("liveAt") or 0) > 0
and incoming_live_at < int(_mmp_hook_status.get("liveAt") or 0))
for k in ("liveCode", "liveName", "liveAt", "installedCode", "installedName", "source"):
if k not in patch:
continue
if stale_live and k in ("liveCode", "liveName", "liveAt", "source"):
continue
# 无心跳时不要用 0 覆盖已有运行版本
if incoming_live_at <= 0 and k in ("liveCode", "liveName", "liveAt"):
continue
val = patch[k]
if k in ("liveCode", "liveAt", "installedCode"):
try:
val = int(val or 0)
except (TypeError, ValueError):
val = 0
else:
val = str(val or "")
if k in ("installedCode",) and val <= 0:
continue
if k in ("installedName",) and not val:
continue
_mmp_hook_status[k] = val
_save_hook_status()
def _describe_hook_status():
with _lock:
st = dict(_mmp_hook_status)
installed = int(st.get("installedCode") or 0)
installed_name = st.get("installedName") or ""
live_code = int(st.get("liveCode") or 0)
live_name = st.get("liveName") or ""
live_at = int(st.get("liveAt") or 0)
now_ms = int(time.time() * 1000)
if installed > 0:
apk = "模块APK" + (installed_name or str(installed))
if installed < EXPECTED_MODULE_CODE:
apk += "(旧,期望{0})".format(EXPECTED_MODULE_NAME)
else:
apk = "模块APK未知等手机同步"
age = (now_ms - live_at) if live_at > 0 else -1
if age < 0:
live = "运行中:未检测到(请强停再开 TNG"
ok = False
elif age > 3 * 60 * 1000:
live = "运行中:心跳偏旧({0}分钟前 {1})".format(
age // 60000, live_name or live_code or "?")
ok = False
elif live_code >= EXPECTED_MODULE_CODE:
live = "运行中:最新({0})".format(live_name or live_code)
ok = True
elif live_code > 0:
live = "运行中:旧版({0},期望{1})".format(
live_name or live_code, EXPECTED_MODULE_NAME)
ok = False
else:
live = "运行中:未知"
ok = False
return {
"ok": ok,
"text": apk + " · " + live,
"expectedCode": EXPECTED_MODULE_CODE,
"expectedName": EXPECTED_MODULE_NAME,
"liveCode": live_code,
"liveName": live_name,
"liveAt": live_at,
"installedCode": installed,
"installedName": installed_name,
"ageMs": age,
}
def _packet_score(it):
board = it.get("leaderboard") or it.get("claims") or []
return (
1 if it.get("finished") else 0,
1 if it.get("issuedAt") else 0,
len(board),
it.get("updatedAt") or it.get("fetchedAt") or it.get("latestAt") or "",
)
def _normalize_synced_packet(raw):
if not isinstance(raw, dict):
return None
pid = str(raw.get("packetId") or raw.get("packet") or "").strip()
if not pid:
return None
board = raw.get("leaderboard") or []
if not isinstance(board, list):
board = []
norm_board = []
for i, row in enumerate(board):
if not isinstance(row, dict):
continue
amt = row.get("amount")
if amt is None:
amt = _normalize_money(row.get("amountText"))
try:
amt = float(amt or 0)
except (TypeError, ValueError):
amt = 0.0
nick = (row.get("nickname") or "?").strip() or "?"
norm_board.append({
"nickname": nick,
"userId": (row.get("userId") or "").strip(),
"poolId": (row.get("poolId") or "").strip(),
"amount": round(amt, 4),
"amountText": row.get("amountText") or "{0:.2f}".format(amt),
"claimTime": row.get("claimTime") or "",
"rank": int(row.get("rank") or (i + 1)),
})
norm_board.sort(key=lambda x: x["amount"], reverse=True)
for i, row in enumerate(norm_board, 1):
row["rank"] = i
best = norm_board[0] if norm_board else None
worst = norm_board[-1] if norm_board else None
sum_claimed = raw.get("sumClaimed")
try:
sum_claimed = float(sum_claimed) if sum_claimed is not None else round(
sum(x["amount"] for x in norm_board), 4)
except (TypeError, ValueError):
sum_claimed = round(sum(x["amount"] for x in norm_board), 4)
total = raw.get("total") or ""
total_num = _normalize_money(total)
if total_num is not None:
total = "{0:.2f}".format(total_num)
elif sum_claimed:
total = "{0:.2f}".format(sum_claimed)
issued = raw.get("issuedAt") or raw.get("issued") or ""
expires = raw.get("expiresAt") or raw.get("expire") or raw.get("expireTime") or ""
status = raw.get("status") or raw.get("activityStatus") or ""
finished = bool(raw.get("finished")) or _is_terminal_status(status) or _is_expire_passed(expires)
return {
"packetId": pid,
"title": raw.get("title") or "TNG 红包",
"sender": raw.get("sender") or "",
"group": raw.get("group") or "",
"total": total,
"via": raw.get("via") or "phone-sync",
"issuedAt": issued,
"expiresAt": expires,
"status": status,
"updatedAt": raw.get("updatedAt") or "",
"fetchedAt": raw.get("updatedAt") or raw.get("fetchedAt") or _now_iso(),
"latestAt": issued or raw.get("updatedAt") or "",
"finished": finished,
"snapshots": int(raw.get("snapshots") or 1),
"claimedCount": int(raw.get("claimedCount") or 0),
"totalCount": int(raw.get("totalCount") or 0),
"leaderboard": norm_board,
"claimantCount": len(norm_board) if norm_board else int(raw.get("claimantCount") or 0),
"sumClaimed": sum_claimed,
"bestNick": (best or {}).get("nickname") or "",
"bestAmount": (best or {}).get("amountText") or "",
"worstNick": (worst or {}).get("nickname") or "",
"worstAmount": (worst or {}).get("amountText") or "",
"fromSync": True,
}
def _load_mmp_synced():
global _mmp_synced
try:
if not os.path.isfile(PACKETS_PATH):
return
with open(PACKETS_PATH, "r", encoding="utf-8") as f:
data = json.load(f)
items = data.get("packets") if isinstance(data, dict) else data
if not isinstance(items, list):
return
synced = {}
for raw in items:
p = _normalize_synced_packet(raw)
if p:
synced[p["packetId"]] = p
with _lock:
_mmp_synced = synced
print("loaded mmp synced packets:", len(synced))
except Exception as e:
print("load mmp packets failed:", e)
def _save_mmp_synced():
try:
with _lock:
packets = list(_mmp_synced.values())
with open(PACKETS_PATH, "w", encoding="utf-8") as f:
json.dump({"packets": packets, "savedAt": _now_iso()}, f, ensure_ascii=False, indent=2)
except Exception as e:
print("save mmp packets failed:", e)
def _sync_mmp_packets(payload):
"""手机全量同步:合并进落盘镜像,电脑刷新 /mmp 即可看到。"""
items = []
if isinstance(payload, dict):
items = payload.get("packets") or []
hook = payload.get("hookStatus")
if isinstance(hook, dict):
_update_hook_status({
"liveCode": hook.get("liveCode") or hook.get("moduleVersionCode") or 0,
"liveName": hook.get("liveName") or hook.get("moduleVersionName") or "",
"liveAt": hook.get("liveAt") or hook.get("updatedAt") or int(time.time() * 1000),
"installedCode": hook.get("installedCode") or 0,
"installedName": hook.get("installedName") or "",
"source": "phone-sync",
})
elif isinstance(payload, list):
items = payload
if not isinstance(items, list):
return {"ok": False, "error": "packets must be list", "count": 0}
merged = 0
with _lock:
for raw in items:
p = _normalize_synced_packet(raw)
if not p:
continue
key = p["packetId"]
old = _mmp_synced.get(key)
if old is None or _packet_score(p) >= _packet_score(old):
if old and old.get("finished"):
p["finished"] = True
if old and not p.get("issuedAt") and old.get("issuedAt"):
p["issuedAt"] = old["issuedAt"]
p["latestAt"] = p["issuedAt"] or p.get("latestAt") or ""
_mmp_synced[key] = p
merged += 1
total = len(_mmp_synced)
_save_mmp_synced()
return {"ok": True, "count": merged, "total": total}
def _mmp_packets():
with _lock:
# 独立队列 + 兼容旧版混入通用消息的 MMP
msgs = list(_mmp_messages) + [m for m in _messages if _is_mmp_message(m)]
# 每个红包只保留「最新一次完整领取榜」快照
packets = {}
for msg in msgs:
meta, claims = _parse_mmp_content(msg.get("content") or "")
if not claims:
continue
packet_id = meta.get("packet") or meta.get("packetId") or ""
if not packet_id or packet_id in ("unknown", "TNG 红包", "TNG Money Packet"):
packet_id = "红包-" + _claims_fingerprint(claims)[:48]
key = str(packet_id)
issued = meta.get("issued") or ""
finished = str(meta.get("done") or "").lower() in ("1", "true") \
or _is_terminal_status(meta.get("status") or "") \
or _is_expire_passed(meta.get("expire") or meta.get("expireTime") or "")
item = {
"packetId": key,
"title": msg.get("title") or "TNG 红包",
"sender": meta.get("sender") or "",
"group": meta.get("group") or "",
"total": meta.get("total") or "",
"via": meta.get("via") or "",
"issuedAt": issued,
"expiresAt": meta.get("expire") or meta.get("expireTime") or "",
"status": meta.get("status") or "",
"fetchedAt": msg.get("receivedAt") or "",
"latestAt": issued or (msg.get("receivedAt") or ""),
"finished": finished,
"snapshots": 1,
"claims": claims,
}
_apply_counts_from_meta(item, meta)
existing = packets.get(key)
def _score(it):
return (
1 if it.get("issuedAt") else 0,
len(it.get("claims") or []),
it.get("fetchedAt") or "",
)
if existing is None or _score(item) >= _score(existing):
if existing is not None:
item["snapshots"] = int(existing.get("snapshots") or 1) + 1
if not item.get("issuedAt") and existing.get("issuedAt"):
item["issuedAt"] = existing["issuedAt"]
item["latestAt"] = item["issuedAt"] or item.get("fetchedAt") or ""
if existing.get("finished"):
item["finished"] = True
packets[key] = item
else:
existing["snapshots"] = int(existing.get("snapshots") or 1) + 1
if not existing.get("issuedAt") and issued:
existing["issuedAt"] = issued
existing["latestAt"] = issued
if finished:
existing["finished"] = True
result = []
for p in packets.values():
ranked = _aggregate_claims(p.get("claims") or [])
p["leaderboard"] = ranked
p["claimantCount"] = len(ranked)
p["claimEventCount"] = len(p.get("claims") or [])
try:
p["sumClaimed"] = round(sum(x["amount"] for x in ranked), 4)
except Exception:
p["sumClaimed"] = 0
total_num = _normalize_money(p.get("total"))
if total_num is None or (p["sumClaimed"] > 0 and abs(total_num - p["sumClaimed"]) > 0.001
and total_num <= max((x["amount"] for x in ranked), default=0) + 1e-9):
p["total"] = "{0:.2f}".format(p["sumClaimed"])
elif total_num is not None:
p["total"] = "{0:.2f}".format(total_num)
best = ranked[0] if ranked else None
worst = ranked[-1] if ranked else None
p["bestNick"] = (best or {}).get("nickname") or ""
p["bestAmount"] = (best or {}).get("amountText") or ""
p["worstNick"] = (worst or {}).get("nickname") or ""
p["worstAmount"] = (worst or {}).get("amountText") or ""
if not p.get("claimedCount"):
p["claimedCount"] = len(ranked)
if p.get("totalCount") and int(p.get("claimedCount") or 0) >= int(p.get("totalCount") or 0):
p["finished"] = True
del p["claims"]
result.append(p)
# 合并手机全量同步落盘数据(电脑重启后仍可看)
with _lock:
synced_items = list(_mmp_synced.values())
by_id = {p["packetId"]: p for p in result}
for sp in synced_items:
key = sp.get("packetId")
if not key:
continue
cur = by_id.get(key)
if cur is None or _packet_score(sp) >= _packet_score(cur):
merged = dict(sp)
if cur and cur.get("finished"):
merged["finished"] = True
if cur and not merged.get("issuedAt") and cur.get("issuedAt"):
merged["issuedAt"] = cur["issuedAt"]
merged["latestAt"] = merged["issuedAt"]
by_id[key] = merged
result = list(by_id.values())
for p in result:
_refresh_packet_closed(p)
p["statusLabel"] = _packet_status_label(p)
result.sort(key=lambda x: _mmp_time_sort_key(x.get("issuedAt") or x.get("latestAt") or ""), reverse=True)
return result
def _mmp_time_sort_key(text):
"""把 13/07/2026 11:54:44 / ISO 等统一成可比较字符串。"""
s = (text or "").strip()
m = re.match(r"^(\d{2})/(\d{2})/(\d{4})(?:\s+(\d{2}:\d{2}(?::\d{2})?))?", s)
if m:
return "{0}-{1}-{2} {3}".format(m.group(3), m.group(2), m.group(1), m.group(4) or "")
return s
def _parse_mmp_time_ms(text):
s = (text or "").strip()
if not s:
return 0
m = re.match(r"^(\d{2})/(\d{2})/(\d{4})(?:\s+(\d{2}):(\d{2})(?::(\d{2}))?)?", s)
if m:
try:
dt = datetime(
int(m.group(3)), int(m.group(2)), int(m.group(1)),
int(m.group(4) or 23), int(m.group(5) or 59), int(m.group(6) or 59))
return int(dt.timestamp() * 1000)
except ValueError:
return 0
m = re.match(r"^(\d{4})-(\d{2})-(\d{2})(?:[ T](\d{2}):(\d{2})(?::(\d{2}))?)?", s)
if m:
try:
dt = datetime(
int(m.group(1)), int(m.group(2)), int(m.group(3)),
int(m.group(4) or 0), int(m.group(5) or 0), int(m.group(6) or 0))
return int(dt.timestamp() * 1000)
except ValueError:
return 0
if re.match(r"^\d{13}$", s):
return int(s)
if re.match(r"^\d{10}$", s):
return int(s) * 1000
return 0
def _is_terminal_status(status):
s = (status or "").upper()
if not s:
return False
return any(k in s for k in (
"FINISH", "COMPLETE", "EXPIRE", "ENDED", "CLOSED", "CANCEL", "DONE"))
def _is_expire_passed(expire_text):
ms = _parse_mmp_time_ms(expire_text)
return ms > 0 and int(time.time() * 1000) >= ms
def _refresh_packet_closed(p):
if not isinstance(p, dict):
return p
if p.get("finished"):
return p
if _is_terminal_status(p.get("status") or "") or _is_expire_passed(
p.get("expiresAt") or p.get("expire") or ""):
p["finished"] = True
return p
def _packet_status_label(p):
_refresh_packet_closed(p)
claimed = int(p.get("claimedCount") or 0)
total = int(p.get("totalCount") or 0)
board_n = len(p.get("leaderboard") or []) if isinstance(p.get("leaderboard"), list) else 0
claimed = max(claimed, board_n, int(p.get("claimantCount") or 0))
fully = total > 0 and claimed >= total
if fully:
progress = "已全部领取"
elif total > 0:
progress = "已领取 {0}/{1}".format(claimed, total)
elif claimed > 0:
progress = "已领取 {0}".format(claimed)
else:
progress = "未领取" if p.get("finished") else "领取中"
expired = _is_expire_passed(p.get("expiresAt") or "") or any(
k in (p.get("status") or "").upper() for k in ("EXPIRE", "ENDED", "CLOSED", "CANCEL"))
if expired and not fully:
return progress + " · 已过期"
return progress
def _apply_counts_from_meta(item, meta):
counts = (meta.get("counts") or "").strip()
if counts and "/" in counts:
left, right = counts.split("/", 1)
try:
item["claimedCount"] = int(left.strip() or 0)
except ValueError:
pass
try:
item["totalCount"] = int(right.strip() or 0)
except ValueError:
pass
board = item.get("claims") or item.get("leaderboard") or []
if not item.get("claimedCount") and isinstance(board, list):
item["claimedCount"] = len(board)
if item.get("totalCount") and item.get("claimedCount", 0) >= item["totalCount"]:
item["finished"] = True
def _mmp_html_page(filename="mmp_page.html"):
page = os.path.join(os.path.dirname(os.path.abspath(__file__)), filename)
try:
with open(page, "r", encoding="utf-8") as f:
return f.read().encode("utf-8")
except Exception as e:
body = "<h1>%s missing</h1><pre>%s</pre>" % (filename, e)
return body.encode("utf-8")
def _html_page():
html = """<!DOCTYPE html>
<html lang="zh-CN">
@@ -91,6 +969,8 @@ def _html_page():
header { padding: 14px 20px; background: #161b22; border-bottom: 1px solid #30363d; display: flex; gap: 14px; align-items: center; flex-wrap: wrap; flex-shrink: 0; }
h1 { margin: 0; font-size: 18px; }
.stat { color: #8b949e; font-size: 13px; }
a.nav { color: #58a6ff; text-decoration: none; font-size: 13px; }
a.nav:hover { text-decoration: underline; }
button { background: #238636; color: #fff; border: 0; padding: 8px 14px; border-radius: 6px; cursor: pointer; font-family: inherit; font-size: 13px; }
button.secondary { background: #21262d; border: 1px solid #30363d; }
.layout { display: flex; flex: 1; min-height: 0; }
@@ -117,6 +997,7 @@ def _html_page():
<span class="stat" id="count">0 条</span>
<span class="stat" id="groupCount">0 群</span>
<span class="stat" id="updated">-</span>
<a class="nav" href="/mmp">红包领取台(独立) →</a>
<button onclick="loadMessages()">刷新</button>
<button class="secondary" onclick="clearMessages()">清空</button>
</header>
@@ -237,6 +1118,22 @@ class Handler(BaseHTTPRequestHandler):
self.end_headers()
self.wfile.write(body)
return
if path == "/mmp":
body = _mmp_html_page("mmp_page.html")
self.send_response(200)
self.send_header("Content-Type", "text/html; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
return
if path == "/mmp/help":
body = _mmp_html_page("mmp_help.html")
self.send_response(200)
self.send_header("Content-Type", "text/html; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
return
if path == "/api/messages":
with _lock:
data = list(_messages)
@@ -247,6 +1144,15 @@ class Handler(BaseHTTPRequestHandler):
data = _group_messages(list(_messages))
_json_response(self, 200, data)
return
if path == "/api/mmp":
_json_response(self, 200, _mmp_packets())
return
if path == "/api/mmp/status":
_json_response(self, 200, _describe_hook_status())
return
if path == "/api/mmp/settings":
_json_response(self, 200, _get_mmp_settings())
return
if path == "/health":
_json_response(self, 200, {"ok": True})
return
@@ -254,6 +1160,28 @@ class Handler(BaseHTTPRequestHandler):
def do_POST(self):
path = urlparse(self.path).path
if path == "/api/mmp/settings":
length = int(self.headers.get("Content-Length", "0"))
raw = self.rfile.read(length) if length else b"{}"
try:
payload = json.loads(raw.decode("utf-8") or "{}")
except ValueError:
_json_response(self, 400, {"error": "invalid json"})
return
_json_response(self, 200, _set_mmp_settings(payload))
return
if path == "/api/mmp/sync":
length = int(self.headers.get("Content-Length", "0"))
raw = self.rfile.read(length) if length else b"{}"
try:
payload = json.loads(raw.decode("utf-8") or "{}")
except ValueError:
_json_response(self, 400, {"error": "invalid json"})
return
result = _sync_mmp_packets(payload)
print("[{0}] MMP sync from phone: {1}".format(_now_iso(), result))
_json_response(self, 200 if result.get("ok") else 400, result)
return
if path not in ("/api/messages", "/api/debug/push", "/api/bills/app-upload"):
_json_response(self, 404, {"error": "not found"})
return
@@ -287,20 +1215,36 @@ class Handler(BaseHTTPRequestHandler):
def do_DELETE(self):
path = urlparse(self.path).path
if path == "/api/mmp":
_clear_mmp_messages()
_json_response(self, 200, {"ok": True})
return
if path != "/api/messages":
_json_response(self, 404, {"error": "not found"})
return
with _lock:
# 只清通用消息,保留红包独立队列
keep = [m for m in _messages if _is_mmp_message(m)]
_messages.clear()
# 旧数据里的 MMP 迁入独立队列
for m in keep:
_mmp_messages.appendleft(m)
_json_response(self, 200, {"ok": True})
_load_mmp_synced()
_load_hook_status()
def main():
server = ThreadingHTTPServer((HOST, PORT), Handler)
print("notiMessage debug server: http://127.0.0.1:{0}".format(PORT))
print("浏览器打开上述地址即可查看消息")
print("手机经 USB 调试时先执行: adb reverse tcp:8765 tcp:8765")
print("Wi-Fi 调试时将 AppConfig.DEBUG_SERVER_URL 改为 http://<PC局域网IP>:8765")
print("通用调试台: http://127.0.0.1:{0}/".format(PORT))
print("红包领取台: http://127.0.0.1:{0}/mmp".format(PORT))
print("手机同步: POST /api/mmp/sync → 落盘 mmp_packets.json电脑打开 /mmp 即可查看")
print("手机经 USB: adb reverse tcp:8765 tcp:8765走 127.0.0.1")
print("手机经 Wi-Fi: AppConfig 已含局域网地址,与电脑同一网段即可")
print("App 会对 DEBUG_SERVER_URLS 全部推送;不可达的会失败,可达的生效")
try:
server.serve_forever()
except KeyboardInterrupt:

View File

@@ -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()]

View File

@@ -1,6 +1,43 @@
# 消息抓取架构与 Hook 扩展指南
本文说明 notiMessage 的双通道抓取机制、Telegram 的实现方式,以及日后接入其他 App 的步骤。
本文说明 notiMessage 的双通道抓取机制、Telegram 的实现方式、**Xposed 与 LSPosed**,以及日后接入其他 App 的步骤。
> 手机部署步骤见 [`手机操作手册.md`](手机操作手册.md) · **Telegram 专文**见 [`Telegram抓消息说明.md`](Telegram抓消息说明.md) · MariBank 见 [`MariBank风控与载荷说明.md`](MariBank风控与载荷说明.md)
---
## 0. Xposed 与 LSPosed
### 0.1 是什么关系
| | **Xposed经典** | **LSPosed** |
|--|-------------------|-------------|
| 性质 | Hook 框架概念 + 老实现(改 `/system` | 现代实现,**不动 system** |
| 依赖 | 老 Root / Recovery | **Magisk + Zygisk** |
| 作用域 | 全局或 Installer 里选 | **按 App 勾选** |
| 模块 API | `IXposedHookLoadPackage` 等 | **同一套 API** |
| 本项目 | 源码在 `xposed-module/` | **手机上实际加载框架** |
日常说法:**「Xposed 模块」= APK****「启用 Xposed」= 在 LSPosed 里打开模块并勾选作用域**。
### 0.2 与本项目的关系
```
Magisk → Zygisk → LSPosed → com.miraclegarden.smsmessage.xposed
├── MariBankRootBypassHook银行
├── TelegramMessageHookTelegram
└── …
```
测银行 App 时通常还需 **Shamiko**Hide Magisk与 LSPosed 分工见 [`MariBank风控与载荷说明.md` §6](MariBank风控与载荷说明.md)。
### 0.3 与其他工具对比
| 工具 | 特点 |
|------|------|
| **LSPosed** | 常驻、开机自动,适合银行 bypass |
| **Frida** | 临时 attach与 LSPosed 同时开易冲突 |
| **Magisk 模块** | 改系统属性serial不是 Hook Java |
---
@@ -48,6 +85,8 @@ notiMessage 使用 **两条独立通道** 抓取消息,互为补充:
## 2. Telegram 实现方式
> **完整安装、双通道、排错、logcat**:见专文 [`Telegram抓消息说明.md`](Telegram抓消息说明.md)。本节保留架构摘要。
### 2.1 包名与作用域
Pixel 6 等设备上 Telegram 常见包名:

View File

@@ -0,0 +1,590 @@
# MariBank SG `3100012` 根因分析与突破方案
> 基于 `register_20260706_1636.txt`675 行)+ 全部 Hook 源码 + 逆向文档综合分析
---
## 一、关键日志发现
### 1.1 加密前明文已被完整捕获
日志行 **#400**`uvwuvwuv.vvuuvvv``uvwvuww` 加密入口前):
```json
{
"cyCode": "65",
"paramInfo": {"publicKey": "MIIBIjAN...(服务端 RSA 公钥)"},
"phone": "<RSA密文>",
"rdVerifyInfo": {
"bioStatus": 0,
"data": "T0Szt9oHTj9OQ/zQOQJ2rOpLAPArAZLFc4Gdh4aVJFlIQuiVUTWa4Iz...",
"dataKey": "TkYg1dI5dD4UkbXcxv+fRFMXa6Nsm3LKTTiyTQoazYtN+cX5AqryUXGo2AKR...",
"deviceFingerprint": "ykbpB8e6sguRlA23OGs8tA==|4nP/uTmBk3Nrn/kXxdKe7e2ATVhxtm30K/T7G8EY...|8+hvSUQahER+Tpwd|00|0",
"random": "1783326963701_-4760471421264355822",
"softTokenActivated": false,
"afExtInfo": {"modeInCall":"N","modeInCommunication":"N","modeCallScreening":"N"}
},
"scene": "REGISTRATION",
"step": "BE"
}
```
**关键结论**`data`/`dataKey`**native 生成后「已是密文」** 地塞进这个 JSON 的,不是在这个 JSON 组装后再加密的。Java 层改此 JSON 不影响 `data`/`dataKey` 内容本身,因为此时内容已经是 native 加密过的密文。
### 1.2 `data`/`dataKey` 尺寸分析
- `dataLen=154`Base64字符数→ 原始 **~115 字节**
- `dataKeyLen=351` → 原始 **~263 字节**
RSA-2048 密文 = 256 bytes → base64 = 344 chars351 比 344 多 7可能含头部或为 RSA-2048+padding
**推断加密结构**
```
dataKey = Base64( RSA_OAEP_encrypt( AES_session_key_32bytes, server_RSA_pubkey ) )
data = Base64( AES_GCM_encrypt( env_attestation_json, AES_session_key ) )
```
### 1.3 register body 的加密密钥链路(行 359-368
```
uvwuvwuv.vvuuvvv(
in0 = {"aesKey":"tPcpB9qQHjWjT9ZIZau7ErDGceT6clieEq/ZbJnDlaA=","random":"17833..."},
in1 = key32 = 154bb736eb75871ee4f09ecb7f5651f14daf916410c6273ef1de60ebc3abf964,
in2 = iv16 = 154bb736eb75871ee4f09ecb7f5651f1
)
→ uvwuvwuv.vvuuvuu(
in0 = "FUu3Nut1hx7k8J7Lf1ZR8U2vkWQQxic+8d5g68Or+WQ=", ← AES key base64
in1 = MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA... ← RSA公钥2048-bit
)
```
这条链路加密的是 **register body 外层(`encryptData`字段)**,不是 `data`/`dataKey`
### 1.4 🔑 `proc_version` AVC Denied行 284— 关键缺口
```
07-06 16:36:01.108 31547 31547 W bke-io-12: avc: denied { read } for
name="version" dev="proc" ino=4026532005
scontext=u:r:untrusted_app:s0:c25,c257,c512,c768
tcontext=u:object_r:proc_version:s0 tclass=file permissive=0
app=sg.com.maribankmobile.digitalbank
```
**这是最重要的发现**`libshpssdk_bank.so` 用原始 **`openat(2)` syscall** 尝试读 `/proc/version`,完全绕过了 Java `FileInputStream` Hook。虽然这次被 SELinux 拒绝了permissive=0但说明 native 在走独立的系统调用路径探测内核版本信息。
---
## 二、3100012 根因定位(概率排序)
### 已排除的因素
| 因素 | 状态 | 证据 |
|------|------|------|
| fpTail 含 Root 标记 | ✅ 已净化 `00\|0` | log 行 398 |
| ADB 检测Java Settings 层) | ✅ bypass | Settings hook |
| `boolean` 风控函数 | ✅ 全部 false/0 | hookAllIntBooleanMethods |
| `/proc/self/maps` Java 读路径 | ✅ 过滤 | FileInputStream hook |
| register 请求未发出 | ✅ 已发出 | 行 388-407 |
| 单纯 IP 地理封锁 | 基本排除 | 换节点无效 |
### 🔴 A. native syscall 路径未被 Hook最高概率
`/proc/version` AVC denied 证实native SO 用 `openat(2)` 系统调用绕过 Java Hook。
**可能被 native 用 syscall 探测的路径**
- `/proc/self/maps` → 直接 mmap 或 read 系统调用,发现 `liblspd.so`/`libzygisk.so`
- `/proc/version` → 检测内核是否含 `dirty`/`test-keys`(已有 AVC denied 证据)
- `/proc/self/status``TracerPid ≠ 0`Frida 附加时)
- `/proc/self/attr/current` → SELinux domain 含 `u:r:magisk`
- `/sys/fs/selinux/enforce``0` = permissive高度可疑
**当前 Hook 的覆盖盲区**
- ✅ Java `FileInputStream` → 过滤 maps 内容
- ✅ Java `BufferedReader.readLine()` → 过滤 maps 行
- ❌ native `openat()` syscall → **未拦截**
- ❌ native `mmap()` 直读 /proc → **未拦截**
-`dl_iterate_phdr()` 枚举所有 .so → **未拦截**
### 🔴 B. Play Integrity 级别不足(高概率)
Pixel 6 解锁 bootloader 后 Play Integrity 状态:
- `MEETS_BASIC_INTEGRITY`
- `MEETS_DEVICE_INTEGRITY` ❌(需要 locked bootloader + certified device
- `MEETS_STRONG_INTEGRITY` ❌(需要 hardware-backed attestation
**SG vs PH 的差异**SG MariBank v3.2.2 服务端策略很可能要求 `MEETS_DEVICE_INTEGRITY`,而 PH SeaBank 3.22.0 可能仅要求 `MEETS_BASIC_INTEGRITY`。当前 Hook 无任何 Play Integrity API 覆盖。
### 🟡 C. `data` 内部含 Hook/Magisk 特征(中概率)
`libshpssdk_bank.so` 生成 `data` 时在 native 层可能检测:
- `dl_iterate_phdr()` → 遍历到 `liblspd.so` / `libgadget.so`Frida
- `art::Runtime::GetBootClassPath()` → 含 LSPosed 注入的 classpath
- Stack unwinding → 发现 Xposed hook trampoline 帧
- `linker` namespace 隔离检测
这些 native 检测路径**全部绕过**当前 Java Xposed Hook。
### 🟡 D. 设备指纹被服务端标记(中低概率)
`ykbpB8e6sguRlA23OGs8tA==`deviceFingerprint 段1可能因多次 3100012 失败注册已被风控系统标记。但可通过**换 serial/android_id已做**后 fingerprint 值是否变化来验证。
---
## 三、7个核心问题的逆向答案
### Q1: `rdVerifyInfo.data` 明文结构推断
基于 Shopee SHPSSDK 体系SeaBank PH 同源 SDK 已知结构):
```json
{
"appId": "sg.com.maribankmobile.digitalbank",
"appVersion": "3.2.2",
"deviceId": "<ANDROID_ID or serial hash>",
"isRoot": false, Hook native
"isEmulator": false, OK
"isHooked": false, native dl_iterate liblspd
"bootloaderLocked": false, Pixel 6
"integrityResult": "BASIC", SG DEVICE
"selinuxEnforcing": true, OKpermissive=0
"timestamp": 1783326963701,
"random": "1783326963701_-4760471421264355822",
"nonce": "<random bytes>"
}
```
`isHooked`native 检测到 liblspd.so`integrityResult`(非 DEVICE 级别)是最可能触发 3100012 的字段。
### Q2: 哪条 native 函数生成 `data`
根据 RegisterNatives 输出应能找到(需 Frida spawn 验证):
```
com.shopee.shpssdkbank.wvvvuwwu.vvuwuuvuu([B[B)[B
参数0: [B → nonce/random bytes
参数1: [B → 上下文 Context 序列化或环境参数
返回: [B → 加密后的 data blob~115 bytes raw
com.shopee.shpssdkbank.wvvvuwwu.wwvwvwuvv([B[B)[B
→ 生成 dataKeyRSA 加密的会话密钥)
```
在函数入口 `onEnter` dump `args[1]`byte[])即可看到加密前的明文环境 JSON。
### Q3: SG vs PH attestation 差异
| 项目 | PH 3.22.0 | SG 3.2.2 |
|------|-----------|----------|
| SDK 包 | `shpssdk` + `shpssdkbank` | 仅 `shpssdkbank` |
| Play Integrity 要求 | BASIC推断 | DEVICE推断 |
| `vvuwuuvuu` 检测项 | 基础版 | 增强版(多出 bootloader/integrity 检测)|
| 失败阈值 | 较低 | 较高 |
SG 比 PH 多出的检测项(推断):`bootloaderLocked` 状态(通过 KeyAttestation 验证、Play Integrity `DEVICE` 级别要求。
### Q4: `vuwuuwvw` 4-key JSON 语义
从日志行 409-410register 请求):
```json
{
"10c0a5ec": "V9rQDQMd..." (20B = IV/nonce A),
"1ca96197": "DXK5vhoi..." (20B = IV/nonce B HMAC tag),
"b4a937c8": "uK92+EOS..." (~1220B = SAP blob),
"dddcab8a": "7RWp0fXi..." (20B = MAC ),
"x-sap-ri": "f3684b6a..." (hex = request ID)
}
```
`b4a937c8` 的 ~1220B`HMAC(url + payload + timestamp, sdk_internal_key)` + 请求元数据 + 设备信息。密钥硬编码在 `libshpssdk_bank.so`SDK 版本级别,非设备绑定)。
**重要**:服务端对 SAP 签名的验证独立于 `rdVerifyInfo` 的验证。即使 SAP 签名通过,`data` 内容不干净仍返回 3100012。两者是串联校验不是并联。
### Q5: Play Integrity / TEE / KeyStore 参与情况
**高概率参与**`libshpssdk_bank.so` 内部推断调用链:
```
vvuwuuvuu()
→ collectEnvInfo()
→ android.security.keystore.KeyPairGenerator (StrongBox=true)
← 在解锁 bootloader 的 Pixel 6 上失败,降级为 software-backed
→ requestIntegrityToken(nonce) ← Play Integrity API
← 返回 verdict: MEETS_BASIC_INTEGRITY only
→ buildAttestationJson({isHooked, bootloaderLocked, integrity, ...})
→ AES_GCM_encrypt(attestation_json) → data
```
### Q6: 干净机 data/dataKey 重放可行性
**理论可行,有时效限制**
- `data`/`dataKey``random`(时间戳+随机数),服务端可能设 5 分钟有效窗口
-`deviceFingerprint` 段 1/2 是设备哈希,服务端**可能不 bind session**(仅风控评分)
- **最小实验**3 分钟内,干净机 data → Root 机重放,看是否 code=0
若重放成功 → 确认是 attestation 内容导致(而非设备黑名单)
若重放失败且错误码不同 → session 绑定问题,需另寻路径
### Q7: 3100012 精确触发条件
**多层评分系统(推断)**
```
score = 0
if isHooked: score += 40 ← native dl_iterate 检测到 liblspd
if bootloaderUnlocked: score += 30 ← KeyAttestation 无法通过
if integrityNotDevice: score += 20 ← Play Integrity 不是 DEVICE 级
if deviceBlacklisted: score += 100 ← 直接 ban
if score > SG_THRESHOLD:
return 3100012
else:
return code=0, step=BSO
```
SG_THRESHOLD 比 PH 低很多PH 容许更高 score
---
## 四、可执行突破方案
### ⚡ 方案 1PlayIntegrityFix今天30 分钟)
安装 Magisk 模块,伪造 Pixel 6 的 Play Integrity 为 DEVICE 级别:
```bash
# Magisk Manager → Modules → 安装以下模块之一:
# 1. PlayIntegrityFix (chiteroman) - 最主流,含 custom keybox 注入
# 2. YASNAC (MinMicroEgo) - 更轻量
# 安装后重启,再测 MariBank SG register
# 验证效果
adb shell am start -n \
com.google.android.gms/.phenotype.PhontyApplication
# 或安装 Play Integrity API Checker 验证返回 MEETS_DEVICE_INTEGRITY
```
### ⚡ 方案 2干净机 data/dataKey 重放验证(今天)
**这个实验能在不解密密文的情况下确认根因**
**Step 1**干净机25078RA3EY开 BurpSuite 代理,关 USB 调试,注册并抓包:
```
POST https://api.maribank.com.sg/uapi/v2/register
→ 保存 rdVerifyInfo.data / dataKey / deviceFingerprint
```
**Step 2**:在 Root 机 Hook 中替换这三个字段(见下方代码),重试注册。
**Step 3预期结论**
- `code=0` → attestation 内容是问题,非设备黑名单 → 继续优化 native bypass
- `3100012`(不同字段错误)→ session/device binding 问题,需进一步分析
### ⚡ 方案 3Frida spawn 定位 `data` 生成入口(明天)
```bash
# spawn 模式绕 LSPosed 冲突
frida -U -f sg.com.maribankmobile.digitalbank \
-l reverse/frida/trace_maribank_sg_native.js \
--no-pause 2>&1 | tee reverse/logs/frida_spawn_$(date +%H%M).txt
# 关注:
# RegisterNatives class=com.shopee.shpssdkbank.wvvvuwwu
# JNI vvuwuuvuu([B[B)[B -> libshpssdk_bank.so+0x????
# 拿到偏移后 → Ghidra 分析 → 找环境 JSON 组装点
```
### ⚡ 方案 4Native `openat` hook修补已知缺口
`trace_maribank_sg_native.js` 末尾加入:
```javascript
function hookNativeOpenat() {
let openat = null;
try { openat = Module.getExportByName(null, 'openat'); } catch(e) {}
if (!openat) { console.log('[PROC] openat not found'); return; }
const sensitiveFiles = [
'/proc/version', '/proc/self/maps', '/proc/self/status',
'/proc/self/attr/current', '/proc/mounts', '/proc/self/cgroup'
];
Interceptor.attach(openat, {
onEnter(args) {
try {
this.path = args[1].readCString();
} catch(e) { this.path = ''; }
},
onLeave(retval) {
if (!this.path) return;
for (const p of sensitiveFiles) {
if (this.path.endsWith(p)) {
console.log('[PROC] native openat(' + this.path + ') fd=' + retval);
// 如需 block返回 ENOENT=-1retval.replace(ptr(-1));
}
}
}
});
console.log('[PROC] hooked native openat');
}
hookNativeOpenat();
```
---
## 五、代码实现
### 5.1 MariBankDataReplayHook.java干净机重放验证
```java
package com.miraclegarden.smsmessage.xposed.hook;
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;
import java.lang.reflect.Method;
import java.nio.charset.StandardCharsets;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* 干净机 rdVerifyInfo.data/dataKey/deviceFingerprint 重放钩子。
* 用于验证 3100012 是「attestation内容」还是「设备黑名单」导致的。
*
* 使用方法:
* 1. 干净机 BurpSuite 抓 /uapi/v2/register 明文uvwvuww 入口前 in0
* 2. 复制 data/dataKey/deviceFingerprint 三个值填入下方常量
* 3. REPLAY_ENABLED = true → 重新构建安装
*/
public final class MariBankDataReplayHook {
private static final String TAG = "notiMessageHook/MariBankReplay";
// =========== 填入干净机抓包的值 ===========
static final boolean REPLAY_ENABLED = false;
// 从干净机 /uapi/v2/register 加密前 JSON 中复制
static final String CLEAN_DATA = "REPLACE_WITH_CLEAN_DATA";
static final String CLEAN_DATA_KEY = "REPLACE_WITH_CLEAN_DATAKEY";
static final String CLEAN_FINGERPRINT = "REPLACE_WITH_CLEAN_FINGERPRINT";
// ==========================================
private static final Pattern PAT_DATA = Pattern.compile(
"\"data\"\\s*:\\s*\"([^\"]+)\"");
private static final Pattern PAT_DATAKEY = Pattern.compile(
"\"dataKey\"\\s*:\\s*\"([^\"]+)\"");
private static final Pattern PAT_FP = Pattern.compile(
"\"deviceFingerprint\"\\s*:\\s*\"([^\"]+)\"");
private MariBankDataReplayHook() {
}
public static void install(XC_LoadPackage.LoadPackageParam lpparam) {
if (!REPLAY_ENABLED) {
XposedBridge.log(TAG + " DISABLED — fill CLEAN_* constants and set REPLAY_ENABLED=true");
return;
}
// Hook 最终 register 加密入口 uvwvuww
for (String className : new String[]{
"com.shopee.bke.lib.jni.utils.uvwuvwuv",
"com.shopee.bke.lib.jni.utils.uvwwwwuv",
}) {
hookClass(lpparam, className);
}
XposedBridge.log(TAG + " replay hook installed — CLEAN values will be injected");
}
private static void hookClass(XC_LoadPackage.LoadPackageParam lpparam, String className) {
try {
Class<?> clazz = XposedHelpers.findClass(className, lpparam.classLoader);
for (Method m : clazz.getDeclaredMethods()) {
if (!"uvwvuww".equals(m.getName())) continue;
if (m.getParameterCount() < 1) continue;
Class<?> firstParam = m.getParameterTypes()[0];
if (firstParam != byte[].class && firstParam != String.class) continue;
XposedBridge.hookMethod(m, new XC_MethodHook() {
@Override
protected void beforeHookedMethod(MethodHookParam param) {
try {
Object arg0 = param.args[0];
boolean isBytes = arg0 instanceof byte[];
String json = isBytes
? new String((byte[]) arg0, StandardCharsets.UTF_8)
: (String) arg0;
if (!json.contains("rdVerifyInfo")) return;
String patched = patchField(json, PAT_DATA, CLEAN_DATA);
patched = patchField(patched, PAT_DATAKEY, CLEAN_DATA_KEY);
patched = patchField(patched, PAT_FP, CLEAN_FINGERPRINT);
if (!patched.equals(json)) {
XposedBridge.log(TAG + " injected clean data/dataKey/fp into register JSON");
param.args[0] = isBytes
? patched.getBytes(StandardCharsets.UTF_8)
: patched;
}
} catch (Throwable t) {
XposedBridge.log(TAG + " inject err: " + t.getMessage());
}
}
});
XposedBridge.log(TAG + " hooked " + className + "#uvwvuww");
}
} catch (Throwable t) {
XposedBridge.log(TAG + " skip " + className + ": " + t.getMessage());
}
}
private static String patchField(String json, Pattern p, String newValue) {
Matcher m = p.matcher(json);
if (!m.find()) return json;
StringBuffer sb = new StringBuffer();
m.appendReplacement(sb, Matcher.quoteReplacement(
m.group(0).replaceFirst("\"[^\"]+\"$", "\"" + newValue + "\"")));
m.appendTail(sb);
return sb.toString();
}
}
```
### 5.2 dump_rdverify_data.jsFrida 明文截获脚本)
```javascript
'use strict';
/**
* MariBank SG — rdVerifyInfo.data 生成前明文截获
* 运行frida -U -f sg.com.maribankmobile.digitalbank \
* -l reverse/frida/dump_rdverify_data.js --no-pause
* 目标:找到 vvuwuuvuu 的 native 参数(加密前的环境 JSON
*/
Java.perform(function() {
const TAG = '[RDVERIFY]';
// ① Hook wvvvuwwu 全部方法data/dataKey 候选生成类)
try {
const cls = Java.use('com.shopee.shpssdkbank.wvvvuwwu');
['vvuwuuvuu', 'wwvwvwuvv', 'vuwuuuwv', 'vuwuuwvw', 'vuwuuwvu'].forEach(function(mName) {
try {
cls[mName].overloads.forEach(function(ovl) {
const sig = ovl.argumentTypes.map(t => t.className).join(',');
ovl.implementation = function() {
console.log(TAG + ' wvvvuwwu.' + mName + '(' + sig + ') CALLED');
for (let i = 0; i < arguments.length; i++) {
const a = arguments[i];
if (a === null || a === undefined) {
console.log(' arg[' + i + '] = null');
continue;
}
try {
// byte[] → try UTF-8, fallback hex
if (Java.array('byte', []).getClass && a.getClass && a.getClass().getName() === '[B') {
const s = Java.use('java.lang.String').$new(a, 'UTF-8').toString();
const isPrintable = /^[\x20-\x7e\u4e00-\u9fff\r\n\t]+$/.test(s.substring(0,100));
if (isPrintable) {
console.log(' arg[' + i + '] byte[' + a.length + '] utf8=' + s.substring(0, 2000));
} else {
const hex = Array.from(a).slice(0,32).map(b => (b & 0xff).toString(16).padStart(2,'0')).join('');
console.log(' arg[' + i + '] byte[' + a.length + '] hex=' + hex + '...');
}
} else {
console.log(' arg[' + i + '] = ' + a.toString().substring(0, 500));
}
} catch(e) {
console.log(' arg[' + i + '] err=' + e);
}
}
const ret = ovl.apply(this, arguments);
if (ret !== null && ret !== undefined) {
try {
console.log(TAG + ' ret byte[' + ret.length + '] ← 这是 data/dataKey 候选!');
} catch(e) {
console.log(TAG + ' ret = ' + ret);
}
}
return ret;
};
console.log(TAG + ' hooked wvvvuwwu.' + mName);
});
} catch(e) {
console.log(TAG + ' skip ' + mName + ': ' + e.message);
}
});
} catch(e) {
console.log(TAG + ' wvvvuwwu not found: ' + e.message);
}
// ② Hook vvuuuuvvv.wwvuwuwvu — DFP/riskToken
try {
const dfpCls = Java.use('com.shopee.shpssdkbank.uwuvuvvww.vvuuuuvvv');
dfpCls.wwvuwuwvu.overloads.forEach(function(ovl) {
ovl.implementation = function() {
const ret = ovl.apply(this, arguments);
console.log(TAG + ' DFP.wwvuwuwvu = ' + ret);
return ret;
};
});
} catch(e) {}
// ③ Hook uvwvuww — 最终 register 加密入口(可确认明文注入点)
['com.shopee.bke.lib.jni.utils.uvwuvwuv',
'com.shopee.bke.lib.jni.utils.uvwwwwuv'].forEach(function(className) {
try {
const encCls = Java.use(className);
if (encCls['uvwvuww']) {
encCls['uvwvuww'].overloads.forEach(function(ovl) {
ovl.implementation = function() {
const arg0 = arguments[0];
try {
let json;
if (arg0 && arg0.getClass && arg0.getClass().getName() === '[B') {
json = Java.use('java.lang.String').$new(arg0, 'UTF-8').toString();
} else {
json = '' + arg0;
}
if (json.includes('rdVerifyInfo')) {
console.log(TAG + ' uvwvuww register plaintext (len=' + json.length + '):\n' + json.substring(0, 3000));
}
} catch(e) {}
return ovl.apply(this, arguments);
};
});
console.log(TAG + ' hooked ' + className + '#uvwvuww');
}
} catch(e) {}
});
console.log(TAG + ' all hooks installed — trigger MariBank registration now');
});
```
---
## 六、结论
**3100012 最可能的触发链**
```
libshpssdk_bank.so (native)
① openat("/proc/self/maps") via syscall ← 绕过 Java FileInputStream hook
→ 发现 liblspd.so / libzygisk.so / libgadget.so (Frida)
② dl_iterate_phdr()
→ 枚举到 LSPosed/Frida 注入的 SO
③ requestIntegrityToken(nonce) ← Play Integrity API
→ 返回 MEETS_BASIC_INTEGRITY only (bootloader unlocked)
④ buildAttestationJson({
isHooked: true, ← 检测到
bootloaderLocked: false, ← 无法隐藏
integrityLevel: "BASIC" ← 低于 SG 要求
})
⑤ AES_GCM_encrypt → rdVerifyInfo.data
⑥ 服务端解密 → risk_score > SG_THRESHOLD → 3100012
```
**优先级最高的三步**
1. **PlayIntegrityFix** → 提升 Integrity 级别至 DEVICE30 分钟)
2. **干净机重放实验** → 验证根因(需干净机配合)
3. **Frida spawn + RegisterNatives** → 定位 `vvuwuuvuu` 偏移 → Ghidra 分析明文结构
*2026-07-06 17:05 SGT*

View File

@@ -0,0 +1,256 @@
# MariBank 菲律宾注册突破 & 新加坡现状2026-07-06
> **设备**Pixel 6`1C081FDF600K5Q`)· Magisk 30.7 + Zygisk + LSPosed
> **结论****菲律宾 SeaBank 已能发 OTP****新加坡 MariBank 仍 3100012**
> **关联**[`MariBank实现说明.md`](MariBank实现说明.md) · [`MariBank新加坡逆向.md`](MariBank新加坡逆向.md)
---
## 1. 结果一览
| 地区 | 包名 | API | 注册结果 | 错误码 |
|------|------|-----|----------|--------|
| **菲律宾** | `ph.seabank.seabank` | `https://api.seabank.ph` | ✅ **成功 → OTP 短信** | `code=0` |
| **新加坡** | `sg.com.maribankmobile.digitalbank` | `https://api.maribank.com.sg` | ❌ 仍失败 | **3100012** |
**关键意义**:同一台 Root 机、同一套 Xposed + Magisk 伪装下,**PH 服务端已放行注册**,说明 Hook 链与 attestation 载荷 **对 PH 有效**SG 失败更可能是 **区域风控策略差异****SG 侧设备黑名单**而非「Root bypass 完全无效」。
---
## 2. 测试环境
| 项 | 值 |
|----|-----|
| 手机 | Google Pixel 6oriole |
| Root | Magisk **30.7**`MAGISK:R` |
| Magisk 模块 | `maribank_device_spoof``zygisk_shamiko``playintegrityfix``zygisk_vector` |
| DenyList | MariBank PH/SG 均已加入;**Enforce DenyList = OFF**Shamiko 要求) |
| LSPosed 模块 | `com.miraclegarden.smsmessage.xposed` |
| LSPosed 作用域 | `ph.seabank.seabank``sg.com.maribankmobile.digitalbank` 等 |
| 伪装 serial | `Y1Rr2fxhOI0ZCQSb`resetprop + Magisk 模块) |
| 伪装 android_id | `87ec910ab5d6f423` |
| 对照干净机 | `25078RA3EY`:关 USB 调试可进 SG OTP无 LSPosed |
---
## 3. 菲律宾突破 — log 证据2026-07-06 13:40
### 3.1 第一次 registerstep=BE
```text
POST https://api.seabank.ph/uapi/v2/register
```
加密前明文(`MariBankEncrypt in0 byte[1180]`)核心字段:
```json
{
"cyCode": "63",
"phone": "<RSA 加密>",
"rdVerifyInfo": {
"bioStatus": 0,
"data": "tPNHr/0eZzaPXs9oimvMNVWI/...",
"dataKey": "H/A6AqA8NhXZoaL0alG5VGRovcfuJykY92U...",
"deviceFingerprint": "jrKyNF/Fx4gcnzVpf1u9bw==|WJMksio9SaKKuyk1KMSilUGivdF+SHbqjB+aL65fucQcOCdojhBN3GKoY58sxVomiCfi5w==|TXeh8FyEf/8qHdAS|00|0",
"afExtInfo": { "modeInCall": "N", "modeInCommunication": "N", "modeCallScreening": "N" }
},
"scene": "REGISTRATION",
"step": "BE",
"source": "app"
}
```
服务端响应:
```json
{"code":0,"msg":"success","data":{"scene":"REGISTRATION","step":"BSO","tranId":"7f21098a-2113-40fb-bb6a-e2e651460f53",...}}
```
### 3.2 第二次 registerOTP 触发step=BSO
```json
{
"cyCode": "63",
"rdVerifyInfo": {
"action": "OTP_SMS_TRIGGER",
"deviceFingerprint": "jrKyNF/...|00|0",
"operationId": "61f9bcc6-dc0c-4f3d-8566-e1d3dc02653f1783316458430"
},
"scene": "REGISTRATION",
"step": "BSO",
"tranId": "7f21098a-2113-40fb-bb6a-e2e651460f53"
}
```
响应仍为 **`code=0`** → **App 可发验证码**
### 3.3 与 7 月 3 日对比
| 日期 | PH register | 说明 |
|------|-------------|------|
| 2026-07-03 | ❌ 4067012 | 仅 riskToken 尾部净化,无加密前 Hook |
| 2026-07-06 | ✅ code=0 → OTP | 加密前 Hook + Attestation Hook + 方案 B 换 ID + Shamiko |
---
## 4. 新加坡仍失败 — log 证据
### 4.1 典型失败11:50换 ID 前)
```text
POST https://api.maribank.com.sg/uapi/v2/register
→ {"code":3100012,"msg":"Unexpected error occurred. Please try again later. "}
```
加密前 `deviceFingerprint` 示例:
```text
X4Ln9cRV1v6aabPt4Ymnqw==|RmdlV34wu8nk8n9nj9A+32Z0BjF/29py3UVnqGWHBJRj5HuTmmOPX+pfJWkZBOn/n41pYw==|3ailMzmgCFzRDFGv|00|0
```
尾部 `\|00\|0` 已净化,**仍 3100012**。
### 4.2 SG 错误码含义(推断)
| 码 | 地区 | 文案 |
|----|------|------|
| 4067012 | PH | For your account's security, temporarily blocked... |
| 3100012 | SG | Unexpected error occurred. Please try again later. |
同属 **服务端风控拒绝**,非本地 Root 弹窗。
---
## 5. 已实现的 bypass 架构
### 5.1 分层模型
```
客户端本地层 服务端层
───────────────── ─────────────────
SafeMode Root 弹窗/自杀 → (不决定 OTP只决定能否进 App
SG ADB 全屏拦截页 →
SHPSSDK assessRisk → 解密 rdVerifyInfo.data/dataKey
riskToken / deviceFingerprint → 查 deviceHash 黑名单
Magisk resetprop 换 serial → 区域策略PH 松 / SG 严)
```
### 5.2 Xposed 模块文件
| 文件 | 作用 |
|------|------|
| `MariBankRootBypassHook.java` | SafeMode、SHPSSDK、ADB Settings、OkHttp 日志、4067/3100012 记录 |
| `MariBankShpsNativeHook.java` | `/proc` 过滤、SystemProperties、requestDefense 净化(不再 block |
| `MariBankSdkUtilsHook.java` | 加密前 Hook `utils.d` / `uvwuvwuv` / Gson |
| `MariBankRegisterPayloadUtil.java` | `scene=REGISTRATION` JSON 净化 |
| `MariBankAttestationHook.java` | native 桥 attestation 链 + 环境探测拦截 |
| `MariBankRiskTokenUtil.java` | riskToken 尾部 `\|09\|1``\|00\|0` |
### 5.3 方案 BMagisk 设备 ID 伪装
路径:`scripts/magisk/maribank-device-spoof/`
| 脚本 | 时机 | 内容 |
|------|------|------|
| `post-fs-data.sh` | 早期启动 | `resetprop` serial、boot 属性、关 adb 属性 |
| `service.sh` | 开机完成后 | 写入新 `Settings.Secure.android_id` |
PC 一键:
```powershell
.\scripts\maribank-spoof-device.ps1 # 一次性 resetprop
.\scripts\maribank-spoof-device.ps1 -InstallModule # 安装持久模块
.\scripts\maribank-scheme-b-finish.ps1 # LSPosed 作用域 + pm clear
```
### 5.4 Shamiko 配置要点
1. Magisk → 开启 **Zygisk**
2. **Configure DenyList** 开启 → 勾选 MariBank PH/SG 全部进程
3. **Enforce DenyList 必须关闭**(否则 Shamiko 不生效)
4. 安装 **Shamiko** 模块并重启
---
## 6. 为何 PH 能过、SG 不过(推断)
> **详细展开**(菲律宾真实原因、`afExtInfo`、USB 调试与 Hook见 [`MariBank风控与载荷说明.md`](MariBank风控与载荷说明.md) §4§5。
| 维度 | PH ✅ | SG ❌ |
|------|-------|-------|
| 同一 deviceFingerprint 结构 | 有,尾部 `\|00\|0` | 有,尾部 `\|00\|0` |
| `data`/`dataKey` attestation | 服务端接受 | 服务端拒绝3100012 |
| 测试次数 / 黑名单 | 较少失败记录 | Pixel 6 多次测 SG 注册 |
| API 风控强度 | 当日实测 **通过** | 当日实测 **拒绝** |
| 干净机对照 | 未详测 PH | 25078RA3EY 关 ADB 可 OTP |
**结论**
1. **改 riskToken 尾部 alone 不够**,但加上 **加密前 Hook + Shamiko + 换 ID****PH 足够**
2. SG 可能在 attestation 解密后仍有 **更严规则**Play Integrity、设备信誉、区域黑名单
3. PH 成功 **不能** 直接类推 SG**diff 两次 register 明文****换未测过 SG 的环境** 再试。
---
## 7. 已知问题与踩坑
| 问题 | 现象 | 处理 |
|------|------|------|
| Gson Hook 误报 | i18n 文案含 "register" 刷屏 | 已改为只匹配 `scene=REGISTRATION` |
| `requestDefense` 被 block | 缺 `x-sap-fixme` | 已改为执行后净化,不 block |
| ProcessBuilder 抛异常 | `which su` → SecurityException | **待修**:应返回空进程而非抛异常 |
| Magisk zip 在 Windows 打包 | 只解压 module.prop | 用 `tar -a -cf` 或 adb 手动 push sh |
| 关 USB 调试后 adb 断开 | 收尾脚本卡住 | `maribank-scheme-b-finish.ps1` 已加 60s 超时 |
| Attest native Hook 命中少 | 仅 8 个 hook | `data`/`dataKey` 仍未在 native 层改写 |
### 禁止操作(会导致白屏/崩溃)
- Hook `System.loadLibrary`SG RN 崩溃)
- Hook `RealInterceptorChain.proceed`
- 过早 Hook `ShpssInstall` / `vuvuwwwuw`
---
## 8. 常用命令
```powershell
$adb = "C:\Users\Administrator\AppData\Local\Android\Sdk\platform-tools\adb.exe"
# 构建安装
.\scripts\build-debug.ps1
.\scripts\install-debug.ps1
# 抓注册 log先 logcat -c再点 Next
& $adb logcat -d | Select-String "MariBankEncrypt in0 byte\[1|MariBankRoot HTTP.*register|3100012|4067012|OTP_SMS"
# 验证伪装 ID
& $adb shell su -c "getprop ro.serialno; settings get secure android_id"
```
---
## 9. 新加坡后续建议
1. **用 PH 成功时的同一环境** 测 SG关 USB 调试、`pm clear` SG 包、冷启动
2. **diff PH vs SG** 加密前 register JSON`deviceFingerprint` 段、`data` 长度与字段)
3. 修复 **ProcessBuilder** 不抛异常,避免 SHPSSDK 检测 tamper
4. 若仍 3100012考虑 **未测过 SG 的干净机****native hook `libshpssdk_bank.so`**
5. 菲律宾侧:继续走完 **OTP → 开户** 验证全流程稳定性
---
## 10. 相关文档
| 文档 | 说明 |
|------|------|
| [`MariBank风控与载荷说明.md`](MariBank风控与载荷说明.md) | **register 字段、afExtInfo、USB 调试、PH 真实原因、Xposed/LSPosed** |
| [`手机操作手册.md`](手机操作手册.md) | **Root 机部署、LSPosed、日常操作、抓 log** |
| [`Hook指南.md`](Hook指南.md) | Xposed/LSPosed 概念、Telegram Hook、扩展 App |
| [`MariBank实现说明.md`](MariBank实现说明.md) | PH Hook 实现细节(含 7/3 失败记录,已追加 7/6 突破) |
| [`MariBank新加坡逆向.md`](MariBank新加坡逆向.md) | SG 逆向与 ADB 检测 |
| [`MariBank新加坡突破.md`](MariBank新加坡突破.md) | **SG 3100012 突破计划与测试流程** |
| [`工作日志_2026-07-03.md`](工作日志_2026-07-03.md) | 7/3 工作记录 |
---
*记录日期2026-07-06 · 最后更新:文档体系整理(操作手册 + 风控载荷说明)*

View File

@@ -1,11 +1,11 @@
# MariBank 实现说明与问题记录
> **日期**2026-07-03
> **日期**2026-07-03**2026-07-06 更新PH 注册已成功发 OTP**,见 [`MariBank_2026-07-06_菲律宾突破.md`](MariBank_2026-07-06_菲律宾突破.md)
> **App**MariBank / SeaBank PH · `ph.seabank.seabank` · v3.22.0 (32200)
> **设备**Pixel 6 · Magisk + Zygisk + LSPosed
> **设备**Pixel 6 · Magisk + Zygisk + LSPosed + Shamiko
> **源码**`xposed-module/.../hook/MariBank*.java`
本文档专门说明当日对 MariBank **注册流程风控绕过** 的 Xposed 实现细节,以及开发/测试中遇到的全部主要问题。通用工作汇总见 [`WORKLOG_2026-07-03.md`](WORKLOG_2026-07-03.md)。
本文档专门说明当日对 MariBank **注册流程风控绕过** 的 Xposed 实现细节,以及开发/测试中遇到的全部主要问题。通用工作汇总见 [`工作日志_2026-07-03.md`](工作日志_2026-07-03.md)。
---
@@ -21,7 +21,21 @@
→ 进入 OTP / 下一步
```
### 1.2 当日最终结果
### 1.2 最终结果2026-07-06 更新)
| 阶段 | 2026-07-03 | **2026-07-06** |
| -------------------- | ---------- | -------------- |
| 本地 Root 检测 / 自杀 / 弹窗 | **大部分绕过** | ✅ 稳定 |
| DFP 设备指纹上报 | **成功** `code=0` | ✅ |
| riskToken 尾部净化 | ✅ Hook 生效 | ✅ |
| 加密前 register 明文 | ❌ 未抓到 | ✅ `MariBankSdkUtilsHook` |
| 注册接口 PH | ❌ **4067012** | ✅ **`code=0` → OTPBSO** |
| 注册接口 SG | — | ❌ **3100012**(见 SG 文档) |
**结论2026-07-06**:在 Pixel 6 + Shamiko + 方案 B 换 ID + 全套 Hook 下,**菲律宾 SeaBank 注册已通过服务端校验并可发验证码**;新加坡 MariBank 仍被拒。详见 [`MariBank_2026-07-06_菲律宾突破.md`](MariBank_2026-07-06_菲律宾突破.md)。
### 1.2.1 当日最终结果2026-07-03 历史记录,已被 7/6 突破)
| 阶段 | 结果 |
@@ -33,7 +47,7 @@
| 注册请求明文 | **未抓到**body native 加密) |
**结论**:本地层 bypass 已推进到能正常发起注册请求,但 **服务端风控仍拒绝**;当前实现不足以完成注册
**结论2026-07-03**:本地层 bypass 已推进到能正常发起注册请求,但 **服务端风控仍拒绝**
---
@@ -332,9 +346,12 @@ Hook 对含 `temporarily blocked` / `8424 8050` 的 Toast 会 **记录栈**`l
## 5. 遇到的问题(完整清单)
### 5.1 服务端注册拒绝 — 4067012核心未解问题
### 5.1 服务端注册拒绝 — 40670122026-07-06 已突破 PH
**现象**
> **更新**2026-07-06 在 Shamiko + 方案 B + `MariBankSdkUtilsHook` / `MariBankAttestationHook`PH register 返回 **`code=0`step=BSOOTP_SMS_TRIGGER 成功**。详见 [`MariBank_2026-07-06_菲律宾突破.md`](MariBank_2026-07-06_菲律宾突破.md)。
> 以下内容为 **7 月 3 日** 失败时的记录,保留作对照。
**现象2026-07-03**
- 能进入 Sign up 页
- 请求发出后弹服务端封锁弹窗
@@ -560,7 +577,10 @@ Log in → 输入已开户手机号 → 继续
| `reverse/frida/trace_maribank_register.js` | Frida trace 脚本 |
| `scripts/logcat-maribank.ps1` | logcat 过滤 |
| `scripts/start-mari-trace.ps1` | Frida + logcat 联启 |
| `docs/WORKLOG_2026-07-03.md` | 当日全项目工作汇总 |
| `docs/工作日志_2026-07-03.md` | 当日全项目工作汇总 |
| `docs/MariBank_2026-07-06_菲律宾突破.md` | PH OTP 突破 + SG 现状 |
| `docs/MariBank风控与载荷说明.md` | register 字段、afExtInfo、USB 调试 |
| `docs/手机操作手册.md` | Root 机部署与日常操作 |
---

View File

@@ -0,0 +1,139 @@
# MariBank 新加坡注册突破计划2026-07-06
> **现状**PH ✅ OTP`code=0`SG ❌ **3100012**`api.maribank.com.sg`
> **关联**[`MariBank_2026-07-06_菲律宾突破.md`](MariBank_2026-07-06_菲律宾突破.md) · [`MariBank新加坡逆向.md`](MariBank新加坡逆向.md) · [`MariBank风控与载荷说明.md`](MariBank风控与载荷说明.md)
---
## 1. 问题定位
| 层级 | SG 状态 | 说明 |
|------|---------|------|
| 本地 Root / ADB 页 | ✅ 可 bypass | 能进注册页、点 Next |
| DFP 上报 | ✅ 通常 `code=0` | 只表示收到,不等于注册过 |
| `deviceFingerprint` 尾部 | ✅ 已 `\|00\|0` | Java 层净化生效 |
| **`rdVerifyInfo.data` / `dataKey`** | ❌ 服务端拒 | **3100012 主因** |
| 干净机 `25078RA3EY` | ✅ 关 USB 调试可 OTP | 无 LSPosed |
**结论**SG 卡在 **native attestation 密文 + 区域风控**,不是再堆本地弹窗拦截。
---
## 2. 与 PH 的差异(实测 + 逆向)
| 维度 | PH | SG |
|------|----|----|
| API | `api.seabank.ph` | `api.maribank.com.sg` |
| 错误码 | 4067012 | **3100012** |
| App 版本 | 3.22.0 | 3.2.2 |
| 国家码 `cyCode` | 63 | **65** |
| 本地 ADB 检测 | 较弱 | **RISK_USB_ADB / RISK_WIFI_ADB** |
| 服务端 strictness | 7/6 已通过 | **更严** |
| 设备黑名单 | 较少 | Pixel 6 多次测 SG 可能已标记 |
---
## 3. 本轮代码改动2026-07-06
| 改动 | 目的 |
|------|------|
| **`ProbeGuard`** | `ProcessBuilder` / `Runtime.exec` 返回假进程,**不再抛 SecurityException**(避免 SHPSSDK 记 tamper |
| **`MariBankAttestationHook`** | 增加 `vvuuuuvvv``uvuwwuvwv.uvwwuuvvw` attestation 类 Hook |
| **`MariBankShpsNativeHook`** | native-core 增加 `vvuuuuvvv` |
| **`MariBankRegisterPayloadUtil`** | 加密前 log`cyCode``dataLen``dataKeyLen``fpTail` |
---
## 4. 推荐测试流程Root 机)
### 4.1 每次测 SG 前(降低黑名单概率)
```powershell
cd C:\Users\Administrator\Desktop\notiMessage
# 1. 编译安装最新 Hook
.\scripts\build-debug.ps1
.\scripts\install-debug.ps1
# 2. 新设备 ID保持 USB 调试,便于继续 adb
.\scripts\maribank-sg-register.ps1 -NewIdentity -KeepAdb
# 3. 上机测 SG 前再关 USB 调试(会断开 PC adb属预期
.\scripts\maribank-sg-register.ps1 -DisableUsbDebug
```
### 4.2 手机侧
> **LSPosed 里两个都叫「MariBank」**:菲律宾 `ph.seabank.seabank`v3.22.0)与新加坡 `sg.com.maribankmobile.digitalbank`v3.2.2)桌面名相同,从 LSPosed 作用域点开会容易进错。**请认包名**,或 PC 执行 `.\scripts\launch-maribank-sg.ps1` 直接打开新加坡版。log 里应出现 `api.maribank.com.sg`,若全是 `api.seabank.ph` 说明开的是菲律宾 App。
1. LSPosed模块启用作用域含 **`sg.com.maribankmobile.digitalbank`**
2. **软重启** MariBank SG不是只杀进程
3. Shamiko + DenyList 勾选 SG 全部进程,**Enforce=OFF**
4. (建议)**关闭 VPN**
5. Sign up → 新加坡手机号 → **Next**
### 4.3 抓 log
```powershell
.\scripts\maribank-sg-register.ps1 -CaptureLog -KeepAdb
```
> **注意**:旧版脚本默认会关 USB 调试,导致 PC 立刻 `no devices`。抓 log 必须加 **`-KeepAdb`**,或先在手机上重新打开 USB 调试。
关注:
```text
MariBankRegister: register summary cy=65 scene=REGISTRATION step=BE fpTail=00|0 dataLen=... dataKeyLen=...
MariBankRoot HTTP .../uapi/v2/register ... code=0 ← 成功
MariBankRoot HTTP .../uapi/v2/register ... code=3100012 ← 仍失败
ProbeGuard: fake probe process ← 探针已静默拦截
```
---
## 5. 若仍 3100012 — 按优先级排查
| 优先级 | 动作 | 原因 |
|--------|------|------|
| P0 | **换新 serial + android_id**`-NewIdentity` | SG 可能设备级黑名单 |
| P0 | **关 USB 调试 + 无线调试** | 干净机对照:开调试本地 ADB 页;服务端 SG 更严 |
| P0 | **关 VPN** | 截图曾见 VPN 图标,可能进 risk |
| P1 | diff PH vs SG 同机 `register summary` | 对比 `dataLen`、fingerprint 段 |
| P1 | 用 **未测过 SG 的干净机** 注册一次 | 分离「设备黑」vs「Root 载荷不可过」 |
| P2 | Play Integrity / PIF 模块是否生效 | SG 可能校验 attestation 内 integrity |
| P3 | native hook `libshpssdk_bank.so` 生成链 | Java 层改不了 `data` 密文内容 |
---
## 6. 成功标准
```json
POST https://api.maribank.com.sg/uapi/v2/register
{"code":0,"msg":"success","data":{"scene":"REGISTRATION","step":"BSO",...}}
```
随后 App 进入 **OTP 短信** 步骤(与 PH 相同 step 流转)。
---
## 7. 禁止操作
与 PH 相同,会导致白屏 / RN 崩溃:
- Hook `System.loadLibrary`
- Hook `RealInterceptorChain.proceed`
- 过早 Hook `ShpssInstall` / `vuvuwwwuw`
---
## 8. 相关脚本
| 脚本 | 用途 |
|------|------|
| `scripts/maribank-sg-register.ps1` | SG 测前准备 + 抓 log |
| `scripts/maribank-spoof-device.ps1 -NewIdentity` | 换 serial / android_id |
| `scripts/maribank-scheme-b-finish.ps1` | LSPosed 作用域 + pm clear |
---
*最后更新2026-07-06*

View File

@@ -0,0 +1,217 @@
# MariBank 新加坡版逆向报告
> **日期**2026-07-06
> **设备**Pixel 6已 Root + LSPosed + ADB 开启)
> **APK 来源**:从本机 pull`reverse/apks/maribank_sg_*.apk`
---
## 1. 基本信息
| 项 | 值 |
|----|-----|
| **包名** | `sg.com.maribankmobile.digitalbank` |
| **显示名** | MariBank新加坡 |
| **版本** | 3.2.2versionCode **30220** |
| **对比 PH 版** | `ph.seabank.seabank` · v3.22.0 (32200) |
| **API 域名** | `https://api.maribank.com.sg`staging: `api.staging.maribank.com.sg` |
| **Application** | `com.shopee.bke.digitalbank.BkeApplication`(与 PH **相同** |
本地 APK
```
reverse/apks/maribank_sg_base.apk
reverse/apks/maribank_sg_arm64.apk → SO 在 reverse/extracted/native/
```
---
## 2. 截图现象(你当前遇到的)
启动后出现 **两层拦截**
| 现象 | 类型 | 说明 |
|------|------|------|
| 全屏 **「ADB/Wireless ADB Detected」** | 环境检测页 | 文案 key`bke_title_adb_wirelessadb_control``bke_btn_turn_off_adb_wireless_adb` |
| Toast **「Does not support root device」** | Root 检测 | SHPSSDK / SafeMode 典型提示 |
**含义**
1. **ADB 检测****Root 检测****独立项**——新加坡版在 PH 版基础上 **明显加强了 USB/无线调试检测**
2. 状态栏 **VPN 小钥匙** 可能额外触发 `RISK_*` 或网络风控(需 logcat 确认)。
3. 当前 **PH 版 Xposed Hook 未作用到 SG 包名**(只 Hook `ph.seabank.seabank`),所以 SG 版 **完全未 bypass**
**临时验证(无 Hook**:关闭 **USB 调试 + 无线调试** 后冷启动,可确认 ADB 页是否消失Root Toast 仍会存在。
---
## 3. 技术栈对比SG vs PH
| 组件 | SG 3.2.2 | PH 3.22.0 | 结论 |
|------|----------|-----------|------|
| Shopee BKE | ✅ | ✅ | 同框架 |
| `BkeApplication` | ✅ | ✅ | Hook 时机相同(`attachBaseContext` 后) |
| SafeMode | `com.shopee.bke.lib.safemode.b` 等 | 相同 | 可复用 boolean Hook |
| `SafeModeRecoverActivity` | ✅ | ✅ | Root 弹窗/恢复页 |
| SHPSSDK Bank | `com.shopee.shpssdkbank.SHPSSDK` | 相同 | 可复用 riskToken / requestDefense 链 |
| Native SO | `libshpssdk_bank.so``libsdkutils.so``libbkutils.so` | 相同 | SG 额外有 **`libshpssdk.so`** |
| 字符串解密 | `uvuwwuvwv.uvwwuuvvw` | 相同 | 混淆包名可能不同 |
**结论**SG 与 PH 是 **同一套 Shopee 银行 SDK**PH 上已写的 Hook **大部分可迁移**,需改 **包名****补充 ADB 风控**
---
## 4. 新加坡版新增ADB / 无线调试检测
### 4.1 SHPSSDK 风险常量DEX 内)
```
RISK_USB_ADB ← USB 调试
RISK_WIFI_ADB ← 无线调试
RISK_ADB
RISK_ROOT
RISK_HOOK
RISK_DEBUG
RISK_DEVELOPER_MODE
RISK_EMULATOR
...
```
与截图中 **ADB 拦截页 + Root Toast** 对应。
### 4.2 UI 字符串(资源 key
| Key | 用途 |
|-----|------|
| `bke_title_adb_wirelessadb_control` | 标题「ADB/Wireless ADB Detected」 |
| `bke_btn_turn_off_adb_wireless_adb` | 按钮「Turn Off ADB/Wireless ADB」 |
| `bke_desc_explain_detect_adb_1/2` | 说明文案 |
| `bke_desc_safeguard_bank_app_from_adb_wirelessadb_1/2` | 安全说明 |
| `AdbDetected` / `WifiAdbDetected` | RN / 业务路由标识 |
### 4.3 检测思路(推断)
1. **Settings.Global** / `adb_enabled`、无线调试相关属性
2. SHPSSDK `getRiskSync` → 命中 `RISK_USB_ADB` / `RISK_WIFI_ADB`
3. 进入 **ADB 专用拦截 UI**(非仅 SafeModeRecoverActivity
4. Root 仍走 `RISK_ROOT` → Toast「Does not support root device」
---
## 5. API 与登录SG
PH 实测重点为 `POST /uapi/v2/register`SG DEX 内可见:
| 类型 | 路径示例 |
|------|----------|
| 登录 | `/v3/login``/v4/login``/v2/login/bke/linkage` |
| 注册 | `/v2/register` |
| 登录前 | `/v1/list/latest-notice/pre-login` |
| 人脸 | `/v3/login/facial/verification` |
| DFP | `/dfp/`(与 PH 同类) |
| 通用 uapi | `/uapi/`47 处引用) |
**Host**`https://api.maribank.com.sg`(非 PH 的 `api.seabank.ph`)。
---
## 6. Native 库arm64 split
已从 `maribank_sg_arm64.apk` 解出至 `reverse/extracted/native/`,与风控相关:
| SO | 大小(约) | 作用 |
|----|-----------|------|
| `libshpssdk_bank.so` | 8.5 MB | SHPSSDK 银行风控、riskToken |
| `libshpssdk.so` | 6.3 MB | **SG 额外**PH 侧以 bank 为主) |
| `libsdkutils.so` | 789 KB | 注册/body 加密 |
| `libbkutils.so` | 102 KB | JNI 工具 |
| `libmemory_security.so` | 3.8 MB | 内存/安全相关 |
---
## 7. 与现有 Xposed 模块的关系
当前 `MariBankRootBypassHook` / `MariBankShpsNativeHook` **仅绑定**
```java
public static final String PACKAGE = "ph.seabank.seabank";
```
**SG 包名未纳入**,因此截图中的拦截 **预期行为**
### 7.1 可复用(改包名即可试)
- SafeMode boolean Hook`safemode.b` 等)
- `Process.killProcess` / `finishAffinity` 反自杀
- `/proc/self/maps` 过滤、`SystemProperties` 伪装
- SHPSSDK `getRiskSync` 清空、`getRiskToken` 净化
- OkHttp 出站 riskToken 净化
**已实现2026-07-06**`MariBankRootBypassHook` 已支持 `sg.com.maribankmobile.digitalbank`,含 ADB Settings 伪装、ADB 全屏页 finish、Root/ADB Toast 拦截。
### 7.2 SG 专用(已部分实现)
| 项 | 状态 |
|----|------|
| **包名** | ✅ `MainHook` + `arrays.xml` |
| **ADB 风险** | ✅ `Settings.Global/Secure` adb 键 → 0`init.svc.adbd` → stopped |
| **ADB 拦截页** | ✅ `Activity.onResume` 检测 ADB 文案后 `finish()` |
| **Root Toast** | ✅ 「Does not support root device」 |
### 7.3 不建议照搬 PH 服务端结论
PH 的 **4067012****菲律宾服务端** 结论SG 需单独测 `api.maribank.com.sg`
---
## 8. 逆向脚本
```powershell
# 已从手机 pull APK 后可本地扫描
python reverse/scripts/scan_maribank_sg.py
python reverse/scripts/find_sg_adb_strings.py
python reverse/scripts/find_sg_adb_classes.py
python reverse/scripts/list_safemode.py reverse/apks/maribank_sg_base.apk
```
从手机 pull已执行过
```powershell
adb shell pm path sg.com.maribankmobile.digitalbank
adb pull <base.apk> reverse/apks/maribank_sg_base.apk
```
---
## 9. 建议下一步2026-07-06 更新)
1. ~~LSPosed 增加 SG 包名~~ ✅ 已完成
2. ~~扩展 ADB bypass~~ ✅ 已完成
3. **SG**diff PH/SG register 明文;修复 ProcessBuilder 异常;仍 3100012 则试干净机
4. **PH**:走完 OTP → 开户全流程,确认稳定性
5. logcat`MariBankEncrypt in0 byte``3100012``OTP_SMS`
---
## 10. 实测状态2026-07-06
| 包 | 注册 API | 结果 |
|----|----------|------|
| `ph.seabank.seabank` | `api.seabank.ph/uapi/v2/register` | ✅ **code=0 → OTP** |
| `sg.com.maribankmobile.digitalbank` | `api.maribank.com.sg/uapi/v2/register` | ❌ **3100012** |
完整说明:[`MariBank_2026-07-06_菲律宾突破.md`](MariBank_2026-07-06_菲律宾突破.md)
---
## 11. 相关文档
| 文档 | 说明 |
|------|------|
| [`MariBank_2026-07-06_菲律宾突破.md`](MariBank_2026-07-06_菲律宾突破.md) | **PH OTP 突破 + SG 现状 + 方案 B** |
| [`MariBank实现说明.md`](MariBank实现说明.md) | PH Hook 实现与问题清单 |
| [`工作日志_2026-07-03.md`](工作日志_2026-07-03.md) | PH 版 7/3 工作记录 |
---
*记录日期2026-07-06 · 最后更新:菲律宾 OTP 突破*

View File

@@ -0,0 +1,215 @@
# MariBank 风控与注册载荷说明
> 客户端检测分层、register 请求字段含义、菲律宾突破原因、USB 调试与 Hook 关系。
> 关联:[`MariBank_2026-07-06_菲律宾突破.md`](MariBank_2026-07-06_菲律宾突破.md) · [`MariBank实现说明.md`](MariBank实现说明.md) · [`手机操作手册.md`](手机操作手册.md)
---
## 1. 风控两层模型
| 层级 | 谁在做 | 失败表现 | bypass 方式 |
|------|--------|----------|-------------|
| **客户端本地** | SafeMode、SHPSSDK、SG ADB 页 | 弹窗、全屏拦截、自杀 | Xposed boolean / Settings / finish Activity |
| **服务端** | `api.seabank.ph` / `api.maribank.com.sg` | 4067012 / 3100012 | 让出站 attestation「够干净」+ 区域策略 |
**DFP `code=0` 只表示上报收到,不等于注册会通过。**
干净机对照25078RA3EY**开 USB 调试 → SG 本地 ADB 页;关调试 → SG OTP 可过**。说明本地检测与服务端决策 **相互独立**
---
## 2. 注册请求载荷结构App 发出)
加密前 JSON`MariBankEncrypt` / `uvwuvwuv.uvwvuww` 入口)典型结构:
```json
{
"cyCode": "63",
"phone": "<RSA 加密>",
"scene": "REGISTRATION",
"step": "BE",
"rdVerifyInfo": {
"bioStatus": 0,
"data": "<SHPSSDK native attestation 密文>",
"dataKey": "<attestation 密钥>",
"deviceFingerprint": "段1|段2|段3|00|0",
"fvInfo": {},
"afExtInfo": {
"modeInCall": "N",
"modeInCommunication": "N",
"modeCallScreening": "N"
},
"publicKeyAuthen": "pk2",
"random": "...",
"softTokenActivated": false
}
}
```
流程:**App 本地采集 → 拼 JSON → native 加密 → POST `/uapi/v2/register`**
---
## 3. 字段说明
### 3.1 `deviceFingerprint`
pipe 分隔字符串,示例:
```text
jrKyNF/Fx4gcnzVpf1u9bw==|WJMksio9SaKKuyk1KMSilUGivdF+...|TXeh8FyEf/8qHdAS|00|0
↑ Base64 段1 ↑ Base64 段2设备哈希 ↑ 短码 ↑ 风险码|标志
```
| 部分 | 含义 | Hook 能否改 |
|------|------|-------------|
| 前两段 Base64 | 设备 / 环境哈希,服务端可索引黑名单 | 换 serial/android_id 后 **重新生成** |
| 第三段 | 短码 | 随 SDK 变 |
| 最后 `\|xx\|y` | 风险摘要(如 Root+Hook `\|09\|1` | ✅ 已净化为 `\|00\|0` |
**只改尾部不够**:服务端还验 `data`/`dataKey` 解密内容。
### 3.2 `data` / `dataKey`
-**`libshpssdk_bank.so` native** 生成并签名
- 内含 Root / Hook / 调试等 attestation**Java 层改 JSON 字段无法重写密文**
- SG 3100012 时多半此层或设备黑名单未过PH 7/6 成功时服务端 **接受了** 当前密文
### 3.3 `afExtInfo`(银行 App 上报,非 PC 添加)
```json
"afExtInfo": {
"modeInCall": "N",
"modeInCommunication": "N",
"modeCallScreening": "N"
}
```
| 字段 | 推测含义 | `"N"` |
|------|----------|-------|
| `modeInCall` | 是否正在通话 | No |
| `modeInCommunication` | 是否通信/音频占用 | No |
| `modeCallScreening` | 来电筛选等相关状态 | No |
- **来源**App / SDK 读系统状态写入 `rdVerifyInfo`
- **去向**:随 register 加密后发给服务端
- **当前模块****未专门修改** 这三项(原样上报)
- **用途**:反欺诈辅助信号,**不是** USB 调试开关
### 3.4 其他常见字段
| 字段 | 说明 |
|------|------|
| `cyCode` | 国家码PH=`63`SG=`65` |
| `step` | `BE` 首次注册 → 成功后可到 `BSO`OTP |
| `action` | `OTP_SMS_TRIGGER` 触发短信验证码 |
| `bioStatus` | 生物识别状态,注册时多为 `0` |
---
## 4. 为何 USB 调试开着仍能发 OTP菲律宾实测
### 4.1 两个「USB 调试」不是一回事
| | PC 侧 adb | App 内部读到的值 |
|--|-----------|------------------|
| 你开 USB 调试 | ✅ 能 `adb shell` | 被 Hook **伪装为关闭** |
### 4.2 模块已做的 ADB bypass`MariBankRootBypassHook`
**Java Settings**
- `adb_enabled` / `adb_wifi_enabled` / `development_settings_enabled``0`
**系统属性(`MariBankShpsNativeHook`**
- `init.svc.adbd``stopped`
- `persist.sys.adb_enable``0`
**UI**
- 全屏「ADB / Wireless ADB Detected」页 → `Activity.finish()`
- 相关 Toast 拦截
因此:**PC 连着 adbApp 仍可能认为未开调试**本地不拦、register 载荷也不带「adb 开」的自报字段。
### 4.3 与干净机对照
| 环境 | USB 调试 | 结果 |
|------|----------|------|
| 干净机(无 Hook | 开 | 本地 ADB 页,进不了 OTP |
| 干净机 | 关 | ✅ OTP |
| Pixel 6 + LSPosed + Shamiko | **开** | ✅ PH OTPHook 伪装) |
### 4.4 注意
- native 仍可能用 Hook 未覆盖的路径读调试状态
- **SG 更严**,开 adb 风险高于 PH
- 文档仍建议测 SG 时 **关 USB 调试**
---
## 5. 菲律宾能发验证码的真实原因
### 5.1 能确定
- `api.seabank.ph` 返回 **`code=0`**step 进入 **BSO****OTP_SMS_TRIGGER** 成功
- 同一台机、同一套 Hook 下 **SG 仍 3100012** → 差异在 **服务端**,非「没风控」
### 5.2 7/3 失败 → 7/6 成功的变化
| 能力 | 7/3 | 7/6 |
|------|-----|-----|
| riskToken 尾部净化 | ✅ | ✅ |
| 加密前 register 明文 Hook | ❌ | ✅ |
| Attestation / 环境 Hook | 弱 | ✅ |
| Shamiko 藏 Magisk | 无/未配 | ✅ |
| 换 serial / android_id | 无 | ✅ |
**不是单一开关**,而是 **Shamiko + 新设备 ID + 加密前/采集链净化** 组合后PH 服务端认为 attestation **可接受**
### 5.3 高概率推断
1. **PH / SG 两套 API、两套规则** — 同一载荷 PH 过、SG 不过
2. **PH 侧设备黑名单** — Pixel 6 在 SG 测多次PH 可能未标记
3. **7/3 的 4067012** — bypass 不完整非「PH 永远不能 Root 注册」
### 5.4 无法无源码 100% 证实
- PH 后台具体哪条规则放行
- `data` 解密后哪一位从拒变收
---
## 6. Magisk / LSPosed / Shamiko 分工
```
MagiskRoot 权限)
└── Zygisk
├── LSPosed → 加载 xposed-module改 Java 检测、抓加密前 JSON
└── Shamiko → 对银行进程隐藏 Magisk/LSPosed
Magisk 模块 maribank_device_spoof → resetprop 换 serial 等
```
| 组件 | 作用 |
|------|------|
| **Magisk** | Root |
| **LSPosed** | 运行 Xposed 模块(与经典 Xposed 同 API见 [`Hook指南.md` §0](Hook指南.md) |
| **Shamiko** | 进程内 Hide Root改善 native attestation |
| **Xposed 模块** | SafeMode / ADB / 加密前净化 |
---
## 7. 服务端可能校验项(反推)
| 校验项 | 说明 |
|--------|------|
| `rdVerifyInfo.data` / `dataKey` | native attestation 解密 |
| `deviceFingerprint` 段 1/2 | 设备黑名单 |
| DFP 历史画像 | 与 register 是否一致 |
| 区域策略 | PH 松 / SG 严(实测) |
| IP / 号码 / 频率 | 次要,非本次主因 |
---
*最后更新2026-07-06*

View File

@@ -0,0 +1,171 @@
# TNG Money Packet 领取台说明
> 更新2026-08-04
> 相关代码:`TngMoneyPacketHook.java`、`debug-server/server.py`、`AppConfig.DEBUG_SERVER_URLS`
## 目标
抓取 TNG eWallet **Money Packet红包** 的领取排行:谁领了、领了多少,并在 PC 调试台按**单个红包**展示。
## 手机端领取台
主 App 首页有 **红包领取台(独立)** 入口(`MmpClaimActivity`
- Hook 收到 `[MMP统计]` 后写入本地 `MmpPacketStore`**不进入**通用「情况」监听台、也不走正式上传
- 手机数据可同步到 PC打开领取台会自动同步也可点「同步电脑」PC 打开 http://127.0.0.1:8765/mmp 即可看(落盘 `mmp_packets.json`,重启调试台不丢)
- PC 调试台红包数据走独立队列,与 `/` 通用消息台隔离
- 列表支持筛选(全部/领取中/已领完 + 关键词)、详情/卡片显示手气(领完)或目前最高/最低(领取中)
- 点进看领取排行;支持清空(仅红包)
- 需监听列表勾选 TNG且 Xposed 模块生效
- **功能说明页**:手机顶栏「说明」→ `MmpHelpActivity`;电脑 http://127.0.0.1:8765/mmp/help
PC 页 http://127.0.0.1:8765/mmp 手机浏览器也可访问USB reverse 或局域网 IP
---
## 页面与地址
| 入口 | 地址 |
|------|------|
| 本机USB + `adb reverse` | http://127.0.0.1:8765/mmp |
| 功能说明 | http://127.0.0.1:8765/mmp/help |
| 局域网 / USB 共享网 | http://&lt;电脑IP&gt;:8765/mmp (当前常见:`http://10.151.104.25:8765/mmp` |
| 通用消息台 | http://127.0.0.1:8765/ |
手机推送会同时尝试:
- `http://127.0.0.1:8765`(需 `adb reverse tcp:8765 tcp:8765`
- `http://10.151.104.25:8765`USB 共享网段;若电脑 IP 变了,改 `AppConfig.DEBUG_SERVER_URLS`
启动调试台:
```powershell
powershell -ExecutionPolicy Bypass -File scripts/start-debug-server.ps1
```
## 红包唯一标识
正式字段是 **`activityId`**UUID例如
- `b8c37a58-bb2c-4aa2-83ef-f15466b9211e`
- `e5dcf293-4062-4df4-a692-e3bf194d9a37`
领取台左侧按 `activityId` 分条;详情页会显示完整 ID。
转发正文格式示例:
```text
[MMP统计] packet=<activityId> | sender=... | total=1.00 | via=rpc | src=moneyPacketDetail
昵称A -> 0.71
昵称B -> 0.29
```
金额在 App 内多为 Money 对象 `{"amount":"0.71","cent":"71",...}`Hook / 调试台会规范成数字再展示。
## 数据从哪来
| 来源 | Quake op / 方法 | 内容 |
|------|-----------------|------|
| 历史列表 | `moneyPacketHistoryList` / `ap.tngdwallet.moneyPacket.list.retrieve` | 仅摘要:`activityId`、总额、时间等,**无领取排行** |
| 红包详情 | `moneyPacketDetail` / `ap.tngdwallet.moneyPacket.retrieve` | 含 `activityPoolInfos`(领取人 + 金额) |
外部 mitm 因证书 pinning **解不开** TNG API 正文;必须用进程内 Xposed`TngMoneyPacketHook`)。
消费端详情领取列表字段主要是 **`activityPoolInfos`**`receiverName` + `amount`),不是商户版的 `receiverList`
## 推荐操作流程(自动拉历史 + 详情)
登录后**无需再手动点详情灌模板**,也**不必反复进历史页**
1. 打开 TNG 并完成登录 / PIN任意前台页面即可
2. Hook 从 `ILoginStorage` / 历史请求自动取 `sessionId``Activity.onResume` 按设置冷却主动调 `moneyPacketHistoryList`
3. 历史返回后自动按 `activityId` 串行拉详情(间隔见设置),推送到领取台
4. 仅打开 **Money Packet 历史页**时App 自己的列表响应也会立刻触发步骤 3
5. 打开 http://127.0.0.1:8765/mmp ;点 **设置** 可调刷新速度,保存后约 10 秒内生效
日志期望:
- `hooked ILoginStorage getters` / `login from storage session=…` / `cached history request template`
- `history hit → schedule auto detail``auto-history ok → schedule details`
- `auto-detail ok activityId=… claims=N`
若仍无数据:确认已安装最新 Xposed 模块并强停重启 TNG看是否出现 `auto-detail empty claims` / `no sessionId`
## 去重与刷新行为
冷却时间可在领取台 **设置** 面板调整(`/mmp` → 设置),保存后约 **10 秒内**手机 Hook 生效。默认偏快(历史/详情约 8 秒)。
| 规则 | 说明 |
|------|------|
| 标识 | 以 `activityId` 区分红包 |
| sessionId | 自动:`ILoginStorage` / Repository.loginStorage有真实请求时也会缓存 |
| 自动历史 | 前台 `onResume` 触发;冷却见设置「历史列表冷却」 |
| 短时去重 | 同一 `activityId` + **相同领取名单**,「相同名单去重」秒内不重复推送 |
| 自动拉冷却 | 同一 `activityId` 自动拉详情成功后,「详情刷新冷却」内不重复自动拉 |
| 包间间隔 | 自动拉详情串行,「详情请求间隔」毫秒/条 |
| 无模板 | 「无模板时自动打开历史页」可关,避免突然跳转 |
| 调试台 | 同一 `activityId` 只保留最新完整快照;双通道短时内容去重 |
预设:**极速**(约 5s/ **标准**(约 8s/ **省流**(约 3045s
日志里若出现 `skip dup activityId=...`,表示短时去重生效,不是没抓到。
`settings applied hist=…` 表示 Hook 已拉到新配置。
| 已领完停刷 | `remainingCount<=0` / `claimed>=total` / status FINISH·COMPLETE·EXPIRE / 金额凑齐,且**已有领取名单**后不再自动拉该包详情 |
| 断线重连 | session 丢失或 RPC 鉴权/网络失败 → 标记断开session 恢复或 Activity resume 时**强制**重拉历史 |
日志:`packet DONE stop-refresh``session lost``session recovered → force re-fetch`
---
## 排障
### 打开 TNG 闪退
装了 Xposed / 防护相关模块后TNG 偶发一打开就闪退,属常见现象:
1. **多点几次**图标再打开(前几次崩、后面能进的情况不少见)
2. 仍不行:系统设置 → 应用 → TNG → **强制停止**,再重新打开
3. 还不行:强制停止后清「缓存」(尽量别清「数据」,否则要重新登录)
4. 推送 / 更新过 Hook 模块后:务必强制停止再开 TNG否则新逻辑可能不生效或更易闪
5. 连续闪退:先关掉 LSPosed 里本模块对 TNG 的作用域试能否正常打开,确认后再勾回并强停重开
手机 / PC 功能说明页也写了同样步骤(「说明」或 `/mmp/help`)。
```text
adb logcat | findstr TngMmp
```
常见日志:
| 日志 | 含义 |
|------|------|
| `TngMmp installed` | Hook 已加载 |
| `cached RPC task MoneyPacketRpcTask` | 已缓存 RPC 代理 |
| `cached detail request template` | 已缓存手动详情请求(可选加速) |
| `login from storage session=…` | 已从 ILoginStorage 自动取到 session |
| `auto-history ok → schedule details` | 已自动拉历史列表 |
| `auto-detail empty claims ... totalCount:0` | 请求缺登录态或服务端空结果 |
| `auto-detail ok activityId=... claims=N` | 自动拉详情成功 |
| `captured activityId=... claims=N` | 已转发到 notiMessage / 调试台 |
其它检查:
1. LSPosed 作用域包含 `my.com.tngdigital.ewallet`,模块为最新 APK期望 **1.2.0 / versionCode 3**
2. 强停后再开 TNG使新 Hook 生效;领取台状态栏应显示「运行中:最新」
3. 领取人重名时看详情里的 `ID``receiverId`),不要只看昵称
4. 调试台进程在跑USB 时执行过 `adb reverse`
5. notiMessage 监听列表勾选了 TNGHook 转发依赖主 App 接收广播)
6. 电脑 USB 共享 IP 变化后更新 `AppConfig.DEBUG_SERVER_URLS` 并重装主 App
## 相关文件
| 文件 | 作用 |
|------|------|
| `xposed-module/.../hook/TngMoneyPacketHook.java` | Quake RPC Hook、自动拉详情、轮询领取台设置 |
| `app/.../mmp/MmpHookStatus.java` | 检测 TNG 内 Hook 是否最新模块 |
| `app/.../mmp/MmpClaim.java` | 领取人含 `userId`receiverId |
| `debug-server/server.py` | `/mmp` 领取台、`/api/mmp``/api/mmp/settings` |
| `debug-server/mmp_settings.json` | 领取台刷新设置(自动生成) |
| `app/.../AppConfig.java` | `DEBUG_SERVER_URLS` 双地址 |
| `app/.../network/DebugForwarder.java` | 向多个调试地址推送 |
| `scripts/start-debug-server.ps1` | 启动调试台 + adb reverse |

64
docs/TNG_captcha逆向.md Normal file
View File

@@ -0,0 +1,64 @@
# TNG eWallet 滑动验证码(阿里云 Captcha逆向笔记2026-08-03
## 现象
注册/登录手机号页点「继续」→ 弹阿里云滑动拼图验证 → 滑块拖对通过 →
**不返回**TTCaptcha 未回调 TNG 业务层)→ 手动点「继续」→ 再次弹验证 → 循环。
## 回调链1.9.10 dex 逆向)
```
用户滑动成功
→ JS postMessage → CaptchaWebViewDialog$2.a (action=sendAliyunCaptchaVerifyData, data 含 success/message)
→ Captcha.generateResult(char, zzbfs) → JSON {code, retCode, message, certifyId}
→ setVerifyResult(true) → Captcha$1(Handler) → VerificationCallback.onSuccess(result)
→ TTCaptcha 反射 Proxy → TTCaptchaCallback.callBack(result)
→ parseJson → TTCaptchaResponse{code, certifyId}
code==0 且 certifyId 非空 ?
→ notifySuccess(certifyId) → TTInListener.success → TNG 业务层提交 RPC
→ notifyFailure(code) / handleFailure("Result is null"|"Invalid code or certifyId")
→ 服务端二次校验 certifyId 失败
→ quake 抛 CaptchaNeededException("Captcha needed")
/ CaptchaNotPassedException("Captcha not passed")
→ 验证拦截器再弹滑块 → 循环
```
## 关键类dex 定位)
| 类 | 作用 |
|----|------|
| `com.aliyun.captcha.Captcha`classes10 | 单例verify/generateResult/showDialog |
| `com.aliyun.captcha.CaptchaWebViewDialog` + `$2` | 滑块 WebView + JS postMessage 桥 |
| `com.aliyun.TigerTally.captcha.api.TTCaptcha` | TigerTally 封装,**反射**调 aliyun Captcha |
| `com.aliyun.TigerTally.captcha.core.TTCaptchaCallback` | 解析 resultcode==0 且 certifyId 非空才 success |
| `my.com.tngdigital.captcha.TigerTallyApiWrapper` | TNG 业务侧封装Kotlin 协程 showCaptcha |
| `...aliservice.quake.CaptchaNeededException` / `CaptchaNotPassedException` | 服务端要验证 / 验证未通过 |
| `...amcs.CaptchaConfigCenter` / `CaptchaInitializer` | 远程下发 wafCaptchaKey / captcha_switch |
| `...opmpaasexpress.interceptor.OpMpVerifyInterceptor` 等 | RPC 验证拦截器,触发滑块 |
## 判定点(本次 hook 已打点)
1. `Captcha.generateResult` 返回的 JSON —— **certifyId 是否为空**(滑块是否真正拿到服务端签发)
2. `CaptchaWebViewDialog$2.a` —— JS postMessage 的 data 内容
3. `TTCaptchaCallback.callBack/notifySuccess/notifyFailure` —— TNG 是否拿到 certifyId
4. `CaptchaNeeded/NotPassedException` 构造 message —— 服务端二次校验失败原因
5. `TTCaptcha.verifyByReflect/buildParams` —— captcha 参数region/appKey 等)
## 根因候选
- **TigerTally 设备指纹umidToken异常**`hookTigerTally` 短路了
`TigerTallyAPI.init/initCommon``t.B.genericNt1`(防 ANR fork 卡死)。
若 captcha 服务端用 umidToken 校验设备,缺失/变化会导致 certifyId 校验失败。
- **captcha 全量方法打点拖慢 JS 桥回调**(已修复:改为精准打点)。
- **region 错误**`TTCaptcha.buildParams``t.B.genericNt14()` 取 region。
## 抓 logcat 判定
```powershell
powershell -File scripts/logcat-tng.ps1
```
复现滑块 → 观察:
- `captcha JS postMessage` 后是否有 `captcha RET generateResult`(含 certifyId
- `captchaCb CALL notifySuccess` 是否出现(成功)还是 `notifyFailure`
- `captcha EXC ...CaptchaNotPassedException` 的 msg

View File

@@ -0,0 +1,36 @@
# TNG 开放问题与抓包能力2026-08-04
## 待办:冷启动假掉登录(先不改)
**现象:** 登录成功后退出再进,界面像未登录,需重新登录。
**根因判断(已基本确认):**
旧逻辑:`SplashActivity.onCreate` **无条件** 2s 强拉 `UserLoginActivity`
**已修复2026-08-04** 改为 4s **卡住救援**——已自行跳到 PIN/首页则取消;仅仍停在 Splash 时救援(有本地会话优先 `UserPinActivity`,否则 `UserLoginActivity`)。
---
## Money Packet 领取统计
**专用说明(操作 / 标识 / 自动拉详情 / 排障):** 见 [TNG_MoneyPacket领取台.md](TNG_MoneyPacket领取台.md)。
### 能力摘要
| 路线 | 状态 | 说明 |
|------|------|------|
| 外部 mitm | **不可行** | API 证书 pinning解不开正文 |
| Xposed `TngMoneyPacketHook` | **已实证** | Hook Quake `moneyPacketDetail` / 历史列表触发自动拉详情;标识为 `activityId` |
| PC 领取台 | **已上线** | http://127.0.0.1:8765/mmp ,按单个红包展示排行 |
### 要点
1. 历史列表只有摘要;领取排行来自详情 RPC`activityPoolInfos`)。
2. **sessionId**`ILoginStorage` 自动读取,一般不必再手动点详情。
3. **自动拉历史**:前台 Activity resume约 45s 冷却)主动调 `moneyPacketHistoryList`,再级联拉详情。
4. 去重:同一 `activityId` + 相同领取名单约 20s 内不重复推。
### eKYC「验证您的帐户」强制页2026-08-04
`HomeEkycVerifyActivity` 挡首页。测试期:`hookHomeEkycVerifySkip` — finish 该页 + 拦 Intent + `canBypassEkyc`/`enforceEkyc` stub。
**注意:** 服务端仍可能在部分功能(转账/红包)二次校验 eKYC首页跳过不等于全功能可用。

View File

@@ -0,0 +1,85 @@
# TNG eWallet 逆向笔记 — 自杀链2026-07-31
## 运行时铁证
| 现象 | 含义 |
|------|------|
| `Fatal signal 6 (SIGABRT), code 0 (SI_USER)` | 用户态 `kill(pid, SIGABRT)`**不是** `exit_group`,也不是 `abort()``SI_TKILL` |
| PLT hook `kill/raise` **从不打日志** | Promon 走 **内联 SVC**,不经 PLT |
| `E Report : Exiting:` + `xwwqazamx.W: 16` + `bl.a` | Java 层检测入口仍是 `xwwqazamx.bl#a/#b` |
| `Displayed ...UserLoginActivity` | UI 能到登录页;随后数秒内被杀 |
| `Lcom/aliyun/TigerTally/t/B` 反射 `Thread.dispatchUncaughtException` | 另有一套阿里 TigerTally SDK |
## 自杀阶梯(当前理解)
```
Promon native 检测 (Thread-N 扫 /proc、selinux、wifi prop…)
→ xwwqazamx.bl#a / #b
→ 抛 W:16Report 打 Exiting
→ 同时/随后 inline kill(SIGABRT, SI_USER)
→ KillApplicationHandlerXposed 可拦)
→ 若 ABRT 被 ignore可能改 SIGKILL / BRK待证
```
并行:
- `AppSecurityManager.handle*Callback`Xposed 已拦)
- `openSecurityUrl` Root FAQ已拦
- `UnhandledEvent``finishAllActivityAndKillApp`(已 hook
- ForceExitCountdown已 hook
- TigerTally / SecurityGuard `JNICLibrary.doCommand`(当前 stub可能过度
## 静态 SO
`libtngdigital_ewallet.so`~7.7MB,打包态):
- `brk` 假阳性/加密指令很多imm 离散)
- 真实 `svc #0` 极少;运行时解密后才出现 exit 序列
- **禁止**在 `JNI_OnLoad` 前/中 patch SO会 Bad JNI / SIGILL
## 已验证无效/有害手段
- seccomp 拦 `exit_group` → Promon 改 UDF/SIGILL
- 改 libc exit SVC→RET → 同上
- 改 Promon SO 字节 → Bad JNI / SIGILL
- 高频 `sigaction` 重装 → 易被当成 hook 指纹
## 较有效手段
- Xposed拦 FAQ / KillApplicationHandler / handle*Callback / ForceExit / UnhandledEvent
- Xposed`bl#a/#b` **beforeHook short-circuit**(避免跑进 native 杀进程)
- ZygiskPLT 观察 + `SIGABRT` ignore对 SI_USER 理论有效Promon 可能复位 handler
## 新发现2026-07-31 续)
### `libtiger_tally.so`~4.6MB
- 与 Promon `libtngdigital_ewallet.so` **并行加载**
- 动态依赖:`abort@LIBC``signal@LIBC`**走 PLT**,可被 Zygisk PLT hook
- 内嵌少量 syscall stubopenat/read/faccessat…**无** kill/exit stub
- `BIND_NOW`
### 打包态 SO
- `libtngdigital_ewallet.so` / `libtiger_tally.so` 静态 **0**`movz x8,#129 + svc`
- 自杀用的 inline `kill` 仍可能在 **运行时解密** 后出现 → Zygisk **延迟 2s** 扫描 patch
### 日志结论2026-07-31 11:3711:47
| 现象 | 含义 |
|------|------|
| `exited cleanly (1)` | inline `exit_group(1)`PLT 拦不住) |
| kill SVC→RET 后 `signal 9` | SO 完整性/备用路径 → **SIGKILL** |
| 过早 patch exit_group | 启动即崩CEM |
| seccomp 拦 `exit`+`exit_group` | ~1s 死(误伤线程 exit |
| seccomp **只拦 exit_group** | 存活 **~16s**,随后 **SIGILL 风暴**count 7000+ |
| `caught sig=4 si_code=1` | Promon UDF 兜底;+4 skip 无效 |
### Rest 配置2026-07-31 12:55已装机
| 层 | 策略 |
|----|------|
| Zygisk | PLT 拦 exit/abort/kill**exit_group-only seccomp @2s****禁止**改 SO会 SIGKILL |
| 信号 | ABRT ignoreSEGV/ILL **+4 skip****不** freeze 线程freeze 会 ANR |
| Xposed | Splash `reportFullyDrawn` + **2.5s 强拉 UserLogin** + finish Splashbl SC / 自杀 Java / JNIC stub |
实测:进程 **≥40s** 存活,焦点 `UserLoginActivity``phase done seccomp=1 caught=0`
注意:点亮屏幕后再开 App`svc power stayon true`);勿把 TNG 放进 Magisk DenyList。

View File

@@ -0,0 +1,295 @@
# Telegram 抓消息说明
> **用途**:在 notiMessage 中抓取 Telegram 消息(前台 + 后台),转发到 App 本地日志与 PC 调试台。
> **关联**[`Hook指南.md`](Hook指南.md)通用架构、Xposed/LSPosed· [`手机操作手册.md`](手机操作手册.md) · [`更新说明.md`](更新说明.md)
---
## 1. 能力概览
| 场景 | 通道 | 是否需要 Root / LSPosed | 日志标识 |
|------|------|-------------------------|----------|
| TG **后台** / 锁屏,系统弹出通知 | 通知监听 `NotificationService` | ❌ 不需要(仅需通知使用权) | 无前缀 |
| TG **前台** 打开聊天(通常无系统通知) | Xposed `TelegramMessageHook` | ✅ 需要 Magisk + LSPosed + 模块 | `[Hook/xposed_telegram]` |
**重要**:只开通知监听、不装 Xposed 时,**TG 在前台收消息抓不到**。要完整覆盖,必须启用 Hook 模块。
---
## 2. 必须安装什么
### 2.1 完整能力(前台 + 后台)
| 序号 | 组件 | 说明 |
|------|------|------|
| 1 | **Telegram** | 包名见 §3 |
| 2 | **notiMessage 主 App** | `com.miraclegarden.smsmessage` |
| 3 | **Xposed 模块 APK** | `com.miraclegarden.smsmessage.xposed`(与主 App 同仓库 `:xposed-module` |
| 4 | **Magisk + Zygisk** | Root 环境 |
| 5 | **LSPosed** | 加载 Xposed 模块 |
抓 TG **不需要** Shamiko那是 MariBank 等银行 App 用的)。
### 2.2 仅后台通知(不抓前台)
| 必须 | 不需要 |
|------|--------|
| notiMessage 主 App | Xposed 模块 |
| Telegram | LSPosed / Root |
| 通知监听权限 | |
### 2.3 PC 端(调试台,可选)
| 组件 | 用途 |
|------|------|
| 本仓库源码 + JDK + Android SDK | 编译安装 |
| **adb** | USB 安装、`adb reverse` |
| **Python 3** | `configure-lsposed.py``install-full.ps1` 用) |
| `debug-server/server.py` | PC 浏览器查看消息(端口 **8765** |
---
## 3. 包名与 LSPosed 作用域
Pixel 6 等设备常见包名:
| 安装来源 | 包名 |
|----------|------|
| Play / 官网 APK | `org.telegram.messenger` |
| 部分渠道 / Web 版 | `org.telegram.messenger.web` |
**必须勾选与实际安装一致的包名**(两个都装则两个都勾)。
LSPosed 作用域需勾选:
1. **目标 Telegram 包名**(上表)
2. **notiMessage 主 App** `com.miraclegarden.smsmessage`(接收 Hook 广播、写日志)
3. 模块本身已在 LSPosed 里 **启用**
默认作用域见 `xposed-module/src/main/res/values/arrays.xml`
一键安装时 `scripts/configure-lsposed.py` 会写入上述包名。
**每次更新 Xposed 模块 APK 后**LSPosed 里对 Telegram **软重启**(或整机重启),否则 Hook 不注入。
---
## 4. 安装与配置(推荐流程)
### 4.1 PC 一键安装
```powershell
cd C:\Users\Administrator\Desktop\notiMessage
.\scripts\install-full.ps1
```
自动完成:编译 → 安装双 APK → 写入 LSPosed 作用域 → `adb reverse` → 电池白名单 → 通知监听授权。
仅编译安装、不写作用域:
```powershell
.\scripts\build-debug.ps1
.\scripts\install-debug.ps1
```
> **注意**Android Studio 直接 Run 只装主 App**不会**装 Xposed 模块。完整 TG 抓取必须用 `install-full.ps1` 或手动装两个 APK。
### 4.2 手机上
1. **LSPosed** → 启用模块 **notiMessage Xposed** → 作用域勾选 Telegram + 主 App → **软重启 Telegram**
2. 打开 **notiMessage****监听设置** → 添加 **Telegram**(包名须与 LSPosed 一致)
3. 回到 **监听控制台****开始监听** → 授予通知使用权
4. (可选)设置 → 给 Telegram / notiMessage **电池不受限制**
### 4.3 PC 调试台
```powershell
.\scripts\start-debug-server.ps1
```
浏览器打开 **http://127.0.0.1:8765**,按群/会话分组展示。
脚本会自动执行 `adb reverse tcp:8765 tcp:8765`
### 4.4 验证
1. 启动调试台(可选)
2. notiMessage 开始监听后 **切到桌面**
3. 打开 Telegram在任意群/私聊 **收一条别人发的消息**(前台场景)
4. 期望:
- App 监听页出现 `[Hook/xposed_telegram] 群名 发送者: 内容`
- PC 调试台同内容
5. 再把 TG 切后台,收一条 **会弹系统通知** 的消息 → 走通知通道(无前缀)
---
## 5. 双通道架构
```
Telegram
┌──────────────┴──────────────┐
│ │
后台弹系统通知 前台收到新消息
│ │
▼ ▼
NotificationService TelegramMessageHook
(NotificationListener) (xposed-module 进程内)
│ │
│ │ 广播 HOOK_MESSAGE
│ ▼
│ HookMessageReceiver
│ ├─ MessageLogStore
│ ├─ DebugForwarder → PC :8765
│ └─ (可选)正式后端上传
└──────────────┬──────────────┘
监听页 / PC 调试台
```
| 配置项 | 文件 | 默认值 |
|--------|------|--------|
| PC 转发开关 | `AppConfig.ENABLE_DEBUG_FORWARD` | `true` |
| 调试台地址 | `AppConfig.DEBUG_SERVER_URL` | `http://127.0.0.1:8765` |
| 正式后端上传 | `AppConfig.ENABLE_SERVER_UPLOAD` | `false` |
Hook 广播格式:
```text
Action: com.miraclegarden.smsmessage.action.HOOK_MESSAGE
Package: com.miraclegarden.smsmessage
Extras: packageName, title, content, timestamp, source=xposed_telegram
```
`HookMessageReceiver` 会检查 Telegram 是否在 **监听列表**;未添加则日志 `[Hook] 未配置监听: org.telegram...` 并丢弃。
---
## 6. 实现原理TelegramMessageHook
源码:`xposed-module/src/main/java/.../hook/TelegramMessageHook.java`
### 6.1 为何不 Hook SQLite
Telegram 消息存在 SQLite 的 **`data` 字段TL 二进制序列化)**,不是明文 `content`/`text`
通用 `SqliteMessageHook` **对 Telegram 无效**,因此单独实现本 Hook。
### 6.2 Hook 路径(三条,互为补充)
| 优先级 | Hook 点 | 触发时机 |
|--------|---------|----------|
| **主路径** | `NotificationCenter.postNotificationName(int, Object[])`,当 `id == didReceiveNewMessages` | 前台收到新消息、消息已解密为 `MessageObject` |
| **通知路径** | `NotificationsController.appendMessage(MessageObject)` | 后台准备弹系统通知时 |
| **兜底** | `MessageObject` 全部构造函数 | 主路径安装失败时 |
### 6.3 过滤与去重
- 只处理 **非 outgoing**`messageOwner.out == false`),忽略自己发出的消息
- 去重键:`dialogId:messageId`,内存保留最近 512 条
### 6.4 字段提取
| 字段 | 提取方式 | 展示 |
|------|----------|------|
| **群名/会话名** | `MessagesController.getPeerTitle(dialogId)``getName()``getChat().title` | 广播 `title` |
| **发送者** | `MessageObject.getFromName()` | 群内:`发送者: 正文` |
| **正文** | `messageText``messageOwner.message``caption` | 广播 `content` |
| **媒体** | `isPhoto` / `isVideo` 等 → `[图片]``[视频]`… | 与 caption 合并 |
支持的媒体标签:`[图片]``[视频]``[GIF]``[贴纸]``[语音]``[音频]``[文件]``[位置]``[联系人]``[媒体消息]`
**注意**:不要把 `TLRPC$...` 对象直接 `toString()` 当标题;代码里 `safeText()` 会过滤这类字符串。
---
## 7. 常见问题
| 现象 | 原因 | 处理 |
|------|------|------|
| 前台有消息,无 `[Hook/...]` | Hook 未注入 | LSPosed 启用模块;作用域勾 **实际 TG 包名****软重启 TG** |
| 日志 `[Hook] 未配置监听` | 监听列表未加 Telegram | 监听设置里添加对应包名 |
| 只有后台有、前台没有 | 未装 Xposed / 作用域错误 | 安装 xposed 模块 + `install-full.ps1` |
| PC 无数据 | `adb reverse` 未设或调试台未开 | 跑 `start-debug-server.ps1` |
| notiMessage 切后台后 PC 断 | 旧版依赖 startService | 更新到 v2.2.1+`HookMessageReceiver` 内直接转发 |
| 标题显示 `TLRPC$...` | 旧版或异常路径 | 更新模块;见 `TelegramMessageHook.extractTitle` |
| 图片只有「图片」无说明 | 说明在 caption | 已合并 caption仍空则 TG 未带说明 |
| 群静音 / 无通知 | 后台通道无触发 | 前台靠 Hook后台需 TG 允许通知且未静音 |
| TG 被系统冻结 | 省电策略 | TG、notiMessage 设「不受限制」 |
| logcat 无 `notiMessageHook/Telegram` | Hook 未加载 | 查 LSPosed 日志;确认包名是否为 `.web` 版 |
---
## 8. 诊断 logcat
```powershell
$adb = "$env:LOCALAPPDATA\Android\Sdk\platform-tools\adb.exe"
# Hook 是否安装
& $adb logcat -d | Select-String "notiMessageHook/Telegram"
# 主 App 是否收到广播
& $adb logcat -d | Select-String "HookMessageReceiver|DebugForwarder"
# 通知通道
& $adb logcat -d | Select-String "NotificationService"
```
期望看到:
```text
notiMessageHook/Telegram: installed for org.telegram.messenger.web, didReceiveNewMessages=...
notiMessageHook/Telegram: appendMessage hook installed for ...
HookMessageReceiver: hook received: pkg=org.telegram.messenger.web source=xposed_telegram title=...
```
---
## 9. 与 MariBank 模块的关系
本仓库 **同一个 Xposed APK** 里还包含 MariBank bypass、澳银 Hook 等;**Telegram 抓消息与银行 bypass 互不依赖**
| 能力 | 是否需要 Telegram Hook | 是否需要 MariBank Hook |
|------|------------------------|------------------------|
| 抓 TG 消息 | ✅ | ❌ |
| MariBank 注册 bypass | ❌ | ✅ |
LSPosed 作用域可以 **只勾 Telegram + 主 App**,不勾银行包名。
详见 [`Hook指南.md`](Hook指南.md) 中「模块与通知管理的关系」说明。
---
## 10. 相关文件
```text
xposed-module/
├── src/main/assets/xposed_init
├── src/main/java/.../MainHook.java # 路由到 TelegramMessageHook
├── src/main/java/.../HookForwarder.java
├── src/main/java/.../hook/TelegramMessageHook.java
└── src/main/res/values/arrays.xml # 默认 xposed_scope
app/
├── src/main/java/.../service/HookMessageReceiver.java
├── src/main/java/.../service/NotificationService.java
├── src/main/java/.../MessageLogStore.java
├── src/main/java/.../network/DebugForwarder.java
└── src/main/java/.../AppConfig.java
debug-server/server.py
scripts/install-full.ps1
scripts/start-debug-server.ps1
```
---
## 11. 最短路径速查
```text
PC: install-full.ps1 → start-debug-server.ps1
手机: LSPosed 启用模块 → 勾 Telegram + 主 App → 软重启 TG
App: 监听设置添加 Telegram → 开始监听
验证: TG 前台收消息 → 出现 [Hook/xposed_telegram]
```
---
*最后更新2026-07-06*

View File

@@ -198,7 +198,7 @@ reverse/
- **44 个** 脚本内路径改为相对 `reverse/` 根(`apks/``extracted/``tmp/``output/`)。
- Frida 日志输出改到 `reverse/logs/frida/`
- 更新 `.gitignore``docs/BANK_REVERSE.md``pull_split_apk.ps1``start-mari-trace.ps1`
- 更新 `.gitignore``docs/银行逆向.md``pull_split_apk.ps1``start-mari-trace.ps1`
- `apk_extract/` 迁入 `extracted/apk_extract/``seabank.zip` 迁入 `apks/`
---
@@ -223,10 +223,13 @@ reverse/
| 文档 / 路径 | 说明 |
| ---------------------------------- | ----------------------------------- |
| `docs/MARIBANK_IMPLEMENTATION.md` | **MariBank 实现细节与问题记录(专文)** |
| `docs/BANK_REVERSE.md` | 澳大利亚三家银行 HookUp / Suncorp / ubank |
| `docs/HOOK_GUIDE.md` | 通用 Hook 架构与扩展 |
| `docs/CHANGELOG.md` | 主 App / TG Hook 版本历史 |
| `docs/MariBank实现说明.md` | **MariBank 实现细节与问题记录(专文)** |
| `docs/MariBank_2026-07-06_菲律宾突破.md` | PH OTP 突破 + SG 现状 |
| `docs/MariBank风控与载荷说明.md` | register 字段、afExtInfo、USB 调试 |
| `docs/手机操作手册.md` | Root 机部署与日常操作 |
| `docs/银行逆向.md` | 澳大利亚三家银行 HookUp / Suncorp / ubank |
| `docs/Hook指南.md` | 通用 Hook 架构、Xposed/LSPosed、扩展 |
| `docs/更新说明.md` | 主 App / TG Hook 版本历史 |
| `reverse/README.md` | 逆向工作区目录说明 |
| `reverse/frida/jni_targets.md` | JNI Hook 目标备忘 |
| `xposed-module/.../MariBank*.java` | MariBank bypass 实现 |

160
docs/手机操作手册.md Normal file
View File

@@ -0,0 +1,160 @@
# 手机操作手册
> 在 Root 手机上部署 **notiMessage** 主 App + **Xposed 模块**,以及 **MariBank / SeaBank** bypass 的日常步骤。
> 关联:[`Hook指南.md`](Hook指南.md) · [`MariBank_2026-07-06_菲律宾突破.md`](MariBank_2026-07-06_菲律宾突破.md) · [`MariBank风控与载荷说明.md`](MariBank风控与载荷说明.md)
---
## 1. 手机前置条件(一次性)
| 步骤 | 操作 |
|------|------|
| 1 | 刷 **Magisk**Magisk 设置里开启 **Zygisk** |
| 2 | 安装 **LSPosed**Magisk 模块或 Manager APK |
| 3 | 安装 **Shamiko**(银行 App **强烈建议** |
| 4 | Magisk → **配置排除列表** → 开启 |
| 5 | 排除列表勾选目标银行 **全部子进程**PH / SG 包名见下) |
| 6 | **不要** 开启「强制启用排除列表」Shamiko 要求 Enforce = OFF |
| 7 | (可选)安装 Magisk 模块 `maribank-device-spoof` 持久换 serial / android_id |
### 目标包名
| App | 包名 |
|-----|------|
| 菲律宾 SeaBank | `ph.seabank.seabank` |
| 新加坡 MariBank | `sg.com.maribankmobile.digitalbank` |
| notiMessage 主 App | `com.miraclegarden.smsmessage` |
| Xposed 模块 | `com.miraclegarden.smsmessage.xposed` |
| Telegram抓消息 | `org.telegram.messenger``org.telegram.messenger.web` |
---
## 2. PC 安装 / 更新USB 连手机)
### 2.1 一键全套(推荐)
```powershell
cd C:\Users\Administrator\Desktop\notiMessage
.\scripts\install-full.ps1
```
自动完成:编译 → 安装双 APK → 配置 LSPosed 作用域 → 电池白名单 → 通知监听授权等。
### 2.2 仅编译安装 APK
```powershell
.\scripts\build-debug.ps1
.\scripts\install-debug.ps1
```
### 2.3 MariBank 测前收尾
```powershell
.\scripts\maribank-scheme-b-finish.ps1
```
作用:确认 Magisk 模块、写入 LSPosed 作用域、关 USB 调试(可选)、`pm clear` 银行 App。
换设备 ID
```powershell
.\scripts\maribank-spoof-device.ps1 -InstallModule # 持久模块
.\scripts\maribank-spoof-device.ps1 -ClearMariBank # 一次性 resetprop + 清数据
```
---
## 3. 手机上 LSPosed 必做步骤
**每次新装或更新 Xposed 模块 APK 后:**
1. 打开 **LSPosed**
2. 找到模块 **notiMessage Xposed****启用**
3. 进入 **作用域**,勾选需要的 App见 §1 包名表)
4.**每个已勾选 App** 执行 **软重启**(或整机重启)
未软重启时 Hook **不会** 注入目标 App。
---
## 4. 日常使用
### 4.1 菲律宾 SeaBank 注册(当前可 OTP
1. 确认 LSPosed 已启用且作用域含 `ph.seabank.seabank`
2. (建议)`pm clear ph.seabank.seabank` 或跑 `maribank-scheme-b-finish.ps1`
3. 打开 App → **Sign up** → 输入菲律宾手机号 → **Next**
4. 等待 OTP 短信
> **说明**:实测 **USB 调试可保持开启** 仍能 OTP因 Xposed 对 App 伪装 adb 状态(详见 [`MariBank风控与载荷说明.md` §4](MariBank风控与载荷说明.md))。测 SG 或求稳时仍建议关调试。
### 4.2 新加坡 MariBank
流程同上,包名为 `sg.com.maribankmobile.digitalbank`。详见 **[`MariBank新加坡突破.md`](MariBank新加坡突破.md)**3100012 排查与 `maribank-sg-register.ps1`)。
### 4.3 notiMessage 抓 Telegram
详见 **[`Telegram抓消息说明.md`](Telegram抓消息说明.md)**(安装、作用域、双通道、排错)。
简要步骤:
1. 打开 **notiMessage****监听设置** 添加 Telegram → **开始监听** → 授予通知使用权
2. LSPosed 作用域勾选 Telegram + 主 App → **软重启 Telegram**
3. PC 调试台(可选):`.\scripts\start-debug-server.ps1`
---
## 5. 抓 logPC
```powershell
$adb = "C:\Users\Administrator\AppData\Local\Android\Sdk\platform-tools\adb.exe"
& $adb logcat -c
# 手机操作Sign up → Next
& $adb logcat -d | Select-String "MariBankEncrypt in0 byte\[1|MariBankRoot HTTP.*register|3100012|4067012|OTP_SMS"
```
验证伪装 ID
```powershell
& $adb shell su -c "getprop ro.serialno; settings get secure android_id"
```
---
## 6. 换机 / 新机迁移
1. 新机 Root + Magisk + Zygisk + LSPosed + Shamiko
2. PC 执行 `install-full.ps1`
3. LSPosed 启用模块 + 作用域 + 软重启
4. 安装 `maribank-device-spoof` → 重启 → `maribank-scheme-b-finish.ps1`
5. DenyList 重新勾选银行 App
---
## 7. 常见问题
| 现象 | 处理 |
|------|------|
| 银行 App 白屏 | LSPosed 软重启;勿改 Hook 加载时机(见实现说明) |
| Root / ADB 弹窗 | 确认模块启用 + 作用域已勾银行 App |
| adb 连不上 | 开发者选项重新开 **USB 调试**,解锁点允许 |
| 菲律宾又 4067012 | `pm clear ph.seabank.seabank`,必要时重跑 spoof |
| Hook 不生效 | LSPosed **软重启**目标 App不是只杀进程 |
| 收尾脚本卡住 | adb 断开;先开 USB 调试连 PC跑完脚本再关 |
---
## 8. 最短路径速查
```
PC: install-full.ps1
手机: LSPosed 启用 → 勾选 App → 软重启
银行: maribank-scheme-b-finish.ps1 → Sign up → Next
抓包: logcat -c → Next → logcat -d | Select-String register
```
---
*最后更新2026-07-06*

View File

@@ -1,5 +1,31 @@
# 更新说明
## TNG eWallet 登录/注册区号2026-08-03
- **包名** `my.com.tngdigital.ewallet` v1.9.10Android 16Pixel 6Root + LSPosed
- **问题**:登录/注册点区号时 `i7.l` loading 弹窗触发 HWUI gralloc ABRTsignal 6 黑屏)
- **修复**`TngRootBypassHook`
- Login / Register 统一 **skip `i7.l` Dialog.show**`Dialog.isShowing()` 返回 true 防卡死
- `UserSearchCallingCodeActivity` 正常打开,国家列表可见
- **自动化**`reverse/scripts/test_tng_full_flow.py`(注册区号 + 登录 PIN 区号,两次冷启动)
- **一键**`powershell -File scripts/test-tng-full-flow.ps1`
---
## MariBank 风控 bypass2026-07-06
- **菲律宾 SeaBank**`ph.seabank.seabank`Root Pixel 6 上 **注册成功、可发 OTP**`api.seabank.ph``code=0`
- **新加坡 MariBank**`sg.com.maribankmobile.digitalbank`):仍 **3100012**
- 新增 / 更新文档:
- [`docs/MariBank_2026-07-06_菲律宾突破.md`](MariBank_2026-07-06_菲律宾突破.md) — 突破记录与 PH/SG 对比
- [`docs/MariBank风控与载荷说明.md`](MariBank风控与载荷说明.md) — register 字段、afExtInfo、USB 调试、真实原因
- [`docs/手机操作手册.md`](手机操作手册.md) — Root 机部署与日常操作
- [`docs/Hook指南.md`](Hook指南.md) — 新增 §0 Xposed 与 LSPosed
- 脚本:`scripts/maribank-spoof-device.ps1``scripts/maribank-scheme-b-finish.ps1``scripts/magisk/maribank-device-spoof/`
- 文档文件名已全部改为中文(见 `docs/` 目录)
---
## v2.2.12026-07-02
### 修复Hook 通道在 notiMessage 后台时丢失消息
@@ -75,7 +101,8 @@ adb logcat | findstr /i "HookMessageReceiver DebugForwarder NotificationService
### 文档
- 架构与扩展:`docs/HOOK_GUIDE.md`
- Telegram 专文:`docs/Telegram抓消息说明.md`
- 架构与扩展:`docs/Hook指南.md`
- Agent 说明:`AGENTS.md`
---

View File

@@ -0,0 +1,11 @@
LOCAL_PATH := $(call my-dir)
include $(CLEAR_VARS)
LOCAL_MODULE := tng_exit_guard
LOCAL_SRC_FILES := main.cpp
LOCAL_C_INCLUDES := $(LOCAL_PATH)
LOCAL_LDLIBS := -llog -ldl
LOCAL_CFLAGS := -Wall -Wextra -fno-rtti -fvisibility=hidden
LOCAL_CPPFLAGS := -std=c++17
LOCAL_LDFLAGS := -Wl,--exclude-libs,ALL
include $(BUILD_SHARED_LIBRARY)

View File

@@ -0,0 +1,4 @@
APP_ABI := arm64-v8a
APP_PLATFORM := android-24
APP_STL := c++_static
APP_CPPFLAGS := -std=c++17

View File

@@ -0,0 +1,916 @@
/*
* TNG eWallet — Zygisk companion.
*
* stable: PLT + ABRT/SIGTRAP swallow + exit_group seccomp@400ms.
* Promon worker SIGSEGV (libtngdigital_ewallet.so null deref): LR-return skip (cap N).
* pc==lr 循环 SEGV 也 skiplibc++abi __cxa_guard_acquire → SIGABRT 吞掉。
*/
#include <android/log.h>
#include <dlfcn.h>
#include <errno.h>
#include <linux/audit.h>
#include <linux/filter.h>
#include <linux/seccomp.h>
#include <pthread.h>
#include <signal.h>
#include <stddef.h>
#include <sys/mman.h>
#include <sys/prctl.h>
#include <sys/syscall.h>
#include <sys/sysmacros.h>
#include <ucontext.h>
#include <unistd.h>
#include <atomic>
#include <cstdint>
#include <cstdio>
#include <cstring>
#include "zygisk.hpp"
#define SC_RET_ALLOW 0x7fff0000U
#define SC_RET_ERRNO_EPERM (0x00050000U | 1U)
#define LOG_TAG "TngExitGuard"
#define LOGI(...) __android_log_print(ANDROID_LOG_INFO, LOG_TAG, __VA_ARGS__)
#define LOGE(...) __android_log_print(ANDROID_LOG_ERROR, LOG_TAG, __VA_ARGS__)
static constexpr const char *kTargetPkg = "my.com.tngdigital.ewallet";
static constexpr const char *kPromonSo = "libtngdigital_ewallet.so";
static bool g_enabled = false;
static zygisk::Api *g_api = nullptr;
static std::atomic<int> g_cxx_plt{0};
static std::atomic<int> g_seccomp_ok{0};
static std::atomic<int> g_stack_chk{0};
static std::atomic<int> g_promon_segv{0};
static std::atomic<uintptr_t> g_promon_start{0};
static std::atomic<uintptr_t> g_promon_end{0};
static std::atomic<pid_t> g_main_tid{0};
static std::atomic<int> g_abrt_swallow{0};
static std::atomic<pid_t> g_abrt_last_tid{0};
static std::atomic<uintptr_t> g_abrt_last_pc{0};
static std::atomic<int> g_abrt_streak{0};
/* 隔离进程 :goacqowmmt 会循环 SEGV主进程 worker 也狂刷。cap 后 freeze 该线程 */
static constexpr int kMaxPromonSegvSkip = 200;
static constexpr int kMaxAbrtStreak = 3;
static std::atomic<int> g_soft_sig_logged{0};
static void freeze_forever() {
for (;;) pause();
}
static void refresh_promon_so_range() {
FILE *fp = fopen("/proc/self/maps", "r");
if (!fp) return;
char line[1024];
uintptr_t start = 0, end = 0;
while (fgets(line, sizeof(line), fp)) {
unsigned long s = 0, e = 0;
char path[512] = {};
int n = sscanf(line, "%lx-%lx %*s %*s %*s %*s %511[^\n]", &s, &e, path);
if (n < 3) continue;
char *p = path;
while (*p == ' ') ++p;
if (strstr(p, kPromonSo) == nullptr) continue;
if (start == 0 || s < start) start = s;
if (e > end) end = e;
}
fclose(fp);
if (start != 0 && end > start) {
g_promon_start.store(start);
g_promon_end.store(end);
}
}
static bool pc_in_promon_so(uintptr_t pc) {
uintptr_t start = g_promon_start.load();
uintptr_t end = g_promon_end.load();
return start != 0 && pc >= start && pc < end;
}
static void promon_segv_handler(int sig, siginfo_t *info, void *ctx) {
(void)sig;
(void)info;
ucontext_t *uc = reinterpret_cast<ucontext_t *>(ctx);
#if defined(__aarch64__)
uintptr_t pc = uc->uc_mcontext.pc;
uintptr_t lr = uc->uc_mcontext.regs[30];
#else
uintptr_t pc = 0;
uintptr_t lr = 0;
#endif
refresh_promon_so_range();
/* Promon 典型 pc==lr 自旋 null derefmaps 尚未刷新时也按此 skip */
if (pc != 0 && pc == lr) {
int n = ++g_promon_segv;
if (n <= 3 || n % 50 == 0) {
LOGI("promon pc==lr SIGSEGV tid=%d pc=%lx n=%d — skip to pc+4",
(int)gettid(), (unsigned long)pc, n);
}
uc->uc_mcontext.pc = pc + 4;
return;
}
if (pc_in_promon_so(pc) || pc_in_promon_so(lr)) {
int n = ++g_promon_segv;
uintptr_t target = lr;
if (target == 0 || target == pc) {
target = pc + 4;
}
if (kMaxPromonSegvSkip <= 0 || n <= kMaxPromonSegvSkip) {
if (n <= 3 || n % 50 == 0) {
LOGI("promon SIGSEGV tid=%d pc=%lx lr=%lx n=%d — skip to %lx",
(int)gettid(), (unsigned long)pc, (unsigned long)lr, n,
(unsigned long)target);
}
uc->uc_mcontext.pc = target;
return;
}
LOGI("promon SIGSEGV tid=%d n=%d — cap hit, freeze", (int)gettid(), n);
freeze_forever();
}
/* 1.9.10Login 后出现 Promon so 外 SEGV → 旧逻辑 re-raise 直接闪退 */
{
int n = ++g_promon_segv;
pid_t tid = gettid();
if (n <= 5 || n % 50 == 0) {
LOGI("non-promon SIGSEGV tid=%d pc=%lx lr=%lx n=%d — pc+4",
(int)tid, (unsigned long)pc, (unsigned long)lr, n);
}
if (n > 200 && tid != g_main_tid.load()) {
LOGI("non-promon SEGV storm tid=%d — freeze", (int)tid);
freeze_forever();
}
if (pc != 0) {
uc->uc_mcontext.pc = pc + 4;
return;
}
}
freeze_forever();
}
/**
* ABRT/TRAP一律 pc+4。跳远距 LR 会弄坏主线程 Looper闪退观感
* 工作线程同 PC 连 abort 超限 → freeze主线程始终 pc+4。
*/
static void fatal_skip_handler(int sig, siginfo_t *info, void *ctx) {
(void)info;
ucontext_t *uc = reinterpret_cast<ucontext_t *>(ctx);
#if defined(__aarch64__)
uintptr_t pc = uc->uc_mcontext.pc;
uintptr_t lr = uc->uc_mcontext.regs[30];
pid_t tid = gettid();
if (sig == SIGABRT) {
int streak = 1;
if (g_abrt_last_tid.load() == tid && g_abrt_last_pc.load() == pc) {
streak = g_abrt_streak.fetch_add(1) + 1;
} else {
g_abrt_last_tid.store(tid);
g_abrt_last_pc.store(pc);
g_abrt_streak.store(1);
}
if (streak > kMaxAbrtStreak && tid != g_main_tid.load()) {
LOGI("ABRT streak cap tid=%d pc=%lx n=%d — freeze worker", (int)tid,
(unsigned long)pc, streak);
freeze_forever();
}
int n = ++g_abrt_swallow;
/* 主线程abort 后 _exit 被 seccomp 拦 → abort 内部死循环(还复位 handler
* 主线程永久卡死 → 黑屏/ANR。主线程 ABRT 时跳回 lrLooper 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;
}
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;
}
if (sig == SIGTRAP) {
int n = ++g_abrt_swallow;
if (n <= 5 || n % 50 == 0) {
LOGI("TRAP pc+4 tid=%d pc=%lx", (int)tid, (unsigned long)pc);
}
uc->uc_mcontext.pc = pc != 0 ? pc + 4 : lr;
return;
}
#endif
freeze_forever();
}
static void install_fatal_skip_handlers() {
struct sigaction sa {};
sa.sa_sigaction = fatal_skip_handler;
sa.sa_flags = SA_SIGINFO | SA_ONSTACK;
sigemptyset(&sa.sa_mask);
sigaction(SIGABRT, &sa, nullptr);
sigaction(SIGTRAP, &sa, nullptr);
LOGI("fatal skip handlers (ABRT/TRAP always pc+4)");
}
static void install_promon_segv_handler() {
refresh_promon_so_range();
struct sigaction sa {};
sa.sa_sigaction = promon_segv_handler;
sa.sa_flags = SA_SIGINFO | SA_ONSTACK;
sigemptyset(&sa.sa_mask);
if (sigaction(SIGSEGV, &sa, nullptr) != 0) {
LOGE("SIGSEGV handler install failed errno=%d", errno);
return;
}
LOGI("promon SIGSEGV handler armed range=%lx-%lx",
(unsigned long)g_promon_start.load(),
(unsigned long)g_promon_end.load());
}
static void install_soft_signals() {
struct sigaction sa {};
sa.sa_sigaction = fatal_skip_handler;
sa.sa_flags = SA_SIGINFO | SA_ONSTACK;
sigemptyset(&sa.sa_mask);
sigaction(SIGABRT, &sa, nullptr);
sigaction(SIGTRAP, &sa, nullptr);
if (g_soft_sig_logged.fetch_add(1) == 0) {
LOGI("soft signals (ABRT/TRAP always pc+4)");
}
}
#if defined(__aarch64__)
#define _BPFI(code, jt, jf, k) \
((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=129Promon 自杀是 kill(pid,SIGABRT) → si_code=SI_USER。
* 不拦 tgkill/tkillART 的 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), // 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, __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])),
.filter = filter,
};
prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0);
long rc = syscall(__NR_seccomp, SECCOMP_SET_MODE_FILTER,
SECCOMP_FILTER_FLAG_TSYNC, &prog);
if (rc != 0) {
rc = prctl(PR_SET_SECCOMP, SECCOMP_MODE_FILTER, &prog);
if (rc != 0) {
LOGE("seccomp failed errno=%d", errno);
return -1;
}
LOGI("seccomp exit_group+kill-ABRT via prctl");
} else {
LOGI("seccomp exit_group+kill-ABRT via TSYNC");
}
g_seccomp_ok.store(1);
return 0;
}
#else
static int install_seccomp_exit_group_only() { return -1; }
#endif
using exit_fn = void (*)(int);
using kill_fn = int (*)(pid_t, int);
using tgkill_fn = int (*)(int, int, int);
using raise_fn = int (*)(int);
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<void *>(static_cast<uintptr_t>(-1))
#endif
static exit_fn orig_exit = nullptr;
static exit_fn orig__exit = nullptr;
static void (*orig_abort)() = nullptr;
static void (*orig_stack_chk_fail)() = nullptr;
static kill_fn orig_kill = nullptr;
static tgkill_fn orig_tgkill = nullptr;
static raise_fn orig_raise = nullptr;
static pthread_kill_fn orig_pthread_kill = nullptr;
static bool deadly(int sig) {
return sig == SIGKILL || sig == SIGABRT || sig == SIGTERM ||
sig == SIGTRAP || sig == SIGILL ||
sig == 9 || sig == 6 || sig == 5 || sig == 4 || sig == 15;
}
static void hooked_exit(int code) { LOGI("blocked exit(%d)", code); }
static void hooked__exit(int code) { LOGI("blocked _exit(%d)", code); }
static void hooked_abort() {
LOGI("blocked abort() tid=%d", (int)gettid());
}
static void hooked_stack_chk_fail() {
int n = ++g_stack_chk;
LOGI("blocked __stack_chk_fail tid=%d n=%d", (int)gettid(), n);
}
static int hooked_raise(int sig) {
if (deadly(sig)) {
LOGI("blocked raise(%d)", sig);
return 0;
}
return orig_raise ? orig_raise(sig) : -1;
}
static int hooked_kill(pid_t pid, int sig) {
if (deadly(sig)) {
LOGI("blocked kill(%d,%d)", (int)pid, sig);
return 0;
}
return orig_kill ? orig_kill(pid, sig) : -1;
}
static int hooked_tgkill(int tgid, int tid, int sig) {
if (deadly(sig)) {
LOGI("blocked tgkill(%d,%d,%d)", tgid, tid, sig);
return 0;
}
return orig_tgkill ? orig_tgkill(tgid, tid, sig) : -1;
}
static int hooked_pthread_kill(pthread_t thread, int sig) {
if (deadly(sig)) {
LOGI("blocked pthread_kill(sig=%d)", sig);
return 0;
}
return orig_pthread_kill ? orig_pthread_kill(thread, sig) : -1;
}
/** 仅打断递归初始化;系统 libc++ 静态 ctor含 gralloc必须真实执行。 */
static thread_local void *tl_cxa_guard = nullptr;
static thread_local int tl_cxa_depth = 0;
static int hooked_cxa_guard_acquire(void *guard) {
if (guard != nullptr && tl_cxa_guard == guard) {
LOGI("cxa_guard recursive skip tid=%d", (int)gettid());
return 1;
}
if (!orig_cxa_guard_acquire) {
return 1;
}
void *prev = tl_cxa_guard;
tl_cxa_guard = guard;
++tl_cxa_depth;
int r = orig_cxa_guard_acquire(guard);
--tl_cxa_depth;
tl_cxa_guard = prev;
return r;
}
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<uintptr_t>(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<dlopen_fn>(dlsym(RTLD_DEFAULT, "dlopen"));
}
if (!orig_sphal_load) {
orig_sphal_load = reinterpret_cast<sphal_load_fn>(
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 (name == nullptr) {
return handle;
}
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",
"libandroid.so",
};
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;
}
static bool find_lib_match(const char *suffix, const char *contains,
dev_t *dev, ino_t *ino) {
FILE *fp = fopen("/proc/self/maps", "r");
if (!fp) return false;
char line[1024];
bool ok = false;
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 n = sscanf(line, "%lx-%lx %7s %llx %31s %lu %511[^\n]",
&start, &end, perms, &offset, deststr, &inode, path);
if (n < 7 || inode == 0) continue;
char *p = path;
while (*p == ' ') ++p;
bool match = false;
if (suffix != nullptr) {
size_t plen = strlen(p);
size_t slen = strlen(suffix);
match = plen >= slen && strcmp(p + plen - slen, suffix) == 0;
} else if (contains != nullptr) {
match = strstr(p, contains) != nullptr;
}
if (!match) continue;
unsigned maj = 0, min = 0;
if (sscanf(deststr, "%x:%x", &maj, &min) != 2) continue;
*dev = makedev(maj, min);
*ino = inode;
ok = true;
break;
}
fclose(fp);
return ok;
}
static bool find_lib_by_suffix(const char *suffix, dev_t *dev, ino_t *ino) {
return find_lib_match(suffix, nullptr, dev, ino);
}
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 调用方 PLTlibhwui/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;
ino_t ino = 0;
if (find_libc(&dev, &ino)) {
register_plt(api, dev, ino, "exit", (void *)hooked_exit, (void **)&orig_exit);
register_plt(api, dev, ino, "_exit", (void *)hooked__exit, (void **)&orig__exit);
register_plt(api, dev, ino, "abort", (void *)hooked_abort, (void **)&orig_abort);
register_plt(api, dev, ino, "__stack_chk_fail",
(void *)hooked_stack_chk_fail, (void **)&orig_stack_chk_fail);
register_plt(api, dev, ino, "raise", (void *)hooked_raise, (void **)&orig_raise);
register_plt(api, dev, ino, "kill", (void *)hooked_kill, (void **)&orig_kill);
register_plt(api, dev, ino, "tgkill", (void *)hooked_tgkill, (void **)&orig_tgkill);
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 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() {
if (g_cxx_plt.load() || !g_api) return;
dev_t dev = 0;
ino_t ino = 0;
bool any = false;
// 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);
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 libtngdigital_ewallet");
}
if (!any) return;
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)");
}
}
static void *phase_thread(void *) {
install_promon_segv_handler();
/* Promon 用 SVC exit_group 绕过 PLT必须 seccomp。延迟 400ms 避开最早的 fork/getprop。 */
usleep(400 * 1000);
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();
install_promon_segv_handler();
try_install_cxx_guard_plt();
if (i % 5 == 0) refresh_promon_so_range();
}
LOGI("phase done seccomp=%d stack_chk=%d promon_segv=%d",
g_seccomp_ok.load(), g_stack_chk.load(), g_promon_segv.load());
return nullptr;
}
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+seccomp@400ms)",
getpid(), (int)g_main_tid.load());
install_fatal_skip_handlers();
install_soft_signals();
install_plt(api);
pthread_t th;
if (pthread_create(&th, nullptr, phase_thread, nullptr) == 0) {
pthread_detach(th);
}
LOGI("ready");
}
class TngExitGuardModule : public zygisk::ModuleBase {
public:
void onLoad(zygisk::Api *api, JNIEnv *env) override {
this->api = api;
this->env = env;
}
void preAppSpecialize(zygisk::AppSpecializeArgs *args) override {
const char *nice = nullptr;
if (args->nice_name) {
nice = env->GetStringUTFChars(args->nice_name, nullptr);
}
bool match = nice && (
std::strncmp(nice, kTargetPkg, std::strlen(kTargetPkg)) == 0);
if (nice) env->ReleaseStringUTFChars(args->nice_name, nice);
g_enabled = match;
if (!match) {
api->setOption(zygisk::Option::DLCLOSE_MODULE_LIBRARY);
return;
}
LOGI("preAppSpecialize matched");
}
void postAppSpecialize(const zygisk::AppSpecializeArgs *args) override {
(void)args;
if (!g_enabled) return;
install_all(api);
}
private:
zygisk::Api *api = nullptr;
JNIEnv *env = nullptr;
};
REGISTER_ZYGISK_MODULE(TngExitGuardModule)

View File

@@ -0,0 +1,391 @@
/* Copyright 2022-2023 John "topjohnwu" Wu
*
* Permission to use, copy, modify, and/or distribute this software for any
* purpose with or without fee is hereby granted.
*
* THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
* REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
* AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
* INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
* LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
* OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
* PERFORMANCE OF THIS SOFTWARE.
*/
// This is the public API for Zygisk modules.
// DO NOT MODIFY ANY CODE IN THIS HEADER.
#pragma once
#include <jni.h>
#define ZYGISK_API_VERSION 4
/*
***************
* Introduction
***************
On Android, all app processes are forked from a special daemon called "Zygote".
For each new app process, zygote will fork a new process and perform "specialization".
This specialization operation enforces the Android security sandbox on the newly forked
process to make sure that 3rd party application code is only loaded after it is being
restricted within a sandbox.
On Android, there is also this special process called "system_server". This single
process hosts a significant portion of system services, which controls how the
Android operating system and apps interact with each other.
The Zygisk framework provides a way to allow developers to build modules and run custom
code before and after system_server and any app processes' specialization.
This enable developers to inject code and alter the behavior of system_server and app processes.
Please note that modules will only be loaded after zygote has forked the child process.
THIS MEANS ALL OF YOUR CODE RUNS IN THE APP/SYSTEM_SERVER PROCESS, NOT THE ZYGOTE DAEMON!
*********************
* Development Guide
*********************
Define a class and inherit zygisk::ModuleBase to implement the functionality of your module.
Use the macro REGISTER_ZYGISK_MODULE(className) to register that class to Zygisk.
Example code:
static jint (*orig_logger_entry_max)(JNIEnv *env);
static jint my_logger_entry_max(JNIEnv *env) { return orig_logger_entry_max(env); }
class ExampleModule : public zygisk::ModuleBase {
public:
void onLoad(zygisk::Api *api, JNIEnv *env) override {
this->api = api;
this->env = env;
}
void preAppSpecialize(zygisk::AppSpecializeArgs *args) override {
JNINativeMethod methods[] = {
{ "logger_entry_max_payload_native", "()I", (void*) my_logger_entry_max },
};
api->hookJniNativeMethods(env, "android/util/Log", methods, 1);
*(void **) &orig_logger_entry_max = methods[0].fnPtr;
}
private:
zygisk::Api *api;
JNIEnv *env;
};
REGISTER_ZYGISK_MODULE(ExampleModule)
-----------------------------------------------------------------------------------------
Since your module class's code runs with either Zygote's privilege in pre[XXX]Specialize,
or runs in the sandbox of the target process in post[XXX]Specialize, the code in your class
never runs in a true superuser environment.
If your module require access to superuser permissions, you can create and register
a root companion handler function. This function runs in a separate root companion
daemon process, and an Unix domain socket is provided to allow you to perform IPC between
your target process and the root companion process.
Example code:
static void example_handler(int socket) { ... }
REGISTER_ZYGISK_COMPANION(example_handler)
*/
namespace zygisk {
struct Api;
struct AppSpecializeArgs;
struct ServerSpecializeArgs;
class ModuleBase {
public:
// This method is called as soon as the module is loaded into the target process.
// A Zygisk API handle will be passed as an argument.
virtual void onLoad([[maybe_unused]] Api *api, [[maybe_unused]] JNIEnv *env) {}
// This method is called before the app process is specialized.
// At this point, the process just got forked from zygote, but no app specific specialization
// is applied. This means that the process does not have any sandbox restrictions and
// still runs with the same privilege of zygote.
//
// All the arguments that will be sent and used for app specialization is passed as a single
// AppSpecializeArgs object. You can read and overwrite these arguments to change how the app
// process will be specialized.
//
// If you need to run some operations as superuser, you can call Api::connectCompanion() to
// get a socket to do IPC calls with a root companion process.
// See Api::connectCompanion() for more info.
virtual void preAppSpecialize([[maybe_unused]] AppSpecializeArgs *args) {}
// This method is called after the app process is specialized.
// At this point, the process has all sandbox restrictions enabled for this application.
// This means that this method runs with the same privilege of the app's own code.
virtual void postAppSpecialize([[maybe_unused]] const AppSpecializeArgs *args) {}
// This method is called before the system server process is specialized.
// See preAppSpecialize(args) for more info.
virtual void preServerSpecialize([[maybe_unused]] ServerSpecializeArgs *args) {}
// This method is called after the system server process is specialized.
// At this point, the process runs with the privilege of system_server.
virtual void postServerSpecialize([[maybe_unused]] const ServerSpecializeArgs *args) {}
};
struct AppSpecializeArgs {
// Required arguments. These arguments are guaranteed to exist on all Android versions.
jint &uid;
jint &gid;
jintArray &gids;
jint &runtime_flags;
jobjectArray &rlimits;
jint &mount_external;
jstring &se_info;
jstring &nice_name;
jstring &instruction_set;
jstring &app_data_dir;
// Optional arguments. Please check whether the pointer is null before de-referencing
jintArray *const fds_to_ignore;
jboolean *const is_child_zygote;
jboolean *const is_top_app;
jobjectArray *const pkg_data_info_list;
jobjectArray *const whitelisted_data_info_list;
jboolean *const mount_data_dirs;
jboolean *const mount_storage_dirs;
AppSpecializeArgs() = delete;
};
struct ServerSpecializeArgs {
jint &uid;
jint &gid;
jintArray &gids;
jint &runtime_flags;
jlong &permitted_capabilities;
jlong &effective_capabilities;
ServerSpecializeArgs() = delete;
};
namespace internal {
struct api_table;
template <class T> void entry_impl(api_table *, JNIEnv *);
}
// These values are used in Api::setOption(Option)
enum Option : int {
// Force Magisk's denylist unmount routines to run on this process.
//
// Setting this option only makes sense in preAppSpecialize.
// The actual unmounting happens during app process specialization.
//
// Set this option to force all Magisk and modules' files to be unmounted from the
// mount namespace of the process, regardless of the denylist enforcement status.
FORCE_DENYLIST_UNMOUNT = 0,
// When this option is set, your module's library will be dlclose-ed after post[XXX]Specialize.
// Be aware that after dlclose-ing your module, all of your code will be unmapped from memory.
// YOU MUST NOT ENABLE THIS OPTION AFTER HOOKING ANY FUNCTIONS IN THE PROCESS.
DLCLOSE_MODULE_LIBRARY = 1,
};
// Bit masks of the return value of Api::getFlags()
enum StateFlag : uint32_t {
// The user has granted root access to the current process
PROCESS_GRANTED_ROOT = (1u << 0),
// The current process was added on the denylist
PROCESS_ON_DENYLIST = (1u << 1),
};
// All API methods will stop working after post[XXX]Specialize as Zygisk will be unloaded
// from the specialized process afterwards.
struct Api {
// Connect to a root companion process and get a Unix domain socket for IPC.
//
// This API only works in the pre[XXX]Specialize methods due to SELinux restrictions.
//
// The pre[XXX]Specialize methods run with the same privilege of zygote.
// If you would like to do some operations with superuser permissions, register a handler
// function that would be called in the root process with REGISTER_ZYGISK_COMPANION(func).
// Another good use case for a companion process is that if you want to share some resources
// across multiple processes, hold the resources in the companion process and pass it over.
//
// The root companion process is ABI aware; that is, when calling this method from a 32-bit
// process, you will be connected to a 32-bit companion process, and vice versa for 64-bit.
//
// Returns a file descriptor to a socket that is connected to the socket passed to your
// module's companion request handler. Returns -1 if the connection attempt failed.
int connectCompanion();
// Get the file descriptor of the root folder of the current module.
//
// This API only works in the pre[XXX]Specialize methods.
// Accessing the directory returned is only possible in the pre[XXX]Specialize methods
// or in the root companion process (assuming that you sent the fd over the socket).
// Both restrictions are due to SELinux and UID.
//
// Returns -1 if errors occurred.
int getModuleDir();
// Set various options for your module.
// Please note that this method accepts one single option at a time.
// Check zygisk::Option for the full list of options available.
void setOption(Option opt);
// Get information about the current process.
// Returns bitwise-or'd zygisk::StateFlag values.
uint32_t getFlags();
// Exempt the provided file descriptor from being automatically closed.
//
// This API only make sense in preAppSpecialize; calling this method in any other situation
// is either a no-op (returns true) or an error (returns false).
//
// When false is returned, the provided file descriptor will eventually be closed by zygote.
bool exemptFd(int fd);
// Hook JNI native methods for a class
//
// Lookup all registered JNI native methods and replace it with your own methods.
// The original function pointer will be saved in each JNINativeMethod's fnPtr.
// If no matching class, method name, or signature is found, that specific JNINativeMethod.fnPtr
// will be set to nullptr.
void hookJniNativeMethods(JNIEnv *env, const char *className, JNINativeMethod *methods, int numMethods);
// Hook functions in the PLT (Procedure Linkage Table) of ELFs loaded in memory.
//
// Parsing /proc/[PID]/maps will give you the memory map of a process. As an example:
//
// <address> <perms> <offset> <dev> <inode> <pathname>
// 56b4346000-56b4347000 r-xp 00002000 fe:00 235 /system/bin/app_process64
// (More details: https://man7.org/linux/man-pages/man5/proc.5.html)
//
// The `dev` and `inode` pair uniquely identifies a file being mapped into memory.
// For matching ELFs loaded in memory, replace function `symbol` with `newFunc`.
// If `oldFunc` is not nullptr, the original function pointer will be saved to `oldFunc`.
void pltHookRegister(dev_t dev, ino_t inode, const char *symbol, void *newFunc, void **oldFunc);
// Commit all the hooks that was previously registered.
// Returns false if an error occurred.
bool pltHookCommit();
private:
internal::api_table *tbl;
template <class T> friend void internal::entry_impl(internal::api_table *, JNIEnv *);
};
// Register a class as a Zygisk module
#define REGISTER_ZYGISK_MODULE(clazz) \
void zygisk_module_entry(zygisk::internal::api_table *table, JNIEnv *env) { \
zygisk::internal::entry_impl<clazz>(table, env); \
}
// Register a root companion request handler function for your module
//
// The function runs in a superuser daemon process and handles a root companion request from
// your module running in a target process. The function has to accept an integer value,
// which is a Unix domain socket that is connected to the target process.
// See Api::connectCompanion() for more info.
//
// NOTE: the function can run concurrently on multiple threads.
// Be aware of race conditions if you have globally shared resources.
#define REGISTER_ZYGISK_COMPANION(func) \
void zygisk_companion_entry(int client) { func(client); }
/*********************************************************
* The following is internal ABI implementation detail.
* You do not have to understand what it is doing.
*********************************************************/
namespace internal {
struct module_abi {
long api_version;
ModuleBase *impl;
void (*preAppSpecialize)(ModuleBase *, AppSpecializeArgs *);
void (*postAppSpecialize)(ModuleBase *, const AppSpecializeArgs *);
void (*preServerSpecialize)(ModuleBase *, ServerSpecializeArgs *);
void (*postServerSpecialize)(ModuleBase *, const ServerSpecializeArgs *);
module_abi(ModuleBase *module) : api_version(ZYGISK_API_VERSION), impl(module) {
preAppSpecialize = [](auto m, auto args) { m->preAppSpecialize(args); };
postAppSpecialize = [](auto m, auto args) { m->postAppSpecialize(args); };
preServerSpecialize = [](auto m, auto args) { m->preServerSpecialize(args); };
postServerSpecialize = [](auto m, auto args) { m->postServerSpecialize(args); };
}
};
struct api_table {
// Base
void *impl;
bool (*registerModule)(api_table *, module_abi *);
void (*hookJniNativeMethods)(JNIEnv *, const char *, JNINativeMethod *, int);
void (*pltHookRegister)(dev_t, ino_t, const char *, void *, void **);
bool (*exemptFd)(int);
bool (*pltHookCommit)();
int (*connectCompanion)(void * /* impl */);
void (*setOption)(void * /* impl */, Option);
int (*getModuleDir)(void * /* impl */);
uint32_t (*getFlags)(void * /* impl */);
};
template <class T>
void entry_impl(api_table *table, JNIEnv *env) {
static Api api;
api.tbl = table;
static T module;
ModuleBase *m = &module;
static module_abi abi(m);
if (!table->registerModule(table, &abi)) return;
m->onLoad(&api, env);
}
} // namespace internal
inline int Api::connectCompanion() {
return tbl->connectCompanion ? tbl->connectCompanion(tbl->impl) : -1;
}
inline int Api::getModuleDir() {
return tbl->getModuleDir ? tbl->getModuleDir(tbl->impl) : -1;
}
inline void Api::setOption(Option opt) {
if (tbl->setOption) tbl->setOption(tbl->impl, opt);
}
inline uint32_t Api::getFlags() {
return tbl->getFlags ? tbl->getFlags(tbl->impl) : 0;
}
inline bool Api::exemptFd(int fd) {
return tbl->exemptFd != nullptr && tbl->exemptFd(fd);
}
inline void Api::hookJniNativeMethods(JNIEnv *env, const char *className, JNINativeMethod *methods, int numMethods) {
if (tbl->hookJniNativeMethods) tbl->hookJniNativeMethods(env, className, methods, numMethods);
}
inline void Api::pltHookRegister(dev_t dev, ino_t inode, const char *symbol, void *newFunc, void **oldFunc) {
if (tbl->pltHookRegister) tbl->pltHookRegister(dev, inode, symbol, newFunc, oldFunc);
}
inline bool Api::pltHookCommit() {
return tbl->pltHookCommit != nullptr && tbl->pltHookCommit();
}
} // namespace zygisk
extern "C" {
[[gnu::visibility("default"), maybe_unused]]
void zygisk_module_entry(zygisk::internal::api_table *, JNIEnv *);
[[gnu::visibility("default"), maybe_unused]]
void zygisk_companion_entry(int);
} // extern "C"

View File

@@ -0,0 +1,6 @@
id=tng_exit_guard
name=TNG Exit Guard
version=v1.0
versionCode=1
author=miraclegarden
description=Zygisk: block Promon native exit_group for my.com.tngdigital.ewallet. Pair with notiMessage Xposed TngRoot hooks. Do NOT put TNG on Magisk DenyList.

Binary file not shown.

View File

@@ -22,16 +22,15 @@ logcat 标签:`NativeEncrypt: loading JNI`、`CharacterCryptoManager`
## 运行
```powershell
# 1. 手机启动 frida-server (root)
adb push frida-server /data/local/tmp/
adb shell su -c 'chmod 755 /data/local/tmp/frida-server && /data/local/tmp/frida-server -D &'
# SG 专用 native trace推荐
.\scripts\run-frida-sg-native.ps1
# 2. PC 安装 frida-tools 后
#
cd reverse\frida
.\run-frida-trace.ps1 -Mode spawn
C:\Users\Administrator\AppData\Local\Programs\Python\Python312\python.exe run_frida_sg_native.py attach
# 3. App 内 Sign up → 输入号码 → Next
# 关注 [MB-TRACE] NativeEncryptWrapper / NativeEncryptUtils / HTTP .../register
# PH 旧脚本(勿用于 SG
.\run-frida-trace.ps1 -Mode spawn
```
建议测试时**暂时关闭 LSPosed 对 MariBank 的作用域**,避免与 Frida 冲突。

25
reverse/frida/mini_run.py Normal file
View File

@@ -0,0 +1,25 @@
# -*- coding: utf-8 -*-
import subprocess, time
import frida
ADB = r"C:\Users\Administrator\AppData\Local\Android\Sdk\platform-tools\adb.exe"
PKG = "my.com.tngdigital.ewallet"
def adb(*a): return subprocess.run([ADB, *a], capture_output=True, text=True)
def on_msg(m, d):
line = m.get("payload") if m.get("type") in ("send", "log") else str(m)
print(line, flush=True)
d = frida.get_usb_device(10)
adb("shell", "am", "force-stop", PKG); time.sleep(1)
pid = d.spawn([PKG])
s = d.attach(pid)
sc = s.create_script(open("mini_timer.js", encoding="utf-8").read())
sc.on("message", on_msg)
sc.load()
print("resume", pid, flush=True)
d.resume(pid)
for i in range(20):
time.sleep(1)
p = adb("shell", "pidof", PKG).stdout.strip()
print("t=%d pid=%s" % (i+1, p or "DEAD"), flush=True)
if not p: break
try: s.detach()
except: pass

View File

@@ -0,0 +1,9 @@
"use strict";
console.log("[MINI] start");
let i = 0;
const t = setInterval(() => {
i++;
const m = Process.findModuleByName("libtiger_tally.so");
console.log(`[MINI] tick=${i} tiger_loaded=${!!m}`);
if (i > 15) clearInterval(t);
}, 1000);

View File

@@ -0,0 +1,143 @@
# -*- coding: utf-8 -*-
"""Frida SG native trace — attach to sg.com.maribankmobile.digitalbank."""
import sys
import time
from datetime import datetime
from pathlib import Path
import frida
PKG = "sg.com.maribankmobile.digitalbank"
HERE = Path(__file__).resolve().parent
LOGS_DIR = HERE.parent / "logs" / "frida"
LOGS_DIR.mkdir(parents=True, exist_ok=True)
SCRIPT = HERE / "trace_maribank_sg_native.js"
LOG = LOGS_DIR / ("sg_native_%s.log" % datetime.now().strftime("%Y%m%d_%H%M%S"))
def on_message(message, data):
mtype = message.get("type")
if mtype == "send":
line = message.get("payload")
elif mtype == "log":
line = message.get("payload", message.get("description", ""))
else:
line = str(message)
text = line if isinstance(line, str) else repr(line)
print(text, flush=True)
with open(LOG, "a", encoding="utf-8") as f:
f.write(text + "\n")
if mtype == "error":
with open(str(LOG) + ".err", "a", encoding="utf-8") as f:
f.write(text + "\n")
def wait_for_process(device, pkg, timeout_sec=60):
deadline = time.time() + timeout_sec
while time.time() < deadline:
for app in device.enumerate_applications():
if app.identifier == pkg and app.pid and app.pid > 0:
return app.pid
for proc in device.enumerate_processes():
if proc.name == pkg:
return proc.pid
time.sleep(0.5)
return None
def launch_app(pkg):
import subprocess
adb = r"C:\Users\Administrator\AppData\Local\Android\Sdk\platform-tools\adb.exe"
subprocess.run([adb, "shell", "am", "force-stop", pkg], check=False, capture_output=True)
time.sleep(1)
subprocess.run(
[
adb,
"shell",
"am",
"start",
"-n",
pkg + "/com.shopee.bke.digitalbank.ui.MainActivity",
],
check=False,
capture_output=True,
)
def ensure_frida_server():
import subprocess
adb = r"C:\Users\Administrator\AppData\Local\Android\Sdk\platform-tools\adb.exe"
out = subprocess.run(
[adb, "shell", "su", "-c", "pgrep frida-server"],
capture_output=True,
text=True,
)
if out.stdout.strip():
return
subprocess.run(
[adb, "shell", "su", "-c", "/data/local/tmp/frida-server -D &"],
check=False,
capture_output=True,
)
time.sleep(2)
def main():
mode = "attach"
if len(sys.argv) > 1:
mode = sys.argv[1]
if not SCRIPT.is_file():
raise SystemExit("missing script: %s" % SCRIPT)
ensure_frida_server()
device = frida.get_usb_device(timeout=10)
source = SCRIPT.read_text(encoding="utf-8")
print("Package: %s" % PKG)
print("Script: %s" % SCRIPT)
print("Log: %s" % LOG)
print("")
print("IMPORTANT: keep LSPosed scope ENABLED for SG (bypasses ADB page while tracing)")
print("")
if mode == "spawn":
pid = device.spawn([PKG])
session = device.attach(pid)
else:
pid = wait_for_process(device, PKG, 3)
if pid is None:
print("Launching MariBank SG ...")
launch_app(PKG)
pid = wait_for_process(device, PKG, 90)
if pid is None:
raise SystemExit(
"SG MariBank not running — open app to Sign up page, then re-run"
)
print("Attach pid=%s" % pid)
session = device.attach(pid)
script = session.create_script(source)
script.on("message", on_message)
script.load()
if mode == "spawn":
device.resume(pid)
print("Spawn resumed, wait JVM 15s ...")
time.sleep(15)
else:
time.sleep(3)
print("Trace running. Sign up -> +65 -> Next. Ctrl+C to stop.")
try:
while True:
time.sleep(1)
except KeyboardInterrupt:
print("Stopping...")
session.detach()
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,113 @@
# -*- coding: utf-8 -*-
"""Spawn TNG eWallet with Frida native exit blockers."""
import subprocess
import sys
import time
from datetime import datetime
from pathlib import Path
import frida
PKG = "my.com.tngdigital.ewallet"
HERE = Path(__file__).resolve().parent
LOGS_DIR = HERE.parent / "logs" / "frida"
LOGS_DIR.mkdir(parents=True, exist_ok=True)
SCRIPT = HERE / "trace_tng_native_exit.js"
LOG = LOGS_DIR / ("tng_native_%s.log" % datetime.now().strftime("%Y%m%d_%H%M%S"))
ADB = r"C:\Users\Administrator\AppData\Local\Android\Sdk\platform-tools\adb.exe"
HOLD_SEC = 45
def on_message(message, data):
mtype = message.get("type")
if mtype == "send":
line = message.get("payload")
elif mtype == "log":
line = message.get("payload", "")
else:
line = str(message)
text = line if isinstance(line, str) else repr(line)
print(text, flush=True)
with open(LOG, "a", encoding="utf-8") as f:
f.write(text + "\n")
def adb(*args):
return subprocess.run([ADB, *args], capture_output=True, text=True)
def ensure_frida_server():
out = adb("shell", "su", "-c", "pgrep -x frida-server")
if out.stdout.strip():
print("frida-server already running pid=%s" % out.stdout.strip())
return
print("starting frida-server ...")
adb("shell", "su", "-c", "pkill -9 frida-server; true")
# run in background via nohup-like
subprocess.Popen(
[ADB, "shell", "su", "-c", "/data/local/tmp/frida-server -D"],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
time.sleep(2)
out = adb("shell", "su", "-c", "pgrep -x frida-server")
if not out.stdout.strip():
raise RuntimeError("frida-server failed to start")
print("frida-server pid=%s" % out.stdout.strip())
def main():
ensure_frida_server()
adb("shell", "am", "force-stop", PKG)
time.sleep(1)
device = frida.get_usb_device(10)
source = SCRIPT.read_text(encoding="utf-8")
print("Spawning %s ..." % PKG)
print("log=%s" % LOG)
pid = device.spawn([PKG])
session = device.attach(pid)
script = session.create_script(source)
script.on("message", on_message)
script.load()
print("script loaded, resume pid=%s" % pid)
device.resume(pid)
alive = 0
for i in range(HOLD_SEC):
time.sleep(1)
# check process still alive
out = adb("shell", "pidof", PKG)
pids = out.stdout.strip()
if not pids:
print("DEAD after %ss" % (i + 1))
break
alive = i + 1
if (i + 1) % 5 == 0:
print("alive %ss pid=%s" % (alive, pids))
else:
print("STABLE %ss pid=%s" % (HOLD_SEC, adb("shell", "pidof", PKG).stdout.strip()))
# dump activity focus
focus = adb("shell", "dumpsys", "activity", "activities")
for line in focus.stdout.splitlines():
if "tngdigital" in line.lower() and (
"mResumedActivity" in line
or "topResumedActivity" in line
or "UserLogin" in line
or "SecurityError" in line
or "Splash" in line
):
print("ACT: " + line.strip())
try:
session.detach()
except Exception:
pass
print("done alive=%ss log=%s" % (alive, LOG))
return 0 if alive >= 15 else 1
if __name__ == "__main__":
sys.exit(main())

View File

@@ -0,0 +1,178 @@
# -*- coding: utf-8 -*-
"""Compare: Xposed-only vs Frida-stealth-spawn."""
import subprocess
import time
import frida
ADB = r"C:\Users\Administrator\AppData\Local\Android\Sdk\platform-tools\adb.exe"
PKG = "my.com.tngdigital.ewallet"
STEALTH = r"""
"use strict";
function log(m){ send("[stealth] "+m); }
function findExport(mod, name) {
try {
var m = Process.getModuleByName(mod);
var a = m.findExportByName(name);
if (a) return a;
} catch (e) {}
try { return Module.getGlobalExportByName(name); } catch (e2) { return null; }
}
// Rename frida threads
try {
var pthread_setname = findExport("libc.so", "pthread_setname_np");
// also patch existing: best-effort via Java later
} catch (e) {}
// Hide maps
var markers = ["frida","gadget","linjector","gum-js","gmain","pool-frida","hluda"];
var tracked = {};
function hideLine(line) {
var l = (line||"").toLowerCase();
for (var i=0;i<markers.length;i++) if (l.indexOf(markers[i])>=0) return true;
return false;
}
function filterBuf(buf, len) {
try {
var t = buf.readUtf8String(len);
if (!t) return len;
var out = t.split("\n").filter(function(x){return !hideLine(x);}).join("\n");
var b = Memory.allocUtf8String(out);
var n = Math.min(len, out.length);
Memory.copy(buf, b, n);
return n;
} catch (e) { return len; }
}
var openat = findExport("libc.so","openat");
var readFn = findExport("libc.so","read");
if (openat) {
Interceptor.attach(openat, {
onEnter: function(args){ this.path = args[1].isNull()?null:args[1].readCString(); },
onLeave: function(retval){
var fd=retval.toInt32();
if (fd>=0 && this.path && (this.path.indexOf("maps")>=0 || this.path.indexOf("status")>=0 || this.path.indexOf("task")>=0))
tracked[fd]=this.path;
}
});
}
if (readFn) {
Interceptor.attach(readFn, {
onEnter: function(args){ this.fd=args[0].toInt32(); this.buf=args[1]; },
onLeave: function(retval){
var n=retval.toInt32();
if (n>0 && tracked[this.fd]) retval.replace(ptr(filterBuf(this.buf, n)));
}
});
}
// Patch libc exit_group SVC
var libc = Process.getModuleByName("libc.so");
libc.enumerateRanges("r-x").forEach(function(r){
for (var off=0; off+8<r.size; off+=4) {
var p=r.base.add(off);
var w;
try { w=p.readU32(); } catch(e){ return; }
if (w!==0xd2800bc8 && w!==0xd2800ba8 && w!==0x52800bc8 && w!==0x52800ba8) continue;
for (var j=4;j<=24;j+=4) {
var s=p.add(j);
try {
if (s.readU32()===0xd4000001) {
Memory.protect(s,4,"rwx");
s.writeU32(0xd65f03c0);
log("SVC->RET "+s);
}
} catch(e2){}
}
}
});
// Replace exit APIs
["_exit","exit","abort"].forEach(function(n){
var a=findExport("libc.so",n);
if(!a) return;
try {
Interceptor.replace(a, new NativeCallback(function(c){ log("block "+n+"("+c+")"); }, "void", ["int"]));
} catch(e){}
});
log("stealth ready");
"""
def adb(*a):
return subprocess.run([ADB, *a], capture_output=True, text=True)
def on_msg(m, _d):
print(m.get("payload", m), flush=True)
def wait_alive(sec, label):
for i in range(sec):
time.sleep(1)
p = adb("shell", "pidof", PKG).stdout.strip()
act = ""
dump = adb("shell", "dumpsys", "activity", "activities").stdout
for line in dump.splitlines():
if "tngdigital" in line.lower() and any(
x in line for x in ("UserLogin", "SecurityError", "Splash", "topResumedActivity")
):
act = line.strip()[:120]
break
print("%s t=%ds pid=%s act=%s" % (label, i + 1, p or "DEAD", act), flush=True)
if not p:
return i + 1
return sec
def test_xposed_only():
print("=== Xposed-only (monkey) ===", flush=True)
adb("shell", "am", "force-stop", PKG)
adb("logcat", "-c")
time.sleep(0.3)
adb(
"shell",
"monkey",
"-p",
PKG,
"-c",
"android.intent.category.LAUNCHER",
"1",
)
alive = wait_alive(15, "XPOSED")
print("xposed_alive_sec", alive, flush=True)
for line in adb("logcat", "-d").stdout.splitlines():
if "TngRoot" in line and any(
x in line for x in ("install", "UserLogin", "short-circuit", "finishing", "blocked intent")
):
print(line[line.find("TngRoot") :][:180], flush=True)
if "Displayed" in line and "tngdigital" in line:
print(line.strip()[:200], flush=True)
if "exited cleanly" in line:
print(line.strip()[:160], flush=True)
def test_frida_stealth():
print("=== Frida stealth spawn ===", flush=True)
adb("shell", "am", "force-stop", PKG)
time.sleep(0.3)
d = frida.get_usb_device(10)
pid = d.spawn([PKG])
s = d.attach(pid)
sc = s.create_script(STEALTH)
sc.on("message", on_msg)
sc.load()
d.resume(pid)
alive = wait_alive(15, "FRIDA")
print("frida_alive_sec", alive, flush=True)
def main():
test_xposed_only()
test_frida_stealth()
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,48 @@
# -*- coding: utf-8 -*-
import subprocess
import time
from pathlib import Path
import frida
ADB = r"C:\Users\Administrator\AppData\Local\Android\Sdk\platform-tools\adb.exe"
PKG = "my.com.tngdigital.ewallet"
SCRIPT = Path(__file__).with_name("trace_tng_diag_exit.js").read_text(encoding="utf-8")
def adb(*a):
return subprocess.run([ADB, *a], capture_output=True, text=True)
def on_msg(m, d):
if m.get("type") == "send":
print(m["payload"], flush=True)
else:
print(m, flush=True)
def main():
adb("shell", "am", "force-stop", PKG)
time.sleep(0.5)
d = frida.get_usb_device(10)
pid = d.spawn([PKG])
s = d.attach(pid)
sc = s.create_script(SCRIPT)
sc.on("message", on_msg)
sc.load()
print("resume", pid, flush=True)
d.resume(pid)
for i in range(15):
time.sleep(1)
p = adb("shell", "pidof", PKG).stdout.strip()
print("t=%ds pid=%s" % (i + 1, p or "DEAD"), flush=True)
if not p:
break
try:
s.detach()
except Exception:
pass
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,42 @@
# -*- coding: utf-8 -*-
import subprocess
import time
import frida
ADB = r"C:\Users\Administrator\AppData\Local\Android\Sdk\platform-tools\adb.exe"
PKG = "my.com.tngdigital.ewallet"
def adb(*a):
return subprocess.run([ADB, *a], capture_output=True, text=True)
def main():
adb("shell", "am", "force-stop", PKG)
adb("logcat", "-c")
time.sleep(0.3)
d = frida.get_usb_device(10)
pid = d.spawn([PKG])
s = d.attach(pid)
sc = s.create_script('send("empty ok " + String(Process.id));')
sc.on("message", lambda m, _d: print(m, flush=True))
sc.load()
print("resume", pid, flush=True)
d.resume(pid)
for i in range(10):
time.sleep(1)
p = adb("shell", "pidof", PKG).stdout.strip()
print("t=%d %s" % (i + 1, p or "DEAD"), flush=True)
if not p:
break
print("--- death lines ---", flush=True)
for line in adb("logcat", "-d").stdout.splitlines():
if "exited cleanly" in line or "has died" in line and "tngdigital" in line:
print(line[:240], flush=True)
if "TngRoot" in line and ("install" in line or "short-circuit" in line or "blocked" in line):
print(line[:240], flush=True)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,214 @@
# -*- coding: utf-8 -*-
"""Resolve libc _exit real target and patch its SVC."""
import subprocess
import time
import frida
ADB = r"C:\Users\Administrator\AppData\Local\Android\Sdk\platform-tools\adb.exe"
PKG = "my.com.tngdigital.ewallet"
SCRIPT = r"""
"use strict";
function log(m){ send("[TNG-native] "+m); }
function findExport(mod, name) {
try {
var m = Process.getModuleByName(mod);
var a = m.findExportByName(name);
if (a) return a;
} catch (e) {}
try { return Module.getGlobalExportByName(name); } catch (e2) { return null; }
}
function resolveTrampoline(addr, name) {
// Follow simple ADRP+ADD+BR / LDR+BR patterns a few times
var cur = addr;
for (var depth = 0; depth < 5; depth++) {
var w0 = cur.readU32();
var w1 = cur.add(4).readU32();
// BR Xn: 0xD61F0000 | (Rn<<5)
if ((w1 & 0xfffffc1f) === 0xd61f0000) {
var rn = (w1 >> 5) & 0x1f;
// LDR Xn, [PC, #imm] : 0x58000000
if ((w0 & 0xff000000) === 0x58000000) {
var imm19 = (w0 >> 5) & 0x7ffff;
if (imm19 & 0x40000) imm19 -= 0x80000;
var targetPtr = cur.add(imm19 * 4);
var target = targetPtr.readPointer();
log(name + " trampoline LDR+BR -> " + target);
cur = target;
continue;
}
// ADRP Xn, page
if ((w0 & 0x9f000000) === 0x90000000) {
var rd = w0 & 0x1f;
var immhi = (w0 >> 5) & 0x7ffff;
var immlo = (w0 >> 29) & 0x3;
var imm = ((immhi << 2) | immlo) << 12;
if (imm & 0x100000000) imm = imm - 0x200000000;
var page = cur.and(ptr("0xfffffffffffff000")).add(imm);
// next might be LDR/ADD
var w2 = cur.add(8).readU32();
log(name + " ADRP page="+page+" rn="+rn+" rd="+rd+" w2="+w2.toString(16));
}
log(name + " BR X" + rn + " at " + cur + " (stop follow)");
break;
}
break;
}
return cur;
}
function patchSvcNear(addr, name, windowSize) {
var nop = [0x1f, 0x20, 0x03, 0xd5];
var n = 0;
for (var i = 0; i < windowSize; i += 4) {
try {
var p = addr.add(i);
if (p.readU32() !== 0xd4000001) continue;
Memory.protect(p, 4, "rwx");
// replace svc with: mov x0, x0; ret — or just nop and hope
// Better: movz x0, #0; ret so "exit" becomes return 0
// movz x0,#0 = 0xD2800000; ret = 0xD65F03C0
p.writeU32(0xd2800000); // movz x0, #0
if (i + 4 < windowSize) {
var p2 = addr.add(i + 4);
// only overwrite next if also svc/brk or nop pad — safer: write ret at svc place only via branch
}
// Just NOP the svc — caller may hang; use ret instead by overwriting svc with ret
p.writeU32(0xd65f03c0); // RET
n++;
log("patched SVC->RET @ " + p + " (" + name + "+" + i + ")");
} catch (e) {
log("patch fail: " + e);
}
}
return n;
}
function hookByPatchingLibcExit() {
var libc = Process.getModuleByName("libc.so");
// Scan entire libc for the classic exit_group sequence:
// mov x8, #94; mov x0, ...; svc #0 OR svc inside _exit impl
var count = 0;
var svcAddrs = [];
// Focused: exports that must lead to exit
["_exit", "exit"].forEach(function (n) {
var a = libc.findExportByName(n);
if (!a) return;
log(n + " export " + a);
// DebugSymbol / Instruction parse: find first BL/B to real impl
});
// Brute: scan libc executable for movz x8,#94 followed within 16 bytes by svc
var ranges = libc.enumerateRanges("r-x");
ranges.forEach(function (r) {
for (var off = 0; off + 8 < r.size; off += 4) {
var p = r.base.add(off);
var w;
try { w = p.readU32(); } catch (e) { return; }
// movz x8, #94 = 0xD2800BC8 ; movz w8,#94 = 0x52800BC8
// movz x8, #93 = 0xD2800BA8
if (w !== 0xd2800bc8 && w !== 0x52800bc8 && w !== 0xd2800ba8 && w !== 0x52800ba8) continue;
// look ahead for svc
for (var j = 4; j <= 24; j += 4) {
try {
if (p.add(j).readU32() === 0xd4000001) {
svcAddrs.push(p.add(j));
log("exit-seq movz@ " + p + " svc@ " + p.add(j));
}
} catch (e2) {}
}
}
});
svcAddrs.forEach(function (svc) {
try {
Memory.protect(svc, 4, "rwx");
// Replace svc with ret — turns exit into function return
svc.writeU32(0xd65f03c0);
count++;
log("SVC->RET " + svc);
} catch (e) {
log("SVC patch fail " + svc + ": " + e);
}
});
log("libc exit SVC patches=" + count);
}
function installEntryLog() {
["_exit", "exit", "abort"].forEach(function (n) {
var a = findExport("libc.so", n);
if (!a) return;
try {
Interceptor.attach(a, {
onEnter: function (args) {
log("ENTER " + n + "(" + args[0] + ")");
}
});
} catch (e) {}
});
}
// Also Interceptor.replace as backup
function installReplace() {
["_exit", "exit", "abort"].forEach(function (n) {
var a = findExport("libc.so", n);
if (!a) return;
try {
Interceptor.replace(a, new NativeCallback(function (code) {
log("REPLACED-HIT " + n + "(" + (code|0) + ")");
}, "void", ["int"]));
log("replaced " + n);
} catch (e) {
log("replace " + n + " fail: " + e);
}
});
}
log("pid=" + Process.id);
hookByPatchingLibcExit();
installReplace();
installEntryLog();
log("ready");
"""
def adb(*a):
return subprocess.run([ADB, *a], capture_output=True, text=True)
def on_msg(m, d):
print(m.get("payload", m), flush=True)
def main():
adb("shell", "am", "force-stop", PKG)
time.sleep(0.4)
device = frida.get_usb_device(10)
pid = device.spawn([PKG])
session = device.attach(pid)
script = session.create_script(SCRIPT)
script.on("message", on_msg)
script.load()
print("resume", pid, flush=True)
device.resume(pid)
for i in range(25):
time.sleep(1)
p = adb("shell", "pidof", PKG).stdout.strip()
print("t=%ds %s" % (i + 1, p or "DEAD"), flush=True)
if not p:
break
else:
print("STABLE", flush=True)
focus = adb("shell", "dumpsys", "activity", "activities").stdout
for line in focus.splitlines():
if "tngdigital" in line.lower() and any(
x in line for x in ("UserLogin", "SecurityError", "Splash", "mResumed", "topResumed")
):
print("ACT", line.strip()[:200], flush=True)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,107 @@
# -*- coding: utf-8 -*-
"""Spawn TNG, observe TigerTally fread blocking (observe-only, no behavior change)."""
import subprocess
import sys
import time
from datetime import datetime
from pathlib import Path
import frida
PKG = "my.com.tngdigital.ewallet"
HERE = Path(__file__).resolve().parent
LOGS_DIR = HERE.parent / "logs" / "frida"
LOGS_DIR.mkdir(parents=True, exist_ok=True)
SCRIPT = HERE / "trace_tng_tiger_fread.js"
LOG = LOGS_DIR / ("tng_tiger_fread_%s.log" % datetime.now().strftime("%Y%m%d_%H%M%S"))
ADB = r"C:\Users\Administrator\AppData\Local\Android\Sdk\platform-tools\adb.exe"
HOLD_SEC = 45
def on_message(message, data):
mtype = message.get("type")
if mtype == "send":
line = message.get("payload")
elif mtype == "log":
line = message.get("payload", "")
else:
line = str(message)
text = line if isinstance(line, str) else repr(line)
print(text, flush=True)
with open(LOG, "a", encoding="utf-8") as f:
f.write(text + "\n")
def adb(*args):
return subprocess.run([ADB, *args], capture_output=True, text=True)
def ensure_frida_server():
out = adb("shell", "su", "-c", "pgrep -x frida-server")
if out.stdout.strip():
print("frida-server running pid=%s" % out.stdout.strip())
return
adb("shell", "su", "-c", "pkill -9 frida-server; true")
subprocess.Popen(
[ADB, "shell", "su", "-c", "/data/local/tmp/frida-server -D"],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
time.sleep(2)
out = adb("shell", "su", "-c", "pgrep -x frida-server")
if not out.stdout.strip():
raise RuntimeError("frida-server failed to start")
print("frida-server pid=%s" % out.stdout.strip())
def main():
ensure_frida_server()
adb("shell", "am", "force-stop", PKG)
time.sleep(1)
device = frida.get_usb_device(10)
source = SCRIPT.read_text(encoding="utf-8")
print("spawn %s ..." % PKG)
print("log=%s" % LOG)
pid = device.spawn([PKG])
session = device.attach(pid)
script = session.create_script(source)
script.on("message", on_message)
script.load()
print("script loaded, resume pid=%s" % pid)
device.resume(pid)
alive = 0
for i in range(HOLD_SEC):
time.sleep(1)
out = adb("shell", "pidof", PKG)
pids = out.stdout.strip()
if not pids:
print("DEAD after %ss" % (i + 1))
break
alive = i + 1
if (i + 1) % 5 == 0:
print("alive %ss pid=%s" % (alive, pids))
else:
print("STABLE %ss pid=%s" % (HOLD_SEC, adb("shell", "pidof", PKG).stdout.strip()))
# 焦点 Activity
focus = adb("shell", "dumpsys", "activity", "activities")
for line in focus.stdout.splitlines():
if "tngdigital" in line.lower() and (
"mResumedActivity" in line or "topResumedActivity" in line
or "UserLogin" in line or "SecurityError" in line or "Splash" in line
):
print("ACT: " + line.strip())
try:
session.detach()
except Exception:
pass
print("done alive=%ss log=%s" % (alive, LOG))
return 0 if alive >= 15 else 1
if __name__ == "__main__":
sys.exit(main())

View File

@@ -0,0 +1,60 @@
# -*- coding: utf-8 -*-
"""Spawn TNG and strace for exit syscalls (needs root)."""
import subprocess
import time
import sys
ADB = r"C:\Users\Administrator\AppData\Local\Android\Sdk\platform-tools\adb.exe"
PKG = "my.com.tngdigital.ewallet"
def adb(*args, timeout=30):
return subprocess.run([ADB, *args], capture_output=True, text=True, timeout=timeout)
def main():
adb("shell", "am", "force-stop", PKG)
time.sleep(0.5)
adb("logcat", "-c")
# start app
adb(
"shell",
"monkey",
"-p",
PKG,
"-c",
"android.intent.category.LAUNCHER",
"1",
)
time.sleep(0.4)
out = adb("shell", "pidof", PKG)
pid = out.stdout.strip().split()[0] if out.stdout.strip() else ""
if not pid:
print("no pid")
return 1
print("pid", pid)
# strace briefly
p = subprocess.Popen(
[
ADB,
"shell",
"su",
"-c",
f"timeout 8 strace -f -e trace=exit,exit_group,kill,tkill,tgkill,write -p {pid} 2>&1 | head -80",
],
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
)
try:
stdout, _ = p.communicate(timeout=15)
print(stdout)
except subprocess.TimeoutExpired:
p.kill()
print(p.stdout.read() if p.stdout else "timeout")
print("alive?", adb("shell", "pidof", PKG).stdout.strip())
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@@ -0,0 +1,634 @@
'use strict';
/**
* MariBank SG 3.2.2 — Java + native attestation / encrypt trace
* Package: sg.com.maribankmobile.digitalbank
*
* SG 差异: 无 utils.d / com.shopee.shpssdk.*,仅 shpssdkbank + uvwuvwuv
*/
const TAG = '[MB-NATIVE]';
const MAX_STR = 4000;
const MAX_BYTES_LOG = 8192;
const HOOKED_NATIVE_PTRS = {};
const JAVA_TARGETS = [
'com.shopee.shpssdkbank.wvvvuwwu',
'com.shopee.shpssdkbank.uwuvuvvww.vvuuuuvvv',
'com.shopee.shpssdkbank.uwuvuvvww.uvwuuuuuw.vvvvuwwvu',
'com.shopee.shpssdkbank.uwuvuvvww.wvvuuwvwu',
'com.shopee.shpssdkbank.uvuwwuvwv.uvwwuuvvw',
'com.shopee.bke.lib.jni.utils.uvwuvwuv',
'com.shopee.bke.lib.jni.utils.uvwwwwuv',
'com.shopee.shpssdkbank.SHPSSDK',
];
const SO_WATCH = [
'libshpssdk_bank.so',
'libshpssdk.so',
'libsdkutils.so',
'libbkutils.so',
];
function log(msg) {
send(TAG + ' ' + msg);
}
function jniFn(envPtr, index, ret, args) {
const funcs = envPtr.readPointer();
const addr = funcs.add(index * Process.pointerSize).readPointer();
if (!addr || addr.isNull()) return null;
return new NativeFunction(addr, ret, args);
}
function jniReadByteArray(envPtr, jarrayPtr) {
if (!jarrayPtr || jarrayPtr.isNull()) return null;
try {
const GetArrayLength = jniFn(envPtr, 171, 'int', ['pointer', 'pointer']);
const GetByteArrayElements = jniFn(envPtr, 184, 'pointer', ['pointer', 'pointer', 'pointer']);
const ReleaseByteArrayElements = jniFn(envPtr, 187, 'void', ['pointer', 'pointer', 'pointer', 'int']);
if (!GetArrayLength || !GetByteArrayElements || !ReleaseByteArrayElements) {
return jniReadByteArrayArt(envPtr, jarrayPtr);
}
const len = GetArrayLength(envPtr, jarrayPtr);
if (len <= 0 || len > MAX_BYTES_LOG) return { len: len, hex: '', text: '' };
const elems = GetByteArrayElements(envPtr, jarrayPtr, ptr(0));
if (!elems || elems.isNull()) return { len: len, hex: '', text: '' };
const raw = elems.readByteArray(Math.min(len, MAX_BYTES_LOG));
ReleaseByteArrayElements(envPtr, jarrayPtr, elems, 0);
return bytesToPreview(raw, len);
} catch (e) {
return { len: -1, hex: 'err:' + e, text: '' };
}
}
function bytesToPreview(raw, len) {
const arr = new Uint8Array(raw);
let text = '';
try {
text = String.fromCharCode.apply(null, arr);
if (text.indexOf('\u0000') >= 0 || !/^[\x20-\x7e\r\n\t\u4e00-\u9fff\u0100-\u024f]+$/.test(text.substring(0, Math.min(text.length, 200)))) {
text = '';
}
} catch (e) {
text = '';
}
if (text.length > MAX_STR) text = text.substring(0, MAX_STR) + '...';
return { len: len, hex: hexPreview(arr, 64), text: text, arr: arr };
}
function jniReadByteArrayArt(envPtr, jarrayPtr) {
const art = moduleByName('libart.so');
if (!art) return { len: -1, hex: 'err:no-art', text: '' };
let sym = null;
art.enumerateSymbols().forEach(function (s) {
if (sym) return;
if (s.name.indexOf('GetByteArrayRegion') >= 0 && s.name.indexOf('JNI') >= 0) {
sym = s.address;
}
});
if (!sym) return { len: -1, hex: 'err:no-GetByteArrayRegion', text: '' };
const GetLen = jniFn(envPtr, 171, 'int', ['pointer', 'pointer']);
const len = GetLen ? GetLen(envPtr, jarrayPtr) : 0;
if (len <= 0 || len > MAX_BYTES_LOG) return { len: len, hex: '', text: '' };
const buf = Memory.alloc(len);
const GetRegion = new NativeFunction(sym, 'void', ['pointer', 'pointer', 'int', 'int', 'pointer']);
GetRegion(envPtr, jarrayPtr, 0, len, buf);
return bytesToPreview(buf.readByteArray(len), len);
}
function jniReadJstring(envPtr, jstrPtr) {
if (!jstrPtr || jstrPtr.isNull()) return '';
try {
const GetStringUTFChars = jniFn(envPtr, 169, 'pointer', ['pointer', 'pointer', 'pointer']);
const ReleaseStringUTFChars = jniFn(envPtr, 170, 'void', ['pointer', 'pointer', 'pointer']);
if (!GetStringUTFChars || !ReleaseStringUTFChars) return '';
const chars = GetStringUTFChars(envPtr, jstrPtr, ptr(0));
if (!chars || chars.isNull()) return '';
const s = chars.readCString();
ReleaseStringUTFChars(envPtr, jstrPtr, chars);
return s || '';
} catch (e) {
return '';
}
}
function dumpNativeArgs(methodName, sig, envPtr, args) {
if (methodName === 'vuwuuwvw' && sig.indexOf('[B[B') >= 0) {
const a0 = jniReadByteArray(envPtr, args[2]);
const a1 = jniReadByteArray(envPtr, args[3]);
if (a0) log(' nat in0 len=' + a0.len + ' hex=' + a0.hex);
if (a1) log(' nat in1 len=' + a1.len + ' hex=' + a1.hex);
return;
}
if (methodName === 'uvwuuww') {
const plain = jniReadByteArray(envPtr, args[2]);
const key = jniReadJstring(envPtr, args[3]);
const flag = args[4] ? args[4].toInt32() : 0;
if (plain) {
log(' nat plain len=' + plain.len + ' hex=' + plain.hex);
if (plain.text) log(' nat plain utf8=' + plain.text);
}
if (key) log(' nat key=' + key + ' flag=' + flag);
return;
}
if (methodName === 'vuwuuuwv' && sig.indexOf('[B[B') >= 0) {
const a0 = jniReadByteArray(envPtr, args[2]);
const a1 = jniReadByteArray(envPtr, args[3]);
if (a0) log(' nat defense in0 len=' + a0.len + ' hex=' + a0.hex);
if (a1) log(' nat defense in1 len=' + a1.len + ' hex=' + a1.hex);
}
}
function hexPreview(arr, limit) {
if (!arr) return '';
const n = Math.min(arr.length, limit || 64);
let hex = '';
for (let i = 0; i < n; i++) {
const b = (arr[i] & 0xff).toString(16);
hex += (b.length === 1 ? '0' : '') + b;
}
if (arr.length > n) hex += '...(' + arr.length + ')';
return hex;
}
function dumpBytes(label, jobj) {
if (jobj === null || jobj === undefined) {
log(label + ' = null');
return;
}
try {
const arr = Java.cast(jobj, Java.use('[B'));
let text = '';
try {
text = Java.use('java.lang.String').$new(arr, 'UTF-8').toString();
} catch (e) {
text = '';
}
const printable = text.length > 0 && text.indexOf('\u0000') < 0;
if (printable && (text.indexOf('rdVerifyInfo') >= 0 || text.indexOf('REGISTRATION') >= 0
|| text.indexOf('deviceFingerprint') >= 0 || text.length < MAX_STR)) {
const show = text.length > MAX_STR ? text.substring(0, MAX_STR) + '...' : text;
log(label + ' byte[' + arr.length + '] utf8=' + show);
} else {
log(label + ' byte[' + arr.length + '] hex=' + hexPreview(arr, 48));
}
} catch (e) {
log(label + ' dump err=' + e);
}
}
function dumpJava(label, obj) {
if (obj === null || obj === undefined) {
log(label + ' = null');
return;
}
try {
const cls = obj.getClass().getName();
if (cls === '[B') {
dumpBytes(label, obj);
return;
}
if (cls === 'java.lang.String') {
const s = Java.cast(obj, Java.use('java.lang.String')).toString();
const show = s.length > MAX_STR ? s.substring(0, MAX_STR) + '...' : s;
log(label + ' String(' + s.length + ') ' + show);
return;
}
if (cls === '[Ljava.lang.String;') {
const arr = Java.cast(obj, Java.use('[Ljava.lang.String;'));
log(label + ' String[' + arr.length + ']');
for (let i = 0; i < arr.length; i++) dumpJava(label + '[' + i + ']', arr[i]);
return;
}
if (cls === '[[B') {
const outer = Java.cast(obj, Java.use('[[B'));
log(label + ' byte[][] len=' + outer.length);
for (let i = 0; i < outer.length; i++) dumpBytes(label + '[' + i + ']', outer[i]);
return;
}
log(label + ' ' + cls + ' = ' + obj.toString());
} catch (e) {
log(label + ' err=' + e);
}
}
function shouldLogRegisterText(s) {
if (!s) return false;
const low = s.toLowerCase();
return low.indexOf('register') >= 0 || low.indexOf('rdverifyinfo') >= 0
|| low.indexOf('datakey') >= 0 || low.indexOf('fingerprint') >= 0
|| low.indexOf('3100012') >= 0 || s.indexOf('|') >= 0;
}
/* ---------- native: dlopen + RegisterNatives ---------- */
function moduleExport(moduleName, symbol) {
if (typeof Module.getExportByName === 'function') {
try {
return Module.getExportByName(moduleName, symbol);
} catch (e) {
return null;
}
}
if (typeof Module.findExportByName === 'function') {
return Module.findExportByName(moduleName, symbol);
}
return null;
}
function moduleByName(name) {
if (typeof Process.getModuleByName === 'function') {
try {
return Process.getModuleByName(name);
} catch (e) {
return null;
}
}
if (typeof Process.findModuleByName === 'function') {
return Process.findModuleByName(name);
}
return null;
}
function moduleByAddress(addr) {
if (typeof Process.getModuleByAddress === 'function') {
try {
return Process.getModuleByAddress(addr);
} catch (e) {
return null;
}
}
if (typeof Process.findModuleByAddress === 'function') {
return Process.findModuleByAddress(addr);
}
return null;
}
function hookDlopen() {
const names = ['android_dlopen_ext', '__loader_android_dlopen_ext', 'dlopen'];
names.forEach(function (sym) {
const addr = moduleExport(null, sym);
if (!addr) return;
Interceptor.attach(addr, {
onEnter(args) {
try {
this.path = args[0].readCString();
} catch (e) {
this.path = '';
}
},
onLeave() {
if (!this.path) return;
SO_WATCH.forEach(function (so) {
if (this.path.indexOf(so) >= 0) log('dlopen ' + this.path);
}, this);
},
});
log('hooked ' + sym);
});
}
function findRegisterNatives() {
const art = moduleByName('libart.so');
if (!art) return null;
let found = null;
art.enumerateSymbols().forEach(function (sym) {
if (found) return;
const n = sym.name;
if (n.indexOf('RegisterNatives') >= 0
&& n.indexOf('CheckJNI') < 0
&& n.indexOf('art') >= 0) {
found = sym.address;
}
});
return found;
}
function hookNativePtr(className, methodName, sig, fnPtr) {
const key = fnPtr.toString();
if (HOOKED_NATIVE_PTRS[key]) return;
HOOKED_NATIVE_PTRS[key] = true;
const mod = moduleByAddress(fnPtr);
const modName = mod ? mod.name : '?';
const off = mod ? fnPtr.sub(mod.base) : fnPtr;
log('RegisterNatives HOOK ' + className + '.' + methodName + sig
+ ' @ ' + modName + '+0x' + off.toString(16));
try {
Interceptor.attach(fnPtr, {
onEnter(args) {
this.mname = methodName;
this.msig = sig;
this.env = args[0];
log('native>> ' + className + '.' + methodName + sig);
dumpNativeArgs(methodName, sig, this.env, args);
},
onLeave(retval) {
log('native<< ' + className + '.' + methodName + ' ret=' + retval);
},
});
} catch (e) {
log('Interceptor.attach fail ' + methodName + ': ' + e);
}
}
function resolveJClassName(jclassPtr) {
if (typeof Java === 'undefined' || !Java.available) {
return '';
}
let className = '';
const run = (typeof Java.performNow === 'function') ? Java.performNow : Java.perform;
try {
run(function () {
className = Java.cast(jclassPtr, Java.use('java.lang.Class')).getName();
});
} catch (e) {
className = '';
}
return className;
}
function isInterestingSo(modName) {
if (!modName) return false;
return modName.indexOf('shpssdk') >= 0
|| modName.indexOf('sdkutils') >= 0
|| modName.indexOf('bkutils') >= 0;
}
function hookRegisterNatives() {
const addr = findRegisterNatives();
if (!addr) {
log('RegisterNatives symbol not found');
return;
}
Interceptor.attach(addr, {
onEnter(args) {
const count = args[3].toInt32();
const methods = args[2];
const clazz = args[1];
const className = resolveJClassName(clazz) || '<unknown>';
const classHit = className.indexOf('shpssdk') >= 0
|| className.indexOf('jni.utils') >= 0
|| className.indexOf('bke.lib.jni') >= 0;
const ptrSize = Process.pointerSize;
let loggedClass = false;
for (let i = 0; i < count; i++) {
const base = methods.add(i * ptrSize * 3);
const name = base.readPointer().readCString();
const sig = base.add(ptrSize).readPointer().readCString();
const fnPtr = base.add(ptrSize * 2).readPointer();
const mod = moduleByAddress(fnPtr);
const modName = mod ? mod.name : '';
if (!classHit && !isInterestingSo(modName)) continue;
if (!loggedClass) {
log('RegisterNatives class=' + className + ' count=' + count);
loggedClass = true;
}
log(' JNI ' + name + sig + ' -> ' + fnPtr + ' (' + modName + ')');
hookNativePtr(className, name, sig, fnPtr);
}
},
});
log('hooked RegisterNatives @ ' + addr);
}
/* ---------- Java: hook static native + key methods ---------- */
function hookJavaMethod(className, clazz, methodName, isNative, retName, isStatic) {
try {
const overloads = clazz[methodName].overloads;
overloads.forEach(function (ovl) {
ovl.implementation = function () {
const args = [].slice.call(arguments);
log('Java>> ' + className + '.' + methodName
+ (isStatic ? ' static' : '')
+ (isNative ? ' native' : '') + ' args=' + args.length);
args.forEach(function (a, idx) { dumpJava(' in' + idx, a); });
const ret = ovl.apply(this, args);
if (retName === 'void') {
log('Java<< ' + methodName + ' void');
} else if (retName === '[B') {
dumpBytes(' out', ret);
} else if (retName === 'java.lang.String') {
dumpJava(' out', ret);
} else if (retName === '[[B') {
dumpJava(' out', ret);
} else if (retName === 'boolean' || retName === 'int' || retName === 'long') {
log(' out=' + ret);
} else {
dumpJava(' out', ret);
}
return ret;
};
});
log('hooked ' + className + '.' + methodName + ' overloads=' + overloads.length
+ (isNative ? ' native' : '') + (isStatic ? ' static' : ''));
return 1;
} catch (e) {
return 0;
}
}
function hookClassMethods(className, staticOnly, instanceOnly) {
let clazz;
try {
clazz = Java.use(className);
} catch (e) {
log('skip Java class ' + className + ': ' + e);
return 0;
}
const Modifier = Java.use('java.lang.reflect.Modifier');
const declared = clazz.class.getDeclaredMethods();
let hooked = 0;
for (let i = 0; i < declared.length; i++) {
const m = declared[i];
const isStatic = Modifier.isStatic(m.getModifiers());
if (staticOnly && !isStatic) continue;
if (instanceOnly && isStatic) continue;
const methodName = m.getName();
const isNative = Modifier.isNative(m.getModifiers());
const retName = m.getReturnType().getName();
hooked += hookJavaMethod(className, clazz, methodName, isNative, retName, isStatic);
}
return hooked;
}
function hookShpsSdkFacade() {
try {
const SHPSSDK = Java.use('com.shopee.shpssdkbank.SHPSSDK');
['getRiskToken', 'getRiskSync', 'requestDefense', 'assessRisk'].forEach(function (name) {
if (!SHPSSDK[name]) return;
SHPSSDK[name].overloads.forEach(function (ovl) {
ovl.implementation = function () {
const args = [].slice.call(arguments);
log('Java>> SHPSSDK.' + name);
args.forEach(function (a, idx) { dumpJava(' in' + idx, a); });
const ret = ovl.apply(this, args);
dumpJava(' out', ret);
return ret;
};
});
log('hooked SHPSSDK.' + name);
});
} catch (e) {
log('SHPSSDK facade skip: ' + e);
}
}
function hookOkHttp() {
try {
const RealCall = Java.use('okhttp3.RealCall');
RealCall.execute.implementation = function () {
const req = this.request();
const url = req.url().toString();
if (url.indexOf('register') >= 0 || url.indexOf('dfp') >= 0 || url.indexOf('uapi') >= 0) {
log('HTTP>> ' + req.method() + ' ' + url);
}
const resp = this.execute.call(this);
if (url.indexOf('register') >= 0) {
try {
const peek = resp.peekBody(Java.use('java.lang.Long').parseLong('1048576'));
log('HTTP<< register ' + peek.string());
} catch (e) {
log('HTTP<< register peek err=' + e);
}
}
return resp;
};
log('hooked OkHttp RealCall.execute');
} catch (e) {
log('OkHttp skip: ' + e);
}
}
function hookGsonRegister() {
try {
const Gson = Java.use('com.google.gson.Gson');
Gson.toJson.overload('java.lang.Object').implementation = function (obj) {
const ret = this.toJson(obj);
if (shouldLogRegisterText(ret)) {
const show = ret.length > MAX_STR ? ret.substring(0, MAX_STR) + '...' : ret;
log('Gson.toJson REGISTRATION len=' + ret.length + ' ' + show);
}
return ret;
};
log('hooked Gson.toJson');
} catch (e) {
log('Gson skip: ' + e);
}
}
function hookRiskTokenEntry() {
try {
const V = Java.use('com.shopee.shpssdkbank.uwuvuvvww.vvuuuuvvv');
hookJavaMethod(
'com.shopee.shpssdkbank.uwuvuvvww.vvuuuuvvv',
V, 'wwvuwuwvu', false, 'java.lang.String', true);
} catch (e) {
log('vvuuuuvvv skip: ' + e);
}
}
function hookEncryptHelper() {
hookClassMethods('com.shopee.bke.lib.jni.utils.uvwwwwuv', false, true);
}
function isAdbSettingKey(key) {
if (!key) return false;
const lower = key.toLowerCase();
return lower.indexOf('adb') >= 0
|| lower === 'development_settings_enabled'
|| lower.indexOf('wireless_debug') >= 0;
}
function hookAdbBypassJava() {
try {
const fakeInt = function (key) {
if (isAdbSettingKey(key)) {
log('fake Settings int ' + key + ' -> 0');
return 0;
}
return null;
};
const fakeStr = function (key) {
if (isAdbSettingKey(key)) {
log('fake Settings str ' + key + ' -> 0');
return '0';
}
return null;
};
['Global', 'Secure', 'System'].forEach(function (bucket) {
const Cls = Java.use('android.provider.Settings$' + bucket);
Cls.getInt.overloads.forEach(function (ovl) {
ovl.implementation = function () {
const key = arguments[1];
const f = fakeInt(String(key));
if (f !== null) return f;
return ovl.apply(this, arguments);
};
});
if (Cls.getString) {
Cls.getString.overloads.forEach(function (ovl) {
ovl.implementation = function () {
const key = arguments[1];
const f = fakeStr(String(key));
if (f !== null) return f;
return ovl.apply(this, arguments);
};
});
}
});
const SysProp = Java.use('android.os.SystemProperties');
SysProp.get.overload('java.lang.String').implementation = function (key) {
if (key === 'init.svc.adbd' || key === 'init.svc.adb_wifi') {
log('fake SystemProperties ' + key + ' -> stopped');
return 'stopped';
}
return this.get(key);
};
log('hooked ADB Settings/SystemProperties bypass');
} catch (e) {
log('ADB Java bypass skip: ' + e);
}
}
function installJavaHooks() {
hookAdbBypassJava();
let total = 0;
JAVA_TARGETS.forEach(function (cn) {
total += hookClassMethods(cn, true, false);
});
hookRiskTokenEntry();
hookEncryptHelper();
hookShpsSdkFacade();
hookOkHttp();
hookGsonRegister();
log('Java hooks installed methods=' + total + ' pid=' + Process.id);
log('READY SG — Sign up -> +65 -> Next (watch native>> / Gson / HTTP)');
}
function waitForJava(n) {
n = n || 0;
if (typeof Java === 'undefined' || !Java.available) {
if (n % 10 === 0) log('waiting Java.available attempt=' + n);
setTimeout(function () { waitForJava(n + 1); }, 500);
return;
}
Java.perform(function () {
installJavaHooks();
});
}
setImmediate(function () {
log('SG native trace loaded pid=' + Process.id);
hookDlopen();
hookRegisterNatives();
waitForJava(0);
});

View File

@@ -0,0 +1,74 @@
/**
* Diagnostic: only LOG exit-related calls, do not block.
*/
"use strict";
function log(msg) { send("[TNG-diag] " + msg); }
function findExport(mod, name) {
try {
var m = Process.findModuleByName(mod);
if (m) { var a = m.findExportByName(name); if (a) return a; }
} catch (e) {}
try { return Module.getGlobalExportByName(name); } catch (e2) { return null; }
}
function bt(ctx) {
try {
return Thread.backtrace(ctx, Backtracer.FUZZY).map(DebugSymbol.fromAddress).slice(0, 8).join(" <- ");
} catch (e) { return "?"; }
}
["_exit", "exit", "abort", "quick_exit"].forEach(function (n) {
var a = findExport("libc.so", n);
if (!a) return;
Interceptor.attach(a, {
onEnter: function (args) {
log("CALL " + n + "(" + args[0] + ") " + bt(this.context));
}
});
log("watch " + n + " @ " + a);
});
["kill", "tgkill", "raise"].forEach(function (n) {
var a = findExport("libc.so", n);
if (!a) return;
Interceptor.attach(a, {
onEnter: function (args) {
log("CALL " + n + "(" + args[0] + "," + args[1] + ") " + bt(this.context));
}
});
});
var sys = findExport("libc.so", "syscall");
if (sys) {
Interceptor.attach(sys, {
onEnter: function (args) {
var nr = args[0].toInt32();
if (nr === 93 || nr === 94 || nr === 129 || nr === 131) {
log("CALL syscall(" + nr + ") " + bt(this.context));
}
}
});
}
// count mprotect EXEC
var mp = findExport("libc.so", "mprotect");
if (mp) {
Interceptor.attach(mp, {
onEnter: function (args) {
if (args[2].toInt32() & 4) {
log("mprotect EXEC " + args[0] + " len=" + args[1]);
}
}
});
}
log("diag ready pid=" + Process.id);
setTimeout(function () {
var n = 0;
Process.enumerateRanges("r-x").forEach(function (r) {
var file = r.file ? r.file.path : "anon";
if (file.indexOf("/system") === 0 || file.indexOf("/apex") === 0) return;
n++;
log("RX " + file + " " + r.base + " +" + r.size);
});
log("app RX ranges=" + n);
}, 800);

View File

@@ -0,0 +1,282 @@
/**
* TNG — catch Promon exit after runtime code decrypt (mmap/mprotect RX).
* Frida 17 compatible.
*/
"use strict";
function log(msg) {
send("[TNG-native] " + msg);
}
function findExport(moduleName, name) {
try {
if (moduleName) {
var m = Process.findModuleByName(moduleName);
if (m) {
var a = m.findExportByName(name);
if (a) return a;
}
}
} catch (e) {}
try {
return Module.getGlobalExportByName(name);
} catch (e2) {
return null;
}
}
var patched = {};
function looksLikeExitSetup(addr) {
for (var i = 1; i <= 12; i++) {
try {
var w = addr.sub(i * 4).readU32();
var opc = w & 0xff800000;
if (opc === 0x52800000 || opc === 0xd2800000) {
var rd = w & 0x1f;
var imm = (w >> 5) & 0xffff;
if (rd === 8 && (imm === 93 || imm === 94)) return imm;
}
// mov x8, xN then earlier load — also catch svc after mov x0, #imm (exit code)
if ((w & 0xffe0ffff) === 0xaa0003e8) return 8; // mov x8, x0.. pattern loose
} catch (e) {}
}
return 0;
}
function patchRegion(base, size, tag) {
if (size <= 0 || size > 64 * 1024 * 1024) return;
var key = base + ":" + size;
if (patched[key]) return;
patched[key] = true;
var nop = [0x1f, 0x20, 0x03, 0xd5];
var n = 0;
var totalSvc = 0;
try {
// only scan 4-byte aligned by walking manually for reliability
var end = base.add(size - 4);
for (var p = base; p.compare(end) <= 0; p = p.add(4)) {
var w;
try {
w = p.readU32();
} catch (e) {
break;
}
if (w !== 0xd4000001) continue; // svc #0
totalSvc++;
var kind = looksLikeExitSetup(p);
if (!kind) continue;
try {
Memory.protect(p, 4, "rwx");
p.writeByteArray(nop);
n++;
log("patched exit SVC#" + kind + " @ " + p + " [" + tag + "]");
} catch (e2) {
log("patch err " + p + ": " + e2);
}
}
if (totalSvc > 0) {
log("region " + tag + " svc#0=" + totalSvc + " patched=" + n + " size=" + size);
}
} catch (e) {
log("scan err " + tag + ": " + e);
}
}
function scanAllExecutable(tag) {
Process.enumerateRanges("r-x").forEach(function (r) {
var file = r.file ? r.file.path : "anon";
// skip system libs except if anonymous / app
if (file.indexOf("/system/") === 0 || file.indexOf("/apex/") === 0) return;
if (file.indexOf("frida") >= 0) return;
patchRegion(r.base, r.size, tag + ":" + file);
});
}
function installLibcExitHooks() {
function blockExit(name, address) {
try {
Interceptor.replace(
address,
new NativeCallback(
function (code) {
log("BLOCKED " + name + "(" + (code | 0) + ")");
},
"void",
["int"]
)
);
log("replaced " + name);
} catch (e) {
Interceptor.attach(address, {
onEnter: function (args) {
log("BLOCKED(attach) " + name + "(" + args[0].toInt32() + ")");
while (true) Thread.sleep(60);
},
});
log("attached " + name);
}
}
["_exit", "exit", "abort", "quick_exit"].forEach(function (n) {
var a = findExport("libc.so", n);
if (a) blockExit(n, a);
});
["kill", "tgkill", "pthread_kill", "raise"].forEach(function (n) {
var a = findExport("libc.so", n);
if (!a) return;
Interceptor.attach(a, {
onEnter: function (args) {
var pid = args[0].toInt32();
var sig = args[1].toInt32();
if ((pid === Process.id || pid === 0 || pid === -1) &&
(sig === 9 || sig === 15 || sig === 6 || sig === 5)) {
log("BLOCKED " + n + " sig=" + sig);
args[1] = ptr(0);
}
},
});
});
var sys = findExport("libc.so", "syscall");
if (sys) {
Interceptor.attach(sys, {
onEnter: function (args) {
var nr = args[0].toInt32();
if (nr === 93 || nr === 94) {
log("BLOCKED syscall exit " + nr);
args[0] = ptr(-1);
}
},
});
}
log("libc hooks OK");
}
function installMprotectWatcher() {
var mprotect = findExport("libc.so", "mprotect");
var mmap = findExport("libc.so", "mmap");
if (mprotect) {
Interceptor.attach(mprotect, {
onEnter: function (args) {
this.addr = args[0];
this.len = args[1].toInt32();
this.prot = args[2].toInt32();
},
onLeave: function () {
// PROT_EXEC = 4
if (this.prot & 4) {
log("mprotect+EXEC " + this.addr + " len=" + this.len);
patchRegion(this.addr, this.len, "mprotect");
}
},
});
}
if (mmap) {
Interceptor.attach(mmap, {
onEnter: function (args) {
this.len = args[1].toInt32();
this.prot = args[2].toInt32();
},
onLeave: function (retval) {
if ((this.prot & 4) && !retval.isNull()) {
log("mmap+EXEC " + retval + " len=" + this.len);
patchRegion(retval, this.len, "mmap");
}
},
});
}
log("mprotect/mmap watchers OK");
}
function installMapsHide() {
var markers = ["frida", "gadget", "xposed", "lsposed", "vector", "zygisk", "magisk", "liblspd"];
var tracked = {};
function hide(line) {
var l = (line || "").toLowerCase();
for (var i = 0; i < markers.length; i++) if (l.indexOf(markers[i]) >= 0) return true;
return false;
}
function filter(buf, len) {
try {
var text = buf.readUtf8String(len);
if (!text) return len;
var out = text.split("\n").filter(function (x) { return !hide(x); }).join("\n");
var bytes = Memory.allocUtf8String(out);
var n = Math.min(len, out.length);
Memory.copy(buf, bytes, n);
return n;
} catch (e) {
return len;
}
}
var openat = findExport("libc.so", "openat");
var readFn = findExport("libc.so", "read");
if (openat) {
Interceptor.attach(openat, {
onEnter: function (args) {
this.path = args[1].isNull() ? null : args[1].readCString();
},
onLeave: function (retval) {
var fd = retval.toInt32();
if (fd >= 0 && this.path && this.path.indexOf("maps") >= 0) tracked[fd] = 1;
},
});
}
if (readFn) {
Interceptor.attach(readFn, {
onEnter: function (args) {
this.fd = args[0].toInt32();
this.buf = args[1];
},
onLeave: function (retval) {
var n = retval.toInt32();
if (n > 0 && tracked[this.fd]) retval.replace(ptr(filter(this.buf, n)));
},
});
}
log("maps hide OK");
}
function installJavaGuards() {
if (typeof Java === "undefined") {
setTimeout(installJavaGuards, 500);
return;
}
Java.perform(function () {
try {
Java.use("java.lang.System").exit.implementation = function (c) {
log("Java System.exit(" + c + ") blocked");
};
} catch (e) {}
try {
var R = Java.use("java.lang.Runtime");
R.exit.overload("int").implementation = function (c) {
log("Java Runtime.exit(" + c + ") blocked");
};
} catch (e) {}
try {
var P = Java.use("android.os.Process");
P.killProcess.implementation = function (pid) {
if (pid === P.myPid()) {
log("Java killProcess(self) blocked");
return;
}
return this.killProcess(pid);
};
} catch (e) {}
log("Java guards OK");
});
}
log("load pid=" + Process.id);
installLibcExitHooks();
installMapsHide();
installMprotectWatcher();
scanAllExecutable("boot");
setInterval(function () {
scanAllExecutable("tick");
}, 1000);
installJavaGuards();
log("ready");

View File

@@ -0,0 +1,147 @@
"use strict";
/*
* TNG eWallet — 定位 TigerTally(libtiger_tally.so) 启动期 fread 阻塞。观察不改行为。
*
* 背景(ANR 栈): CaptchaInitializer → TigerTallyAPI.init → t.B.genericNt1(native)
* → libtiger_tally.so (mNYjyzyN23) → fread → __sread → read 永远读不到数据
*/
const TIGER_SO = "libtiger_tally.so";
const LIBC = Process.getModuleByName("libc.so");
const readlink = new NativeFunction(
LIBC.findExportByName("readlink"), "long", ["pointer", "pointer", "ulong"]);
/* ---- Tiger 模块范围(热路径缓存,每 2s 刷新一次) ---- */
let tigerMod = null;
function refreshTiger() {
const m = Process.findModuleByName(TIGER_SO);
if (m) tigerMod = m;
return !!tigerMod;
}
function inTiger(addr) {
if (!addr) return false;
if (!tigerMod) return false;
return addr.compare(tigerMod.base) >= 0 && addr.compare(tigerMod.base.add(tigerMod.size)) < 0;
}
setInterval(() => { refreshTiger(); }, 2000);
function resolveFd(fd) {
try {
const link = Memory.allocUtf8String(`/proc/self/fd/${fd}`);
const out = Memory.alloc(256);
const n = readlink(link, out, 256);
if (n > 0) return out.readUtf8String(Math.min(n, 255));
} catch (e) { /* ignore */ }
return "?";
}
function fdKind(fd) {
const p = resolveFd(fd);
if (p.indexOf("socket:") === 0) return "SOCKET " + p;
if (p.indexOf("pipe:") === 0) return "PIPE " + p;
if (p.indexOf("anon_inode:") === 0) return "ANON " + p;
return p;
}
function threadName() {
try { return Process.getCurrentThreadName(); } catch (e) { return "?"; }
}
function fmtAddr(a) { return a ? a.toString(16) : "?"; }
const stats = {}; // tid -> info
function bump(fd, kind, ret) {
const tid = Process.getCurrentThreadId();
let s = stats[tid];
if (!s) { s = { name: threadName(), reads: 0, lastFd: fd, lastFdKind: kind, lastRet: ret }; stats[tid] = s; }
s.name = threadName();
s.reads++;
s.lastFd = fd;
s.lastFdKind = kind;
s.lastRet = ret;
}
/* ---- fread: FILE* 第4参数; bionic __sFILE._file 偏移约 18 ---- */
Interceptor.attach(LIBC.findExportByName("fread"), {
onEnter(args) {
const caller = this.returnAddress;
if (!inTiger(caller)) return;
const fp = args[3];
let fd = -1;
for (const off of [18, 16, 24, 20]) {
try { const v = fp.add(off).readU16(); if (v > 0 && v < 4096) { fd = v; break; } }
catch (e) { /* try next */ }
}
const kind = fd > 0 ? fdKind(fd) : "?";
bump(fd, kind, "pending");
console.log(`[FREAD] tid=${Process.getCurrentThreadId()} "${threadName()}" caller=${fmtAddr(caller)} fd=${fd} kind=${kind}`);
},
onLeave(ret) {
if (!inTiger(this.returnAddress)) return;
bump(-1, "", ret.toInt32());
console.log(`[FREAD-LEAVE] tid=${Process.getCurrentThreadId()} ret=${ret.toInt32()}`);
}
});
/* ---- read: 只观察调用者位于 libtiger_tally.so 的 ---- */
Interceptor.attach(LIBC.findExportByName("read"), {
onEnter(args) {
const caller = this.returnAddress;
if (!inTiger(caller)) return;
const fd = args[0].toInt32();
const kind = fdKind(fd);
bump(fd, kind, "?");
console.log(`[READ] tid=${Process.getCurrentThreadId()} "${threadName()}" caller=${fmtAddr(caller)} fd=${fd} kind=${kind}`);
},
onLeave(ret) {
if (!inTiger(this.returnAddress)) return;
const tid = Process.getCurrentThreadId();
const s = stats[tid];
const fd = s ? s.lastFd : -1;
const r = ret.toInt32();
if (s) s.lastRet = r;
if (r < 0) console.log(`[READ-RET] tid=${tid} fd=${fd} ret=${r} (blocked/error)`);
else if (r === 0) console.log(`[READ-EOF] tid=${tid} fd=${fd} ret=0 (EOF/closed)`);
else console.log(`[READ-RET] tid=${tid} fd=${fd} ret=${r}`);
}
});
/* ---- 周期 dump ---- */
function dumpThreads() {
try {
const dir = new File("/proc/self/task", "r");
const entries = dir.list();
dir.close();
let relevant = [];
for (const e of entries) {
let nm = "?";
try { const nf = new File(`/proc/self/task/${e}/comm`, "r"); nm = nf.readString().trim(); nf.close(); } catch (err) {}
const s = stats[e] || null;
const lower = nm.toLowerCase();
if (lower.indexOf("location") >= 0 || lower.indexOf("tally") >= 0 || s) {
let info = `tid=${e} "${nm}"`;
if (s) info += ` tigerReads=${s.reads} lastFd=${s.lastFd} kind=${s.lastFdKind} lastRet=${s.lastRet}`;
relevant.push(info);
}
}
console.log(`[DUMP] tiger-fread threads: ${relevant.length ? relevant.join(" | ") : "(none)"}`);
} catch (e) {
console.log(`[DUMP] failed: ${e}`);
}
}
setInterval(() => {
dumpThreads();
try {
const dir = new File("/proc/self/fd", "r");
const fds = dir.list();
dir.close();
let pipes = [], socks = [];
for (const f of fds) {
const kind = fdKind(parseInt(f, 10));
if (kind.indexOf("PIPE") === 0) pipes.push(f + ":" + kind.split(" ").slice(1).join(" "));
if (kind.indexOf("SOCKET") === 0) socks.push(f + ":" + kind.split(" ").slice(1).join(" "));
}
if (pipes.length) console.log(`[DUMP-FD] pipes: ${pipes.join(" | ")}`);
if (socks.length) console.log(`[DUMP-FD] sockets: ${socks.join(" | ")}`);
} catch (e) { /* ignore */ }
}, 3000);
console.log("[TIGER-FREAD] armed (observe-only)");

View File

View File

@@ -0,0 +1,41 @@
#!/usr/bin/env python3
"""Dump aliyun captcha method signatures from TNG APK."""
import zipfile
import re
import sys
apk = r"C:\Users\Administrator\Desktop\notiMessage\reverse\dumps\tng_1.9.10_base.apk"
targets = [
b"Lcom/aliyun/captcha/Captcha;",
b"Lcom/aliyun/captcha/CaptchaWebViewDialog;",
b"Lcom/aliyun/captcha/Captcha$VerificationCallback;",
b"Lcom/aliyun/captcha/CaptchaWebViewDialog$CaptchaCompletionListener;",
b"Lcom/aliyun/captcha/a;",
b"Lcom/aliyun/captcha/b;",
b"Lcom/aliyun/captcha/c;",
]
# Method refs: Lclass;->name(args)ret
pat = re.compile(
rb"(Lcom/aliyun/captcha/[A-Za-z0-9_/$]+;)->([A-Za-z0-9_<>$]+)\(([^)]*)\)([A-Za-z0-9_/;$[\]-]+)"
)
found = {t.decode(): set() for t in targets}
with zipfile.ZipFile(apk) as z:
for n in z.namelist():
if not n.endswith(".dex"):
continue
data = z.read(n)
for m in pat.finditer(data):
clazz = m.group(1).decode()
if clazz not in found:
continue
name = m.group(2).decode()
args = m.group(3).decode()
ret = m.group(4).decode()
found[clazz].add(f"{name}({args}){ret}")
for clazz, methods in found.items():
print("====", clazz, "n=", len(methods))
for s in sorted(methods):
print(" ", s)

View File

@@ -0,0 +1,81 @@
# -*- coding: utf-8 -*-
"""Scan TNG APK for CallingCode / country UI / HW-related classes."""
import re
import sys
import zipfile
APK = r"C:\Users\Administrator\Desktop\notiMessage\reverse\dumps\tng_base_scan.apk"
NEEDLES = [
b"UserSearchCallingCodeActivity",
b"CallingCode",
b"ll_country",
b"BottomSelect",
b"BottomSelectDialogFragment",
b"ftv_title",
b"i7.l",
b"enableHardwareAcceleration",
b"FLAG_HARDWARE_ACCELERATED",
]
def main():
z = zipfile.ZipFile(APK)
print("=== DEX STRING HITS ===")
for name in sorted(z.namelist()):
if not name.endswith(".dex"):
continue
data = z.read(name)
hits = [s.decode() for s in NEEDLES if s in data]
if hits:
print("%s -> %s" % (name, hits))
print("\n=== CLASS NAMES (CallingCode / BottomSelect / country) ===")
pat = re.compile(
rb"L[a-zA-Z0-9_$/]*(?:CallingCode|BottomSelect|Country|country)[a-zA-Z0-9_$/]*;"
)
found = set()
for name in sorted(z.namelist()):
if not name.endswith(".dex"):
continue
data = z.read(name)
for m in pat.findall(data):
found.add(m.decode("ascii", "ignore")[1:-1].replace("/", "."))
for c in sorted(found):
print(" ", c)
print("\n=== CONTEXT AROUND UserSearchCallingCodeActivity ===")
target = b"UserSearchCallingCodeActivity"
for name in sorted(z.namelist()):
if not name.endswith(".dex"):
continue
data = z.read(name)
start = 0
n = 0
while True:
idx = data.find(target, start)
if idx < 0:
break
s = max(0, idx - 80)
e = min(len(data), idx + len(target) + 120)
chunk = re.sub(rb"[^\x20-\x7e]+", b".", data[s:e])
print("[%s @%d] %s" % (name, idx, chunk.decode("ascii", "ignore")))
start = idx + 1
n += 1
if n >= 8:
break
# Manifest component
print("\n=== ANDROIDMANIFEST snippets ===")
try:
# binary manifest — just search utf16/utf8 remnants in apk
data = z.read("AndroidManifest.xml")
for key in (b"CallingCode", b"hardwareAccelerated", b"user.view"):
if key in data or key.decode().encode("utf-16le") in data:
print(" manifest contains", key)
except KeyError:
pass
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,68 @@
# -*- coding: utf-8 -*-
"""Deeper scan: UserSearchCallingCodeActivity methods / Compose / launch."""
import re
import zipfile
APK = r"C:\Users\Administrator\Desktop\notiMessage\reverse\dumps\tng_base_scan.apk"
def strings_near(data, needle, radius=200, limit=15):
out = []
start = 0
while len(out) < limit:
idx = data.find(needle, start)
if idx < 0:
break
s = max(0, idx - radius)
e = min(len(data), idx + len(needle) + radius)
chunk = re.sub(rb"[^\x20-\x7e]+", b".", data[s:e])
out.append(chunk.decode("ascii", "ignore"))
start = idx + 1
return out
def main():
z = zipfile.ZipFile(APK)
# Collect interesting strings from classes that have CallingCode
keys = [
b"CallingListScreen",
b"setContent",
b"ComposeView",
b"AbstractComposeView",
b"ComponentActivity",
b"getCallingCodeList",
b"CountryListRepository",
b"startActivity",
b"UserSearchCallingCodeActivity",
b"ll_country",
b"hardwareAccelerated",
b"RecyclerView",
b"LazyColumn",
b"androidx/compose",
]
for dex in sorted(n for n in z.namelist() if n.endswith(".dex")):
data = z.read(dex)
if b"UserSearchCallingCodeActivity" not in data and b"CallingListScreen" not in data and b"ll_country" not in data:
continue
print("\n========", dex, "========")
for k in keys:
if k in data:
print("HAS", k.decode())
if b"CallingListScreen" in data:
print("--- CallingListScreen ctx ---")
for c in strings_near(data, b"CallingListScreen", 120, 6):
print(" ", c[:240])
if b"ll_country" in data:
print("--- ll_country ctx ---")
for c in strings_near(data, b"ll_country", 100, 8):
print(" ", c[:240])
# Who references UserSearchCallingCodeActivity (launchers)
print("\n=== who references UserSearchCallingCodeActivity class desc ===")
desc = b"Lmy/com/tngdigital/user/view/UserSearchCallingCodeActivity;"
for dex in sorted(n for n in z.namelist() if n.endswith(".dex")):
data = z.read(dex)
count = data.count(desc)
if count:
print(dex, "count=", count)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,16 @@
import re
import zipfile
apk = r"C:\Users\Administrator\Desktop\notiMessage\reverse\dumps\tng_1.9.10_base.apk"
z = zipfile.ZipFile(apk)
pat = re.compile(rb"Lcom/aliyun/captcha/[A-Za-z0-9_/$]+;")
found = set()
for n in z.namelist():
if not n.endswith(".dex"):
continue
data = z.read(n)
for m in pat.findall(data):
found.add(m.decode())
for c in sorted(found):
print(c)
print("total", len(found))

View File

@@ -0,0 +1,59 @@
# -*- coding: utf-8 -*-
"""Compare CallingCode vs CountrySelect activities / intent extras."""
import re
import zipfile
APK = r"C:\Users\Administrator\Desktop\notiMessage\reverse\dumps\tng_base_scan.apk"
def near(data, needle, r=180, lim=12):
out = []
start = 0
while len(out) < lim:
i = data.find(needle, start)
if i < 0:
break
chunk = re.sub(rb"[^\x20-\x7e]+", b".", data[max(0, i - r): i + len(needle) + r])
out.append(chunk.decode("ascii", "ignore"))
start = i + 1
return out
def main():
z = zipfile.ZipFile(APK)
for dex in sorted(n for n in z.namelist() if n.endswith(".dex")):
data = z.read(dex)
if b"UserCountrySelectActivity" not in data and b"UserSearchCallingCodeActivity" not in data:
continue
print("\n====", dex, "====")
for n in (b"UserCountrySelectActivity", b"AbsCountrySelectActivity",
b"newIntent", b"CallingListScreen"):
if n in data:
print("HAS", n.decode())
for n in (b"UserSearchCallingCodeActivity", b"UserCountrySelectActivity"):
if n not in data:
continue
print("--", n.decode(), "--")
for c in near(data, n, 100, 6):
if "Hilt_" in c and ".java" in c:
continue
print(" ", c[:220])
print("\n=== calling/country intent-like strings ===")
seen = set()
for dex in sorted(n for n in z.namelist() if n.endswith(".dex")):
data = z.read(dex)
if b"CallingCode" not in data and b"CountrySelect" not in data:
continue
for m in re.findall(
rb"(?:EXTRA_|KEY_|arg_|ARG_)[A-Za-z0-9_]{2,40}|"
rb"[A-Za-z0-9_]{0,15}(?:calling_code|CallingCode|country_code|CountryCode|countryList)[A-Za-z0-9_]{0,20}",
data):
s = m.decode("ascii", "ignore")
if s not in seen and len(s) > 6:
seen.add(s)
print(" ", s)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,41 @@
import zipfile
import re
z = zipfile.ZipFile(r"reverse/dumps/tng_1.9.10_base.apk")
names = [n for n in z.namelist() if n.endswith(".dex")]
# Find packages that look like Promon (short random package + few classes)
# Also search string markers
markers = [
b"promon",
b"Promon",
b"PROMON",
b"xwwqazamx",
b"Rooting",
b"jailbroken",
b"JailBroken",
b"W:16",
b"libtngdigital_ewallet",
]
for m in markers:
hits = 0
for n in names:
hits += z.read(n).count(m)
print(f"marker {m!r}: {hits}")
# Extract L.../...; type descriptors that contain 'promon' case-insensitive or weird short pkgs
pkg_re = re.compile(rb"L([a-z]{6,12})/([A-Za-z0-9_$]{1,20});")
pkg_counts = {}
for n in names:
data = z.read(n)
for m in pkg_re.findall(data):
pkg = m[0].decode("ascii", errors="ignore")
pkg_counts[pkg] = pkg_counts.get(pkg, 0) + 1
# Show rare short packages (likely obfuscated)
cands = [(p, c) for p, c in pkg_counts.items() if 5 <= c <= 500 and p.isalpha() and len(p) <= 12]
cands.sort(key=lambda x: -x[1])
print("\ncandidate obfuscated packages:")
for p, c in cands[:40]:
print(f" {p}: {c}")

View File

@@ -0,0 +1,41 @@
# -*- coding: utf-8 -*-
import re
import zipfile
from pathlib import Path
APK = Path(__file__).resolve().parent.parent / "apks" / "tng" / "base.apk"
if not APK.exists():
APK = Path(__file__).resolve().parent.parent / "apks" / "tng" / "tng.apk"
acts = set()
ops = set()
xww = set()
tng = set()
with zipfile.ZipFile(str(APK)) as zf:
for name in zf.namelist():
if not name.endswith(".dex"):
continue
data = zf.read(name)
for m in re.finditer(rb"my/com/tngdigital/[a-zA-Z0-9_/]+Activity", data):
acts.add(m.group().decode().replace("/", "."))
for m in re.finditer(rb"com\.(?:abl|tngd|zoloz|alipayplus)\.[a-z0-9.]+", data):
s = m.group().decode("ascii", "ignore")
if any(k in s for k in ("wallet", "otp", "login", "register", "phone", "member", "jail", "customer", "pin", "mobile")):
ops.add(s)
for m in re.finditer(rb"xwwqazamx/[a-zA-Z0-9_]+", data):
xww.add(m.group().decode().replace("/", "."))
print("=== User flow Activities ===")
for a in sorted(acts):
if any(k in a for k in ("User", "Splash", "Guide", "Registration", "Login", "Otp", "Pin", "WebView", "Security")):
print(a)
print("\n=== Promon xwwqazamx classes (sample) ===")
for c in sorted(xww)[:40]:
print(c)
print(f"... total {len(xww)}")
print("\n=== RPC operationTypes (sample) ===")
for o in sorted(ops)[:50]:
print(o)

View File

@@ -0,0 +1,14 @@
import zipfile
import re
z = zipfile.ZipFile(r"reverse/dumps/tng_1.9.10_base.apk")
names = [n for n in z.namelist() if n.endswith(".dex")]
pat = re.compile(rb"Lvhvlnqgy/([^;\s]{1,80});")
found = set()
for n in names:
data = z.read(n)
for m in pat.findall(data):
found.add(m.decode("ascii", errors="ignore"))
print("vhvlnqgy classes", len(found))
for c in sorted(found):
print(f"vhvlnqgy.{c.replace('/', '.')}")

View File

@@ -0,0 +1,24 @@
"""Dump vhvlnqgy bl/R/bd method refs from TNG 1.9.10 dex."""
import re
import zipfile
APK = r"reverse/dumps/tng_1.9.10_base.apk"
with zipfile.ZipFile(APK) as z:
data = b"".join(z.read(n) for n in z.namelist() if n.endswith(".dex"))
for cls in ["Lvhvlnqgy/bl;", "Lvhvlnqgy/R;", "Lvhvlnqgy/bd;", "Lvhvlnqgy/w;", "Lvhvlnqgy/W;", "Lvhvlnqgy/a;"]:
print("===", cls, "===")
refs = sorted(set(re.findall(cls.encode() + rb"->[^\x00]{1,80}", data)))
for r in refs[:30]:
print(r.decode("ascii", "ignore"))
print()
print("=== R callers (who invokes R.a/R.b) ===")
for m in sorted(set(re.findall(rb"Lvhvlnqgy/[^;]+;->[a-zA-Z]+[^\x00]{0,40}Lvhvlnqgy/R;", data))):
print(m.decode("ascii", "ignore"))
print("\n=== bd throw sites ===")
for m in sorted(set(re.findall(rb"[^\x00]{0,40}Lvhvlnqgy/bd;", data))):
s = m.decode("ascii", "ignore")
if "vhvlnqgy" in s:
print(s)

View File

@@ -0,0 +1,15 @@
import re
import zipfile
APK = r"reverse/dumps/tng_1.9.10_base.apk"
with zipfile.ZipFile(APK) as z:
data = b"".join(z.read(n) for n in z.namelist() if n.endswith(".dex"))
for cls in ["Lvhvlnqgy/w;", "Lvhvlnqgy/u;"]:
print("===", cls, "refs ===")
pat = cls.encode() + rb"[^\x00]{0,120}"
hits = sorted(set(re.findall(pat, data)))
for h in hits[:40]:
print(h.decode("ascii", "ignore"))
print("count", len(hits))
print()

View File

@@ -0,0 +1,68 @@
#!/usr/bin/env python3
import json
import time
import urllib.parse
import urllib.request
BASE = "http://127.0.0.1:9090"
def wait_api(retries=15):
for _ in range(retries):
try:
with urllib.request.urlopen(BASE + "/proxies", timeout=3) as r:
return json.loads(r.read())
except Exception:
time.sleep(1)
raise SystemExit("clash api not ready")
def put_proxy(group: str, target: str):
enc_g = urllib.parse.quote(group, safe="")
req = urllib.request.Request(
BASE + "/proxies/" + enc_g,
data=json.dumps({"name": target}).encode(),
method="PUT",
headers={"Content-Type": "application/json"},
)
with urllib.request.urlopen(req, timeout=5) as r:
print("switched", group, "->", target, "status", r.status)
def main():
data = wait_api()
proxies = data.get("proxies", {})
group = None
for gname in ["🚀节点选择", "GLOBAL"]:
if gname in proxies:
group = gname
break
if not group:
raise SystemExit("selector group not found")
print("group=", group, "now=", proxies.get(group, {}).get("now"))
candidates = [
"🇸🇬狮城节点",
"🇸🇬AWS新加坡01 | 电信移动联通推荐",
"🇸🇬新加坡01 | 电信联通推荐",
"🇸🇬新加坡 | 高速专线-hy2",
]
target = next((c for c in candidates if c in proxies), None)
if not target:
for k in proxies:
if "新加坡" in k or "AWS新加坡" in k:
target = k
break
if not target:
raise SystemExit("no SG proxy found")
put_proxy(group, target)
enc_g = urllib.parse.quote(group, safe="")
with urllib.request.urlopen(BASE + "/proxies/" + enc_g, timeout=3) as r:
print("verify now=", json.loads(r.read()).get("now"))
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,114 @@
#!/usr/bin/env python3
"""Download TNG eWallet XAPK from Uptodown eAPI (arm64-v8a)."""
from __future__ import annotations
import json
import re
import subprocess
import sys
import urllib.request
import zipfile
from pathlib import Path
UA = "Mozilla/5.0 (Linux; Android 13) Chrome/120.0.0.0 Mobile Safari/537.36"
APP_CODE = "1000382462"
VERSION = "1.9.10"
ARCH = "arm64-v8a, armeabi-v7a, x86_64"
BASE = "https://touch-n-go-ewallet.en.uptodown.com"
OUT_XAPK = Path("reverse/dumps/tng_1.9.10.xapk")
OUT_DIR = Path("reverse/dumps/tng_xapk_extracted")
def fetch(url: str) -> bytes:
req = urllib.request.Request(url, headers={"User-Agent": UA})
with urllib.request.urlopen(req, timeout=120) as resp:
return resp.read()
def fetch_text(url: str) -> str:
return fetch(url).decode("utf-8", "ignore")
def main() -> int:
versions = json.loads(fetch_text(f"{BASE}/android/apps/{APP_CODE}/versions/1"))
entry = next(x for x in versions["data"] if x["version"] == VERSION)
version_id = entry["versionURL"]["versionID"]
print(f"version {VERSION} fileID={entry['fileID']} kind={entry['kindFile']}")
dl_page = fetch_text(f"{BASE}/android/download/{version_id}")
m = re.search(r'class="button variants" data-version="(\d+)"', dl_page)
if not m:
print("variants data-version not found", file=sys.stderr)
return 1
data_version = m.group(1)
print("data_version", data_version)
files_json = json.loads(fetch_text(f"{BASE}/app/{APP_CODE}/version/{data_version}/files"))
content = files_json.get("content", "")
# parse variant rows from HTML fragment
rows = re.findall(
r'class="variant".*?data-file-id="(\d+)".*?<span>([^<]+)</span>',
content,
flags=re.S,
)
if not rows:
# fallback: any data-file-id near xapk
rows = re.findall(r'data-file-id="(\d+)"', content)
rows = [(rid, "?") for rid in rows]
print("variants", rows)
target_file_id = None
for fid, arch in rows:
if ARCH in arch or "arm64-v8a" in arch:
target_file_id = fid
print("pick", fid, arch)
break
if not target_file_id and rows:
target_file_id = rows[0][0]
print("fallback file_id", target_file_id)
if not target_file_id:
print("no file id", file=sys.stderr)
return 1
variant_page = fetch_text(f"{BASE}/android/download/{target_file_id}-x")
token_m = re.search(r'id="detail-download-button"[^>]*data-url="(-[^"]+)"', variant_page)
if not token_m:
token_m = re.search(r'data-url="(-[^"]+)"', variant_page)
if not token_m:
print("download token not found", file=sys.stderr)
return 1
token = token_m.group(1)
print("downloading XAPK...")
data = fetch(f"https://dw.uptodown.com/dwn/{token}")
OUT_XAPK.write_bytes(data)
print("saved", OUT_XAPK, "bytes", len(data))
with zipfile.ZipFile(OUT_XAPK) as z:
apks = [n for n in z.namelist() if n.endswith(".apk")]
print("apk splits", apks)
if not apks:
print("no apk inside xapk", file=sys.stderr)
return 1
if OUT_DIR.exists():
import shutil
shutil.rmtree(OUT_DIR)
OUT_DIR.mkdir(parents=True)
import zipfile as zf
with zf.ZipFile(OUT_XAPK) as z:
z.extractall(OUT_DIR)
apk_files = sorted(OUT_DIR.rglob("*.apk"))
adb = r"C:\Users\Administrator\AppData\Local\Android\Sdk\platform-tools\adb.exe"
cmd = [adb, "install-multiple", "-r"] + [str(p) for p in apk_files]
print("install:", " ".join(cmd))
r = subprocess.run(cmd, capture_output=True, text=True)
print(r.stdout)
print(r.stderr)
return 0 if r.returncode == 0 else r.returncode
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,38 @@
# -*- coding: utf-8 -*-
import subprocess
import zipfile
from pathlib import Path
APK = Path(__file__).resolve().parent.parent / "apks" / "maribank_sg_base.apk"
DEXDUMP = Path(r"C:\Users\Administrator\AppData\Local\Android\Sdk\build-tools\37.0.0\dexdump.exe")
TARGETS = [
"Lcom/shopee/bke/lib/safemode/model/ErrorType;",
"Lcom/shopee/bke/lib/safemode/activity/SafeModeRecoverActivity;",
"Lcom/shopee/bke/lib/safemode/b;",
]
with zipfile.ZipFile(str(APK)) as zf:
for name in zf.namelist():
if not name.endswith(".dex"):
continue
data = zf.read(name)
if not any(t.replace("L", "").replace(";", "").encode() in data for t in TARGETS):
continue
tmp = Path(__file__).resolve().parent.parent / "tmp" / "sg_safemode.dex"
tmp.parent.mkdir(parents=True, exist_ok=True)
tmp.write_bytes(data)
out = subprocess.check_output(
[str(DEXDUMP), "-d", str(tmp)], universal_newlines=True, errors="replace"
)
for target in TARGETS:
if target.replace("L", "").replace(";", "") not in data.decode("latin1", errors="ignore"):
continue
print("=" * 60, name, target)
cap = False
for line in out.splitlines():
if f"Class descriptor : '{target}'" in line:
cap = True
elif cap and line.startswith(" Class descriptor") and target not in line:
break
if cap and ("name :" in line or "const-string" in line or "ErrorType" in line):
print(line.strip()[:140])

View File

@@ -0,0 +1,110 @@
"""Minimal DEX parser: dump methods for target classes."""
import struct
import zipfile
from pathlib import Path
TARGET = {
"Lcom/tngd/networksdk/common/NativeLib;",
"Lcom/tngd/networksdk/common/ApiSixSecretKeys;",
"Lmy/com/tngdigital/common/internal/libs/RetrieveFromNativeLibs;",
}
def uleb(data, i):
result = 0
shift = 0
while True:
b = data[i]
i += 1
result |= (b & 0x7F) << shift
if (b & 0x80) == 0:
break
shift += 7
return result, i
def parse_dex(data: bytes, label: str):
if data[:4] != b"dex\n":
return
string_ids_size, string_ids_off = struct.unpack_from("<II", data, 56)
type_ids_size, type_ids_off = struct.unpack_from("<II", data, 64)
proto_ids_size, proto_ids_off = struct.unpack_from("<II", data, 72)
field_ids_size, field_ids_off = struct.unpack_from("<II", data, 80)
method_ids_size, method_ids_off = struct.unpack_from("<II", data, 88)
class_defs_size, class_defs_off = struct.unpack_from("<II", data, 96)
def string_at(idx):
off = struct.unpack_from("<I", data, string_ids_off + idx * 4)[0]
size, p = uleb(data, off)
return data[p : p + size].decode("utf-8", "replace")
def type_at(idx):
return string_at(struct.unpack_from("<I", data, type_ids_off + idx * 4)[0])
def proto_at(idx):
shorty_idx, return_type_idx, parameters_off = struct.unpack_from(
"<III", data, proto_ids_off + idx * 12
)
ret = type_at(return_type_idx)
params = []
if parameters_off:
size = struct.unpack_from("<I", data, parameters_off)[0]
for i in range(size):
tidx = struct.unpack_from("<H", data, parameters_off + 4 + i * 2)[0]
params.append(type_at(tidx))
return ret, params
def method_at(idx):
class_idx, proto_idx, name_idx = struct.unpack_from(
"<HHI", data, method_ids_off + idx * 8
)
ret, params = proto_at(proto_idx)
return type_at(class_idx), string_at(name_idx), ret, params
print(f"\n===== {label} =====")
for c in range(class_defs_size):
class_idx, access_flags, superclass_idx, interfaces_off, source_file_idx, annotations_off, class_data_off, static_values_off = struct.unpack_from(
"<IIIIIIII", data, class_defs_off + c * 32
)
cname = type_at(class_idx)
if cname not in TARGET:
continue
print(f"\nCLASS {cname} access=0x{access_flags:x}")
if not class_data_off:
print(" (no class_data)")
continue
p = class_data_off
static_fields_size, p = uleb(data, p)
instance_fields_size, p = uleb(data, p)
direct_methods_size, p = uleb(data, p)
virtual_methods_size, p = uleb(data, p)
# skip fields
for _ in range(static_fields_size + instance_fields_size):
_, p = uleb(data, p)
_, p = uleb(data, p)
mid = 0
for kind, count in (("direct", direct_methods_size), ("virtual", virtual_methods_size)):
mid = 0
for _ in range(count):
diff, p = uleb(data, p)
access, p = uleb(data, p)
code_off, p = uleb(data, p)
mid += diff
cls, name, ret, params = method_at(mid)
flags = []
if access & 0x100:
flags.append("native")
if access & 0x8:
flags.append("static")
if access & 0x10000:
flags.append("constructor")
print(f" [{kind}] {' '.join(flags)} {name}({', '.join(params)}){ret} code=0x{code_off:x}")
apk = Path(r"C:\Users\Administrator\Desktop\notiMessage\reverse\apks\tng\base.apk")
with zipfile.ZipFile(apk) as z:
for n in z.namelist():
if n.endswith(".dex"):
data = z.read(n)
if any(t.encode() in data for t in ("NativeLib;", "ApiSixSecretKeys;", "RetrieveFromNativeLibs;")):
parse_dex(data, n)

View File

@@ -0,0 +1,167 @@
#!/usr/bin/env python3
"""Race-window: dump TNG executable maps and hunt kill+SVC after launch."""
import re
import subprocess
import sys
import time
from pathlib import Path
ADB = r"C:\Users\Administrator\AppData\Local\Android\Sdk\platform-tools\adb.exe"
PKG = "my.com.tngdigital.ewallet"
OUT = Path(__file__).resolve().parents[1] / "dumps" / "tng_runtime"
OUT.mkdir(parents=True, exist_ok=True)
# aarch64 movz x8/w8,#129/130/131 + svc #0
KILL_IMMS = [
bytes.fromhex("281080d2"),
bytes.fromhex("28108052"),
bytes.fromhex("481080d2"),
bytes.fromhex("48108052"),
bytes.fromhex("681080d2"),
bytes.fromhex("68108052"),
]
SVC0 = bytes.fromhex("010000d4")
EXIT_IMM = bytes.fromhex("c80b80d2") # movz x8,#94 exit_group
EXIT2 = bytes.fromhex("ba0b80d2") # movz x8,#93 exit
def adb(*args, check=False):
r = subprocess.run([ADB, *args], capture_output=True)
out = (r.stdout or b"") + (r.stderr or b"")
if check and r.returncode != 0:
raise RuntimeError(out.decode("utf-8", "ignore"))
return r.returncode, out.decode("utf-8", "replace")
def su(cmd):
return adb("shell", f"su -c '{cmd}'")
def main():
adb("shell", "am", "force-stop", PKG)
time.sleep(0.5)
adb("shell", "monkey", "-p", PKG, "-c", "android.intent.category.LAUNCHER", "1")
pid = None
for _ in range(40):
time.sleep(0.25)
_, out = adb("shell", "pidof", PKG)
toks = out.strip().split()
if toks:
pid = toks[0]
break
if not pid:
print("FAIL: no pid")
return 1
print(f"pid={pid}")
# pull maps
_, maps = su(f"cat /proc/{pid}/maps")
maps_path = OUT / f"maps_{pid}.txt"
maps_path.write_text(maps, encoding="utf-8", errors="replace")
print(f"maps -> {maps_path} lines={len(maps.splitlines())}")
targets = []
for line in maps.splitlines():
if "r-xp" not in line and "r-x" not in line:
# also rw-p with execute rarely; keep x
if "x" not in line.split()[1] if len(line.split()) > 1 else "":
continue
if "tngdigital" in line or "libnative" in line or "ewallet" in line.lower():
parts = line.split()
rng = parts[0]
start_s, end_s = rng.split("-")
start, end = int(start_s, 16), int(end_s, 16)
path = parts[-1] if len(parts) >= 6 else ""
targets.append((start, end, path, line))
print(f"target segments={len(targets)}")
for start, end, path, line in targets[:12]:
print(f" {hex(start)}-{hex(end)} {path}")
# dump via dd from /proc/pid/mem
remote = f"/data/local/tmp/tng_rt_{pid}.bin"
su(f"rm -f {remote}")
total = 0
for i, (start, end, path, _) in enumerate(targets):
size = end - start
if size <= 0 or size > 32 * 1024 * 1024:
continue
# append dump
cmd = (
f"dd if=/proc/{pid}/mem bs=4096 skip={start // 4096} "
f"count={(size + 4095) // 4096} 2>/dev/null >> {remote}"
)
# dd skip is in blocks from file start — wrong for /proc/pid/mem!
# Use busybox dd with seek on output and skip via python on device instead.
cmd = (
f"toybox dd if=/proc/{pid}/mem of={remote}.p{i} "
f"bs=1 skip={start} count={size} 2>/dev/null"
)
code, _ = su(cmd)
if code == 0:
total += size
print(f" dumped p{i} size={size} from {path}")
else:
# fallback: python on device
py = (
f"python3 -c \"import sys;f=open('/proc/{pid}/mem','rb');"
f"f.seek({start});d=f.read({size});open('{remote}.p{i}','wb').write(d)\""
)
code2, out2 = su(py)
if code2 == 0:
total += size
print(f" dumped p{i} via python size={size}")
else:
print(f" FAIL dump p{i}: {out2[:120]}")
# pull pieces and scan
local_dir = OUT / f"mem_{pid}"
local_dir.mkdir(exist_ok=True)
kill_hits = 0
exit_hits = 0
for i, (start, end, path, _) in enumerate(targets):
rem = f"{remote}.p{i}"
loc = local_dir / f"seg_{i}_{start:x}.bin"
code, _ = adb("shell", f"su -c 'test -f {rem} && echo OK'")
if "OK" not in _:
continue
adb("pull", rem, str(loc))
if not loc.exists():
continue
data = loc.read_bytes()
for imm in KILL_IMMS:
pos = 0
while True:
j = data.find(imm, pos)
if j < 0:
break
win = data[j : j + 36]
if SVC0 in win:
kill_hits += 1
delta = win.find(SVC0)
print(f"KILL+SVC seg{i} file+0x{j:x} va=0x{start+j:x} delta={delta} path={path}")
pos = j + 4
for imm in (EXIT_IMM, EXIT2):
pos = 0
while True:
j = data.find(imm, pos)
if j < 0:
break
win = data[j : j + 36]
if SVC0 in win:
exit_hits += 1
if exit_hits <= 15:
print(f"EXIT+SVC seg{i} file+0x{j:x} va=0x{start+j:x} path={path}")
pos = j + 4
# also count raw svc
print(f" seg{i} svc0={data.count(SVC0)} size={len(data)}")
print(f"DONE kill+svc={kill_hits} exit+svc={exit_hits} dumped_bytes~={total}")
_, alive = adb("shell", "pidof", PKG)
print(f"still alive? {alive.strip() or 'NO'}")
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@@ -0,0 +1,22 @@
# -*- coding: utf-8 -*-
import re
import zipfile
from pathlib import Path
APK = Path(__file__).resolve().parent.parent / "apks" / "maribank_sg_base.apk"
with zipfile.ZipFile(str(APK)) as zf:
d = b"".join(zf.read(n) for n in zf.namelist() if n.endswith(".dex"))
for m in re.finditer(rb"Lcom/[^;]{0,160}CharacterCrypto[^;]{0,40};", d):
print(m.group().decode()[1:-1].replace("/", "."))
for m in re.finditer(rb"Lcom/[^;]{0,160}NativeEncrypt[^;]{0,40};", d):
print(m.group().decode()[1:-1].replace("/", "."))
# find class with getDfpByMMKV - search all Lcom paths and check if followed by getDfp in same method table is hard
# instead search for MMKV + dfp strings proximity
idx = d.find(b"getDfpByMMKV:")
if idx >= 0:
chunk = d[max(0, idx - 500) : idx + 500]
for m in re.finditer(rb"Lcom/[^;\x00]{5,200};", chunk):
print("near getDfpByMMKV:", m.group().decode()[1:-1].replace("/", "."))

View File

@@ -0,0 +1,20 @@
# -*- coding: utf-8 -*-
import re
import zipfile
from pathlib import Path
APK = Path(__file__).resolve().parent.parent / "apks" / "maribank_sg_base.apk"
with zipfile.ZipFile(str(APK)) as zf:
d = b"".join(zf.read(n) for n in zf.namelist() if n.endswith(".dex"))
for m in re.finditer(rb"CharacterCrypto[\w$]{0,40}", d):
print(m.group().decode())
for m in re.finditer(rb"getDfp[\w$]{0,20}", d):
s = m.group().decode()
if s not in ("getDfp",):
print("method:", s)
# utils.d wrapper
for m in re.finditer(rb"Lcom/shopee/bke/lib/jni/utils/[\w$]{1,20};", d):
print(m.group().decode()[1:-1].replace("/", "."))

View File

@@ -0,0 +1,21 @@
# -*- coding: utf-8 -*-
import re
import zipfile
from pathlib import Path
APK = Path(__file__).resolve().parent.parent / "apks" / "maribank_sg_base.apk"
with zipfile.ZipFile(str(APK)) as zf:
d = b"".join(zf.read(n) for n in zf.namelist() if n.endswith(".dex"))
for needle in [b"CharacterCryptoManager", b"CharacterCryptoManagerWrapper", b"NativeEncryptUtilsWrapper"]:
print("\n===", needle.decode(), "===")
for m in re.finditer(re.escape(needle) + rb"[\w$]{0,30}", d):
name = m.group().decode()
if "$" in name or name.endswith("Wrapper") or name.endswith("Manager"):
pass
for m in re.finditer(rb"Lcom/[^;]{0,200}" + re.escape(needle) + rb"[^;]{0,20};", d):
print(m.group().decode()[1:-1].replace("/", "."))
# also search for class ending with .d
for m in re.finditer(rb"Lcom/shopee/bke/lib/jni/utils/[a-z];", d):
print("short utils:", m.group().decode()[1:-1].replace("/", "."))

View File

@@ -0,0 +1,23 @@
# -*- coding: utf-8 -*-
import re
import zipfile
from pathlib import Path
APK = Path(__file__).resolve().parent.parent / "apks" / "maribank_sg_base.apk"
needle = b"The dfp is empty in register scene"
with zipfile.ZipFile(str(APK)) as zf:
data = b"".join(zf.read(n) for n in zf.namelist() if n.endswith(".dex"))
idx = data.find(needle)
ctx = data[max(0, idx - 2000) : idx + 2000]
classes = sorted(set(m.group().decode()[1:-1].replace("/", ".")
for m in re.finditer(rb"Lcom/[^;\x00]{5,200};", ctx)))
print("classes near register dfp empty:")
for c in classes:
print(c)
print("\nstrings:")
for m in re.finditer(rb"[\x20-\x7e]{6,100}", ctx):
s = m.group().decode()
if any(k in s.lower() for k in ("dfp", "register", "fingerprint", "empty", "scene", "monitor", "iv_")):
print(s)

View File

@@ -0,0 +1,27 @@
# -*- coding: utf-8 -*-
import re
import zipfile
from pathlib import Path
APK = Path(__file__).resolve().parent.parent / "apks" / "maribank_sg_base.apk"
with zipfile.ZipFile(str(APK)) as zf:
data = b"".join(zf.read(n) for n in zf.namelist() if n.endswith(".dex"))
for needle in [b"getDfp", b"DeviceFingerprintResp", b"deviceFingerprint", b"/dfp/", b"dfp/v1"]:
print("\n===", needle.decode(), "===")
for m in re.finditer(re.escape(needle) + rb"[\x00-\xff]{0,80}", data):
chunk = data[m.start() : m.start() + 120]
s = re.sub(rb"[^\x20-\x7e]+", b"|", chunk).decode("ascii", "ignore")
print(s[:140])
break
# classes with fingerprint in name
fps = sorted(set(m.group().decode()[1:-1].replace("/", ".")
for m in re.finditer(rb"Lcom/shopee/bke/[^;\x00]{0,120}[Ff]ingerprint[^;\x00]{0,40};", data)))
print("\n=== fingerprint classes ===")
for c in fps[:30]:
print(c)
# RegisterViewModel methods - search string RegisterViewModel in dex
for m in re.finditer(rb"Lcom/shopee/bke/biz/user/viewmodel/RegisterViewModel[^;\x00]*;", data):
print("\nRegisterViewModel:", m.group().decode())

View File

@@ -0,0 +1,19 @@
# -*- coding: utf-8 -*-
import re
import zipfile
from pathlib import Path
APK = Path(__file__).resolve().parent.parent / "apks" / "maribank_sg_base.apk"
with zipfile.ZipFile(str(APK)) as zf:
d = b"".join(zf.read(n) for n in zf.namelist() if n.endswith(".dex"))
idx = d.find(b"getDfpByMMKV")
print("idx", idx)
window = d[max(0, idx - 20000) : idx + 20000]
classes = re.findall(rb"Lcom/[a-zA-Z0-9_$/]{5,200};", window)
unique = sorted(set(x.decode()[1:-1].replace("/", ".") for x in classes))
print("classes in 40k window:", len(unique))
for c in unique:
cl = c.lower()
if any(k in cl for k in ("crypto", "dfp", "finger", "device", "user", "jni", "utils", "manager", "wrapper", "register", "login")):
print(c)

View File

@@ -0,0 +1,31 @@
# -*- coding: utf-8 -*-
import re
import zipfile
from pathlib import Path
APK = Path(__file__).resolve().parent.parent / "apks" / "maribank_sg_base.apk"
needles = [b"getDfp empty!", b"getDfp onError:", b"getDfpByMMKV:", b"The dfp is empty in register scene"]
with zipfile.ZipFile(str(APK)) as zf:
data = b"".join(zf.read(n) for n in zf.namelist() if n.endswith(".dex"))
all_classes = [m.group().decode()[1:-1].replace("/", ".")
for m in re.finditer(rb"Lcom/shopee/bke/[\w$/]{5,160};", data)]
for needle in needles:
print("\n===", needle.decode(), "===")
idx = data.find(needle)
if idx < 0:
print("not found")
continue
window = data[max(0, idx - 8000) : idx + 8000]
nearby = sorted(set(m.group().decode()[1:-1].replace("/", ".")
for m in re.finditer(rb"Lcom/shopee/bke/[\w$/]{5,160};", window)))
for c in nearby:
if any(k in c.lower() for k in ("dfp", "finger", "device", "register", "user", "util", "manager", "helper", "repo", "data", "rn")):
print(" ", c)
dfp_classes = sorted(set(c for c in all_classes if "dfp" in c.lower() or "fingerprint" in c.lower()))
print("\n=== dfp/fingerprint class names ===")
for c in dfp_classes:
print(c)

View File

@@ -0,0 +1,21 @@
# -*- coding: utf-8 -*-
import re
import zipfile
from pathlib import Path
APK = Path(__file__).resolve().parent.parent / "apks" / "maribank_sg_base.apk"
needle = b"getDfp empty!"
with zipfile.ZipFile(str(APK)) as zf:
data = b"".join(zf.read(n) for n in zf.namelist() if n.endswith(".dex"))
idx = data.find(needle)
window = data[max(0, idx - 12000) : idx + 12000]
classes = sorted(set(m.group().decode()[1:-1].replace("/", ".")
for m in re.finditer(rb"Lcom/[^;\x00]{5,200};", window)))
print("all classes near getDfp empty (filtered):")
for c in classes:
cl = c.lower()
if any(k in cl for k in ("dfp", "finger", "shps", "bke", "jni", "utils", "sdk", "device", "monitor", "crypto", "register")):
print(c)
print("\nall com classes count:", len(classes))

View File

@@ -0,0 +1,28 @@
# -*- coding: utf-8 -*-
import re
import zipfile
from pathlib import Path
APK = Path(__file__).resolve().parent.parent / "apks" / "maribank_sg_base.apk"
with zipfile.ZipFile(str(APK)) as zf:
data = b"".join(zf.read(n) for n in zf.namelist() if n.endswith(".dex"))
print("=== Adb / SafeMode related classes ===")
for m in re.finditer(rb"L[\w$/]*(Adb|ADB|SafeMode|safemode|Recover)[\w$/]*;", data):
c = m.group().decode()
if "shopee" in c or "bke" in c or "maribank" in c.lower():
print(c)
print("\n=== shpssdk bank ===")
for m in re.finditer(rb"Lcom/shopee/shpssdk[\w$/]*;", data):
print(m.group().decode())
print("\n=== api.seabank / maribank hosts ===")
for m in re.finditer(rb"https?://[a-zA-Z0-9._/-]{8,80}", data):
u = m.group().decode()
if "maribank" in u or "seabank" in u:
print(u)
print("\n=== login/register uapi ===")
for m in re.finditer(rb"/uapi/[a-zA-Z0-9_/-]+", data):
print(m.group().decode())

View File

@@ -0,0 +1,39 @@
# -*- coding: utf-8 -*-
import re
import zipfile
from pathlib import Path
APK = Path(__file__).resolve().parent.parent / "apks" / "maribank_sg_base.apk"
with zipfile.ZipFile(str(APK)) as zf:
data = b"".join(zf.read(n) for n in zf.namelist() if n.endswith(".dex"))
for pat in [
b"Does not support",
b"Turn Off ADB",
b"Wireless ADB",
b"ADB/Wireless",
b"support root",
b"RISK_USB_ADB",
b"RISK_WIFI_ADB",
b"KEY_ALLOW_ADB",
b"SafeModeRecover",
b"sg.com.maribank",
]:
print(pat.decode(), "->", data.count(pat))
print("\n--- UI strings ---")
for m in re.finditer(rb"[\x20-\x7e]{10,200}", data):
s = m.group().decode("ascii", "ignore")
sl = s.lower()
if ("adb" in sl and ("detect" in sl or "turn" in sl or "wireless" in sl or "debug" in sl)) or "does not support root" in sl:
print(s)
print("\n--- Application ---")
for m in re.finditer(rb"Lcom/shopee/bke/[\w$/]*Application[\w$/]*;", data):
print(m.group().decode())
print("\n--- ADB risk classes ---")
for m in re.finditer(rb"L[\w$/]*(adb|Adb|ADB)[\w$/]*;", data):
c = m.group().decode()
if "shopee" in c.lower() or "bke" in c.lower() or "shps" in c.lower():
print(c)

View File

@@ -0,0 +1,17 @@
# -*- coding: utf-8 -*-
import re
import zipfile
from pathlib import Path
APK = Path(__file__).resolve().parent.parent / "apks" / "maribank_sg_base.apk"
with zipfile.ZipFile(str(APK)) as zf:
for name in zf.namelist():
if not name.endswith((".json", ".jsbundle")):
continue
data = zf.read(name)
if b"currently unavailable" not in data and b"system is currently" not in data:
continue
text = data.decode("utf-8", errors="replace")
for m in re.finditer(r".{0,40}currently unavailable.{0,60}", text):
print(f"\n[{name}]")
print(m.group().replace("\n", " ")[:200])

View File

@@ -0,0 +1,24 @@
# -*- coding: utf-8 -*-
import re
import zipfile
from pathlib import Path
APK = Path(__file__).resolve().parent.parent / "apks" / "maribank_sg_base.apk"
with zipfile.ZipFile(str(APK)) as zf:
for name in sorted(zf.namelist()):
low = name.lower()
if not name.endswith(".json"):
continue
if "user" not in low and "auth" not in low and "register" not in low and "ekyc" not in low:
continue
data = zf.read(name).decode("utf-8", errors="replace")
hits = []
for m in re.finditer(r'"[^"]+"\s*:\s*"[^"]{8,200}"', data):
s = m.group()
sl = s.lower()
if any(k in sl for k in ("unavailable", "register", "dfp", "phone", "otp", "system is")):
hits.append(s[:220])
if hits:
print("\n===", name, "===")
for h in hits[:40]:
print(h)

View File

@@ -0,0 +1,13 @@
#!/usr/bin/env python3
import sqlite3
import sys
p = sys.argv[1] if len(sys.argv) > 1 else "clash_cache.db"
con = sqlite3.connect(p)
for (name,) in con.execute("SELECT name FROM sqlite_master WHERE type='table'"):
print("TABLE", name)
cols = [c[1] for c in con.execute(f"PRAGMA table_info({name})")]
print(" cols", cols)
for row in con.execute(f"SELECT * FROM {name} LIMIT 8"):
s = str(row)
print(" ", s[:300] + ("..." if len(s) > 300 else ""))

View File

@@ -0,0 +1,18 @@
# -*- coding: utf-8 -*-
import re
import zipfile
from pathlib import Path
APK = Path(__file__).resolve().parent.parent / "apks" / "maribank_sg_base.apk"
with zipfile.ZipFile(str(APK)) as zf:
d = b"".join(zf.read(n) for n in zf.namelist() if n.endswith(".dex"))
classes = sorted(set(m.group().decode()[1:-1].replace("/", ".")
for m in re.finditer(rb"Lcom/shopee/bke/lib/safemode/[^;]{1,80};", d)))
print("safemode classes:", len(classes))
for c in classes[:30]:
print(c)
print("\nrisk classes:")
for m in re.finditer(rb"Lcom/shopee/bke/biz/base/risk/[^;]{1,40};", d):
print(m.group().decode()[1:-1].replace("/", "."))

View File

@@ -0,0 +1,28 @@
# -*- coding: utf-8 -*-
import re
import zipfile
from pathlib import Path
APK = Path(__file__).resolve().parent.parent / "apks" / "maribank_sg_base.apk"
with zipfile.ZipFile(str(APK)) as zf:
d = b"".join(zf.read(n) for n in zf.namelist() if n.endswith(".dex"))
patterns = [
rb"Lcom/shopee/bke/lib/safemode/[a-zA-Z$][\w$]{0,30};",
rb"Lcom/shopee/bke/lib/safemode/[a-z]+/[a-zA-Z$][\w$]{0,40};",
]
seen = set()
for pat in patterns:
for m in re.finditer(pat, d):
c = m.group().decode()[1:-1].replace("/", ".")
if c.startswith("com.shopee.bke.lib.safemode.R"):
continue
if c not in seen:
seen.add(c)
print(c)
print("\n--- short obfuscated bke classes (root/adb) ---")
for m in re.finditer(rb"Lcom/shopee/bke/[a-z]+/[a-z]{1,2};", d):
c = m.group().decode()[1:-1].replace("/", ".")
if "lib" in c or "safemode" in c or "risk" in c:
print(c)

View File

@@ -0,0 +1,21 @@
# -*- coding: utf-8 -*-
import re
import zipfile
from pathlib import Path
APK = Path(__file__).resolve().parent.parent / "apks" / "maribank_sg_base.apk"
with zipfile.ZipFile(str(APK)) as zf:
d = b"".join(zf.read(n) for n in zf.namelist() if n.endswith(".dex"))
for pat in [
rb"Lcom/shopee/shpssdk/[\w$/]{3,100};",
rb"Lcom/shopee/shpssdkbank/[\w$/]{3,100};",
]:
cs = sorted(set(m.group().decode()[1:-1].replace("/", ".") for m in re.finditer(pat, d)))
print("\n", pat.decode(), len(cs))
for c in cs:
if "R" != c.split(".")[-1] or "$" in c:
print(" ", c)
for needle in [b"RISK_USB", b"RISK_WIFI", b"RISK_ROOT", b"RISK_HOOK", b"requestDefense"]:
print(needle.decode(), d.count(needle))

View File

@@ -0,0 +1,20 @@
# -*- coding: utf-8 -*-
import re
import sys
import zipfile
from pathlib import Path
APK = Path(__file__).resolve().parent.parent / "apks" / "maribank_sg_base.apk"
if len(sys.argv) > 1:
APK = Path(sys.argv[1])
with zipfile.ZipFile(str(APK)) as zf:
data = b"".join(zf.read(n) for n in zf.namelist() if n.endswith(".dex"))
for pkg in ("shpssdkbank", "shpssdk"):
pat = re.compile(rf"Lcom/shopee/{pkg}/[\w$]+;".encode())
classes = sorted(set(m.group().decode()[1:-1].replace("/", ".") for m in pat.finditer(data)))
print(f"\n=== {pkg} ({len(classes)} classes) ===")
short = [c for c in classes if len(c.split(".")[-1]) <= 12 and "shpssdk" in c]
for c in short[:40]:
print(" ", c)

View File

@@ -0,0 +1,14 @@
import sys
path = sys.argv[1]
lines = open(path, encoding="utf-8", errors="ignore").read().splitlines()
keys = (
"Runtime aborting", "Aborting", "stack corruption", "blocked __stack",
"vhvlnqgy.u", "UserRegistration", "F libc", "has died",
)
for i, line in enumerate(lines):
if any(k in line for k in keys):
start = max(0, i - 2)
end = min(len(lines), i + 6)
print("---")
print("\n".join(lines[start:end]))

View File

@@ -0,0 +1,20 @@
import sys
import xml.etree.ElementTree as ET
path = sys.argv[1] if len(sys.argv) > 1 else "reverse/dumps/ui_login.xml"
root = ET.parse(path).getroot()
keywords = [
"register", "sign", "login", "mobile", "phone", "continue", "next",
"create", "otp", "skip", "started", "email", "password", "pin",
]
for node in root.iter("node"):
text = node.get("text", "")
desc = node.get("content-desc", "")
clickable = node.get("clickable", "")
bounds = node.get("bounds", "")
label = (text or desc).strip()
if not label and clickable != "true":
continue
hay = (text + " " + desc).lower()
if clickable == "true" or any(k in hay for k in keywords):
print(f"{label!r} bounds={bounds} clickable={clickable}")

View File

@@ -0,0 +1,13 @@
import sys
import xml.etree.ElementTree as ET
path = sys.argv[1]
root = ET.parse(path).getroot()
for node in root.iter("node"):
text = (node.get("text") or "").strip()
desc = (node.get("content-desc") or "").strip()
rid = node.get("resource-id") or ""
bounds = node.get("bounds") or ""
clickable = node.get("clickable") or "false"
if text or desc or "login" in rid.lower() or "register" in rid.lower():
print(f"text={text!r} desc={desc!r} id={rid} bounds={bounds} click={clickable}")

View File

@@ -0,0 +1,156 @@
# -*- coding: utf-8 -*-
"""Parse vuwuuwvw attestation JSON from logcat or raw JSON file."""
import base64
import hashlib
import json
import re
import sys
from pathlib import Path
SUSPICIOUS = re.compile(
rb"(root|hook|xposed|lsposed|magisk|frida|substrate|emulator|debug|adb|"
rb"selinux|\bsu\b|/proc/|zygisk|riru|shamiko|tamper|integrity|"
rb"jailbreak|virtual|mock|proxy|vpn|developer)",
re.I,
)
KNOWN_FIELDS = [
"root", "hook", "xposed", "lsposed", "magisk", "frida", "adb", "debug",
"debuggable", "emulator", "simulator", "vpn", "proxy", "mock",
"selinux", "su", "supersu", "zygisk", "riru", "shamiko", "substrate",
"integrity", "safetynet", "playIntegrity", "deviceId", "androidId",
"serial", "fingerprint", "model", "brand", "manufacturer", "board",
"host", "tags", "type", "user", "display", "product", "hardware",
"usb", "wifi", "adb_enabled", "development_settings_enabled",
"RISK_ROOT", "RISK_HOOK", "RISK_USB_ADB", "RISK_WIFI_ADB", "RISK_ADB",
"RISK_EMULATOR", "RISK_DEBUG", "RISK_VPN", "RISK_PROXY", "RISK_MOCK",
"rdVerifyInfo", "deviceFingerprint", "data", "dataKey", "riskToken",
"isRoot", "isHook", "isDebug", "isAdb", "isEmulator", "isVirtual",
"tamper", "jailbreak", "bootloader", "verifiedbootstate", "vbmeta",
"init.svc.adbd", "/proc/self/maps", "RealInterceptorChain",
]
# keys seen in 16:29-16:30 Pixel6 logs (from vuwuuwvw head=...)
SAMPLE_KEYS = """
2535994b 3923d741 68e69650 37132b99 1c5681ce 324f4370 4ea521fa
2236b022 5a5532da 1309e885 1bb219c0 3ade7f65 3ade7f66 1c560a56 1c560a55
3d33c1b1 1854d9b1 21e5cca2 23a20fae 36f30e66 29e2320e 2652ab1c 122c5826
269b494b 22b1f08d 169b85f 1610b055 2baf3770 5bc1a01a 37132b99
""".split()
def md5_key(name: str) -> str:
return hashlib.md5(name.encode()).hexdigest()[:8]
def guess_keys(keys):
table = {md5_key(n): n for n in KNOWN_FIELDS}
out = []
for k in keys:
if k.lower() in table:
out.append((k, table[k.lower()]))
return out
def scan_value(path, val, hits):
if isinstance(val, str):
b = val.encode("utf-8", "replace")
m = SUSPICIOUS.search(b)
if m:
hits.append(f"{path} str hit={m.group().decode()} val={val[:120]}")
if re.fullmatch(r"[A-Za-z0-9+/=]+", val) and 8 <= len(val) <= 512:
try:
raw = base64.b64decode(val + "==="[: (4 - len(val) % 4) % 4])
if sum(32 <= c < 127 for c in raw) * 100 // max(len(raw), 1) >= 85:
inner = raw.decode("utf-8", "replace")
m2 = SUSPICIOUS.search(inner.encode())
if m2:
hits.append(f"{path} b64utf8 hit={m2.group().decode()} val={inner[:120]}")
else:
hx = raw[:32].hex()
hits.append(f"{path} b64 bin len={len(raw)} hex={hx}")
except Exception:
pass
elif isinstance(val, (int, float, bool)):
if val in (1, True):
hits.append(f"{path} ={val} (flag?)")
def parse_json(text, label=""):
obj = json.loads(text)
keys = sorted(obj.keys())
print(f"\n=== {label} keys={len(keys)} ===")
print("first keys:", keys[:12])
hits = []
for k in keys:
scan_value(k, obj[k], hits)
if hits:
print("SUSPICIOUS:")
for h in hits[:30]:
print(" ", h)
else:
print("no plain suspicious strings")
matched = guess_keys(keys)
if matched:
print("MD5 key guesses:")
for k, n in matched:
print(f" {k} => {n}")
return obj
def extract_from_log(path):
text = Path(path).read_text(encoding="utf-8", errors="replace")
# MariBankCapture chunked: [vuwuuwvw.out REGISTER] 1/N ...
chunks = {}
current = None
for line in text.splitlines():
if "vuwuuwvw.out REGISTER" in line or "vuwuuwvw.out]" in line:
m = re.search(r"\] (\d+)/(\d+) (.+)$", line)
if m:
idx, total, part = int(m.group(1)), int(m.group(2)), m.group(3)
key = (total, line.split("REGISTER")[0])
chunks.setdefault(key, {})[idx] = part
elif " len=" in line and " parts=" not in line:
m2 = re.search(r"\] len=\d+ (.+)$", line)
if m2:
current = m2.group(1)
elif "vuwuuwvw.out REGISTER] len=" in line and " parts=" not in line:
m2 = re.search(r"len=\d+ (.+)$", line)
if m2:
current = m2.group(1)
if current and current.startswith("{"):
return [current]
out = []
for parts in chunks.values():
if parts:
joined = "".join(parts[i] for i in sorted(parts))
if joined.startswith("{"):
out.append(joined)
# fallback: head= lines won't work for full JSON
return out
def main():
print("=== MD5 key table (known fields -> 8 hex) ===")
for name in KNOWN_FIELDS[:20]:
print(f" {md5_key(name):8s} {name}")
print(" ...")
print("\n=== sample keys from device logs ===")
matched = guess_keys(SAMPLE_KEYS)
if matched:
for k, n in matched:
print(f" {k} => {n}")
else:
print(" (no MD5 match — keys may use different hash algo)")
if len(sys.argv) > 1:
p = Path(sys.argv[1])
if p.suffix == ".json":
parse_json(p.read_text(encoding="utf-8"), p.name)
else:
for i, blob in enumerate(extract_from_log(p)):
parse_json(blob, f"log#{i+1}")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,28 @@
# -*- coding: utf-8 -*-
import re
import zipfile
from pathlib import Path
APK = Path(__file__).resolve().parent.parent / "apks" / "maribank_sg_base.apk"
needle = b"dfp is empty"
with zipfile.ZipFile(str(APK)) as zf:
data = b"".join(zf.read(n) for n in zf.namelist() if n.endswith(".dex"))
idx = 0
while True:
idx = data.find(needle, idx)
if idx < 0:
break
ctx = data[max(0, idx - 400) : idx + 400]
classes = sorted(set(m.group().decode()[1:-1].replace("/", ".")
for m in re.finditer(rb"Lcom/[^;\x00]{5,160};", ctx)))
print("\n--- hit at", idx, "---")
for c in classes:
if any(k in c.lower() for k in ("user", "register", "dfp", "fingerprint", "viewmodel", "rn", "helper")):
print(" ", c)
# printable strings nearby
for m in re.finditer(rb"[\x20-\x7e]{4,80}", ctx):
s = m.group().decode()
if any(k in s.lower() for k in ("dfp", "register", "empty", "error", "unavailable", "fingerprint")):
print(" str:", s)
idx += len(needle)

View File

@@ -0,0 +1,82 @@
# -*- coding: utf-8 -*-
"""Quick DEX scan for MariBank SG detection / auth strings."""
import re
import sys
import zipfile
from pathlib import Path
APK = Path(__file__).resolve().parent.parent / "apks" / "maribank_sg_base.apk"
if len(sys.argv) > 1:
APK = Path(sys.argv[1])
needles = [
b"ADB",
b"Wireless ADB",
b"USB debugging",
b"Wireless debugging",
b"Does not support root",
b"root device",
b"rooted",
b"jailbroken",
b"safemode",
b"SafeMode",
b"shpssdk",
b"SHPSSDK",
b"getRiskToken",
b"requestDefense",
b"BkeApplication",
b"errorcodehandler",
b"4067012",
b"4067004",
b"/uapi/",
b"/dfp/",
b"register",
b"login",
b"auth/precheck",
]
class_needles = [
rb"Lcom/shopee/bke/[\w$/]+;",
rb"Lcom/shopee/shpssdk[\w$/]*;",
]
with zipfile.ZipFile(str(APK)) as zf:
data = b"".join(zf.read(n) for n in zf.namelist() if n.endswith(".dex"))
print("APK:", APK.name, "dex bytes:", len(data))
print("\n=== string hits ===")
for n in needles:
c = data.count(n)
if c:
print(f" {n.decode(errors='replace')!r}: {c}")
print("\n=== api paths (sample) ===")
paths = sorted(set(m.group().decode() for m in re.finditer(rb"/v[0-9]/[a-zA-Z0-9_/-]{4,80}", data)))
for p in paths:
pl = p.lower()
if any(k in pl for k in ("auth", "login", "register", "otp", "dfp", "user", "mobile", "pin")):
print(" ", p)
print("\n=== shopee/bke classes (sample) ===")
classes = sorted(set(m.group().decode() for m in re.finditer(rb"Lcom/shopee/bke/[\w$/]{8,120};", data)))
keywords = ("safemode", "risk", "adb", "debug", "root", "error", "user", "digitalbank", "Application")
shown = 0
for c in classes:
cl = c.lower()
if any(k in cl for k in keywords):
print(" ", c)
shown += 1
if shown >= 40:
break
print(f" ... total bke classes: {len(classes)}")
print("\n=== context: ADB Detected ===")
idx = data.find(b"ADB")
while idx >= 0 and idx < len(data):
chunk = data[max(0, idx - 30) : idx + 80]
if b"Detect" in chunk or b"debug" in chunk.lower() or b"Wireless" in chunk:
s = re.sub(rb"[^\x20-\x7e]+", b" ", chunk).decode("ascii", "ignore").strip()
if len(s) > 20:
print(" ", s[:120])
idx = data.find(b"ADB", idx + 1)
if idx > 0 and data.find(b"ADB", idx + 1) == -1:
break

View File

@@ -0,0 +1,28 @@
# -*- coding: utf-8 -*-
import re
import zipfile
from pathlib import Path
APK = Path(__file__).resolve().parent.parent / "apks" / "maribank_sg_base.apk"
with zipfile.ZipFile(str(APK)) as zf:
d = b"".join(zf.read(n) for n in zf.namelist() if n.endswith(".dex"))
for kw in [
b"CharacterCrypto",
b"IV_Monitor",
b"getDfpByMMKV",
b"NativeEncryptUtilsWrapper",
b"dfp/v1/data/report",
b"com/shopee/bke/lib/jni/utils/d",
]:
print(kw.decode(), d.count(kw))
print("\ndfp-related classes:")
for m in re.finditer(rb"Lcom/[^;]{0,120}[Dd][Ff][Pp][^;]{0,40};", d):
print(m.group().decode()[1:-1].replace("/", "."))
print("\nMonitor classes:")
for m in re.finditer(rb"Lcom/[^;]{0,120}Monitor[^;]{0,40};", d):
s = m.group().decode()[1:-1].replace("/", ".")
if "bke" in s or "shps" in s:
print(s)

View File

@@ -0,0 +1,20 @@
# -*- coding: utf-8 -*-
import re
import zipfile
from pathlib import Path
APK = Path(__file__).resolve().parent.parent / "apks" / "maribank_sg_base.apk"
with zipfile.ZipFile(str(APK)) as zf:
data = b"".join(zf.read(n) for n in zf.namelist() if n.endswith(".dex"))
for needle in [b"dfp is empty", b"getDfp", b"DfpManager", b"DeviceFingerprint"]:
print("\n===", needle.decode(), "count=", data.count(needle))
idx = 0
for _ in range(5):
idx = data.find(needle, idx)
if idx < 0:
break
ctx = data[max(0, idx - 120) : idx + 200]
for m in re.finditer(rb"Lcom/[^;\x00]{5,140};", ctx):
print(" ", m.group().decode()[1:-1].replace("/", "."))
idx += len(needle)

View File

@@ -0,0 +1,35 @@
# -*- coding: utf-8 -*-
"""Find jni/utils and safemode classes across SG split dex files."""
import re
import zipfile
from pathlib import Path
APK = Path(__file__).resolve().parent.parent / "apks" / "maribank_sg_base.apk"
with zipfile.ZipFile(str(APK)) as zf:
for dex in sorted(n for n in zf.namelist() if n.endswith(".dex")):
d = zf.read(dex)
needles = [
b"Lcom/shopee/bke/lib/jni/utils/",
b"Lcom/shopee/bke/lib/safemode/",
b"CharacterCrypto",
b"rdVerifyInfo",
]
if not any(n in d for n in needles):
continue
print("\n===", dex, "===")
for pat in [
rb"Lcom/shopee/bke/lib/jni/utils/[^;]{1,40};",
rb"Lcom/shopee/bke/lib/safemode/[^;]{1,60};",
rb"Lcom/shopee/bke/biz/base/risk/[^;]{1,40};",
]:
cs = sorted(
set(
m.group().decode()[1:-1].replace("/", ".")
for m in re.finditer(pat, d)
)
)
for c in cs:
if ".R" in c and c.endswith(".R"):
continue
if "$" in c or not c.endswith(".R"):
print(" ", c)

View File

@@ -0,0 +1,33 @@
# -*- coding: utf-8 -*-
import re
import zipfile
from pathlib import Path
APKS = Path(__file__).resolve().parent.parent / "apks"
for name in ["maribank_sg_base.apk", "maribank_sg_arm64.apk"]:
p = APKS / name
if not p.exists():
print(name, "missing")
continue
with zipfile.ZipFile(p) as z:
dex = [n for n in z.namelist() if n.endswith(".dex")]
print("\n", name, "dex:", dex)
if not dex:
so = [n for n in z.namelist() if n.endswith(".so")][:5]
print(" native:", so)
continue
d = b"".join(z.read(n) for n in dex)
for needle in [
b"safemode.b",
b"safemode/catchs",
b"safemode/util",
b"USB_ADB",
b"RISK_USB",
b"lib/safemode/",
]:
print(" ", needle.decode(), d.count(needle))
classes = sorted(set(m.group().decode()[1:-1].replace("/", ".")
for m in re.finditer(rb"Lcom/shopee/bke/lib/safemode/[^;]{1,80};", d)))
for c in classes:
if not c.endswith(".R") and ".R$" not in c:
print(" ", c)

View File

@@ -0,0 +1,34 @@
# -*- coding: utf-8 -*-
import zipfile
from pathlib import Path
APK = Path(__file__).resolve().parent.parent / "apks" / "maribank_sg_base.apk"
needles = [
b"system is currently unavailable",
b"currently unavailable",
b"Unexpected error occurred",
b"Please try again later",
b"3100012",
b"deviceFingerprint",
b"preCheck",
b"preRegister",
b"getDfp",
b"dfp is empty",
b"dfpReady",
b"isDfpReady",
b"IV_Monitor",
b"register scene",
b"GlobalAuthError",
b"ErrorFlowHelper",
]
with zipfile.ZipFile(str(APK)) as zf:
data = b"".join(
zf.read(n)
for n in zf.namelist()
if n.endswith((".dex", ".jsbundle", ".json"))
)
for n in needles:
print(n.decode(), data.count(n))
idx = data.find(b"currently unavailable")
if idx >= 0:
print("\ncontext:", data[max(0, idx - 100) : idx + 150])

View File

@@ -0,0 +1,94 @@
# -*- coding: utf-8 -*-
"""Scan TNG eWallet APK for AppProtect / root detection artifacts."""
import re
import sys
import zipfile
KEYS = [
b"AppProtect", b"appprotect", b"vkey", b"V-Key", b"VKey", b"VGuard",
b"jailbroken", b"Jailbroken", b"Rooted Device", b"rooted device",
b"Close app", b"tngdigital", b"TNG eWallet", b"How to keep device safe",
b"enhanced our security", b"RootBeer", b"SafetyNet", b"PlayIntegrity",
b"detectRoot", b"isRooted", b"checkRoot", b"magisk", b"xposed", b"lsposed",
b"frida", b"emulator", b"su binary", b"/system/xbin/su",
]
CLASS_KEYS = [
b"safemode", b"SafeMode", b"appprotect", b"AppProtect", b"vkey", b"VKey",
b"vguard", b"VGuard", b"rooted", b"RootBeer", b"integrity", b"jailbreak",
b"security", b"RiskDevice", b"tamper", b"hook", b"frida",
]
def scan_strings(apk_path):
print("=== STRING SCAN: %s ===" % apk_path)
with zipfile.ZipFile(apk_path) as zf:
for name in sorted(zf.namelist()):
if not name.endswith(".dex"):
continue
data = zf.read(name)
hits = []
for key in KEYS:
start = 0
while True:
idx = data.find(key, start)
if idx < 0:
break
s = max(0, idx - 60)
e = min(len(data), idx + len(key) + 100)
chunk = re.sub(rb"[^\x20-\x7e]+", b" ", data[s:e])
chunk = chunk.decode("ascii", "ignore").strip()
if chunk and chunk not in hits:
hits.append(chunk)
start = idx + 1
if hits:
print("\n--- %s (%d hits) ---" % (name, len(hits)))
for h in sorted(set(hits))[:40]:
print(" ", h)
if len(hits) > 40:
print(" ... +%d more" % (len(hits) - 40))
def scan_classes(apk_path):
print("\n=== CLASS SCAN: %s ===" % apk_path)
with zipfile.ZipFile(apk_path) as zf:
for name in sorted(zf.namelist()):
if not name.endswith(".dex"):
continue
data = zf.read(name)
classes = set(re.findall(rb"L[a-zA-Z0-9_$/]+;", data))
hits = []
for raw in classes:
low = raw.lower()
if any(k.lower() in low for k in CLASS_KEYS):
s = raw.decode("ascii", "ignore")[1:-1].replace("/", ".")
hits.append(s)
if hits:
print("\n--- %s (%d classes) ---" % (name, len(hits)))
for h in sorted(set(hits))[:60]:
print(" ", h)
if len(hits) > 60:
print(" ... +%d more" % (len(hits) - 60))
def scan_native(apk_path):
print("\n=== NATIVE LIB SCAN: %s ===" % apk_path)
with zipfile.ZipFile(apk_path) as zf:
for name in sorted(zf.namelist()):
if not name.endswith(".so"):
continue
data = zf.read(name)
lib = name.split("/")[-1]
found = []
for key in KEYS + [b"libvos", b"libvkey", b"libvguard", b"libappprotect"]:
if key.lower() in data.lower():
found.append(key.decode("ascii", "ignore"))
if found:
print(" %s: %s" % (lib, ", ".join(sorted(set(found)))))
if __name__ == "__main__":
path = sys.argv[1] if len(sys.argv) > 1 else "reverse/apks/tng/base.apk"
scan_strings(path)
scan_classes(path)
scan_native(path)

View File

@@ -0,0 +1,18 @@
"""Dump xwwqazamx bl/w/A method refs from TNG dex."""
import re
import zipfile
APK = r"C:\Users\Administrator\Desktop\notiMessage\reverse\apks\tng\base.apk"
with zipfile.ZipFile(APK) as z:
data = b"".join(z.read(n) for n in z.namelist() if n.endswith(".dex"))
for cls in ["Lxwwqazamx/bl;", "Lxwwqazamx/w;", "Lxwwqazamx/W;", "Lxwwqazamx/A;"]:
print("===", cls, "===")
refs = sorted(set(re.findall(cls.encode() + rb"->[^\x00]{1,80}", data)))
for r in refs[:30]:
print(r.decode("ascii", "ignore"))
print()
print("=== lifecycle on xwwqazamx/w ===")
for m in sorted(set(re.findall(rb"Lxwwqazamx/w;->on[A-Za-z]+", data))):
print(m.decode())

View File

@@ -0,0 +1,25 @@
"""Scan TNG AppSecurityManager callbacks and bl methods."""
import re
import zipfile
APK = r"C:\Users\Administrator\Desktop\notiMessage\reverse\apks\tng\base.apk"
with zipfile.ZipFile(APK) as z:
data = b"".join(z.read(n) for n in z.namelist() if n.endswith(".dex"))
keys = [
b"handleRootingCallback", b"handleEmulatorCallback", b"handleHookingCallback",
b"handleMalwareCallback", b"onBlockStaticCheck", b"addIntoQueue",
b"ForceExit", b"exitApplication", b"startForceExit", b"Lxwwqazamx/bl;",
]
for k in keys:
i = data.find(k)
if i < 0:
continue
print("---", k.decode(), "---")
s = re.sub(rb"[^\x20-\x7e]+", b" ", data[max(0, i - 150) : i + len(k) + 200])
print(s.decode("ascii", "ignore")[:400])
print()
print("=== bl method refs ===")
for m in sorted(set(re.findall(rb"Lxwwqazamx/bl;->[a-zA-Z0-9_$<>\[\]]+", data))):
print(m.decode())

View File

@@ -0,0 +1,37 @@
"""Scan TNG dex for Promon exit paths and ActivityThread refs."""
import re
import sys
import zipfile
apk = sys.argv[1] if len(sys.argv) > 1 else r"C:\Users\Administrator\AppData\Local\Temp\tng_base.apk"
with zipfile.ZipFile(apk) as z:
data = b"".join(z.read(n) for n in z.namelist() if n.endswith(".dex"))
patterns = [
rb"handleExitApplication",
rb"System;->exit",
rb"Runtime;->exit",
rb"Process;->killProcess",
rb"Runtime;->halt",
rb"xwwqazamx/bl",
rb"xwwqazamx/w",
rb"xwwqazamx/W",
rb"addIntoQueue",
rb"handleRootingCallback",
rb"startForceExit",
]
for pat in patterns:
hits = len(re.findall(pat, data))
print(f"{pat.decode('utf-8', 'ignore')}: {hits}")
print("\n=== xwwqazamx class names (sample) ===")
classes = sorted(set(m.group().decode("utf-8", "ignore") for m in re.finditer(rb"Lxwwqazamx/[A-Za-z0-9_$]+;", data)))
for c in classes[:60]:
print(c)
print(f"... total {len(classes)}")
for pat in [b"startForceExit", b"ForceExit", b"openSecurityUrl", b"Lxwwqazamx/w;", b"Lxwwqazamx/bl;->"]:
print("---", pat.decode())
hits = sorted(set(m.group().decode("utf-8", "ignore") for m in re.finditer(pat + rb"[^\x00]{0,100}", data)))
for h in hits[:20]:
print(h)

View File

@@ -0,0 +1,72 @@
"""Find System.exit(10) / killProcess callers and nearby strings in TNG DEX."""
import re
import zipfile
from pathlib import Path
apk = Path(r"C:\Users\Administrator\Desktop\notiMessage\reverse\apks\tng\base.apk")
with zipfile.ZipFile(apk) as z:
dex_blobs = [(n, z.read(n)) for n in z.namelist() if n.endswith(".dex")]
needles = [
b"finishAllActivityAndKillApp",
b"UnhandledEvent detected",
b"AppSecurityManager: UnhandledEvent",
b"openSecurityUrl",
b"startForceExitCountdown",
b"ForceExitCountdown",
b"killProcess",
b"SecurityForceExit",
b"handleExitApplication",
b"exitApplication",
b"Jailbroken/Rooted",
b"Detected by AppProtect",
]
print("=== string hits ===")
for name, data in dex_blobs:
for n in needles:
c = data.count(n)
if c:
print(f"{name}: {n.decode(errors='ignore')} x{c}")
# Find UTF-16 / UTF-8 contexts around exit-related
print("\n=== contexts near 'exit' security strings ===")
for name, data in dex_blobs:
for m in re.finditer(rb"[\x20-\x7e]{0,30}(exit|KillApp|killApp|ForceExit|Unhandled)[\x20-\x7e]{0,80}", data):
s = m.group().decode("ascii", "ignore")
if any(k in s.lower() for k in ("force", "kill", "unhandled", "security", "promon", "root")):
print(f"{name}: {s}")
# Smali-ish type refs
print("\n=== type refs ===")
patterns = [
rb"Lmy/com/tngdigital/common/internal/_ContextKt;",
rb"Lmy/com/tngdigital/common/security/model/UnhandledEvent;",
rb"Lxwwqazamx/W;",
rb"Lxwwqazamx/bl;",
rb"Landroid/os/Process;->killProcess",
rb"Ljava/lang/System;->exit",
rb"Ljava/lang/Runtime;->exit",
]
for name, data in dex_blobs:
for pat in patterns:
hits = len(re.findall(pat, data))
if hits:
print(f"{name}: {pat.decode(errors='ignore')} x{hits}")
# Look for const/16 near exit - hard in raw dex; instead find methods that mention exit code strings
print("\n=== classes near ForceExit / KillApp strings ===")
for name, data in dex_blobs:
for pat in [b"finishAllActivityAndKillApp", b"UnhandledEvent detected", b"startForceExitCountdownIfNeeded"]:
i = 0
while True:
j = data.find(pat, i)
if j < 0:
break
# scan backwards for L...; class descriptor within 2KB
window = data[max(0, j - 2048):j]
classes = re.findall(rb"L[\w/$]+;", window)
if classes:
print(f"{name} @{j} near {pat.decode()}: ...{classes[-5:]}")
i = j + 1

View File

@@ -0,0 +1,103 @@
#!/usr/bin/env python3
"""Deeper TNG reverse: Exiting/Report, TigerTally API, kill-SVC in SO."""
from pathlib import Path
import re
import struct
ROOT = Path(__file__).resolve().parents[2]
APK_DIR = ROOT / "reverse" / "apks" / "tng"
SO = APK_DIR / "libtngdigital_ewallet.so"
def load_dexes():
files = sorted(APK_DIR.glob("classes*.dex"))
if not files:
# try extracted under other layouts
files = sorted((ROOT / "reverse" / "apks").rglob("tng*/classes*.dex"))
return files
def near(data, needle, before=40, after=80):
out = []
for m in re.finditer(re.escape(needle), data):
s = max(0, m.start() - before)
e = min(len(data), m.end() + after)
chunk = data[s:e]
printable = "".join(chr(b) if 32 <= b < 127 else "." for b in chunk)
out.append(printable)
return out
def main():
dexes = load_dexes()
print(f"dex count={len(dexes)}")
all_data = b""
for d in dexes:
data = d.read_bytes()
all_data += data
hits = []
for k in [b"Exiting:", b"Exiting", b"Report", b"W: 16", b"W:16",
b"TigerTallyAPI", b"ttInit", b"collect", b"killProcess",
b"SIGABRT", b"abort(", b"tgkill"]:
c = data.count(k)
if c:
hits.append(f"{k.decode('latin1')}x{c}")
if hits:
print(f"{d.name}: {', '.join(hits)}")
print("\n=== near Exiting ===")
for s in near(all_data, b"Exiting")[:15]:
print(" ", s)
print("\n=== TigerTallyAPI method refs ===")
for m in sorted(set(re.findall(rb"Lcom/aliyun/TigerTally/TigerTallyAPI;->[A-Za-z0-9_<>$]+", all_data))):
print(" ", m.decode())
print("\n=== TigerTally t/ classes ===")
for m in sorted(set(re.findall(rb"Lcom/aliyun/TigerTally/[a-z]/[A-Za-z0-9_/$]*;", all_data))):
print(" ", m.decode())
print("\n=== xwwqazamx/bl method refs ===")
for m in sorted(set(re.findall(rb"Lxwwqazamx/bl;->[A-Za-z0-9_<>$]+", all_data)))[:40]:
print(" ", m.decode())
print("\n=== Process.killProcess / Runtime.exit refs near Promon ===")
for pat in [rb"Landroid/os/Process;->killProcess", rb"Ljava/lang/System;->exit",
rb"Ljava/lang/Runtime;->exit", rb"Ljava/lang/Runtime;->halt"]:
print(pat.decode(), "count=", len(re.findall(pat, all_data)))
if SO.exists():
so = SO.read_bytes()
print(f"\n=== SO {SO.name} size={len(so)} ===")
# movz x8,#129 = D2801028 LE
patterns = {
"movz_x8_129": bytes.fromhex("281080d2"),
"movz_w8_129": bytes.fromhex("28108052"),
"movz_x8_130": bytes.fromhex("481080d2"),
"movz_x8_131": bytes.fromhex("681080d2"),
"svc0": bytes.fromhex("010000d4"),
"brk0": bytes.fromhex("000020d4"),
}
for name, pat in patterns.items():
print(f" {name}: {so.count(pat)}")
# find movz kill + nearby svc within 32 bytes
kill_imm = [bytes.fromhex(x) for x in ("281080d2", "28108052", "481080d2", "681080d2")]
svc = bytes.fromhex("010000d4")
found = 0
for imm in kill_imm:
start = 0
while True:
i = so.find(imm, start)
if i < 0:
break
window = so[i:i+36]
if svc in window:
found += 1
if found <= 20:
off = window.find(svc)
print(f" kill+svc @ file+0x{i:x} svc_delta={off}")
start = i + 4
print(f" kill+svc pairs (packed): {found}")
else:
print(f"\nSO missing: {SO}")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,70 @@
# -*- coding: utf-8 -*-
import re
import zipfile
APK = "reverse/apks/tng/base.apk"
KEYS = [
b"AppProtect", b"JailBroken", b"jailbroken", b"isRooted", b"isJailbroken",
b"VKey", b"Promon", b"promon", b"APSE", b"Close app", b"Rooted Device",
b"enhanced our security", b"How to keep device safe", b"Detected by",
]
CLASS_NEEDLES = [
b"JailBroken", b"AppProtect", b"ApSecurity", b"Promon", b"VKey", b"VGuard",
b"RootDetect", b"DeviceRisk", b"SecurityInitializer", b"libAPSE",
]
def main():
with zipfile.ZipFile(APK) as zf:
data = b"".join(zf.read(n) for n in zf.namelist() if n.endswith(".dex"))
print("=== KEY STRINGS ===")
for k in KEYS:
idx = 0
while True:
idx = data.find(k, idx)
if idx < 0:
break
s = max(0, idx - 80)
e = min(len(data), idx + len(k) + 120)
chunk = re.sub(rb"[^\x20-\x7e]+", b" ", data[s:e]).decode("ascii", "ignore")
print(f"[{k.decode()}] {chunk.strip()}")
idx += 1
print("\n=== KEY CLASSES ===")
classes = set(re.findall(rb"L[a-zA-Z0-9_$/]+;", data))
hits = []
for raw in classes:
if any(n in raw for n in CLASS_NEEDLES):
hits.append(raw.decode()[1:-1].replace("/", "."))
for h in sorted(set(hits)):
print(h)
FLOW_KEYS = [
b"Detected by", b"Close app", b"AppSecurityManager", b"RootEvent", b"RootI18n",
b"PromonError", b"showJailBrokenAlert", b"onShowPopupDisable", b"isJailBroken",
b"detectJailBroken", b"APSecuritySdk", b"no/promon/shield", b"HookingFrameworks",
b"How to keep device safe", b"onBlockStaticCheck",
]
def scan_flow():
with zipfile.ZipFile(APK) as zf:
data = b"".join(zf.read(n) for n in zf.namelist() if n.endswith(".dex"))
print("\n=== FLOW STRINGS ===")
for k in FLOW_KEYS:
idx = data.find(k)
if idx < 0:
continue
s = max(0, idx - 80)
e = min(len(data), idx + len(k) + 120)
chunk = re.sub(rb"[^\x20-\x7e]+", b" ", data[s:e]).decode("ascii", "ignore")
print(f"[{k.decode()}] {chunk.strip()}")
if __name__ == "__main__":
main()
scan_flow()

View File

@@ -0,0 +1,53 @@
"""Find NativeLib / loadLibrary targets in TNG APK."""
import re
import zipfile
from pathlib import Path
apk = Path(r"C:\Users\Administrator\Desktop\notiMessage\reverse\apks\tng\base.apk")
split = Path(r"C:\Users\Administrator\Desktop\notiMessage\reverse\apks\tng\split_config.arm64_v8a.apk")
with zipfile.ZipFile(apk) as z:
data = b"".join(z.read(n) for n in z.namelist() if n.endswith(".dex"))
print("base dex files:", [n for n in z.namelist() if n.endswith(".dex")])
print("base lib entries:", [n for n in z.namelist() if "lib/" in n][:40])
print("\nsplit libs:")
with zipfile.ZipFile(split) as z:
libs = [n for n in z.namelist() if n.endswith(".so")]
for n in libs:
print(" ", n)
# strings related to NativeLib
needles = [
b"NativeLib",
b"tngd.networksdk",
b"RetrieveFromNativeLibs",
b"getApiSixSecretKeys",
b"networksdk",
b"libtng",
b"loadLibrary",
]
print("\n=== string hits ===")
for n in needles:
hits = list(re.finditer(n, data))
print(f"{n!r}: {len(hits)}")
for h in hits[:5]:
ctx = data[max(0, h.start()-30):h.end()+80]
ctx = bytes(c if 32 <= c < 127 else 46 for c in ctx)
print(" ", ctx)
# library name candidates near NativeLib
print("\n=== lib name-like strings near 'NativeLib' / networksdk ===")
for m in re.finditer(rb"[\x20-\x7e]{4,60}", data):
s = m.group().decode()
if "network" in s.lower() or "tngd" in s.lower() or s.startswith("lib") and "tng" in s.lower():
if len(s) < 80:
print(" ", s)
# specific: System.loadLibrary argument often stored as short string without lib/ prefix
print("\n=== candidate loadLibrary short names ===")
cands = set(re.findall(rb"[\x00]([A-Za-z0-9_]{3,40})[\x00]", data))
for c in sorted(cands):
s = c.decode()
if any(k in s.lower() for k in ("tng", "network", "native", "promon", "shield", "apse")):
print(" ", s)

View File

@@ -0,0 +1,19 @@
"""Find NativeLib method signatures / loadLibrary name via dex string proximity."""
import re
import zipfile
from pathlib import Path
apk = Path(r"C:\Users\Administrator\Desktop\notiMessage\reverse\apks\tng\base.apk")
with zipfile.ZipFile(apk) as z:
for name in z.namelist():
if not name.endswith(".dex"):
continue
data = z.read(name)
if b"NativeLib" not in data and b"native-lib" not in data:
continue
print("===", name, "===")
for pat in [b"NativeLib", b"native-lib", b"getApiSixSecretKeys", b"RetrieveFromNativeLibs", b"Lcom/tngd/networksdk"]:
for m in re.finditer(pat, data):
ctx = data[max(0, m.start()-60):m.end()+100]
ctx = bytes(c if 32 <= c < 127 else 46 for c in ctx)
print(pat.decode(), "@", m.start(), ":", ctx.decode())

View File

@@ -0,0 +1,16 @@
import re
import sys
import zipfile
apk = sys.argv[1] if len(sys.argv) > 1 else r"C:\Users\Administrator\AppData\Local\Temp\tng_base.apk"
with zipfile.ZipFile(apk) as z:
data = b"".join(z.read(n) for n in z.namelist() if n.endswith(".dex"))
for cls in ["xwwqazamx/bl", "xwwqazamx/W", "xwwqazamx/a", "JNICLibrary", "hzchengdun"]:
pattern = cls.encode("utf-8") + rb"[^\x00]{0,120}"
hits = sorted(set(
m.group().decode("utf-8", "ignore") for m in re.finditer(pattern, data)
))
print(f"\n=== {cls} ({len(hits)} strings) ===")
for h in hits[:40]:
print(h)

View File

@@ -0,0 +1,18 @@
"""Find SecurityErrorActivity onCreate signature."""
import re
import zipfile
APK = r"C:\Users\Administrator\Desktop\notiMessage\reverse\apks\tng\base.apk"
with zipfile.ZipFile(APK) as z:
data = b"".join(z.read(n) for n in z.namelist() if n.endswith(".dex"))
for pat in [
b"SecurityErrorActivity",
b"launchProcessNextSecurityState",
b"addIntoQueueAndLaunch",
b"SecurityErrorBaseActivity;->onCreate",
]:
print("===", pat.decode(), "===")
for m in sorted(set(re.findall(pat + rb"[^\x00]{0,120}", data))):
print(m.decode("ascii", "ignore")[:150])
print()

View File

@@ -0,0 +1,61 @@
"""Scan libtngdigital_ewallet.so for SVC / exit patterns."""
from pathlib import Path
import struct
SO = Path(r"C:\Users\Administrator\Desktop\notiMessage\reverse\apks\tng\libtngdigital_ewallet.so")
if not SO.exists():
# try from split apk
import zipfile
apk = Path(r"C:\Users\Administrator\Desktop\notiMessage\reverse\apks\tng\split_config.arm64_v8a.apk")
if apk.exists():
with zipfile.ZipFile(apk) as z:
for n in z.namelist():
if n.endswith("libtngdigital_ewallet.so"):
SO.write_bytes(z.read(n))
print("extracted from", apk, "->", SO)
break
data = SO.read_bytes()
print("size", len(data), SO)
svc = b"\x01\x00\x00\xd4"
idxs = []
start = 0
while True:
i = data.find(svc, start)
if i < 0:
break
idxs.append(i)
start = i + 4
print("total svc#0:", len(idxs))
# movz x8,#93 = d2 80 0b a8 ; movz x8,#94 = d2 80 0b c8 (LE)
# bytes LE: A8 0B 80 D2 / C8 0B 80 D2
exit_setups = [
(b"\xa8\x0b\x80\xd2", 93), # movz x8, #93
(b"\xc8\x0b\x80\xd2", 94), # movz x8, #94
(b"\xa8\x0b\x80\x52", 93), # movz w8, #93
(b"\xc8\x0b\x80\x52", 94), # movz w8, #94
]
for pat, nr in exit_setups:
c = data.count(pat)
print(f"movz *8,#{nr} pattern count={c}")
print("\nSVC with nearby exit setup (lookback 32 bytes):")
hits = 0
for i in idxs[:2000]:
window = data[max(0, i - 32) : i]
for pat, nr in exit_setups:
if pat in window:
print(f" off=0x{i:x} exit_group/exit via #{nr}")
hits += 1
break
print("hits", hits)
# also search brk
brk = b"\x00\x00\x20\xd4" # brk #0
print("brk#0 count", data.count(brk))
# string refs
for s in [b"_exit", b"exit_group", b"abort", b"frida", b"/proc/self/maps", b"xposed"]:
print(s, "->", data.find(s))

View File

@@ -0,0 +1,41 @@
#!/usr/bin/env python3
from pathlib import Path
DIR = Path(__file__).resolve().parents[1] / "apks" / "tng"
KILL = [bytes.fromhex(x) for x in (
"281080d2", "28108052", "481080d2", "48108052", "681080d2", "68108052",
)]
SVC = bytes.fromhex("010000d4")
EXIT = [bytes.fromhex(x) for x in ("c80b80d2", "ba0b80d2")] # exit_group, exit
for so in sorted(DIR.glob("lib*.so")):
data = so.read_bytes()
pairs = 0
for imm in KILL:
i = 0
while True:
j = data.find(imm, i)
if j < 0:
break
if SVC in data[j:j + 36]:
pairs += 1
if pairs <= 10:
print(f"{so.name} KILL+SVC @0x{j:x} d={data[j:j+36].find(SVC)}")
i = j + 4
ep = 0
for imm in EXIT:
i = 0
while True:
j = data.find(imm, i)
if j < 0:
break
if SVC in data[j:j + 36]:
ep += 1
if ep <= 8:
print(f"{so.name} EXIT+SVC @0x{j:x}")
i = j + 4
print(
f"{so.name}: size={len(data)} svc0={data.count(SVC)} "
f"kill+svc={pairs} exit+svc={ep} "
f"abort={data.count(b'abort')} kill={data.count(b'kill')}"
)

View File

@@ -0,0 +1,40 @@
# -*- coding: utf-8 -*-
"""TNG reverse notes helper — ForceExit / abort / Promon suicide map."""
import re
import zipfile
from pathlib import Path
APK = Path(__file__).resolve().parents[1] / "apks" / "tng" / "base.apk"
SO = Path(__file__).resolve().parents[1] / "apks" / "tng" / "libtngdigital_ewallet.so"
def main():
with zipfile.ZipFile(APK) as z:
data = b"".join(z.read(n) for n in z.namelist() if n.endswith(".dex"))
print("=== suicide ladder (from runtime + static) ===")
print("1) Promon root hit -> openSecurityUrl Rooting FAQ (Xposed blocks)")
print("2) xwwqazamx.W -> KillApplicationHandler (Xposed blocks)")
print("3) native exit_group(1) OR SIGABRT SI_USER via libc abort/raise/tgkill")
print("4) AppSecurityManager.startForceExitCountdown* / addIntoQueueAndLaunch")
print()
print("=== ForceExit-related descriptors ===")
for m in sorted(set(re.findall(rb"L[A-Za-z0-9_/$]*ForceExit[A-Za-z0-9_/$]*;", data))):
print(m.decode())
print("\n=== AppSecurityManager log strings (detection events) ===")
for m in re.finditer(rb"AppSecurityManager: [A-Za-z][^\x00]{5,80}", data):
s = m.group().decode("utf-8", "ignore")
if any(k in s for k in ("Root", "Hook", "Emulator", "Force", "Unhandled", "Navigat")):
print(s)
if SO.exists():
raw = SO.read_bytes()
print("\n=== SO imports of interest ===")
for s in (b"abort", b"raise", b"tgkill", b"kill", b"exit"):
print(s.decode(), "at", hex(raw.find(s)) if raw.find(s) >= 0 else None)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,66 @@
# -*- coding: utf-8 -*-
"""Scan TNG DEX for Aliyun TigerTally / abort / SI_USER suicide helpers."""
import re
import zipfile
from pathlib import Path
APK = Path(__file__).resolve().parents[1] / "apks" / "tng" / "base.apk"
KEYS = [
b"TigerTally",
b"aliyun",
b"Aliyun",
b"com/aliyun/TigerTally",
b"UnhandledEvent detected",
b"finishAllActivityAndKillApp",
b"trackUnhandledEvent",
b"SI_USER",
b"raise",
b"SIGABRT",
b"pthread_kill",
b"dispatchUncaughtException",
b"AppProtect",
b"promon",
b"xwwqazamx",
]
def main():
with zipfile.ZipFile(APK) as z:
entries = [(n, z.read(n)) for n in z.namelist() if n.endswith(".dex")]
print("=== key hits ===")
for name, data in entries:
for k in KEYS:
c = data.count(k)
if c:
print(f"{name}: {k.decode('utf-8','ignore')} x{c}")
print("\n=== TigerTally class descriptors ===")
all_data = b"".join(d for _, d in entries)
classes = sorted(set(re.findall(rb"Lcom/aliyun/TigerTally/[A-Za-z0-9_/$]*;", all_data)))
for c in classes[:80]:
print(c.decode())
print("total", len(classes))
print("\n=== nearby strings TigerTally ===")
for m in re.finditer(rb"TigerTally[\x20-\x7e]{0,60}", all_data):
print(m.group().decode("ascii", "ignore"))
print("\n=== finishAll / Unhandled contexts ===")
for pat in [
b"finishAllActivityAndKillApp",
b"UnhandledEvent detected",
b"trackUnhandledEvent",
b"dispatchUncaughtException",
]:
idx = all_data.find(pat)
if idx < 0:
continue
ctx = all_data[max(0, idx - 40) : idx + len(pat) + 80]
printable = "".join(chr(b) if 32 <= b < 127 else "." for b in ctx)
print(pat.decode(), "=>", printable)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,36 @@
# -*- coding: utf-8 -*-
import re
import zipfile
APK = "reverse/apks/tng/base.apk"
KEYS = [
b"36616543382169", b"support.tngdigital", b"Rooting", b"How to keep device safe",
b"showJailBroken", b"openUrl", b"openBrowser", b"launchUrl", b"ACTION_VIEW",
b"RootI18n", b"SecurityError", b"startChrome", b"IntentDispatcher",
]
def main():
with zipfile.ZipFile(APK) as zf:
data = b"".join(zf.read(n) for n in zf.namelist() if n.endswith(".dex"))
for k in KEYS:
idx = 0
while True:
idx = data.find(k, idx)
if idx < 0:
break
s = max(0, idx - 80)
e = min(len(data), idx + len(k) + 120)
chunk = re.sub(rb"[^\x20-\x7e]+", b" ", data[s:e]).decode("ascii", "ignore")
print(f"[{k.decode()}] {chunk.strip()}")
idx += 1
print("\n=== classes with Root/security ===")
classes = set(re.findall(rb"Lmy/com/tngdigital/common/security[^;]+;", data))
for c in sorted(classes):
s = c.decode()[1:-1].replace("/", ".")
if any(x in s.lower() for x in ["root", "error", "jail", "shield", "promon"]):
print(s)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,24 @@
# -*- coding: utf-8 -*-
import zipfile
from pathlib import Path
APK = Path(__file__).resolve().parent.parent / "apks" / "maribank_sg_base.apk"
needles = [
b"The system is currently unavailable",
b"dfp is empty",
b"getDfp",
b"preCheck",
b"register",
b"Sign up with mobile",
b"msg_ekyc_singpass_service_error",
b"general_error",
b"GlobalAuth",
]
with zipfile.ZipFile(str(APK)) as zf:
bundles = [n for n in zf.namelist() if n.endswith(".jsbundle")]
print("bundles:", len(bundles))
for name in bundles:
data = zf.read(name)
hits = [n.decode() for n in needles if n in data]
if hits:
print(name, hits)

View File

@@ -0,0 +1,89 @@
import re
import subprocess
import sys
import time
import xml.etree.ElementTree as ET
ADB = ["adb"]
def adb(*args):
subprocess.run(ADB + list(args), check=False)
def main():
adb("shell", "am", "force-stop", "my.com.tngdigital.ewallet")
time.sleep(2)
adb("shell", "am", "start", "-n", "my.com.tngdigital.ewallet/.ui.SplashActivity")
time.sleep(16)
adb("shell", "uiautomator", "dump", "/sdcard/ui_cc.xml")
xml = subprocess.check_output(
ADB + ["shell", "cat", "/sdcard/ui_cc.xml"], text=True, errors="ignore")
root = ET.fromstring(xml)
target = None
register_btn = None
for node in root.iter("node"):
rid = node.get("resource-id") or ""
text = node.get("text") or ""
if "tv_left" in rid or (text.strip().startswith("+") and len(text.strip()) < 8):
target = node
if "注册" in text and node.get("clickable") == "true":
register_btn = node
if target is None and register_btn is not None:
bounds = register_btn.get("bounds", "")
m = re.match(r"\[(\d+),(\d+)\]\[(\d+),(\d+)\]", bounds)
if m:
x1, y1, x2, y2 = map(int, m.groups())
x, y = (x1 + x2) // 2, (y1 + y2) // 2
print(f"tap register at {x},{y}")
adb("shell", "input", "tap", str(x), str(y))
time.sleep(4)
adb("shell", "uiautomator", "dump", "/sdcard/ui_cc.xml")
xml = subprocess.check_output(
ADB + ["shell", "cat", "/sdcard/ui_cc.xml"], text=True, errors="ignore")
root = ET.fromstring(xml)
for node in root.iter("node"):
rid = node.get("resource-id") or ""
text = node.get("text") or ""
if "tv_left" in rid or (text.strip().startswith("+") and len(text.strip()) < 8):
target = node
break
if target is None:
for node in root.iter("node"):
text = node.get("text") or ""
if "注册" in text or "Register" in text.lower():
print("login screen text:", text[:40])
print("ERROR: country code control not found")
return 1
bounds = target.get("bounds", "")
m = re.match(r"\[(\d+),(\d+)\]\[(\d+),(\d+)\]", bounds)
if not m:
print("bad bounds", bounds)
return 1
x1, y1, x2, y2 = map(int, m.groups())
x, y = (x1 + x2) // 2, (y1 + y2) // 2
print(f"tap country {target.get('text','')} at {x},{y}")
adb("shell", "input", "tap", str(x), str(y))
time.sleep(3)
adb("shell", "uiautomator", "dump", "/sdcard/ui_cc2.xml")
xml2 = subprocess.check_output(
ADB + ["shell", "cat", "/sdcard/ui_cc2.xml"], text=True, errors="ignore")
root2 = ET.fromstring(xml2)
countries = []
for node in root2.iter("node"):
text = (node.get("text") or "").strip()
if "+61" in text or "+86" in text or "Australia" in text or "Malaysia" in text:
countries.append(text)
pid = subprocess.check_output(
ADB + ["shell", "pidof", "my.com.tngdigital.ewallet"], text=True).strip()
print("pid:", pid or "DEAD")
print("countries visible:", countries[:8])
if countries:
print("OK region picker visible")
return 0
print("FAIL region picker empty/black")
return 1
if __name__ == "__main__":
sys.exit(main())

View File

@@ -0,0 +1,466 @@
#!/usr/bin/env python3
"""TNG 全流程自动化:注册区号 + 登录 PIN/区号;两次冷启动,互不干扰。"""
import re
import subprocess
import sys
import time
import xml.etree.ElementTree as ET
PKG = "my.com.tngdigital.ewallet"
SPLASH = f"{PKG}/.ui.SplashActivity"
ADB = ["adb"]
TNG_LOG = re.compile(
r"TngRoot|F HWUI|GraphicBuffer|Runtime abort|signal 6|exited due to signal"
r"|UserSearchCallingCode|UserLogin|UserRegistration|SecurityError"
r"|Dialog\.show|skip loading|registration flow",
re.I,
)
CRASH_LOG = re.compile(
r"signal 6|Runtime aborting|F HWUI|F GraphicBuffer|gralloc-mapper is missing",
re.I,
)
def adb(*args, timeout=60):
r = subprocess.run(ADB + list(args), capture_output=True, text=True, timeout=timeout, errors="ignore")
return r.returncode, (r.stdout or "") + (r.stderr or "")
def is_tng_foreground():
_, out = adb("shell", "dumpsys", "activity", "activities")
for line in out.splitlines():
if "topResumedActivity=" in line:
return PKG in line
return False
def bring_tng_foreground():
adb("shell", "input", "keyevent", "KEYCODE_WAKEUP")
if is_tng_foreground():
return
adb("shell", "am", "start", "-n", SPLASH)
time.sleep(2)
def dump_ui(path="/sdcard/tng_ui.xml", retries=2):
adb("shell", "input", "keyevent", "KEYCODE_WAKEUP")
for _ in range(retries):
adb("shell", "uiautomator", "dump", path)
code, xml = adb("shell", "cat", path)
if code == 0 and xml.strip().startswith("<?xml"):
try:
return ET.fromstring(xml)
except ET.ParseError:
pass
time.sleep(1)
return None
def center(bounds_str):
m = re.match(r"\[(\d+),(\d+)\]\[(\d+),(\d+)\]", bounds_str or "")
if not m:
return None
x1, y1, x2, y2 = map(int, m.groups())
return (x1 + x2) // 2, (y1 + y2) // 2
def find_pin_login_row(root):
candidates = []
for node in root.iter("node"):
t = node.get("text") or ""
if "忘记" in t:
continue
pt = center(node.get("bounds"))
if pt is None:
continue
# 登录方式页 PIN 行 y≈600780
if 600 <= pt[1] <= 780 and (
"PIN" in t.upper() or "6位数" in t or "6位" in t or (len(t) >= 4 and "PIN" in t)
):
candidates.append((pt[1], node))
if not candidates:
return None
candidates.sort(key=lambda x: x[0])
return candidates[0][1]
def wait_phone_page(timeout=20):
return wait_for(lambda: is_phone_page(dump_ui()), timeout=timeout, interval=1.5, desc="phone page")
def find_nodes(root, **kwargs):
out = []
for node in root.iter("node"):
text = (node.get("text") or "").strip()
rid = node.get("resource-id") or ""
cls = node.get("class") or ""
clickable = node.get("clickable") == "true"
ok = True
if "text_contains" in kwargs and kwargs["text_contains"] not in text:
ok = False
if "text_excludes" in kwargs:
for ex in kwargs["text_excludes"]:
if ex in text:
ok = False
if "rid_contains" in kwargs and kwargs["rid_contains"] not in rid:
ok = False
if "class_contains" in kwargs and kwargs["class_contains"] not in cls:
ok = False
if kwargs.get("clickable") and not clickable:
ok = False
if ok:
out.append(node)
return out
def tap_node(node, label=""):
pt = center(node.get("bounds"))
if not pt:
print(f" skip tap {label}: bad bounds")
return False
x, y = pt
print(f" tap {label!r} at {x},{y}")
adb("shell", "input", "tap", str(x), str(y))
return True
def find_country_control(root, min_y=0, max_y=9999):
nodes = find_nodes(root, rid_contains="ll_country", clickable=True)
for node in nodes:
pt = center(node.get("bounds"))
if pt and min_y <= pt[1] <= max_y:
return node
nodes = find_nodes(root, rid_contains="tv_left", clickable=True)
for node in nodes:
pt = center(node.get("bounds"))
if pt and min_y <= pt[1] <= max_y:
return node
return None
def find_register_btn(root):
for node in root.iter("node"):
if node.get("clickable") != "true":
continue
t = node.get("text") or ""
if ("注册" in t or "Register" in t.lower()) and "已经" not in t and "已注" not in t:
return node
pt = center(node.get("bounds"))
if pt and 1050 < pt[1] < 1220 and 430 < pt[0] < 660:
return node
return None
def is_phone_page(root):
return bool(find_nodes(root, rid_contains="userContinueBtn"))
def is_register_page(root):
return bool(
find_nodes(root, rid_contains="ftv_register_title")
or find_nodes(root, rid_contains="userRegisterContinueBtn")
)
def has_country_list(root):
if root is None:
return False
for node in root.iter("node"):
t = node.get("text") or ""
if any(k in t for k in ("Malaysia", "Singapore", "Australia", "China", "+86", "+61")):
return True
return False
def pid():
code, out = adb("shell", "pidof", PKG)
return out.strip() if code == 0 and out.strip() else ""
def top_activity():
_, out = adb("shell", "dumpsys", "activity", "activities")
for line in out.splitlines():
if PKG not in line:
continue
if "topResumedActivity=" in line:
m = re.search(r"/([^/}\s]+)", line)
return m.group(1) if m else "?"
for line in out.splitlines():
if "ResumedActivity:" in line and PKG in line:
m = re.search(r"/([^/}\s]+)", line)
if m:
return m.group(1)
return "?"
def wait_for(fn, timeout=35, interval=1.5, desc=""):
deadline = time.time() + timeout
while time.time() < deadline:
if fn():
return True
time.sleep(interval)
print(f" timeout: {desc}")
return False
def wait_ui_widgets(timeout=30):
"""等待 Compose 控件出现在 dumpSplash WebView 退场后)。"""
def ready():
root = dump_ui()
if root is None:
return False
return bool(
find_pin_login_row(root)
or find_register_btn(root)
or is_phone_page(root)
or find_nodes(root, rid_contains="ftv_content")
or find_nodes(root, rid_contains="ftv_register_title")
)
return wait_for(ready, timeout=timeout, interval=2, desc="login/register widgets")
def wait_login_method(timeout=40):
def ready():
if not pid():
return False
if not is_tng_foreground():
bring_tng_foreground()
return "UserLoginActivity" in top_activity()
ok = wait_for(ready, timeout=timeout, interval=2, desc="UserLoginActivity ready")
if ok:
wait_ui_widgets(timeout=25)
return ok
def wait_login_method_register(timeout=40):
return wait_login_method(timeout=timeout)
def wait_country_list(timeout=30):
def activity_has_country_list():
act = top_activity()
if "UserSearchCallingCode" in act:
return True
_, out = adb("shell", "dumpsys", "activity", "activities")
return "UserSearchCallingCodeActivity" in out and PKG in out
def ready():
if activity_has_country_list():
return True
root = dump_ui()
return has_country_list(root)
return wait_for(ready, timeout=timeout, interval=1.5, desc="country list")
def cold_start(clear_log=False):
if clear_log:
adb("logcat", "-c")
adb("shell", "am", "force-stop", PKG)
time.sleep(3)
adb("shell", "input", "keyevent", "KEYCODE_WAKEUP")
adb("shell", "am", "start", "-W", "-n", SPLASH)
ok = wait_for(
lambda: bool(pid()) and "UserLoginActivity" in top_activity(),
timeout=60,
interval=2,
desc="UserLoginActivity after cold start",
)
if not ok:
return False
bring_tng_foreground()
time.sleep(3)
return True
def cold_start_with_retry(clear_log=False, attempts=3):
for i in range(attempts):
if cold_start(clear_log and i == 0):
return True
print(f" cold start retry {i + 1}/{attempts}")
adb("shell", "am", "force-stop", PKG)
time.sleep(5)
return bool(pid()) and "UserLoginActivity" in top_activity()
def tap_pin_row(root):
if root is None:
print(" PIN fallback (no dump) 561,677")
adb("shell", "input", "tap", "561", "677")
return
pin_text = find_pin_login_row(root)
if pin_text is None:
if find_nodes(root, rid_contains="ftv_content") or find_nodes(root, rid_contains="ftv_title"):
print(" PIN layout fallback 561,677")
adb("shell", "input", "tap", "561", "677")
return
print(" PIN fallback tap 540,677")
adb("shell", "input", "tap", "540", "677")
return
pt = center(pin_text.get("bounds"))
print(f" tap PIN at {pt[0]},{pt[1]}")
adb("shell", "input", "tap", str(pt[0]), str(pt[1]))
def collect_logs(n=3000):
_, out = adb("shell", "logcat", "-d", "-t", str(n))
return [ln for ln in out.splitlines() if TNG_LOG.search(ln)]
def collect_crashes(n=4000):
_, out = adb("shell", "logcat", "-d", "-t", str(n))
return [ln for ln in out.splitlines() if CRASH_LOG.search(ln) and "digital.ewallet" in ln]
def step(name, fn):
print(f"\n=== {name} ===")
bring_tng_foreground()
ok = fn()
print(f" pid={pid() or 'DEAD'} activity={top_activity()}")
return ok and bool(pid())
def wait_register_page(timeout=20):
def ready():
root = dump_ui()
if root is None or not is_register_page(root):
return False
ctrl = find_country_control(root, min_y=680)
if ctrl is None:
return False
pt = center(ctrl.get("bounds"))
# 注册页 ll_country 中心 y 通常 > 680
return pt is not None and pt[1] >= 680
ok = wait_for(ready, timeout=timeout, interval=1.5, desc="register page settled")
if ok:
time.sleep(1)
return ok
def flow_register():
time.sleep(5)
if not cold_start_with_retry():
return False
if not wait_login_method_register():
print(" register entry not visible")
return False
root = dump_ui()
reg = find_register_btn(root)
if reg is None:
print(" register fallback 540,1137")
adb("shell", "input", "tap", "540", "1137")
else:
tap_node(reg, "register")
if not wait_register_page():
return False
root2 = dump_ui()
target = find_country_control(root2, min_y=680)
if target is None:
print(" no country control on register page")
return False
tap_node(target, "register country")
if not wait_country_list(timeout=25):
return False
root3 = dump_ui()
if root3 is not None:
for node in root3.iter("node"):
t = node.get("text") or ""
if "Malaysia" in t or "Singapore" in t:
print(f" country UI: {t[:40]}")
break
logs = collect_logs(300)
for ln in logs:
if "skip loading" in ln or "UserSearchCallingCode" in ln:
print(f" log: {ln[:120]}")
break
return True
def flow_login():
if not cold_start_with_retry(clear_log=True):
return False
if not wait_login_method():
return False
root = dump_ui()
if is_phone_page(root):
print(" already on phone page")
else:
for attempt in range(2):
root = dump_ui()
if root is None:
time.sleep(2)
continue
if is_phone_page(root):
print(" phone page ready")
break
tap_pin_row(root)
if wait_phone_page(timeout=15):
break
print(f" phone page retry {attempt + 1}/2")
time.sleep(2)
else:
return False
root2 = dump_ui()
target = find_country_control(root2, min_y=450, max_y=650)
if target is None:
print(" no country control on phone page")
return False
tap_node(target, "login country")
if not wait_country_list():
return False
root3 = dump_ui()
if root3 is not None:
for node in root3.iter("node"):
t = node.get("text") or ""
if "Malaysia" in t or "Singapore" in t:
print(f" country UI: {t[:40]}")
break
logs = collect_logs(300)
for ln in logs:
if "skip loading" in ln:
print(f" log: {ln[:120]}")
break
return "UserSearchCallingCode" in top_activity() or has_country_list(root3)
def main():
adb("shell", "svc", "power", "stayon", "true")
adb("logcat", "-c")
adb("shell", "am", "force-stop", PKG)
time.sleep(5)
# 先登录后注册:首次冷启动最稳定
results = [
("login_country", step("A. login PIN → country list", flow_login)),
("register_country", step("B. register → country list", flow_register)),
]
crashes = collect_crashes()
p = pid()
print("\n=== SUMMARY ===")
for name, ok in results:
print(f" {'PASS' if ok else 'FAIL'} {name}")
print(f" pid={p or 'DEAD'} activity={top_activity()}")
print(f" crashes={len(crashes)}")
if crashes:
for ln in crashes[-5:]:
print(" ", ln[:150])
print("\n=== TngRoot (last 20) ===")
for ln in collect_logs(2000)[-20:]:
if "TngRoot" in ln:
print(ln[:180])
ok = bool(p) and all(r[1] for r in results)
if crashes and not ok:
print(" (crash lines may include prior process; see pid)")
return 0 if ok else 1
if __name__ == "__main__":
sys.exit(main())

View File

@@ -0,0 +1,110 @@
#!/usr/bin/env python3
"""Automate TNG login/register tap test and report crashes."""
import re
import subprocess
import sys
import time
PKG = "my.com.tngdigital.ewallet"
TAGS = re.compile(
r"FATAL EXCEPTION|AndroidRuntime.*Process: " + PKG
+ r"|has died|exited due to signal|vhvlnqgy\.bd|blocked killProcess|blocked System\.exit"
+ r"|UserRegistration|UserOtp|UserLogin|Displayed.*tngdigital|ACT on(Create|Resume)"
+ r"|TngExitGuard.*ABRT|TngRoot hooked 6 vhvlnqgy\.R",
re.I,
)
TAPS = [
("register_continue", 540, 959, 8),
("maybe_otp_continue", 540, 959, 6),
("back_to_login", None, None, 2),
("login_tab", 540, 1168, 5),
]
def adb(*args, timeout=30):
cmd = ["adb"] + list(args)
r = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
out = (r.stdout or "") + (r.stderr or "")
return r.returncode, out.strip()
def pid_alive():
code, out = adb("shell", "pidof", PKG)
return code == 0 and out.strip().split()
def top_activity():
code, out = adb("shell", "dumpsys", "activity", "activities")
if code != 0:
return "?"
for line in out.splitlines():
if "topResumedActivity=" in line:
m = re.search(r"/([^/]+)$", line.strip())
if m:
return m.group(1).rstrip("}")
return line.strip()
return "?"
def logcat_since(start):
code, out = adb("shell", "logcat", "-d", "-t", start)
hits = []
if code == 0:
for line in out.splitlines():
if TAGS.search(line):
hits.append(line)
return hits
def main():
adb("logcat", "-c")
adb("shell", "am", "force-stop", PKG)
time.sleep(1)
adb("shell", "am", "start", "-n", f"{PKG}/.ui.SplashActivity")
time.sleep(12)
print("=== after cold start ===")
print("pids:", pid_alive())
print("activity:", top_activity())
# dismiss country picker if open
adb("shell", "input", "keyevent", "KEYCODE_BACK")
time.sleep(1)
results = []
for name, x, y, wait_s in TAPS:
if x is not None:
adb("shell", "input", "tap", str(x), str(y))
print(f"\n=== tap {name} ({x},{y}) ===")
else:
adb("shell", "input", "keyevent", "KEYCODE_BACK")
print(f"\n=== {name} ===")
time.sleep(wait_s)
pids = pid_alive()
act = top_activity()
crash_buf = adb("shell", "logcat", "-d", "-b", "crash", "-t", "50")[1]
fatal = [l for l in crash_buf.splitlines() if PKG in l or "vhvlnqgy" in l]
results.append((name, pids, act, fatal))
print("pids:", pids or "DEAD")
print("activity:", act)
if fatal:
print("CRASH:", fatal[-3:])
print("\n=== summary ===")
ok = True
for name, pids, act, fatal in results:
status = "OK" if pids and not fatal else "FAIL"
if status == "FAIL":
ok = False
print(f"{status} {name}: pids={pids} activity={act} crash_lines={len(fatal)}")
hits = logcat_since("2000")
print(f"\n=== key log lines ({len(hits)}) ===")
for line in hits[-40:]:
print(line)
return 0 if ok else 1
if __name__ == "__main__":
sys.exit(main())

10
scripts/_tng_anr_dump.sh Normal file
View File

@@ -0,0 +1,10 @@
#!/system/bin/sh
ANR=/data/anr/anr_2026-07-31-13-30-08-628
echo "=== header ==="
su -c "head -40 $ANR"
echo "=== main tid ==="
su -c "grep -n '\"main\"' $ANR | head -5"
echo "=== main block ==="
su -c "awk '/\"main\" prio/{p=1} p{print} p&&/^$/{c++} c>=2{exit}' $ANR" | head -80
echo "=== cpu ==="
su -c "grep -A20 'CPU usage' $ANR | head -30"

View File

@@ -0,0 +1,6 @@
#!/system/bin/sh
ANR=/data/anr/anr_2026-07-31-13-30-08-628
echo "=== RenderThread ==="
su -c "awk '/\"RenderThread\"/{p=1} p{print} p&&/^\"/{if(!/RenderThread/){exit}}' $ANR" | head -60
echo "=== Quake / promon threads ==="
su -c "grep -E '^\"|libtng|Quake|xwwq|promon|tiger' $ANR | head -80"

View File

@@ -0,0 +1,22 @@
#!/system/bin/sh
# 抓到 getprop 子进程瞬间 dump 其 syscall/status验证 exit_group 被 seccomp 卡住)
PKG="my.com.tngdigital.ewallet"
am force-stop "$PKG" 2>/dev/null
sleep 1
am start -n "$PKG/.ui.SplashActivity" 2>/dev/null
i=0
while [ $i -lt 200 ]; do
i=$((i+1))
for gp in $(pidof getprop 2>/dev/null); do
ppid=$(awk '/^PPid/{print $2}' /proc/$gp/status 2>/dev/null)
pp=$(tr '\0' ' ' < /proc/$ppid/cmdline 2>/dev/null)
echo "GETPROP pid=$gp ppid=$ppid pp=[$pp]"
echo " seccomp: $(grep -i seccomp /proc/$gp/status 2>/dev/null | tr '\n' ' ')"
echo " syscall: $(cat /proc/$gp/syscall 2>/dev/null)"
echo " wchan: $(cat /proc/$gp/wchan 2>/dev/null) state=$(awk '/^State/{print $2}' /proc/$gp/status 2>/dev/null)"
done
sleep 0.05
done
echo "=== done ==="

24
scripts/_tng_pin_check.sh Normal file
View File

@@ -0,0 +1,24 @@
#!/system/bin/sh
logcat -c
input keyevent KEYCODE_WAKEUP
settings put system screen_off_timeout 600000
am force-stop my.com.tngdigital.ewallet
sleep 1
monkey -p my.com.tngdigital.ewallet -c android.intent.category.LAUNCHER 1 >/dev/null 2>&1
i=0
while [ $i -lt 40 ]; do
sleep 1
i=$((i+1))
p=$(pidof my.com.tngdigital.ewallet)
if [ -z "$p" ]; then
echo "t=${i}s DEAD"
break
fi
if [ $((i % 5)) -eq 0 ]; then
echo "t=${i}s pid=$p"
fi
done
echo "=== focus ==="
dumpsys window 2>/dev/null | grep -E 'mCurrentFocus|mFocusedApp' | head -4
echo "=== key ==="
logcat -d 2>/dev/null | grep -E 'TngExitGuard|Displayed.*User|UserPin|UserLogin|seccomp|caught sig=|exited cleanly|Fatal signal|ANR in my.com.tng' | tail -40

View File

@@ -0,0 +1,39 @@
#!/system/bin/sh
# 抓 TigerTally fread 阻塞的 pipe 写端进程。自动启动 TNG 并连续扫描。
PKG="my.com.tngdigital.ewallet"
am force-stop "$PKG" 2>/dev/null
sleep 1
am start -n "$PKG/.ui.SplashActivity" 2>/dev/null
i=0
while [ $i -lt 40 ]; do
i=$((i+1))
pid=""
for p in $(pidof "$PKG"); do
if [ "$(tr '\0' ' ' < /proc/$p/cmdline 2>/dev/null | tr -d ' ')" = "$PKG" ]; then pid=$p; break; fi
done
if [ -z "$pid" ]; then sleep 0.5; continue; fi
for d in /proc/$pid/task/*/; do
t="${d%/}"
s=$(cat "$t/syscall" 2>/dev/null)
case "$s" in
63*)
c=$(cat "$t/comm" 2>/dev/null)
fd=$(echo "$s" | awk '{print $2}')
fd=$((fd))
tgt=$(readlink "/proc/$pid/fd/$fd" 2>/dev/null)
echo "[$i] pid=$pid TID=${t##*/} comm=$c wchan=$(cat "$t/wchan" 2>/dev/null) syscall=$s"
echo "[$i] fd=$fd -> $tgt"
ino=$(echo "$tgt" | sed 's/.*\[//; s/\]//')
if [ -n "$ino" ]; then
echo "[$i] writer-search inode=$ino:"
find /proc/[0-9]*/fd -lname "*$ino*" 2>/dev/null
fi
;;
esac
done
sleep 0.5
done
echo "=== scan done ==="

View File

@@ -0,0 +1,65 @@
#!/system/bin/sh
# 抓 TigerTally fread 阻塞的 pipe 写端进程,命中后立即 dump 该进程身份/冻结状态。
PKG="my.com.tngdigital.ewallet"
am force-stop "$PKG" 2>/dev/null
sleep 1
am start -n "$PKG/.ui.SplashActivity" 2>/dev/null
i=0
while [ $i -lt 60 ]; do
i=$((i+1))
pid=""
for p in $(pidof "$PKG"); do
if [ "$(tr '\0' ' ' < /proc/$p/cmdline 2>/dev/null | tr -d ' ')" = "$PKG" ]; then pid=$p; break; fi
done
if [ -z "$pid" ]; then sleep 0.5; continue; fi
for d in /proc/$pid/task/*/; do
t="${d%/}"
s=$(cat "$t/syscall" 2>/dev/null)
case "$s" in
63*)
c=$(cat "$t/comm" 2>/dev/null)
case "$c" in
*pool*|*Tiger*|*tiger*|*tally*|*Tally*)
fd=$(echo "$s" | awk '{print $2}')
fd=$((fd))
tgt=$(readlink "/proc/$pid/fd/$fd" 2>/dev/null)
echo "[$i] MAIN pid=$pid TID=${t##*/} comm=$c wchan=$(cat "$t/wchan" 2>/dev/null)"
echo "[$i] MAIN fd=$fd -> $tgt"
ino=$(echo "$tgt" | sed 's/.*\[//; s/\]//')
[ -z "$ino" ] && continue
echo "[$i] writer-search inode=$ino:"
for wp in $(find /proc/[0-9]*/fd -lname "*$ino*" 2>/dev/null); do
echo "[$i] $wp"
done
# dump 所有非主进程端点
for wp in $(find /proc/[0-9]*/fd -lname "*$ino*" 2>/dev/null); do
wproc=$(echo "$wp" | cut -d/ -f3)
[ "$wproc" = "$pid" ] && continue
wfd=$(echo "$wp" | cut -d/ -f5)
echo "[$i] WRITER proc=$wproc fd=$wfd"
echo "[$i] cmdline: $(tr '\0' ' ' < /proc/$wproc/cmdline 2>/dev/null)"
echo "[$i] comm: $(cat /proc/$wproc/comm 2>/dev/null) state=$(cat /proc/$wproc/stat 2>/dev/null | awk '{print $3}')"
cg=$(cat /proc/$wproc/cgroup 2>/dev/null | grep -v freezer | head -1)
echo "[$i] cgroup: $cg"
# cgroup v2 freezer
cgpath=$(echo "$cg" | sed 's/^[0-9]*://')
if [ -f "/sys/fs/cgroup${cgpath}/cgroup.freeze" ]; then
echo "[$i] cgroup.freeze=$(cat /sys/fs/cgroup${cgpath}/cgroup.freeze 2>/dev/null)"
fi
echo "[$i] threads(wchan):"
for td in /proc/$wproc/task/*/; do
ttn=${td%/}
echo "[$i] ${ttn##*/} $(cat $ttn/comm 2>/dev/null) $(cat $ttn/wchan 2>/dev/null)"
done
done
;;
esac
;;
esac
done
sleep 0.4
done
echo "=== scan done ==="

46
scripts/_tng_proc_scan.sh Normal file
View File

@@ -0,0 +1,46 @@
#!/system/bin/sh
# TNG eWallet — 启动期 /proc 扫描:定位阻塞在 read 的 TigerTally 线程及其 fd 目标。
# 用法: adb shell su -c 'sh /data/local/tmp/_tng_proc_scan.sh [loop_seconds]'
PKG="my.com.tngdigital.ewallet"
LOOP="${1:-1}" # 持续扫描秒数(默认 1 秒抓一次快照)
# 选主进程cmdline 恰为包名,排除 :tools / :goacqowmmt 等子进程)
main_pid=""
for p in $(pidof "$PKG"); do
if [ "$(tr '\0' ' ' < /proc/$p/cmdline 2>/dev/null | tr -d ' ')" = "$PKG" ]; then
main_pid="$p"
break
fi
done
[ -z "$main_pid" ] && { echo "NO-MAIN-PROC pidof=$(pidof $PKG)"; exit 1; }
echo "=== main pid=$main_pid ==="
i=0
while [ $i -lt "$LOOP" ]; do
i=$((i+1))
echo "--- scan #$i ---"
# 1) 所有可疑线程的状态
for tid in $(ls /proc/$main_pid/task 2>/dev/null); do
comm=$(cat /proc/$main_pid/task/$tid/comm 2>/dev/null)
case "$comm" in
*pool*|*location*|*tally*|*Tiger*|*tiger*)
syscall=$(cat /proc/$main_pid/task/$tid/syscall 2>/dev/null)
wchan=$(cat /proc/$main_pid/task/$tid/wchan 2>/dev/null)
stat=$(cat /proc/$main_pid/task/$tid/stat 2>/dev/null | awk '{print $3}')
echo "TID=$tid comm=$comm state=$stat syscall=[$syscall] wchan=$wchan"
;;
esac
done
# 2) 所有管道/套接字 fdTigerTally 握手候选)
for fd in /proc/$main_pid/fd/*; do
tgt=$(readlink "$fd" 2>/dev/null)
case "$tgt" in
*pipe:*|*socket:*|*anon_inode:*)
echo "FD=$(basename $fd) -> $tgt"
;;
esac
done
[ $i -lt "$LOOP" ] && sleep 1
done
echo "=== done ==="

View File

@@ -0,0 +1,55 @@
# Build + install TNG Zygisk exit guard
param(
[switch]$SkipInstall,
[switch]$NoReboot
)
$ErrorActionPreference = "Stop"
$Root = Split-Path -Parent $PSScriptRoot
$Mod = Join-Path $Root "magisk-modules\tng_exit_guard"
$Ndk = "C:\Users\Administrator\AppData\Local\Android\Sdk\ndk\21.4.7075529"
$NdkBuild = Join-Path $Ndk "ndk-build.cmd"
$Adb = "C:\Users\Administrator\AppData\Local\Android\Sdk\platform-tools\adb.exe"
if (-not (Test-Path $NdkBuild)) {
throw "ndk-build not found: $NdkBuild"
}
Write-Host "=== ndk-build ===" -ForegroundColor Cyan
Push-Location $Mod
& $NdkBuild NDK_PROJECT_PATH=. APP_BUILD_SCRIPT=jni/Android.mk NDK_APPLICATION_MK=jni/Application.mk -j8
if ($LASTEXITCODE -ne 0) { Pop-Location; throw "ndk-build failed" }
$built = Join-Path $Mod "libs\arm64-v8a\libtng_exit_guard.so"
if (-not (Test-Path $built)) {
# some ndk versions omit lib prefix based on LOCAL_MODULE
$built = Get-ChildItem (Join-Path $Mod "libs\arm64-v8a") -Filter "*.so" | Select-Object -First 1 -ExpandProperty FullName
}
if (-not $built -or -not (Test-Path $built)) { Pop-Location; throw "built .so missing" }
$zygiskDir = Join-Path $Mod "zygisk"
New-Item -ItemType Directory -Force -Path $zygiskDir | Out-Null
Copy-Item $built (Join-Path $zygiskDir "arm64-v8a.so") -Force
Write-Host "built -> zygisk\arm64-v8a.so ($((Get-Item (Join-Path $zygiskDir 'arm64-v8a.so')).Length) bytes)"
Pop-Location
if ($SkipInstall) { return }
Write-Host "=== install Magisk module ===" -ForegroundColor Cyan
& $Adb wait-for-device
& $Adb shell "su -c 'mkdir -p /data/adb/modules/tng_exit_guard/zygisk'"
& $Adb push (Join-Path $Mod "module.prop") /data/local/tmp/tng_exit_guard_module.prop
& $Adb push (Join-Path $zygiskDir "arm64-v8a.so") /data/local/tmp/tng_exit_guard.so
& $Adb shell "su -c 'cp /data/local/tmp/tng_exit_guard_module.prop /data/adb/modules/tng_exit_guard/module.prop; cp /data/local/tmp/tng_exit_guard.so /data/adb/modules/tng_exit_guard/zygisk/arm64-v8a.so; chmod 644 /data/adb/modules/tng_exit_guard/module.prop; chmod 755 /data/adb/modules/tng_exit_guard/zygisk/arm64-v8a.so; rm -f /data/adb/modules/tng_exit_guard/disable /data/adb/modules/tng_exit_guard/remove; ls -la /data/adb/modules/tng_exit_guard/ /data/adb/modules/tng_exit_guard/zygisk/'"
if (-not $NoReboot) {
Write-Host "=== soft reboot zygote (module loads on next specialize) ===" -ForegroundColor Yellow
Write-Host "Magisk Zygisk modules usually need a FULL reboot. Rebooting device..."
& $Adb reboot
Write-Host "Waiting for device..."
& $Adb wait-for-device
Start-Sleep -Seconds 25
& $Adb shell "getprop sys.boot_completed"
}
Write-Host "Done. Launch TNG and check: adb logcat -s TngExitGuard:I LSPosed-Bridge:I" -ForegroundColor Green

View File

@@ -9,9 +9,12 @@ scopes = [
"org.telegram.messenger.web",
"org.telegram.messenger",
"com.miraclegarden.smsmessage",
"sg.com.maribankmobile.digitalbank",
"ph.seabank.seabank",
"au.com.up.money",
"au.com.suncorp.marketplace",
"au.com.bank86400",
"my.com.tngdigital.ewallet",
]
shutil.copy2(db_path, db_path + ".bak")

View File

@@ -0,0 +1,21 @@
#!/system/bin/sh
set -x
echo "=== magisk ver ==="
magisk -c
magisk -v
echo "=== zygisk setting ==="
magisk --sqlite 'SELECT * FROM settings'
echo "=== path ==="
MAGISK_PATH=$(magisk --path)
echo "MAGISK_PATH=$MAGISK_PATH"
ls -la "$MAGISK_PATH" 2>/dev/null | head -30
ls -la "$MAGISK_PATH/zygisk" 2>/dev/null
ls -la /data/adb/modules/
ls -la /data/adb/modules/tng_exit_guard/
ls -la /data/adb/modules/tng_exit_guard/zygisk/
echo "=== denylist tng ==="
magisk --denylist ls 2>/dev/null | grep -i tng || echo "TNG not on denylist"
echo "=== processes ==="
ps -A | grep -iE 'magiskd|zygisk|lspd|vector|tngdigital' || true
echo "=== logcat zygisk ==="
logcat -d | grep -iE 'zygisk|TngExit|tng_exit' | tail -40

View File

@@ -0,0 +1,35 @@
# 解压 XAPK 并通过 adb install-multiple 安装 TNG
param(
[Parameter(Mandatory = $true)]
[string]$XapkPath
)
$ErrorActionPreference = "Stop"
$adb = "C:\Users\Administrator\AppData\Local\Android\Sdk\platform-tools\adb.exe"
if (-not (Test-Path $XapkPath)) {
Write-Host "文件不存在: $XapkPath" -ForegroundColor Red
exit 1
}
$extractDir = Join-Path ([IO.Path]::GetDirectoryName($XapkPath)) "tng_xapk_extracted"
if (Test-Path $extractDir) { Remove-Item $extractDir -Recurse -Force }
New-Item -ItemType Directory -Path $extractDir | Out-Null
Add-Type -AssemblyName System.IO.Compression.FileSystem
[System.IO.Compression.ZipFile]::ExtractToDirectory((Resolve-Path $XapkPath), $extractDir)
$apks = Get-ChildItem $extractDir -Filter "*.apk" -Recurse | Sort-Object Name
if ($apks.Count -eq 0) {
Write-Host "XAPK 内未找到 apk 文件" -ForegroundColor Red
exit 1
}
Write-Host "找到 $($apks.Count) 个 APK开始安装..." -ForegroundColor Cyan
$apkArgs = @("install-multiple", "-r") + ($apks | ForEach-Object { $_.FullName })
& $adb @apkArgs
if ($LASTEXITCODE -eq 0) {
Write-Host "TNG 安装成功" -ForegroundColor Green
& $adb shell monkey -p my.com.tngdigital.ewallet -c android.intent.category.LAUNCHER 1
} else {
Write-Host "安装失败exit=$LASTEXITCODE" -ForegroundColor Red
}

View File

@@ -0,0 +1,87 @@
# Launch MariBank Singapore (not PH SeaBank)
param(
[switch]$ClearData,
[switch]$StopPh,
[switch]$ColdStart,
[int]$WaitSeconds = 0
)
$ErrorActionPreference = "Stop"
$PkgSg = "sg.com.maribankmobile.digitalbank"
$PkgPh = "ph.seabank.seabank"
$Activity = "com.shopee.bke.digitalbank.ui.MainActivity"
function Resolve-AdbPath {
$candidates = @(
(Join-Path $env:LOCALAPPDATA "Android\Sdk\platform-tools\adb.exe"),
"C:\Users\Administrator\AppData\Local\Android\Sdk\platform-tools\adb.exe"
)
foreach ($path in $candidates) {
if (Test-Path $path) { return $path }
}
$cmd = Get-Command adb -ErrorAction SilentlyContinue
if ($cmd) { return $cmd.Source }
return $null
}
$adb = Resolve-AdbPath
if (-not $adb) {
Write-Host "adb not found." -ForegroundColor Red
exit 1
}
$devices = & $adb devices 2>&1 | Where-Object { $_ -match "\tdevice$" }
if (-not $devices) {
Write-Host "No authorized adb device." -ForegroundColor Red
exit 1
}
Write-Host "=== Launch MariBank SINGAPORE ===" -ForegroundColor Cyan
Write-Host "Package: $PkgSg (v3.2.2)"
Write-Host ""
Write-Host "[Required] After Xposed module update: LSPosed -> scope SG pkg -> re-optimize / force-stop / launch" -ForegroundColor Yellow
Write-Host " Without soft reboot, old hooks may cause BLANK-PAGE white screen." -ForegroundColor Yellow
Write-Host ""
Write-Host "Package: sg.com.maribankmobile.digitalbank (NOT ph.seabank.seabank)" -ForegroundColor Yellow
Write-Host ""
if ($StopPh) {
& $adb shell am force-stop $PkgPh | Out-Null
}
if ($ClearData) {
Write-Host "Clearing SG app data..."
& $adb shell pm clear $PkgSg | Out-Null
}
if ($ColdStart) {
Write-Host "Cold start: force-stop SG (first screen may stay white 30-60s)" -ForegroundColor Cyan
& $adb shell am force-stop $PkgSg | Out-Null
Start-Sleep -Seconds 2
} else {
Write-Host "Warm start (recommended). Use -ColdStart for cold start." -ForegroundColor Cyan
}
& $adb shell am start -n "$PkgSg/$Activity"
Write-Host ""
Write-Host "MariBank SG launched." -ForegroundColor Green
Write-Host "RN may show BLANK-PAGE for ~25-45s before welcome screen."
Write-Host "If white screen > 1 min: LSPosed soft reboot SG, then retry -ColdStart"
Write-Host "Verify API: adb logcat -d | Select-String api.maribank.com.sg"
if ($WaitSeconds -gt 0) {
Write-Host ""
Write-Host "Waiting ${WaitSeconds}s for UI..." -ForegroundColor Cyan
Start-Sleep -Seconds $WaitSeconds
& $adb shell uiautomator dump /sdcard/ui_launch_check.xml 2>&1 | Out-Null
$prevEap = $ErrorActionPreference
$ErrorActionPreference = "Continue"
& $adb pull /sdcard/ui_launch_check.xml "$env:TEMP\ui_launch_check.xml" 2>&1 | Out-Null
$ErrorActionPreference = $prevEap
if (Test-Path "$env:TEMP\ui_launch_check.xml") {
$xml = Get-Content "$env:TEMP\ui_launch_check.xml" -Raw
$blank = $xml -match "BLANK-PAGE"
Write-Host ("BLANK-PAGE=" + $blank)
}
}

View File

@@ -1,29 +1,15 @@
# MariBank / Hook 相关 logcatadb 不在 PATH 时也可用)
param(
[switch]$Clear,
[switch]$Follow
)
$ErrorActionPreference = "Stop"
$sdk = "C:\Users\Administrator\AppData\Local\Android\Sdk"
$adb = Join-Path $sdk "platform-tools\adb.exe"
# Capture MariBank SG hook logs (clears buffer first if -Clear switch passed)
param([switch]$Clear)
$adb = "C:\Users\Administrator\AppData\Local\Android\Sdk\platform-tools\adb.exe"
if (-not (Test-Path $adb)) {
Write-Host "adb not found at $adb" -ForegroundColor Red
Write-Host "adb not found: $adb" -ForegroundColor Red
exit 1
}
$pattern = "MariBankRoot|1201|seabank|ClashMeta|LSPosed-Bridge.*notiMessage"
if ($Clear) {
& $adb logcat -c
Write-Host "logcat cleared." -ForegroundColor Green
Write-Host "Logcat cleared. Now click Next in MariBank, then run without -Clear:" -ForegroundColor Yellow
Write-Host " .\scripts\logcat-maribank.ps1"
exit 0
}
if ($Follow) {
Write-Host "Following logcat (Ctrl+C to stop)..." -ForegroundColor Cyan
& $adb logcat | Select-String -Pattern $pattern
} else {
& $adb logcat -d | Select-String -Pattern $pattern
}
& $adb logcat -d 2>&1 | Select-String -Pattern "MariBankRoot|MariBankNative|MariBankDfp|MariBankEncrypt|MariBankCapture" |
Select-String -Pattern "HTTP|outbound|register|dfp/v1|dfp is empty|3100012|4067|faked|finish adb|blocked|ErrorFlow|RegisterViewModel|skip error|late app|late native|assessRisk|risk callback"

14
scripts/logcat-tng.ps1 Normal file
View File

@@ -0,0 +1,14 @@
# Capture TNG eWallet hook logs (clears buffer first if -Clear switch passed)
param([switch]$Clear)
$adb = "C:\Users\Administrator\AppData\Local\Android\Sdk\platform-tools\adb.exe"
if (-not (Test-Path $adb)) {
$adb = "adb"
}
if ($Clear) {
& $adb logcat -c
Write-Host "Logcat cleared. Launch TNG eWallet, then run without -Clear:" -ForegroundColor Yellow
Write-Host " .\scripts\logcat-tng.ps1"
exit 0
}
& $adb logcat -d 2>&1 | Select-String -Pattern "notiMessageHook/TngRoot|LSPosed-Bridge.*TngRoot|LSPosed-Bridge.*notiMessageHook|support.tngdigital|SecurityError|xwwqazamx|UserLogin|blocked intent|blocked Promon" |
Select-Object -Last 80

View File

@@ -0,0 +1,6 @@
id=maribank_device_spoof
name=MariBank Device Spoof
version=v1.0
versionCode=1
author=miraclegarden
description=Spoof serial/boot/build props for MariBank SHPSSDK fingerprint. Pair with Shamiko + DenyList for sg.com.maribankmobile.digitalbank. Generates a stable fake serial and android_id on first boot.

View File

@@ -0,0 +1,50 @@
#!/system/bin/sh
# Early boot: spoof read-only props before most apps start.
# resetprop is provided by Magisk.
MODDIR=${0%/*}
LOGTAG="maribank_device_spoof"
log() {
echo "[$LOGTAG] $*" >> /cache/maribank_device_spoof.log 2>/dev/null
echo "[$LOGTAG] $*"
}
if [ ! -f "$MODDIR/serial.txt" ]; then
# 16-char alphanumeric serial, stable across reboots
SERIAL=$(cat /proc/sys/kernel/random/uuid 2>/dev/null | tr -d '-' | cut -c1-16)
[ -z "$SERIAL" ] && SERIAL="MB$(date +%s | tail -c 9)"
echo "$SERIAL" > "$MODDIR/serial.txt"
fi
SERIAL=$(cat "$MODDIR/serial.txt")
log "serial=$SERIAL"
# --- device identity (SHPSSDK / attestation often reads these) ---
resetprop -n ro.serialno "$SERIAL"
resetprop -n ro.boot.serialno "$SERIAL"
resetprop -n ro.boot.serialno "$SERIAL"
resetprop -n persist.sys.serialno "$SERIAL"
# --- hide root / debug fingerprint ---
resetprop -n ro.debuggable 0
resetprop -n ro.secure 1
resetprop -n ro.adb.secure 1
resetprop -n ro.build.type user
resetprop -n ro.build.tags release-keys
resetprop -n ro.boot.verifiedbootstate green
resetprop -n ro.boot.flash.locked 1
resetprop -n ro.boot.vbmeta.device_state locked
resetprop -n vendor.boot.vbmeta.device_state locked
resetprop -n ro.boot.veritymode enforcing
resetprop -n ro.boot.warranty_bit 0
resetprop -n ro.crypto.state encrypted
# --- adb off (match Java-layer bypass) ---
resetprop -n init.svc.adbd stopped
resetprop -n init.svc.adb stopped
resetprop -n service.adb.root 0
resetprop -n persist.sys.adb_enable 0
resetprop -n persist.adb.wifi.enabled 0
log "post-fs-data done"

View File

@@ -0,0 +1,37 @@
#!/system/bin/sh
# After boot: rotate Settings.Secure.android_id once (global, affects all apps).
MODDIR=${0%/*}
LOGTAG="maribank_device_spoof"
log() {
echo "[$LOGTAG] $*" >> /cache/maribank_device_spoof.log 2>/dev/null
}
# Wait for SettingsProvider
i=0
while [ "$(getprop sys.boot_completed)" != "1" ] && [ "$i" -lt 120 ]; do
sleep 1
i=$((i + 1))
done
sleep 3
if [ ! -f "$MODDIR/android_id.txt" ]; then
# 16 hex chars (standard ANDROID_ID format)
AID=$(cat /proc/sys/kernel/random/uuid 2>/dev/null | tr -d '-' | cut -c1-16)
[ -z "$AID" ] && AID="$(date +%s | md5sum 2>/dev/null | cut -c1-16)"
echo "$AID" > "$MODDIR/android_id.txt"
fi
AID=$(cat "$MODDIR/android_id.txt")
settings put secure android_id "$AID" 2>/dev/null
log "android_id=$AID"
# Clear MariBank cache so SHPSSDK re-collects with new props (optional, user can disable)
PKG="sg.com.maribankmobile.digitalbank"
if [ -f "$MODDIR/clear_maribank_on_boot" ]; then
pm clear "$PKG" 2>/dev/null
log "pm clear $PKG"
fi
log "service.sh done"

View File

@@ -0,0 +1,75 @@
# Finish scheme B after reboot: LSPosed scope + verify spoof + clear MariBank
param([switch]$Reboot)
$ErrorActionPreference = "Stop"
$ProjectRoot = Split-Path -Parent $PSScriptRoot
$adb = "C:\Users\Administrator\AppData\Local\Android\Sdk\platform-tools\adb.exe"
$Pkg = "sg.com.maribankmobile.digitalbank"
$WaitSeconds = 60
Write-Host "Checking adb ($WaitSeconds s timeout)..." -ForegroundColor Cyan
$deadline = (Get-Date).AddSeconds($WaitSeconds)
$ready = $false
while ((Get-Date) -lt $deadline) {
$lines = & $adb devices 2>&1
if ($lines -match "1C081FDF600K5Q\s+device") {
$ready = $true
break
}
if ($lines -match "\tunauthorized") {
Write-Host "Device connected but UNAUTHORIZED — unlock phone and tap Allow USB debugging." -ForegroundColor Red
}
Start-Sleep -Seconds 2
}
if (-not $ready) {
Write-Host @"
No adb device found after ${WaitSeconds}s.
On Pixel 6:
1. USB cable connected (data port, not charge-only)
2. Settings -> Developer options -> USB debugging ON
3. USB mode: File transfer / PTP
4. Unlock screen -> tap Allow on RSA prompt
5. Re-run: .\scripts\maribank-scheme-b-finish.ps1
If USB debugging was turned off earlier, you must enable it on the phone first.
"@ -ForegroundColor Yellow
exit 1
}
Start-Sleep -Seconds 2
Write-Host "`n=== Modules ===" -ForegroundColor Cyan
& $adb shell su -c "ls /data/adb/modules/"
& $adb shell su -c "magisk --denylist status"
Write-Host "`n=== Identity ===" -ForegroundColor Cyan
& $adb shell su -c "getprop ro.serialno; getprop ro.boot.serialno; settings get secure android_id"
& $adb shell su -c "cat /data/adb/modules/maribank_device_spoof/serial.txt 2>/dev/null; cat /data/adb/modules/maribank_device_spoof/android_id.txt 2>/dev/null"
Write-Host "`n=== LSPosed scope (MariBank SG) ===" -ForegroundColor Cyan
$apkPath = (& $adb shell pm path com.miraclegarden.smsmessage.xposed 2>$null) -replace '^package:', ''
$apkPath = $apkPath.Trim()
if (-not $apkPath) {
Write-Host "Xposed module not installed" -ForegroundColor Red
exit 1
}
& $adb shell su -c "cp /data/adb/lspd/config/modules_config.db /sdcard/Download/modules_config.db; chmod 644 /sdcard/Download/modules_config.db"
$db = Join-Path $env:TEMP "modules_config_finish.db"
& $adb pull /sdcard/Download/modules_config.db $db | Out-Null
python "$ProjectRoot\scripts\configure-lsposed.py" $db $apkPath
& $adb push $db /sdcard/Download/modules_config.db | Out-Null
& $adb shell su -c "cp /sdcard/Download/modules_config.db /data/adb/lspd/config/modules_config.db; rm -f /data/adb/lspd/config/modules_config.db-wal /data/adb/lspd/config/modules_config.db-shm; chmod 660 /data/adb/lspd/config/modules_config.db"
Write-Host "`n=== Disable USB debug + clear MariBank ===" -ForegroundColor Cyan
& $adb shell su -c "settings put global adb_enabled 0; settings put global development_settings_enabled 0"
& $adb shell su -c "pm clear $Pkg"
Write-Host "`n=== Done ===" -ForegroundColor Green
Write-Host "Open MariBank -> Sign up -> enter phone -> Next"
Write-Host "Log: adb logcat -d | Select-String 'MariBankEncrypt|MariBankAttest|3100012|deviceFingerprint'"
if ($Reboot) {
Write-Host "Rebooting..." -ForegroundColor Yellow
& $adb reboot
}

View File

@@ -0,0 +1,58 @@
# MariBank SG 一键:编译 → 安装模块 → 换 ID → 清数据 → 启动 SG
# 用法:
# .\scripts\maribank-sg-all-in.ps1
# .\scripts\maribank-sg-all-in.ps1 -DisableUsbDebug # 测前关 USB 调试(会断 adb
# .\scripts\maribank-sg-all-in.ps1 -SkipBuild # 仅换 ID + 启动
param(
[switch]$SkipBuild,
[switch]$DisableUsbDebug,
[switch]$KeepAdb,
[switch]$ColdStart
)
$ErrorActionPreference = "Stop"
$ProjectRoot = Split-Path -Parent $PSScriptRoot
Write-Host "=== MariBank SG All-In ===" -ForegroundColor Cyan
if (-not $SkipBuild) {
Write-Host "`n[1/5] Build debug APKs..." -ForegroundColor Cyan
& "$ProjectRoot\scripts\build-debug.ps1"
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
Write-Host "`n[2/5] Install APKs..." -ForegroundColor Cyan
& "$ProjectRoot\scripts\install-debug.ps1"
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
} else {
Write-Host "`n[1-2/5] Skip build/install (-SkipBuild)" -ForegroundColor Yellow
}
Write-Host "`n[3/5] New device identity + clear MariBank SG..." -ForegroundColor Cyan
& "$ProjectRoot\scripts\maribank-spoof-device.ps1" -NewIdentity -ClearMariBank
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
Write-Host "`n[4/5] LSPosed 必做PC 无法代劳)" -ForegroundColor Yellow
Write-Host " 1. LSPosed -> 模块 -> notiMessage 启用"
Write-Host " 2. 作用域勾选: sg.com.maribankmobile.digitalbank"
Write-Host " 3. 对该包: 重新优化 -> 强行停止 -> 启动(等价软重启)"
Write-Host " 4. Shamiko DenyList 含 SG 全部进程Enforce=OFF"
Write-Host ""
Read-Host "完成 LSPosed 软重启后按 Enter 继续" | Out-Null
$launchArgs = @("-StopPh")
if ($ColdStart) { $launchArgs += "-ColdStart" }
Write-Host "`n[5/5] Launch MariBank SG..." -ForegroundColor Cyan
& "$ProjectRoot\scripts\launch-maribank-sg.ps1" @launchArgs
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
if ($DisableUsbDebug -and -not $KeepAdb) {
Write-Host "`n=== Disable USB debugging (SG stricter) ===" -ForegroundColor Cyan
& "$ProjectRoot\scripts\maribank-sg-register.ps1" -DisableUsbDebug
} else {
Write-Host "`n=== Ready to test ===" -ForegroundColor Green
Write-Host "Phone: Sign up -> +65 -> Next"
Write-Host "Log: .\scripts\maribank-sg-register.ps1 -CaptureLog -KeepAdb"
Write-Host " .\scripts\logcat-maribank.ps1"
Write-Host "Strict test (no adb): .\scripts\maribank-sg-all-in.ps1 -SkipBuild -DisableUsbDebug"
}

View File

@@ -0,0 +1,153 @@
# MariBank SG register test — new identity, optional log capture
param(
[switch]$NewIdentity,
[switch]$CaptureLog,
[switch]$DumpLog,
[switch]$InstallModule,
[switch]$DisableUsbDebug,
[switch]$KeepAdb,
[switch]$All
)
$ErrorActionPreference = "Stop"
$ProjectRoot = Split-Path -Parent $PSScriptRoot
$Pkg = "sg.com.maribankmobile.digitalbank"
function Resolve-AdbPath {
$candidates = @(
(Join-Path $env:LOCALAPPDATA "Android\Sdk\platform-tools\adb.exe"),
"C:\Users\Administrator\AppData\Local\Android\Sdk\platform-tools\adb.exe"
)
foreach ($path in $candidates) {
if (Test-Path $path) { return $path }
}
$cmd = Get-Command adb -ErrorAction SilentlyContinue
if ($cmd) { return $cmd.Source }
return $null
}
$adb = Resolve-AdbPath
if (-not $adb) {
Write-Host "adb not found. Install Android SDK platform-tools or add adb to PATH." -ForegroundColor Red
exit 1
}
function Get-AdbDevicesText {
& $adb devices 2>&1 | Out-String
}
function Test-AdbAuthorized {
$lines = & $adb devices 2>&1 | Where-Object { $_ -match "\tdevice$" }
return [bool]$lines
}
function Invoke-AdbShell([string]$cmd) {
& $adb shell $cmd 2>&1
}
function Show-AdbHelp {
param([string]$DevicesText)
Write-Host "`nadb devices output:" -ForegroundColor Yellow
Write-Host $DevicesText
Write-Host @"
1. USB
USB + 线
2. 线 / USB / MTP
3. USB
4. PC adb kill-server && adb start-server && adb devices
5. log .\scripts\maribank-sg-register.ps1 -CaptureLog -KeepAdb
"@ -ForegroundColor Cyan
}
Write-Host "=== MariBank SG Register Test ===" -ForegroundColor Cyan
Write-Host "adb: $adb"
if ($All) {
$allArgs = @()
if ($DisableUsbDebug -and -not $KeepAdb) { $allArgs += "-DisableUsbDebug" }
if ($KeepAdb) { $allArgs += "-KeepAdb" }
& "$ProjectRoot\scripts\maribank-sg-all-in.ps1" @allArgs
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
if (-not $CaptureLog) { exit 0 }
}
& $adb start-server 2>&1 | Out-Null
$devicesText = Get-AdbDevicesText
if (-not (Test-AdbAuthorized)) {
Write-Host "No authorized adb device." -ForegroundColor Red
Show-AdbHelp -DevicesText $devicesText
exit 1
}
Write-Host $devicesText
if ($NewIdentity -or $InstallModule) {
$spoofArgs = @()
if ($NewIdentity) { $spoofArgs += "-NewIdentity" }
if ($InstallModule) { $spoofArgs += "-InstallModule" }
& "$ProjectRoot\scripts\maribank-spoof-device.ps1" @spoofArgs -ClearMariBank
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
}
$shouldDisableAdb = $DisableUsbDebug -and -not $KeepAdb
if ($shouldDisableAdb) {
Write-Host "`n=== Disable USB / wireless debugging (SG stricter) ===" -ForegroundColor Cyan
Write-Host "WARNING: PC adb will disconnect after this. Re-enable USB debug on phone to connect again." -ForegroundColor Yellow
Invoke-AdbShell "settings put global adb_enabled 0" | Out-Null
Invoke-AdbShell "settings put global development_settings_enabled 0" | Out-Null
Invoke-AdbShell "settings put secure adb_wifi_enabled 0" | Out-Null
Invoke-AdbShell "su -c 'resetprop init.svc.adbd stopped; resetprop persist.sys.adb_enable 0'" 2>$null | Out-Null
} elseif (-not $KeepAdb) {
Write-Host "`n=== Skip disabling USB debug (default) ===" -ForegroundColor Cyan
Write-Host "Use -DisableUsbDebug when ready to test SG without PC adb; use -KeepAdb with -CaptureLog."
}
if (-not $CaptureLog) {
Write-Host "`n=== Clear SG app + verify IDs ===" -ForegroundColor Cyan
Invoke-AdbShell "pm clear $Pkg" | Out-Null
Invoke-AdbShell "su -c 'getprop ro.serialno; settings get secure android_id'"
}
Write-Host ""
Write-Host "=== LSPosed: pick the correct MariBank package ===" -ForegroundColor Yellow
Write-Host " SG: sg.com.maribankmobile.digitalbank (v3.2.2, api.maribank.com.sg)"
Write-Host " PH: ph.seabank.seabank (v3.22.0, api.seabank.ph)"
Write-Host " Launch SG: .\scripts\launch-maribank-sg.ps1"
Write-Host ""
Write-Host "Phone: LSPosed -> soft reboot $Pkg"
Write-Host "Then: MariBank SG -> Sign up -> +65 phone -> Next"
if ($CaptureLog -or $DumpLog) {
if ($CaptureLog) {
Write-Host ""
Write-Host "=== logcat cleared; tap Next then press Enter ===" -ForegroundColor Cyan
& $adb logcat -c
Write-Host "Tap Next on phone, then press Enter..."
Read-Host | Out-Null
} else {
Write-Host ""
Write-Host "=== dump current logcat (no clear) ===" -ForegroundColor Cyan
}
Write-Host ""
Write-Host "--- register / dfp / attestation ---" -ForegroundColor Cyan
$lines = & $adb logcat -d | Select-String "MariBankCapture|MariBankRegister|MariBankEncrypt|MariBankAttest|MariBankDfp|MariBankNative|MariBankRoot HTTP|ProbeGuard|3100012|4067012|OTP_SMS|register summary|dfp/v1|code=0|Gson REGISTRATION|uapi/v2/register|attestation hooks|native-core"
if ($lines) {
$lines
} else {
Write-Host "(no matches)" -ForegroundColor Yellow
Write-Host "Likely: Enter pressed before Next, or register API not sent yet."
Write-Host "You are on phone screen? Tap Next, wait for loading, then run:"
Write-Host " .\scripts\maribank-sg-register.ps1 -DumpLog -KeepAdb"
}
} else {
Write-Host "`nLog after Next:" -ForegroundColor Cyan
Write-Host " .\scripts\maribank-sg-register.ps1 -CaptureLog -KeepAdb"
Write-Host " .\scripts\maribank-sg-register.ps1 -DumpLog -KeepAdb # no clear, dump now"
}
Write-Host ""
Write-Host "Doc: docs/MariBank新加坡突破.md" -ForegroundColor Green

View File

@@ -0,0 +1,121 @@
# MariBank device spoof — Magisk resetprop + optional module install
# Usage:
# .\scripts\maribank-spoof-device.ps1 # apply resetprop once via adb su
# .\scripts\maribank-spoof-device.ps1 -InstallModule # zip & push Magisk module
# .\scripts\maribank-spoof-device.ps1 -NewIdentity # regenerate serial/android_id files on device
# .\scripts\maribank-spoof-device.ps1 -ClearMariBank # pm clear MariBank after spoof
param(
[switch]$InstallModule,
[switch]$NewIdentity,
[switch]$ClearMariBank,
[string]$DeviceSerial = ""
)
$ErrorActionPreference = "Stop"
$ProjectRoot = Split-Path -Parent $PSScriptRoot
$adb = "C:\Users\Administrator\AppData\Local\Android\Sdk\platform-tools\adb.exe"
$ModuleDir = Join-Path $PSScriptRoot "magisk\maribank-device-spoof"
$Pkg = "sg.com.maribankmobile.digitalbank"
if (-not (Test-Path $adb)) {
Write-Host "adb not found: $adb" -ForegroundColor Red
exit 1
}
function Invoke-AdbShell($cmd) {
& $adb shell "su -c '$cmd'" 2>&1
}
function Test-Magisk {
$m = Invoke-AdbShell "command -v resetprop 2>/dev/null || ls /data/adb/magisk/magisk 2>/dev/null"
return ($LASTEXITCODE -eq 0 -and "$m" -match "resetprop|magisk")
}
Write-Host "=== MariBank Device Spoof (方案 B: Magisk resetprop) ===" -ForegroundColor Cyan
& $adb devices -l
if (-not (Test-Magisk)) {
Write-Host "Magisk/resetprop not found on device. Install Magisk first." -ForegroundColor Red
exit 1
}
if ($InstallModule) {
$zipPath = Join-Path $env:TEMP "maribank-device-spoof.zip"
if (Test-Path $zipPath) { Remove-Item $zipPath -Force }
# Use tar (Windows 10+) for Unix paths; Magisk needs post-fs-data.sh at zip root
Push-Location $ModuleDir
tar -a -cf $zipPath module.prop post-fs-data.sh service.sh
Pop-Location
Write-Host "Pushing module to /sdcard/Download/ ..."
& $adb push $zipPath /sdcard/Download/maribank-device-spoof.zip
Write-Host @"
Module zip pushed. On phone:
1. Magisk -> Modules -> Install from storage -> maribank-device-spoof.zip
2. Reboot
3. Enable Shamiko (see below)
"@ -ForegroundColor Yellow
}
if ($NewIdentity) {
Write-Host "Removing saved identity (module will regenerate on next boot) ..."
Invoke-AdbShell "rm -f /data/adb/modules/maribank_device_spoof/serial.txt /data/adb/modules/maribank_device_spoof/android_id.txt"
}
if ($DeviceSerial -eq "") {
$DeviceSerial = -join ((48..57) + (65..90) + (97..122) | Get-Random -Count 16 | ForEach-Object { [char]$_ })
}
$AndroidId = -join ((48..57) + (97..102) | Get-Random -Count 16 | ForEach-Object { [char]$_ })
Write-Host "Applying one-shot resetprop (serial=$DeviceSerial android_id=$AndroidId) ..."
$props = @(
"resetprop ro.serialno $DeviceSerial",
"resetprop ro.boot.serialno $DeviceSerial",
"resetprop persist.sys.serialno $DeviceSerial",
"resetprop ro.debuggable 0",
"resetprop ro.secure 1",
"resetprop ro.build.tags release-keys",
"resetprop ro.boot.verifiedbootstate green",
"resetprop ro.boot.flash.locked 1",
"resetprop ro.boot.vbmeta.device_state locked",
"resetprop ro.boot.veritymode enforcing",
"resetprop init.svc.adbd stopped",
"resetprop persist.sys.adb_enable 0"
)
foreach ($p in $props) {
Invoke-AdbShell $p | Out-Null
}
Invoke-AdbShell "settings put secure android_id $AndroidId" | Out-Null
Write-Host "Verify:" -ForegroundColor Green
Invoke-AdbShell "getprop ro.serialno; getprop ro.boot.serialno; settings get secure android_id"
if ($ClearMariBank) {
Write-Host "Clearing MariBank app data ..."
Invoke-AdbShell "pm clear $Pkg"
Write-Host "MariBank data cleared. Cold start Sign up again." -ForegroundColor Green
}
Write-Host @"
--- Shamiko checklist (required for scheme B) ---
1. Magisk -> Settings -> Configure DenyList -> enable DenyList
2. DenyList -> add $Pkg (all sub-processes)
3. Install Shamiko module (Magisk repo / GitHub releases)
4. Magisk -> Settings -> hide Magisk app (optional)
5. LSPosed: keep module scoped to MariBank; soft reboot MariBank after spoof
6. Turn OFF USB debugging before testing register (or rely on Xposed adb bypass)
To install persistent module:
.\scripts\maribank-spoof-device.ps1 -InstallModule
To force new identity on next boot:
.\scripts\maribank-spoof-device.ps1 -NewIdentity -InstallModule
(then reboot)
Log on device: /cache/maribank_device_spoof.log
"@ -ForegroundColor Cyan

32
scripts/pull-tng-apk.ps1 Normal file
View File

@@ -0,0 +1,32 @@
# 从已安装 TNG 的手机 pull 完整 split APK供另一台 adb install-multiple
$ErrorActionPreference = "Stop"
$adb = "C:\Users\Administrator\AppData\Local\Android\Sdk\platform-tools\adb.exe"
$pkg = "my.com.tngdigital.ewallet"
$outDir = Join-Path (Split-Path -Parent $PSScriptRoot) "reverse\dumps\tng_splits"
$paths = & $adb shell pm path $pkg 2>$null
if (-not $paths) {
Write-Host "设备未安装 $pkg" -ForegroundColor Red
Write-Host "请先在已装 TNG 的手机(如 Pixel 6上 USB 调试连接。"
exit 1
}
New-Item -ItemType Directory -Force -Path $outDir | Out-Null
Remove-Item "$outDir\*.apk" -Force -ErrorAction SilentlyContinue
$i = 0
foreach ($line in $paths) {
if ($line -match "package:(.+)") {
$remote = $Matches[1].Trim()
$name = Split-Path $remote -Leaf
if ($name -eq "base.apk") { $local = Join-Path $outDir "base.apk" }
else { $local = Join-Path $outDir $name }
Write-Host "Pull $remote -> $local"
& $adb pull $remote $local
$i++
}
}
Write-Host "`n已 pull $i 个 APK 到 $outDir" -ForegroundColor Green
Write-Host "安装到另一台手机:"
Write-Host " adb install-multiple -r $outDir\*.apk"

4
scripts/pull-tng-log.sh Normal file
View File

@@ -0,0 +1,4 @@
#!/system/bin/sh
logcat -d > /data/local/tmp/tnglog.txt
grep -E '7500|exited cleanly|Process my.com.tngdigital|TngRoot|UserLogin|FATAL|Abort message|tombstone|blocked syscall|has died' /data/local/tmp/tnglog.txt | tail -120 > /data/local/tmp/tnglog2.txt
wc -l /data/local/tmp/tnglog2.txt

View File

@@ -0,0 +1,48 @@
# MariBank SG Frida native attestation trace
# Usage:
# .\scripts\run-frida-sg-native.ps1
# .\scripts\run-frida-sg-native.ps1 -Spawn
param(
[switch]$Attach,
[switch]$SkipLsposedHint
)
$ErrorActionPreference = "Stop"
$ProjectRoot = Split-Path -Parent $PSScriptRoot
$FridaDir = Join-Path $ProjectRoot "reverse\frida"
$Py312 = "C:\Users\Administrator\AppData\Local\Programs\Python\Python312\python.exe"
$Adb = Join-Path $env:LOCALAPPDATA "Android\Sdk\platform-tools\adb.exe"
if (-not (Test-Path $Adb)) {
$Adb = "C:\Users\Administrator\AppData\Local\Android\Sdk\platform-tools\adb.exe"
}
Write-Host "=== MariBank SG Frida Native Trace ===" -ForegroundColor Cyan
Write-Host "Package: sg.com.maribankmobile.digitalbank"
Write-Host "Script: reverse\frida\trace_maribank_sg_native.js"
Write-Host ""
if (-not $SkipLsposedHint) {
Write-Host "[Required before trace]" -ForegroundColor Yellow
Write-Host " 1. LSPosed -> KEEP scope ENABLED for sg.com.maribankmobile.digitalbank"
Write-Host " (module bypasses ADB page; disabling scope shows ADB Detected screen)"
Write-Host " 2. Soft reboot SG app (force-stop then reopen)"
Write-Host " 3. frida-server running: .\scripts\install-frida.ps1 -StartServer"
Write-Host ""
Read-Host "Done? Press Enter to continue" | Out-Null
}
& $Adb devices
& $Adb shell "su -c 'pgrep frida-server || /data/local/tmp/frida-server -D &'" 2>&1 | Out-Null
Start-Sleep -Seconds 1
if (-not (Test-Path $Py312)) {
Write-Host "Python 3.12 not found at $Py312" -ForegroundColor Red
Write-Host "Run: .\scripts\install-frida.ps1"
exit 1
}
$mode = if ($Attach) { "attach" } else { "spawn" }
Write-Host "Mode: $mode (default spawn — open Sign up after app starts)" -ForegroundColor Cyan
Write-Host ""
& $Py312 (Join-Path $FridaDir "run_frida_sg_native.py") $mode

View File

@@ -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
}

View File

@@ -0,0 +1,20 @@
# TNG eWallet 全流程自动化:注册/登录 → 点区号 → 国家列表
# 依赖adb、设备已 Root + LSPosed + notiMessage xposed-module 已勾选 TNG
$ErrorActionPreference = "Stop"
$Root = Split-Path -Parent $PSScriptRoot
Set-Location $Root
Write-Host "== build xposed-module ==" -ForegroundColor Cyan
& .\gradlew :xposed-module:assembleDebug
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
Write-Host "== install ==" -ForegroundColor Cyan
adb install -r xposed-module\build\outputs\apk\debug\xposed-module-debug.apk
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
adb shell am force-stop my.com.tngdigital.ewallet
Start-Sleep -Seconds 3
Write-Host "== full flow test ==" -ForegroundColor Cyan
python reverse/scripts/test_tng_full_flow.py
exit $LASTEXITCODE

View File

@@ -0,0 +1,16 @@
#!/system/bin/sh
logcat -c
am force-stop my.com.tngdigital.ewallet
sleep 1
monkey -p my.com.tngdigital.ewallet -c android.intent.category.LAUNCHER 1 >/dev/null 2>&1
sleep 2
echo "=== @2s ==="; ps -A | grep -i tng || echo NONE
sleep 5
echo "=== @7s ==="; ps -A | grep -i tng || echo NONE
sleep 8
echo "=== @15s ==="; ps -A | grep -i tng || echo NONE
logcat -d > /data/local/tmp/tngfull.txt
echo "=== guard ==="
grep TngExitGuard /data/local/tmp/tngfull.txt | tail -30
echo "=== outcome ==="
grep -E 'exited cleanly|exited due|has died|skipped SIGILL|blocked kill|blocked tgkill|UserLogin|Displayed|PLT blocked' /data/local/tmp/tngfull.txt | tail -30

View File

@@ -0,0 +1,51 @@
# TNG eWallet bypass: install xposed + LSPosed scope + optional clear app data
param(
[switch]$ClearTng,
[switch]$SkipBuild
)
$ErrorActionPreference = "Stop"
$ProjectRoot = Split-Path -Parent $PSScriptRoot
Set-Location $ProjectRoot
$sdk = "C:\Users\Administrator\AppData\Local\Android\Sdk"
$adb = Join-Path $sdk "platform-tools\adb.exe"
if (-not (Test-Path $adb)) { $adb = "adb" }
if (-not $SkipBuild) {
& "$ProjectRoot\scripts\build-debug.ps1"
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
}
$xposedApk = Join-Path $ProjectRoot "xposed-module\build\outputs\apk\debug\xposed-module-debug.apk"
Write-Host "=== Install Xposed module ===" -ForegroundColor Cyan
& $adb install -r -t -g $xposedApk
if ($LASTEXITCODE -ne 0) {
& $adb install -r -t -g --bypass-low-target-sdk-block $xposedApk
}
Write-Host "=== Configure LSPosed scope (incl. TNG) ===" -ForegroundColor Cyan
$apkPath = (& $adb shell pm path com.miraclegarden.smsmessage.xposed) -replace '^package:', ''
$apkPath = $apkPath.Trim()
& $adb shell "su -c 'cp /data/adb/lspd/config/modules_config.db /sdcard/Download/modules_config.db; rm -f /data/adb/lspd/config/modules_config.db-wal /data/adb/lspd/config/modules_config.db-shm'"
$db = Join-Path $env:TEMP "modules_config_tng_finish.db"
& $adb pull /sdcard/Download/modules_config.db $db
python "$ProjectRoot\scripts\configure-lsposed.py" $db $apkPath
& $adb push $db /sdcard/Download/modules_config.db
& $adb shell "su -c 'cp /sdcard/Download/modules_config.db /data/adb/lspd/config/modules_config.db; rm -f /data/adb/lspd/config/modules_config.db-wal /data/adb/lspd/config/modules_config.db-shm; chmod 660 /data/adb/lspd/config/modules_config.db'"
Write-Host "=== Phone checklist ===" -ForegroundColor Yellow
Write-Host " 1. TNG must NOT be in Magisk DenyList (DenyList blocks Vector/LSPosed injection)"
Write-Host " 2. MariBank/Seabank may stay on DenyList; Shamiko + ProcMaps hook hide root for them"
Write-Host " 3. LSPosed: notiMessage Xposed enabled, scope includes my.com.tngdigital.ewallet"
Write-Host " 4. LSPosed soft reboot / zygote restart after scope update"
Write-Host " 5. Disable USB debugging before test (recommended)"
if ($ClearTng) {
Write-Host "=== Clear TNG data ===" -ForegroundColor Cyan
& $adb shell "su -c 'pm clear my.com.tngdigital.ewallet'"
}
& $adb shell "am force-stop my.com.tngdigital.ewallet"
Write-Host ""
Write-Host "Done. Launch TNG eWallet, then run: .\scripts\logcat-tng.ps1" -ForegroundColor Green

View File

@@ -10,8 +10,8 @@ android {
applicationId "com.miraclegarden.smsmessage.xposed"
minSdk 24
targetSdk 34
versionCode 2
versionName "1.1.0"
versionCode 5
versionName "1.2.2"
}
buildTypes {

View File

@@ -4,11 +4,18 @@ public final class HookBridge {
public static final String TARGET_APP_PACKAGE = "com.miraclegarden.smsmessage";
public static final String ACTION_HOOK_MESSAGE = "com.miraclegarden.smsmessage.action.HOOK_MESSAGE";
public static final String ACTION_HOOK_STATUS = "com.miraclegarden.smsmessage.action.HOOK_STATUS";
public static final String EXTRA_PACKAGE_NAME = "packageName";
public static final String EXTRA_TITLE = "title";
public static final String EXTRA_CONTENT = "content";
public static final String EXTRA_TIMESTAMP = "timestamp";
public static final String EXTRA_SOURCE = "source";
public static final String EXTRA_MODULE_VERSION_CODE = "moduleVersionCode";
public static final String EXTRA_MODULE_VERSION_NAME = "moduleVersionName";
public static final String EXTRA_HOST_PACKAGE = "hostPackage";
/** 与 xposed-module/build.gradle 保持同步 */
public static final int MODULE_VERSION_CODE = 5;
public static final String MODULE_VERSION_NAME = "1.2.2";
public static final String SOURCE_XPOSED_SQLITE = "xposed_sqlite";
public static final String SOURCE_XPOSED_WECHAT = "xposed_wechat";
public static final String SOURCE_XPOSED_TELEGRAM = "xposed_telegram";
@@ -18,6 +25,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() {
}

View File

@@ -33,4 +33,23 @@ public final class HookForwarder {
XposedBridge.log(TAG + " sendBroadcast failed: " + t.getMessage());
}
}
/** 向主 App 汇报当前进程内 Hook 模块版本(用于检测是否最新) */
public static void reportStatus(Context context, String hostPackage, String source) {
if (context == null) {
return;
}
try {
Intent intent = new Intent(HookBridge.ACTION_HOOK_STATUS);
intent.setPackage(HookBridge.TARGET_APP_PACKAGE);
intent.putExtra(HookBridge.EXTRA_HOST_PACKAGE, hostPackage);
intent.putExtra(HookBridge.EXTRA_SOURCE, source);
intent.putExtra(HookBridge.EXTRA_MODULE_VERSION_CODE, HookBridge.MODULE_VERSION_CODE);
intent.putExtra(HookBridge.EXTRA_MODULE_VERSION_NAME, HookBridge.MODULE_VERSION_NAME);
intent.putExtra(HookBridge.EXTRA_TIMESTAMP, System.currentTimeMillis());
context.sendBroadcast(intent);
} catch (Throwable t) {
XposedBridge.log(TAG + " reportStatus failed: " + t.getMessage());
}
}
}

View File

@@ -3,6 +3,8 @@ 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;
import com.miraclegarden.smsmessage.xposed.hook.UpBankMessageHook;
@@ -20,7 +22,6 @@ public class MainHook implements IXposedHookLoadPackage {
private static final String UP_BANK_PACKAGE = "au.com.up.money";
private static final String SUNCORP_PACKAGE = "au.com.suncorp.marketplace";
private static final String UBANK_PACKAGE = "au.com.bank86400";
private static final String MARIBANK_PACKAGE = MariBankRootBypassHook.PACKAGE;
private static final String MAIN_APP_PACKAGE = "com.miraclegarden.smsmessage";
@Override
@@ -58,12 +59,18 @@ public class MainHook implements IXposedHookLoadPackage {
return;
}
if (MARIBANK_PACKAGE.equals(lpparam.packageName)) {
if (MariBankRootBypassHook.isTargetPackage(lpparam.packageName)) {
MariBankShpsNativeHook.install(lpparam);
MariBankRootBypassHook.install(lpparam);
return;
}
if (TngRootBypassHook.isTargetPackage(lpparam.packageName)) {
TngRootBypassHook.install(lpparam);
TngMoneyPacketHook.install(lpparam);
return;
}
SqliteMessageHook.install(lpparam);
}
}

View File

@@ -0,0 +1,308 @@
package com.miraclegarden.smsmessage.xposed.hook;
import java.lang.reflect.Method;
import java.lang.reflect.Modifier;
import java.nio.charset.StandardCharsets;
import java.util.HashSet;
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;
/**
* SHPSSDK attestation 生成链 Hook含 native JNI
* 目标:在 {@code rdVerifyInfo.data/dataKey} 组装前,让 native 采集层读到「干净环境」。
*/
public final class MariBankAttestationHook {
private static final String TAG = "notiMessageHook/MariBankAttest";
private static volatile boolean probesInstalled = false;
private static volatile boolean loadClassWatcherInstalled = false;
private static final Set<String> HOOKED_ATTESTATION_CLASSES = new HashSet<>();
private MariBankAttestationHook() {
}
public static int installLate(XC_LoadPackage.LoadPackageParam lpparam) {
if (!probesInstalled) {
hookEnvironmentProbes(lpparam);
probesInstalled = true;
}
installLoadClassWatcher(lpparam);
RootBypassHelper.hookSecurityClass(lpparam, "com.shopee.bke.biz.base.risk.a");
int n = 0;
for (String className : MariBankRegionProfile.shpsAttestationCoreClasses(lpparam.packageName)) {
n += hookAttestationClass(lpparam, className);
}
n += hookKnownAttestationMethods(lpparam);
if (n > 0) {
XposedBridge.log(TAG + " attestation hooks=" + n
+ " region=" + MariBankRegionProfile.label(lpparam.packageName)
+ " classes=" + HOOKED_ATTESTATION_CLASSES.size());
}
return n;
}
private static void installLoadClassWatcher(XC_LoadPackage.LoadPackageParam lpparam) {
if (loadClassWatcherInstalled) {
return;
}
loadClassWatcherInstalled = true;
try {
XposedHelpers.findAndHookMethod(
ClassLoader.class,
"loadClass",
String.class,
boolean.class,
new XC_MethodHook() {
@Override
protected void afterHookedMethod(MethodHookParam param) {
if (param.getThrowable() != null) {
return;
}
String name = (String) param.args[0];
if (name == null) {
return;
}
if (name.contains("shpssdk")
|| name.contains("CharacterCrypto")
|| name.contains("bke.biz.base.risk")) {
if (MariBankRegionProfile.isSingapore(lpparam.packageName)
&& MariBankRegionProfile.isShpssdkLegacyPackage(name)) {
return;
}
installLate(lpparam);
}
}
});
XposedBridge.log(TAG + " ClassLoader attestation watcher OK");
} catch (Throwable t) {
XposedBridge.log(TAG + " skip ClassLoader watcher: " + t.getMessage());
}
}
/** 逆向确认的 attestation / requestDefense 桥接方法。 */
private static int hookKnownAttestationMethods(XC_LoadPackage.LoadPackageParam lpparam) {
int count = 0;
count += hookMethodByName(lpparam, "com.shopee.shpssdkbank.wvvvuwwu", "vvuwuuvuu");
count += hookMethodByName(lpparam, "com.shopee.shpssdkbank.wvvvuwwu", "wwvwvwuvv");
count += hookMethodByName(lpparam, "com.shopee.shpssdkbank.wvvvuwwu", "vuwuuuwv");
count += hookMethodByName(lpparam, "com.shopee.shpssdkbank.wvvvuwwu", "vuwuuwvw");
count += hookMethodByName(lpparam, "com.shopee.shpssdkbank.wvvvuwwu", "vuwuuwvvw");
count += hookMethodByName(lpparam, "com.shopee.shpssdkbank.wvvvuwwu", "vuwuuwvu");
count += hookMethodByName(lpparam, "com.shopee.shpssdkbank.uwuvuvvww.vvuuuuvvv", "wuvwuvwwu");
count += hookMethodByName(lpparam, "com.shopee.shpssdkbank.uwuvuvvww.vvuuuuvvv", "wwvuwuwvu");
return count;
}
private static int hookMethodByName(
XC_LoadPackage.LoadPackageParam lpparam, String className, String methodName) {
int count = 0;
try {
Class<?> clazz = XposedHelpers.findClass(className, lpparam.classLoader);
for (Method method : clazz.getDeclaredMethods()) {
if (!methodName.equals(method.getName())) {
continue;
}
if (hookAttestationMethod(className, method)) {
count++;
}
}
} catch (Throwable t) {
XposedBridge.log(TAG + " skip " + className + "." + methodName + ": " + t.getMessage());
}
return count;
}
private static int hookAttestationClass(
XC_LoadPackage.LoadPackageParam lpparam, String className) {
if (HOOKED_ATTESTATION_CLASSES.contains(className)) {
return 0;
}
int count = 0;
try {
Class<?> clazz = XposedHelpers.findClass(className, lpparam.classLoader);
for (Method method : clazz.getDeclaredMethods()) {
if (!Modifier.isStatic(method.getModifiers())) {
continue;
}
if (hookAttestationMethod(className, method)) {
count++;
}
}
if (count > 0) {
HOOKED_ATTESTATION_CLASSES.add(className);
}
} catch (Throwable t) {
XposedBridge.log(TAG + " skip class " + className + ": " + t.getMessage());
}
return count;
}
private static boolean hookAttestationMethod(String className, Method method) {
final String methodName = method.getName();
final boolean isVuwuuwvw = "vuwuuwvw".equals(methodName);
Class<?> rt = method.getReturnType();
if (rt == String.class) {
XposedBridge.hookMethod(method, new XC_MethodHook() {
private byte[][] vuwuInputs;
@Override
protected void beforeHookedMethod(MethodHookParam param) {
if (!isVuwuuwvw) {
return;
}
vuwuInputs = new byte[param.args.length][];
for (int i = 0; i < param.args.length; i++) {
if (param.args[i] instanceof byte[]) {
byte[] b = (byte[]) param.args[i];
vuwuInputs[i] = b;
XposedBridge.log(TAG + " >> vuwuuwvw in" + i + " len=" + b.length);
}
}
}
@Override
protected void afterHookedMethod(MethodHookParam param) {
Object result = param.getResult();
if (!(result instanceof String)) {
return;
}
String s = (String) result;
if (isVuwuuwvw) {
MariBankAttestationJsonUtil.logVuwuuwvwCall(vuwuInputs, s);
}
String sanitized = sanitizeAttestationString(s);
if (!sanitized.equals(s)) {
param.setResult(sanitized);
XposedBridge.log(TAG + " " + className + "#" + method.getName()
+ (Modifier.isNative(method.getModifiers()) ? " (native)" : "")
+ " sanitized len=" + s.length());
}
}
});
return true;
}
if (rt == byte[].class) {
XposedBridge.hookMethod(method, new XC_MethodHook() {
@Override
protected void afterHookedMethod(MethodHookParam param) {
Object result = param.getResult();
if (!(result instanceof byte[])) {
return;
}
byte[] bytes = (byte[]) result;
byte[] out = MariBankRegisterPayloadUtil.sanitizeRegistrationBytes(bytes);
if (out != bytes) {
param.setResult(out);
XposedBridge.log(TAG + " " + className + "#" + method.getName()
+ " byte[] sanitized");
} else {
byte[] tokenOut = MariBankRiskTokenUtil.sanitizeBytes(bytes, 0, bytes.length);
if (tokenOut != bytes) {
param.setResult(tokenOut);
XposedBridge.log(TAG + " " + className + "#" + method.getName()
+ " byte[] riskToken sanitized");
}
}
}
});
return true;
}
if (rt == boolean.class || rt == Boolean.class) {
XposedBridge.hookMethod(method, new XC_MethodHook() {
@Override
protected void beforeHookedMethod(MethodHookParam param) {
param.setResult(false);
}
});
return true;
}
if (rt == int.class || rt == Integer.class) {
XposedBridge.hookMethod(method, new XC_MethodHook() {
@Override
protected void beforeHookedMethod(MethodHookParam param) {
param.setResult(0);
}
});
return true;
}
return false;
}
private static String sanitizeAttestationString(String s) {
if (s == null || s.isEmpty()) {
return s;
}
if (MariBankRegisterPayloadUtil.isRegistrationPayload(s)) {
return MariBankRegisterPayloadUtil.sanitizeRegistrationJson(s);
}
if (s.contains("|")) {
return MariBankRiskTokenUtil.sanitizeRiskToken(s);
}
return MariBankRiskTokenUtil.sanitizeAllInText(s);
}
private static void hookEnvironmentProbes(XC_LoadPackage.LoadPackageParam lpparam) {
try {
XposedHelpers.findAndHookMethod(
"android.os.Debug",
lpparam.classLoader,
"isDebuggerConnected",
new XC_MethodHook() {
@Override
protected void beforeHookedMethod(MethodHookParam param) {
param.setResult(false);
}
}
);
} catch (Throwable ignored) {
}
try {
XposedHelpers.findAndHookMethod(
"android.os.Debug",
lpparam.classLoader,
"waitingForDebugger",
new XC_MethodHook() {
@Override
protected void beforeHookedMethod(MethodHookParam param) {
param.setResult(false);
}
}
);
} catch (Throwable ignored) {
}
RootBypassHelper.hookFileExists(lpparam);
RootBypassHelper.hookRuntimeExec(lpparam);
RootBypassHelper.hookSystemGetProperty(lpparam);
hookProcessBuilder(lpparam);
}
private static void hookProcessBuilder(XC_LoadPackage.LoadPackageParam lpparam) {
try {
XposedHelpers.findAndHookMethod(
ProcessBuilder.class,
"start",
new XC_MethodHook() {
@Override
protected void beforeHookedMethod(MethodHookParam param) {
ProcessBuilder pb = (ProcessBuilder) param.thisObject;
if (pb == null || pb.command() == null) {
return;
}
if (ProbeGuard.isBlockedCommand(pb.command())) {
param.setResult(ProbeGuard.fakeFailedProcess(
String.join(" ", pb.command())));
}
}
}
);
} catch (Throwable t) {
XposedBridge.log(TAG + " ProcessBuilder hook failed: " + t.getMessage());
}
}
}

View File

@@ -0,0 +1,251 @@
package com.miraclegarden.smsmessage.xposed.hook;
import android.util.Base64;
import org.json.JSONArray;
import org.json.JSONObject;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Iterator;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import de.robv.android.xposed.XposedBridge;
/**
* 解析 {@code vuwuuwvw} 返回的 obfuscated-key JSONkey 多为 8 位 hex
*/
final class MariBankAttestationJsonUtil {
private static final String TAG = "notiMessageHook/MariBankAttestJson";
private static final Pattern HEX_KEY = Pattern.compile("^[0-9a-fA-F]{8}$");
private static final Pattern SUSPICIOUS = Pattern.compile(
"(root|hook|xposed|lsposed|magisk|frida|substrate|emulator|debug|adb|"
+ "selinux|su\\b|/proc/|zygisk|riru|shamiko|tamper|integrity|"
+ "jailbreak|virtual|mock|proxy|vpn|developer)",
Pattern.CASE_INSENSITIVE);
private MariBankAttestationJsonUtil() {
}
static void logVuwuuwvwCall(byte[][] inputs, String output) {
if (inputs != null) {
for (int i = 0; i < inputs.length; i++) {
if (inputs[i] != null && inputs[i].length > 0) {
MariBankCaptureUtil.logBytes("vuwuuwvw.in" + i, inputs[i]);
}
}
}
if (output == null || output.isEmpty()) {
return;
}
boolean registerLike = isRegisterAttestationCall(inputs, output);
String label = registerLike ? "vuwuuwvw.out REGISTER" : "vuwuuwvw.out";
MariBankCaptureUtil.logText(label, output);
parseAndSummarize(output, registerLike);
}
static boolean isRegisterAttestationCall(byte[][] inputs, String output) {
if (inputs != null) {
for (byte[] in : inputs) {
if (in != null && in.length >= 200 && in.length <= 400) {
return true;
}
}
}
return output != null && output.length() >= 2200 && output.length() <= 2500;
}
static void parseAndSummarize(String json, boolean highlight) {
try {
JSONObject obj = new JSONObject(json);
List<String> keys = new ArrayList<>();
Iterator<String> it = obj.keys();
while (it.hasNext()) {
keys.add(it.next());
}
Collections.sort(keys);
XposedBridge.log(TAG + " keys=" + keys.size()
+ (highlight ? " [REGISTER-LIKE]" : "")
+ " sample=" + keys.subList(0, Math.min(6, keys.size())));
List<String> hits = new ArrayList<>();
for (String key : keys) {
Object val = obj.get(key);
scanValue(key, val, hits);
}
if (!hits.isEmpty()) {
XposedBridge.log(TAG + " SUSPICIOUS count=" + hits.size());
for (int i = 0; i < Math.min(hits.size(), 24); i++) {
XposedBridge.log(TAG + " " + hits.get(i));
}
} else {
XposedBridge.log(TAG + " no plain suspicious strings in values");
}
logKeyGuesses(keys);
} catch (Throwable t) {
XposedBridge.log(TAG + " parse fail: " + t.getMessage()
+ " head=" + json.substring(0, Math.min(120, json.length())));
}
}
private static void scanValue(String key, Object val, List<String> hits) {
if (val instanceof String) {
String s = (String) val;
noteIfSuspicious(key, "str", s, hits);
if (s.length() >= 8 && s.length() <= 512 && looksBase64(s)) {
byte[] decoded = tryBase64(s);
if (decoded != null) {
String inner = new String(decoded, StandardCharsets.UTF_8);
if (isMostlyPrintable(inner)) {
noteIfSuspicious(key, "b64utf8", inner, hits);
} else {
noteIfSuspicious(key, "b64hex", hexPreview(decoded, 32), hits);
}
}
}
return;
}
if (val instanceof Number || val instanceof Boolean) {
String s = String.valueOf(val);
if ("1".equals(s) || "true".equalsIgnoreCase(s)) {
hits.add(key + " =" + s + " (flag?)");
}
return;
}
if (val instanceof JSONObject) {
JSONObject nested = (JSONObject) val;
Iterator<String> it = nested.keys();
while (it.hasNext()) {
String nk = it.next();
try {
scanValue(key + "." + nk, nested.get(nk), hits);
} catch (Throwable ignored) {
}
}
return;
}
if (val instanceof JSONArray) {
JSONArray arr = (JSONArray) val;
for (int i = 0; i < arr.length(); i++) {
try {
scanValue(key + "[" + i + "]", arr.get(i), hits);
} catch (Throwable ignored) {
}
}
}
}
private static void noteIfSuspicious(String key, String kind, String text, List<String> hits) {
if (text == null || text.isEmpty()) {
return;
}
Matcher m = SUSPICIOUS.matcher(text);
if (m.find()) {
String show = text.length() > 120 ? text.substring(0, 120) + "..." : text;
hits.add(key + " " + kind + " hit=" + m.group().toLowerCase(Locale.ROOT) + " val=" + show);
}
}
private static void logKeyGuesses(List<String> keys) {
Set<String> candidates = knownFieldNames();
List<String> matched = new ArrayList<>();
for (String key : keys) {
if (!HEX_KEY.matcher(key).matches()) {
continue;
}
String lower = key.toLowerCase(Locale.ROOT);
for (String name : candidates) {
if (hashKey(name).equals(lower)) {
matched.add(key + "=>" + name);
}
}
}
if (!matched.isEmpty()) {
XposedBridge.log(TAG + " key guesses: " + matched);
}
}
private static Set<String> knownFieldNames() {
Set<String> names = new LinkedHashSet<>();
String[] base = {
"root", "hook", "xposed", "lsposed", "magisk", "frida", "adb", "debug",
"debuggable", "emulator", "simulator", "vpn", "proxy", "mock",
"selinux", "su", "supersu", "zygisk", "riru", "shamiko", "substrate",
"integrity", "safetynet", "playIntegrity", "deviceId", "androidId",
"serial", "fingerprint", "model", "brand", "manufacturer", "board",
"host", "tags", "type", "user", "display", "product", "hardware",
"usb", "wifi", "adb_enabled", "development_settings_enabled",
"RISK_ROOT", "RISK_HOOK", "RISK_USB_ADB", "RISK_WIFI_ADB", "RISK_ADB",
"RISK_EMULATOR", "RISK_DEBUG", "RISK_VPN", "RISK_PROXY", "RISK_MOCK",
"rdVerifyInfo", "deviceFingerprint", "data", "dataKey", "riskToken",
"isRoot", "isHook", "isDebug", "isAdb", "isEmulator", "isVirtual",
"tamper", "jailbreak", "bootloader", "verifiedbootstate", "vbmeta",
"init.svc.adbd", "/proc/self/maps", "RealInterceptorChain",
};
Collections.addAll(names, base);
return names;
}
private static String hashKey(String name) {
try {
MessageDigest md = MessageDigest.getInstance("MD5");
byte[] dig = md.digest(name.getBytes(StandardCharsets.UTF_8));
StringBuilder sb = new StringBuilder();
for (int i = 0; i < 4; i++) {
sb.append(String.format(Locale.ROOT, "%02x", dig[i] & 0xff));
}
return sb.toString();
} catch (Throwable t) {
return "";
}
}
private static boolean looksBase64(String s) {
return s.matches("^[A-Za-z0-9+/=]+$");
}
private static byte[] tryBase64(String s) {
try {
return Base64.decode(s, Base64.DEFAULT);
} catch (Throwable t) {
return null;
}
}
private static boolean isMostlyPrintable(String s) {
if (s == null || s.isEmpty()) {
return false;
}
int ok = 0;
for (int i = 0; i < s.length(); i++) {
char c = s.charAt(i);
if (c >= 0x20 && c < 0x7f) {
ok++;
}
}
return ok * 100 / s.length() >= 85;
}
private static String hexPreview(byte[] b, int max) {
if (b == null) {
return "";
}
int n = Math.min(b.length, max);
StringBuilder sb = new StringBuilder();
for (int i = 0; i < n; i++) {
sb.append(String.format(Locale.ROOT, "%02x", b[i] & 0xff));
}
if (b.length > n) {
sb.append("...");
}
return sb.toString();
}
}

View File

@@ -0,0 +1,139 @@
package com.miraclegarden.smsmessage.xposed.hook;
import android.util.Base64;
import java.nio.charset.StandardCharsets;
import de.robv.android.xposed.XposedBridge;
/**
* 分段 logcat 输出,避免单条超过 ~4KB 被截断。
*/
final class MariBankCaptureUtil {
static final String TAG = "notiMessageHook/MariBankCapture";
private static final int CHUNK = 3500;
private static final int HEX_PREVIEW = 96;
private MariBankCaptureUtil() {
}
static void logText(String section, String text) {
if (text == null) {
XposedBridge.log(TAG + " [" + section + "] null");
return;
}
if (text.length() <= CHUNK) {
XposedBridge.log(TAG + " [" + section + "] len=" + text.length() + " " + text);
return;
}
int parts = (text.length() + CHUNK - 1) / CHUNK;
XposedBridge.log(TAG + " [" + section + "] len=" + text.length() + " parts=" + parts);
for (int i = 0; i < parts; i++) {
int start = i * CHUNK;
int end = Math.min(start + CHUNK, text.length());
XposedBridge.log(TAG + " [" + section + "] " + (i + 1) + "/" + parts + " "
+ text.substring(start, end));
}
}
static void logBytes(String section, byte[] bytes) {
if (bytes == null) {
XposedBridge.log(TAG + " [" + section + "] null");
return;
}
String utf8 = tryUtf8(bytes);
if (isMostlyPrintable(utf8)) {
logText(section + " utf8", utf8);
} else {
XposedBridge.log(TAG + " [" + section + "] byte[" + bytes.length + "] hex="
+ hexPreview(bytes) + " b64=" + base64(bytes));
}
}
static void logArg(String section, Object arg) {
if (arg == null) {
XposedBridge.log(TAG + " [" + section + "] null");
return;
}
if (arg instanceof String) {
logText(section, (String) arg);
return;
}
if (arg instanceof byte[]) {
logBytes(section, (byte[]) arg);
return;
}
if (arg instanceof String[]) {
String[] arr = (String[]) arg;
XposedBridge.log(TAG + " [" + section + "] String[" + arr.length + "]");
for (int i = 0; i < arr.length; i++) {
logText(section + "[" + i + "]", arr[i]);
}
return;
}
if (arg instanceof byte[][]) {
byte[][] arr = (byte[][]) arg;
XposedBridge.log(TAG + " [" + section + "] byte[][] len=" + arr.length);
for (int i = 0; i < arr.length; i++) {
logBytes(section + "[" + i + "]", arr[i]);
}
return;
}
XposedBridge.log(TAG + " [" + section + "] " + arg.getClass().getName()
+ " = " + String.valueOf(arg));
}
static boolean isBankUrl(String url) {
if (url == null || url.isEmpty()) {
return false;
}
String lower = url.toLowerCase();
return lower.contains("maribank.com")
|| lower.contains("seabank.ph")
|| lower.contains("/uapi/");
}
private static String tryUtf8(byte[] bytes) {
try {
return new String(bytes, StandardCharsets.UTF_8);
} catch (Throwable t) {
return "";
}
}
static boolean isMostlyPrintable(String s) {
if (s == null || s.isEmpty()) {
return false;
}
int printable = 0;
int sample = Math.min(s.length(), 512);
for (int i = 0; i < sample; i++) {
char c = s.charAt(i);
if (c == '\n' || c == '\r' || c == '\t' || (c >= 32 && c < 127)) {
printable++;
}
}
return printable * 100 / sample >= 85;
}
private static String hexPreview(byte[] bytes) {
int n = Math.min(bytes.length, HEX_PREVIEW);
StringBuilder sb = new StringBuilder(n * 2);
for (int i = 0; i < n; i++) {
sb.append(String.format("%02x", bytes[i] & 0xff));
}
if (bytes.length > n) {
sb.append("...");
}
return sb.toString();
}
private static String base64(byte[] bytes) {
try {
return Base64.encodeToString(bytes, Base64.NO_WRAP);
} catch (Throwable t) {
return "<b64 err>";
}
}
}

View File

@@ -0,0 +1,209 @@
package com.miraclegarden.smsmessage.xposed.hook;
import java.lang.reflect.Method;
import java.util.Arrays;
import java.util.HashSet;
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;
/**
* DFPdevice fingerprint初始化诊断register 前若 dfp 为空会弹
* "The system is currently unavailable",且不会发 /uapi/v2/register。
*/
public final class MariBankDfpHook {
private static final String TAG = "notiMessageHook/MariBankDfp";
private static final Set<String> HOOKED_CLASSES = new HashSet<>();
private static volatile boolean loadClassWatcherInstalled = false;
private static volatile boolean registerVmHooked = false;
private MariBankDfpHook() {
}
public static void installLate(XC_LoadPackage.LoadPackageParam lpparam) {
hookRegisterViewModel(lpparam);
hookKnownDfpClasses(lpparam);
installLoadClassWatcher(lpparam);
}
private static void hookRegisterViewModel(XC_LoadPackage.LoadPackageParam lpparam) {
if (registerVmHooked) {
return;
}
String className = "com.shopee.bke.biz.user.viewmodel.RegisterViewModel";
try {
Class<?> clazz = XposedHelpers.findClass(className, lpparam.classLoader);
registerVmHooked = true;
for (Method method : clazz.getDeclaredMethods()) {
XposedBridge.hookMethod(method, new XC_MethodHook() {
@Override
protected void beforeHookedMethod(MethodHookParam param) {
String args = param.args == null ? "[]" : Arrays.toString(param.args);
if (args.length() > 800) {
args = args.substring(0, 800) + "...";
}
XposedBridge.log(TAG + " RegisterViewModel."
+ method.getName() + " args=" + args);
}
@Override
protected void afterHookedMethod(MethodHookParam param) {
Object result = param.getResult();
if (result == null) {
return;
}
String rs = String.valueOf(result);
if (rs.length() > 600) {
rs = rs.substring(0, 600) + "...";
}
if (rs.isEmpty()
|| rs.toLowerCase().contains("dfp")
|| rs.toLowerCase().contains("error")
|| rs.toLowerCase().contains("unavailable")
|| rs.contains("3100012")
|| rs.contains("4067")) {
XposedBridge.log(TAG + " RegisterViewModel."
+ method.getName() + " result=" + rs);
}
}
});
}
XposedBridge.log(TAG + " RegisterViewModel hooked methods="
+ clazz.getDeclaredMethods().length);
} catch (Throwable t) {
XposedBridge.log(TAG + " skip RegisterViewModel: " + t.getMessage());
}
}
/** 启动时类可能已加载,先尝试已知命名。 */
private static void hookKnownDfpClasses(XC_LoadPackage.LoadPackageParam lpparam) {
String[] candidates = {
"com.shopee.bke.lib.jni.security.CharacterCryptoManager",
"com.shopee.bke.lib.jni.security.CharacterCryptoManagerWrapper",
"com.shopee.bke.lib.jni.utils.NativeEncryptUtilsWrapper",
};
for (String cn : candidates) {
tryHookDfpClass(lpparam, cn);
}
}
private static void installLoadClassWatcher(XC_LoadPackage.LoadPackageParam lpparam) {
if (loadClassWatcherInstalled) {
return;
}
loadClassWatcherInstalled = true;
try {
XposedHelpers.findAndHookMethod(
ClassLoader.class,
"loadClass",
String.class,
boolean.class,
new XC_MethodHook() {
@Override
protected void afterHookedMethod(MethodHookParam param) {
if (param.getThrowable() != null) {
return;
}
String name = (String) param.args[0];
if (name == null) {
return;
}
if (name.contains("CharacterCrypto")
|| name.contains("NativeEncryptUtils")
|| (name.contains("dfp") && name.contains("bke"))) {
tryHookDfpClass(lpparam, name);
}
}
});
XposedBridge.log(TAG + " ClassLoader dfp watcher OK");
} catch (Throwable t) {
XposedBridge.log(TAG + " skip ClassLoader watcher: " + t.getMessage());
}
}
private static void tryHookDfpClass(XC_LoadPackage.LoadPackageParam lpparam, String className) {
if (HOOKED_CLASSES.contains(className)) {
return;
}
try {
Class<?> clazz = XposedHelpers.findClass(className, lpparam.classLoader);
int count = 0;
for (Method method : clazz.getDeclaredMethods()) {
String mn = method.getName();
if (!mn.startsWith("getDfp") && !mn.contains("Fingerprint")) {
continue;
}
if (method.getReturnType() != String.class) {
continue;
}
XposedBridge.hookMethod(method, new XC_MethodHook() {
@Override
protected void afterHookedMethod(MethodHookParam param) {
Object result = param.getResult();
String s = result instanceof String ? (String) result : null;
if (s == null || s.isEmpty()) {
XposedBridge.log(TAG + " EMPTY " + className + "."
+ method.getName() + " → register may fail");
} else {
String tail = s.length() > 80
? s.substring(0, 40) + "..." + s.substring(s.length() - 20)
: s;
XposedBridge.log(TAG + " " + className + "."
+ method.getName() + " len=" + s.length() + " val=" + tail);
}
}
});
count++;
}
if (count > 0) {
HOOKED_CLASSES.add(className);
XposedBridge.log(TAG + " hooked " + count + " dfp getters in " + className);
}
} catch (Throwable ignored) {
}
}
/** 拦截 dfp 空检查日志源(含 register scene 文案的类方法)。 */
public static void hookDfpEmptyGuards(XC_LoadPackage.LoadPackageParam lpparam) {
try {
XposedHelpers.findAndHookMethod(
"android.util.Log",
lpparam.classLoader,
"e",
String.class,
String.class,
new XC_MethodHook() {
@Override
protected void beforeHookedMethod(MethodHookParam param) {
String msg = param.args[1] instanceof String ? (String) param.args[1] : null;
if (msg == null) {
return;
}
String lower = msg.toLowerCase();
if (lower.contains("dfp is empty")
|| lower.contains("getdfp empty")
|| lower.contains("getdfp onerror")) {
XposedBridge.log(TAG + " Log.e: " + msg);
logStack();
}
}
});
} catch (Throwable t) {
XposedBridge.log(TAG + " skip Log.e dfp guard: " + t.getMessage());
}
}
private static void logStack() {
for (StackTraceElement frame : Thread.currentThread().getStackTrace()) {
String cn = frame.getClassName();
if (cn.startsWith("com.shopee.bke") || cn.contains("CharacterCrypto")) {
XposedBridge.log(TAG + " at " + cn + "." + frame.getMethodName());
}
}
}
}

View File

@@ -0,0 +1,153 @@
package com.miraclegarden.smsmessage.xposed.hook;
import java.util.concurrent.atomic.AtomicInteger;
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;
/**
* MariBank / SeaBank HTTP 全量抓包(仅 OkHttp 头 + 响应体)。
* 加密明文 / attestation 由 {@link MariBankSdkUtilsHook} 负责,勿在此重复 Hook Gson/native。
*/
public final class MariBankFullCaptureHook {
private static final String TAG = MariBankCaptureUtil.TAG;
private static final AtomicInteger SEQ = new AtomicInteger();
private static volatile boolean installed = false;
private MariBankFullCaptureHook() {
}
public static void installLate(XC_LoadPackage.LoadPackageParam lpparam) {
if (installed) {
return;
}
installed = true;
int n = hookOkHttpRealCall(lpparam);
XposedBridge.log(TAG + " http capture hooks=" + n);
}
private static int hookOkHttpRealCall(XC_LoadPackage.LoadPackageParam lpparam) {
XC_MethodHook hook = new XC_MethodHook() {
@Override
protected void beforeHookedMethod(MethodHookParam param) {
try {
Object request = XposedHelpers.getObjectField(param.thisObject, "originalRequest");
if (request == null) {
return;
}
captureRequest(request);
} catch (Throwable t) {
XposedBridge.log(TAG + " req capture err: " + t.getMessage());
}
}
@Override
protected void afterHookedMethod(MethodHookParam param) {
if (!"execute".equals(param.method.getName())) {
return;
}
try {
Object response = param.getResult();
if (response != null) {
captureResponse(response);
}
} catch (Throwable t) {
XposedBridge.log(TAG + " resp capture err: " + t.getMessage());
}
}
};
int count = 0;
for (String className : new String[]{
"okhttp3.RealCall", "okhttp3.internal.connection.RealCall"
}) {
try {
XposedHelpers.findAndHookMethod(className, lpparam.classLoader, "execute", hook);
XposedHelpers.findAndHookMethod(className, lpparam.classLoader, "enqueue",
"okhttp3.Callback", hook);
count += 2;
} catch (Throwable ignored) {
}
}
return count;
}
private static void captureRequest(Object request) {
Object urlObj = XposedHelpers.callMethod(request, "url");
String url = urlObj != null ? String.valueOf(urlObj) : "?";
if (!MariBankCaptureUtil.isBankUrl(url)) {
return;
}
int seq = SEQ.incrementAndGet();
String method = (String) XposedHelpers.callMethod(request, "method");
XposedBridge.log(TAG + " >>> #" + seq + " " + method + " " + url);
logHeaders("reqHdr #" + seq, XposedHelpers.callMethod(request, "headers"));
}
private static void captureResponse(Object response) {
try {
Object request = XposedHelpers.callMethod(response, "request");
Object urlObj = request != null ? XposedHelpers.callMethod(request, "url") : null;
String url = urlObj != null ? String.valueOf(urlObj) : "?";
if (!MariBankCaptureUtil.isBankUrl(url)) {
return;
}
int code = (int) XposedHelpers.callMethod(response, "code");
XposedBridge.log(TAG + " <<< HTTP " + code + " " + url);
logHeaders("respHdr", XposedHelpers.callMethod(response, "headers"));
try {
Object peek = XposedHelpers.callMethod(response, "peekBody", 256L * 1024L);
if (peek != null) {
Object ctObj = XposedHelpers.callMethod(peek, "contentType");
String contentType = ctObj != null ? String.valueOf(ctObj).toLowerCase() : "";
if (contentType.contains("pdf") || contentType.contains("octet-stream")) {
long size = 0L;
try {
size = (long) XposedHelpers.callMethod(peek, "contentLength");
} catch (Throwable ignored) {
}
XposedBridge.log(TAG + " <<< respBody skipped binary ct="
+ contentType + " len=" + size + " " + url);
} else {
String body = (String) XposedHelpers.callMethod(peek, "string");
if (body != null && !body.isEmpty()) {
if (body.length() > 512
&& !MariBankCaptureUtil.isMostlyPrintable(body)) {
XposedBridge.log(TAG + " <<< respBody skipped non-text len="
+ body.length() + " " + url);
} else {
MariBankCaptureUtil.logText("respBody", body);
}
}
}
}
} catch (Throwable t) {
XposedBridge.log(TAG + " peekBody err: " + t.getMessage());
}
} catch (Throwable t) {
XposedBridge.log(TAG + " captureResponse err: " + t.getMessage());
}
}
private static void logHeaders(String section, Object headers) {
if (headers == null) {
return;
}
try {
int size = (int) XposedHelpers.callMethod(headers, "size");
StringBuilder sb = new StringBuilder();
for (int i = 0; i < size; i++) {
String name = (String) XposedHelpers.callMethod(headers, "name", i);
String value = (String) XposedHelpers.callMethod(headers, "value", i);
sb.append(name).append(": ").append(value).append("\n");
}
MariBankCaptureUtil.logText(section, sb.toString().trim());
} catch (Throwable t) {
XposedBridge.log(TAG + " [" + section + "] headers err: " + t.getMessage());
}
}
}

View File

@@ -0,0 +1,123 @@
package com.miraclegarden.smsmessage.xposed.hook;
/**
* PH vs SG MariBank 差异配置(基于 reverse/apks 扫描,非「换包名即相同」)。
* <p>
* SG 3.2.2dex 内无 {@code com.shopee.shpssdk.*}、无 {@code safemode.b/catchs.a/util.c}、
* 无 {@code jni.utils.d};风控仅 {@code shpssdkbank}。
*/
public final class MariBankRegionProfile {
private MariBankRegionProfile() {
}
public static boolean isSingapore(String packageName) {
return MariBankRootBypassHook.PACKAGE_SG.equals(packageName);
}
public static boolean isPhilippines(String packageName) {
return MariBankRootBypassHook.PACKAGE.equals(packageName);
}
public static String label(String packageName) {
if (isSingapore(packageName)) {
return "SG";
}
if (isPhilippines(packageName)) {
return "PH";
}
return "?";
}
/** PH SafeMode boolean 类SG base/split dex 均无,勿 findClass。 */
public static String[] safeModeBooleanClasses(String packageName) {
if (isPhilippines(packageName)) {
return new String[]{
"com.shopee.bke.lib.safemode.b",
"com.shopee.bke.lib.safemode.catchs.a",
"com.shopee.bke.lib.safemode.util.c",
};
}
return new String[0];
}
/** 两区共有或 SG 专用 risk 类classes3.dex。 */
public static String[] sharedRiskClasses() {
return new String[]{"com.shopee.bke.biz.base.risk.a"};
}
/** SHPSSDK Java 入口SG 仅 bank 包。 */
public static String[] shpsSdkClasses(String packageName) {
if (isSingapore(packageName)) {
return new String[]{"com.shopee.shpssdkbank.SHPSSDK"};
}
return new String[]{
"com.shopee.shpssdk.SHPSSDK",
"com.shopee.shpssdkbank.SHPSSDK",
};
}
public static String[] shpsAssessRiskClasses(String packageName) {
if (isSingapore(packageName)) {
return new String[]{"com.shopee.shpssdkbank.SPSAssessRisk"};
}
return new String[]{
"com.shopee.shpssdkbank.SPSAssessRisk",
"com.shopee.shpssdk.SPSAssessRisk",
};
}
public static String[] shpsCallbackAdapterClasses(String packageName) {
if (isSingapore(packageName)) {
return new String[]{"com.shopee.shpssdkbank.SPSCallbackAdapter"};
}
return new String[]{
"com.shopee.shpssdk.SPSCallbackAdapter",
"com.shopee.shpssdkbank.SPSCallbackAdapter",
};
}
/** attestation / native-coreSG 无 com.shopee.shpssdk.wvvvuwwu。 */
public static String[] shpsAttestationCoreClasses(String packageName) {
if (isSingapore(packageName)) {
return new String[]{
"com.shopee.shpssdkbank.wvvvuwwu",
"com.shopee.shpssdkbank.uwuvuvvww.uvwuuuuuw.vvvvuwwvu",
"com.shopee.shpssdkbank.uwuvuvvww.vvuuuuvvv",
"com.shopee.shpssdkbank.uwuvuvvww.wvvuuwvwu",
"com.shopee.shpssdkbank.uvuwwuvwv.uvwwuuvvw",
};
}
return new String[]{
"com.shopee.shpssdkbank.wvvvuwwu",
"com.shopee.shpssdkbank.uwuvuvvww.uvwuuuuuw.vvvvuwwvu",
"com.shopee.shpssdkbank.uwuvuvvww.vvuuuuvvv",
"com.shopee.shpssdkbank.uwuvuvvww.wvvuuwvwu",
"com.shopee.shpssdk.wvvvuwwu",
};
}
/** 加密 JNIPH 有 utils.d 包装SG 仅 uvwuvwuv + uvwwwwuvclasses6.dex。 */
public static boolean hasEncryptWrapperD(String packageName) {
return isPhilippines(packageName);
}
public static boolean hasEncryptHelperUvwwwwuv(String packageName) {
return isSingapore(packageName);
}
/** late hooks 完成条件SG 不依赖 PH safemode booleanHooks。 */
public static boolean isLateHooksReady(
String packageName, int booleanHooks, int encryptHooks, int attestHooks) {
if (isSingapore(packageName)) {
return encryptHooks > 0 || attestHooks > 0;
}
return booleanHooks > 0 || encryptHooks > 0 || attestHooks > 0;
}
public static boolean isShpssdkLegacyPackage(String className) {
return className != null
&& className.startsWith("com.shopee.shpssdk.")
&& !className.startsWith("com.shopee.shpssdkbank.");
}
}

View File

@@ -0,0 +1,110 @@
package com.miraclegarden.smsmessage.xposed.hook;
import java.nio.charset.StandardCharsets;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import de.robv.android.xposed.XposedBridge;
/**
* register 加密前 JSON 净化:{@code scene=REGISTRATION} 路径上的 deviceFingerprint / riskToken。
*/
final class MariBankRegisterPayloadUtil {
private static final String TAG = "notiMessageHook/MariBankRegister";
private static final Pattern DEVICE_FINGERPRINT = Pattern.compile(
"\"deviceFingerprint\"\\s*:\\s*\"([^\"]+)\"");
private MariBankRegisterPayloadUtil() {
}
static boolean isRegistrationPayload(String text) {
if (text == null || text.length() < 24) {
return false;
}
return text.contains("\"scene\":\"REGISTRATION\"")
|| text.contains("\"scene\": \"REGISTRATION\"")
|| (text.contains("rdVerifyInfo") && text.contains("\"step\":\"BE\""));
}
static byte[] sanitizeRegistrationBytes(byte[] data) {
if (data == null || data.length == 0) {
return data;
}
String text = new String(data, StandardCharsets.UTF_8);
if (!isRegistrationPayload(text)) {
return MariBankRiskTokenUtil.sanitizeBytes(data, 0, data.length);
}
String out = sanitizeRegistrationJson(text);
if (out.equals(text)) {
return data;
}
XposedBridge.log(TAG + " sanitized register payload len=" + data.length + " -> " + out.length());
return out.getBytes(StandardCharsets.UTF_8);
}
static String sanitizeRegistrationJson(String json) {
if (json == null) {
return null;
}
String out = MariBankRiskTokenUtil.sanitizeAllInText(json);
Matcher m = DEVICE_FINGERPRINT.matcher(out);
StringBuffer sb = new StringBuffer();
boolean changed = false;
while (m.find()) {
String old = m.group(1);
String neu = MariBankRiskTokenUtil.sanitizeRiskToken(old);
if (!neu.equals(old)) {
changed = true;
}
m.appendReplacement(sb, Matcher.quoteReplacement(
"\"deviceFingerprint\":\"" + neu + "\""));
}
if (changed) {
m.appendTail(sb);
out = sb.toString();
XposedBridge.log(TAG + " deviceFingerprint sanitized in register JSON");
}
logRegisterSummary(out);
return out;
}
private static void logRegisterSummary(String json) {
if (json == null) {
return;
}
String cy = extractJsonString(json, "cyCode");
String scene = extractJsonString(json, "scene");
String step = extractJsonString(json, "step");
Matcher fp = DEVICE_FINGERPRINT.matcher(json);
String fpTail = fp.find() ? MariBankRiskTokenUtil.tail(fp.group(1)) : "?";
int dataLen = lengthOfJsonString(json, "\"data\"");
int dataKeyLen = lengthOfJsonString(json, "\"dataKey\"");
XposedBridge.log(TAG + " register summary cy=" + cy + " scene=" + scene
+ " step=" + step + " fpTail=" + fpTail
+ " dataLen=" + dataLen + " dataKeyLen=" + dataKeyLen);
}
private static String extractJsonString(String json, String key) {
Pattern p = Pattern.compile("\"" + Pattern.quote(key) + "\"\\s*:\\s*\"([^\"]*)\"");
Matcher m = p.matcher(json);
return m.find() ? m.group(1) : "?";
}
private static int lengthOfJsonString(String json, String key) {
int idx = json.indexOf(key);
if (idx < 0) {
return -1;
}
int start = json.indexOf('"', idx + key.length());
if (start < 0) {
return -1;
}
int end = json.indexOf('"', start + 1);
if (end < 0) {
return -1;
}
return end - start - 1;
}
}

View File

@@ -2,7 +2,10 @@ package com.miraclegarden.smsmessage.xposed.hook;
import android.app.Activity;
import android.app.Dialog;
import android.content.ContentResolver;
import android.content.res.Resources;
import android.os.Handler;
import android.os.Looper;
import android.os.Process;
import android.view.View;
import android.view.ViewGroup;
@@ -14,7 +17,9 @@ import java.lang.reflect.Method;
import java.lang.reflect.Proxy;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import de.robv.android.xposed.XC_MethodHook;
import de.robv.android.xposed.XposedBridge;
@@ -22,13 +27,30 @@ import de.robv.android.xposed.XposedHelpers;
import de.robv.android.xposed.callbacks.XC_LoadPackage;
/**
* MariBank / SeaBank PHph.seabank.seabankRoot 检测绕过。
* 逆向SafeMode SDK + SHPSSDK检测到 Root 后会 Toast 并 Process.killProcess 自杀
* MariBank / SeaBank Root 检测绕过PH + SG
* 逆向SafeMode SDK + SHPSSDKSG 额外有 USB/无线 ADB 检测RISK_USB_ADB / RISK_WIFI_ADB
*/
public final class MariBankRootBypassHook {
private static final String TAG = "notiMessageHook/MariBankRoot";
/** 菲律宾 MariBank / SeaBank PH */
public static final String PACKAGE = "ph.seabank.seabank";
/** 新加坡 MariBank */
public static final String PACKAGE_SG = "sg.com.maribankmobile.digitalbank";
private static final String[] TARGET_PACKAGES = {PACKAGE, PACKAGE_SG};
public static boolean isTargetPackage(String packageName) {
if (packageName == null) {
return false;
}
for (String pkg : TARGET_PACKAGES) {
if (pkg.equals(packageName)) {
return true;
}
}
return false;
}
/** 服务端注册被拒错误码logcat 实测)。 */
private static final int ERROR_CODE_SECURITY_BLOCKED = 4067004;
@@ -45,29 +67,40 @@ public final class MariBankRootBypassHook {
private static final long FINISH_BURST_WINDOW_MS = 800L;
private static final int FINISH_BURST_THRESHOLD = 2;
private static final String[] SAFE_MODE_CLASSES = {
"com.shopee.bke.lib.safemode.b",
"com.shopee.bke.lib.safemode.catchs.a",
"com.shopee.bke.lib.safemode.util.c",
"com.shopee.bke.biz.base.risk.a",
};
private static final String[] ERROR_FLOW_CLASSES = {
"com.shopee.bke.biz.user.errorcodehandler.a",
"com.shopee.bke.biz.user.errorcodehandler.b",
"com.shopee.bke.biz.user.rn.helper.ErrorFlowHelper",
"com.shopee.bke.biz.user.viewmodel.RegisterViewModel",
};
private static String[] booleanHookTargets(XC_LoadPackage.LoadPackageParam lpparam) {
String[] ph = MariBankRegionProfile.safeModeBooleanClasses(lpparam.packageName);
String[] shared = MariBankRegionProfile.sharedRiskClasses();
String[] out = new String[ph.length + shared.length];
System.arraycopy(ph, 0, out, 0, ph.length);
System.arraycopy(shared, 0, out, ph.length, shared.length);
return out;
}
private MariBankRootBypassHook() {
}
private static volatile boolean deferredHooksInstalled = false;
private static volatile boolean lateHooksComplete = false;
private static volatile boolean lateHooksCoreInstalled = false;
private static volatile boolean lateHandlerRetriesDone = false;
private static volatile int lateHookAttempts = 0;
private static final int MAX_LATE_HOOK_ATTEMPTS = 10;
private static final Set<String> HOOKED_SAFE_MODE_CLASSES = new HashSet<>();
public static void install(XC_LoadPackage.LoadPackageParam lpparam) {
if (!PACKAGE.equals(lpparam.packageName)) {
if (!isTargetPackage(lpparam.packageName)) {
return;
}
XposedBridge.log(TAG + " install for " + lpparam.packageName
+ " region=" + MariBankRegionProfile.label(lpparam.packageName));
hookAntiSuicide(lpparam);
hookAntiSoftCrash(lpparam);
scheduleAppHooks(lpparam);
@@ -85,6 +118,13 @@ public final class MariBankRootBypassHook {
MariBankShpsNativeHook.installDeferred(lpparam);
}
};
XC_MethodHook afterOnCreate = new XC_MethodHook() {
@Override
protected void afterHookedMethod(MethodHookParam param) {
MariBankShpsNativeHook.installLateNativeHooks(lpparam);
scheduleLateAppHooks(lpparam);
}
};
try {
XposedHelpers.findAndHookMethod(
"com.shopee.bke.digitalbank.BkeApplication",
@@ -92,11 +132,163 @@ public final class MariBankRootBypassHook {
"attachBaseContext",
"android.content.Context",
afterAttach);
XposedBridge.log(TAG + " waiting attachBaseContext for app hooks");
XposedHelpers.findAndHookMethod(
"com.shopee.bke.digitalbank.BkeApplication",
lpparam.classLoader,
"onCreate",
afterOnCreate);
hookMainActivityForLateHooks(lpparam);
XposedBridge.log(TAG + " waiting attachBaseContext + onCreate for app hooks");
} catch (Throwable t) {
XposedBridge.log(TAG + " attachBaseContext hook failed, install now: " + t.getMessage());
XposedBridge.log(TAG + " BkeApplication hook failed, install now: " + t.getMessage());
installDeferredHooks(lpparam);
MariBankShpsNativeHook.installDeferred(lpparam);
MariBankShpsNativeHook.installLateNativeHooks(lpparam);
scheduleLateAppHooks(lpparam);
}
}
/** SG split-dex 在 onCreate 时类可能未加载MainActivity 再试。 */
private static void hookMainActivityForLateHooks(XC_LoadPackage.LoadPackageParam lpparam) {
try {
XposedHelpers.findAndHookMethod(
"com.shopee.bke.digitalbank.ui.MainActivity",
lpparam.classLoader,
"onResume",
new XC_MethodHook() {
@Override
protected void afterHookedMethod(MethodHookParam param) {
scheduleLateAppHooks(lpparam);
}
}
);
} catch (Throwable t) {
XposedBridge.log(TAG + " skip MainActivity.onResume retry: " + t.getMessage());
}
}
private static void scheduleLateAppHooks(XC_LoadPackage.LoadPackageParam lpparam) {
tryInstallLateAppHooks(lpparam, false);
}
/** onCreate 之后补装SG 3.2.2 split dex 常需延迟重试。 */
private static void tryInstallLateAppHooks(XC_LoadPackage.LoadPackageParam lpparam) {
tryInstallLateAppHooks(lpparam, false);
}
private static void tryInstallLateAppHooks(
XC_LoadPackage.LoadPackageParam lpparam, boolean fromSplitDex) {
if (lateHooksComplete) {
return;
}
if (!lateHooksCoreInstalled) {
lateHooksCoreInstalled = true;
hookShpsRisk(lpparam);
hookShpsToken(lpparam);
hookErrorFlowLogging(lpparam);
MariBankFullCaptureHook.installLate(lpparam);
}
int booleanHooks = 0;
for (String className : booleanHookTargets(lpparam)) {
booleanHooks += hookAllBooleanChecks(lpparam, className);
}
int encryptHooks = MariBankSdkUtilsHook.installLate(lpparam);
int attestHooks = MariBankAttestationHook.installLate(lpparam);
MariBankDfpHook.installLate(lpparam);
MariBankDfpHook.hookDfpEmptyGuards(lpparam);
if (MariBankRegionProfile.isLateHooksReady(
lpparam.packageName, booleanHooks, encryptHooks, attestHooks)) {
lateHooksComplete = true;
XposedBridge.log(TAG + " late app hooks ready region="
+ MariBankRegionProfile.label(lpparam.packageName)
+ " booleanHooks=" + booleanHooks
+ " encryptHooks=" + encryptHooks + " attestHooks=" + attestHooks
+ " attempts=" + lateHookAttempts
+ (fromSplitDex ? " viaSplitDex" : ""));
return;
}
if (fromSplitDex) {
XposedBridge.log(TAG + " split-dex class loaded, safemode still pending encryptHooks="
+ encryptHooks + " attestHooks=" + attestHooks);
return;
}
if (lateHandlerRetriesDone) {
return;
}
lateHookAttempts++;
if (lateHookAttempts >= MAX_LATE_HOOK_ATTEMPTS) {
lateHandlerRetriesDone = true;
XposedBridge.log(TAG + " late handler retries done (split-dex may load later) attempts="
+ lateHookAttempts + " encryptHooks=" + encryptHooks
+ " attestHooks=" + attestHooks);
return;
}
long delay = Math.min(400L * lateHookAttempts, 2500L);
XposedBridge.log(TAG + " late hooks retry #" + lateHookAttempts + " in " + delay + "ms");
new Handler(Looper.getMainLooper()).postDelayed(
() -> tryInstallLateAppHooks(lpparam, false), delay);
}
private static boolean isLateHookTargetClass(String name, String packageName) {
if (name == null) {
return false;
}
for (String cn : MariBankRegionProfile.safeModeBooleanClasses(packageName)) {
if (cn.equals(name)) {
return true;
}
}
for (String cn : MariBankRegionProfile.sharedRiskClasses()) {
if (cn.equals(name)) {
return true;
}
}
for (String prefix : ERROR_FLOW_CLASSES) {
if (name.equals(prefix) || name.startsWith(prefix.substring(0, prefix.lastIndexOf('.') + 1))) {
return true;
}
}
return name.startsWith("com.shopee.bke.lib.safemode.")
|| name.equals("com.shopee.bke.lib.jni.utils.uvwuvwuv")
|| name.equals("com.shopee.bke.lib.jni.utils.uvwwwwuv")
|| (MariBankRegionProfile.hasEncryptWrapperD(packageName)
&& name.equals("com.shopee.bke.lib.jni.utils.d"))
|| name.startsWith("com.shopee.shpssdkbank.")
|| (!MariBankRegionProfile.isSingapore(packageName)
&& name.startsWith("com.shopee.shpssdk."));
}
/** SG split-dexsafemode / utils.d 延迟加载时在 loadClass 补装 Hook。 */
private static void hookClassLoaderSplitDex(XC_LoadPackage.LoadPackageParam lpparam) {
try {
XposedHelpers.findAndHookMethod(
ClassLoader.class,
"loadClass",
String.class,
boolean.class,
new XC_MethodHook() {
@Override
protected void afterHookedMethod(MethodHookParam param) {
if (param.getThrowable() != null) {
return;
}
String name = (String) param.args[0];
if (isLateHookTargetClass(name, lpparam.packageName)) {
tryInstallLateAppHooks(lpparam, true);
}
}
}
);
XposedBridge.log(TAG + " ClassLoader.loadClass split-dex watcher OK");
} catch (Throwable t) {
XposedBridge.log(TAG + " skip ClassLoader.loadClass watcher: " + t.getMessage());
}
}
@@ -106,23 +298,18 @@ public final class MariBankRootBypassHook {
}
deferredHooksInstalled = true;
int hooked = 0;
for (String className : SAFE_MODE_CLASSES) {
hooked += hookAllBooleanChecks(lpparam, className);
}
hookSafeModeDialog(lpparam);
hookRootDialogBlock(lpparam);
hookErrorFlowLogging(lpparam);
hooked += hookShpsRisk(lpparam);
hookShpsToken(lpparam);
hookAdbBypass(lpparam);
hookNetworkLogging(lpparam);
hookClassLoaderSplitDex(lpparam);
XposedBridge.log(TAG + " app hooks installed, booleanHooks=" + hooked);
XposedBridge.log(TAG + " early app hooks installed");
}
/** SafeMode 类方法名被混淆Hook 所有返回 boolean/int 的实例方法。 */
private static int hookAllBooleanChecks(XC_LoadPackage.LoadPackageParam lpparam, String className) {
if (HOOKED_SAFE_MODE_CLASSES.contains(className)) {
return 0;
}
int count = 0;
try {
Class<?> clazz = XposedHelpers.findClass(className, lpparam.classLoader);
@@ -151,6 +338,7 @@ public final class MariBankRootBypassHook {
count++;
}
if (count > 0) {
HOOKED_SAFE_MODE_CLASSES.add(className);
XposedBridge.log(TAG + " hooked " + count + " checks in " + className);
}
} catch (Throwable t) {
@@ -416,7 +604,9 @@ public final class MariBankRootBypassHook {
}
String args = Arrays.toString(param.args);
if (args.contains("1201") || args.contains("-1201")
|| args.toLowerCase().contains("error")) {
|| args.contains("406")
|| args.toLowerCase().contains("error")
|| args.toLowerCase().contains("unavailable")) {
XposedBridge.log(TAG + " " + className + "."
+ method.getName() + " args=" + args);
}
@@ -432,11 +622,7 @@ public final class MariBankRootBypassHook {
/** SHPSSDK 风控:仅 Hook 返回 boolean 的实例方法。 */
private static int hookShpsRisk(XC_LoadPackage.LoadPackageParam lpparam) {
int count = 0;
String[] riskClasses = {
"com.shopee.shpssdkbank.SPSAssessRisk",
"com.shopee.shpssdk.SPSAssessRisk",
};
for (String className : riskClasses) {
for (String className : MariBankRegionProfile.shpsAssessRiskClasses(lpparam.packageName)) {
try {
Class<?> clazz = XposedHelpers.findClass(className, lpparam.classLoader);
for (Method method : clazz.getDeclaredMethods()) {
@@ -471,11 +657,7 @@ public final class MariBankRootBypassHook {
*/
private static void hookShpsToken(XC_LoadPackage.LoadPackageParam lpparam) {
final String contextClass = "android.content.Context";
String[] shpsSdkClasses = {
"com.shopee.shpssdk.SHPSSDK",
"com.shopee.shpssdkbank.SHPSSDK",
};
for (String className : shpsSdkClasses) {
for (String className : MariBankRegionProfile.shpsSdkClasses(lpparam.packageName)) {
hookEmptyRiskList(lpparam, className, "getRiskSync", contextClass);
hookEmptyRiskList(lpparam, className, "getExtRiskSync", contextClass);
hookRiskAsyncCallback(lpparam, className, "getRiskAsync", contextClass,
@@ -553,11 +735,7 @@ public final class MariBankRootBypassHook {
} catch (Throwable ignored) {
}
String[] assessRiskClasses = {
"com.shopee.shpssdkbank.SPSAssessRisk",
"com.shopee.shpssdk.SPSAssessRisk",
};
for (String className : assessRiskClasses) {
for (String className : MariBankRegionProfile.shpsAssessRiskClasses(lpparam.packageName)) {
try {
XposedHelpers.findAndHookMethod(
className,
@@ -574,11 +752,7 @@ public final class MariBankRootBypassHook {
}
}
String[] callbackAdapters = {
"com.shopee.shpssdk.SPSCallbackAdapter",
"com.shopee.shpssdkbank.SPSCallbackAdapter",
};
for (String className : callbackAdapters) {
for (String className : MariBankRegionProfile.shpsCallbackAdapterClasses(lpparam.packageName)) {
try {
XposedHelpers.findAndHookMethod(
className,
@@ -903,12 +1077,17 @@ public final class MariBankRootBypassHook {
body = sanitized;
}
String lower = body.toLowerCase();
if (lower.contains("blocked")
String url = CURRENT_REQUEST_URL.get();
boolean maribankApi = url != null
&& (url.contains("maribank.com") || url.contains("seabank.ph"));
boolean interesting = lower.contains("blocked")
|| body.contains(String.valueOf(ERROR_CODE_SECURITY_BLOCKED))
|| body.contains(String.valueOf(ERROR_CODE_SECURITY_BLOCKED_ALT))
|| lower.contains("risktoken")
|| lower.contains("\"code\"")) {
String url = CURRENT_REQUEST_URL.get();
|| lower.contains("\"code\"")
|| (maribankApi && (lower.contains("register")
|| lower.contains("error") || lower.contains("unavailable")));
if (interesting) {
String snippet = body.length() > 600
? body.substring(0, 600) + "..." : body;
XposedBridge.log(TAG + " HTTP"
@@ -1035,6 +1214,20 @@ public final class MariBankRootBypassHook {
if (sanitized != request) {
XposedHelpers.setObjectField(param.thisObject, "originalRequest", sanitized);
}
try {
Object req = XposedHelpers.getObjectField(param.thisObject, "originalRequest");
if (req != null) {
Object url = XposedHelpers.callMethod(req, "url");
if (url != null) {
String urlStr = String.valueOf(url);
if (urlStr.contains("maribank.com") || urlStr.contains("seabank.ph")
|| urlStr.contains("/register")) {
XposedBridge.log(TAG + " outbound " + urlStr);
}
}
}
} catch (Throwable ignored) {
}
} catch (Throwable ignored) {
}
}
@@ -1218,8 +1411,11 @@ public final class MariBankRootBypassHook {
XposedBridge.invokeOriginalMethod(
param.method, param.thisObject, new Object[]{buffer});
String content = (String) XposedHelpers.callMethod(buffer, "readUtf8");
String sanitized = MariBankRiskTokenUtil.sanitizeAllInText(content);
String url = CURRENT_REQUEST_URL.get();
if (!content.isEmpty() && url != null && MariBankCaptureUtil.isBankUrl(url)) {
MariBankCaptureUtil.logText("reqBody wire " + url, content);
}
String sanitized = MariBankRiskTokenUtil.sanitizeAllInText(content);
if (url != null && url.contains("/register") && !content.isEmpty()) {
int show = Math.min(content.length(), 400);
XposedBridge.log(TAG + " register body(raw): "
@@ -1516,6 +1712,152 @@ public final class MariBankRootBypassHook {
}
}
/** SGADB / 无线调试检测 + Root 弹窗/Toast/全屏页拦截。 */
private static void hookAdbBypass(XC_LoadPackage.LoadPackageParam lpparam) {
hookAdbSettings(lpparam);
hookAdbActivityEscape(lpparam);
}
private static void hookAdbSettings(XC_LoadPackage.LoadPackageParam lpparam) {
XC_MethodHook fakeDisabled = new XC_MethodHook() {
@Override
protected void beforeHookedMethod(MethodHookParam param) {
if (param.args.length < 2 || !(param.args[1] instanceof String)) {
return;
}
String key = (String) param.args[1];
if (!isAdbSettingKey(key)) {
return;
}
Class<?> ret = ((Method) param.method).getReturnType();
if (ret == int.class || ret == Integer.class) {
param.setResult(0);
} else if (ret == long.class || ret == Long.class) {
param.setResult(0L);
} else if (ret == String.class) {
param.setResult("0");
}
XposedBridge.log(TAG + " faked Settings key=" + key);
}
};
String[][] targets = {
{"android.provider.Settings$Global", "getInt"},
{"android.provider.Settings$Global", "getLong"},
{"android.provider.Settings$Global", "getString"},
{"android.provider.Settings$Secure", "getInt"},
{"android.provider.Settings$Secure", "getString"},
{"android.provider.Settings$System", "getInt"},
};
for (String[] target : targets) {
try {
Class<?> clazz = XposedHelpers.findClass(target[0], lpparam.classLoader);
for (Method method : clazz.getDeclaredMethods()) {
if (!target[1].equals(method.getName())) {
continue;
}
Class<?>[] params = method.getParameterTypes();
if (params.length >= 2 && ContentResolver.class.isAssignableFrom(params[0])
&& params[1] == String.class) {
XposedBridge.hookMethod(method, fakeDisabled);
}
}
// getInt(cr, key, def) — 部分 SDK 走三参数重载
if ("getInt".equals(target[1])) {
try {
XposedHelpers.findAndHookMethod(
clazz,
"getInt",
ContentResolver.class,
String.class,
int.class,
fakeDisabled);
} catch (Throwable ignored) {
}
}
} catch (Throwable t) {
XposedBridge.log(TAG + " skip Settings hook " + target[0] + ": " + t.getMessage());
}
}
}
/** 若全屏/RN 页已展示 ADB 拦截文案,直接 finish 退出该 Activity。 */
private static void hookAdbActivityEscape(XC_LoadPackage.LoadPackageParam lpparam) {
try {
XposedHelpers.findAndHookMethod(
Activity.class,
"onResume",
new XC_MethodHook() {
@Override
protected void afterHookedMethod(MethodHookParam param) {
Activity activity = (Activity) param.thisObject;
if (activity == null || activity.isFinishing()) {
return;
}
String name = activity.getClass().getName();
if (name.contains("SafeModeRecoverActivity")) {
return;
}
String text = extractActivityText(activity);
if (isAdbBlockText(text)) {
XposedBridge.log(TAG + " finish adb block activity: " + name);
activity.finish();
}
}
}
);
} catch (Throwable t) {
XposedBridge.log(TAG + " adb activity hook failed: " + t.getMessage());
}
}
private static String extractActivityText(Activity activity) {
StringBuilder sb = new StringBuilder();
try {
CharSequence title = activity.getTitle();
if (title != null) {
sb.append(title);
}
} catch (Throwable ignored) {
}
try {
if (activity.getWindow() != null) {
collectTextViews(activity.getWindow().getDecorView(), sb);
}
} catch (Throwable ignored) {
}
return sb.toString();
}
private static boolean isAdbSettingKey(String key) {
if (key == null) {
return false;
}
String lower = key.toLowerCase();
return lower.contains("adb")
|| "development_settings_enabled".equals(lower)
|| lower.contains("wireless_debug");
}
private static boolean isAdbBlockText(String text) {
if (text == null || text.isEmpty()) {
return false;
}
String lower = text.toLowerCase();
return lower.contains("adb/wireless adb")
|| lower.contains("wireless adb detected")
|| lower.contains("usb debugging")
|| lower.contains("wireless debugging")
|| lower.contains("turn off adb")
|| lower.contains("third parties to access")
|| lower.contains("safeguard your banking")
|| (lower.contains("adb") && lower.contains("detect"));
}
private static boolean isEnvironmentBlockText(String text) {
return isRootBlockText(text) || isAdbBlockText(text);
}
private static void hookSafeModeDialog(XC_LoadPackage.LoadPackageParam lpparam) {
try {
XposedHelpers.findAndHookMethod(
@@ -1540,9 +1882,9 @@ public final class MariBankRootBypassHook {
XC_MethodHook blankRootTextHook = new XC_MethodHook() {
@Override
protected void beforeHookedMethod(MethodHookParam param) {
if (param.args.length > 0 && isRootBlockText(String.valueOf(param.args[0]))) {
if (param.args.length > 0 && isEnvironmentBlockText(String.valueOf(param.args[0]))) {
param.args[0] = " ";
XposedBridge.log(TAG + " blanked root dialog message");
XposedBridge.log(TAG + " blanked env block dialog message");
}
}
};
@@ -1574,9 +1916,9 @@ public final class MariBankRootBypassHook {
@Override
protected void afterHookedMethod(MethodHookParam param) {
String s = (String) param.getResult();
if (isRootBlockText(s)) {
if (isEnvironmentBlockText(s)) {
param.setResult(" ");
XposedBridge.log(TAG + " blanked root string resource");
XposedBridge.log(TAG + " blanked env block string resource");
}
}
}
@@ -1593,8 +1935,8 @@ public final class MariBankRootBypassHook {
@Override
protected void beforeHookedMethod(MethodHookParam param) {
Dialog dialog = (Dialog) param.thisObject;
if (isRootBlockText(extractDialogText(dialog))) {
XposedBridge.log(TAG + " blocked root Dialog.show: "
if (isEnvironmentBlockText(extractDialogText(dialog))) {
XposedBridge.log(TAG + " blocked env Dialog.show: "
+ dialog.getClass().getSimpleName());
param.setResult(null);
}
@@ -1620,6 +1962,9 @@ public final class MariBankRootBypassHook {
if (isRootBlockText(text)) {
XposedBridge.log(TAG + " blocked root Toast.show");
param.setResult(null);
} else if (isAdbBlockText(text)) {
XposedBridge.log(TAG + " blocked adb Toast.show");
param.setResult(null);
}
}
}
@@ -1638,7 +1983,9 @@ public final class MariBankRootBypassHook {
|| lower.contains("modified device")
|| lower.contains("magisk/xposed/frida")
|| lower.contains("cannot be accessed on such devices")
|| lower.contains("restore to factory settings");
|| lower.contains("restore to factory settings")
|| lower.contains("does not support root")
|| lower.contains("support root device");
}
private static boolean isSecurityBlockText(String text) {

View File

@@ -0,0 +1,313 @@
package com.miraclegarden.smsmessage.xposed.hook;
import java.lang.reflect.Method;
import java.lang.reflect.Modifier;
import java.nio.charset.StandardCharsets;
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;
/**
* register body 加密前 Hook{@code com.shopee.bke.lib.jni.utils.d} / {@code uvwuvwuv}。
* 勿 Hook {@code utils.f}SoUtils否则会 libsdkutils 白屏。
*/
public final class MariBankSdkUtilsHook {
private static final String TAG = "notiMessageHook/MariBankEncrypt";
private static final String WRAPPER = "com.shopee.bke.lib.jni.utils.d";
private static final String NATIVE_ENCRYPT = "com.shopee.bke.lib.jni.utils.uvwuvwuv";
private static final String SG_ENCRYPT_HELPER = "com.shopee.bke.lib.jni.utils.uvwwwwuv";
private static volatile boolean encryptHooksInstalled = false;
private static volatile boolean gsonHookInstalled = false;
private static volatile boolean encryptLoadClassWatcherInstalled = false;
private static volatile String installedForPackage;
private MariBankSdkUtilsHook() {
}
/** BkeApplication.onCreate 之后安装libsdkutils 已加载)。 */
public static int installLate(XC_LoadPackage.LoadPackageParam lpparam) {
installEncryptLoadClassWatcher(lpparam);
if (encryptHooksInstalled && lpparam.packageName.equals(installedForPackage)) {
return 0;
}
int n = 0;
if (MariBankRegionProfile.hasEncryptWrapperD(lpparam.packageName)) {
n += hookEncryptWrapper(lpparam);
}
n += hookNativeEncryptUtils(lpparam);
if (MariBankRegionProfile.hasEncryptHelperUvwwwwuv(lpparam.packageName)) {
n += hookEncryptHelperClass(lpparam, SG_ENCRYPT_HELPER, "uvwwwwuv");
}
if (n > 0) {
encryptHooksInstalled = true;
installedForPackage = lpparam.packageName;
}
if (!gsonHookInstalled) {
int g = hookGsonRegister(lpparam);
if (g > 0) {
gsonHookInstalled = true;
n += g;
}
}
if (n > 0) {
XposedBridge.log(TAG + " late encrypt hooks=" + n + " region="
+ MariBankRegionProfile.label(lpparam.packageName));
}
return n;
}
private static void installEncryptLoadClassWatcher(XC_LoadPackage.LoadPackageParam lpparam) {
if (encryptLoadClassWatcherInstalled) {
return;
}
encryptLoadClassWatcherInstalled = true;
final String pkg = lpparam.packageName;
try {
XposedHelpers.findAndHookMethod(
ClassLoader.class,
"loadClass",
String.class,
boolean.class,
new XC_MethodHook() {
@Override
protected void afterHookedMethod(MethodHookParam param) {
if (param.getThrowable() != null) {
return;
}
String name = (String) param.args[0];
if (NATIVE_ENCRYPT.equals(name)
|| SG_ENCRYPT_HELPER.equals(name)
|| (MariBankRegionProfile.hasEncryptWrapperD(pkg)
&& WRAPPER.equals(name))) {
installLate(lpparam);
}
}
});
XposedBridge.log(TAG + " ClassLoader encrypt watcher OK");
} catch (Throwable t) {
XposedBridge.log(TAG + " skip encrypt ClassLoader watcher: " + t.getMessage());
}
}
private static int hookEncryptHelperClass(
XC_LoadPackage.LoadPackageParam lpparam, String className, String logLabel) {
int count = 0;
try {
Class<?> clazz = XposedHelpers.findClass(className, lpparam.classLoader);
for (Method method : clazz.getDeclaredMethods()) {
if (Modifier.isStatic(method.getModifiers())) {
continue;
}
XposedBridge.hookMethod(method, new XC_MethodHook() {
@Override
protected void beforeHookedMethod(MethodHookParam param) {
XposedBridge.log(TAG + " >> " + logLabel + "." + method.getName());
for (int i = 0; i < param.args.length; i++) {
Object sanitized = sanitizeArg(param.args[i]);
if (sanitized != param.args[i]) {
param.args[i] = sanitized;
}
logArg(" in" + i, param.args[i]);
}
}
@Override
protected void afterHookedMethod(MethodHookParam param) {
Object result = param.getResult();
if (result instanceof byte[]) {
logArg(" out", result);
} else if (result instanceof String) {
logArg(" out", result);
}
}
});
count++;
}
if (count > 0) {
XposedBridge.log(TAG + " hooked " + logLabel + " methods=" + count);
}
} catch (Throwable t) {
XposedBridge.log(TAG + " skip " + logLabel + ": " + t.getMessage());
}
return count;
}
private static int hookEncryptWrapper(XC_LoadPackage.LoadPackageParam lpparam) {
int count = 0;
try {
Class<?> clazz = XposedHelpers.findClass(WRAPPER, lpparam.classLoader);
for (Method method : clazz.getDeclaredMethods()) {
if (Modifier.isStatic(method.getModifiers())) {
continue;
}
XposedBridge.hookMethod(method, new XC_MethodHook() {
@Override
protected void beforeHookedMethod(MethodHookParam param) {
String name = method.getName();
XposedBridge.log(TAG + " >> utils.d." + name);
for (int i = 0; i < param.args.length; i++) {
Object sanitized = sanitizeArg(param.args[i]);
if (sanitized != param.args[i]) {
param.args[i] = sanitized;
}
logArg(" in" + i, param.args[i]);
}
}
@Override
protected void afterHookedMethod(MethodHookParam param) {
Object result = param.getResult();
if (result == null) {
return;
}
if (result instanceof String[]) {
for (int i = 0; i < ((String[]) result).length; i++) {
logArg(" out" + i, ((String[]) result)[i]);
}
} else if (result instanceof byte[]) {
logArg(" out", result);
}
}
});
count++;
}
if (count > 0) {
XposedBridge.log(TAG + " hooked utils.d methods=" + count);
}
} catch (Throwable t) {
XposedBridge.log(TAG + " skip utils.d: " + t.getMessage());
}
return count;
}
private static int hookNativeEncryptUtils(XC_LoadPackage.LoadPackageParam lpparam) {
int count = 0;
try {
Class<?> clazz = XposedHelpers.findClass(NATIVE_ENCRYPT, lpparam.classLoader);
for (Method method : clazz.getDeclaredMethods()) {
if (!Modifier.isStatic(method.getModifiers())) {
continue;
}
XposedBridge.hookMethod(method, new XC_MethodHook() {
@Override
protected void beforeHookedMethod(MethodHookParam param) {
XposedBridge.log(TAG + " >> uvwuvwuv." + method.getName());
for (int i = 0; i < param.args.length; i++) {
Object sanitized = sanitizeArg(param.args[i]);
if (sanitized != param.args[i]) {
param.args[i] = sanitized;
}
logArg(" in" + i, param.args[i]);
}
}
});
count++;
}
if (count > 0) {
XposedBridge.log(TAG + " hooked uvwuvwuv methods=" + count);
}
} catch (Throwable t) {
XposedBridge.log(TAG + " skip uvwuvwuv: " + t.getMessage());
}
return count;
}
private static int hookGsonRegister(XC_LoadPackage.LoadPackageParam lpparam) {
try {
XposedHelpers.findAndHookMethod(
"com.google.gson.Gson",
lpparam.classLoader,
"toJson",
Object.class,
new XC_MethodHook() {
@Override
protected void afterHookedMethod(MethodHookParam param) {
if (!(param.getResult() instanceof String)) {
return;
}
String json = (String) param.getResult();
if (!looksLikeRegisterJson(json)) {
return;
}
String sanitized = MariBankRegisterPayloadUtil.isRegistrationPayload(json)
? MariBankRegisterPayloadUtil.sanitizeRegistrationJson(json)
: MariBankRiskTokenUtil.sanitizeAllInText(json);
if (!sanitized.equals(json)) {
param.setResult(sanitized);
json = sanitized;
}
if (MariBankRegisterPayloadUtil.isRegistrationPayload(json)) {
MariBankCaptureUtil.logText("Gson REGISTRATION plaintext", json);
}
}
}
);
return 1;
} catch (Throwable t) {
XposedBridge.log(TAG + " skip Gson.toJson: " + t.getMessage());
return 0;
}
}
private static boolean looksLikeRegisterJson(String text) {
if (text == null || text.length() < 8) {
return false;
}
if (MariBankRegisterPayloadUtil.isRegistrationPayload(text)) {
return true;
}
String lower = text.toLowerCase();
return lower.contains("rdverifyinfo")
|| lower.contains("devicefingerprint")
|| (lower.contains("encphone") && lower.contains("scene"));
}
private static Object sanitizeArg(Object arg) {
if (arg instanceof String) {
String s = (String) arg;
if (!s.contains("|") && !looksLikeRegisterJson(s)) {
return arg;
}
String out = MariBankRegisterPayloadUtil.isRegistrationPayload(s)
? MariBankRegisterPayloadUtil.sanitizeRegistrationJson(s)
: MariBankRiskTokenUtil.sanitizeAllInText(s);
return out.equals(s) ? arg : out;
}
if (arg instanceof byte[]) {
byte[] bytes = (byte[]) arg;
byte[] out = MariBankRegisterPayloadUtil.sanitizeRegistrationBytes(bytes);
return out == bytes ? arg : out;
}
return arg;
}
private static void logArg(String label, Object arg) {
if (arg == null) {
XposedBridge.log(TAG + label + " null");
return;
}
if (arg instanceof String) {
String s = (String) arg;
if (s.length() > 4 || s.contains("|") || looksLikeRegisterJson(s)) {
MariBankCaptureUtil.logText(label, s);
}
return;
}
if (arg instanceof byte[]) {
byte[] bytes = (byte[]) arg;
String text;
try {
text = new String(bytes, StandardCharsets.UTF_8);
} catch (Throwable t) {
text = "<bin>";
}
if (text.contains("|") || looksLikeRegisterJson(text) || bytes.length < 8192) {
MariBankCaptureUtil.logBytes(label, bytes);
}
}
}
}

View File

@@ -72,6 +72,17 @@ public final class MariBankShpsNativeHook {
}
private static volatile boolean deferredInstalled = false;
private static volatile boolean nativeBridgeInstalled = false;
private static volatile boolean nativeLoadClassWatcherInstalled = false;
private static final Set<String> HOOKED_NATIVE_CORE_CLASSES = new HashSet<>();
/**
* 勿 Hook负责 SoUtils.loadSoLibrary / libshpssdk_bank.so 加载Hook 会导致 SO 找不到。
*/
private static final Set<String> NATIVE_BRIDGE_EXCLUDED = new HashSet<>(Arrays.asList(
"com.shopee.shpssdkbank.vuvuwwwuw",
"com.shopee.shpssdkbank.vwuuwwvwv"
));
/** loadPackage 阶段只装 /proc 过滤,避免过早触发 SHPSSDK / libsdkutils 死循环白屏。 */
public static void install(XC_LoadPackage.LoadPackageParam lpparam) {
@@ -82,21 +93,60 @@ public final class MariBankShpsNativeHook {
XposedBridge.log(TAG + " early hooks OK (proc only)");
}
/** attachBaseContext 之后安装 SHPSSDK 相关 HookClassLoader 已就绪)。 */
/** attachBaseContext 之后:仅装不干扰 SO/RN 加载的 Hook。 */
public static void installDeferred(XC_LoadPackage.LoadPackageParam lpparam) {
if (deferredInstalled) {
return;
}
deferredInstalled = true;
hookLoadLibrary(lpparam);
hookShpssInstall(lpparam);
hookRequestDefense(lpparam);
hookShpsNativeBridge(lpparam);
hookShpsNativeCore(lpparam);
hookBuildFields(lpparam);
XposedBridge.log(TAG + " deferred hooks installed for " + lpparam.packageName);
}
/** BkeApplication.onCreate 之后RN / shpssdk SO 已加载,再装 native 桥接 Hook。 */
public static void installLateNativeHooks(XC_LoadPackage.LoadPackageParam lpparam) {
if (!nativeBridgeInstalled) {
nativeBridgeInstalled = true;
hookShpsNativeBridge(lpparam);
installNativeLoadClassWatcher(lpparam);
}
hookShpsNativeCore(lpparam);
}
private static void installNativeLoadClassWatcher(XC_LoadPackage.LoadPackageParam lpparam) {
if (nativeLoadClassWatcherInstalled) {
return;
}
nativeLoadClassWatcherInstalled = true;
try {
XposedHelpers.findAndHookMethod(
ClassLoader.class,
"loadClass",
String.class,
boolean.class,
new XC_MethodHook() {
@Override
protected void afterHookedMethod(MethodHookParam param) {
if (param.getThrowable() != null) {
return;
}
String name = (String) param.args[0];
if (name != null && name.contains("shpssdk")) {
if (MariBankRegionProfile.isSingapore(lpparam.packageName)
&& MariBankRegionProfile.isShpssdkLegacyPackage(name)) {
return;
}
hookShpsNativeCore(lpparam);
}
}
});
XposedBridge.log(TAG + " ClassLoader native-core watcher OK");
} catch (Throwable t) {
XposedBridge.log(TAG + " skip native ClassLoader watcher: " + t.getMessage());
}
}
/** native 直接读 /proc/self/maps 查 hook 库;过滤内容。 */
private static void hookProcAccess(XC_LoadPackage.LoadPackageParam lpparam) {
try {
@@ -233,37 +283,9 @@ public final class MariBankShpsNativeHook {
return "";
}
private static void hookLoadLibrary(XC_LoadPackage.LoadPackageParam lpparam) {
XC_MethodHook logHook = new XC_MethodHook() {
@Override
protected void beforeHookedMethod(MethodHookParam param) {
String lib = String.valueOf(param.args[param.args.length - 1]);
if (lib.contains("shpssdk")) {
XposedBridge.log(TAG + " loading native lib: " + lib);
}
}
};
try {
XposedHelpers.findAndHookMethod(
Runtime.class, "loadLibrary0", ClassLoader.class, String.class, logHook);
} catch (Throwable ignored) {
}
try {
XposedHelpers.findAndHookMethod(
System.class, "loadLibrary", String.class, logHook);
} catch (Throwable ignored) {
}
}
private static void hookShpssInstall(XC_LoadPackage.LoadPackageParam lpparam) {
// 勿 Hook ShpssInstall / vuvuwwwuw会干扰 SoUtils.loadSoLibrary导致 libsdkutils.so 死循环白屏。
}
/** 保留 requestDefense 执行(生成 x-sap-fixme仅净化返回值中的 risk 字段。 */
private static void hookRequestDefense(XC_LoadPackage.LoadPackageParam lpparam) {
for (String className : new String[]{
"com.shopee.shpssdkbank.SHPSSDK",
"com.shopee.shpssdk.SHPSSDK",
}) {
for (String className : MariBankRegionProfile.shpsSdkClasses(lpparam.packageName)) {
try {
XposedHelpers.findAndHookMethod(
className,
@@ -271,9 +293,26 @@ public final class MariBankShpsNativeHook {
"requestDefense",
new XC_MethodHook() {
@Override
protected void beforeHookedMethod(MethodHookParam param) {
XposedBridge.log(TAG + " blocked requestDefense");
param.setResult(null);
protected void afterHookedMethod(MethodHookParam param) {
Object result = param.getResult();
if (result instanceof String) {
String s = (String) result;
if (s.contains("|")) {
String sanitized = MariBankRiskTokenUtil.sanitizeRiskToken(s);
if (!sanitized.equals(s)) {
param.setResult(sanitized);
XposedBridge.log(TAG + " requestDefense String sanitized");
}
}
} else if (result instanceof byte[]) {
byte[] bytes = (byte[]) result;
byte[] out = MariBankRiskTokenUtil.sanitizeBytes(
bytes, 0, bytes.length);
if (out != bytes) {
param.setResult(out);
XposedBridge.log(TAG + " requestDefense byte[] sanitized");
}
}
}
}
);
@@ -304,6 +343,10 @@ public final class MariBankShpsNativeHook {
};
int total = 0;
for (String className : classes) {
if (NATIVE_BRIDGE_EXCLUDED.contains(className)) {
XposedBridge.log(TAG + " skip native-bridge (so loader): " + className);
continue;
}
total += hookAllIntBooleanMethods(lpparam, className);
total += hookAllStringSanitize(lpparam, className);
}
@@ -360,6 +403,10 @@ public final class MariBankShpsNativeHook {
&& rt != int.class && rt != Integer.class) {
continue;
}
if (Modifier.isNative(method.getModifiers()) && rt != boolean.class
&& rt != Boolean.class && rt != int.class && rt != Integer.class) {
continue;
}
if (method.getParameterTypes().length > 4) {
continue;
}
@@ -387,21 +434,19 @@ public final class MariBankShpsNativeHook {
* SHPSSDK 核心 native 桥wvvvuwwu.wwvwvwuvv / vvuwuuvuu 等直接生成 risk 数据。
*/
private static void hookShpsNativeCore(XC_LoadPackage.LoadPackageParam lpparam) {
String[] coreClasses = {
"com.shopee.shpssdkbank.wvvvuwwu",
"com.shopee.shpssdkbank.uwuvuvvww.uvwuuuuuw.vvvvuwwvu",
"com.shopee.shpssdkbank.uwuvuvvww.wvvuuwvwu",
"com.shopee.shpssdk.wvvvuwwu",
};
int total = 0;
for (String className : coreClasses) {
for (String className : MariBankRegionProfile.shpsAttestationCoreClasses(lpparam.packageName)) {
total += hookNativeCoreClass(lpparam, className);
}
XposedBridge.log(TAG + " native-core total hooks=" + total);
XposedBridge.log(TAG + " native-core total hooks=" + total + " region="
+ MariBankRegionProfile.label(lpparam.packageName));
}
private static int hookNativeCoreClass(
XC_LoadPackage.LoadPackageParam lpparam, String className) {
if (HOOKED_NATIVE_CORE_CLASSES.contains(className)) {
return 0;
}
int count = 0;
try {
Class<?> clazz = XposedHelpers.findClass(className, lpparam.classLoader);
@@ -424,12 +469,41 @@ public final class MariBankShpsNativeHook {
if (!sanitized.equals(s)) {
param.setResult(sanitized);
XposedBridge.log(TAG + " core String sanitized "
+ className + "#" + method.getName()
+ (Modifier.isNative(method.getModifiers()) ? " (native)" : ""));
}
} else if (s.length() > 40) {
String sanitized = MariBankRegisterPayloadUtil.isRegistrationPayload(s)
? MariBankRegisterPayloadUtil.sanitizeRegistrationJson(s)
: MariBankRiskTokenUtil.sanitizeAllInText(s);
if (!sanitized.equals(s)) {
param.setResult(sanitized);
XposedBridge.log(TAG + " core register String sanitized "
+ className + "#" + method.getName());
}
}
}
});
count++;
} else if (rt == byte[].class) {
XposedBridge.hookMethod(method, new XC_MethodHook() {
@Override
protected void afterHookedMethod(MethodHookParam param) {
Object result = param.getResult();
if (!(result instanceof byte[])) {
return;
}
byte[] bytes = (byte[]) result;
byte[] out = MariBankRegisterPayloadUtil.sanitizeRegistrationBytes(bytes);
if (out != bytes) {
param.setResult(out);
XposedBridge.log(TAG + " core byte[] sanitized "
+ className + "#" + method.getName()
+ (Modifier.isNative(method.getModifiers()) ? " (native)" : ""));
}
}
});
count++;
} else if (rt == boolean.class || rt == Boolean.class
|| rt == int.class || rt == Integer.class) {
XposedBridge.hookMethod(method, new XC_MethodHook() {
@@ -446,7 +520,8 @@ public final class MariBankShpsNativeHook {
}
}
if (count > 0) {
XposedBridge.log(TAG + " hooked " + count + " core natives in " + className);
HOOKED_NATIVE_CORE_CLASSES.add(className);
XposedBridge.log(TAG + " hooked " + count + " core methods in " + className);
}
} catch (Throwable t) {
XposedBridge.log(TAG + " skip core " + className + ": " + t.getMessage());
@@ -547,6 +622,15 @@ public final class MariBankShpsNativeHook {
if ("ro.debuggable".equals(key)) {
return "0";
}
if ("init.svc.adbd".equals(key) || "init.svc.adb".equals(key)) {
return "stopped";
}
if ("service.adb.root".equals(key)) {
return "0";
}
if ("persist.sys.adb_enable".equals(key)) {
return "0";
}
if ("ro.secure".equals(key)) {
return "1";
}

View File

@@ -0,0 +1,77 @@
package com.miraclegarden.smsmessage.xposed.hook;
import java.io.ByteArrayInputStream;
import java.io.InputStream;
import java.io.OutputStream;
import java.util.List;
import java.util.Locale;
import de.robv.android.xposed.XposedBridge;
/**
* 拦截 root 探测命令时返回「空 / 失败」假进程,避免抛异常被 SHPSSDK 记为 tamper。
*/
public final class ProbeGuard {
private static final String TAG = "notiMessageHook/ProbeGuard";
private ProbeGuard() {
}
public static boolean isBlockedCommand(String command) {
if (command == null || command.isEmpty()) {
return false;
}
String lower = command.toLowerCase(Locale.US);
return lower.contains(" magisk")
|| lower.contains("/su")
|| lower.startsWith("su")
|| lower.contains("which su")
|| lower.contains("getprop ro.debuggable")
|| lower.contains("busybox")
|| lower.equals("su");
}
public static boolean isBlockedCommand(List<String> commands) {
if (commands == null || commands.isEmpty()) {
return false;
}
return isBlockedCommand(String.join(" ", commands));
}
public static Process fakeFailedProcess(String reason) {
XposedBridge.log(TAG + " fake probe process: " + reason);
return new FakeProcess();
}
private static final class FakeProcess extends Process {
@Override
public OutputStream getOutputStream() {
return OutputStream.nullOutputStream();
}
@Override
public InputStream getInputStream() {
return new ByteArrayInputStream(new byte[0]);
}
@Override
public InputStream getErrorStream() {
return new ByteArrayInputStream(new byte[0]);
}
@Override
public int waitFor() {
return 1;
}
@Override
public int exitValue() {
return 1;
}
@Override
public void destroy() {
}
}
}

View File

@@ -0,0 +1,370 @@
package com.miraclegarden.smsmessage.xposed.hook;
import android.os.Build;
import java.io.BufferedReader;
import java.io.File;
import java.io.FileInputStream;
import java.util.Arrays;
import java.util.HashSet;
import java.util.Locale;
import java.util.Set;
import java.util.WeakHashMap;
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;
/**
* 过滤 /proc/self/maps 等敏感路径,隐藏 Xposed / Magisk / Zygisk 库名。
*/
public final class ProcMapsFilterHook {
private static final String TAG = "notiMessageHook/ProcMaps";
private static final Set<String> PROC_SENSITIVE = new HashSet<>(Arrays.asList(
"/proc/self/maps",
"/proc/version",
"/proc/self/status",
"/proc/mounts",
"/proc/self/attr/current",
"/proc/self/mountinfo"
));
private static final String[] MAPS_HIDE_MARKERS = {
"xposed", "lsposed", "edxposed", "magisk", "frida", "substrate",
"libpine", "pine.so", "zygisk", "riru", "shamiko", "notimessage",
"miraclegarden", "libbytehook", "libgadget", "libfrida", "libriru",
"liblspd", "libzygisk", "libvector", "zygisk_vector", "vector",
};
private static final String FAKE_SELINUX_CTX =
"u:r:untrusted_app:s0:c512,c768";
private static final WeakHashMap<Object, String> TRACKED_INPUTS = new WeakHashMap<>();
private static volatile boolean installed = false;
private ProcMapsFilterHook() {
}
public static void install(XC_LoadPackage.LoadPackageParam lpparam) {
if (installed) {
return;
}
installed = true;
hookProcAccess(lpparam);
hookProcViaRandomAccessFile(lpparam);
hookBufferedReader(lpparam);
hookSystemProperties(lpparam);
XposedBridge.log(TAG + " installed for " + lpparam.packageName);
}
private static void hookProcAccess(XC_LoadPackage.LoadPackageParam lpparam) {
try {
XposedHelpers.findAndHookConstructor(
FileInputStream.class,
String.class,
new XC_MethodHook() {
@Override
protected void afterHookedMethod(MethodHookParam param) {
String path = normalizeProcPath((String) param.args[0]);
if (path != null) {
TRACKED_INPUTS.put(param.getResult(), path);
}
}
}
);
} catch (Throwable t) {
XposedBridge.log(TAG + " FileInputStream hook failed: " + t.getMessage());
}
try {
XposedHelpers.findAndHookConstructor(
FileInputStream.class,
File.class,
new XC_MethodHook() {
@Override
protected void afterHookedMethod(MethodHookParam param) {
File file = (File) param.args[0];
if (file != null) {
String path = normalizeProcPath(file.getAbsolutePath());
if (path != null) {
TRACKED_INPUTS.put(param.getResult(), path);
}
}
}
}
);
} catch (Throwable ignored) {
}
XC_MethodHook readFilter = new XC_MethodHook() {
@Override
protected void afterHookedMethod(MethodHookParam param) {
String path = TRACKED_INPUTS.get(param.thisObject);
if (path == null || param.getResult() == null) {
return;
}
if (param.getResult() instanceof Integer) {
int read = (Integer) param.getResult();
if (read <= 0 || param.args.length == 0 || !(param.args[0] instanceof byte[])) {
return;
}
byte[] buf = (byte[]) param.args[0];
int off = param.args.length > 1 ? (Integer) param.args[1] : 0;
filterProcBytes(path, buf, off, read);
} else if (param.getResult() instanceof byte[]) {
byte[] data = (byte[]) param.getResult();
param.setResult(filterProcBytesAll(path, data));
} else if (param.getResult() instanceof String) {
param.setResult(filterProcText(path, (String) param.getResult()));
}
}
};
try {
XposedHelpers.findAndHookMethod(
FileInputStream.class, "read", byte[].class, readFilter);
XposedHelpers.findAndHookMethod(
FileInputStream.class, "read", byte[].class, int.class, int.class, readFilter);
} catch (Throwable t) {
XposedBridge.log(TAG + " FileInputStream.read hook failed: " + t.getMessage());
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
try {
XposedHelpers.findAndHookMethod(
"java.nio.file.Files",
lpparam.classLoader,
"readAllBytes",
"java.nio.file.Path",
new XC_MethodHook() {
@Override
protected void afterHookedMethod(MethodHookParam param) {
if (!(param.getResult() instanceof byte[])) {
return;
}
String norm = normalizeProcPath(String.valueOf(param.args[0]));
if (norm != null) {
param.setResult(filterProcBytesAll(
norm, (byte[]) param.getResult()));
}
}
}
);
} catch (Throwable ignored) {
}
}
}
private static void hookProcViaRandomAccessFile(XC_LoadPackage.LoadPackageParam lpparam) {
try {
XposedHelpers.findAndHookConstructor(
"java.io.RandomAccessFile",
lpparam.classLoader,
String.class,
String.class,
new XC_MethodHook() {
@Override
protected void afterHookedMethod(MethodHookParam param) {
String path = normalizeProcPath((String) param.args[0]);
if (path != null) {
TRACKED_INPUTS.put(param.getResult(), path);
}
}
}
);
} catch (Throwable ignored) {
}
try {
XposedHelpers.findAndHookMethod(
"java.io.RandomAccessFile",
lpparam.classLoader,
"read",
byte[].class,
new XC_MethodHook() {
@Override
protected void afterHookedMethod(MethodHookParam param) {
String path = TRACKED_INPUTS.get(param.thisObject);
if (path == null || !(param.getResult() instanceof Integer)) {
return;
}
int read = (Integer) param.getResult();
if (read > 0 && param.args[0] instanceof byte[]) {
filterProcBytes(path, (byte[]) param.args[0], 0, read);
}
}
}
);
} catch (Throwable ignored) {
}
}
private static void hookBufferedReader(XC_LoadPackage.LoadPackageParam lpparam) {
try {
XposedHelpers.findAndHookMethod(
BufferedReader.class,
"readLine",
new XC_MethodHook() {
@Override
protected void afterHookedMethod(MethodHookParam param) {
if (!(param.getResult() instanceof String)) {
return;
}
String line = (String) param.getResult();
if (shouldHideMapsLine(line)) {
param.setResult(readNextSafeLine((BufferedReader) param.thisObject));
}
}
}
);
} catch (Throwable t) {
XposedBridge.log(TAG + " BufferedReader hook failed: " + t.getMessage());
}
}
private static String readNextSafeLine(BufferedReader reader) {
try {
String line;
while ((line = reader.readLine()) != null) {
if (!shouldHideMapsLine(line)) {
return line;
}
}
} catch (Throwable ignored) {
}
return "";
}
private static void hookSystemProperties(XC_LoadPackage.LoadPackageParam lpparam) {
try {
Class<?> sp = XposedHelpers.findClass("android.os.SystemProperties", lpparam.classLoader);
for (java.lang.reflect.Method method : sp.getDeclaredMethods()) {
if (!"get".equals(method.getName())) {
continue;
}
XposedBridge.hookMethod(method, new XC_MethodHook() {
@Override
protected void afterHookedMethod(MethodHookParam param) {
if (param.args.length == 0 || !(param.args[0] instanceof String)) {
return;
}
String key = (String) param.args[0];
String spoofed = spoofProperty(key, param.getResult());
if (spoofed != null) {
param.setResult(spoofed);
}
}
});
}
} catch (Throwable t) {
XposedBridge.log(TAG + " SystemProperties hook failed: " + t.getMessage());
}
}
private static String spoofProperty(String key, Object current) {
if ("ro.debuggable".equals(key)) {
return "0";
}
if ("init.svc.adbd".equals(key) || "init.svc.adb".equals(key)) {
return "stopped";
}
if ("service.adb.root".equals(key)) {
return "0";
}
if ("ro.secure".equals(key)) {
return "1";
}
if ("ro.build.tags".equals(key)) {
if (current instanceof String && String.valueOf(current).contains("test-keys")) {
return "release-keys";
}
}
if ("ro.boot.verifiedbootstate".equals(key)) {
return "green";
}
if ("ro.boot.vbmeta.device_state".equals(key)
|| "vendor.boot.vbmeta.device_state".equals(key)) {
return "locked";
}
return null;
}
private static String normalizeProcPath(String path) {
if (path == null) {
return null;
}
String norm = path.trim();
for (String p : PROC_SENSITIVE) {
if (norm.equals(p) || norm.endsWith(p)) {
return p;
}
}
return null;
}
private static byte[] filterProcBytesAll(String path, byte[] data) {
return filterProcText(path, new String(data)).getBytes();
}
private static void filterProcBytes(String path, byte[] buf, int off, int len) {
String text = new String(buf, off, len);
String filtered = filterProcText(path, text);
if (filtered.equals(text)) {
return;
}
byte[] out = filtered.getBytes();
int copy = Math.min(len, out.length);
System.arraycopy(out, 0, buf, off, copy);
if (copy < len) {
Arrays.fill(buf, off + copy, off + len, (byte) 0);
}
}
private static String filterProcText(String path, String text) {
if ("/proc/self/maps".equals(path) || "/proc/self/mountinfo".equals(path)
|| "/proc/mounts".equals(path)) {
StringBuilder sb = new StringBuilder();
for (String line : text.split("\n")) {
if (!shouldHideMapsLine(line)) {
if (sb.length() > 0) {
sb.append('\n');
}
sb.append(line);
}
}
return sb.toString();
}
if ("/proc/self/attr/current".equals(path)) {
String lower = text.toLowerCase(Locale.US);
if (lower.contains("magisk") || lower.contains("su") || lower.contains("zygisk")
|| lower.contains("xposed")) {
return FAKE_SELINUX_CTX;
}
return text;
}
if ("/proc/version".equals(path)) {
return text.replace("dirty", "").replace("test-keys", "release-keys");
}
if ("/proc/self/status".equals(path)) {
return text.replaceAll("(?m)^TracerPid:\\s*[1-9]\\d*",
"TracerPid:\t0");
}
return text;
}
private static boolean shouldHideMapsLine(String line) {
if (line == null || line.isEmpty()) {
return false;
}
String lower = line.toLowerCase(Locale.US);
for (String marker : MAPS_HIDE_MARKERS) {
if (lower.contains(marker)) {
return true;
}
}
return false;
}
}

View File

@@ -131,25 +131,26 @@ public final class RootBypassHelper {
}
public static void hookRuntimeExec(XC_LoadPackage.LoadPackageParam lpparam) {
XC_MethodHook blockExec = new XC_MethodHook() {
@Override
protected void beforeHookedMethod(MethodHookParam param) {
String cmd = null;
if (param.args.length > 0 && param.args[0] instanceof String) {
cmd = (String) param.args[0];
} else if (param.args.length > 0 && param.args[0] instanceof String[]) {
cmd = String.join(" ", (String[]) param.args[0]);
}
if (ProbeGuard.isBlockedCommand(cmd)) {
param.setResult(ProbeGuard.fakeFailedProcess(cmd));
}
}
};
try {
XposedHelpers.findAndHookMethod(Runtime.class, "exec", String.class, blockExec);
XposedHelpers.findAndHookMethod(Runtime.class, "exec", String[].class, blockExec);
XposedHelpers.findAndHookMethod(Runtime.class, "exec", String.class, String[].class, blockExec);
XposedHelpers.findAndHookMethod(
Runtime.class,
"exec",
String.class,
new XC_MethodHook() {
@Override
protected void beforeHookedMethod(MethodHookParam param) {
String cmd = (String) param.args[0];
if (cmd == null) {
return;
}
String lower = cmd.toLowerCase(Locale.US);
if (lower.contains("su") || lower.contains("magisk") || lower.contains("which su")) {
throw new SecurityException("blocked root probe");
}
}
}
);
Runtime.class, "exec", String[].class, String[].class, blockExec);
} catch (Throwable t) {
XposedBridge.log(TAG + " Runtime.exec hook failed: " + t.getMessage());
}

View File

@@ -0,0 +1,2868 @@
package com.miraclegarden.smsmessage.xposed.hook;
import android.content.Context;
import android.os.Handler;
import android.os.Looper;
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.io.BufferedReader;
import java.io.InputStreamReader;
import java.lang.reflect.Array;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.net.HttpURLConnection;
import java.net.URL;
import java.nio.charset.StandardCharsets;
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<String> RECENT_KEYS = new ArrayDeque<>();
private static final HashSet<String> RECENT_SET = new HashSet<>();
/** activityId → 上次转发时间 */
private static final java.util.HashMap<String, Long> RECENT_PACKET_AT = new java.util.HashMap<>();
/** activityId → 领取指纹 */
private static final java.util.HashMap<String, String> RECENT_PACKET_CLAIMS = new java.util.HashMap<>();
/** 同一 activityId + 相同领取名单短时不重复推(可由领取台设置覆盖) */
private static final long DEFAULT_PACKET_DEDUP_MS = 3_000L;
private static final ThreadLocal<String> CURRENT_REQUEST_URL = new ThreadLocal<>();
/** 历史列表触发的自动拉详情:已排队 / 上次成功时间 */
private static final java.util.HashSet<String> AUTO_DETAIL_PENDING = new java.util.HashSet<>();
private static final java.util.HashMap<String, Long> AUTO_DETAIL_AT = new java.util.HashMap<>();
private static final long DEFAULT_DETAIL_COOLDOWN_MS = 8_000L;
/** activityId → 历史列表里的发放时间 createTime */
private static final java.util.HashMap<String, String> ACTIVITY_ISSUE_TIME =
new java.util.HashMap<>();
/** 已成功拿到领取名单的 activityId */
private static final java.util.HashSet<String> PACKETS_WITH_DATA = new java.util.HashSet<>();
/** 已领完且已有数据 → 不再自动刷详情 */
private static final java.util.HashSet<String> COMPLETED_PACKETS = new java.util.HashSet<>();
/** 自动拉历史列表冷却(无需打开历史页;可由领取台设置覆盖) */
private static final long DEFAULT_HISTORY_COOLDOWN_MS = 8_000L;
private static final long DEFAULT_DETAIL_GAP_MS = 80L;
private static final long SETTINGS_POLL_MS = 10_000L;
/** 与 AppConfig.DEBUG_SERVER_URLS 对齐 */
private static final String[] SETTINGS_BASE_URLS = {
"http://127.0.0.1:8765",
"http://10.151.104.25:8765",
};
private static volatile long sHistoryCooldownMs = DEFAULT_HISTORY_COOLDOWN_MS;
private static volatile long sDetailCooldownMs = DEFAULT_DETAIL_COOLDOWN_MS;
private static volatile long sPacketDedupMs = DEFAULT_PACKET_DEDUP_MS;
private static volatile long sDetailGapMs = DEFAULT_DETAIL_GAP_MS;
private static volatile boolean sOpenHistoryIfNoTemplate = true;
/** 断线/拉历史失败时:自动进一下历史页再返回,重建模板与连接 */
private static volatile boolean sAutoBounceHistoryOnDisconnect = true;
private static volatile long sLastSettingsPollAt;
private static volatile long sLastAutoHistoryAt;
private static volatile boolean sAutoHistoryRunning;
private static volatile long sLastHistoryBounceAt;
private static volatile boolean sBounceHistoryPending;
private static volatile long sLastHookStatusAt;
private static final long HISTORY_BOUNCE_COOLDOWN_MS = 120_000L;
private static final long HISTORY_BOUNCE_FINISH_DELAY_MS = 2_200L;
/** 无模板时最多主动打开历史页次数(进程内),避免一直把用户拽进历史页 */
private static volatile int sNoTemplateOpenCount;
private static final int MAX_NO_TEMPLATE_OPEN = 2;
private static final long HOOK_STATUS_COOLDOWN_MS = 20_000L;
/** 曾拿到过 session之后变空 / RPC 鉴权失败 → 视为断线,恢复后强制重拉 */
private static volatile boolean sHadSession;
private static volatile boolean sSessionDisconnected;
/** 本线程正在主动拉历史,避免 Quake afterHook 再 schedule 一遍 */
private static final ThreadLocal<Boolean> AUTO_HISTORY_SELF = new ThreadLocal<>();
private static volatile Object sMoneyPacketRepository;
private static volatile Object sMoneyPacketRpcTask;
private static volatile ClassLoader sAppClassLoader;
private static volatile String sCachedUserId;
private static volatile String sCachedSessionId;
private static volatile String sCachedLoginId;
/** 真实详情/历史请求模板(有则克隆;无则用 ILoginStorage 自造) */
private static volatile Object sTemplateDetailRequest;
private static volatile Object sTemplateHistoryRequest;
/** 最近一次带登录态的请求(历史/详情),用于给自造详情请求补 session */
private static volatile Object sLoginDonorRequest;
private static final Handler MAIN_HANDLER = new Handler(Looper.getMainLooper());
private static final java.util.concurrent.ExecutorService AUTO_DETAIL_EXEC =
java.util.concurrent.Executors.newSingleThreadExecutor(r -> {
Thread t = new Thread(r, "TngMmp-auto-detail");
t.setDaemon(true);
return t;
});
private TngMoneyPacketHook() {
}
public static void install(XC_LoadPackage.LoadPackageParam lpparam) {
if (!TngRootBypassHook.isTargetPackage(lpparam.packageName)) {
return;
}
sAppClassLoader = lpparam.classLoader;
hookOkHttpUrl(lpparam);
hookResponseBody(lpparam);
hookGsonFromJson(lpparam);
hookQuakeRpc(lpparam);
hookLoginStorage(lpparam);
hookActivityResumeForAutoHistory(lpparam);
startSettingsPoller();
startAutoHistoryPoller();
reportHookAlive("install");
XposedBridge.log(TAG + " installed for " + lpparam.packageName
+ " mod=" + HookBridge.MODULE_VERSION_NAME
+ "/" + HookBridge.MODULE_VERSION_CODE);
}
/** 按历史冷却定时拉列表 + 心跳,避免无新红包时状态条误报「久无数据」 */
private static void startAutoHistoryPoller() {
Thread t = new Thread(() -> {
while (true) {
try {
reportHookAlive("poll");
scheduleAutoHistory("poll");
long sleepMs = Math.max(5_000L, sHistoryCooldownMs);
Thread.sleep(sleepMs);
} catch (InterruptedException ie) {
Thread.currentThread().interrupt();
return;
} catch (Throwable ignored) {
try {
Thread.sleep(Math.max(5_000L, sHistoryCooldownMs));
} catch (InterruptedException ie2) {
Thread.currentThread().interrupt();
return;
}
}
}
}, "TngMmp-auto-hist-poll");
t.setDaemon(true);
t.start();
}
/** 后台轮询领取台 /api/mmp/settings约 10 秒内生效 */
private static void startSettingsPoller() {
Thread t = new Thread(() -> {
while (true) {
try {
pollMmpSettings(true);
Thread.sleep(SETTINGS_POLL_MS);
} catch (InterruptedException ie) {
Thread.currentThread().interrupt();
return;
} catch (Throwable ignored) {
try {
Thread.sleep(SETTINGS_POLL_MS);
} catch (InterruptedException ie2) {
Thread.currentThread().interrupt();
return;
}
}
}
}, "TngMmp-settings");
t.setDaemon(true);
t.start();
}
private static void pollMmpSettings(boolean force) {
long now = System.currentTimeMillis();
if (!force && now - sLastSettingsPollAt < SETTINGS_POLL_MS) {
return;
}
sLastSettingsPollAt = now;
for (String base : SETTINGS_BASE_URLS) {
if (tryApplySettingsFromUrl(base + "/api/mmp/settings")) {
return;
}
}
}
private static boolean tryApplySettingsFromUrl(String urlStr) {
HttpURLConnection conn = null;
try {
URL url = new URL(urlStr);
conn = (HttpURLConnection) url.openConnection();
conn.setConnectTimeout(2500);
conn.setReadTimeout(2500);
conn.setRequestMethod("GET");
conn.setUseCaches(false);
int code = conn.getResponseCode();
if (code != 200) {
return false;
}
StringBuilder sb = new StringBuilder();
try (BufferedReader br = new BufferedReader(
new InputStreamReader(conn.getInputStream(), StandardCharsets.UTF_8))) {
String line;
while ((line = br.readLine()) != null) {
sb.append(line);
}
}
JSONObject json = new JSONObject(sb.toString());
long historyMs = Math.max(1, json.optInt("historyCooldownSec", 8)) * 1000L;
long detailMs = Math.max(1, json.optInt("detailCooldownSec", 8)) * 1000L;
long dedupMs = Math.max(0, json.optInt("dedupSec", 3)) * 1000L;
long gapMs = Math.max(0, json.optInt("detailGapMs", 80));
boolean openHistory = json.optBoolean("openHistoryIfNoTemplate", true);
boolean bounceHistory = json.optBoolean("autoBounceHistoryOnDisconnect", true);
boolean changed = historyMs != sHistoryCooldownMs
|| detailMs != sDetailCooldownMs
|| dedupMs != sPacketDedupMs
|| gapMs != sDetailGapMs
|| openHistory != sOpenHistoryIfNoTemplate
|| bounceHistory != sAutoBounceHistoryOnDisconnect;
sHistoryCooldownMs = historyMs;
sDetailCooldownMs = detailMs;
sPacketDedupMs = dedupMs;
sDetailGapMs = gapMs;
sOpenHistoryIfNoTemplate = openHistory;
sAutoBounceHistoryOnDisconnect = bounceHistory;
if (changed) {
XposedBridge.log(TAG + " settings applied hist=" + (historyMs / 1000)
+ "s detail=" + (detailMs / 1000) + "s dedup=" + (dedupMs / 1000)
+ "s gap=" + gapMs + "ms openHist=" + openHistory
+ " bounceHist=" + bounceHistory
+ " from " + urlStr);
}
return true;
} catch (Throwable ignored) {
return false;
} finally {
if (conn != null) {
conn.disconnect();
}
}
}
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;
}
String n = className;
return n.contains("MmpDetail")
|| n.contains("MmpClaim")
|| n.contains("MmpReceiver")
|| n.contains("MmpLeaderboard")
|| n.contains("MoneyPacketDetail")
|| n.contains("MoneyPacketClaim")
|| n.contains("MoneyPacketLeader")
|| n.contains("MoneyPacketDoClaim")
|| n.contains("ClaimResultDto")
|| n.contains("ClaimDto")
|| n.contains("ReceiverList")
|| n.contains("packetReceiver");
}
private static void hookQuakeRpc(XC_LoadPackage.LoadPackageParam lpparam) {
// TNG 封装 + Quake 底层都挂,避免历史/详情只走其中一条链路时漏抓
String[] rpcClasses = {
"my.com.tngdigital.common.aliservice.quake.TngdRpcInvocationHandlerHost",
"com.alipay.imobile.network.quake.rpc.RpcInvocationHandler",
};
XC_MethodHook hook = new XC_MethodHook() {
@Override
protected void afterHookedMethod(MethodHookParam param) {
if (param.hasThrowable() || param.getResult() == null) {
return;
}
cacheMoneyPacketRpcTask(param.args);
cacheLoginFromInvokeArgs(param.args);
String op = extractMoneyPacketRpcOp(param.args);
if (op == null) {
return;
}
XposedBridge.log(TAG + " rpc op=" + op
+ " via=" + param.thisObject.getClass().getSimpleName());
Object result = param.getResult();
// 历史列表:无领取排行,但可用 activityId 自动拉详情
if (isHistoryRpcOp(op)) {
Object historyReq = extractRpcRequestArg(param.args);
if (historyReq != null) {
sTemplateHistoryRequest = cloneRequestShallow(historyReq);
sLoginDonorRequest = historyReq;
cacheLoginFromRequestObject(historyReq);
sNoTemplateOpenCount = 0;
XposedBridge.log(TAG + " cached history request template"
+ " session=" + abbreviate(stringField(historyReq,
"sessionId", "getSessionId")));
}
// 无论是否自己触发的历史请求,都先缓存发放时间
cacheIssueTimesFromHistory(result);
if (Boolean.TRUE.equals(AUTO_HISTORY_SELF.get())) {
return;
}
XposedBridge.log(TAG + " history hit → schedule auto detail");
scheduleAutoDetailsFromHistory(result);
return;
}
if (!isDetailRpcOp(op)) {
return;
}
String className = result.getClass().getName();
XposedBridge.log(TAG + " rpc hit op=" + op + " type=" + className);
try {
Object detailReq = extractRpcRequestArg(param.args);
String json = buildJsonFromObject(result);
if (TextUtils.isEmpty(json)) {
json = tryFastjson(result);
}
if (TextUtils.isEmpty(json)) {
XposedBridge.log(TAG + " rpc serialize empty op=" + op);
return;
}
MmpSnapshot probe = parseSnapshot(json);
enrichSnapshotFromJavaResult(result, probe);
if (probe != null && (!probe.claims.isEmpty() || snapshotHasPacketMeta(probe))) {
if (detailReq != null) {
sTemplateDetailRequest = cloneRequestShallow(detailReq);
sLoginDonorRequest = detailReq;
cacheLoginFromRequestObject(detailReq);
XposedBridge.log(TAG + " cached detail request template"
+ " userId=" + stringField(detailReq, "userId", "getUserId")
+ " session=" + abbreviate(stringField(detailReq,
"sessionId", "getSessionId"))
+ " page=" + stringField(detailReq, "page", "getPage")
+ " max=" + stringField(detailReq, "maxResult", "getMaxResult"));
}
forwardSnapshot(probe, op, "rpc");
} else {
String snippet = json.length() > 500 ? json.substring(0, 500) + "..." : json;
XposedBridge.log(TAG + " rpc no-claims op=" + op + " body=" + snippet);
}
} catch (Throwable t) {
XposedBridge.log(TAG + " rpc capture failed: " + t.getMessage());
}
}
};
for (String className : rpcClasses) {
try {
Class<?> clazz = XposedHelpers.findClass(className, lpparam.classLoader);
int hooked = 0;
for (Method method : clazz.getDeclaredMethods()) {
if (!"invoke".equals(method.getName())) {
continue;
}
XposedBridge.hookMethod(method, hook);
hooked++;
}
if (hooked > 0) {
XposedBridge.log(TAG + " hooked RPC " + className + " invoke=" + hooked);
}
} catch (Throwable t) {
XposedBridge.log(TAG + " skip RPC " + className + ": " + t.getMessage());
}
}
hookMoneyPacketRepository(lpparam);
}
/** DI 创建 Repository 时立刻缓存 task + login不必等用户进红包页 */
private static void hookMoneyPacketRepository(XC_LoadPackage.LoadPackageParam lpparam) {
try {
Class<?> repoCls = XposedHelpers.findClass(
"my.com.tngdigital.funding.moneypacket.common.data.repo.MoneyPacketRepositoryImpl",
lpparam.classLoader);
XposedBridge.hookAllConstructors(repoCls, new XC_MethodHook() {
@Override
protected void afterHookedMethod(MethodHookParam param) {
captureRepoInstance(param.thisObject, "ctor");
}
});
// Dagger 可能先无参构造再注入字段;在业务方法入口再捕获一次
for (String methodName : new String[]{
"getMoneyPacketList", "getMoneyPacketDetail", "doClaim", "preClaim"}) {
try {
for (Method m : repoCls.getDeclaredMethods()) {
if (!methodName.equals(m.getName())) {
continue;
}
XposedBridge.hookMethod(m, new XC_MethodHook() {
@Override
protected void beforeHookedMethod(MethodHookParam param) {
captureRepoInstance(param.thisObject, "method:" + methodName);
}
});
}
} catch (Throwable ignored) {
}
}
XposedBridge.log(TAG + " hooked MoneyPacketRepositoryImpl");
} catch (Throwable t) {
XposedBridge.log(TAG + " repo hook skip: " + t.getMessage());
}
}
private static void captureRepoInstance(Object repo, String reason) {
if (repo == null) {
return;
}
try {
sMoneyPacketRepository = repo;
Object task = null;
try {
task = XposedHelpers.getObjectField(repo, "task");
} catch (Throwable ignored) {
}
if (task != null && sMoneyPacketRpcTask == null) {
sMoneyPacketRpcTask = task;
XposedBridge.log(TAG + " cached RPC task from repo (" + reason + ")");
}
Object loginStorage = null;
try {
loginStorage = XposedHelpers.getObjectField(repo, "loginStorage");
} catch (Throwable ignored) {
}
applyLoginStorage(loginStorage);
if (sMoneyPacketRpcTask != null && !TextUtils.isEmpty(sCachedSessionId)) {
scheduleAutoHistory("repo-" + reason);
}
} catch (Throwable t) {
XposedBridge.log(TAG + " captureRepo fail: " + t.getMessage());
}
}
/** 与 App 一致:发 RPC 前走 Repository.addNetworkHandle否则易 RpcApiException / 空结果 */
private static void prepareRpcRequest(Object req) {
if (req == null) {
return;
}
Object repo = sMoneyPacketRepository;
if (repo == null) {
repo = obtainMoneyPacketRepository(sAppClassLoader);
if (repo != null) {
sMoneyPacketRepository = repo;
}
}
if (repo == null) {
XposedBridge.log(TAG + " prepareRpc skip: no repository");
return;
}
try {
// addNetworkHandle$default(repo, request, processor=null, onError=null, mask=3, marker=null)
XposedHelpers.callStaticMethod(repo.getClass(), "addNetworkHandle$default",
repo, req, null, null, 3, null);
XposedBridge.log(TAG + " prepareRpc addNetworkHandle$default ok");
return;
} catch (Throwable t) {
XposedBridge.log(TAG + " addNetworkHandle$default fail: " + t.getMessage());
}
try {
XposedHelpers.callMethod(repo, "addNetworkHandle", req, null, null);
XposedBridge.log(TAG + " prepareRpc addNetworkHandle ok");
} catch (Throwable t) {
XposedBridge.log(TAG + " addNetworkHandle fail: " + t.getMessage());
}
}
private static boolean isHistoryRpcOp(String op) {
if (TextUtils.isEmpty(op)) {
return false;
}
String lower = op.toLowerCase(Locale.US);
return lower.contains("historylist")
|| lower.contains("history")
|| lower.contains("list.retrieve")
|| lower.equals("moneypackethistorylist");
}
/** 只要详情/领取查询,历史列表走 auto-detail */
private static boolean isDetailRpcOp(String op) {
if (TextUtils.isEmpty(op) || isHistoryRpcOp(op)) {
return false;
}
String lower = op.toLowerCase(Locale.US);
return lower.contains("detail")
|| lower.contains("retrieve")
|| lower.contains("claim.result")
|| lower.contains("leaderboard")
|| lower.contains("moneypacketdetail");
}
/** InvocationHandler.invoke(proxy, method, args) → 缓存 MoneyPacketRpcTask 代理 */
private static void cacheMoneyPacketRpcTask(Object[] args) {
if (args == null || args.length == 0) {
return;
}
try {
Object proxy = args[0];
if (proxy == null || !java.lang.reflect.Proxy.isProxyClass(proxy.getClass())) {
return;
}
if (sMoneyPacketRpcTask != null) {
return;
}
for (Class<?> iface : proxy.getClass().getInterfaces()) {
String name = iface.getName();
if (name.endsWith("MoneyPacketRpcTask") || name.contains("MoneyPacketRpc")) {
sMoneyPacketRpcTask = proxy;
XposedBridge.log(TAG + " cached RPC task " + name);
scheduleAutoHistory("rpc-task-cached");
return;
}
}
try {
proxy.getClass().getMethod("moneyPacketDetail",
XposedHelpers.findClass(
"my.com.tngdigital.funding.moneypacket.common.data.rpc.MoneyPacketDetailRequest",
sAppClassLoader));
sMoneyPacketRpcTask = proxy;
XposedBridge.log(TAG + " cached RPC task via moneyPacketDetail method");
scheduleAutoHistory("rpc-task-cached");
} catch (Throwable ignored) {
}
} catch (Throwable ignored) {
}
}
/** 从历史/详情请求里偷登录态,供自动拉详情复用 */
private static void cacheLoginFromInvokeArgs(Object[] args) {
cacheLoginFromRequestObject(extractRpcRequestArg(args));
}
private static void cacheLoginFromRequestObject(Object req) {
if (req == null) {
return;
}
String userId = stringField(req, "userId", "getUserId");
String sessionId = stringField(req, "sessionId", "getSessionId");
String loginId = stringField(req, "loginId", "getLoginId");
if (!TextUtils.isEmpty(userId)) {
sCachedUserId = userId;
}
if (!TextUtils.isEmpty(sessionId)) {
sCachedSessionId = sessionId;
}
if (!TextUtils.isEmpty(loginId)) {
sCachedLoginId = loginId;
}
}
private static String abbreviate(String s) {
if (TextUtils.isEmpty(s)) {
return "-";
}
return s.length() <= 8 ? s : s.substring(0, 4) + "" + s.substring(s.length() - 4);
}
private static Object cloneRequestShallow(Object src) {
if (src == null) {
return null;
}
try {
Object copy = src.getClass().getDeclaredConstructor().newInstance();
copyAllFields(src, copy);
return copy;
} catch (Throwable t) {
// Kotlin data class 可能无无参构造,直接复用引用(自动拉取在单线程)
return src;
}
}
private static void copyAllFields(Object src, Object dst) {
Class<?> c = src.getClass();
while (c != null && c != Object.class) {
for (java.lang.reflect.Field f : c.getDeclaredFields()) {
if (f.getName().contains("$")) {
continue;
}
try {
f.setAccessible(true);
f.set(dst, f.get(src));
} catch (Throwable ignored) {
}
}
c = c.getSuperclass();
}
}
private static void applyActivityToRequest(Object req, String activityId, String senderUserId) {
try {
XposedHelpers.setObjectField(req, "activityId", activityId);
} catch (Throwable ignored) {
trySet(req, "setActivityId", activityId);
}
try {
XposedHelpers.setObjectField(req, "senderUserId", senderUserId);
} catch (Throwable ignored) {
trySet(req, "setSenderUserId", senderUserId);
}
// 尽量一次拉全领取名单(避免默认 20 条截断)
bumpDetailPageSize(req);
}
private static void bumpDetailPageSize(Object req) {
if (req == null) {
return;
}
try {
XposedHelpers.setIntField(req, "page", 0);
} catch (Throwable ignored) {
}
try {
XposedHelpers.setIntField(req, "maxResult", 200);
} catch (Throwable ignored) {
try {
XposedHelpers.setObjectField(req, "maxResult", 200);
} catch (Throwable ignored2) {
}
}
}
private static Object extractRpcRequestArg(Object[] args) {
if (args == null) {
return null;
}
// InvocationHandler.invoke(proxy, method, Object[] methodArgs)
if (args.length >= 3 && args[2] instanceof Object[]) {
Object[] methodArgs = (Object[]) args[2];
if (methodArgs.length > 0 && methodArgs[0] != null) {
String n = methodArgs[0].getClass().getName();
if (n.contains("Request") || n.contains("moneypacket") || n.contains("MoneyPacket")) {
return methodArgs[0];
}
}
}
for (Object arg : args) {
if (arg == null || arg instanceof Method || arg instanceof String) {
continue;
}
if (arg instanceof Object[]) {
continue;
}
String n = arg.getClass().getName();
if (n.contains("Request") && (n.contains("MoneyPacket") || n.contains("moneypacket")
|| n.contains("Mmp"))) {
return arg;
}
}
return null;
}
private static void scheduleAutoDetailsFromHistory(Object historyResult) {
if (historyResult == null) {
return;
}
pollMmpSettings(false);
// 解析放调用线程;真正 RPC 必须丢到主线程Quake/登录态常绑主线程)
final List<String[]> jobs;
try {
jobs = extractHistoryJobs(historyResult);
} catch (Throwable t) {
XposedBridge.log(TAG + " extract history jobs failed: " + t.getMessage());
return;
}
XposedBridge.log(TAG + " auto-detail jobs=" + jobs.size());
if (jobs.isEmpty()) {
return;
}
long delayMs = 0L;
for (String[] job : jobs) {
final String activityId = job[0];
final String senderUserId = job[1];
final String createTime = job.length > 2 ? job[2] : null;
if (!TextUtils.isEmpty(createTime)) {
synchronized (ACTIVITY_ISSUE_TIME) {
ACTIVITY_ISSUE_TIME.put(activityId, createTime);
}
}
long now = System.currentTimeMillis();
long detailCooldown = sDetailCooldownMs;
if (COMPLETED_PACKETS.contains(activityId)) {
continue;
}
// job[3]=remainingCount, job[4]=claimedCount/totalCount hint, job[5]=status
String remainHint = job.length > 3 ? job[3] : null;
String countHint = job.length > 4 ? job[4] : null;
String statusHint = job.length > 5 ? job[5] : null;
if (isFinishedFlags(remainHint, countHint, statusHint)
&& PACKETS_WITH_DATA.contains(activityId)) {
COMPLETED_PACKETS.add(activityId);
XposedBridge.log(TAG + " skip completed(history) id=" + activityId);
continue;
}
synchronized (AUTO_DETAIL_AT) {
Long last = AUTO_DETAIL_AT.get(activityId);
if (last != null && now - last < detailCooldown) {
continue;
}
if (!AUTO_DETAIL_PENDING.add(activityId)) {
continue;
}
}
final long thisDelay = delayMs;
delayMs += Math.max(0L, sDetailGapMs);
AUTO_DETAIL_EXEC.execute(() -> {
try {
if (thisDelay > 0) {
Thread.sleep(thisDelay);
}
boolean ok = fetchDetailByActivityId(activityId, senderUserId);
if (ok) {
synchronized (AUTO_DETAIL_AT) {
AUTO_DETAIL_AT.put(activityId, System.currentTimeMillis());
}
}
} catch (InterruptedException ie) {
Thread.currentThread().interrupt();
} finally {
synchronized (AUTO_DETAIL_AT) {
AUTO_DETAIL_PENDING.remove(activityId);
}
}
});
}
}
private static List<String[]> extractHistoryJobs(Object historyResult) {
List<String[]> jobs = new ArrayList<>();
try {
Object infos = null;
try {
infos = XposedHelpers.callMethod(historyResult, "getParticipantInfos");
} catch (Throwable ignored) {
}
if (infos == null) {
try {
infos = XposedHelpers.getObjectField(historyResult, "participantInfos");
} catch (Throwable ignored) {
}
}
if (!(infos instanceof Iterable)) {
return jobs;
}
for (Object info : (Iterable<?>) infos) {
if (info == null) {
continue;
}
String activityId = stringField(info, "activityId", "getActivityId");
String senderUserId = stringField(info, "senderUserId", "getSenderUserId");
String createTime = stringField(info, "createTime", "getCreateTime");
if (looksLikeActivityId(activityId) && !TextUtils.isEmpty(senderUserId)) {
String remain = firstNonEmpty(
stringField(info, "remainingCount", "getRemainingCount"),
stringField(info, "remainCount", "getRemainCount"));
String claimed = stringField(info, "claimedCount", "getClaimedCount");
String totalCnt = stringField(info, "totalCount", "getTotalCount");
String countHint = "";
if (!TextUtils.isEmpty(claimed) || !TextUtils.isEmpty(totalCnt)) {
countHint = nullToEmpty(claimed) + "/" + nullToEmpty(totalCnt);
}
String status = firstNonEmpty(
stringField(info, "activityStatus", "getActivityStatus"),
stringField(info, "participantStatus", "getParticipantStatus"),
stringField(info, "status", "getStatus"),
boolFieldText(info, "isFinished", "isFinished"));
jobs.add(new String[]{
activityId,
senderUserId,
TextUtils.isEmpty(createTime) ? "" : createTime,
nullToEmpty(remain),
countHint,
nullToEmpty(status)
});
}
}
} catch (Throwable t) {
XposedBridge.log(TAG + " extract history jobs failed: " + t.getMessage());
}
return jobs;
}
private static String stringField(Object obj, String field, String getter) {
try {
Object v = XposedHelpers.callMethod(obj, getter);
if (v != null) {
return String.valueOf(v);
}
} catch (Throwable ignored) {
}
try {
Object v = XposedHelpers.getObjectField(obj, field);
if (v != null) {
return String.valueOf(v);
}
} catch (Throwable ignored) {
}
return null;
}
private static String boolFieldText(Object obj, String field, String getter) {
try {
Object v = XposedHelpers.callMethod(obj, getter);
if (v instanceof Boolean) {
return Boolean.TRUE.equals(v) ? "FINISHED" : null;
}
} catch (Throwable ignored) {
}
try {
Object v = XposedHelpers.getObjectField(obj, field);
if (v instanceof Boolean && Boolean.TRUE.equals(v)) {
return "FINISHED";
}
} catch (Throwable ignored) {
}
return null;
}
private static String nullToEmpty(String s) {
return s == null ? "" : s;
}
/** remaining / claimed/total / status 任一表明已领完或已结束 */
private static boolean isFinishedFlags(String remain, String claimedSlashTotal, String status) {
if (!TextUtils.isEmpty(remain)) {
try {
if (Integer.parseInt(remain.trim()) <= 0) {
return true;
}
} catch (NumberFormatException ignored) {
}
}
if (!TextUtils.isEmpty(claimedSlashTotal) && claimedSlashTotal.contains("/")) {
String[] parts = claimedSlashTotal.split("/", 2);
try {
int claimed = Integer.parseInt(parts[0].trim());
int total = Integer.parseInt(parts[1].trim());
if (total > 0 && claimed >= total) {
return true;
}
} catch (NumberFormatException ignored) {
}
}
if (!TextUtils.isEmpty(status)) {
String s = status.toUpperCase(Locale.US);
// TNG ActivityStatus: ACTIVE/INIT/FINISHED/ENDED/CLOSED/CANCELLED
if (s.contains("FINISH") || s.contains("COMPLETE") || s.contains("EXPIRE")
|| s.contains("ENDED") || s.contains("CLOSED") || s.contains("CANCEL")
|| s.contains("DONE") || "TRUE".equals(s)) {
return true;
}
}
return false;
}
private static boolean evaluateFinished(MmpSnapshot snapshot) {
if (snapshot == null) {
return false;
}
if (isFinishedFlags(snapshot.remainingCount,
buildCountHint(snapshot.claimedCount, snapshot.totalCount),
snapshot.activityStatus)) {
return true;
}
if (isExpireTimePassed(snapshot.expireTime)) {
return true;
}
// 已领金额 ≈ 总额
Double claimedAmt = parseMoneyDouble(snapshot.claimedAmountText);
Double totalAmt = parseMoneyDouble(snapshot.totalAmount);
if (claimedAmt == null && snapshot.claims != null && !snapshot.claims.isEmpty()) {
double sum = 0;
for (ClaimLine line : snapshot.claims) {
Double a = parseMoneyDouble(line.amount);
if (a != null) {
sum += a;
}
}
claimedAmt = sum;
}
if (claimedAmt != null && totalAmt != null && totalAmt > 0
&& Math.abs(claimedAmt - totalAmt) < 0.02) {
return true;
}
return false;
}
/** expireTime 形如 05/08/2026 14:06:56 或 ISO已过则视为结束 */
private static boolean isExpireTimePassed(String expireTime) {
long ms = parseIssueTimeMs(expireTime);
return ms > 0 && System.currentTimeMillis() >= ms;
}
private static long parseIssueTimeMs(String text) {
if (TextUtils.isEmpty(text)) {
return 0L;
}
String s = text.trim();
try {
java.util.regex.Matcher m = java.util.regex.Pattern
.compile("^(\\d{2})/(\\d{2})/(\\d{4})(?:\\s+(\\d{2}):(\\d{2})(?::(\\d{2}))?)?")
.matcher(s);
if (m.find()) {
java.util.Calendar c = java.util.Calendar.getInstance();
c.set(Integer.parseInt(m.group(3)),
Integer.parseInt(m.group(2)) - 1,
Integer.parseInt(m.group(1)),
m.group(4) != null ? Integer.parseInt(m.group(4)) : 23,
m.group(5) != null ? Integer.parseInt(m.group(5)) : 59,
m.group(6) != null ? Integer.parseInt(m.group(6)) : 59);
c.set(java.util.Calendar.MILLISECOND, 999);
return c.getTimeInMillis();
}
if (s.matches("^\\d{13}$")) {
return Long.parseLong(s);
}
if (s.matches("^\\d{10}$")) {
return Long.parseLong(s) * 1000L;
}
// 2026-08-05T14:06:56 / 2026-08-05 14:06:56
java.util.regex.Matcher iso = java.util.regex.Pattern
.compile("^(\\d{4})-(\\d{2})-(\\d{2})[ T](\\d{2}):(\\d{2})(?::(\\d{2}))?")
.matcher(s);
if (iso.find()) {
java.util.Calendar c = java.util.Calendar.getInstance();
c.set(Integer.parseInt(iso.group(1)),
Integer.parseInt(iso.group(2)) - 1,
Integer.parseInt(iso.group(3)),
Integer.parseInt(iso.group(4)),
Integer.parseInt(iso.group(5)),
iso.group(6) != null ? Integer.parseInt(iso.group(6)) : 0);
c.set(java.util.Calendar.MILLISECOND, 0);
return c.getTimeInMillis();
}
} catch (Throwable ignored) {
}
return 0L;
}
private static String buildCountHint(String claimed, String total) {
if (TextUtils.isEmpty(claimed) && TextUtils.isEmpty(total)) {
return null;
}
return nullToEmpty(claimed) + "/" + nullToEmpty(total);
}
private static Double parseMoneyDouble(String raw) {
if (TextUtils.isEmpty(raw)) {
return null;
}
try {
String n = normalizeMoneyText(raw);
if (TextUtils.isEmpty(n)) {
return null;
}
return Double.parseDouble(n);
} catch (Throwable ignored) {
return null;
}
}
private static void rememberPacketData(String packetId, MmpSnapshot snapshot, Object result) {
if (snapshot == null) {
return;
}
boolean hasClaims = snapshot.claims != null && !snapshot.claims.isEmpty();
if (!hasClaims && !snapshotHasPacketMeta(snapshot)) {
return;
}
String id = !TextUtils.isEmpty(packetId) ? packetId : snapshot.packetId;
if (TextUtils.isEmpty(id) || !looksLikeActivityId(id)) {
// 指纹 id 也记,避免反复刷
if (!TextUtils.isEmpty(id) && hasClaims) {
PACKETS_WITH_DATA.add(id);
}
} else if (hasClaims) {
PACKETS_WITH_DATA.add(id);
}
if (result != null
&& TextUtils.isEmpty(snapshot.remainingCount)
&& TextUtils.isEmpty(snapshot.claimedCount)
&& TextUtils.isEmpty(snapshot.activityStatus)) {
enrichSnapshotFromJavaResult(result, snapshot);
}
if (!snapshot.finished) {
snapshot.finished = evaluateFinished(snapshot);
}
if (snapshot.finished && hasClaims && !TextUtils.isEmpty(id)) {
if (COMPLETED_PACKETS.add(id)) {
XposedBridge.log(TAG + " packet DONE stop-refresh id=" + id
+ " claims=" + snapshot.claims.size()
+ " remain=" + snapshot.remainingCount
+ " status=" + snapshot.activityStatus);
}
}
}
private static void onSessionObserved(String sid) {
boolean has = !TextUtils.isEmpty(sid) && !"null".equalsIgnoreCase(sid);
if (has) {
sCachedSessionId = sid;
if (sSessionDisconnected) {
sSessionDisconnected = false;
XposedBridge.log(TAG + " session recovered → force re-fetch");
scheduleAutoHistoryForced("session-recovered");
}
sHadSession = true;
} else if (sHadSession) {
if (!sSessionDisconnected) {
sSessionDisconnected = true;
XposedBridge.log(TAG + " session lost / disconnected");
}
}
}
private static void noteSessionAlive() {
if (!TextUtils.isEmpty(sCachedSessionId)) {
sHadSession = true;
if (sSessionDisconnected) {
sSessionDisconnected = false;
XposedBridge.log(TAG + " connection alive again");
}
}
}
private static void tryRefreshLogin(ClassLoader cl) {
try {
Object ls = obtainLoginStorage(cl);
if (ls != null) {
applyLoginStorage(ls);
}
} catch (Throwable ignored) {
}
}
private static void markMaybeDisconnected(Throwable t, String where) {
String msg = t == null ? "" : String.valueOf(t.getMessage());
if (TextUtils.isEmpty(msg) && t != null) {
msg = t.getClass().getSimpleName();
}
String low = msg.toLowerCase(Locale.US);
if (low.contains("session") || low.contains("login") || low.contains("auth")
|| low.contains("unauthorized") || low.contains("timeout")
|| low.contains("unable to resolve") || low.contains("failed to connect")
|| low.contains("network") || low.contains("disconnect")
|| low.contains("socket") || low.contains("ssl")
|| msg.contains("未登录") || msg.contains("非法") || msg.contains("登录")
|| msg.contains("过期")) {
sSessionDisconnected = true;
XposedBridge.log(TAG + " disconnect suspected @" + where + ": " + msg);
tryRefreshLogin(sAppClassLoader);
}
}
private static void notePossibleDisconnectFromEmpty(Object result, String activityId) {
// 新发红包无人领取时 activityPoolInfos 为空、totalCount/claimedCount 也常为 0不是断线
if (looksLikeValidEmptyPacket(result, activityId)) {
XposedBridge.log(TAG + " empty claims but valid summary id=" + activityId
+ " → keep (0 claimants)");
return;
}
// totalCount:0 + 无领取人 且无有效摘要时,才怀疑缺登录态
String totalCnt = stringField(result, "totalCount", "getTotalCount");
if ("0".equals(totalCnt) && sHadSession) {
XposedBridge.log(TAG + " empty claims+totalCount0 id=" + activityId
+ " → treat as possible disconnect");
sSessionDisconnected = true;
tryRefreshLogin(sAppClassLoader);
}
}
/** 详情有 summaryACTIVE / 有总额)但领取名单为空:刚发出去、还没人领 */
private static boolean looksLikeValidEmptyPacket(Object result, String activityId) {
if (result == null) {
return false;
}
try {
Object summary = null;
try {
summary = XposedHelpers.callMethod(result, "getSummaryInfo");
} catch (Throwable ignored) {
}
if (summary == null) {
try {
summary = XposedHelpers.getObjectField(result, "summaryInfo");
} catch (Throwable ignored) {
}
}
if (summary == null) {
return false;
}
String id = firstNonEmpty(
stringField(summary, "activityId", "getActivityId"),
activityId);
if (!looksLikeActivityId(id)) {
return false;
}
String status = stringField(summary, "activityStatus", "getActivityStatus");
if (!TextUtils.isEmpty(status)) {
String up = status.toUpperCase(Locale.US);
if (up.contains("ACTIVE") || up.contains("OPEN") || up.contains("PROGRESS")
|| up.contains("FINISH") || up.contains("COMPLETE")
|| up.contains("EXPIRE")) {
return true;
}
}
String total = null;
try {
Object money = XposedHelpers.callMethod(summary, "getTotalAmount");
if (money != null) {
total = normalizeMoneyText(String.valueOf(
XposedHelpers.callMethod(money, "getAmount")));
}
} catch (Throwable ignored) {
}
return !TextUtils.isEmpty(total);
} catch (Throwable ignored) {
return false;
}
}
private static boolean snapshotHasPacketMeta(MmpSnapshot snapshot) {
return snapshot != null
&& looksLikeActivityId(snapshot.packetId)
&& (!TextUtils.isEmpty(snapshot.totalAmount)
|| !TextUtils.isEmpty(snapshot.activityStatus)
|| !TextUtils.isEmpty(snapshot.senderName));
}
private static boolean fetchDetailByActivityId(String activityId, String senderUserId) {
ClassLoader cl = sAppClassLoader;
ensureRpcTaskAndLogin(cl);
Object task = sMoneyPacketRpcTask;
if (cl == null || task == null) {
XposedBridge.log(TAG + " auto-detail skip: no rpc task yet, id=" + activityId);
return false;
}
try {
Class<?> reqCls = XposedHelpers.findClass(
"my.com.tngdigital.funding.moneypacket.common.data.rpc.MoneyPacketDetailRequest",
cl);
Object req;
if (sTemplateDetailRequest != null
&& reqCls.isInstance(sTemplateDetailRequest)) {
req = cloneRequestShallow(sTemplateDetailRequest);
applyActivityToRequest(req, activityId, senderUserId);
XposedBridge.log(TAG + " auto-detail using template request id=" + activityId);
} else {
req = newDetailRequest(reqCls, activityId, senderUserId);
if (req == null) {
XposedBridge.log(TAG + " auto-detail newRequest failed id=" + activityId);
return false;
}
}
fillLoginOnRequest(req, cl);
prepareRpcRequest(req);
XposedBridge.log(TAG + " auto-detail req id=" + activityId
+ " userId=" + stringField(req, "userId", "getUserId")
+ " session=" + abbreviate(stringField(req, "sessionId", "getSessionId"))
+ " loginId=" + abbreviate(stringField(req, "loginId", "getLoginId"))
+ " page=" + stringField(req, "page", "getPage")
+ " max=" + stringField(req, "maxResult", "getMaxResult"));
if (TextUtils.isEmpty(stringField(req, "sessionId", "getSessionId"))) {
XposedBridge.log(TAG + " auto-detail skip: no sessionId id=" + activityId
+ " (ILoginStorage 尚未就绪,等登录后 Activity resume 会重试)");
return false;
}
Object result = XposedHelpers.callMethod(task, "moneyPacketDetail", req);
if (result == null) {
XposedBridge.log(TAG + " auto-detail null result id=" + activityId);
return false;
}
String json = buildJsonFromObject(result);
if (TextUtils.isEmpty(json)) {
json = tryFastjson(result);
}
if (TextUtils.isEmpty(json)) {
XposedBridge.log(TAG + " auto-detail empty json id=" + activityId);
return false;
}
MmpSnapshot probe = parseSnapshot(json);
enrichSnapshotFromJavaResult(result, probe);
if (probe == null) {
XposedBridge.log(TAG + " auto-detail parse null id=" + activityId);
return false;
}
if (TextUtils.isEmpty(probe.packetId)) {
probe.packetId = activityId;
}
if (probe.claims.isEmpty()) {
String snippet = json.length() > 240 ? json.substring(0, 240) + "..." : json;
XposedBridge.log(TAG + " auto-detail empty claims id=" + activityId
+ " body=" + snippet
+ " userId=" + stringField(req, "userId", "getUserId"));
if (!snapshotHasPacketMeta(probe) && !looksLikeValidEmptyPacket(result, activityId)) {
notePossibleDisconnectFromEmpty(result, activityId);
return false;
}
// 刚发出、还没人领:照样入库,方便列表看到「领取中 0人」
rememberPacketData(probe.packetId, probe, result);
forwardSnapshot(probe, "auto:" + activityId, "rpc");
XposedBridge.log(TAG + " auto-detail ok(empty) activityId=" + activityId
+ " status=" + probe.activityStatus
+ " total=" + probe.totalAmount
+ " issued=" + probe.issueTime);
noteSessionAlive();
return true;
}
rememberPacketData(probe.packetId != null ? probe.packetId : activityId, probe, result);
forwardSnapshot(probe, "auto:" + activityId, "rpc");
XposedBridge.log(TAG + " auto-detail ok activityId=" + activityId
+ " claims=" + probe.claims.size()
+ " issued=" + probe.issueTime
+ (COMPLETED_PACKETS.contains(activityId) ? " DONE" : ""));
noteSessionAlive();
return true;
} catch (Throwable t) {
XposedBridge.log(TAG + " auto-detail fail id=" + activityId + ": " + t.getMessage());
markMaybeDisconnected(t, "auto-detail");
return false;
}
}
private static Object newDetailRequest(Class<?> reqCls, String activityId, String senderUserId) {
// 优先: (activityId, senderUserId, loadTime, page, maxResult) — maxResult 拉大以尽量一次取全
try {
return XposedHelpers.newInstance(reqCls, activityId, senderUserId, null, 0, 200);
} catch (Throwable ignored) {
}
try {
return XposedHelpers.newInstance(reqCls, activityId, senderUserId, null, 0, 200,
31, null);
} catch (Throwable ignored) {
}
try {
Object req = reqCls.getDeclaredConstructor().newInstance();
XposedHelpers.setObjectField(req, "activityId", activityId);
XposedHelpers.setObjectField(req, "senderUserId", senderUserId);
bumpDetailPageSize(req);
return req;
} catch (Throwable t) {
XposedBridge.log(TAG + " newDetailRequest error: " + t.getMessage());
return null;
}
}
private static void fillLoginOnRequest(Object req, ClassLoader cl) {
ensureRpcTaskAndLogin(cl);
// 0) 从最近真实请求拷贝登录相关字段(历史页打开后即可,无需点详情)
Object donor = sLoginDonorRequest;
if (donor == null) {
donor = sTemplateHistoryRequest;
}
if (donor == null) {
donor = sTemplateDetailRequest;
}
if (donor != null && donor != req) {
copyLoginFields(donor, req);
cacheLoginFromRequestObject(donor);
}
// 1) 再用缓存的登录态覆盖
trySet(req, "setUserId", sCachedUserId);
trySet(req, "setSessionId", sCachedSessionId);
trySet(req, "setLoginId", sCachedLoginId);
try {
if (!TextUtils.isEmpty(sCachedUserId)) {
XposedHelpers.setObjectField(req, "userId", sCachedUserId);
}
if (!TextUtils.isEmpty(sCachedSessionId)) {
XposedHelpers.setObjectField(req, "sessionId", sCachedSessionId);
}
if (!TextUtils.isEmpty(sCachedLoginId)) {
XposedHelpers.setObjectField(req, "loginId", sCachedLoginId);
}
} catch (Throwable ignored) {
}
if (!TextUtils.isEmpty(stringField(req, "userId", "getUserId"))
&& !TextUtils.isEmpty(stringField(req, "sessionId", "getSessionId"))) {
return;
}
// 2) 从 Repository.loginStorage / ILoginStorage 再读一遍
Object loginStorage = obtainLoginStorage(cl);
if (loginStorage == null) {
return;
}
applyLoginStorage(loginStorage);
trySet(req, "setUserId", sCachedUserId);
trySet(req, "setSessionId", sCachedSessionId);
trySet(req, "setLoginId", sCachedLoginId);
try {
if (!TextUtils.isEmpty(sCachedUserId)) {
XposedHelpers.setObjectField(req, "userId", sCachedUserId);
}
if (!TextUtils.isEmpty(sCachedSessionId)) {
XposedHelpers.setObjectField(req, "sessionId", sCachedSessionId);
}
if (!TextUtils.isEmpty(sCachedLoginId)) {
XposedHelpers.setObjectField(req, "loginId", sCachedLoginId);
}
} catch (Throwable ignored) {
}
}
private static void copyLoginFields(Object src, Object dst) {
if (src == null || dst == null) {
return;
}
String[] keys = {"userId", "sessionId", "loginId", "securityId", "apiName"};
for (String key : keys) {
try {
Object v = null;
try {
v = XposedHelpers.getObjectField(src, key);
} catch (Throwable ignored) {
}
if (v == null) {
continue;
}
try {
XposedHelpers.setObjectField(dst, key, v);
} catch (Throwable ignored) {
}
String setter = "set" + Character.toUpperCase(key.charAt(0)) + key.substring(1);
trySet(dst, setter, v);
} catch (Throwable ignored) {
}
}
// extParams 等 Map 也尽量带上
try {
Object ext = XposedHelpers.getObjectField(src, "extParams");
if (ext != null) {
XposedHelpers.setObjectField(dst, "extParams", ext);
}
} catch (Throwable ignored) {
}
}
private static void trySet(Object req, String setter, Object value) {
if (value == null) {
return;
}
try {
XposedHelpers.callMethod(req, setter, value);
} catch (Throwable ignored) {
}
}
/** 被动缓存App 任何地方读 session 时同步到本 Hook */
private static void hookLoginStorage(XC_LoadPackage.LoadPackageParam lpparam) {
// 不能 hook 接口抽象方法;只挂实现类
try {
Class<?> impl = XposedHelpers.findClass(
"my.com.tngdigital.user.model.CacheLoginStorageImpl", lpparam.classLoader);
XC_MethodHook cacheHook = new XC_MethodHook() {
@Override
protected void afterHookedMethod(MethodHookParam param) {
Object v = param.getResult();
String name = param.method.getName();
if ("getSessionId".equals(name)) {
onSessionObserved(v == null ? "" : String.valueOf(v));
} else if (v == null || TextUtils.isEmpty(String.valueOf(v))) {
return;
} else if ("getAccountId".equals(name)) {
sCachedUserId = String.valueOf(v);
} else if ("getLoginId".equals(name)) {
sCachedLoginId = String.valueOf(v);
}
}
};
for (String m : new String[]{"getSessionId", "getAccountId", "getLoginId"}) {
XposedBridge.hookAllMethods(impl, m, cacheHook);
}
XposedBridge.log(TAG + " hooked CacheLoginStorageImpl getters");
} catch (Throwable t) {
XposedBridge.log(TAG + " CacheLoginStorageImpl hook skip: " + t.getMessage());
}
}
/** 前台 Activity 恢复时自动拉历史列表(再级联详情),无需手动进红包历史页 */
private static void hookActivityResumeForAutoHistory(XC_LoadPackage.LoadPackageParam lpparam) {
try {
XposedHelpers.findAndHookMethod(
"android.app.Activity",
lpparam.classLoader,
"onResume",
new XC_MethodHook() {
@Override
protected void afterHookedMethod(MethodHookParam param) {
try {
Object act = param.thisObject;
if (act == null) {
return;
}
String pkg = String.valueOf(
XposedHelpers.callMethod(act, "getPackageName"));
if (!PACKAGE.equals(pkg)) {
return;
}
String actName = act.getClass().getName();
if (actName.contains("MoneyPacketHistoryActivity")
&& sBounceHistoryPending) {
scheduleHistoryBounceFinish(act);
}
reportHookAlive("activity-resume");
if (sSessionDisconnected) {
tryRefreshLogin(sAppClassLoader);
scheduleAutoHistoryForced("reconnect-resume");
} else {
scheduleAutoHistory("activity-resume");
}
} catch (Throwable ignored) {
}
}
});
XposedBridge.log(TAG + " hooked Activity.onResume for auto history");
} catch (Throwable t) {
XposedBridge.log(TAG + " Activity.onResume hook skip: " + t.getMessage());
}
}
private static void scheduleAutoHistory(String reason) {
scheduleAutoHistoryInternal(reason, false);
}
/** 断线恢复后忽略冷却,立刻重拉历史/详情 */
private static void scheduleAutoHistoryForced(String reason) {
scheduleAutoHistoryInternal(reason, true);
}
private static void scheduleAutoHistoryInternal(String reason, boolean force) {
pollMmpSettings(false);
long now = System.currentTimeMillis();
if (sAutoHistoryRunning) {
return;
}
if (!force && now - sLastAutoHistoryAt < sHistoryCooldownMs) {
return;
}
sAutoHistoryRunning = true;
final String why = reason;
AUTO_DETAIL_EXEC.execute(() -> {
try {
// 等登录/Hilt/Network 就绪;不要在主线程阻塞等 RPC
Thread.sleep(why != null && why.startsWith("repo") ? 1500L : 800L);
boolean ok = fetchHistoryListAuto(why);
if (ok) {
sLastAutoHistoryAt = System.currentTimeMillis();
noteSessionAlive();
reportHookAlive("auto-history-ok");
}
} catch (InterruptedException ie) {
Thread.currentThread().interrupt();
} finally {
sAutoHistoryRunning = false;
}
});
}
/** 主动调 moneyPacketHistoryList结果走现有 scheduleAutoDetailsFromHistory */
private static boolean fetchHistoryListAuto(String reason) {
ClassLoader cl = sAppClassLoader;
ensureRpcTaskAndLogin(cl);
Object task = sMoneyPacketRpcTask;
if (cl == null || task == null) {
XposedBridge.log(TAG + " auto-history skip: no rpc task (" + reason + ")");
maybeBounceHistoryPage("no-rpc-task");
return false;
}
if (TextUtils.isEmpty(sCachedSessionId)) {
Object ls = obtainLoginStorage(cl);
if (ls != null) {
applyLoginStorage(ls);
}
}
if (TextUtils.isEmpty(sCachedSessionId)) {
XposedBridge.log(TAG + " auto-history skip: no sessionId yet (" + reason + ")");
if (sHadSession) {
sSessionDisconnected = true;
}
maybeBounceHistoryPage("no-session");
return false;
}
try {
Class<?> reqCls = XposedHelpers.findClass(
"my.com.tngdigital.funding.moneypacket.common.data.rpc.MoneyPacketHistoryListRequest",
cl);
// 1) 已有官方请求模板(首次打开历史页后会缓存)→ 静默复用
if (sTemplateHistoryRequest != null
&& reqCls.isInstance(sTemplateHistoryRequest)) {
Object req = cloneRequestShallow(sTemplateHistoryRequest);
XposedBridge.log(TAG + " auto-history using template (" + reason + ")");
fillLoginOnRequest(req, cl);
prepareRpcRequest(req);
AUTO_HISTORY_SELF.set(Boolean.TRUE);
Object result;
try {
result = callHistoryListRpc(task, req);
} finally {
AUTO_HISTORY_SELF.remove();
}
if (result != null) {
if (historyHasJobs(result)) {
XposedBridge.log(TAG + " auto-history ok(template) → schedule details");
scheduleAutoDetailsFromHistory(result);
} else {
XposedBridge.log(TAG + " auto-history ok(template) but empty list");
}
return true;
}
// 有模板但 RPC 失败:不要误当成「无模板」反复乱跳;按断线处理并短进历史页
XposedBridge.log(TAG + " auto-history template rpc failed (" + reason + ")");
maybeBounceHistoryPage("template-rpc-fail");
return false;
}
// 2) 无模板时:自造请求易「非法参数」,短进官方历史页让 App 发正确 RPC限次 + 冷却)
if (!sOpenHistoryIfNoTemplate && !sAutoBounceHistoryOnDisconnect) {
XposedBridge.log(TAG + " auto-history no template, openHistory disabled ("
+ reason + ")");
return false;
}
if (sNoTemplateOpenCount >= MAX_NO_TEMPLATE_OPEN) {
XposedBridge.log(TAG + " auto-history no template, open capped ("
+ reason + " count=" + sNoTemplateOpenCount + ")");
return false;
}
sNoTemplateOpenCount++;
XposedBridge.log(TAG + " auto-history no template → open HistoryActivity ("
+ reason + " #" + sNoTemplateOpenCount + ")");
openMoneyPacketHistoryActivity(true);
return false;
} catch (Throwable t) {
XposedBridge.log(TAG + " auto-history fail: " + t.getMessage());
markMaybeDisconnected(t, "auto-history");
maybeBounceHistoryPage("history-exception");
return false;
}
}
private static boolean historyHasJobs(Object historyResult) {
return !extractHistoryJobs(historyResult).isEmpty();
}
/** 断线/失败时短进历史页再建连(有冷却,避免刷屏) */
private static void maybeBounceHistoryPage(String reason) {
if (!sAutoBounceHistoryOnDisconnect) {
XposedBridge.log(TAG + " bounce history disabled (" + reason + ")");
return;
}
openMoneyPacketHistoryActivity(true);
XposedBridge.log(TAG + " bounce history for " + reason);
}
/** 拉起官方历史页bounceBack=true 时约 2 秒后自动 finish 返回 */
private static void openMoneyPacketHistoryActivity(boolean bounceBack) {
try {
long now = System.currentTimeMillis();
if (bounceBack) {
if (now - sLastHistoryBounceAt < HISTORY_BOUNCE_COOLDOWN_MS) {
XposedBridge.log(TAG + " bounce history cooldown skip ageMs="
+ (now - sLastHistoryBounceAt));
return;
}
}
// 已在历史页则不再 startActivity避免反复跳转
try {
Context ctx0 = getContext();
if (ctx0 instanceof android.app.Activity) {
String cur = ((android.app.Activity) ctx0).getClass().getName();
if (cur.contains("MoneyPacketHistoryActivity")) {
XposedBridge.log(TAG + " already on HistoryActivity, skip open");
if (bounceBack) {
sLastHistoryBounceAt = now;
sBounceHistoryPending = true;
scheduleHistoryBounceFinish(ctx0);
}
return;
}
}
} catch (Throwable ignored) {
}
if (bounceBack) {
sLastHistoryBounceAt = now;
sBounceHistoryPending = true;
}
Context ctx = getContext();
if (ctx == null) {
sBounceHistoryPending = false;
return;
}
android.content.Intent intent = new android.content.Intent();
intent.setClassName(PACKAGE,
"my.com.tngdigital.funding.moneypacket.create.ui.MoneyPacketHistoryActivity");
intent.addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK
| android.content.Intent.FLAG_ACTIVITY_NO_ANIMATION);
ctx.startActivity(intent);
XposedBridge.log(TAG + " started MoneyPacketHistoryActivity bounce=" + bounceBack);
} catch (Throwable t) {
sBounceHistoryPending = false;
XposedBridge.log(TAG + " start HistoryActivity fail: " + t.getMessage());
}
}
private static void scheduleHistoryBounceFinish(final Object activity) {
if (!(activity instanceof android.app.Activity)) {
return;
}
final android.app.Activity act = (android.app.Activity) activity;
MAIN_HANDLER.postDelayed(() -> {
try {
if (!sBounceHistoryPending) {
return;
}
if (act.isFinishing()) {
sBounceHistoryPending = false;
return;
}
act.finish();
XposedBridge.log(TAG + " bounce history finished → back");
} catch (Throwable t) {
XposedBridge.log(TAG + " bounce history finish fail: " + t.getMessage());
} finally {
sBounceHistoryPending = false;
}
}, HISTORY_BOUNCE_FINISH_DELAY_MS);
}
private static Object callHistoryListRpc(Object task, Object req) {
try {
return XposedHelpers.callMethod(task, "moneyPacketHistoryList", req);
} catch (Throwable t) {
XposedBridge.log(TAG + " history rpc fail: " + t.getMessage());
markMaybeDisconnected(t, "history-rpc");
return null;
}
}
private static void ensureRpcTaskAndLogin(ClassLoader cl) {
if (cl == null) {
return;
}
if (sMoneyPacketRpcTask == null || TextUtils.isEmpty(sCachedSessionId)) {
Object repo = obtainMoneyPacketRepository(cl);
if (repo != null) {
if (sMoneyPacketRpcTask == null) {
try {
Object task = XposedHelpers.getObjectField(repo, "task");
if (task != null) {
sMoneyPacketRpcTask = task;
XposedBridge.log(TAG + " cached RPC task from repository");
}
} catch (Throwable ignored) {
}
}
try {
Object loginStorage = XposedHelpers.getObjectField(repo, "loginStorage");
applyLoginStorage(loginStorage);
} catch (Throwable ignored) {
}
}
}
if (TextUtils.isEmpty(sCachedSessionId)) {
applyLoginStorage(obtainLoginStorage(cl));
}
}
private static void applyLoginStorage(Object loginStorage) {
if (loginStorage == null) {
return;
}
try {
Object accountId = XposedHelpers.callMethod(loginStorage, "getAccountId");
Object sessionId = XposedHelpers.callMethod(loginStorage, "getSessionId");
Object loginId = XposedHelpers.callMethod(loginStorage, "getLoginId");
if (accountId != null && !TextUtils.isEmpty(String.valueOf(accountId))) {
sCachedUserId = String.valueOf(accountId);
}
if (sessionId != null && !TextUtils.isEmpty(String.valueOf(sessionId))) {
onSessionObserved(String.valueOf(sessionId));
} else if (sHadSession) {
onSessionObserved("");
}
if (loginId != null && !TextUtils.isEmpty(String.valueOf(loginId))) {
sCachedLoginId = String.valueOf(loginId);
}
if (!TextUtils.isEmpty(sCachedSessionId)) {
XposedBridge.log(TAG + " login from storage session="
+ abbreviate(sCachedSessionId)
+ " userId=" + sCachedUserId);
}
} catch (Throwable t) {
XposedBridge.log(TAG + " applyLoginStorage skip: " + t.getMessage());
}
}
private static Object obtainLoginStorage(ClassLoader cl) {
try {
Object repo = obtainMoneyPacketRepository(cl);
if (repo != null) {
try {
Object ls = XposedHelpers.getObjectField(repo, "loginStorage");
if (ls != null) {
return ls;
}
} catch (Throwable ignored) {
}
}
} catch (Throwable ignored) {
}
// Hilt尝试常见 EntryPoint 上返回 ILoginStorage 的无参方法
try {
Context ctx = getContext();
if (ctx == null || cl == null) {
return null;
}
String[] entryPoints = {
"my.com.tngdigital.member.di.MemberEntryPoint",
"my.com.tngdigital.user.di.UserEntryPoint",
"my.com.tngdigital.common.di.CommonEntryPoint",
};
Class<?> entryPointsCls = Class.forName("dagger.hilt.EntryPoints", false, cl);
for (String epName : entryPoints) {
try {
Class<?> epCls = Class.forName(epName, false, cl);
Object ep = XposedHelpers.callStaticMethod(
entryPointsCls, "get", ctx.getApplicationContext(), epCls);
for (Method m : ep.getClass().getMethods()) {
if (m.getParameterTypes().length != 0) {
continue;
}
if (m.getReturnType().getName().contains("ILoginStorage")) {
Object ls = m.invoke(ep);
if (ls != null) {
return ls;
}
}
}
} catch (Throwable ignored) {
}
}
} catch (Throwable ignored) {
}
return null;
}
private static Object obtainMoneyPacketRepository(ClassLoader cl) {
Context ctx = getContext();
if (ctx == null || cl == null) {
return null;
}
Context appCtx = ctx.getApplicationContext() != null ? ctx.getApplicationContext() : ctx;
Class<?> entryPoint;
try {
entryPoint = XposedHelpers.findClass(
"my.com.tngdigital.funding.moneypacket.common.di.MoneyPacketEntryPoint", cl);
} catch (Throwable t) {
XposedBridge.log(TAG + " MoneyPacketEntryPoint missing: " + t.getMessage());
return null;
}
// 1) EntryPointAccessors.fromApplicationHilt Android 推荐)
try {
Class<?> accessors = XposedHelpers.findClass(
"dagger.hilt.android.EntryPointAccessors", cl);
Object ep = XposedHelpers.callStaticMethod(
accessors, "fromApplication", appCtx, entryPoint);
Object repo = invokeRepository(ep);
if (repo != null) {
return repo;
}
} catch (Throwable t) {
XposedBridge.log(TAG + " EntryPointAccessors fail: " + t.getMessage());
}
// 2) EntryPoints.get
try {
Class<?> entryPoints = XposedHelpers.findClass("dagger.hilt.EntryPoints", cl);
Object ep = XposedHelpers.callStaticMethod(
entryPoints, "get", appCtx, entryPoint);
Object repo = invokeRepository(ep);
if (repo != null) {
return repo;
}
} catch (Throwable t) {
XposedBridge.log(TAG + " EntryPoints.get fail: " + t.getMessage());
}
return null;
}
private static Object invokeRepository(Object ep) {
if (ep == null) {
return null;
}
try {
return XposedHelpers.callMethod(ep, "repository");
} catch (Throwable ignored) {
}
for (Method m : ep.getClass().getMethods()) {
if (m.getParameterTypes().length == 0
&& m.getReturnType().getName().contains("MoneyPacketRepository")) {
try {
return m.invoke(ep);
} catch (Throwable ignored) {
}
}
}
return null;
}
private static String extractMoneyPacketRpcOp(Object[] args) {
if (args == null) {
return null;
}
for (Object arg : args) {
if (arg instanceof Method) {
String name = ((Method) arg).getName().toLowerCase(Locale.US);
if (name.contains("moneypacket") || name.contains("mmp")) {
return ((Method) arg).getName();
}
} else if (arg instanceof String) {
String text = (String) arg;
String lower = text.toLowerCase(Locale.US);
if (lower.contains("moneypacket") || lower.contains(".mmp")
|| lower.contains("tngdwallet.money")) {
return text;
}
}
}
return null;
}
private static String tryFastjson(Object obj) {
try {
Class<?> json = Class.forName("com.alibaba.fastjson.JSON", false,
obj.getClass().getClassLoader());
Object out = XposedHelpers.callStaticMethod(json, "toJSONString", obj);
return out != null ? String.valueOf(out) : null;
} catch (Throwable ignored) {
return null;
}
}
private static void forwardParsedJson(String json, String sourceHint, String channel) {
MmpSnapshot snapshot = parseSnapshot(json);
if (snapshot == null || snapshot.claims.isEmpty()) {
return;
}
forwardSnapshot(snapshot, sourceHint, channel);
}
private static void forwardSnapshot(MmpSnapshot snapshot, String sourceHint, String channel) {
if (snapshot == null) {
return;
}
if (snapshot.claims.isEmpty() && !snapshotHasPacketMeta(snapshot)) {
return;
}
dedupeClaims(snapshot);
snapshot.applyCachedIssueTime();
rememberPacketData(snapshot.packetId, snapshot, null);
if (!shouldForwardPacket(snapshot)) {
return;
}
Context context = getContext();
if (context == null) {
XposedBridge.log(TAG + " skip forward: no context, claims="
+ snapshot.claims.size());
return;
}
String title = TextUtils.isEmpty(snapshot.title) ? "TNG 红包" : snapshot.title;
if (!TextUtils.isEmpty(snapshot.packetId) && snapshot.packetId.length() >= 8) {
title = title + " · " + snapshot.packetId.substring(0, 8);
}
String content = snapshot.formatForForward(channel, sourceHint);
HookForwarder.forward(context, PACKAGE, title, content, HookBridge.SOURCE_XPOSED_TNG_MMP);
XposedBridge.log(TAG + " captured activityId=" + snapshot.packetId
+ " claims=" + snapshot.claims.size()
+ " issued=" + snapshot.issueTime
+ (snapshot.finished ? " DONE" : "")
+ " via " + channel);
}
/** 直接从 Java 结果对象读 gmtModified / createTime不依赖 JSON 字段是否序列化成功 */
private static void enrichSnapshotFromJavaResult(Object result, MmpSnapshot snapshot) {
if (result == null || snapshot == null) {
return;
}
try {
Object pools = null;
try {
pools = XposedHelpers.callMethod(result, "getActivityPoolInfos");
} catch (Throwable ignored) {
}
if (pools == null) {
try {
pools = XposedHelpers.getObjectField(result, "activityPoolInfos");
} catch (Throwable ignored) {
}
}
if (pools instanceof Iterable) {
for (Object info : (Iterable<?>) pools) {
if (info == null) {
continue;
}
String name = stringField(info, "receiverName", "getReceiverName");
String receiverId = firstNonEmpty(
stringField(info, "receiverId", "getReceiverId"),
stringField(info, "userId", "getUserId"),
stringField(info, "receiverUserId", "getReceiverUserId"));
String poolId = stringField(info, "activityPoolId", "getActivityPoolId");
String claimTime = firstNonEmpty(
stringField(info, "gmtModified", "getGmtModified"),
stringField(info, "gmtCreate", "getGmtCreate"),
stringField(info, "createTime", "getCreateTime"));
String amount = null;
try {
Object money = XposedHelpers.callMethod(info, "getAmount");
if (money != null) {
amount = normalizeMoneyText(String.valueOf(
XposedHelpers.callMethod(money, "getAmount")));
}
} catch (Throwable ignored) {
}
if (TextUtils.isEmpty(amount)) {
amount = normalizeMoneyText(stringField(info, "amount", "getAmount"));
}
if (TextUtils.isEmpty(name) && TextUtils.isEmpty(receiverId)) {
continue;
}
if (TextUtils.isEmpty(name)) {
name = "用户";
}
boolean matched = false;
for (ClaimLine line : snapshot.claims) {
boolean sameId = !TextUtils.isEmpty(receiverId)
&& receiverId.equals(line.receiverId);
boolean sameName = TextUtils.isEmpty(receiverId)
&& name.equals(line.nickname);
if (sameId || sameName) {
if (TextUtils.isEmpty(line.claimTime) && !TextUtils.isEmpty(claimTime)) {
line.claimTime = claimTime;
}
if (TextUtils.isEmpty(line.receiverId) && !TextUtils.isEmpty(receiverId)) {
line.receiverId = receiverId;
}
if (TextUtils.isEmpty(line.poolId) && !TextUtils.isEmpty(poolId)) {
line.poolId = poolId;
}
matched = true;
}
}
if (!matched && !TextUtils.isEmpty(amount)) {
ClaimLine line = new ClaimLine();
line.nickname = name;
line.receiverId = receiverId;
line.poolId = poolId;
line.amount = amount;
line.claimTime = claimTime;
snapshot.claims.add(line);
}
}
}
Object summary = null;
try {
summary = XposedHelpers.callMethod(result, "getSummaryInfo");
} catch (Throwable ignored) {
}
if (summary == null) {
try {
summary = XposedHelpers.getObjectField(result, "summaryInfo");
} catch (Throwable ignored) {
}
}
if (summary != null) {
if (TextUtils.isEmpty(snapshot.senderName)) {
snapshot.senderName = stringField(summary, "senderName", "getSenderName");
}
if (TextUtils.isEmpty(snapshot.packetId)) {
snapshot.packetId = stringField(summary, "activityId", "getActivityId");
}
if (TextUtils.isEmpty(snapshot.totalAmount)) {
try {
Object money = XposedHelpers.callMethod(summary, "getTotalAmount");
if (money != null) {
snapshot.totalAmount = normalizeMoneyText(String.valueOf(
XposedHelpers.callMethod(money, "getAmount")));
}
} catch (Throwable ignored) {
}
}
snapshot.remainingCount = firstNonEmpty(
stringField(summary, "remainingCount", "getRemainingCount"),
stringField(result, "remainingCount", "getRemainingCount"));
snapshot.claimedCount = firstNonEmpty(
stringField(summary, "claimedCount", "getClaimedCount"),
stringField(result, "claimedCount", "getClaimedCount"));
snapshot.totalCount = firstNonEmpty(
stringField(summary, "totalCount", "getTotalCount"),
stringField(result, "totalCount", "getTotalCount"));
snapshot.activityStatus = firstNonEmpty(
stringField(summary, "activityStatus", "getActivityStatus"),
stringField(result, "activityStatus", "getActivityStatus"),
boolFieldText(summary, "isFinished", "isFinished"),
boolFieldText(result, "isFinished", "isFinished"));
snapshot.expireTime = firstNonEmpty(
stringField(summary, "expireTime", "getExpireTime"),
stringField(result, "expireTime", "getExpireTime"),
stringField(summary, "expiryTime", "getExpiryTime"),
stringField(result, "expiryTime", "getExpiryTime"));
if (TextUtils.isEmpty(snapshot.claimedAmountText)) {
try {
Object money = XposedHelpers.callMethod(summary, "getClaimedAmount");
if (money != null) {
snapshot.claimedAmountText = normalizeMoneyText(String.valueOf(
XposedHelpers.callMethod(money, "getAmount")));
}
} catch (Throwable ignored) {
}
}
}
snapshot.applyCachedIssueTime();
snapshot.finished = evaluateFinished(snapshot);
} catch (Throwable t) {
XposedBridge.log(TAG + " enrich times failed: " + t.getMessage());
}
}
private static void cacheIssueTimesFromHistory(Object historyResult) {
if (historyResult == null) {
return;
}
try {
for (String[] job : extractHistoryJobs(historyResult)) {
if (job.length > 2 && looksLikeActivityId(job[0]) && !TextUtils.isEmpty(job[2])) {
synchronized (ACTIVITY_ISSUE_TIME) {
ACTIVITY_ISSUE_TIME.put(job[0], job[2]);
}
}
}
XposedBridge.log(TAG + " cached issue times size=" + ACTIVITY_ISSUE_TIME.size());
} catch (Throwable t) {
XposedBridge.log(TAG + " cache issue times failed: " + t.getMessage());
}
}
private static void dedupeClaims(MmpSnapshot snapshot) {
if (snapshot == null || snapshot.claims.size() <= 1) {
return;
}
java.util.LinkedHashMap<String, ClaimLine> map = new java.util.LinkedHashMap<>();
for (ClaimLine line : snapshot.claims) {
if (line == null) {
continue;
}
if (TextUtils.isEmpty(line.nickname) && TextUtils.isEmpty(line.receiverId)) {
continue;
}
String key = !TextUtils.isEmpty(line.receiverId)
? ("id:" + line.receiverId)
: ("n:" + line.nickname);
ClaimLine old = map.get(key);
if (old == null) {
map.put(key, line);
continue;
}
double oldAmt = parseMoneyDouble(old.amount) != null ? parseMoneyDouble(old.amount) : -1;
double newAmt = parseMoneyDouble(line.amount) != null ? parseMoneyDouble(line.amount) : -1;
if (newAmt >= oldAmt) {
if (TextUtils.isEmpty(line.claimTime) && !TextUtils.isEmpty(old.claimTime)) {
line.claimTime = old.claimTime;
}
if (TextUtils.isEmpty(line.receiverId)) {
line.receiverId = old.receiverId;
}
if (TextUtils.isEmpty(line.poolId)) {
line.poolId = old.poolId;
}
map.put(key, line);
} else {
if (TextUtils.isEmpty(old.claimTime) && !TextUtils.isEmpty(line.claimTime)) {
old.claimTime = line.claimTime;
}
if (TextUtils.isEmpty(old.receiverId) && !TextUtils.isEmpty(line.receiverId)) {
old.receiverId = line.receiverId;
}
if (TextUtils.isEmpty(old.poolId) && !TextUtils.isEmpty(line.poolId)) {
old.poolId = line.poolId;
}
}
}
snapshot.claims.clear();
snapshot.claims.addAll(map.values());
}
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();
boolean rootPass = base == null;
// 领取行不再回填 meta其它嵌套节点可补 packetId/sender/total
if (rootPass || parseClaimLine(root) == null) {
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<String> 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) {
snapshot.claims.add(line);
}
parseSnapshot((JSONObject) elem, snapshot);
}
}
}
}
if (rootPass && TextUtils.isEmpty(snapshot.packetId) && !snapshot.claims.isEmpty()) {
snapshot.packetId = "fp" + Integer.toHexString(snapshot.dedupKey().hashCode());
}
return snapshot;
}
private static JSONArray findReceiverList(JSONObject obj) {
Iterator<String> keys = obj.keys();
while (keys.hasNext()) {
String key = keys.next();
Object val = obj.opt(key);
if (val instanceof JSONArray && isReceiverListKey(key)) {
return (JSONArray) val;
}
if (val instanceof JSONObject) {
JSONArray nested = findReceiverList((JSONObject) val);
if (nested != null) {
return nested;
}
}
}
return null;
}
private static boolean isReceiverListKey(String key) {
if (TextUtils.isEmpty(key)) {
return false;
}
String k = key.toLowerCase(Locale.US);
return "receiverlist".equals(k)
|| "leaderboardlist".equals(k)
|| "leaderboard".equals(k)
|| "rankinglist".equals(k)
|| "claimlist".equals(k)
|| "claimers".equals(k)
|| "packetreceiverlist".equals(k)
|| "receivers".equals(k)
|| "activitypoolinfos".equals(k)
|| k.contains("receiverlist")
|| k.contains("leaderboard")
|| k.contains("activitypool");
}
private static void fillMeta(JSONObject obj, MmpSnapshot snapshot) {
// activityId 是红包唯一标识UUID优先于其它 id 字段
putIfPresent(obj, snapshot, "activityId", "packetId", "mmpId", "moneyPacketId",
"packetCode", "fundOrderId", "orderNo", "bizNo", "bizOrderId");
putIfPresent(obj, snapshot, "groupId", "chatId", "conversationId");
putIfPresent(obj, snapshot, "groupName", "chatName", "conversationName");
putIfPresent(obj, snapshot, "senderName", "senderNickName", "operatorName", "creatorName");
putIfPresent(obj, snapshot, "totalAmount", "packetAmount", "packetTotalAmount",
"totalPacketAmount", "originalAmount");
putIfPresent(obj, snapshot, "createTime", "createdTime", "gmtCreate", "issueTime",
"issuedTime", "sendTime", "packetCreateTime");
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 = readFieldText(obj, key);
if (TextUtils.isEmpty(val) || "null".equalsIgnoreCase(val)) {
continue;
}
String keyLower = key.toLowerCase(Locale.US);
if (keyLower.contains("createtime") || keyLower.contains("created")
|| keyLower.contains("gmtcreate") || keyLower.contains("issue")
|| keyLower.contains("sendtime")) {
if (TextUtils.isEmpty(snapshot.issueTime) && !looksLikeMoneyJson(val)) {
snapshot.issueTime = val;
}
} else if (keyLower.equals("activityid") || keyLower.contains("packet")
|| keyLower.contains("mmp") || keyLower.contains("order")
|| keyLower.contains("biz") || keyLower.equals("packetcode")) {
// 已有 UUID 形态的 activityId 时不覆盖
if (TextUtils.isEmpty(snapshot.packetId) && !looksLikeMoneyJson(val)) {
snapshot.packetId = val;
} else if (!TextUtils.isEmpty(snapshot.packetId)
&& keyLower.equals("activityid")
&& looksLikeActivityId(val)) {
snapshot.packetId = val;
}
} else if (keyLower.contains("group") || keyLower.contains("chat")
|| keyLower.contains("conversation")) {
if (keyLower.contains("name")) {
if (TextUtils.isEmpty(snapshot.title)) {
snapshot.title = val;
}
} else if (TextUtils.isEmpty(snapshot.groupId)) {
snapshot.groupId = val;
}
} else if (keyLower.contains("sender") || keyLower.contains("operator")
|| keyLower.contains("creator")) {
if (TextUtils.isEmpty(snapshot.senderName) && !looksLikeMoneyJson(val)) {
snapshot.senderName = val;
}
} else if (keyLower.contains("total") || keyLower.contains("packetamount")
|| keyLower.contains("original")) {
String money = normalizeMoneyText(val);
if (TextUtils.isEmpty(snapshot.totalAmount) && !TextUtils.isEmpty(money)) {
snapshot.totalAmount = money;
}
}
}
}
private static boolean looksLikeActivityId(String val) {
if (TextUtils.isEmpty(val)) {
return false;
}
// b8c37a58-bb2c-4aa2-83ef-f15466b9211e
return val.length() >= 32 && val.indexOf('-') > 0;
}
private static boolean looksLikeClaimRow(JSONObject obj) {
return parseClaimLine(obj) != null;
}
private static ClaimLine parseClaimLine(JSONObject obj) {
String name = firstNonEmpty(
readFieldText(obj, "nickName"),
readFieldText(obj, "nickname"),
readFieldText(obj, "displayName"),
readFieldText(obj, "userName"),
readFieldText(obj, "receiverName"),
readFieldText(obj, "receiverAccountName"),
readFieldText(obj, "accountName"),
readFieldText(obj, "participantName"),
readFieldText(obj, "name"));
String amount = firstNonEmpty(
normalizeMoneyText(readFieldText(obj, "claimedAmount")),
normalizeMoneyText(readFieldText(obj, "receiveAmount")),
normalizeMoneyText(readFieldText(obj, "receiverClaimedAmount")),
normalizeMoneyText(readFieldText(obj, "claimAmount")),
normalizeMoneyText(readFieldText(obj, "amount")));
if (TextUtils.isEmpty(name) || TextUtils.isEmpty(amount)) {
return null;
}
if (name.length() > 64 || looksLikeMoneyJson(name)) {
return null;
}
ClaimLine line = new ClaimLine();
line.nickname = name.trim();
line.amount = amount;
line.receiverId = firstNonEmpty(
readFieldText(obj, "receiverId"),
readFieldText(obj, "receiverUserId"),
readFieldText(obj, "userId"),
readFieldText(obj, "participantId"));
// userName 字段误当 id 时:纯数字/长串才留
if (!TextUtils.isEmpty(line.receiverId) && line.receiverId.equals(name)) {
line.receiverId = null;
}
line.poolId = firstNonEmpty(
readFieldText(obj, "activityPoolId"),
readFieldText(obj, "poolId"));
line.claimTime = firstNonEmpty(
readFieldText(obj, "claimTime"),
readFieldText(obj, "claimedTime"),
readFieldText(obj, "receiveTime"),
readFieldText(obj, "gmtClaim"),
readFieldText(obj, "gmtModified"),
readFieldText(obj, "gmtModify"),
readFieldText(obj, "gmtCreate"),
readFieldText(obj, "createTime"),
readFieldText(obj, "time"));
return line;
}
/** 读取字段:兼容 Money 对象 / 嵌套 JSON / 普通字符串。 */
private static String readFieldText(JSONObject obj, String key) {
if (obj == null || TextUtils.isEmpty(key) || !obj.has(key)) {
return null;
}
Object raw = obj.opt(key);
if (raw == null || raw == JSONObject.NULL) {
return null;
}
if (raw instanceof JSONObject) {
return normalizeMoneyText(((JSONObject) raw).toString());
}
String text = String.valueOf(raw).trim();
if (TextUtils.isEmpty(text) || "null".equalsIgnoreCase(text)) {
return null;
}
if (looksLikeMoneyJson(text)) {
return normalizeMoneyText(text);
}
return text;
}
private static boolean looksLikeMoneyJson(String text) {
if (TextUtils.isEmpty(text)) {
return false;
}
String t = text.trim();
return t.startsWith("{") && t.contains("\"amount\"");
}
private static String normalizeMoneyText(String text) {
if (TextUtils.isEmpty(text) || "null".equalsIgnoreCase(text)) {
return null;
}
String t = text.trim().replace("RM", "").trim();
if (looksLikeMoneyJson(t)) {
try {
JSONObject money = new JSONObject(t);
String amount = money.optString("amount", null);
if (!TextUtils.isEmpty(amount) && !"null".equalsIgnoreCase(amount)) {
return amount.trim();
}
String cent = money.optString("cent", null);
if (!TextUtils.isEmpty(cent) && !"null".equalsIgnoreCase(cent)) {
try {
return String.format(Locale.US, "%.2f", Integer.parseInt(cent) / 100.0);
} catch (Throwable ignored) {
}
}
} catch (Throwable ignored) {
}
return null;
}
// 纯数字或 1.00
try {
return String.format(Locale.US, "%.2f", Double.parseDouble(t.replace(",", "")));
} catch (Throwable ignored) {
return t;
}
}
private static String buildJsonFromObject(Object root) {
JSONObject json = objectToJson(root, new HashSet<Integer>(), 0);
return json != null ? json.toString() : null;
}
private static JSONObject objectToJson(Object obj, Set<Integer> 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<Integer> 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 shouldForwardPacket(MmpSnapshot snapshot) {
String packetId = snapshot.packetId;
if (TextUtils.isEmpty(packetId)) {
packetId = "fp" + Integer.toHexString(snapshot.dedupKey().hashCode());
snapshot.packetId = packetId;
}
String claimsFp = snapshot.claimsFingerprint();
long now = System.currentTimeMillis();
long dedupMs = sPacketDedupMs;
synchronized (RECENT_PACKET_AT) {
Long lastAt = RECENT_PACKET_AT.get(packetId);
String lastClaims = RECENT_PACKET_CLAIMS.get(packetId);
if (lastAt != null && lastClaims != null
&& claimsFp.equals(lastClaims)
&& now - lastAt < dedupMs) {
XposedBridge.log(TAG + " skip dup activityId=" + packetId
+ " ageMs=" + (now - lastAt));
return false;
}
RECENT_PACKET_AT.put(packetId, now);
RECENT_PACKET_CLAIMS.put(packetId, claimsFp);
// 顺带清理过期项,防止无限涨
if (RECENT_PACKET_AT.size() > DEDUP_SIZE) {
java.util.Iterator<java.util.Map.Entry<String, Long>> it =
RECENT_PACKET_AT.entrySet().iterator();
while (it.hasNext()) {
java.util.Map.Entry<String, Long> e = it.next();
if (now - e.getValue() > Math.max(dedupMs, 1L) * 3) {
String k = e.getKey();
it.remove();
RECENT_PACKET_CLAIMS.remove(k);
}
}
}
}
return true;
}
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 void reportHookAlive(String reason) {
long now = System.currentTimeMillis();
if (!"install".equals(reason) && now - sLastHookStatusAt < HOOK_STATUS_COOLDOWN_MS) {
return;
}
Context ctx = getContext();
if (ctx == null) {
return;
}
sLastHookStatusAt = now;
HookForwarder.reportStatus(ctx, PACKAGE, HookBridge.SOURCE_XPOSED_TNG_MMP);
XposedBridge.log(TAG + " hook status reported (" + reason + ") mod="
+ HookBridge.MODULE_VERSION_NAME);
}
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;
/** 发放时间(历史 createTime 或详情里的 createTime */
String issueTime;
String remainingCount;
String claimedCount;
String totalCount;
String activityStatus;
String expireTime;
String claimedAmountText;
boolean finished;
final List<ClaimLine> claims = new ArrayList<>();
String dedupKey() {
StringBuilder sb = new StringBuilder();
sb.append(TextUtils.isEmpty(packetId) ? "?" : packetId).append('|');
sb.append(claimsFingerprint());
return sb.toString();
}
String claimsFingerprint() {
StringBuilder sb = new StringBuilder();
for (ClaimLine line : claims) {
sb.append(line.nickname).append('=').append(line.amount).append(';');
}
return sb.toString();
}
void applyCachedIssueTime() {
if (!TextUtils.isEmpty(issueTime) || TextUtils.isEmpty(packetId)) {
return;
}
synchronized (ACTIVITY_ISSUE_TIME) {
String cached = ACTIVITY_ISSUE_TIME.get(packetId);
if (!TextUtils.isEmpty(cached)) {
issueTime = cached;
}
}
}
String formatForForward(String channel, String sourceHint) {
applyCachedIssueTime();
if (!finished) {
finished = evaluateFinished(this);
}
// 详情无发放时间时,用最早领取时间兜底(仍优于拉取时间)
if (TextUtils.isEmpty(issueTime)) {
for (ClaimLine line : claims) {
if (!TextUtils.isEmpty(line.claimTime)) {
if (TextUtils.isEmpty(issueTime)
|| line.claimTime.compareTo(issueTime) < 0) {
issueTime = line.claimTime;
}
}
}
}
StringBuilder sb = new StringBuilder();
sb.append("[MMP统计]");
if (!TextUtils.isEmpty(packetId)) {
sb.append(" packet=").append(sanitizeMeta(packetId));
}
if (!TextUtils.isEmpty(groupId)) {
sb.append(" | group=").append(sanitizeMeta(groupId));
}
if (!TextUtils.isEmpty(senderName)) {
sb.append(" | sender=").append(sanitizeMeta(senderName));
}
if (!TextUtils.isEmpty(totalAmount)) {
sb.append(" | total=").append(sanitizeMeta(totalAmount));
}
if (!TextUtils.isEmpty(issueTime)) {
sb.append(" | issued=").append(sanitizeMeta(issueTime));
}
if (!TextUtils.isEmpty(expireTime)) {
sb.append(" | expire=").append(sanitizeMeta(expireTime));
}
if (!TextUtils.isEmpty(activityStatus)) {
sb.append(" | status=").append(sanitizeMeta(activityStatus));
}
String claimed = claimedCount;
String totalCnt = totalCount;
if (TextUtils.isEmpty(claimed) && claims != null) {
claimed = String.valueOf(claims.size());
}
if (!TextUtils.isEmpty(claimed) || !TextUtils.isEmpty(totalCnt)) {
sb.append(" | counts=").append(sanitizeMeta(nullToEmpty(claimed)))
.append('/').append(sanitizeMeta(nullToEmpty(totalCnt)));
}
if (finished) {
sb.append(" | done=1");
}
sb.append(" | via=").append(sanitizeMeta(channel));
sb.append(" | mod=").append(HookBridge.MODULE_VERSION_NAME)
.append('/').append(HookBridge.MODULE_VERSION_CODE);
if (!TextUtils.isEmpty(sourceHint)) {
String src = sourceHint.length() > 120
? sourceHint.substring(0, 120) + "..." : sourceHint;
sb.append(" | src=").append(sanitizeMeta(src));
}
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(')');
}
if (!TextUtils.isEmpty(line.receiverId)) {
sb.append(" #rid=").append(sanitizeMeta(line.receiverId));
}
if (!TextUtils.isEmpty(line.poolId)) {
sb.append(" #pool=").append(sanitizeMeta(line.poolId));
}
sb.append('\n');
}
return sb.toString().trim();
}
private static String sanitizeMeta(String value) {
if (value == null) {
return "";
}
// 避免空格拆坏 meta竖线是分隔符
return value.replace('|', '/').trim();
}
}
private static final class ClaimLine {
String nickname;
String receiverId;
String poolId;
String amount;
String claimTime;
}
}

View File

@@ -0,0 +1,2537 @@
package com.miraclegarden.smsmessage.xposed.hook;
import android.app.Activity;
import android.app.Instrumentation;
import android.content.Context;
import android.content.Intent;
import android.net.Uri;
import android.app.Application;
import android.app.Dialog;
import android.app.ActivityManager;
import android.os.Build;
import android.os.Bundle;
import android.os.Handler;
import android.os.Looper;
import android.os.Process;
import android.view.Window;
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;
import java.lang.reflect.Proxy;
import java.util.Collections;
import java.util.Locale;
import java.util.Set;
import java.util.WeakHashMap;
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 Root / Promon / JailBroken 检测绕过。
* 包名my.com.tngdigital.ewalletv1.9.9+
*/
public final class TngRootBypassHook {
public static final String PACKAGE = "my.com.tngdigital.ewallet";
private static final String TAG = "notiMessageHook/TngRoot";
/** Promon 混淆包名1.9.10 为 vhvlnqgy旧版为 xwwqazamx。 */
private static final String[] PROMON_PKG_PREFIXES = {"vhvlnqgy", "xwwqazamx"};
private static final String SECURITY_ERROR_ACTIVITY =
"my.com.tngdigital.common.security.ui.SecurityErrorActivity";
private static final String[] BLOCKED_SUPPORT_MARKERS = {
"36616543382169-rooting",
"36616508159769-emulator",
"36616480108697-malicious",
"36616444815257-multiboxing",
"45485757513113-private-space",
"48493749288857-malware",
"/articles/",
"support.tngdigital.com.my/hc/",
};
private static volatile long lastBlockedSuicideAt = 0L;
private static final long SOFT_CRASH_GUARD_MS = 10000L;
private static final String[] REGISTRATION_FLOW_MARKERS = {
"GuideActivity",
"UserRegistrationMobileActivity",
"UserOtpVerificationActivity",
"UserRegistrationIdentityActivity",
"UserRegistrationSixPinActivity",
"UserRegistrationStrengthenPinActivity",
"UserRegistrationSecurityQuestionActivity",
"UserRegistrationSuccessActivity",
"UserSearchCallingCodeActivity",
"UserPinActivity",
"UserSecurePinActivity",
"UserPinVerifyActivity",
"EmailOtpVerificationActivity",
};
private static final String[] REGISTRATION_RPC_MARKERS = {
"phonecheck", "com.abl.wallet.phone", "com.abl.wallet.otp",
"customer.registration", "customer.verify", "customer.login",
"login.options", "callingcode", "pin.token", "module.whitelist",
"secauth",
};
private TngRootBypassHook() {
}
public static boolean isTargetPackage(String packageName) {
return PACKAGE.equals(packageName);
}
public static void install(XC_LoadPackage.LoadPackageParam lpparam) {
if (!isTargetPackage(lpparam.packageName)) {
return;
}
XposedBridge.log(TAG + " install for " + lpparam.packageName
+ " pid=" + Process.myPid()
+ " proc=" + getProcessName());
hookEarlyAttachLog(lpparam);
hookConscryptStatsLogGuard();
// 登录优先:藏 root + 拦自杀 Java 链 + bl#a/b不碰 native-bridge 探测短路。
hookSplashForceLogin(lpparam);
hookLoginDismissSplash(lpparam);
// 点区号会走 onCountryClick → i7.l loading真 show → HWUI setName/gralloc abort
hookLoadingDialogSkip();
// setName 一律 noopA16+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);
// 首页强制 eKYC「验证您的帐户」— 测试期直接跳过
hookHomeEkycVerifySkip(lpparam);
// seccomp 开着时必须 stub TigerTally init否则 fork getprop 永不退出 → App.onCreate ANR
hookTigerTally(lpparam);
hookTigerTallyAppWrappers(lpparam);
XposedBridge.log(TAG + " login-first hooks armed");
}
/** 进程启动最早打点,便于确认 LSPosed 是否注入(注册闪退常因 hook 未生效)。 */
private static void hookEarlyAttachLog(XC_LoadPackage.LoadPackageParam lpparam) {
try {
XposedHelpers.findAndHookMethod(Application.class, "attachBaseContext", Context.class,
new XC_MethodHook() {
@Override
protected void afterHookedMethod(MethodHookParam param) {
Context ctx = (Context) param.args[0];
if (ctx != null && PACKAGE.equals(ctx.getPackageName())) {
XposedBridge.log(TAG + " attachBaseContext pid="
+ Process.myPid());
}
}
});
} catch (Throwable t) {
XposedBridge.log(TAG + " early attach log failed: " + t.getMessage());
}
}
/** 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;
/**
* 仅当 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);
}
pendingForceLoginRunnable = new Runnable() {
@Override
public void run() {
pendingForceLoginRunnable = null;
try {
if (splashNavigationDone) {
XposedBridge.log(TAG + " skip splash rescue (" + reason + ", already left)");
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 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, target);
intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP
| Intent.FLAG_ACTIVITY_SINGLE_TOP);
if (splashAct != null && !splashAct.isFinishing()) {
splashAct.startActivity(intent);
try {
splashAct.finish();
} catch (Throwable ignored) {
}
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 + " splash stuck → " + shortActivityName(target)
+ " (" + reason + ", session=" + hasSession + ", from AppCtx)");
}
splashNavigationDone = true;
} catch (Throwable t) {
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("</string>")
&& !lower.contains("><"))) {
return true;
}
if (lower.contains("<string") && lower.contains("</string>")) {
int a = lower.indexOf('>');
int b = lower.lastIndexOf("</string>");
if (a >= 0 && b > a + 1 && (b - a) > 8) {
return true;
}
}
if (lower.contains("<boolean") && lower.contains("value=\"true\"")) {
return true;
}
}
}
} catch (Throwable ignored) {
} finally {
if (reader != null) {
try {
reader.close();
} catch (Throwable ignored) {
}
}
}
return false;
}
/** 后台 Service 重启(如 Firebase SessionLifecycle无可见 Activity强拉会被 BAL 拦截。 */
private static boolean isUiVisibleForForceLogin(Context ctx) {
ActivityManager.RunningAppProcessInfo state = new ActivityManager.RunningAppProcessInfo();
ActivityManager.getMyMemoryState(state);
if (state.importance > ActivityManager.RunningAppProcessInfo.IMPORTANCE_VISIBLE) {
return false;
}
try {
ActivityManager am = (ActivityManager) ctx.getSystemService(Context.ACTIVITY_SERVICE);
if (am != null) {
for (ActivityManager.AppTask task : am.getAppTasks()) {
ActivityManager.RecentTaskInfo info = task.getTaskInfo();
if (info != null && info.topActivity != null
&& PACKAGE.equals(info.topActivity.getPackageName())) {
return true;
}
}
}
} catch (Throwable ignored) {
}
return state.importance <= ActivityManager.RunningAppProcessInfo.IMPORTANCE_FOREGROUND;
}
/**
* Zygisk/命名空间下 android.util.StatsLog native 常 UnsatisfiedLinkError
* Conscrypt TLS 指标线程一写就炸 → ART fatal。直接 noop 指标写入。
*/
private static void hookConscryptStatsLogGuard() {
int hooked = 0;
for (String className : new String[]{
"com.android.org.conscrypt.metrics.StatsLogImpl",
"com.android.org.conscrypt.metrics.ConscryptStatsLog",
"com.google.android.gms.org.conscrypt.metrics.StatsLogImpl",
}) {
try {
Class<?> clazz = Class.forName(className);
for (Method method : clazz.getDeclaredMethods()) {
String n = method.getName();
if (!n.startsWith("write")
&& !n.startsWith("report")
&& !n.startsWith("count")
&& !"startWriterThread".equals(n)) {
continue;
}
hookMethodNoopSilent(method);
hooked++;
}
} catch (Throwable ignored) {
}
}
try {
Class<?> statsLog = Class.forName("android.util.StatsLog");
for (Method method : statsLog.getDeclaredMethods()) {
String n = method.getName();
if ("loadNativeLibrary".equals(n) || n.startsWith("write")) {
hookMethodNoopSilent(method);
hooked++;
}
}
} catch (Throwable ignored) {
}
XposedBridge.log(TAG + " conscrypt/StatsLog guard hooked n=" + hooked);
}
/**
* 区号 Compose Activity 生命周期标记。
* 必须在 execStart/onCreate 前设为 true供 enableHardwareAcceleration 放行。
*/
private static volatile boolean callingCodeSurfaceActive = false;
private static boolean isCaptchaDialog(String owner) {
return owner != null
&& (owner.contains("CaptchaWebViewDialog") || owner.contains("com.aliyun.captcha"));
}
private static boolean isLoadingDialog(String owner) {
return owner != null
&& (owner.contains("i7.") || owner.contains("Loading") || owner.contains("Progress"));
}
private static boolean isCallingCodeActivity(String className) {
return className != null && className.contains("UserSearchCallingCodeActivity");
}
private static boolean isCallingCodeIntent(Intent intent) {
if (intent == null) {
return false;
}
if (intent.getComponent() != null) {
String cn = intent.getComponent().getClassName();
if (isCallingCodeActivity(cn)) {
return true;
}
}
String action = intent.getAction();
return action != null && action.contains("CallingCode");
}
private static String shortStack(int maxFrames) {
StackTraceElement[] stack = Thread.currentThread().getStackTrace();
StringBuilder sb = new StringBuilder();
int n = 0;
for (StackTraceElement el : stack) {
String cn = el.getClassName();
if (cn.startsWith("java.") || cn.startsWith("dalvik.")
|| cn.startsWith("android.os.") || cn.startsWith("de.robv.android.xposed")
|| cn.contains("TngRootBypassHook") || cn.contains("LSPosed")
|| cn.contains("XposedBridge")) {
continue;
}
if (n > 0) {
sb.append(" <- ");
}
sb.append(el.getClassName()).append("#").append(el.getMethodName())
.append(":").append(el.getLineNumber());
if (++n >= maxFrames) {
break;
}
}
return sb.length() == 0 ? "(empty)" : sb.toString();
}
/** 已 skip show 的 loading Dialog让 isShowing=true 避免业务层卡死。 */
private static final Set<Dialog> skippedLoadingDialogs =
Collections.newSetFromMap(new WeakHashMap<>());
/**
* i7.l 等 loading一律 skip show + 伪装 isShowing。
* Captcha 弹窗清 HW flag避免 Android 16 gralloc abort。
*/
private static void hookLoadingDialogSkip() {
try {
XposedHelpers.findAndHookMethod(Dialog.class, "show", new XC_MethodHook() {
@Override
protected void beforeHookedMethod(MethodHookParam param) {
Dialog dialog = (Dialog) param.thisObject;
String owner = dialog.getClass().getName();
if (isLoadingDialog(owner)) {
skippedLoadingDialogs.add(dialog);
XposedBridge.log(TAG + " Dialog.show skip loading " + owner
+ " callingCode=" + callingCodeSurfaceActive);
param.setResult(null);
return;
}
if (isCaptchaDialog(owner)) {
try {
Window cw = dialog.getWindow();
if (cw != null) {
cw.clearFlags(WindowManager.LayoutParams.FLAG_HARDWARE_ACCELERATED);
}
} catch (Throwable ignored) {
}
XposedBridge.log(TAG + " Dialog.show captcha software " + owner);
}
}
});
XposedBridge.log(TAG + " hooked Dialog.show (skip loading only)");
} catch (Throwable t) {
XposedBridge.log(TAG + " Dialog.show hook failed: " + t.getMessage());
}
try {
XposedHelpers.findAndHookMethod(Dialog.class, "isShowing", new XC_MethodHook() {
@Override
protected void beforeHookedMethod(MethodHookParam param) {
if (skippedLoadingDialogs.contains(param.thisObject)) {
param.setResult(true);
}
}
});
XposedBridge.log(TAG + " hooked Dialog.isShowing (skipped loading)");
} catch (Throwable t) {
XposedBridge.log(TAG + " Dialog.isShowing hook failed: " + t.getMessage());
}
try {
XposedHelpers.findAndHookMethod(Dialog.class, "dismiss", new XC_MethodHook() {
@Override
protected void beforeHookedMethod(MethodHookParam param) {
String owner = param.thisObject.getClass().getName();
if (isLoadingDialog(owner)) {
skippedLoadingDialogs.remove(param.thisObject);
}
}
});
XposedBridge.log(TAG + " hooked Dialog.dismiss (loading cleanup)");
} catch (Throwable t) {
XposedBridge.log(TAG + " Dialog.dismiss hook failed: " + t.getMessage());
}
}
/**
* 一律跳过 HardwareRenderer.setName。
* Pixel/A16 + Zygisk 命名空间下 native setName → dlopen("libandroid.so") 失败会 ART abort。
*/
private static void hookHardwareRendererSetNameNoop() {
try {
XposedHelpers.findAndHookMethod(
"android.graphics.HardwareRenderer", null, "setName", String.class,
new XC_MethodHook() {
@Override
protected void beforeHookedMethod(MethodHookParam param) {
param.setResult(null);
}
});
XposedBridge.log(TAG + " hooked HardwareRenderer.setName (always noop)");
} catch (Throwable t) {
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");
}
private static boolean isRegistrationFlowActivity(String className) {
if (className == null) {
return false;
}
for (String marker : REGISTRATION_FLOW_MARKERS) {
if (className.endsWith("." + marker) || className.contains(marker)) {
return true;
}
}
return false;
}
private static String getProcessName() {
try {
Class<?> activityThread = Class.forName("android.app.ActivityThread");
Method current = activityThread.getDeclaredMethod("currentProcessName");
current.setAccessible(true);
Object name = current.invoke(null);
return name != null ? String.valueOf(name) : "?";
} catch (Throwable t) {
return "?";
}
}
private static boolean isTopActivityRegistrationFlow(Context ctx) {
try {
android.app.ActivityManager am =
(android.app.ActivityManager) ctx.getSystemService(Context.ACTIVITY_SERVICE);
if (am == null) {
return false;
}
for (android.app.ActivityManager.AppTask task : am.getAppTasks()) {
android.app.ActivityManager.RecentTaskInfo info = task.getTaskInfo();
if (info == null || info.topActivity == null) {
continue;
}
if (isRegistrationFlowActivity(info.topActivity.getClassName())) {
return true;
}
}
} catch (Throwable ignored) {
}
return false;
}
/**
* 强拉 Login 后系统 Splash 遮罩常挂在 UserLogin 上windows=Splash Screen
* 导致复进「未响应」。onCreate/onResume 强制 dismiss。
*/
private static void hookLoginDismissSplash(XC_LoadPackage.LoadPackageParam lpparam) {
final String login = "my.com.tngdigital.user.view.UserLoginActivity";
try {
XC_MethodHook dismissHook = new XC_MethodHook() {
@Override
protected void afterHookedMethod(MethodHookParam param) {
Activity activity = (Activity) param.args[0];
if (activity == null) {
return;
}
String name = activity.getClass().getName();
if (!login.equals(name) && !name.endsWith(".UserLoginActivity")) {
return;
}
dismissSplashScreen(activity);
}
};
XposedHelpers.findAndHookMethod(
Instrumentation.class, "callActivityOnCreate",
Activity.class, Bundle.class, dismissHook);
XposedHelpers.findAndHookMethod(
Instrumentation.class, "callActivityOnResume",
Activity.class, dismissHook);
XposedBridge.log(TAG + " hooked UserLogin splash dismiss (onCreate+onResume)");
} catch (Throwable t) {
XposedBridge.log(TAG + " login splash dismiss hook failed: " + t.getMessage());
}
}
/** 移除 Android 12+ / androidx 启动页遮罩,避免挡在 Login 前导致 ANR。 */
private static void dismissSplashScreen(Activity activity) {
activity.runOnUiThread(new Runnable() {
@Override
public void run() {
try {
activity.reportFullyDrawn();
} catch (Throwable ignored) {
}
if (Build.VERSION.SDK_INT >= 31) {
try {
android.window.SplashScreen ss = activity.getSplashScreen();
if (ss != null) {
ss.setOnExitAnimationListener(
splashScreenView -> splashScreenView.remove());
}
} catch (Throwable ignored) {
}
}
try {
ClassLoader cl = activity.getClassLoader();
Class<?> splashCl = XposedHelpers.findClass(
"androidx.core.splashscreen.SplashScreen", cl);
Object splash = XposedHelpers.callStaticMethod(
splashCl, "installSplashScreen", activity);
Class<?> condCl = XposedHelpers.findClass(
"androidx.core.splashscreen.SplashScreen$KeepOnScreenCondition", cl);
Object keepOff = Proxy.newProxyInstance(
cl, new Class<?>[] { condCl },
(proxy, method, args) -> false);
XposedHelpers.callMethod(splash, "setKeepOnScreenCondition", keepOff);
Class<?> exitCl = XposedHelpers.findClass(
"androidx.core.splashscreen.SplashScreen$OnExitAnimationListener", cl);
Object exitListener = Proxy.newProxyInstance(
cl, new Class<?>[] { exitCl },
(proxy, method, args) -> {
if (args != null && args.length > 0 && args[0] != null) {
XposedHelpers.callMethod(args[0], "remove");
}
return null;
});
XposedHelpers.callMethod(splash, "setOnExitAnimationListener", exitListener);
} catch (Throwable t) {
XposedBridge.log(TAG + " dismissSplashScreen compat: " + t.getMessage());
}
}
});
}
/**
* Splash 卡住救援:不立刻强拉 Login。
* onCreate 只调度延迟检查;若已自行跳到 PIN/首页则取消。
* 超时仍停在 SplashPromon 堵死)才救援。
*/
private static void hookSplashForceLogin(XC_LoadPackage.LoadPackageParam lpparam) {
final String splash = "my.com.tngdigital.ewallet.ui.SplashActivity";
try {
XposedHelpers.findAndHookMethod(
Instrumentation.class,
"callActivityOnCreate",
Activity.class,
Bundle.class,
new XC_MethodHook() {
@Override
protected void beforeHookedMethod(MethodHookParam param) {
final Activity activity = (Activity) param.args[0];
if (activity == null) {
return;
}
String name = activity.getClass().getName();
if (isSplashActivityName(name)) {
XposedBridge.log(TAG
+ " Splash.onCreate enter — schedule stuck rescue @4s");
scheduleSplashStuckRescue(
activity.getApplicationContext(),
activity,
"Splash/beforeOnCreate",
4000L);
return;
}
// 任何非 Splash Activity 创建 → 取消 Splash 救援
if (PACKAGE.equals(activity.getPackageName())) {
markSplashNavigationDone(name);
}
}
@Override
protected void afterHookedMethod(MethodHookParam param) {
final Activity activity = (Activity) param.args[0];
if (activity == null) {
return;
}
String name = activity.getClass().getName();
if (!isSplashActivityName(name)) {
return;
}
try {
activity.reportFullyDrawn();
} catch (Throwable ignored) {
}
dismissSplashScreen(activity);
}
});
XposedHelpers.findAndHookMethod(
Instrumentation.class,
"callActivityOnResume",
Activity.class,
new XC_MethodHook() {
@Override
protected void afterHookedMethod(MethodHookParam param) {
Activity activity = (Activity) param.args[0];
if (activity == null) {
return;
}
String name = activity.getClass().getName();
if (PACKAGE.equals(activity.getPackageName())
&& !isSplashActivityName(name)) {
markSplashNavigationDone(name);
}
}
});
XposedBridge.log(TAG + " hooked Splash stuck-rescue (not always→Login)");
} catch (Throwable t) {
XposedBridge.log(TAG + " Splash force hook failed: " + t.getMessage());
}
}
private static void markSplashNavigationDone(String activityName) {
if (splashNavigationDone) {
return;
}
splashNavigationDone = true;
if (pendingForceLoginRunnable != null) {
FORCE_LOGIN_HANDLER.removeCallbacks(pendingForceLoginRunnable);
pendingForceLoginRunnable = null;
XposedBridge.log(TAG + " cancel splash rescue — now on " + activityName);
}
}
private static volatile long lastSuicideLogAt = 0L;
private static volatile int suicideBlockCount = 0;
private static void hookAntiSuicide() {
XC_MethodHook blockSelfKill = new XC_MethodHook() {
@Override
protected void beforeHookedMethod(MethodHookParam param) {
if (!shouldBlockThisSuicideCall(param)) {
return;
}
lastBlockedSuicideAt = System.currentTimeMillis();
suicideBlockCount++;
long now = lastBlockedSuicideAt;
if (now - lastSuicideLogAt > 3000L) {
lastSuicideLogAt = now;
XposedBridge.log(TAG + " blocked suicide #" + suicideBlockCount
+ " " + param.method.getDeclaringClass().getSimpleName()
+ "#" + param.method.getName()
+ argsSummary(param.args)
+ " thread=" + Thread.currentThread().getName()
+ " stack=" + shortStack(6));
}
param.setResult(null);
// 非主线程:打断自杀循环线程,避免只拦 kill 却卡死 UI
freezeSuicideCallerThread();
}
};
try {
XposedHelpers.findAndHookMethod(Process.class, "killProcess", int.class, blockSelfKill);
} catch (Throwable t) {
XposedBridge.log(TAG + " killProcess hook failed: " + t.getMessage());
}
try {
XposedHelpers.findAndHookMethod(System.class, "exit", int.class, blockSelfKill);
} catch (Throwable t) {
XposedBridge.log(TAG + " System.exit hook failed: " + t.getMessage());
}
try {
XposedHelpers.findAndHookMethod(Runtime.class, "exit", int.class, blockSelfKill);
} catch (Throwable t) {
XposedBridge.log(TAG + " Runtime.exit hook failed: " + t.getMessage());
}
try {
XposedHelpers.findAndHookMethod(Runtime.class, "halt", int.class, blockSelfKill);
} catch (Throwable t) {
XposedBridge.log(TAG + " Runtime.halt hook failed: " + t.getMessage());
}
try {
XposedHelpers.findAndHookMethod(
Process.class, "sendSignal", int.class, int.class,
new XC_MethodHook() {
@Override
protected void beforeHookedMethod(MethodHookParam param) {
int pid = (Integer) param.args[0];
int signal = (Integer) param.args[1];
if (pid != Process.myPid()) {
return;
}
if (signal != 9 && signal != 15 && signal != 6) {
return;
}
lastBlockedSuicideAt = System.currentTimeMillis();
suicideBlockCount++;
if (lastBlockedSuicideAt - lastSuicideLogAt > 3000L) {
lastSuicideLogAt = lastBlockedSuicideAt;
XposedBridge.log(TAG + " blocked sendSignal(self," + signal
+ ") #" + suicideBlockCount);
}
param.setResult(null);
freezeSuicideCallerThread();
}
});
} catch (Throwable t) {
XposedBridge.log(TAG + " sendSignal hook failed: " + t.getMessage());
}
XC_MethodHook blockFinish = new XC_MethodHook() {
@Override
protected void beforeHookedMethod(MethodHookParam param) {
Activity activity = (Activity) param.thisObject;
if (!isSecurityRelatedActivity(activity)) {
return;
}
if (System.currentTimeMillis() - lastBlockedSuicideAt > SOFT_CRASH_GUARD_MS) {
return;
}
XposedBridge.log(TAG + " blocked " + param.method.getName()
+ " on " + activity.getClass().getSimpleName());
param.setResult(null);
}
};
try {
XposedHelpers.findAndHookMethod(Activity.class, "finish", blockFinish);
XposedHelpers.findAndHookMethod(Activity.class, "finishAffinity", blockFinish);
XposedHelpers.findAndHookMethod(Activity.class, "finishAndRemoveTask", blockFinish);
} catch (Throwable t) {
XposedBridge.log(TAG + " finish hooks failed: " + t.getMessage());
}
XposedBridge.log(TAG + " anti-suicide armed (rate-limited + freeze caller)");
}
private static boolean shouldBlockThisSuicideCall(XC_MethodHook.MethodHookParam param) {
// 真崩溃KillApplicationHandler放行避免僵尸进程卡 Splash/黑屏
if (stackHasClass("com.android.internal.os.RuntimeInit$KillApplicationHandler")) {
return false;
}
String name = param.method.getName();
if ("killProcess".equals(name)) {
return ((Integer) param.args[0]) == Process.myPid();
}
// System/Runtime.exit/halt一律拦TNG 正常退出极少走这里)
return true;
}
private static boolean stackHasClass(String className) {
try {
for (StackTraceElement e : Thread.currentThread().getStackTrace()) {
if (className.equals(e.getClassName())) {
return true;
}
}
} catch (Throwable ignored) {
}
return false;
}
private static String argsSummary(Object[] args) {
if (args == null || args.length == 0) {
return "()";
}
StringBuilder sb = new StringBuilder("(");
for (int i = 0; i < args.length; i++) {
if (i > 0) {
sb.append(",");
}
sb.append(args[i]);
}
return sb.append(")").toString();
}
private static void freezeSuicideCallerThread() {
Thread t = Thread.currentThread();
if (t.getId() == Looper.getMainLooper().getThread().getId()) {
return;
}
// 栈里有 Promon 包才打断,避免误伤业务线程
if (!stackHasPromon()) {
return;
}
try {
t.interrupt();
} catch (Throwable ignored) {
}
}
private static boolean stackHasPromon() {
try {
for (StackTraceElement e : Thread.currentThread().getStackTrace()) {
if (isPromonPackageClass(e.getClassName())) {
return true;
}
}
} catch (Throwable ignored) {
}
return false;
}
/**
* 自杀链上游:只补 AppSecurityManager 杀进程路径 noop。
* 勿拦 Handler.post / 勿 short-circuit G/H 等:会弄死 ContentProvider 初始化Splash 僵尸卡住。
* bl/R/a 仍由 hookPromonNativeGuard 处理。
*/
private static void hookPromonSuicideUpstream(XC_LoadPackage.LoadPackageParam lpparam) {
hookAppSecurityKillPaths(lpparam);
XposedBridge.log(TAG + " promon suicide upstream armed (kill-path only)");
}
/** AppSecurityManager 里明确杀进程/退出的路径直接 noop勿匹配 destroy/shutdown。 */
private static void hookAppSecurityKillPaths(XC_LoadPackage.LoadPackageParam lpparam) {
try {
Class<?> clazz = XposedHelpers.findClass(
"my.com.tngdigital.common.security.shielding.AppSecurityManager",
lpparam.classLoader);
int hooked = 0;
for (Method method : clazz.getDeclaredMethods()) {
String lower = method.getName().toLowerCase(Locale.US);
if (!lower.contains("kill")
&& !lower.contains("exit")
&& !lower.contains("force")
&& !lower.contains("die")
&& !lower.contains("suicide")
&& !lower.contains("terminate")) {
continue;
}
hookMethodNoopSilent(method);
hooked++;
}
XposedBridge.log(TAG + " AppSecurityManager kill-path noop n=" + hooked);
} catch (Throwable t) {
XposedBridge.log(TAG + " AppSecurityManager kill-path failed: " + t.getMessage());
}
}
private static boolean isSecurityRelatedActivity(Activity activity) {
String name = activity.getClass().getName();
return name.contains("SecurityError")
|| name.contains("security.ui")
|| name.contains("Promon");
}
private static void hookBlockSecurityErrorLaunch(XC_LoadPackage.LoadPackageParam lpparam) {
XC_MethodHook blockLaunch = new XC_MethodHook() {
@Override
protected void beforeHookedMethod(MethodHookParam param) {
Intent intent = extractIntent(param.args);
if (intent != null && shouldBlockIntent(intent)) {
XposedBridge.log(TAG + " blocked intent via "
+ param.method.getDeclaringClass().getSimpleName()
+ "#" + param.method.getName()
+ " data=" + intent.getDataString());
param.setResult(null);
}
}
};
hookStartActivityOverloads("android.app.Activity", null, blockLaunch);
hookStartActivityOverloads("android.content.ContextWrapper", null, blockLaunch);
hookStartActivityOverloads("android.app.ContextImpl", null, blockLaunch);
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 (shouldBlockIntent(intent)) {
XposedBridge.log(TAG + " blocked execStartActivity "
+ intent.getDataString());
param.setResult(-1);
}
}
});
} catch (Throwable t) {
XposedBridge.log(TAG + " execStartActivity hook failed: " + t.getMessage());
}
}
/** Promon Shield 安全回调由 handle*Callback / bl short-circuit 处理lifecycle 回调勿拦。 */
private static void hookStartActivityOverloads(
String className, ClassLoader classLoader, XC_MethodHook blockLaunch) {
try {
if ("android.app.Activity".equals(className)) {
XposedHelpers.findAndHookMethod(
Activity.class, "startActivity", Intent.class, blockLaunch);
XposedHelpers.findAndHookMethod(
Activity.class, "startActivity", Intent.class, Bundle.class, blockLaunch);
return;
}
if (classLoader == null) {
XposedHelpers.findAndHookMethod(
className, null, "startActivity", Intent.class, blockLaunch);
XposedHelpers.findAndHookMethod(
className, null, "startActivity", Intent.class, Bundle.class, blockLaunch);
return;
}
XposedHelpers.findAndHookMethod(
className, classLoader, "startActivity", Intent.class, blockLaunch);
XposedHelpers.findAndHookMethod(
className, classLoader, "startActivity", Intent.class, Bundle.class, blockLaunch);
} catch (Throwable t) {
XposedBridge.log(TAG + " startActivity hooks failed for " + className + ": "
+ t.getMessage());
}
}
private static Intent extractIntent(Object[] args) {
if (args == null) {
return null;
}
for (Object arg : args) {
if (arg instanceof Intent) {
return (Intent) arg;
}
}
return null;
}
private static boolean shouldBlockIntent(Intent intent) {
if (intent == null) {
return false;
}
if (isSecurityErrorIntent(intent) || isHomeEkycVerifyIntent(intent)) {
return true;
}
Uri data = intent.getData();
if (data != null && isBlockedSupportUrl(data.toString())) {
return true;
}
String action = intent.getAction();
if (Intent.ACTION_VIEW.equals(action) && data != null) {
return isBlockedSupportUrl(data.toString());
}
return false;
}
private static boolean isHomeEkycVerifyIntent(Intent intent) {
if (intent == null) {
return false;
}
if (intent.getComponent() != null) {
String cls = intent.getComponent().getClassName();
if (cls != null && cls.contains("HomeEkycVerify")) {
return true;
}
}
return false;
}
private static boolean isBlockedSupportUrl(String url) {
if (url == null || url.isEmpty()) {
return false;
}
String lower = url.toLowerCase(Locale.US);
if (!lower.contains("support.tngdigital.com.my")) {
return false;
}
for (String marker : BLOCKED_SUPPORT_MARKERS) {
if (lower.contains(marker)) {
return true;
}
}
return lower.contains("rooting") || lower.contains("jailbroken")
|| lower.contains("emulator") || lower.contains("malware");
}
/** 阻止 Promon 混淆层抛出 W/bd:16 并触发浏览器 fallback。 */
private static void hookPromonNativeGuard(XC_LoadPackage.LoadPackageParam lpparam) {
hookPromonBlSwallowExceptions(lpparam);
for (String simple : new String[]{"W", "A", "bd"}) {
hookPromonExceptionClass(lpparam, simple);
}
hookPromonRunnable(lpparam);
}
/**
* Promon lifecycle1.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) {
for (String simple : new String[]{"w", "u"}) {
hookPromonLifecycleClass(lpparam, simple);
}
}
private static void hookPromonLifecycleClass(
XC_LoadPackage.LoadPackageParam lpparam,
String simpleName) {
Class<?> lifecycleClass = findPromonClass(lpparam.classLoader, simpleName);
if (lifecycleClass == null) {
return;
}
final String lifecycleName = lifecycleClass.getName();
try {
int hooked = 0;
for (Method method : lifecycleClass.getDeclaredMethods()) {
String name = method.getName();
if (!name.startsWith("onActivity") && !name.startsWith("onApplication")) {
continue;
}
// 不调原 native避免 JNI DeleteLocalRef → ART abort
XposedBridge.hookMethod(method, new XC_MethodHook() {
@Override
protected void beforeHookedMethod(MethodHookParam param) {
setSafeHookResult(param, method);
}
});
hooked++;
}
XposedBridge.log(TAG + " hooked " + hooked + " " + lifecycleName
+ " lifecycle (noop)");
} catch (Throwable t) {
XposedBridge.log(TAG + " skip lifecycle " + lifecycleName + ": " + t.getMessage());
}
}
/** 吞掉 :tools 进程里 Promon 抛出的未捕获 W:16。 */
private static void hookUncaughtPromonException(XC_LoadPackage.LoadPackageParam lpparam) {
try {
XposedHelpers.findAndHookMethod(
Thread.class,
"dispatchUncaughtException",
Throwable.class,
new XC_MethodHook() {
@Override
protected void beforeHookedMethod(MethodHookParam param) {
Throwable t = (Throwable) param.args[0];
if (t == null) {
return;
}
if (isPromonThrowableName(t.getClass().getName())
|| isStatsLogNoise(t)) {
XposedBridge.log(TAG + " swallowed uncaught " + t.getClass().getSimpleName()
+ " in " + lpparam.processName
+ " msg=" + t.getMessage());
param.setResult(null);
}
}
});
XposedBridge.log(TAG + " hooked Thread.dispatchUncaughtException");
} catch (Throwable t) {
XposedBridge.log(TAG + " dispatchUncaughtException hook failed: " + t.getMessage());
}
}
/**
* 登录优先:只 short-circuit bl#a / bl#b文档有效路径。全拦 bl/R 六个方法会破坏
* u.onActivityCreated JNI → ART aborta.run 仍单独 noop。
*/
private static void hookPromonBlSwallowExceptions(XC_LoadPackage.LoadPackageParam lpparam) {
for (String simple : new String[]{"bl", "R"}) {
hookPromonClassShortCircuit(lpparam, simple);
}
}
private static void hookPromonClassShortCircuit(
XC_LoadPackage.LoadPackageParam lpparam, String simpleName) {
Class<?> clazz = findPromonClass(lpparam.classLoader, simpleName);
if (clazz == null) {
return;
}
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) {
setSafeHookResult(param, method);
}
});
hooked++;
}
if (hooked > 0) {
XposedBridge.log(TAG + " hooked " + hooked + " " + clazz.getName()
+ " method(s), a/b short-circuit only");
}
} catch (Throwable t) {
XposedBridge.log(TAG + " " + clazz.getName() + " short-circuit failed: "
+ t.getMessage());
}
}
/** Promon 后台 Runnablebl#b 检测线程beforeHook 直接 noop禁止跑 native。 */
private static void hookPromonRunnable(XC_LoadPackage.LoadPackageParam lpparam) {
Class<?> runnableClass = findPromonClass(lpparam.classLoader, "a");
if (runnableClass == null) {
XposedBridge.log(TAG + " Promon a runnable not found");
return;
}
try {
XposedHelpers.findAndHookMethod(runnableClass, "run", new XC_MethodHook() {
@Override
protected void beforeHookedMethod(MethodHookParam param) {
param.setResult(null);
}
});
XposedBridge.log(TAG + " hooked " + runnableClass.getName() + ".run (short-circuit)");
} catch (Throwable t) {
XposedBridge.log(TAG + " Promon a.run hook failed: " + t.getMessage());
}
}
private static void hookPromonExceptionClass(
XC_LoadPackage.LoadPackageParam lpparam, String simpleName) {
Class<?> promonExc = findPromonClass(lpparam.classLoader, simpleName);
if (promonExc == null) {
return;
}
final String className = promonExc.getName();
try {
for (Method method : promonExc.getDeclaredMethods()) {
XposedBridge.hookMethod(method, new XC_MethodHook() {
@Override
protected void beforeHookedMethod(MethodHookParam param) {
XposedBridge.log(TAG + " swallowed " + className + "#" + method.getName());
setSafeHookResult(param, method);
}
});
}
try {
XposedHelpers.findAndHookConstructor(promonExc, int.class, new XC_MethodHook() {
@Override
protected void beforeHookedMethod(MethodHookParam param) {
XposedBridge.log(TAG + " swallowed " + className + "<init>(int)");
param.setResult(null);
}
});
} catch (Throwable ignored) {
// constructor overload may differ
}
XposedBridge.log(TAG + " hooked Promon exception " + className);
} catch (Throwable t) {
XposedBridge.log(TAG + " " + className + " hook failed: " + t.getMessage());
}
}
private static boolean isPromonThrowable(Throwable t, Class<?> promonExc) {
if (t == null) {
return false;
}
if (promonExc != null && promonExc.isInstance(t)) {
return true;
}
return isPromonThrowableName(t.getClass().getName());
}
private static boolean isPromonThrowableName(String className) {
if (className == null) {
return false;
}
if (!isPromonPackageClass(className)) {
return false;
}
// W/A/bd 等 Promon 异常;排除 bl/w/bg/R 等功能类
int dot = className.lastIndexOf('.');
if (dot < 0) {
return false;
}
String simple = className.substring(dot + 1);
if ("bd".equals(simple)) {
return true;
}
return simple.length() == 1;
}
private static boolean isStatsLogNoise(Throwable t) {
for (Throwable cur = t; cur != null; cur = cur.getCause()) {
String name = cur.getClass().getName();
String msg = cur.getMessage();
if (name != null && name.contains("StatsLog")) {
return true;
}
if (msg != null && msg.contains("StatsLog")) {
return true;
}
if (cur instanceof UnsatisfiedLinkError
&& msg != null
&& (msg.contains("stats") || msg.contains("Stats"))) {
return true;
}
}
return false;
}
private static boolean isPromonPackageClass(String className) {
for (String prefix : PROMON_PKG_PREFIXES) {
if (className.startsWith(prefix + ".")) {
return true;
}
}
return false;
}
private static Class<?> findPromonClass(ClassLoader loader, String simpleName) {
for (String prefix : PROMON_PKG_PREFIXES) {
try {
return XposedHelpers.findClass(prefix + "." + simpleName, loader);
} catch (Throwable ignored) {
}
}
return null;
}
/**
* Aliyun TigerTally与 Promon 并行的设备指纹/风控 SDKlibtiger_tally.so
*
* ANR 根因2026-07-31init 链TigerTallyAPI.init → initCommon → t.B.genericNt1
* native会 fork 子进程跑 `getprop ro.build.version.sdk` 并用 pipe 等其 stdout。
* Zygisk tng_exit_guard 的 exit_group seccomp 被 fork 继承 → getprop 永不退出 →
* fread 永久阻塞 → StartupManager latch 卡死 → Application.onCreate ANR。
*
* 对策:对 init 链做 beforeHook 短路(跳过 native启动不再 fork 等待。
* 其它方法保留 afterHook 清异常软化。
*/
private static void hookTigerTally(XC_LoadPackage.LoadPackageParam lpparam) {
String[] classes = {
"com.aliyun.TigerTally.TigerTallyAPI",
"com.aliyun.TigerTally.t.B",
"com.aliyun.TigerTally.t.C",
"com.aliyun.TigerTally.s.A",
};
for (String className : classes) {
try {
Class<?> clazz = XposedHelpers.findClass(className, lpparam.classLoader);
int hooked = 0;
int shorted = 0;
for (Method method : clazz.getDeclaredMethods()) {
if (Modifier.isAbstract(method.getModifiers())) {
continue;
}
// 不拦 Object 基础方法
String name = method.getName();
if ("equals".equals(name) || "hashCode".equals(name)
|| "toString".equals(name) || "getClass".equals(name)) {
continue;
}
final boolean sc = isTigerShortCircuit(className, name);
XposedBridge.hookMethod(method, new XC_MethodHook() {
@Override
protected void beforeHookedMethod(MethodHookParam param) {
if (sc) {
XposedBridge.log(TAG + " TigerTally SC "
+ className + "#" + method.getName());
setSafeHookResult(param, method);
}
}
@Override
protected void afterHookedMethod(MethodHookParam param) {
if (sc) {
return;
}
if (param.hasThrowable()) {
XposedBridge.log(TAG + " TigerTally err "
+ className + "#" + method.getName()
+ " " + param.getThrowable().getClass().getSimpleName()
+ ": " + param.getThrowable().getMessage());
param.setThrowable(null);
setSafeHookResult(param, method);
}
}
});
if (sc) {
shorted++;
}
hooked++;
}
XposedBridge.log(TAG + " hooked TigerTally " + className
+ " methods=" + hooked + " sc=" + shorted);
} catch (Throwable t) {
XposedBridge.log(TAG + " TigerTally " + className
+ " hook skipped: " + t.getMessage());
}
}
}
/**
* 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)
|| "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());
}
}
}
/** 拦截 ActivityThread / Handler 触发的应用退出Promon 常走 native→H.exit。 */
private static void hookActivityThreadExit(XC_LoadPackage.LoadPackageParam lpparam) {
hookActivityThreadExitMethods(null);
hookActivityThreadExitMethods(lpparam.classLoader);
hookActivityThreadHandlerExit(lpparam);
hookShutdownExit();
}
private static void hookActivityThreadExitMethods(ClassLoader classLoader) {
try {
Class<?> atClass = XposedHelpers.findClass("android.app.ActivityThread", classLoader);
int hooked = 0;
for (Method method : atClass.getDeclaredMethods()) {
String name = method.getName();
if (!isActivityThreadExitMethod(name)) {
continue;
}
XposedBridge.hookMethod(method, new XC_MethodHook() {
@Override
protected void beforeHookedMethod(MethodHookParam param) {
XposedBridge.log(TAG + " blocked ActivityThread#" + name);
setSafeHookResult(param, method);
}
});
hooked++;
XposedBridge.log(TAG + " registered ActivityThread exit hook: " + name);
}
if (hooked > 0) {
XposedBridge.log(TAG + " hooked " + hooked + " ActivityThread exit method(s)"
+ (classLoader == null ? " [boot]" : " [app]"));
}
} catch (Throwable t) {
XposedBridge.log(TAG + " ActivityThread exit hook failed: " + t.getMessage());
}
}
/** 仅拦截真正执行退出的方法,勿匹配 isHandleSplashScreenExit 等查询方法。 */
private static boolean isActivityThreadExitMethod(String name) {
if (name.startsWith("is") || name.startsWith("get") || name.startsWith("has")) {
return false;
}
String lower = name.toLowerCase(Locale.US);
return lower.contains("exitapplication")
|| lower.equals("exit")
|| lower.contains("handleexit")
|| lower.contains("appexit");
}
/** 拦截 EXIT_APPLICATION 等 Handler 消息Android 16 上 handleExitApplication 签名已变)。 */
private static void hookActivityThreadHandlerExit(XC_LoadPackage.LoadPackageParam lpparam) {
try {
Class<?> handlerClass = XposedHelpers.findClass("android.app.ActivityThread$H", lpparam.classLoader);
XposedHelpers.findAndHookMethod(
handlerClass,
"handleMessage",
Message.class,
new XC_MethodHook() {
@Override
protected void beforeHookedMethod(MethodHookParam param) {
Message msg = (Message) param.args[0];
if (msg == null) {
return;
}
int what = msg.what;
// AOSP: KILL_APPLICATION=109, EXIT_APPLICATION=111
if (what == 109 || what == 111) {
XposedBridge.log(TAG + " blocked ActivityThread$H msg.what=" + what);
param.setResult(null);
}
}
});
XposedBridge.log(TAG + " hooked ActivityThread$H.handleMessage");
} catch (Throwable t) {
XposedBridge.log(TAG + " ActivityThread$H hook failed: " + t.getMessage());
}
}
private static void hookShutdownExit() {
try {
Class<?> shutdownClass = Class.forName("java.lang.Shutdown");
XposedHelpers.findAndHookMethod(
shutdownClass,
"exit",
int.class,
new XC_MethodHook() {
@Override
protected void beforeHookedMethod(MethodHookParam param) {
XposedBridge.log(TAG + " blocked Shutdown.exit(" + param.args[0] + ")");
param.setResult(null);
}
});
XposedBridge.log(TAG + " hooked Shutdown.exit");
} catch (Throwable t) {
XposedBridge.log(TAG + " Shutdown.exit hook failed: " + t.getMessage());
}
}
/** 阻止 RuntimeInit 默认 handler 因 Promon W 异常杀进程。 */
private static void hookKillApplicationHandler(XC_LoadPackage.LoadPackageParam lpparam) {
try {
Class<?> handlerClass = XposedHelpers.findClass(
"com.android.internal.os.RuntimeInit$KillApplicationHandler",
lpparam.classLoader);
XposedHelpers.findAndHookMethod(
handlerClass,
"uncaughtException",
Thread.class,
Throwable.class,
new XC_MethodHook() {
@Override
protected void beforeHookedMethod(MethodHookParam param) {
Throwable t = (Throwable) param.args[1];
if (t == null || !isPromonThrowableName(t.getClass().getName())) {
return;
}
XposedBridge.log(TAG + " blocked KillApplicationHandler for "
+ t.getClass().getSimpleName()
+ " proc=" + lpparam.processName);
param.setResult(null);
}
});
XposedBridge.log(TAG + " hooked RuntimeInit$KillApplicationHandler");
} catch (Throwable t) {
XposedBridge.log(TAG + " KillApplicationHandler hook failed: " + t.getMessage());
}
}
/** Promon / SecurityError 强制退出倒计时。 */
/**
* dexdump 定位UnhandledEvent 走
* AppSecurityManager.showSecurityScreenForState → _ContextKt.finishAllActivityAndKillApp。
* 这是 UserLogin 之后 Java 层杀进程的主路径之一。
*/
private static void hookFinishAllActivityAndKillApp(XC_LoadPackage.LoadPackageParam lpparam) {
String[] classes = {
"my.com.tngdigital.common.internal._ContextKt",
"my.com.tngdigital.common.internal.ContextKt",
};
for (String className : classes) {
try {
Class<?> clazz = XposedHelpers.findClass(className, lpparam.classLoader);
int hooked = 0;
for (Method method : clazz.getDeclaredMethods()) {
String name = method.getName();
if (!name.toLowerCase(Locale.US).contains("finishall")
&& !name.toLowerCase(Locale.US).contains("killapp")
&& !name.equals("finishAllActivityAndKillApp")) {
continue;
}
XposedBridge.hookMethod(method, new XC_MethodHook() {
@Override
protected void beforeHookedMethod(MethodHookParam param) {
XposedBridge.log(TAG + " blocked " + className + "#" + name);
param.setResult(null);
}
});
hooked++;
}
if (hooked > 0) {
XposedBridge.log(TAG + " hooked " + hooked + " kill-app method(s) in " + className);
}
} catch (Throwable t) {
XposedBridge.log(TAG + " skip " + className + ": " + t.getMessage());
}
}
}
/** 消费 Promon Java 层安全回调,避免检测后走 SecurityError / native fallback 退出链。 */
private static void hookAppSecurityCallbacks(XC_LoadPackage.LoadPackageParam lpparam) {
try {
Class<?> clazz = XposedHelpers.findClass(
"my.com.tngdigital.common.security.shielding.AppSecurityManager",
lpparam.classLoader);
int hooked = 0;
for (Method method : clazz.getDeclaredMethods()) {
String name = method.getName();
if (!name.startsWith("handle") || !name.endsWith("Callback")) {
continue;
}
hookMethodNoopSilent(method);
hooked++;
}
if (hooked > 0) {
XposedBridge.log(TAG + " hooked " + hooked + " AppSecurityManager callback(s) (silent)");
}
} catch (Throwable t) {
XposedBridge.log(TAG + " AppSecurityManager callbacks hook failed: " + t.getMessage());
}
}
private static void hookSecurityUrlOpeners(XC_LoadPackage.LoadPackageParam lpparam) {
String[] classes = {
"my.com.tngdigital.common.security.shielding.AppSecurityManager",
"my.com.tngdigital.common.security.ui.SecurityErrorBaseActivity",
"my.com.tngdigital.common.security.ui.SecurityErrorActivity",
"my.com.tngdigital.common.security.shielding.utils.HelperKt",
"my.com.tngdigital.app.launcher.initializer.AppSecurityInitializer",
};
String[] methods = {
"openSecurityUrl", "openUrlByBrowser", "openBrowser", "openUrl",
"openWebUrl",
};
for (String className : classes) {
for (String methodName : methods) {
hookUrlOrCallbackMethod(lpparam, className, methodName);
}
}
}
private static void hookUrlOrCallbackMethod(
XC_LoadPackage.LoadPackageParam lpparam, String className, String methodName) {
try {
Class<?> clazz = XposedHelpers.findClass(className, lpparam.classLoader);
int hooked = 0;
for (Method method : clazz.getDeclaredMethods()) {
if (!methodName.equals(method.getName())) {
continue;
}
XposedBridge.hookMethod(method, new XC_MethodHook() {
@Override
protected void beforeHookedMethod(MethodHookParam param) {
if (param.args != null && param.args.length > 0
&& param.args[0] instanceof String) {
String url = (String) param.args[0];
if (isBlockedSupportUrl(url)) {
XposedBridge.log(TAG + " blocked " + className
+ "#" + methodName + " url=" + url);
setSafeHookResult(param, method);
return;
}
}
if ("openSecurityUrl".equals(methodName)
|| "openUrlByBrowser".equals(methodName)
|| "openBrowser".equals(methodName)) {
XposedBridge.log(TAG + " blocked " + className + "#" + methodName);
setSafeHookResult(param, method);
}
}
});
hooked++;
}
if (hooked > 0) {
XposedBridge.log(TAG + " hooked " + hooked + " " + className + "#" + methodName);
}
} catch (Throwable ignored) {
// class/method may not exist in this APK split
}
}
/** 高频回调(如 handleTapjackingCallback禁止逐次打 log避免复进主线程 ANR。 */
private static void hookMethodNoopSilent(Method method) {
XposedBridge.hookMethod(method, new XC_MethodHook() {
@Override
protected void beforeHookedMethod(MethodHookParam param) {
setSafeHookResult(param, method);
}
});
}
/** methodName 为 null 时 hook 类内全部方法(用于 Promon 混淆类)。 */
private static void hookAllMethodsNoop(
XC_LoadPackage.LoadPackageParam lpparam, String className, String methodName) {
try {
Class<?> clazz = XposedHelpers.findClass(className, lpparam.classLoader);
int hooked = 0;
for (Method method : clazz.getDeclaredMethods()) {
if (methodName != null && !methodName.equals(method.getName())) {
continue;
}
XposedBridge.hookMethod(method, new XC_MethodHook() {
@Override
protected void beforeHookedMethod(MethodHookParam param) {
XposedBridge.log(TAG + " blocked " + className + "#" + method.getName());
setSafeHookResult(param, method);
}
});
hooked++;
}
if (hooked > 0) {
XposedBridge.log(TAG + " hooked " + hooked + " method(s) in " + className
+ (methodName != null ? "#" + methodName : ""));
}
} catch (Throwable t) {
XposedBridge.log(TAG + " skip " + className + ": " + t.getMessage());
}
}
private static boolean isSecurityErrorIntent(Intent intent) {
if (intent == null) {
return false;
}
if (intent.getComponent() != null) {
String cls = intent.getComponent().getClassName();
if (cls != null && cls.contains("SecurityError")) {
return true;
}
}
String target = intent.getStringExtra("targetActivity");
return target != null && target.contains("SecurityError");
}
private static void hookJailBroken(XC_LoadPackage.LoadPackageParam lpparam) {
hookNoArgVoid(lpparam, "my.com.tngdigital.common.jailbrkendetect.JailBrokenManager",
"showJailBrokenAlert");
hookNoArgVoid(lpparam, "my.com.tngdigital.common.jailbrkendetect.JailBrokenManager",
"showJailBrokenAlert$lambda$15");
hookReturnFalse(lpparam, "my.com.tngdigital.common.jailbrkendetect.JailBrokenApiImpl",
"isJailBroken");
hookReturnFalse(lpparam, "my.com.tngdigital.common.jailbrkendetect.JailBrokenApiImpl",
"detectJailBroken");
hookReturnFalse(lpparam, "my.com.tngdigital.common.jailbrkendetect.JailBrokenManager",
"isJailBroken");
hookReturnFalse(lpparam, "my.com.tngdigital.common.jailbrkendetect.JailBrokenManager",
"detectJailBroken");
// 以下为 void 回调(非 boolean触发时弹窗/杀进程,必须 noop。
hookNoArgVoid(lpparam, "my.com.tngdigital.common.jailbrkendetect.JailBrokenApiImpl",
"onBlockStaticCheck");
hookNoArgVoid(lpparam, "my.com.tngdigital.common.jailbrkendetect.JailBrokenApiImpl",
"onShowPopupDisable");
hookNoArgVoid(lpparam, "my.com.tngdigital.common.jailbrkendetect.JailBrokenApiImpl",
"onEmptyToken");
hookNoArgVoid(lpparam, "my.com.tngdigital.common.jailbrkendetect.JailBrokenApiImpl",
"onRpcCheckValid");
hookJailBrokenResult(lpparam);
}
/** 拦截 jail.broken.detect RPC本地直接回调「未越狱」结果不发网关。 */
private static void hookJailBrokenRpc(XC_LoadPackage.LoadPackageParam lpparam) {
String implClass = "my.com.tngdigital.common.jailbrkendetect.JailBrokenApiImpl";
String[] shortCircuitMethods = {
"rpcCheckJailBroken",
"jailBrokenDetect",
"rpcCheck",
};
try {
Class<?> clazz = XposedHelpers.findClass(implClass, lpparam.classLoader);
int hooked = 0;
for (Method method : clazz.getDeclaredMethods()) {
String name = method.getName();
boolean match = false;
for (String target : shortCircuitMethods) {
if (target.equals(name)) {
match = true;
break;
}
}
if (!match) {
continue;
}
XposedBridge.hookMethod(method, new XC_MethodHook() {
@Override
protected void beforeHookedMethod(MethodHookParam param) {
XposedBridge.log(TAG + " skip jail RPC " + implClass + "#" + name);
Object clean = buildCleanJailBrokenResult(lpparam.classLoader);
invokeJailBrokenCallbacks(param.args, clean);
setSafeHookResult(param, method);
}
});
hooked++;
}
if (hooked > 0) {
XposedBridge.log(TAG + " hooked jail RPC short-circuit methods=" + hooked);
}
} catch (Throwable t) {
XposedBridge.log(TAG + " jail RPC short-circuit failed: " + t.getMessage());
}
hookRpcInvocationJailBypass(lpparam);
}
private static void hookRpcInvocationJailBypass(XC_LoadPackage.LoadPackageParam lpparam) {
String[] rpcClasses = {
"my.com.tngdigital.common.aliservice.quake.TngdRpcInvocationHandlerHost",
"com.alipay.imobile.network.quake.rpc.RpcInvocationHandler",
};
XC_MethodHook rpcHook = new XC_MethodHook() {
@Override
protected void beforeHookedMethod(MethodHookParam param) {
String op = extractRpcOperation(param.args);
if (op != null && (isRegistrationRpc(op) || op.contains("phoneCheck"))) {
XposedBridge.log(TAG + " RPC req op=" + op + " "
+ describeRpcInvocation(param.args));
}
if (!isJailBrokenRpcInvocation(param.args)) {
return;
}
XposedBridge.log(TAG + " block jail.broken RPC invoke "
+ describeRpcInvocation(param.args));
Object clean = buildCleanJailBrokenResult(lpparam.classLoader);
if (clean != null) {
param.setResult(clean);
return;
}
param.setResult(null);
}
@Override
protected void afterHookedMethod(MethodHookParam param) {
String op = extractRpcOperation(param.args);
if (op == null || (!isRegistrationRpc(op) && !op.contains("phoneCheck"))) {
return;
}
if (param.hasThrowable()) {
XposedBridge.log(TAG + " RPC rsp op=" + op + " err "
+ param.getThrowable().getClass().getSimpleName()
+ ": " + param.getThrowable().getMessage());
return;
}
Object result = param.getResult();
String snippet = result != null ? String.valueOf(result) : "null";
if (snippet.length() > 800) {
snippet = snippet.substring(0, 800) + "...";
}
XposedBridge.log(TAG + " RPC rsp op=" + op + " body=" + snippet);
}
};
for (String className : rpcClasses) {
try {
Class<?> clazz = XposedHelpers.findClass(className, lpparam.classLoader);
int hooked = 0;
for (Method method : clazz.getDeclaredMethods()) {
if (!"invoke".equals(method.getName())) {
continue;
}
XposedBridge.hookMethod(method, rpcHook);
hooked++;
}
if (hooked > 0) {
XposedBridge.log(TAG + " hooked RPC guard+log " + className
+ " invoke=" + hooked);
}
} catch (Throwable t) {
XposedBridge.log(TAG + " skip RPC guard " + className
+ ": " + t.getMessage());
}
}
}
private static String extractRpcOperation(Object[] args) {
if (args == null) {
return null;
}
for (Object arg : args) {
if (!(arg instanceof String)) {
continue;
}
String text = (String) arg;
if (text.startsWith("com.abl.")
|| text.startsWith("ap.tngd")
|| text.startsWith("alipayplus.")) {
return text;
}
}
return null;
}
private static boolean isRegistrationRpc(String op) {
if (op == null) {
return false;
}
String lower = op.toLowerCase(Locale.US);
for (String marker : REGISTRATION_RPC_MARKERS) {
if (lower.contains(marker)) {
return true;
}
}
return false;
}
private static boolean isJailBrokenRpcInvocation(Object[] args) {
if (args == null) {
return false;
}
for (Object arg : args) {
if (arg == null) {
continue;
}
if (arg instanceof Method) {
String name = ((Method) arg).getName().toLowerCase(Locale.US);
if (name.contains("jail")) {
return true;
}
continue;
}
if (arg instanceof String) {
String text = ((String) arg).toLowerCase(Locale.US);
if (text.contains("jail.broken")
|| text.contains("jailbroken")
|| text.contains("jail_broken")) {
return true;
}
continue;
}
// 勿对 RPC 动态代理 toString会再次进入 invoke 导致栈溢出。
if (Proxy.isProxyClass(arg.getClass())) {
continue;
}
Class<?> clazz = arg.getClass();
if (clazz.isArray()) {
continue;
}
String simple = clazz.getSimpleName().toLowerCase(Locale.US);
if (simple.contains("jailbroken") || simple.contains("jailbrokenrequest")) {
return true;
}
}
return false;
}
private static String describeRpcInvocation(Object[] args) {
if (args == null || args.length == 0) {
return "[]";
}
StringBuilder sb = new StringBuilder("[");
for (int i = 0; i < args.length; i++) {
if (i > 0) {
sb.append(", ");
}
Object arg = args[i];
if (arg instanceof Method) {
sb.append("Method=").append(((Method) arg).getName());
} else if (arg instanceof String) {
String s = (String) arg;
sb.append(s.length() > 120 ? s.substring(0, 120) + "..." : s);
} else if (Proxy.isProxyClass(arg.getClass())) {
sb.append("Proxy=").append(arg.getClass().getInterfaces()[0].getSimpleName());
} else {
sb.append(arg.getClass().getSimpleName());
}
}
sb.append("]");
return sb.toString();
}
private static Object buildCleanJailBrokenResult(ClassLoader loader) {
try {
Class<?> clazz = XposedHelpers.findClass(
"my.com.tngdigital.common.jailbrkendetect.JailBrokenResult", loader);
for (Constructor<?> constructor : clazz.getDeclaredConstructors()) {
Class<?>[] types = constructor.getParameterTypes();
Object[] args = new Object[types.length];
boolean ok = true;
for (int i = 0; i < types.length; i++) {
Class<?> type = types[i];
if (type == boolean.class || type == Boolean.class) {
args[i] = Boolean.FALSE;
} else if (type == String.class) {
args[i] = "";
} else if (type == int.class) {
args[i] = 0;
} else if (type == Integer.class) {
args[i] = Integer.valueOf(0);
} else if (!type.isPrimitive()) {
args[i] = null;
} else {
ok = false;
break;
}
}
if (!ok) {
continue;
}
try {
constructor.setAccessible(true);
Object result = constructor.newInstance(args);
sanitizeJailBrokenResult(result);
return result;
} catch (Throwable ignored) {
}
}
} catch (Throwable t) {
XposedBridge.log(TAG + " buildCleanJailBrokenResult failed: " + t.getMessage());
}
return null;
}
private static void sanitizeJailBrokenResult(Object result) {
if (result == null) {
return;
}
try {
for (java.lang.reflect.Field field : result.getClass().getDeclaredFields()) {
field.setAccessible(true);
String name = field.getName().toLowerCase(Locale.US);
Class<?> type = field.getType();
if ((type == boolean.class || type == Boolean.class)
&& (name.contains("jail") || name.contains("root"))) {
field.set(result, Boolean.FALSE);
} else if (type == String.class && name.contains("msg")) {
field.set(result, "");
}
}
} catch (Throwable ignored) {
}
}
private static void invokeJailBrokenCallbacks(Object[] args, Object cleanResult) {
if (args == null || cleanResult == null) {
return;
}
for (Object arg : args) {
if (arg == null) {
continue;
}
try {
for (Method method : arg.getClass().getDeclaredMethods()) {
if (method.getParameterTypes().length != 1) {
continue;
}
Class<?> paramType = method.getParameterTypes()[0];
if (!paramType.getName().contains("JailBrokenResult")) {
continue;
}
method.setAccessible(true);
method.invoke(arg, cleanResult);
XposedBridge.log(TAG + " delivered clean JailBrokenResult via "
+ arg.getClass().getSimpleName() + "#" + method.getName());
}
} catch (Throwable ignored) {
}
try {
XposedHelpers.callMethod(arg, "invoke", cleanResult);
} catch (Throwable ignored) {
}
}
}
private static void hookJailBrokenResult(XC_LoadPackage.LoadPackageParam lpparam) {
try {
Class<?> resultClass = XposedHelpers.findClass(
"my.com.tngdigital.common.jailbrkendetect.JailBrokenResult",
lpparam.classLoader);
for (Method method : resultClass.getDeclaredMethods()) {
String name = method.getName();
Class<?> returnType = method.getReturnType();
if (returnType == boolean.class || returnType == Boolean.class) {
if (name.toLowerCase(Locale.US).contains("jail")
|| name.toLowerCase(Locale.US).contains("root")) {
XposedBridge.hookMethod(method, new XC_MethodHook() {
@Override
protected void beforeHookedMethod(MethodHookParam param) {
param.setResult(false);
}
});
XposedBridge.log(TAG + " hooked JailBrokenResult#" + name);
}
} else if (returnType == String.class
&& name.toLowerCase(Locale.US).contains("msg")) {
XposedBridge.hookMethod(method, new XC_MethodHook() {
@Override
protected void beforeHookedMethod(MethodHookParam param) {
param.setResult("");
}
});
XposedBridge.log(TAG + " hooked JailBrokenResult#" + name);
}
}
XposedHelpers.findAndHookConstructor(resultClass, new XC_MethodHook() {
@Override
protected void afterHookedMethod(MethodHookParam param) {
sanitizeJailBrokenResult(param.getResult());
}
});
} catch (Throwable t) {
XposedBridge.log(TAG + " JailBrokenResult hooks failed: " + t.getMessage());
}
}
private static void hookAppSecurityManager(XC_LoadPackage.LoadPackageParam lpparam) {
// addIntoQueue 勿拦:拦截后 Promon 会走 native _exit fallback
hookReturnFalse(lpparam,
"my.com.tngdigital.common.security.malwarescan.MalwareScanUtils",
"isShowErrorScreen");
}
private static void hookSecurityErrorActivity(XC_LoadPackage.LoadPackageParam lpparam) {
hookSecurityErrorLaunch(lpparam);
hookGenericSecurityErrorFinish();
XC_MethodHook closeSecurityScreen = new XC_MethodHook() {
@Override
protected void afterHookedMethod(MethodHookParam param) {
Activity activity = (Activity) param.thisObject;
XposedBridge.log(TAG + " closing " + activity.getClass().getSimpleName());
activity.finish();
}
};
for (String activityClass : new String[]{
SECURITY_ERROR_ACTIVITY,
"my.com.tngdigital.common.security.ui.SecurityErrorBaseActivity",
}) {
hookAllOnCreateMethods(lpparam, activityClass, closeSecurityScreen);
}
}
private static void hookAllOnCreateMethods(
XC_LoadPackage.LoadPackageParam lpparam,
String className,
XC_MethodHook hook) {
try {
Class<?> clazz = XposedHelpers.findClass(className, lpparam.classLoader);
int hooked = 0;
for (Method method : clazz.getDeclaredMethods()) {
if (!"onCreate".equals(method.getName())) {
continue;
}
XposedBridge.hookMethod(method, hook);
hooked++;
}
if (hooked > 0) {
XposedBridge.log(TAG + " hooked " + hooked + " onCreate overload(s) in " + className);
} else {
XposedBridge.log(TAG + " no onCreate in " + className);
}
} catch (Throwable t) {
XposedBridge.log(TAG + " " + className + " onCreate hook failed: " + t.getMessage());
}
}
/** SecurityErrorActivity 为 Compose无独立 onCreate在 Activity 基类统一 finish。 */
private static void hookGenericSecurityErrorFinish() {
try {
XposedHelpers.findAndHookMethod(
Activity.class,
"onCreate",
Bundle.class,
new XC_MethodHook() {
@Override
protected void afterHookedMethod(MethodHookParam param) {
Activity activity = (Activity) param.thisObject;
String name = activity.getClass().getName();
if (name.contains("SecurityError")) {
XposedBridge.log(TAG + " finishing " + name);
activity.finish();
}
}
});
XposedBridge.log(TAG + " hooked Activity.onCreate SecurityError finish");
} catch (Throwable t) {
XposedBridge.log(TAG + " generic SecurityError finish failed: " + t.getMessage());
}
}
/** 拦截 Security 状态机启动 SecurityErrorActivity。 */
private static void hookSecurityErrorLaunch(XC_LoadPackage.LoadPackageParam lpparam) {
// launchProcessNextSecurityStateIfIdle / addIntoQueue 勿拦:
// 拦截后 Promon 会立刻走 native exit_group(1)。
// 杀进程改由 finishAllActivityAndKillApp / showSecurityScreenForState 兜底。
XposedBridge.log(TAG + " skip launchProcessNext noop (avoid native exit fallback)");
}
/**
* 首页强制「验证您的帐户」(HomeEkycVerifyActivity) — 测试期跳过。
* 拦启动 + onCreate finishcanBypassEkyc=trueenforceEkyc=false。
*/
private static void hookHomeEkycVerifySkip(XC_LoadPackage.LoadPackageParam lpparam) {
try {
XposedHelpers.findAndHookMethod(
Activity.class,
"onCreate",
Bundle.class,
new XC_MethodHook() {
@Override
protected void afterHookedMethod(MethodHookParam param) {
Activity activity = (Activity) param.thisObject;
String name = activity.getClass().getName();
if (name != null && name.contains("HomeEkycVerify")) {
XposedBridge.log(TAG + " skip eKYC — finish " + name);
activity.finish();
}
}
});
XposedBridge.log(TAG + " hooked HomeEkycVerify Activity finish");
} catch (Throwable t) {
XposedBridge.log(TAG + " HomeEkycVerify finish hook failed: " + t.getMessage());
}
// 白名单绕过:返回 true
hookBooleanMethodsByName(lpparam,
"my.com.tngdigital.home.viewmodel.BypassEkycWhiteListChecker",
true, "canBypassEkyc", "bypassEkyc", "isWhitelist", "inWhitelist");
// 强制 eKYC 开关:返回 false
for (String className : new String[]{
"my.com.tngdigital.home.help.HomeEkycCheckHelper",
"my.com.tngdigital.home.ekyc.KycHomepagePopUpManager",
"my.com.tngdigital.home.ekyc.HomeEkycVerifyViewModel",
"my.com.tngdigital.home.viewmodel.HomeListActivityViewModel",
}) {
hookBooleanMethodsByName(lpparam, className, false,
"getEnforceEkyc", "enforceEkyc", "needShowEkyc", "needForceEkyc",
"isForceEkyc", "checkEkyc", "firstCheckEkyc", "needShowEkycCddAudit");
hookVoidMethodsByName(lpparam, className,
"checkEkyc", "checkEkycStatus", "checkEkycRequest",
"requestEkycStatus", "showEkyc", "launchEkyc", "openEkyc");
}
}
private static void hookBooleanMethodsByName(
XC_LoadPackage.LoadPackageParam lpparam,
String className,
boolean result,
String... nameHints) {
try {
Class<?> 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 {
Class<?> clazz = XposedHelpers.findClass(className, lpparam.classLoader);
int hooked = 0;
for (Method method : clazz.getDeclaredMethods()) {
if (!methodName.equals(method.getName())) {
continue;
}
Class<?> returnType = method.getReturnType();
if (returnType != boolean.class && returnType != Boolean.class) {
continue;
}
XposedBridge.hookMethod(method, new XC_MethodHook() {
@Override
protected void beforeHookedMethod(MethodHookParam param) {
param.setResult(false);
}
});
hooked++;
}
if (hooked > 0) {
XposedBridge.log(TAG + " hooked " + hooked + " overload(s) "
+ className + "#" + methodName + " -> false");
} else {
XposedBridge.log(TAG + " no boolean method " + className + "#" + methodName);
}
} catch (Throwable t) {
XposedBridge.log(TAG + " skip " + className + "#" + methodName + ": " + t.getMessage());
}
}
private static void hookNoArgVoid(
XC_LoadPackage.LoadPackageParam lpparam, String className, String methodName) {
hookAllMethodsNoop(lpparam, className, methodName);
}
private static void setSafeHookResult(XC_MethodHook.MethodHookParam param, Method method) {
Class<?> returnType = method.getReturnType();
if (returnType == void.class) {
param.setResult(null);
} else if (returnType == boolean.class) {
param.setResult(false);
} else if (returnType == int.class) {
param.setResult(0);
} else if (returnType == long.class) {
param.setResult(0L);
} else if (returnType == float.class) {
param.setResult(0f);
} else if (returnType == double.class) {
param.setResult(0d);
} else if (returnType == byte.class) {
param.setResult((byte) 0);
} else if (returnType == short.class) {
param.setResult((short) 0);
} else if (returnType == char.class) {
param.setResult((char) 0);
} else if (returnType == Boolean.class) {
param.setResult(Boolean.FALSE);
} else if (returnType == Integer.class) {
param.setResult(Integer.valueOf(0));
} else if (returnType == Long.class) {
param.setResult(Long.valueOf(0L));
} else if (returnType == Float.class) {
param.setResult(Float.valueOf(0f));
} else if (returnType == Double.class) {
param.setResult(Double.valueOf(0d));
} else if (returnType == String.class) {
param.setResult("");
} else if (returnType == byte[].class) {
param.setResult(new byte[0]);
} else {
param.setResult(null);
}
}
}

View File

@@ -9,5 +9,6 @@
<item>au.com.suncorp.marketplace</item>
<item>au.com.bank86400</item>
<item>ph.seabank.seabank</item>
<item>sg.com.maribankmobile.digitalbank</item>
</string-array>
</resources>