
Secure-by-default demo lab showing how container hardening (distroless images, non-root, read-only filesystem, runtime-injected secrets) can neutralize a critical Next.js/React Server Actions RCE (CVE-2025-55182 “React2Shell”), with side-by-side safe vs unsafe deployments and exploit logs
This project demonstrates a critical Remote Code Execution (RCE) vulnerability in a Next.js application (specifically via Server Actions) and how infrastructure hardening effectively neutralizes the attack even when the code vulnerability remains.
It contrasts a standard "Unsafe" deployment with a hardened "Safe" deployment using Distroless images and Read-Only Filesystems.
Software vulnerabilities are inevitable. When code fails, your infrastructure must prevent the attacker from expanding their foothold.
A Critical RCE (CVE-2025-55182, aka React2Shell) exists in the React Server Components (RSC) implementation used by Next.js.
spawnSync) without authentication.curl, wget, ls, cat) to steal secrets or download malware.
child_process.spawnSync(). This executes binaries directly, without needing a shell (/bin/sh).chmod +x), and runs it.The following logs demonstrate what the attack attempts look like from the application's perspective. This contrast vividly highlights the effectiveness of the security measures.
logs/server.safe.log)The logs show repeated failures (ENOENT).
spawnSync tries to run ls, id, curl. The Distroless image simply does not have these binaries. It is not just about a missing shell; the tools themselves are gone.[Instrumentation] Logging initialized. Writing to: /app/logs/server.safe.log
⨯ Error: NEXT_REDIRECT
... digest: '`{"step":"1. Write Initial Chunk to /tmp/hello_test","success":true}`'
⨯ Error: NEXT_REDIRECT
... digest: '`{"step":"2. Verify Binary was Written","verification":{...},"success":true}`'
⨯ Error: NEXT_REDIRECT
... digest: '`{"step":"4. Execute Binary","stdout":"","stderr":"","error_obj":{"message":"spawnSync /tmp/hello_test EACCES","code":"EACCES"},"success":true}`'
logs/server.unsafe.log)The logs confirm successful command execution and file system manipulation.
[Instrumentation] Logging initialized. Writing to: /app/logs/server.unsafe.log
⨯ Error: NEXT_REDIRECT
... digest: '`{"step":"1. Write Initial Chunk to /tmp/hello_test","success":true}`'
⨯ Error: NEXT_REDIRECT
... digest: '`{"step":"4. Execute Binary","stdout":"Hello from Go binary!\\n","stderr":"","error_obj":null,"success":true}`'
⨯ Error: NEXT_REDIRECT
... digest: '`{"command":"id","args":[],"stdout":"uid=0(root) gid=0(root) ...","stderr":"","status":0,"signal":null}`'
⨯ Error: NEXT_REDIRECT
... digest: '`{"command":"cat","args":["/app/.env"],"stdout":"","stderr":"cat: can\'t open \'/app/.env\': No such file or directory\n","status":1,"signal":null}`'
(Note: In the unsafe logs, cat /app/.env fails above because the file is named .env in the root, but ls -la in the full logs would reveal the directory structure.)
Attempting to run standard shell commands.
id, ls, cat .env, and access sensitive data.spawnSync /bin/sh ENOENT. There is no shell to execute commands.Attempting to bypass "missing tools" by uploading a custom binary.
/tmp/malware.chmod +x.EROFS: read-only file system.Can an attacker load a binary into a variable and execute it directly from memory?
global.payload = "..."), then execute it.child_process functions (spawn, exec) require a file path. They cannot execute a buffer or string directly.memfd_create (a syscall to create an anonymous file in RAM).memfd_create natively. Accessing it would require a C++ addon (like ffi-napi) to be pre-installed in node_modules.gcc, make), an attacker cannot build this addon on the fly."Distroless" images contain only your application and its runtime dependencies. They do not contain package managers, shells, or standard UNIX tools.
ls), download files (curl), or escalate privileges easily.Configure your container runtime to mount the root filesystem as read-only.
docker-compose.yml:
read_only: true
tmpfs:
- /tmp:noexec # CRITICAL: explicitly block execution!
/tmp (write success), but execution fails with EACCES (Permission Denied) because of the noexec flag. This balances functionality (writable tmp) with security.Do not ship .env files in your container images. If an attacker can read files (e.g., cat .env), your secrets are compromised.
environment key).Start the Environment:
Both the safe and unsafe applications are defined in a single docker-compose.yml file.
docker compose up --build -d
Run the Exploits: You can run the exploits against the specific ports to see the difference.
Targeting Unsafe App (Port 3001):
# 1. Standard RCE (LotL) - SUCCEEDS
python exploit/poc.py http://localhost:3001
# 2. Advanced Attack (BYOL) - SUCCEEDS
python exploit/poc_advanced.py http://localhost:3001
Targeting Safe App (Port 3000):
# 1. Standard RCE (LotL) - FAILS (ENOENT)
python exploit/poc.py http://localhost:3000
# 2. Advanced Attack (BYOL) - FAILS (EACCES/EROFS)
python exploit/poc_advanced.py http://localhost:3000
Clean Up:
docker compose down
| Feature | ❌ Unsafe Environment (Port 3001) | ✅ Safe Environment (Port 3000) |
|---|
| Base Image | node:20-alpine (Contains ls, curl, wget, etc.) | gcr.io/distroless/nodejs20-debian12 (No shell, no tools) |
| Filesystem | Writable (Standard Docker default) | Read-Only (read_only: true) |
| Secrets | .env file on disk (Vulnerable to cat .env) | Environment Variables (Injected at runtime) |
| User | root (Default) | Non-root (Enforced by Distroless) |