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 混淆会把变量名、方法名都搅成 a、b、c,但有些字符串是不会被改的——因为它们是框架 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 用了 RegisterNatives 在 JNI_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 到 HttpURLConnectionImpl、HttpsURLConnectionImpl、DelegatingHttpsURLConnection 等好几个类。同一个值会被打印 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 方法(getApiKey、getSessionToken、getConfigHash 等),其中 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 可能有 getApiKey、getToken、getSecret 好几个方法,但只有一个是真的。从 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 复合判定哈希 + 直接系统调用