您的位置 首页 java

一起来学习用JNI抵御黑客破解你的Java代码,文末有彩蛋

What is JNI ?

JNI (Java Native Interface) JVM 为上层java应用提供的调用 Native 模块的渠道。我们知道 Java 语言属于托管语言,依赖于JVM 虚拟机 的解析执行,并非直接运行于操作系统中,而对于操作系统而言,虚拟机只是一个普通进程而已。

JNI 机制对于 JVM 来说,可以说是一个很重要的机制,不仅仅为java代码的开发提供了调用native模块的机会,虚拟机自身的一些重要实现也依赖于JNI调用。这里作者随意查看了系统中的java进程的依赖库,可以发现一些重要模块通过JNI机制,也封装入了动态链接库中:

那么思考一个问题,为什么java进程要使用jni技术?这个问题和进程为什么需要使用动态链接库一个道理。主要有两个原因:

  1. 从软件开发的角度来说,一些模块放入动态链接库,便于java虚拟机的模块化开发与管理,使得虚拟机的实现不至于那么复杂臃肿,这一点符合软件工程的解耦原则。最近 Java 9 的发布,一个重要的特性就是模块化,虽然作者没有考究其实现机制,但猜测应该也离不开 JNI 的支持。

  2. 动态链接库有助于节省内存,可以使进程占用更少的内存资源。这里就需要说一下动态链接库的知识了(以 Windows 动态链接库DLL来说),如果系统中两个进程都依赖于同一个动态链接库,那么系统内存中该动态链接库只会被加载一次(更准确的说是通过VirtualAlloc API分配一次内存空间),这样就大大提高了系统的内存的使用率。如果java中一些关键模块都是直接内联入虚拟机中实现的,那么相同的模块就会重复加载,造成内存浪费。当然PE与ELF等涉及的内容还很多,不是本文重点,想要深入学习的小伙伴,可以看《程序员的自我修养》一书。

在传统项目的java开发中,一般很少用到JNI技术,基本上java层就能作所有事情了,而JNI技术一般用在安全加固方面,如市面上很多apk都将自己的加解密函数,甚至协议包的封装,都放入了动态链接库中实现,提高了破解的难度。

JNI的使用与开发

JNI的开发分为两个部分,一个是java层对于调用接口的定义,另一重要部分是C/C++动态链接库对于接口的实现(这里的接口并非指java中的interface,实际上申明的还是class)。Java部分接口的申明需要使用native关键字接口声明,并且需要必须声明为静态方法:

之后,就可以使用javah工具根据声明类生成C/C++工程需要引用的函数定义 头文件 ,命令如下:

javah会根据java接口定义的包名,类名,方法名按照jni的命名规则生成函数定义头文件,同时该函数会被声明为动态链接库的导出函数:

头文件生成后,就能在C/C++项目中直接引用该头文件,开发动态链接库了。

JNI实现之动态链接库

由于动态链接库使用C/C++进行开发,就不具备跨平台的特性了。在windows系统下,动态链接库是遵循PE文件格式的DLL文件,linux(android)系统下则是ELF格式的so文件。在Windows平台下动态链接库的编译,需要在编译时为编译器指定/DLL开关,也可以使用 Visual Stuido 直接创建一个DLL项目进行开发,动态链接库的项目属性配置如下:

具体的开发过程,本文就不讨论了,开发什么功能都是由项目需求决定的,这里作者列举一些动态链接库在开发过程中需要注意的问题:

1. Dll的平台版本问题。 在32位操作系统中,32位进程(x86)只能使用不超过4GB的内存空间,并且高2GB的内存地址还是由操作系统内核所占用(默认1:1),因此4GB内存地址对于今天的大型系统来说往往是不够使用的。为了使进程可以使用超过4GB的内存空间,现代CPU都支持了64位操作系统,但操作系统一般会兼容之前的32位软件,64位版本的Windows系统就通过Wow64技术来对32位进程提供了运行的支持。但是对于动态链接库的开发来说,程序员还是要需要区分动态链接库的平台版本,因为64位进程无法加载32位动态链接库,所以必须确保java进程与jni动态链接库的平台一致。不过程序员无需对32位与64位的区分做额外的开发工作(除非要使用内联汇编),只需要设置编译器的编译平台类型支持即可,如Visual Studio下只需添加对应对应 platform 的支持即可。

