Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions docs/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,27 @@ Supported user-facing options:
| `--sysroot PATH` | Resolve guest absolute paths under `PATH`, falling back to the host for paths it does not hold |
| `--create-sysroot PATH` | Provision a case-sensitive APFS sparsebundle mounted at `PATH`, then use it as the sysroot |
| `--no-rosetta` | Disable the x86_64-via-Rosetta translator (also `ELFUSE_NO_ROSETTA=1`) |
| `--fakeroot` | Start the guest as uid/gid 0 with full emulated capabilities (also `ELFUSE_FAKEROOT=1`) |
| `--gdb PORT` | Listen for a GDB RSP client on `PORT` (aarch64 guests only) |
| `--gdb-stop-on-entry` | Stop before the first guest instruction |
| `--` | End `elfuse` option parsing; remaining tokens are guest argv |

`ELFUSE_FAKEROOT_EXEC` has no flag form. It names one executable, by absolute
path, whose `execve` enters fakeroot mode, so a guest can run unprivileged and
raise privilege the way `sudo` does rather than paying for root over the whole
session. Two properties are worth knowing before using it:

- The match is on file identity, not on the pathname. Any spelling that reaches
the marked executable elevates -- guest path or host path under `--sysroot`,
through symlinks, relative or not -- and a spelling that reaches some other
file does not. Replacing the file at that path replaces what elevates.
- Elevation is never dropped. The marked image, everything it `exec`s
afterwards, and everything it forks all stay root. It is a `sudo`-shaped
transition for a process tree, not a per-command one.

Unset, which is the default, no exec ever elevates. A value that is not an
absolute path is rejected at startup rather than ignored.

`--timeout` is a run-loop watchdog. It does not cap total process runtime. It
only bounds a single `hv_vcpu_run()` iteration before the host regains control,
which is what allows host-side timers and signals to be observed promptly.
Expand Down
33 changes: 32 additions & 1 deletion src/main.c
Original file line number Diff line number Diff line change
Expand Up @@ -314,7 +314,17 @@ int main(int argc, char **argv)
" --gdb PORT Listen for GDB Remote Serial "
"Protocol on PORT\n"
" --gdb-stop-on-entry Halt before the first guest "
"instruction\n");
"instruction\n"
"\n"
"Environment:\n"
" ELFUSE_NO_ROSETTA=1 Same as --no-rosetta\n"
" ELFUSE_FAKEROOT=1 Same as --fakeroot\n"
" ELFUSE_FAKEROOT_EXEC Absolute path of the one executable "
"whose exec enters fakeroot mode, letting a guest start "
"unprivileged and raise privilege later; matched by file "
"identity, and never dropped afterwards, so that image and "
"everything it execs or forks stay root (unset: no exec ever "
"elevates)\n");
return 0;
}
}
Expand Down Expand Up @@ -417,6 +427,27 @@ int main(int argc, char **argv)
}
proc_set_fakeroot_enabled(fakeroot);

/* Opt-in sudo-style transition: name one executable whose exec enters
* fakeroot, so a guest can start unprivileged and raise privilege later.
* Nothing happens unless the embedder sets this.
*
* A malformed value is fatal, like every other rejected option in the loop
* above. Starting anyway would leave a guest whose marked command silently
* never elevates, and a privilege boundary that fails quietly is worse than
* one that refuses to start. Arming only records the path; whether it names
* an existing file is decided per exec, so a missing file is not an error
* here.
*/
const char *fakeroot_exec_env = getenv("ELFUSE_FAKEROOT_EXEC");
if (fakeroot_exec_env && *fakeroot_exec_env &&
!proc_set_fakeroot_exec_path(fakeroot_exec_env)) {
log_error(
"ELFUSE_FAKEROOT_EXEC must be an absolute path shorter than %d "
"bytes",
LINUX_PATH_MAX);
return 1;
}

