Continuing the series on Server-Side Template Injection (SSTI) based on the PortSwigger Web Security Academy labs. This is the 6th in the series and we’re stepping up to an expert level lab. We’ve moved past rudimentary injection attacks and moved into labs where we need to have an understanding of the template language underpinning the web app. We’re now firmly into real-world methods for trying to identify SSTI vulnerabilities.
- SSTI – Basic server-side template injection
- SSTI – Basic server-side template injection (code context)
- SSTI – Server-side template injection using documentation
- SSTI – Server-side template injection in an unknown language with a documented exploit
- SSTI – Server-side template injection with information disclosure via user-supplied objects
- >>SSTI – Server-side template injection in a sandboxed environment<<
- SSTI – Server-side template injection with a custom exploit
NOTE – Links will light up as posts become available.
Resources:
- Lab: https://portswigger.net/web-security/server-side-template-injection/exploiting/lab-server-side-template-injection-using-documentation
- PayloadsAllTheThings SSTI: https://github.com/swisskyrepo/PayloadsAllTheThings/tree/master/Server%20Side%20Template%20Injection
Lab: Server-side template injection in a sandboxed environment
This lab has a slightly different focus than previous ones. We’re not looking to get full RCE (remote code execution); rather, we’re trying to read a specific file within Carlos’s home directory.
- Read my_password.txt from Carlos’s home directory
- Submit to solve the lab
Let’s see if we can figure out where templates are in use, what type of template engine we’re dealing with, and then see if we can enumerate the file system and then ultimately discover this file and exfil the contents. The goal here is to not drive straight towards the solution to the lab, but rather get to the answer in a methodical way such that we would have discovered this file and made off with the contents in a real world scenario.
Let’s begin!
Lab: Server-side template injection in a sandboxed environment
As always we’re going to have to start with recon.
Step 1: Recon

We see here that we have the standard store interface we get as part of these PortSwigger labs. Poking around unauthenticated doesn’t yield anything interesting as far as SSTI goes, so let’s login:

Post login, navigating back to a product page shows us the “Edit template” button just as in previous labs.

Let’s edit the template and then get rid of the noise. All we’re really interested in is where we can see the actual functionality of the template. Delete all of the rest and then save. Navigate back into “Edit template” screen and now it looks nice and clean.

Let’s see if we can get code execution. We see that this template engine uses the ${ }
syntax. Let’s start with the standard ${7*7}
and see what we get.

We have coded execution. As we can see, the template did the math. Now, let’s see what kind of template engine we’re dealing with. Feed it random characters. Feed it keywords. In this case, I feed it ${debug}

Awesome. Here we see that we are using Java FreeMarker. Now, let’s poke at it some more.
Step 2: Verify SSTI
I like going to PayloadsAllTheThings and using their SSTI payloads. Let’s navigate there.
PayloadsAllTheThings-Freemarker: https://github.com/swisskyrepo/PayloadsAllTheThings/blob/master/Server%20Side%20Template%20Injection/Java.md#freemarker
Here’s one of the first payloads listed. It’s a bit long with a chain of function calls.
${product.getClass().getProtectionDomain().getCodeSource().getLocation().toURI().resolve('path_to_the_file').toURL().openStream().readAllBytes()?join(" ")}
Convert the returned bytes to ASCII
Unfortunately, this is essentially the answer to the lab. That does tend to happen – these Web Security Academy labs are very popular and a simple Google search will often give you the exact answer (if you try to avoid). With that being said, this may essentially lead us directly to the solve, but let’s step back and make sure we understand the payload. How could we have figured this out ourselves?
Step 3: Engineer the Exploit
If I were to tackle this myself, it would have been an exercise in recursion. During bug bounty, you don’t have a specific goal like you do in these labs. As such, I would have wanted to recursively explore all of the possible attributes and functions of the ${product} object to see what we can see. Let’s walk the object.
product
<– The initial object in the FreeMarker template. This could be any provided object but the lab gives us${product}
so let’s start there.

Here we can see we have the product object. After looking at the FreeMarker docs, I see we can walk the attributes for the product object with the ?keys operator along with the ?join operator for readability. Here are the attributes:

We can see that we have attributes and ‘getters’. I would take this list and recursively explore each one of them until the tree dead ends. The one of interest here is the getClass()
call.
getClass()
<– Returns aClass
object representing the runtime class ofproduct
. We’re now no longer dealing with a specific instance of a product, but rather now the class definition itself.

In the same way we look at the options available on the product instance, let’s look at the class object. Attributes:

Big list, but that’s ok. In bug bounty, this big list is a gold mine because there is most likely some sort of vulnerability down one of these paths. Again, I would recursively explore all of these to the end. It would take a long time, but that’s where the pay day will live.
getProtectionDomain()
<– Available onClass
objects. Returns aProtectionDomain
that contains security-related information about the class. Security objects often are great spots to get things like api or crypto keys. Sometimes they contain other odd and unexpected functionality.

