JNI深入
JNI深入
本文适合
已经能用 jadx 看 APK Java 层、但遇到 native 方法和 .so 校验逻辑会卡住的学习者。学完你能:判断 JNI 静态/动态注册方式,定位 Java 方法对应的 native 函数,并用静态分析或 Frida 验证参数和返回值。
JNI 是 Java Native Interface,用来让 Java 或 Kotlin 代码调用 native C/C++ 代码。APK 逆向中,关键校验逻辑常常通过 JNI 藏到 .so 文件里。
一句话判断
当 jadx 里出现 native 方法、System.loadLibrary、JNI_OnLoad 或 .so 库,并且 Java 层只负责收集输入时,就要转入 JNI 分析。
JNI 分析的核心是把 Java 方法名和 native 函数地址对上,再看 native 函数如何取字符串、数组和返回校验结果。
题目中常见信号
- Java/Kotlin 里有
public native boolean check(String s)。 - 静态代码块里有
System.loadLibrary("native-lib")。 - APK 的
lib/arm64-v8a/、armeabi-v7a/下有lib*.so。 - so 导出表里有
Java_com_xxx_MainActivity_check。 - so 里有
JNI_OnLoad、RegisterNatives、GetStringUTFChars、NewStringUTF。 - Java 层没有算法,只把输入传给 native 方法。
核心概念
JNI 有两种关键绑定方式:
- 静态注册:native 函数名按
Java_包名_类名_方法名导出。 - 动态注册:
JNI_OnLoad中调用RegisterNatives,把 Java 方法名映射到任意 native 地址。
native 函数前两个参数通常是 JNIEnv* 和 jobject/jclass,真实 Java 参数从第三个开始。很多反编译误读都来自把 JNIEnv* 当成业务参数。
最小分析流程
- 在 jadx 搜
native和System.loadLibrary,记录 Java 方法名、签名和库名。 - 从 APK 提取对应架构的
.so。 - 用
readelf -s或 IDA/Ghidra 搜Java_导出,判断是否静态注册。 - 若没有导出,找
JNI_OnLoad和RegisterNatives,解析方法名、签名、函数地址。 - 进入 native 函数,标注
JNIEnv*、jobject和真实参数。 - 找
GetStringUTFChars、GetByteArrayElements、memcmp/strcmp、返回值分支。 - 用 Frida hook Java native 方法或 native 函数,验证输入、目标值和返回值。
最小验证示例
静态注册:
unzip -o target.apk "lib/*/*.so" -d so_out
readelf -s so_out/lib/arm64-v8a/libnative-lib.so | grep Java_
strings so_out/lib/arm64-v8a/libnative-lib.so | grep -Ei "check|flag|RegisterNatives"动态注册时,用 Frida 打印注册表:
Interceptor.attach(Module.findExportByName(null, "RegisterNatives"), {
onEnter(args) {
const count = args[3].toInt32();
for (let i = 0; i < count; i++) {
const item = args[2].add(i * Process.pointerSize * 3);
console.log(item.readPointer().readUtf8String(),
item.add(Process.pointerSize).readPointer().readUtf8String(),
item.add(Process.pointerSize * 2).readPointer());
}
}
});看到 nativeCheck (Ljava/lang/String;)Z -> 0x... 后,就能把 Java 方法和 native 地址对上。
常见利用 / 解题路线
路线总览:
- 静态注册路线:导出表找
Java_...,直接进函数还原算法。 - 动态注册路线:分析
JNI_OnLoad或 hookRegisterNatives,得到函数映射。 - 参数打印路线:hook Java native 方法或
GetStringUTFChars,确认输入如何进入 C 层。 - 比较函数路线:hook
strcmp/memcmp或查看调用点,读取目标字符串/字节。 - 资源联动路线:native 读取 assets、raw、so 内常量或 Java 字段,需回到 APK 资源层。
- 返回值验证路线:patch native 返回 true 或 Frida 改返回值,确认成功路径是否真实。
JNI 解决什么问题
Android 应用主要运行在 ART 虚拟机上,逻辑常写在 Java 或 Kotlin 中。
但开发者可以把性能敏感、平台相关或想隐藏的逻辑写成 native 库。
Java 层通过 native 方法调用 .so 中的函数。
CTF 中常见情况是:Java 层只是收集输入,真正校验在 native 层。
静态注册和动态注册
静态注册会按照固定命名规则导出函数,例如:
Java_包名_类名_方法名动态注册会在运行时调用 RegisterNatives,把 Java 方法和 native 函数地址绑定。
动态注册更隐蔽,导出表里不一定能直接看到完整方法名。
分析 JNI 时要判断注册方式。
JNI 函数参数
native 方法常见前两个参数是:
JNIEnv*
jobject 或 jclass后面才是 Java 传入的真实参数。
JNIEnv 提供大量函数,用来操作 Java 字符串、数组、对象和类。
如果不理解参数位置,很容易把反编译结果看错。
字符串和数组转换
Java 字符串不能直接当 C 字符串使用。
native 层通常会调用类似 GetStringUTFChars 的函数获取 C 字符串。
数组也需要通过 JNI API 获取元素。
逆向时,关注这些转换点可以定位输入从 Java 层进入 native 层的位置。
native 层常见校验
JNI 中常见校验逻辑包括:
- 字符串变换。
- XOR。
- AES 或自定义加密。
- 查表。
- CRC 或 hash。
- 与 assets 中数据联动。
- 调用反调试或环境检查。
如果 .so 里只看到算法片段,还要回到 Java 层看输入来源和输出判断。
动态分析
JNI 题可以用动态调试或 hook 验证。
常见观察点:
- Java 层 native 方法调用前后。
RegisterNatives绑定表。GetStringUTFChars的返回内容。- native 校验函数返回值。
- 关键比较函数参数。
静态和动态结合,能减少大量猜测。
JNI 函数注册表
常用 JNI 函数速查
字符串操作:
GetStringUTFChars 获取 Java 字符串的 C 表示
NewStringUTF 从 C 字符串创建 Java 字符串
GetStringUTFLength 获取字符串字节长度
ReleaseStringUTFChars 释放字符串内存
数组操作:
GetByteArrayElements 获取 byte 数组元素
GetIntArrayElements 获取 int 数组元素
GetArrayLength 获取数组长度
ReleaseByteArrayElements 释放数组内存
对象操作:
GetObjectClass 获取对象的类引用
GetFieldID 获取字段 ID
GetIntField 获取 int 字段值
GetObjectField 获取对象字段值
CallIntMethod 调用对象的 int 方法
CallObjectMethod 调用对象方法
类操作:
FindClass 查找类
GetMethodID 获取方法 ID
GetStaticMethodID 获取静态方法 ID
GetStaticFieldID 获取静态字段 ID
异常处理:
ExceptionOccurred 检查是否有异常
ExceptionClear 清除异常JNIEnv 函数表结构
// JNIEnv 本质上是一个函数指针表
// 在 IDA 中看到的 JNI 调用都是通过函数指针间接调用
// JNIEnv* -> JNINativeInterface* -> 函数指针数组
// 常见偏移(64位系统):
// GetStringUTFChars -> 偏移 0x120 (函数表第 72 项,72*8=0x240)
// FindClass -> 偏移 0x060 (函数表第 24 项)
// GetMethodID -> 偏移 0x070 (函数表第 28 项)
// CallIntMethod -> 偏移 0xF0 (函数表第 60 项)so 文件分析
IDA 分析 JNI 函数的技巧
1. 先找 JNI_OnLoad 或 RegisterNatives
2. 通过 RegisterNatives 的参数找到 native 函数地址
3. native 函数前两个参数是 JNIEnv* 和 jobject/jclass
4. 在 IDA 中重命名参数以便分析
5. 追踪 JNI 调用找到字符串比较、加密等关键逻辑JNI 函数在 IDA 中的识别
// 典型 JNI 调用模式(x86-64):
// rdi = JNIEnv*
// 通过 [rdi + 偏移] 获取函数指针
// 然后 call 调用
// 示例:调用 GetStringUTFChars
mov rax, [rdi] ; 加载函数表指针
mov rax, [rax + 0x240] ; GetStringUTFChars 偏移
call rax常见 so 文件分析步骤
# 1. 查看导出表
readelf -s libnative.so | grep -i "java\|native\|register"
# 2. 查看依赖库
readelf -d libnative.so | grep NEEDED
# 3. 查看字符串
strings libnative.so | grep -i "flag\|key\|encrypt\|check\|verify"
# 4. 使用 IDA 打开,查找 JNI_OnLoad
# 在 Functions 窗口搜索 JNI_OnLoad
# 5. 查找 RegisterNatives 调用
# 搜索字符串 "RegisterNatives" 或搜索特征字节JNI_OnLoad 分析
JNI_OnLoad 是 so 库被加载时自动调用的函数,通常用于动态注册 native 方法。
// JNI_OnLoad 的典型结构
jint JNI_OnLoad(JavaVM* vm, void* reserved) {
JNIEnv* env;
// 获取 JNIEnv
(*vm)->GetEnv(vm, (void**)&env, JNI_VERSION_1_6);
// 动态注册 native 方法
JNINativeMethod methods[] = {
{"nativeCheck", "(Ljava/lang/String;)Z", (void*)native_check},
{"nativeEncrypt", "(Ljava/lang/String;)Ljava/lang/String;", (void*)native_encrypt},
};
jclass clazz = (*env)->FindClass(env, "com/example/MainActivity");
(*env)->RegisterNatives(env, clazz, methods, 2);
return JNI_VERSION_1_6;
}分析 JNI_OnLoad 的关键:
- 找到 RegisterNatives 调用。
- 解析 JNINativeMethod 数组,得到 Java 方法名到 native 函数的映射。
- 跳转到对应的 native 函数分析校验逻辑。
JNINativeMethod 结构
// 每个方法项包含三个字段
typedef struct {
char* name; // Java 方法名
char* signature; // 方法签名(参数和返回值类型)
void* fnPtr; // native 函数指针
} JNINativeMethod;
// 签名格式:
// (参数类型)返回类型
// Ljava/lang/String; -> Java String 类型
// Z -> boolean
// I -> int
// [B -> byte[]
// V -> voidFrida hook JNI
hook JNI 函数
// hook GetStringUTFChars 查看 native 函数接收的字符串
Interceptor.attach(Module.findExportByName("libnative.so", "GetStringUTFChars"), {
onEnter: function(args) {
this.str = args[1];
},
onLeave: function(retval) {
console.log("GetStringUTFChars: " + Memory.readUtf8String(retval));
}
});
// hook RegisterNatives 查看动态注册的函数
Interceptor.attach(Module.findExportByName(null, "RegisterNatives"), {
onEnter: function(args) {
var className = Java.vm.tryGetEnv().getClassName(args[1]);
var methodCount = args[3].toInt32();
console.log("RegisterNatives: " + className + " methods=" + methodCount);
for (var i = 0; i < methodCount; i++) {
var namePtr = args[2].add(i * Process.pointerSize * 3).readPointer();
var sigPtr = args[2].add(i * Process.pointerSize * 3 + Process.pointerSize).readPointer();
var fnPtr = args[2].add(i * Process.pointerSize * 3 + Process.pointerSize * 2).readPointer();
console.log(" " + namePtr.readUtf8String() + " " + sigPtr.readUtf8String() + " -> " + fnPtr);
}
}
});
// hook native 函数(假设地址已知)
var nativeCheckAddr = Module.findBaseAddress("libnative.so").add(0x1234);
Interceptor.attach(nativeCheckAddr, {
onEnter: function(args) {
console.log("native_check called");
console.log(" arg1 (JNIEnv*): " + args[0]);
console.log(" arg2 (jobject): " + args[1]);
console.log(" arg3 (jstring): " + args[2]);
// 读取 Java 字符串参数
var env = Java.vm.tryGetEnv();
var str = env.getStringUtfChars(args[2], null);
console.log(" input string: " + str.readUtf8String());
},
onLeave: function(retval) {
console.log(" return: " + retval);
}
});hook JNI_OnLoad 在加载时分析
// 等待 so 加载后 hook JNI_OnLoad
var moduleName = "libnative.so";
var module = Process.findModuleByName(moduleName);
if (module) {
var JNI_OnLoad = Module.findExportByName(moduleName, "JNI_OnLoad");
if (JNI_OnLoad) {
Interceptor.attach(JNI_OnLoad, {
onEnter: function(args) {
console.log("JNI_OnLoad called");
console.log(" JavaVM*: " + args[0]);
},
onLeave: function(retval) {
console.log("JNI_OnLoad returned: " + retval);
}
});
}
}
// Java 层 hook native 方法
Java.perform(function() {
var MainActivity = Java.use("com.example.MainActivity");
// hook native 方法的 Java 声明
MainActivity.nativeCheck.implementation = function(input) {
console.log("nativeCheck called with: " + input);
var result = this.nativeCheck(input);
console.log("nativeCheck returned: " + result);
return result;
};
});IDA + Frida 联合分析流程
1. IDA 静态分析:找到 JNI_OnLoad,解析 RegisterNatives 参数
2. 确定 native 函数地址和参数结构
3. Frida 动态 hook:在 native 函数入口打印参数值
4. hook JNI 函数查看字符串转换过程
5. hook strcmp/memcmp 等比较函数获取期望值
6. 根据期望值还原校验逻辑常见失败原因
- 只看 Java 层:Java 层如果只是
return nativeCheck(input),关键逻辑就在 so。 - 动态注册找不到函数:先看
JNI_OnLoad和RegisterNatives,不要只搜Java_。 - 参数读错:native 函数前两个参数不是业务输入,真实参数通常从第三个开始。
- 忽略资源联动:native 可能通过 Java 字段、assets 或 raw 读取 key 和密文。
- 只逆 so 不看 Java 调用:签名、参数类型、调用时机都在 Java 层。
- 架构选错:模拟器和真机可能加载不同 ABI 的 so,分析和 hook 要对应。
- Frida 只 hook Java 层:若 Java 层被 wrapper 包住,还要 hook native 地址或 JNI API。
迷你案例
jadx 中看到:
static { System.loadLibrary("check"); }
public native boolean nativeCheck(String input);导出表没有 Java_ 函数,说明可能动态注册。hook RegisterNatives 打印出:
nativeCheck (Ljava/lang/String;)Z -> libcheck.so+0x1350在 IDA 跳到 base+0x1350,发现它调用 GetStringUTFChars 后对输入每字节 xor 0x5a,再和 so 内数组比较。提取数组逆运算得到 flag。这个案例的闭环是:Java native 声明 -> RegisterNatives 映射 -> native 函数参数 -> 算法还原。