Java技术栈漏洞目前业已是web安全领域的主流战场,随着IPS、RASP等防御系统的更新迭代,Java攻防交战阵地已经从磁盘升级到了内存里面。在今年7月份上海银针安全沙龙上,我分享了《Java内存攻击技术漫谈》的议题,个人觉得PPT承载的信息比较离散,技术类的内容还是更适合用文章的形式来分享,所以一直想着抽时间写一篇和议题配套的文章,不巧赶上南京的新冠疫情,这篇文章拖了一个多月才有时间写。
Java的instrument是Java内存攻击常用的一种机制,instrument通过attach方法提供了在JVM运行时动态查看、修改Java类的功能,比如通过instrument动态注入内存马。但是在Java9及以后的版本中,默认不允许SelfAttach:
Attach API cannot be used to attach to the current VM by default
也就是说,系统提供了一个jdk.attach.allowAttachSelf的VM参数,这个参数默认为false,且必须在Java启动时指定才生效。
编写一个demo尝试attach自身PID,提示Can not attach to current VM,如下:
经过分析attch API的执行流程,定位到如下代码:
由上图可见,attach的时候会创建一个HotSpotVirtualMachine的父类,这个类在初始化的时候会去获取VM的启动参数,并把这个参数保存至HotSpotVirtualMachine的ALLOW_ATTACH_SELF属性中,恰好这个属性是个静态属性,所以我们可以通过反射动态修改这个属性的值。构造如下POC:
Class cls=Class.forName("sun.tools.attach.HotSpotVirtualMachine");
由于ALLOW_ATTACH_SELF字段有final修饰符,所以在修改ALLOW_ATTACH_SELF值的同时,也需要把它的final修饰符给去掉(修改的时候,会有告警产提示,不影响最终效果,可以忽略)。修改后,可以成功attach到自身进程,如下图:
这样,我们就成功绕过了allowAttachSelf的限制。
随着攻防热度的升级,内存马注入现在已经发展成为一个常用的攻击技术。目前业界的内存马主要分为两大类:
•Agent型
利用instrument机制,在不增加新类和新方法的情况下,对现有类的执行逻辑进行修改。JVM层注入,通用性强。•非Agent型
通过新增一些Java web组件(如Servlet、Filter、Listener、Controller等)来实现拦截请求,从而注入木马代码,对目标容器环境有较强的依赖性,通用性较弱。
由于内存马技术的火热,内存马的检测也如火如荼,针对内存马的检测,目前业界主要有两种方法:
•基于反射的检测方法
该方法是一种轻量级的检测方法,不需要注入Java进程,主要用于检测非Agent型的内存马,由于非Agent型的内存马会在Java层新增多个类和对象,并且会修改一些已有的数组,因此通过反射的方法即可检测,但是这种方法无法检测Agent型内存马。
•基于instrument机制的检测方法
该方法是一种通用的重量级检测方法,需要将检测逻辑通过attach API注入Java进程,理论上可以检测出所有类型的内存马。当然instrument不仅能用于内存马检测,java.lang.instrument是Java 1.5引入的一种可以通过修改字节码对Java程序进行监测的一种机制,这种机制广泛应用于各种Java性能检测框架、程序调试框架,如JProfiler、IntelliJ IDE等,当然近几年比较流行的RASP也是基于此类技术。
既然通过instrument机制能检测到Agent型内存马,那我们怎么样才能避免被检测到呢?答案比较简单,也比较粗暴,那就是把instrument机制破坏掉。这也是在冰蝎3.0中内存马防检测机制的实现原理,检测软件无法attach,自然也就无法检测。
首先,我们先分析一下instrument的工作流程,如下图:
1.检测工具作为Client,根据指定的PID,向目标JVM发起attach请求;2.JVM收到请求后,做一些校验(比如上文提到的jdk.attach.allowAttachSelf的校验),校验通过后,会打开一个IPC通道。3.接下来Client会封装一个名为AttachOperation的C++对象,发送给Server端;4.Server端会把Client发过来的AttachOperation对象放入一个队列;5.Server端另外一个线程会从队列中取出AttachOperation对象并解析,然后执行对应的操作,并把执行结果通过IPC通道返回Client。
由于该套流程的具体实现在不同的操作系统平台上略有差异,因此接下来我分平台来展开。
通过分析定位到如下关键代码:
可以看到当var5不等于0的时候,attach会报错,而var5是从var4中读取的,var4是execute的返回值,跟入execute,如下:
可以看到,execute方法又把核心工作交给了方法enqueue,这个方法是一个native方法,如下图:
继续跟入enqueue方法:
可以看到enqueue中封装了一个DataBlock对象,里面有几个关键参数:
strcpy(data.jvmLib, "jvm");
以上操作都发生在Client侧,接下来我们转到Server侧,定位到如下代码:
这段代码是把Client发过来的对象进行解包,然后解析里面的指令。经常写Windows shellcode的人应该会看到两个特别熟悉的API:GetModuleHandle、GetProcAddress,这是动态定位DLL中导出函数的常用API。这里的操作就是动态从jvm.dll中动态定位名称为JVM_EnqueueOperation和_JVM_EnqueueOperation@20的两个导出函数,这两个函数就是上文流程图中将AttachOperation对象放入队列的执行函数。
到这里我想大家应该知道接下来该怎么做了,那就是inlineHook。我们只要把jvm.dll中的这两个导出函数给NOP掉,不就可以成功把instrument的流程给破坏掉了么?
静态分析结束了,接下来动态调试Server侧,定位到如下位置:
图中RIP所指即为JVM_EnqueueOperation函数的入口,我们只要让RIP执行到这里直接返回即可:
怎么修改呢?当然是用JNI,核心代码如下:
unsigned char buf[]="\xc2\x14\x00"; //32,direct return enqueue function
注意这里要考虑32位和64位的区别,同时要注意堆栈平衡,否则可能会导致进程crash。到此,我们就实现了Windows平台上的内存马防检测(Anti-Attach)功能,我们尝试用JProfiler连接试一下,可见已经无法attach到目标进程了:
以上即是Windows平台上的内存马防检测功能原理。
在Linux平台,instrument的实现略有不同,通过跟踪整个流程定位到如下代码:
可以看到,在Linux平台上,IPC通信采用的是UNIX Domain Socket,因此想破坏Linux平台下的instrument attach流程还是比较简单的,只要把对应的UNIX Domain Socket文件删掉就可以了。删掉后,我们尝试对目标JVM进行attach,便会提示无法attach:
到此,我们就实现了Linux平台上的内存马防检测(Anti-Attach)功能,当然其他*nix-like的操作系统平台也同样适用于此方法。
最后说一句,内存马防检测,其实可以在上述instrument流程图中的任意一个环节进行破坏,都可以实现Anti-Attach的效果。
在Windows平台上,进程代码注入有很多种方法,最经典的方法要属CreateRemoteThread,但是这些方法大都被防护系统盯得死死的,比如我写了如下一个最简单的远程注入shellcode的demo:
往当前进程里植入一个弹计算器的shellcode,编译,运行,然后意料之中出现如下这种情况:
但是经过分析JVM的源码我发现,在Windows平台上,Java在实现instrument的时候,出现了一个比较怪异的操作。
在Linux平台,客户端首先是先和服务端协商一个IPC通道,然后后续的操作都是通过这个通道传递AttachOperation对象来实现,换句话说,这中间传递的都是数据,没有代码。
但是在Windows平台,客户端也是首先和服务端协商了一个IPC通道(用的是命名管道),但是在Java层的enqueue函数中,同时还使用了CreateRemoteThread在服务端启动了一个stub线程,让这个线程去在服务端进程空间里执行enqueue操作:
这个stub执行体pCode是在客户端的native层生成的,生成之后作为thread_func传给服务端。但是,虽然stub是在native生成的,这个stub却又在Java层周转了一圈,最终在Java层以字节数组的方式作为Java层enqueue函数的一个参数传进Native。
这样就形成了一个完美的原生远程进程注入,构造如下POC:
import java.lang.reflect.Method;
编译,执行:
成功执行shellcode,而且Windows Defender没有告警,天然免杀。毕竟,谁能想到有着合法签名安全可靠的Java.exe会作恶呢:)
至此,我们实现了Windows平台上的Java远程进程注入。另外,这个技术还有个额外效果,那就是当注入进程的PID设置为-1的时候,可以往当前Java进程注入任意Native代码,以实现不用JNI执行任意Native代码的效果。这样就不需要再单独编写JNI库来执行Native代码了,也就是说,上文提到的内存马防检测机制,不需要依赖JNI,只要纯Java代码也可以实现。
冰蝎3.0中提供了一键cs上线功能,采用的是JNI机制,中间需要上传一个临时库文件才能实现上线。现在利用这个技术,可以实现一个JSP文件或者一个反序列化Payload即可上线CS:
在上一小节Java原生远程进程注入中,我的POC里是通过反射创建了一个sun.tools.attach.VirtualMachineImpl类,然后再去调用类里面的enqueue这个Native方法。这时可能会有同学有疑惑,这个Native方法位于attach.dll,这个dll是JDK和Server-JRE默认自带的,但是这个sun.tools.attach.VirtualMachineImpl类所在的tools.jar包并不是每个JDK环境都有的。这个技术岂不是要依赖tools.jar?因为有些JDK环境是没有tools.jar的。当然,这个担心是没必要的。
我们只要自己写一个类,类的限定名为sun.tools.attach.VirtualMachineImpl即可。不过可能还会有疑问,我们自己写一个sun.tools.attach.VirtualMachineImpl类,但是如果某个目标里确实有tools.jar,那我们自己写的类在加载的时候就会报错,有没有一个更通用的方法呢?当然还是有的。
其实这个方法在冰蝎1.0版本的时候就已经解决了,那就是用一个自定义的classLoader。但是我们都知道classLoader在loadClass的时候采用双亲委托机制,也就是如果系统中已经存在一个类,即使我们用自定义的classLoader去loadClass,也会返回系统内置的那个类。但是如果我们绕过loadClass,直接去defineClass即可从我们指定的字节码数组里创建类,而且类名我们可以任意自定义,重写java.lang.String都没问题:) 然后再用defineClass返回的Class去实例化,然后再调用我们想调用的Native函数即可。因为Native函数在调用的时候只检测发起调用的类限定名,并不检测发起调用类的ClassLoader,这是我们这个方法能成功的原因。
比如我们自定义如下这个类:
package sun.tools.attach;
然后把这个类编译成class文件,把这个文件用Base64编码,然后写到如下POC里:
import java.io.*;
这样就可以通过自定义一个系统内置类来加载系统库函数的Native方法。
前面我们讲到了目前Java内存马的分类:Agent型内存马和非Agent型内存马。由于非Agent型内存马注入后,会产生新的类和对象,同时还会产生各种错综复杂的相互引用关系,比如要创建一个恶意Filter内存马,需要先修改已有的FilterMap,然后新增FilterConfig、FilterDef,最后还要修改FilterChain,这一系列操作产生的脏数据过多,不够整洁。因此我还是认为Agent型内存马才是更理想的内存马。
但是目前来看,Agent型内存马的缺点也非常明显:
•磁盘有agent文件落地•需要上传文件,植入步骤复杂•如无写文件权限,则无法植入
众所周知,想要动态修改JVM中已经加载的类的字节码,必须要通过加载一个Agent来实现,这个Agent可以是Java层的agent.jar,也可以是Native层的agent.so,但是必须要有个agent。有没有一种方法可以既优雅又简洁的植入Agent型内存马呢?换句话说,有没有一种方法可以在不依赖额外Agent的情况下,动态修改JVM中已经加载的类的字节码呢?以前没有,现在有了:)
首先,我们先看一下通过Agent动态修改类的流程:
1.在客户端和目标JVM建立IPC连接以后,客户端会封装一个用来加载agent.jar的AttachOperation对象,这个对象里面有三个关键数据:actioName、libName和agentPath;2.服务端收到AttachOperation后,调用enqueue压入AttachOperation队列等待处理;3.服务端处理线程调用dequeue方法取出AttachOperation;4.服务端解析AttachOperation,提取步骤1中提到的3个参数,调用actionName为load的对应处理分支,然后加载libinstrument.so(在windows平台为instrument.dll),执行AttachOperation的On_Attach函数(由此可以看到,Java层的instrument机制,底层都是通过Native层的Instrument来封装的);5.libinstrument.so中的On_Attach会解析agentPath中指定的jar文件,该jar中调用了redefineClass的功能;6.执行流转到Java层,JVM会实例化一个InstrumentationImpl类,这个类在构造的时候,有个非常重要的参数mNativeAgent:
这个参数是long型,其值是一个Native层的指针,指向的是一个C++对象JPLISAgent。7.InstrumentationImpl实例化之后,再继续调用InstrumentationImpl类的redefineClasses方法,做稍许校验之后继续调用InstrumentationImpl的Native方法redefineClasses08.执行流继续走入Native层:
继续跟入:
做了一系列判断之后,最终调用jvmtienv的redefineClasses方法执行类redefine操作:
接下来理一下思路,在上面的8个步骤中,我们只要能跳过前面5个步骤,直接从步骤6开始执行,即可实现我们的目标。那么问题来了,步骤6中在实例化InstrumentationImpl的时候需要的非常重要的mNativeAgent参数值,这个值是一个指向JPLISAgent对象的指针,这个值我们不知道。只有一个办法,我们需要自己在Native层组装一个JPLISAgent对象,然后把这个对象的地址传给Java层InstrumentationImpl的构造器,就可以顺利完成后面的步骤。
想要在Native内存上创建对象,首先要获取可控的Native内存操作能力。我们知道Java有个DirectByteBuffer,可以提供用户申请堆外内存的能力,这也就说明DirectByteBuffer是有操作Native内存的能力,而DirectByteBuffer底层其实使用的是Java提供的Unsafe类来操作底层内存的,这里我们也直接使用Unsafe进行Native内存操作。
通过如下代码获取Unsafe:
Unsafe unsafe = null;
通过unsafe的allocateMemory、putlong、getAddress方法,可以实现Native内存的分配、读写。
接下来,就是分析JPLISAgent对象的结构了,如下:
JPLISAgent是一个复杂的数据结构。由上文中redefineClasses代码可知,最终实现redefineClasses操作的是*jvmtienv的redefineClasses函数。但是这个jvmtienv的指针,是通过jvmti(JPLISAgent)推导出来的,如下:
而jvmti是一个宏:
而在执行到*jvmtienv的redefineClasses之前,还有多处如下调用都用到了jvmtienv:
因此,我们至少要保证我们自己组装的JPLISAgent对象需要成功推导出jvmtienv的指针,也就是JPLISAgent的mNormalEnvironment成员,其结构如下:
可以看到这个结构里存在一个回环指针mAgent,又指向了JPLISAgent对象,另外,还有个最重要的指针mJVMTIEnv,这个指针是指向内存中的JVMTIEnv对象的,这是JVMTI机制的核心对象。另外,经过分析,JPLISAgent对象中还有个mRedefineAvailable成员,必须要设置成true。
接下来就是要确定JVMTIEnv的地址了。
通过动态分析可知,0x000002E62D8EE950为JPLISAgent的地址,0x000002E62D8EE950+0x8(0x000002E62D8EEB60)为mJVMTIEnv,即指向JVMTIEnv指针的指针:
转到该指针:
可以看到0x6F78A220即为JVMTIEnv对象的真实地址,通过分析发现,该对象存在于jvm模块的地址空间中,而且偏移量是固定的,那只要找到jvm模块的加载基址,加加上固定的偏移量即是JVMTIEnv对象的真实地址。但是,现代操作系统默认都开启了ASLR,因此jvm模块的基址并不可知。
由上文可知,Unsafe提供了堆外内存的分配能力,这里的堆并不是OS层面的堆,而是Java层面的堆,无论是Unsafe分配的堆外地址,还是Java的堆内地址,其都在OS层的堆空间内。经过分析发现,在通过Unsafe分配一个很小的堆外空间时,这个堆外空间的前后内存中,存在大量的指针,而这些指针中,有一些指针指向jvm的地址空间。编写如下代码:
long allocateMemory = unsafe.allocateMemory(3);
输出如下:
定位到地址0x2e61a1b67d0:
可见前后有很多指针,绿色的那些指针,都指向jvm的地址空间:
但是,这部分指针并不可复现,也就是说这些指针相对于allocateMemory的偏移量和指针值都不是固定的,也就是说我们根本无法从这些动态的指针里去推导出一个固定的jvm模块基址。当对一个事物的内部运作机制不了解时,最高效的方法就是利用统计学去解决问题。于是我通过开发辅助程序,多次运行程序,收集大量的前后指针列表,这些指针中有大量是重复出现的,然后根据指针末尾两个字节,做了一个字典,当然只做2个字节的匹配,很容易出错,于是我又根据这些大量指针指向的指针,取末尾两个字节,又做了一个和前面一一对应的字典。这样我们就制作了一个二维字典,并根据指针重复出现的频次排序。POC运行的时候,会以allocateMemory开始,往前往后进行字典匹配,可以准确的确定jvm模块的基址。部分字典结构如下:"'3920':'a5b0':'633920','fe00':'a650':'60fe00','99f0':'cccc':'5199f0','8250':'a650':'638250','d200':'fdd0':'63d200','da70':'b7e0':'67da70' 每个条目含有3个元素,第一个为指针末尾2字节,第二个元素为指针指向的指针末尾两个字节,第三个元素为指针与baseAddress的偏移量。基址确定了,jvmtienv的具体地址就确定了。当然拿到了jvm的地址,加上JavaVM的偏移量便可以直接获得JavaVM的地址。
拿到jvm模块的基址后,就万事俱备了,下面准备装配JPLISAgent对象,代码如下:
private static long getAgent(long jvmtiAddress)
入参为上一阶段获取的jvmti的地址,返回值为JPLISAgent的地址。
完整POC如下(跨平台):
package net.rebeyond;
Bird.java
package net.rebeyond;
编译,运行:
上述环境是win10+Jdk1.8.0_301_x64,注释中内置了linux+jdk1.8.0_301_x64和win10+Jdk1.8.0_271_x64指纹,如果是其他OS或者JDK版本,指纹库需要对应更新。
可以看到,我们成功通过纯Java代码实现了动态修改类字节码。
按照惯例,我提出一种新的技术理论的时候,一般会直接给出一个下载即可用的exp,但是现在为了合规起见,此处只给出demo,不再提供完整的利用工具。
上文中,我们介绍了在Windows平台下巧妙利用instrument的不恰当实现来进行进程注入的技术,当注入的目标进行为-1时,可以往当前Java进程注入shellcode,实现不依赖JNI执行任意Native代码。但是这个方法仅适用于Windows平台。只适用于Windows平台的技术是不完整的:)
上一小节我们在伪造JPLISAgent对象的时候,留意到redefineClasses函数里面有这种代码:
allocate函数的第一个参数是jvmtienv指针,我们跟进allocate函数:
void *allocate(jvmtiEnv * jvmtienv, size_t bytecount) {
可以看到最终是调用的jvmtienv对象的一个成员函数,先看一下真实的jvmtienv是什么样子:
对象里是很多函数指针,看到这里,如果你经常分析二进制漏洞的话,可能会马上想到这里jvmtienv是我们完全可控的,我们只要在伪造的jvmtienv对象指定的偏移位置覆盖这个函数指针即可实现任意代码执行。
构造如下POC:
先动态调试看一下我们布局的payload:
0x219d1b1a810为我们通过unsafe.allocateMemory分配内存的首地址,我们从这里开始布局JPLISAgent对象,0x219d1b1a818处的值0x219d1b1a820是指向jvmtienv的指针,跟进0x219d1b1a820,其值为指向真实的jvmtienv对象的指针,这里我们把他指向了他自己0x219d1b1a820,接下来我们就可以在0x219d1b1a820处布置最终的jvmtienv对象了。根据动态调试得知allocate函数指针在jvmtienv对象的偏移量为0x168,我们只要覆盖0x219d1b1a820+0x168(0x219d1b1a988)的值为我们shellcode的地址即可将RIP引入shellcode。此处我们把0x219d1b1a988处的值设置为0x219d1b1a990,紧跟在0x219d1b1a988的后面,然后往0x219d1b1a990写入shellcode。
编译,运行:
进程crash了,报的异常是意料之中,仔细看下报的异常:
#EXCEPTION_ACCESS_VIOLATION (0xc0000005) at pc=0x00000219d1b1a990, pid=24840, tid=0x0000000000005bfc
内存访问异常,但是pc的值是0x00000219d1b1a990,这就是我们shellcode的首地址。说明我们的payload布置是正确的,只不过系统开启了NX(DEP),导致我们没办法去执行shellcode,下图是异常的现场,可见RIP已经到了shellcode:
上文的POC中我们已经可以劫持RIP,但是我们的shellcode部署在堆上,不方便通过ROP关闭DEP。那能不能找一块rwx的内存呢?熟悉浏览器漏洞挖掘的朋友都知道JIT区域天生RWE,而Java也是有JIT特性的,通过分析进程内存布局,可以看到Java进程确实也存在这样一个区域,如下图:
我们只要通过unsafe把shellcode写入这个区域即可。但是,还有ASLR,需要绕过ASLR才能获取到这块JIT区域。
在前面我们已经提到了一种通过匹配指针指纹绕过ASLR的方法,这个方法在这里同样适用。不过,这里我想换一种方法,因为通过指纹匹配的方式,需要针对不同的Java版本做适配,还是比较麻烦的。这里采用了搜索内存的方法,如下:
package net.rebeyond;
编译,运行,成功执行了shellcode,弹出计算器。
到此,我们通过纯Java代码实现了跨平台的任意Native代码执行,从而可以解锁很多新玩法,比如绕过RASP实现命令执行、文件读写、数据库连接等等。
本文主要介绍了几种我最近研究的内存相关的攻击方法,欢迎大家交流探讨,文中使用的测试环境为Win10_x64、Ubuntu16.04_x64、Java 1.8.0_301_x64、Java 1.8.0_271_x64。由于文章拖得比较久了,所以行文略有仓促,若有纰漏之处,欢迎批评指正。