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
Walk me through it- 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 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')()}}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 2List the directorySwap the command from
idtolsto 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')()}}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 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.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')()}}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.
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.