Java 内存马(二):HTTP 请求链与组件生命周期

Java 内存马要处理 HTTP 请求,必须进入服务器真实执行链。Servlet、Filter 和 Listener 名字相似,触发方式却完全不同:Servlet 是请求终点,Filter 包围请求处理过程,Listener 等待特定事件发生。

先看懂这条链,才能判断一个运行时对象是否可以接收请求、为什么一直存活,以及清除时应该断开哪段引用。

本文实验只增加响应头和日志,不读取命令参数,不执行系统命令。

一、一次请求经过了什么

以传统 Java Web 应用为例,请求进入 Tomcat 后大致经过:

1
2
3
4
5
6
7
8
客户端
→ Connector 接收连接
→ Tomcat 容器处理链
→ Web 应用 Context
→ 匹配 URL 和 DispatcherType
→ FilterChain
→ Servlet
→ 业务代码

三个 Servlet 规范组件的位置不同:

组件 触发方式 能否直接处理请求 常见持有位置
Servlet URL 映射命中 可以 Web 应用注册表
Filter URL 或 Servlet Name 映射命中 可以拦截、放行或提前返回 Filter 注册表与过滤链
Listener Context、Request、Session 等事件发生 取决于监听的事件 Web 应用监听器集合

Listener 不是固定处于 Filter 前面或后面。ServletContextListener 只关心应用启动与销毁,ServletRequestListener 才会在请求创建和销毁时回调,HttpSessionListener 处理的是会话事件。

如果应用使用 Spring MVC,请求链还会继续细分:

1
2
3
4
5
FilterChain
→ DispatcherServlet
→ HandlerMapping
→ HandlerInterceptor
→ Controller

因此,Spring Controller、Servlet Filter 和 Tomcat Valve 不是同一种入口。它们位于不同层,由不同对象保存,也需要用不同方式枚举和清除。

二、用最小实验看见 FilterChain

下面使用 Jakarta Servlet API,适用于 Tomcat 10 及之后的版本。Tomcat 9 及更早版本通常使用 javax.servlet.*,替换包名即可,不能在同一个应用中混用两套 API。

先创建一个只增加响应头的 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
package lab;

import jakarta.servlet.Filter;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.ServletRequest;
import jakarta.servlet.ServletResponse;
import jakarta.servlet.annotation.WebFilter;
import jakarta.servlet.http.HttpServletResponse;

import java.io.IOException;

@WebFilter("/*")
public final class TraceFilter implements Filter {
@Override
public void doFilter(
ServletRequest request,
ServletResponse response,
FilterChain chain) throws IOException, ServletException {

if (response instanceof HttpServletResponse httpResponse) {
httpResponse.setHeader("X-Request-Chain", "filter-reached");
}

chain.doFilter(request, response);
}
}

再创建请求终点:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
package lab;

import jakarta.servlet.annotation.WebServlet;
import jakarta.servlet.http.HttpServlet;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;

import java.io.IOException;

@WebServlet("/demo")
public final class DemoServlet extends HttpServlet {
@Override
protected void doGet(
HttpServletRequest request,
HttpServletResponse response) throws IOException {

response.setContentType("text/plain;charset=UTF-8");
response.getWriter().println("DEMO_OK");
}
}

部署应用后访问:

1
curl -i http://127.0.0.1:8080/app/demo

正常结果同时包含:

1
2
3
4
HTTP/1.1 200
X-Request-Chain: filter-reached

DEMO_OK

响应头来自 Filter,响应体来自 Servlet,证明一次请求先进入 Filter,再由 chain.doFilter() 交给后续组件。若 Filter 不再调用 chain.doFilter(),后面的 Servlet 就不会执行;内存马也正可能利用这个位置读取请求、修改响应或提前截断业务链。

三、组件为什么能够持续工作

Servlet 组件不是每次请求临时创建。应用启动时,容器完成类发现、实例创建、初始化和映射装配;请求到达后复用这些对象;应用停止时再执行销毁流程。

1
2
3
4
5
6
7
发现组件
→ 创建实例
→ 注册名称与映射
→ init()
→ 处理多次请求
→ destroy()
→ 解除引用

容器长期持有组件,解释了两个关键现象:

  • 局部注入代码执行结束后,已注册对象仍然可以继续工作;
  • 只删除创建对象时使用的临时文件,不会自动移除容器中的实例和映射。

标准 Servlet API 允许在应用初始化阶段动态注册组件。例如 ServletContainerInitializer.onStartup() 中可以调用:

1
2
3
4
5
6
7
FilterRegistration.Dynamic registration =
servletContext.addFilter("traceFilter", new TraceFilter());

registration.addMappingForUrlPatterns(
EnumSet.of(DispatcherType.REQUEST),
false,
"/*");

上下文初始化完成后,标准动态注册窗口通常已经关闭,再调用 addFilter()、addServlet() 等方法会抛出 IllegalStateException。运行期注入之所以依赖 Tomcat、Jetty 或 Undertow 的内部对象,就是为了绕过标准 API 的生命周期限制,直接修改已经运行的注册表或处理链。

一次外部请求也不一定只进入一次 Filter:

DispatcherType 场景 是否进入 Filter
REQUEST 客户端首次请求 取决于 REQUEST 映射
FORWARD 服务端转发 取决于 FORWARD 映射
ERROR 错误页派发 取决于 ERROR 映射
ASYNC 异步重新派发 取决于 ASYNC 映射及异步配置

所以判断触发范围不能只看 URL。映射对象、DispatcherType、组件顺序和异步支持共同决定它何时进入执行链。

四、从请求链定位异常组件

应用内部可以先读取 Servlet 规范公开注册表:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
ServletContext context = request.getServletContext();

context.getServletRegistrations().forEach((name, registration) ->
System.out.printf(
"servlet=%s, class=%s, mappings=%s%n",
name,
registration.getClassName(),
registration.getMappings()));

context.getFilterRegistrations().forEach((name, registration) ->
System.out.printf(
"filter=%s, class=%s, urls=%s, servlets=%s%n",
name,
registration.getClassName(),
registration.getUrlPatternMappings(),
registration.getServletNameMappings()));

排查时不要只搜索 Runtime.exec() 或可疑类名,应按执行关系核对:

1
2
3
4
5
6
注册了什么组件
→ 映射到哪些请求
→ 位于执行链什么位置
→ 实际实例由谁持有
→ 类由哪个 ClassLoader 加载
→ 能否由当前发布制品解释

公开注册表只能看到 Servlet 规范层,无法覆盖 Tomcat Valve、Jetty Handler、Undertow HttpHandler、Spring 动态路由或 JVM 字节码修改。即使注册名称和类名正常,运行中的类也可能已经被替换或重转换,因此最终仍要结合实例、类来源和字节码基线判断。

至此可以把 Servlet 请求型内存马抽象成一条完整关系:

1
2
3
4
5
6
7
8
请求匹配
→ 进入组件链
→ 命中触发条件
→ 执行异常逻辑

容器上下文
→ 持有注册信息和实例
→ 组件持续驻留

只看到可疑对象不等于它能够处理请求;只删除映射也不等于实例、植入入口和复活机制已经消失。请求链决定触发面,持有关系决定生命周期,两者必须一起分析。

上一篇
下一篇