SSTI – Server-side template injection with a custom exploit

This one was definitely a step up in complexity. This is the 7th (and final as of Feb 2025) Server-Side Template Injection lab available on the PortSwigger Web Security Academy. I really enjoyed the series and intend on doing additional SSTI investigation across other platforms and CTFs.

This one was fun – a true puzzle. This specific lab requires a bit of a deeper understanding of the underlying code and framework. It includes recon, exploiting an LFI (Local File Inclusion), triggering server-side to display an overly verbose error message, and then putting it all together to ultimately solve the lab. This is the kind of vulnerability that maybe won’t be a caught by basic scanners as the standard wordlist payloads just break the code rather than giving execution. That means this is the kind of vulnerability that might actually lead to a unique bug bounty and avoid the dreaded duplicate.

Here are all 7 labs in the series:

  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<<

Let’s get rolling on the final lab from the PortSwigger Web Academy labs on the topic.

Lab: Server-side template injection with a custom exploit

Step 1: Recon

Let’s pop open Chromium being proxied through Caido and start poking at the site.

a screenshot of the standard blog interface for the labs. This is lab: server-side template injection with a custom exploit

We get the standard blog interface for these labs. What other functionality do we have?

a screenshot of blog comment functionality

As an unauthenticated user, all I can really do is post anonymous comments. So, let’s login:

a screenshot of logging in with lab creds

Now that we are logged in, we can see that we get some interesting functionality. Not only can we change our email and our preferred name, but we can also now upload an avatar image.

a screenshot of logged in and the user panel. new functionality here is setting an avatar via image

Let’s do exactly that. I am just going to grab a picture of one of our dogs – Birdie.

a screenshot of uploaded image avatar

This is a real picture – undoctored – bat dog.

a screenshot of a shitzu poodle mix with her eyes closed

Anyway, uploading a picture works just fine. Now, let’s try to upload not a picture.

a screenshot of setting the file to a README.md

This is a markdown file – basically just a text file from one of the various git repos I have cloned to my machine.

a screenshot of error message stating mine type is wrong, the path of /home/carlos/User.php for the file, and User->setAvatar() with two params passed in

The app does not like this. It throws a WAY too informative error. First off, the file is not of the right type (mime type) which we knew. Second, it tells us the path to the code we are currently executing (‘/home/carlo/User.php’), and it gives us what looks to be a function call associated with a User class object. This is juicy. We’ll have to circle back to this and figure out how to use this info later on in the lab.

For now, I set the avatar back. Let’s try to add a comment:

a screenshot of submitting a test comment on the blog

Result:

a screenshot of the test comment on the blog with the byline

We can see here that we get the avatar and then what looks to be the full name of the user in the comment header.

a screenshot of setting the preferred name to nickname instead of name for our user

Let’s change it to Nickname and see what we get in Caido:

a screenshot of caido showing the POST for setting the avatar. user.nickname is specified

Here, we can see the author display is getting set to “user.nickname”. Again, we have a user object and we’re hitting what looks to be a property of the class.

a screenshot of the comment posted previously only now the byline shows the nickname for the user

Going back to the comment, we can see the display name has changed from the full name of the user to what looks to be a nickname. What if we try to stuff an SSTI payload through the field?

a screenshot of setting the payload to {{7*7}} instead of user.nickname

Classic {{7*7}} gives us:

a screenshot of error message - unexpected toke "punctuation" of value "{"

Looks like we’re dealing with PHP (knew that from the error before) and Twig as a templating engine.

PayloadsAllTheThings: https://github.com/swisskyrepo/PayloadsAllTheThings/blob/master/Server%20Side%20Template%20Injection/PHP.md#twig—basic-injection

