SSTI – Server-side template injection in a sandboxed environment

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.

  1. SSTI – Basic server-side template injection
  2. SSTI – Basic server-side template injection (code context)
  3. SSTI – Server-side template injection using documentation
  4. SSTI – Server-side template injection in an unknown language with a documented exploit
  5. SSTI – Server-side template injection with information disclosure via user-supplied objects
  6. >>SSTI – Server-side template injection in a sandboxed environment<<
  7. SSTI – Server-side template injection with a custom exploit

NOTE – Links will light up as posts become available.

Resources:

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

screenshot of standard store interface for the Lab: Server-side template injection in a sandboxed environment

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:

screenshot of logging in

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

screenshot of product page with Edit template button

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.

screenshot of simplified template

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.

screenshot of code execution with the ${7*7} payload

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}

screenshot of debug message showing FreeMarker template

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.
screenshot of current instance of product object

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:

screenshot of product object attributes listed

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 a Class object representing the runtime class of product. We’re now no longer dealing with a specific instance of a product, but rather now the class definition itself.
screenshot of product with getClass call

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

screenshot of attributes of getClass run on product

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 on Class objects. Returns a ProtectionDomain 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.
screenshot of and getProtectionDomain being called in the chain

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:

screenshot of attributes of getProtectionDomain

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 a CodeSource object, which represents the location from where the class was loaded.
screenshot of getCodeSource being called

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

screenshot of attributes of getCodeSource
  • getLocation() <– Returns a URL representing the location of the JAR or directory where the class was loaded from.
screenshot of getLocation being called

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

screenshot of attributes of getLocation
  • toURI() <– Converts the URL to a URI object for further path manipulation. Perfect. Now, we’ll have a URI instead of just a string that looks like a URI.
screenshot of toURI being called

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:

screenshot of attributes of getURI
  • 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.
screenshot of resolve getting called - error raised indicating the wrong number of arguments being passed - it should take a single string

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:

screenshot of getClass being called in the chain - java.net.URI is 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.

screenshot of resolve docs and what the input is (string)

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.

screenshot of resolve being called with /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:

screenshot of attributes of resolve with /etc/passwd being passed in
  • toURL() <– Converts the resolved URI back to a URL, 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:
screenshot of to URL being called and attributes shown
  • 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:
screenshot of openStream being called and showing attributes
  • readAllBytes() <– Reads all bytes from the stream into a byte array. Very promising. Let’s call it and see what we get:
screenshot of readAllBytes being called - error. Template states it wants a string but received a sequence

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:

screenshot of how to turn a sequence into a string using the join operator

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.
screenshot of join being called an a series of space delimited numbers being displayed

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

screenshot of getClass being called - shows java.io.BufferedInputStream

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

screenshot of docs on readAllBytes for BufferedInputStream - returns a byte array

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.

screenshot of /etc/passwd content being displayed

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.

screenshot of directory listing for /

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(" ")}
screenshot of payload and resultant bytes for reading the my_password.txt file. Also contains decoded bytes in cmd window

Grab the value from the file and submit!

screenshot of the lab being solved

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!

Leave a Reply