Skip to content

Commit f2f1621

Browse files
committed
init: close internal fds before execve
If we leak a file descriptor referencing the host filesystem, an attacker could use a /proc/self/fd magic-link as the source for execve to execute a host binary in the container. This would allow the binary itself (or a process inside the container in the 'runc exec' case) to write to a host binary, leading to a container escape. The simple solution is to make sure we close all file descriptors immediately before the execve(2) step. Doing this earlier can lead to very serious issues in Go (as file descriptors can be reused, any (*os.File) reference could start silently operating on a different file) so we have to do it as late as possible. Unfortunately, there are some Go runtime file descriptors that we must not close (otherwise the Go scheduler panics randomly). The only way of being sure which file descriptors cannot be closed is to sneakily go:linkname the runtime internal "internal/poll.IsPollDescriptor" function. This is almost certainly not recommended but there isn't any other way to be absolutely sure, while also closing any other possible files. In addition, we can keep the logrus forwarding logfd open because you cannot execve a pipe and the contents of the pipe are so restricted (JSON-encoded in a format we pick) that it seems unlikely you could even construct shellcode. Closing the logfd causes issues if there is an error returned from execve. In mainline runc, runc-dmz protects us against this attack because the intermediate execve(2) closes all of the O_CLOEXEC internal runc file descriptors and thus runc-dmz cannot access them to attack the host. Fixes: GHSA-xr7r-f8xq-vfvv CVE-2024-21626 Signed-off-by: Aleksa Sarai <[email protected]>
1 parent 8e1cd2f commit f2f1621

File tree

5 files changed

+117
-13
lines changed

5 files changed

+117
-13
lines changed

libcontainer/init_linux.go

+1-1
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,7 @@ func Init() {
9090
}
9191
// Normally, StartInitialization() never returns, meaning
9292
// if we are here, it had failed.
93-
os.Exit(1)
93+
os.Exit(255)
9494
}
9595

9696
// Normally, this function does not return. If it returns, with or without an

libcontainer/logs/logs.go

+9
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,19 @@ import (
44
"bufio"
55
"encoding/json"
66
"io"
7+
"os"
78

89
"github.com/sirupsen/logrus"
910
)
1011

12+
// IsLogrusFd returns whether the provided fd matches the one that logrus is
13+
// currently outputting to. This should only ever be called by UnsafeCloseFrom
14+
// from `runc init`.
15+
func IsLogrusFd(fd uintptr) bool {
16+
file, ok := logrus.StandardLogger().Out.(*os.File)
17+
return ok && file.Fd() == fd
18+
}
19+
1120
func ForwardLogs(logPipe io.ReadCloser) chan error {
1221
done := make(chan error, 1)
1322
s := bufio.NewScanner(logPipe)

libcontainer/setns_init_linux.go

+19
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import (
1515
"github.com/opencontainers/runc/libcontainer/keys"
1616
"github.com/opencontainers/runc/libcontainer/seccomp"
1717
"github.com/opencontainers/runc/libcontainer/system"
18+
"github.com/opencontainers/runc/libcontainer/utils"
1819
)
1920

2021
// linuxSetnsInit performs the container's initialization for running a new process
@@ -139,5 +140,23 @@ func (l *linuxSetnsInit) Init() error {
139140
l.config.Args[0] = name
140141
return system.Fexecve(l.dmzExe.Fd(), l.config.Args, os.Environ())
141142
}
143+
// Close all file descriptors we are not passing to the container. This is
144+
// necessary because the execve target could use internal runc fds as the
145+
// execve path, potentially giving access to binary files from the host
146+
// (which can then be opened by container processes, leading to container
147+
// escapes). Note that because this operation will close any open file
148+
// descriptors that are referenced by (*os.File) handles from underneath
149+
// the Go runtime, we must not do any file operations after this point
150+
// (otherwise the (*os.File) finaliser could close the wrong file). See
151+
// CVE-2024-21626 for more information as to why this protection is
152+
// necessary.
153+
//
154+
// This is not needed for runc-dmz, because the extra execve(2) step means
155+
// that all O_CLOEXEC file descriptors have already been closed and thus
156+
// the second execve(2) from runc-dmz cannot access internal file
157+
// descriptors from runc.
158+
if err := utils.UnsafeCloseFrom(l.config.PassedFilesCount + 3); err != nil {
159+
return err
160+
}
142161
return system.Exec(name, l.config.Args, os.Environ())
143162
}

libcontainer/standard_init_linux.go

+18
Original file line numberDiff line numberDiff line change
@@ -282,5 +282,23 @@ func (l *linuxStandardInit) Init() error {
282282
l.config.Args[0] = name
283283
return system.Fexecve(l.dmzExe.Fd(), l.config.Args, os.Environ())
284284
}
285+
// Close all file descriptors we are not passing to the container. This is
286+
// necessary because the execve target could use internal runc fds as the
287+
// execve path, potentially giving access to binary files from the host
288+
// (which can then be opened by container processes, leading to container
289+
// escapes). Note that because this operation will close any open file
290+
// descriptors that are referenced by (*os.File) handles from underneath
291+
// the Go runtime, we must not do any file operations after this point
292+
// (otherwise the (*os.File) finaliser could close the wrong file). See
293+
// CVE-2024-21626 for more information as to why this protection is
294+
// necessary.
295+
//
296+
// This is not needed for runc-dmz, because the extra execve(2) step means
297+
// that all O_CLOEXEC file descriptors have already been closed and thus
298+
// the second execve(2) from runc-dmz cannot access internal file
299+
// descriptors from runc.
300+
if err := utils.UnsafeCloseFrom(l.config.PassedFilesCount + 3); err != nil {
301+
return err
302+
}
285303
return system.Exec(name, l.config.Args, os.Environ())
286304
}

