Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -362,7 +362,17 @@ GDB-UI implements robust multi-user isolation to support multiple concurrent use
- **Compiling Safely**: When compilation (`/compile`) or file upload (`/upload_file`) is requested, the server locks the session, checks if a debug session is active, and if so, blocks compilation and returns `409 Conflict`. Otherwise, it writes the file and runs the compiler cleanly, updating the program state.
- **Error Resilience**: Malformed GDB Machine Interface (MI) tokens are caught by the `_parse_response` wrapper inside `SessionManager`, returning structured error payloads to the client without terminating the debug session.

### Sandbox Mode (Docker)

For deployments running untrusted code, GDB-UI can execute **both compilation and debugging inside a per-session Docker container** instead of on the host. This closes the `call system("rm -rf /")` expression-injection hole described below: even a malicious expression only runs inside an isolated container.

- **Off by default**: opt-in via the `GDBUI_DOCKER=true` environment variable on the server (default `false`). When disabled, behavior is unchanged from above.
- **Build the image**: `docker compose --profile build build sandbox-image`
- **What's isolated**: each session gets a container with `--read-only` rootfs, `--network none`, and a `--tmpfs /tmp` scratch area. `g++` (compile) and `gdb` (debug) are invoked via `docker exec`. The session's `output/{session_id}/` directory is bind-mounted at `/workspace`, so compiled binaries are immediately available to GDB.
- **Docker-outside-of-Docker**: the server container mounts `/var/run/docker.sock` to manage sandbox containers.
- **Fail-closed**: if the sandbox container cannot be started while `GDBUI_DOCKER=true`, compilation and debugging are refused rather than silently running on the host.

### Known Limitations

- **BLOCKED_COMMANDS is not sufficient against expression injection**: The `BLOCKED_COMMANDS` set blocks shell-level commands (`shell`, `python`, `!`, etc.) but does NOT prevent malicious expressions from executing through GDB's expression evaluator. For example, `call system("rm -rf /")` is a valid GDB MI expression that bypasses the command-level blocklist. Full sandboxing requires Phase 3 Docker container isolation.
- **BLOCKED_COMMANDS is not sufficient against expression injection**: The `BLOCKED_COMMANDS` set blocks shell-level commands (`shell`, `python`, `!`, etc.) but does NOT prevent malicious expressions from executing through GDB's expression evaluator. For example, `call system("rm -rf /")` is a valid GDB MI expression that bypasses the command-level blocklist. Mitigation: enable [Sandbox Mode](#sandbox-mode-docker) so such expressions run inside an isolated container.
- **Session ID as sole auth token**: The `session_id` UUID is the only authorization mechanism. This is acceptable for local or trusted-network deployments. Public internet exposure would require additional authentication (signed tokens, user accounts, or HTTPS + HttpOnly cookies).
11 changes: 11 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,22 @@ services:
context: ./gdbui_server
volumes:
- ./gdbui_server:/app
- /var/run/docker.sock:/var/run/docker.sock
environment:
- GDBUI_DOCKER=false
ports:
- "10000:10000"
expose:
- 10000

sandbox-image:
build:
dockerfile: sandbox.Dockerfile
context: ./gdbui_server/docker
image: gdbui-sandbox:latest
profiles:
- build

webapp:
build:
dockerfile: DockerFile.dev
Expand Down
9 changes: 9 additions & 0 deletions gdbui_server/docker/sandbox.Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
FROM python:3.10-slim

