diff --git a/pyisolate/runtime/child.py b/pyisolate/runtime/child.py index c488f7e..c9885d5 100644 --- a/pyisolate/runtime/child.py +++ b/pyisolate/runtime/child.py @@ -42,6 +42,7 @@ from typing import Any from .. import errors +from .confine import apply_confinement from .thread import _SAFE_BUILTINS, _blocked_open, _make_importer, _thread_local _LEN = struct.Struct("!I") @@ -185,6 +186,27 @@ def _serve(sock: socket.socket) -> None: ) channel = _GuestChannel(sock) guest_globals = _build_guest_globals(channel, allowed_imports) + + # Confine the process *before* any guest code runs. Everything this module + # needs is already imported; guest imports (openat/mmap/read) are unaffected + # by the deny-list, but execve/ptrace/module-load/etc. now kill the process. + if bootstrap.get("confine", True): + report = apply_confinement( + mem_bytes=bootstrap.get("mem_bytes"), + cpu_seconds=bootstrap.get("cpu_seconds"), + require_seccomp=bool(bootstrap.get("require_seccomp", False)), + ) + _send_frame( + sock, + { + "ev": "confinement", + "seccomp": report.seccomp, + "seccomp_denied": report.seccomp_denied, + "rlimits": report.rlimits, + "skipped": report.skipped, + }, + ) + _send_frame(sock, {"ev": "ready"}) while True: diff --git a/pyisolate/runtime/confine.py b/pyisolate/runtime/confine.py new file mode 100644 index 0000000..12b45c7 --- /dev/null +++ b/pyisolate/runtime/confine.py @@ -0,0 +1,244 @@ +"""Kernel-level confinement applied to a ``backend="process"`` guest process. + +The process boundary (see :mod:`pyisolate.runtime.process_backend`) already +stops guest code from reaching the supervisor's address space. This module +adds the *kernel* half of the boundary, applied inside the guest process before +any guest code runs: + +* ``PR_SET_NO_NEW_PRIVS`` so the guest can never gain privileges (and so an + unprivileged process is allowed to install a seccomp filter); +* a **seccomp** syscall filter that kills the process if it issues an + unambiguously dangerous syscall -- executing new programs, tracing, mounting, + joining/creating namespaces, loading kernel modules or BPF, reading another + process's memory, and so on. Because ``no_new_privs`` is set the filter + cannot be removed, and seccomp filters are inherited across ``fork``/``clone``, + so any child the guest spawns stays confined; +* resource limits (:mod:`resource`) that bound core dumps, address space, and + CPU time. + +This is a **deny-list**, not a complete allow-list: it is a strong, robust +reduction of the syscall attack surface that still lets a normal CPython +interpreter run, not a proof that only a fixed syscall set is reachable. The +filter is x86-64 specific; on other architectures seccomp is skipped and the +report records that it was not applied. +""" + +from __future__ import annotations + +import ctypes +import platform +import resource +import sys +from dataclasses import dataclass, field + +# prctl options +_PR_SET_NO_NEW_PRIVS = 38 +_PR_SET_SECCOMP = 22 +_SECCOMP_MODE_FILTER = 2 + +# seccomp return actions +_SECCOMP_RET_KILL_PROCESS = 0x80000000 +_SECCOMP_RET_ALLOW = 0x7FFF0000 + +# BPF opcodes for the classic filter program seccomp consumes. +_BPF_LD = 0x00 +_BPF_W = 0x00 +_BPF_ABS = 0x20 +_BPF_JMP = 0x05 +_BPF_JEQ = 0x10 +_BPF_RET = 0x06 +_BPF_K = 0x00 + +# Audit arch token; the filter refuses to run under a different arch so a 32-bit +# or x32 syscall ABI cannot be used to reach a denied syscall by a different +# number. +_AUDIT_ARCH_X86_64 = 0xC000003E + +# Offsets into ``struct seccomp_data``. +_SECCOMP_DATA_NR_OFFSET = 0 +_SECCOMP_DATA_ARCH_OFFSET = 4 + +# x86-64 syscall numbers for the deny-list. These are a stable ABI and never +# change for this architecture. A normal compute workload never issues any of +# them; every entry is an escape, execution, kernel-management, or +# cross-process-memory primitive. +DANGEROUS_SYSCALLS_X86_64: dict[str, int] = { + "execve": 59, + "execveat": 322, + "ptrace": 101, + "mount": 165, + "umount2": 166, + "pivot_root": 155, + "chroot": 161, + "setns": 308, + "unshare": 272, + "kexec_load": 246, + "kexec_file_load": 320, + "init_module": 175, + "finit_module": 313, + "delete_module": 176, + "bpf": 321, + "perf_event_open": 298, + "process_vm_readv": 310, + "process_vm_writev": 311, + "add_key": 248, + "request_key": 249, + "keyctl": 250, + "reboot": 169, + "swapon": 167, + "swapoff": 168, +} + + +class _SockFilter(ctypes.Structure): + _fields_ = [ + ("code", ctypes.c_uint16), + ("jt", ctypes.c_uint8), + ("jf", ctypes.c_uint8), + ("k", ctypes.c_uint32), + ] + + +class _SockFprog(ctypes.Structure): + _fields_ = [ + ("len", ctypes.c_uint16), + ("filter", ctypes.POINTER(_SockFilter)), + ] + + +@dataclass +class ConfinementReport: + """What confinement was actually applied to the guest process.""" + + no_new_privs: bool = False + seccomp: bool = False + seccomp_denied: int = 0 + rlimits: list[str] = field(default_factory=list) + skipped: list[str] = field(default_factory=list) + + +def seccomp_supported() -> bool: + """Return whether the seccomp filter in this module can be installed here.""" + return sys.platform.startswith("linux") and platform.machine() == "x86_64" + + +def _build_filter_program(denied: list[int]) -> "ctypes.Array[_SockFilter]": + instructions: list[_SockFilter] = [ + # A = seccomp_data.arch + _SockFilter(_BPF_LD | _BPF_W | _BPF_ABS, 0, 0, _SECCOMP_DATA_ARCH_OFFSET), + # if arch == x86-64, skip the kill; otherwise fall through to it + _SockFilter(_BPF_JMP | _BPF_JEQ | _BPF_K, 1, 0, _AUDIT_ARCH_X86_64), + _SockFilter(_BPF_RET | _BPF_K, 0, 0, _SECCOMP_RET_KILL_PROCESS), + # A = seccomp_data.nr + _SockFilter(_BPF_LD | _BPF_W | _BPF_ABS, 0, 0, _SECCOMP_DATA_NR_OFFSET), + ] + # One JEQ per denied syscall, each jumping to the shared KILL return that + # sits immediately after the ALLOW return at the end of the program. + count = len(denied) + kill_index = len(instructions) + count + 1 + for nr in denied: + # ``len(instructions)`` is the index this JEQ will occupy; on a match it + # jumps forward to the shared KILL return at the end of the program. + current_index = len(instructions) + jt = kill_index - current_index - 1 + instructions.append(_SockFilter(_BPF_JMP | _BPF_JEQ | _BPF_K, jt, 0, nr)) + instructions.append(_SockFilter(_BPF_RET | _BPF_K, 0, 0, _SECCOMP_RET_ALLOW)) + instructions.append(_SockFilter(_BPF_RET | _BPF_K, 0, 0, _SECCOMP_RET_KILL_PROCESS)) + return (_SockFilter * len(instructions))(*instructions) + + +def install_seccomp_filter(denied: list[int] | None = None) -> int: + """Install the seccomp deny-list filter. Returns the number of denied calls. + + Raises :class:`OSError` if ``no_new_privs`` or the filter cannot be set, and + :class:`RuntimeError` on an unsupported architecture. + """ + if not seccomp_supported(): + raise RuntimeError("seccomp filter is only implemented for x86-64 Linux") + if denied is None: + denied = list(DANGEROUS_SYSCALLS_X86_64.values()) + + libc = ctypes.CDLL(None, use_errno=True) + + if libc.prctl(_PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) != 0: + raise OSError(ctypes.get_errno(), "prctl(PR_SET_NO_NEW_PRIVS) failed") + + program = _build_filter_program(denied) + fprog = _SockFprog(len(program), program) + if ( + libc.prctl(_PR_SET_SECCOMP, _SECCOMP_MODE_FILTER, ctypes.byref(fprog), 0, 0) + != 0 + ): + raise OSError(ctypes.get_errno(), "prctl(PR_SET_SECCOMP) failed") + return len(denied) + + +def _apply_rlimits( + report: ConfinementReport, + *, + mem_bytes: int | None, + cpu_seconds: int | None, +) -> None: + # Never leak memory contents through a core dump of the guest. + try: + resource.setrlimit(resource.RLIMIT_CORE, (0, 0)) + report.rlimits.append("core=0") + except (ValueError, OSError): + report.skipped.append("rlimit_core") + + if mem_bytes is not None: + try: + resource.setrlimit(resource.RLIMIT_AS, (mem_bytes, mem_bytes)) + report.rlimits.append(f"as={mem_bytes}") + except (ValueError, OSError): + report.skipped.append("rlimit_as") + + if cpu_seconds is not None: + try: + resource.setrlimit(resource.RLIMIT_CPU, (cpu_seconds, cpu_seconds)) + report.rlimits.append(f"cpu={cpu_seconds}") + except (ValueError, OSError): + report.skipped.append("rlimit_cpu") + + +def apply_confinement( + *, + mem_bytes: int | None = None, + cpu_seconds: int | None = None, + seccomp: bool = True, + require_seccomp: bool = False, +) -> ConfinementReport: + """Confine the *current* process before it runs guest code. + + Resource limits are always attempted (best-effort, recorded in the report). + The seccomp filter is installed when ``seccomp`` is true and the platform + supports it; ``require_seccomp`` turns an unsupported platform or a failed + install into a raised error instead of a skip. + """ + report = ConfinementReport() + _apply_rlimits(report, mem_bytes=mem_bytes, cpu_seconds=cpu_seconds) + + if not seccomp: + report.skipped.append("seccomp_disabled") + return report + + if not seccomp_supported(): + if require_seccomp: + raise RuntimeError( + "seccomp confinement required but unsupported on this platform" + ) + report.skipped.append("seccomp_unsupported") + return report + + try: + denied = install_seccomp_filter() + except OSError as exc: + if require_seccomp: + raise + report.skipped.append(f"seccomp_failed:{exc}") + return report + + report.no_new_privs = True + report.seccomp = True + report.seccomp_denied = denied + return report diff --git a/pyisolate/runtime/process_backend.py b/pyisolate/runtime/process_backend.py index ffed8aa..8381b70 100644 --- a/pyisolate/runtime/process_backend.py +++ b/pyisolate/runtime/process_backend.py @@ -80,6 +80,10 @@ def __init__( policy: Any = None, allowed_imports: Optional[list[str]] = None, backend: str = "process", + mem_bytes: Optional[int] = None, + cpu_seconds: Optional[int] = None, + confine: bool = True, + require_seccomp: bool = False, ) -> None: self.name = name self._backend = backend @@ -100,6 +104,9 @@ def __init__( self._quarantine_reason: Optional[str] = None self._ops = 0 self._errors = 0 + # Populated from the child's "confinement" frame during startup. + self.confinement: Optional[dict[str, Any]] = None + self._confined = threading.Event() fs, tcp = _extract_fs_tcp(policy) @@ -126,6 +133,10 @@ def __init__( "allowed_imports": allowed_imports, "fs": fs, "tcp": tcp, + "confine": confine, + "mem_bytes": mem_bytes, + "cpu_seconds": cpu_seconds, + "require_seccomp": require_seccomp, } ) @@ -133,6 +144,8 @@ def __init__( target=self._read_loop, name=f"pyisolate-proc-{name}", daemon=True ) self._reader.start() + if not confine: + self._confined.set() # -- transport --------------------------------------------------------- @@ -171,7 +184,16 @@ def _read_loop(self) -> None: except (json.JSONDecodeError, UnicodeDecodeError): continue self._dispatch(frame) - self._closed = True + # The channel closed. If this was not a caller-initiated stop, the guest + # process died on its own -- e.g. a seccomp-denied syscall killed it -- + # so surface that to any waiter instead of letting recv() hang to + # timeout. + if not self._closed: + self._closed = True + self._confined.set() + self._outbox.put( + errors.SandboxError("guest process terminated unexpectedly") + ) def _dispatch(self, frame: dict[str, Any]) -> None: ev = frame.get("ev") @@ -180,6 +202,9 @@ def _dispatch(self, frame: dict[str, Any]) -> None: elif ev == "error": self._errors += 1 self._outbox.put(self._rebuild_exception(frame)) + elif ev == "confinement": + self.confinement = frame + self._confined.set() # "ready", "done", "log", and "metric" are lifecycle/telemetry frames # that do not feed recv(); logging/metrics routing is added with the # observability wiring for this backend. @@ -195,6 +220,11 @@ def _rebuild_exception(frame: dict[str, Any]) -> Exception: return cls(message) return errors.SandboxError(f"{exc_type}: {message}") + def wait_confined(self, timeout: float | None = None) -> Optional[dict[str, Any]]: + """Block until the child reports its confinement, returning the report.""" + self._confined.wait(timeout) + return self.confinement + # -- cell ABI ---------------------------------------------------------- def exec(self, src: str) -> None: @@ -225,6 +255,14 @@ def recv(self, timeout: Optional[float] = None): def is_alive(self) -> bool: return self._proc.poll() is None + @property + def returncode(self) -> Optional[int]: + """Child exit status: ``None`` if running, else exit code or ``-signal``. + + A guest killed by its seccomp filter reports ``-signal.SIGSYS``. + """ + return self._proc.poll() + def cancel(self, timeout: float = 0.2) -> bool: with self._lock: if not self._closed: diff --git a/pyisolate/supervisor.py b/pyisolate/supervisor.py index 82af2ca..578c952 100644 --- a/pyisolate/supervisor.py +++ b/pyisolate/supervisor.py @@ -333,6 +333,7 @@ def spawn( name, policy=policy, allowed_imports=allowed_imports, + mem_bytes=mem_bytes, tenant=tenant, tenant_quota=tenant_quota, ) @@ -454,6 +455,7 @@ def _spawn_process( *, policy: Any, allowed_imports: Optional[list[str]], + mem_bytes: Optional[int], tenant: Optional[str], tenant_quota: Optional[int], ) -> Sandbox: @@ -485,6 +487,8 @@ def _spawn_process( policy=policy, allowed_imports=allowed_imports, backend="process", + mem_bytes=mem_bytes, + require_seccomp=self._rollout_mode == "hardened", ) except Exception: if usage_reserved and tenant: diff --git a/tests/test_process_confinement.py b/tests/test_process_confinement.py new file mode 100644 index 0000000..933dfdc --- /dev/null +++ b/tests/test_process_confinement.py @@ -0,0 +1,152 @@ +"""Tests for kernel confinement of ``backend="process"`` guest processes. + +The seccomp/rlimit layer is x86-64-Linux specific; tests that depend on the +filter actually being installed skip elsewhere. Tests that install a +kill-on-syscall filter run in a forked child so a denied syscall cannot take +down the pytest process. +""" + +import os +import signal +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) + +import pytest + +import pyisolate as iso +from pyisolate.runtime import confine + +requires_seccomp = pytest.mark.skipif( + not confine.seccomp_supported(), + reason="seccomp deny-list filter is implemented for x86-64 Linux only", +) + +# Escape that defeats the Python import guard and reaches the *real* os, then +# calls the real execve -- which the kernel seccomp filter must kill. +_ESCAPE_EXECVE = """ +for cls in ().__class__.__base__.__subclasses__(): + if cls.__name__ == "catch_warnings": + _os = cls()._module.__builtins__["__import__"]("os") + post("reached-os") + _os.execv("/bin/true", ["/bin/true"]) + break +""" + + +def test_filter_program_has_only_valid_forward_jumps(): + # Regression guard: an earlier builder double-counted the instruction index + # and produced negative jump offsets that wrapped to 255 in the u8 field, + # which the kernel rejects with EINVAL. Every jump must land inside the + # program and every path must end in a RET. + denied = list(confine.DANGEROUS_SYSCALLS_X86_64.values()) + program = confine._build_filter_program(denied) + length = len(program) + assert length == len(denied) + 6 + _BPF_CLASS_MASK = 0x07 + _BPF_JMP = 0x05 + _BPF_RET = 0x06 + for index in range(length): + ins = program[index] + if ins.code & _BPF_CLASS_MASK == _BPF_JMP: + # Only jump instructions consult jt/jf; both targets must be in range. + assert index + 1 + ins.jt < length, f"jt out of bounds at {index}" + assert index + 1 + ins.jf < length, f"jf out of bounds at {index}" + # Last two instructions are the ALLOW then KILL returns. + assert program[length - 1].code & _BPF_CLASS_MASK == _BPF_RET + assert program[length - 2].code & _BPF_CLASS_MASK == _BPF_RET + + +def _run_in_forked_child(fn) -> int: + """Run ``fn`` in a forked child and return its exit status word.""" + pid = os.fork() + if pid == 0: # pragma: no cover - runs in the child + try: + fn() + except BaseException: + os._exit(70) + os._exit(0) + _, status = os.waitpid(pid, 0) + return status + + +@requires_seccomp +def test_apply_confinement_installs_seccomp_and_allows_normal_syscalls(tmp_path): + marker = tmp_path / "ok.txt" + + def child(): + report = confine.apply_confinement() + assert report.seccomp is True + assert report.no_new_privs is True + assert report.seccomp_denied == len(confine.DANGEROUS_SYSCALLS_X86_64) + # Ordinary syscalls (open/write/read) must still work after the filter. + marker.write_text("ok", encoding="utf-8") + assert marker.read_text(encoding="utf-8") == "ok" + + status = _run_in_forked_child(child) + assert os.WIFEXITED(status) + assert os.WEXITSTATUS(status) == 0 + assert marker.read_text(encoding="utf-8") == "ok" + + +@requires_seccomp +def test_seccomp_kills_process_on_denied_syscall(): + def child(): + confine.apply_confinement() + # execve is on the deny-list; this must be killed by SIGSYS, not run. + os.execv("/bin/true", ["/bin/true"]) + + status = _run_in_forked_child(child) + assert os.WIFSIGNALED(status) + assert os.WTERMSIG(status) == signal.SIGSYS + + +def test_apply_confinement_disables_core_dumps(): + def child(): + report = confine.apply_confinement(seccomp=False) + assert "core=0" in report.rlimits + + status = _run_in_forked_child(child) + assert os.WIFEXITED(status) and os.WEXITSTATUS(status) == 0 + + +@requires_seccomp +def test_process_sandbox_is_confined_by_default(): + with iso.spawn("conf-default", allowed_imports=["math"], backend="process") as sb: + report = sb._thread.wait_confined(timeout=5) + assert report is not None + assert report["seccomp"] is True + # Confinement does not break ordinary guest execution. + sb.exec("from math import sqrt; post(sqrt(9))") + assert sb.recv(timeout=5) == 3.0 + + +@requires_seccomp +def test_real_execve_escape_is_killed_by_the_kernel(): + # The guest defeats the Python import guard and reaches the real os.execv; + # the seccomp filter must kill the guest process at the kernel level. + sb = iso.spawn("conf-escape", allowed_imports=["math"], backend="process") + try: + sb.exec(_ESCAPE_EXECVE) + assert sb.recv(timeout=5) == "reached-os" + with pytest.raises(iso.SandboxError): + sb.recv(timeout=5) + sb.close() + # -SIGSYS confirms the kernel (not a normal exit) terminated the guest. + assert sb._thread.returncode == -signal.SIGSYS + finally: + sb.close() + + +def test_process_sandbox_confinement_can_be_disabled(): + from pyisolate.runtime.process_backend import ProcessSandbox + + proc = ProcessSandbox("conf-off", allowed_imports=["math"], confine=False) + try: + proc.exec("from math import sqrt; post(sqrt(4))") + assert proc.recv(timeout=5) == 2.0 + assert proc.confinement is None + finally: + proc.stop()