Grant each binary's own ELF loader instead of the FHS ones - #247
charnock-fr wants to merge 2 commits into
Conversation
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 <tom@charnock.fr>
|
@claude can you figure out why the tests fail? |
|
Claude finished @EiffL's task in 23s —— View job Why the tests failThe failures are not caused by this PR's sandbox change. What failed: the Which tests: every failure is in Root cause: the container image build fails at this step: The 3.13 job fails the same way with The smoke tests build an image that pins the exact patch of the CI runner's interpreter ( This depends on the GitHub runner's toolcache Python version and the pinned uv digest. It is unrelated to the ELF-loader change, and the same failure would likely show on Likely fix, in a separate change: bump the pinned uv digest (the I haven't changed any files. The |
|
So it looks like tests pass on all four jobs now with the version of uv bumped (#248). But I think the problem with eval and Claude Code Review failing is because this PR comes from a fork and doesn't inherit the secrets. I guess this happens from all PRs for forks, I can either make pushes directly or we could skip eval and Claude Code review workflows when |
Would close #246.
What this changes
Before, the policy granted ELF loaders found by globbing FHS locations (
/lib64/ld-linux-*and friends) but it now reads the loader each binary in the exec set names (itsPT_INTERP) and grants that, so a NixOS binary gets the glibc in its own store path andenvstarts again.src/lightcone/engine/sandbox/policy.py_elf_interpreterreadsPT_INTERPfrom each file in the exec set and grants its realpath._ELF_LOADER_GLOBS,elf_loaders()and_loader_patterns()are removed.aarch64 and a 32-bit tool gets no loader grant (which makes it fail as a denial).
tests/test_sandbox_policy.py: the host-dependent loader test becomes three that run anywhere:docs/api/sandbox.md: describing what the sandbox now does.How it affects other systems (mediumly tested)
/lib/ld-linux.so.2on a multilib host.Testing
/usr/bin/env: Permission deniedwhen no loader is granted. They still deny an undeclared tool that runs fine unsandboxed.LC_ALL=C: 1150 passed, 2 failed. Both are docker tests that also fail onmainon my host (in-container uv cannot open the host uv cache).LC_ALL=C,test_a_denial_reaches_the_user_through_the_boundaryalso fails, becausedenial.explain()matches English messages only.Known gaps
/nix/store; with the store made unreadable,envgets past the loader and then fails onlibgmp.so.10. The utility search path also names only NixOS's system profile.ld.so /usr/bin/git --versionruns wheregitalone 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 holds onmaintoday. It is outside the threat model (accidental leakage, not hostile recipes) so not dealt with - maybe a new issue?