$ 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.
$ cat /etc/rate-limit
Rate limit reached (20 reads/hour per IP). Showing preview only — full content returns at the next hour roll-over.
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
...
$ grep --similar