Description
A note-taking app stores notes as files. Path traversal via backslash + SSTI leads to RCE.
Setup
Open the challenge URL and try creating a note to understand how the app works.
Solution
Want to try it yourself first?
The guided walkthrough reveals hints one step at a time.
Step 1Understand the filename behavior
ObservationThe app uses note content directly as a filename under notes/ and filters only forward slashes. That sanitization is incomplete: a backslash may pass the filter and get normalized afterwards.When you create a note, the first 128 characters of your note content become the filename under a notes/ directory. The app filters forward slashes / but not backslashes \.Learn more
Path traversal (also known as directory traversal) is a vulnerability where user-controlled input is used to construct file system paths without sufficient sanitization. The classic attack sequence
../(dot-dot-slash) navigates up one directory level - repeated traversal sequences escape the intended directory entirely.This application attempted to block path traversal by filtering forward slashes (
/). However, on Unix-like systems, only/is the path separator - backslashes (\) in filenames are treated as literal characters. The vulnerability arises because Werkzeug, the Python WSGI library underlying Flask, normalizes URLs and paths, converting\to/before the application processes the result.This filter bypass illustrates a key security principle: input validation must happen on the canonical form of the data, not the raw form. Normalizing (canonicalizing) input before applying filters prevents bypasses via alternate representations. OWASP CWE-22 (Path Traversal) lists dozens of encoding and representation tricks used to bypass path filters.
Step 2Exploit backslash path traversal
ObservationWerkzeug's url_fix turns backslashes into forward slashes after the app's own filter has run. Craft a backslash path that traverses into the templates directory and writes a Jinja2 template there.Python's Werkzeug url_fix() normalizes \ to / - so the app's slash filter runs before normalization. Use \..\templates\errors\pwn as the beginning of your note to write a file at templates/errors/pwn, which the app serves as a Jinja2 template.bashcurl -X POST <url>/new -d 'content=\..\templates\errors\pwn{{config.__class__.__init__.__globals__["os"].popen("cat flag.txt").read()}}'Expected output
picoCTF{styl1ng_susp1c10usly_s1m1l4r_t0_p4steb1n}What didn't work first
Tried: Use forward slashes in the path traversal payload instead of backslashes
The app strips forward slashes before Werkzeug sees the input, so a traversal built from them ends up inside notes/ under a mangled name. Backslashes are not filtered, and url_fix normalizes them only after the filter has run. That ordering is the whole bug.
Tried: Use a simpler SSTI payload like {{7*7}} first to confirm injection before going straight to os.popen
That confirms Jinja2 is evaluating, and only if you trigger the file through the error parameter. Look at the notes listing instead and you see the literal expression, not its value. Confirm the trigger URL before upgrading to a command-execution payload.
Learn more
Server-Side Template Injection (SSTI) occurs when user-controlled data is embedded directly into a template that is then rendered by a template engine. Jinja2, Flask's default template engine, evaluates expressions inside
{{ }}delimiters as Python code. If an attacker can inject content into a template file, they can execute arbitrary Python.The payload
{{config.__class__.__init__.__globals__["os"].popen("cat flag.txt").read()}}exploits Jinja2's access to Python's object model.configis a Flask context variable;.__class__.__init__.__globals__navigates Python's internal attribute chain to reach the global namespace of the__init__method, which includes theosmodule.os.popen(cmd).read()executes a shell command and returns the output.This two-stage exploit chain (path traversal to write a file + SSTI to execute code) is a powerful combination in web security research. The path traversal places the malicious template in a directory the app serves; the SSTI activates when the app renders that template. Defenses include: never using user input in template rendering, storing uploads outside the web root, and using sandboxed template environments with restricted attribute access.
Step 3Trigger the SSTI
ObservationThe app renders error templates by loading whatever the error query parameter names from templates/errors/. Request the file you just wrote and Jinja2 evaluates it.Request the error page that loads your injected template. The Jinja2 engine evaluates the expression and returns the flag in the response.bashcurl '<url>/?error=pwn'What didn't work first
Tried: Request the note directly from the notes/ directory instead of using the ?error= parameter
The app serves notes as raw files, not templates, so requesting one through that path returns the literal expression text. Only the error rendering path runs a file through the template engine.
Tried: Use ?error=../templates/errors/pwn with path components in the error parameter
The error parameter is appended directly to templates/errors/ as a filename, so path separators inside it are either filtered or produce a file-not-found. The file landed there under its bare basename, so pass just that.
Learn more
The application loads error templates by name based on the
errorquery parameter:?error=pwncauses it to rendertemplates/errors/pwn- the file you wrote via path traversal. When Jinja2 renders this file, it evaluates the injected template expression, executes the OS command, and returns the output in the HTTP response body.This request-response flow is the "trigger" step that distinguishes two-stage exploits from direct injection. Stage 1 (the path traversal) plants the payload. Stage 2 (this request) activates it. The gap between the two stages is an opportunity to prepare additional payloads - for example, first read
/etc/passwdto confirm RCE, then read the flag, then establish a reverse shell.In real-world bug bounty hunting, SSTI is a critical severity finding because it grants full Remote Code Execution on the web server. It is consistently found in Flask, Django (with unsafe template construction), Ruby's ERB, Java's FreeMarker/Thymeleaf, and PHP's Twig/Smarty. The detection payload
{{7*7}}is safe and non-destructive - if the response contains "49," the injection point is confirmed.
Interactive tools
- Regex TesterTest regular expressions against a string with live match highlighting, flag toggles, and common CTF pattern shortcuts.
Flag
Reveal flag
picoCTF{styl1ng_susp1c10usly_s1m1l4r_t0_p4steb1n}
Path traversal combined with SSTI is a powerful chain - writing a Jinja2 template to a location the app serves lets you execute arbitrary Python expressions server-side.