TngRootBypassHook 增强 captcha 诊断、TigerTally/JNIC 分层与 HWUI 策略;新增逆向脚本、Frida 工具与 UI dump;同步 MariBank SG hook 与 tng_exit_guard 更新。
467 lines
14 KiB
Python
467 lines
14 KiB
Python
#!/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())
|