June 12, 2026
SSTI: When the Server Runs What You Type
You entered a name. The server treated it as code. Learning how to identify and fingerprint Server-Side Template Injection vulnerabilities.
0x4rt1st
4 min read
New series. New vulnerability class. If you've been following the SSRF series — we're done with that. This one is about something different, but the core idea is familiar: user input ending up somewhere it was never supposed to go.
Template Engines, Quickly
Before getting into the vulnerability, you need to know what a template engine is. Not deeply — just enough.
Most web applications don't serve static HTML. They have pages that look the same structurally but change based on who's logged in, what data exists, what time it is. To handle that, developers use template engines. You write a template — a skeleton of a page — and mark the spots where dynamic content goes. The engine fills those spots in at runtime.
A simple Jinja template looks like this:
Hello {{ name }}!Hello {{ name }}!You pass name = "Alex" to the rendering function, it comes out as Hello Alex!. The double curly braces are how Jinja says "put a value here." Other engines use slightly different syntax, but the idea is the same.
The key thing: the engine doesn't just do text replacement. It actually evaluates what's inside those delimiters. Variables, conditions, loops, math — it all runs. Which means if an attacker can get their input inside the template itself instead of being passed as a value, the engine will evaluate that too.
That's SSTI. Server-Side Template Injection.
How It Actually Happens
There's a right way and a wrong way to use a template engine. The difference is one line of code — but the impact is not.
The right way: user input goes into the values passed to the rendering function. The template stays fixed.
# Safe — user input is a value, not part of the template
name = request.args.get("name")
template = "Hello {{ name }}!"
render_template_string(template, name=name)# Safe — user input is a value, not part of the template
name = request.args.get("name")
template = "Hello {{ name }}!"
render_template_string(template, name=name)Here the engine takes the template, finds {{ name }}, and replaces it with whatever string you passed in. The user's input never touches the template itself. Even if someone types {{7*7}} in the name field, it just gets printed as text — because it's a value, not template code.
The wrong way: user input gets concatenated directly into the template string before rendering.
# Vulnerable — user input becomes part of the template
name = request.args.get("name")
template = "Hello " + name + "!"
render_template_string(template)# Vulnerable — user input becomes part of the template
name = request.args.get("name")
template = "Hello " + name + "!"
render_template_string(template)Now the engine sees the full string — including whatever the user typed — as the template. If someone passes {{7*7}}, the template becomes Hello {{7*7}}!, and the engine evaluates it. The user just ran code on your server.
The Lab
Simple Test Server. One input field — "Enter your name." You type something, hit Submit, and the page greets you with it.
That string — ${{<%[%'"}}%. — is the standard SSTI probe. It's not trying to do anything useful. It's just throwing every special character that means something in popular template engines at the input all at once. The goal is to break the template syntax and see if the server chokes.
500 Internal Server Error. The server crashed trying to process that input. That alone doesn't confirm SSTI — but something in that string upset the backend. Worth going further.
Figuring Out Which Engine
Here's where it gets methodical. Different template engines use slightly different syntax, and they handle certain inputs differently. That difference is what lets you fingerprint which one you're dealing with — and you need to know, because the exploit depends entirely on it.
There's a well-known decision tree for this. You start with one payload, look at what the server does with it, and follow the path based on whether it executed or not:
We start with ${7*7}.
Printed back as-is. The ${} syntax didn't do anything here. Follow the red arrow — try {{7*7}} next.
- The engine evaluated
7*7and returned the result. That's not text substitution — the server ran our input as code. SSTI confirmed.
Now the last step: tell Jinja from Twig. Both use {{}} syntax but they handle {{7*'7'}} differently. Jinja multiplies a string by a number by repeating it — 7777777. Twig treats it as numeric multiplication and gives 49.
49 — and we already confirmed from the lab context this is a twig application. So we know the engine. We know the injection works. Time to actually use it.
What This Means
SQL injection is about breaking out of a query. XSS is about injecting into a page a browser renders. SSTI is about injecting into a template an engine executes — on the server, with whatever access the server process has.
That's why the impact ceiling is high. Template engines aren't sandboxed. Depending on the engine and how it's configured, you can walk from a math expression like {{7*7}} all the way to reading files, running system commands, getting a full shell. The next parts show exactly how that walk happens.
Next part — exploiting SSTI in Jinja2. From confirmed injection to command execution.