Skip to content

Commit 027f7b8

Browse files
committed
rootfs: prohibit symlinks that conflicts with readonlyPaths, maskedPaths
"/proc" and "/sys" in the container rootfs can no longer be symlinks, as they could be tricked to obtain the host root. Fix issue 3751 ("CVE-2019-19921 re-introduction/regression") Fix CVE-2023-27561 The CVE has been already public, so I'm just going to open this PR publicly. Signed-off-by: Akihiro Suda <[email protected]>
1 parent 69225fa commit 027f7b8

File tree

3 files changed

+136
-2
lines changed

3 files changed

+136
-2
lines changed

libcontainer/rootfs_linux.go

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"errors"
55
"fmt"
66
"io"
7+
"io/fs"
78
"os"
89
"path"
910
"path/filepath"
@@ -52,6 +53,10 @@ func needsSetupDev(config *configs.Config) bool {
5253
// finalizeRootfs after this function to finish setting up the rootfs.
5354
func prepareRootfs(pipe io.ReadWriter, iConfig *initConfig, mountFds []int) (err error) {
5455
config := iConfig.Config
56+
if err := validateRoot(config); err != nil {
57+
return fmt.Errorf("error validating rootfs: %w", err)
58+
}
59+
5560
if err := prepareRoot(config); err != nil {
5661
return fmt.Errorf("error preparing rootfs: %w", err)
5762
}
@@ -1115,3 +1120,51 @@ func setRecAttr(m *configs.Mount, rootfs string) error {
11151120
return unix.MountSetattr(-1, procfd, unix.AT_RECURSIVE, m.RecAttr)
11161121
})
11171122
}
1123+
1124+
// validateRoot detects symlinks in config.Rootfs that may break config.ReadonlyPaths
1125+
// and config.MaskPaths.
1126+
//
1127+
// For preventing CVE-2023-27561 (CVE-2019-19921 re-introduction/regression).
1128+
// https://github.com/opencontainers/runc/issues/3751
1129+
func validateRoot(config *configs.Config) error {
1130+
mustNotBeSymlink := make(map[string]struct{})
1131+
for _, f := range append(config.ReadonlyPaths, config.MaskPaths...) {
1132+
mustNotBeSymlink[f] = struct{}{}
1133+
a, err := ancestorPaths(f)
1134+
if err != nil {
1135+
return fmt.Errorf("failed to compute the ancestor paths of %q: %w", f, err)
1136+
}
1137+
for _, g := range a {
1138+
mustNotBeSymlink[g] = struct{}{}
1139+
}
1140+
}
1141+
// mustNotBeSymlink typically contains {"/sys", "/proc"} in addition to readonlyPaths and maskedPaths.
1142+
for f := range mustNotBeSymlink {
1143+
if f != filepath.Clean(f) {
1144+
return fmt.Errorf("dirty path: %q", f)
1145+
}
1146+
// securejoin can't be used here, as it follows symlinks.
1147+
absMustNotBeSymlink := filepath.Join(config.Rootfs, f)
1148+
st, err := os.Lstat(absMustNotBeSymlink)
1149+
// absMustNotBeSymlink isn't always stattable, so we discard non-nil err here.
1150+
if err == nil && st.Mode()&fs.ModeSymlink != 0 {
1151+
return fmt.Errorf("must not be a symlink (conflicts with readonlyPaths and/or maskedPaths): %q", absMustNotBeSymlink)
1152+
}
1153+
}
1154+
return nil
1155+
}
1156+
1157+
// ancestorPaths returns ancestor paths excluding "/".
1158+
//
1159+
// ancestorPaths("/foo/bar/baz") = {"/foo/bar", "/foo"} ("/" is not included).
1160+
func ancestorPaths(f string) ([]string, error) {
1161+
if f != filepath.Clean(f) {
1162+
return nil, fmt.Errorf("dirty path: %q", f)
1163+
}
1164+
dir := filepath.Dir(f)
1165+
if dir == "/" {
1166+
return []string{}, nil
1167+
}
1168+
rec, err := ancestorPaths(dir)
1169+
return append([]string{dir}, rec...), err
1170+
}

libcontainer/rootfs_linux_test.go

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
package libcontainer
22

