chore: 清理临时逆向脚本,保留 TNG 安装工具与 captcha 文档
删除未跟踪的一次性 _*.py 扫描脚本,并从仓库移除已过时辅助脚本。
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -32,3 +32,4 @@ magisk-modules/tng_exit_guard/obj/
|
||||
magisk-modules/tng_exit_guard/libs/
|
||||
# 调试抓包/截图/ANR/APK 产物,不入库
|
||||
reverse/dumps/
|
||||
reverse/scripts/__pycache__/
|
||||
|
||||
64
docs/TNG_captcha逆向.md
Normal file
64
docs/TNG_captcha逆向.md
Normal 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` | 解析 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
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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}")
|
||||
@@ -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)
|
||||
114
reverse/scripts/download_install_tng.py
Normal file
114
reverse/scripts/download_install_tng.py
Normal 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())
|
||||
35
scripts/install-tng-xapk.ps1
Normal file
35
scripts/install-tng-xapk.ps1
Normal 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
|
||||
}
|
||||
32
scripts/pull-tng-apk.ps1
Normal file
32
scripts/pull-tng-apk.ps1
Normal 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"
|
||||
Reference in New Issue
Block a user