From f9426d6b689219a3c93bdee9476d70b925eb507f Mon Sep 17 00:00:00 2001 From: mars <3361409208@qq.com> Date: Tue, 4 Aug 2026 13:48:47 +0800 Subject: [PATCH] =?UTF-8?q?chore:=20=E6=B8=85=E7=90=86=E4=B8=B4=E6=97=B6?= =?UTF-8?q?=E9=80=86=E5=90=91=E8=84=9A=E6=9C=AC=EF=BC=8C=E4=BF=9D=E7=95=99?= =?UTF-8?q?=20TNG=20=E5=AE=89=E8=A3=85=E5=B7=A5=E5=85=B7=E4=B8=8E=20captch?= =?UTF-8?q?a=20=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 删除未跟踪的一次性 _*.py 扫描脚本,并从仓库移除已过时辅助脚本。 --- .gitignore | 1 + docs/TNG_captcha逆向.md | 64 +++++++++++++ reverse/scripts/_debug_pin.py | 20 ----- reverse/scripts/_parse_country_ui.py | 27 ------ reverse/scripts/_parse_ui.py | 15 ---- reverse/scripts/_scan_tng_xww.py | 15 ---- reverse/scripts/download_install_tng.py | 114 ++++++++++++++++++++++++ scripts/install-tng-xapk.ps1 | 35 ++++++++ scripts/pull-tng-apk.ps1 | 32 +++++++ 9 files changed, 246 insertions(+), 77 deletions(-) create mode 100644 docs/TNG_captcha逆向.md delete mode 100644 reverse/scripts/_debug_pin.py delete mode 100644 reverse/scripts/_parse_country_ui.py delete mode 100644 reverse/scripts/_parse_ui.py delete mode 100644 reverse/scripts/_scan_tng_xww.py create mode 100644 reverse/scripts/download_install_tng.py create mode 100644 scripts/install-tng-xapk.ps1 create mode 100644 scripts/pull-tng-apk.ps1 diff --git a/.gitignore b/.gitignore index 2593e33..7126adf 100644 --- a/.gitignore +++ b/.gitignore @@ -32,3 +32,4 @@ magisk-modules/tng_exit_guard/obj/ magisk-modules/tng_exit_guard/libs/ # 调试抓包/截图/ANR/APK 产物,不入库 reverse/dumps/ +reverse/scripts/__pycache__/ diff --git a/docs/TNG_captcha逆向.md b/docs/TNG_captcha逆向.md new file mode 100644 index 0000000..cfabe26 --- /dev/null +++ b/docs/TNG_captcha逆向.md @@ -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` | 解析 result,code==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 diff --git a/reverse/scripts/_debug_pin.py b/reverse/scripts/_debug_pin.py deleted file mode 100644 index f17c25e..0000000 --- a/reverse/scripts/_debug_pin.py +++ /dev/null @@ -1,20 +0,0 @@ -#!/usr/bin/env python3 -import re -import sys -import xml.etree.ElementTree as ET - -path = sys.argv[1] -root = ET.parse(path).getroot() - -def center(b): - m = re.match(r"\[(\d+),(\d+)\]\[(\d+),(\d+)\]", b or "") - if not m: - return None - x1, y1, x2, y2 = map(int, m.groups()) - return (x1 + x2) // 2, (y1 + y2) // 2 - -for n in root.iter("node"): - t = n.get("text") or "" - pt = center(n.get("bounds")) - if pt and 600 <= pt[1] <= 800: - print(repr(t), pt, "PIN" in t.upper(), "6位" in t) diff --git a/reverse/scripts/_parse_country_ui.py b/reverse/scripts/_parse_country_ui.py deleted file mode 100644 index 4667364..0000000 --- a/reverse/scripts/_parse_country_ui.py +++ /dev/null @@ -1,27 +0,0 @@ -# -*- coding: utf-8 -*- -import xml.etree.ElementTree as ET -import re -import sys - -path = sys.argv[1] if len(sys.argv) > 1 else r"reverse/dumps/ui_login_now.xml" -root = ET.parse(path).getroot() -for n in root.iter("node"): - rid = n.attrib.get("resource-id", "") - text = n.attrib.get("text", "") - desc = n.attrib.get("content-desc", "") - click = n.attrib.get("clickable") - bounds = n.attrib.get("bounds") - blob = (rid + " " + text + " " + desc).lower() - if any(k in blob for k in ("country", "ll_country", "+60", "calling", "flag")): - print("HIT", rid, repr(text), repr(desc), click, bounds) - if text and (("+" in text[:3]) or text.strip() in ("PIN",) or "Malaysia" in text): - print("TEXT", repr(text), bounds, click, rid) - -# print clickable centers for ll_country -for n in root.iter("node"): - rid = n.attrib.get("resource-id", "") - if "ll_country" in rid and n.attrib.get("clickable") == "true": - m = re.match(r"\[(\d+),(\d+)\]\[(\d+),(\d+)\]", n.attrib.get("bounds", "")) - if m: - x1, y1, x2, y2 = map(int, m.groups()) - print("TAP", (x1 + x2) // 2, (y1 + y2) // 2, rid) diff --git a/reverse/scripts/_parse_ui.py b/reverse/scripts/_parse_ui.py deleted file mode 100644 index ec82d9b..0000000 --- a/reverse/scripts/_parse_ui.py +++ /dev/null @@ -1,15 +0,0 @@ -#!/usr/bin/env python3 -import sys -import xml.etree.ElementTree as ET - -path = sys.argv[1] if len(sys.argv) > 1 else "/sdcard/tng_ui.xml" -root = ET.parse(path).getroot() -for n in root.iter("node"): - t = (n.get("text") or "").strip() - rid = n.get("resource-id") or "" - desc = (n.get("content-desc") or "").strip() - c = n.get("clickable") == "true" - b = n.get("bounds") or "" - if t or rid or desc: - if t or "tv_" in rid or "country" in rid.lower() or "PIN" in (t + desc).upper(): - print(f"text={t!r} rid={rid} desc={desc!r} click={c} {b}") diff --git a/reverse/scripts/_scan_tng_xww.py b/reverse/scripts/_scan_tng_xww.py deleted file mode 100644 index 4d5f381..0000000 --- a/reverse/scripts/_scan_tng_xww.py +++ /dev/null @@ -1,15 +0,0 @@ -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")] -print("dex files", names) -pat = re.compile(rb"Lxwwqazamx/[^;\s]{1,60};") -found = set() -for n in names: - data = z.read(n) - for m in pat.findall(data): - found.add(m.decode("ascii", errors="ignore")) -print("xwwqazamx classes", len(found)) -for c in sorted(found): - print(c) diff --git a/reverse/scripts/download_install_tng.py b/reverse/scripts/download_install_tng.py new file mode 100644 index 0000000..c8dd262 --- /dev/null +++ b/reverse/scripts/download_install_tng.py @@ -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+)".*?([^<]+)', + 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()) diff --git a/scripts/install-tng-xapk.ps1 b/scripts/install-tng-xapk.ps1 new file mode 100644 index 0000000..d165e5f --- /dev/null +++ b/scripts/install-tng-xapk.ps1 @@ -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 +} diff --git a/scripts/pull-tng-apk.ps1 b/scripts/pull-tng-apk.ps1 new file mode 100644 index 0000000..2b57d83 --- /dev/null +++ b/scripts/pull-tng-apk.ps1 @@ -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"