目录
  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. 4. JNI 方法签名生成工具
  5. 5. Native 崩溃分析
    1. 5.1. Tombstone 文件
    2. 5.2. addr2line — 地址转行号
    3. 5.3. ndk-stack — 一键还原调用栈
  6. 6. 关键 NDK 构建变量
  7. 7. 面试常考问题
  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 等规则处理。

动态注册(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 调用。

关键 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"));

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
int I
long J
float F
double D
String Ljava/lang/String;
Object L全限定类名;
数组 [元素类型,如 [B = byte[]

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 函数(如 CallIntMethodCallVoidMethodCallObjectMethod)。

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:

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 过的版本)。

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 构建变量

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"))) 的函数,增强安全性并减小编译产物体积。

面试常考问题

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 后的版本。

Q3: 如何在 Native 线程中安全地调用 Java 方法?

A: 通过 pthread_createstd::thread 创建的 native 线程没有 JNIEnv,需要先调用 JavaVM->AttachCurrentThread() 获取 JNIEnv 指针。此外,native 线程默认使用系统 ClassLoader,无法直接通过 FindClass 找到应用类。需要先获取应用的 ClassLoader 对象(在 JNI_OnLoad 中缓存为全局引用),再通过该 ClassLoader 的 loadClass 方法加载目标类。使用完毕后应调用 JavaVM->DetachCurrentThread() 释放资源。

参考

  • AOSP: libnativehelper/include/nativehelper/jni.h — JNI API 声明
  • AOSP: art/runtime/jni_internal.cc — JNI 函数 ART 实现
  • AOSP: system/core/debuggerd/ — tombstone 生成机制(debuggerd)
  • NDK 官方文档: https://developer.android.com/ndk/guides
  • JNI 规范: https://docs.oracle.com/javase/8/docs/technotes/guides/jni/spec/jniTOC.html
  • $NDK_HOME/docs/Programmers_Prebuilt/ — NDK 工具链文档
打赏
  • 微信
  • 支付宝

评论