chore: 备份 TNG 注册/captcha 逆向与 MariBank SG bypass 进展
TngRootBypassHook 增强 captcha 诊断、TigerTally/JNIC 分层与 HWUI 策略;新增逆向脚本、Frida 工具与 UI dump;同步 MariBank SG hook 与 tng_exit_guard 更新。
This commit is contained in:
466
reverse/scripts/test_tng_full_flow.py
Normal file
466
reverse/scripts/test_tng_full_flow.py
Normal 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≈600–780
|
||||
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 控件出现在 dump(Splash 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())
|
||||
Reference in New Issue
Block a user