From React2Shell to toy labs: understanding insecure deserialization
"Insecure deserialization often leads to remote code execution. Even if deserialization flaws do not result in remote code execution, they can be used to perform attacks, including replay attacks, injection attacks, and privilege escalation." — OWASP
CVE-2025-55182 ("React2Shell") pushed insecure deserialization back into the spotlight:
- It’s a CVSS 10.0 RCE in React Server Components (RSC).
- The bug lives in the Flight protocol used by RSC: attacker-controlled data is deserialized unsafely, letting crafted objects influence server-side execution and achieve code execution on the Node.js server.
You absolutely shouldn’t be testing real React2Shell payloads against anything but an isolated lab. But you can understand the core pattern with a tiny, self-contained Node.js example that mimics the same class of bug: deserialization of untrusted data → treating it as special internal objects → executing attacker-controlled behavior.
This post walks through:
- A quick refresher on insecure deserialization.
- A toy "fake Flight" server in Node/Express that’s intentionally vulnerable.
- A harmless "payload" that only evaluates math.
- A safer version of the same endpoint.
- How this maps conceptually to real-world bugs like React2Shell.
1. Insecure deserialization in one paragraph
Insecure deserialization is when an application takes untrusted data, deserializes it into objects, and then uses those objects in ways that let an attacker:
- Manipulate logic,
- Abuse "magic" methods or callbacks,
- Or even execute arbitrary code.
That’s why it shows up in the OWASP Top 10 and is treated as a high-risk category: unsafe deserialization logic can be a straight path to RCE.
React2Shell is a modern instance of this: the server deserializes Flight payloads and interprets them as internal “Chunk” objects. Because that deserialization didn’t sufficiently validate or constrain what those objects could look like, attackers found a way to make React resolve fake chunks and end up running arbitrary JavaScript on the server.
Let’s recreate that pattern in a tiny Node app.
2. A tiny "fake Flight" server (intentionally vulnerable)
We’ll build a Node/Express server that:
- Accepts a JSON payload that looks like a "slot map" (
"0","1", etc.). - Pulls out slot
"1"as a "chunk". - Treats any chunk with a
thenfield as special. - Calls a handler based on
chunk.then. - Uses
chunk._exprinsidenew Function().
This is obviously unsafe; that’s the point. It’s a simplified version of the larger deserialization & "thenable" handling patterns described in real-world React2Shell writeups.
2.1 Setup
mkdir fake-flight-lab
cd fake-flight-lab
npm init -y
npm install express body-parser
2.2 Vulnerable server (server-vuln.js)
const express = require("express");
const bodyParser = require("body-parser");
const app = express();
app.use(bodyParser.json());
// --- Fake "chunk" resolver (vulnerable) ---
// Very naive "promise-like" resolver: if an object has a `then` field,
// we treat it as something that needs to be "resolved" via a handler.
const thenHandlers = {
// Our "legit" handler; mapping is attacker-controllable in this toy
logAndEval: function (chunk, response) {
console.log("[*] logAndEval called with chunk:", chunk);
// Attacker-controlled field:
const expr = chunk._expr;
// VULNERABLE: directly compile and run attacker-controlled string.
// In real bugs, this might be an internal expression or dynamic call.
const fn = new Function(`return (${expr});`);
const result = fn();
response.value = result;
},
};
// Fake "Flight" endpoint
app.post("/fake-flight", (req, res) => {
const payload = req.body;
console.log("[*] Received payload:", JSON.stringify(payload, null, 2));
// Pick slot "1" as our main "chunk"
const chunk = payload["1"];
const response = { value: null };
try {
// VULN #1: trust that whatever is in `chunk.then` is a valid handler name
const handlerName = chunk.then;
// VULN #2: look it up dynamically without validation
const handler = thenHandlers[handlerName];
if (typeof handler !== "function") {
throw new Error("No such handler");
}
// Call the handler with the (attacker-controlled) chunk
handler(chunk, response);
// Serialize the result
res.json({
ok: true,
result: response.value,
});
} catch (e) {
console.error("[!] Error while processing fake-flight payload:", e);
res.status(500).json({ ok: false, error: e.message });
}
});
app.listen(3000, () => {
console.log("VULN fake-flight server listening on http://localhost:3000");
});
What’s insecure here:
- We treat any object with
.thenas special and use that field to control which function runs. - We take a string from
_exprand feed it straight intonew Function→ arbitrary code path inside the Node process. This mirrors how insecure deserialization can escalate into code execution in real apps.
3. A harmless "malicious" payload (just math)
Start the server:
node server-vuln.js
Now send a JSON payload that looks suspiciously like a "chunk":
curl -X POST http://localhost:3000/fake-flight -H "Content-Type: application/json" -d '{
"0": "meta",
"1": {
"status": "resolved_model",
"_expr": "1 + 2 + 3 * 4",
"then": "logAndEval"
}
}'
What happens:
- The server reads
payload["1"]intochunk. - It sees
chunk.then === "logAndEval"and callsthenHandlers.logAndEval(chunk, response). logAndEvalpullschunk._expr("1 + 2 + 3 * 4").- It builds
new Function("return (" + expr + ");")→new Function("return (1 + 2 + 3 * 4);"). - Executes it and returns:
{
"ok": true,
"result": 15
}
This is benign — we only evaluated a math expression — but structurally we’ve reproduced the core pattern:
- Attacker controls object fields (
then,_expr) in the deserialized payload. - The server uses those fields to drive control flow (which handler to run) and compose code to execute.
That’s exactly the kind of chain OWASP warns about: untrusted data being deserialized and then used to manipulate object attributes or insert new ones, leading to logic abuse or code execution.
4. Fixing the toy: structured data, no new Function
Now let’s add a safe variant of the same endpoint, so you can see what “good” looks like in contrast.
4.1 Safer handler
Add this to the same file (or a new one):
// --- Safer variant: explicit, whitelisted operations only ---
const SAFE_OPS = new Set(["add", "mul"]);
function safeEvalExpression(op, a, b) {
if (!SAFE_OPS.has(op)) {
throw new Error("Unsupported op");
}
a = Number(a);
b = Number(b);
if (Number.isNaN(a) || Number.isNaN(b)) {
throw new Error("Invalid numbers");
}
return op === "add" ? a + b : a * b;
}
app.post("/fake-flight-safe", (req, res) => {
const payload = req.body;
const chunk = payload["1"];
const response = { value: null };
try {
// Validate structure explicitly
if (!chunk || chunk.status !== "resolved_model") {
throw new Error("bad status");
}
const { op, a, b } = chunk;
const result = safeEvalExpression(op, a, b);
response.value = result;
res.json({ ok: true, result: response.value });
} catch (e) {
console.error("[!] Error in safe endpoint:", e);
res.status(400).json({ ok: false, error: e.message });
}
});
Now call:
curl -X POST http://localhost:3000/fake-flight-safe -H "Content-Type: application/json" -d '{
"1": {
"status": "resolved_model",
"op": "add",
"a": 10,
"b": 20
}
}'
You should see:
{
"ok": true,
"result": 30
}
Here we:
- Don’t treat
thenas a magic field. - Don’t run arbitrary expressions.
- Do:
- Validate fields,
- Constrain operations to a whitelist,
- Coerce types.
This matches common guidance on safe deserialization: define a strict schema, validate all fields, and never deserialize directly into objects with behavior (callbacks, dynamic code) that can be triggered by untrusted data.
5. Mapping this toy back to React2Shell
Real-world CVE-2025-55182 is obviously more complex, but the shape of the bug is similar:
- RSC has a Flight protocol with a structured payload (slots, references, status codes).
- The server deserializes that payload into internal “Chunks”.
- Those chunks are treated like promise-like objects and resolved using their
thenand related fields. - Because the code did not sufficiently validate or lock down the structure, attacker-supplied objects can be interpreted as internal chunks, and their fields end up influencing internal callbacks and state — eventually leading to arbitrary JS execution in the Node process.
Security writeups on React2Shell repeatedly describe it as an unsafe / insecure deserialization flaw in the Flight protocol: untrusted data, parsed into internal structures, used to control resolution logic and server-side behavior.
This toy example gives you a mental model to read those writeups:
- Anywhere you see “fake Chunk objects”, think: our
chunkobject with attacker-controlled fields. - Anywhere you see “thenable abused”, think: our
chunk.thencontrolling which function runs. - Anywhere you see “code injected into internal expression”, think: our
_exprstring passed intonew Function().
6. Takeaways for your own code
Whether or not you ever touch React Server Components, this pattern shows up everywhere:
- JSON / binary payloads mapped into "magic" objects.
- ORMs or serializers that hydrate classes with methods and hooks.
- Message formats that allow callbacks or special fields (
__proto__,then,_onLoad, etc.).
To avoid ending up in the same situation:
- Don’t deserialize untrusted data into rich objects with behavior.
Deserialize into plain data structures and validate them first. - Treat protocol fields like code, not just data.
Anything that controls which function gets called (then,type,handler) needs strict allowlists. - Never
eval/new Functionon data from the network.
If you really must interpret expressions, constrain them to a safe DSL and sandbox heavily. - For complex protocols (like Flight), write fuzzers and negative tests.
When you see a deserializer that:- Builds graphs of objects,
- Treats some fields specially,
- Traverses prototypes / thenables…
…that’s a huge red flag and deserves targeted testing.