Skip to content

Commit 581d923

Browse files
committed
dmz: use overlayfs to write-protect /proc/self/exe if possible
Commit b999376 ("nsenter: cloned_binary: remove bindfd logic entirely") removed the read-only bind-mount logic from our cloned binary code because it wasn't really safe because a container with CAP_SYS_ADMIN could remove the MS_RDONLY bit and get write access to /proc/self/exe (even with user namespaces this could've been an issue because it's not clear if the flags are locked). However, copying a binary does seem to have a minor performance impact. The only way to have no performance impact would be for the kernel to block these write attempts, but barring that we could try to reduce the overhead by coming up with a mount that cannot have it's read-only bits cleared. The "simplest" solution is to create a temporary overlayfs using fsopen(2) which uses the directory where runc exists as a lowerdir, ensuring that the container cannot access the underlying file -- and we don't have to do any copies. While fsopen(2) is not free because mount namespace cloning is usually expensive (and so it seems like the difference would be marginal), some basic performance testing seems to indicate there is a ~60% improvement doing it this way and that it has effectively no overhead even when compared to just using /proc/self/exe directly: % hyperfine --warmup 50 \ > "./runc-overlayfs run -b bundle ctr" \ > "./runc-memfd run -b bundle ctr" \ > "./runc-noclone run -b bundle ctr" Benchmark 1: ./runc-overlayfs run -b bundle ctr Time (mean ± σ): 14.3 ms ± 1.0 ms [User: 5.8 ms, System: 11.7 ms] Range (min … max): 11.4 ms … 17.0 ms 500 runs Benchmark 2: ./runc-memfd run -b bundle ctr Time (mean ± σ): 23.2 ms ± 1.2 ms [User: 6.5 ms, System: 20.5 ms] Range (min … max): 20.3 ms … 27.8 ms 500 runs Benchmark 3: ./runc-noclone run -b bundle ctr Time (mean ± σ): 14.2 ms ± 1.0 ms [User: 6.1 ms, System: 11.2 ms] Range (min … max): 11.5 ms … 17.2 ms 500 runs Summary ./runc-noclone run -b bundle ctr ran 1.01 ± 0.10 times faster than ./runc-overlayfs run -b bundle ctr 1.64 ± 0.14 times faster than ./runc-memfd run -b bundle ctr Signed-off-by: Aleksa Sarai <[email protected]>
1 parent d82235c commit 581d923

File tree

3 files changed

+155
-0
lines changed

3 files changed

+155
-0
lines changed

