Practical Guide

拿到一个 App,从哪里下手?

这不是一篇论文。这是我在逆向 13 个 APK 之后总结出来的实战手册——从最简单的 strings 一搜就出来,到被反检测打得满地找牙只能从内存里扣数据,每一步都有可以直接跑的代码。

13 个实战案例 7 章 + 速查表 从入门到被打回来

CHAPTER 01拿到 APK 先做什么

你拿到一个 .apk 文件,第一反应可能是想装到手机上跑一跑。先别急——APK 本质上就是个 ZIP 压缩包,你可以直接用 unzip 看里面有什么。这个步骤花不了 30 秒,但能帮你判断接下来该走哪条路。

30 秒快速分诊

先看看里面有没有 native 代码(.so 文件):

bash
# 列出所有 .so 和 .dex 文件
unzip -l target.apk | grep -E '\.(so|dex)$'

判断结果

没有 .so 纯 Java/Kotlin 应用。jadx 反编译基本就够了,不需要碰 native 层。
有 .so 有 native 层。需要 nm/readelf 分析,可能要 Frida 动态调试。
有 .so + 加壳特征 商业保护壳(如 AItguard、NMM Protect)。准备好打持久战。

用 strings 碰碰运气

你可能觉得「不会这么简单吧」——但真的有 App 把密钥明文放在 dex 里。花 5 秒试试又不亏:

bash
# 先从 APK 里解出 classes.dex
unzip -p target.apk classes.dex > classes.dex

# 碰运气四连
strings classes.dex | grep -E '^[a-f0-9]{64,}$'        # 明文 hex token?
strings classes.dex | grep -E '^https?://' | sort -u   # API 端点泄漏?
strings classes.dex | grep -iE 'authorization|bearer'  # auth 关键字?
strings classes.dex | grep -E '^[A-Za-z0-9+/=]{32,}$' > b64_candidates.txt  # base64 嫌疑人
经验之谈
如果 strings 直接搜到了 128 字符的 hex 串,先别高兴太早——有些 App(比如 hajimi)会故意把完整的 key 放在 .rodata 里当诱饵,但运行时会打乱顺序。你拿着 strings 的结果去请求服务器,大概率拿到 401。

升级路线图

逆向一个 App 的秘诀是:从最便宜的方法开始,搞不定再升级。每一级都比上一级贵 10 倍时间,所以别上来就掏 Frida。

strings
jadx
Python 还原
native 分析
Frida
堆快照

前三步能解决大部分没做 native 加固的 App——我实测 13 个目标里,7 个用纯静态分析就搞定了,每个不超过 35 分钟。


CHAPTER 02jadx 静态分析入门

strings 碰不到运气的时候(大多数时候),下一步就是反编译。jadx 是目前最好用的 Android 反编译器,能把 dex 还原成可读的 Java 代码。

bash
jadx -d jadx_out target.apk
# 忽略那行 "finished with errors"——输出照样能用
ls jadx_out/sources/com/    # 找到 App 自己的包名

反编译出来的代码分两大类:App 自己写的(在 sources/<包名>/ 下),和框架/库代码。如果 App 做了 R8 混淆,你还会看到一个 defpackage/ 目录——那是被混淆到默认包的类。先看 App 自己的代码。

找入口:锚点字符串

R8 混淆会把变量名、方法名都搅成 abc,但有些字符串是不会被改的——因为它们是框架 API 要求的参数。这些「锚点」就是你的突破口:

锚点告诉你什么
"Authorization"auth 请求的调用点。旁边的 const-string 就是 header 名 + 值
header("...", ...)OkHttp 设置 header 的地方
addHeader(...)同上,另一种 API
Cipher.getInstance("AES/...")使用了静态 AES 加密
SecretKeySpecAES 密钥构造
GCMParameterSpecAES-GCM 模式(常见)
IvParameterSpecAES-CBC 模式
Base64.decode(...)有 base64 编码的数据,可能是密文

从网络请求往回追

最有效的策略是从 HTTP 请求倒着追:找到设置 Authorization header 的地方,然后一层层追问「这个值从哪来的?」

MainActivity getApiKey() HelperClass.decrypt() Cipher.getInstance("AES/GCM") key/IV 字面量

追到最后,你会发现密钥要么是:

小技巧
在 jadx 的输出目录里直接 grep -r "Authorization" jadx_out/sources/,比在 jadx GUI 里搜快得多。

CHAPTER 03常见的混淆套路(和怎么破)

大部分 App 不会把密钥明文放着。但它们用的混淆手段说实话也就那么几招——认出来之后,Python 几行就能解。

XOR 混淆

最常见也最弱的一种。在反编译代码里看到一个 byte[] 数组和一个短 key 在循环里做 ^ 操作,那就是 XOR。

实战案例:mcdonald-manager(4 字节 XOR key)

这个 App 用了 React Native + Hermes 引擎,看起来很唬人(24 个 native 库),但真正的密钥逻辑在纯 Java 的 ApiKeyModule 里,就是一个简单的 XOR:

python
ENCRYPTED_KEY = [
    42, 15, 38, 12, 118, 84, 117, 91,
    118, 81, 116, 11, 36, 5, 112, 13,
    # ... 共 128 个字节
]
XOR_KEY = [19, 55, 66, 105]

key = "".join(chr(b ^ XOR_KEY[i % 4]) for i, b in enumerate(ENCRYPTED_KEY))
print(key)  # 128 字符的 hex 密钥

就这么简单。整个 React Native 框架、Hermes 字节码、24 个 native 库——全是噪音。真正的逻辑就是一行 XOR。

Base64 + AES/GCM

稍微正经一点的做法:密文用 Base64 编码存储,密钥和 IV 可能做了 XOR 分拆(两个数组异或得到真正的值)。

实战案例:sentinelx(反转 Base64 + XOR-split AES-GCM)

这个 App 把 Base64 密文分成 4 段,每段还做了字符串反转。密钥和 IV 各用两个数组 XOR 合成。

python
import base64
from cryptography.hazmat.primitives.ciphers.aead import AESGCM

def signed_to_bytes(arr):
    # Java byte 是有符号的!-64 → 0xc0 (192)
    return bytes((b & 0xff) for b in arr)

def xor_bytes(a, b):
    return bytes(x ^ y for x, y in zip(a, b))

# 四段 base64,每段反转后拼接
chunks = ["BjcbqyuHw/eZIgHWS6aRmhUwkQO9ZQPwZKdpiX+0jsQUyG/d", ...]
b64 = "".join(c[::-1] for c in chunks)
ct = base64.b64decode(b64)

# Key = 两个 16 字节数组做 XOR
key_a = signed_to_bytes([91, 40, -64, -102, ...])
key_b = signed_to_bytes([90, 51, 108, 39, ...])
key = xor_bytes(key_a, key_b)

# IV 同理
iv = xor_bytes(signed_to_bytes(iv_a), signed_to_bytes(iv_b))

# 解密(GCM tag 附在密文末尾,Python 库自动处理)
plaintext = AESGCM(key).decrypt(iv, ct, None).decode()

StringFog

这是一个开源的字符串混淆库(com.github.megatronking.stringfog)。如果你在反编译代码里看到每个字符串都变成了 StringFog.decrypt(byte[], byte[]),恭喜——算法是公开的,就是重复 XOR:

python
def stringfog(data, key):
    d = bytes((b & 0xff) for b in data)
    k = bytes((b & 0xff) for b in key)
    return bytes(d[i] ^ k[i % len(k)] for i in range(len(d))).decode()

证书哈希绑定

有些 App 用 APK 签名证书的 SHA-256 哈希作为 AES 密钥的一部分。思路是:如果你反编译修改了 APK,重新签名后证书哈希变了,密钥也就错了。

