On August 20, 2026, Broadcom/VMware disclosed a batch of Spring security advisories that included CVE-2026-59285, a critical (CVSS 9.2) unsafe deserialization vulnerability in Spring for GraphQL. The bug lives in how the library handles cursors for paginated Connection fields — a pattern that shows up in nearly every production GraphQL API, regardless of language or framework. It’s a useful case study because the root cause isn’t exotic; it’s the same mistake that’s been showing up in Java applications since the early gadget-chain disclosures a decade ago, just relocated into a newer piece of infrastructure.
What went wrong
GraphQL’s Relay-style cursor pagination requires the server to hand clients an opaque cursor string that encodes “where you left off” in a result set. The client sends that string back on the next request (after: "<cursor>"), and the server needs to reconstruct enough state from it to resume the query.
The convenient way to build that cursor is to serialize some internal object — sort keys, an offset, a scroll position — and hand the client a Base64-encoded blob. The dangerous way to build it is to let that blob be deserialized back into a typed object using a polymorphic deserializer that trusts a type hint embedded in the data itself. If an attacker can influence which class gets instantiated during that process, and any class reachable on the classpath has a “gadget” — a constructor, setter, or readObject/readResolve that has a side effect — the result can be remote code execution. This is exactly the deserialization mechanism that Jackson’s @JsonTypeInfo / enableDefaultTyping misuse has enabled repeatedly over the years, and it’s what made CVE-2026-59285 exploitable when Spring GraphQL apps combined paginated fields, Jackson-based JSON handling, and certain classes present on the classpath.
The fix Spring shipped (2.0.4, 1.4.6, 1.3.9) tightens how cursor payloads are decoded, but the broader lesson applies to any team implementing pagination, caching tokens, or “resume state” features: never let client-supplied data dictate which class gets instantiated during deserialization.
The vulnerable pattern (Java / Jackson)
// VULNERABLE: trusts a type hint from the client-supplied cursor
ObjectMapper mapper = new ObjectMapper();
mapper.activateDefaultTyping(
mapper.getPolymorphicTypeValidator(),
ObjectMapper.DefaultTyping.NON_FINAL); // allows arbitrary class resolution
String decoded = new String(Base64.getDecoder().decode(clientCursor));
Object cursorState = mapper.readValue(decoded, Object.class); // attacker picks the type
Because activateDefaultTyping embeds and trusts a @class field in the JSON itself, an attacker who controls the cursor string controls which class Jackson instantiates — the same primitive behind most Java deserialization RCEs.
The fix: allow-list types, or better, don’t deserialize untrusted state at all
// FIXED: restrict polymorphic resolution to an explicit allow-list
PolymorphicTypeValidator ptv = BasicPolymorphicTypeValidator.builder()
.allowIfSubType(PageCursor.class) // only your known cursor type
.build();
ObjectMapper mapper = JsonMapper.builder()
.polymorphicTypeValidator(ptv)
.activateDefaultTyping(ptv, ObjectMapper.DefaultTyping.NON_FINAL)
.build();
String decoded = new String(Base64.getDecoder().decode(clientCursor));
PageCursor cursorState = mapper.readValue(decoded, PageCursor.class); // fixed target type
Even better than allow-listing: treat the cursor as an opaque, signed token rather than a container you deserialize at all. The server signs the cursor when it’s issued; on the next request it verifies the signature before touching the payload, and rejects anything tampered with — no attacker-supplied bytes ever reach a deserializer.
// Node.js: cursor as an HMAC-signed opaque token instead of raw deserialized JSON
const crypto = require("crypto");
const SECRET = process.env.CURSOR_SIGNING_KEY;
function issueCursor(state) {
const payload = Buffer.from(JSON.stringify(state)).toString("base64url");
const sig = crypto.createHmac("sha256", SECRET).update(payload).digest("base64url");
return `${payload}.${sig}`;
}
function resolveCursor(cursor) {
const [payload, sig] = cursor.split(".");
const expected = crypto.createHmac("sha256", SECRET).update(payload).digest("base64url");
if (!crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) {
throw new Error("Invalid cursor"); // reject before any parsing of untrusted structure
}
return JSON.parse(Buffer.from(payload, "base64url").toString());
}
JSON.parse alone doesn’t grant RCE the way Java/Jackson polymorphic deserialization does, but the signature check still matters: it stops attackers from forging arbitrary offsets, bypassing access-control filters baked into the cursor, or probing internal IDs.
Auditing your own code
If you maintain a GraphQL API, or any endpoint that accepts an opaque “resume token,” check for:
- Any
ObjectMapperwithenableDefaultTyping()/activateDefaultTyping()that isn’t paired with a restrictivePolymorphicTypeValidator. - Cursor, pagination, or session tokens built by directly serializing internal objects and handing them to the client without a signature.
- Dependency versions: run
mvn dependency:treeor check your SBOM forspring-graphqlbelow 1.3.9/1.4.6/2.0.4, and patch immediately if found — this one is wormable in the sense that any exposed paginated field is a potential entry point.
Unsafe deserialization keeps resurfacing in new places because it’s genuinely convenient — serialize an object, hand it to the client, deserialize it back later. The fix isn’t to avoid convenience entirely; it’s to make sure the deserializer only ever produces types you explicitly trust, and ideally to keep the client from being able to hand you a deserialization target at all.