Java Agent 内存马简介
一、Java Agent技术开端
无文件内存马目前大致两个方向,一是依靠服务器框架进行动态注册路由等植入恶意服务,另一方向就是基于Java本身的 agent 机制进行更底层的代码执行。后者的提出可谓精彩,需要对Java和计算机原理以及二进制都要有足够的理解。
Java SE 5开始就有了Instrumentation类,2018年 rebeyond 师傅的《利用“进程注入”实现无文件复活 WebShell》开始讨论Java内存马的实现,并且使用Java Instrumentation类通过agent机制动态改变JVM中类的方法。无文件内存马还有另外的一个大方向
0.复现
开山的文章提及了完整的实现原理,尝试进行复刻并记录一些细节。该方法需要生成三样文件:
- evil.class:方法被修改了的目标类
- agent.jar:负责对jvm中的类进行遍历,并动态加载class文件去改变原有方法
- agent-starter.jar:通过agent机制把agent.jar植入到jvm中
本次复现使用Java 11、idea 2020.2、7zip
1. 正常服务
为了演示,先写一个正常的服务:
1 | public class Bird { |
1 | public class Main { |
把两个类打包成normal.jar包,注意在idea打包时选择:
File->Project Structure->Artifacts-> + ->Jar->From modules…->选择 Main Class ->把 MANIFEST.MF 路径中的 src 改为 src/main/resources
不改变最后的路径jar包会报错
2. 恶意类构造
对于正常的类:
1 | public class Bird { |
直接修改它正常的方法,使得可以具有恶意的webshell功能,这里为了示例只改变打印内容:
1 | public class Bird { |
通过编译新的类得到Bird.class
3. agent构造
3.1 Transformer类
第一个类Transformer只是负责把恶意的Bird.class读取为字节数组
1 | import java.io.File; |
3.2 AgentEntry类
第二个类AgentEntry遍历jvm里的类,通过Instrumentation进行替换方法
1 | import java.lang.instrument.Instrumentation; |
3.3 打包成agent.jar
idea本身可以将上述两个类打包,但是这里需要修改MANIFEST.MF来指定agent机制的入口类,所以手动打包:
1 | javac -encoding UTF-8 Transformer.java AgentEntry.java |
得到agent.jar后用7z直接打开压缩包(不解压),进入META-INF文件夹,右键编辑MENIFEST.MF:
1 | Manifest-Version: 1.0 |
然后保存更新压缩包,注意MENIFEST.MF最后空一行。MENIFEST.MF中的Agent-Class指定了入口类为AgentEntry,或者Premain-Class指定。
- 后来知道了如何idea里使用MANIFEST文件了:
4. agent-starter构建
agent-starter负责把agent.jar通过agent机制注册入各个jvm
1 | import com.sun.tools.attach.VirtualMachine; |
然后打包成agent-starter.jar
5. 运行测试
开一个窗口先运行normal.jar,模拟正常的服务,会不断打印出 “bird say hello”
然后运行agent-starter.jar,可以发现输出已经找到jvm的信息,然后正常服务输出的语句也变成了”bird is gone.”
6. 实际应用细节
上述只是一个简单的示例,为了投入实际应用,师傅还给出了具体的实现细节,包括注入类和方法的选取、通过复活进行持久化,并实现了memShell工具。
6.1 注入类和方法选择
注入类不管路由是否存在都要能响应请求,需要找一个特殊的类,这个类要尽可能在http请求调用栈的上方,又不能与具体的URL有耦合,而且还能接受客户端request中的数据。因此师傅选择了org.apache.catalina.core.ApplicationFilterChain,对应方法选择了internalDoFilter,具体的修改代码师傅文字很详细,不赘诉。
6.2 持久化
内存马最大的问题就是关机会丢失,因此师傅的方法是在关机时添加一个线程重新将内存中的inject.jar(也就是agent-starter.jar)和agent.jar(没有class文件是因为完全可以直接通过字节数组写在源代码里,因此agent.jar也承担了webshell的功能),然后直接jar -jar inject.jar,使得inject.jar保活
1 | public static void persist() { |
6.3 大致流程
- 通过已有的webshell上传两个jar,然后以tomcat运行者的身份运行jar -jar inject.jar
- 然后inject.jar会进行注入,成功后才会退出
- 执行成功后,agent.jar执行agentmain方法:
- 修改目标类的方法
- 把磁盘上的inject.jar和agent.jar读进tomcat内存
- 对memShell做初始访问,使得tomcat自动引用类入内存,防止删除文件后找不到类
- 删除两个jar,linux直接删,windows下需要通过DuplicateHandle解除jvm文件占用句柄后再删除(封装功能在exe里,由agent.jar判断系统释放出来)
- memShell注入完毕,正常接收请求
- 当JVM关闭时,会首先执行我们注册的ShutdownHook,把jar从内存写回硬盘,再循环inject.jar,从而保活
二、Linux 下无文件 Java Agent 植入
回顾第一种方法,虽然实现了植入内存马的目的,但是本质上是依赖两个在硬盘上的jar包,这样的方式不够干净也不够“优雅”,于是游望之 师傅就分析了agent植入的过程和本质,尝试直接构造Instrumentation类进行注入,Xiaopan233 师傅也对此进行了复现和总结,实现了Linux下进行无文件Agent植入。
参考文章:
Linux下内存马进阶植入技术:https://xz.aliyun.com/t/10186
Linux下无文件Java agent探究:https://tttang.com/archive/1525/
论如何优雅的注入 Java Agent 内存马:https://paper.seebug.org/1945/
0. 依赖Jar包的原因
观察第一种方法的代码,需要构造两个jar包:agent.jar 和 inject.jar。大致的执行流程是:
- 执行inject.jar,调用loadAgent()方法植入agent.jar
- agent.jar中使用 Instrumentation 对象的 retransformClasses() 方法进行动态修改字节码
而 loadAgent() 需要的是 agent.jar 的路径,所以需要jar包落地。那么如果能直接调用Instrumentation 的方法就可以直接进行agent植入了,这也正是师傅们所做的。
1. 构造流程
师傅们的分析都很详细了,从Java代码层到Java native,到c语言层,最后手搓汇编,实在太佩服了。这里不再过多的源码推导,而是正向简单阐述构造的过程。
1.1 InstrumentationImpl
Instrumentation 只是接口,真正实现的类是 InstrumentationImpl
1 | public class InstrumentationImpl implements Instrumentation { |
可以看到构造函数最需要的是一个nativeAgent指针,这个指针被 redefineClasses->redefineClasses0
用到,而 redefineClasses
正是植入 agent
的作用函数,因此需要得到nativeAgent指针。
1.2 nativeAgent和JPLISAgent指针
redefineClasses0
是个native函数,看代码:
1 | JNIEXPORT void JNICALL |
redefineClasses0
通过JNI方法传到了 native 层的 redefineClasses
函数,可以看到 long nativeAgent
实际上是一个 JPLISAgent *
类型的指针:
1 | struct _JPLISAgent { |
创建 JPLISAgent
可以使用 createNewJPLISAgent
函数,但是它是内部函数,Java无法直接使用,但是还是能模仿这个函数的构造:
1 | JPLISInitializationError createNewJPLISAgent( |
createNewJPLISAgent 的大致流程就是:
- 传入
*vm
和**agent_ptr
- 根据
*vm
生成*jvmtienv
:(*vm)->GetEnv( vm, (void **) &jvmtienv, JVMTI_VERSION_1_1)
*agent
根据*jvmtienv
分配空间- 调用
initializeJPLISAgent
函数,通过*vm
和*jvmtienv
进行初始化*agent
- 初始化成功后把
**agent_ptr
指向*agent
,成功得到JPLISAgent
指针
可以看出下一步关键的就是vm,也就是JavaVM*
的获取。
当然,我们实际需要的是jvmtiEnv *
,虽然构造JPLISAgent需要vm,但是调用redefineClasses只需要jvmtiEnv *
。
1.3 JavaVM指针
在JDK的 jni.h 中导出了获取方法:
1 | _JNI_IMPORT_OR_EXPORT_ jint JNICALL JNI_GetCreatedJavaVMs(JavaVM **vm_buf, jsize bufLen, jsize *numVMs) { |
该方法最后被编译到 libjvm.so 中。接下来就是如何调用 libjvm.so 中的 JNI_GetCreatedJavaVMs 函数了。得益于Linux的特性,我们不需要额外把so传上去,Java本身就会把so加载到内存里,可以通过读取 /proc/{pid或self}/maps
获取所有已加载ELF(so文件)对象的基址及文件路径,maps 里内容如下:
1 | passbya@raspberrypi:~ $ cat /proc/23390/maps | grep libjvm |
可以看到提供了内存地址范围,这也让我们找到了ELF的基址。但是为了调用函数还需要寻找到JNI_GetCreatedJavaVMs的偏移地址,这需要我们继续解析ELF。
1.4 JNI_GetCreatedJavaVMs 偏移
何为ELF?简单来说就是加载到内存中的可执行程序,so文件的格式就是 ELF 格式。ELF格式结构化地存储了函数信息,理论上按照格式协议读取就可以获取我们想要的函数的偏移。这里不细讲ELF的格式,只是讲一下获取偏移的流程:
- 读取
File header
,由e_shoff
字段得到Section header
的偏移,由e_shnum
字段得到所有的Section header
的数量 - 读取
File header
的e_shstrnds
字段,知道.shstrtab
是第几个Section header
,从而定位到.shstrtab
,同理也知道.strtab
的位置 - 读取
.shstrtab
中的sh_offset
字段,得到字符串表String table
。寻找这个字符串表的目的是为了得到各个Section header
的名称。每个Section header
的sh_name
存储着该Section header
的名称字符串在字符串表里的偏移。同理可以构建.strtab
,只不过存的是变量、函数名等等。 - 遍历各个
Section header
,找出名称为.symtab
和.dynsym
的Section header
- 遍历读取
.symtab
和.dynsym
里的Symbol table
(.symtab
),优先读取.dynsym
,Symbol table
结构如下:1
2
3
4
5
6
7
8
9typedef struct
{
Elf64_Word st_name; /* 4 byte */ //table的名称,也是基于字符串表偏移的
unsigned char st_info; /* 1 byte */ //低4位需要为代表函数实体指针的值2
unsigned char st_other;
Elf64_Half st_shndx;
Elf64_Addr st_value; /* 8 byte */ //目标的偏移地址
Elf64_Xword st_size; /* 8 byte */ //指向符号的大小
} Elf64_Sym;
- 读取每个
Symbol table
里的st_name
,通过在.strtab
里的偏移得到名称,对应函数名称,判断是否等于JNI_GetCreatedJavaVMs
,如果等于那么可以读取st_value
字段得到函数在ELF中偏移,上一步最后的ELF基址+偏移地址就得到了函数在内存中的地址,也就使得我们可以在汇编的层次上调用函数了。
1.5 汇编调用 JNI_GetCreatedJavaVMs 获取 JavaVM*
Xiaopan233 师傅讲的是联合混编的方式,首先新建一个C文件:
1 | extern void call(); //导出 |
然后手搓Linux的汇编(AT&T语法):
1 | .text |
编译:gcc -o A ./A.c ./A.s -g
在Linux的汇编层面中,调用函数时,传参时寄存器和函数形参关系:
- rdi -> arg1
- rsi -> arg2
- rdx -> arg3
- rcx -> arg4
- r8 -> arg5
- r9 -> arg6
在call addr
前需要把参数塞进寄存器里:最后修改的是传入的二级指针,所以不用管返回值1
2
3
4
5
6
7_JNI_IMPORT_OR_EXPORT_ jint JNICALL JNI_GetCreatedJavaVMs(JavaVM **vm_buf, jsize bufLen, jsize *numVMs) {
//...
if (vm_created == 1) {
if (numVMs != NULL) *numVMs = 1;
if (bufLen > 0) *vm_buf = (JavaVM *)(&main_vm);
}
}
师傅的核心代码:这样就调用了 JNI_GetCreatedJavaVMs 函数,但是还需要得到1
2
3
4
5
6
7
8
9
10
11
12//.....
lea (%rsp), %rdi //把二级指针传入rdi,lea相当于取指针,对应JavaVM **
mov $0x1, %rsi //设置第二个参数为1,满足函数内的条件,对应jsize
lea 0x8(%rsp), %rdx //设置第三个参数,设为(rsp+8)这空间的地址,对应jsize*
#调用GetCreatedJavaVms(),这里的函数地址是上文解析基址和偏移得到的,需要动态组装,这里暂时占位
mov $0xffffffffffffffff, %rax
call %rax
#调用结束后,rsp指向的空间应当被赋值成了JavaVM*指针
//.....jvmtiEnv *
,因为而 jvmtienv 是根据vm的GetEnv函数产生的:1
2
3
4initializeJPLISAgent(agent, //空的,进行变量填充
vm, //传入的vm
jvmtienv //根据vm获取的jvmtienv
);前面我们调用 JNI_GetCreatedJavaVMs 函数得到了1
(*vm)->GetEnv( vm, (void **) &jvmtienv, JVMTI_VERSION_1_1 )
(*vm)
,而GetEnv是它的成员函数,可以根据结构体得到函数 GetEnv 的偏移地址为0+48:根据对应关系,可以知道传入 GetEnv 的第二个参数最后会得到1
2
3
4
5
6
7
8
9
10
11
12
13
14
15typedef JavaVM_ JavaVM;
struct JavaVM_ {
const struct JNIInvokeInterface_ *functions; //偏移0
//...
jint GetEnv(void **penv, jint version) {
return functions->GetEnv(this, penv, version);
}
}
struct JNIInvokeInterface_ {
....
/*前面有6个函数指针,所以GetEnv函数指针的偏移量是 6*8=48*/
jint (JNICALL *GetEnv)(JavaVM *vm, void **penv, jint version);
};jvmtienv
,不过要注意第二个参数penv
是个二级指针。具体的汇编也不复制了,和上面的过程差不多,只不过需要考虑到需要得到 GetEnv 的地址。
对整个汇编程序进行编译得到机器码,然后找到需要填入ELF基址的8字节,分成前后两份,中间的基址动态填入。当然,还有一些变量需要说明,看师傅的文章吧。
1.6 替换函数运行机器码
到这里我们得到了获取到了jvmtiEnv *
这个调用 redefineClasses 必须的变量。但是又遇到新的问题,就是Java是没办法直接运行机器码的。于是为了运行这段机器码,要把这段机器码替换掉其他Java函数的机器码,这样我们就能在Java层面上使用这个函数了。
在Linux上,可以通过修改 /proc/self/mem
修改自身内存,因此我们类似1.4节查出目标替换函数的地址,然后直接替换 /proc/self/mem
对应地址的机器码为我们新编译出来的机器码即可。为了替换,这样的替换函数需要满足:
- 返回值长度必须也是
jvmtiEnv *
,就是 long ,使得我们可以在Java层接收结果 - 使用频率低,不大会影响正常的业务
- 函数空间足够大,可以放得下新的机器码
游望之师傅选择 libjava.so 中的 Java_java_io_RandomAccessFile_length 函数,而Xiaopan233师傅选择了libjava.so 里的 Java_java_io_FileInputStream_skip0 函数。当然这里的shllcode还要进一步加一个返回值功能(把 jvmtiEnv* 传入 rax 中):替换成功后就可以使用Java运行我们的机器码: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
26void * shellcode()
{
struct JavaVM_ * vm;
jsize count;
JNI_GetCreatedJavaVMs(&vm, 1, &count);
struct jvmtiEnv_ * _jvmti_env;
vm->functions->GetEnv(vm, (void **)&_jvmti_env, JVMTI_VERSION_1_2);
return _jvmti_env;
}
/*
movabs rax, _JNI_GetCreatedJavaVMs
sub rsp, 20h
xor rsi, rsi
inc rsi
lea rdx, [rsp+4]
lea rdi, [rsp+8]
call rax
mov rdi, [rsp+8]
lea rsi, [rsp+10h]
mov edx, 30010200h
mov rax, [rdi]
call qword ptr [rax+30h]
mov rax, [rsp+10h]
add rsp, 20h
ret
*/当然,为了不影响原有功能,在替换前需要备份原有机器码,获取完指针后要还原现场。1
2
3//替换Java_java_io_FileInputStream_skip0函数后
FileInputStream fileInputStream = new FileInputStream("/proc/self/maps");
long jvmtiEnvPointer = fileInputStream.skip(1L); //skip函数返回的就是jvmtiEnv *
1.7 构造JPLISAgent
回顾 JPLISAgent 的结构:
1 | struct _JPLISEnvironment { |
根据结构依次分配空间即可:
1 | Field theUnsafe = Unsafe.class.getDeclaredField("theUnsafe"); |
最后得到 JPLISAgent指针 theUnsafe
。
值得注意的是can_redefine_classes这个值的位置是必须要填的,而且偏移是根据Java版本改变而改变的,具体的获取方式Xiaopan233师傅有解析。
1.8 反射构造InstrumentationImpl
1 | byte[] evilClassClassBytes = new byte[]{ |
到这里就已经成功实现Agent的植入了。
2. 总结流程
- 查elf获取 JNI_GetCreatedJavaVMs 和 替换函数的地址
- 根据 JNI_GetCreatedJavaVMs 地址构建获取
jvmtiEnv*
的机器码 - 把构建的得到的机器码替换到替换函数的内存空间
- Java层面调用替换函数获取
jvmtiEnv*
,还原替换函数现场 - Java分配 unsafe 内存块构建 JPLISAgent 指针
- 通过反射传入 JPLISAgent 指针构建 InstrumentationImpl 对象
- 调用 InstrumentationImpl 的 redefineClasses 方法修改目的对象
3. 实现
三、Windows 下无文件 Java Agent 植入
Linux下无文件植入Agent 内存马的方式很具有Linux特色,包括使用/proc/self/maps
、/proc/self/mem
,以及解析elf
,显然在Windows没办法植入。
0.可行性分析
Windows 下无文件植入 Agent 思路其实和Linux差不多,核心都是构造JPLISAgent指针,也就是获取JavaVM指针,或者说如何调用 JNI_GetCreatedJavaVMs 函数和获取返回值。
《Java内存攻击技术漫谈》一文中,rebeyond 师傅提出了Windows下直接使用 Java 执行 shellcode 的方式,也就不需要通过修改/proc/self/mem
的方式执行shellcode了,算是替换了其中一步。那么还需要解决的就是获取 JNI_GetCreatedJavaVMs 的地址,一旦得到函数地址就可以调用函数。至于返回值的获取虽然不能修改别的 native 函数,但是实际上可以Java先在堆上分配一片空间,获取空间地址,在通过shellcode执行函数后把值存在那片空间即可。
1. 暴力统计匹配获取 jvmtiEnv*
Windows 中 JNI_GetCreatedJavaVMs 函数在 jvm.dll 中。dll的特性是内部函数相对地址固定,而基址在dll加载时动态分配,所以最大的难题是获取 jvm.dll 的基址。
1.1 内存信息泄露
在 rebeyond 师傅的《Java内存攻击技术漫谈》中,师傅发现通过Unsafe分配一个很小的堆外空间时,这个堆外空间的前后内存中,存在大量的指针,而这些指针中,有一些指针指向jvm的地址空间,同时我们是可以直接访问这些内存的:
1 | Field theUnsafe = Unsafe.class.getDeclaredField("theUnsafe"); |
1.3 统计获取基址
师傅发现指针末尾的偏移其实是伪随机的,相同功能的指针(指向相同内容)地址会重复在某个集合重复随机出现。于是师傅多次分配空间,通过外部工具调试收集前后较大空间里确实指向jvm空间的指针的信息,构建 (指针末尾两字节-offset,指针指向的空间的末尾两字节-value,该指针与jvm.dll基址的偏移量-delta),形成一个匹配字典。
应用的时候先分配小内存,然后不断获取前后的一个地址指针,堆该指针遍历整个字典,如果发现offset相同且value相同,那么就可以直接用delta获取到基址了:基址 = 指针-delta
。下面贴一下获取offset和value的代码:
1 | import sun.misc.Unsafe; |
前面也说过,dll 里信息的相对偏移也是固定的(额外分析 dll ),只要找到 dll 基址,甚至可以直接获取到 jvmtiEnv*
,于是便可以直接像上上大节的1.7一样直接构造 JPLISAgent。
1.3 不足分析
- 很显然统计的方法鲁棒性不好,Java版本、Windows版本、系统位数都会影响到统计表和 dll 内相对地址。
- 不好判断指针指向的内容是否仍是是合法范围的指针,如果越界会导致程序崩溃。
2. JNI 调用 dll 函数获取 jvmtiEnv*
我们的核心问题一直是如何获取 JNI_GetCreatedJavaVMs 函数,这个函数在 dll 里,C/C++可以很方便调用 dll 里的函数,如果 Java 能运行 C/C++ 代码也就很方便了。但Java若想要执行C/C++的代码,一般得通过JNI,即Java本地调用(Java Native Interface),加载 JNI 链接库,调用 Native 方法实现。
那么直接编写一个使用能加载 jvm.dll 后运行JNI_GetCreatedJavaVMs 函数的 dll 或 so,然后通过 JNI 途径的 System.load 函数加载 dll 岂不是美哉?然而!这样需要一个 dll 或者 so 落地,这和无文件 Agent 植入的初衷违背了。
不过虽然违背,但我觉得还是有必要实现一波,之前提到 Windows 下 Java 可以直接执行 shellcode,那么我们可以模仿编写C/C++程序的思路调用 JNI_GetCreatedJavaVMs 函数,编写 shellcode。
2.1 参考文章
- https://www.cobaltstrike.com/blog/how-to-inject-shellcode-from-java/
- https://blog.csdn.net/zh123456_789/article/details/113184863
- https://www.jianshu.com/p/e84ffacd420b
- https://blog.csdn.net/u011520181/article/details/79765336
2.2 生成 JNI 头文件
IDEA 新建一个项目 jni-demo,这里我选择了Java 11 版本,记得对应后面的引用文件。然后项目新建一个Demo类,注册了一个 native 函数 inject:
1 | public class Demo { |
接着jni-demo目录下运行:javac ./src/Demo.java -h cpp-src
在 cpp-src 目录生成了 Demo.h
文件,需要我们进一步实现方法。
- Java 11 移除了
javah
工具,融合到了javac
工具的-h
参数中 “参考” - 如果使用 Java 8 可以看 2.1 的参考文章,使用
javah -classpath bin -jni -o src/injector.h Demo
当然,src 目录下还生成了一个 Demo.class 可以删了,后面 idea 会再次生成的。
2.3 编译 CMake 项目
使用 Visual Studio 新建一个 CMake 项目 :demo_dll,生成后修改 demo_dll 文件夹下的 CMakeLists.txt 文件,注意不是根目录那个。
1 | # CMakeList.txt: demo_dll 的 CMake 项目,在此处包括源代码并定义 |
需要改动的地方:
- add_executable一行注释掉,然后添加
add_library(demo_dll SHARED demo_dll.cpp demo_dll.h)
- 然后依次添加两个
include_directories
,路径对应 Java 版本的 两个include 目录,因为我们需要引用 jni.h 这个头文件
保存后会自动更新项目,然后 Ctrl+Shift+B 应该可以正常生成 dll 文件。
我们上一步得到的Demo.h
为:这个头文件的内容直接全部复制到1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21/* DO NOT EDIT THIS FILE - it is machine generated */
/* Header for class Demo */
extern "C" {
/*
* Class: Demo
* Method: inject
* Signature: ([B)J
*/
JNIEXPORT jlong JNICALL Java_Demo_inject
(JNIEnv *, jobject, jbyteArray);
}demo_dll.h
中,不能不复制过去而只修改cpp文件,这样会后面 Java 报错java.lang.UnsatisfiedLinkError
。修改后的demo_dll.h
如下: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
// TODO: 在此处引用程序需要的其他标头。
/* DO NOT EDIT THIS FILE - it is machine generated */
/* Header for class Demo */
extern "C" {
/*
* Class: Demo
* Method: inject
* Signature: ([B)J
*/
JNIEXPORT jlong JNICALL Java_Demo_inject
(JNIEnv*, jobject, jbyteArray);
}
然后根据函数定义JNIEXPORT void JNICALL Java_Demo_inject(JNIEnv *, jobject, jbyteArray)
,修改 demo_dll.cpp
文件:
1 | // demo_dll.cpp: 定义应用程序的入口点。 |
jdata*
转 char*
见参考文章3 ,返回值的构造见参考文章4 。inject
函数实现我们真正要的功能。关于Java_Demo_inject
函数 env 参数和函数结构,不同 Java 版本不同,和引入的jni.h
有关,所以直接用 cs官方的做法 会报错,留意 Visual Studio 的提示修改就好。
修改完成后再次 Ctrl+Shift+B 编译应该会成功编译得到 demo_dll.dll
,在out\build\x64-Debug\demo_dll
目录下。
2.4 JNI 引用 DLL
回到 2.2 新建的 IDEA 工程,新建测试类:DemoTest
1 | public class DemoTest { |
运行后输出:
2.5 获取 JavaVM 和 jvmtiEnv
为了获取 jvmtiEnv*
,需要调用 JavaVM
的 GetEnv
方法,而获取 JavaVM*
可以用 JNI_GetCreatedJavaVMs
函数。JNI_GetCreatedJavaVMs
是 jvm.dll
里导出的函数,可以使用 C/C++ 的 Windows.h
的 GetProcAddress
函数通过函数名获取。当然在获取函数前需要用 LoadLibraryA
函数加载 dll。于是总的流程如下:
- LoadLibraryA 加载 DLL
- GetProcAddress 查找获得函数 JNI_GetCreatedJavaVMs 地址
- 调用 JNI_GetCreatedJavaVMs 函数获得 JavaVM*
- 调用JavaVM* 的 GetEnv 方法获取 jvmtiEnv*
- 通过 JNI 返回 jvmtiEnv*
- Java 层利用 jvmtiEnv* 构造 JPLISAgent,反射构造 InstrumentationImpl
- 反射调用 InstrumentationImpl 的 redefineClasses 方法动态修改类方法
在 C/C++ 那边,我们只需要修改上一小节中的 inject
函数,当然还需要在头文件里引入Windows.h
:
1 | /* inject some shellcode... */ |
注意 jlong
的长度也就是 Java 里 long
类型的长度是 8 字节(64位系统),而 C 中 long
是4字节,long long
才是8字节。还有要注意指针传参。修改完 inject 函数后 Ctrl+Shift+B 重新编译DLL。
2.6 构造 JPLISAgent 和 InstrumentationImpl
在 IDEA 中新建一个 Bird类,内容和第一大节那个一样,如果还留着那个恶意的 Bird.class 可以直接拿过来用。
结合第二大节的 1.7 和 1.8 小节的内容,修改上一节的DemoTest
:
1 | import sun.misc.Unsafe; |
运行项目,可以看到方法确实被修改了:
3. 构造 shellcode 获取 jvmtiEnv*
上一节我们使用了 C/C++ 的 LoadLibraryA
和 GetProcAddress
函数,那么能不能直接构造 shellcode 调用这两个函数呢?当然可以!kernel32.dll 导出了 GetProcAddress
函数,在《论如何优雅的注入 Java Agent 内存马》 一文中给出了 shellcode 的操作流程:
- 获取
kernel32.dll
基址 - 通过
kernel32.dll
的 导出表/输出表 得到GetProcAddress
函数的地址 - 调用
GetProcAddress
函数获取LoadLibraryA
函数的地址 - 使用
LoadLibraryA
函数加载jvm.dll
- 使用
GetProcAddress
函数获取jvm.dll
中JNI_GetCreatedJavaVMs
的函数地址 - 调用
JNI_GetCreatedJavaVMs
函数得到JavaVM*
- 得到
JavaVM*
根据相对偏移调用GetEnv
函数,得到jvmtiEnv*
- 把
jvmtiEnv*
写入通过 Java Unsafe 库分配的内存里
最后 Java 读取 unsfe 内存块的数据,得到jvmtiEnv*
。shellcode 的执行可以用 《Java内存攻击技术漫谈》中提出的方式,所以主要难点是如何编写 shellcode。
3.1 shellcode 构造
近期太忙了,没有太多时间学习汇编了,所以只贴一些文章先码住:
- 完整 shellcode:https://paper.seebug.org/1945/#windows
- x86 查找
kernel32.dll
基址原理:https://xz.aliyun.com/t/10478 - x86 和 x64 查找
kernel32.dll
基址的代码:https://gist.github.com/CCCougar/fb7ce99bede829d1484d0ffc92bd3800
3.2 Java 11 执行 shellcode
《Java内存攻击技术漫谈》一文给出了具体的构造方式,核心在于 sun.tools.attach.WindowsVirtualMachine
类的 enqueue
函数,这个native函数可以动态加载 shellcode。在另一篇文章 《Java利用技巧——通过jsp加载Shellcode》 里,3gstudent 师傅
解释了很多构造的细节,还提供了 jsp 的构造方法,可以慢慢看。Windows 的汇编还是挺复杂度,又要分x86和x64,有空再补全,逃…
但是上述两篇文章都是基于Java 8 的,但在 Java 11 里是没有 WindowsVirtualMachine
这个类的,WindowsVirtualMachine
这个类定义在 tools.jar
里。文章里有说过通过手动构造 sun.tools.attach.WindowsVirtualMachine
和使用 ClassLoader
的 defineClass
方法防止没有系统没有引用 tools.jar
。但…… Java 11 也把 tools.jar 从 %JAVA_HOME%/lib
里删掉了,所以用 sun.tools.attach.WindowsVirtualMachine
会报错找不到 Native 符号。
虽然把类删了,但是我们明明可以在 Java 11 使用 agent 植入啊是吧。对比 jdk.attach 包下的类,发现虽然没了 WindowsVirtualMachine
,但是多了 VirtualMachineImpl
,查看发现了熟悉的 enqueue
函数,除了参数名完全一直,所以如果在Java 11 环境,只需要把 WindowsVirtualMachine
全换成 VirtualMachineImpl
即可。对比:
1 | //WindowsVirtualMachine |
根据 3gstudent 师傅文章构造新的项目,新建一个 sun.tools.attach.VirtualMachineImpl
类,用于存储和输入 shellcode:
1 | package sun.tools.attach; |
然后把类编译,得到 VirtualMachineImpl.class
,读取字节码转成 base64 字符串,当然只是测试也可以直接读取字节数组然后输入到 defineClass
也行。这个编码功能用 ClassToBase64 类实现:
1 | import java.io.*; |
运行main方法得到 Base64 字符串。手动复制下字符串,把字符串写入 LoadShellcode 类:
1 | import java.lang.reflect.Method; |
最后运行,可以弹出计算器。
3.3 组装shellcode,构造JPLISAgent 和 InstrumentationImpl
具体的代码师傅们都写了,我这里写一下思路:
- Java 代码通过 unsafe 获得一片地址空间,也就得到了这片空间的地址
- shellcode 最后会把获取到的
jvmtiEnv*
写入某片内存空间,地址由外部指定。指定的方式是:- 正常编写汇编,写入
jvmtiEnv*
的地址先随便填充 - 编译汇编得到机器码,找到那个填充的地址
- 根据填充的地址将机器码分成前后两份 $S_1$ 和 $S_2$
- 当使用时,将指定的地址进行小端序编码,补充到合法指针长度,得到 $A_1$
- 最后拼接三者:$S_1 + A_1 + S_2$,得到可以指定
jvmtiEnv*
返回地址的 shellcode
- 正常编写汇编,写入
- 把 shellcode 的
jvmtiEnv*
返回地址指向第一步开辟的空间 - Java 通过 3.2 的方式执行组装好的 shellcode
- Java 访问开辟的空间的指定位置就可以得到
jvmtiEnv*
了,当然也可以直接把空间开辟为 JPLISAgent 的构造空间,把jvmtiEnv*
返回到需要的位置 - 补充 JPLISAgent 其他信息,反射构造 InstrumentationImpl
- 反射调用 redefineClasses 方法修改指定类方法
4. 总结
相比 Linux 的构造,Windows 要复杂的多,最优越方式还是执行 shellcode 的构造方式。不管是 Linux 还是 Windows,核心都是获取 jvmtiEnv*
,于是也就都沿着
1 | libjvm.so/jvm.dll |
这一条路构造 InstrumentationImpl。
在本大节还介绍了通过 JNI 获取 jvmtiEnv*
的方法,这也是冰蝎使用的方法,只不过这样需要额外上传一个 so 或者 dll 文件,虽然不够感觉,但是实现起来比写 shellcode 兼容性、简易性和可修改性很好。
另外, rebeyond 师傅在《Java内存攻击技术漫谈》 还提出了Java跨平台任意Native代码执行的想法,使得 linux 也可以执行 shellcode,所以这使得Linux其实可以通过 Java 执行 shellcode 而不是替换 native 函数的方式获取 jvmtiEnv*
。