diff --git a/docs/api/sandbox.md b/docs/api/sandbox.md index 8709d6b7..a2b3a888 100644 --- a/docs/api/sandbox.md +++ b/docs/api/sandbox.md @@ -58,6 +58,13 @@ boundary. Container environment variables remain an explicit allowlist. green, because the allowlisted binaries are exactly the ones that were going to work. This shipped once (a venv on a system python); the rule and its test are the fix. +- **The ELF loader is read from each granted binary, never looked + for.** Landlock checks EXECUTE on the loader's own open, so a binary + whose loader is not granted fails before it starts. A binary's + `PT_INTERP` may name a loader anywhere, and the FHS path may hold a + different file — a NixOS binary names a glibc inside its own store + path, while `/lib64` holds a stub or nix-ld — so looking for loaders + at the FHS paths denies `env`, the first exec of every run. - **SBPL is last-match-wins; Landlock unions.** The asymmetry decides where a rule can live: the macOS guard takes back writes the vendored defaults hand out, and the write tier is restated *after* diff --git a/src/lightcone/engine/sandbox/policy.py b/src/lightcone/engine/sandbox/policy.py index 3b2a062c..9565b1cf 100644 --- a/src/lightcone/engine/sandbox/policy.py +++ b/src/lightcone/engine/sandbox/policy.py @@ -24,12 +24,11 @@ from __future__ import annotations -import functools import os import shutil +import struct import tempfile from collections.abc import Iterable, Sequence -from fnmatch import fnmatch from pathlib import Path from lightcone.engine.project import ProjectError @@ -140,18 +139,8 @@ def utility(name: str) -> Path | None: "/dev/null", "/dev/zero", "/dev/full", "/dev/tty", "/dev/pts", "/dev/ptmx", ) # fmt: skip -#: The ELF interpreter. Landlock checks EXECUTE on the *loader's* open, -#: so without these every dynamically linked binary — bash and python -#: included — fails EACCES and the sandbox is unusable. Globbed rather -#: than hardcoded: the path differs -#: across glibc/musl and architectures. -_ELF_LOADER_GLOBS = ( - "/lib64/ld-linux-*.so.*", - "/lib/ld-linux*.so.*", - "/lib/ld-musl-*.so.*", - "/usr/lib/ld-linux*.so.*", - "/usr/lib64/ld-linux-*.so.*", -) +#: The ELF program header type naming a binary's loader. +_PT_INTERP = 3 #: Prefixes shared with the rest of the host. An interpreter installed #: into one of these does not bring its own tree with it, so only the @@ -391,7 +380,7 @@ def _stdlib_root(python: Path | None) -> list[Path]: def _exec_set(env_dir: Path, python: Path | None) -> list[Path]: - """The two exec tiers: the environment, and the utility allowlist. + """The two exec tiers, the environment and the utility allowlist, plus their loaders. Grants are per *file* for the utilities, never per directory: ``/usr/bin`` holds ``bash`` and ``latex`` alike, so a directory grant @@ -436,48 +425,57 @@ def _exec_set(env_dir: Path, python: Path | None) -> list[Path]: found = utility(name) if found is not None: paths.append(found) - paths.extend(elf_loaders()) + loaders = [_elf_interpreter(path) for path in paths] + paths.extend(loader for loader in loaders if loader is not None) return paths -@functools.cache -def elf_loaders() -> tuple[Path, ...]: - """Find the dynamic loaders present on this host. +def _elf_interpreter(binary: Path) -> Path | None: + """The realpath of the loader an ELF binary names, if it names one. + + Landlock checks EXECUTE on the loader's own open, so a granted binary + whose loader is not granted fails ``EACCES`` before it starts — bash + and python included. The loader is read from the binary rather than + looked for at the FHS paths, because a binary may name one anywhere + and the FHS path may hold a different file — a NixOS binary names a + glibc inside its own store path, while ``/lib64`` holds a stub or + nix-ld. + + Only little-endian ELF64 is read: on Linux lc installs only for x86_64 + and aarch64. A 32-bit tool on such a host gets no loader grant, so it + fails with a denial rather than running unsandboxed. - Landlock checks EXECUTE on the loader's open, so without these every - dynamically linked binary fails ``EACCES``. Scans each distinct - directory once rather than globbing five patterns — on a merged- - ``/usr`` system all five resolve to the same directory, and globbing - re-lists ~8000 entries per pattern, which measured as 95% of the - policy build. + The path is read up to its first NUL, as the kernel reads it, and kept + only when it is absolute and resolves to a file. A malformed entry + must never grant a directory. Returns: - The realpath'd loaders. Cached: the answer cannot change while - the process runs. + ``None`` for anything else: a script, a static binary, a Mach-O, + a directory, a malformed loader entry. """ - found: set[Path] = set() - for directory, patterns in _loader_patterns().items(): - try: - entries = list(os.scandir(directory)) - except OSError: - continue - for entry in entries: - # Cheap prefix reject before fnmatch: almost nothing in a - # library directory starts with `ld-`. - if entry.name.startswith("ld-") and any( - fnmatch(entry.name, pattern) for pattern in patterns - ): - found.add(Path(entry.path).resolve()) - return tuple(sorted(found)) - - -def _loader_patterns() -> dict[str, set[str]]: - """The loader globs, grouped by the real directory they name.""" - grouped: dict[str, set[str]] = {} - for pattern in _ELF_LOADER_GLOBS: - directory, _, name = pattern.rpartition("/") - grouped.setdefault(os.path.realpath(directory), set()).add(name) - return grouped + try: + with binary.open("rb") as f: + header = f.read(64) + if header[:6] != b"\x7fELF\x02\x01": + return None + (table_offset,) = struct.unpack_from(" tuple[Path, ...]: diff --git a/tests/test_sandbox_policy.py b/tests/test_sandbox_policy.py index 66978d7b..e1f38ddc 100644 --- a/tests/test_sandbox_policy.py +++ b/tests/test_sandbox_policy.py @@ -8,6 +8,7 @@ import os import shutil +import struct import sys from collections.abc import Iterator from pathlib import Path @@ -354,14 +355,86 @@ def test_the_env_the_seam_execs_is_one_the_policy_granted( assert built.grants(spawned, built.execute) -@pytest.mark.skipif(sys.platform != "linux", reason="the ELF loader tier is Linux-only") -def test_the_elf_loader_is_in_the_exec_set(built: policy_module.Policy) -> None: - """Landlock checks EXECUTE on the loader's own open, so without this - every dynamically linked binary — bash and python included — fails - EACCES and the sandbox is unusable.""" - loaders = policy_module.elf_loaders() - assert loaders, "no ELF loader found on this host" - assert all(loader in built.execute for loader in loaders) +def _elf(path: Path, interpreter: str | None, *, ident: bytes = b"\x7fELF\x02\x01") -> Path: + """Write an executable little-endian ELF64 header and program table: a + `PT_LOAD`, then a `PT_INTERP` naming ``interpreter`` when one is + given. ``ident`` overrides the magic, class and byte order alone.""" + name = interpreter.encode() + b"\0" if interpreter else b"" + count = 2 if interpreter else 1 + elf = ident + b"\x01" + bytes(9) + elf += struct.pack(" None: + """Landlock checks EXECUTE on the loader's own open, so a granted + binary whose loader is not granted fails EACCES before it starts. + + The loader sits outside every FHS path on purpose: a binary may name + one anywhere — a NixOS binary names a glibc inside its own store + path, while `/lib64` holds a stub or nix-ld — and a policy that + looked for loaders at the FHS paths instead of reading them denied + every command on such a host.""" + loader = tmp_path / "store" / "glibc" / "lib" / "ld-linux-x86-64.so.2" + loader.parent.mkdir(parents=True) + loader.write_bytes(b"") + project = tmp_path / "proj" + bin_dir = project / ".venv" / "bin" + bin_dir.mkdir(parents=True) + _elf(bin_dir / "tool", str(loader)) + + with scope(policy_module.exec_policy(project)) as built: + assert loader.resolve() in built.execute + + +def test_only_a_dynamically_linked_elf_names_a_loader(tmp_path: Path) -> None: + """Scripts, static binaries and directories sit in the exec set too, + and a malformed file must not fail the policy build. + + ELF32 and big-endian files are refused at the header rather than + misread through offsets laid out for little-endian ELF64 — no Linux + lc installs on is either, and a 32-bit tool left without its loader + is a denial, never an unsandboxed run.""" + loader = tmp_path / "ld.so" + loader.write_bytes(b"") + script = tmp_path / "script" + script.write_text("#!/bin/sh\n") + truncated = tmp_path / "truncated" + truncated.write_bytes(_elf(tmp_path / "whole", str(loader)).read_bytes()[:70]) + elf32 = _elf(tmp_path / "elf32", str(loader), ident=b"\x7fELF\x01\x01") + big_endian = _elf(tmp_path / "big-endian", str(loader), ident=b"\x7fELF\x02\x02") + static = _elf(tmp_path / "static", None) + for path in (script, static, truncated, elf32, big_endian, tmp_path): + assert policy_module._elf_interpreter(path) is None, path + assert policy_module._elf_interpreter(tmp_path / "whole") == loader.resolve() + + +def test_a_malformed_loader_entry_grants_nothing( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """The loader path is read up to its first NUL, as the kernel reads + it, and granted only when it is absolute and names a file. + + A binary cut off before its loader path reads an empty one, which + resolves to the working directory — and EXECUTE on a directory + reaches everything below it. The relative path names a real file + from the working directory, so only the absolute check refuses it.""" + monkeypatch.chdir(tmp_path) + loader = tmp_path / "ld.so" + loader.write_bytes(b"") + cut = tmp_path / "cut" + cut.write_bytes(_elf(tmp_path / "whole", str(loader)).read_bytes()[: 64 + 56 * 2]) + relative = _elf(tmp_path / "relative", "ld.so") + directory = _elf(tmp_path / "directory", str(tmp_path)) + for path in (cut, relative, directory): + assert policy_module._elf_interpreter(path) is None, path + padded = _elf(tmp_path / "padded", f"{loader}\0junk") + assert policy_module._elf_interpreter(padded) == loader.resolve() def test_the_venv_and_the_interpreter_behind_it_are_granted(tmp_path: Path) -> None: