Practical Guide
拿到一个 App,从哪里下手?
这不是一篇论文。这是我在逆向 13 个 APK 之后总结出来的实战手册——从最简单的 strings 一搜就出来,到被反检测打得满地找牙只能从内存里扣数据,每一步都有可以直接跑的代码。
13 个实战案例
7 章 + 速查表
从入门到被打回来
CHAPTER 01拿到 APK 先做什么
你拿到一个 .apk 文件,第一反应可能是想装到手机上跑一跑。先别急——APK 本质上就是个 ZIP 压缩包,你可以直接用 unzip 看里面有什么。这个步骤花不了 30 秒,但能帮你判断接下来该走哪条路。
30 秒快速分诊
先看看里面有没有 native 代码(.so 文件):
# 列出所有 .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 秒试试又不亏:
# 先从 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 代码。
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 加密 |
SecretKeySpec | AES 密钥构造 |
GCMParameterSpec | AES-GCM 模式(常见) |
IvParameterSpec | AES-CBC 模式 |
Base64.decode(...) | 有 base64 编码的数据,可能是密文 |
从网络请求往回追
最有效的策略是从 HTTP 请求倒着追:找到设置 Authorization header 的地方,然后一层层追问「这个值从哪来的?」
MainActivity
→
getApiKey()
→
HelperClass.decrypt()
→
Cipher.getInstance("AES/GCM")
→
key/IV 字面量
追到最后,你会发现密钥要么是:
- 直接硬编码在 dex 里(最简单,抄出来就行)
- 经过混淆(XOR、Base64、StringFog 等,需要 Python 还原)
- 来自 native(
native 关键字出现了——得看 .so)
- 依赖运行时信息(证书哈希、设备指纹等——可能需要 Frida)
小技巧
在 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:
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 合成。
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:
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)
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,错误的直接报错:
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 函数有多大。
提取和初步分析
# 从 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 函数:
| 偏移 | 索引 | 函数名 | 用途 |
0x538 | 167 | NewStringUTF | 创建 Java 字符串(key 经常从这里出去) |
0x548 | 169 | GetStringUTFChars | 读取 Java 字符串参数 |
0x0C8 | 6 | FindClass | 查找 Java 类 |
0x540 | 168 | GetStringUTFLength | 获取字符串长度 |
小函数:直接读汇编
如果函数不到 50 条指令,直接读汇编比搞 Frida 快得多。
实战案例:hajimi(40 条指令的 chunk 排列)
strings 能看到完整的 128 字符 hex blob,但直接拿去请求返回 401。原因是 native 函数按照特定顺序重新排列了 8 个 16 字节的 chunk:
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 了——下一章讲。
# 用 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 运行时注入代码,直接看它在做什么。
环境搭建
# 把 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 不会被加载。你必须手动强制初始化:
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 的实现:
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 函数:
// 等 .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:
// 在反汇编里找到的特征:bl <check>; tbz w0, #0x0, <good_path>
Interceptor.replace(
lib.base.add(0x63af8),
new NativeCallback(() => 0, "int", [])
);
分散式多分支 NOP
更复杂的情况:6 个条件跳转分散在函数各处,全部指向同一个 __cxa_throw 块(抛出 "Security risk" 异常)。找到它们,全部 NOP 掉:
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 相关的字符串原地替换成等长的无害名字:
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:
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,手动生成一张图片(证明密钥有效),然后从外部读进程内存:
# 找到进程 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 输出里搜索:
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 文件
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
- 小函数(<200 条指令):直接读反汇编,在 Python 里重写逻辑
- 大函数(>1000 条指令):用 Frida,按下面的步骤走
Step 4:Frida 绕过 + 调用
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 条实战心得
- 从最便宜的方法开始。 strings + jadx 能解决大部分 App,不要上来就掏 Frida。
- 从调用点追,不要从方法名追。 防御者会故意起误导性的名字。谁真正设了 header?追那条链。
- 多管线全解。 遇到 N 条平行代码路径?全部解密。GCM tag 通过的那条是真的。
- 每个候选 key 都要 live-verify。 一次 HTTP 请求花不了几秒,却能省你几小时的假阳性。
- Java signed bytes 会咬你。 每次 Java → Python 转写,都要
(b & 0xff)。这是第一大翻车原因。
- 小函数直接读汇编。 50 条指令以下的 native 函数,比搭 Frida 环境快。
- 大函数直接跑。 2800 条指令?别想了,stub 反篡改然后调用。
- 反篡改不一定 abort。 最危险的防御是悄悄改输出。如果 key 看着对但 401,怀疑数据投毒。
- 堆快照能绕过一切进程内检测。
/proc/pid/mem 读取发生在内核层面,App 感知不到。
- 不是所有目标都能搞定。 vitality 的复合判定哈希 + 直接系统调用打败了所有工具。认清现实。
- 检查两个 host。 有些 App 同时请求沙盒和正式服务器。sandbox 返回 200 不代表 production 也行。
- 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 |
复合判定哈希 + 直接系统调用 |