Reproducing CVE-2026-75604: Next.js Path Traversal to RCE on Windows

Next.js path traversal CVE-2026-75604 Fortbridge research hero
Vulnerability research · CVE-2026-75604

A single un-escaped backslash in the Next.js incremental cache creates a Next.js path traversal that lets an unauthenticated attacker read and write files on Windows hosts, and, on the right versions and app shape, run commands. Here is the whole chain, reproduced end to end.

CVE
2026-75604
Severity
9.0 Critical
Class
Path traversal
Platform
Windows only
Affected
13.4-15.5.23 · 16.0-16.3.2
Fixed in
15.5.24 · 16.3.3
The short version

What a decision-maker needs to know

What it is. Next.js failed to neutralise the backslash character in cached route paths. On Windows, where \ is a directory separator, an unauthenticated request can escape the framework’s cache directory and read or write arbitrary files on the server.

Who is affected. Any Next.js app on Windows running an affected version (13.4-15.5.23 or 16.0-16.3.2) that uses the Pages Router or the App Router without Cache Components. The bug does not touch Linux or macOS; it is specific to the Windows path separator.

Impact. At minimum: disclosure of server secrets (including the key that protects Server Actions) and corruption of build files (denial of service). At worst, on specific versions and app patterns: unauthenticated remote code execution (which, unlike the traversal, applies only to a subset of the affected versions; see the version table below).

The fix. Upgrade to 15.5.24 or 16.3.3. There is no configuration workaround for affected Windows hosts.

01Why every affected app has the Next.js path traversal

This is a framework bug, not an application bug. You did not have to write anything wrong. If you run an affected version on Windows, the traversal is present in the framework code you shipped.

When Next.js serves incremental-cache content, it builds a filename from the request path. The routing layer runs each URL segment through a helper that escapes the delimiters used by the router:

// packages/next/src/shared/lib/router/utils/escape-path-delimiters.ts
segment.replace(
  new RegExp(`([/#?]${escapeEncoded ? '|%(2f|23|3f|5c)' : ''})`, 'gi'),
  encodeURIComponent
)
//            ^^^^^ the backslash is missing from this character class

The class escapes /, # and ?, but not \. So a request that carries a percent-encoded backslash (%5C) in a path segment decodes to a raw \ and passes through untouched. That value lands in the cache key, which is joined onto a directory with path.join():

Windows : "\" IS a path separator  ->  the key walks out of the cache root
Linux   : "\" is an ordinary filename character  ->  no traversal
macOS   : same as Linux

That is the entire root cause, and the reason it is Windows-only. The patched releases add \ to the character class and a containment check that rejects any resolved path outside the cache root.

Two primitives fall out of it

The vulnerable function backs both cache reads and cache writes, so a single flaw gives you two capabilities, both unauthenticated, both on any affected app:

  • Arbitrary file read: pull any file the server process can reach (build manifests, source, secrets) back in an ordinary HTTP(S) response.
  • Arbitrary file write: the cache writes a response to a path you choose (fixed extension, templated content). Enough to corrupt build manifests and break the app.

Everything below builds on the read: we use it to steal the one secret that unlocks the rest.

02The chain, end to end

Reproduced on a real Windows 11 host running Next.js 16.2.0. Every screenshot below shares one build, one stolen key, and one action id, so the steps line up. The target app exposes three routes: the home page / (which carries a Server Action), a cached App Router catch-all /app-cache/[...rest], and a Pages Router catch-all /pages-cache/[...rest]. The attack feeds traversal payloads to the two catch-alls and posts to /.

(A Server Action is a function marked 'use server' that runs on the server but can be invoked straight from the browser, for example on a form submit, without you writing a separate API endpoint; Next.js serialises the call and response for you.)

The two catch-all routes are provided by the lab, not by the framework, to exercise both surfaces of the incremental cache that the bug touches. The Pages Router route gives the cleanest read vector (its data endpoint, /_next/data/<buildId>/pages-cache/....json, serves cache files straight off disk); the App Router route gives the cached write we warm, and its closure-bound action is what forces the build to emit the encryptionKey we steal. A real app has its own routes: any cached dynamic route on an affected Windows host exposes the same primitives.

STEP 0reconnaissance · public value

Grab the buildId

The cache-read endpoint uses the address /_next/data/<buildId>/.... The buildId is not a secret; Next.js prints it in the HTML of every page. One unauthenticated request yields it, no traversal required yet.

Next.js path traversal CVE-2026-75604: curl of a normal page showing the buildId in the page source
Step 0. curl of an ordinary page. The buildId appears in __NEXT_DATA__ and in the /_next/static/<buildId>/ asset paths. Public by design; we just need it to address the cache.
STEP 1read primitive · secret disclosure

Traverse out and steal the encryptionKey

Now the backslash. We request the Pages data endpoint for the catch-all route, but the [...rest] value is a traversal payload: ..%5C..%5Cserver-reference-manifest. On Windows this walks out of the cache directory and hands back server-reference-manifest.json, an internal build file whose contents include encryptionKey, the AES-GCM key Next uses to protect Server Action arguments.

Browser JSON viewer showing the server-reference-manifest with encryptionKey highlighted
Step 1. The traversal read returns the raw manifest in the browser; encryptionKey is highlighted. This is the key that unlocks steps 3 to 5.
STEP 2offline · proving the key

Decrypt a genuine bound argument

A closure-bound Server Action ships its captured values to the browser as an encrypted blob in the page’s form ($ACTION_x:2). With the stolen key we decrypt one straight out of the page HTML; the AES-GCM tag validates and the plaintext is a small React Flight document. This confirms the key is real and usable in both directions.

Terminal showing decryption of a genuine Server Action bound argument
Step 2. The CLI decrypts a real bound-args blob with the stolen key: tag valid, actionId prefix present. The server can no longer trust that these blobs are unforgeable. The first line is a printed template showing the argument order; the three lines beneath it are the real values the run actually used, echoed back by the tool: stolen key is the base64 key leaked in step 1 (v+A0BSmLzrANOxUDRNK9uiqLmOFtn1XuPq3OPhC2T3o=), action id is the 40-hex id read from the home page (60643c0f13c2ee14a5be90700d6962db85a9337371), and blob is an encrypted bound-args value copied from the page HTML (a long base64 string, shown truncated). Those three are what produce the successful decryption at the bottom.
STEP 3offline · forging the gadget

Forge a bound argument that becomes Function

Because we hold the key, we can re-encrypt a bound argument of our choosing. The payload is a Flight property-chain: row 1:{} (an empty object) and 0:["$1:constructor:constructor"]. When the server deserialises it, {}.constructor.constructor resolves to the JavaScript Function constructor, an eval primitive. We AES-GCM it with the stolen key and the server will accept it as its own.

Terminal showing the constructor:constructor gadget being forged into an encrypted blob
Step 3. Forging $1:constructor:constructor into Function, re-encrypted with the stolen key. This blob replaces a bound value the server believes it created.
STEP 4write / execute · one POST

Fire the forged Server Action

A single POST / delivers the forged blob in $ACTION_x:2 and our JavaScript in the public, unencrypted value field. The app’s action calls its (now-forged) bound value with our input and then calls the result, Function(ourJS)(), so our code runs while the request is handled. The response is a plain HTTP 200; invoking the action is the trigger.

Burp showing the forged POST request with the gadget blob and attacker JavaScript, HTTP 200 response
Step 4. The forged POST / in Burp. $ACTION_1:2 is the gadget blob, value is the attacker JS (here delivered as powershell -EncodedCommand; Burp’s decoder shows the original ConvertTo-Json ... Set-Content). Response: 200. The two fields play different roles: $ACTION_1:2 is the forged bound argument (it decides what the bound value is, here the Function constructor), while value is the public form field passed to the action at call time (the JavaScript source). The action runs Function(value)(): one field supplies the eval primitive, the other the code it runs. The blob looks different from step 3 because AES-GCM uses a fresh random IV on every encryption, so the same gadget re-encrypts to different ciphertext each run.
STEP 5proof of impact

Read the command output back

A 200 only proves the server accepted the request. To prove execution, the injected command wrote its output into the cache tree, and we read it back with the same traversal primitive from step 1. The response body carries the live output of whoami and hostname run on the server.

Burp showing the read-back request returning whoami and hostname output from the server
Step 5. The read-back returns {"hostname":...,"whoami":"...\\...","cwd":...}, command output produced by the server. Unauthenticated RCE, confirmed on real Windows.

03Whether your version is actually RCE-able

This is the part most coverage got wrong. The path traversal spans the whole affected range, but the RCE gadget from step 3 hangs on a React internal. The Flight property-chain that reaches Function only works where react-server-dom-webpack lacks a reference guard that React added mid-lifecycle. That reference guard is a check inside React’s Flight parser that rejects the constructor:constructor property-walk, refusing to resolve it and throwing Invalid reference. before it can reach Function. Unguarded versions (the guard is absent) run the gadget; guarded versions throw Invalid reference., which is why testers on current 16.3.x report “no RCE” even though their app is still traversal-vulnerable.

Next.js rangeGadget to FunctionImpact
13.4 – 14.xnono property-path support: read / write / authz-bypass only
15.0.0 – 15.5.22RCE-capableunguarded: full chain works
15.5.23noguard added: traversal only (still unpatched)
16.0.0 – 16.2.xRCE-capableunguarded: full chain works
16.3.0 – 16.3.2noguard added: traversal only (still unpatched)
15.5.24 / 16.3.3patchedtraversal fixed: not vulnerable

Empirically verified by feeding the exact gadget to each version’s bundled parser: 15.0.0, 15.4.7, 15.5.0, 15.5.22, 16.0.0, 16.2.0 resolve to the real Function constructor; 15.5.23, 16.3.0, 16.3.2 throw Invalid reference.

04Is your app RCE-able? What to scan for

Even on an unguarded version, code execution needs one thing in your code: a Server Action that treats a closure-bound value as callable and feeds it attacker input. Forging changes what a bound value is; your app has to be the thing that calls it. The dangerous shape is a higher-order Server Action, one that invokes a bound value and then invokes the result:

// the vulnerable pattern: a bound value is called with user input, then the result is called
const make = await boundFactory(formData.get('value'))
return make()                 // Function(attackerJS)()  ->  RCE

When you review a Next.js codebase, grep the 'use server' functions and flag any where a closure-captured variable is used as a function, especially with request data flowing in. Concretely, look for:

  • A closure-bound value that is called: boundThing(...), await boundThing(...), or its return value called again, fn()().
  • A bound value passed to anything that executes strings or modules: eval, new Function, require, child_process, vm, a template compiler, a query builder.
  • Factory patterns: an action that returns a function, or resolves one from a bound argument, and then runs it.

Actions that only read, compare, or stringify their bound values (the common case) are not an RCE sink through this bug. Those apps still face disclosure, write/DoS, and Server Action authorization bypass (forging a bound ownerId or isAdmin the server trusts because it thinks it set it). That authz bypass, not RCE, is the realistic impact for most real apps.

05Fix, detect, and what forging leaves behind

Remediate

  • Upgrade now to 15.5.24 or 16.3.3. There is no in-place workaround for affected Windows hosts.
  • Not upgrading today? The bug is Windows-only, so moving the deployment to Linux removes the traversal, and disabling the routes that back the file-system cache reduces reach. Treat these as stopgaps, not fixes.
  • Rotate anything the read may have exposed, most importantly set a fresh NEXT_SERVER_ACTIONS_ENCRYPTION_KEY, since a leaked key stays valid across rebuilds whenever you pin it.

Detect and hunt

  • Requests containing %5C (or %5c) in a route segment, or ..%5C sequences, to /_next/data/... and cached App Router paths.
  • A build manifest whose contents are a {"pageProps":...,"__N_SSG":true} stub instead of real data. That shape is a rendered Pages-route response, not manifest content: the read is destructive, so fetching the manifest through the data endpoint makes the cache re-render the Pages route for the traversed path and overwrite the real manifest with that render. Finding it clobbered is the tell-tale, and the stub even preserves the attacker’s traversal payload in its rest array.
  • Cache files (.html, .rsc, .meta) appearing outside .next\server\app\ or .next\server\pages\, evidence of the write primitive.

06Reproduce it yourself

The lab and tooling that produced every screenshot above ship openly for defenders and testers. It stands up a minimal vulnerable target on a Windows host and drives the full chain.

# build + start the vulnerable lab (Next.js 16.2.0)
powershell -File setup-windows.ps1

# step 1 (browser): warm, then open the traversal read to get encryptionKey
# steps 2-3: forge-cli.mjs decrypt|gadget <key> <actionId> [blob]
# steps 4-5 (through Burp): one command does POST + read-back
node send-action.mjs http://<host>:3000 --key <key> --action <actionId> --proof pwn --burp
Authorised testing only. This is a deliberately vulnerable application; keep it on an isolated lab network, never on anything reachable from production or the internet.

Repository: github.com/FORTBRIDGE-UK/cve-2026-75604

CVE-2026-75604 · path traversal in the Next.js incremental cache (Windows). Affected 13.4-15.5.23 and 16.0-16.3.2; fixed 15.5.24 and 16.3.3. Research and reproduction by Fortbridge. Findings verified against the shipped framework code and on a real Windows 11 host; RCE requires an unguarded version and an application-side higher-order Server Action.

About Post Author