Servlet、Filter 和 Listener 都属于 Servlet 应用层;Valve 则是 Tomcat 自己的请求处理组件。它不出现在 Servlet 注册表中,而是挂在 Engine、Host、Context 或 Wrapper 的 Pipeline 上。
Valve 内存马最关键的问题不是“有没有 Valve”,而是:
1 2 3 4
| 挂在哪个 Container → 位于哪条 Pipeline → 前后连接哪些 Valve → 覆盖哪些请求
|
本文以 Tomcat 10.1 和 jakarta.servlet.* 为实验基线。示例只在响应中增加固定标记,不执行系统命令。
仅在本地或授权靶场验证。Pipeline 是 Tomcat 内部运行链,错误删除 Basic Valve 会直接破坏请求分发。
一、Pipeline 与 Valve 如何处理请求
Tomcat 将容器分成四层:

1 2 3 4
| Engine 一个 Catalina Service 的请求入口 └─ Host 一个虚拟主机 └─ Context 一个 Web 应用 └─ Wrapper 一个 Servlet
|
每个 Container 都有自己的 Pipeline。Pipeline 中可以有多个普通 Valve,末尾还有一个不可缺少的 Basic Valve:
1 2 3 4
| Pipeline → Custom Valve 1 → Custom Valve 2 → Basic Valve
|
Basic Valve 负责完成当前层的核心工作,并把请求推进到下一层。例如,Engine 的 Basic Valve 选择 Host,Host 的 Basic Valve 选择 Context,Context 的 Basic Valve 选择 Wrapper,Wrapper 的 Basic Valve 最终创建 FilterChain 并调用 Servlet。
一次典型请求会跨过四条 Pipeline:
1 2 3 4 5 6
| Engine Pipeline → Host Pipeline → Context Pipeline → Wrapper Pipeline → FilterChain → Servlet
|
所以“Valve 一定比所有组件更早执行”并不严谨。Engine Valve 确实早于 Host 和应用组件,Context Valve 则只在请求已经选中当前应用后执行;Wrapper Valve 更靠近目标 Servlet。挂载层级直接决定覆盖范围:
| 挂载位置 |
主要覆盖范围 |
| Engine |
当前 Engine 下的 Host 请求 |
| Host |
当前虚拟主机下的应用请求 |
| Context |
当前 Web 应用请求 |
| Wrapper |
发往某个 Servlet 的请求 |
自定义 Valve 通常继承 ValveBase,在 invoke() 中完成前置处理,再显式调用下一个 Valve:
1 2 3 4 5 6
| @Override public void invoke(Request request, Response response) throws IOException, ServletException { getNext().invoke(request, response); }
|
如果不调用 getNext().invoke(),请求会在此处终止,后面的 Container、Filter 和 Servlet 都不会执行。
二、受控靶场挂载 Context Valve
下面的 JSP 取得当前应用对应的 StandardContext,再向它的 Pipeline 添加一个 LabValve。当请求携带 X-Valve-Lab: trace 时,Valve 增加响应头,随后继续原请求链。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66
| <%@ page import="java.io.IOException" %> <%@ page import="java.lang.reflect.Field" %> <%@ page import="jakarta.servlet.ServletContext" %> <%@ page import="jakarta.servlet.ServletException" %> <%@ page import="org.apache.catalina.Valve" %> <%@ page import="org.apache.catalina.connector.Request" %> <%@ page import="org.apache.catalina.connector.Response" %> <%@ page import="org.apache.catalina.core.ApplicationContext" %> <%@ page import="org.apache.catalina.core.StandardContext" %> <%@ page import="org.apache.catalina.valves.ValveBase" %> <%@ page contentType="text/plain;charset=UTF-8" %>
<%! public static final class LabValve extends ValveBase { @Override public void invoke(Request request, Response response) throws IOException, ServletException {
if ("trace".equals(request.getHeader("X-Valve-Lab"))) { response.setHeader("X-Valve-Lab-Reached", "true"); }
Valve next = getNext(); if (next != null) { next.invoke(request, response); } } }
private Object readField(Object target, String name) throws Exception { Class<?> type = target.getClass(); while (type != null) { try { Field field = type.getDeclaredField(name); field.setAccessible(true); return field.get(target); } catch (NoSuchFieldException ignored) { type = type.getSuperclass(); } } throw new NoSuchFieldException(name); } %>
<% ServletContext facade = request.getServletContext(); ApplicationContext applicationContext = (ApplicationContext) readField(facade, "context"); StandardContext standardContext = (StandardContext) readField(applicationContext, "context");
boolean exists = false; for (Valve valve : standardContext.getPipeline().getValves()) { if (valve instanceof LabValve) { exists = true; break; } }
if (exists) { response.getWriter().println("ALREADY_EXISTS"); } else { standardContext.getPipeline().addValve(new LabValve()); response.getWriter().println("REGISTERED"); } %>
|
第一次访问 JSP 完成挂载:
1
| curl -i http://127.0.0.1:8080/app/valve-lab.jsp
|
当前请求已经进入 Context Pipeline,因此新 Valve 从下一次请求开始生效。随后访问应用内任意正常路径:
1 2 3
| curl -i \ -H "X-Valve-Lab: trace" \ http://127.0.0.1:8080/app/demo
|
预期响应中出现:
1
| X-Valve-Lab-Reached: true
|
去掉 Header 后,业务请求仍正常执行,但不会出现标记。这里没有新增 Servlet URL、FilterMap 或 Spring 路由;StandardContext → StandardPipeline → LabValve 这条引用链就是驻留位置。
Pipeline.addValve() 不只是修改链表。Tomcat 会为实现了 Contained 的 Valve 设置所属 Container;如果 Pipeline 已经运行,还会按生命周期启动新 Valve。因此,清点时要同时保存对象、顺序、所属容器和生命周期状态。
三、从 Engine 到 Wrapper 全量枚举
只检查当前 StandardContext 会漏掉 Host、Engine 和 Wrapper 层。可以先从当前 Context 向上找到 Engine,再递归遍历整个容器树:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36
| private static void printContainer( org.apache.catalina.Container container, String indent) {
org.apache.catalina.Pipeline pipeline = container.getPipeline(); org.apache.catalina.Valve basic = pipeline.getBasic();
System.out.println(indent + "container=" + container.getName() + ", type=" + container.getClass().getName());
for (org.apache.catalina.Valve valve : pipeline.getValves()) { Class<?> type = valve.getClass(); ProtectionDomain domain = type.getProtectionDomain();
System.out.println(indent + " valve=" + type.getName() + ", basic=" + (valve == basic) + ", async=" + valve.isAsyncSupported()); System.out.println(indent + " loader=" + type.getClassLoader()); System.out.println(indent + " source=" + (domain == null ? null : domain.getCodeSource())); }
for (org.apache.catalina.Container child : container.findChildren()) { printContainer(child, indent + " "); } }
org.apache.catalina.Container root = standardContext; while (root.getParent() != null) { root = root.getParent(); } printContainer(root, "");
|
代码需要导入 java.security.ProtectionDomain。这份结果应与 server.xml、context.xml、应用配置和同版本可信节点比较。
排查顺序可以固定为:
1 2 3 4 5 6
| Valve 属于哪个 Container → 在 Pipeline 中排第几 → 是否为 Basic Valve → 前后连接哪些 Valve → 类、ClassLoader 和 CodeSource 是否可信 → 配置与发布制品能否解释它
|
常见的 Tomcat 内置 Valve 包括各层 Basic Valve、ErrorReportValve、AccessLogValve、RemoteIpValve 和认证相关 Valve。名称正常不代表实例可信,同名类可能来自不同 ClassLoader,运行中的类也可能被 Agent 修改字节码。
还要确认枚举范围。上面的代码覆盖当前 Engine 的容器树;如果一个 JVM 配置了多个 Service 或 Engine,需要从 Tomcat Server 层分别取得每个 Engine,不能用当前应用代表整台 JVM。
四、按对象身份卸载并验证链路
Valve 没有名称映射,卸载时必须先确定所属 Pipeline 和目标对象身份。受控实验可以这样移除 LabValve:
1 2 3 4 5 6 7
| org.apache.catalina.Pipeline pipeline = standardContext.getPipeline();
for (org.apache.catalina.Valve valve : pipeline.getValves()) { if (valve instanceof LabValve) { pipeline.removeValve(valve); } }
|
绝不能删除 pipeline.getBasic() 返回的对象。Basic Valve 是当前 Container 的核心分发节点,删除后不是“少一道检查”,而是整条请求链无法进入下一层。
removeValve() 会解除链路,并按 Tomcat 生命周期处理 Valve;但并发请求可能已经进入旧链。生产操作前应先将节点摘流,避免正在处理的请求跨越 Pipeline 修改时点。
卸载后完成三项验证:
1 2 3
| getValves() 中不再存在目标对象 携带 X-Valve-Lab: trace 时响应头不再出现 普通请求、错误请求和异步请求仍能正常完成
|
只移除 Context Valve 不能证明其他层干净,还要重新枚举 Engine、Host 和 Wrapper。最终处置仍需修复初始代码执行入口,清除恶意 JSP、JAR、启动项或 Agent,并从可信制品重建节点;否则 Valve 可以再次挂回 Pipeline。