Skip to main content

notepad picoMini by redpwn Solution

A web exploitation challenge involving server-side template injection hidden behind a path traversal vulnerability.

Published: April 2, 2026Updated: August 13, 2026

Description

A note-taking app stores notes as files. Path traversal via backslash + SSTI leads to RCE.

Remote

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.

Walk me through it
  1. Step 1Understand the filename behavior
    Observation
    The 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.

  2. Step 2Exploit backslash path traversal
    Observation
    Werkzeug'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.
    bash
    curl -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. config is a Flask context variable; .__class__.__init__.__globals__ navigates Python's internal attribute chain to reach the global namespace of the __init__ method, which includes the os module. 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.

  3. Step 3Trigger the SSTI
    Observation
    The 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.
    bash
    curl '<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 error query parameter: ?error=pwn causes it to render templates/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/passwd to 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.

Key takeaway

Path traversal happens when an application validates one restricted form of input, such as forward slashes, without canonicalizing first, so an alternate separator or encoding walks past the check. Chain it with template injection and you have a two-stage path to code execution: write a malicious template into the template directory, then get the engine to render it. Both classes appear in nearly every framework, and the defense is the same in each: canonicalize before validating, and never let user data become raw template content.

Related reading

Useful tools for Web Exploitation

Where to go next