Servlet 通常需要绑定一个明确路径,Filter 却可以覆盖一组 URL、某个 Servlet,甚至只在 ERROR 或 ASYNC 派发时触发。它位于业务 Servlet 之前,既能读取请求,也能决定是否继续调用后续链路。
Filter 内存马真正修改的不是一个对象,而是 Tomcat 中相互关联的三类状态:
1 2 3
| FilterDef 定义 Filter 是谁 FilterMap 决定哪些请求进入它以及执行顺序 ApplicationFilterConfig 持有初始化后的 Filter 实例
|
可以把它们理解成三张相互关联的表:FilterDef 是名单,FilterMap 是路由规则,ApplicationFilterConfig 是已经开始工作的实例。名单里有名字但没有路由,请求进不来;有路由但实例没有初始化,执行时就会失败。
本文以 Tomcat 10.1 和 jakarta.servlet.* 为实验基线。示例只识别固定请求头并增加响应标记,不执行系统命令。
仅在本地或授权靶场验证。filterStart() 会操作当前应用的 Filter 初始化状态,不应直接用于生产排查。
一、Filter 如何进入请求链
Tomcat 完成 URL 到 Servlet 的映射后,StandardWrapperValve 会根据当前请求构造 ApplicationFilterChain:
1 2 3 4 5 6
| 请求 → URL 映射到 Wrapper → 根据 URL、Servlet Name、DispatcherType 匹配 FilterMap → 按映射顺序加入 ApplicationFilterChain → Filter.doFilter() → Servlet.service()
|

Filter 的代码通常分成请求前、后续链和响应后三段:
1 2 3 4 5 6 7 8 9
| public void doFilter( ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
chain.doFilter(request, response); }
|
如果不调用 chain.doFilter(),请求会在当前 Filter 截止,后续 Filter 和 Servlet 都不会执行。内存马可以只在特定条件下截断链路,其他请求继续放行,因此普通业务访问未必出现异常。
匹配范围由四项共同决定:
| 条件 |
作用 |
| URL Pattern |
匹配 /api/*、/* 等路径 |
| Servlet Name |
只拦截发往指定 Servlet 的请求 |
| DispatcherType |
区分 REQUEST、FORWARD、ERROR、ASYNC 等派发 |
| FilterMap 顺序 |
决定 Filter 在链中的先后位置 |
addFilterMapBefore() 会把映射放到普通描述符映射之前,addFilterMap() 则追加到相应映射区域。分析 Filter 时只看 /* 不够,顺序和 DispatcherType 同样决定能否触发。
换句话说,Filter 比 Servlet 多了一层“链路顺序”:同一个请求可以经过多个 Filter,先执行谁会直接影响认证、日志和响应处理。异常 Filter 如果排在鉴权组件之前,就可能在安全检查发生前接触请求。
二、受控靶场完成运行时注册
下面的 JSP 取得当前应用的 StandardContext,动态注册名为 filterLab 的 Filter。只有请求头 X-Filter-Lab: trace 命中时,它才增加 X-Filter-Lab-Reached: true,随后继续原请求链。
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 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93
| <%@ page import="java.io.IOException" %> <%@ page import="java.lang.reflect.Field" %> <%@ page import="jakarta.servlet.*" %> <%@ page import="jakarta.servlet.http.HttpServletRequest" %> <%@ page import="jakarta.servlet.http.HttpServletResponse" %> <%@ page import="org.apache.catalina.core.ApplicationContext" %> <%@ page import="org.apache.catalina.core.StandardContext" %> <%@ page import="org.apache.tomcat.util.descriptor.web.FilterDef" %> <%@ page import="org.apache.tomcat.util.descriptor.web.FilterMap" %> <%@ 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 filterName = "filterLab";
ServletContext facade = request.getServletContext(); ApplicationContext applicationContext = (ApplicationContext) readField(facade, "context"); StandardContext standardContext = (StandardContext) readField(applicationContext, "context");
if (standardContext.findFilterDef(filterName) != null) { response.getWriter().println("ALREADY_EXISTS"); return; }
Filter filter = new Filter() { @Override public void doFilter( ServletRequest req, ServletResponse resp, FilterChain chain) throws IOException, ServletException {
HttpServletRequest httpReq = (HttpServletRequest) req; HttpServletResponse httpResp = (HttpServletResponse) resp;
if ("trace".equals(httpReq.getHeader("X-Filter-Lab"))) { httpResp.setHeader("X-Filter-Lab-Reached", "true"); } chain.doFilter(req, resp); } };
FilterDef def = new FilterDef(); def.setFilterName(filterName); def.setFilterClass(filter.getClass().getName()); def.setFilter(filter); def.setAsyncSupported("true");
FilterMap map = new FilterMap(); map.setFilterName(filterName); map.addURLPattern("/*"); map.setDispatcher(DispatcherType.REQUEST.name());
boolean defAdded = false; boolean mapAdded = false; try { standardContext.addFilterDef(def); defAdded = true;
standardContext.addFilterMapBefore(map); mapAdded = true;
if (!standardContext.filterStart()) { throw new ServletException("filterStart failed"); }
response.getWriter().println("REGISTERED " + filterName); } catch (Exception e) { if (mapAdded) { standardContext.removeFilterMap(map); } if (defAdded) { standardContext.removeFilterDef(def); } throw e; } %>
|
先访问该 JSP 完成注册,再对应用中任意正常路径发送请求:
1 2
| curl -i -H "X-Filter-Lab: trace" \ http://127.0.0.1:8080/app/demo
|
响应中应出现:
1
| X-Filter-Lab-Reached: true
|
去掉请求头后业务响应仍然正常,但不会出现标记。这验证了三个事实:Filter 已进入真实请求链,条件可以控制异常分支,未命中条件时可以继续执行原有业务。
这也是 Filter 内存马不容易通过功能测试发现的原因:它不需要占用一个新 URL,只要混入已有请求链,并在绝大多数请求中调用 chain.doFilter(),应用表面上就能继续正常工作。
三、从三类状态定位异常 Filter
Tomcat 清点不能只调用 ServletContext.getFilterRegistrations()。公开接口适合获得名称和映射,但内部三态能更直接地反映当前运行状态。
排查目标很明确:先找“登记了谁”,再看“拦截哪些请求”,最后确认“内存里实际运行的是哪个对象”。下面的代码正好对应这三个问题。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| for (FilterDef def : standardContext.findFilterDefs()) { Object config = standardContext.findFilterConfig(def.getFilterName());
System.out.println("name=" + def.getFilterName() + ", declaredClass=" + def.getFilterClass() + ", instanceClass=" + (def.getFilter() == null ? "<not stored in FilterDef>" : def.getFilter().getClass().getName()) + ", config=" + (config == null ? "<not initialized>" : config.getClass().getName())); }
for (FilterMap map : standardContext.findFilterMaps()) { System.out.println("name=" + map.getFilterName() + ", urls=" + java.util.Arrays.toString(map.getURLPatterns()) + ", servlets=" + java.util.Arrays.toString(map.getServletNames()) + ", dispatcherMask=" + map.getDispatcherMapping()); }
|
输出后按以下关系逐项核对:
1 2 3 4 5 6
| FilterDef 是否存在 → FilterMap 是否指向同名定义 → URL、Servlet Name 和 DispatcherType 是否合理 → ApplicationFilterConfig 是否已初始化 → 实际类和 ClassLoader 来自哪里 → 发布制品能否解释这组运行时状态
|
重点异常包括:
- 运行时存在,但
web.xml、注解和发布制品中没有对应定义;
- 映射范围突然扩大为
/*,或顺序被插到安全组件之前;
- 只绑定
ERROR、ASYNC 等不易在普通请求中触发的派发类型;
- 类名看似正常,但 ClassLoader、CodeSource 或实际字节码与可信节点不同;
- FilterDef、FilterMap、ApplicationFilterConfig 之间出现孤立或同名冲突。
名称中出现 shell 不是判断标准,随机名称也不必然恶意。可靠结论来自运行时状态、发布基线和请求行为三者的交叉验证。
四、精确卸载与恢复验证
卸载顺序必须先切断新请求,再释放实例,最后删除定义:
1 2 3 4 5
| 移除全部 FilterMap → 新请求不再进入 Filter → 从 filterConfigs 移除 ApplicationFilterConfig → 调用 release() 执行 Filter.destroy() → 删除 FilterDef
|
顺序不能反过来。先删定义但保留映射,会留下指向不存在组件的规则;只删映射但不释放配置,Filter 虽然暂时不可达,实例仍可能被容器引用,也不会正常执行 destroy()。
下面代码适用于已核对实现的 Tomcat 10.1 授权靶场:
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
| String filterName = "filterLab";
for (FilterMap map : standardContext.findFilterMaps()) { if (filterName.equals(map.getFilterName())) { standardContext.removeFilterMap(map); } }
Field configsField = StandardContext.class.getDeclaredField("filterConfigs"); configsField.setAccessible(true);
@SuppressWarnings("unchecked") Map<String, Object> filterConfigs = (Map<String, Object>) configsField.get(standardContext);
Object config = filterConfigs.remove(filterName); if (config != null) { Method release = config.getClass().getDeclaredMethod("release"); release.setAccessible(true); release.invoke(config); }
FilterDef def = standardContext.findFilterDef(filterName); if (def != null) { standardContext.removeFilterDef(def); }
|
代码还需要导入 java.lang.reflect.Method 和 java.util.Map。不同 Tomcat 版本的字段和生命周期实现可能变化,执行前必须核对当前版本源码,不能把私有字段名当作稳定 API。
卸载后重新发送带标记请求,响应头应消失;再次枚举时,三类状态中都不应再出现该名称。同时验证正常请求、异常派发和异步请求,防止只移除了一条映射。
生产环境优先摘除节点并保留证据,然后修复初始入口、替换可信制品并重建实例。在线卸载只能切断当前驻留对象,无法自动清除恶意 JSP、漏洞入口、启动项或 Agent 等复活来源。