libcontainer/utils/utils_unix.go

+70-12
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,13 @@ import (
1111
"runtime"
1212
"strconv"
1313
"sync"
14+
_ "unsafe" // for go:linkname
1415

1516
securejoin "github.com/cyphar/filepath-securejoin"
1617
"github.com/sirupsen/logrus"
1718
"golang.org/x/sys/unix"
19+
20+
"github.com/opencontainers/runc/libcontainer/logs"
1821
)
1922

2023
// EnsureProcHandle returns whether or not the given file handle is on procfs.
@@ -53,14 +56,11 @@ func haveCloseRangeCloexec() bool {
5356
return haveCloseRangeCloexecBool
5457
}
5558

56-
// CloseExecFrom applies O_CLOEXEC to all file descriptors currently open for
57-
// the process (except for those below the given fd value).
58-
func CloseExecFrom(minFd int) error {
59-
if haveCloseRangeCloexec() {
60-
err := unix.CloseRange(uint(minFd), math.MaxUint, unix.CLOSE_RANGE_CLOEXEC)
61-
return os.NewSyscallError("close_range", err)
62-
}
59+
type fdFunc func(fd int)
6360

61+
// fdRangeFrom calls the passed fdFunc for each file descriptor that is open in
62+
// the current process.
63+
func fdRangeFrom(minFd int, fn fdFunc) error {
6464
procSelfFd, closer := ProcThreadSelf("fd")
6565
defer closer()
6666

@@ -88,15 +88,73 @@ func CloseExecFrom(minFd int) error {
8888
if fd < minFd {
8989
continue
9090
}
91-
// Intentionally ignore errors from unix.CloseOnExec -- the cases where
92-
// this might fail are basically file descriptors that have already
93-
// been closed (including and especially the one that was created when
94-
// os.ReadDir did the "opendir" syscall).
95-
unix.CloseOnExec(fd)
91+
// Ignore the file descriptor we used for readdir, as it will be closed
92+
// when we return.
93+
if uintptr(fd) == fdDir.Fd() {
94+
continue
95+
}
96+
// Run the closure.
97+
fn(fd)
9698
}
9799
return nil
98100
}
99101

102+
// CloseExecFrom sets the O_CLOEXEC flag on all file descriptors greater or
103+
// equal to minFd in the current process.
104+
func CloseExecFrom(minFd int) error {
105+
// Use close_range(CLOSE_RANGE_CLOEXEC) if possible.
106+
if haveCloseRangeCloexec() {
107+
err := unix.CloseRange(uint(minFd), math.MaxUint, unix.CLOSE_RANGE_CLOEXEC)
108+
return os.NewSyscallError("close_range", err)
109+
}
110+
// Otherwise, fall back to the standard loop.
111+
return fdRangeFrom(minFd, unix.CloseOnExec)
112+
}
113+
114+
//go:linkname runtime_IsPollDescriptor internal/poll.IsPollDescriptor
115+
116+
// In order to make sure we do not close the internal epoll descriptors the Go
117+
// runtime uses, we need to ensure that we skip descriptors that match
118+
// "internal/poll".IsPollDescriptor. Yes, this is a Go runtime internal thing,
119+
// unfortunately there's no other way to be sure we're only keeping the file
120+
// descriptors the Go runtime needs. Hopefully nothing blows up doing this...
121+
func runtime_IsPollDescriptor(fd uintptr) bool //nolint:revive
122+
123+
// UnsafeCloseFrom closes all file descriptors greater or equal to minFd in the
124+
// current process, except for those critical to Go's runtime (such as the
125+
// netpoll management descriptors).
126+
//
127+
// NOTE: That this function is incredibly dangerous to use in most Go code, as
128+
// closing file descriptors from underneath *os.File handles can lead to very
129+
// bad behaviour (the closed file descriptor can be re-used and then any
130+
// *os.File operations would apply to the wrong file). This function is only
131+
// intended to be called from the last stage of runc init.
132+
func UnsafeCloseFrom(minFd int) error {
133+
// We cannot use close_range(2) even if it is available, because we must
134+
// not close some file descriptors.
135+
return fdRangeFrom(minFd, func(fd int) {
136+
if runtime_IsPollDescriptor(uintptr(fd)) {
137+
// These are the Go runtimes internal netpoll file descriptors.
138+
// These file descriptors are operated on deep in the Go scheduler,
139+
// and closing those files from underneath Go can result in panics.
140+
// There is no issue with keeping them because they are not
141+
// executable and are not useful to an attacker anyway. Also we
142+
// don't have any choice.
143+
return
144+
}
145+
if logs.IsLogrusFd(uintptr(fd)) {
146+
// Do not close the logrus output fd. We cannot exec a pipe, and
147+
// the contents are quite limited (very little attacker control,
148+
// JSON-encoded) making shellcode attacks unlikely.
149+
return
150+
}
151+
// There's nothing we can do about errors from close(2), and the
152+
// only likely error to be seen is EBADF which indicates the fd was
153+
// already closed (in which case, we got what we wanted).
154+
_ = unix.Close(fd)
155+
})
156+
}
157+
100158
// NewSockPair returns a new SOCK_STREAM unix socket pair.
101159
func NewSockPair(name string) (parent, child *os.File, err error) {
102160
fds, err := unix.Socketpair(unix.AF_LOCAL, unix.SOCK_STREAM|unix.SOCK_CLOEXEC, 0)

0 commit comments

Comments
 (0)