3v@l picoCTF 2025 Solution

Published: April 2, 2025

Description

ABC Bank's loan calculator naively feeds user input to Python's eval while blocking a short keyword list. Build around the filter to execute shell commands and read /flag.txt.

Open DevTools (F12) > Elements and search the HTML/JS for banned or blacklist to see the exact filter logic.

The blocklist sits in plain sight: substrings like os, eval, exec, import, ls, cat, /, flag, sh, system are rejected client-side, then sent to a server eval.

Probe the filter: send os and watch it get rejected, then send 'o'+'s' and watch it pass. That confirms static substring matching with runtime concatenation as the bypass.

Before reading /flag.txt, list the root with the obfuscated ls to confirm the file actually lives there.

bash
__import__('o'+'s').popen('l'+'s').read()
bash
__import__('o'+'s').popen('c'+'at '+chr(47)+'fl'+'ag.txt').read()

Solution

Want to try it yourself first?

The guided walkthrough reveals hints one step at a time.

Walk me through it
  1. Step 1
    Bypass the filter
    Observation
    I noticed the calculator's description mentioned a keyword blocklist fed to Python's eval, which suggested the filter uses static substring matching and could be defeated by constructing banned tokens at runtime using string concatenation and chr() instead of writing them literally.
    Open DevTools and grep the JS for banned to see the exact rejected substrings. Probe with os (rejected) vs 'o'+'s' (accepted) to confirm static matching. Then build forbidden tokens at runtime: 'o'+'s' dodges the os check, chr(47) gives / without a literal slash, and __import__ dodges the import keyword block.
    What didn't work first

    Tried: Trying to find the filter logic by looking at the page source with View Source.

    View Source shows the raw HTML sent by the server, but the filter logic is often injected or modified by JavaScript after load. DevTools Elements panel reflects the live DOM including dynamically added scripts, so searching there (or in the Sources tab) is the right place to find client-side filter code.

    Tried: Attempting standard eval bypass payloads like os.system('cat /flag.txt') without checking which substrings are blocked.

    Guessing which tokens are banned wastes time and can get your session rate-limited or blocked. Reading the actual blocklist first tells you exactly which string patterns to avoid, so you can target only those with concatenation or chr() substitutions rather than trial-and-error testing every possible payload.

    Learn more

    Python's eval executes any arbitrary Python expression passed to it as a string. When web applications expose eval to user input - even with a blocklist - they create a code injection vulnerability. Blocklists that operate on raw string matching are fundamentally weak because Python offers many ways to construct the same string at runtime.

    String concatenation bypass works because the filter scans for the literal token os but never sees it: 'o'+'s' produces the same string only after Python evaluates the expression. The chr() built-in similarly converts an integer to a character, so chr(47) yields / without ever writing a slash in the input. These techniques exploit the fact that static string matching cannot track runtime values.

    __import__ is the lower-level function that backs Python's import statement. Because it accepts a plain string argument, it can import any module dynamically - including os - even when the import keyword itself is blocked. Once os is imported, os.popen opens a subprocess whose output is readable as a file object.

    The real-world lesson here is that blocklists are not a safe defense for code injection. Proper remediation means never passing user input to eval, exec, or similar functions. If dynamic evaluation is genuinely required, use an allowlist restricted to the exact operations the feature needs. See the Command Injection guide for filter-bypass patterns that map cleanly to shell injection too, and the Burp Suite for picoCTF guide for the Repeater loop that lets you iterate through these bypass payloads without retyping the request every send.

  2. Step 2
    Dump the filesystem
    Observation
    I noticed the filter blocks the literal substrings 'ls', 'cat', '/', and 'flag', and that the bypass technique from the previous step works by building strings at runtime, which suggested applying the same concatenation and chr() approach to enumerate the root directory and then read /flag.txt.
    First list / with __import__('o'+'s').popen('l'+'s').read() to confirm flag.txt is there (don't guess the path). Then a cat payload like __import__('o'+'s').popen('c'+'at '+chr(47)+'fl'+'ag.txt').read() exfiltrates the contents through the calculator's response field.
    What didn't work first

    Tried: Jumping straight to reading /flag.txt without listing the directory first.

    If the flag is stored at /root/flag.txt or under a subdirectory, the cat command silently returns an empty string or a 'No such file or directory' error that the calculator may not display clearly. Listing / first takes one extra payload and removes all ambiguity about where the file lives.

    Tried: Trying to split 'cat' as 'c'+'at' but leaving the path as a literal /flag.txt string in the same payload.

    The blocklist matches substrings independently, so splitting 'cat' still leaves the literal slash and the word 'flag' in the input - both of which are typically on the blocklist. Every blocked substring in the full command string needs its own bypass: chr(47) for the slash and splitting 'flag' as 'fl'+'ag' or similar.

    Learn more

    Once arbitrary command execution is established, an attacker follows a standard enumeration pattern: first list directories to understand the filesystem layout, then read target files. Flags in CTF challenges are conventionally placed at /flag.txt or /root/flag.txt on Linux containers.

    os.popen launches a shell command and returns a file-like object. Calling .read() on it captures all stdout output as a Python string, which then gets returned to the web application and displayed in the response - completing the exfiltration loop without any separate network channel.

    In real penetration testing this step is called post-exploitation enumeration. Attackers typically run whoami, id, uname -a, and then read /etc/passwd to understand privilege level and available users. The same obfuscation techniques used in this challenge apply to those commands as well, demonstrating how a single bypass technique enables full system access.

Interactive tools
  • Reverse Shell GeneratorGenerate reverse shell payloads (bash, nc, python, perl, ruby, php, node, powershell) and matching listeners. Set host and port once, copy any variant.

Flag

Reveal flag

picoCTF{D0nt_Use_Unsecure_f@nctionsd06...}

Any payload that spawns /bin/sh via the obfuscated os import works; the concatenation trick keeps the blacklist asleep.

Key takeaway

Blocklist-based defenses against code injection are inherently breakable because any programming language provides multiple ways to express the same computation; string concatenation, chr(), and __import__() reconstruct any banned token at runtime without ever writing it literally in the input. The only safe approach is to never pass user input to eval or exec; if dynamic evaluation is genuinely necessary, use a strict allowlist restricted to the exact operations the feature requires.

Related reading

Want more picoCTF 2025 writeups?

Tools used in this challenge

What to try next