目录
  1. 1. 前言
  2. 2. JNI 函数命名规则
    1. 2.1. 静态注册(Static Registration)
    2. 2.2. 动态注册(Dynamic Registration)
  3. 3. 关键 JNI API 详解
    1. 3.1. FindClass — 获取类引用
    2. 3.2. GetMethodID / GetStaticMethodID — 获取方法 ID
    3. 3.3. CallStaticMethod / CallMethod — 调用方法
    4. 3.4. GetFieldID / SetFieldID — 字段操作
    5. 3.5. NewGlobalRef / DeleteGlobalRef — 引用管理
  4. 4. JNI 方法签名生成工具
  5. 5. Native 崩溃分析
    1. 5.1. Tombstone 文件
    2. 5.2. addr2line — 地址转行号
    3. 5.3. ndk-stack — 一键还原调用栈
    4. 5.4. 自定义崩溃处理器(信号处理)
  6. 6. 关键 NDK 构建变量
  7. 7. 高级 JNI 技术:内联 Hook 与 Native 插桩
    1. 7.1. PLT/GOT Hook 原理
    2. 7.2. Inline Hook 原理
  8. 8. 面试常考问题
【逆向安全技术-基础篇】NDK开发技巧

前言

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 + 包名(点号替换为下划线) + 类名 + 方法名

例如:

// Java 方法声明: package com.example.app 中的 MainActivity.stringFromJNI()
// 对应的 JNI 函数:
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

这个流程揭示了三个重要事实:

  1. 静态注册的函数必须被导出(在 ELF 的 .dynsym 表中可见)
  2. 延迟解析:native 方法首次调用时才执行符号查找,而非 so 加载时
  3. 可以利用 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 调用。

动态注册的高级用法——延迟注册与条件注册:

// 混淆加固中常见的模式:在运行时动态构建 JNINativeMethod 数组
// 使得静态分析无法直接获取 Java-Native 映射关系

static void decrypt_and_register_natives(JNIEnv *env) {
// 1. 从加密段读取方法表
unsigned char* encrypted_data = get_encrypted_section();
size_t data_len = get_section_size();

// 2. 动态解密(密钥可能来自设备指纹)
unsigned char key[32];
derive_key_from_device_id(key, sizeof(key));
aes_decrypt(encrypted_data, data_len, key);

// 3. 解析解密后的数据结构
int method_count = *(int*)encrypted_data; // 前 4 字节是方法数
JNINativeMethod* methods = (JNINativeMethod*)(encrypted_data + 4);

// 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 — 获取类引用

// 获取 Java 类的 jclass 引用
jclass clazz = (*env)->FindClass(env, "com/example/app/Utils");
if (clazz == NULL) {
// 类未找到,检查类名是否正确,以及是否存在 ClassNotFoundException
return NULL;
}

注意:在非主线程中使用 FindClass 时,类加载器可能不同。如果是 native 线程(通过 pthread_create 创建),需要先获取 Application 的 ClassLoader:

// 在 native 线程中查找类
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 — 调用方法

// 调用静态方法:Class.method(String) -> String
jstring input = (*env)->NewStringUTF(env, "hello");
jstring result = (*env)->CallStaticObjectMethod(env, clazz, static_method, input);

// 调用实例方法:obj.method(int, String) -> boolean
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 — 字段操作

// 获取字段 ID
jfieldID field_id = (*env)->GetFieldID(env, clazz, "secretKey",
"Ljava/lang/String;");

// 读取实例字段
jstring key = (*env)->GetObjectField(env, obj, field_id);

// 修改实例字段(用于 Patch 运行时状态)
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);

// 弱全局引用(允许 GC 回收)
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 局部引用的容量限制(重要!):

// ART 默认每个 JNI 调用帧最多 512 个局部引用
// 在循环中创建大量局部引用而不释放会导致崩溃

// 错误示例:
for (int i = 0; i < 10000; i++) {
jobject item = (*env)->GetObjectArrayElement(env, array, i);
// item 作为局部引用在循环结束后才释放!
// 超过 512 个时触发 JNI 错误
}

// 正确写法 1:使用 PushLocalFrame/PopLocalFrame
(*env)->PushLocalFrame(env, 512); // 创建新的局部引用帧
for (int i = 0; i < 10000; i++) {
jobject item = (*env)->GetObjectArrayElement(env, array, i);
// ... 处理 item ...
if (i % 512 == 0) {
(*env)->PopLocalFrame(env, NULL);
(*env)->PushLocalFrame(env, 512);
}
}
(*env)->PopLocalFrame(env, NULL);

// 正确写法 2:及时释放
for (int i = 0; i < 10000; i++) {
jobject item = (*env)->GetObjectArrayElement(env, array, i);
// ... 处理 item ...
(*env)->DeleteLocalRef(env, item);
}

JNI 方法签名生成工具

手动写 JNI 方法签名容易出错,使用 javap 快速生成:

# 生成类中所有 native 方法的 JNI 签名
javap -s com.example.app.MainActivity

# 输出示例:
# public native java.lang.String stringFromJNI();
# descriptor: ()Ljava/lang/String;
# public native static int calculate(int, java.lang.String);
# descriptor: (ILjava/lang/String;)I

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 定位源码行号:

# 路径根据 ABI 选择
$NDK_HOME/toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-addr2line \
-e obj/local/arm64-v8a/libnative.so \
-f -C 0x0000000000001a34

# 输出:
# my_crash_function
# /path/to/source/native-lib.cpp:42

注意:必须使用带符号表的 .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>

// 使用 libunwind 获取调用栈(比 backtrace() 更可靠)
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);
}
}

// 恢复默认处理器重新触发信号,生成 tombstone
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.mkCMakeLists.txt 中常用的构建配置:

# 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 # libandroid.so — Android 原生 API
log # liblog.so — __android_log_print
dl # libdl.so — dlopen / dlsym
)

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 # 隐藏所有非 JNI 导出符号
include $(BUILD_SHARED_LIBRARY)

设置 -fvisibility=hidden 可隐藏内部符号,只导出 JNI_OnLoad 和显式标记 __attribute__((visibility("default"))) 的函数,增强安全性并减小编译产物体积。

逆向相关的高级编译选项:

# 反调试:启用栈保护
LOCAL_CFLAGS += -fstack-protector-strong

# 反逆向:禁用异常展开信息(使 backtrace 工具失效)
LOCAL_CFLAGS += -fno-unwind-tables -fno-asynchronous-unwind-tables

# 反篡改:链接时生成哈希校验值
LOCAL_LDFLAGS += -Wl,--hash-style=gnu

# 代码混淆:启用 LTO(链接时优化,改变代码结构)
LOCAL_CFLAGS += -flto

# 防 GOT 劫持:使用 RELRO
LOCAL_LDFLAGS += -Wl,-z,relro -Wl,-z,now
# -z relro: 将 GOT 中已解析部分设为只读
# -z now: 立即绑定(启动时解析所有符号,而非延迟解析)

# 移除无用代码(减小体积,改变函数布局)
LOCAL_CFLAGS += -ffunction-sections -fdata-sections
LOCAL_LDFLAGS += -Wl,--gc-sections

# PIE 编译(Android 5.0+ 强制要求)
LOCAL_CFLAGS += -fPIE
LOCAL_LDFLAGS += -fPIE -pie

高级 JNI 技术:内联 Hook 与 Native 插桩

在逆向分析中,了解以下 JNI 层面的 Hook 技术原理有助于理解加固应用的反调试机制:

PLT/GOT Hook 原理

// 通过修改 GOT 表项劫持 libc 函数调用
void* get_got_entry(const char* lib_path, const char* func_name) {
void* handle = dlopen(lib_path, RTLD_NOLOAD);
if (!handle) return NULL;

// 在进程内存中定位 .got.plt 段
// 计算目标 func_name 对应的 GOT 表项地址
// ...

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;

// 修改 GOT 表项指向 Hook 函数
// 需要先 mprotect 修改内存权限
mprotect((void*)((uintptr_t)got_entry & ~(PAGE_SIZE - 1)),
PAGE_SIZE, PROT_READ | PROT_WRITE);
*(void**)got_entry = hook_func;
}

Inline Hook 原理

// 修改目标函数的前几条指令,跳转到 Hook 函数
// ARM64 示例:用 B 指令替换函数入口
void install_inline_hook(void* target_func, void* hook_func,
void** trampoline) {
// 1. 修改目标函数所在内存页为可写
uintptr_t page_start = (uintptr_t)target_func & ~(PAGE_SIZE - 1);
mprotect((void*)page_start, PAGE_SIZE * 2,
PROT_READ | PROT_WRITE | PROT_EXEC);

// 2. 保存原始指令(用于 trampoline)
uint32_t original_instrs[4];
memcpy(original_instrs, target_func, sizeof(original_instrs));

// 3. 分配 trampoline 内存并构造
// trampoline = 原始指令 + 跳转回 target_func + offset
*trampoline = mmap(NULL, PAGE_SIZE, PROT_READ | PROT_WRITE | PROT_EXEC,
MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
// 写入原始指令 + B target_func_offset
// ...

// 4. 修改 target_func 入口为 B hook_func
uint32_t jump_instr = 0x14000000; // ARM64 B 指令
// 计算偏移并编码...
*(uint32_t*)target_func = jump_instr;

// 5. 刷新指令缓存(关键步骤!)
__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_createstd::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.cckLocalsDefault 中)。如果在一个循环中创建大量局部引用而不显式释放(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 的 openstrcmp)时,实际调用流程是 代码 → PLT 存根 → GOT 表项 → 真实函数地址。GOT 表项在首次调用时被动态链接器(linker)填充为函数在内存中的实际地址。PLT/GOT Hook 的原理就是修改 GOT 表项,将其指向自定义的 Hook 函数,从而拦截对该外部函数的所有调用。在逆向中的应用包括:(1) 绕过反调试检测(Hook ptracefopen 读取 /proc/self/status);(2) 修改加密逻辑(Hook AES_set_encrypt_key 记录密钥);(3) 绕过 SSL 证书校验(Hook SSL_get_verify_result 返回成功)。与 Inline Hook 相比,PLT/GOT Hook 更稳定但只能拦截通过 PLT 调用的函数。

打赏
  • 微信
  • 支付宝

评论