Leftovers
gpnctf2026
Task: JDK 26 Javalin web app shipped with a custom fastdebug OpenJDK and a poisoned JEP 483 AOT cache (cache.aot) whose cached Server.lambda$main$15 differs from the JAR bytecode, silently changing the set-image-dir password check. Solution: confirm the JAR password 'supersecret' is rejected, attach a dynamic JVMTI agent via jcmd (without invalidating the cache), redefineModule to introspect the constant pool, retransform-dump the AOT-linked bytecode and diff it against the JAR, invert the ROT13->reverse->XOR check to recover password algomaster99, then chain PUT product + POST set-image-dir(newPath=/) + GET /images/flag for arbitrary file read of /flag.
$ ls tags/ techniques/
$ cat /etc/rate-limit
Rate limit reached (20 reads/hour per IP). Showing preview only — full content returns at the next hour roll-over.
Leftovers — GPNCTF 2026
Description
Looking through my Fridge (why does it contain Java programs again?), I found some stale food from yesterday. Surely there's no chance for food poisoning, is there?
English summary: We are given a Java web application (leftovers.jar, Javalin 7.2.0), a custom fastdebug OpenJDK 26 build (my-jdk/), and a ~51 MB AOT cache (cache.aot). The app is launched with -XX:AOTCache=cache.aot. The "food poisoning" pun is the entire challenge: the AOT cache is poisoned — its cached class bytecode differs from the shipped JAR, silently changing application logic. The goal is to abuse this to read /flag.
Analysis
The application (from javap on the JAR)
A Javalin app on port 1337 holding a Set<Product> and an ImageStore. Routes:
GET /— renders a "Fridge tracker" HTML listing products.PUT /products/{name}— bodyProductInput{product, imageUrl:URI}. Validatesname == path param,quantity > 0,bestBefore/notAfternot null,imageUrlschemehttp/https. On success registers the product, and ifimageUrl != nulldoes an HTTP GET ofimageUrland writes the body tofolderPath.resolve(sanitizeName(name))(an SSRF, but onlyhttp/https).GET /images/{name}— finds the registeredProductby name, then readsfolderPath.resolve(sanitizeName(name))if it exists / is a regular file / is readable.POST /set-image-dir— bodySetImageDir{password:String, newPath:Path}. Validatespassword != null, runs a password check, and requiresnewPathto exist and be a directory. On success setsImageStore.folderPath = newPathto any existing directory.
Key helper sanitizeName replaces [^a-zA-Z0-9_-] with _ — so no path traversal in the name (dots and slashes are stripped). Default folderPath = Path.of("images") → /app/images.
The intended file-read primitive
The combination is an arbitrary file read:
set-image-dirlets us pointfolderPathat any directory (e.g./).GET /images/{name}readsfolderPath.resolve(sanitize(name)).
...
$ grep --similar
Similar writeups
- [reverse][free]Leftover Leftovers— gpnctf2026
- [crypto][free]Just Follow the Recipe— kitctf
- [web][Pro]Double Shop— srdnlen
- [pwn][Pro]Taste— grodno_new_year_2026
- [reverse][Pro]Challenge7— tamuctf