reversefreehard

Leftover Leftovers

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.

$ ls tags/ techniques/
aot_cache_bytecode_recoveryjava_instrument_retransformattach_api_agenthash_collision_blind_spotsymbol_vs_heap_string_patchpath_normalization_traversalmultipart_upload_chain

$ cat /etc/rate-limit

Rate limit reached (20 reads/hour per IP). Showing preview only — full content returns at the next hour roll-over.

Leftover Leftovers — GPNCTF 2026

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.sh runs 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. 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.

Recovering bytecode that only lives in the AOT cache

...

$ grep --similar

Similar writeups