Apache OFBiz 漏洞整理分析

CVE-2023-49070 & CVE-2020-9496 & CVE-2023-51467

参考

https://xz.aliyun.com/t/13211

https://mp.weixin.qq.com/s/iAvitO6otPdHSu1SjRNX3g

https://xz.aliyun.com/t/8184

CVE-2023-49070

CVE-2023-49070主要是绕过了先前修复的所有检测手段,包括

  • 对于</serializable关键词检测
  • XML-RPC接口认证

借此机会分析一下整个RCE的原理

先看一下他是怎么绕过补丁的

首先是</serializable关键词绕过

进入org.apache.ofbiz.base.util.CacheFilter#doFilter,可以看到使用两个if对数据包内容进行过滤,首先对于url进行检验,其次检测body中是否包含</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);
}

payload这边利用到了Tomcat对于url中;,Tomcat可以通过;方式在路径中添加Matrix Parameters,所以这边加个分号即可绕过

vulhub起的环境,远程调试下个断点,发包看一下,可以看到直接绕过了第一个if,返回了登录页面

image-20240109161722764其次,绕过XML-RPC接口的认证

查看登录鉴权位置org.apache.ofbiz.webapp.control.LoginWorker#checkLogin

首先获取USERNAME,PASSWORD,token,判断是否非空,然后做了一个if判断,如果username为空或者password、token同时为空或者login函数返回error,满足其中一条则进入if返回error,因此只要不进入if,就可以绕过鉴权

 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
public static String checkLogin(HttpServletRequest request, HttpServletResponse response) {
    GenericValue userLogin = checkLogout(request, response);
    // have to reget this because the old session object will be invalid
    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))) {

            // make sure this attribute is not in the request; this avoids infinite recursion when a login by less stringent criteria (like not checkout the hasLoggedOut field) passes; this is not a normal circumstance but can happen with custom code or in funny error situations when the userLogin service gets the userLogin object but runs into another problem and fails to return an error
            request.removeAttribute("_LOGIN_PASSED_");

            // keep the previous request name in the session
            session.setAttribute("_PREVIOUS_REQUEST_", request.getPathInfo());

            // NOTE: not using the old _PREVIOUS_PARAMS_ attribute at all because it was a security hole as it was used to put data in the URL (never encrypted) that was originally in a form field that may have been encrypted
            // keep 2 maps: one for URL parameters and one for form parameters
            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);
            }

            //if (Debug.infoOn()) Debug.logInfo("checkLogin: PathInfo=" + request.getPathInfo(), module);

            return "error";
        }
    }

进入前两个判断没什么好说的,看一下login函数,重点看下面这部分

如果requirePasswordChange为Y则不返回error,因为前面的函数中判断的是username == null,所以设置了username但不赋值即可得到空字符串而非null

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

所以payload的URL就可以这样构造

1
webtools/control/xmlrpc;/?USERNAME=&PASSWORD=1&requirePasswordChange=Y

CVE-2020-9496

现在脱掉了壳,只剩下Pre-Auth RCE了,借此机会分析一下这个RCE(CVE-2020-9496)

进入org.apache.ofbiz.webapp.control.ControlServlet

前面看了一圈没什么提到处理XML body的位置,直接看处理请求的位置

image-20240109171426591

进入doRequest

先看一下/xmlrpc路由的作用

首先获取到xmlrpc,然后看一下哪里进行了调用

image-20240110141048184

前面看了一圈没什么相关的,然后定位到这里,运行请求的事件,传入的事件类型为xmlrpc,因此路由的xmlrpc咋这里用到了

image-20240110141546652

进入runEvent看一下,利用事件工厂类生成根据对应的事件生成事件处理类,然后调用事件处理类处理对应的请求事件