We get some interesting security information here. We now know we’re most likely dealing with a Linux OS since the jar file is located within /opt/jars
. That was obviously probably going to be the case, but it is good to confirm. Attributes:

In this case, we see all of the attributes and ‘getters’ associated with the getProtectionDomain() call. Any one of these could lead us to something good.
getCodeSource()
<– Returns aCodeSource
object, which represents the location from where the class was loaded.

Notice that this one returns a URI for the jar file and information about certifications. The URI is interesting. Attributes:

getLocation()
<– Returns aURL
representing the location of the JAR or directory where the class was loaded from.

This call returns what looks to be just the URI. Attributes:

toURI()
<– Converts theURL
to aURI
object for further path manipulation. Perfect. Now, we’ll have a URI instead of just a string that looks like a URI.

Looks no different from the results of getLocation, right? The difference can be seen if we call getClass().getLocation()
returns class java.net.URL, whereas getURI()
returns class java.net.URI. Attributes:

resolve('path_to_the_file')
<– Resolves the given relative file path (path_to_the_file
) against the base URI. This constructs a new URI pointing to that file.

Here’s where this recursive exploration can take a long time. Some of these function calls require parameters to be passed in. For these, we need to resort to reading documentation to figure out what information needs to be passed in for the functional call to execute. First, let’s get the class:

Great – now we can Google this exact class and get the various different calls available on the class. With that, we can look at the specific one we are interested in – in this case, it’s the resolve call.

We can see that it takes a string parameter. Since we’ve been dealing with local files so far (e.g. /opt/jars/freemarker.jar) let’s point the resolve call at a local file. For fun, let’s go for /etc/passwd.

This didn’t throw an error, so let’s keep going. Earlier in the chain we had a URL object that pointed at ‘/opt/jars/freemarker.jar’. Now, we have a URI object pointed at ‘/etc/passwd’. Attributes:

toURL()
<– Converts the resolvedURI
back to aURL
, making it accessible for reading. Now, we’ve come full circle. We’re still not sure if we can do anything with this URL object, however, we’re exploring all options. Let’s get the attributes:

openStream()
<– Opens an input stream to the file, allowing it to be read as raw bytes. Sweet – this makes it look like we might be able to read the file. Calling this returns a ‘BufferedInputStream’ object. Let’s get the attributes:

readAllBytes()
<– Reads all bytes from the stream into a byte array. Very promising. Let’s call it and see what we get:

Reading the error, the template was not able to render whatever was returned because it is expecting a string. However, it looks like the readAllBytes() call returns a sequence. What’s a sequence? Can we convert that into a string? Quick Internet search and:

Great – we’ve been using the join operator all along!
?join(" ")
<– Joins the byte array into a space-separated string, effectively outputting the ASCII values of the file’s contents.

Fantastic! But, what do we do with all of these numbers? What’s the class?

Google ‘java.io.BufferedInputStream’ to look at the docs:

Returns a byte array and it implicitly converts the byte array to integers. Lucky for us, these integer values actually align with the ASCII values for the chars. With the join operator, we get a space-separated string. Now, all we have to do is convert the ASCII string to readable text.
TODO – Need to research doing this in Caido. Possibly a contribution to the ‘Convert Tools’ plugin.
Regardless, we can grab the space delimited string of characters and use whatever scripting language to convert them into readable text.
$asciiValues = "ascii values here"
$decodedText = ($asciiValues -split " " | ForEach-Object { [char][int]$_ }) -join ""
Write-Output $decodedText
For me, PowerShell FTW. Super easy to convert this byte content into a readable format.

Bam! We have the ‘/etc/passwd’ file.
Bonus learning here – the exact same payload we are using to read files can be used to do directory listings as well:
${product.getClass().getProtectionDomain().getCodeSource().getLocation().toURI().resolve('/').toURL().openStream().readAllBytes()?join(" ")}
This payload will try to resolve('/')
which is the root file system.

We see here on the left that we get the directory listing for ‘/’. This method can be used to recursively walk the file system and find our target file for the lab.
Step 4: Solve
With the technique above, we can see that we have a ‘home’ directory. It’s now trivial to walk the file system and ultimately read the my_password.txt file within Carlos’s home directory.
${product.getClass().getProtectionDomain().getCodeSource().getLocation().toURI().resolve('/home/carlos/my_password.txt').toURL().openStream().readAllBytes()?join(" ")}

Grab the value from the file and submit!

Summary
In this lab, we demonstrated how to leverage SSTI in a sandboxed environment to recursively explore the application and ultimately read sensitive files. Key lessons learned include:
- Recon and Template Engine Identification:
Identify injection points and determine the underlying template engine (FreeMarker) using simple test expressions. - Recursive Object Exploration:
Enumerate available properties and methods on objects (e.g.,${product}
) to recursively discover exploitable attributes. - Iterative Exploitation Strategy:
Chain function calls (e.g.,getClass()
,getProtectionDomain()
,getCodeSource()
, etc.) and refine your approach with documentation to build a complete SSTI exploit chain.
Happy Hacking!