notepad picoMini by redpwn Solution

Published: April 2, 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 1
    Understand the filename behavior
    Observation
    I noticed the app used note content directly as a filename under a notes/ directory and filtered only forward slashes, which suggested the sanitization was incomplete and that an alternate path separator like backslash might bypass the filter before Werkzeug normalized it.
    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 2
    Exploit backslash path traversal
    Observation
    I noticed Werkzeug's url_fix() converts backslashes to forward slashes after the app's slash filter has already run, which suggested crafting a payload starting with \..\templates\errors\pwn to write a Jinja2 template into the app's templates directory.
    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 filters forward slashes before handing the input to Werkzeug, so a payload like /../templates/errors/pwn is stripped and the file ends up inside notes/ with a mangled name. Backslashes are not filtered, and Werkzeug's url_fix() only normalizes them to slashes after the filter has already run, which is why the bypass works.

    Tried: Use a simpler SSTI payload like {{7*7}} first to confirm injection before going straight to os.popen

    This confirms that Jinja2 is evaluating the template, but the file containing {{7*7}} must be triggered via the ?error= parameter to actually render - if you check the notes/ listing instead of hitting /?error=pwn you will just see the literal string, not the evaluated output. Confirming the trigger URL is the necessary second step before upgrading to the os.popen 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 3
    Trigger the SSTI
    Observation
    I noticed the app rendered error templates by reading the ?error= query parameter and loading the matching file from templates/errors/, which suggested that requesting ?error=pwn would cause Jinja2 to evaluate the injected expression and return the flag.
    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 as Jinja2 templates, so hitting something like /notes/..%2Ftemplates%2Ferrors%2Fpwn just returns the literal template expression text rather than evaluating it. Only the error rendering path passes the file through the Jinja2 engine, which is why ?error=pwn is required to trigger code execution.

    Tried: Use ?error=../templates/errors/pwn with path components in the error parameter

    The app appends the error parameter value directly to templates/errors/ as a filename, so including path separators either gets filtered or causes a file-not-found error depending on the sanitization applied to query parameters. The file was written as templates/errors/pwn (just the basename), so ?error=pwn with no extra path components is the correct trigger.

    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 vulnerabilities arise when applications validate a restricted form of user input (such as filtering forward slashes) without first normalizing it to its canonical representation, allowing alternate separator characters or encodings to bypass the check. Chaining path traversal with server-side template injection creates a two-stage RCE primitive: the first stage writes a malicious template file into the application's template directory, and the second stage triggers the template engine to evaluate and execute it. Both vulnerability classes appear across nearly every web framework and language, and the defense in each case is the same: canonicalize input before validating it, and never pass user-controlled data into a template engine as raw template content.

Related reading

Want more picoMini by redpwn writeups?

Useful tools for Web Exploitation

What to try next