1
2
3
4
5
6
7
public String runEvent(HttpServletRequest request, HttpServletResponse response,
        ConfigXMLReader.Event event, ConfigXMLReader.RequestMap requestMap, String trigger) throws EventHandlerException {
    EventHandler eventHandler = eventFactory.getEventHandler(event.type);
    String eventReturn = eventHandler.invoke(event, requestMap, request, response);
    if (Debug.verboseOn() || (Debug.infoOn() && "request".equals(trigger))) Debug.logInfo("Ran Event [" + event.type + ":" + event.path + "#" + event.invoke + "] from [" + trigger + "], result is [" + eventReturn + "]", module);
    return eventReturn;
}

因此直接进入xmlrpc对应的事件处理类XmlRpcEventHandler的invoke方法即可

image-20240110142112115

进入看一下,因为echo没有所以直接进execute

 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 String invoke(Event event, RequestMap requestMap, HttpServletRequest request, HttpServletResponse response) throws EventHandlerException {
    String report = request.getParameter("echo");
    if (report != null) {
        BufferedReader reader = null;
        StringBuilder buf = new StringBuilder();
        try {
            // read the inputstream buffer
            String line;
            reader = new BufferedReader(new InputStreamReader(request.getInputStream()));
            while ((line = reader.readLine()) != null) {
                buf.append(line).append("\n");
            }
        } catch (Exception e) {
            throw new EventHandlerException(e.getMessage(), e);
        } finally {
            if (reader != null) {
                try {
                    reader.close();
                } catch (IOException e) {
                    throw new EventHandlerException(e.getMessage(), e);
                }
            }
        }
        Debug.logInfo("Echo: " + buf.toString(), module);

        // echo back the request
        try {
            response.setContentType("text/xml");
            Writer out = response.getWriter();
            out.write("<?xml version=\"1.0\" encoding=\"UTF-8\"?>");
            out.write("<methodResponse>");
            out.write("<params><param>");
            out.write("<value><string><![CDATA[");
            out.write(buf.toString());
            out.write("]]></string></value>");
            out.write("</param></params>");
            out.write("</methodResponse>");
            out.flush();
        } catch (Exception e) {
            throw new EventHandlerException(e.getMessage(), e);
        }
    } else {
        try {
            this.execute(this.getXmlRpcConfig(request), new HttpStreamConnection(request, response));
        } catch (XmlRpcException e) {
            Debug.logError(e, module);
            throw new EventHandlerException(e.getMessage(), e);
        }
    }

    return null;
}

看一下execute,这边使用getRequest处理xml请求

image-20240110145245180

进入getRequest,利用XmlRpcRequestParser作为处理器处理xml

image-20240110150003827

直接看一下XmlRpcRequestParse如何进行处理,使用switch逐级处理,进入value后配置一些参数,然后进入父类的startElement方法,调用链如下

image-20240110161322289

 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
public void startElement(String pURI, String pLocalName, String pQName,
                    Attributes pAttrs) throws SAXException {
    switch (level++) {
       case 0:
          if (!"".equals(pURI)  ||  !"methodCall".equals(pLocalName)) {
             throw new SAXParseException("Expected root element 'methodCall', got "
                   + new QName(pURI, pLocalName),
                   getDocumentLocator());
          }
          break;
       case 1:
          if (methodName == null) {
             if ("".equals(pURI)  &&  "methodName".equals(pLocalName)) {
                inMethodName = true;
             } else {
                throw new SAXParseException("Expected methodName element, got "
                                     + new QName(pURI, pLocalName),
                                     getDocumentLocator());
             }
          } else if (params == null) {
             if ("".equals(pURI)  &&  "params".equals(pLocalName)) {
                params = new ArrayList();
             } else {
                throw new SAXParseException("Expected params element, got "
                                     + new QName(pURI, pLocalName),
                                     getDocumentLocator());
             }
          } else {
             throw new SAXParseException("Expected /methodCall, got "
                                  + new QName(pURI, pLocalName),
                                  getDocumentLocator());
          }
          break;
       case 2:
          if (!"".equals(pURI)  ||  !"param".equals(pLocalName)) {
             throw new SAXParseException("Expected param element, got "
                                  + new QName(pURI, pLocalName),
                                  getDocumentLocator());
          }
          break;
       case 3:
          if (!"".equals(pURI)  ||  !"value".equals(pLocalName)) {
             throw new SAXParseException("Expected value element, got "
                                  + new QName(pURI, pLocalName),
                                  getDocumentLocator());
          }
          startValueTag();
          break;
       default:
          super.startElement(pURI, pLocalName, pQName, pAttrs);
          break;
    }
}

看一下父类RecursiveTypeParserImpl,因为触发了startValueTag方法所以会进入if,首先在工厂类中寻找处理的Parser

 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
protected void startValueTag() throws SAXException {
  inValueTag = true;
  text.setLength(0);
  typeParser = null;
}

public void startElement(String pURI, String pLocalName,
             String pQName, Attributes pAttrs) throws SAXException {
  if (inValueTag) {
    if (typeParser == null) {
      typeParser = factory.getParser(cfg, context, pURI, pLocalName);
      if (typeParser == null) {
        if (XmlRpcWriter.EXTENSIONS_URI.equals(pURI)  &&  !cfg.isEnabledForExtensions()) {
          String msg = "The tag " + new QName(pURI, pLocalName) + " is invalid, if isEnabledForExtensions() == false.";
          throw new SAXParseException(msg, getDocumentLocator(),
                        new XmlRpcExtensionException(msg));
        } else {
          throw new SAXParseException("Unknown type: " + new QName(pURI, pLocalName),
                        getDocumentLocator());
        }
      }
      typeParser.setDocumentLocator(getDocumentLocator());
      typeParser.startDocument();
      if (text.length() > 0) {
        typeParser.characters(text.toString().toCharArray(), 0, text.length());
                  text.setLength(0);
      }
    }
    typeParser.startElement(pURI, pLocalName, pQName, pAttrs);
  } else {
    throw new SAXParseException("Invalid state: Not inside value tag.",
        getDocumentLocator());
  }
}

下断点调试一下,取到的是SerializableParser,它继承自ByteArrayParser,直接看一下startElement,使用Base64解码数据,解释了payload为什么用base64

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
public void startElement(String pURI, String pLocalName, String pQName, Attributes pAttrs) throws SAXException {
    if (level++ == 0) {
       baos = new ByteArrayOutputStream();
       decoder = new Base64.Decoder(1024){
          protected void writeBuffer(byte[] pBytes, int pOffset, int pLen) throws IOException {
             baos.write(pBytes, pOffset, pLen);
          }
       };
    } else {
       throw new SAXParseException("Unexpected start tag in atomic element: "
                            + new QName(pURI, pLocalName),
                            getDocumentLocator());
    }
}

然后就是如何触发反序列化的了,这里偷了个懒,直接在SerializableParser#getReault的readObject位置下断点看一下调用链,这样就清楚他是怎么触发到反序列化执行的了

image-20240110162038941

OFBit包含CB1链,所以直接传入base64 cb1就rce了

CVE-2023-51467

18.12.10版本直接把xmlrpc删了,但并未修复49070的登录权限绕过,所以这是利用登录权限绕过从而rce的另一个口子,利用到了groovy

前面分析了路由,所以这边直接进入org.apache.ofbiz.webapp.control.RequestHandler#doRequest

前面省略直接看这边,type为view所以进入这里

image-20240110211234039

进入renderView方法,跟进看一下如何获取视图,可以看到前面做一些处理之后这边通过view名字拿到视图为 component://webtools/widget/EntityScreens.xml#ProgramExport

image-20240110211946291

去对应页面看一下,对应ProgramExport如下,然后调用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,这里需要groovy的一些基本语法才能看懂,这里可以看出直接使用GroovyShell.evaluate()执行了传入的字符串变量

image-20240111161226068

0%