安卓逆向实战:从 SO 层入手定位并绕过常见签名校验与反调试机制
很多 Android 应用把关键校验逻辑从 Java 层下沉到 Native,也就是 .so 里。原因很直接:Java 层太“透明”,被 Jadx 一扫基本就能看个七七八八;而 SO 层至少还能靠 JNI、字符串混淆、反调试拖慢分析速度。
这篇文章我想换一个更贴近实战的角度:不从“代码漂亮不漂亮”入手,而是从“怎么尽快找到关键点并验证思路”入手。目标是带你完成一条比较完整的链路:
- 识别 App 是否把签名校验放到了 SO 层
- 在 JNI 注册或导出函数附近定位校验逻辑
- 分析常见签名比对方式
- 识别并处理常见反调试
- 用动态插桩完成验证
说明:本文内容用于安全研究、加固分析、攻防演练与合规测试,请勿用于未授权目标。
背景与问题
在实际项目里,SO 层常见的两类保护是:
- 签名校验:检查 APK 当前签名是否与预期一致,防止重打包
- 反调试:阻止你附加调试器、阻止 Frida/Xposed 一类动态分析工具
很多同学卡住,通常不是不会用工具,而是不知道先看哪里。尤其是 JNI 层代码一旦经过 OLLVM、字符串拆分、导出表裁剪,看上去像一团麻。
我当时最常踩的坑有两个:
- 一上来就硬啃反编译伪代码,结果浪费很多时间
- 看到
ptrace、/proc/self/status、TracerPid就想一把梭 patch,结果程序后面还有二次检测
所以这篇教程强调一个策略:先建立“定位路径”,再决定是 patch、hook 还是绕过环境检测。
前置知识
建议你至少具备这些基础:
- 会用
jadx看 Java 层调用关系 - 知道 JNI 的基本结构:
Java_xxx导出函数、JNI_OnLoad、动态注册 - 会用
adb logcat - 用过至少一种动态工具:
Frida/gdbserver/IDA debugger
如果你对 ELF、JNI 签名还不熟,也没关系,本文会尽量边走边解释。
环境准备
一个常见的分析环境可以是:
- Android 模拟器或测试机
adbjadxIDA或Ghidrareadelf/nm/stringsFrida- Python 3
示例命令行工具准备:
adb devices
readelf --version
python3 --version
frida --version
分析总流程
先给一张全局图,避免中途迷路。
flowchart TD
A[用 Jadx 找 native 调用点] --> B[确认加载了哪些 so]
B --> C[看导出符号和 JNI_OnLoad]
C --> D[定位签名校验逻辑]
D --> E[识别反调试点]
E --> F[选择验证手段]
F --> G[Frida Hook]
F --> H[二进制 Patch]
G --> I[验证功能是否恢复]
H --> I
背景样例:Java 层如何把你引到 SO
现实中经常能看到类似代码:
public class SecurityBridge {
static {
System.loadLibrary("sec");
}
public native boolean checkSignature(android.content.Context context);
public native int antiDebug();
public boolean verify(android.content.Context context) {
return checkSignature(context) && antiDebug() == 0;
}
}
这段代码已经告诉我们两件事:
- 核心逻辑在
libsec.so - Java 层只是桥,真正的判定要去 Native 看
如果是静态注册,你会在导出表里直接看到类似:
Java_com_xxx_SecurityBridge_checkSignatureJava_com_xxx_SecurityBridge_antiDebug
但很多应用用的是动态注册,这时你在导出表里未必找得到目标函数名。
核心原理
这一节不讲太散,只盯住最常见的几个点。
1. SO 层签名校验通常怎么做
Native 层签名校验常见路线大概有三类:
路线 A:通过 PackageManager 拿签名摘要再比对
流程通常是:
- 通过 JNI 调 Java API
- 调
Context.getPackageManager() - 调
PackageManager.getPackageInfo() - 取
signatures或signingInfo - 对签名做
MD5/SHA1/SHA256 - 与 SO 内硬编码值比较
这类逻辑的特点是:一定会出现大量 JNI 调用,比如:
FindClassGetMethodIDCallObjectMethodGetArrayLengthGetObjectArrayElementGetByteArrayElements
路线 B:直接读取 APK / META-INF
有些应用会直接解析 APK 里的证书文件或 V2/V3 签名块。
这种方式更“底层”,但复杂度高一些,通常会出现:
- ZIP 解析
- 证书解析
- 哈希函数调用
路线 C:Java 层拿到结果,SO 层只做对比
比如 Java 层先取签名字符串,再传给 Native 比较。
这种方式的优点是开发方便,缺点是入口更明显。
2. SO 层反调试通常怎么做
最常见的反调试手法其实就那么几类:
ptrace(PTRACE_TRACEME, ...)- 读
/proc/self/status看TracerPid - 读
/proc/self/maps查 Frida/Xposed 关键字 - 枚举端口、线程名、进程名
- 时间差检测,防单步调试
- 检测
ro.debuggable、ro.secure等系统属性
这些检测常常不是只做一次,而是:
- 启动时做一次
- 关键功能调用前再做一次
- 后台线程循环做
这就是为什么只 patch 一个位置经常不够。
3. 为什么 JNI_OnLoad 是高价值入口
动态注册一般会在 JNI_OnLoad 里做:
- 找类
- 拼
JNINativeMethod表 - 调
RegisterNatives
所以只要你定位到 JNI_OnLoad,大概率就能顺着 JNINativeMethod 找到:
- Java 方法名
- JNI 签名
- Native 函数地址
这比盲目在整个 SO 里搜字符串效率高太多。
定位思路:从 JNI_OnLoad 找到目标函数
先看一个典型流程图。
sequenceDiagram
participant Java as Java层
participant Loader as System.loadLibrary
participant SO as libsec.so
participant JNI as JNI_OnLoad
participant Reg as RegisterNatives
Java->>Loader: loadLibrary("sec")
Loader->>SO: 加载 ELF
SO->>JNI: 调用 JNI_OnLoad
JNI->>Reg: RegisterNatives(methods)
Reg-->>Java: native 方法与地址绑定
Java->>SO: 调用 checkSignature/antiDebug
步骤 1:确认目标 SO
adb shell pm path com.example.target
adb pull /data/app/~~xxx/base.apk .
unzip -l base.apk | grep '\.so'
如果是多 ABI,优先看设备实际加载的架构,比如 arm64-v8a。
步骤 2:看导出符号
readelf -Ws libsec.so | grep -E 'JNI_OnLoad|Java_'
如果能看到 Java_...checkSignature,说明是静态注册,直接进函数。
如果只有 JNI_OnLoad,大概率是动态注册。
步骤 3:在 IDA/Ghidra 中跟 JNI_OnLoad
动态注册常见伪代码像这样:
static JNINativeMethod methods[] = {
{"checkSignature", "(Landroid/content/Context;)Z", (void *)sub_1234},
{"antiDebug", "()I", (void *)sub_2345}
};
如果字符串被拆了,也别慌。你仍然可以看:
RegisterNatives的参数- 数组内每项是否呈现“方法名指针 / 签名指针 / 函数地址”结构
- 附近是否有
FindClass("com/example/SecurityBridge")
实战代码:用 Frida 快速验证签名校验与反调试点
下面给一套可运行的 Frida 脚本,用来做两件事:
- Hook
RegisterNatives,打印动态注册信息 - Hook
ptrace和/proc/self/status读取,辅助定位反调试
使用前请确保目标环境允许 Frida 注入,包名按实际替换。
1)打印动态注册的 native 方法
// file: hook_register_natives.js
'use strict';
function hookRegisterNatives() {
const addr = Module.findExportByName(null, 'RegisterNatives');
if (addr) {
console.log('[!] RegisterNatives exported directly:', addr);
}
Java.perform(function () {
const env = Java.vm.getEnv();
console.log('[*] Java VM Env ready:', env);
});
const libart = Process.findModuleByName('libart.so');
if (!libart) {
console.log('[-] libart.so not found');
return;
}
const symbols = libart.enumerateSymbols();
let target = null;
for (const s of symbols) {
if (s.name.indexOf('RegisterNatives') !== -1 &&
s.name.indexOf('JNI') !== -1) {
target = s.address;
console.log('[+] Found candidate:', s.name, s.address);
break;
}
}
if (!target) {
console.log('[-] RegisterNatives symbol not found in libart');
return;
}
Interceptor.attach(target, {
onEnter(args) {
const env = args[0];
const jclass = args[1];
const methods = args[2];
const count = args[3].toInt32();
console.log('\n[*] RegisterNatives called, count =', count);
for (let i = 0; i < count; i++) {
const base = methods.add(i * Process.pointerSize * 3);
const namePtr = base.readPointer();
const sigPtr = base.add(Process.pointerSize).readPointer();
const fnPtr = base.add(Process.pointerSize * 2).readPointer();
let name = '';
let sig = '';
try { name = namePtr.readCString(); } catch (e) {}
try { sig = sigPtr.readCString(); } catch (e) {}
const module = Process.findModuleByAddress(fnPtr);
console.log(` [${i}] ${name} ${sig} => ${fnPtr} ${module ? module.name : ''}`);
}
}
});
}
setImmediate(hookRegisterNatives);
运行方式:
frida -U -f com.example.target -l hook_register_natives.js --no-pause
如果输出里看到了:
checkSignature (Landroid/content/Context;)Z => 0x...antiDebug ()I => 0x...
那后面就可以直接对这些地址继续下手。
2)Hook 常见反调试接口
// file: hook_antidebug.js
'use strict';
function hookPtrace() {
const ptraceAddr = Module.findExportByName(null, 'ptrace');
if (!ptraceAddr) {
console.log('[-] ptrace not found');
return;
}
Interceptor.replace(ptraceAddr, new NativeCallback(function (request, pid, addr, data) {
console.log('[*] ptrace called, request =', request, 'pid =', pid);
return 0;
}, 'int', ['int', 'int', 'pointer', 'pointer']));
console.log('[+] ptrace replaced');
}
function hookOpen() {
const openAddr = Module.findExportByName(null, 'open');
if (!openAddr) {
console.log('[-] open not found');
return;
}
Interceptor.attach(openAddr, {
onEnter(args) {
this.path = args[0].readCString();
if (this.path.indexOf('/proc/self/status') !== -1 ||
this.path.indexOf('/proc/self/maps') !== -1) {
console.log('[*] open =>', this.path);
}
}
});
console.log('[+] open hooked');
}
function hookRead() {
const readAddr = Module.findExportByName(null, 'read');
if (!readAddr) {
console.log('[-] read not found');
return;
}
Interceptor.attach(readAddr, {
onEnter(args) {
this.fd = args[0].toInt32();
this.buf = args[1];
},
onLeave(retval) {
const size = retval.toInt32();
if (size > 0 && size < 4096) {
try {
const text = this.buf.readUtf8String(size);
if (text && text.indexOf('TracerPid:') !== -1) {
const patched = text.replace(/TracerPid:\s*\d+/g, 'TracerPid:\t0');
Memory.writeUtf8String(this.buf, patched);
console.log('[+] Patched TracerPid => 0');
}
} catch (e) {}
}
}
});
console.log('[+] read hooked');
}
setImmediate(function () {
hookPtrace();
hookOpen();
hookRead();
});
运行:
frida -U -f com.example.target -l hook_antidebug.js --no-pause
实战代码:直接 Hook Native 函数返回值
如果你已经通过 RegisterNatives 找到了 checkSignature 和 antiDebug 的函数地址,可以直接改返回值完成验证。
下面演示一个更常用的写法:等待目标 SO 加载,再按偏移 Hook。
偏移值需要你自己在 IDA/Ghidra 中确认。
// file: hook_native_return.js
'use strict';
const soName = 'libsec.so';
const checkOffset = 0x1234; // 示例偏移
const antiOffset = 0x2345; // 示例偏移
function waitForModule(name, callback) {
const timer = setInterval(function () {
const m = Process.findModuleByName(name);
if (m) {
clearInterval(timer);
callback(m);
}
}, 100);
}
waitForModule(soName, function (m) {
console.log('[+] module loaded:', m.name, m.base);
const checkAddr = m.base.add(checkOffset);
const antiAddr = m.base.add(antiOffset);
Interceptor.attach(checkAddr, {
onLeave(retval) {
console.log('[*] checkSignature original =>', retval.toInt32());
retval.replace(1);
console.log('[+] checkSignature forced => 1');
}
});
Interceptor.attach(antiAddr, {
onLeave(retval) {
console.log('[*] antiDebug original =>', retval.toInt32());
retval.replace(0);
console.log('[+] antiDebug forced => 0');
}
});
});
运行:
frida -U -f com.example.target -l hook_native_return.js --no-pause
这个脚本的意义在于:
先验证你的定位是否正确,再决定要不要落地 patch。
我个人很少上来就改二进制,通常都会先这样动态验证一次。因为只要验证通过,后续无论是 NOP 某个分支,还是改常量,心里都更有底。
如果要做静态 Patch,通常改哪里
这部分只讲高层思路,不展开危险细节。
常见 patch 点:
- 签名比对结果处,把失败分支改成功分支
- 反调试函数统一返回“未检测到调试器”
ptrace调用前后直接跳过strcmp/memcmp结果判断处强制置零
一个典型判断逻辑可能是:
if (memcmp(calc_digest, expected_digest, 32) != 0) {
return 0;
}
return 1;
此时 patch 的思路可能有两种:
- 改条件跳转,让失败不成立
- 直接让函数末尾固定返回成功
从维护性来说,我更推荐尽量 patch 最终判断,而不是中间数据流,因为中间数据流容易牵连更多副作用。
如何识别“签名校验”而不是别的校验
很多人会把设备完整性校验、环境检测、资源校验误认为签名校验。可以从特征入手。
classDiagram
class SignatureCheck {
+PackageManager
+PackageInfo
+SigningInfo/Signature
+Digest compare
}
class AntiDebug {
+ptrace
+TracerPid
+maps scan
+thread/process scan
}
class EnvCheck {
+ro.debuggable
+su path
+magisk artifact
+test-keys
}
签名校验的高频特征
- 出现包名相关 API
- 出现
PackageInfo、Signature、SigningInfo - 出现证书摘要算法
SHA1、SHA256 - 出现与固定摘要值的比较
反调试的高频特征
ptrace/proc/self/statusTracerPid- 线程轮询
- 调试端口或 Frida 特征字符串
环境检测的高频特征
/system/bin/sumagisktest-keysro.debuggable
把这三类区分开,分析会快很多。
逐步验证清单
建议按下面顺序做,每一步都留证据,不要跳步。
第 1 步:确认 Native 是否参与决策
- Java 层是否有
native方法 - 是否
System.loadLibrary - 崩溃或弹窗前是否调用 Native 方法
第 2 步:找到入口函数
- 导出表是否有
Java_ - 没有则看
JNI_OnLoad - Hook
RegisterNatives
第 3 步:判断是哪类校验
- 是签名校验?
- 是环境检测?
- 还是反调试?
第 4 步:动态验证
- Hook 返回值
- Hook
ptrace - Hook
/proc/self/status
第 5 步:决定最终策略
- 仅研究验证:Frida 即可
- 需要离线样本分析:二进制 patch
- 需要长期自动化:脚本化定位流程
常见坑与排查
这部分很重要,很多时间其实都花在这些坑上。
1. 只 Hook 了 Java 层,结果没效果
原因通常是:
- Java 层只是桥
- 真正判断在 SO 层
- 甚至 Java 层返回值会被 Native 二次确认
排查方法:
adb logcat | grep -i -E 'jni|native|sig|debug'
再配合 RegisterNatives Hook 看实际调用函数。
2. 找到了 ptrace,绕过后还是闪退
常见原因:
- 还有
TracerPid检测 - 还有
/proc/self/maps扫描 Frida - 有 watchdog 线程在循环检查
- Native 层检测到 Hook 痕迹
处理思路:
- 同时 Hook
ptrace、open、read - 观察是否有后台线程持续访问
/proc - 关注
pthread_create创建的线程入口
3. IDA 里偏移对不上,Frida Hook 失败
原因通常包括:
- 你看的是文件偏移,不是虚拟地址相对偏移
arm32/thumb地址最低位问题- 目标实际加载的是另一个 ABI 的 SO
- App 有壳,真正 SO 是运行时解密释放的
建议这样确认:
Process.enumerateModules().forEach(function (m) {
if (m.name.indexOf('sec') !== -1) {
console.log(m.name, m.base, m.size);
}
});
如果是 32 位 ARM,还要确认 Thumb 模式地址问题。
4. Hook 太早或太晚
- 太早:SO 还没加载
- 太晚:校验已经做完,App 已经退出
处理方式:
- 用
-f启动并--no-pause - 轮询等待模块加载
- 必要时 Hook
android_dlopen_ext
示例:
// file: hook_dlopen.js
'use strict';
const dlopen = Module.findExportByName(null, 'android_dlopen_ext');
if (dlopen) {
Interceptor.attach(dlopen, {
onEnter(args) {
this.name = args[0].readCString();
},
onLeave(retval) {
if (this.name && this.name.indexOf('libsec.so') !== -1) {
console.log('[+] loaded:', this.name);
}
}
});
}
5. 看到很多字符串比较,不知道哪个才是关键
经验上优先看:
- 最终返回值附近的
strcmp/memcmp - 与证书摘要长度匹配的比较
- 失败后直接导致
exit/abort/raise(SIGKILL)的分支
不要一上来就追所有字符串比较,不然很容易陷进去。
安全/性能最佳实践
虽然这是逆向分析文章,但在操作层面也有“最佳实践”。
1. 先动态验证,后静态 Patch
这是最重要的一条。
原因很简单:
- 动态验证成本低
- 回滚方便
- 更容易确认哪个点是“最小有效改动”
如果动态改返回值都无效,说明你定位点大概率不对,或者还有联动校验。
2. 尽量最小化 Hook 范围
不要无脑全局 Hook 大量 libc API,尤其在性能敏感或多线程场景中。
建议:
- 先精确 Hook 某个 Native 目标函数
- 再按需补
ptrace/open/read - 打印日志时控制频率
3. 注意多线程与重复检测
反调试常常由后台线程触发。
如果你只在主线程行为上做验证,很可能误判“已经绕过”。
可以关注:
pthread_create- 定时轮询
prctl设置线程名
4. 区分研究环境与生产环境
- 测试机可以 root、可以 Frida
- 真实设备环境可能有 SELinux、厂商 ROM 差异、ABI 差异
所以结论一定要带边界条件。
例如:某个 Hook 在模拟器有效,不代表在所有真机都有效。
5. 对开发者的反向建议
如果你是从防护视角看这篇文章,那么建议是:
- 不要只做单点签名校验
- 不要只依赖
ptrace - 重要校验应多点、多时机触发
- 校验结果不要只走单一布尔返回值
- 尽量把校验和业务状态绑定,而不是“校验失败就弹 toast”
单点防护,在动态插桩面前通常撑不了多久。
一个更实用的定位策略总结
我把自己常用的方法压缩成一句话:
先抓注册,再抓返回,再抓检测点,最后才考虑 patch。
对应到行动上就是:
Jadx找 native 入口readelf看JNI_OnLoad/ 导出函数FridaHookRegisterNatives拿真实函数地址- 先强改返回值验证
- 再补反调试绕过
- 最后决定是否做静态 patch
这个路径的好处是,不容易一头扎进伪代码细节里出不来。
总结
从 SO 层定位并绕过签名校验与反调试,核心不是“把所有代码都看懂”,而是建立一条可靠的分析路径:
- 签名校验优先看 JNI 调 Java 取签名、摘要计算、最终比较
- 反调试优先看
ptrace、TracerPid、maps扫描、后台线程 - 动态注册优先从
JNI_OnLoad -> RegisterNatives入手 - 验证策略优先用 Frida 改返回值,而不是直接 patch 二进制
如果你是第一次系统做这类分析,我建议你先拿一个结构比较简单的练手样本,只做三件事:
- 打印
RegisterNatives - 找到一个签名校验函数
- 找到一个反调试函数并动态绕过
把这三个动作跑通,后面遇到混淆更重的 SO,思路也不会乱。
最后再强调一次边界:本文方法适合授权测试、安全研究、样本分析。在复杂商业壳、虚拟化保护、内联混淆很重的场景下,单纯依靠本文流程未必一步到位,但它仍然是非常稳的起点。