January 31, 2024·6 min read·engineeringkubernetessecurityruntimes

How runc lost track of the container working directory

CVE-2024-21626 exposes a gap between entering a container mount namespace and proving that its working directory belongs there. The runc fix checks that boundary and closes internal descriptor leaks.

A container process can enter its intended mount namespace and still start with a working directory outside that namespace's root. That is the failure behind CVE-2024-21626, disclosed on January 31. The relevant mistake was an assumption in runtime setup. After changing the process root and entering the container environment, runc treated a successful directory change as sufficient evidence that execution would begin inside the container filesystem. A directory handle opened earlier on the host made that assumption unsafe.

The runc security advisory identifies affected versions through 1.1.11, starting at 1.0.0-rc93, and names 1.1.12 as the patched release. It describes both malicious-image attacks and variants involving execution inside an existing container. Some variants can overwrite host binaries. This matters to Kubernetes installations whose runtime uses affected runc builds, because the vulnerable boundary sits below the orchestration API.

I value Kubernetes for its reconciliation model and its ability to recover from interrupted control-plane work. That confidence does not establish the security of a container's execution environment. The runtime must independently prove that the process it starts has the filesystem access intended by the workload configuration. This disclosure makes that dependency concrete without requiring a failure in a Kubernetes controller.

A directory handle retains access

The working-directory validation patch explains the attack in terms of a host directory descriptor reaching runc init. A configuration can refer to that descriptor through /proc/self/fd/ and select it as the working directory. The process then has a reference to a host location despite the container setup around it. The patch also describes a variant in which a container process replaces an expected execution directory with a symlink before an administrator runs a command there.

The descriptor's lifetime is the significant detail. It need not remain open in the final application for the directory selection to be unsafe. The 1.1.12 changelog explicitly says that the vulnerable descriptor leaked internally within runc, without leaking into the eventual container process. Closing a descriptor during the final transition therefore does not prove that every earlier operation using it was safe. Directory selection has already happened by that point.

That distinction changes how to reason about cleanup. A review confined to the descriptors visible after application startup can miss an earlier use of a privileged handle. The directory selected through that handle becomes part of the process state. Removing the original handle does not itself undo the selection. In this case, validating the resulting working directory is necessary even when descriptor cleanup also exists.

The patch's explanation also limits the claim. It identifies a leaked /sys/fs/cgroup handle in the runc 1.1 implementation. It does not say that any cgroup configuration automatically produces an escape, or that every operation on a container uses the affected path. The problem requires the runtime's setup behavior and an attacker-controlled directory choice to interact. Treating it as a generic property of cgroups would obscure the actual fault.

Checking the resulting state

The validation patch introduces verifyCwd() and calls it after the configured directory change. Its comments explain that Linux can detect a current working directory outside the process's namespace root. The kernel's directory-reporting behavior allows the userspace implementation to return ENOENT for that condition. The runtime can then reject execution instead of accepting a working directory that it cannot resolve safely inside the container.

The implementation uses unix.Getwd() for this check. That choice is deliberate. The patch notes that os.Getwd() has a workaround involving the PWD environment variable and a stat of the current directory. That operation can fail when the container process cannot access the directory. Security checks need to examine the relevant kernel state without introducing an unrelated dependency on whether the process can inspect the directory through that alternative path.

The check also rejects a returned working directory that is not absolute. An unexpected error produces an error from setup rather than permission to continue. Together, these branches make the invariant explicit. The runtime must obtain a valid absolute working directory within the intended root before it executes the command. Merely completing chdir no longer satisfies that requirement.

There is a useful consequence for maintainers reading the associated tests. The same patch changes seccomp integration tests that intentionally denied getcwd. They instead exercise denial of syslog through dmesg. Once directory verification becomes part of startup, denying its required operation prevents startup itself. The test needs a different syscall to keep testing the intended behavior. That is a compatibility consequence visible directly in the security patch, rather than a reason to relax the new check.

I prefer this explicit validation because it expresses the isolation requirement at the point where it becomes meaningful. Reviewing the sequence of namespace operations remains necessary, but the resulting state gets its own check. A future setup change then has to preserve a property that the code actually tests during execution.

Repairing descriptor ownership

The cgroup descriptor patch repairs the specific host handle involved. The open flags gain O_CLOEXEC, and the stored handle changes from an integer descriptor to an *os.File. These are separate changes with different purposes. The close-on-exec flag controls inheritance across execution, while the file wrapper gives the runtime a managed object for the handle's lifetime.

The commit explains that the file object can also be cleaned up if setup fails and the object goes out of scope. It updates subsequent operations to obtain the descriptor through that object. Error reporting follows the same change, so the diagnostic code examines the descriptor actually owned by the stored file. This is a small implementation detail, but ownership needs to remain consistent across the successful and failed setup paths.

The author considered stronger protection using OPEN_TREE_CLONE and recorded the performance concern about creating an anonymous mount namespace. That comment gives a concrete example of the tradeoff inside a runtime fix. The selected repair changes inheritance and ownership without adding that proposed isolation mechanism. The separate working-directory check still tests the condition that matters before starting the workload.

The release changelog describes additional hardening beyond the original leak. Its stated purpose is to prevent future internal leaks from enabling the same kind of breakout. The repair therefore includes both the known defect and a broader rule about what execution may inherit. That broader rule matters because a runtime cannot depend on every future directory open being correct by coincidence.

Cleanup changes execution order

Another patch in the release moves executable lookup earlier in the setns initialization path. Its explanation connects that move to closing descriptors immediately before execution. exec.LookPath can read an environment variable, which can trigger test logging in a Go test process. If the logging descriptor has already closed, that lookup occurs too late for the test environment.

The code resolves the executable name before the final execution step and passes the resolved name to system.Exec. It also checks executable access at that earlier point. The patch documents a compatibility reason for that explicit check with older Go versions and filesystems mounted with noexec. This is why the full repair cannot be understood as adding one missing close call. Tightening descriptor lifetime changes which operations remain valid immediately before execution.

For Kubernetes operators, the practical repair target is the runtime delivered on the node. The advisory's patched version gives a concrete upstream reference, while the release changelog explains the behavior that a downstream security update needs to contain. A workload manifest alone cannot add the runtime's missing directory verification. The package or node image providing the affected implementation must carry the repair. This also makes the runtime patch set useful evidence when a distributor backports a fix without adopting the upstream release number. The relevant question is whether the repaired implementation performs these checks.

My concern here is the boundary between declared workload intent and the process the node actually starts. Kubernetes can reconcile the declared state successfully while a lower-level runtime implements that state unsafely. The runc changes address that gap by checking the final directory and tightening descriptor handling during setup. The executable-lookup adjustment shows the remaining engineering obligation, every operation in the startup sequence must still work after those checks and cleanup rules become stricter.