Leftover Leftovers
Leftover Leftovers
Platform: GPN CTF | Category: Reversing | Type: Challenge | Difficulty: Hard | OS: NA | Author: D3v0o0Nu11 | Date: 2026-05-30 | Status: Solved Techniques: aot_cache_bytecode_recovery, attach_api_agent, hash_collision_blind_spot, java_instrument_retransform, multipart_upload_chain, path_normalization_traversal, symbol_vs_heap_string_patch
Summary
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.
Recon
Port scan
nmap -p- -sV -sC <TARGET> --min-rate 1000 -Pn
| Port | Service | Version | Notes |
|---|---|---|---|
| <PORT> | <SVC> | <VER> | <notes> |
Enumeration highlights
- Event:
gpnctf2026| ID:20260530_gpnctf2026_leftover_leftovers - Tags: path_traversal, java, binary_patching, jdk26, aot_cache, cds, project_leyden, javalin, jackson, hash_blind_spot
- Indicators: JDK AOT/CDS cache file (.aot) as handout, application classes absent from jar (compiled -g:none), custom OpenJDK fastdebug build shipped, AOTCache flag in exec.sh, integrity check hashes CP slot pointers + bytecode but not string bytes
- Source:
20260530_gpnctf2026_leftover_leftovers.md
Foothold
Vulnerability / Misconfiguration
- Aot_cache_bytecode_recovery
- Attach_api_agent
- Hash_collision_blind_spot
- Java_instrument_retransform
- Multipart_upload_chain
<command>
Exploitation
- See original writeup content for detailed exploitation.
Privilege Escalation
Enumeration
sudo -l find / -perm -4000 2>/dev/null getcap -r / 2>/dev/null cat /etc/crontab ps aux
Exploitation
- N/A for challenge-type writeup; see exploitation above.
- Flag obtained via challenge solve.
<command>
Flags
| Flag | Location | Value |
|---|---|---|
| flag | REDACTED |
Key Takeaways / Lessons
- aot_cache_bytecode_recovery
- attach_api_agent
- hash_collision_blind_spot
- java_instrument_retransform
- multipart_upload_chain
- path_normalization_traversal
- symbol_vs_heap_string_patch
- Tags: path_traversal, java, binary_patching, jdk26, aot_cache, cds, project_leyden, javalin, jackson, hash_blind_spot
Original Writeup
<details><summary>Click to expand original content</summary>Description
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.
Analysis
Handout layout
exec.shruns two stages:- Stage 1 (OuterServer):
java -XX:AOTCache=outer-cache.aot -cp leftovers2.jar de.kitctf.gpn24.leftovers2.OuterServer serve /tmp/cache.aot cache.aot - Stage 2 (Server):
java -XX:AOTCache=/tmp/cache.aot -jar leftovers2.jar leftovers2.jar— Javalin 7.2.0 + Jackson 2.21.2 + Kotlin stdlib + Jetty 12.1.8. Thede.kitctfapplication 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), commit35b0de3d4d4e8212227af5462fafbd464103f058.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.
Recovering bytecode that only lives in the AOT cache
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:
- A
ClassFileTransformerthat dumps any class whose name starts withde/kitctfto disk. - Stage 1 (OuterServer): attach the agent at runtime via the Attach API (
VirtualMachine.attach(pid).loadAgent(agent.jar)) and callInstrumentation.retransformClasseson allde.kitctfclasses. - Stage 2 (Server): run
java -XX:AOTCache=cache.aot -jar leftovers2.jarnormally, 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.
The integrity check (OuterServer.verifyStuff) — the blind spot
Stage 1 exposes two routes on port 1337:
POST /init— saves the uploadedcache.aotfield to/tmp/cache.aot, opens it with the customAotCachereader, runsverifyStuff(). IfverifyStuff(uploaded).equals(expectedHash)→System.exit(0)(success).expectedHashis 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:
- Constant-pool entries: for each entry,
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 decodeddisplayValue(the actual string) is not hashed for CP entries. - Methods (sorted by address):
accessFlags,codeSize,flags,maxLocals,maxStack, the bytecode bytes, and the method name + signaturedisplayValues.
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.
Stage 2 (Server) — the target logic
Routes (Javalin, port 1337):
GET /— list products (HTML).PUT /products/{name}— Jackson-deserializes a recordProductInput { @JsonUnwrapped Product product; URI imageUrl }. Validates name match,quantity>0, non-nullbestBefore/notAfter, andimageUrlschemehttp/https. Adds the product (optionally downloads an image).GET /images/{name}— finds the product by name, thenImageStore.getImage(product)→folderPath.resolve(sanitizeName(product.name())), returning the file if it exists, is a regular file, and is readable.sanitizeNamereplaces every char NOT in[a-zA-Z0-9_-]with_(so a product namedflagstaysflag, but/in the name is killed → no traversal via product name).POST /set-image-dir— dead: a validator ischeck(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.
Solution
1. Pick a same-length replacement string
"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.
2. Locate the two "images" copies in cache.aot
There 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.
3. Patch and verify the hash is unchanged
#!/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.
4. Upload chain → flag
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.
Remote run
The instance served Stage 1 (GET /cache returned the 53 MB cache, /init present). Upload → PUT product → GET /images/flag:
GPNCTF{REDACTED}
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.
</details>Auto-tracked: saved to WriteUps; run
/xesor-reviseto fold lessons into XESXor_Methodology.md.
signed by XESXOR