From e71aa743709287d5f7379c7b6068a716a850bf2d Mon Sep 17 00:00:00 2001 From: Tom Charnock Date: Sat, 3 Oct 2026 11:28:53 +0200 Subject: [PATCH] Grant each binary's own ELF loader instead of the FHS ones On NixOS every sandboxed command failed with `lc sandbox: /usr/bin/env: Permission denied` (exit 126): `lc run`, every `lc materialize` recipe, and the enforcement suite. Landlock checks EXECUTE on a binary's ELF loader, and the policy granted loaders found by globbing FHS paths (`/lib64/ld-linux-*` and friends). A Nix-built binary names a glibc loader inside its own store path, while `/lib64/ld-linux-x86-64.so.2` is NixOS's stub-ld or nix-ld, so `env`, the first exec of every run, could not start. - `policy._elf_interpreter` reads `PT_INTERP` from each file in the exec set and grants its realpath, replacing `_ELF_LOADER_GLOBS`, `elf_loaders()` and `_loader_patterns()`. - The path is read up to its first NUL, as the kernel reads it, and granted only when it is absolute and resolves to a file. - Only little-endian ELF64 is read: on Linux lc installs only for x86_64 and aarch64. A 32-bit tool gets no loader grant, so it fails as a denial, never unsandboxed. - glibc and musl hosts grant what they did before because their binaries name the loader the globs found. A loader that is present but that nothing in the exec set names is no longer granted (musl beside glibc, the 32-bit `/lib/ld-linux.so.2` on multilib, etc.). - The policy build costs about 5 ms more per task; the reads are not cached. - Docs: `docs/api/sandbox.md`. The rule is layout-agnostic, but other non-FHS layouts still need their store readable and the read baseline currently only sets `/nix/store`. Not changed here: a granted loader can start any readable binary (`ld.so /usr/bin/git --version` runs where `git` alone is denied), because Landlock checks EXECUTE on an exec, not on the mapping a loader makes. FHS hosts already grant their system loader so this currently holds on `main`. It is probably outside the threat model (accidental leakage rather than hostile recipes). - `test_the_loader_a_granted_binary_names_is_granted` builds a synthetic ELF whose loader sits outside every FHS path, pinning the fix on any host. - `test_only_a_dynamically_linked_elf_names_a_loader` covers scripts, static binaries, truncated files, directories, ELF32 and big-endian files. - `test_a_malformed_loader_entry_grants_nothing` covers a binary cut off before its loader path, a relative path, a path naming a directory, and junk after the first NUL. - All were mutation-checked: dropping the loader grant, reducing the header check to the ELF magic, dropping the absolute-path check, dropping the file check, or reading past the first NUL each fails exactly one of the loader tests. Checked in containers with the old and new code side by side: Debian 12 and 13, Alpine 3.24 and Void musl each grant the same loader before and after. On Alpine and Void musl, real runs through the shim pass with either version, fail with the NixOS error when no loader is granted, and still deny an undeclared tool. On a NixOS host, the sandbox enforcement, denial and wrap tests go from 14 failed, 48 passed to 62 passed (with `LC_ALL=C`). `uv run pytest` with `LC_ALL=C`: 1150 passed and 2 failed. Both fail identically on `main` because in-container uv cannot open the host uv cache. Without `LC_ALL=C`, `test_a_denial_reaches_the_user_through_the_boundary` also fails under a French locale, because `denial.explain()` matches English messages only. Signed-off-by: Tom Charnock --- docs/api/sandbox.md | 7 ++ src/lightcone/engine/sandbox/policy.py | 98 +++++++++++++------------- tests/test_sandbox_policy.py | 89 ++++++++++++++++++++--- 3 files changed, 136 insertions(+), 58 deletions(-) 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: