runc container escape via "masked path" abuse due to mount race conditions
Description
runc is a CLI tool for spawning and running containers according to the OCI specification. In versions 1.2.7 and below, 1.3.0-rc.1 through 1.3.1, 1.4.0-rc.1 and 1.4.0-rc.2 files, runc would not perform sufficient verification that the source of the bind-mount (i.e., the container's /dev/null) was actually a real /dev/null inode when using the container's /dev/null to mask. This exposes two methods of attack: an arbitrary mount gadget, leading to host information disclosure, host denial of service, container escape, or a bypassing of maskedPaths. This issue is fixed in versions 1.2.8, 1.3.3 and 1.4.0-rc.3.
AI Insight
LLM-synthesized narrative grounded in this CVE's description and references.
runc fails to verify /dev/null before using it to mask container paths, enabling container escape via mount races.
Vulnerability
Description
A vulnerability in runc (CVE-2025-31133) arises from insufficient verification of the /dev/null inode when the container runtime uses it to mask sensitive paths as specified by the OCI maskedPaths feature. Specifically, while masking file paths, runc bind-mounts the container's /dev/null onto the target without ensuring that the source is a genuine /dev/null device inode [2]. This flaw enables an attacker to replace the /dev/null file with a malicious symlink through race conditions, either by sharing mount namespaces with other containers or via parallel container builds (e.g., during docker buildx builds).
Attack
Vector and Exploitation
Exploitation requires the ability to manipulate the /dev/null inode within a container before runc uses it for masking. In practice, an attacker can leverage mount races with other containers that share the same volume or mount namespace, or trigger parallel execution where one container modifies /dev/null while another is being created [2]. Once the attacker-controlled symlink points to an arbitrary host path, runc will bind-mount that source into the container, effectively granting the attacker control over which host file or directory is mounted inside the container.
Impact
Two primary attack methods stem from this bug. First, an "arbitrary mount gadget" allows an attacker to mount any host path inside the container, which can lead to host information disclosure (reading sensitive files), host denial of service (e.g., by mounting /proc/sysrq-trigger as writable), or full container escape (by mounting host filesystems like /dev/sda1) [2]. Second, the attacker can bypass maskedPaths entirely, exposing host files that are supposed to be hidden from containers [2]. Both scenarios grant the attacker privileges far beyond what the container security model intends.
Mitigation
Patches have been released in runc versions 1.2.8, 1.3.3, and 1.4.0-rc.3 [1][2]. The fix introduces robust verification of the /dev/null inode before use, preventing symlink or inode substitution [4]. Users should upgrade immediately; if upgrading is not possible, precautions such as restricting shared mounts and limiting parallel container builds can reduce the attack surface [2].
AI Insight generated on May 19, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
github.com/opencontainers/runcGo | < 1.2.8 | 1.2.8 |
github.com/opencontainers/runcGo | >= 1.3.0-rc.1, < 1.3.3 | 1.3.3 |
github.com/opencontainers/runcGo | >= 1.4.0-rc.1, < 1.4.0-rc.3 | 1.4.0-rc.3 |
Affected products
1- opencontainers/runcv5Range: < 1.2.8
Patches
4db19bbed5348internal/sys: add VerifyInode helper
2 files changed · +35 −0
internal/sys/doc.go+5 −0 added@@ -0,0 +1,5 @@ +// Package sys is an internal package that contains helper methods for dealing +// with Linux that are more complicated than basic wrappers. Basic wrappers +// usually belong in internal/linux. If you feel something belongs in +// libcontainer/utils or libcontainer/system, it probably belongs here instead. +package sys
internal/sys/verify_inode_unix.go+30 −0 added@@ -0,0 +1,30 @@ +package sys + +import ( + "fmt" + "os" + "runtime" + + "golang.org/x/sys/unix" +) + +// VerifyInodeFunc is the callback passed to [VerifyInode] to check if the +// inode is the expected type (and on the correct filesystem type, in the case +// of filesystem-specific inodes). +type VerifyInodeFunc func(stat *unix.Stat_t, statfs *unix.Statfs_t) error + +// VerifyInode verifies that the underlying inode for the given file matches an +// expected inode type (possibly on a particular kind of filesystem). This is +// mainly a wrapper around [VerifyInodeFunc]. +func VerifyInode(file *os.File, checkFunc VerifyInodeFunc) error { + var stat unix.Stat_t + if err := unix.Fstat(int(file.Fd()), &stat); err != nil { + return fmt.Errorf("fstat %q: %w", file.Name(), err) + } + var statfs unix.Statfs_t + if err := unix.Fstatfs(int(file.Fd()), &statfs); err != nil { + return fmt.Errorf("fstatfs %q: %w", file.Name(), err) + } + runtime.KeepAlive(file) + return checkFunc(&stat, &statfs) +}
5d7b24240724libct: maskPaths: don't rely on ENOTDIR for mount
1 file changed · +17 −11
libcontainer/rootfs_linux.go+17 −11 modified@@ -1292,19 +1292,25 @@ func maskPaths(paths []string, mountLabel string) error { } return fmt.Errorf("can't mask path %q: %w", path, err) } - - dstFd := filepath.Join(procSelfFd, strconv.Itoa(int(dstFh.Fd()))) - err = mountViaFds("", devNullSrc, path, dstFd, "", unix.MS_BIND, "") - dstFh.Close() + st, err := dstFh.Stat() if err != nil { - if !errors.Is(err, unix.ENOTDIR) { - return fmt.Errorf("can't mask path %q: %w", path, err) - } + dstFh.Close() + return fmt.Errorf("can't mask path %q: %w", path, err) + } + var dstType string + if st.IsDir() { // Destination is a directory: bind mount a ro tmpfs over it. - err := mount("tmpfs", path, "tmpfs", unix.MS_RDONLY, label.FormatMountLabel("", mountLabel)) - if err != nil { - return fmt.Errorf("can't mask dir %q: %w", path, err) - } + dstType = "dir" + err = mount("tmpfs", path, "tmpfs", unix.MS_RDONLY, label.FormatMountLabel("", mountLabel)) + } else { + // Destination is a file: mount it to /dev/null. + dstType = "path" + dstFd := filepath.Join(procSelfFd, strconv.Itoa(int(dstFh.Fd()))) + err = mountViaFds("", devNullSrc, path, dstFd, "", unix.MS_BIND, "") + } + dstFh.Close() + if err != nil { + return fmt.Errorf("can't mask %s %q: %w", dstType, path, err) } }
1a30a8f3d921libct: maskPaths: only ignore ENOENT on mount dest
1 file changed · +15 −1
libcontainer/rootfs_linux.go+15 −1 modified@@ -1280,9 +1280,23 @@ func maskPaths(paths []string, mountLabel string) error { return fmt.Errorf("can't mask paths: %w", err) } devNullSrc := &mountSource{Type: mountSourcePlain, file: devNull} + procSelfFd, closer := utils.ProcThreadSelf("fd/") + defer closer() for _, path := range paths { - if err := mountViaFds("", devNullSrc, path, "", "", unix.MS_BIND, ""); err != nil && !errors.Is(err, os.ErrNotExist) { + // Open the target path; skip if it doesn't exist. + dstFh, err := os.OpenFile(path, unix.O_PATH|unix.O_CLOEXEC, 0) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + continue + } + return fmt.Errorf("can't mask path %q: %w", path, err) + } + + dstFd := filepath.Join(procSelfFd, strconv.Itoa(int(dstFh.Fd()))) + err = mountViaFds("", devNullSrc, path, dstFd, "", unix.MS_BIND, "") + dstFh.Close() + if err != nil { if !errors.Is(err, unix.ENOTDIR) { return fmt.Errorf("can't mask path %q: %w", path, err) }
8476df83b534libct: add/use isDevNull, verifyDevNull
3 files changed · +49 −22
libcontainer/init_linux.go+4 −7 modified@@ -505,19 +505,16 @@ func setupUser(config *initConfig) error { // The ownership needs to match because it is created outside of the container and needs to be // localized. func fixStdioPermissions(uid int) error { - var null unix.Stat_t - if err := unix.Stat("/dev/null", &null); err != nil { - return &os.PathError{Op: "stat", Path: "/dev/null", Err: err} - } for _, file := range []*os.File{os.Stdin, os.Stdout, os.Stderr} { var s unix.Stat_t if err := unix.Fstat(int(file.Fd()), &s); err != nil { return &os.PathError{Op: "fstat", Path: file.Name(), Err: err} } - // Skip chown if uid is already the one we want or any of the STDIO descriptors - // were redirected to /dev/null. - if int(s.Uid) == uid || s.Rdev == null.Rdev { + // Skip chown if: + // - uid is already the one we want, or + // - fd is opened to /dev/null. + if int(s.Uid) == uid || isDevNull(&s) { continue }
libcontainer/rootfs_linux.go+42 −11 modified@@ -26,6 +26,7 @@ import ( "github.com/opencontainers/cgroups/fs2" "github.com/opencontainers/runc/internal/linux" "github.com/opencontainers/runc/internal/pathrs" + "github.com/opencontainers/runc/internal/sys" "github.com/opencontainers/runc/libcontainer/configs" "github.com/opencontainers/runc/libcontainer/utils" ) @@ -398,7 +399,7 @@ func mountCgroupV2(m *configs.Mount, c *mountConfig) error { // Mask `/sys/fs/cgroup` to ensure it is read-only, even when `/sys` is mounted // with `rbind,ro` (`runc spec --rootless` produces `rbind,ro` for `/sys`). err = utils.WithProcfd(c.root, m.Destination, func(procfd string) error { - return maskPath(procfd, c.label) + return maskPaths([]string{procfd}, c.label) }) } return err @@ -889,20 +890,20 @@ func setupDevSymlinks(rootfs string) error { // needs to be called after we chroot/pivot into the container's rootfs so that any // symlinks are resolved locally. func reOpenDevNull() error { - var stat, devNullStat unix.Stat_t file, err := os.OpenFile("/dev/null", os.O_RDWR, 0) if err != nil { return err } defer file.Close() - if err := unix.Fstat(int(file.Fd()), &devNullStat); err != nil { - return &os.PathError{Op: "fstat", Path: file.Name(), Err: err} + if err := verifyDevNull(file); err != nil { + return fmt.Errorf("can't reopen /dev/null: %w", err) } for fd := range 3 { + var stat unix.Stat_t if err := unix.Fstat(fd, &stat); err != nil { return &os.PathError{Op: "fstat", Path: "fd " + strconv.Itoa(fd), Err: err} } - if stat.Rdev == devNullStat.Rdev { + if isDevNull(&stat) { // Close and re-open the fd. if err := linux.Dup3(int(file.Fd()), fd, 0); err != nil { return err @@ -1251,18 +1252,48 @@ func remountReadonly(m *configs.Mount) error { return fmt.Errorf("unable to mount %s as readonly max retries reached", dest) } -// maskPath masks the top of the specified path inside a container to avoid +func isDevNull(st *unix.Stat_t) bool { + return st.Mode&unix.S_IFMT == unix.S_IFCHR && st.Rdev == unix.Mkdev(1, 3) +} + +func verifyDevNull(f *os.File) error { + return sys.VerifyInode(f, func(st *unix.Stat_t, _ *unix.Statfs_t) error { + if !isDevNull(st) { + return errors.New("container's /dev/null is invalid") + } + return nil + }) +} + +// maskPaths masks the top of the specified paths inside a container to avoid // security issues from processes reading information from non-namespace aware // mounts ( proc/kcore ). // For files, maskPath bind mounts /dev/null over the top of the specified path. // For directories, maskPath mounts read-only tmpfs over the top of the specified path. -func maskPath(path string, mountLabel string) error { - if err := mount("/dev/null", path, "", unix.MS_BIND, ""); err != nil && !errors.Is(err, os.ErrNotExist) { - if errors.Is(err, unix.ENOTDIR) { - return mount("tmpfs", path, "tmpfs", unix.MS_RDONLY, label.FormatMountLabel("", mountLabel)) +func maskPaths(paths []string, mountLabel string) error { + devNull, err := os.OpenFile("/dev/null", unix.O_PATH, 0) + if err != nil { + return fmt.Errorf("can't mask paths: %w", err) + } + defer devNull.Close() + if err := verifyDevNull(devNull); err != nil { + return fmt.Errorf("can't mask paths: %w", err) + } + devNullSrc := &mountSource{Type: mountSourcePlain, file: devNull} + + for _, path := range paths { + if err := mountViaFds("", devNullSrc, path, "", "", unix.MS_BIND, ""); err != nil && !errors.Is(err, os.ErrNotExist) { + if !errors.Is(err, unix.ENOTDIR) { + return fmt.Errorf("can't mask path %q: %w", path, err) + } + // Destination is a directory: bind mount a ro tmpfs over it. + err := mount("tmpfs", path, "tmpfs", unix.MS_RDONLY, label.FormatMountLabel("", mountLabel)) + if err != nil { + return fmt.Errorf("can't mask dir %q: %w", path, err) + } } - return err } + return nil }
libcontainer/standard_init_linux.go+3 −4 modified@@ -142,10 +142,9 @@ func (l *linuxStandardInit) Init() error { return fmt.Errorf("can't make %q read-only: %w", path, err) } } - for _, path := range l.config.Config.MaskPaths { - if err := maskPath(path, l.config.Config.MountLabel); err != nil { - return fmt.Errorf("can't mask path %s: %w", path, err) - } + + if err := maskPaths(l.config.Config.MaskPaths, l.config.Config.MountLabel); err != nil { + return err } pdeath, err := system.GetParentDeathSignal() if err != nil {
Vulnerability mechanics
Generated on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
7- github.com/advisories/GHSA-9493-h29p-rfm2ghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2025-31133ghsaADVISORY
- github.com/opencontainers/runc/commit/1a30a8f3d921acbbb6a4bb7e99da2c05f8d48522ghsax_refsource_MISCWEB
- github.com/opencontainers/runc/commit/5d7b2424072449872d1cd0c937f2ca25f418eb66ghsax_refsource_MISCWEB
- github.com/opencontainers/runc/commit/8476df83b534a2522b878c0507b3491def48db9fghsax_refsource_MISCWEB
- github.com/opencontainers/runc/commit/db19bbed5348847da433faa9d69e9f90192bfa64ghsax_refsource_MISCWEB
- github.com/opencontainers/runc/security/advisories/GHSA-9493-h29p-rfm2ghsax_refsource_CONFIRMWEB
News mentions
0No linked articles in our index yet.