Server-Side Template Injection: Testing the Template Boundary
How I identify server-side template injection, reproduce it in a small Flask lab, and fix the separation between template source and data.

Contents
Summary
Server-side template injection happens when an application lets untrusted input become template source. The engine then interprets that input instead of treating it as data.
I care about the boundary where this happens. A user-controlled name rendered by a fixed template is ordinary application behavior. Concatenating that name into a template and compiling the result is a different operation.
The possible impact ranges from unexpected calculations and information disclosure to server-side code execution. It depends on the engine, exposed objects, configuration, and privileges. I establish those conditions rather than promising that every SSTI leads to a shell.
How it works
A template engine combines a template with values supplied by the application. In Flask, Jinja renders placeholders and control structures into a response.
The dangerous case is building the template itself from user input. For example, an application might concatenate a submitted value into a greeting, then pass the entire string to render_template_string.
HTML escaping does not fix this mistake. Escaping is intended to protect an output context, such as HTML text. It does not prevent the template engine from evaluating expressions that have already become part of its source.
I keep engine identification separate from detection. Jinja, Twig, FreeMarker, and other engines have different syntax and behavior. One successful arithmetic probe is a useful observation, but it is not a complete engine fingerprint. The PortSwigger SSTI overview describes this distinction.
Conditions and affected versions
This article covers an unsafe application pattern, not a specific CVE or affected version range. A current template engine can still be used unsafely if an application compiles untrusted strings.
For the lab, I need Python 3, Flask, and its Jinja dependency. I run it locally in a disposable working directory. I do not expose Flask's development server to a public interface.
python3 -m venv .venv
source .venv/bin/activate
python -m pip install Flask
python -m pip show Flask Jinja2
Reproduce in a lab
Authorized testing only
Only run this against systems you own or have written permission to test.
Create a vulnerable route and a comparison route
I save this as app.py:
from flask import Flask, render_template_string, request
app = Flask(__name__)
@app.get("/unsafe")
def unsafe():
name = request.args.get("name", "reader")
return render_template_string("Hello " + name)
@app.get("/safe")
def safe():
name = request.args.get("name", "reader")
return render_template_string("Hello {{ name }}", name=name)
The unsafe route lets name change the template source. The comparison route keeps the template fixed and supplies name as a value. I start the server without debug mode:
flask --app app run --host 127.0.0.1 --port 5000
In another terminal, I send the same small expression to both routes:
curl -sS -G --data-urlencode 'name={{7*7}}' \
http://127.0.0.1:5000/unsafe
curl -sS -G --data-urlencode 'name={{7*7}}' \
http://127.0.0.1:5000/safe
The intended distinction is evaluation in the unsafe route and literal input in the fixed-template route. I preserve the actual responses when running the lab.
Identify the engine and context
After confirming evaluation, I inspect the application dependencies or authorized error information. A template expression that behaves differently across engines can help, but I avoid turning one result into certainty.
The original article used {{7*'7'}} to compare behavior. In a normal Jinja environment, string repetition is different from numeric multiplication. Type handling can vary across engines and versions, so I record the engine and result together.
I use small probes. Large calculations or repeated expansion can consume resources, but deliberately exhausting memory is unnecessary for confirming this boundary. My older screenshots suggested increased memory use; they did not provide enough measurement to quantify a denial-of-service result.
Impact
The first confirmed impact in the lab is expression evaluation. Further impact depends on what the template can access.
An application that exposes configuration, request objects, or powerful helpers may give an attacker more useful capabilities than a minimal rendering environment. Even without code execution, disclosing secrets through template-accessible values can be serious.
My earlier notes showed an OWASP Security Knowledge Framework lab and a later shell demonstration. I keep those as historical observations rather than treating their payload as portable.
The old payload relied on Python object traversal with fixed numeric indexes. Those indexes depend on runtime details. One expression also used a method-resolution index that is invalid for the Python 3 string hierarchy. It should not be copied as a working exploit for current Flask.
When documenting escalation, I explain which exposed object enabled it and what the process could access. A screenshot of a shell without the environment and request leaves out the part another practitioner needs to reproduce.
Detection
During review, I search for code that builds template strings from request data, stored user content, or externally supplied configuration. Stored input matters too: the unsafe rendering can happen later in an administrative page or background job.
Application logs can show template syntax errors and unexpected expressions. Those are investigation leads. I correlate them with the affected route and source code rather than relying on a keyword alone.
A regression test should verify that an expression-looking input stays data. For the safe route above, I check that the literal expression survives instead of becoming a calculated result.
Fix and mitigation
I use a fixed template and pass untrusted values through the template context. For ordinary pages, that usually means a template file rendered with named arguments.
I keep output encoding enabled for the final output context. This protects against a different problem鈥攕uch as HTML injection鈥攁fter I have fixed the template-source boundary. Flask documents its defaults and rendering behavior in Templates.
If the product intentionally accepts user-authored templates, I treat that as a separate feature with a constrained execution environment. Jinja's sandbox documentation describes controls and remaining resource concerns. I also limit the data exposed to templates and apply process-level resource limits.
Automation and further practice
The original post linked tplmap for automated testing. I check its supported engines and compatibility before using it in a current environment. Automation can suggest a path, but it does not replace explaining the source of evaluation or verifying the impact.
For practice, my MuzzyBox challenge includes template-injection work. The original demonstration video provides historical context; it is not a promise that the same command works against every modern runtime.
I close an SSTI finding when the input remains data, the original proof no longer evaluates, and the application still performs its intended rendering. That is a more useful endpoint than collecting one more payload.
Ask or Share Thoughts
Your message goes straight to me. Email is optional. The form records your IP address and browser information.




