本文最后更新于 2026-04-13T20:51:19+08:00
昨天已经学习了Android的基础结构,今天学习一下安卓的底层开发基础,主要是JNI的学习,顺便找了相应的题学习加深一下学习印象。
SDK、NDK、JNI 与SO/ELF 的关系解析
SDK是 Android 应用开发工具包,主要用于 Java/Kotlin 层开发,提供 UI、组件(Activity、Service 等)及系统 API 支持。
NDK是 Android 原生开发工具包,用于使用 C/C++ 编写底层代码,并编译生成 .so 本地库。
JNI是 Java 与 C/C++ 之间的调用桥梁,负责实现 Java 层与 Native 层之间的函数调用与数据交互。
.so是 Android 中的本地动态链接库,由 NDK 编译生成,在运行时被加载执行。
**ELF**是 Linux 系统下的可执行文件格式,.so 文件本质上就是 ELF 文件的一种形式。
1 2 3 4 5 6 7 8 9
| SDK 写 Java 层逻辑 ↓ JNI 负责调用 ↓ NDK 编译 C/C++ ↓ 生成 .so ↓ .so 本质是 ELF 文件并在系统中执行
|
SDK 负责应用层开发,JNI 作为桥梁连接 Java 与 Native,NDK 负责构建 Native 代码,而生成的 .so 文件本质上是 ELF 格式,最终在 Android 系统中被加载执行。
JNI基础学习
1 .JNI 开发的基本流程
一个标准的 JNI 开发流程通常包括以下步骤:
编写 Java 类并声明 native 方法,作为 Java 调用本地代码的入口;
编译 Java 源文件,并使用 javac -h 生成对应的 JNI 头文件(.h),其中定义了本地方法的函数原型;
在 C/C++ 源文件中实现头文件中声明的函数逻辑;
使用编译工具将本地代码编译为动态库文件(.so);
在 Java 程序中通过 System.loadLibrary(...) 加载该动态库;
运行程序,完成 Java 层与本地 C/C++ 层之间的调用与数据交互。
该流程用示意图表示如下:

1
| JNI 开发流程本质就是:声明 → 生成接口 → 实现 → 编译 → 加载 → 调用
|
2.注册 JNI 函数
2.1静态注册
静态注册采用的是按照固定的命名规则,通过 javah 可以自动生成 native 方法对应的函数声明。例如:
1
| Java_com_swdd_summertrain_MainActivity_Check
|
不过,静态注册的命名规则分为无重载和有重载2 种情况:无重载时采用短名称规则,有重载时采用长名称规则。
可以通过这个文章进行学习:NDK 系列(6):说一下注册 JNI 函数的方式和时机
2.2动态注册
动态注册需要提前手动建立映射关系,不用遵守静态注册的 JNI 函数命名规则。
动态注册需要使用 RegisterNatives(...) 函数,其定义在 jni.h 文件中:
1 2 3 4 5 6 7 8 9 10
| struct JNINativeInterface { jint (*RegisterNatives)(JNIEnv*, jclass, const JNINativeMethod*, jint); jint (*UnregisterNatives)(JNIEnv*, jclass); };
typedef struct { const char* name; const char* signature; void* fnPtr; } JNINativeMethod;
|
JNINativeInterface = JNI 函数表(函数指针表)
RegisterNatives 函数是最终的注册函数,需要传递 jclass、JNINativeMethod 结构体数组和数组长度。
RegisterNatives 方式的本质是直接通过结构体指定映射关系,而不是等到调用 native 方法时搜索 JNI 函数指针。
此外,还能减少生成 so 库文件中导出符号的数量,增加进一步逆向的难度。
3.加载so库
整个流程:
1 2 3 4 5 6 7 8 9 10 11
| System.load / loadLibrary ↓ Runtime.load0 / loadLibrary0 ↓ 找到 so 的绝对路径 ↓ nativeLoad ↓ dlopen 加载 so ↓ 执行 JNI_OnLoad
|
3.1 System.load()方法
1
| System.load("/data/app/libxxx.so");
|
特点是必须是绝对路径,不做任何查找。
3.2 System.loadLibrary()方法
1
| System.loadLibrary("native-lib");
|
特点是传的是库名,自动补全。
Android 加载 so 库的本质流程是:通过 System.load 或 loadLibrary 获取库路径,再由 nativeLoad 调用 dlopen 加载到内存,并执行 JNI_OnLoad 完成初始化。
上面的内容比较基础,还没涉及到真正的JNI开发,后续会持续更新。
下面的内容是找的相应的题目练习,题目来源于swdd~
1、静态注册
关键函数,程序加载native库,看主函数确定主要逻辑在native层
1 2 3 4 5
| public native Boolean Check(String str);
static { System.loadLibrary("summertrain"); }
|
so文件主函数:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112
| __int64 __fastcall Java_com_swdd_summertrain_MainActivity_Check(__int64 a1, __int64 a2, __int64 a3) { const char *s; const char *s_1; size_t n; int8x16_t *s1; int8x16_t *s1_1; bool v10; unsigned __int64 n_1; __int64 v12; size_t v13; _BYTE *v14; const char *v15; char v16; _BOOL4 v17; __int64 v18; __int64 v19; int8x16_t *v21; int8x16_t v22; int8x16_t *v23; unsigned __int64 v24; int8x16_t v25; int8x16_t v26; unsigned __int64 n_2; int8x8_t *v28; int8x8_t *v29; unsigned __int64 v30; int8x8_t v31;
s = (*(*a1 + 1352LL))(a1, a3, 0LL); if ( s ) { s_1 = s; n = strlen(s); s1 = operator new[](n + 1); s1_1 = s1; if ( !n ) { LABEL_15: s1->n128_u8[n] = 0; v17 = memcmp(s1, "U_RTH}\\Dlj\\Flx]\\Dl}RGZEVlWJ]R^ZPlAVTZ@GARGZ\\]Ncovariant return thunk to ", n) == 0; v18 = (*(*a1 + 48LL))(a1, "java/lang/Boolean"); v19 = (*(*a1 + 264LL))(a1, v18, "<init>", "(Z)V"); v12 = _JNIEnv::NewObject(a1, v18, v19, v17); (*(*a1 + 1360LL))(a1, a3, s_1); operator delete[](s1_1); return v12; } if ( n < 8 || (s1 < &s_1[n] ? (v10 = s_1 >= s1 + n) : (v10 = 1), !v10) ) { n_1 = 0LL; LABEL_13: v13 = n - n_1; v14 = s1 + n_1; v15 = &s_1[n_1]; do { v16 = *v15++; --v13; *v14++ = v16 ^ 0x33; } while ( v13 ); goto LABEL_15; } if ( n >= 0x20 ) { n_1 = n & 0xFFFFFFFFFFFFFFE0LL; v21 = (s_1 + 16); v22.n128_u64[0] = 0x3333333333333333LL; v22.n128_u64[1] = 0x3333333333333333LL; v23 = s1 + 1; v24 = n & 0xFFFFFFFFFFFFFFE0LL; do { v25 = v21[-1]; v26 = *v21; v21 += 2; v24 -= 32LL; v23[-1] = veorq_s8(v25, v22); *v23 = veorq_s8(v26, v22); v23 += 2; } while ( v24 ); if ( n == n_1 ) goto LABEL_15; if ( (n & 0x18) == 0 ) goto LABEL_13; } else { n_1 = 0LL; } n_2 = n_1; n_1 = n & 0xFFFFFFFFFFFFFFF8LL; v28 = &s_1[n_2]; v29 = (s1 + n_2); v30 = n_2 - (n & 0xFFFFFFFFFFFFFFF8LL); do { v31.n64_u64[0] = v28->n64_u64[0]; ++v28; v30 += 8LL; v29->n64_u64[0] = veor_s8(v31, 0x3333333333333333LL).n64_u64[0]; ++v29; } while ( v30 ); if ( n == n_1 ) goto LABEL_15; goto LABEL_13; } return 0LL; }
|
就是一个xor0x33的简单逻辑
1 2 3 4 5 6
| target = r"U_RTH}\Dlj\Flx]\Dl}RGZEVlWJ]R^ZPlAVTZ@GARGZ\]N"
flag = "".join(chr(ord(c) ^ 0x33) for c in target)
print("flag:", flag) //flag{Now_You_Know_Native_dynamic_registration}
|
2、动态注册
和上述流程一样,不过在查找主函数时需要注意这个题目是动态注册,包名已经不按规定了,看JNI_OnLoad函数里调用check最终调用主函数

主函数就是简单的移位
1 2 3 4 5 6 7 8 9 10
| s2 = [ 0x99,0x93,0x9E,0x98,0x84,0xB1,0x90,0x88,0xA0,0xA6,0x90,0x8A,0xA0,0xB4,0x91,0x90, 0x88,0xA0,0xB1,0x9E,0x8B,0x96,0x89,0x9A,0xA0,0x8D,0x9A,0x9E,0x93,0xA0,0x9B,0x86, 0x91,0x9E,0x92,0x96,0x9C,0xA0,0x8D,0x9A,0x98,0x96,0x8C,0x8B,0x8D,0x9E,0x8B,0x96, 0x90,0x91,0x82 ]
flag = ''.join(chr((~b) & 0xff) for b in s2)
print(flag)//flag{Now_You_Know_Native_real_dynamic_registration}
|
静态注册vs动态注册
静态注册:
Java 直接声明 native 函数:
1
| public native Boolean Check(String str);
|
JNI 会自动按照 固定命名规则去寻找 C 函数,函数名必须是:
例如静态注册中的函数名:Java_com_swdd_summertrain_MainActivity_Check,很容易定位函数
动态注册:
在java中还是和静态注册时一样声明,但是 native函数名可以随便写,例如:
1
| jboolean sub_1544C(JNIEnv *env, jobject obj, jstring input)
|
然后在 JNI_OnLoad里手动绑定:
1 2 3
| static JNINativeMethod methods[] = { {"Check", "(Ljava/lang/String;)Z", (void*)sub_1544C} };
|
注册:
1
| env->RegisterNatives(clazz, methods, 1);
|
动态注册依赖一个结构体:
1 2 3 4 5
| typedef struct { const char* name; const char* signature; void* fnPtr; } JNINativeMethod;
|
动态注册可以隐藏函数名,减少字符信息,可以添加混淆