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.
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.
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.

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.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.

encryptionKey is highlighted. This is the key that unlocks steps 3 to 5.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.

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.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.

$1:constructor:constructor into Function, re-encrypted with the stolen key. This blob replaces a bound value the server believes it created.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.

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.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.

{"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 range | Gadget to Function | Impact |
|---|---|---|
| 13.4 – 14.x | no | no property-path support: read / write / authz-bypass only |
| 15.0.0 – 15.5.22 | RCE-capable | unguarded: full chain works |
| 15.5.23 | no | guard added: traversal only (still unpatched) |
| 16.0.0 – 16.2.x | RCE-capable | unguarded: full chain works |
| 16.3.0 – 16.3.2 | no | guard added: traversal only (still unpatched) |
| 15.5.24 / 16.3.3 | patched | traversal 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..%5Csequences, 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 itsrestarray. - 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
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.