From PayloadsAllTheThings, {{7*7}} is a potential payload, so let’s look at the error a little more in depth. It clearly states there is an unexpected ‘{‘. Let’s think about that – our payload {{7*7}} starts with a ‘{‘. What if the backend code looks something like this:

{{ whatever < injection > }}

Therefore, when we do an injection we get this:

{{ whatever  {{7*7}} }}

If that is the case, it makes sense that the leading ‘{‘ would be an issue. Chances are the extra ‘}}’ will cause an issue as well. Let’s try and tweak the payload to look like this:

}}{{7*7

Which would result in the backend code looking something like this:

{{ whatever   }}{{7*7}}

Let’s see if that works:

a screenshot of error message calling out "" as an issue with unexpected toke "end of print statement"

No, but we did get a different error. Now, the error is complaining about an empty string. Since we failed to actually provide a string or any content to the “whatever” side of the backend code, it is possible this is an empty string and causing the error. Let’s tweak again:

a }}{{7*7

This way, the resulting backend code would look like this:

{{ whatever   a}}{{7*7}}

Let’s give a shot:

a screenshot of adding an 'a' to the beginning of the payload such that there is now a string being provided

Executing the updated payload via Caido Replay and we get:

a screenshot of 49 being displayed in the name field - the result of 7*7

Bam! We have code execution. We have successfully identified the template engine (Twin on PHP) and have started to craft an exploit.

Step 2: Exploring

Now, let’s see if we can get more information. What is an object we can play with? All we know at this point is that we have a user object. Let’s see if we can render the object:

a screenshot of a}}{{user being sent in the payload

Updating the payload and running via Replay gives us this:

a screenshot of error because Object of class User could not be converted to string

The error states that a string is needed here and an object of class User was provided. Is there a way to do a join or force a toString()? Looking at the payloads on PayloadsAllTheThings, it turns out there is a ‘join’ we can utilize here:

a}}{{user|join(',')

Which gives us:

a screenshot of comment after |join operator was used. It shows all of the properties of the class instance for the current user

Nice! We can see all of the values for our user’s specific instance of the user object. Are there other operators we can use on the object? I played here for a bit and kept getting results like this:

a screenshot of error - unknow "filter" filter.

There was more testing to do here, but I decided to move on for now. What else do we know about the user object?

same as before - a screenshot of error message stating mine type is wrong, the path of /home/carlos/User.php for the file, and User->setAvatar() with two params passed in

Going back to the error message we saw early on in the recon, there was a reference to a setAvatar method on the User class. Can we run it?

a screenshot of the payload a}}{{user.setAvatar() being sent

Let’s stuff it into Replay and give it a shot:

a screenshot of error - Too few arguments to function

Based on above, 2 parameters:

  • File – looks to take a file from the local file system. Can see it was /tmp/whatever
  • Mime Type – probably needs to be an image
a screenshot of arguments being passed into the function. passing in /etc/passwd and a mime type of 'image/png'

Now, we should have satisfied the mime type for sure. Will the function let us pass it an arbitrary file? Let’s go and check out our comment:

a screenshot of the test comment with a broken image

Broken image – that’s OK, though, since the image would obviously not work. The browser is not going to be able to render ‘/etc/passwd’ as an image. Let’s check the HTTP History in Caido to see what we see:

a screenshot of enabling seeing images in HTTP history within Caido

First, we have to allow image retrieval to be shown in the history log. Then, we just need to find the image GET:

a screenshot of the image GET within Caido. We can see the contents of /etc/passwd in the Response

Look at that – gold. We have successfully retrieved the ‘/etc/passwd’ file. This is fantastic!

Step 3: Additional Recon

To solve the lab, create a custom exploit to delete the file /.ssh/id_rsa from Carlos’s home directory. So far, all we can do is read arbitrary files. While this may be enough to get a decent bounty, this still isn’t the same as being able to manipulate the machine or get full code execution. For this lab, we need to be able to delete the target file so we have to do some additional digging.

How do we move past reading a file (which is great) to either full RCE or at least file deletion. I dug a lot here and failed to find anything (that worked) within Twig that would allow for a file to be deleted. Other than LFI (Local File Inclusion), the sandbox seems intact.

It’s a good time to step back and understand what we have so far:

  • Code: PHP with Twig
  • We can read files
    • Are there any special files we know about?
same as before - a screenshot of error message stating mine type is wrong, the path of /home/carlos/User.php for the file, and User->setAvatar() with two params passed in

Yes => /home/carlos/User.php. Let’s get the contents of the file. All we have to do is specify the file within our Replay payload (and send)

a screenshot of the payload a}}{{user.setAvatar('/home/carlos/User.php','image/png') being sent

Then view the comment:

a screenshot of viewing the test comment - again, the image is broken

And then look in the log to grab the contents of the file:

a screenshot of viewing the response for the image GET in Caido - User.php returned

There it is! Here’s the full file for reference:

<?php

class User {
    public $username;
    public $name;
    public $first_name;
    public $nickname;
    public $user_dir;

    public function __construct($username, $name, $first_name, $nickname) {
        $this->username = $username;
        $this->name = $name;
        $this->first_name = $first_name;
        $this->nickname = $nickname;
        $this->user_dir = "users/" . $this->username;
        $this->avatarLink = $this->user_dir . "/avatar";

        if (!file_exists($this->user_dir)) {
            if (!mkdir($this->user_dir, 0755, true))
            {
                throw new Exception("Could not mkdir users/" . $this->username);
            }
        }
    }

    public function setAvatar($filename, $mimetype) {
        if (strpos($mimetype, "image/") !== 0) {
            throw new Exception("Uploaded file mime type is not an image: " . $mimetype);
        }

        if (is_link($this->avatarLink)) {
            $this->rm($this->avatarLink);
        }

        if (!symlink($filename, $this->avatarLink)) {
            throw new Exception("Failed to write symlink " . $filename . " -> " . $this->avatarLink);
        }
    }

    public function delete() {
        $file = $this->user_dir . "/disabled";
        if (file_put_contents($file, "") === false) {
            throw new Exception("Could not write to " . $file);
        }
    }

    public function gdprDelete() {
        $this->rm(readlink($this->avatarLink));
        $this->rm($this->avatarLink);
        $this->delete();
    }

    private function rm($filename) {
        if (!unlink($filename)) {
            throw new Exception("Could not delete " . $filename);
        }
    }
}

?>

Looks like we have deletes – it’s almost like this is a lab and we are given exactly what we need to solve the lab. 🙂

gdprDelete() calls rm() as well as delete(). Since this code defines the user class, we should be able to call user.gdprDelete() via the same ‘blog-author-display parameter’ in the payload we have been using this entire time. No params required. This will rm() the avatarLink which is the file specified in as the avatar.

Step 4: Solve

As it sits right now, the avatarLink for the wiener account is pointed at the ‘/home/carlos/User.php’ file. Let’s point it at ‘/home/carlos/.ssh/id_rsa’ (the target for the lab) and then call the gdprDelete() function.

a screenshot of the payload pointing at the /home/carlos/id_rsa file

Note – make sure you hit send here and don’t just take a screenshot like I did. 🙂

And now, all we have to do is execute the gdprDelete():

a screenshot of calling the user.gdprDelete() method

And – boom. The lab blows up. I was too busy documenting the steps that I actually forgot (I think) to submit the payload to set the avatar file to the private SSH key for the carlos user.

a screenshot of error message - User.php failed to open

Since I think I forgot to submit the payload to set the avatar, I believe it was still pointed at ‘/home/carlos/User.php’. The error above says that it can’t open ‘User.php’ so it looks like our delete worked. Yay!

a screenshot of Account disabled message - unable to login

The ‘wiener’ user could no longer even log in. The rest of the code executing including the delete() which disabled the test account. The good news here is that we’re dealing with a lab and all we have to do is wait 20 minutes for the current lab to expire. At that point, we can spin up a new instance.

New lab:

a screenshot of the payload of setting the avatar to /home/carlos/id_rsa

Set the avatar (and hit send)

a screenshot of a comment being submitted

Post a comment so we can execute the avatar code.

a screenshot of the test comment with a broken image

View a comment so that the avatar code executes. At this point, the avatar is now set to the target file (‘/home/carlos/id_rsa’).

a screenshot of the payload to execute the gdprDelete() method

Set the code for the avatar to the gdprDelete() method.

a screenshot of the test comment with a broken image

View the comment to execute the avatar code and then refresh the page:

a screenshot of the lab showing as solved

Solved!

Summary

This lab was a solid reminder that sometimes the path to exploitation requires a mix of creativity and a deep dive into the underlying code. In this case, combining recon, LFI, and custom payloads allowed us to turn an overly verbose error message into full code execution—even if it was just to delete a file.

Lessons learned:

  • Verbose errors are gold: Detailed error messages can reveal file paths and code structures that lead to deeper vulnerabilities.
  • Always probe beyond the obvious: Features like avatar uploads can hide unexpected functionalities, such as exposing internal methods or objects.
  • Layered exploitation works: Using multiple techniques in tandem—SSTI, LFI, and custom method invocation—can break even well-sandboxed systems.
  • Persistence pays off: Tweaking payloads and understanding the templating engine (Twig in this case) are crucial steps toward a successful exploit.

Happy Hacking!

Leave a Reply