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

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

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

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.

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

This is a real picture – undoctored – bat dog.

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

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

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:

Result:

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.

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

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.

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?

Classic {{7*7}}
gives us:

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:

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:

Executing the updated payload via Caido Replay and we get:

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:

Updating the payload and running via Replay gives us this:

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:

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:

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

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?

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

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

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:

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:

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

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?

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)

Then view the comment:

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

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.

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()
:

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.

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!

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:

Set the avatar (and hit send)

Post a comment so we can execute the avatar code.

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’).

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

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

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!