/* Top-level processes establish the capacity; fork helpers normally inherit
* it, but recheck before receiving the parent's FD table. Guest
* RLIMIT_NOFILE state is virtualized separately and cannot lower this
Expand Down
93 changes: 88 additions & 5 deletions src/syscall/exec.c
Original file line number Diff line number Diff line change
Expand Up @@ -334,6 +334,41 @@ static int read_string_array(guest_t *g,
return count;
}

/* True when st -- the fstat of the image execve actually opened -- is the file
* ELFUSE_FAKEROOT_EXEC names.
*
* Matching on (st_dev, st_ino) rather than on the pathname is what makes the
* hatch safe to hand a guest-supplied string. A name compare would decide on
* one path and execute another: the guest spelling and the host spelling of a
* sysroot file differ, translation collapses symlinks and ".." on the way to
* the file, and a writable parent directory lets the guest swap the leaf
* between the compare and the open. Identity answers "is this that file" about
* the descriptor already open, so every spelling that reaches the marked
* executable elevates and nothing else does.
*
* Resolved per call rather than cached at startup so that replacing the marked
* executable takes effect, and because --sysroot is not established yet when
* the environment is parsed. Fails closed on anything unresolvable, including a
* fuse-materialized image, whose private temp copy has an identity of its own.
*/
static bool exec_matches_fakeroot_target(const struct stat *st)
{
const char *marked = proc_fakeroot_exec_path();
if (!marked || !st)
return false;

path_translation_t tx;
if (path_translate_at(LINUX_AT_FDCWD, marked, PATH_TR_NONE, &tx) < 0)
return false;
if (tx.fuse_path)
return false;

struct stat marked_st;
if (stat(tx.host_path, &marked_st) != 0)
return false;
return marked_st.st_dev == st->st_dev && marked_st.st_ino == st->st_ino;
}

