漏洞概述
JeecgBoot是适用于企业 Web 应用程序的国产Java低代码平台。
近期,网宿安全演武实验室监测到JeecgBoot JimuReport 1.7.8版本及之前版本存在安全漏洞(网宿评分:高危):该漏洞源于权限设置不安全,攻击者在请求中添加特定参数,即可绕过授权机制,从而查看敏感信息并通过 Aviator 表达式注入在1.6.0版本后实现远程代码执行。
目前该漏洞POC状态已在互联网公开,建议用户尽快做好自查及防护。
受影响版本
JimuReport <= 1.7.8
漏洞分析
首先看权限绕过问题的成因,以历史漏洞接口/jeecg-boot/jmreport/queryFieldBySql为例。
显然这里已经做了权限限制,而/jeecg-boot/jmreport/**具体的拦截器实现在
org.jeecg.modules.jmreport.config.firewall.interceptor.JimuReportTokenInterceptor#preHandle()。
这是一个返回值为boolean类型的public方法,主要对用户请求进行了一些预处理和判断,那么只要在校验token前使其返回true,即可实现权限绕过。
当我们请求/jeecg-boot/jmreport/queryFieldBySql时,该方法先进行了xss检测。
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;
}
public class n {
private static final String[] a = new String[]{"<", "%3C", ">", "%3E", "\\(", "%28", "\\)", "%29", "'", "eval\\((.*)\\)"};
private static Pattern[] b = new Pattern[]{Pattern.compile("<script>(.*?)</script>", 2), Pattern.compile("src[\r\n]*=[\r\n]*\\'(.*?)\\'", 42), Pattern.compile("src[\r\n]*=[\r\n]*\\\"(.*?)\\\"", 42), Pattern.compile("</script>", 2), Pattern.compile("<script(.*?)>", 42), Pattern.compile("eval\\((.*?)\\)", 42), Pattern.compile("e\u00adxpression\\((.*?)\\)", 42), Pattern.compile("javascript:", 2), Pattern.compile("vbscript:", 2), Pattern.compile("onload(.*?)=", 42)};
...
private static boolean a(String var0, Pattern var1) {
Matcher var2 = var1.matcher(var0);
return var2.find();
}
...
}
并且对/jmreport/shareView/和JimuNoLoginRequired注解的路由直接放行。
if (var4.contains("/jmreport/shareView/")) {
return true;
}
JimuNoLoginRequired var9 = (JimuNoLoginRequired)var8.getAnnotation(JimuNoLoginRequired.class);
if (j.d(var9)) {
return true;
}
接着通过jwt验证用户传入token的合法性。
try {
var10 = this.verifyToken(request);
} catch (Exception var14) {
}
public static boolean verifyToken(String token, CommonAPI commonApi, RedisUtil redisUtil) {
if (StringUtils.isBlank(token)) {
throw new JeecgBoot401Exception("token不能为空!");
}
// 解密获得username,用于和数据库进行对比
String username = JwtUtil.getUsername(token);
if (username == null) {
throw new JeecgBoot401Exception("token非法无效!");
}
// 查询用户信息
LoginUser user = TokenUtils.getLoginUser(username, commonApi, redisUtil);
//LoginUser user = commonApi.getUserByName(username);
if (user == null) {
throw new JeecgBoot401Exception("用户不存在!");
}
// 判断用户状态
if (user.getStatus() != 1) {
throw new JeecgBoot401Exception("账号已被锁定,请联系管理员!");
}
// 校验token是否超时失效 & 或者账号密码是否错误
if (!jwtTokenRefresh(token, username, user.getPassword(), redisUtil)) {
throw new JeecgBoot401Exception(CommonConstant.TOKEN_IS_INVALID_MSG);
}
return true;
}
显然攻击者未持有合法token,继续往下走发现如果访问的路由在白名单内即放行。
if (!var10) {
if (this.jimuReportShareService.isSharingEffective(var4, request)) {
return true;
} else {
...
}
}
GET_QUERY_INFO("/jmreport/getQueryInfo"),
SHARE_VERIFICATION("/jmreport/share/verification"),
ADD_VIEW_COUNT("/jmreport/addViewCount/"),
SHOW_DATA("/jmreport/show"),
EXPORT_PDF_STREAM("/jmreport/exportPdfStream"),
EXPORT_ALL_EXCEL_STREAM("/jmreport/exportAllExcelStream"),
CHECK_PARAM("/jmreport/checkParam/"),
QUERYMAP_BY_CODE("/jmreport/map/queryMapByCode"),
QUREST_SQL("/jmreport/qurestSql"),
QUREST_API("/jmreport/qurestApi"),
GET_CHAR_DATA("/jmreport/getCharData");
但/jeecg-boot/jmreport/queryFieldBySql未在其中,与此同时我们发现接下来的else分支存在突破点,只要用户在请求中携带previousPage参数,且使isShareingToken方法返回true,即可成功绕过拦截器。
else {
String var16 = request.getParameter("previousPage");
if (j.d(var16)) {
if (this.jimuReportShareService.isShareingToken(var4, request)) {
return true;
}
...
}
步入isShareingToken方法。
public boolean isShareingToken(String requestPath, HttpServletRequest request) {
String var3 = request.getHeader("JmReport-Share-Token");
String var4 = "";
if (j.c(var3)) {
var3 = request.getParameter("shareToken");
}
String var5 = request.getParameter("jmLink");
if (j.d(var5)) {
try {
byte[] var6 = Base64Utils.decodeFromString(var5);
String var7 = new String(var6);
String[] var8 = var7.split("\\|\\|");
if (ArrayUtils.isNotEmpty(var8) && var8.length == 2) {
var3 = var8[0];
var4 = var8[1];
}
} catch (IllegalArgumentException var9) {
a.error("解密失败:" + var9.getMessage());
a.error(var9.getMessage(), var9);
return false;
}
}
if (j.c(var3)) {
return false;
} else {
JimuReportShare var10 = this.jimuReportShareDao.getShareByShareToken(var3);
if (var10 != null) {
var10 = this.compareToDate(var10);
if (!"0".equals(var10.getStatus())) {
return false;
}
}
if (requestPath.startsWith("/jmreport/view")) {
if (!j.d(var4)) {
return false;
}
Long var11 = this.jimuReportLinkDao.selectLinkCountByLinkId(var4);
if (null != var11 && var11 > 0L) {
return true;
}
}
return true;
}
}
不难发现其先提取解码后的jmLink参数值赋给var3、var4,并交由getShareByShareToken判断share_token有效性,但显然攻击者传入的值,在数据库中是查不到对应的 JimuReportShare 对象的,即var10为null。
@Sql("select status,share_token,last_update_time,term_of_validity from jimu_report_share where share_token = :shareToken")
JimuReportShare getShareByShareToken(@Param("shareToken") String var1);
而这里之后又辨别了路由是否以/jmreport/view开头,如果不是则返回true,显然我们请求的路由不包括在内。
所以实际环境验证成功。
接着根据漏洞情报(https://github.com/jeecgboot/JimuReport/issues/2848),可定位至/jeecg-boot/jmreport/save和/jeecg-boot/jmreport/show两个接口。
对应函数分别为:
org.jeecg.modules.jmreport.desreport.service.a.e#saveReport()
org.jeecg.modules.jmreport.desreport.service.a.e#show()
前者的作用是将用户的报表数据保存至数据库,后者则是通过前面传入的id提取数据。
@GetMapping({"/show"})
public Result<?> a(@RequestParam(name = "id") String var1, HttpServletRequest var2) {
a.info("--------进入show方法-----------, id = " + var1);
String var3 = var2.getParameter("params");
try {
return this.jmReportDesignService.show(var1, var3, (List)null);
} catch (Exception var5) {
a.error(var5.getMessage(), var5);
return Result.error(var5.getMessage());
}
}
至于漏洞字段,则位于org.jeecg.modules.jmreport.desreport.express.a#c(),这里限制了传入格式,即只解析”text”字段中,通过=()包含的值。
String var9 = var8.getString("text");
if (var9 != null && !var9.isEmpty()) {
var9 = var9.trim();
Pattern var10 = Pattern.compile("^([^=]*)(=[A-Z_.a-z0-9]*\\(.*\\))(([*/+\\-][0-9]+)*)(.*)");
Matcher var11 = var10.matcher(var9);
String var12;
String var15;
if (var11.find()) {
var12 = var11.group(2);
String var13 = var12.substring(var12.startsWith("=") ? 1 : 0, var12.indexOf("("));
if (ExpressUtil.f.contains(var13.toLowerCase())) {
String var14 = var11.group(1);
var15 = var11.group(3);
String var16 = var11.group(5);
var8.put("jeTempText", var14 + "$expVal$" + var16);
var9 = var12.trim() + var15.trim();
var8.put("text", var9);
}
}
...
最终编译攻击者传入的payload。
public Expression compile(String expression, boolean cached) {
return this.compile(expression, expression, cached);
}
漏洞复现
首先通过/jeecg-boot/jmreport/save,构造恶意报表数据。
再借助/jeecg-boot/jmreport/show根据id提取数据,即可触发该漏洞。
修复方案
不推荐直接将脚本的执行开放到任何不可信的环境,如果确实需要,可以参考https://www.yuque.com/boyan-avfmj/aviatorscript/ou23gy#elOSu,设置必要的选项。
产品支持
网宿全站防护-WAF已支持对该漏洞利用攻击的防护,并持续挖掘分析其他变种攻击方式和各类组件漏洞,第一时间上线防护规则,缩短防护“空窗期”。