前言
Android NDK(Native Development Kit)允许开发者使用 C/C++ 编写高性能代码,并通过 JNI 与 Java 层交互。对于逆向工程师而言,掌握 NDK 开发不仅是分析 Native 层代码的基础,也是在 Frida Hook、Xposed 模块开发等场景中编写 Native 代码的必备技能。本文聚焦逆向领域中常用的 NDK 开发技巧和关键知识点。
JNI 函数命名规则
JNI 函数命名是逆向分析中第一个要掌握的技能。当你在 IDA 中打开一个 .so 文件,看到一个名为 Java_com_example_app_MainActivity_stringFromJNI 的函数时,如何解读它的结构?
JNI 函数命名规则分为两种模式:
静态注册(Static Registration)
格式为:Java_<包名>_<类名>_<方法名>
Java + 包名(点号替换为下划线) + 类名 + 方法名
|
例如:
JNIEXPORT jstring JNICALL Java_com_example_app_MainActivity_stringFromJNI(JNIEnv *env, jobject thiz);
|
如果包名或方法名中包含下划线,需用 _1 转义;如果包含 _ 本身再加下划线,则用 _2 等规则处理。
静态注册的完整签名规则:
包名规则:com.example.my_app.internal → com_example_my_1app_1internal (下划线前加 _1 转义,连续的 _ 用 _2、_3 等)
内部类规则:com.example.MainActivity$InnerClass → com_example_MainActivity_00024InnerClass ($ 替换为 _00024)
函数重载规则(关键!): void foo(int a) → Java_..._foo__I void foo(String s) → Java_..._foo__Ljava_lang_String_2 void foo(int a, String s) → Java_..._foo__ILjava_lang_String_2 (参数签名用双下划线 __ 分隔,防止方法名与参数签名混淆)
|
ART 中的静态注册查找流程:
Java 层调用 native 方法 → art::ArtMethod::Invoke() → 检查入口点是否为 JNI 存根 → 若为 JNI 存根,调用 art::JniEntryPoints::FindNativeMethod() → 在 so 的符号表中按 "Java_<包名>_<类名>_<方法名>" 格式查找 → 找到:调用该函数 → 未找到:抛出 UnsatisfiedLinkError
|
这个流程揭示了三个重要事实:
- 静态注册的函数必须被导出(在 ELF 的
.dynsym 表中可见)
- 延迟解析:native 方法首次调用时才执行符号查找,而非 so 加载时
- 可以利用
dlsym(RTLD_DEFAULT, "Java_...") 模拟 ART 的查找过程
动态注册(Dynamic Registration)
通过 JNI_OnLoad 中调用 RegisterNatives 注册,函数名可以是任意合法的 C 函数名:
JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM *vm, void *reserved) { JNIEnv *env; if ((*vm)->GetEnv(vm, (void**)&env, JNI_VERSION_1_6) != JNI_OK) { return JNI_ERR; }
JNINativeMethod methods[] = { {"stringFromJNI", "()Ljava/lang/String;", (void*)my_custom_native_func}, {"calculateHash", "(Ljava/lang/String;)Ljava/lang/String;", (void*)calc_hash}, };
jclass clazz = (*env)->FindClass(env, "com/example/app/MainActivity"); (*env)->RegisterNatives(env, clazz, methods, sizeof(methods) / sizeof(methods[0]));
return JNI_VERSION_1_6; }
|
动态注册的好处是函数名不会被轻易猜到,有一定隐蔽性。逆向分析时,如果找不到符合静态注册命名规则的函数,应重点检查 JNI_OnLoad 中的 RegisterNatives 调用。
动态注册的高级用法——延迟注册与条件注册:
static void decrypt_and_register_natives(JNIEnv *env) { unsigned char* encrypted_data = get_encrypted_section(); size_t data_len = get_section_size();
unsigned char key[32]; derive_key_from_device_id(key, sizeof(key)); aes_decrypt(encrypted_data, data_len, key);
int method_count = *(int*)encrypted_data; JNINativeMethod* methods = (JNINativeMethod*)(encrypted_data + 4);
jclass clazz = (*env)->FindClass(env, "com/example/app/MainActivity"); (*env)->RegisterNatives(env, clazz, methods, method_count); }
JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM *vm, void *reserved) { JNIEnv *env; (*vm)->GetEnv(vm, (void**)&env, JNI_VERSION_1_6); decrypt_and_register_natives(env); return JNI_VERSION_1_6; }
|
逆向定位动态注册函数的策略:
1. 静态分析: IDA 中查找 JNI_OnLoad 函数 → 查找对 RegisterNatives 的调用 → 回溯第三个参数(JNINativeMethod* methods)的来源 → 解析 JNINativeMethod 结构体获得函数指针
2. 动态分析: Frida Hook RegisterNatives → 打印 name、signature、fnPtr 三个字段 → 获得完整的 Java 方法与 Native 函数的映射表
3. 辅助分析: 在 IDA 中搜索 JNINativeMethod 的特征字节序列 → 结构体通常位于 .rodata 或 .data.rel.ro 段 → 前两个字段是指向字符串的指针,第三个是函数指针
|
关键 JNI API 详解
逆向工程中经常需要从 Native 层调用 Java 方法(例如 Hook 场景),以下是核心 API:
FindClass — 获取类引用
jclass clazz = (*env)->FindClass(env, "com/example/app/Utils"); if (clazz == NULL) { return NULL; }
|
注意:在非主线程中使用 FindClass 时,类加载器可能不同。如果是 native 线程(通过 pthread_create 创建),需要先获取 Application 的 ClassLoader:
jclass app_class = (*env)->FindClass(env, "android/app/Application"); jmethodID get_classloader = (*env)->GetMethodID(env, app_class, "getClassLoader", "()Ljava/lang/ClassLoader;"); jobject classloader = (*env)->CallObjectMethod(env, app_instance, get_classloader); jclass clz_classloader = (*env)->FindClass(env, "java/lang/ClassLoader"); jmethodID loadclass = (*env)->GetMethodID(env, clz_classloader, "loadClass", "(Ljava/lang/String;)Ljava/lang/Class;"); jclass target_clazz = (*env)->CallObjectMethod(env, classloader, loadclass, (*env)->NewStringUTF(env, "com/example/Utils"));
|
FindClass 在 ART 中的内部实现路径:
JNI FindClass("com/example/Foo") → ClassLinker::FindClass() → 查找 ClassTable(当前 ClassLoader 的已加载类缓存) → 命中:直接返回 → 未命中: → 获取当前线程的 ClassLoader → ClassLoader.loadClass("com.example.Foo") → 这可能会触发 DEX 文件加载(首次使用时)
|
关键提示:FindClass 返回的 jclass 是局部引用(local reference),在 JNI 函数返回后自动释放。如果需要跨函数使用,必须用 NewGlobalRef 转为全局引用。
GetMethodID / GetStaticMethodID — 获取方法 ID
jmethodID method = (*env)->GetMethodID(env, clazz, "encrypt", "(Ljava/lang/String;)Ljava/lang/String;");
jmethodID static_method = (*env)->GetStaticMethodID(env, clazz, "init", "(Landroid/content/Context;)V");
|
方法签名的 JNI 表示法(Type Descriptor):
| Java 类型 |
签名 |
| void |
V |
| boolean |
Z |
| byte |
B |
| char |
C |
| short |
S |
| int |
I |
| long |
J |
| float |
F |
| double |
D |
| String |
Ljava/lang/String; |
| Object |
L全限定类名; |
| 数组 |
[元素类型,如 [B = byte[],[[I = int[][] |
| 方法 |
(参数签名)返回类型签名 |
方法签名的完整构成规则:
整体格式: (参数1签名 参数2签名 ... )返回类型签名
示例: void foo() → ()V String toString() → ()Ljava/lang/String; int hashCode(String input) → (Ljava/lang/String;)I byte[] encrypt(byte[] data, int len)→ ([BI)[B boolean equals(Object obj) → (Ljava/lang/Object;)Z
|
CallStaticMethod / CallMethod — 调用方法
jstring input = (*env)->NewStringUTF(env, "hello"); jstring result = (*env)->CallStaticObjectMethod(env, clazz, static_method, input);
jboolean flag = (*env)->CallBooleanMethod(env, obj, method, 42, input);
|
根据返回值类型选择对应的 CallXxxMethod 函数:
CallVoidMethod → void CallBooleanMethod → jboolean CallByteMethod → jbyte CallCharMethod → jchar CallShortMethod → jshort CallIntMethod → jint CallLongMethod → jlong CallFloatMethod → jfloat CallDoubleMethod → jdouble CallObjectMethod → jobject (String、数组、自定义对象)
|
GetFieldID / SetFieldID — 字段操作
jfieldID field_id = (*env)->GetFieldID(env, clazz, "secretKey", "Ljava/lang/String;");
jstring key = (*env)->GetObjectField(env, obj, field_id);
jstring fake_key = (*env)->NewStringUTF(env, "bypassed"); (*env)->SetObjectField(env, obj, field_id, fake_key);
jfieldID static_field = (*env)->GetStaticFieldID(env, clazz, "isDebug", "Z"); jboolean is_debug = (*env)->GetStaticBooleanField(env, clazz, static_field); (*env)->SetStaticBooleanField(env, clazz, static_field, JNI_TRUE);
|
逆向实战:通过 Frida 调用 SetStaticBooleanField 可以强制开启应用的 Debug 模式,绕过 BuildConfig.DEBUG 检查。
NewGlobalRef / DeleteGlobalRef — 引用管理
jobject global_classloader = (*env)->NewGlobalRef(env, classloader);
jweak weak_ref = (*env)->NewWeakGlobalRef(env, obj);
(*env)->DeleteGlobalRef(env, global_classloader); (*env)->DeleteWeakGlobalRef(env, weak_ref);
|
JNI 引用类型对比:
| 引用类型 |
作用域 |
GC 行为 |
用途 |
| Local Reference |
当前 JNI 调用帧,返回即失效 |
不会被 GC |
临时操作 |
| Global Reference |
手动释放前一直有效 |
不会被 GC |
缓存对象 |
| Weak Global Reference |
手动释放前一直有效 |
可被 GC 回收 |
监听对象生命周期 |
JNI 局部引用的容量限制(重要!):
for (int i = 0; i < 10000; i++) { jobject item = (*env)->GetObjectArrayElement(env, array, i); }
(*env)->PushLocalFrame(env, 512); for (int i = 0; i < 10000; i++) { jobject item = (*env)->GetObjectArrayElement(env, array, i); if (i % 512 == 0) { (*env)->PopLocalFrame(env, NULL); (*env)->PushLocalFrame(env, 512); } } (*env)->PopLocalFrame(env, NULL);
for (int i = 0; i < 10000; i++) { jobject item = (*env)->GetObjectArrayElement(env, array, i); (*env)->DeleteLocalRef(env, item); }
|
JNI 方法签名生成工具
手动写 JNI 方法签名容易出错,使用 javap 快速生成:
javap -s com.example.app.MainActivity
|
Native 崩溃分析
Native 层崩溃比 Java 层更难以排查。以下是核心分析工具和流程:
Tombstone 文件
Native 崩溃时,Android 系统会生成 tombstone 文件(位于 /data/tombstones/),包含崩溃时的寄存器状态、调用栈、内存映射等。
Tombstone 文件结构详解:
*** *** *** *** *** *** *** *** *** *** *** *** *** *** *** *** Build fingerprint: 'google/sunfish/sunfish:12/...' Revision: '0' ABI: 'arm64' Timestamp: 2020-10-12 20:43:40.123456789+0800 Process uptime: 123s Cmdline: com.example.app pid: 12345, tid: 12346, name: Thread-2 >>> com.example.app <<< uid: 10123 signal 11 (SIGSEGV), code 1 (SEGV_MAPERR), fault addr 0x0000000000000000 x0 0000000000000000 x1 0000007f12345678 x2 0000000000000001 x3 0000000000000002 x4 0000007f87654321 x5 0000000000000000 ... lr 0000007f12345678 sp 0000007fc1234560 pc 0000007f12345670 pstate 0000000060000000
backtrace: #00 pc 0000000000001670 /data/app/.../libnative.so (encrypt+48) #01 pc 0000000000001824 /data/app/.../libnative.so (process+128) #02 pc 0000000000001a00 /data/app/.../libnative.so (Java_com_example_MainActivity_encrypt+32) ...
|
Tombstone 中的关键字段含义:
| 字段 |
含义 |
逆向分析用途 |
signal |
崩溃信号类型 |
SIGSEGV(11)=段错误, SIGABRT(6)=断言失败, SIGILL(4)=非法指令 |
fault addr |
访问的非法地址 |
0x0 通常为空指针解引用 |
pc |
程序计数器(崩溃指令地址) |
在 so 中的偏移 = pc - so 基址 |
lr |
链接寄存器(返回地址) |
定位调用者的函数 |
sp |
栈指针 |
结合栈回溯重建调用链 |
backtrace |
调用栈帧列表 |
每帧的 pc 值通过 addr2line 转换为源码行 |
提取 tombstone:
adb shell ls /data/tombstones/ adb pull /data/tombstones/tombstone_00
|
addr2line — 地址转行号
从 tombstone 中提取崩溃地址后,使用 NDK 自带的 addr2line 定位源码行号:
$NDK_HOME/toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-addr2line \ -e obj/local/arm64-v8a/libnative.so \ -f -C 0x0000000000001a34
|
注意:必须使用带符号表的 .so(即 obj/local/ 下的未 strip 版本,而非 libs/ 目录下 strip 过的版本)。
addr2line 参数说明:
-f 显示函数名 -C 对 C++ 符号进行 demangle(将 _Z3foo ... 还原为 foo()) -e 指定包含调试信息的 ELF 文件 -p 以紧凑格式输出(一行显示文件名:行号) -i 显示内联函数调用链
|
ndk-stack — 一键还原调用栈
ndk-stack 可以直接将 tombstone 中的地址批量还原为可读的调用栈:
adb logcat | $NDK_HOME/ndk-stack -sym obj/local/arm64-v8a/
|
或者直接对 tombstone 文件处理:
$NDK_HOME/ndk-stack -sym obj/local/arm64-v8a/ -dump tombstone_00
|
ndk-stack 工作原理:
ndk-stack 本质上是一个 Python 脚本,它读取 logcat 或 tombstone 中的地址行,匹配形如 #00 pc 00001670 libnative.so 的模式,提取出 libnative.so 名称和偏移量 00001670,然后在 -sym 指定的目录中查找对应的未 strip 的 so 文件,调用 addr2line 完成地址转换。
自定义崩溃处理器(信号处理)
在逆向工程中,有时需要自定义崩溃处理来捕获调用栈信息:
#include <signal.h> #include <unwind.h> #include <dlfcn.h>
struct BacktraceState { void** current; void** end; };
static _Unwind_Reason_Code unwind_callback(struct _Unwind_Context* context, void* arg) { struct BacktraceState* state = (struct BacktraceState*)arg; uintptr_t pc = _Unwind_GetIP(context); if (pc) { if (state->current == state->end) { return _URC_END_OF_STACK; } *state->current++ = (void*)pc; } return _URC_NO_REASON; }
static size_t capture_backtrace(void** buffer, size_t max) { struct BacktraceState state = {buffer, buffer + max}; _Unwind_Backtrace(unwind_callback, &state); return state.current - buffer; }
static void crash_handler(int sig, siginfo_t* info, void* ucontext) { void* buffer[128]; size_t count = capture_backtrace(buffer, 128);
__android_log_print(ANDROID_LOG_ERROR, "CrashHandler", "Signal %d at address %p", sig, info->si_addr);
Dl_info dl_info; for (size_t i = 0; i < count; i++) { if (dladdr(buffer[i], &dl_info) && dl_info.dli_fname) { uintptr_t offset = (uintptr_t)buffer[i] - (uintptr_t)dl_info.dli_fbase; const char* sym_name = dl_info.dli_sname ? dl_info.dli_sname : "???"; __android_log_print(ANDROID_LOG_ERROR, "CrashHandler", " #%zu %s (%s+0x%lx)", i, dl_info.dli_fname, sym_name, offset); } }
signal(sig, SIG_DFL); raise(sig); }
void install_crash_handler() { struct sigaction sa; memset(&sa, 0, sizeof(sa)); sa.sa_sigaction = crash_handler; sa.sa_flags = SA_SIGINFO | SA_ONSTACK; sigaction(SIGSEGV, &sa, NULL); sigaction(SIGBUS, &sa, NULL); sigaction(SIGABRT, &sa, NULL); }
|
关键 NDK 构建变量
在 Android.mk 或 CMakeLists.txt 中常用的构建配置:
cmake_minimum_required(VERSION 3.4.1)
add_library(native-lib SHARED src/main/cpp/native-lib.cpp src/main/cpp/crypto.cpp )
target_include_directories(native-lib PRIVATE ${CMAKE_SOURCE_DIR}/src/main/cpp/include )
target_link_libraries(native-lib android log dl )
|
Android.mk 关键变量:
LOCAL_PATH := $(call my-dir) include $(CLEAR_VARS) LOCAL_MODULE := native-lib LOCAL_SRC_FILES := native-lib.cpp crypto.cpp LOCAL_LDLIBS := -llog -ldl -landroid LOCAL_CFLAGS := -fvisibility=hidden include $(BUILD_SHARED_LIBRARY)
|
设置 -fvisibility=hidden 可隐藏内部符号,只导出 JNI_OnLoad 和显式标记 __attribute__((visibility("default"))) 的函数,增强安全性并减小编译产物体积。
逆向相关的高级编译选项:
LOCAL_CFLAGS += -fstack-protector-strong
LOCAL_CFLAGS += -fno-unwind-tables -fno-asynchronous-unwind-tables
LOCAL_LDFLAGS += -Wl,--hash-style=gnu
LOCAL_CFLAGS += -flto
LOCAL_LDFLAGS += -Wl,-z,relro -Wl,-z,now
LOCAL_CFLAGS += -ffunction-sections -fdata-sections LOCAL_LDFLAGS += -Wl,--gc-sections
LOCAL_CFLAGS += -fPIE LOCAL_LDFLAGS += -fPIE -pie
|
高级 JNI 技术:内联 Hook 与 Native 插桩
在逆向分析中,了解以下 JNI 层面的 Hook 技术原理有助于理解加固应用的反调试机制:
PLT/GOT Hook 原理
void* get_got_entry(const char* lib_path, const char* func_name) { void* handle = dlopen(lib_path, RTLD_NOLOAD); if (!handle) return NULL;
return got_entry; }
void install_got_hook(const char* lib_path, const char* func_name, void* hook_func, void** original_func) { void* got_entry = get_got_entry(lib_path, func_name); if (!got_entry) return;
*original_func = *(void**)got_entry;
mprotect((void*)((uintptr_t)got_entry & ~(PAGE_SIZE - 1)), PAGE_SIZE, PROT_READ | PROT_WRITE); *(void**)got_entry = hook_func; }
|
Inline Hook 原理
void install_inline_hook(void* target_func, void* hook_func, void** trampoline) { uintptr_t page_start = (uintptr_t)target_func & ~(PAGE_SIZE - 1); mprotect((void*)page_start, PAGE_SIZE * 2, PROT_READ | PROT_WRITE | PROT_EXEC);
uint32_t original_instrs[4]; memcpy(original_instrs, target_func, sizeof(original_instrs));
*trampoline = mmap(NULL, PAGE_SIZE, PROT_READ | PROT_WRITE | PROT_EXEC, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
uint32_t jump_instr = 0x14000000; *(uint32_t*)target_func = jump_instr;
__builtin___clear_cache((char*)target_func, (char*)target_func + sizeof(uint32_t)); __builtin___clear_cache((char*)*trampoline, (char*)*trampoline + sizeof(original_instrs)); }
|
面试常考问题
Q1: JNI 静态注册和动态注册的区别?各自优缺点?
A: 静态注册遵循 Java_包名_类名_方法名 的命名规则,javah 自动生成头文件,开发简单但函数名暴露,容易被逆向分析定位。动态注册在 JNI_OnLoad 中通过 RegisterNatives 手动映射,函数名可以自定义,有一定隐蔽性,且不需要为每个 native 方法生成单独的头文件。逆向分析时,静态注册可通过函数名直接定位,动态注册需要在 JNI_OnLoad 中找到 RegisterNatives 调用,从其 JNINativeMethod 数组参数中获取映射关系。
Q2: Native 崩溃后如何定位崩溃代码位置?
A: (1) 从 /data/tombstones/ 获取 tombstone 文件,找到崩溃的 PC 寄存器值和崩溃 so 的基址偏移;(2) 使用 addr2line 将偏移地址转换为源文件和行号,或使用 ndk-stack 工具批量还原整个调用栈;(3) 关键的注意点是必须使用未 strip 的包含调试符号的 .so 文件(位于 obj/local/<abi>/ 目录下),而非 APK 中的 strip 后的版本。(4) 在无法获取原始符号文件的情况下,可以通过崩溃 so 在 IDA 中加载,定位到偏移地址对应的反汇编代码,结合寄存器状态(x0-x30 的值)和栈回溯(backtrace 中的 LR 值)手动分析崩溃时执行到了哪条指令、参数值为多少。
Q3: 如何在 Native 线程中安全地调用 Java 方法?
A: 通过 pthread_create 或 std::thread 创建的 native 线程没有 JNIEnv,需要先调用 JavaVM->AttachCurrentThread() 获取 JNIEnv 指针。此外,native 线程默认使用系统 ClassLoader,无法直接通过 FindClass 找到应用类。需要先获取应用的 ClassLoader 对象(在 JNI_OnLoad 中缓存为全局引用),再通过该 ClassLoader 的 loadClass 方法加载目标类。使用完毕后应调用 JavaVM->DetachCurrentThread() 释放资源。注意:每个线程只能 Attach 一次,重复 Attach 是无操作(no-op),而忘记 Detach 会导致线程退出时资源泄露。
Q4: JNI 局部引用的生命周期是怎样的?为什么在长循环中会导致崩溃?
A: JNI 局部引用(Local Reference)的生命周期仅限于创建它的 JNI 调用帧。当 native 方法返回 Java 层时,所有在该方法中创建的局部引用会被自动释放。然而,ART 对每个 JNI 调用帧的局部引用数量有上限(通常为 512 个,定义在 art/runtime/jni_internal.cc 的 kLocalsDefault 中)。如果在一个循环中创建大量局部引用而不显式释放(DeleteLocalRef),一旦超过 512 个,ART 会触发 “JNI ERROR (app bug): local reference table overflow” 并 abort 进程。解决方法是使用 PushLocalFrame/PopLocalFrame 创建临时引用帧,或及时调用 DeleteLocalRef。
Q5: 什么是 PLT/GOT Hook?它在 Android 逆向中有什么应用?
A: PLT(Procedure Linkage Table)和 GOT(Global Offset Table)是 ELF 动态链接的核心机制。当 so 调用外部函数(如 libc 的 open、strcmp)时,实际调用流程是 代码 → PLT 存根 → GOT 表项 → 真实函数地址。GOT 表项在首次调用时被动态链接器(linker)填充为函数在内存中的实际地址。PLT/GOT Hook 的原理就是修改 GOT 表项,将其指向自定义的 Hook 函数,从而拦截对该外部函数的所有调用。在逆向中的应用包括:(1) 绕过反调试检测(Hook ptrace、fopen 读取 /proc/self/status);(2) 修改加密逻辑(Hook AES_set_encrypt_key 记录密钥);(3) 绕过 SSL 证书校验(Hook SSL_get_verify_result 返回成功)。与 Inline Hook 相比,PLT/GOT Hook 更稳定但只能拦截通过 PLT 调用的函数。