Files
Kir Kolyshkin 1b016e9027 libct: simplify setReadonly, keep rootfs flags
setReadonly remounts / with MS_BIND|MS_REMOUNT|MS_RDONLY. A bind
remount sets the per-mount flags to exactly the ones given, so when
not in a user namespace it silently clears nosuid, nodev, noexec and
nosymfollow which may be set on the rootfs mount.

This does not happen in a user namespace, where these flags are locked,
so the remount fails, and the fallback keeps them.

The fix is to always use the existing flags, not only on fallback.
In other words, the fallback is no longer needed.

While at it
 - use statfsToMountFlags to convert, since ST_* and MS_*
   values may differ (e.g. ST_NOSYMFOLLOW != MS_NOSYMFOLLOW);
 - do not take other flags (e.g *ATIME*) as the kernel preserves
   those on remount.

Add an integration test.

Signed-off-by: Kir Kolyshkin <kolyshkin@gmail.com>
2026-10-27 18:52:21 -07:00

421 lines
12 KiB
Bash

#!/usr/bin/env bats
load helpers
function setup() {
setup_busybox
}
function teardown() {
umount -l "$ROOT/bundle/rootfs" 2>/dev/null || true
teardown_bundle
}
# https://github.com/opencontainers/runc/security/advisories/GHSA-m8cg-xc2p-r3fc
#
# This needs to be placed at the top of the bats file to work around
# a shellcheck bug. See <https://github.com/koalaman/shellcheck/issues/2873>.
function test_ro_cgroup_mount() {
local lines status
# shellcheck disable=SC2016
update_config '.process.args |= ["sh", "-euc", "for f in `grep /sys/fs/cgroup /proc/mounts | awk \"{print \\\\$2}\"| uniq`; do test -e $f && grep -w $f /proc/mounts | tail -n1; done"]'
run -0 runc run test_busybox
[ "${#lines[@]}" -ne 0 ]
for line in "${lines[@]}"; do [[ "${line}" == *'ro,'* ]]; done
}
# Parse an "optstring" of the form foo,bar into $is_foo and $is_bar variables.
# Usage: parse_optstring foo,bar foo bar baz
function parse_optstring() {
optstring="$1"
shift
for flag in "$@"; do
is_set=
if grep -wq "$flag" <<<"$optstring"; then
is_set=1
fi
eval "is_$flag=$is_set"
done
}
function config_add_bind_mount() {
src="$1"
dst="$2"
parse_optstring "${3:-}" rbind idmap
bindtype=bind
if [ -n "$is_rbind" ]; then
bindtype=rbind
fi
mappings=""
if [ -n "$is_idmap" ]; then
mappings='
"uidMappings": [{"containerID": 0, "hostID": 100000, "size": 65536}],
"gidMappings": [{"containerID": 0, "hostID": 100000, "size": 65536}],
'
fi
update_config '.mounts += [{
"source": "'"$src"'",
"destination": "'"$dst"'",
"type": "bind",
'"$mappings"'
"options": [ "'"$bindtype"'", "rprivate" ]
}]'
}
# This needs to be placed at the top of the bats file to work around
# a shellcheck bug. See <https://github.com/koalaman/shellcheck/issues/2873>.
function test_mount_order() {
parse_optstring "${1:-}" userns idmap
if [ -n "$is_userns" ]; then
requires root
update_config '.linux.namespaces += [{"type": "user"}]
| .linux.uidMappings += [{"containerID": 0, "hostID": 100000, "size": 65536}]
| .linux.gidMappings += [{"containerID": 0, "hostID": 100000, "size": 65536}]'
remap_rootfs
fi
ctr_src_opts="rbind"
if [ -n "$is_idmap" ]; then
requires root
requires_kernel 5.12
requires_idmap_fs .
ctr_src_opts+=",idmap"
fi
mkdir -p rootfs/{mnt,final}
# Create a set of directories we can create a mount tree with.
for subdir in a/x b/y c/z; do
dir="bind-src/$subdir"
mkdir -p "$dir"
echo "$subdir" >"$dir/file"
# Add a symlink to make sure
topdir="$(dirname "$subdir")"
ln -s "$topdir" "bind-src/sym-$topdir"
done
# In userns tests, make sure that the source directory cannot be accessed,
# to make sure we're exercising the bind-mount source fd logic.
chmod o-rwx bind-src
rootfs="$(pwd)/rootfs"
rm -rf rootfs/mnt
mkdir rootfs/mnt
# Create a bind-mount tree.
config_add_bind_mount "$PWD/bind-src/a" "/mnt"
config_add_bind_mount "$PWD/bind-src/sym-b" "/mnt/x"
config_add_bind_mount "$PWD/bind-src/c" "/mnt/x/y"
config_add_bind_mount "$PWD/bind-src/sym-a" "/mnt/x/y/z"
# Create a recursive bind-mount that uses part of the current tree in the
# container.
config_add_bind_mount "$rootfs/mnt/x" "/mnt/x/y/z/x" "$ctr_src_opts"
config_add_bind_mount "$rootfs/mnt/x/y" "/mnt/x/y/z" "$ctr_src_opts"
# Finally, bind-mount the whole thing on top of /final.
config_add_bind_mount "$rootfs/mnt" "/final" "$ctr_src_opts"
# Check that the entire tree was copied and the mounts were done in the
# expected order.
update_config '.process.args = ["cat", "/final/x/y/z/z/x/y/z/x/file"]'
run -0 runc run test_busybox
assert_output --partial "a/x" # the final "file" was from a/x.
}
# This needs to be placed at the top of the bats file to work around
# a shellcheck bug. See <https://github.com/koalaman/shellcheck/issues/2873>.
test_mount_target() {
src="$1"
dst="$2"
real_dst="${3:-$dst}"
echo "== $src -> $dst (=> $real_dst) =="
old_config="$(mktemp ./config.json.bak.XXXXXX)"
cp ./config.json "$old_config"
update_config '.mounts += [{
source: "'"$src"'",
destination: "'"$dst"'",
options: ["bind"]
}]'
# Make sure the target path is at the right spot and is actually a
# bind-mount of the correct inode.
update_config '.process.args = ["stat", "-c", "%n %d:%i", "--", "'"$real_dst"'"]'
run -0 runc run test_busybox
assert_output "$real_dst $(stat -c "%d:%i" -- "$src")"
# Make sure there is a mount entry for the target path.
# shellcheck disable=SC2016
update_config '.process.args = ["awk", "-F", "PATH='"$real_dst"'", "$2 == PATH", "/proc/self/mounts"]'
run -0 runc run test_busybox
assert_output --partial "$real_dst"
# Switch back the old config so this function can be called multiple times.
mv "$old_config" "./config.json"
}
# https://github.com/opencontainers/runc/issues/3991
@test "runc run [tmpcopyup]" {
mkdir -p rootfs/dir1/dir2
chmod 777 rootfs/dir1/dir2
update_config ' .mounts += [{
source: "tmpfs",
destination: "/dir1",
type: "tmpfs",
options: ["tmpcopyup"]
}]
| .process.args |= ["ls", "-ld", "/dir1/dir2"]'
umask 022
run -0 runc run test_busybox
assert_line --index 0 --partial 'drwxrwxrwx'
}
@test "runc run [bind mount]" {
update_config ' .mounts += [{
source: ".",
destination: "/tmp/bind",
options: ["bind"]
}]
| .process.args |= ["ls", "/tmp/bind/config.json"]'
run -0 runc run test_busybox
assert_line --index 0 --partial '/tmp/bind/config.json'
}
@test "runc run [bind mount with rootfs-prefixed destination]" {
# Mount destination is a path inside the container,
# even if it starts with the host path to rootfs.
dest="$(pwd -P)/rootfs/tmp/bind"
update_config ' .mounts += [{
source: ".",
destination: "'"$dest"'",
options: ["bind"]
}]
| .process.args |= ["ls", "'"$dest"'/config.json"]'
run -0 runc run test_busybox
assert_line --index 0 --partial "$dest/config.json"
# The prefix is not stripped.
[ ! -e rootfs/tmp/bind ]
}
# https://github.com/opencontainers/runc/issues/2246
@test "runc run [ro tmpfs mount]" {
update_config ' .mounts += [{
source: "tmpfs",
destination: "/mnt",
type: "tmpfs",
options: ["ro", "nodev", "nosuid", "mode=755"]
}]
| .process.args |= ["grep", "^tmpfs /mnt", "/proc/mounts"]'
run -0 runc run test_busybox
assert_line --index 0 --partial 'ro,'
}
# https://github.com/opencontainers/runc/issues/3248
@test "runc run [ro /dev mount]" {
update_config ' .mounts |= map((select(.destination == "/dev") | .options += ["ro"]) // .)
| .process.args |= ["grep", "^tmpfs /dev", "/proc/mounts"]'
run -0 runc run test_busybox
assert_line --index 0 --partial 'ro,'
}
# https://github.com/opencontainers/runc/issues/2683
@test "runc run [tmpfs mount with absolute symlink]" {
# in container, /conf -> /real/conf
mkdir -p rootfs/real/conf
ln -s /real/conf rootfs/conf
update_config ' .mounts += [{
type: "tmpfs",
source: "tmpfs",
destination: "/conf/stack",
options: ["ro", "nodev", "nosuid"]
}]
| .process.args |= ["true"]'
run -0 runc run test_busybox
}
# CVE-2023-27561 CVE-2019-19921
@test "runc run [/proc is a symlink]" {
# Make /proc in the container a symlink.
rm -rf rootfs/proc
mkdir -p rootfs/bad-proc
ln -sf /bad-proc rootfs/proc
# This should fail.
run ! runc run test_busybox
assert_output --regexp 'proc.*directory'
}
# https://github.com/opencontainers/runc/issues/4401
@test "runc run [setgid / + mkdirall]" {
mkdir rootfs/setgid
chmod '=7755' rootfs/setgid
update_config '.mounts += [{
type: "tmpfs",
source: "tmpfs",
destination: "/setgid/a/b/c",
options: ["ro", "nodev", "nosuid"]
}]'
update_config '.process.args |= ["true"]'
run -0 runc run test_busybox
# Verify that the setgid bit is inherited.
[[ "$(stat -c %a rootfs/setgid)" == 7755 ]]
[[ "$(stat -c %a rootfs/setgid/a)" == 2755 ]]
[[ "$(stat -c %a rootfs/setgid/a/b)" == 2755 ]]
[[ "$(stat -c %a rootfs/setgid/a/b/c)" == 2755 ]]
}
# https://github.com/opencontainers/runc/issues/4971
@test "runc run [tmpfs mount mode= inherit]" {
mkdir rootfs/tmpfs
chmod "=0710" rootfs/tmpfs
update_config '.mounts += [{
type: "tmpfs",
source: "tmpfs",
destination: "/tmpfs",
options: ["rw", "nodev", "nosuid"]
}]'
update_config '.process.args = ["stat", "-c", "%a", "/tmpfs"]'
run -0 runc run test_busybox
assert_output "710"
update_config '.process.args = ["cat", "/proc/self/mounts"]'
run -0 runc run test_busybox
grep -Ex "tmpfs /tmpfs tmpfs [^ ]*\bmode=710\b[^ ]* .*" <<<"$output"
}
# https://github.com/opencontainers/runc/issues/4971
@test "runc run [tmpfs mount explicit mode=]" {
mkdir rootfs/tmpfs
chmod "=0710" rootfs/tmpfs
update_config '.mounts += [{
type: "tmpfs",
source: "tmpfs",
destination: "/tmpfs",
options: ["rw", "nodev", "nosuid", "mode=1500"]
}]'
update_config '.process.args = ["stat", "-c", "%a", "/tmpfs"]'
# Explicitly setting mode= overrides whatever mode we would've inherited.
run -0 runc run test_busybox
assert_output "1500"
update_config '.process.args = ["cat", "/proc/self/mounts"]'
run -0 runc run test_busybox
grep -Ex "tmpfs /tmpfs tmpfs [^ ]*\bmode=1500\b[^ ]* .*" <<<"$output"
# Verify that the actual directory was not chmod-ed.
[[ "$(stat -c %a rootfs/tmpfs)" == 710 ]]
}
# https://github.com/opencontainers/runc/issues/4971
@test "runc run [tmpfs mount mode=1777 default]" {
update_config '.mounts += [{
type: "tmpfs",
source: "tmpfs",
destination: "/non-existent/foo/bar/baz",
options: ["rw", "nodev", "nosuid"]
}]'
update_config '.process.args = ["stat", "-c", "%a", "/non-existent/foo/bar/baz"]'
rm -rf rootfs/non-existent
run -0 runc run test_busybox
assert_output "1777"
update_config '.process.args = ["cat", "/proc/self/mounts"]'
rm -rf rootfs/non-existent
run -0 runc run test_busybox
# We don't explicitly set a mode= in this case, it is just the tmpfs default.
grep -Ex "tmpfs /non-existent/foo/bar/baz tmpfs .*" <<<"$output"
run ! grep -Ex "tmpfs /non-existent/foo/bar/baz tmpfs [^ ]*\bmode=[0-7]+\b[^ ]* .*" <<<"$output"
# Verify that the actual modes are *not* 1777.
[[ "$(stat -c %a rootfs/non-existent)" == 755 ]]
[[ "$(stat -c %a rootfs/non-existent/foo)" == 755 ]]
[[ "$(stat -c %a rootfs/non-existent/foo/bar)" == 755 ]]
[[ "$(stat -c %a rootfs/non-existent/foo/bar/baz)" == 755 ]]
}
@test "runc run [ro /sys/fs/cgroup mounts]" {
# Without cgroup namespace.
update_config '.linux.namespaces -= [{"type": "cgroup"}]'
test_ro_cgroup_mount
}
@test "runc run [ro /sys/fs/cgroup mounts + cgroupns]" {
requires cgroupns
# With cgroup namespace.
update_config '.linux.namespaces |= if index({"type": "cgroup"}) then . else . + [{"type": "cgroup"}] end'
test_ro_cgroup_mount
}
@test "runc run [mount order, container bind-mount source]" {
test_mount_order
}
@test "runc run [mount order, container bind-mount source] (userns)" {
test_mount_order userns
}
@test "runc run [mount order, container idmap source]" {
test_mount_order idmap
}
@test "runc run [mount order, container idmap source] (userns)" {
test_mount_order userns,idmap
}
@test "runc run [bind mount through a dangling symlink component]" {
rm -rf rootfs/etc/hosts
ln -s /tmp/foo/bar rootfs/jump
ln -s /jump/baz/hosts rootfs/etc/hosts
rm -rf rootfs/tmp/foo
test_mount_target ./config.json /etc/hosts /tmp/foo/bar/baz/hosts
}
@test "runc run [bind mount through a trailing dangling symlink]" {
rm -rf rootfs/etc/hosts
ln -s /tmp/hosts rootfs/etc/hosts
# File.
rm -rf rootfs/tmp/hosts
test_mount_target ./config.json /etc/hosts /tmp/hosts
# Directory.
rm -rf rootfs/tmp/hosts
test_mount_target . /etc/hosts /tmp/hosts
}
@test "runc run [readonly rootfs keeps nosuid,nodev]" {
requires root
mount --bind rootfs rootfs
mount -o remount,bind,nosuid,nodev rootfs
update_config '.root.readonly = true'
# shellcheck disable=SC2016
update_config --arg prog '$5 == "/" {print "," $6 ","}' \
'.process.args = ["awk", $prog, "/proc/self/mountinfo"]'
run -0 runc run test_busybox
assert_output --partial ",ro,"
assert_output --partial ",nosuid,"
assert_output --partial ",nodev,"
}