内存马原理总纲
本系列的总纲篇。前面的文章按”类型”拆开讲了 Filter / Servlet / Listener / Valve / Agent / Spring / WebSocket 各自的内存马;这一篇回答为什么会有内存马,以及每一种内存马到底在模拟容器的哪一步。
本文涉及的 Tomcat 调用链均已用
javap对 Tomcat 8.5.39 的 catalina.jar 逐一核实,不是凭记忆写的。
一、为什么会有内存马
一句话:内存马的本质,是把「恶意代码 + 触发条件」在运行时动态组装进容器的注册表/调度机制,让容器像调度正常组件一样调度它。
1.1 动态注册是容器/框架的合法能力
Web 容器和框架天生支持运行时动态注册组件——这是给插件化、热部署、监控用的设计:
| 容器/框架 | 动态注册能力 | 用途 |
|---|---|---|
| Tomcat | addFilterDef / addChild / addApplicationEventListener / Pipeline.addValve |
运行时加 Filter / Servlet / Listener / Valve |
| Spring MVC | registerMapping / 拦截器装配 |
动态加路由 / 拦截器 |
| JVM | Instrumentation.addTransformer + retransformClasses |
APM、热更新 |
1.2 容器眼里,组件只是「(代码, 触发条件)」
| 组件 | 代码 | 触发条件 |
|---|---|---|
| Filter | doFilter 逻辑 |
URL pattern |
| Servlet | service 逻辑 |
URL path |
| Listener | 回调方法 | 事件 |
| Valve | invoke 逻辑 |
请求进入容器 |
| Controller | 方法体 | 路由 |
| Agent 插入代码 | 字节码 | 方法被调用 |
容器的调度机制只认”注册表里有没有”,不关心代码来源——jar、注解扫描、配置文件、运行时反射塞进去的,对容器没有区别。攻击者拿到代码执行后,就能模拟正常的注册过程,把恶意代码和触发条件填进注册表。
1.3 对比文件型 webshell
- JSP webshell = 「磁盘文件(代码)」+「URL 映射」两个都在磁盘上,删文件即失效;
- 内存马 = 把「代码 + 映射」直接组装进运行时的注册表,磁盘上什么都没有,随进程存活。
所以内存马存在的根本原因:动态注册是合法特性,容器无法区分运行时注册的是正常组件还是恶意组件。
二、Tomcat 请求处理总链路(8.5.39 实测调用链)
先把一条 HTTP 请求从进入到返回的真实调用链画出来(所有类.方法均经 javap 核实):
CoyoteAdapter.service()
→ Mapper.map() // URL 路由:定位 Host / Context / Wrapper
→ StandardEngineValve.invoke() // Engine 管道的基础阀
→ StandardHostValve.invoke() // Host 管道的基础阀
└→ fireRequestInitEvent(request) // ← Listener 内存马的触发点
→ StandardContextValve.invoke() // Context 管道的基础阀
→ StandardWrapperValve.invoke() // Wrapper 管道的基础阀
├→ StandardWrapper.allocate() // 拿 Servlet 实例
└→ ApplicationFilterFactory.createFilterChain() // ← Filter 内存马的"组装点"
→ ApplicationFilterChain.doFilter()
→ internalDoFilter() // ← Agent 内存马的 hook 点
→ Filter1.doFilter() ... FilterN.doFilter()
→ servlet.service() // ← Servlet 内存马的执行点
关键观察:这条链的每一环,都对应一种内存马的”插入点”:
- 请求还没进 Filter 链,
StandardHostValve就先派发 Listener 事件 → Listener 型在这里; ApplicationFilterFactory按注册表构建 Filter 链 → Filter 型在这里;- 链的终点是
servlet.service()→ Servlet 型在这里; - 四个容器各自的 Pipeline → Valve 型可以挂在任意一段;
internalDoFilter是每个请求必经的方法体 → Agent 型直接改它的字节码。
三、逐类:正常注册流程 vs 内存马模拟
下表”正常流程”列的是容器启动/请求时的真实调用(类.方法 + 作用);”内存马模拟”列的是攻击者用哪个 API/反射替代它。所有方法均对 8.5.39 javap 核实。
3.1 Filter 内存马
| 步骤 | 正常流程(类.方法 → 作用) | 内存马模拟 |
|---|---|---|
| 1 | ContextConfig 解析 web.xml / @WebFilter |
反射拿 StandardContext |
| 2 | 构造 FilterDef(filterName+filterClass) |
构造 FilterDef,filterClass = 恶意类 |
| 3 | 构造 FilterMap(URL 模式) |
构造 FilterMap,/* |
| 4 | StandardContext.filterStart():遍历 filterDefs → new ApplicationFilterConfig(Context, FilterDef) → 存 filterConfigs |
反射调用同一个构造器 ApplicationFilterConfig(Context, FilterDef) 并放进 filterConfigs |
| 5 | 请求时 ApplicationFilterFactory.createFilterChain():遍历 findFilterMaps() → filterConfigs.get(name) → chain.addFilter(config) |
恶意 Filter 已在这三个集合里,被当作普通 Filter 一起调度 |
本质:模拟 addFilterDef + addFilterMap + ApplicationFilterConfig 构造,把「恶意 doFilter 代码 + /* 映射」组装进 filterDefs / filterMaps / filterConfigs。
3.2 Servlet 内存马
| 步骤 | 正常流程(类.方法 → 作用) | 内存马模拟 |
|---|---|---|
| 1 | StandardContext.createWrapper() 创建 Wrapper |
同方法 |
| 2 | StandardContext.addChild(wrapper) → 存 children(启动时自动 child.start()) |
同方法,setServletClass 指向恶意类(或 setServlet(实例)) |
| 3 | StandardContext.addServletMappingDecoded(pattern, name) → 写 servletMappings + Mapper |
同方法,pattern=/shell |
| 4 | 请求时 CoyoteAdapter.service() → Mapper.map() 路由到 Wrapper → StandardWrapperValve.invoke() → allocate() → service() |
访问 /shell 即命中 |
本质:模拟 createWrapper + addChild + addServletMappingDecoded,把「恶意 service 代码 + 精确路径」组装进 children + Mapper。
3.3 Listener 内存马
| 步骤 | 正常流程(类.方法 → 作用) | 内存马模拟 |
|---|---|---|
| 1 | ContextConfig 注册监听器 |
直接 addApplicationEventListener(new EvilListener()) |
| 2 | StandardContext.addApplicationEventListener() → applicationEventListenersList.add(listener) |
同一个 public 方法 |
| 3 | 请求时 StandardHostValve.invoke() → Context.fireRequestInitEvent() → 遍历 getApplicationEventListeners() → instanceof ServletRequestListener → listener.requestInitialized() |
恶意 Listener 已在列表里,每个请求到达即触发 |
本质:模拟 addApplicationEventListener,把「恶意回调 + 请求到达事件」组装进监听器列表。触发条件不需要 URL 映射——事件由容器自动派发。
3.4 Valve 内存马
| 步骤 | 正常流程(类.方法 → 作用) | 内存马模拟 |
|---|---|---|
| 1 | ContainerBase.addValve(valve) → Pipeline.addValve(valve) + valve.setContainer(this) + valve.start() |
同方法 getPipeline().addValve(new EvilValve()) |
| 2 | StandardPipeline.addValve():设 first,valve.setNext(...) 链到基础阀前 |
同方法,无需反射 |
| 3 | 请求进入容器时沿 Pipeline 逐个 invoke() |
恶意 Valve 在管道上,任何请求都经过 |
本质:模拟 Pipeline.addValve,把「恶意 invoke 代码 + 请求进入容器」组装进管道。比 Filter 更底层——不碰 URL 映射,404、静态资源都经过。
3.5 Spring Interceptor / Controller 内存马
| 类型 | 正常流程(类.方法 → 作用) | 内存马模拟 |
|---|---|---|
| Interceptor | 配置声明 → AbstractHandlerMapping 启动时适配进 adaptedInterceptors 列表;请求时 getHandlerExecutionChain() 遍历、preHandle() |
反射取 adaptedInterceptors(private final List,可 add),把恶意 HandlerInterceptor add 进去 |
| Controller | 启动扫描 @RequestMapping → registerHandlerMethod() 填 mappingRegistry |
直接调 public RequestMappingHandlerMapping.registerMapping(info, handler, method) |
本质:Interceptor 模拟”装配拦截器链”,把恶意 preHandle + 每个请求组装进 adaptedInterceptors;Controller 模拟”登记路由”,把恶意方法 + /evil 组装进 mappingRegistry。
3.6 Agent 内存马
| 步骤 | 正常流程(类.方法 → 作用) | 内存马模拟 |
|---|---|---|
| 1 | JVM 加载类时若有 ClassFileTransformer 则回调 |
attach 后 inst.addTransformer(t, true) |
| 2 | APM/热更新对已加载类用 retransformClasses() |
同方法,定位 ApplicationFilterChain |
| 3 | 转换器返回修改后的字节码 | javassist insertBefore 在 internalDoFilter 开头插入命令执行,return 短路 |
本质:这里的”组装”不再是往注册表加条目,而是直接改写代码本身的位置——恶意代码成了正常方法体的一部分。所以最难检测:无新组件、无新映射、类名原装。
四、统一视角
| 类型 | 模拟的注册过程 | 恶意代码 | 触发条件 | 组装进 |
|---|---|---|---|---|
| Filter | addFilterDef/addFilterMap/ApplicationFilterConfig |
doFilter | URL /* |
StandardContext 三个集合 |
| Servlet | createWrapper/addChild/addServletMappingDecoded |
service | 精确路径 | children + Mapper |
| Listener | addApplicationEventListener |
requestInitialized | 请求事件 | 监听器列表 |
| Valve | Pipeline.addValve |
invoke | 进入容器 | 管道 |
| Spring Interceptor | 装配 adaptedInterceptors |
preHandle | 每个请求 | 拦截器链 |
| Spring Controller | registerMapping |
控制器方法 | 路由 | mappingRegistry |
| Agent | Instrumentation retransform | 插入方法体 | 方法调用 | 类字节码 |
五、为什么难检测,防御总思路
- 文件侧失效:内存马无磁盘文件,文件扫描、目录审计看不到。
- 注册表难区分:它用的是和正常组件完全相同的注册接口(
addValve、addApplicationEventListener、registerMapping等),容器无法在”注册行为”层面区分。 - 因此防御必须转向”行为检测”:
- 事前:修复反序列化 / 表达式注入等 RCE 漏洞,切断”先拿到代码执行”这个前提;
- 事中:RASP 监控
addValve/addFilterDef/registerMapping/retransformClasses等注册调用,判断调用栈是否来自启动阶段、线程是否可疑; - 事后:周期性对比运行时注册表基线(Filter 列表 / Servlet 映射 / Listener / 管道 Valve / Spring HandlerMapping),以及 JVM 字节码 diff(Agent 型)。
六、系列导航
- Tomcat Filter 内存马
- Tomcat Servlet 内存马
- Tomcat Listener 内存马
- Tomcat Valve 内存马
- Java Agent 内存马
- Spring 容器内存马
- WebSocket / Executor / Upgrade 内存马(偏门型)
- 反序列化与 JNDI 无文件注入内存马
- TomcatEcho 无 request 命令回显
- Fastjson 实战注入内存马
- 内存马检测与清除实操
七、阅读建议与通用说明
- 阅读顺序:建议先读本篇(总纲)建立整体框架,再按需读各分篇;各分篇之间用”延伸阅读”互相跳转。
- 命令执行代码:各分篇里的
exec方法(ProcessBuilder+redirectErrorStream(true))都是同一套写法——合并 stderr、字节循环读取,避免命令输出与错误流互相阻塞;因各篇需自包含而重复出现。 - JSP 触发脚本的两种形态:
- 讲解简化:Filter / Servlet / Listener 三篇正文里的 JSP 直接引用注入器类,假设恶意类已部署,便于阅读(已标注);
- 自包含(真实可用):Valve 篇 3.4 与测试工程里的 JSP 把恶意类内联进
<%! %>,单独一个文件即可完成注入——真实利用(上传 JSP)应使用这种形态。