JNI学习补充
2025-05-30 15:54:21

之前学习基础的rasp绕过的时候一直没有好好过一遍JNI。全称Java Native Interface​,用于扩展java与其他语言的交互,主要为java与C,C++之间的交互,但也不妨碍其他语言的设计。

其设计的意义主要有两点:特定场景下可以提升应用性能。对于代码层的保护,提升反编译的难度。

但是这么做就会出现一个问题,对于C和C++编译形成的动态链接库是不能够全平台互通的。需要特制。

正常使用情况下,java侧如果想要通过JNI去和native层进行交互就必须先向JNI注册native函数,之后JNI才能够找到对应的native函数位置。注册方式主要分为两种,一种是静态注册,一种是动态注册。两者的区别在于静态注册的native方法在查找的时候是根据特定的 命名方式去查找的,所以我们写的native方法或者说生成的native方法名是不变的,这么找效率其实也有点低。动态注册的话由于存在JNINativeMethod这张映射表,所以找起来就快一点,而且方法名不会限制。

所以其实JNI本质上还是通过Java的功能接口去加载相应的DLL或者so这种动态链接库。具体在java侧的触发就是通过调用到native修饰的方法。

这一点也可以利用到Rasp当中做nativehook。

JNI基础与静态注册

静态注册的话在Java侧首次调用native方法时完成注册

具体使用如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
package Test;

public class JNITest {
static {
System.loadLibrary("Hello");
}

public native void hello();
public native void sum();


public static void main(String[] args) {
new JNITest().hello();
}

}

这个时候再通过javac -h . JNITest.java​指令编译并且生成对应的JNI头文件

image

可以看到此时的JNI头文件的命令规则是<PackageName>_<classname>

我们再看一下该文件的具体内容

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
/* DO NOT EDIT THIS FILE - it is machine generated */
#include <jni.h>
/* Header for class Test_JNITest */

#ifndef _Included_Test_JNITest
#define _Included_Test_JNITest
#ifdef __cplusplus
extern "C" {
#endif
/*
* Class: Test_JNITest
* Method: hello
* Signature: ()V
*/
JNIEXPORT void JNICALL Java_Test_JNITest_hello
(JNIEnv *, jobject);

/*
* Class: Test_JNITest
* Method: sum
* Signature: ()V
*/
JNIEXPORT void JNICALL Java_Test_JNITest_sum
(JNIEnv *, jobject);

#ifdef __cplusplus
}
#endif
#endif

#include <jni.h>位于JDK的include目录下,引入了JVM所定义的所有所有JNI规范

然后就是一段看不太懂的东西,这一段主要用于告诉 C++ 编译器,接下来的代码需要用 C 的方式来编译,因为静态注册对于native方法有命令的要求,如果采用C++的形式编译,方法名由于能够被重载,编译之后函数名会被改变,变成:函数名+参数类型的特殊类型形式。C不允许,所以没有这样的问题

1
2
3
4
#ifndef _Included_Test_JNITest
#define _Included_Test_JNITest
#ifdef __cplusplus
extern "C" {

由于我们定义了两个native方法,一个Hello一个sum方法,所以这里会生成两段对应的静态注册函数,都由JNIEXPORTJNICALL​所修饰,然后方法对应函数的返回值肯定是一样,都是void

可以发现命名规则为包名+类名+方法名。其中还有两个参数的传递JNIEnv * ,jobject

  • JNIEnv这是一个指向 JNI 运行环境的指针,我们可以通过这个指针访问 JNI 函数
  • 指代 java 中的 this 对象

JNIEnv这个指针能够允许我们将java层的一些数据带出来,也可以在native层中直接执行一些java层的操作

部分的结构内容如下,其实大部分功能还是通过const struct JNINativeInterface_ *functions;这个指针去调用得到的

image

所以我们可以继续跟进JNINativeInterface的定义,这里其实是回调到Java层之后返回结果,要想具体知道如何创建的调用的,得看JVM源码了,这里不细谈,了解JNIEnv作用和对应组成即可

image

回过头来,根据刚才javah生成的jni文件写一份Cpp文件然后编译成DLL

1
2
3
4
5
6
7
8
9
10
11
12
13
// HelloJNI.cpp
# include <jni.h>
# include <iostream>
# include "Test_JNITest.h"

// 实现本地方法:注意函数名称需要和头文件中的函数名称保持一致
JNIEXPORT void JNICALL Java_Test_JNITest_Hello(JNIEnv *env, jobject obj) {
std::cout << "Hello from C++!" << std::endl;
}

JNIEXPORT void JNICALL Java_Test_JNITest_sum(JNIEnv *env, jobject obj) {
std::cout << "Sum from C++" << std::endl;
}

如果没用编辑器啥的,纯文本编辑然后直接gcc编译的,所以记得加上这些引入头文件的路径

1
g++ -shared -o hello.dll -I"%JAVA_HOME%\include" -I"%JAVA_HOME%\include\win32" ImplJNI.cpp

image

之后就得到了一份能用的DLL,然后存放在项目根目录运行当前函数就能看到结果了。这里存在一个问题,当前win11下我如果用g++这种命令形式的编译DLL再去加载会出现Can't find dependent libraries​的报错情况,DLL引用中会缺点东西,所以最后还是用Visual studio去运行编译的

image

动态注册

通过静态注册辅助动态注册

动态注册关键是JNI接口下的RegisterNatives()​方法。本质上还是在静态注册的过程。

这里我们可以先看看JDK原生中有哪些类是用到了RegisterNatives​方法的

image

在Class中定义了一段静态的native private static native void registerNatives();

然后又在静态代码块对其进行了调用,之后观察每一个样例,都是同一个过程,也就是先定义一个静态nativeregisterNatives​,然后在静态代码块中对其进行调用

1
2
3
4
5
6
static {
registerNatives();
useCaches = true;
serialPersistentFields = new ObjectStreamField[0];
initted = false;
}

为什么要这么做呢?这里可以看一下registerNatives​在各个类的C实现中具体是怎么写的,比如在Class.c中

他其实最终还是调用JNIEnv​的RegisterNatives方法进行的注册,只不过这里我们要注意一个变量methods

1
2
3
4
5
6
7
JNIEXPORT void JNICALL
Java_java_lang_Class_registerNatives(JNIEnv *env, jclass cls)
{
methods[1].fnPtr = (void *)(*env)->GetSuperclass;
(*env)->RegisterNatives(env, cls, methods,
sizeof(methods)/sizeof(JNINativeMethod));
}

c中调用还不能够完全了解,跟进jni.h中JNIEnv-RegisterNatives方法的具体内容

1
2
3
jint (JNICALL *RegisterNatives)
(JNIEnv *env, jclass clazz, const JNINativeMethod *methods,
jint nMethods);

发现他是一个JNINativeMethod类型,本质也是一段结构,用来存储对应native方法的名称以及函数签名与函数指针。

image

可这么一个结构很明显是单个方法的描述,为什么要传入一个*methods指针呢?很明显还有一个methods方法数组在作用。回到Class的c中定义,他其实维护了一个静态JNINativeMethod​类型的列表。根据刚才JNINativeMethod的类型,方法名,函数签名,函数指针,这里能够确定调用RegisterNatives方法传入的Methods指针就是这个方法表的指针。

image

所以本质上通过静态注册辅助动态注册的一个过程可以看作动态代理,我们只需要在具体的动态链接库的C中维护一个methods列表,然后在C中实现一个按照静态注册方法名格式的registerNatives​方法,并且在方法内容中调用JNIEnv的RegisterNatives​方法代理一下具体的注册过程,把方法表传进去即完成了动态注册在动态链接库中的具体实现。

剩下的就是java侧在静态代码块中调用registerNatives​然后自己定义一个静态的registerNatives​native方法即可。后续就不用在头文件中声明其他native方法了

然后注册c侧写实现的时候传参第二位就不是Jobject了,还是Jclass

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
package Test;

public class JNITest {
static {
System.loadLibrary("JNIDll");
registerNatives();
}
private static native void registerNatives();
public native void Hello();

public static void main(String[] args) {
new JNITest().Hello();
}

}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// JNIImpl.cpp
# include <jni.h>
# include <iostream>
# include "Test_JNITest.h"

static JNINativeMethod methods[] = {
{(char*)"Hello", (char*)"()V", (void*)NativeHello},
};
JNIEXPORT void JNICALL Java_Test_JNITest_registerNatives(JNIEnv *env, jclass cls) {
env->RegisterNatives(cls, methods, sizeof(methods) / sizeof(methods[0]));
}

*/
JNIEXPORT void NativeHello(JNIEnv *env,jobject){
std::cout << "Hello from C++!" << std::endl;
}

头文件的话就可以只写registerNatives的声明了

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
/* DO NOT EDIT THIS FILE - it is machine generated */
#include <jni.h>
/* Header for class Test_JNITest */

#ifndef _Included_Test_JNITest
#define _Included_Test_JNITest
#ifdef __cplusplus
extern "C" {
#endif
/*
* Class: Test_JNITest
* Method: registerNatives
* Signature: ()V
*/
JNIEXPORT void JNICALL Java_Test_JNITest_registerNatives
(JNIEnv*, jclass);


#ifdef __cplusplus
}
#endif
#endif

之后编译该C实现为动态链接库,就可以直接使用了

image

JNI_OnLoad()动态加载

这个方式起源一个思考,在自实现中,即使是静态注册辅助动态注册,我们也还是要在静态代码块中通过System.loadLibrary加载我们所编译的DLL。JNI的调用无非就两个过程,Java层中声明并调用native,JVM去寻找对应注册的C实现。那JVM是怎么知道对应的具体实现在哪的呢?这里涉及到一个资源加载的问题。load的对象是我们自己写的动态链接库,那么System.loadLibrary其实就是将我们动态链接库的内容传授给JVM,然后JVM就知道对应的C实现在哪了,也就能够调用到了。这个过程中是否存在能够抽象出来的点供我们自己使用呢?

我们可以稍微跟进一下loadLibrary的具体方法,流程会持续跟进到ClassLoader$NativeLibrary的nativeload方法。过程中也能够看到见到很多可以记录的地方,比如有哪些参数是我们可以通过启动参数去进行预加载的

image

image

System.loadLibrary()在加载动态链接库的时候会首先在库中查找JNI_OnLoad方法,如果该函数存在,则先执行它,而官方对于JNI_OnLoad的定义其实有三个层面

  • 告知JVM当前这个动态链接库需要多少版本的JNI
  • 执行具体的初始化操作
  • 将javaVM参数保存为全局对象,方便之后能够在任何地方取到JNIEnv对象

image

每一个native方法的C实现中如果有JNI_OnLoad其实内容是不相同的,我们自己也可以通过OnLoad的方式,在方法定义中完成registerNatives​的完整调用。那么我们甚至连头文件也不需要生成了,直接写实现即可

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
# include <jni.h>
# include <iostream>
#include <stdio.h>
# include "Test_JNITest.h"

void NativeHello(JNIEnv* env, jobject) {
std::cout << "Hello from JNI!" << std::endl;
}

static JNINativeMethod methods[] = {
{(char*)"Hello", (char*)"()V", (void*)NativeHello},
};

JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM* vm, void* reserved) {
JNIEnv* env = NULL;

// 获取 JNIENV
if (vm->GetEnv(reinterpret_cast<void**>(&env), JNI_VERSION_1_6) != JNI_OK){
return -1;
}

// 获取 Java Native 对应的 Class
jclass jClassName = env->FindClass("Test/JNITest");
jint ret = env->RegisterNatives(jClassName, methods, sizeof(methods) / sizeof(methods[0]));

return JNI_VERSION_1_6;
}



​​

image

讲到这里对于JNI的基本使用就差不多了,这里再补充一点JNI_OnUnload()的触发时机吧,能够代码执行的点还是记录一下:According to the JNI documentation, it will be called when the classloader that loaded the libarary is garbage collected.

当GC回收了加载这个库的ClassLoader时,该函数被调用

结合思考

之前在探究一些C侧的rasp绕过,看着看着越来越陷入瓶颈。于是想着沉淀一些基础性的东西。很多java内存攻击的实现都要借助一些JNI的接口,虽然最后实现完了有很多自己的想法,然后一一去问同事,发现基本上所有思路都被拦截了就很操蛋。

后续退而求其次去看了一下简单的nativeRasp,发现CRASP的实现其实也是通过JNI的一些接口与Java层进行交互的,所以去阅读时候也没有那么多障碍了,可以继续探究。

比较有趣的是JNI_OnLoad和UnOnLoad这两个方法可以做到类似于filter形式的触发,恶意的代码执行完全可写进C实现中的JNI_OnLoad的即可,就不用LoadLibary之后还调方法了。

参考文章

https://tttang.com/archive/1622/#toc_native-jni_onload

https://www.cnblogs.com/zhujiabin/p/10605745.html

上一页
2025-05-30 15:54:21