Java Agent 内存马
Java Agent 内存马
- Java Agent 内存马
概述
一般来说,java 内存马主要可以分为两种形式:
- 创建如 controller、servlet、filter、valve 等 java web 组件,并通过如反射等形式进行注册或替换
- 通过 java agent 技术,修改一些关键类 (如 servlet) 的代码
这两种方式可以说各有优劣,对于第一种方式来说,虽然利用起来更为简单,但是需要依赖于具体组件,且由于注入的类位置比较明确且没有实体文件,所以比较容易检测出来。
而 Agent 型内存马,其真正修改的类位置并不固定,且被修改的类并不是纯粹的“内存”类,相对来说检测起来会更复杂一些。而这方面的技术也越来越多,从一开始的落地 Jar 命令执行命令注入,到 Self Attach,再到无文件落地,借助 shellcode 的 Agent 注入。相关的技术实现也越来越精彩。
我们用 java agent 的目标就是修改一些关键类, 正常情况下,java agent 在 JVM 中有两种加载形式:
- Agent_OnLoad:相当于 java 运行时,通过
-javaagent
参数加载指定的agent
。 - Agent_OnAttach:通过
VM.attach
方法,向指定的 java 进程中,注入agent
。
分析其代码会看到处理逻辑大同小异,主要流程就是创建 JPLISAgent 以及 java.lang.instrument.Instrumentation
实例。然后调用 agentMain
或者 preMain
进行处理。
我们注入的 agent
代码中所能拿到的 InstrumentationImpl
就是在上面的逻辑中创建的。
而作为攻击方,我们往往会使用 redefineClasses
或者 addTransform + retransform
的方式,去修改类。要了解这两种方式分别是怎样修改的需要分析 jvm 中类的加载流程。了解了底层逻辑,才能在攻防之中占据主动地位。
JVM 类加载流程
关于类的加载流程,可以从三个方面去入手:
- 正常的类加载流程
- 被
redefineClasses
后的类的加载流程 - 被
retransformClasses
后的类的加载流程
如下是 java 类的加载流程图(若图中有不准确的地方,欢迎指正),可结合图下面的文字阐述进行理解。
在 JVM(Java Virtual Machine)中,每个加载的 Java 类在内存中以 InstanceKlass 的形式存在。
InstanceKlass
是 HotSpot JVM 的内部类,用于表示一个具体的 Java 类。- 它包含了类的所有元数据信息,如类的名称、父类、实现的接口、字段(变量)、方法等。
InstanceKlass
使 JVM 能够在运行时有效地管理和操作类,例如进行方法调用、字段访问、继承关系检查等
Java Agent 是一种允许在类加载过程中对类字节码进行修改的技术,常用于性能监控、日志记录、代码注入等。
通过实现
java.lang.instrument.ClassFileTransformer
接口,可以拦截并修改类的字节码。不过需要注意的是,虽然我们可以在
ClassFileTransformer.transform
中能拿到并修改指定类的字修改后的字节码会被 JVM 使用, 但内存中默认情况下其实是不会保存 java 类的原始字节码的。JVM 在加载类时,会将字节码转换为内部的
InstanceKlass
结构,并不保留原始的字节码数据。这意味着一旦类被加载,原始的字节码信息不会存在于内存中,只存在于
InstanceKlass
中的元数据
正常的 java 类加载时,会从指定位置(一般也就是本地的 jar 包中)获取到类字节码,然后会经过 JvmtiClassFileLoadHookPoster 的转换后,得到最终的字节码。然后编译优化为对应的
InstanceKlass
JvmtiClassFileLoadHookPoster
中维护着一个 JvmtiEnv 链 ,我们所用到的java agent
技术中,当 agent 加载时,其实就是在这个JvmtiEnv
链上添加一个JvmtiEnv
节点,从而修改类的字节码,如 post_all_envs() 中所示。JvmtiEnv
实例中有个关键的变量:_env_local_storage
,这个变量所对应的类型是_JPLISEnvironment
,从中我们可以看到与之关联的JPLISAgent
。而这个
JPLISAgent
就是InstrumentationImpl
构造方法中的mNativeAgent
。从这个
_JPLISAgent
中我们也可找到对应的 instrumentation 实例,以及其要执行的方法: mTransform,也就是InstrumentationImpl
类中的 transform 方法。对于
JvmtiEnv
节点来说,具体的转换流程便是通过 callback 而实现的,具体的callback
方法便是eventHandlerClassFileLoadHook,从中我们可以看到这个回调函数便是在 transformClassFile 方法中调用的InstrumentationImpl
对象的transform
方法,这样便回到了我们熟知的java
代码中。
Java Instrumentation
Java Instrumentation | 素十八 (su18.org)
RASP技术 · 攻击Java Web应用-Java Web安全(javasec.org)
JDK 1.5 开始,Java新增了 Instrumentation ( Java Agent API )和 JVMTI ( JVM Tool Interface )功能,允许JVM在加载某个 class 文件之前对其字节码进行修改,同时也支持对已加载的 class (类字节码)进行重新加载( Retransform )。
开发者可以在一个普通 Java 程序(带有 main 函数的 Java 类)运行时,通过 –javaagent 参数指定一个特定的 jar 文件(包含 Instrumentation 代理)来启动 Instrumentation 的代理程序。在类的字节码载入 jvm 前会调用 ClassFileTransformer 的 transform 方法,从而实现修改原类方法的功能,实现 AOP 。
在字节码加载前进行注入,一般有两种写法,重写 ClassLoader 或利用 Instrumentation,而如果重写 ClassLoader,仍然对现有代码进行了修改,而 Instrumentation 则可以做到完全无侵入,利用这种特性,衍生出了诸多新型技术和产品,RASP(Runtime Application Self-Protection) 就是其中之一。
instrument 的底层实现依赖于 JVMTI ,也就是 JVM Tool Interface ,它是 JVM 暴露出来的一些供用户扩展的接口集合, JVMTI 是基于事件驱动的, JVM 每执行到一定的逻辑就会调用一些事件的回调接口(如果有的话),这些接口可以供开发者去扩展自己的逻辑。
JVMTIAgent 是一个利用 JVMTI 暴露出来的接口提供了代理启动时加载(agent on load)、代理通过 attach 形式加载(agent on attach)和代理卸载(agent on unload)功能的动态库。
而 instrument agent 可以理解为一类 JVMTIAgent 动态库,别名是 JPLISAgent (Java Programming Language Instrumentation Services Agent),也就是专门为 Java 语言编写的插桩服务提供支持的代理。
Java Agent
Java Agent 内存马学习 | Drunkbaby's Blog (drun1baby.top)
Java Agent(JVMTIAgent) 技术总体来说就是可以使用 Instrumentation 提供的 retransform 或 redefine 来动态修改 JVM 中 class 的一种字节码增强技术,可以直接理解为,这是 JVM 层面的一个拦截器。
redefineClasses
,顾名思义,重定义一个类,与普通的类加载流程相比,这里主要就是将类的来源更换为指定的字节码。具体的类加载流程并无太大差别。当 java 类要被
retransformClasses
转换时,会根据InstanceKlass
重新生成一份对应的类字节码,并存入缓存中InstanceKlass._cached_class_file
,下次再被retransformClasses
时将直接使用缓存中的类字节码。与正常的类加载流程相比,被
retransformClasses
所重新加载的类,不会再经过no retransformable jvmti
链的处理。java agent 在被加载时(onLoad / onAttach),jvm 将创建一个
jvmtiEnv
实例,对应了上图中的no retransformable jvmti 链
。- 当第一次添加
retransformer
(也就是在addTransformer
时指定canRetransform
为true
)时,会通过 setHasRetransformableTransformers 方法在 jvmti 链上追加一个新的节点,也就是上图中的retransformable jvmti 链
。 - 关于图中的
no retransformable jvmti
链 与retransformable jvmti
链,其实都是在一条链表上,只不过在使用时根据env->is_retransformable()
而分为两批使用。在类加载或是被重定义时,对我们在java agent
中添加的transformer
来说,普通的transformer
永远在canRetransform
为 true 的transformer
之前执行。
- 当第一次添加
我们知道Java是一种静态强类型语言,在运行之前必须将其编译成.class
字节码,然后再交给JVM处理运行。Java Agent 就是一种能在不影响正常编译的前提下,修改 Java 字节码,进而动态地修改已加载或未加载的类、属性和方法的技术。
实际上,平时较为常见的技术如热部署、一些诊断工具等都是基于Java Agent技术来实现的。
就Java Agent技术的具体实现而言, 对于 Agent(代理)来讲,其大致可以分为两种
- 一种是在 JVM 启动前通过
-javaagent
参数指定加载的premain-Agent
, 从而在 JVM 启动之前修改 class 内容 (自 JDK 1.5) - 另一种是 JVM 启动之后通过
VirtualMachine.attach()
方法加载的agentmain-Agent
, 将 agent 附加在启动后的 JVM 进程中, 进而动态修改 class 内容 (自 JDK 1.6)
两种方式分别需要实现 premain 和 agentmain 方法, 而这些方法又有如下四种签名:
public static void agentmain(String agentArgs, Instrumentation inst);
public static void agentmain(String agentArgs);
public static void premain(String agentArgs, Instrumentation inst);
public static void premain(String agentArgs);
其中带有
Instrumentation inst
参数的方法优先级更高, 会优先被调用
这里我们可以将其理解成一种特殊的 Interceptor(拦截器),如下图:
Premain-Agent
agentmain-Agent
:
Java Agent 实例
premain-Agent
Java 规定 Java Agent 程序必须要打包成 jar 格式,同时需要提供一个 MANIFEST.MF
文件来配置 Java Agent 的相关参数
从官方文档中可知晓,首先我们必须实现 premain 方法,同时我们 jar 文件的清单(mainfest)中必须要含有 Premain-Class 属性;
我们可在命令行利用 -javaagent 来实现启动时加载。
premain 方法顾名思义,会在我们运行 main 方法之前进行调用,即在运行 main 方法之前会先去调用我们 jar 包中 Premain-Class 类中的 premain 方法
我们首先来实现一个简单的 premain-Agent
,创建一个 Maven 项目
编写一个简单的 premain-Agent
,创建的类需要实现 premain 方法
package com.summery233;
import java.lang.instrument.Instrumentation;
public class JavaAgentPremain {
public static void premain(String args, Instrumentation inst) {
System.out.println("调用了premain-Agent!");
System.err.println("传入参数:" + args);
}
}
编辑 pom.xml
指定 Permain-Class
:
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-jar-plugin</artifactId>
<version>3.2.0</version>
<configuration>
<archive>
<manifestEntries>
<Premain-Class>com.summery233.JavaAgentPremain</Premain-Class>
</manifestEntries>
</archive>
</configuration>
</plugin>
</plugins>
</build>
MANIFEST.MF
文件是 JAR 文件中的一个特殊文件,用于存储有关 JAR 文件的元数据。它位于 JAR 文件的META-INF
目录中。MANIFEST.MF
文件可以包含各种属性,这些属性定义了 JAR 文件的行为和内容。
常见属性
:
Manifest-Version
:清单文件的版本。Created-By
:创建 JAR 文件的工具和版本。Main-Class
:指定 JAR 文件的主类(用于可执行 JAR 文件)。Premain-Class
:指定 Java 代理的预处理类(用于 Java 代理)
然后 mvn clean package
编译项目得到一个 jar 包, 可以先将其移出来:
接着创建一个目标类
public class Hello {
public static void main(String[] args) {
System.out.println("Hello World!");
}
}
mvn clean package
编译项目得到一个 jar 包, 可以先将其移出来
接下来我们只需要在 java -jar
中添加 -javaagent:agent.jar
即可在启动时优先加载 agent , 而且可利用如下方式获取传入我们的 agentArgs 参数
java -javaagent:.\permain-agent-demo-agent-0.1.jar=InputArgHello -jar .\permain-agent-demo-main-0.1.jar
.\permain-agent-demo-agent-0.1.jar=InputArgHello
:指定要加载的 Java 代理 JAR 文件为当前目录下的permain-agent-demo-agent-0.1.jar
,并传递参数InputArgHello
给代理。permain-agent-demo-agent-0.1.jar
:包含代理类的 JAR 文件。=InputArgHello
:传递给代理的参数,可以在代理的 premain 方法中使用。
-jar .\permain-agent-demo-main-0.1.jar
:指定要运行的 Java 应用程序 JAR 文件为当前目录下的permain-agent-demo-main-0.1.jar
或者直接编在一起也行, 毕竟入口点不一样:
java -javaagent:permain-agent-demo-0.1.jar=InputArgHello -jar permain-agent-demo-0.1.jar
agentmain-Agent
Java Agent 内存马学习 - 几种 Java Agent 实例 - agentmain-Agent | Drunkbaby's Blog (drun1baby.top)
相较于 premain-Agent 只能在 JVM 启动前加载,agentmain-Agent 能够在JVM启动之后加载并实现相应的修改字节码功能。
下面我们来了解一下和 JVM 有关的两个类。
VirtualMachine类
com.sun.tools.attach.VirtualMachine
类可以实现获取JVM信息,内存dump、现成dump、类信息统计(例如JVM加载的类)等功能。
该类允许我们通过给 attach 方法传入一个 JVM 的 PID,来远程连接到该 JVM 上 ,之后我们就可以对连接的 JVM 进行各种操作,如注入 Agent。下面是该类的主要方法
//允许我们传入一个JVM的PID,然后远程连接到该JVM上
VirtualMachine.attach()
//向JVM注册一个代理程序agent,在该agent的代理程序中会得到一个Instrumentation实例,该实例可以 在class加载前改变class的字节码,也可以在class加载后重新加载。在调用Instrumentation实例的方法时,这些方法会使用ClassFileTransformer接口中提供的方法进行处理
VirtualMachine.loadAgent()
//获得当前所有的JVM列表
VirtualMachine.list()
//解除与特定JVM的连接
VirtualMachine.detach()
每次执行一个 JAR 包时,都会启动一个独立的 Java 进程,每个进程对应一个独立的 JVM 实例。这意味着每个运行的 JAR 文件都有自己独立的内存空间和运行环境。
这里先编译一个可以一直保持运行状态的 jar 包
使用 jps 列出当前正在运行的 Java 虚拟机(JVM)进程
记下 16272
这个 PID
然后编写一个 Agent.jar
// Agent.java
import java.lang.instrument.Instrumentation;
public class Agent {
public static void agentmain(String agentArgs, Instrumentation inst) {
System.out.println("Agent 已加载");
// 添加你的代理逻辑
}
public static void premain(String agentArgs, Instrumentation inst) {
System.out.println("Agent 已启动");
// 添加你的代理逻辑
}
}
# MANIFEST.MF
Manifest-Version: 1.0
Premain-Class: Agent
Agent-Class: Agent
Can-Redefine-Classes: true
Can-Retransform-Classes: true
Can-Redefine-Classes: true
: 允许代理在运行时重新定义已加载的类通过
Instrumentation
接口,代理可以修改类的字节码,从而改变类的行为。这在调试、监控或热修复代码时非常有用Can-Retransform-Classes: true
: 允许代理在运行时重新转换已加载的类除了重新定义类,代理还可以添加或移除类文件的转换逻辑。这对于实现更复杂的字节码修改和动态功能增强非常有帮助。
# 编译 Agent 类
javac Agent.java
# 创建 JAR 文件
jar cmf MANIFEST.MF agent.jar Agent.class
编写 agent 加载代码:
import com.sun.tools.attach.VirtualMachine;
import com.sun.tools.attach.AttachNotSupportedException;
import com.sun.tools.attach.AgentLoadException;
import com.sun.tools.attach.AgentInitializationException;
import java.io.IOException;
public class VirtualMachineExample {
public static void main(String[] args) {
if (args.length != 2) {
System.out.println("用法: java VirtualMachineExample <PID> <AgentJarPath>");
return;
}
String pid = args[0];
String agentJarPath = args[1];
try {
// 连接到目标JVM
VirtualMachine vm = VirtualMachine.attach(pid);
// 加载Agent
vm.loadAgent(agentJarPath);
// 分离
vm.detach();
System.out.println("Agent 加载成功");
} catch (AttachNotSupportedException | IOException | AgentLoadException | AgentInitializationException e) {
e.printStackTrace();
}
}
}
编译与执行程序
# 编译代码
javac VirtualMachineExample.java
# 使用目标 JVM 的 PID 和 Agent JAR 的路径运行程序
java VirtualMachineExample <PID> <Agent.jar的绝对路径>
VirtualMachineDescriptor 类
com.sun.tools.attach.VirtualMachineDescriptor
类是一个用来描述特定虚拟机的类,其方法可以获取虚拟机的各种信息如PID、虚拟机名称等。下面是一个获取特定虚拟机PID的示例
import com.sun.tools.attach.VirtualMachine;
import com.sun.tools.attach.VirtualMachineDescriptor;
import java.util.List;
public class GetPID {
public static void main(String[] args) {
if (args.length != 1) {
System.out.println("请提供目标 JVM 名称作为参数。");
return;
}
String targetName = args[0];
List<VirtualMachineDescriptor> list = VirtualMachine.list();
boolean found = false;
for (VirtualMachineDescriptor vmd : list){
if(vmd.displayName().equals(targetName)) {
System.out.println("PID: " + vmd.id());
found = true;
break;
}
}
if (!found) {
System.out.println("未找到名称为 " + targetName + " 的 JVM。");
}
}
}
先用 jps -l
命令看下各个 JVM 进程 PID 和完整的主类或 JAR 文件路径
参数 | 显示内容 | 示例输出 |
---|---|---|
jps | PID 和简短的主类名称 | 16272 pending-hello-1.0-SNAPSHOT.jar |
jps -l | PID 和完整的主类或 JAR 文件路径 | 16152 c:\Users\Win10Pro\.vscode\extensions\... |
JVM 的名称指的就是启动 JVM 时指定的主类的完全限定名或所运行的 JAR 文件的完整路径。这些名称用于区分不同的 Java 进程并识别它们所运行的应用程序, 所以需要用 -l
参数给出完整输出而不能用 jps 给出的简短名称
动态修改字节码 Instrumentation
Java Agent 内存马学习 - 几种 Java Agent 实例 - 动态修改字节码 Instrumentation | Drunkbaby's Blog (drun1baby.top)
Java Agent 内存马|Java Agent|Instrumentation修改字节码 - X1r0z Blog (exp10it.io)
Instrumentation 是 Java Agent 提供给我们的用于修改 class 字节码的 API, 它的的具体使用可参考官方文档 java.instrument (Java SE 9 & JDK 9 ) (oracle.com)
如下是几个常用的方法:
// 获取已被 JVM 加载的所有 class
Class[] getAllLoadedClasses();
// 添加 transformer 用于拦截即将被加载或重加载的 class, canRetransform 参数用于指定能否利用该 transformer 重加载某个 class
void addTransformer(ClassFileTransformer transformer, boolean canRetransform);
// 重加载某个 class, 注意在重加载 class 的过程中, 之前设置的 transformer 会拦截该 class
void retransformClasses(Class<?>... classes);
添加的 transformer 必须要实现 ClassFileTransformer 接口:
public interface ClassFileTransformer {
byte[]
transform( ClassLoader loader,
String className,
Class<?> classBeingRedefined,
ProtectionDomain protectionDomain,
byte[] classfileBuffer)
throws IllegalClassFormatException;
}
className 是 JVM 形式的 class name, 例如
java.util.HashMap
在 JVM 中的形式为java/util/HashMap
(.
被替换成了/
)classfileBuffer 是原始的 class 字节码, 如果我们不想修改某个 class 就需要把这个变量原样返回
剩下的参数一般用不到
在实现 premain 的时候,我们除了能获取到 agentArgs 参数,还可以获取 Instrumentation 实例,那么 Instrumentation 实例是什么,在聊这个之前要先简单了解一下 Javassist
Javassist
Java 字节码以二进制的形式存储在 .class 文件中,每一个.class文件包含一个Java类或接口。Javaassist 就是一个用来处理Java字节码的类库。它可以在一个已经编译好的类中添加新的方法,或者是修改已有的方法,并且不需要对字节码方面有深入的了解。同时也可以通过手动的方式去生成一个新的类对象。其使用方式类似于反射。
Javassist 示例-Intrumentation-Transformer-agentmain
开一个 maven 项目写一个主类一个目标类
// TargetClass.java
package com.example.target;
public class TargetClass {
public void targetMethod() {
System.out.println("原始方法执行");
}
}
// Main.java
package com.example.target;
public class Main {
public static void main(String[] args) {
TargetClass tc = new TargetClass();
tc.targetMethod();
}
}
<!-- pom.xml -->
<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>com.example</groupId>
<artifactId>target-app</artifactId>
<version>1.0-SNAPSHOT</version>
<packaging>jar</packaging>
<build>
<plugins>
<!-- Maven Shade Plugin,用于打包可执行的 JAR -->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-shade-plugin</artifactId>
<version>3.2.4</version>
<executions>
<execution>
<phase>package</phase>
<goals>
<goal>shade</goal>
</goals>
<configuration>
<createDependencyReducedPom>false</createDependencyReducedPom>
<transformers>
<transformer implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
<mainClass>com.example.target.Main</mainClass>
</transformer>
</transformers>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>
然后开一个 maven 项目写 Agent
// Agent.java
package com.summery233.agent;
import java.lang.instrument.Instrumentation;
public class Agent {
public static void premain(String agentArgs, Instrumentation inst) {
System.out.println("Agent 启动");
inst.addTransformer(new MyTransformer());
}
}
// MyTransformer.java
package com.summery233.agent;
import java.lang.instrument.ClassFileTransformer;
import java.lang.instrument.IllegalClassFormatException;
import java.security.ProtectionDomain;
import javassist.*;
import java.io.IOException;
public class MyTransformer implements ClassFileTransformer {
@Override
public byte[] transform(ClassLoader loader, String className, Class<?> classBeingRedefined,
ProtectionDomain protectionDomain, byte[] classfileBuffer)
throws IllegalClassFormatException {
// 转换类名格式:com/example/target/TargetClass -> com.example.target.TargetClass
String transformedClassName = className.replace('/', '.');
String targetClassName = "com.example.target.TargetClass";
if (transformedClassName.equals(targetClassName)) {
try {
// 获取默认的 ClassPool
ClassPool classPool = ClassPool.getDefault();
// 绑定当前线程的类加载器
classPool.appendClassPath(new LoaderClassPath(loader));
// 获取目标类
CtClass ctClass = classPool.get(targetClassName);
// 获取目标方法
CtMethod ctMethod = ctClass.getDeclaredMethod("targetMethod");
// 在方法开头插入代码
ctMethod.insertBefore("{ System.out.println(\"方法开始执行\"); }");
// 返回字节码
byte[] byteCode = ctClass.toBytecode();
ctClass.detach();
return byteCode;
} catch (NotFoundException | CannotCompileException | IOException e) {
e.printStackTrace();
}
}
// 返回 null 不修改字节码
return null;
}
}
<!-- pom.xml -->
<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>com.summery233</groupId>
<artifactId>agent</artifactId>
<version>1.0-SNAPSHOT</version>
<packaging>jar</packaging>
<dependencies>
<!-- 引入 Javassist 库 -->
<dependency>
<groupId>org.javassist</groupId>
<artifactId>javassist</artifactId>
<version>3.29.0-GA</version>
</dependency>
</dependencies>
<build>
<plugins>
<!-- Maven Shade Plugin,用于打包所有依赖并生成正确的 MANIFEST.MF -->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-shade-plugin</artifactId>
<version>3.2.4</version>
<executions>
<execution>
<phase>package</phase>
<goals>
<goal>shade</goal>
</goals>
<configuration>
<!-- 打包所有依赖 -->
<createDependencyReducedPom>false</createDependencyReducedPom>
<transformers>
<!-- 指定 Premain-Class -->
<transformer implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
<manifestEntries>
<Premain-Class>com.summery233.agent.Agent</Premain-Class>
</manifestEntries>
</transformer>
</transformers>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>
分别打包两个 maven 项目得到两个 jar
java -javaagent:".\agent-1.0-SNAPSHOT.jar" -jar ".\target-app-1.0-SNAPSHOT.jar"
CtClass
CtClass
是 Javassist 库中的一个类,表示 Java 类的字节码结构, 可以从 ClassPool.get(ClassName)
中获取。它允许在运行时动态修改类的结构,例如添加或修改方法和字段。通过 CtClass
,开发者可以实现类的增强和字节码操作。
例如:
import javassist.*;
public class Example {
public static void main(String[] args) throws Exception {
ClassPool pool = ClassPool.getDefault();
CtClass cc = pool.get("com.example.MyClass");
// 添加一个新方法
CtMethod newMethod = CtNewMethod.make(
"public void newMethod() { System.out.println(\"新方法\"); }",
cc
);
cc.addMethod(newMethod);
// 加载修改后的类
Class<?> clazz = cc.toClass();
Object obj = clazz.newInstance();
clazz.getMethod("newMethod").invoke(obj);
}
}
在上面的示例中,CtClass
用于加载 com.example.MyClass
类,添加一个新方法 newMethod
,然后将修改后的类加载到 JVM 中并调用该方法。
ClassPool
ClassPool
是CtClass
对象的容器。CtClass
对象必须从该对象获得。如果get()
在此对象上调用,则它将搜索表示的各种源ClassPath
以查找类文件,然后创建一个CtClass
表示该类文件的对象。创建的对象将返回给调用者。可以将其理解为一个存放CtClass
对象的容器。
获得方法:
ClassPool cp = ClassPool.getDefault();
通过 ClassPool.getDefault()
获取的 ClassPool
使用 JVM 的类搜索路径。
如果程序运行在 JBoss 或者 Tomcat 等 Web 服务器上,ClassPool 可能无法找到用户的类,因为Web服务器使用多个类加载器作为系统类加载器。在这种情况下,ClassPool 必须添加额外的类搜索路径。
cp.insertClassPath(new ClassClassPath(<Class>));
CtMethod
同理,可以理解成加强版的Method
对象。可通过CtClass.getDeclaredMethod(MethodName)
获取,该类提供了一些方法以便我们能够直接修改方法体
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);
}
传递给方法 insertBefore()
,insertAfter()
和 insertAt()
的 String 对象是由Javassist
的编译器编译的。 由于编译器支持语言扩展,以 $ 开头的几个标识符有特殊的含义:
javassist示例-生成与写入类字节码
package com.summery233;
import java.lang.reflect.Modifier;
import javassist.ClassPool;
import javassist.CtClass;
import javassist.CtConstructor;
import javassist.CtField;
import javassist.CtMethod;
import javassist.CtNewMethod;
import java.nio.file.Path;
import java.nio.file.Paths;
public class Main {
public static void main(String[] args) {
System.out.println("Hello world!");
try {
Create_Person();
} catch (Exception e) {
System.out.println("使用javassist创建类失败,报错如下:");
e.printStackTrace();
}
}
public static void Create_Person() throws Exception {
// 获取 CtClass 对象的容器 ClassPool
ClassPool classPool = ClassPool.getDefault();
// 创建一个新类 Javassist.Learning.Person
CtClass ctClass = classPool.makeClass("javassist.Person");
// 创建一个类属性 name
CtField ctField1 = new CtField(classPool.get("java.lang.String"), "name", ctClass);
// 设置属性访问符
ctField1.setModifiers(Modifier.PRIVATE);
// 将 name 属性添加进 Person 中,并设置初始值为 Drunkbaby
ctClass.addField(ctField1, CtField.Initializer.constant("Drunkbaby"));
// 向 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 = \"Drunkbaby\";}");
// 向Person类中添加无参构造
ctClass.addConstructor(ctConstructor);
// 创建一个类方法printName
CtMethod ctMethod = new CtMethod(CtClass.voidType, "printName", new CtClass[] {}, ctClass);
// 设置方法访问符
ctMethod.setModifiers(Modifier.PRIVATE);
// 设置方法体
ctMethod.setBody("{System.out.println(name);}");
// 将该方法添加进Person中
ctClass.addMethod(ctMethod);
ctClass.writeFile("out");
}
}
<?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>com.summery233</groupId>
<artifactId>javassist-create-class</artifactId>
<version>1.0-SNAPSHOT</version>
<properties>
<maven.compiler.source>17</maven.compiler.source>
<maven.compiler.target>17</maven.compiler.target>
</properties>
<dependencies>
<dependency>
<groupId>org.javassist</groupId>
<artifactId>javassist</artifactId>
<version>3.27.0-GA</version>
</dependency>
</dependencies>
</project>
运行程序会在当前项目目录下生成 out/javassist
使用 javassist 生成恶意 class
在如下场景中, 我们的恶意类需要继承AbstractTranslet
类,并重写两个transform()
方法。否则编译无法通过,无法生成.class
文件。
AbstractTranslet
是 Apache Xalan 库中的一个抽象类,Xalan 是一个用于处理 XSLT 转换的库。改类提供了一些基础设施,用于在 XSLT 转换过程中执行特定的操作。- 在生成恶意类时,重写
transform()
方法可以插入恶意代码,使得在调用这些方法时执行攻击者指定的恶意操作。 - 如果不重写这些方法,编译器会认为类没有实现抽象方法,从而导致编译错误,无法生成有效的
.class
文件。
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;
import java.io.IOException;
public class shell extends AbstractTranslet {
public void transform(DOM document, SerializationHandler[] handlers) throws TransletException {
}
public void transform(DOM document, DTMAxisIterator iterator, SerializationHandler handler) throws TransletException {
}
public shell() throws IOException {
try {
Runtime.getRuntime().exec("calc");
} catch (Exception var2) {
var2.printStackTrace();
}
}
}
但是该恶意类在执行过程中并没有用到重写的方法,所以我们可以直接使用Javassist从字节码层面来生成恶意class,跳过恶意类的编译过程。代码如下。
package javassist;
import java.io.File;
import java.io.FileOutputStream;
public class EvilPayload {
public static byte[] getTemplatesImpl(String cmd) {
try {
ClassPool pool = ClassPool.getDefault();
CtClass ctClass = pool.makeClass("Evil");
CtClass superClass = pool.get("com.sun.org.apache.xalan.internal.xsltc.runtime.AbstractTranslet");
ctClass.setSuperclass(superClass);
CtConstructor constructor = ctClass.makeClassInitializer();
constructor.setBody(" try {\n" +
" Runtime.getRuntime().exec(\"" + cmd +
"\");\n" +
" } catch (Exception ignored) {\n" +
" }");
byte[] bytes = ctClass.toBytecode();
ctClass.defrost();
return bytes;
} catch (Exception e) {
e.printStackTrace();
return new byte[]{};
}
}
public static void writeShell() throws Exception {
byte[] shell = EvilPayload.getTemplatesImpl("Calc");
FileOutputStream fileOutputStream = new FileOutputStream(new File("S"));
fileOutputStream.write(shell);
}
public static void main(String[] args) throws Exception {
writeShell();
}
}
生成的恶意文件被我们输出到了 S
这个文件中,其实很多反序列化在用的时候,是没有把这个字节码提取保存出来,本质上还是可以保存的。
保存出来的文件代码如下
//
// 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 进行交互,从而达到修改数据的效果。
其在 Java 中是一个接口,常用方法如下
public interface Instrumentation {
//增加一个Class 文件的转换器,转换器用于改变 Class 二进制流的数据,参数 canRetransform 设置是否允许重新转换。
void addTransformer(ClassFileTransformer transformer, boolean 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();
//获取一个对象的大小
long getObjectSize(Object objectToSize);
}
ClassFileTransformer
转换类文件,该接口下只有一个方法:transform,重写该方法即可转换任意类文件,并返回新的被取代的类文件,在 java agent 内存马中便是在该方法下重写恶意代码,从而修改原有类文件代码逻辑,与 addTransformer 搭配使用。
//增加一个Class 文件的转换器,转换器用于改变 Class 二进制流的数据,参数 canRetransform 设置是否允许重新转换。
void addTransformer(ClassFileTransformer transformer, boolean canRetransform);
Instrumentation的局限性
Java Agent 内存马学习-几种Java Agent 实例-Instrumentation的局限性 | Drunkbaby's Blog (drun1baby.top)
大多数情况下,我们使用 Instrumentation 都是使用其字节码插桩的功能,简单来说就是类重定义功能(Class Redefine),但是有以下局限性:
` premain 和 agentmain 两种方式修改字节码的时机都是类文件加载之后,也就是说必须要带有 Class 类型的参数,不能通过字节码文件和自定义的类名重新定义一个本来不存在的类。
类的字节码修改称为类转换 (Class Transform),类转换其实最终都回归到类重定义
Instrumentation#redefineClasses
方法,此方法有以下限制:- 新类和老类的父类必须相同
- 新类和老类实现的接口数也要相同,并且是相同的接口
- 新类和老类访问符必须一致。 新类和老类字段数和字段名要一致
- 新类和老类新增或删除的方法必须是 private static/final 修饰的
- 可以修改方法体
Agent 内存马实现思路
对于 agent 型内存马来说,其主要目的就是修改一些关键类的字节码。总的来说有两种方式:
- 借助 redefineClasses 方法去重定义指定的类。参考类转换流程图中的
Redefine Class
路线。 - 借助 retransformClasses方法,让指定的类重新转换,当然在执行此方法前,需要先用
addTransform
方法添加一个 "reTransformer",从而在对应类重新转换时,用自己刚才添加的transformer
修改对应的类。参考类转换流程图中的RetransformClasses
路线。
当然,具体到实现上,有最基础的,上传一个 agent.jar
到受害者服务器,然后再 loadAgent
从而获取 Instrumentation
对象。之后便可以通过 redefineClasses
或者retransformClasses
修改关键类。
也有比较复杂的,如 冰蝎的 借助 shellcode 组装出一个 JPLISAgent
,从而构造出 Instrumentation
对象。再通过 redefineClasses
修改 javax.servlet.http.HttpServlet
。参考: 论如何优雅的注入 Java Agent 内存马。
这两者之间更多的体现在Instrumentation
对象的构造方式不同,冰蝎的这种方式不依赖于 jvm attach
也不需要在本地上传 jar
包,会更加隐蔽。不过单从修改类的方式来说,都可以归为这两种方式: redefineClasses
以及 retransformClasses
。
Java Agent 内存马实现
这里直接来看一下内存马的实现。
需要注意的是, 和之前做的 Tomcat Servlet/Listener/Filter 这些内存马不同, Agent 内存马主要是获取到目标主机权限后做权限维持用的
首先是冰蝎作者 rebeyond 师傅,他的项目 rebeyond/memShell: a webshell resides in the memory of java web server (github.com) 提出了这种想法,在这个项目中,他 hook 了 Tomcat 的 ApplicationFilterChain 的 internalDoFilter
方法。
使用 javassist 在其中插入了自己的判断逻辑,也就是项目的 ReadMe 中 usage 中提供的一些逻辑,
也就是说在 Tomcat 调用 ApplicationFilterChain 对请求调用 filter 链处理之前加入恶意逻辑。
agent 端在 net/rebeyond/behinder/resource/tools
中,应该是根据不同的类型会上传不同的注入包。
但是这次不再 Hook Tomcat 的方法,而是选择 Hook 了 Servlet-API 中更具有通用性的 javax.servlet.http.HttpServlet
的 service
方法,如果检测出是 Weblogic,则选择 Hook weblogic.servlet.internal.ServletStubImpl
方法。
使用插桩技术的 RASP(Runtime Application Self-Protection)、IAST(Interactive Application Security Testing) 的使用者一下就可以明白:如果都能做到这一步了,能玩的就太多了。能下的 Hook 点太多,能玩的姿势也太多了。
比如,在 memshell-inject 项目中,su18 师傅模仿冰蝎的实现方式,hook 了 HttpServletRequest 实现类的 getQueryString
方法,在方法返回时修改返回内容进行测试。
TODO: 这部分没有完全看懂, 没复现成功
Agent 内存马检测思路
想要检测某些关键类是否被修改,必须要设法从内存中获取到对应的类。一般来说,能走的也只有两条路:
- 直接解析 jvm 内存,从中 dump 出一些关键类,参考 CLSHDB。不过这种方式非常复杂,类字节码并不是原原本本的存在内存中的,而是经过了编译 优化,且不同版本的 jdk 实现细节也不一样,内存中相关区域也可能会经常更新,所以很少有人会选择使用这种方式
- 同样的借助 java agent 技术,添加自己的 "reTransformer",并在关键类加载(或是主动对其
retransformClasses
)时,拿到该类真实的字节码进行检测。
就 java agent
技术来说,防守方有两种使用方式:
防护模式:在 java 应用运行时,便加载一个
java agent
,并添加自己的检测reTransformer
,每当关键类加载(或重新加载)时,可以检测该类的字节码是否有异常在“防护模式”下,防守方占据了先手,可以做到更多,比如监控
addTransformer
、监控retransformClasses
、监控redefineClasses
方法等。临时检测模式:对于正常运行的可能被植入内存马的 java 应用,通过如 VirtualMachine.attach 的方式,加载自己的
java agent
,添加一个临时的reTransformer
,进而获取到指定类字节码。
在攻击者占据先手的情况下,攻击者也可能会采用一些方式来阻止防御方 Agent 的加载。例如通过删除 /tmp/.java_pid<pid>
文件,来阻止 JVM 进程通信,从而使防御方的 Agent 无法加载; 通过阻止后续 ClassFileTransformer
加载的方式,避免被后续的 Java Agent
检测等。不过这些方式在阻止了防御方 Agent 加载的同时,基本上也可以认为正式的暴露了自己。
防护模式
防护模式下,相当于防守方在 retransforrmable jvmti
链上添加了自己的“检测模块”,每当类重新定义时,检测模块可检测类的字节码是否被恶意修改。
当攻击者通过 redefineClasses
修改关键类时,如下图中的红色路径所示,被重新定义的类会经过防守方的“检测模块”,从而被检测到该类被植入恶意代码。
当攻击者先添加自己的 reTransfomer
后,再通过 retransformClasses
修改指定类时,如下图所示,因为攻击者的 agent
是在防御者之后注入的,所以其修改类字节码的逻辑(攻击模块)在防御者的“检测模块”之后加载。这种情况下,虽然防守方可以感知到类被重新加载了,但是却无法拿到被攻击者修改之后的类字节码。
除了这些外,在防护模式下,只要有类被重定义或是重新转换,都可以被防护模式自己的 agent 感知到,正如 transform 方法中的 classBeingRedefined
参数,而在一个正常运行的应用中,几乎不会有这种情况。所以说,即便防御方事前不知道攻击者将要修改的类,也可以通过这种方式发现某个类被修改了,进而去检测。
临时检测模式
这种情况下,往往是攻击方先植入内存马,防御方需要检测关键类是否被修改的情况。
当攻击者选择添加一个 reTransformer
,然后再 retransformClasses
使指定的类重新加载时,可以参考上面”防护模式“中的第二张图,只不过这次攻守易位,”检测模块“会在”攻击模块“之后加载,所以可正常检测到对应的类被攻击者修改。
但当攻击者使用 redefineClasses
重定义类时,而防御方再检测时,会变的有些不一样。
回到原来的图,可以看到 retransformClasses
的转换路线中,有个非常关键的概念:“缓存字节码”,也就是 _cached_class_file
。这是个东西有什么用呢?
当防御方通过 retransformClasses
重新加载类时,JVM 会先判断对应类是否有缓存,若没有缓存,则会根据当前类生成对应的类字节码,而这个类字节码其实就是攻击者通过 redefineClasseses
所传入的恶意类字节码。也就是,这种情况下,防御方是可以检测到关键类被攻击者修改了。
但是,如果此时该类是缓存的,则会直接使用缓存字节码,而缓存字节码是在第一次被reTransformer
修改时,才会生成(注意,这里“修改”的意思是,只要在 transform
方法中,没有返回 null
,就认为该类被转换为新类),这里可参考关键代码 parseClassFile.cpp 以及 jvmtiExport.cpp。所以,如果在攻击者用 redefineClasses
重定义关键类之前,对应的类已经有了缓存字节码,此时,防御者再用 retransformClasses
时,会直接使攻击者的修改失效,达到“清除内存马”的效果。但是,这也就意味着,此时防御者也就无法知道这个类之前是被攻击者修改过的了。
TODO: Agent 内存马的攻防之道 - 先知社区 (aliyun.com) 作者研究的比较深入, 我没能完全吃完, 重点还是放在内存马的实现上, 后续再回来看攻防对抗
TODOLIST
- OneTab - Shared tabs (one-tab.com)
- OneTab - Shared tabs (one-tab.com)
- [03.Java Agent 内存马 · d4m1ts 知识库 (gm7.org)](https://blog.gm7.org/个人知识库/02.代码审计/01.java安全/05.内存马/03.Java Agent 内存马.html)
- Spring Java Agent 内存马
- 论如何优雅的注入Java Agent内存马 (qq.com)