March 11, 2024·6 min read·engineeringnixsecuritysandboxingbuilds

Nix 2.21 closes a gap between hashing an output and keeping it immutable

CVE-2024-27297 let cooperating builds retain writable descriptors after output registration. Copying outputs before verification removes the retained descriptor's access to the accepted object.

A hash check only describes the bytes that the checker actually read. If another process can change those bytes after the check, the expected hash does not establish what later consumers will receive. CVE-2024-27297 exposed that gap in Nix fixed-output derivations on Linux. A build could pass a writable descriptor to another process, finish, and leave that process able to change an output Nix had accepted as immutable.

The Nix 2.21 release notes, dated 11 March, include the correction. Its security issue was already public in the 7 March security advisory. The release incorporates a fix to a concrete sandbox escape, rather than introducing a new kind of hash or changing the intended content of a package.

I find this vulnerability especially relevant to Nix's declarative model. The usefulness of declaring build inputs depends on the store preserving the accepted results. An output that changes after registration breaks the relationship between the declared build and the bytes another build consumes.

Fixed-output derivations need network access

The original report linked by the advisory explains why fixed-output derivations have a different network arrangement. Downloading sources requires access outside the normal build environment. A fixed-output derivation declares the expected output hash in advance, allowing the result of that network operation to be checked against a known value.

The build's ability to access a network does not grant it permission to choose arbitrary accepted content. An expected hash is the restriction that makes the exception useful. The vulnerability concerns maintaining that restriction through the transition from a running builder to a registered store output.

On Linux, abstract Unix-domain sockets belong to the network namespace. Their names do not require the communicating processes to share a filesystem location. Fixed-output derivations could therefore communicate through such a socket with another fixed-output derivation or a process on the host, despite filesystem isolation.

That communication ability has a particularly relevant capability. Unix-domain sockets can transfer file descriptors between processes. Passing a descriptor supplies access to an already opened object. It does not require the receiving process to locate and open the same pathname in its own filesystem view.

This distinction is central to the report. A sandbox can restrict which paths a process can open while an inherited or transferred descriptor still provides access to an object outside that visible set of paths. Reasoning only about the receiver's directory tree misses the authority represented by the descriptor itself.

A descriptor outlived the builder

The reported sequence begins with a build opening its output for writing and sending the descriptor through an abstract socket. Another process retains that descriptor. Nix can then finish the build and register the output while the receiving process still holds a way to modify the underlying file.

Killing the processes inside the original build sandbox is insufficient for that sequence. The authority has moved to another process. The report explicitly uses a cooperating receiver so the writable descriptor can survive the lifecycle of the builder that created it.

The security advisory states the consequence narrowly. Output can change after Nix has registered the path as valid and immutable, including fixed-output content changing away from the expected value. That is an integrity failure in the accepted build result. It is not a claim that the attacker found a collision in the declared hash algorithm.

The distinction matters because increasing hash strength would not fix it. Here, the attacker arranges for the correct bytes to exist when Nix checks them, then changes the object later. The calculation can be completely correct for the input it sees while the larger acceptance procedure still fails to preserve that input.

Verification must operate on an object the builder cannot change

The project's security announcement describes the fix as copying the paths before verification. The copy separates the object Nix accepts from the object reachable through a retained descriptor. A write through the old descriptor can no longer modify the copied output being checked.

That ordering is important. Copying an output after deciding which bytes are valid would not establish that the copied bytes are the ones the hash approved. The accepted object must be separated before the relevant verification so the check and subsequent use concern the isolated result.

The original report considers this approach independently of stronger network isolation. It describes copying as neutralising the effect of descriptor transfer. The builder's old descriptor still refers to its old object, but the object Nix checks is no longer the one under that descriptor's control.

The tradeoff is copying work. The report points out that its cost depends on the filesystem and output shape, and that a tree of many small files can be a different case from a simple output. There is no benchmark in the release notes that would justify assigning a universal overhead to the fix.

The security value comes from changing the ownership of the accepted bytes at the relevant stage. It does not depend on predicting every possible communication mechanism available to a malicious build. That makes output separation useful even when other sandbox restrictions are also under consideration.

Stronger network isolation has different consequences

The original report discusses blocking abstract sockets and placing fixed-output builders in a network namespace as possible approaches. These are proposed mitigations in the report, not a description of everything delivered by 2.21. Keeping that distinction avoids turning the design discussion into a claim about the released implementation.

Blocking abstract sockets targets the descriptor-transfer route. A separate network namespace would instead change the builder's network environment more broadly and require a way to preserve the intended source-download capability. The report notes implications for addressing and DNS in that approach.

Those choices illustrate the design tension behind the exception for source downloads. A fixed-output builder needs access to information outside the store. That requirement does not mean every host-local communication capability is necessary to perform the download. Separating the useful network access from other authority is more involved than toggling a single word called sandboxing.

I prefer the explanation in terms of the actual capability that crossed the boundary. Saying only that the build was sandboxed would conceal the descriptor route. Saying that the hash failed would conceal the fact that the accepted file could still change after a correct check.

Shared builders define the immediate exposure

The advisory recommends not allowing untrusted builds on shared infrastructure as a workaround. That scope follows the cooperation involved in the attack. An untrusted derivation needs a receiver that can retain the descriptor and act at the relevant time. A shared execution environment creates opportunities for such cooperating processes.

This is also why the issue matters beyond the malicious derivation's own output. A fixed-output result can become an input to another build. If its contents change after acceptance, a downstream derivation can consume content different from the value the fixed-output check was intended to guarantee.

The project announcement lists corrected releases on existing branches, including 2.20.5 and 2.18.2. Nix 2.21 includes the same security correction in its own release. The distinction is useful for a deployment following a maintenance branch, because obtaining the security fix does not necessarily require adopting every other 2.21 change.

An accepted output also needs to remain stable for independent consumers to agree about it. If two downstream builds read at different times while a retained writer changes the file, the shared pathname stops identifying a stable input. The vulnerability therefore undermines the ordinary reuse of a result as well as the initial fixed-output check.

The rest of 2.21 concerns different boundaries

The release also adds --arg-from-file and --arg-from-stdin. These pass file or standard-input contents into a Nix expression as a string argument. They concern getting input into evaluation, and do not themselves alter fixed-output verification or descriptor handling.

Other changes improve the debugger's access to bindings and the information shown in errors. Those additions can help explain evaluation, but they should remain separate from the security guarantee restored here. The fixed-output correction is about the persistence of a checked result after its builder exits.

For a system built around reusable store paths, that persistence is essential. The March fix moves verification onto an output that a retained builder descriptor cannot alter. Subsequent consumers can then use the accepted object without inheriting the writer that escaped from the original build.