Skip to content
Open
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
7 changes: 7 additions & 0 deletions docs/api/sandbox.md
Original file line number Diff line number Diff line change
Expand Up @@ -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*
Expand Down
98 changes: 48 additions & 50 deletions src/lightcone/engine/sandbox/policy.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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("<Q", header, 32)
entry_size, count = struct.unpack_from("<HH", header, 54)
f.seek(table_offset)
table = f.read(entry_size * count)
for index in range(count):
kind, _, offset, _, _, size = struct.unpack_from(
"<IIQQQQ", table, index * entry_size
)
if kind == _PT_INTERP:
f.seek(offset)
name = f.read(size).split(b"\0", 1)[0]
if not name.startswith(b"/"):
return None
loader = Path(os.fsdecode(name)).resolve()
return loader if loader.is_file() else None
except (OSError, struct.error):
return None
return None


def _declared(paths: Iterable[Path]) -> tuple[Path, ...]:
Expand Down
89 changes: 81 additions & 8 deletions tests/test_sandbox_policy.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

import os
import shutil
import struct
import sys
from collections.abc import Iterator
from pathlib import Path
Expand Down Expand Up @@ -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("<HHIQQQIHHHHHH", 2, 62, 1, 0, 64, 0, 0, 64, 56, count, 0, 0, 0)
elf += struct.pack("<IIQQQQQQ", 1, 0, 0, 0, 0, 0, 0, 1)
if interpreter:
elf += struct.pack("<IIQQQQQQ", 3, 0, 64 + 56 * count, 0, 0, len(name), len(name), 1)
path.write_bytes(elf + name)
path.chmod(0o755)
return path


def test_the_loader_a_granted_binary_names_is_granted(tmp_path: Path) -> 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:
Expand Down
Loading