0day–JeecgBootv3.9.1多漏洞审计过程

admin 2026-04-16 04:48:56 网络安全文章 来源:ZONE.CI 全球网 0 阅读模式

文章总结: 本文审计JeecgBootv3.9.1发现两类漏洞:AIRAGMCP模块因endpoint字段未过滤直接拼入sh-c导致命令执行;字典接口SQL注入可通过%0a换行、current_user、mysql.innodb_table_stats等绕过黑名单,且部分接口未应用过滤。建议替换MyBatis的${}为#{}参数化查询,避免依赖黑名单方案。 综合评分: 85 文章分类: 代码审计,漏洞分析,WEB安全


cover_image

0day–JeecgBoot v3.9.1 多漏洞审计过程

xia0mai xia0mai

道一安全

2026年4月13日 10:25 北京

在小说阅读器读本章

去阅读

免责声明

道一安全(本公众号)的技术文章仅供参考,此文所提供的信息只为网络安全人员对自己所负责的网站、服务器等(包括但不限于)进行检测或维护参考,未经授权请勿利用文章中的技术资料对任何计算机系统进行入侵操作。利用此文所提供的信息而造成的直接或间接后果和损失,均由使用者本人负责。本文所提供的工具仅用于学习,禁止用于其他!!!

前言

本文不涉及任何poc或者exp的构建过程,只有漏洞挖掘过程供参考,感兴趣的师傅可以自行去复现

原文链接:https://xz.aliyun.com/news/91897

项目介绍

JeecgBoot,github上star 41k的低代码平台,国内企业用的比较多,项目是标准的Spring Boot结构,后端业务走的Spring MVC,MyBatis做持久层

3.9.1版本新增了AIRAG模块支持MCP协议,同时字典相关的接口还用的黑名单防注入方案

这次审计发现了两类漏洞:AIRAG MCP模块的命令执行和字典接口的SQL注入(绕过之前的修复)

漏洞一:AIRAG MCP 命令执行

漏洞背景

AIRAG MCP模块的saveAndSync接口将用户可控的endpoint字段在stdio模式下直接拼进sh -c执行,认证用户即可RCE

前置条件:已认证用户 + 配置文件中allow-sensitive-nodes包含stdio(开发/演示环境默认开启)

挖掘过程

看了一下模块列表注意到jeecg-boot-module-airag是个新模块,MCP本身就涉及工具调用和进程通信,这种模块天然就是审计的重点目标。AiragMcpController里面有个saveAndSync接口,发现他保存并同步,同步什么?

AiragMcpController.java

逻辑很简单,先edit保存再sync同步,对传入的字段没有做任何校验,跟进entity看下有什么

AiragMcp.java

注释写的清清楚楚,”stdio类型为命令”,type和endpoint都是@RequestBody进来的完全可控

跟进AiragMcpServiceImpl.java的sync方法,来到stdio分支

endpoint.trim()直接塞进了["sh", "-c", endpoint],中间零过滤。注释里自己都写了”endpoint 可能是一个命令行”,但没做任何安全处理

唯一的门槛是allowSensitiveNodes配置检查,看下默认值

jeecg:  ai-rag:    # AI流程敏感节点(stdio=命令行节点, sql=SQL节点)    allow-sensitive-nodes: sql,stdio

默认就开着,开发和演示环境都是这个配置

简化调用链1 &nbsp;POST /jeecg-boot/airag/airagMcp/saveAndSync2 &nbsp;AiragMcpController.saveAndSync(@RequestBody AiragMcp)3 &nbsp;airagMcpService.edit(airagMcp) ← endpoint持久化4 &nbsp;airagMcpService.sync(id)5 &nbsp;cmdParts = ["sh",&nbsp;"-c", endpoint.trim()]6 &nbsp;StdioMcpTransport → ProcessBuilder → /bin/sh -c&nbsp;"<endpoint>"

漏洞二:SQL注入(绕过历史修复)

漏洞背景

JeecgBoot的字典接口之前就出过SQL注入,官方在v3.4.x加了个黑名单方案修复,用的是SqlInjectionUtil.specialFilterContentForDictSql(),这次是分析这个黑名单找到绕过,同时发现部分接口连黑名单都没走

挖掘过程

审计思路就是看这个黑名单到底拦了什么、有没有绕过的可能

先看看之前修复加的防护逻辑,SqlInjectionUtil.java里的specialFilterContentForDictSql方法

这是黑名单关键词列表,注意几个关键点:select后面带空格、information_schema完整匹配、没有exists、没有and/or

