container escape due to /dev/console mount and related races
Description
runc is a CLI tool for spawning and running containers according to the OCI specification. Versions 1.0.0-rc3 through 1.2.7, 1.3.0-rc.1 through 1.3.2, and 1.4.0-rc.1 through 1.4.0-rc.2, due to insufficient checks when bind-mounting /dev/pts/$n to /dev/console inside the container, an attacker can trick runc into bind-mounting paths which would normally be made read-only or be masked onto a path that the attacker can write to. This attack is very similar in concept and application to CVE-2025-31133, except that it attacks a similar vulnerability in a different target (namely, the bind-mount of /dev/pts/$n to /dev/console as configured for all containers that allocate a console). This happens after pivot_root(2), so this cannot be used to write to host files directly -- however, as with CVE-2025-31133, this can load to denial of service of the host or a container breakout by providing the attacker with a writable copy of /proc/sysrq-trigger or /proc/sys/kernel/core_pattern (respectively). 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.
A container escape vulnerability in runc due to insufficient checks when bind-mounting /dev/pts/$n to /dev/console, allowing an attacker to gain writable access to sensitive /proc files and potentially escape the container or cause host denial of service.
Root
Cause
CVE-2025-52565 is a vulnerability in runc, the command-line tool for spawning and running OCI-compliant containers. In versions from 1.0.0-rc3 to 1.2.7, 1.3.0-rc.1 to 1.3.2, and 1.4.0-rc.1 to 1.4.0-rc.2, insufficient validation is performed when bind-mounting /dev/pts/$n to /dev/console inside a container. The attack is conceptually similar to CVE-2025-31133, but targets this different mount point [1][3]. The vulnerability occurs because the /dev/console bind-mount takes place before maskedPaths and readonlyPaths are applied, allowing an attacker to trick runc into bind-mounting paths that should otherwise be read-only or masked onto a writable path [3].
Exploitation
Conditions
This vulnerability can be exploited by an attacker who can craft a malicious container configuration that leads to a console being allocated. The attack happens after pivot_root(2), so it cannot be used to directly write to host files [1][3]. However, it can provide the attacker with a writable copy of sensitive /proc files, such as /proc/sysrq-trigger or /proc/sys/kernel/core_pattern, by using the flawed bind-mount logic [1].
Impact
Exploitation can lead to denial of service of the host (via /proc/sysrq-trigger) or a full container breakout (via /proc/sys/kernel/core_pattern), as these files become writable inside the container [1][3]. The advisory notes a CVSSv4 score of 7.3 (High) with vector CVSS:4.0/AV:L/AC:L/AT:P/PR:L/UI:A/VC:H/VI:H/VA:H/SC:H/SI:H/SA:H [3].
Mitigation
The issue is fixed in runc versions 1.2.8, 1.3.3, and 1.4.0-rc.3 [1][3]. Users running any of the affected version ranges should update to a patched release immediately. No workarounds are documented; updating runc is the recommended course of action.
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.0.0-rc3, < 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
2- Range: 1.0.0-rc3 through 1.2.7, 1.3.0-rc.1 through 1.3.2, and 1.4.0-rc.1 through 1.4.0-rc.2
- opencontainers/runcv5Range: >= 1.0.0-rc3, < 1.2.8
Patches
8398955bccb7fconsole: add fallback for pre-TIOCGPTPEER kernels
1 file changed · +54 −1
libcontainer/console_linux.go+54 −1 modified@@ -1,6 +1,7 @@ package libcontainer import ( + "errors" "fmt" "os" "runtime" @@ -9,8 +10,60 @@ import ( "golang.org/x/sys/unix" "github.com/opencontainers/runc/internal/linux" + "github.com/opencontainers/runc/internal/pathrs" + "github.com/opencontainers/runc/internal/sys" ) +func isPtyNoIoctlError(err error) bool { + // The kernel converts -ENOIOCTLCMD to -ENOTTY automatically, but handle + // -EINVAL just in case (which some drivers do, include pty). + return errors.Is(err, unix.EINVAL) || errors.Is(err, unix.ENOTTY) +} + +func getPtyPeer(pty console.Console, unsafePeerPath string, flags int) (*os.File, error) { + peer, err := linux.GetPtyPeer(pty.Fd(), unsafePeerPath, flags) + if err == nil || !isPtyNoIoctlError(err) { + return peer, err + } + + // On pre-TIOCGPTPEER kernels (Linux < 4.13), we need to fallback to using + // the /dev/pts/$n path generated using TIOCGPTN. We can do some validation + // that the inode is correct because the Unix-98 pty has a consistent + // numbering scheme for the device number of the peer. + + peerNum, err := unix.IoctlGetUint32(int(pty.Fd()), unix.TIOCGPTN) + if err != nil { + return nil, fmt.Errorf("get peer number of pty: %w", err) + } + //nolint:revive,staticcheck,nolintlint // ignore "don't use ALL_CAPS" warning // nolintlint is needed to work around the different lint configs + const ( + UNIX98_PTY_SLAVE_MAJOR = 136 // from <linux/major.h> + ) + wantPeerDev := unix.Mkdev(UNIX98_PTY_SLAVE_MAJOR, peerNum) + + // Use O_PATH to avoid opening a bad inode before we validate it. + peerHandle, err := os.OpenFile(unsafePeerPath, unix.O_PATH|unix.O_CLOEXEC, 0) + if err != nil { + return nil, err + } + defer peerHandle.Close() + + if err := sys.VerifyInode(peerHandle, func(stat *unix.Stat_t, statfs *unix.Statfs_t) error { + if statfs.Type != unix.DEVPTS_SUPER_MAGIC { + return fmt.Errorf("pty peer handle is not on a real devpts mount: super magic is %#x", statfs.Type) + } + if stat.Mode&unix.S_IFMT != unix.S_IFCHR || stat.Rdev != wantPeerDev { + return fmt.Errorf("pty peer handle is not the real char device for pty %d: ftype %#x %d:%d", + peerNum, stat.Mode&unix.S_IFMT, unix.Major(stat.Rdev), unix.Minor(stat.Rdev)) + } + return nil + }); err != nil { + return nil, err + } + + return pathrs.Reopen(peerHandle, flags) +} + // safeAllocPty returns a new (ptmx, peer pty) allocation for use inside a // container. func safeAllocPty() (pty console.Console, peer *os.File, Err error) { @@ -24,7 +77,7 @@ func safeAllocPty() (pty console.Console, peer *os.File, Err error) { } }() - peer, err = linux.GetPtyPeer(pty.Fd(), unsafePeerPath, unix.O_RDWR|unix.O_NOCTTY) + peer, err = getPtyPeer(pty, unsafePeerPath, unix.O_RDWR|unix.O_NOCTTY) if err != nil { return nil, nil, fmt.Errorf("failed to get peer end of newly-allocated console: %w", err) }
db19bbed5348internal/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) +}
ff94f9991bd3*: switch to safer securejoin.Reopen
3 files changed · +39 −8
internal/pathrs/procfs_securejoin.go+30 −0 added@@ -0,0 +1,30 @@ +// SPDX-License-Identifier: Apache-2.0 +/* + * Copyright (C) 2025 Aleksa Sarai <cyphar@cyphar.com> + * Copyright (C) 2025 SUSE LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package pathrs + +import ( + "os" + + securejoin "github.com/cyphar/filepath-securejoin" +) + +// Reopen is a wrapper around securejoin.Reopen. +func Reopen(file *os.File, flags int) (*os.File, error) { + return securejoin.Reopen(file, flags) +}
libcontainer/exeseal/cloned_binary_linux.go+2 −1 modified@@ -10,6 +10,7 @@ import ( "github.com/sirupsen/logrus" "golang.org/x/sys/unix" + "github.com/opencontainers/runc/internal/pathrs" "github.com/opencontainers/runc/libcontainer/system" ) @@ -71,7 +72,7 @@ func sealFile(f **os.File) error { // When sealing an O_TMPFILE-style descriptor we need to // re-open the path as O_PATH to clear the existing write // handle we have. - opath, err := os.OpenFile(fmt.Sprintf("/proc/self/fd/%d", (*f).Fd()), unix.O_PATH|unix.O_CLOEXEC, 0) + opath, err := pathrs.Reopen(*f, unix.O_PATH|unix.O_CLOEXEC) if err != nil { return fmt.Errorf("reopen tmpfile: %w", err) }
libcontainer/standard_init_linux.go+7 −7 modified@@ -12,6 +12,7 @@ import ( "golang.org/x/sys/unix" "github.com/opencontainers/runc/internal/linux" + "github.com/opencontainers/runc/internal/pathrs" "github.com/opencontainers/runc/libcontainer/apparmor" "github.com/opencontainers/runc/libcontainer/configs" "github.com/opencontainers/runc/libcontainer/keys" @@ -259,19 +260,17 @@ func (l *linuxStandardInit) Init() error { return fmt.Errorf("close log pipe: %w", err) } - fifoPath, closer := utils.ProcThreadSelfFd(l.fifoFile.Fd()) - defer closer() - // Wait for the FIFO to be opened on the other side before exec-ing the // user process. We open it through /proc/self/fd/$fd, because the fd that // was given to us was an O_PATH fd to the fifo itself. Linux allows us to // re-open an O_PATH fd through /proc. - fd, err := linux.Open(fifoPath, unix.O_WRONLY|unix.O_CLOEXEC, 0) + fifoFile, err := pathrs.Reopen(l.fifoFile, unix.O_WRONLY|unix.O_CLOEXEC) if err != nil { - return err + return fmt.Errorf("reopen exec fifo: %w", err) } - if _, err := unix.Write(fd, []byte("0")); err != nil { - return &os.PathError{Op: "write exec fifo", Path: fifoPath, Err: err} + defer fifoFile.Close() + if _, err := fifoFile.Write([]byte("0")); err != nil { + return &os.PathError{Op: "write exec fifo", Path: fifoFile.Name(), Err: err} } // Close the O_PATH fifofd fd before exec because the kernel resets @@ -280,6 +279,7 @@ func (l *linuxStandardInit) Init() error { // N.B. the core issue itself (passing dirfds to the host filesystem) has // since been resolved. // https://github.com/torvalds/linux/blob/v4.9/fs/exec.c#L1290-L1318 + _ = fifoFile.Close() _ = l.fifoFile.Close() if s := l.config.SpecState; s != nil {
de87203e625cconsole: verify /dev/pts/ptmx before use
1 file changed · +49 −1
libcontainer/console_linux.go+49 −1 modified@@ -15,6 +15,32 @@ import ( "github.com/opencontainers/runc/libcontainer/utils" ) +// checkPtmxHandle checks that the given file handle points to a real +// /dev/pts/ptmx device inode on a real devpts mount. We cannot (trivially) +// check that it is *the* /dev/pts for the container itself, but this is good +// enough. +func checkPtmxHandle(ptmx *os.File) error { + //nolint:revive,staticcheck,nolintlint // ignore "don't use ALL_CAPS" warning // nolintlint is needed to work around the different lint configs + const ( + PTMX_MAJOR = 5 // from TTYAUX_MAJOR in <linux/major.h> + PTMX_MINOR = 2 // from mknod_ptmx in fs/devpts/inode.c + PTMX_INO = 2 // from mknod_ptmx in fs/devpts/inode.c + ) + return sys.VerifyInode(ptmx, func(stat *unix.Stat_t, statfs *unix.Statfs_t) error { + if statfs.Type != unix.DEVPTS_SUPER_MAGIC { + return fmt.Errorf("ptmx handle is not on a real devpts mount: super magic is %#x", statfs.Type) + } + if stat.Ino != PTMX_INO { + return fmt.Errorf("ptmx handle has wrong inode number: %v", stat.Ino) + } + if stat.Mode&unix.S_IFMT != unix.S_IFCHR || stat.Rdev != unix.Mkdev(PTMX_MAJOR, PTMX_MINOR) { + return fmt.Errorf("ptmx handle is not a real char ptmx device: ftype %#x %d:%d", + stat.Mode&unix.S_IFMT, unix.Major(stat.Rdev), unix.Minor(stat.Rdev)) + } + return nil + }) +} + func isPtyNoIoctlError(err error) bool { // The kernel converts -ENOIOCTLCMD to -ENOTTY automatically, but handle // -EINVAL just in case (which some drivers do, include pty). @@ -68,7 +94,29 @@ func getPtyPeer(pty console.Console, unsafePeerPath string, flags int) (*os.File // safeAllocPty returns a new (ptmx, peer pty) allocation for use inside a // container. func safeAllocPty() (pty console.Console, peer *os.File, Err error) { - pty, unsafePeerPath, err := console.NewPty() + // TODO: Use openat2(RESOLVE_NO_SYMLINKS|RESOLVE_NO_XDEV). + ptmxHandle, err := os.OpenFile("/dev/pts/ptmx", unix.O_PATH|unix.O_NOFOLLOW|unix.O_CLOEXEC, 0) + if err != nil { + return nil, nil, err + } + defer ptmxHandle.Close() + + if err := checkPtmxHandle(ptmxHandle); err != nil { + return nil, nil, fmt.Errorf("verify ptmx handle: %w", err) + } + + ptyFile, err := pathrs.Reopen(ptmxHandle, unix.O_RDWR|unix.O_NOCTTY) + if err != nil { + return nil, nil, fmt.Errorf("reopen ptmx to get new pty pair: %w", err) + } + // On success, the ownership is transferred to pty. + defer func() { + if Err != nil { + _ = ptyFile.Close() + } + }() + + pty, unsafePeerPath, err := console.NewPtyFromFile(ptyFile) if err != nil { return nil, nil, err }
01de9d65dc72rootfs: avoid using os.Create for new device inodes
3 files changed · +130 −23
internal/sys/opath_linux.go+53 −0 added@@ -0,0 +1,53 @@ +package sys + +import ( + "fmt" + "os" + "runtime" + "strconv" + + "golang.org/x/sys/unix" + + "github.com/opencontainers/runc/internal/pathrs" +) + +// FchmodFile is a wrapper around fchmodat2(AT_EMPTY_PATH) with fallbacks for +// older kernels. This is distinct from [File.Chmod] and [unix.Fchmod] in that +// it works on O_PATH file descriptors. +func FchmodFile(f *os.File, mode uint32) error { + err := unix.Fchmodat(int(f.Fd()), "", mode, unix.AT_EMPTY_PATH) + // If fchmodat2(2) is not available at all, golang.org/x/unix (probably + // in order to mirror glibc) returns EOPNOTSUPP rather than EINVAL + // (what the kernel actually returns for invalid flags, which is being + // emulated) or ENOSYS (which is what glibc actually sees). + if err != unix.EINVAL && err != unix.EOPNOTSUPP { //nolint:errorlint // unix errors are bare + // err == nil is implicitly handled + return os.NewSyscallError("fchmodat2 AT_EMPTY_PATH", err) + } + + // AT_EMPTY_PATH support was added to fchmodat2 in Linux 6.6 + // (5daeb41a6fc9d0d81cb2291884b7410e062d8fa1). The alternative for + // older kernels is to go through /proc. + fdDir, closer, err2 := pathrs.ProcThreadSelfOpen("fd/", unix.O_DIRECTORY) + if err2 != nil { + return fmt.Errorf("fchmodat2 AT_EMPTY_PATH fallback: %w", err2) + } + defer closer() + defer fdDir.Close() + + err = unix.Fchmodat(int(fdDir.Fd()), strconv.Itoa(int(f.Fd())), mode, 0) + if err != nil { + err = fmt.Errorf("fchmodat /proc/self/fd/%d: %w", f.Fd(), err) + } + runtime.KeepAlive(f) + return err +} + +// FchownFile is a wrapper around fchownat(AT_EMPTY_PATH). This is distinct +// from [File.Chown] and [unix.Fchown] in that it works on O_PATH file +// descriptors. +func FchownFile(f *os.File, uid, gid int) error { + err := unix.Fchownat(int(f.Fd()), "", uid, gid, unix.AT_EMPTY_PATH) + runtime.KeepAlive(f) + return os.NewSyscallError("fchownat AT_EMPTY_PATH", err) +}
libcontainer/rootfs_linux.go+57 −23 modified@@ -7,6 +7,7 @@ import ( "os" "path" "path/filepath" + "runtime" "strconv" "strings" "syscall" @@ -932,17 +933,18 @@ func createDevices(config *configs.Config) error { return nil } -func bindMountDeviceNode(rootfs, dest string, node *devices.Device) error { - f, err := os.Create(dest) - if err != nil && !os.IsExist(err) { - return err - } - if f != nil { - _ = f.Close() +func bindMountDeviceNode(destDir *os.File, destName string, node *devices.Device) error { + dstFile, err := utils.Openat(destDir, destName, unix.O_CREAT|unix.O_NOFOLLOW|unix.O_CLOEXEC, 0o000) + if err != nil { + return fmt.Errorf("create device inode %s: %w", node.Path, err) } - return utils.WithProcfd(rootfs, dest, func(dstFd string) error { - return mountViaFds(node.Path, nil, dest, dstFd, "bind", unix.MS_BIND, "") - }) + defer dstFile.Close() + + dstFd, closer := utils.ProcThreadSelfFd(dstFile.Fd()) + defer closer() + + dstPath := filepath.Join(destDir.Name(), destName) + return mountViaFds(node.Path, nil, dstPath, dstFd, "bind", unix.MS_BIND, "") } // Creates the device node in the rootfs of the container. @@ -951,31 +953,33 @@ func createDeviceNode(rootfs string, node *devices.Device, bind bool) error { // The node only exists for cgroup reasons, ignore it here. return nil } - dest, err := securejoin.SecureJoin(rootfs, node.Path) + destPath, err := securejoin.SecureJoin(rootfs, node.Path) if err != nil { return err } - if dest == rootfs { + if destPath == rootfs { return fmt.Errorf("%w: mknod over rootfs", errRootfsToFile) } - if err := pathrs.MkdirAllInRoot(rootfs, filepath.Dir(dest), 0o755); err != nil { - return err + destDirPath, destName := filepath.Split(destPath) + destDir, err := pathrs.MkdirAllInRootOpen(rootfs, destDirPath, 0o755) + if err != nil { + return fmt.Errorf("mkdir parent of device inode %q: %w", node.Path, err) } if bind { - return bindMountDeviceNode(rootfs, dest, node) + return bindMountDeviceNode(destDir, destName, node) } - if err := mknodDevice(dest, node); err != nil { + if err := mknodDevice(destDir, destName, node); err != nil { if errors.Is(err, os.ErrExist) { return nil } else if errors.Is(err, os.ErrPermission) { - return bindMountDeviceNode(rootfs, dest, node) + return bindMountDeviceNode(destDir, destName, node) } return err } return nil } -func mknodDevice(dest string, node *devices.Device) error { +func mknodDevice(destDir *os.File, destName string, node *devices.Device) error { fileMode := node.FileMode switch node.Type { case devices.BlockDevice: @@ -991,14 +995,44 @@ func mknodDevice(dest string, node *devices.Device) error { if err != nil { return err } - if err := unix.Mknod(dest, uint32(fileMode), int(dev)); err != nil { - return &os.PathError{Op: "mknod", Path: dest, Err: err} + if err := unix.Mknodat(int(destDir.Fd()), destName, uint32(fileMode), int(dev)); err != nil { + return &os.PathError{Op: "mknodat", Path: filepath.Join(destDir.Name(), destName), Err: err} } - // Ensure permission bits (can be different because of umask). - if err := os.Chmod(dest, fileMode); err != nil { + + // Get a handle and verify that it matches the expected inode type and + // major:minor before we operate on it. + devFile, err := utils.Openat(destDir, destName, unix.O_NOFOLLOW|unix.O_PATH, 0) + if err != nil { + return fmt.Errorf("open new %c device inode %s: %w", node.Type, node.Path, err) + } + defer devFile.Close() + + if err := sys.VerifyInode(devFile, func(stat *unix.Stat_t, _ *unix.Statfs_t) error { + if stat.Mode&unix.S_IFMT != uint32(fileMode)&unix.S_IFMT { + return fmt.Errorf("new %c device inode %s has incorrect ftype: %#x doesn't match expected %#v", + node.Type, node.Path, + stat.Mode&unix.S_IFMT, fileMode&unix.S_IFMT) + } + if stat.Rdev != dev { + return fmt.Errorf("new %c device inode %s has incorrect major:minor: %d:%d doesn't match expected %d:%d", + node.Type, node.Path, + unix.Major(stat.Rdev), unix.Minor(stat.Rdev), + unix.Major(dev), unix.Minor(dev)) + } + return nil + }); err != nil { return err } - return os.Chown(dest, int(node.Uid), int(node.Gid)) + + // Ensure permission bits (can be different because of umask). + if err := sys.FchmodFile(devFile, uint32(fileMode)); err != nil { + return fmt.Errorf("update new %c device inode %s file mode: %w", node.Type, node.Path, err) + } + if err := sys.FchownFile(devFile, int(node.Uid), int(node.Gid)); err != nil { + return fmt.Errorf("update new %c device inode %s owner: %w", node.Type, node.Path, err) + } + runtime.KeepAlive(devFile) + return nil } // rootfsParentMountPrivate ensures rootfs parent mount is private.
libcontainer/system/linux.go+20 −0 modified@@ -160,3 +160,23 @@ func SetLinuxPersonality(personality int) error { } return nil } + +// GetPtyPeer is a wrapper for ioctl(TIOCGPTPEER). +func GetPtyPeer(ptyFd uintptr, unsafePeerPath string, flags int) (*os.File, error) { + // Make sure O_NOCTTY is always set -- otherwise runc might accidentally + // gain it as a controlling terminal. O_CLOEXEC also needs to be set to + // make sure we don't leak the handle either. + flags |= unix.O_NOCTTY | unix.O_CLOEXEC + + // There is no nice wrapper for this kind of ioctl in unix. + peerFd, _, errno := unix.Syscall( + unix.SYS_IOCTL, + ptyFd, + uintptr(unix.TIOCGPTPEER), + uintptr(flags), + ) + if errno != 0 { + return nil, os.NewSyscallError("ioctl TIOCGPTPEER", errno) + } + return os.NewFile(peerFd, unsafePeerPath), nil +}
aee7d3fe355dci: add lint to forbid the usage of os.Create
2 files changed · +16 −1
.golangci.yml+15 −0 modified@@ -11,6 +11,7 @@ formatters: linters: enable: - errorlint + - forbidigo - nolintlint - unconvert - unparam @@ -25,6 +26,20 @@ linters: - -ST1003 # https://staticcheck.dev/docs/checks/#ST1003 Poorly chosen identifier. - -ST1005 # https://staticcheck.dev/docs/checks/#ST1005 Incorrectly formatted error string. - -QF1008 # https://staticcheck.dev/docs/checks/#QF1008 Omit embedded fields from selector expression. + forbidigo: + forbid: + # os.Create implies O_TRUNC without O_CREAT|O_EXCL, which can lead to + # an even more severe attacks than CVE-2024-45310, where host files + # could be wiped. Always use O_EXCL or otherwise ensure we are not + # going to be tricked into overwriting host files. + - pattern: ^os\.Create$ + pkg: ^os$ + analyze-types: true exclusions: + rules: + # forbidigo lints are only relevant for main code. + - path: '(.+)_test\.go' + linters: + - forbidigo presets: - std-error-handling
libcontainer/criu_linux.go+1 −1 modified@@ -1090,7 +1090,7 @@ func (c *Container) criuNotifications(resp *criurpc.CriuResp, process *Process, logrus.Debugf("notify: %s\n", script) switch script { case "post-dump": - f, err := os.Create(filepath.Join(c.stateDir, "checkpoint")) + f, err := os.Create(filepath.Join(c.stateDir, "checkpoint")) //nolint:forbidigo // this is a host-side operation in a runc-controlled directory if err != nil { return err }
9be1dbf4ac67console: avoid trivial symlink attacks for /dev/console
1 file changed · +10 −11
libcontainer/console_linux.go+10 −11 modified@@ -12,6 +12,7 @@ import ( "github.com/opencontainers/runc/internal/linux" "github.com/opencontainers/runc/internal/pathrs" "github.com/opencontainers/runc/internal/sys" + "github.com/opencontainers/runc/libcontainer/utils" ) func isPtyNoIoctlError(err error) bool { @@ -87,22 +88,20 @@ func safeAllocPty() (pty console.Console, peer *os.File, Err error) { // mountConsole bind-mounts the provided pty on top of /dev/console so programs // that operate on /dev/console operate on the correct container pty. func mountConsole(peerPty *os.File) error { - f, err := os.Create("/dev/console") - if err != nil && !os.IsExist(err) { - return err - } - if f != nil { - // Ensure permission bits (can be different because of umask). - if err := f.Chmod(0o666); err != nil { - return err - } - f.Close() + console, err := os.OpenFile("/dev/console", unix.O_NOFOLLOW|unix.O_CREAT|unix.O_CLOEXEC, 0o666) + if err != nil { + return fmt.Errorf("create /dev/console mount target: %w", err) } + defer console.Close() + + dstFd, closer := utils.ProcThreadSelfFd(console.Fd()) + defer closer() + mntSrc := &mountSource{ Type: mountSourcePlain, file: peerPty, } - return mountViaFds(peerPty.Name(), mntSrc, "/dev/console", "", "bind", unix.MS_BIND, "") + return mountViaFds(peerPty.Name(), mntSrc, "/dev/console", dstFd, "bind", unix.MS_BIND, "") } // dupStdio replaces stdio with the given peerPty.
531ef794e4ecconsole: use TIOCGPTPEER when allocating peer PTY
3 files changed · +61 −16
internal/linux/linux.go+20 −0 modified@@ -85,3 +85,23 @@ func SetMempolicy(mode uint, mask *unix.CPUSet) error { }) return os.NewSyscallError("set_mempolicy", err) } + +// GetPtyPeer is a wrapper for ioctl(TIOCGPTPEER). +func GetPtyPeer(ptyFd uintptr, unsafePeerPath string, flags int) (*os.File, error) { + // Make sure O_NOCTTY is always set -- otherwise runc might accidentally + // gain it as a controlling terminal. O_CLOEXEC also needs to be set to + // make sure we don't leak the handle either. + flags |= unix.O_NOCTTY | unix.O_CLOEXEC + + // There is no nice wrapper for this kind of ioctl in unix. + peerFd, _, errno := unix.Syscall( + unix.SYS_IOCTL, + ptyFd, + uintptr(unix.TIOCGPTPEER), + uintptr(flags), + ) + if errno != 0 { + return nil, os.NewSyscallError("ioctl TIOCGPTPEER", errno) + } + return os.NewFile(peerFd, unsafePeerPath), nil +}
libcontainer/console_linux.go+37 −13 modified@@ -1,15 +1,39 @@ package libcontainer import ( + "fmt" "os" + "runtime" - "github.com/opencontainers/runc/internal/linux" + "github.com/containerd/console" "golang.org/x/sys/unix" + + "github.com/opencontainers/runc/internal/linux" ) -// mount initializes the console inside the rootfs mounting with the specified mount label -// and applying the correct ownership of the console. -func mountConsole(slavePath string) error { +// safeAllocPty returns a new (ptmx, peer pty) allocation for use inside a +// container. +func safeAllocPty() (pty console.Console, peer *os.File, Err error) { + pty, unsafePeerPath, err := console.NewPty() + if err != nil { + return nil, nil, err + } + defer func() { + if Err != nil { + _ = pty.Close() + } + }() + + peer, err = linux.GetPtyPeer(pty.Fd(), unsafePeerPath, unix.O_RDWR|unix.O_NOCTTY) + if err != nil { + return nil, nil, fmt.Errorf("failed to get peer end of newly-allocated console: %w", err) + } + return pty, peer, nil +} + +// mountConsole bind-mounts the provided pty on top of /dev/console so programs +// that operate on /dev/console operate on the correct container pty. +func mountConsole(peerPty *os.File) error { f, err := os.Create("/dev/console") if err != nil && !os.IsExist(err) { return err @@ -21,20 +45,20 @@ func mountConsole(slavePath string) error { } f.Close() } - return mount(slavePath, "/dev/console", "bind", unix.MS_BIND, "") + mntSrc := &mountSource{ + Type: mountSourcePlain, + file: peerPty, + } + return mountViaFds(peerPty.Name(), mntSrc, "/dev/console", "", "bind", unix.MS_BIND, "") } -// dupStdio opens the slavePath for the console and dups the fds to the current -// processes stdio, fd 0,1,2. -func dupStdio(slavePath string) error { - fd, err := linux.Open(slavePath, unix.O_RDWR, 0) - if err != nil { - return err - } +// dupStdio replaces stdio with the given peerPty. +func dupStdio(peerPty *os.File) error { for _, i := range []int{0, 1, 2} { - if err := linux.Dup3(fd, i, 0); err != nil { + if err := linux.Dup3(int(peerPty.Fd()), i, 0); err != nil { return err } } + runtime.KeepAlive(peerPty) return nil }
libcontainer/init_linux.go+4 −3 modified@@ -377,12 +377,13 @@ func setupConsole(socket *os.File, config *initConfig, mount bool) error { // the UID owner of the console to be the user the process will run as (so // they can actually control their console). - pty, slavePath, err := console.NewPty() + pty, peerPty, err := safeAllocPty() if err != nil { return err } // After we return from here, we don't need the console anymore. defer pty.Close() + defer peerPty.Close() if config.ConsoleHeight != 0 && config.ConsoleWidth != 0 { err = pty.Resize(console.WinSize{ @@ -396,7 +397,7 @@ func setupConsole(socket *os.File, config *initConfig, mount bool) error { // Mount the console inside our rootfs. if mount { - if err := mountConsole(slavePath); err != nil { + if err := mountConsole(peerPty); err != nil { return err } } @@ -407,7 +408,7 @@ func setupConsole(socket *os.File, config *initConfig, mount bool) error { runtime.KeepAlive(pty) // Now, dup over all the things. - return dupStdio(slavePath) + return dupStdio(peerPty) } // syncParentReady sends to the given pipe a JSON payload which indicates that
Vulnerability mechanics
Generated on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
11- github.com/advisories/GHSA-qw9x-cqr3-wc7rghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2025-52565ghsaADVISORY
- github.com/opencontainers/runc/commit/01de9d65dc72f67b256ef03f9bfb795a2bf143b4ghsax_refsource_MISCWEB
- github.com/opencontainers/runc/commit/398955bccb7f20565c224a3064d331c19e422398ghsax_refsource_MISCWEB
- github.com/opencontainers/runc/commit/531ef794e4ecd628006a865ad334a048ee2b4b2eghsax_refsource_MISCWEB
- github.com/opencontainers/runc/commit/9be1dbf4ac67d9840a043ebd2df5c68f36705d1dghsax_refsource_MISCWEB
- github.com/opencontainers/runc/commit/aee7d3fe355dd02939d44155e308ea0052e0d53aghsax_refsource_MISCWEB
- github.com/opencontainers/runc/commit/db19bbed5348847da433faa9d69e9f90192bfa64ghsax_refsource_MISCWEB
- github.com/opencontainers/runc/commit/de87203e625cd7a27141fb5f2ad00a320c69c5e8ghsax_refsource_MISCWEB
- github.com/opencontainers/runc/commit/ff94f9991bd32076c871ef0ad8bc1b763458e480ghsax_refsource_MISCWEB
- github.com/opencontainers/runc/security/advisories/GHSA-qw9x-cqr3-wc7rghsax_refsource_CONFIRMWEB
News mentions
0No linked articles in our index yet.