static int check_exec_permission(const struct stat *st)
{
uint32_t uid = proc_get_euid();
Expand Down Expand Up @@ -455,12 +490,14 @@ int64_t sys_execve(hv_vcpu_t vcpu,
* /proc filesystem.
*/
if (!strcmp(path, "/proc/self/exe")) {
const char *exe = proc_get_elf_path();
if (!exe) {
/* Snapshot rather than proc_get_elf_path(): that pointer is shared
* mutable state, and a sibling execve republishing it would tear the
* string this copy -- and the fakeroot decision below -- reads.
*/
if (!proc_elf_path_snapshot(path, sizeof(path))) {
err = -LINUX_ENOENT;
goto fail;
}
str_copy_trunc(path, exe, sizeof(path));
log_debug("execve resolved to \"%s\"", path);
}

Expand Down Expand Up @@ -518,6 +555,19 @@ int64_t sys_execve(hv_vcpu_t vcpu,
goto fail;
}

/* Decide the fakeroot transition from the directly-executed file, before
* the shebang loop repoints path_host at an interpreter: a marked wrapper
* script has to elevate on its own identity, not on /bin/sh's.
*
* Unlike the setuid rule below, a script is not excluded. That rule exists
* because any file on the system can carry a setuid bit, so the kernel
* cannot trust the interpreter line of one it never vetted. Here the
* embedder named exactly one file out of band; its shebang line is as much
* the embedder's choice as its ELF contents would be, and a marked dynamic
* binary would trust guest-reachable shared libraries just the same.
*/
bool enter_fakeroot = exec_matches_fakeroot_target(&exec_st);

while (true) {
char interp_start[256];
char interp_arg[256];
Expand Down Expand Up @@ -820,9 +870,42 @@ int64_t sys_execve(hv_vcpu_t vcpu,
*/
/* Commit credentials right before the Point of No Return.
* Saved UID/GID are refreshed from the final effective IDs.
*
* The fakeroot transition lands here, past every failure path, so an exec
* that never happens leaves the current image unprivileged. It mirrors what
* --fakeroot gives a process at startup (proc_identity_init): root IDs plus
* the process-wide gate, and it reaches fork children through the
* --fakeroot argv forkipc already derives from that gate. Nothing clears
* the gate afterwards, so this elevates the whole process tree from here
* on, not just the image being loaded.
*
* elfuse never tears sibling guest threads down at exec, so a sibling that
* outlives this call keeps the credentials committed here for the rest of
* the process lifetime -- a multithreaded guest that execs the marked
* binary from one thread hands root to every thread that was already
* running. The gate is published after the IDs because no permission check
* grants on the gate alone: proc-identity.c pairs it with "emu_euid == 0 ||
* fakeroot", and sys_getgroups and capget require both. Publishing it last
* can therefore only narrow the window, never open one.
*
* The ATTN_BIT_CRED bracket is the same protocol the setuid family uses in
* syscall.c: without it the shim's EL1 identity cache keeps answering
* sibling getuid fast paths with pre-exec IDs until
* exec_republish_shim_globals_or_die runs, well past guest_reset.
*/
proc_set_ids(proc_get_uid(), new_euid, new_euid, proc_get_gid(), new_egid,
new_egid);
uint32_t new_uid = proc_get_uid();
uint32_t new_gid = proc_get_gid();
if (enter_fakeroot) {
new_uid = new_euid = 0;
new_gid = new_egid = 0;
}
shim_globals_attn_or(g, ATTN_BIT_CRED);
proc_set_ids(new_uid, new_euid, new_euid, new_gid, new_egid, new_egid);

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.

This credential commit does not use the CRED_BRACKETED protocol that src/syscall/syscall.c:192-211 established for exactly this situation, so the shim's EL1 identity cache keeps serving pre-exec ids to sibling vCPUs until exec_republish_shim_globals_or_die runs, well past guest_reset. A sibling calling getuid on the inline fast path gets the old value while the SVC path returns 0.

The window skews unprivileged, so nothing is granted early, and the pre-existing setuid commit had the same gap. This branch widens it from euid-only to all six ids. Wrapping the proc_set_ids plus proc_set_fakeroot_enabled pair in the same ATTN_BIT_CRED bracket forces the fast path to the host for the duration.

if (enter_fakeroot)
proc_set_fakeroot_enabled(true);

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 gate is a one-way latch with no clear path anywhere, which makes "raise privilege per command" not quite what the code does.

Once this fires, uid_is_permitted (src/syscall/proc-identity.c:139) returns true unconditionally, so a process that drops privilege with setuid(1000) can take it straight back with setuid(0). Every later execve of an unmarked binary also keeps uid 0, because new_uid = proc_get_uid() is already 0 by then, and every fork inherits through the --fakeroot argv.

That may well be the intended sudo-like shape, since a real sudo child also keeps root for its descendants. The gap is that nothing says so. Decide whether the gate is per-image or per-process-tree, and if it persists, document it in proc.h and stop describing it as per-command.

shim_globals_publish_creds(g, proc_get_uid(), proc_get_euid(),
proc_get_gid(), proc_get_egid());
shim_globals_attn_and(g, ~ATTN_BIT_CRED);

if (0) {
fail:
Expand Down
23 changes: 23 additions & 0 deletions src/syscall/proc-identity.c
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,10 @@
#include <stdlib.h>
#include <errno.h>
#include <pthread.h>
#include <string.h>

#include "syscall/abi.h"
#include "syscall/internal.h"
#include "core/shim-globals.h"
#include "runtime/thread.h"
#include "syscall/proc-identity.h"
Expand Down Expand Up @@ -41,6 +43,27 @@ bool proc_fakeroot_enabled(void)
return atomic_load(&fakeroot_enabled);
}

/* Written once during startup, before any guest code runs, and only read after
* that. No lock: exec reads it from guest threads that all start later.
*/
static char fakeroot_exec_path[LINUX_PATH_MAX];

bool proc_set_fakeroot_exec_path(const char *path)
{
size_t len = path ? strlen(path) : 0;
if (len == 0 || path[0] != '/' || len >= sizeof(fakeroot_exec_path)) {
fakeroot_exec_path[0] = '\0';
return false;
}
memcpy(fakeroot_exec_path, path, len + 1);
return true;
}

const char *proc_fakeroot_exec_path(void)
{
return fakeroot_exec_path[0] ? fakeroot_exec_path : NULL;
}

void proc_identity_init(void)
{
guest_pid = 1;
Expand Down
32 changes: 32 additions & 0 deletions src/syscall/proc.h
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,38 @@ bool proc_rosetta_active(void);
void proc_set_fakeroot_enabled(bool enabled);
bool proc_fakeroot_enabled(void);

/* Opt-in escape hatch letting a guest-initiated exec enter fakeroot mode.
* Without it fakeroot can only be turned on before the first image runs
* (--fakeroot / ELFUSE_FAKEROOT), so a guest shell has no way to raise
* privilege for a single command the way sudo does on Linux.
*
* path names the one executable allowed to make that transition, as an absolute
* path resolved the way the guest resolves paths: under --sysroot the sysroot
* spelling and the host spelling of one file both name that file, because the
* match is on file identity rather than on the string (see
* proc_fakeroot_exec_path). Returns false without arming anything for NULL, an
* empty string, a relative path, or one that does not fit.
*
* Startup only. The stored path is read without a lock by every vCPU thread
* that reaches execve, so this must be called before any of them exists;
* calling it later is a data race.
*/
bool proc_set_fakeroot_exec_path(const char *path);

/* The configured fakeroot exec path, or NULL when none was armed.
*
* The caller resolves and stats it to compare against the file it actually
* opened, so any spelling that reaches that file elevates and a spelling that
* reaches a different file does not. What elevates is therefore an inode, not a
* name: replacing the file at the configured path replaces what elevates, and
* moving the marked executable away disarms the hatch.
*
* The elevation this feeds is not per-command. Nothing ever clears the fakeroot
* gate, so the marked image, everything it execs afterwards, and everything it
* forks all stay root -- the same shape as a sudo child, not a one-shot.
*/
const char *proc_fakeroot_exec_path(void);

/* Store the guest command line for /proc/self/cmdline emulation. argv is a
* NULL-terminated array of strings.
*/
Expand Down
Loading
Loading