From e0d23b4362164592a226578b15f1622cc88c3ec3 Mon Sep 17 00:00:00 2001 From: lifubang Date: Tue, 12 May 2026 08:54:29 +0000 Subject: [PATCH 1/5] libct: enforce strict tmpfs limits for masked paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously, masked directories (e.g., /proc/acpi, /proc/scsi) were mounted as read-only tmpfs without explicit size or inode limits. Although these mounts are meant to be empty and unwritable, the lack of resource constraints means that—should an attacker bypass the read-only protection (e.g., via container escape, mount namespace manipulation, or a kernel vulnerability)—the tmpfs could consume up to 50% of system memory by default (the kernel's default tmpfs limit). To mitigate this risk in high-density container environments and adhere to the principle of least privilege, we now explicitly set: - nr_blocks=1 (sufficient for at most one block size) - nr_inodes=1 (sufficient for at most one inode) Ref: https://man7.org/linux/man-pages/man5/tmpfs.5.html These limits ensure that even if compromised, kernel memory usage remains strictly bounded and negligible. This change aligns with best practices used by other container runtimes and strengthens defense-in-depth for sensitive masked paths. Co-authored-by: Davanum Srinivas Refactored-by: lifubang Signed-off-by: lifubang (cherry picked from commit e57a7a4c8f76da8bbc299bc8beb14cd1cba741e0) Signed-off-by: lifubang --- libcontainer/rootfs_linux.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libcontainer/rootfs_linux.go b/libcontainer/rootfs_linux.go index 31d0517484c..d6f696cf3a2 100644 --- a/libcontainer/rootfs_linux.go +++ b/libcontainer/rootfs_linux.go @@ -1371,7 +1371,7 @@ func maskPaths(paths []string, mountLabel string) error { if st.IsDir() { // Destination is a directory: bind mount a ro tmpfs over it. dstType = "dir" - err = mount("tmpfs", path, "tmpfs", unix.MS_RDONLY, label.FormatMountLabel("", mountLabel)) + err = mount("tmpfs", path, "tmpfs", unix.MS_RDONLY, label.FormatMountLabel("nr_blocks=1,nr_inodes=1", mountLabel)) } else { // Destination is a file: mount it to /dev/null. dstType = "path" From dd9efbcd69daf9fbbb1c41c0f57427af32e4b549 Mon Sep 17 00:00:00 2001 From: lifubang Date: Tue, 12 May 2026 04:08:05 +0000 Subject: [PATCH 2/5] libct: reuse tmpfs for directory masks Kubernetes may add one sysfs thermal_throttle entry per CPU to maskedPaths. On large Intel systems this can produce many directory masks for a single container. runc currently handles each directory mask with a separate read-only tmpfs mount, and therefore a separate tmpfs superblock. On Linux 4.18/RHEL 8 kernels, creating and tearing down many tmpfs superblocks can contend on the global shrinker_rwsem when containers start or stop concurrently. Use one read-only tmpfs for directory masks and bind-mount it over the remaining directory targets. The first non-procfs-fd directory mount is reopened through the container root fd before it is reused. File masks still bind /dev/null, and procfs fd targets keep the existing one-tmpfs-per-target behaviour because they are fd aliases rather than stable rootfs paths. If the bind-mount of the shared source fails (e.g. due to kernel restrictions), fall back to individual tmpfs mounts for all remaining directories. Tmpfs mounts use nr_blocks=1,nr_inodes=1 to minimise kernel resource usage. The bind mounts do not create additional tmpfs superblocks. They also retain the read-only mount flag inherited from the source vfsmount, so the masking semantics remain unchanged. xref: kubernetes/kubernetes#138512 xref: kubernetes/kubernetes#138388 xref: kubernetes/kubernetes#131018 Co-authored-by: Davanum Srinivas Refactored-by: lifubang Signed-off-by: lifubang (cherry picked from commit c046c9b973defb8c4617a8deb01696902c227779) Signed-off-by: lifubang --- libcontainer/rootfs_linux.go | 40 ++++++++++++++++++++++++++--- libcontainer/standard_init_linux.go | 12 +++++++-- 2 files changed, 47 insertions(+), 5 deletions(-) diff --git a/libcontainer/rootfs_linux.go b/libcontainer/rootfs_linux.go index d6f696cf3a2..99140061c96 100644 --- a/libcontainer/rootfs_linux.go +++ b/libcontainer/rootfs_linux.go @@ -400,7 +400,7 @@ func mountCgroupV2(m mountEntry, 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.WithProcfdFile(m.dstFile, func(procfd string) error { - return maskPaths([]string{procfd}, c.label) + return maskPaths(c.root, []string{procfd}, c.label) }) } return err @@ -1340,7 +1340,7 @@ func verifyDevNull(f *os.File) error { // 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 maskPaths(paths []string, mountLabel string) error { +func maskPaths(rootFd *os.File, 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) @@ -1353,6 +1353,16 @@ func maskPaths(paths []string, mountLabel string) error { procSelfFd, closer := utils.ProcThreadSelf("fd/") defer closer() + var ( + sharedMaskFile *os.File + sharedMaskSrc *mountSource + bindFailed bool + ) + defer func() { + if sharedMaskFile != nil { + _ = sharedMaskFile.Close() + } + }() for _, path := range paths { // Open the target path; skip if it doesn't exist. dstFh, err := os.OpenFile(path, unix.O_PATH|unix.O_CLOEXEC, 0) @@ -1371,7 +1381,31 @@ func maskPaths(paths []string, mountLabel string) error { if st.IsDir() { // Destination is a directory: bind mount a ro tmpfs over it. dstType = "dir" - err = mount("tmpfs", path, "tmpfs", unix.MS_RDONLY, label.FormatMountLabel("nr_blocks=1,nr_inodes=1", mountLabel)) + if !bindFailed && sharedMaskSrc != nil { + dstFd := filepath.Join(procSelfFd, strconv.Itoa(int(dstFh.Fd()))) + err = mountViaFds("", sharedMaskSrc, path, dstFd, "", unix.MS_BIND, "") + if err != nil { + // A bind-mount inherits MNT_READONLY from the source vfsmount, + // but if it fails fall back to individual tmpfs mounts. + bindFailed = true + logrus.WithError(err).Warn("maskPaths: shared tmpfs bind-mount failed, falling back to per-directory tmpfs") + } + } + if bindFailed || sharedMaskSrc == nil { + err = mount("tmpfs", path, "tmpfs", unix.MS_RDONLY, label.FormatMountLabel("nr_blocks=1,nr_inodes=1", mountLabel)) + if err == nil && !bindFailed && sharedMaskSrc == nil { + // Establish this mount as the reusable shared source. reopenAfterMount + // resolves the underlying inode via procfs and re-opens it through + // rootFd, so the resulting fd is anchored to the real path inside the + // container rootfs even if path was a /proc/self/fd/N alias. + reopened, err := reopenAfterMount(rootFd, dstFh, unix.O_PATH|unix.O_CLOEXEC) + if err != nil { + return fmt.Errorf("can't reopen shared directory mask: %w", err) + } + sharedMaskFile = reopened + sharedMaskSrc = &mountSource{Type: mountSourcePlain, file: sharedMaskFile} + } + } } else { // Destination is a file: mount it to /dev/null. dstType = "path" diff --git a/libcontainer/standard_init_linux.go b/libcontainer/standard_init_linux.go index 21516bd3388..f73bc740edb 100644 --- a/libcontainer/standard_init_linux.go +++ b/libcontainer/standard_init_linux.go @@ -141,8 +141,16 @@ func (l *linuxStandardInit) Init() error { } } - if err := maskPaths(l.config.Config.MaskPaths, l.config.Config.MountLabel); err != nil { - return err + if len(l.config.Config.MaskPaths) > 0 { + rootFd, err := os.OpenFile("/", unix.O_DIRECTORY|unix.O_CLOEXEC|unix.O_PATH, 0) + if err != nil { + return fmt.Errorf("open rootfs handle for masked paths: %w", err) + } + err = maskPaths(rootFd, l.config.Config.MaskPaths, l.config.Config.MountLabel) + rootFd.Close() + if err != nil { + return err + } } pdeath, err := system.GetParentDeathSignal() if err != nil { From 02887a1e82f024b6b662d4ffd64b0e3855979924 Mon Sep 17 00:00:00 2001 From: lifubang Date: Tue, 12 May 2026 04:17:27 +0000 Subject: [PATCH 3/5] integration: reuse tmpfs for directory masks Co-authored-by: Davanum Srinivas Signed-off-by: lifubang (cherry picked from commit 124772f3545b62f24ea1ea6a9e90c9d560f625c1) Signed-off-by: lifubang --- tests/integration/mask.bats | 32 +++++++++++++++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/tests/integration/mask.bats b/tests/integration/mask.bats index d324eb32524..8f1edc3a4a3 100644 --- a/tests/integration/mask.bats +++ b/tests/integration/mask.bats @@ -6,7 +6,7 @@ function setup() { setup_busybox # Create fake rootfs. - mkdir rootfs/testdir + mkdir rootfs/testdir rootfs/testdir2 rootfs/testdir3 echo "Forbidden information!" >rootfs/testfile # add extra masked paths @@ -75,3 +75,33 @@ function teardown() { # so we merely check that it fails, and do not check the exact error # message like for /proc above. } + +@test "mask paths [directories share tmpfs]" { + update_config '(.. | select(.maskedPaths? != null)) .maskedPaths += ["/testdir2", "/testdir3"]' + runc run -d --console-socket "$CONSOLE_SOCKET" test_busybox + [ "$status" -eq 0 ] + + # shellcheck disable=SC2016 + runc exec test_busybox sh -euc ' + set -- $(stat -c %d /testdir /testdir2 /testdir3) + [ "$1" = "$2" ] + [ "$2" = "$3" ] + ' + [ "$status" -eq 0 ] + + runc exec test_busybox touch /testdir2/foo + [ "$status" -eq 1 ] + [[ "${output}" == *"Read-only file system"* ]] +} + +@test "mask paths [directory with read-only rootfs]" { + update_config '(.. | select(.maskedPaths? != null)) .maskedPaths += ["/testdir2", "/testdir3"]' + update_config '.root.readonly = true' + + runc run -d --console-socket "$CONSOLE_SOCKET" test_busybox + [ "$status" -eq 0 ] + + runc exec test_busybox ls /testdir + [ "$status" -eq 0 ] + [ -z "$output" ] +} From 76424b4b0bb8af2da1fd2e68b665edd53534638e Mon Sep 17 00:00:00 2001 From: lifubang Date: Sat, 16 May 2026 04:12:44 +0000 Subject: [PATCH 4/5] libct: optimize maskPaths for single-directory case This is a follow-up to #5275. That change reused a single tmpfs mount to mask multiple directories, which is efficient when masking more than one path. However, it introduced unnecessary overhead when only one directory is masked. This commit restores the original behavior for the single-path case while preserving shared tmpfs logic for multiple paths. Signed-off-by: lifubang (cherry picked from commit e7e2f00248a2700827f1fbc54e8767a53ad11117) Signed-off-by: lifubang --- libcontainer/rootfs_linux.go | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/libcontainer/rootfs_linux.go b/libcontainer/rootfs_linux.go index 99140061c96..0b13866b255 100644 --- a/libcontainer/rootfs_linux.go +++ b/libcontainer/rootfs_linux.go @@ -400,7 +400,7 @@ func mountCgroupV2(m mountEntry, 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.WithProcfdFile(m.dstFile, func(procfd string) error { - return maskPaths(c.root, []string{procfd}, c.label) + return maskDir(procfd, c.label) }) } return err @@ -1335,6 +1335,11 @@ func verifyDevNull(f *os.File) error { }) } +// maskDir mounts a read-only tmpfs on top of the specified path. +func maskDir(path, mountLabel string) error { + return mount("tmpfs", path, "tmpfs", unix.MS_RDONLY, label.FormatMountLabel("nr_blocks=1,nr_inodes=1", mountLabel)) +} + // 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 ). @@ -1392,7 +1397,7 @@ func maskPaths(rootFd *os.File, paths []string, mountLabel string) error { } } if bindFailed || sharedMaskSrc == nil { - err = mount("tmpfs", path, "tmpfs", unix.MS_RDONLY, label.FormatMountLabel("nr_blocks=1,nr_inodes=1", mountLabel)) + err = maskDir(path, mountLabel) if err == nil && !bindFailed && sharedMaskSrc == nil { // Establish this mount as the reusable shared source. reopenAfterMount // resolves the underlying inode via procfs and re-opens it through From 9a2fec0a9a261b5f9ed987584bb2862a39bdd7fb Mon Sep 17 00:00:00 2001 From: lifubang Date: Sat, 16 May 2026 04:40:37 +0000 Subject: [PATCH 5/5] libct: close rootFd ASAP in maskPaths Close the root file descriptor immediately after use in maskPaths to reduce the window during which an attacker could potentially exploit an open fd to access or manipulate the root filesystem. This follows the principle of least privilege and mitigates risks in compromised or malicious container scenarios. Co-authored-by: Kir Kolyshkin Signed-off-by: lifubang (cherry picked from commit b88635e57ec51c83a4b3d2ef281e3cfb692e30ca) Signed-off-by: lifubang --- libcontainer/rootfs_linux.go | 7 +++++-- libcontainer/standard_init_linux.go | 13 +++---------- 2 files changed, 8 insertions(+), 12 deletions(-) diff --git a/libcontainer/rootfs_linux.go b/libcontainer/rootfs_linux.go index 0b13866b255..0bd49c4d1c5 100644 --- a/libcontainer/rootfs_linux.go +++ b/libcontainer/rootfs_linux.go @@ -1345,7 +1345,10 @@ func maskDir(path, mountLabel string) error { // 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 maskPaths(rootFd *os.File, paths []string, mountLabel string) error { +func maskPaths(rootFs string, paths []string, mountLabel string) error { + if len(paths) == 0 { + return nil + } devNull, err := os.OpenFile("/dev/null", unix.O_PATH, 0) if err != nil { return fmt.Errorf("can't mask paths: %w", err) @@ -1403,7 +1406,7 @@ func maskPaths(rootFd *os.File, paths []string, mountLabel string) error { // resolves the underlying inode via procfs and re-opens it through // rootFd, so the resulting fd is anchored to the real path inside the // container rootfs even if path was a /proc/self/fd/N alias. - reopened, err := reopenAfterMount(rootFd, dstFh, unix.O_PATH|unix.O_CLOEXEC) + reopened, err := reopenAfterMount(rootFs, dstFh, unix.O_PATH|unix.O_CLOEXEC) if err != nil { return fmt.Errorf("can't reopen shared directory mask: %w", err) } diff --git a/libcontainer/standard_init_linux.go b/libcontainer/standard_init_linux.go index f73bc740edb..0e96dcf23c3 100644 --- a/libcontainer/standard_init_linux.go +++ b/libcontainer/standard_init_linux.go @@ -141,17 +141,10 @@ func (l *linuxStandardInit) Init() error { } } - if len(l.config.Config.MaskPaths) > 0 { - rootFd, err := os.OpenFile("/", unix.O_DIRECTORY|unix.O_CLOEXEC|unix.O_PATH, 0) - if err != nil { - return fmt.Errorf("open rootfs handle for masked paths: %w", err) - } - err = maskPaths(rootFd, l.config.Config.MaskPaths, l.config.Config.MountLabel) - rootFd.Close() - if err != nil { - return err - } + if err := maskPaths("/", l.config.Config.MaskPaths, l.config.Config.MountLabel); err != nil { + return err } + pdeath, err := system.GetParentDeathSignal() if err != nil { return fmt.Errorf("can't get pdeath signal: %w", err)