Leave a started program a descriptor for itself, not for its base directory - #31
Merged
Merged
Conversation
…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>
This was referenced Oct 1, 2026
Merged
FDOP_CLOSE above position two cites the clause it rests on (openkal 0.14.1)
mcpplibs/openkal-musl#44
Merged
Co-authored-by: speak-agent <248744407+speak-agent@users.noreply.github.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #30
kal_process_spawnduplicated the base directory withF_DUPFD, without close-on-exec, and handed the copy toexecveat. The copy exists so that a#!script or abinfmt_miscinterpreter 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(noO_CLOEXEC) in the child and callexecveat(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 andopenat(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 runsbwrap --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.
$0used 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 ascommalso differs on the 7.2 kernel I used: a script showed asbashinstead of its own name, and/usr/bin/shasbashinstead ofsh. I did not check kernels older than 6.14, where I would expectcommto show the descriptor number instead. This is the tradefexecvein 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
openatfails, its errno goes through the same pipe as anexecveatfailure does, and the child exits 127 as before. A symlink in the name is followed, as it was. The constantso_path(010000000) andat_empty_path(0x1000) are the same on x86_64 and aarch64, and the syscall numbers used are already insys.h.f_dupfdhad no other user and is removed.I checked the following.
mcpp testwith llvm@22.1.8 on x86_64 Linux: all seven suites pass (test result ok. 7 passed; 0 failed). The new case inconformance_additionsstarts a#!/bin/shscript 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:#!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.cppforaarch64-linux-gnuand read the generated system call numbers and constants (openat56,execveat281,0x200000,0x1000). I did not build with gcc 16.1.0, which CI uses. Abinfmt_miscinterpreter was not tried, as none is registered here; it takes the same path in the kernel as a#!interpreter.