VYPR
High severityNVD Advisory· Published Nov 6, 2025· Updated Nov 6, 2025

container escape due to /dev/console mount and related races

CVE-2025-52565

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.

PackageAffected versionsPatched versions
github.com/opencontainers/runcGo
>= 1.0.0-rc3, < 1.2.81.2.8
github.com/opencontainers/runcGo
>= 1.3.0-rc.1, < 1.3.31.3.3
github.com/opencontainers/runcGo
>= 1.4.0-rc.1, < 1.4.0-rc.31.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/runcv5
    Range: >= 1.0.0-rc3, < 1.2.8

Patches

8
398955bccb7f

console: add fallback for pre-TIOCGPTPEER kernels

https://github.com/opencontainers/runcAleksa SaraiAug 1, 2025via ghsa
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)
     	}
    
db19bbed5348

internal/sys: add VerifyInode helper

https://github.com/opencontainers/runcAleksa SaraiJul 22, 2025via ghsa
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

https://github.com/opencontainers/runcAleksa SaraiJun 19, 2025via ghsa
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 {
    
de87203e625c

console: verify /dev/pts/ptmx before use

https://github.com/opencontainers/runcAleksa SaraiMay 15, 2025via ghsa
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
     	}
    
01de9d65dc72

rootfs: avoid using os.Create for new device inodes

https://github.com/opencontainers/runcAleksa SaraiMay 15, 2025via ghsa
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
    +}
    
aee7d3fe355d

ci: add lint to forbid the usage of os.Create

https://github.com/opencontainers/runcAleksa SaraiMay 15, 2025via ghsa
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
     		}
    
9be1dbf4ac67

console: avoid trivial symlink attacks for /dev/console

https://github.com/opencontainers/runcAleksa SaraiMay 15, 2025via ghsa
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.
    
531ef794e4ec

console: use TIOCGPTPEER when allocating peer PTY

https://github.com/opencontainers/runcAleksa SaraiMay 15, 2025via ghsa
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

News mentions

0

No linked articles in our index yet.