Skip to content

Commit 28ca488

Browse files
committed
exec: add optional --exec-wait-fifo
When you run exec with --detach you can already get the new process's pidfd and pid before it runs, but nothing actually holds the process there. The program can execve, and possibly exit, before whatever is supervising it has finished adopting it. I didn't want to build out a whole create/start style lifecycle for exec just to get that, when all it really needs is a single wait point right before execve. So this adds an opt-in --exec-wait-fifo <path>. The caller creates and owns the fifo. runc passes it into the setns process, which does all of its normal setup and then, just before execve, opens the fifo for writing. That open blocks until something opens the read end, so a supervisor can finish its handoff and then open the fifo to let the program run. We write a byte and exec. It's the same handshake create/start already use with the internal exec.fifo, so this reuses that path (awaitExecFifo) instead of adding another one. The O_PATH fd is closed before execve so the CVE-2016-9962 workaround still holds on old kernels. Nothing changes when the flag isn't set. Signed-off-by: Brian Goff <cpuguy83@gmail.com>
1 parent fc89fbd commit 28ca488

10 files changed

Lines changed: 186 additions & 22 deletions

File tree

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
66

77
## [Unreleased]
88

9+
### Added ###
10+
- `runc exec` now accepts an optional `--exec-wait-fifo <path>` flag. When set,
11+
the exec process opens the caller-owned FIFO for writing just before `execve`.
12+
Opening a FIFO for writing blocks until a reader is present, so the process
13+
pauses there until an external supervisor opens the read end. This lets the
14+
supervisor register the detached exec process (e.g. one identified via
15+
`--pidfd-socket`) before its program runs. (#5373)
16+
917
### Fixed ###
1018
- The poststart hooks are now executed after starting the user-specified
1119
process, fixing a runtime-spec conformance issue. (#4347, #5186)

exec.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,10 @@ following will output a list of processes running in the container:
4444
Name: "pidfd-socket",
4545
Usage: "path to an AF_UNIX socket which will receive a file descriptor referencing the exec process",
4646
},
47+
&cli.StringFlag{
48+
Name: "exec-wait-fifo",
49+
Usage: "path to a caller-owned FIFO; the exec process blocks opening it for writing just before execve, until an external reader opens the read end",
50+
},
4751
&cli.StringFlag{
4852
Name: "cwd",
4953
Usage: "current working directory in the container",
@@ -196,6 +200,7 @@ func execProcess(cmd *cli.Command) (int, error) {
196200
container: container,
197201
consoleSocket: cmd.String("console-socket"),
198202
pidfdSocket: cmd.String("pidfd-socket"),
203+
execWaitFifo: cmd.String("exec-wait-fifo"),
199204
detach: cmd.Bool("detach"),
200205
pidFile: cmd.String("pid-file"),
201206
action: CT_ACT_RUN,

libcontainer/container_linux.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -619,6 +619,13 @@ func (c *Container) newParentProcess(p *Process) (parentProcess, error) {
619619
)
620620
}
621621

622+
if p.ExecWaitFifo != nil {
623+
cmd.ExtraFiles = append(cmd.ExtraFiles, p.ExecWaitFifo)
624+
cmd.Env = append(cmd.Env,
625+
"_LIBCONTAINER_EXECWAITFD="+strconv.Itoa(stdioFdCount+len(cmd.ExtraFiles)-1),
626+
)
627+
}
628+
622629
// TODO: After https://go-review.googlesource.com/c/go/+/515799 included
623630
// in go versions supported by us, we can remove this logic.
624631
if safeExe != nil {

libcontainer/init_linux.go

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -215,6 +215,19 @@ func startInitialization() (retErr error) {
215215
defer pidfdSocket.Close()
216216
}
217217

218+
// Only setns ("runc exec") processes may be given an exec-wait FIFO.
219+
var execWaitFifo *os.File
220+
if it == initSetns {
221+
if envFd := os.Getenv("_LIBCONTAINER_EXECWAITFD"); envFd != "" {
222+
fd, err := strconv.Atoi(envFd)
223+
if err != nil {
224+
return fmt.Errorf("unable to convert _LIBCONTAINER_EXECWAITFD: %w", err)
225+
}
226+
execWaitFifo = os.NewFile(uintptr(fd), "exec-wait-fifo")
227+
defer execWaitFifo.Close()
228+
}
229+
}
230+
218231
// From here on, we don't need current process environment. It is not
219232
// used directly anywhere below this point, but let's clear it anyway.
220233
os.Clearenv()
@@ -235,10 +248,10 @@ func startInitialization() (retErr error) {
235248
}
236249

237250
// If init succeeds, it will not return, hence none of the defers will be called.
238-
return containerInit(it, &config, syncPipe, consoleSocket, pidfdSocket, fifoFile, logPipe)
251+
return containerInit(it, &config, syncPipe, consoleSocket, pidfdSocket, fifoFile, logPipe, execWaitFifo)
239252
}
240253

241-
func containerInit(t initType, config *initConfig, pipe *syncSocket, consoleSocket, pidfdSocket, fifoFile, logPipe *os.File) error {
254+
func containerInit(t initType, config *initConfig, pipe *syncSocket, consoleSocket, pidfdSocket, fifoFile, logPipe, execWaitFifo *os.File) error {
242255
// Clean the RLIMIT_NOFILE cache in go runtime.
243256
// Issue: https://github.com/opencontainers/runc/issues/4195
244257
maybeClearRlimitNofileCache(config.Rlimits)
@@ -251,6 +264,7 @@ func containerInit(t initType, config *initConfig, pipe *syncSocket, consoleSock
251264
pidfdSocket: pidfdSocket,
252265
config: config,
253266
logPipe: logPipe,
267+
execWaitFifo: execWaitFifo,
254268
}
255269
return i.Init()
256270
case initStandard:
@@ -735,3 +749,25 @@ func setupPidfd(socket *os.File, initType string) error {
735749
}
736750
return unix.Close(pidFd)
737751
}
752+
753+
// awaitExecFifo blocks until a reader opens the other end of the exec FIFO
754+
// referenced by the given O_PATH fd, then writes a single byte to signal that
755+
// the process is about to execve. Both the reopened write end and the original
756+
// O_PATH fd are closed before returning; closing the O_PATH fd before execve
757+
// avoids re-triggering CVE-2016-9962 on older kernels.
758+
func awaitExecFifo(fifo *os.File) error {
759+
// The fd we were handed is an O_PATH fd to the FIFO, which cannot be used
760+
// for I/O. Re-open it for writing through /proc/self/fd; this blocks until
761+
// an external reader opens the read end.
762+
w, err := pathrs.Reopen(fifo, unix.O_WRONLY|unix.O_CLOEXEC)
763+
if err != nil {
764+
return fmt.Errorf("reopen exec fifo: %w", err)
765+
}
766+
defer w.Close()
767+
if _, err := w.Write([]byte("0")); err != nil {
768+
return &os.PathError{Op: "write exec fifo", Path: w.Name(), Err: err}
769+
}
770+
_ = w.Close()
771+
_ = fifo.Close()
772+
return nil
773+
}

libcontainer/process.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,14 @@ type Process struct {
9393
// PidfdSocket provides process file descriptor of it own.
9494
PidfdSocket *os.File
9595

96+
// ExecWaitFifo is an optional FIFO that a setns ("runc exec") process
97+
// opens for writing immediately before execve, blocking until an external
98+
// reader opens the read end. This lets a supervisor register the process
99+
// (e.g. via PidfdSocket) before its requested program starts. The caller
100+
// owns creation and cleanup of the FIFO. It is only honored for exec
101+
// (non-Init) processes.
102+
ExecWaitFifo *os.File
103+
96104
// Init specifies whether the process is the first process in the container.
97105
Init bool
98106

libcontainer/setns_init_linux.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ type linuxSetnsInit struct {
2626
pidfdSocket *os.File
2727
config *initConfig
2828
logPipe *os.File
29+
execWaitFifo *os.File
2930
}
3031

3132
func (l *linuxSetnsInit) getSessionRingName() string {
@@ -151,6 +152,16 @@ func (l *linuxSetnsInit) Init() error {
151152
return fmt.Errorf("close log pipe: %w", err)
152153
}
153154

155+
// If an exec-wait FIFO was provided, block until an external reader opens
156+
// it before running the user process. This gives a supervisor a chance to
157+
// register the (already-created) process before its program starts. See
158+
// the --exec-wait-fifo flag of "runc exec".
159+
if l.execWaitFifo != nil {
160+
if err := awaitExecFifo(l.execWaitFifo); err != nil {
161+
return err
162+
}
163+
}
164+
154165
// Close all file descriptors we are not passing to the container. This is
155166
// necessary because the execve target could use internal runc fds as the
156167
// execve path, potentially giving access to binary files from the host

libcontainer/standard_init_linux.go

Lines changed: 4 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@ import (
1212
"golang.org/x/sys/unix"
1313

1414
"github.com/opencontainers/runc/internal/linux"
15-
"github.com/opencontainers/runc/internal/pathrs"
1615
"github.com/opencontainers/runc/internal/sys"
1716
"github.com/opencontainers/runc/libcontainer/apparmor"
1817
"github.com/opencontainers/runc/libcontainer/configs"
@@ -260,27 +259,12 @@ func (l *linuxStandardInit) Init() error {
260259
}
261260

262261
// Wait for the FIFO to be opened on the other side before exec-ing the
263-
// user process. We open it through /proc/self/fd/$fd, because the fd that
264-
// was given to us was an O_PATH fd to the fifo itself. Linux allows us to
265-
// re-open an O_PATH fd through /proc.
266-
fifoFile, err := pathrs.Reopen(l.fifoFile, unix.O_WRONLY|unix.O_CLOEXEC)
267-
if err != nil {
268-
return fmt.Errorf("reopen exec fifo: %w", err)
269-
}
270-
defer fifoFile.Close()
271-
if _, err := fifoFile.Write([]byte("0")); err != nil {
272-
return &os.PathError{Op: "write exec fifo", Path: fifoFile.Name(), Err: err}
262+
// user process. The fd we were given is an O_PATH fd to the fifo itself,
263+
// which awaitExecFifo re-opens through /proc to signal readiness.
264+
if err := awaitExecFifo(l.fifoFile); err != nil {
265+
return err
273266
}
274267

275-
// Close the O_PATH fifofd fd before exec because the kernel resets
276-
// dumpable in the wrong order. This has been fixed in newer kernels, but
277-
// we keep this to ensure CVE-2016-9962 doesn't re-emerge on older kernels.
278-
// N.B. the core issue itself (passing dirfds to the host filesystem) has
279-
// since been resolved.
280-
// https://github.com/torvalds/linux/blob/v4.9/fs/exec.c#L1290-L1318
281-
_ = fifoFile.Close()
282-
_ = l.fifoFile.Close()
283-
284268
if s := l.config.SpecState; s != nil {
285269
s.Pid = unix.Getpid()
286270
s.Status = specs.StateCreated

man/runc-exec.8.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,16 @@ specification as defined by the
4242
**--pid-file** _path_
4343
: Specify the file to write the container process' PID to.
4444

45+
**--exec-wait-fifo** _path_
46+
: Path to a caller-created and caller-owned FIFO. When set, the exec process
47+
completes its normal setup but blocks opening the FIFO for writing, just before
48+
the final **execve**(2), until an external reader opens the read end. It then
49+
writes one byte and proceeds to execute the requested program. This is a single
50+
opt-in synchronization point — analogous to the FIFO handshake that gates the
51+
start of a **runc-create**(8)d container — that lets a supervisor register a detached
52+
exec process — for example one identified via **--pidfd-socket** — before its
53+
program runs. Intended for use with **--detach**.
54+
4555
**--process-label** _label_
4656
: Set the asm process label for the process commonly used with **selinux**(7).
4757

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
#!/usr/bin/env bats
2+
3+
load helpers
4+
5+
function setup() {
6+
setup_busybox
7+
}
8+
9+
function teardown() {
10+
teardown_bundle
11+
}
12+
13+
@test "runc exec [ --exec-wait-fifo ] does not run the program until the read end is opened" {
14+
update_config '.root.readonly = false'
15+
runc run -d --console-socket "$CONSOLE_SOCKET" test_busybox
16+
[ "$status" -eq 0 ]
17+
testcontainer test_busybox running
18+
19+
local fifo="$PWD/exec-wait.fifo"
20+
mkfifo "$fifo"
21+
22+
# Use __runc, not the run() helper: the exec process blocks before execve
23+
# while holding the inherited stdio, so the run() helper would wait forever
24+
# for those fds to reach EOF. The detached parent still writes the pid file
25+
# and exits.
26+
__runc exec -d --pid-file exec.pid --exec-wait-fifo "$fifo" test_busybox \
27+
sh -c "echo ran > /exec-wait.marker" </dev/null
28+
[ -f exec.pid ]
29+
30+
# The program has not run: the exec process is blocked before execve.
31+
runc exec test_busybox test -e /exec-wait.marker
32+
[ "$status" -ne 0 ]
33+
34+
# Opening the read end unblocks the process, which then runs its program.
35+
timeout 10 cat "$fifo" >/dev/null
36+
37+
retry 10 1 __runc exec test_busybox test -e /exec-wait.marker
38+
}
39+
40+
@test "runc exec [ --exec-wait-fifo ] rejects a path that is not a fifo" {
41+
runc run -d --console-socket "$CONSOLE_SOCKET" test_busybox
42+
[ "$status" -eq 0 ]
43+
testcontainer test_busybox running
44+
45+
local not_fifo="$PWD/not-a-fifo"
46+
: >"$not_fifo"
47+
48+
runc exec --exec-wait-fifo "$not_fifo" test_busybox true
49+
[ "$status" -ne 0 ]
50+
[[ "$output" == *"is not a fifo"* ]]
51+
}
52+
53+
@test "runc exec [ --exec-wait-fifo ] fails for a missing fifo path" {
54+
runc run -d --console-socket "$CONSOLE_SOCKET" test_busybox
55+
[ "$status" -eq 0 ]
56+
testcontainer test_busybox running
57+
58+
runc exec --exec-wait-fifo "$PWD/does-not-exist.fifo" test_busybox true
59+
[ "$status" -ne 0 ]
60+
[[ "$output" == *"open exec wait fifo"* ]]
61+
}

utils_linux.go

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,7 @@ type runner struct {
213213
pidFile string
214214
consoleSocket string
215215
pidfdSocket string
216+
execWaitFifo string
216217
container *libcontainer.Container
217218
action CtAct
218219
notifySocket *notifySocket
@@ -285,6 +286,14 @@ func (r *runner) run(config *specs.Process) (_ int, retErr error) {
285286
defer connClose()
286287
}
287288

289+
if r.execWaitFifo != "" {
290+
fifoClose, err := setupExecWaitFifo(process, r.execWaitFifo)
291+
if err != nil {
292+
return -1, err
293+
}
294+
defer fifoClose()
295+
}
296+
288297
switch r.action {
289298
case CT_ACT_CREATE:
290299
err = r.container.Start(process)
@@ -458,6 +467,31 @@ func setupPidfdSocket(process *libcontainer.Process, sockpath string) (_clean fu
458467
}, nil
459468
}
460469

470+
// setupExecWaitFifo opens the caller-provided FIFO as an O_PATH fd and attaches
471+
// it to the process. The setns init process re-opens it for writing right
472+
// before execve, blocking until an external reader opens the read end. The fd
473+
// is O_PATH so opening it here neither blocks nor counts as a writer.
474+
func setupExecWaitFifo(process *libcontainer.Process, path string) (_clean func(), _ error) {
475+
fifo, err := os.OpenFile(path, unix.O_PATH|unix.O_CLOEXEC, 0)
476+
if err != nil {
477+
return nil, fmt.Errorf("failed to open exec wait fifo: %w", err)
478+
}
479+
fi, err := fifo.Stat()
480+
if err != nil {
481+
fifo.Close()
482+
return nil, err
483+
}
484+
if fi.Mode()&os.ModeNamedPipe == 0 {
485+
fifo.Close()
486+
return nil, fmt.Errorf("exec wait fifo %q is not a fifo", path)
487+
}
488+
489+
process.ExecWaitFifo = fifo
490+
return func() {
491+
fifo.Close()
492+
}, nil
493+
}
494+
461495
func maybeLogCgroupWarning(op string, err error) {
462496
if errors.Is(err, fs.ErrPermission) {
463497
logrus.Warn("runc " + op + " failure might be caused by lack of full access to cgroups")

0 commit comments

Comments
 (0)