libcontainer/dmz/cloned_binary_linux.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,13 @@ func IsCloned(exe *os.File) bool {
212212
// make sure the container process can never resolve the original runc binary.
213213
// For more details on why this is necessary, see CVE-2019-5736.
214214
func CloneSelfExe(tmpDir string) (*os.File, error) {
215+
overlayFile, err := sealedOverlayfs("/proc/self/exe", tmpDir)
216+
if err == nil {
217+
logrus.Debugf("using overlayfs for /proc/self/exe sealing")
218+
return overlayFile, nil
219+
}
220+
logrus.Debugf("could not use overlayfs for /proc/self/exe sealing (%v) -- falling back to standard memfd copy", err)
221+
215222
selfExe, err := os.Open("/proc/self/exe")
216223
if err != nil {
217224
return nil, fmt.Errorf("opening current binary: %w", err)
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
package dmz
2+
3+
import (
4+
"fmt"
5+
"os"
6+
"path/filepath"
7+
"runtime"
8+
"strings"
9+
10+
"golang.org/x/sys/unix"
11+
12+
"github.com/opencontainers/runc/libcontainer/utils"
13+
)
14+
15+
func fsopen(fsName string, flags int) (*os.File, error) {
16+
// Make sure we always set O_CLOEXEC.
17+
flags |= unix.FSOPEN_CLOEXEC
18+
fd, err := unix.Fsopen(fsName, flags)
19+
if err != nil {
20+
return nil, os.NewSyscallError("fsopen "+fsName, err)
21+
}
22+
return os.NewFile(uintptr(fd), "fscontext:"+fsName), nil
23+
}
24+
25+
func fsmount(ctx *os.File, flags, mountAttrs int) (*os.File, error) {
26+
// Make sure we always set O_CLOEXEC.
27+
flags |= unix.FSMOUNT_CLOEXEC
28+
fd, err := unix.Fsmount(int(ctx.Fd()), flags, mountAttrs)
29+
if err != nil {
30+
return nil, os.NewSyscallError("fsmount "+ctx.Name(), err)
31+
}
32+
return os.NewFile(uintptr(fd), "fsmount:"+ctx.Name()), nil
33+
}
34+
35+
func escapeOverlayLowerDir(path string) string {
36+
// If the lowerdir path contains ":" we need to escape them, and if there
37+
// were any escape characters already (\) we need to escape those first.
38+
return strings.ReplaceAll(strings.ReplaceAll(path, `\`, `\\`), `:`, `\:`)
39+
}
40+
41+
func fstatat(dir *os.File, path string, flags int) (unix.Stat_t, error) {
42+
dirFd := unix.AT_FDCWD
43+
if dir != nil {
44+
dirFd = int(dir.Fd())
45+
}
46+
flags |= unix.AT_EMPTY_PATH
47+
48+
var stat unix.Stat_t
49+
err := unix.Fstatat(dirFd, path, &stat, flags)
50+
if err != nil {
51+
err = &os.PathError{Op: "fstatat", Path: path, Err: err}
52+
}
53+
runtime.KeepAlive(dir)
54+
return stat, err
55+
}
56+
57+
// sealedOverlayfs will create an internal overlayfs mount using fsopen() that
58+
// uses the directory containing the binary as a lowerdir and a temporary tmpfs
59+
// as an upperdir. There is no way to "unwrap" this (unlike MS_RDONLY) and so
60+
// we can create a safe zero-copy sealed version of /proc/self/exe.
61+
func sealedOverlayfs(binPath, tmpDir string) (_ *os.File, Err error) {
62+
// binPath is going to be /proc/self/exe, so do a readlink to get the real
63+
// path. overlayfs needs the real underlying directory for this protection
64+
// mode to work properly.
65+
if realPath, err := os.Readlink(binPath); err == nil {
66+
binPath = realPath
67+
}
68+
binLowerDirPath, binName := filepath.Split(binPath)
69+
// Escape any ":"s or "\"s in the path.
70+
binLowerDirPath = escapeOverlayLowerDir(binLowerDirPath)
71+
72+
// Overlayfs requires two lowerdirs in order to run in "lower-only" mode,
73+
// where writes are completely blocked. Ideally we would create a dummy
74+
// tmpfs for this, but it turns out that overlayfs doesn't allow for
75+
// anonymous mountns paths.
76+
// NOTE: I'm working on a patch to fix this but it won't be backported.
77+
dummyLowerDirPath := escapeOverlayLowerDir(tmpDir)
78+
79+
overlayCtx, err := fsopen("overlay", unix.FSOPEN_CLOEXEC)
80+
if err != nil {
81+
return nil, err
82+
}
83+
defer overlayCtx.Close()
84+
85+
// Configure the lowerdirs. The binary lowerdir needs to be on the top to
86+
// ensure that a file called "runc" (binName) in the dummy lowerdir doesn't
87+
// mask the binary.
88+
lowerDirStr := binLowerDirPath + ":" + dummyLowerDirPath
89+
if err := unix.FsconfigSetString(int(overlayCtx.Fd()), "lowerdir", lowerDirStr); err != nil {
90+
return nil, fmt.Errorf("fsconfig set overlayfs lowerdir=%s: %w", lowerDirStr, err)
91+
}
92+
93+
// Get an actual handle to the overlayfs.
94+
if err := unix.FsconfigCreate(int(overlayCtx.Fd())); err != nil {
95+
return nil, os.NewSyscallError("fsconfig create overlayfs", err)
96+
}
97+
overlayFd, err := fsmount(overlayCtx, unix.FSMOUNT_CLOEXEC, unix.MS_RDONLY|unix.MS_NODEV|unix.MS_NOSUID)
98+
if err != nil {
99+
return nil, err
100+
}
101+
defer overlayFd.Close()
102+
103+
// Grab a handle to the binary through overlayfs.
104+
exeFile, err := utils.Openat(overlayFd, binName, unix.O_PATH|unix.O_NOFOLLOW|unix.O_CLOEXEC, 0)
105+
if err != nil {
106+
return nil, fmt.Errorf("open %s from overlayfs (lowerdir=%s): %w", binName, lowerDirStr, err)
107+
}
108+
defer func() {
109+
if Err != nil {
110+
_ = exeFile.Close()
111+
}
112+
}()
113+
114+
// Check that the file is what we expect. Ideally we might check the hash
115+
// of the file against /proc/self/exe, but that would require us to copy
116+
// the binary (negating the benefit of using overlayfs over memfd-copying).
117+
// So instead we just check that the inode number is the same. In theory
118+
// this could result in us allowing an incorrect binary in some scenarios
119+
// but this would require an attacker to modify /usr/sbin in the host
120+
// filesystem -- at which point you've already lost.
121+
procSelfStat, err := fstatat(nil, "/proc/self/exe", 0)
122+
if err != nil {
123+
return nil, err
124+
}
125+
exeFileStat, err := fstatat(exeFile, "", 0)
126+
if err != nil {
127+
return nil, err
128+
}
129+
if procSelfStat.Ino != exeFileStat.Ino {
130+
return nil, fmt.Errorf("overlayfs cloned binary has different inode number %d != %d", procSelfStat.Ino, exeFileStat.Ino)
131+
}
132+
return exeFile, nil
133+
}

libcontainer/utils/utils_unix.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -346,3 +346,18 @@ func MkdirAllInRoot(root, unsafePath string, mode uint32) error {
346346
}
347347
return err
348348
}
349+
350+
// Openat is a Go-friendly openat(2) wrapper.
351+
func Openat(dir *os.File, path string, flags int, mode uint32) (*os.File, error) {
352+
dirFd := unix.AT_FDCWD
353+
if dir != nil {
354+
dirFd = int(dir.Fd())
355+
}
356+
flags |= unix.O_CLOEXEC
357+
358+
fd, err := unix.Openat(dirFd, path, flags, mode)
359+
if err != nil {
360+
return nil, &os.PathError{Op: "openat", Path: path, Err: err}
361+
}
362+
return os.NewFile(uintptr(fd), dir.Name()+"/"+path), nil
363+
}

0 commit comments

Comments
 (0)