原理分析 | Servlet —— Tomcat 内存马
系列:Tomcat 内存马 —— 继 Filter 型之后,聊聊 Servlet 型的动态注入。
目录
- 一、Servlet 是如何注册和工作的
- 二、Filter 和 Servlet 的区别
- 三、回顾对比:Filter 型 vs Servlet 型
- 四、前置知识:Servlet 动态注册 API
- 五、直接调用 addServlet 的问题
- 六、Servlet 型内存马注入代码(反射绕过)
- 七、验证步骤
- 八、注意事项
- 九、总结
一、Servlet 是如何注册和工作的
先来看一个最基础的 Servlet 示例,路径:src/main/java/org/example/filter/EchoServlet.java
1 | package org.example.filter; |
关于 loadOnStartup(懒加载)
@WebServlet(value = "/echo", loadOnStartup = 1) 里面有个懒加载的知识点:
loadOnStartup默认是-1,启用懒加载模式- 规定
loadOnStartup >= 0时,Tomcat 启动时就会加载这个 Servlet - 懒加载模式下,只有第一次请求触发时才会初始化
init() - 关闭懒加载后效果和 Filter 一样,启动服务时就初始化
注意:动态注册的 Servlet 默认也是懒加载(首次访问时才调用
init()),但在内存马场景中,我们通常会在注入后立即主动访问一次后门路径来激活它,因此懒加载并不影响使用。
启动服务后可以看到初始化的打印:

访问 http://localhost:8080/echo?msg=你好:

关闭时触发销毁:

二、Filter 和 Servlet 的区别
Filter 和 Servlet 最大的区别在于:
- **Filter 是”中间拦截器”**,负责预处理和后处理,通过
chain.doFilter()继续传递请求,请求最终要到达 Servlet - **Servlet 是”终点处理器”**,负责生成最终的响应内容
设置 @WebServlet("/echo") 后,访问 /echo 就会由这个 Servlet 处理。
如果设置 @WebServlet("/*"),那么不管访问 /xxxx 什么路径都会由这个 Servlet 处理。比如访问 http://localhost:8080/asdasd?msg=aaa:

依然可以正确显示内容,并没有 404,因为符合通配符规则。
Servlet 的 doGet 方法里没有类似 chain.doFilter() 的结尾,因为 Servlet 就是整条链的终点:
1 | 请求 → Filter1.doFilter() → Filter2.doFilter() → Servlet.doGet() → 响应 |
Filter 和 Servlet 在 Java 继承结构上的区别
Filter实现的是javax.servlet.Filter接口Servlet继承的是javax.servlet.http.HttpServlet抽象类,而HttpServlet又实现了javax.servlet.Servlet接口
抽象类是 Java 中的一种特殊类,它不能被直接实例化(不能 new),必须被继承后才能使用。一句话总结:抽象类就是”不能直接用的模板类”,必须通过继承来补全它缺失的部分。
处理方法对比
| 对比项 | Filter | Servlet |
|---|---|---|
| 处理方法数量 | 1 个:doFilter() |
多个:doGet()、doPost()、doPut() 等 |
| 是否区分 HTTP 方法 | 不区分(需手动判断) | 自动区分,容器帮调度 |
| 内存马适配 | 无需关心方法 | 通常同时覆盖 GET 和 POST |
职责对比
| 对比维度 | Filter | Servlet |
|---|---|---|
| 职责 | 对请求/响应进行预处理或后处理 | 负责生成响应内容(业务逻辑) |
| 链式调用 | 多个 Filter 可形成过滤链,通过 chain.doFilter() 传递 |
单个 Servlet 处理一个请求,不存在链 |
| 拦截范围 | 可匹配多个 URL 模式(/*、/admin/*、*.jsp) |
通常映射到具体的路径(/echo) |
| 是否必须放行 | 必须调用 chain.doFilter() 才能继续 |
处理完毕后直接写回响应,流程结束 |
静态 web.xml 写法
在 src/main/webapp/WEB-INF/web.xml 中,于 <web-app> 标签内添加以下配置:
1 | <!-- 声明 Servlet:给 Servlet 起个内部名字,并指定它对应的 Java 类 --> |
写法和 Filter 的差不多,声明 + 映射两段配置。
三、回顾对比:Filter 型 vs Servlet 型
在上一篇中,通过反射操作 StandardContext,动态注入了一个恶意的 Filter,实现了无文件落地的后门。Filter 型内存马通过拦截所有 URL(/*)并检测特定参数来触发命令执行。
本篇介绍另一种同样常用的内存马形态:Servlet 型。它的核心思路是:动态创建一个恶意的 Servlet,并为其映射一个外部可访问的 URL 路径。由于 Servlet 是专门处理 HTTP 请求的组件,因此这种内存马更像是一个”隐藏的 API 接口”。
| 对比项 | Filter 型 | Servlet 型 |
|---|---|---|
| 注入位置 | 往 filterDefs、filterMaps、filterConfigs 塞数据 |
往 StandardContext 的 children(Wrapper)中添加 |
| 触发方式 | 任何匹配 /* 的请求,带 ?cmd= 参数时触发 |
访问固定的映射路径(如 /evil),可带参数也可不带 |
| 隐蔽性 | 较高,与正常业务混合 | 较低,独立路径容易被扫描发现 |
| 实现难度 | 需要反射操作三个集合(filterDefs、filterMaps、filterConfigs) | 可以直接使用标准 API,几乎无需反射 |
四、前置知识:Servlet 动态注册 API
这里有个关键差异:
- Filter 型:需要反射拿到
StandardContext,因为ServletContext接口没有提供操作 Filter 内部集合的 API。 - Servlet 型:也需要拿
StandardContext弄错了 , 后面有讲。
Servlet 3.0 引入了 ServletContext 接口的几个方法:
1 | public ServletRegistration.Dynamic addServlet(String servletName, Servlet servlet); |
| 重载方式 | 参数 | 实例化责任 | 内存马适用性 |
|---|---|---|---|
addServlet(String, Servlet) |
已创建好的 Servlet 实例 | 你自己 new 好,直接传入 | ✅ 常用,因为要用匿名内部类嵌入恶意逻辑 |
addServlet(String, Class<? extends Servlet>) |
Servlet 类的 Class 对象 | Tomcat 负责通过反射调用 newInstance() 创建实例 |
❌ 不常用,无法直接嵌入匿名内部类的命令执行逻辑 |
| 参数 | 含义 |
|---|---|
servletName |
Servlet 的内部唯一标识名称,相当于 web.xml 中的 <servlet-name> |
servlet |
已经实例化好的 Servlet 对象 |
servletClass |
Servlet 的 Class 对象,容器会通过反射自动创建实例 |
返回值:ServletRegistration.Dynamic 接口实例,用于进一步配置该 Servlet 的映射路径、初始化参数、加载顺序等。
ServletRegistration.Dynamic 提供了 addMapping(String... urlPatterns) 方法,用于为 Servlet 指定 URL 映射(等价于 web.xml 中 <servlet-mapping> 的 <url-pattern>)(弄错了 , 这样只能静态添加 , 动态注册仍要反射用StandardContext里的方法):
1 | ServletRegistration.Dynamic dynamic = servletContext.addServlet("evilServlet", evilServlet); |
五、直接调用 addServlet 的问题
如果不考虑限制,直接写一个 JSP 调用 addServlet:
1 | <%@ page contentType="text/html;charset=UTF-8" language="java" %> |
访问 http://localhost:8080/inject.jsp 后会直接报错:

报错原因:这是 Servlet 规范的限制——一旦 Web 应用启动完成,ServletContext 就进入”已初始化”状态,禁止再调用 addServlet() 动态注册。
addServlet() 方法内部会调用 checkState(),判断应用是否已完成初始化,如果已完成则抛出 IllegalStateException。
六、Servlet 型内存马注入代码(反射绕过)
解决思路
和 Filter 型一样,用反射绕过限制:拿到 StandardContext,然后往它的 children(一个 HashMap<String, Container>)里添加一个 StandardWrapper 对象,并配置映射。
完整注入代码 inject.jsp:
1 | <%@ page contentType="text/html;charset=UTF-8" language="java" %> |
代码结构梳理
整个注入流程可以拆成三步:
- 反射两层,从
ServletContext挖到StandardContext(和 Filter 型一模一样) - 匿名内部类定义恶意 Servlet,重写
doGet/doPost,嵌入命令执行逻辑 - StandardWrapper 包装 →
addChild注册 →addServletMappingDecoded映射路径
和 Filter 型对比,Servlet 型少了操作 filterMaps、filterConfigs 那一套,整体更简洁。
七、验证步骤
1. 准备环境:确保 Tomcat 正在运行,且存在一个 Web 应用(比如之前的 Filter 项目)。
**2. 上传 injectServlet.jsp**:将该 JSP 文件放到 Web 应用的根目录(如 webapp/injectServlet.jsp)。
3. 访问注入页面:浏览器访问 http://localhost:8080/injectServlet.jsp,页面显示”注入成功”。

4. 测试后门:访问 http://localhost:8080/evil?cmd=whoami,看到命令执行结果。

5. 验证无文件落地:删除 injectServlet.jsp 文件,再次访问 http://localhost:8080/injectServlet.jsp 显示 404:

再次访问 /evil?cmd=calc,仍然有效:

6. 重启验证:重启 Tomcat 后直接访问 /evil?cmd=whoami,显示 404 或首页,说明内存马已消失(内存已释放):

八、注意事项
1. 路径冲突问题
如果目标应用本身已经有一个名为 evilServlet 的 Servlet,或存在 /evil 的映射,addServlet 或 addMapping 会抛出异常。在实际渗透中,攻击者通常会使用随机字符串(如 UUID)作为名称和路径,避免冲突。
2. 与 Filter 型的适用场景
- Filter 型:适合”通杀”所有请求,隐蔽性好,但需要较多反射代码。
- Servlet 型:适合需要独立 API 接口的场景,实现简单,但路径固定,容易被扫描器发现。
3. 安全配置的影响
如果目标应用使用了 SecurityManager 或 Tomcat 的 security-constraint 限制了对特定路径的访问,注入的 Servlet 也可能受到限制。
九、总结
Servlet 型内存马整体比 Filter 型简单,核心流程:
- 反射两层拿
StandardContext(和 Filter 型相同) - 匿名内部类写恶意 Servlet,
doPost转发给doGet StandardWrapper包装实例 →addChild→addServletMappingDecoded完成注册
不需要操作三个集合,也不需要手动操作 filterConfigs,整体代码量少了不少。
但相比 Filter 型,隐蔽性较差:Filter 挂载在 /* 上,混在正常业务流量里;Servlet 需要独立路径,容易被安全扫描器或流量分析发现。
下一篇:Listener 型内存马——它无需映射任何 URL,却能监听所有请求,是目前隐蔽性最强的一种形式。
参考环境:Tomcat 9、JDK 8u65、Servlet 3.0+