前言
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() |
如果包名或方法名中包含下划线,需用 _1 转义;如果包含 _ 本身再加下划线,则用 _2 等规则处理。
动态注册(Dynamic Registration)
通过 JNI_OnLoad 中调用 RegisterNatives 注册,函数名可以是任意合法的 C 函数名:
JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM *vm, void *reserved) { |
动态注册的好处是函数名不会被轻易猜到,有一定隐蔽性。逆向分析时,如果找不到符合静态注册命名规则的函数,应重点检查 JNI_OnLoad 中的 RegisterNatives 调用。
关键 JNI API 详解
逆向工程中经常需要从 Native 层调用 Java 方法(例如 Hook 场景),以下是核心 API:
FindClass — 获取类引用
// 获取 Java 类的 jclass 引用 |
注意:在非主线程中使用 FindClass 时,类加载器可能不同。如果是 native 线程(通过 pthread_create 创建),需要先获取 Application 的 ClassLoader:
// 在 native 线程中查找类 |
GetMethodID / GetStaticMethodID — 获取方法 ID
// 实例方法 |
方法签名的 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 |
根据返回值类型选择对应的 CallXxxMethod 函数(如 CallIntMethod、CallVoidMethod、CallObjectMethod)。
JNI 方法签名生成工具
手动写 JNI 方法签名容易出错,使用 javap 快速生成:
# 生成类中所有 native 方法的 JNI 签名 |
Native 崩溃分析
Native 层崩溃比 Java 层更难以排查。以下是核心分析工具和流程:
Tombstone 文件
Native 崩溃时,Android 系统会生成 tombstone 文件(位于 /data/tombstones/),包含崩溃时的寄存器状态、调用栈、内存映射等。
提取 tombstone:
adb shell ls /data/tombstones/ |
addr2line — 地址转行号
从 tombstone 中提取崩溃地址后,使用 NDK 自带的 addr2line 定位源码行号:
# 路径根据 ABI 选择 |
注意:必须使用带符号表的 .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.mk 或 CMakeLists.txt 中常用的构建配置:
# CMakeLists.txt 示例 |
Android.mk 关键变量:
LOCAL_PATH := $(call my-dir) |
设置 -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_create 或 std::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 工具链文档







