Let a marked exec enter fakeroot mode - #268
Conversation
3a65295 to
038b0f7
Compare
038b0f7 to
b2aa5b7
Compare
jserv
left a comment
There was a problem hiding this comment.
Review of the ELFUSE_FAKEROOT_EXEC transition, focused on the privilege boundary.
The headline concern is that the elevation decision is a pathname string compare with no file identity behind it, and that it accepts two namespaces at once. Details are inline. Findings that anchor outside the diff are listed here.
src/syscall/syscall.c:2143-2207 (sc_execveat): execve and execveat disagree about which spelling elevates, for the same file. With a dirfd or AT_EMPTY_PATH, sc_execveat resolves through F_GETPATH and hands sys_execve a canonical host path, which makes host_path non-NULL and skips the if (!host_path) block entirely. Only the raw compare runs, and only against a macOS-canonicalized host path. So a guest-path configuration never matches through that form of execveat, and a host-path configuration never matches a path reached through a symlinked component via execve. Whichever identity rule you settle on, both entry points should reach it through one resolution.
src/syscall/exec.c:463: the /proc/self/exe resolution copies out of proc_get_elf_path(), which src/syscall/proc.h:71-77 documents as "safe only for boolean tests; callers that consume the string must use proc_elf_path_snapshot". That copied string now feeds the privilege decision at line 474, so a concurrent proc_set_elf_path from a sibling execve can tear it. The contract violation predates this branch; this branch is what makes it security-relevant. Suggest switching to proc_elf_path_snapshot(path, sizeof(path)) and treating a false return as -ENOENT.
tests/test-matrix.sh:1244: the elfuse-aarch64 baseline stays at 238 while the branch adds a passing test_rc, so the real count is 239. The gate is a minimum and will not fail, but the comment at line 1210 asks for the bump in the same commit that changes behavior.
docs/usage.md:22: the option table documents ELFUSE_NO_ROSETTA next to its flag, but a new privilege-boundary control is documented only in --help. Worth adding ELFUSE_FAKEROOT_EXEC (and the pre-existing, also-missing --fakeroot) with the matching rule and the "elevation is never dropped" caveat spelled out.
Not raised as findings, for the record: the ordering in main.c is right, the env parse at line 433 sits before the --fork-child dispatch at line 458 so fork children do re-arm the path, and posix_spawn passes environ so the variable survives the hop. The new image also gets correct credentials, since exec_republish_shim_globals_or_die republishes after guest_reset.
| * fakeroot: exec it and the new image runs as root; exec anything else and the | ||
| * caller stays unprivileged. | ||
| * | ||
| * The test re-execs itself, so the matrix points ELFUSE_FAKEROOT_EXEC at this |
There was a problem hiding this comment.
The header claims the test covers the contract, but the host-path half of it has never run. The matrix invokes this without --sysroot, and with no sysroot path_translate_at leaves tx.host_path pointing at the input string (src/syscall/path.c:171), so the second check at exec.c:494 is the same compare as line 474 and has never executed as a distinct path.
Also uncovered: execveat, a marked path that is a shebang script, and an unmarked exec from an already-elevated image (the case that shows the gate never clears).
Fakeroot could only be armed before the first guest image ran, via --fakeroot or ELFUSE_FAKEROOT, so nothing a guest did afterwards could raise privilege. A guest shell had no equivalent of sudo: the one command that needs root forced the whole session to run as root. Add ELFUSE_FAKEROOT_EXEC, naming a single executable whose exec crosses into fakeroot. The decision is made on file identity, not on the pathname: execve resolves the configured path the same way it resolves the target, and compares st_dev/st_ino against the fstat of the descriptor it already opened. A name compare would decide on one path and execute another, since the guest and host spellings of a sysroot file differ, translation collapses symlinks and "..", and a writable parent lets the guest swap the leaf after the compare. Identity also makes execve and execveat reach one decision, because both arrive with the image already open. The target is resolved per call rather than cached, so replacing the marked executable takes effect, and because --sysroot is not established when the environment is parsed. Anything unresolvable fails closed. A script is not excluded the way a setuid script is: that rule distrusts the interpreter line of a file the kernel never vetted, while here the embedder named one file out of band. The transition is committed alongside the setuid credential commit, past every failure path, so an exec that never happens leaves the caller unprivileged. It sets root ids and the process-wide gate, mirroring proc_identity_init under --fakeroot, and reaches fork children through the --fakeroot argv forkipc derives from that gate. Nothing clears the gate, so this elevates the process tree from there on, which the docs now say outright. The pair runs inside the ATTN_BIT_CRED bracket the setuid family already uses, so sibling getuid fast paths cannot read pre-exec ids out of the shim cache. A malformed value is rejected at startup rather than ignored, since a privilege boundary that fails quietly is worse than one that refuses to start. With the variable unset, the default, no exec ever elevates. tests/test-fakeroot-exec.c re-execs itself as the marked path and covers the unprivileged start, the elevated exec, root surviving a fork into a fresh host process, another spelling of the same file elevating too, execveat reaching the same verdict, and a different file not elevating. The escape hatch is elfuse-only, so the qemu lane skips it. Fix sysprog21#265
b2aa5b7 to
10767a0
Compare
|
Updated as follow Let a marked exec enter fakeroot modeFakeroot could only be armed before the first guest image ran, via Add ELFUSE_FAKEROOT_EXEC, naming a single executable whose exec The target is resolved per call rather than cached, so replacing the The transition is committed alongside the setuid credential commit, A malformed value is rejected at startup rather than ignored, since tests/test-fakeroot-exec.c re-execs itself as the marked path and |
Fakeroot could only be armed before the first guest image ran, via
--fakeroot or ELFUSE_FAKEROOT, so nothing a guest did afterwards
could raise privilege. A guest shell had no equivalent of sudo: the
one command that needs root forced the whole session to run as root.
Add ELFUSE_FAKEROOT_EXEC, naming a single executable whose exec
crosses into fakeroot. The decision is made on file identity, not on
the pathname: execve resolves the configured path the same way it
resolves the target, and compares st_dev/st_ino against the fstat of
the descriptor it already opened. A name compare would decide on one
path and execute another, since the guest and host spellings of a
sysroot file differ, translation collapses symlinks and "..", and a
writable parent lets the guest swap the leaf after the compare.
Identity also makes execve and execveat reach one decision, because
both arrive with the image already open.
The target is resolved per call rather than cached, so replacing the
marked executable takes effect, and because --sysroot is not
established when the environment is parsed. Anything unresolvable
fails closed. A script is not excluded the way a setuid script is:
that rule distrusts the interpreter line of a file the kernel never
vetted, while here the embedder named one file out of band.
The transition is committed alongside the setuid credential commit,
past every failure path, so an exec that never happens leaves the
caller unprivileged. It sets root ids and the process-wide gate,
mirroring proc_identity_init under --fakeroot, and reaches fork
children through the --fakeroot argv forkipc derives from that gate.
Nothing clears the gate, so this elevates the process tree from
there on, which the docs now say outright. The pair runs inside the
ATTN_BIT_CRED bracket the setuid family already uses, so sibling
getuid fast paths cannot read pre-exec ids out of the shim cache.
A malformed value is rejected at startup rather than ignored, since
a privilege boundary that fails quietly is worse than one that
refuses to start. With the variable unset, the default, no exec ever
elevates.
tests/test-fakeroot-exec.c re-execs itself as the marked path and
covers the unprivileged start, the elevated exec, root surviving a
fork into a fresh host process, another spelling of the same file
elevating too, execveat reaching the same verdict, and a different
file not elevating. The escape hatch is elfuse-only, so the qemu
lane skips it.
Fix #265
Summary by cubic
Let a marked exec opt into fakeroot so a guest can start unprivileged and elevate one command, like sudo. Fixes #265.
ELFUSE_FAKEROOT_EXEC: absolute path of the one executable whose exec enters fakeroot; matched by file identity, not by name (works with guest/host paths, symlinks, andexecveat); unset is a no‑op; malformed values fail at startup./bin/sh.--helpand docs to describeELFUSE_NO_ROSETTA,ELFUSE_FAKEROOT, andELFUSE_FAKEROOT_EXEC. Addedtests/test-fakeroot-exec.ccovering unprivileged start, marked exec reaching root, root surviving a fork, elevation staying in the exec’d process, other spellings/execveatelevating, and a copy not elevating; skipped in the QEMU lane.Written for commit 10767a0. Summary will update on new commits.