2.DLL的动态链接与静态链接(LINK)。 实际上我们开发的DLL就是由Java进程动态链接入进程内存空间的函数实现的模块,动态与静态链接的区别在于,目标代码(指令)是否直接包含在了可执行文件中,这一点与Java开发中只包含了接口,将jar包放入java进程加载目录中来实现库的调用,还是直接将库的jar包直接打包到了生成包中的区别相似。这里作者提出DLL的动态与静态链接问题的原因是,如果采用动态链接,那么在不同版本的windows系统下,Java进程可能会弹出c++运行时库(msvcrXXX.dll)缺失的问题(相当于Java的ClassNotFound异常),所以一劳永逸的做法就是直接使用静态链接的方式来生成DLL。但如果你十分确定Java的运行平台的运行时库版本,并且追求运行内存的最小化,那么这里可以采用动态链接的方式编译。

3.跨平台的开发问题。 JNI 动态链接库需要注意跨平台开发的问题,如果一套代码能支持多个平台,那将会是一件很愉快的事情。那我们如何做到呢?首先尽量避免使用依赖于平台特性的系统API,能使用C++标准运行时库的函数尽量使用运行时库来实现,如果必须使用特定平台的系统API,可以使用 _WIN32 宏来做代码区分编译。这里作者举一个例子,在jni_md.h中 JNIEXPORT JNIIMPORT 等函数导出宏在不同的平台下具有不同的声明,但该声明没有做到跨平台编译的支持,因此作者在项目中将jni_md.h进行了改造,将linux与Windows平台函数导出的声明放在了一起,并结合使用 _WIN32 宏来区别平台的版本编译,如下:

虚拟机中JNI加载原理

在开发完成动态链接库之后并成功编译生成可执行文件后,就可以引入Java项目,通过 JNI 进行调用了。

熟悉 C/C++ 开发的同学一定知道,在进程中使用动态链接库时,需要通过 LoadLibrary() API 将动态链接库加载到进程内存空间,才能使用,并通过函数指针结合函数地址来进行函数的调用。而在Java中则是由 System.loadLibrary() 静态方法来实现链接库的加载,完成加载后,便可以通过之前的声明的native接口来进行调用了。

这样看来,JNI加载的关键实现就在 System.loadLibrary() 这个函数中了,而该函数的实现也是一个native的掉用,是由JVM中由 os::dll_load() 方法实现,其实质同样是通过系统API来对动态链接库进行加载,只是不同的平台下调用的系统API不同罢了,但原理相似( 顺便说一下,JVM 源码 的确是跨平台C++项目的开发典范 ):

所以, JNI 的就是JVM为Java语言封装了进程调用动态链接库的系统API而已。在第二节中,我们说明了通过javah生成的C/C++头文件的函数命名规则,但读者还需要注意一个地方,那就是函数通过 JNIEXPORT 宏被声明为了导出函数,那么何所谓导出函数,为什么要声明为导出函数?

这里作者以Java程序员的角度来说一下什么是PE(ELF)文件的导出函数。导出函数就相当于Java包中的 public 方法,可以被其他java应用程序通过引用的方式进行调用。而在PE(ELF)文件中,声明为导出函数的函数名称与地址将会在编译时被填写在PE(ELF)文件的导出表中,而其他进程在引用动态链接库时只能调用导出表中的函数(除非使用hack手段,才能调用任意函数,任意地址),通过IDA下查看动态链接库的导出表如下:

