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) { }
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 或重启后异常对象是否再次出现。
生产环境不应把在线删除当作最终恢复。并发请求可能仍持有旧实例,植入入口也可能尚未清除。更可靠的处置方式是先保存证据和隔离节点,再修复入口、替换可信制品并重建实例,最后重新核对请求链。