Skip to content

Commit 9f3ef9e

Browse files
powderluvclaude
andauthored
fix(spurd): drop privilege inside unshare wrapper, not before exec (#128) (#130)
When spurd ran as root and a non-root user submitted a job, two is_root()-guarded branches in `launch_job` interacted incorrectly: 1. `use_namespaces` built `unshare --pid --mount --fork bash wrapper.sh` 2. `cmd.uid(uid)` + `cmd.gid(gid)` were applied via `pre_exec` Because pre_exec runs between fork and exec, the child dropped to the unprivileged uid *before* exec'ing unshare. The unshare(2) syscall then failed with EPERM (CAP_SYS_ADMIN required for CLONE_NEWNS|CLONE_NEWPID), producing the user-visible: unshare: unshare failed: Operation not permitted A second, hidden bug on the same path: even if unshare had succeeded, the wrapper's `mount -t proc proc /proc` and friends would silently no-op (`2>/dev/null || true`) for the unprivileged user, so namespace isolation has been ineffective whenever uid > 0. Fix: - Move the privilege drop *inside* the wrapper script, after unshare creates the namespaces and after the mounts run, using `setpriv --reuid=$U --regid=$G --init-groups -- /bin/bash $SCRIPT`. - Skip the cmd.uid()/cmd.gid() pre_exec hooks when use_namespaces is true, since the wrapper now handles the drop. - `setpriv --init-groups` calls initgroups() based on --reuid, so video/render supplementary groups (needed for /dev/dri and /dev/kfd) are still set for the user payload. Refactor the wrapper construction into `build_namespace_wrapper`, a pure function that returns the bash script. Adds three unit tests: - uid > 0 → wrapper contains setpriv with --reuid/--regid/--init-groups, setpriv runs after the proc mount, and there is no bare `exec /bin/bash` slip-through. - uid == 0 → wrapper exec's bash directly, no setpriv. - gpu_devices → wrapper emits renderD copy lines only for allocated device IDs. setpriv ships with util-linux on every Linux distro, so no new runtime dependency. Closes #128 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude <noreply@anthropic.com>
1 parent ffbcf0e commit 9f3ef9e

1 file changed

Lines changed: 122 additions & 30 deletions

File tree

crates/spurd/src/executor.rs

Lines changed: 122 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -275,35 +275,7 @@ pub async fn launch_job(
275275
let use_namespaces = nix::unistd::geteuid().is_root();
276276
let (launch_cmd, launch_args) = if use_namespaces {
277277
let wrapper_path = PathBuf::from(work_dir).join(format!(".spur_ns_{}.sh", job_id));
278-
let gpu_mounts = gpu_devices
279-
.iter()
280-
.map(|id| {
281-
format!(
282-
" if [ -e $SPUR_HOST_DRI/renderD{r} ]; then\n cp -a $SPUR_HOST_DRI/renderD{r} /dev/dri/renderD{r} 2>/dev/null || true\n fi\n",
283-
r = 128 + id,
284-
)
285-
})
286-
.collect::<Vec<_>>()
287-
.join("");
288-
289-
let wrapper = format!(
290-
concat!(
291-
"#!/bin/bash\n",
292-
"# Namespace isolation wrapper — all mounts best-effort\n",
293-
"mount -t proc proc /proc 2>/dev/null || true\n",
294-
"mount -t tmpfs tmpfs /dev/shm 2>/dev/null || true\n",
295-
"# GPU device restriction: save original /dev/dri, replace with\n",
296-
"# tmpfs, then selectively copy only allocated devices back.\n",
297-
"SPUR_HOST_DRI=$(mktemp -d /tmp/.spur_dri_XXXXXX 2>/dev/null || echo /tmp/.spur_dri)\n",
298-
"if [ -d /dev/dri ] && cp -a /dev/dri/. $SPUR_HOST_DRI/ 2>/dev/null; then\n",
299-
" mount -t tmpfs tmpfs /dev/dri 2>/dev/null || true\n",
300-
"{gpu_mounts}",
301-
"fi\n",
302-
"exec /bin/bash {script}\n",
303-
),
304-
gpu_mounts = gpu_mounts,
305-
script = script_path.display(),
306-
);
278+
let wrapper = build_namespace_wrapper(uid, gid, gpu_devices, &script_path);
307279
tokio::fs::write(&wrapper_path, &wrapper).await?;
308280
#[cfg(unix)]
309281
{
@@ -340,7 +312,12 @@ pub async fn launch_job(
340312
// Issue #99, #107: Run job as the submitting user (not root).
341313
// Must set supplementary groups (video, render) via initgroups()
342314
// so the process can access GPU device nodes.
343-
if uid > 0 && nix::unistd::geteuid().is_root() {
315+
//
316+
// Issue #128: when use_namespaces is true, the wrapper handles the priv
317+
// drop *after* unshare runs (via setpriv). Dropping priv here would cause
318+
// unshare(2) to fail with EPERM since the unprivileged user lacks
319+
// CAP_SYS_ADMIN.
320+
if uid > 0 && nix::unistd::geteuid().is_root() && !use_namespaces {
344321
use std::os::unix::process::CommandExt;
345322
let target_uid = uid;
346323
let target_gid = gid;
@@ -604,6 +581,61 @@ fn resolve_output_path(pattern: &str, job_id: JobId, work_dir: &str) -> String {
604581
/// The `bb` string contains semicolon-separated directives:
605582
/// - `stage_in:<cmd>` — run before the job
606583
/// - `stage_out:<cmd>` — run after the job (best-effort, ignores failures)
584+
/// Build the bash wrapper that runs inside the unshare PID/mount namespace.
585+
///
586+
/// The wrapper executes as root (the same uid as spurd), so it can perform
587+
/// the proc/tmpfs/dri mounts that need CAP_SYS_ADMIN. Once isolation is in
588+
/// place, it drops privilege via `setpriv --init-groups` and exec's the user
589+
/// script.
590+
///
591+
/// Issue #128: previously the priv drop happened in `Command::pre_exec` before
592+
/// exec'ing unshare, which made the unshare(2) syscall fail with EPERM and
593+
/// the mounts silently no-op. Doing the drop inside the wrapper (after the
594+
/// mounts) keeps the unshare and mounts privileged while still landing the
595+
/// user payload as the unprivileged uid.
596+
fn build_namespace_wrapper(uid: u32, gid: u32, gpu_devices: &[u32], script_path: &Path) -> String {
597+
let gpu_mounts = gpu_devices
598+
.iter()
599+
.map(|id| {
600+
format!(
601+
" if [ -e $SPUR_HOST_DRI/renderD{r} ]; then\n cp -a $SPUR_HOST_DRI/renderD{r} /dev/dri/renderD{r} 2>/dev/null || true\n fi\n",
602+
r = 128 + id,
603+
)
604+
})
605+
.collect::<Vec<_>>()
606+
.join("");
607+
608+
let final_exec = if uid > 0 {
609+
format!(
610+
"exec setpriv --reuid={uid} --regid={gid} --init-groups -- /bin/bash {script}\n",
611+
uid = uid,
612+
gid = gid,
613+
script = script_path.display(),
614+
)
615+
} else {
616+
format!("exec /bin/bash {}\n", script_path.display())
617+
};
618+
619+
format!(
620+
concat!(
621+
"#!/bin/bash\n",
622+
"# Namespace isolation wrapper — all mounts best-effort\n",
623+
"mount -t proc proc /proc 2>/dev/null || true\n",
624+
"mount -t tmpfs tmpfs /dev/shm 2>/dev/null || true\n",
625+
"# GPU device restriction: save original /dev/dri, replace with\n",
626+
"# tmpfs, then selectively copy only allocated devices back.\n",
627+
"SPUR_HOST_DRI=$(mktemp -d /tmp/.spur_dri_XXXXXX 2>/dev/null || echo /tmp/.spur_dri)\n",
628+
"if [ -d /dev/dri ] && cp -a /dev/dri/. $SPUR_HOST_DRI/ 2>/dev/null; then\n",
629+
" mount -t tmpfs tmpfs /dev/dri 2>/dev/null || true\n",
630+
"{gpu_mounts}",
631+
"fi\n",
632+
"{final_exec}",
633+
),
634+
gpu_mounts = gpu_mounts,
635+
final_exec = final_exec,
636+
)
637+
}
638+
607639
fn wrap_with_burst_buffer(script: &str, bb: &str) -> String {
608640
let mut stage_in = Vec::new();
609641
let mut stage_out = Vec::new();
@@ -705,4 +737,64 @@ mod tests {
705737
let wrapped = wrap_with_burst_buffer(script, "");
706738
assert_eq!(wrapped, script);
707739
}
740+
741+
/// Issue #128: when uid > 0, the wrapper must drop privilege via setpriv
742+
/// *after* the mounts (which need CAP_SYS_ADMIN). Dropping priv before
743+
/// unshare would cause unshare(2) to fail with EPERM.
744+
#[test]
745+
fn test_namespace_wrapper_drops_priv_via_setpriv() {
746+
let script = PathBuf::from("/work/.spur_job_42.sh");
747+
let wrapper = build_namespace_wrapper(1000, 1000, &[], &script);
748+
749+
// setpriv must appear with both --reuid and --regid plus --init-groups
750+
// (so video/render supplementary groups are picked up for GPU access).
751+
assert!(
752+
wrapper.contains("setpriv --reuid=1000 --regid=1000 --init-groups"),
753+
"wrapper missing setpriv invocation: {wrapper}"
754+
);
755+
// The setpriv exec must be the *last* exec, after the mount commands.
756+
let mount_pos = wrapper.find("mount -t proc").expect("missing proc mount");
757+
let setpriv_pos = wrapper.find("setpriv").expect("missing setpriv");
758+
assert!(
759+
mount_pos < setpriv_pos,
760+
"mounts must run before priv drop:\n{wrapper}"
761+
);
762+
// No bare `exec /bin/bash` slip-through that would run as root.
763+
assert!(
764+
!wrapper.contains("exec /bin/bash /work"),
765+
"uid>0 wrapper must not exec bash directly as root:\n{wrapper}"
766+
);
767+
}
768+
769+
/// When uid == 0 (root job), no priv drop is needed and the wrapper exec's
770+
/// bash directly.
771+
#[test]
772+
fn test_namespace_wrapper_root_no_setpriv() {
773+
let script = PathBuf::from("/work/.spur_job_7.sh");
774+
let wrapper = build_namespace_wrapper(0, 0, &[], &script);
775+
776+
assert!(
777+
!wrapper.contains("setpriv"),
778+
"root job should not invoke setpriv:\n{wrapper}"
779+
);
780+
assert!(
781+
wrapper.contains("exec /bin/bash /work/.spur_job_7.sh"),
782+
"root wrapper should exec the job script directly:\n{wrapper}"
783+
);
784+
}
785+
786+
/// GPU device restriction lines are emitted for each allocated device.
787+
#[test]
788+
fn test_namespace_wrapper_gpu_mounts() {
789+
let script = PathBuf::from("/work/.spur_job_1.sh");
790+
let wrapper = build_namespace_wrapper(1000, 1000, &[0, 2], &script);
791+
792+
// Only allocated GPUs (renderD128 for id 0, renderD130 for id 2) get
793+
// copied back into the tmpfs-masked /dev/dri.
794+
assert!(wrapper.contains("renderD128"));
795+
assert!(wrapper.contains("renderD130"));
796+
// Must not include unallocated devices.
797+
assert!(!wrapper.contains("renderD129"));
798+
assert!(!wrapper.contains("renderD131"));
799+
}
708800
}

0 commit comments

Comments
 (0)