可以发现,这些导出表中的函数名与通过javah工具生成的头文件中的函数名完全一致。最后一个需要探讨的问题就是关于native方法的调用,其实也是由JVM实现的, 从进程的角度看,方法调用实际上就是CPU执行地址寄存器(ESI)在指令内存空间的一次跳跃(JMP),Java在调用native 方法时,java进程通过导出表获取到对应函数在内存中的地址,然后跳过去执行 (注意导出表中的地址并不是函数的内存地址,是文件偏移地址,在被加载到内存后,还需要加上镜像基地址才能找到函数的内存地址) 。当然寻找函数地址的方法,操作系统已经提供了现成的API,这里就不详解了,不同平台下具体实现如下图所示:

关于JNI的调试

JNI调试其实就是源码调试技术,但是由于调用者是Java进程,在Idea、eclipse下貌似没有办法直接从java的JNI接口步入C++实现函数进行调试(Android studio ndk可以调试,但相对于其他C++源码的调试器还是差了些),那么我们真的就无法进行调试了吗?当然不是,其实在使用VS编译生成二进制文件时,编译器会产生一个符号文件(*. pdb 文件),只要调试器在调试该进程时,同时加载了这个pdb文件,那么调试过程中,调试器就会根据符号文件与源码进行匹配,从而实现源码调试。下面看一下使用 IDEA + Visual Studio 进行jni项目调试的具体操作步骤:

首先确保C/C++项目的编译配置中生成了pdb文件:

为了便于调试关闭编译器优化选项:

编译完成后将生成的xxx.dll文件文件放入jni加载路径,运行Java调用程序(最好先在java调用程序中下一个断点)。然后使用 ProcessHacker 工具找到 JNI的调用进程的PID ,注意这个PID一定要找对,系统有的Java进程并不是调用程序的java进程,Java PID的寻找也可使用jps命令:

打开visual studio,并且打开DLL源码项目,通过 Debug -> Attach Process 的方式进行调试:

在进程列表中找到PID与Java调用进程PID相同的Java进程,点击 Attache

在被调用函数中使用 F9 下断点,此时便可以放开IDEA中的断点,java进程在加载了DLL后,并运行至C++中设置的断点后,就会被断下,此时就可以进行源码调试了,如下所示:

C++的源码调试,一定要注意pdb文件与生成的动态链接库版本一致,否则Visual studio会报找不到符号文件的问题,当然并不是说没有符号文件你就不能进行二进制的调试了,你可以使用汇编调试,呵呵!

跨平台JNI加密库的分享与开源

本文作者与 yrzx404 商量后,决定将项目中使用过的一套跨平台JNI加解密项目进行开源,该项目是产品中真实的JNI DEMO项目。在Windows、Android、Ubuntu平台下实际测试稳定运行,线程安全。感兴趣的小伙伴可以学习,如果需要可以自由改造。项目中的加解密函数的实现是作者从开源OpenSSL项目中移植过来的,所以加密算法部分不用担心跨平台的问题。目前,DEMO项目只移植了AES加密算法,如果小伙伴需要其他加密算法或接口可以自己编写,也可在github上向我们提交issue,如果有时间,作者一定会满足小伙伴们提出的需求。此外,该项目的READE.ME还算精华,记录了作者在跨平台JNI开发中遇到的一些坑和其他平台下开发与编译的方法,建议小伙伴可以大致浏览一下。最后项目中附赠一张Windows PE文件格式图片!

最后,感谢可以坚持阅读到此处的小伙伴,很多java开发者认为没有必要考虑如此底层的东西。的确,Java生态的强大,使得今天的Java开发者可以专心于上层构建,在实际工作中,即使需要JNI技术,也一定是由专业的C/C++程序员去实现。但我总认为,系统都是相通的,如果精力允许,多接触一个领域,会多一份看待问题的角度与思路。即使读万卷书,还需要行千里路!

提示 :我们为小伙伴提供了相应的代码实例,大家可以通过 github 来下载,下载地址:。

如果觉得内容不错,记得关注和分享哦,分享是一件有意义的事情!!!

文章来源:智云一二三科技

文章标题:一起来学习用JNI抵御黑客破解你的Java代码,文末有彩蛋

文章地址:https://www.zhihuclub.com/180450.shtml

关于作者: 智云科技

热门文章

网站地图