Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 45 additions & 3 deletions libcontainer/rootfs_linux.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 maskDir(procfd, c.label)
})
}
return err
Expand Down Expand Up @@ -1335,12 +1335,20 @@ 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 ).
// 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(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)
Expand All @@ -1353,6 +1361,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)
Expand All @@ -1371,7 +1389,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("", 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 = 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
// 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(rootFs, 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"
Expand Down
3 changes: 2 additions & 1 deletion libcontainer/standard_init_linux.go
Original file line number Diff line number Diff line change
Expand Up @@ -141,9 +141,10 @@ func (l *linuxStandardInit) Init() error {
}
}

if err := maskPaths(l.config.Config.MaskPaths, l.config.Config.MountLabel); err != nil {
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)
Expand Down
32 changes: 31 additions & 1 deletion tests/integration/mask.bats
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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" ]
}
Loading