但有个问题——App 自己也需要知道证书哈希才能解密。所以哈希值一定存在 dex 的某个地方(通常是用 StringFog 加密的常量)。你只要找到这个嵌入的预期值,就不需要真的去算证书哈希了。

实战案例:give-me-your-money(证书哈希 + AES-256-GCM)
python
import base64, hashlib
from cryptography.hazmat.primitives.ciphers.aead import AESGCM

# 证书 SHA-256(从 apksigner verify --print-certs 获取)
CERT = "809a5f159b0d1d7f2ce674b1e3bf91164028faf9130ba708b250bdb5e06fddc1"
PKG = "com.example.givemeyourmoney"

key = hashlib.sha256((CERT + PKG).encode()).digest()
iv = base64.b64decode("XdiAuLcMtzh/Nz2u")
ct = base64.b64decode("G4OrVBL6roBgJQDVPfk+/EdStzC99BE3...")
pt = AESGCM(key).decrypt(iv, ct, None).decode()
注意
如果 APK 只用了 v2/v3 签名(没有 v1),META-INF/ 下不会有 .RSA 文件。这种情况下用 apksigner verify --print-certs target.apk 来获取证书哈希。

多管线诱饵

最阴险的静态手段之一:App 里有 4 条平行的解密管线,只有 1 条是真的,其他 3 条都是诱饵。如果你只看了第一条就收工,拿到的就是假 key。

破法:全部解密。真的那条 AES-GCM tag 校验会通过,假的会抛 InvalidTag 异常。

实战案例:404-new-era(4 条管线,3 条诱饵)

每条管线用不同的 salt 做 SHA-256 派生密钥,然后 AES-GCM 解密各自的密文。正确的管线解出来是 128 字符 hex,错误的直接报错:

python
for label, func, parts in PIPELINES:
    ct = assemble(parts)
    key = derive_key(func, PKG, cert_hash)
    try:
        pt = aes_gcm_decrypt(ct, key).decode().strip()
        print(f"[+] pipeline {label}: {pt!r}")  # 这条是真的
    except Exception as e:
        print(f"[-] pipeline {label}: {e!r}")    # InvalidTag = 诱饵

CHAPTER 04搞定 Native 代码

当你在 jadx 里看到 native 关键字,意味着这个方法的实现在 .so 文件里——Java 层看不到逻辑。别慌,先判断这个 native 函数有多大。

提取和初步分析

bash
# 从 APK 里提取 arm64 的 .so
unzip -p target.apk lib/arm64-v8a/lib*.so > lib.so

# 看有哪些 JNI 导出函数
nm -D --defined-only lib.so | grep Java_

# 看依赖了什么库(openssl? mbedtls?)
readelf -d lib.so

如果 grep Java_ 什么都没有,说明 App 用了 RegisterNativesJNI_OnLoad 里动态注册——你需要从 JNI_OnLoad 的反汇编里找方法表。

JNI 函数识别速查

在 arm64 反汇编里,x0 指向 JNIEnv*。通过 vtable 偏移可以识别调用了哪个 JNI 函数:

偏移索引函数名用途
0x538167NewStringUTF创建 Java 字符串(key 经常从这里出去)
0x548169GetStringUTFChars读取 Java 字符串参数
0x0C86FindClass查找 Java 类
0x540168GetStringUTFLength获取字符串长度

小函数:直接读汇编

如果函数不到 50 条指令,直接读汇编比搞 Frida 快得多。

实战案例:hajimi(40 条指令的 chunk 排列)

strings 能看到完整的 128 字符 hex blob,但直接拿去请求返回 401。原因是 native 函数按照特定顺序重新排列了 8 个 16 字节的 chunk:

python
from pathlib import Path

data = Path("libsecurity_check.so").read_bytes()
blob = data[0x800:0x800+128].decode("ascii")
chunks = [blob[i:i+16] for i in range(0, 128, 16)]