33
import (
4+
"reflect"
5+
"strings"
46
"testing"
57

68
"github.com/opencontainers/runc/libcontainer/configs"
@@ -105,3 +107,43 @@ func TestNeedsSetupDevStrangeSourceDest(t *testing.T) {
105107
t.Fatal("expected needsSetupDev to be true, got false")
106108
}
107109
}
110+
111+
func TestAncestorPaths(t *testing.T) {
112+
type testCase struct {
113+
f string
114+
expected []string
115+
expectedError string
116+
}
117+
testCases := []testCase{
118+
{
119+
f: "/foo/bar/baz",
120+
expected: []string{"/foo/bar", "/foo"},
121+
},
122+
{
123+
f: "/",
124+
expected: []string{},
125+
},
126+
{
127+
f: "/foo/../../../../bar",
128+
expectedError: "dirty path",
129+
},
130+
}
131+
for _, f := range testCases {
132+
got, err := ancestorPaths(f.f)
133+
if f.expectedError == "" {
134+
if err != nil {
135+
t.Errorf("ancestorPaths(%q): %v", f.f, err)
136+
}
137+
if !reflect.DeepEqual(f.expected, got) {
138+
t.Errorf("expected ancestorPaths(%q)=%v, got %v", f.f, f.expected, got)
139+
}
140+
} else {
141+
if err == nil {
142+
t.Errorf("expected ancestorPaths(%q) to return an error", f.f)
143+
}
144+
if !strings.Contains(err.Error(), f.expectedError) {
145+
t.Errorf("expected ancestorPaths(%q) to return an error that contains %q", f.f, f.expectedError)
146+
}
147+
}
148+
}
149+
}

tests/integration/mask.bats

Lines changed: 41 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,12 @@ function setup() {
66
setup_busybox
77

88
# Create fake rootfs.
9-
mkdir rootfs/testdir
9+
mkdir rootfs/testdir rootfs/testdir2
1010
echo "Forbidden information!" >rootfs/testfile
11+
echo "Forbidden information! (in a nested dir)" >rootfs/testdir2/file2
1112

1213
# add extra masked paths
13-
update_config '(.. | select(.maskedPaths? != null)) .maskedPaths += ["/testdir", "/testfile"]'
14+
update_config '(.. | select(.maskedPaths? != null)) .maskedPaths += ["/testdir", "/testfile", "/testdir2/testfile2"]'
1415
}
1516

1617
function teardown() {
@@ -56,3 +57,41 @@ function teardown() {
5657
[ "$status" -eq 1 ]
5758
[[ "${output}" == *"Operation not permitted"* ]]
5859
}
60+
61+
@test "mask paths [prohibit symlink /proc]" {
62+
ln -s /symlink rootfs/proc
63+
runc run -d --console-socket "$CONSOLE_SOCKET" test_busybox
64+
[ "$status" -eq 1 ]
65+
[[ "${output}" == *"must not be a symlink"* ]]
66+
}
67+
68+
@test "mask paths [prohibit symlink /sys]" {
69+
ln -s /symlink rootfs/sys
70+
runc run -d --console-socket "$CONSOLE_SOCKET" test_busybox
71+
[ "$status" -eq 1 ]
72+
[[ "${output}" == *"must not be a symlink"* ]]
73+
}
74+
75+
@test "mask paths [prohibit symlink /testdir]" {
76+
rmdir rootfs/testdir
77+
ln -s /symlink rootfs/testdir
78+
runc run -d --console-socket "$CONSOLE_SOCKET" test_busybox
79+
[ "$status" -eq 1 ]
80+
[[ "${output}" == *"must not be a symlink"* ]]
81+
}
82+
83+
@test "mask paths [prohibit symlink /testfile]" {
84+
rm -f rootfs/testfile
85+
ln -s /symlink rootfs/testfile
86+
runc run -d --console-socket "$CONSOLE_SOCKET" test_busybox
87+
[ "$status" -eq 1 ]
88+
[[ "${output}" == *"must not be a symlink"* ]]
89+
}
90+
91+
@test "mask paths [prohibit symlink /testdir2 (parent of /testdir2/testfile2)]" {
92+
rm -rf rootfs/testdir2
93+
ln -s /symlink rootfs/testdir2
94+
runc run -d --console-socket "$CONSOLE_SOCKET" test_busybox
95+
[ "$status" -eq 1 ]
96+
[[ "${output}" == *"must not be a symlink"* ]]
97+
}

0 commit comments

Comments
 (0)