RUN apt-get update && apt-get install -y --no-install-recommends \
gdb \
g++ \
&& rm -rf /var/lib/apt/lists/*

WORKDIR /workspace
CMD ["sleep", "infinity"]
38 changes: 38 additions & 0 deletions gdbui_server/flask_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,44 @@ def test_compile_code_failure(self, mock_run):
body = self.assert_v2_error_response(response, 400)
self.assertEqual(body["error"]["code"], "COMPILATION_FAILED")

@mock.patch("main.SANDBOX_ENABLED", True)
@mock.patch("main.start_container", return_value="gdbui-test")
@mock.patch("main.subprocess.run")
def test_v2_compile_uses_docker_exec_when_sandbox_enabled(self, mock_run, mock_start):
mock_run.return_value = mock.Mock(returncode=0, stdout="", stderr="")
payload = {
"session_id": self.session_id,
"code": 'int main() { return 0; }',
"name": "test_program",
}

with mock.patch("builtins.open", mock.mock_open()):
response = self.client.post("/v2/compile", data=json.dumps(payload), content_type="application/json")
self.assert_v2_success_response(response)
mock_start.assert_called_once()
args = mock_run.call_args[0][0]
self.assertEqual(args[:3], ["docker", "exec", "-i"])
self.assertIn("gdbui-test", args)
self.assertIn("g++", args)
self.assertIn("/workspace/test_program", args)
self.assertIn("/workspace/test_program.exe", args)

@mock.patch("main.SANDBOX_ENABLED", True)
@mock.patch("main.start_container", return_value=None)
@mock.patch("main.subprocess.run")
def test_v2_compile_fails_closed_when_container_start_fails(self, mock_run, mock_start):
payload = {
"session_id": self.session_id,
"code": 'int main() { return 0; }',
"name": "test_program",
}

with mock.patch("builtins.open", mock.mock_open()):
response = self.client.post("/v2/compile", data=json.dumps(payload), content_type="application/json")
body = self.assert_v2_error_response(response, 400)
self.assertEqual(body["error"]["code"], "COMPILATION_FAILED")
mock_run.assert_not_called()

@mock.patch("main.subprocess.run")
def test_v2_compile_rejects_missing_payload(self, mock_run):
response = self.client.post("/v2/compile", content_type="application/json")
Expand Down
15 changes: 14 additions & 1 deletion gdbui_server/main.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from flask import Flask, request, jsonify, g
from flask_cors import CORS
from session_manager import SessionManager, ensure_exe_extension, sanitize_program_name
from sandbox import SANDBOX_ENABLED, start_container
import subprocess
import os
import atexit
Expand Down Expand Up @@ -293,8 +294,20 @@ def compile_code():
# Phase 2: Compilation OUTSIDE lock (I/O bound, do not block session)
try:
try:
if SANDBOX_ENABLED:
container_name = start_container(session_id, output_dir)
if container_name is None:
raise RuntimeError("Sandbox container failed to start; compilation refused")
compile_cmd = [
'docker', 'exec', '-i', container_name,
'g++', '-g', '-O0',
f'/workspace/{safe_name}',
'-o', f'/workspace/{ensure_exe_extension(binary_name)}'
]
else:
compile_cmd = ['g++', '-g', '-O0', source_path, '-o', binary_path]
result = subprocess.run(
['g++', '-g', '-O0', source_path, '-o', binary_path],
compile_cmd,
capture_output=True,
text=True,
timeout=30
Expand Down
104 changes: 104 additions & 0 deletions gdbui_server/sandbox.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
"""Per-session Docker container management for GDB sandboxing.

Each session gets a dedicated container with read-only rootfs and no network.
GDB and g++ are invoked via ``docker exec``. The session's output directory
is bind-mounted at /workspace so compiled binaries are immediately available.

Usage::

from sandbox import start_container, stop_container

name = start_container(session_id, output_dir)
if name:
GdbController(command=["docker", "exec", "-i", name, "gdb", "--interpreter=mi2"])
stop_container(session_id)
"""

from __future__ import annotations

import logging
import os
import subprocess

logger = logging.getLogger(__name__)

SANDBOX_ENABLED = os.environ.get("GDBUI_DOCKER", "").lower() in ("1", "true", "yes")
SANDBOX_IMAGE = os.environ.get("GDBUI_SANDBOX_IMAGE", "gdbui-sandbox:latest")
CONTAINER_PREFIX = "gdbui-"


def _container_name(session_id: str) -> str:
"""Deterministic container name derived from session id."""
return f"{CONTAINER_PREFIX}{session_id[:8]}"


def start_container(session_id: str, output_dir: str) -> str | None:
"""Start a sandbox container for *session_id*.

The container stays alive (``sleep infinity``) until explicitly stopped.
*output_dir* is bind-mounted at ``/workspace`` inside the container.

Returns the container name, or ``None`` if sandboxing is disabled.
"""
if not SANDBOX_ENABLED:
return None

name = _container_name(session_id)
abs_output = os.path.abspath(output_dir)
try:
subprocess.run(
[
"docker",
"run",
"-d",
"--rm",
"--name",
name,
"-v",
f"{abs_output}:/workspace",
"--network",
"none",
"--read-only",
"--tmpfs",
"/tmp:rw,noexec,nosuid,size=64m",
SANDBOX_IMAGE,
"sleep",
"86400",
],
capture_output=True,
check=True,
timeout=30,
)
logger.info("Sandbox started: %s (%s)", name, session_id)
return name
except subprocess.CalledProcessError as e:
# Name conflict means the container already exists and is running
# (deterministic name + --rm: it only goes away via stop_container).
if b"is already in use" in e.stderr or b"Conflict" in e.stderr:
logger.info("Sandbox already running: %s", name)
return name
logger.exception("Failed to start sandbox for session %s", session_id)
return None
except Exception:
logger.exception("Failed to start sandbox for session %s", session_id)
return None


def stop_container(session_id: str) -> None:
"""Stop and remove the sandbox container for *session_id*.

Safe to call even if the container was never started or already removed.
"""
if not SANDBOX_ENABLED:
return

name = _container_name(session_id)
try:
subprocess.run(
["docker", "rm", "-f", name],
capture_output=True,
timeout=15,
)
logger.info("Sandbox stopped: %s", name)
except Exception:
logger.exception("Failed to stop sandbox container %s", name)
16 changes: 13 additions & 3 deletions gdbui_server/session_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from gevent.event import Event
from pygdbmi.gdbcontroller import GdbController
import pygdbmi.gdbmiparser as gdbmiparser
from sandbox import SANDBOX_ENABLED, start_container, stop_container

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -105,6 +106,7 @@ def _end_session_if_expired(self, session_id):
except Exception as e:
logger.warning("Failed to exit controller for expired session %s: %s", session_id, e)
if session:
stop_container(session_id)
shutil.rmtree(os.path.join('output', session_id), ignore_errors=True)
logger.info("Expired session cleaned up: %s", session_id)

Expand Down Expand Up @@ -267,6 +269,7 @@ def end_session(self, session_id):
session['controller'].exit()
except Exception as e:
logger.warning("Failed to exit controller for session %s: %s", session_id, e)
stop_container(session_id)
if session:
shutil.rmtree(os.path.join('output', session_id), ignore_errors=True)
logger.info("Session ended: %s", session_id)
Expand Down Expand Up @@ -305,14 +308,20 @@ def start_gdb(self, session_id, program):
old_controller.exit()
except Exception as e:
logger.warning("Failed to exit old controller for session %s: %s", session_id, e)
stop_container(session_id)

controller = GdbController()
container_name = start_container(session_id, session_output_dir) if SANDBOX_ENABLED else None
if SANDBOX_ENABLED and container_name is None:
raise RuntimeError("Sandbox container failed to start. Refusing to run unsandboxed.")
controller = GdbController(command=['docker', 'exec', '-i', container_name, 'gdb', '--interpreter=mi2']) if container_name else GdbController()
try:
binary_name = safe_name.replace('.cpp', '').replace('.c', '').replace('.exe', '')
exe_path = os.path.join('output', session_id, ensure_exe_extension(binary_name))
binary_file = ensure_exe_extension(binary_name)
exe_path = os.path.join('output', session_id, binary_file)
if not os.path.exists(exe_path):
raise RuntimeError(f"Binary not found at {exe_path}. Please compile your program first.")
controller.write(f"-file-exec-and-symbols {exe_path}", timeout_sec=GDB_TIMEOUT)
gdb_path = f'/workspace/{binary_file}' if container_name else exe_path
controller.write(f"-file-exec-and-symbols {gdb_path}", timeout_sec=GDB_TIMEOUT)
except Exception:
try:
controller.exit()
Expand Down Expand Up @@ -367,6 +376,7 @@ def stop_gdb(self, session_id: str):
controller.exit()
except Exception as e:
logger.warning("Error exiting GDB for session %s: %s", session_id, e)
stop_container(session_id)

def execute(self, session_id, command):
validate_command(command)
Expand Down
Loading
Loading