# 从反汇编的 ldr 指令读出 .rodata 偏移顺序
order = [0x800, 0x830, 0x810, 0x820,
         0x850, 0x870, 0x860, 0x840]
key = "".join(chunks[(off-0x800)//16] for off in order)
print(key)  # 正确排列后,请求返回 200
教训
strings 看到的内容可能是对的,但顺序是错的。如果你的 key 长得像回事但 401 了,想想是不是排列问题。

大函数:别硬看

如果 native 函数超过 1000 条指令(比如 amy 的 reconstructKeyNative 有 2800 条),静态逆向要花几个小时。这时候该升级到 Frida 了——下一章讲。

bash
# 用 NDK 自带的 llvm-objdump 反汇编特定函数
OBJDUMP=~/Library/Android/sdk/ndk/30.0.14904198/toolchains/llvm/prebuilt/darwin-x86_64/bin/llvm-objdump
$OBJDUMP -d --disassemble-symbols=Java_pkg_Class_method lib.so

CHAPTER 05Frida 动态分析

到这一步,说明静态分析搞不定了——要么 native 函数太大,要么密钥依赖运行时信息(设备指纹、证书哈希等)。Frida 让你在 App 运行时注入代码,直接看它在做什么。

环境搭建

bash
# 把 frida-server push 到模拟器/设备上
adb push frida-server /data/local/tmp/
adb shell "chmod 755 /data/local/tmp/frida-server"
adb shell "su 0 /data/local/tmp/frida-server -D"

# 激活 Frida Python 环境
source ~/Downloads/test/fri/.venv/bin/activate

# spawn 模式启动 App 并注入脚本
frida -U -f com.example.app -l hook.js

强制类初始化(最容易踩的坑)

Java.use("pkg.Class") 不会触发 <clinit>(类的静态初始化块),所以 System.loadLibrary 不会被调用,.so 不会被加载。你必须手动强制初始化:

javascript
var NB = Java.use("com.example.NativeBridge");

// 强制触发 <clinit>,这样 System.loadLibrary 才会执行
Java.use("java.lang.Class").forName.overload(
    "java.lang.String", "boolean", "java.lang.ClassLoader"
).call(
    Java.use("java.lang.Class"),
    "com.example.NativeBridge",
    true,  // <-- 这个 true 就是「初始化」
    NB.class.getClassLoader()
);

拦截 HTTP 请求头

如果 native 代码不通过 JNI 返回 jstring,而是直接通过 Java 的 HTTP 库发请求,你需要 hook 所有 setRequestProperty 的实现:

javascript
Java.enumerateMethods("*!setRequestProperty/i").forEach(function (loader) {
    loader.classes.forEach(function (clazz) {
        var c = Java.use(clazz.name);
        clazz.methods.forEach(function (m) {
            c[m].overloads.forEach(function (ov) {
                ov.implementation = function (k, v) {
                    console.log("[" + clazz.name + "] " + k + " = " + v);
                    return ov.apply(this, arguments);
                };
            });
        });
    });
});

Android 14 上通常会 hook 到 HttpURLConnectionImplHttpsURLConnectionImplDelegatingHttpsURLConnection 等好几个类。同一个值会被打印 3-6 次(因为委托链),这是正常的。

直接调用 native 函数

实战案例:amy(stub 反篡改 → 调用 native → 拿到 key)

这个 App 的 libnativeguard.so 有 2800 条指令,静态逆向不现实。但反篡改检查是集中式的——只有一个函数在 .so + 0x63af8,返回 1 表示「被篡改」。把它 stub 掉之后,直接调用 native 函数:

javascript
// 等 .so 加载
var lib = Process.findModuleByName("libnativeguard.so");

// Stub 反篡改检查:让它永远返回 0(安全)
var check = lib.base.add(0x63af8);
Interceptor.replace(check, new NativeCallback(function() {
    return 0;
}, "int", []));

// 构造和 Java 调用点一样的参数
var ctor = NativeBridge.class.getDeclaredConstructor(null);
ctor.setAccessible(true);
var inst = ctor.newInstance(null);

var result = NativeBridge.reconstructKeyNative.call(
    inst, certHash, certHash, pkg, pkg,
    derivedHash, chunk6, chunk7, chunk8
);
console.log("[+] key: " + result);

CHAPTER 06反检测和高级绕过

有些 App 会检测 Frida、调试器、root 环境。下面是我遇到过的几种手段,从简单到阴险排列。

集中式布尔检查

最简单的情况:所有检测逻辑汇聚到一个函数,返回 1 = 被篡改。你只要找到这个函数,让它永远返回 0:

javascript
// 在反汇编里找到的特征:bl <check>; tbz w0, #0x0, <good_path>
Interceptor.replace(
    lib.base.add(0x63af8),
    new NativeCallback(() => 0, "int", [])
);

分散式多分支 NOP

更复杂的情况:6 个条件跳转分散在函数各处,全部指向同一个 __cxa_throw 块(抛出 "Security risk" 异常)。找到它们,全部 NOP 掉:

javascript
var nop = [0x1f, 0x20, 0x03, 0xd5];  // aarch64 NOP 指令

[0x62454, 0x62538, 0x626dc,
 0x62bb8, 0x62c10, 0x62c40].forEach(function(off) {
    var addr = lib.base.add(off);
    Memory.protect(addr, 4, "rwx");   // 先改权限
    addr.writeByteArray(nop);           // 写入 NOP
});

怎么找到这些分支?先定位 throw block 的地址(看 .rodata 里的错误信息字符串),然后 grep 所有跳转到那个地址范围的指令。

数据投毒(最阴险的手段)

这是我遇到的最危险的反检测方式:检测到 Frida 后不是 abort,而是悄悄篡改输出。你拿到的 key 看起来完全合理(128 字符 hex),但就是 401。

实战案例:tartarus(数据投毒 + 预投毒钩子)

tartarus 的检测分三层:TCP 端口扫描(检查 27042 等默认 Frida 端口)、/proc 遍历(匹配 Frida 线程名)、/proc/net/tcp6 扫描。

但检测到了不会直接报错——它会设一个 sticky flag,后面格式化 Authorization 值的时候,偷偷把其中 5 个字节替换成垃圾。

绕过第一步:改名不拒绝

fgets/fread 的返回缓冲区里,把 Frida 相关的字符串原地替换成等长的无害名字:

javascript
var subs = [
    ["pool-frida",       "RenderThre"],   // 10 字符
    ["gum-js-loop",      "RenderThrea"],  // 11 字符
    ["gmain",             "Threa"],        // 5 字符
    ["frida-agent-64.so", "libemu_agent.so"],
];
// 在 fgets 返回后、lib 读取前,原地替换缓冲区内容

绕过第二步:在投毒之前截获

关键洞察:native 代码先用 %s%s%s%s%s 拼接 key(此时还是正确的),然后才检查 tamper flag 并投毒。你要 hook 的是早期的拼接调用,不是后面被污染的 Authorization: %s

javascript
var sn = Process.getModuleByName("libc.so")
    .findExportByName("__vsnprintf_chk");

Interceptor.attach(sn, {
    onEnter: function(args) {
        var fmt = args[4].readCString();
        // 找 "%s%s%s%s%s"(key 拼接)而非 "Authorization: %s"(已投毒)
        if (fmt && fmt.indexOf("%s") >= 0) {
            this.dst = args[0];
        }
    },
    onLeave: function(retval) {
        if (this.dst) {
            var s = this.dst.readCString(retval.toInt32());
            if (/[a-f0-9]{40,}/.test(s))
                console.log("[+] pre-poison key: " + s);
        }
    }
});

诱饵方法

flagship-project 注册了 10 个 JNI 方法(getApiKeygetSessionTokengetConfigHash 等),其中 9 个是假的。更阴的是,.rodata 里还有一段「泄漏的源代码注释」,暗示 key 在 getApiKey 里——但那个函数返回的是 deadbeefcafebabe

铁律
永远从 Java 调用点往回追,不要被方法名迷惑。谁真正把值塞进了 Authorization header?只有那条调用链上的方法才是真的。

堆快照——终极手段

当 App 的反 Frida 太狠,任何进程内注入都会被发现时,还有一条路:完全不注入。让 App 正常运行、正常发一次请求,然后从进程外部读内存。

实战案例:what-the-dog-doing(AItguard + 堆快照)

这个 App 有五层防护:AItguard 引导加壳、辅助 dex 字节旋转、运行时解压真实 dex、NMM Protect 自定义虚拟机、每个函数独立 XOR 加密字符串。Frida spawn 模式进不去,attach 模式被 moveTaskToBack 赶到后台。

绕过方法:不用 Frida。正常启动 App,手动生成一张图片(证明密钥有效),然后从外部读进程内存:

bash
# 找到进程 PID
PID=$(adb shell pidof com.example.app)

# 从 Java 堆的内存区域 dump
adb shell "su 0 dd if=/proc/$PID/mem skip=\$((0x12c00000)) bs=1 count=\$((0x17e00000))" > heap.bin

# 搜索 128 字符的 hex 串
grep -oE '[a-f0-9]{128}' heap.bin | sort -u > candidates.txt

# 逐个验证
cat candidates.txt

内核级别的 /proc/pid/mem 读取——App 的反 Frida 检测完全无法感知。


CHAPTER 07实战——OkHttp + JNI 加密怎么搞

现在来回答一个具体的问题:你碰到一个 App,它在 OkHttp 的 header 里放了版本号、OS version 之类的信息,最后用一个 JNI 里的密钥加密。你该怎么搞?

Step 1:找到 OkHttp Interceptor

在 jadx 输出里搜索:

bash
grep -rn '"Authorization"' jadx_out/sources/
grep -rn '\.header(' jadx_out/sources/
grep -rn 'addHeader(' jadx_out/sources/
grep -rn 'addInterceptor' jadx_out/sources/

你大概率会找到一个 OkHttp Interceptor,里面 chain.request().newBuilder() 然后一堆 .header() 调用。重点看 Authorization 或者 X-Signature 之类的 header——它的值是怎么来的?

Step 2:追踪加密值的来源

典型的调用链长这样:

Interceptor .header("Authorization", value) NativeLib.getKey(version, os, ...) native 方法

追到 native 关键字就对了。记下来:这个 native 方法叫什么,接收几个参数,参数都是什么值(版本号、OS 信息、证书哈希等)。

Step 3:分析 .so 文件

bash
unzip -p target.apk lib/arm64-v8a/lib*.so > lib.so
nm -D --defined-only lib.so | grep Java_

# 看看函数有多大
$OBJDUMP -d --disassemble-symbols=Java_com_example_NativeLib_getKey lib.so | wc -l

Step 4:Frida 绕过 + 调用

javascript
Java.perform(function() {
    // 1. 强制类初始化(加载 .so)
    var NL = Java.use("com.example.NativeLib");
    Java.use("java.lang.Class").forName.overload(
        "java.lang.String", "boolean", "java.lang.ClassLoader"
    ).call(Java.use("java.lang.Class"),
        "com.example.NativeLib", true, NL.class.getClassLoader());

    // 2. 等 .so 加载
    var lib = null;
    for (var i = 0; i < 80 && !lib; i++) {
        lib = Process.findModuleByName("libnative.so");
        if (!lib) Thread.sleep(0.05);
    }

    // 3. 绕过反篡改(具体偏移需要从反汇编里找)
    var check = lib.base.add(0xXXXXX);
    Interceptor.replace(check, new NativeCallback(() => 0, "int", []));

    // 4. 构造参数(和 Java 调用点保持一致!)
    var Build = Java.use("android.os.Build");
    var version = "1.0.0";
    var os = "Android " + Build.VERSION.RELEASE.value;
    var fingerprint = Build.FINGERPRINT.value.substring(0, 48);

    // 5. 调用
    var result = NL.getKey(version, os, fingerprint);
    console.log("[+] key: " + result);
});

Step 5:验证

拿到 key 之后,一定要实际请求一下服务器。200 = 成功,401 = 要么 key 是错的,要么你被数据投毒了。

常见的坑
  • 重签名改了证书哈希:如果 native 函数用证书哈希作为输入之一,你重签名 APK 后哈希变了,native 的输出也会变。解法:用原始签名,或者 hook 返回原始哈希值。
  • 模拟器指纹 ≠ 真机Build.FINGERPRINT 在模拟器上和真机上不同。如果 native 函数用它做 key 派生,你在模拟器上拿到的 key 可能不对。
  • System.loadLibrary 还没跑:忘了强制 <clinit>,调用 native 方法会直接 crash。
  • 方法名是诱饵:App 可能有 getApiKeygetTokengetSecret 好几个方法,但只有一个是真的。从 Java 调用点追,不要猜。

APPENDIX经验速查表

12 条实战心得

  1. 从最便宜的方法开始。 strings + jadx 能解决大部分 App,不要上来就掏 Frida。
  2. 从调用点追,不要从方法名追。 防御者会故意起误导性的名字。谁真正设了 header?追那条链。
  3. 多管线全解。 遇到 N 条平行代码路径?全部解密。GCM tag 通过的那条是真的。
  4. 每个候选 key 都要 live-verify。 一次 HTTP 请求花不了几秒,却能省你几小时的假阳性。
  5. Java signed bytes 会咬你。 每次 Java → Python 转写,都要 (b & 0xff)。这是第一大翻车原因。
  6. 小函数直接读汇编。 50 条指令以下的 native 函数,比搭 Frida 环境快。
  7. 大函数直接跑。 2800 条指令?别想了,stub 反篡改然后调用。
  8. 反篡改不一定 abort。 最危险的防御是悄悄改输出。如果 key 看着对但 401,怀疑数据投毒。
  9. 堆快照能绕过一切进程内检测。 /proc/pid/mem 读取发生在内核层面,App 感知不到。
  10. 不是所有目标都能搞定。 vitality 的复合判定哈希 + 直接系统调用打败了所有工具。认清现实。
  11. 检查两个 host。 有些 App 同时请求沙盒和正式服务器。sandbox 返回 200 不代表 production 也行。
  12. Frida 17 的 API 变了。 Module.findExportByName(null, name) 没了,得遍历模块。Memory.writeByteArray 也没了,用 addr.writeByteArray

13 个目标一览

目标 方法 耗时 核心技术
sentinelx 静态 ~10m 反转 Base64 + XOR-split AES-GCM
404-new-era 静态 ~25m StringFog + 4 管线(3 诱饵)
hajimi 静态 ~15m .rodata chunk 排列(40 条指令)
teamgamma 静态 ~15m LCG + 置换数组
give-me-your-money 静态 ~10m 证书哈希 AES-256-GCM
mcdonald-manager 静态 ~10m React Native 里的 4 字节 XOR
jmx 静态 ~35m 重定位指针表 + 音节编码
amy Frida ~45m 单点反篡改 stub
binary-breakers Frida ~60m 6 分支 NOP + Java HTTP 层捕获
flagship-project Frida ~30m 9 个诱饵方法 + 3 个反 Frida 探针
tartarus Frida ~3h 数据投毒 + 预投毒 vsnprintf 钩子
what-the-dog-doing 堆快照 ~40m /proc/pid/mem 冷快照
vitality 未攻破 ~3h 复合判定哈希 + 直接系统调用