再看正则部分

private&nbsp;final&nbsp;static&nbsp;String[]&nbsp;XSS_REGULAR_STR_ARRAY&nbsp;=&nbsp;new&nbsp;String[]{&nbsp; &nbsp;&nbsp;"chr\\s*\\(",&nbsp; &nbsp;&nbsp;"mid\\s*\\(",&nbsp; &nbsp;&nbsp;" char\\s*\\(",&nbsp; &nbsp;&nbsp;"sleep\\s*\\(",&nbsp; &nbsp;&nbsp;"user\\s*\\(",&nbsp; &nbsp;&nbsp;"show\\s+tables",&nbsp; &nbsp;&nbsp;"user[\\s]*\\([\\s]*\\)",&nbsp; &nbsp;&nbsp;"show\\s+databases",&nbsp; &nbsp;&nbsp;"sleep\\(\\d*\\)",&nbsp; &nbsp;&nbsp;"sleep\\(.*\\)",};

user\s*\(匹配的是user()带括号的形式,sleep\s*\(也在拦截列表里

分析完黑名单,绕过思路就很清楚了:

1、select要求后面带空格,用%0a换行符替代空格就能绕过,select%0a1不会被检测到

2、user\s*\(拦截的是user()带括号的形式,但MySQL的current_user不需要括号,直接绕过

3、information_schema被拦了,但mysql.innodb_table_stats一样可以查表名,绕过

那么接下来就是找哪些接口用了这个黑名单过滤、哪些地方用了MyBatis的${}拼接

SysDictMapper.xml里面一搜就看到好几个${}直接拼接的点

<if&nbsp;test="key == '_tableFilterSql'">&nbsp; &nbsp; and&nbsp;${value}</if>

${filterSql}${value}都是直接拼接的,不是#{}参数化查询

那么审计思路就是找非预编译、参数可控、经过specialFilterContentForDictSql黑名单的接口

这里演示几处,主要还是找的思路

第一处:/sys/dict/getDictItems/{dictCode}— 布尔盲注(过了黑名单,可绕)

SysDictController.java

@GetMapping("/getDictItems/{dictCode}")public&nbsp;Result<List<DictModel>>&nbsp;getDictItems(&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;@PathVariable("dictCode")&nbsp;String&nbsp;dictCode,&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;@RequestParam(value =&nbsp;"sign", required =&nbsp;false)&nbsp;String&nbsp;sign) {&nbsp; &nbsp;&nbsp;// ...&nbsp; &nbsp;&nbsp;String[] params = dictCode.split(",");&nbsp; &nbsp;&nbsp;if&nbsp;(params.length&nbsp;==&nbsp;4) {&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;// params[3]就是filterSql,直接传入&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;return&nbsp;Result.OK(sysDictService.queryTableDictItemsByCodeAndFilter(&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; params[0], params[1], params[2], params[3]));&nbsp; &nbsp; }

dictCode用逗号分割,第四个参数直接作为filterSql传入,跟进Service层

SysDictServiceImpl.java

public&nbsp;List<DictModel>&nbsp;queryTableDictItemsByCodeAndFilter(&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;String&nbsp;table,&nbsp;String&nbsp;text,&nbsp;String&nbsp;code,&nbsp;String&nbsp;filterSql) {&nbsp; &nbsp;&nbsp;// 黑名单过滤&nbsp; &nbsp; filterSql =&nbsp;SqlInjectionUtil.specialFilterContentForDictSql(filterSql);&nbsp; &nbsp;&nbsp;return&nbsp;sysDictMapper.queryTableDictWithFilterSql(table, text, code, filterSql);}

过了一层specialFilterContentForDictSql黑名单然后直接进Mapper的${filterSql},前面分析过这个黑名单可以绕,存在布尔盲注

1=1&nbsp;→ 返回数据1=2&nbsp;→ 返回空

第二处:/sys/dict/loadDict/{dictCode}— LIKE注入(连黑名单都没过)

这个更直接,keyword参数连黑名单都没过

private&nbsp;String&nbsp;getFilterSql(String&nbsp;tableSql,&nbsp;String&nbsp;text,&nbsp;String&nbsp;code,&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;String&nbsp;condition,&nbsp;String&nbsp;keyword){&nbsp; &nbsp;&nbsp;// ...&nbsp; &nbsp;&nbsp;if&nbsp;(oConvertUtils.isNotEmpty(keyword)) {&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;if&nbsp;(keyword.contains(SymbolConstant.COMMA)) {&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;String&nbsp;inKeywords =&nbsp;"'"&nbsp;+&nbsp;String.join("','", keyword.split(",")) +&nbsp;"'";&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; keywordSql =&nbsp;"("&nbsp;+ text +&nbsp;" in ("&nbsp;+ inKeywords +&nbsp;") or "&nbsp;+ code +&nbsp;" in ("&nbsp;+ inKeywords +&nbsp;"))";&nbsp; &nbsp; &nbsp; &nbsp; }&nbsp;else&nbsp;{&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; keywordSql =&nbsp;"("+text +&nbsp;" like '%"+keyword+"%' or "+ code +&nbsp;" like '%"+keyword+"%')";&nbsp; &nbsp; &nbsp; &nbsp; }&nbsp; &nbsp; }

keyword直接拼进了LIKE语句,没有调用任何过滤方法,连specialFilterContentForDictSql都没走

keyword=%' OR 1=1 OR '%'='

拼出来就是realname like '%%' OR 1=1 OR '%'='%',LIKE条件直接被绕过返回所有数据

第三处:/sys/dict/loadTreeData— 时间盲注(JSON透传,没过黑名单)

condition参数是个JSON,里面的_tableFilterSql直接透传到Mapper

SysDictServiceImpl.java

if&nbsp;(StringUtils.isNotBlank(condition)) {&nbsp; &nbsp;&nbsp;Map<String,&nbsp;String> query =&nbsp;JSON.parseObject(condition,&nbsp;Map.class);&nbsp; &nbsp;&nbsp;for&nbsp;(String&nbsp;key : query.keySet()) {&nbsp; &nbsp; &nbsp; &nbsp; queryParams.put(key, query.get(key)); &nbsp;// ← _tableFilterSql直接透传&nbsp; &nbsp; }}return&nbsp;sysDictMapper.queryTreeList(queryParams);

Mapper里${value}直接拼接,没有任何过滤,连黑名单都没走,存在时间盲注

condition={"_tableFilterSql":"1=1 and sleep(5)"}基线耗时0.016秒,注入后耗时15秒

同样的模式还有

/sys/api/queryFilterTableDictInfo、/sys/api/queryTableDictItemsByCode、/sys/api/loadDictItemByKeyword、/sys/dict/loadDictItem/{dictCode}

,都是类似的问题,要么走了黑名单可以绕,要么压根没走黑名单

7个接口汇总(其实不止 7 个)

| | | | | | — | — | — | — | | 接口 | 注入参数 | 类型 | 是否过黑名单 | | /sys/dict/getDictItems/{dictCode} | filterSql | 布尔盲注 | 过了,可绕 | | /sys/dict/loadDict/{dictCode} | keyword | LIKE注入 | 没过 | | /sys/dict/loadTreeData | _tableFilterSql | 时间盲注 | 没过 | | /sys/api/queryFilterTableDictInfo | filterSql | 布尔盲注 | 过了,可绕 | | /sys/api/queryTableDictItemsByCode | tableFilterSql | 布尔盲注 | 过了,可绕 | | /sys/api/loadDictItemByKeyword | keyword | LIKE注入 | 没过 | | /sys/dict/loadDictItem/{dictCode} | dictCode(表名) | 布尔盲注 | 过了,可绕 |

总结

这次审计JeecgBoot 3.9.1找到两类问题:MCP命令执行是新模块引入的新洞,endpoint直接进sh -c没有任何过滤。SQL注入是老问题新绕过,之前v3.4.x用黑名单方案修的, select 带空格用 %0a 换行绕、user() 用 current_user 无括号绕、 information_schema 用 mysql.innodb_table_stats 替代,而且7个接口里有3个连黑名单都没走直接拼接的。根因还是MyBatis用了 ${} 而不是 #{} ,黑名单治标不治本。

点分享

点收藏

点在看

点点赞


免责声明:

本文所载程序、技术方法仅面向合法合规的安全研究与教学场景,旨在提升网络安全防护能力,具有明确的技术研究属性。

任何单位或个人未经授权,将本文内容用于攻击、破坏等非法用途的,由此引发的全部法律责任、民事赔偿及连带责任,均由行为人独立承担,本站不承担任何连带责任。

本站内容均为技术交流与知识分享目的发布,若存在版权侵权或其他异议,请通过邮件联系处理,具体联系方式可点击页面上方的联系我

本文转载自:道一安全 xia0mai xia0mai《0day–JeecgBoot v3.9.1 多漏洞审计过程》

评论:0   参与:  0