Skip to content

Leave a started program a descriptor for itself, not for its base directory - #31

Merged
Sunrisepeak merged 4 commits into
mcpplibs:mainfrom
yspbwx2010:fix/spawn-base-descriptor
Oct 1, 2026
Merged

Sunrisepeak merged 4 commits into
mcpplibs:mainfrom
yspbwx2010:fix/spawn-base-descriptor

Conversation

@yspbwx2010

Copy link
Copy Markdown
Contributor

Fixes #30

kal_process_spawn duplicated the base directory with F_DUPFD, without close-on-exec, and handed the copy to execveat. The copy exists so that a #! script or a binfmt_misc interpreter can open /dev/fd/<n>/<name> after the replacement. It also stays open in the started program and is inherited by whatever that program starts, and the base is often /. Under bubblewrap, the sandboxed command can then reach the host file system through /proc/self/fd/<n>/.

This changes the start to open the program itself with O_PATH (no O_CLOEXEC) in the child and call execveat(fd, "", argv, envp, AT_EMPTY_PATH). The kernel gives an interpreter /dev/fd/<n>, which names the file, so scripts start as before. What remains open in the started program is a descriptor for that one file, not for a directory: /proc/self/fd/<n>/.. fails with ENOTDIR and openat(n, ...) has nothing to resolve under.

That descriptor is still a way to reach the program's file. It can be reopened through /proc/self/fd/<n> for reading, and for writing if the user has write permission on the file, the same as /proc/self/exe. In a test I started a user-owned wrapper script that runs bwrap --ro-bind / / ...; inside the sandbox the script itself was read-only by path, but writing through its descriptor changed the file on the host. So this removes access to the directory tree, not to the one file, and a program that runs commands in a sandbox should still close the descriptors it inherits. I do not see a way to leave an interpreter a name to open without leaving some descriptor behind.

There is a visible change for scripts. $0 used to be /dev/fd/<n>/<name> and is now /dev/fd/<n>, so a shell script that finds its neighbours with . "$(dirname "$0")/lib.sh" worked before and now fails, because the directory is /dev/fd. Python is not affected: sys.path[0] still points at the real directory and a sibling import works. The name the kernel reports as comm also differs on the 7.2 kernel I used: a script showed as bash instead of its own name, and /usr/bin/sh as bash instead of sh. I did not check kernels older than 6.14, where I would expect comm to show the descriptor number instead. This is the trade fexecve in glibc already makes, a descriptor for the file in place of one for the directory. If keeping the directory spelling matters more than the exposure, the alternative is to leave the base descriptor as it was and have callers that sandbox close it, which is what I do now.

If the openat fails, its errno goes through the same pipe as an execveat failure does, and the child exits 127 as before. A symlink in the name is followed, as it was. The constants o_path (010000000) and at_empty_path (0x1000) are the same on x86_64 and aarch64, and the syscall numbers used are already in sys.h. f_dupfd had no other user and is removed.

I checked the following.

mcpp test with llvm@22.1.8 on x86_64 Linux: all seven suites pass (test result ok. 7 passed; 0 failed). The new case in conformance_additions starts a #!/bin/sh script that exits 1 if any descriptor it inherited is the base directory. It fails without the change and passes with it.

The conformance suite from the specification repository, taken from the cached openkal 0.14.0 package: 190 held, 0 did not hold, 3 not observed. I did not run it against the specification repository's current main, which is what CI uses.

Through a C library on openkal (musl target), starting programs with posix_spawn, before and after:

before                                          after
$0 = /dev/fd/6/cases-files/script.sh            $0 = /dev/fd/6
6 -> <the directory the name was found in>      6 -> <the script>
ls: 6 -> /                                      ls: 6 -> /usr/bin/ls

#! scripts (absolute and relative names), /usr/bin/true, and a symlink to an ELF all start. A missing file gives ENOENT, a mode 0644 file EACCES, a mode 0755 file with no #! line ENOEXEC, and a directory EACCES, the same as before the change. Under bubblewrap with --ro-bind / / --tmpfs <dir> --ro-bind <dir> <dir>, the command had a descriptor for / before and could read the hidden file and write into the read-only directory; after, it has none, and it can do neither.

I did not run the aarch64 build; I only compiled process.cpp for aarch64-linux-gnu and read the generated system call numbers and constants (openat 56, execveat 281, 0x200000, 0x1000). I did not build with gcc 16.1.0, which CI uses. A binfmt_misc interpreter was not tried, as none is registered here; it takes the same path in the kernel as a #! interpreter.

yspbwx2010 and others added 3 commits October 1, 2026 14:22
…ectory

kal_process_spawn duplicated the base directory without close-on-exec and
passed the copy to execveat, so that a `#!` script or a binfmt_misc
interpreter could open /dev/fd/<n>/<name> after the replacement. The copy
stays open in the started program and is passed on to whatever it starts. The
base is often `/`, and a command run under bubblewrap could reach the host
file system through /proc/self/fd/<n>/.

Open the program itself with O_PATH, without O_CLOEXEC, and start it with
execveat(fd, "", ..., AT_EMPTY_PATH). The interpreter is then given
/dev/fd/<n>, which names the file, and nothing can be walked out of a
descriptor for a file. A name that cannot be opened is reported through the
same pipe as one that cannot be started, with the same errno.

A script now sees $0 as /dev/fd/<n> and not as a path under its directory,
as with fexecve. The descriptor can still be reopened through
/proc/self/fd/<n>, so a program that sandboxes what it starts should close
what it inherits.

The test starts a `#!` script and checks that it inherits no descriptor for
the base directory. It fails without the change.
…ing else

Builds on the previous commit, which left a started program a descriptor for
its own file instead of one for its base directory. Three further departures
from clause 7.13 of the specification are removed.

The descriptor for the program is opened close-on-exec, and only a program that
needs an interpreter keeps it: the kernel refuses such a program with ENOENT
before the point of no return, and the start is repeated with the flag cleared.
An ordinary executable now starts with nothing above the three streams. The
descriptor is moved above the placed positions, so that a caller without a
standard input does not hand a script its own descriptor as one.

What the caller itself inherited without the close-on-exec flag is no longer
passed on. Everything above the grants is marked in the started image with
close_range, or from /proc/self/fd or up to the descriptor limit on a kernel
older than 5.11.

Grants arrive. Every source is moved above the positions being filled before
any is placed, so one placement no longer overwrites another grant, a stream,
or the base the program's name is resolved under; a grant already at its own
position no longer keeps the flag that closed it. The names travel in
KAL_PREOPENS=<pid>{;<fd>,<len>,<name>}, bound to the started process by its
pid in the way systemd's LISTEN_FDS is, and kal_fs_preopen in the started
program enumerates exactly the grants, in order and by name. A count of zero
leaves none; not asking leaves the working directory and / as before.

tests/conformance_spawn.cpp observes each of these from the started program.
Against 0.15.0 seven of its observations fail.

Co-authored-by: speak-agent <248744407+speak-agent@users.noreply.github.com>
The version is a patch: the changes bring the implementation into line with
contracts already declared, and no declaration changes. The specification is
named by its development line until openkal 0.14.1 is published.

Co-authored-by: speak-agent <248744407+speak-agent@users.noreply.github.com>
Co-authored-by: speak-agent <248744407+speak-agent@users.noreply.github.com>
@Sunrisepeak
Sunrisepeak merged commit 3493ed4 into mcpplibs:main Oct 1, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Programs started by kal_process_spawn inherit a descriptor for the base directory, which a command under bubblewrap can use to reach the host filesystem

2 participants