Skip to content

Grant each binary's own ELF loader instead of the FHS ones - #247

Open
charnock-fr wants to merge 2 commits into
LightconeResearch:mainfrom
charnock-fr:fix/sandbox-elf-interpreter
Open

charnock-fr wants to merge 2 commits into
LightconeResearch:mainfrom
charnock-fr:fix/sandbox-elf-interpreter

Conversation

@charnock-fr

Copy link
Copy Markdown

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 (its PT_INTERP) and grants that, so a NixOS binary gets the glibc in its own store path and env starts again.

  • src/lightcone/engine/sandbox/policy.py
    • _elf_interpreter reads PT_INTERP from each file in the exec set and grants its realpath. _ELF_LOADER_GLOBS, elf_loaders() and _loader_patterns() are removed.
    • 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 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:
    • a synthetic ELF whose loader sits outside every FHS path is granted the given loader;
    • scripts, static binaries, truncated files, directories, ELF32 and big-endian files name no loader;
    • a binary cut off before its loader path, a relative path and a path naming a directory grant nothing, and junk after the first NUL is ignored.
  • docs/api/sandbox.md: describing what the sandbox now does.

How it affects other systems (mediumly tested)

  • glibc and musl: unchanged. Their binaries name the loader the globs found, so the same file is granted.
  • Loaders nothing names are no longer granted: a musl loader installed beside glibc, or the 32-bit /lib/ld-linux.so.2 on a multilib host.
  • macOS and Windows: unaffected. Mach-O and PE files name no ELF loader and containerized mode builds no host exec set.
  • Cost: about 5 ms more per policy build since the reads are no longer cached.

Testing

  • NixOS: the issue's command goes from 14 failed, 48 passed to 62 passed.
  • Old and new code side by side, in containers: Debian 12, Debian 13, Alpine 3.24 and Void musl grant the same loader before and after. On Alpine and Void musl, real runs through the shim pass with either version and fail with/usr/bin/env: Permission denied when no loader is granted. They still deny an undeclared tool that runs fine unsandboxed.
  • Mutation checks: 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 new tests.
  • Full suite, NixOS, LC_ALL=C: 1150 passed, 2 failed. Both are docker tests that also fail on main on my host (in-container uv cannot open the host uv cache).
    • Without LC_ALL=C, test_a_denial_reaches_the_user_through_the_boundary also fails, because denial.explain() matches English messages only.

Known gaps

  • Other non-FHS layouts (Guix, Gentoo, etc.) still don't work. A binary's shared libraries need READ, and the read baseline names only /nix/store; with the store made unreadable, env gets past the loader and then fails on libgmp.so.10. The utility search path also names only NixOS's system profile.
  • Unchanged 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 holds on main today. It is outside the threat model (accidental leakage, not hostile recipes) so not dealt with - maybe a new issue?

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>
@EiffL

EiffL commented Oct 3, 2026

Copy link
Copy Markdown
Member

@claude can you figure out why the tests fail?

@claude

claude Bot commented Oct 3, 2026 •

Copy link
Copy Markdown

Claude finished @EiffL's task in 23s —— View job


Why the tests fail

The failures are not caused by this PR's sandbox change.

What failed: the Tests run has 2 red jobs, ubuntu-latest on 3.11 and on 3.13. The 3.11 job ended with 7 failed, 1148 passed, 1 skipped. The 3.13 job fails the same way. ubuntu-latest 3.12 and macos-latest 3.13 passed.

Which tests: every failure is in tests/test_container_smoke.py, for both the podman and docker variants. The sandbox policy tests, including the three new ones, pass.

Root cause: the container image build fails at this step:

RUN UV_PYTHON_INSTALL_DIR=/opt/python uv python install 3.11.17 && chmod -R a+rX /opt
error: No download found for request: cpython-3.11.17-linux-x86_64-gnu

The 3.13 job fails the same way with 3.13.16.

The smoke tests build an image that pins the exact patch of the CI runner's interpreter (.python-version comes from the Python lc runs on). The uv in the pinned image has no download for those patch releases (3.11.17 and 3.13.16). The runner's Python is newer than that uv's python-build-standalone release list. The 3.12 job probably passes because its patch release is in the list.

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 main right now. I haven't run main to confirm.

Likely fix, in a separate change: bump the pinned uv digest (the ghcr.io/astral-sh/uv@sha256:e85be844… constant in engine/image.py) to a uv release that knows those interpreters. Bumping it changes env_version for containerized projects, so it is a deliberate engine release step. Alternatively, rerun CI once the runner images or uv catch up.

I haven't changed any files. The Eval and Claude Code Review runs also show as failed. I didn't look at their logs.

@charnock-fr

Copy link
Copy Markdown
Author

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 head.repo.full_name != github.repository ?

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Sandbox: every sandboxed command fails on NixOS (/usr/bin/env: Permission denied)

2 participants