← Writing
Research

From React2Shell to toy labs: understanding insecure deserialization

Dec 8, 2025 · 8 min read

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

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:


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:

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:

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:


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:

  1. The server reads payload["1"] into chunk.
  2. It sees chunk.then === "logAndEval" and calls thenHandlers.logAndEval(chunk, response).
  3. logAndEval pulls chunk._expr ("1 + 2 + 3 * 4").
  4. It builds new Function("return (" + expr + ");")new Function("return (1 + 2 + 3 * 4);").
  5. 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:

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:

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:

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:


6. Takeaways for your own code

Whether or not you ever touch React Server Components, this pattern shows up everywhere:

To avoid ending up in the same situation:

  1. Don’t deserialize untrusted data into rich objects with behavior.
    Deserialize into plain data structures and validate them first.
  2. Treat protocol fields like code, not just data.
    Anything that controls which function gets called (then, type, handler) needs strict allowlists.
  3. Never eval / new Function on data from the network.
    If you really must interpret expressions, constrain them to a safe DSL and sandbox heavily.
  4. 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.
← All writing