diff --git a/libcontainer/rootfs_linux.go b/libcontainer/rootfs_linux.go index ef764df5f..6a7bd86ba 100644 --- a/libcontainer/rootfs_linux.go +++ b/libcontainer/rootfs_linux.go @@ -419,7 +419,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 @@ -1333,7 +1333,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) @@ -1346,6 +1346,17 @@ 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() + } + }() + maskedPaths := make(map[string]struct{}) 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) @@ -1360,11 +1371,43 @@ func maskPaths(paths []string, mountLabel string) error { dstFh.Close() return fmt.Errorf("can't mask path %q: %w", path, err) } + // skip duplicate masked paths. + cleanPath := pathrs.LexicallyCleanPath(path) + if _, ok := maskedPaths[cleanPath]; ok { + dstFh.Close() + continue + } + maskedPaths[cleanPath] = struct{}{} + var dstType string 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)) + 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 570472bd4..116e659a8 100644 --- a/libcontainer/standard_init_linux.go +++ b/libcontainer/standard_init_linux.go @@ -142,8 +142,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 { diff --git a/tests/integration/mask.bats b/tests/integration/mask.bats index 5783332ea..782edbb30 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 @@ -55,6 +55,20 @@ function teardown() { [[ "${output}" == *"Operation not permitted"* ]] } +@test "mask paths [duplicate paths]" { + update_config '(.. | select(.maskedPaths? != null)) .maskedPaths += ["/testdir", "/testfile"]' + runc run -d --console-socket "$CONSOLE_SOCKET" test_busybox + [ "$status" -eq 0 ] + + runc exec test_busybox sh -c "mount | grep /testdir -c" + [ "$status" -eq 0 ] + [[ "${output}" == "1" ]] + + runc exec test_busybox sh -c "mount | grep /testfile -c" + [ "$status" -eq 0 ] + [[ "${output}" == "1" ]] +} + @test "mask paths [prohibit symlink /proc]" { ln -s /symlink rootfs/proc runc run -d --console-socket "$CONSOLE_SOCKET" test_busybox @@ -73,3 +87,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" ] +}