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:
|
|
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.
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.
This makes the flaw exploitable, and we can easily verify this by sending a request such as the following:
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:
T(java.lang.Runtime)
: This accesses the java.lang.Runtime classgetRuntime()
: This method returns the runtime object associated with the current Java applicationexec('calc')
: This method executes our payload
When we tried this payload on our target, the results were underwhelming… Just a 500 error!
Inspecting the debug log we see the following error:
|
|
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.
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).
Unfortunately this is not enough. When we try to run getRuntime()
we will get a new error:
|
|
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:
|
|
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:
|
|
Then we want the instance of Runtime:
|
|
And finally invoke the method exec
on it:
|
|
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:
|
|
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 :)