diff --git a/README.md b/README.md index 5b540e6..75806ae 100644 --- a/README.md +++ b/README.md @@ -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). diff --git a/docker-compose.yml b/docker-compose.yml index 6e9b741..864ce80 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 diff --git a/gdbui_server/docker/sandbox.Dockerfile b/gdbui_server/docker/sandbox.Dockerfile new file mode 100644 index 0000000..f06c713 --- /dev/null +++ b/gdbui_server/docker/sandbox.Dockerfile @@ -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"] diff --git a/gdbui_server/flask_test.py b/gdbui_server/flask_test.py index 1d289f2..d84b357 100644 --- a/gdbui_server/flask_test.py +++ b/gdbui_server/flask_test.py @@ -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") diff --git a/gdbui_server/main.py b/gdbui_server/main.py index fce0dc8..1e3062b 100644 --- a/gdbui_server/main.py +++ b/gdbui_server/main.py @@ -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 @@ -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 diff --git a/gdbui_server/sandbox.py b/gdbui_server/sandbox.py new file mode 100644 index 0000000..51a4241 --- /dev/null +++ b/gdbui_server/sandbox.py @@ -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) diff --git a/gdbui_server/session_manager.py b/gdbui_server/session_manager.py index e275f92..ef4cd2f 100644 --- a/gdbui_server/session_manager.py +++ b/gdbui_server/session_manager.py @@ -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__) @@ -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) @@ -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) @@ -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() @@ -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) diff --git a/gdbui_server/tests/test_sandbox.py b/gdbui_server/tests/test_sandbox.py new file mode 100644 index 0000000..44650af --- /dev/null +++ b/gdbui_server/tests/test_sandbox.py @@ -0,0 +1,113 @@ +"""Tests for the sandbox module.""" + +import os +import subprocess +import sys +import unittest +from unittest.mock import patch + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + + +class TestSandboxDisabled(unittest.TestCase): + """When GDBUI_DOCKER is not set, start/stop are no-ops.""" + + def setUp(self): + # Ensure env var is not set + os.environ.pop("GDBUI_DOCKER", None) + # Force reimport with clean env + if "sandbox" in sys.modules: + del sys.modules["sandbox"] + + def test_start_returns_none_when_disabled(self): + from sandbox import start_container + + self.assertIsNone(start_container("any-sid", "/tmp")) + + def test_stop_does_nothing_when_disabled(self): + from sandbox import stop_container + + try: + stop_container("any-sid") + except Exception: + self.fail("stop_container raised unexpectedly") + + def test_container_name_ignores_env(self): + from sandbox import _container_name + + self.assertEqual(_container_name("a1b2c3d4"), "gdbui-a1b2c3d4") + self.assertEqual(_container_name("550e8400-e29b-41d4-a716"), "gdbui-550e8400") + + +class TestSandboxEnabled(unittest.TestCase): + """When GDBUI_DOCKER=true, start/stop shell out to docker CLI.""" + + @classmethod + def setUpClass(cls): + os.environ["GDBUI_DOCKER"] = "true" + if "sandbox" in sys.modules: + del sys.modules["sandbox"] + import sandbox as sb + + cls.sb = sb + + @classmethod + def tearDownClass(cls): + os.environ.pop("GDBUI_DOCKER", None) + + @patch("sandbox.subprocess.run") + def test_start_runs_docker(self, mock_run): + name = self.sb.start_container("550e8400-e29b-41d4-a716", "output/sid") + self.assertEqual(name, "gdbui-550e8400") + call_args = mock_run.call_args[0][0] + self.assertIn("docker", call_args) + self.assertIn("run", call_args) + self.assertIn("gdbui-550e8400", call_args) + self.assertIn("--read-only", call_args) + self.assertIn("--network", call_args) + self.assertIn("none", call_args) + + @patch("sandbox.subprocess.run") + def test_start_mounts_output_dir(self, mock_run): + self.sb.start_container("sid12345", "output/sid12345") + call_args = mock_run.call_args[0][0] + vol_idx = call_args.index("-v") + 1 + self.assertIn("output/sid12345:/workspace", call_args[vol_idx]) + + @patch("sandbox.subprocess.run") + def test_stop_runs_docker_rm(self, mock_run): + self.sb.stop_container("550e8400-e29b-41d4-a716") + call_args = mock_run.call_args[0][0] + self.assertIn("docker", call_args) + self.assertIn("rm", call_args) + + @patch("sandbox.subprocess.run") + def test_stop_silent_on_missing(self, mock_run): + mock_run.side_effect = Exception("container not found") + self.sb.stop_container("missing") + + @patch("sandbox.subprocess.run") + def test_start_returns_none_on_failure(self, mock_run): + mock_run.side_effect = Exception("docker daemon not running") + result = self.sb.start_container("any", "/tmp") + self.assertIsNone(result) + + @patch("sandbox.subprocess.run") + def test_start_idempotent_when_name_in_use(self, mock_run): + err = subprocess.CalledProcessError( + 125, ["docker", "run"], stderr=b'Conflict. The container name "/gdbui-550e8400" is already in use' + ) + mock_run.side_effect = err + name = self.sb.start_container("550e8400-e29b-41d4-a716", "output/sid") + self.assertEqual(name, "gdbui-550e8400") + + @patch("sandbox.subprocess.run") + def test_start_returns_none_on_other_calledprocesserror(self, mock_run): + err = subprocess.CalledProcessError(125, ["docker", "run"], stderr=b"docker daemon not reachable") + mock_run.side_effect = err + result = self.sb.start_container("any", "/tmp") + self.assertIsNone(result) + + +if __name__ == "__main__": + unittest.main() diff --git a/gdbui_server/tests/test_session_manager.py b/gdbui_server/tests/test_session_manager.py index bafe746..ba0596e 100644 --- a/gdbui_server/tests/test_session_manager.py +++ b/gdbui_server/tests/test_session_manager.py @@ -278,5 +278,101 @@ def test_normalize_program_name(self): self.assertEqual(normalize_program_name('test'), 'test') +class TestSessionManagerSandbox(unittest.TestCase): + """Sandbox-enabled behavior: GDB runs inside a per-session Docker container.""" + + def _make_mock_controller(self): + controller = MagicMock() + controller.write.return_value = [{'payload': 'mock output'}] + controller.exit.return_value = None + return controller + + @patch('session_manager.SANDBOX_ENABLED', True) + @patch('session_manager.start_container', return_value='gdbui-abc12345') + @patch('session_manager.stop_container') + @patch('session_manager.os.path.exists', return_value=True) + @patch('session_manager.GdbController') + def test_start_gdb_uses_docker_exec(self, MockGdbController, mock_exists, + mock_stop, mock_start): + mock_controller = self._make_mock_controller() + MockGdbController.return_value = mock_controller + + sm = SessionManager() + sid, _ = sm.create_session() + sm.start_gdb(sid, 'program') + + mock_start.assert_called_once_with(sid, os.path.join('output', sid)) + MockGdbController.assert_called_once_with( + command=['docker', 'exec', '-i', 'gdbui-abc12345', 'gdb', '--interpreter=mi2']) + mock_controller.write.assert_called_once_with( + '-file-exec-and-symbols /workspace/program.exe', timeout_sec=30) + sm.shutdown() + + @patch('session_manager.SANDBOX_ENABLED', True) + @patch('session_manager.start_container', return_value='gdbui-abc12345') + @patch('session_manager.os.path.exists', return_value=True) + @patch('session_manager.GdbController') + def test_end_session_stops_container(self, MockGdbController, mock_exists, mock_start): + mock_controller = self._make_mock_controller() + MockGdbController.return_value = mock_controller + + sm = SessionManager() + sid, _ = sm.create_session() + sm.start_gdb(sid, 'program') + + with patch('session_manager.stop_container') as mock_stop: + sm.end_session(sid) + mock_stop.assert_called_once_with(sid) + + @patch('session_manager.SANDBOX_ENABLED', True) + @patch('session_manager.start_container', return_value='gdbui-abc12345') + @patch('session_manager.os.path.exists', return_value=True) + @patch('session_manager.GdbController') + def test_stop_gdb_stops_container(self, MockGdbController, mock_exists, mock_start): + mock_controller = self._make_mock_controller() + MockGdbController.return_value = mock_controller + + sm = SessionManager() + sid, _ = sm.create_session() + sm.start_gdb(sid, 'program') + + with patch('session_manager.stop_container') as mock_stop: + sm.stop_gdb(sid) + mock_stop.assert_called_once_with(sid) + + @patch('session_manager.SANDBOX_ENABLED', True) + @patch('session_manager.start_container', return_value=None) + @patch('session_manager.stop_container') + @patch('session_manager.os.path.exists', return_value=True) + @patch('session_manager.GdbController') + def test_start_gdb_fails_closed_when_container_unavailable(self, MockGdbController, + mock_exists, mock_stop, mock_start): + sm = SessionManager() + sid, _ = sm.create_session() + with self.assertRaises(RuntimeError): + sm.start_gdb(sid, 'program') + MockGdbController.assert_not_called() + sm.shutdown() + + @patch('session_manager.SANDBOX_ENABLED', True) + @patch('session_manager.start_container', return_value='gdbui-abc12345') + @patch('session_manager.stop_container') + @patch('session_manager.os.path.exists', return_value=True) + @patch('session_manager.GdbController') + def test_program_switch_stops_container_before_restart(self, MockGdbController, + mock_exists, mock_stop, mock_start): + controllers = [self._make_mock_controller(), self._make_mock_controller()] + MockGdbController.side_effect = controllers + + sm = SessionManager() + sid, _ = sm.create_session() + sm.start_gdb(sid, 'program_a') + sm.start_gdb(sid, 'program_b') + + self.assertEqual(mock_stop.call_count, 1) + self.assertEqual(mock_start.call_count, 2) + sm.shutdown() + + if __name__ == '__main__': unittest.main()