Bug Bounty Labs · methodology #01 · SSTI

Server-Side Template Injection

The seven-times-seven tell.

One probe separates a harmless string from remote code execution. If the server hands back 49, it didn't echo your input — it ran it. That is SSTI, and this page lets you trigger it yourself.

PortSwigger #1 · 2025 Jinja2 · Twig · Freemarker · ERB Impact: RCE

Interactive lab

Send a probe. Watch it get evaluated.

This is a real (arithmetic-only, sandboxed) template engine — no eval, nothing leaves your browser. Fire a payload at the vulnerable server, then flip it to patched and send the same thing. The difference is the whole technique.

detection console
target server:
renders user input inside a template
{{7*7}}
You
attacker
Server
template engine
Response
 
Authorized use only. The BugBounty skill's first rule: test only systems you own or have explicit written permission to test, stay in program scope, and never exfiltrate real user data. This sandbox exists to teach the detection logic — not to point it at anyone.

The core idea

Reflection prints. Evaluation runs.

A web app becomes injectable the moment it drops user input into the template source instead of passing it as data to a finished template. The engine can't tell your text from the developer's — so it parses your braces too.

▲ Vulnerable — input becomes template
render_template_string("Hi " + name)
name = {{7*7}} Hi 49
▨ Safe — input stays as data
render_template("hi.html", name=name)
name = {{7*7}} Hi {{7*7}}
Why 49 and not {{7*7}}? In the safe version the braces are just characters in a string argument — printed verbatim. In the vulnerable version they land in the template grammar, so 7*7 is arithmetic the engine performs. From there, 7*7 is just the friendly demo — the same injection point reaches Python/Ruby/Java internals.

Technique #1 · detection flow

How a hunter actually confirms it.

Straight from the skill file — including the clever part: you don't need the output reflected at all. A changed error is enough to prove the engine is parsing you (that's the "Successful Errors" of the title).

1
Find reflected inputAny user-controlled value that reappears — GET/POST param, header, cookie, filename, even a username.
2
Send a math probeTry each syntax: {{7*7}}, ${7*7}, <%= 7*7 %>. Different engines, different braces.
3
Output = 49? → confirmedThe engine did the multiplication. That's Server-Side Template Injection, no ambiguity.
4
No output? Go error-basedSend {{undefined.x}}. Blind and WAF'd contexts still leak through the exception path.
5
Error changes → blind SSTIIf the template raises where a plain string wouldn't, the input is being parsed. Boolean and time-based oracles work the same way.
6
Fingerprint the engineError text and behavior name it — Jinja2 vs Twig vs Freemarker vs ERB. Each has its own escape hatch.
7
Escalate to RCESwap the math for the engine-specific gadget chain that reaches os / system.

From 49 to shell

The escalation ladder.

Once 7*7 evaluates, the payload swaps for the engine's object graph. Here's the canonical Jinja2 climb — the same rung the top-ranked research automates.

severity · info

Confirm evaluation

The braces run arithmetic — proven, low-noise.

severity · medium

Read server state

Reach config, secrets, and objects in scope through the template's variables.

severity · critical

Remote code execution

Walk the object graph to the OS: import a module, spawn a process, read its output.

# Jinja2 (Python) — the demo math swapped for a shell command
{{ self.__init__.__globals__.__builtins__.__import__('os').popen('id').read() }}

# Same idea, other engines:
# Twig (PHP)  {{_self.env.registerUndefinedFilterCallback("exec")}}{{_self.env.getFilter("id")}}
# ERB (Ruby) <%= system("id") %>
⚠ This is the part you never fire at a live target casually. Reaching RCE means running commands on someone's server. On a bug bounty program: prove the primitive (a benign id or a controlled read), document it, and report — never pivot, never touch real data.