Skip to content

Let a marked exec enter fakeroot mode - #268

Merged
jserv merged 1 commit into
sysprog21:mainfrom
open-sources-port:fakeroot-exec
Aug 4, 2026
Merged

Let a marked exec enter fakeroot mode#268
jserv merged 1 commit into
sysprog21:mainfrom
open-sources-port:fakeroot-exec

Conversation

@doanbaotrung

@doanbaotrung doanbaotrung commented Aug 3, 2026

Copy link
Copy Markdown
Collaborator

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.

  • New Features
    • Added 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, and execveat); unset is a no‑op; malformed values fail at startup.
    • Decision happens before shebang resolution, so a marked script elevates on its own identity, not /bin/sh.
    • Transition commits only on successful exec; sets uid/gid 0 and the process‑wide fakeroot gate. Elevation is never dropped (applies to the process tree); the exec’ing parent keeps its ids.
    • Updated --help and docs to describe ELFUSE_NO_ROSETTA, ELFUSE_FAKEROOT, and ELFUSE_FAKEROOT_EXEC. Added tests/test-fakeroot-exec.c covering unprivileged start, marked exec reaching root, root surviving a fork, elevation staying in the exec’d process, other spellings/execveat elevating, and a copy not elevating; skipped in the QEMU lane.

Written for commit 10767a0. Summary will update on new commits.

Review in cubic

@doanbaotrung
doanbaotrung marked this pull request as draft August 3, 2026 16:45
cubic-dev-ai[bot]

This comment was marked as resolved.

@doanbaotrung
doanbaotrung marked this pull request as ready for review August 4, 2026 01:08
cubic-dev-ai[bot]

This comment was marked as resolved.

@jserv jserv left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/syscall/exec.c Outdated
Comment thread src/syscall/exec.c Outdated
Comment thread src/syscall/exec.c Outdated
Comment thread src/syscall/exec.c Outdated
Comment thread src/syscall/exec.c Outdated
Comment thread tests/test-fakeroot-exec.c Outdated
Comment thread tests/test-fakeroot-exec.c Outdated
Comment thread tests/test-fakeroot-exec.c
* 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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Comment thread tests/test-matrix.sh Outdated
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
@doanbaotrung

Copy link
Copy Markdown
Collaborator Author

Updated as follow

Let a marked exec enter fakeroot mode

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.

@jserv
jserv merged commit 69103be into sysprog21:main Aug 4, 2026
10 checks passed
@doanbaotrung
doanbaotrung deleted the fakeroot-exec branch August 4, 2026 13:13
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.

No way for a guest-initiated exec to enter fakeroot

2 participants