Tools: Day 5 — Intentionally Building and Breaking an SSTI Vulnerability (Flask + Jinja2)

Tools: Day 5 — Intentionally Building and Breaking an SSTI Vulnerability (Flask + Jinja2)

What is SSTI? ## Goal of the Lab ## Part 1 — The Secure Version ## app_secure.py ## templates/message.html ## Why This Version is Secure ## Test Payload ## Part 2 — Intentionally Introducing the Vulnerability ## app_vulnerable.py ## What Changed? ## Verification ## Proof of Server Access ## Understanding the Vulnerability Chain ## Why the Bug Exists ## Key Lessons Learned ## 1. Framework Defaults Matter ## 2. Never Build Templates from User Input ## 3. SSTI is More Than a Web Bug ## 4. Security is About Context As part of my ongoing rebuild of fundamentals, Day 5 moved away from solving CTF machines and into something far more educational: I created my own vulnerable machine. For the past few days I had been identifying bugs in other people’s systems. Today I wanted to understand something deeper: Not just how to exploit vulnerabilities but how developers accidentally create them. The focus was Server-Side Template Injection (SSTI) using Flask and Jinja2. Instead of immediately making a vulnerable application, I deliberately built two versions: This made the lesson much clearer than reading theory. I wasn’t memorizing payloads anymore. I was watching security break in real time. Normally, a web server renders HTML templates and inserts user data into them. The server replaces {{ username }} with text. But if user input becomes part of the template itself, the server stops rendering a page and starts executing instructions. That is Server-Side Template Injection. In Jinja2, expressions inside {{ }} are evaluated as Python-accessible objects. So if an attacker controls what appears inside those braces, they may reach the Python runtime. I created a minimal “blog message page”: The server displays it back. Simple enough to understand… but powerful enough to demonstrate a full compromise chain. This small design let me practice: I first built the correct implementation. render_template() loads a static template file and passes variables separately. Jinja2 treats name and msg as data. It also auto-escapes HTML. Jinja refused to interpret the payload because the template structure was trusted and the input was untrusted. This was my first important realization: Flask is secure by default. I didn’t need to add filters, sanitizers, or regex. The framework’s design already prevented the vulnerability. I replaced render_template() with render_template_string() and constructed the template using an f-string. This small change is the entire vulnerability. The application now compiles a template at runtime using user data. The server evaluated the expression. The application was no longer a webpage. It had become a remote Python expression interpreter. The page printed Flask configuration values. That means the attacker can access server-side objects, not just HTML output. This is the critical difference between: XSS → affects users SSTI → affects the server Here is what actually happened internally: SSTI is dangerous because the attack does not stay in the web layer. It crosses directly into the application’s execution environment. Individually, these are safe: Combined incorrectly: They form a code execution pipeline. The vulnerability was not caused by a complex algorithm or memory corruption. It was caused by a design decision. Flask encourages safe patterns. Deviating from them introduces risk quickly. Templates must be static. Only variables should be dynamic. It is often a full system compromise entry point. A tool is not insecure by itself. It becomes insecure when used outside its intended model. Templates let you quickly answer FAQs or store snippets for re-use. Are you sure you want to ? It will become hidden in your post, but will still be visible via the comment's permalink. as well , this person and/or CODE_BLOCK: Hello {{ username }} CODE_BLOCK: Hello {{ username }} CODE_BLOCK: Hello {{ username }} CODE_BLOCK: User → sends text Server → prints text CODE_BLOCK: User → sends text Server → prints text CODE_BLOCK: User → sends text Server → prints text CODE_BLOCK: User → sends template code Server → executes it CODE_BLOCK: User → sends template code Server → executes it CODE_BLOCK: User → sends template code Server → executes it CODE_BLOCK: from flask import Flask, request, render_template app = Flask(__name__) @app.route('/') def index(): return """ <form action="/post" method="GET"> Name: <input type="text" name="name"><br> Message: <input type="text" name="msg"><br> <input type="submit"> </form> """ @app.route('/post') def post(): name = request.args.get('name', 'Anonymous') msg = request.args.get('msg', '') return render_template("message.html", name=name, msg=msg) if __name__ == '__main__': app.run(debug=True) CODE_BLOCK: from flask import Flask, request, render_template app = Flask(__name__) @app.route('/') def index(): return """ <form action="/post" method="GET"> Name: <input type="text" name="name"><br> Message: <input type="text" name="msg"><br> <input type="submit"> </form> """ @app.route('/post') def post(): name = request.args.get('name', 'Anonymous') msg = request.args.get('msg', '') return render_template("message.html", name=name, msg=msg) if __name__ == '__main__': app.run(debug=True) CODE_BLOCK: from flask import Flask, request, render_template app = Flask(__name__) @app.route('/') def index(): return """ <form action="/post" method="GET"> Name: <input type="text" name="name"><br> Message: <input type="text" name="msg"><br> <input type="submit"> </form> """ @app.route('/post') def post(): name = request.args.get('name', 'Anonymous') msg = request.args.get('msg', '') return render_template("message.html", name=name, msg=msg) if __name__ == '__main__': app.run(debug=True) COMMAND_BLOCK: <!DOCTYPE html> <html> <head><title>Message</title></head> <body> <h1>New Message</h1> <b>{{ name }}</b> says: <p>{{ msg }}</p> </body> </html> COMMAND_BLOCK: <!DOCTYPE html> <html> <head><title>Message</title></head> <body> <h1>New Message</h1> <b>{{ name }}</b> says: <p>{{ msg }}</p> </body> </html> COMMAND_BLOCK: <!DOCTYPE html> <html> <head><title>Message</title></head> <body> <h1>New Message</h1> <b>{{ name }}</b> says: <p>{{ msg }}</p> </body> </html> CODE_BLOCK: /post?name=test&msg={{7*7}} CODE_BLOCK: /post?name=test&msg={{7*7}} CODE_BLOCK: /post?name=test&msg={{7*7}} CODE_BLOCK: {{7*7}} COMMAND_BLOCK: from flask import Flask, request, render_template_string app = Flask(__name__) @app.route('/') def index(): return """ <form action="/post" method="GET"> Name: <input type="text" name="name"><br> Message: <input type="text" name="msg"><br> <input type="submit"> </form> """ @app.route('/post') def post(): name = request.args.get('name', 'Anonymous') msg = request.args.get('msg', '') template = f""" <!DOCTYPE html> <html> <head><title>Message</title></head> <body> <h1>New Message</h1> <b>{name}</b> says: <p>{msg}</p> </body> </html> """ return render_template_string(template) if __name__ == '__main__': app.run(debug=True) COMMAND_BLOCK: from flask import Flask, request, render_template_string app = Flask(__name__) @app.route('/') def index(): return """ <form action="/post" method="GET"> Name: <input type="text" name="name"><br> Message: <input type="text" name="msg"><br> <input type="submit"> </form> """ @app.route('/post') def post(): name = request.args.get('name', 'Anonymous') msg = request.args.get('msg', '') template = f""" <!DOCTYPE html> <html> <head><title>Message</title></head> <body> <h1>New Message</h1> <b>{name}</b> says: <p>{msg}</p> </body> </html> """ return render_template_string(template) if __name__ == '__main__': app.run(debug=True) COMMAND_BLOCK: from flask import Flask, request, render_template_string app = Flask(__name__) @app.route('/') def index(): return """ <form action="/post" method="GET"> Name: <input type="text" name="name"><br> Message: <input type="text" name="msg"><br> <input type="submit"> </form> """ @app.route('/post') def post(): name = request.args.get('name', 'Anonymous') msg = request.args.get('msg', '') template = f""" <!DOCTYPE html> <html> <head><title>Message</title></head> <body> <h1>New Message</h1> <b>{name}</b> says: <p>{msg}</p> </body> </html> """ return render_template_string(template) if __name__ == '__main__': app.run(debug=True) CODE_BLOCK: Template + Variables CODE_BLOCK: Template + Variables CODE_BLOCK: Template + Variables CODE_BLOCK: User Input → Python String → Jinja Template CODE_BLOCK: User Input → Python String → Jinja Template CODE_BLOCK: User Input → Python String → Jinja Template CODE_BLOCK: /post?name=test&msg={{7*7}} CODE_BLOCK: /post?name=test&msg={{7*7}} CODE_BLOCK: /post?name=test&msg={{7*7}} CODE_BLOCK: 49 CODE_BLOCK: /post?name=test&msg={{config}} CODE_BLOCK: /post?name=test&msg={{config}} CODE_BLOCK: /post?name=test&msg={{config}} CODE_BLOCK: HTTP Request ↓ Python String ↓ Jinja Template Engine ↓ Python Runtime ↓ Operating System CODE_BLOCK: HTTP Request ↓ Python String ↓ Jinja Template Engine ↓ Python Runtime ↓ Operating System CODE_BLOCK: HTTP Request ↓ Python String ↓ Jinja Template Engine ↓ Python Runtime ↓ Operating System - A secure implementation - A vulnerable implementation - Flask routing - request handling - Jinja templating - and most importantly — secure vs unsafe patterns - User input enters Python - Python f-string embeds it - Jinja parses the string - Jinja evaluates expressions - Expressions access Python objects - Python f-strings - Jinja templates