diff --git a/docs/usage.md b/docs/usage.md index 35aa6276..abad19c0 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -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. diff --git a/src/main.c b/src/main.c index 842e1846..6c410c1f 100644 --- a/src/main.c +++ b/src/main.c @@ -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; } } @@ -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 diff --git a/src/syscall/exec.c b/src/syscall/exec.c index 27b71dbb..8f834599 100644 --- a/src/syscall/exec.c +++ b/src/syscall/exec.c @@ -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(); @@ -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); } @@ -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]; @@ -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); + if (enter_fakeroot) + proc_set_fakeroot_enabled(true); + 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: diff --git a/src/syscall/proc-identity.c b/src/syscall/proc-identity.c index 68991d39..c2f94339 100644 --- a/src/syscall/proc-identity.c +++ b/src/syscall/proc-identity.c @@ -10,8 +10,10 @@ #include #include #include +#include #include "syscall/abi.h" +#include "syscall/internal.h" #include "core/shim-globals.h" #include "runtime/thread.h" #include "syscall/proc-identity.h" @@ -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; diff --git a/src/syscall/proc.h b/src/syscall/proc.h index 83104fef..6096f98b 100644 --- a/src/syscall/proc.h +++ b/src/syscall/proc.h @@ -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. */ diff --git a/tests/test-fakeroot-exec.c b/tests/test-fakeroot-exec.c new file mode 100644 index 00000000..c38b122f --- /dev/null +++ b/tests/test-fakeroot-exec.c @@ -0,0 +1,298 @@ +/* + * ELFUSE_FAKEROOT_EXEC opt-in privilege transition + * + * Copyright 2026 elfuse contributors + * SPDX-License-Identifier: Apache-2.0 + * + * Fakeroot can otherwise only be armed before the first guest image runs, so a + * guest shell has no way to raise privilege for one command the way sudo does + * on Linux. ELFUSE_FAKEROOT_EXEC names a single executable whose exec enters + * 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 + * binary. It checks the initial image is unprivileged, that the marked exec + * lands at uid/gid 0, that root survives the fork into a fresh host process, + * that another spelling of the same file elevates too (the match is on file + * identity, not on the pathname), that execveat reaches the same decision, and + * that a copy of the same program at a different path does not elevate. + * + * Not covered here: a sysroot lane, where the guest and host spellings of the + * marked file genuinely differ; a marked path that is a shebang script; and + * what a sibling *thread* observes when another thread execs the marked binary, + * which needs a same-process test rather than the fork-based ones below. + */ + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "test-harness.h" + +#ifndef AT_EMPTY_PATH +#define AT_EMPTY_PATH 0x1000 +#endif + +int passes = 0, fails = 0; + +extern char **environ; + +/* Child modes report through the exit status: 0 when the observed credentials + * match what the mode expects, 1 otherwise. The parent turns that into a check. + */ +#define CHILD_OK 0 +#define CHILD_BAD 1 + +static int all_ids_are(uid_t want) +{ + return getuid() == want && geteuid() == want && getgid() == want && + getegid() == want; +} + +/* True when none of the four ids is root. The group ids matter as much as the + * user ids here: the contract for an unmarked exec is that credentials are + * untouched, not merely that uid stayed non-zero. + */ +static int no_id_is_root(void) +{ + return getuid() != 0 && geteuid() != 0 && getgid() != 0 && getegid() != 0; +} + +static void report_ids(const char *what) +{ + fprintf(stderr, "%s: uid=%d euid=%d gid=%d egid=%d\n", what, (int) getuid(), + (int) geteuid(), (int) getgid(), (int) getegid()); +} + +/* Exec'd as the ELFUSE_FAKEROOT_EXEC path: must be root, and a fork from here + * must land in a host process that is still root. + */ +static int mode_expect_root(void) +{ + if (!all_ids_are(0)) { + report_ids("child: expected root"); + return CHILD_BAD; + } + + pid_t pid = fork(); + if (pid < 0) { + perror("child: fork"); + return CHILD_BAD; + } + if (pid == 0) + _exit(all_ids_are(0) ? CHILD_OK : CHILD_BAD); + + int status = 0; + if (waitpid(pid, &status, 0) < 0) { + perror("child: waitpid"); + return CHILD_BAD; + } + if (!WIFEXITED(status) || WEXITSTATUS(status) != CHILD_OK) { + fprintf(stderr, "child: forked grandchild lost root (status=%d)\n", + status); + return CHILD_BAD; + } + return CHILD_OK; +} + +/* Exec'd under any other path: credentials must be untouched. */ +static int mode_expect_unprivileged(void) +{ + if (!no_id_is_root()) { + report_ids("child: unexpected root"); + return CHILD_BAD; + } + return CHILD_OK; +} + +/* Exit status a child uses when the exec itself never happened, so a staging + * mistake cannot be misread as a verdict about credentials. + */ +#define CHILD_NO_EXEC 127 + +/* Run prog with a single mode argument and return its exit status, or -1. + * use_execveat picks the execveat(AT_EMPTY_PATH) entry point over execve, which + * hands sys_execve a host path it resolved itself rather than the guest string. + */ +static int run_child_via(const char *prog, const char *mode, int use_execveat) +{ + char *argv[] = {(char *) prog, (char *) mode, NULL}; + + pid_t pid = fork(); + if (pid < 0) { + perror("fork"); + return -1; + } + if (pid == 0) { + if (use_execveat) { + int fd = open(prog, O_RDONLY | O_CLOEXEC); + if (fd < 0) { + perror("child: open for execveat"); + _exit(CHILD_NO_EXEC); + } + syscall(SYS_execveat, fd, "", argv, environ, AT_EMPTY_PATH); + perror("child: execveat"); + } else { + execve(prog, argv, environ); + perror("child: execve"); + } + _exit(CHILD_NO_EXEC); + } + + int status = 0; + if (waitpid(pid, &status, 0) < 0) { + perror("waitpid"); + return -1; + } + if (!WIFEXITED(status)) { + fprintf(stderr, "child did not exit normally (status=%d)\n", status); + return -1; + } + if (WEXITSTATUS(status) == CHILD_NO_EXEC) + fprintf(stderr, "child never exec'd %s\n", prog); + return WEXITSTATUS(status); +} + +static int run_child(const char *prog, const char *mode) +{ + return run_child_via(prog, mode, 0); +} + +/* Copy the program to a private path so the negative case is a different file + * rather than a spelling trick on the configured one. + */ +static int copy_program(const char *src, char *dst, size_t dst_sz) +{ + /* mkstemp, not a pid-derived name: it creates the file exclusively, so a + * pre-created path (or a symlink planted at one) cannot redirect the copy. + */ + snprintf(dst, dst_sz, "/tmp/elfuse-fakeroot-exec-copy-XXXXXX"); + + int in = open(src, O_RDONLY); + if (in < 0) { + perror("copy: open src"); + return -1; + } + int out = mkstemp(dst); + if (out < 0) { + perror("copy: mkstemp"); + close(in); + return -1; + } + /* mkstemp creates 0600. The copy needs the other-execute bit: its host + * owner is the real macOS uid, which never matches the emulated guest uid, + * so check_exec_permission only ever consults the other bits. + */ + int rc = 0; + if (fchmod(out, 0755) < 0) { + perror("copy: fchmod"); + rc = -1; + } + + char buf[65536]; + ssize_t n = 0; + while (rc == 0 && (n = read(in, buf, sizeof(buf))) > 0) { + ssize_t off = 0; + while (off < n) { + ssize_t w = write(out, buf + off, (size_t) (n - off)); + if (w < 0) { + perror("copy: write"); + rc = -1; + break; + } + off += w; + } + } + if (rc == 0 && n < 0) { + perror("copy: read"); + rc = -1; + } + close(in); + /* Close before exec: a still-open writable fd makes execve fail ETXTBSY. */ + if (close(out) < 0 && rc == 0) { + perror("copy: close dst"); + rc = -1; + } + /* One drop site for the staged file: mkstemp already created it, so every + * failure from here on has to leave nothing behind in the shared /tmp. + */ + if (rc < 0) + unlink(dst); + return rc; +} + +int main(int argc, char **argv) +{ + if (argc > 1 && !strcmp(argv[1], "expect-root")) + return mode_expect_root(); + if (argc > 1 && !strcmp(argv[1], "expect-unprivileged")) + return mode_expect_unprivileged(); + + const char *marked = getenv("ELFUSE_FAKEROOT_EXEC"); + if (!marked || !*marked) { + /* A hard failure, not a skip: the matrix always exports this, so an + * unset variable means the harness stopped arming the feature and the + * whole file would otherwise report a silent pass. + */ + printf("test-fakeroot-exec: ELFUSE_FAKEROOT_EXEC is not set\n"); + return 1; + } + + printf("ELFUSE_FAKEROOT_EXEC tests (marked=%s)\n", marked); + + TEST("initial image unprivileged"); + EXPECT_TRUE(no_id_is_root(), "started as root"); + + TEST("exec of marked path is root"); + EXPECT_EQ(run_child(marked, "expect-root"), CHILD_OK, + "marked exec did not reach root"); + + /* Cross-process only: run_child forks, and a guest fork is a separate host + * process with its own identity globals, so this pins that elevation does + * not leak back across that boundary. What a surviving sibling *thread* + * sees is a different question and needs a same-process test. + */ + TEST("elevation stays in the exec'd process"); + EXPECT_TRUE(no_id_is_root(), "parent gained root"); + + /* Identity, not spelling: "/./" opens the very same file, so it + * must elevate too. The inverse -- a different file -- is the copy below. + */ + char spelled[PATH_MAX]; + const char *base = strrchr(marked, '/'); + /* Always non-NULL: elfuse rejects a non-absolute ELFUSE_FAKEROOT_EXEC. */ + int spelled_len = snprintf(spelled, sizeof(spelled), "%.*s/.%s", + (int) (base - marked), marked, base); + TEST("other spelling of marked file is root"); + if (spelled_len < 0 || (size_t) spelled_len >= sizeof(spelled)) + FAIL("respelled path did not fit"); + else + EXPECT_EQ(run_child(spelled, "expect-root"), CHILD_OK, + "same file under another name did not reach root"); + + TEST("execveat of marked file is root"); + EXPECT_EQ(run_child_via(marked, "expect-root", 1), CHILD_OK, + "execveat did not reach root"); + + char copy[PATH_MAX]; + if (copy_program(marked, copy, sizeof(copy)) == 0) { + TEST("exec of another file stays unprivileged"); + EXPECT_EQ(run_child(copy, "expect-unprivileged"), CHILD_OK, + "unmarked exec gained root or would not run"); + unlink(copy); + } else { + TEST("exec of another file stays unprivileged"); + FAIL("could not stage program copy"); + } + + SUMMARY("test-fakeroot-exec"); + return fails == 0 ? 0 : 1; +} diff --git a/tests/test-matrix.sh b/tests/test-matrix.sh index 8565766f..7fc9c10d 100755 --- a/tests/test-matrix.sh +++ b/tests/test-matrix.sh @@ -276,6 +276,7 @@ QEMU_SKIP=" test-msync test-credentials test-credentials-fakeroot + test-fakeroot-exec test-sched-policy test-rseq test-tier-a @@ -327,6 +328,9 @@ QEMU_SKIP=" # test-credentials: pins elfuse's restricted "fakeroot" setuid/capset/ # getgroups model (deliberately narrower than real root); the qemu # reference lane runs as genuine root, which has unrestricted privilege. +# test-fakeroot-exec: ELFUSE_FAKEROOT_EXEC is an elfuse-only escape hatch -- +# a real kernel has no notion of an executable that turns on fakeroot, so +# the marked exec simply stays unprivileged there. # test-sched-policy: exercises elfuse's explicitly-a-stub scheduler policy # layer (see the file's own header) -- RT class changes are always # -EPERM'd regardless of privilege, whereas real root can set them. @@ -765,6 +769,12 @@ run_unit_tests() printf "\nCredential/identity emulation\n" test_rc "$runner" "test-credentials" 0 "$bindir/test-credentials" test_rc "$runner" "test-credentials-fakeroot" 0 --fakeroot "$bindir/test-credentials" + # Arm the opt-in transition on the test binary itself: it re-execs its own + # path to cross into fakeroot, and a copy of itself to prove the negative. + # The assignment prefix scopes the variable to this one call, so an + # interrupted run cannot leave it set for anything that follows. + ELFUSE_FAKEROOT_EXEC="$bindir/test-fakeroot-exec" \ + test_rc "$runner" "test-fakeroot-exec" 0 "$bindir/test-fakeroot-exec" printf "\nScheduler policy stub\n" test_rc "$runner" "test-sched-policy" 0 "$bindir/test-sched-policy" @@ -1232,7 +1242,7 @@ run_suite() # observed counts diverge. apple-unknown is the fallback row for SoC strings the # detector does not recognize yet. EXPECTED_BASELINES=( - "elfuse-aarch64|238|0" + "elfuse-aarch64|239|0" "qemu-aarch64|218|0" "elfuse-x86_64:apple-m1-m2|71|0" "elfuse-x86_64:apple-m3-plus|71|0"