前言

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

项目介绍

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  POST /jeecg-boot/airag/airagMcp/saveAndSync
2  AiragMcpController.saveAndSync(@RequestBody AiragMcp)
3  airagMcpService.edit(airagMcp) ← endpoint持久化
4  airagMcpService.sync(id)
5  cmdParts = ["sh", "-c", endpoint.trim()]
6  StdioMcpTransport → ProcessBuilder → /bin/sh -c "<endpoint>"

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

漏洞背景

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

挖掘过程

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

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

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

再看正则部分

private final static String[] XSS_REGULAR_STR_ARRAY = new String[]{
    "chr\\s*\\(",
    "mid\\s*\\(",
    " char\\s*\\(",
    "sleep\\s*\\(",
    "user\\s*\\(",
    "show\\s+tables",
    "user[\\s]*\\([\\s]*\\)",
    "show\\s+databases",
    "sleep\\(\\d*\\)",
    "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 test="key == '_tableFilterSql'">
    and ${value}
</if>

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

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

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

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

SysDictController.java

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

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

SysDictServiceImpl.java

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

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

1=1 → 返回数据
1=2 → 返回空

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

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

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

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 (StringUtils.isNotBlank(condition)) {
    Map<String, String> query = JSON.parseObject(condition, Map.class);
    for (String key : query.keySet()) {
        queryParams.put(key, query.get(key));  // ← _tableFilterSql直接透传
    }
}
return 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用了 ${} 而不是 #{} ,黑名单治标不治本。

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