thumbnail
Java 内存马(七):Tomcat Valve 内存马

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 将容器分成四层:

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 {
// 当前 Valve 的处理逻辑
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。

上一篇
下一篇