$ cat writeup.md…
$ cat writeup.md…
gpnctf2026
Task: hybrid reverse/web challenge on JDK 26 AOT cache (Project Leyden/CDS-AOT); app bytecode lives only inside a 53 MB .aot cache, uploaded to an OuterServer that SHA-256 verifies it before Stage 2 loads it. Solution: recover bytecode via java.lang.instrument + Attach API retransform, find that the integrity hash covers CP slot pointers and bytecode but never the underlying Symbol/heap-String bytes, binary-patch the 'images' folder constant to '//////' at two offsets so Path.of resolves to /flag while keeping the Total hash unchanged, upload, then read /images/flag.
You caught me, I was a bit cheeky with the last one! To make up for it, you can now supply me with some delicious, edible, eatable and completely safe food. I hear you had something cooking the other day? It's probably still good! PS: Sorting my leftovers first sounds like a good idea :)
A hybrid reverse-engineering + web challenge built around the JDK 26 AOT cache (Project Leyden / CDS-AOT). This is the sequel to "Leftovers" (leftovers1), where the /set-image-dir route let you point the image directory at /flag. That path is now disabled, forcing exploitation through the AOT cache itself.
Goal: read /flag (also /flag.txt) on the remote.
exec.sh runs two stages:
java -XX:AOTCache=outer-cache.aot -cp leftovers2.jar de.kitctf.gpn24.leftovers2.OuterServer serve /tmp/cache.aot cache.aotjava -XX:AOTCache=/tmp/cache.aot -jar leftovers2.jarleftovers2.jar — Javalin 7.2.0 + Jackson 2.21.2 + Kotlin stdlib + Jetty 12.1.8. The de.kitctf application classes are NOT in the jar (compiled with -g:none); they exist only inside the AOT cache.cache.aot (53 MB) — inner AOT cache with the Stage 2 server bytecode.outer-cache.aot (38 MB) — AOT cache for the OuterServer (Stage 1).my-jdk/ — custom OpenJDK 26/27 fastdebug build (Linux x86-64), commit 35b0de3d4d4e8212227af5462fafbd464103f058.leftovers-padding.bin (125352 bytes in jar) — unused decoy.The key insight: whatever cache you upload to Stage 1 becomes /tmp/cache.aot, which Stage 2 then loads. Controlling a string constant in the cache controls Stage 2's runtime behavior — if you can get past the integrity check.
strings cache.aot revealed the class names: de/kitctf/gpn24/leftovers/{Server, Product, ImageStore, State, Server$1ProductInput, Server$1SetImageDir} and the leftovers2 AOT-reader helpers (OuterServer, AotCache, ArchiveReader, SymbolView, ConstMethodView, InstanceKlassView, etc.).
To recover the actual bytecode I used java.lang.instrument:
ClassFileTransformer that dumps any class whose name starts with de/kitctf to disk.VirtualMachine.attach(pid).loadAgent(agent.jar)) and call Instrumentation.retransformClasses on all de.kitctf classes.java -XX:AOTCache=cache.aot -jar leftovers2.jar normally, then attach + retransform.Why not
-javaagentat launch? Adding thejava.instrumentmodule breaks AOT cache mapping:Mismatched values for property jdk.module.addmods: java.instrument specified during runtime but not during dump timeandAOT cache has aot-linked classes. Runtime attach + retransform avoids touching the module set.
Reconstruct the package tree from the flattened filenames and decompile with jadx.
Stage 1 exposes two routes on port 1337:
POST /init — saves the uploaded cache.aot field to /tmp/cache.aot, opens it with the custom AotCache reader, runs verifyStuff(). If verifyStuff(uploaded).equals(expectedHash) → System.exit(0) (success). expectedHash is computed at startup from the genuine cache = 7aa5a496dde0fd1be5ef18ef2d5bf8acea749bf5647e31d34d4c0f0707bae5a3.GET /cache — serves the genuine cache bytes.verifyStuff computes a SHA-256 Total hash over all classes (sorted by class name → "Sorting my leftovers first"). Per class it hashes:
Integer.toString(index), Integer.toString(tag), Integer.toString(rawInt), Long.toString(rawLong). For a Utf8/Symbol entry the slot holds a pointer to the Symbol (rawLong), not the string bytes. The decoded displayValue (the actual string) is not hashed for CP entries.accessFlags, codeSize, flags, maxLocals, maxStack, the bytecode bytes, and the method name + signature displayValues.The bug: the byte content of Symbol strings and of archived-heap java.lang.String byte[] values is never hashed — only the CP slot pointer and the method name/signature strings. So you can change a string constant's bytes without changing the Total hash.
Routes (Javalin, port 1337):
GET / — list products (HTML).PUT /products/{name} — Jackson-deserializes a record ProductInput { @JsonUnwrapped Product product; URI imageUrl }. Validates name match, quantity>0, non-null bestBefore/notAfter, and imageUrl scheme http/https. Adds the product (optionally downloads an image).GET /images/{name} — finds the product by name, then ImageStore.getImage(product) → folderPath.resolve(sanitizeName(product.name())), returning the file if it exists, is a regular file, and is readable. sanitizeName replaces every char NOT in [a-zA-Z0-9_-] with _ (so a product named flag stays flag, but / in the name is killed → no traversal via product name).POST /set-image-dir — dead: a validator is check(c -> false, "Password login is currently disabled"). This is leftovers1's exploit path, now disabled ("cheeky with the last one").ImageStore default folder = Path.of("images") — a string constant living in the inner cache. Since traversal via name is blocked, we instead change the "images" folder constant so that folderPath.resolve("flag") == /flag.
"images" is 6 bytes. We need a 6-byte path whose .resolve("flag") yields /flag:
Path.of("//////").resolve("flag") == /flag (readable regular file)
Six forward slashes (//////, 6 bytes) normalize to the filesystem root, so we keep the exact byte length and avoid shifting the file layout.
"images" copies in cache.aotThere are two occurrences that both matter:
| Offset | What it is | Layout |
|---|---|---|
0x146655e | Symbol body (backs the CP Utf8 entry) | length = unsigned short at body-2 (bytes 06 00), UTF-8 bytes start at body |
0x324eb80 | Archived-heap java.lang.String byte[] | 4-byte int length (06 00 00 00) followed by the 6 ASCII bytes |
Patching only the Symbol at
0x146655ewas not enough — the request still returned 404. At runtimePath.of("images")materializes the interned String constant from the archived-heap Stringbyte[], so the copy at0x324eb80is the one that actually drivesfolderPath. Patch both.
#!/usr/bin/env python3 # Patch the two "images" constants in cache.aot to "//////" (same 6-byte length). data = bytearray(open("cache.aot", "rb").read()) OLD = b"images" NEW = b"//////" for off in (0x146655e, 0x324eb80): assert data[off:off+6] == OLD, (hex(off), data[off:off+6]) data[off:off+6] = NEW open("cache_patched2.aot", "wb").write(data) print("patched", len(data), "bytes")
Re-running OuterServer's verifyStuff over cache_patched2.aot yields the identical Total hash:
7aa5a496dde0fd1be5ef18ef2d5bf8acea749bf5647e31d34d4c0f0707bae5a3
So /init verification passes even though Stage 2 will now use / as the image folder.
Step A — upload patched cache to Stage 1 as multipart form-data:
POST /init HTTP/1.1
Content-Type: multipart/form-data; boundary=----X
------X
Content-Disposition: form-data; name="cache.aot"; filename="cache.aot"
Content-Type: application/octet-stream
<bytes of cache_patched2.aot>
------X--
The field name must be exactly cache.aot. On success the server calls System.exit(0), so the connection drops with no HTTP response (HTTP/1.1 header parser received no bytes). That dropped connection is the success signal. exec.sh then launches Stage 2 with our patched /tmp/cache.aot.
Step B — add a product named flag once Stage 2 is up:
PUT /products/flag HTTP/1.1
Content-Type: application/json
{"name":"flag","quantity":1,"bestBefore":"2030-01-01T00:00:00","notAfter":"2030-01-01T00:00:00"}
Response: Added product :).
Step C — read the file:
GET /images/flag
ImageStore resolves Path.of("//////").resolve("flag") == /flag, which is a readable regular file → the flag is returned in the response body.
The instance served Stage 1 (GET /cache returned the 53 MB cache, /init present). Upload → PUT product → GET /images/flag:
GPNCTF{1_h0pe_the_caCHE_I5_Never_pr0vId3D_8Y_118r4R1E5}
The flag itself states the intended lesson: "I hope the cache is never provided by libraries" — AOT/CDS caches must never be loaded from untrusted or library-provided sources.
$ cat /etc/motd
Liked this one?
Pro unlocks every writeup, every flag, and API access. $9/mo.
$ cat pricing.md$ grep --similar