From 07e0f7d8b14bb2b4f38b7c8a21d14a0d6d2ca1dc Mon Sep 17 00:00:00 2001 From: Joshua Covington Date: Sat, 1 Aug 2026 08:50:17 +0000 Subject: [PATCH 01/14] jail: fix /proc,/sys mounting under CLONE_NEWUSER Mounting a fresh /proc or /sys from a jail's own CLONE_NEWUSER fails with EPERM ("VFS: Mount too revealing") whenever the host's reference /proc has locked-down content anywhere under it. The kernel's mount- visibility check (fs_fully_visible()/mount_too_revealing()) is keyed on the new mount's super_block->s_user_ns (the mount namespace's owning user namespace, set from current_user_ns() at mount() time), not the PID namespace. Since the jail's own CLONE_NEWUSER is created together with the PID namespace before /proc,/sys are mounted, the mount namespace ends up owned by that new, non-init user namespace and gets rejected. A related mismatch: /proc is mounted with a hardcoded MS_NOATIME while a host running relatime or strictatime fails the same visibility check's exact atime-class requirement. Defer creation of the jail's own CLONE_NEWUSER until after build_jail_fs()/pivot_root have mounted /proc and /sys, using a handshake between parent and child (enter_userns(), userns_pipe[4]). This keeps the mount namespace owned by the initial user namespace throughout mount setup, which the kernel's visibility check unconditionally exempts. Detect the host's actual atime class from /proc/self/mountinfo and match it instead of hardcoding one: strictatime is represented by the absence of both noatime and relatime in mountinfo, not by a positive token, so that case is treated as MS_STRICTATIME. A mount namespace's owning user namespace is fixed at creation and never changes afterward, so deferring CLONE_NEWUSER this way means container root permanently loses the ability to call mount() at runtime, even after entering the container's own user namespace: the mount namespace was already created while still in the initial one. Unshare a second, private mount namespace immediately after entering the container's own user namespace (guarded on the jail actually having a mount namespace of its own), and again after joining an external user namespace via -j. The new mount namespace is owned by the container's own user namespace, so mount(2)/umount(2) work again for tmpfs, devpts, mqueue, cgroup2, and overlay, matching what mainstream OCI runtimes provide. The mounts already established in the jail (proc/sys, masks, read-only binds) are copied into this new namespace locked, since they originate from a more privileged namespace, so they stay immune to detachment from inside. A nested runtime mounting its own further-nested procfs instance still fails the kernel's mount-visibility check for reasons independent of that locking, unresolved here. The userns_pipe handshake retries read()/write() on EINTR and uses pipe2(O_CLOEXEC), so a signal such as SIGTERM arriving mid-handshake does not abort the jail without running poststop hooks or cleaning up the network namespace. Signed-off-by: Joshua Covington --- jail/fs.c | 80 +++++++++++++++++++++ jail/fs.h | 2 + jail/jail.c | 200 +++++++++++++++++++++++++++++++++++++++++++++------- 3 files changed, 255 insertions(+), 27 deletions(-) diff --git a/jail/fs.c b/jail/fs.c index d0ca2b6..f843c54 100644 --- a/jail/fs.c +++ b/jail/fs.c @@ -41,6 +41,63 @@ #define UJAIL_NOAFILE "/tmp/.ujailnoafile" +/* + * mnt_already_visible() requires a new mount's atime class to match + * the host's existing instance or the kernel refuses it with EPERM + * ("Mount too revealing"). Detect the mountpoint's actual atime class + * instead of hardcoding one, so the new mount can never conflict. + */ +unsigned long detect_atime_flag(const char *mountpoint) +{ + FILE *f; + char *line = NULL; + size_t linelen = 0; + unsigned long ret = MS_RELATIME; /* kernel default if nothing else is known */ + size_t mplen = strlen(mountpoint); + + f = fopen("/proc/self/mountinfo", "r"); + if (!f) + return ret; + + while (getline(&line, &linelen, f) != -1) { + /* mountinfo(5): field 5 is the mountpoint, field 6 its options */ + char *saveptr = NULL; + char *field; + int idx = 0; + char *mp_field = NULL, *opts_field = NULL; + + for (field = strtok_r(line, " \t\n", &saveptr); field; + field = strtok_r(NULL, " \t\n", &saveptr), idx++) { + if (idx == 4) + mp_field = field; + else if (idx == 5) { + opts_field = field; + break; + } + } + + if (!mp_field || !opts_field) + continue; + + if (strlen(mp_field) != mplen || strcmp(mp_field, mountpoint)) + continue; + + /* last matching entry wins: it's the topmost/currently-effective one */ + if (strstr(opts_field, "noatime")) + ret = MS_NOATIME; + else if (strstr(opts_field, "relatime")) + ret = MS_RELATIME; + else + /* strictatime is the absence of noatime/relatime, not a token */ + ret = MS_STRICTATIME; + } + + free(line); + fclose(f); + + return ret; +} + struct mount { struct avl_node avl; const char *source; @@ -55,6 +112,29 @@ struct mount { struct avl_tree mounts; +/* same masking as do_mount()'s is_mask branch, applied immediately + * against an absolute path instead of queued through jail_root */ +int mask_path_now(const char *path) +{ + struct stat s; + + if (stat(path, &s)) + return 0; /* doesn't exist, nothing to mask */ + + if (S_ISDIR(s.st_mode)) { + if (mount("none", path, "tmpfs", MS_RDONLY | MS_NOSUID | MS_NOEXEC | MS_NODEV | MS_RELATIME, "size=0,mode=000")) + return -1; + } else { + if (mount(UJAIL_NOAFILE, path, "bind", MS_BIND, NULL)) + return -1; + if (mount(UJAIL_NOAFILE, path, "bind", MS_REMOUNT | MS_BIND | MS_RDONLY | MS_NOSUID | MS_NOEXEC | MS_NODEV | MS_RELATIME, NULL)) + return -1; + } + + DEBUG("masked path %s\n", path); + return 0; +} + static int do_mount(const char *root, const char *orig_source, const char *target, const char *filesystemtype, unsigned long orig_mountflags, unsigned long propflags, const char *optstr, int error, bool inner) { diff --git a/jail/fs.h b/jail/fs.h index 541030f..73804fc 100644 --- a/jail/fs.h +++ b/jail/fs.h @@ -21,8 +21,10 @@ int add_mount(const char *source, const char *target, const char *filesystemtype int add_mount_inner(const char *source, const char *target, const char *filesystemtype, unsigned long mountflags, unsigned long propflags, const char *optstr, int error); int add_mount_bind(const char *path, int readonly, int error); +int mask_path_now(const char *path); int parseOCImount(struct blob_attr *msg); int add_2paths_and_deps(const char *path, const char *path2, int readonly, int error, int lib); +unsigned long detect_atime_flag(const char *mountpoint); static inline int add_path_and_deps(const char *path, int readonly, int error, int lib) { diff --git a/jail/jail.c b/jail/jail.c index eaaf017..b811af9 100644 --- a/jail/jail.c +++ b/jail/jail.c @@ -679,6 +679,32 @@ static int create_devices(void) } static char jail_root[] = "/tmp/ujail-XXXXXX"; +/* Handshake for the deferred CLONE_NEWUSER creation in enter_userns(). */ +int userns_pipe[4]; + +/* single-byte read()/write(), retrying on EINTR */ +static ssize_t xread_byte(int fd, char *buf) +{ + ssize_t n; + + do { + n = read(fd, buf, 1); + } while (n < 0 && errno == EINTR); + + return n; +} + +static ssize_t xwrite_byte(int fd, char byte) +{ + ssize_t n; + + do { + n = write(fd, &byte, 1); + } while (n < 0 && errno == EINTR); + + return n; +} + static char tmpovdir[] = "/tmp/ujail-overlay-XXXXXX"; static mode_t old_umask; static void enter_jail_fs(void); @@ -794,6 +820,7 @@ static void free_and_exit(int ret) } static void post_jail_fs(void); +static void enter_userns(void); static void enter_jail_fs(void) { char dirbuf[sizeof(jail_root) + 4]; @@ -833,6 +860,63 @@ static void enter_jail_fs(void) mount(NULL, "/", "bind", MS_REMOUNT | MS_BIND | MS_RDONLY, 0); umask(old_umask); + enter_userns(); +} + +/* + * Create our own CLONE_NEWUSER here, after /proc and /sys are already + * mounted, so the PID namespace stays owned by the initial userns + * throughout mount setup. See the comment in exec_jail() for why. + */ +static void enter_userns(void) +{ + char buf[1]; + + if (!((opts.namespace & CLONE_NEWUSER) && opts.setns.user == -1)) { + post_jail_fs(); + return; + } + + if (unshare(CLONE_NEWUSER)) { + ERROR("unshare(CLONE_NEWUSER) failed: %m\n"); + free_and_exit(-1); + } + + buf[0] = 'i'; + if (xwrite_byte(userns_pipe[1], buf[0]) < 1) { + ERROR("can't write to parent\n"); + free_and_exit(-1); + } + close(userns_pipe[1]); + + if (xread_byte(userns_pipe[2], buf) < 1) { + ERROR("can't read from parent\n"); + free_and_exit(-1); + } + close(userns_pipe[2]); + if (buf[0] != 'O') { + ERROR("parent had an error, child exiting\n"); + free_and_exit(-1); + } + + if ((opts.namespace & CLONE_NEWNS) && unshare(CLONE_NEWNS)) { + ERROR("unshare(CLONE_NEWNS) failed: %m\n"); + free_and_exit(-1); + } + + if (setregid(0, 0) < 0) { + ERROR("setgid\n"); + free_and_exit(-1); + } + if (setreuid(0, 0) < 0) { + ERROR("setuid\n"); + free_and_exit(-1); + } + if (setgroups(0, NULL) < 0) { + ERROR("setgroups\n"); + free_and_exit(-1); + } + post_jail_fs(); } @@ -1183,6 +1267,19 @@ static int exec_jail(void *arg) close(pipes[0]); close(pipes[3]); + if ((opts.namespace & CLONE_NEWUSER) && opts.setns.user == -1) { + /* CLONE_NEWUSER is deferred to enter_userns(); keep our + * ends of the handshake open, close only the parent's. */ + close(userns_pipe[0]); + close(userns_pipe[3]); + } else { + /* Not deferring anything: this handshake isn't used at all. */ + close(userns_pipe[0]); + close(userns_pipe[1]); + close(userns_pipe[2]); + close(userns_pipe[3]); + } + setns_open(CLONE_NEWUSER); setns_open(CLONE_NEWNET); setns_open(CLONE_NEWNS); @@ -1204,12 +1301,24 @@ static int exec_jail(void *arg) return EXIT_FAILURE; } + if (opts.setns.user != -1 && (opts.namespace & CLONE_NEWNS) && + unshare(CLONE_NEWNS)) { + ERROR("unshare(CLONE_NEWNS) failed: %m\n"); + return EXIT_FAILURE; + } + if (opts.namespace & CLONE_NEWCGROUP) unshare(CLONE_NEWCGROUP); setns_open(CLONE_NEWCGROUP); - if ((opts.namespace & CLONE_NEWUSER) || (opts.setns.user != -1)) { + /* + * A join of an existing userns (opts.setns.user) can become root + * right away. Our own CLONE_NEWUSER is not created here: doing so + * before /proc,/sys are mounted ties the PID namespace to it, + * which fails mnt_already_visible() on hosts with locked /proc. + */ + if (opts.setns.user != -1) { if (setregid(0, 0) < 0) { ERROR("setgid\n"); free_and_exit(EXIT_FAILURE); @@ -1242,8 +1351,12 @@ static void pre_exec_jail(struct uloop_timeout *t) if ((opts.namespace & CLONE_NEWNS) && build_jail_fs()) { ERROR("failed to build jail fs\n"); free_and_exit(EXIT_FAILURE); - } else { - run_hooks(opts.hooks.createContainer, post_jail_fs); + } else if (!(opts.namespace & CLONE_NEWNS)) { + /* + * No mount namespace to build (plain "-f"): build_jail_fs() + * is skipped, so reach enter_userns() directly here instead. + */ + run_hooks(opts.hooks.createContainer, enter_userns); } } @@ -2956,6 +3069,9 @@ static void post_main(struct uloop_timeout *t) if (pipe(&pipes[0]) < 0 || pipe(&pipes[2]) < 0) free_and_exit(-1); + if (pipe2(&userns_pipe[0], O_CLOEXEC) < 0 || pipe2(&userns_pipe[2], O_CLOEXEC) < 0) + free_and_exit(-1); + if (has_namespaces()) { if (opts.namespace & CLONE_NEWNS) { if (!opts.extroot && (opts.user || opts.group)) { @@ -2989,7 +3105,8 @@ static void post_main(struct uloop_timeout *t) add_mount(NULL, "/dev/pts", "devpts", MS_NOATIME | MS_NOEXEC | MS_NOSUID, 0, "newinstance,ptmxmode=0666,mode=0620,gid=5", 0); if (opts.procfs || opts.ocibundle) { - add_mount("proc", "/proc", "proc", MS_NOATIME | MS_NODEV | MS_NOEXEC | MS_NOSUID, 0, NULL, -1); + add_mount("proc", "/proc", "proc", + detect_atime_flag("/proc") | MS_NODEV | MS_NOEXEC | MS_NOSUID, 0, NULL, -1); /* * hack to make /proc/sys/net read-write while the rest of /proc/sys is read-only @@ -3014,7 +3131,8 @@ static void post_main(struct uloop_timeout *t) } if (opts.sysfs || opts.ocibundle) - add_mount("sysfs", "/sys", "sysfs", MS_RELATIME | MS_NODEV | MS_NOEXEC | MS_NOSUID | MS_RDONLY, 0, NULL, -1); + add_mount("sysfs", "/sys", "sysfs", + detect_atime_flag("/sys") | MS_NODEV | MS_NOEXEC | MS_NOSUID | MS_RDONLY, 0, NULL, -1); } @@ -3052,7 +3170,11 @@ static void post_main(struct uloop_timeout *t) } } - jail_process.pid = clone(exec_jail, child_stack + STACK_SIZE, SIGCHLD | (opts.namespace & (~CLONE_NEWCGROUP)), NULL); + /* + * CLONE_NEWUSER is excluded here; the child creates its own + * later, in enter_userns(). See exec_jail() for why. + */ + jail_process.pid = clone(exec_jail, child_stack + STACK_SIZE, SIGCHLD | (opts.namespace & (~(CLONE_NEWCGROUP | CLONE_NEWUSER))), NULL); } else { jail_process.pid = fork(); } @@ -3094,6 +3216,8 @@ static void post_main(struct uloop_timeout *t) close(opts.setns.cgroup); close(pipes[1]); close(pipes[2]); + close(userns_pipe[1]); + close(userns_pipe[2]); if (read(pipes[0], sig_buf, 1) < 1) { ERROR("can't read from child\n"); free_and_exit(-1); @@ -3104,27 +3228,6 @@ static void post_main(struct uloop_timeout *t) if (opts.ocibundle) cgroups_apply(jail_process.pid); - if (opts.namespace & CLONE_NEWUSER) { - if (write_setgroups(jail_process.pid, true)) { - ERROR("can't write setgroups\n"); - free_and_exit(-1); - } - if (!opts.uidmap) { - bool has_gr = (opts.gr_gid != -1); - if (opts.pw_uid != -1) { - write_single_uid_gid_map(jail_process.pid, 0, opts.pw_uid); - write_single_uid_gid_map(jail_process.pid, 1, has_gr?opts.gr_gid:opts.pw_gid); - } else { - write_single_uid_gid_map(jail_process.pid, 0, 65534); - write_single_uid_gid_map(jail_process.pid, 1, has_gr?opts.gr_gid:65534); - } - } else { - write_uid_gid_map(jail_process.pid, 0, opts.uidmap); - if (opts.gidmap) - write_uid_gid_map(jail_process.pid, 1, opts.gidmap); - } - } - if (opts.namespace & CLONE_NEWNET) jail_network_start(parent_ctx, opts.name, jail_process.pid); @@ -3153,6 +3256,49 @@ static void post_create_runtime(void) free_and_exit(-1); } + /* + * Wait for the child to reach enter_userns() and create its own + * userns before writing its uid/gid maps; see that function. + */ + if ((opts.namespace & CLONE_NEWUSER) && opts.setns.user == -1) { + char ubuf[1]; + + if (xread_byte(userns_pipe[0], ubuf) < 1) { + ERROR("can't read from child\n"); + free_and_exit(-1); + } + close(userns_pipe[0]); + + if (write_setgroups(jail_process.pid, true)) { + ERROR("can't write setgroups\n"); + free_and_exit(-1); + } + if (!opts.uidmap) { + bool has_gr = (opts.gr_gid != -1); + if (opts.pw_uid != -1) { + write_single_uid_gid_map(jail_process.pid, 0, opts.pw_uid); + write_single_uid_gid_map(jail_process.pid, 1, has_gr?opts.gr_gid:opts.pw_gid); + } else { + write_single_uid_gid_map(jail_process.pid, 0, 65534); + write_single_uid_gid_map(jail_process.pid, 1, has_gr?opts.gr_gid:65534); + } + } else { + write_uid_gid_map(jail_process.pid, 0, opts.uidmap); + if (opts.gidmap) + write_uid_gid_map(jail_process.pid, 1, opts.gidmap); + } + + ubuf[0] = 'O'; + if (xwrite_byte(userns_pipe[3], ubuf[0]) < 0) { + ERROR("can't write to child\n"); + free_and_exit(-1); + } + close(userns_pipe[3]); + } else { + close(userns_pipe[0]); + close(userns_pipe[3]); + } + jail_oci_state = OCI_STATE_CREATED; if (opts.ocibundle && !opts.immediately) uloop_run(); /* wait for 'start' command via ubus */ From ae36d7cfa785f40b78d580f9bf62cb2c65213173 Mon Sep 17 00:00:00 2001 From: Joshua Covington Date: Sat, 1 Aug 2026 08:50:47 +0000 Subject: [PATCH 02/14] jail: clear cgroup_path and initialized in cgroups_free cgroups_free() frees cgroup_path but leaves both it and the initialized flag holding their stale values. A second call to cgroups_free() without an intervening cgroups_init() frees cgroup_path again, a double free. cgroups_free() is called from three separate places in jail.c (error-path cleanup, normal cgroup teardown, and process exit), so a path that reaches it twice for the same jail is reachable in practice, not just a theoretical concern. Reset cgroup_path to NULL and initialized to false immediately after the free, so a repeat call is a safe no-op instead of a double free. This mirrors the guard already at the top of the function, which checks initialized before doing any work. Signed-off-by: Joshua Covington --- jail/cgroups.c | 2 ++ 1 file changed, 2 insertions(+) diff --git a/jail/cgroups.c b/jail/cgroups.c index c2f93ea..198b5a2 100644 --- a/jail/cgroups.c +++ b/jail/cgroups.c @@ -96,6 +96,8 @@ void cgroups_free(void) free(valp); } free(cgroup_path); + cgroup_path = NULL; + initialized = false; } } From a1c563323857a14273d40e17920711d0a430cd22 Mon Sep 17 00:00:00 2001 From: Joshua Covington Date: Sat, 1 Aug 2026 08:51:10 +0000 Subject: [PATCH 03/14] jail: detach inherited mounts under /proc,/sys before mounting own Mounts inherited from the host mount namespace under /proc,/sys can be locked, and a jail's own procfs/sysfs mount over one of them is rejected by the kernel's mount-visibility check, independent of the CLONE_NEWUSER mount-ordering requirements. mountinfo(5) escapes space, tab, newline, and backslash in its fields as octal \NNN sequences; comparing mountinfo fields with a plain strcmp() misses any mountpoint containing one of those bytes. Add mountinfo_unescape() and mountinfo_detach_children(), which read /proc/self/mountinfo with getline() (no line-length limit, needed since option strings such as Docker overlay2's lowerdir= entries can exceed any fixed buffer size regardless of whether the line concerns /proc,/sys at all), unescape the mountpoint field, collect mounts nested under a given prefix, and lazily detach (MNT_DETACH) each one, deepest path first, retrying over multiple passes since detaching a parent can expose further nested children. Call this from build_jail_fs(), under CLONE_NEWUSER, before mounting the jail's own /proc and /sys, guarded by opts.procfs/opts.sysfs/opts.ocibundle. Signed-off-by: Joshua Covington --- jail/jail.c | 124 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 124 insertions(+) diff --git a/jail/jail.c b/jail/jail.c index b811af9..76c12a1 100644 --- a/jail/jail.c +++ b/jail/jail.c @@ -708,6 +708,123 @@ static ssize_t xwrite_byte(int fd, char byte) static char tmpovdir[] = "/tmp/ujail-overlay-XXXXXX"; static mode_t old_umask; static void enter_jail_fs(void); + +static size_t path_depth(const char *path) +{ + size_t depth = 0; + + for (; *path; path++) + if (*path == '/') + depth++; + + return depth; +} + +static void mountinfo_unescape(char *s) +{ + char *r = s, *w = s; + + while (*r) { + if (r[0] == '\\' && r[1] >= '0' && r[1] <= '7' && + r[2] >= '0' && r[2] <= '7' && r[3] >= '0' && r[3] <= '7') { + *w++ = (char)(((r[1] - '0') << 6) | ((r[2] - '0') << 3) | (r[3] - '0')); + r += 4; + } else { + *w++ = *r++; + } + } + *w = '\0'; +} + +static int mountinfo_detach_children(const char *prefix) +{ + size_t prefixlen = strlen(prefix); + int pass; + + for (pass = 0; pass < 16; pass++) { + FILE *f; + char *line = NULL; + size_t linecap = 0; + char *paths[128]; + size_t n = 0, dropped = 0, i, j; + bool progress = false; + + f = fopen("/proc/self/mountinfo", "re"); + if (!f) { + ERROR("mountinfo_detach_children(%s): fopen(/proc/self/mountinfo) " + "failed: %m\n", prefix); + return -1; + } + + while (getline(&line, &linecap, f) >= 0) { + char *mp, *save = NULL; + + strtok_r(line, " ", &save); + strtok_r(NULL, " ", &save); + strtok_r(NULL, " ", &save); + strtok_r(NULL, " ", &save); + mp = strtok_r(NULL, " ", &save); + if (!mp) + continue; + mountinfo_unescape(mp); + + if (strncmp(mp, prefix, prefixlen) || mp[prefixlen] != '/') + continue; + + if (n < ARRAY_SIZE(paths)) { + char *dup = strdup(mp); + + if (dup) + paths[n++] = dup; + else + dropped++; + } else { + dropped++; + } + } + free(line); + fclose(f); + + if (dropped) + WARNING("mountinfo_detach_children(%s): %zu nested mount(s) " + "exceeded the %zu-entry per-pass limit or could not " + "be recorded (out of memory); retrying over further " + "passes\n", prefix, dropped, ARRAY_SIZE(paths)); + + if (n == 0 && dropped == 0) + return 0; + + for (i = 0; i < n; i++) { + size_t deepest = i; + + for (j = i + 1; j < n; j++) + if (path_depth(paths[j]) > path_depth(paths[deepest])) + deepest = j; + if (deepest != i) { + char *tmp = paths[i]; + paths[i] = paths[deepest]; + paths[deepest] = tmp; + } + } + + for (i = 0; i < n; i++) { + if (!umount2(paths[i], MNT_DETACH)) + progress = true; + free(paths[i]); + } + + if (!progress && !dropped) { + ERROR("mountinfo_detach_children(%s): %zu nested mount(s) could " + "not be detached; refusing to continue\n", prefix, n); + return -1; + } + } + + ERROR("mountinfo_detach_children(%s): nested mounts remained attached " + "after %d passes; refusing to continue\n", prefix, pass); + return -1; +} + static int build_jail_fs(void) { char *overlaydir = NULL; @@ -731,6 +848,13 @@ static int build_jail_fs(void) return -1; } + if (opts.namespace & CLONE_NEWUSER) { + if ((opts.procfs || opts.ocibundle) && mountinfo_detach_children("/proc")) + return -1; + if ((opts.sysfs || opts.ocibundle) && mountinfo_detach_children("/sys")) + return -1; + } + if (opts.extroot) { if (mount(opts.extroot, jail_root, "bind", MS_BIND, NULL)) { ERROR("extroot mount failed %m\n"); From 3fc9d1199cb1dc44c58bbc790327dbfc2906462e Mon Sep 17 00:00:00 2001 From: Joshua Covington Date: Sat, 1 Aug 2026 08:51:31 +0000 Subject: [PATCH 04/14] jail: run inherited-mount detach before joining an external userns Detaching an inherited mount requires CAP_SYS_ADMIN in the user namespace that owns that mount, established at the mount's creation time, not the caller's current identity. A freshly joined user namespace has no such authority over mounts it did not create. A mount inherited from the host under /proc or /sys - at minimum /sys/fs/cgroup on virtually any real system - is owned by the initial user namespace, so mountinfo_detach_children() fails once a process has joined a different, external user namespace. exec_jail() calls setns_open(CLONE_NEWUSER) to join an externally created namespace (opts.setns.user) before build_jail_fs() runs, so that join drops privilege before mount isolation gets a chance to run. This is a distinct code path from creating the jail's own new user namespace, whose creation is deferred until after mount setup and is unaffected by this ordering. Move the private-mount-plus-detach step out of build_jail_fs() into its own isolate_mountns_and_detach_inherited(), and call it in exec_jail() before setns_open(CLONE_NEWUSER) rather than after, so mount isolation runs while still holding the privilege of whichever namespace the process was in when exec_jail() started, before an external userns join can take it away. Guard the call on needing either the jail's own CLONE_NEWUSER or an external setns.user join, not on CLONE_NEWNS alone: applying it to every ordinary CLONE_NEWNS jail with -p/-s would add mountinfo-parsing overhead and new abort paths to configurations that don't need it. Signed-off-by: Joshua Covington --- jail/jail.c | 49 +++++++++++++++++++++++++++++++++++-------------- 1 file changed, 35 insertions(+), 14 deletions(-) diff --git a/jail/jail.c b/jail/jail.c index 76c12a1..8239fc4 100644 --- a/jail/jail.c +++ b/jail/jail.c @@ -825,6 +825,27 @@ static int mountinfo_detach_children(const char *prefix) return -1; } +/* + * Make the mount namespace private and detach inherited /proc,/sys + * children before build_jail_fs() mounts its own. Must run before + * setns_open(CLONE_NEWUSER) joins an external userns and drops + * privilege; see the call site in exec_jail(). + */ +static int isolate_mountns_and_detach_inherited(void) +{ + if (mount("none", "/", "none", MS_REC|MS_PRIVATE, NULL)) { + ERROR("private mount failed %m\n"); + return -1; + } + + if ((opts.procfs || opts.ocibundle) && mountinfo_detach_children("/proc")) + return -1; + if ((opts.sysfs || opts.ocibundle) && mountinfo_detach_children("/sys")) + return -1; + + return 0; +} + static int build_jail_fs(void) { char *overlaydir = NULL; @@ -842,19 +863,6 @@ static int build_jail_fs(void) return -1; } - /* oldroot can't be MS_SHARED else pivot_root() fails */ - if (mount("none", "/", "none", MS_REC|MS_PRIVATE, NULL)) { - ERROR("private mount failed %m\n"); - return -1; - } - - if (opts.namespace & CLONE_NEWUSER) { - if ((opts.procfs || opts.ocibundle) && mountinfo_detach_children("/proc")) - return -1; - if ((opts.sysfs || opts.ocibundle) && mountinfo_detach_children("/sys")) - return -1; - } - if (opts.extroot) { if (mount(opts.extroot, jail_root, "bind", MS_BIND, NULL)) { ERROR("extroot mount failed %m\n"); @@ -1404,12 +1412,25 @@ static int exec_jail(void *arg) close(userns_pipe[3]); } - setns_open(CLONE_NEWUSER); setns_open(CLONE_NEWNET); setns_open(CLONE_NEWNS); setns_open(CLONE_NEWIPC); setns_open(CLONE_NEWUTS); + /* + * Must run before setns_open(CLONE_NEWUSER) below: joining an + * external userns drops privilege immediately, and our own userns + * is deferred to enter_userns(), so this always runs privileged. + */ + if ((opts.namespace & CLONE_NEWNS) && + ((opts.namespace & CLONE_NEWUSER) || opts.setns.user != -1) && + isolate_mountns_and_detach_inherited()) { + ERROR("failed to detach inherited mounts\n"); + return EXIT_FAILURE; + } + + setns_open(CLONE_NEWUSER); + buf[0] = 'i'; if (write(pipes[1], buf, 1) < 1) { ERROR("can't write to parent\n"); From e90164d76d5c8b0a09a3a48adefb401913298e46 Mon Sep 17 00:00:00 2001 From: Joshua Covington Date: Sat, 1 Aug 2026 09:20:28 +0000 Subject: [PATCH 05/14] jail: validate and bind-mount devices via open_tree and move_mount Creating a custom device with mknod() requires CAP_MKNOD in the user namespace that will contain it. A process joining an external user namespace (-j, opts.setns.user) has already lost that capability by the time devices are created, since setns_open(CLONE_NEWUSER) for that join runs in exec_jail() before build_jail_fs()/create_devices(). A jail creating its own new user namespace (-f) still holds CAP_MKNOD at that point, since entering that namespace is deferred until after build_jail_fs() completes. Bind-mounting a device by path with a plain stat() check leaves a TOCTOU window between validation and mount and performs no major:minor check, so a swapped node could be bind-mounted into the jail unnoticed. devpts using host gid 5 fails to mount under CLONE_NEWUSER, since that gid is unmapped in the namespace. create_devices() tries mknod() first in every case. For the external-join case (opts.setns.user != -1), where mknod() cannot succeed, validate each requested device by opening it O_PATH on the host and fstat()'ing the held descriptor, checking node type and, for character/block devices, major:minor against the requested values. Attach the validated device through the kernel's mount API (open_tree()/mount_setattr()/move_mount(); no glibc wrappers exist for these yet, so three small syscall() wrappers are added) rather than bind-mounting the /proc/self/fd/%d magic symlink: open_tree() clones a detached mount of the held descriptor, mount_setattr() sets MOUNT_ATTR_RDONLY on that detached tree before it is ever attached anywhere, and move_mount() attaches it directly by file descriptor. This does not depend on /proc being mounted or trustworthy at the point the device is attached - relevant since the whole point of this jail is running under an unreliable or masked /proc - and there is no window where the mount is attached but not yet read-only. The mount queue (struct mount, add_mount()) gains a source_fd field and a matching add_mount_fd(); entries with a valid fd are attached via move_mount() in mount_all() through a new do_mount_fd(), instead of going through the existing path-based do_mount(). struct mount_attr's fields are __u64 in the kernel ABI; since unsigned long is only 64-bit on some target architectures and a 32-bit mismatch would pass the wrong size to mount_setattr(). Some of the new mount-API flag constants (MOVE_MOUNT_F_EMPTY_PATH, AT_EMPTY_PATH, in addition to the already-guarded OPEN_TREE_* and MOUNT_ATTR_RDONLY) are missing from musl's headers, so all of them get a local fallback definition guarded by #ifndef. A missing mandatory custom device aborts startup; a missing default device is skipped with a warning. Use gid=0 for devpts under CLONE_NEWUSER instead of the unmapped host gid 5. Signed-off-by: Joshua Covington --- jail/fs.c | 91 +++++++++++++++++++++++++- jail/fs.h | 28 ++++++++ jail/jail.c | 179 +++++++++++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 295 insertions(+), 3 deletions(-) diff --git a/jail/fs.c b/jail/fs.c index f843c54..9cfeede 100644 --- a/jail/fs.c +++ b/jail/fs.c @@ -18,6 +18,7 @@ #include #include #include +#include #include #include #include @@ -108,8 +109,26 @@ struct mount { const char *optstr; int error; bool inner; + int source_fd; }; +/* open_tree()/move_mount()/mount_setattr() have no glibc wrappers yet; + * struct ujail_mount_attr is declared in fs.h */ +int sys_open_tree(int dfd, const char *path, unsigned flags) +{ + return syscall(SYS_open_tree, dfd, path, flags); +} + +static int sys_move_mount(int from_dfd, const char *from_path, int to_dfd, const char *to_path, unsigned flags) +{ + return syscall(SYS_move_mount, from_dfd, from_path, to_dfd, to_path, flags); +} + +int sys_mount_setattr(int dfd, const char *path, unsigned flags, struct ujail_mount_attr *attr, size_t size) +{ + return syscall(SYS_mount_setattr, dfd, path, flags, attr, size); +} + struct avl_tree mounts; /* same masking as do_mount()'s is_mask branch, applied immediately @@ -267,6 +286,7 @@ static int _add_mount(const char *source, const char *target, const char *filesy m->propflags = propflags; m->error = error; m->inner = inner; + m->source_fd = -1; avl_insert(&mounts, &m->avl); DEBUG("adding mount %s %s bind(%d) ro(%d) err(%d)\n", (m->source == (void*)(-1))?"mask":m->source, m->target, @@ -302,6 +322,28 @@ int add_mount_bind(const char *path, int readonly, int error) return _add_mount_bind(path, path, readonly, error); } +int add_mount_fd(int fd, const char *target, int error) +{ + struct mount *m; + + if (avl_find(&mounts, target)) + return 1; + + m = calloc(1, sizeof(struct mount)); + if (!m) + return ENOMEM; + + m->avl.key = m->target = strdup(target); + m->mountflags = MS_BIND; + m->error = error; + m->source_fd = fd; + + avl_insert(&mounts, &m->avl); + DEBUG("adding mount fd:%d %s bind(1) ro(?) err(%d)\n", fd, target, error != 0); + + return 0; +} + enum { OCI_MOUNT_SOURCE, OCI_MOUNT_DESTINATION, @@ -507,6 +549,47 @@ static void build_noafile(void) { return; } +static int do_mount_fd(const char *root, int fd, const char *target, int error) +{ + char new[PATH_MAX]; + struct stat s; + + snprintf(new, sizeof(new), "%s%s", root, target); + + if (fstat(fd, &s)) { + if (error) + ERROR("fstat(fd:%d) failed: %m\n", fd); + close(fd); + return error; + } + + if (S_ISDIR(s.st_mode)) { + mkdir_p(new, 0755); + } else { + mkdir_p(dirname(new), 0755); + snprintf(new, sizeof(new), "%s%s", root, target); + int cfd = open(new, O_CREAT|O_WRONLY|O_TRUNC|O_EXCL, 0644); + if (cfd >= 0) + close(cfd); + if (error && cfd < 0 && errno != EEXIST) { + ERROR("failed to create mount target %s: %m\n", new); + close(fd); + return errno; + } + } + + if (sys_move_mount(fd, "", AT_FDCWD, new, MOVE_MOUNT_F_EMPTY_PATH)) { + if (error) + ERROR("move_mount() to %s failed: %m\n", new); + close(fd); + return error; + } + + close(fd); + DEBUG("move_mount fd to %s\n", new); + return 0; +} + int mount_all(const char *jailroot) { struct library *l; struct mount *m; @@ -516,10 +599,16 @@ int mount_all(const char *jailroot) { avl_for_each_element(&libraries, l, avl) add_mount_bind(l->path, 1, -1); - avl_for_each_element(&mounts, m, avl) + avl_for_each_element(&mounts, m, avl) { + if (m->source_fd >= 0) { + if (do_mount_fd(jailroot, m->source_fd, m->target, m->error)) + return -1; + continue; + } if (do_mount(jailroot, m->source, m->target, m->filesystemtype, m->mountflags, m->propflags, m->optstr, m->error, m->inner)) return -1; + } return 0; } diff --git a/jail/fs.h b/jail/fs.h index 73804fc..0023c78 100644 --- a/jail/fs.h +++ b/jail/fs.h @@ -13,6 +13,7 @@ #ifndef _JAIL_FS_H_ #define _JAIL_FS_H_ +#include #include #include @@ -21,7 +22,34 @@ int add_mount(const char *source, const char *target, const char *filesystemtype int add_mount_inner(const char *source, const char *target, const char *filesystemtype, unsigned long mountflags, unsigned long propflags, const char *optstr, int error); int add_mount_bind(const char *path, int readonly, int error); +int add_mount_fd(int fd, const char *target, int error); int mask_path_now(const char *path); + +/* open_tree()/mount_setattr() wrappers - no glibc wrappers yet. + * Fields must match the kernel's struct mount_attr layout exactly + * (__u64, not unsigned long - many OpenWrt targets are 32-bit). */ +#include +struct ujail_mount_attr { + uint64_t attr_set, attr_clr, propagation, userns_fd; +}; +int sys_open_tree(int dfd, const char *path, unsigned flags); +int sys_mount_setattr(int dfd, const char *path, unsigned flags, struct ujail_mount_attr *attr, size_t size); + +#ifndef OPEN_TREE_CLONE +#define OPEN_TREE_CLONE 1 +#endif +#ifndef OPEN_TREE_CLOEXEC +#define OPEN_TREE_CLOEXEC O_CLOEXEC +#endif +#ifndef MOUNT_ATTR_RDONLY +#define MOUNT_ATTR_RDONLY 0x00000001 +#endif +#ifndef MOVE_MOUNT_F_EMPTY_PATH +#define MOVE_MOUNT_F_EMPTY_PATH 0x00000004 +#endif +#ifndef AT_EMPTY_PATH +#define AT_EMPTY_PATH 0x1000 +#endif int parseOCImount(struct blob_attr *msg); int add_2paths_and_deps(const char *path, const char *path2, int readonly, int error, int lib); unsigned long detect_atime_flag(const char *mountpoint); diff --git a/jail/jail.c b/jail/jail.c index 8239fc4..d1e35f2 100644 --- a/jail/jail.c +++ b/jail/jail.c @@ -611,6 +611,11 @@ static int create_devices(void) if (strncmp(path, "/dev", 4)) return EPERM; + if (opts.setns.user != -1) { + ++cur; + continue; + } + /* make sure parent folder exists */ tmp = strrchr(path, '/'); if (!tmp) @@ -906,8 +911,172 @@ static int build_jail_fs(void) return -1; } - if (mount_all(jail_root)) { + { + /* fds stay open until mount_all() performs the /proc/self/fd/N + * binds below; closing early would drop or swap the source */ + int *held_fds = NULL; + size_t n_devices = 0, n_custom = 0, i; + int fail = 0; + + if (opts.setns.user != -1) { + struct mknod_args *curdef; + + if (opts.devices) { + struct mknod_args **cur; + + for (cur = opts.devices; *cur; cur++) + n_custom++; + } + + for (curdef = default_devices; curdef->path; curdef++) + n_devices++; + + n_devices += n_custom; + + held_fds = malloc(n_devices * sizeof(int)); + if (!held_fds) { + ERROR("out of memory validating devices\n"); + return -1; + } + for (i = 0; i < n_devices; i++) + held_fds[i] = -1; + + if (opts.devices) { + struct mknod_args **cur; + + for (i = 0, cur = opts.devices; *cur && !fail; cur++, i++) { + struct stat st; + + if (strncmp((*cur)->path, "/dev", 4)) { + ERROR("custom device %s is outside of /dev; " + "refusing to bind-mount it\n", + (*cur)->path); + fail = 1; + break; + } + + held_fds[i] = open((*cur)->path, O_PATH | O_CLOEXEC); + if (held_fds[i] < 0) { + ERROR("custom device %s requested but not found " + "on the host; it cannot be created under " + "CLONE_NEWUSER (no privilege to mknod)\n", + (*cur)->path); + fail = 1; + break; + } + + if (fstat(held_fds[i], &st)) { + ERROR("custom device %s: fstat() failed: %m\n", + (*cur)->path); + fail = 1; + break; + } + + if (((*cur)->mode & S_IFMT) && + (st.st_mode & S_IFMT) != ((*cur)->mode & S_IFMT)) { + ERROR("custom device %s exists on the host but " + "is not the requested node type; its " + "major:minor/mode/owner cannot be enforced " + "under CLONE_NEWUSER\n", + (*cur)->path); + fail = 1; + break; + } + + if (((*cur)->mode & S_IFMT) == S_IFCHR || + ((*cur)->mode & S_IFMT) == S_IFBLK) { + if ((*cur)->dev && st.st_rdev != (*cur)->dev) { + ERROR("custom device %s exists on the host " + "but its major:minor (%u:%u) does not " + "match the requested %u:%u; refusing " + "to bind-mount a different device than " + "configured\n", (*cur)->path, + major(st.st_rdev), minor(st.st_rdev), + major((*cur)->dev), minor((*cur)->dev)); + fail = 1; + break; + } + } + + { + int tree; + struct ujail_mount_attr attr = { .attr_set = MOUNT_ATTR_RDONLY }; + + tree = sys_open_tree(held_fds[i], "", OPEN_TREE_CLONE | OPEN_TREE_CLOEXEC | AT_EMPTY_PATH); + if (tree < 0) { + ERROR("open_tree() on custom device %s failed: %m\n", (*cur)->path); + fail = 1; + break; + } + close(held_fds[i]); + held_fds[i] = tree; + + if (sys_mount_setattr(tree, "", AT_EMPTY_PATH, &attr, sizeof(attr))) { + ERROR("mount_setattr() on custom device %s failed: %m\n", (*cur)->path); + fail = 1; + break; + } + } + if (add_mount_fd(held_fds[i], (*cur)->path, -1)) { + ERROR("could not queue bind-mount for mandatory " + "custom device %s; refusing to start with " + "a requested device missing\n", + (*cur)->path); + fail = 1; + break; + } + } + } + + if (!fail) { + size_t j = 0; + + for (curdef = default_devices; curdef->path; curdef++, j++) { + int tree; + struct ujail_mount_attr attr = { .attr_set = MOUNT_ATTR_RDONLY }; + + held_fds[n_custom + j] = open(curdef->path, O_PATH | O_CLOEXEC); + if (held_fds[n_custom + j] < 0) { + WARNING("could not open default device %s; " + "it will be unavailable in the jail\n", + curdef->path); + continue; + } + + tree = sys_open_tree(held_fds[n_custom + j], "", OPEN_TREE_CLONE | OPEN_TREE_CLOEXEC | AT_EMPTY_PATH); + if (tree < 0) { + WARNING("open_tree() on default device %s failed; " + "it will be unavailable in the jail\n", curdef->path); + continue; + } + close(held_fds[n_custom + j]); + held_fds[n_custom + j] = tree; + + if (sys_mount_setattr(tree, "", AT_EMPTY_PATH, &attr, sizeof(attr))) { + WARNING("mount_setattr() on default device %s failed; " + "it will be unavailable in the jail\n", curdef->path); + continue; + } + + if (add_mount_fd(held_fds[n_custom + j], curdef->path, 0)) + WARNING("could not queue bind-mount for default " + "device %s; it will be unavailable in " + "the jail\n", curdef->path); + } + } + } + + if (!fail && mount_all(jail_root)) { ERROR("mount_all() failed\n"); + fail = 1; + } + + for (i = 0; i < n_devices; i++) + if (held_fds && held_fds[i] >= 0) + close(held_fds[i]); + free(held_fds); + + if (fail) return -1; } @@ -3247,7 +3416,13 @@ static void post_main(struct uloop_timeout *t) /* default mounts */ add_mount(NULL, "/dev", "tmpfs", MS_NOATIME | MS_NOEXEC | MS_NOSUID, 0, "size=1M", -1); add_mount("shm", "/dev/shm", "tmpfs", MS_NOSUID | MS_NOEXEC | MS_NODEV, 0, "mode=1777", -1); - add_mount(NULL, "/dev/pts", "devpts", MS_NOATIME | MS_NOEXEC | MS_NOSUID, 0, "newinstance,ptmxmode=0666,mode=0620,gid=5", 0); + { + const char *ptsopts = (opts.namespace & CLONE_NEWUSER) ? + "newinstance,ptmxmode=0666,mode=0620,gid=0" : + "newinstance,ptmxmode=0666,mode=0620,gid=5"; + + add_mount(NULL, "/dev/pts", "devpts", MS_NOATIME | MS_NOEXEC | MS_NOSUID, 0, ptsopts, 0); + } if (opts.procfs || opts.ocibundle) { add_mount("proc", "/proc", "proc", From 835aed0085bbc0e96b7406fd9d63d992fe652f03 Mon Sep 17 00:00:00 2001 From: Joshua Covington Date: Sat, 1 Aug 2026 08:52:20 +0000 Subject: [PATCH 06/14] jail: fix identity resolution under CLONE_NEWUSER getpwnam()/getgrnam() are not reentrant. uid/gid map file descriptors are opened without O_CLOEXEC. dprintf()'s return value is checked as a boolean, so a successful write (a positive byte count) is treated as a failure. Map-write failures are discarded without being checked. A named user's supplementary groups are never resolved; where one of them is host gid 0, the gid_map remaps it to a free inner id, but setgroups() is called with the raw host gid array, so the mapped identity and the applied supplementary groups disagree. post_start_hook() re-derives and reapplies identity a second time, which fails under CLONE_NEWUSER since only namespace uid 0 is mapped into the new namespace, not the raw host uid. A precheck for -U/-G in main() resolves identity against the host passwd/group database unconditionally, but extroot jails resolve identity in post_start_hook() after pivot_root, against the extroot's own /etc/passwd, so a user defined only there is rejected before startup even though that is exactly what -R is for. Use getpwnam_r()/getgrnam_r(), distinguishing "no such user"/"no such group" (a NULL result with no error) from a genuine errno-bearing failure. Add O_CLOEXEC to the map/setgroups file descriptors. Fix the dprintf() check to test for a negative return. Check every map-write's return value and abort on failure instead of continuing with a half-applied identity. Resolve supplementary groups with getgrouplist() and build the corresponding inner gid map through a single, deterministic compute_inner_gids() helper, called independently by the parent (building the gid_map) and the child (calling setgroups(), across the fork/clone boundary) so both sides compute the same inner ids from the same inputs without passing state between them. Skip the redundant identity re-derivation in post_start_hook() when the uid/gid map already came from CLONE_NEWUSER. Restrict the -U/-G precheck in main() to non-extroot jails. Signed-off-by: Joshua Covington --- jail/jail.c | 323 +++++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 295 insertions(+), 28 deletions(-) diff --git a/jail/jail.c b/jail/jail.c index d1e35f2..6655c06 100644 --- a/jail/jail.c +++ b/jail/jail.c @@ -1230,10 +1230,10 @@ static int write_uid_gid_map(pid_t child_pid, bool gidmap, char *mapstr) child_pid, gidmap?"gid_map":"uid_map") < 0) return -1; - if ((map_file = open(map_path, O_WRONLY)) < 0) + if ((map_file = open(map_path, O_WRONLY | O_CLOEXEC)) < 0) return -1; - if (dprintf(map_file, "%s", mapstr)) { + if (dprintf(map_file, "%s", mapstr) < 0) { close(map_file); return -1; } @@ -1251,7 +1251,7 @@ static int write_single_uid_gid_map(pid_t child_pid, bool gidmap, int id) child_pid, gidmap?"gid_map":"uid_map") < 0) return -1; - if ((map_file = open(map_path, O_WRONLY)) < 0) + if ((map_file = open(map_path, O_WRONLY | O_CLOEXEC)) < 0) return -1; if (dprintf(map_file, map_format, 0, id, 1) < 0) { @@ -1273,7 +1273,7 @@ static int write_setgroups(pid_t child_pid, bool allow) return -1; } - if ((setgroups_file = open(setgroups_path, O_WRONLY)) < 0) { + if ((setgroups_file = open(setgroups_path, O_WRONLY | O_CLOEXEC)) < 0) { return -1; } @@ -1288,30 +1288,85 @@ static int write_setgroups(pid_t child_pid, bool allow) static void get_jail_user(int *user, int *user_gid, int *gr_gid) { - struct passwd *p = NULL; - struct group *g = NULL; + struct passwd pwd, *p = NULL; + struct group grp, *g = NULL; + char *buf = NULL; + size_t bufsize; + long sc; + int ret = 0, attempt; if (opts.user) { - p = getpwnam(opts.user); - if (!p) { - ERROR("failed to get uid/gid for user %s: %d (%s)\n", - opts.user, errno, strerror(errno)); + sc = sysconf(_SC_GETPW_R_SIZE_MAX); + bufsize = (sc > 0) ? (size_t)sc : 16384; + + for (attempt = 0; attempt < 8; attempt++) { + buf = malloc(bufsize); + if (!buf) { + ERROR("out of memory resolving user %s\n", opts.user); + free_and_exit(EXIT_FAILURE); + } + + ret = getpwnam_r(opts.user, &pwd, buf, bufsize, &p); + if (ret != ERANGE) + break; + + free(buf); + buf = NULL; + bufsize *= 2; + } + + if (ret || !p) { + free(buf); + if (!ret) + ERROR("failed to get uid/gid for user %s: " + "no such user\n", opts.user); + else + ERROR("failed to get uid/gid for user %s: %d (%s)\n", + opts.user, ret, strerror(ret)); free_and_exit(EXIT_FAILURE); } + *user = p->pw_uid; *user_gid = p->pw_gid; + free(buf); } else { *user = -1; *user_gid = -1; } if (opts.group) { - g = getgrnam(opts.group); - if (!g) { - ERROR("failed to get gid for group %s: %m\n", opts.group); + sc = sysconf(_SC_GETGR_R_SIZE_MAX); + bufsize = (sc > 0) ? (size_t)sc : 16384; + + for (attempt = 0; attempt < 8; attempt++) { + buf = malloc(bufsize); + if (!buf) { + ERROR("out of memory resolving group %s\n", opts.group); + free_and_exit(EXIT_FAILURE); + } + + ret = getgrnam_r(opts.group, &grp, buf, bufsize, &g); + if (ret != ERANGE) + break; + + free(buf); + buf = NULL; + bufsize *= 2; + } + + if (ret || !g) { + free(buf); + if (!ret) + ERROR("failed to get gid for group %s: " + "no such group\n", opts.group); + else + ERROR("failed to get gid for group %s: %d (%s)\n", + opts.group, ret, strerror(ret)); free_and_exit(EXIT_FAILURE); } + *gr_gid = g->gr_gid; + free(buf); } else { *gr_gid = -1; } @@ -1335,6 +1390,114 @@ static void set_jail_user(int pw_uid, int user_gid, int gr_gid) } } +static bool resolve_jail_user_gids(int primary_gid) +{ + gid_t *groups = NULL; + int ngroups = 16; + int ret = -1; + int attempt, i, n; + + if (!opts.user || primary_gid == -1) + return false; + + for (attempt = 0; attempt < 16; attempt++) { + gid_t *tmp = realloc(groups, ngroups * sizeof(gid_t)); + if (!tmp) { + free(groups); + ERROR("out of memory resolving groups for %s\n", opts.user); + return false; + } + groups = tmp; + + ret = getgrouplist(opts.user, primary_gid, groups, &ngroups); + if (ret >= 0) + break; + } + + if (ret < 0) { + free(groups); + ERROR("could not resolve groups for %s\n", opts.user); + return false; + } + + for (n = 0, i = 0; i < ret; i++) { + if ((int)groups[i] == primary_gid) + continue; + groups[n++] = groups[i]; + } + + opts.additional_gids = groups; + opts.num_additional_gids = n; + + return true; +} + +/* deterministic: parent and child (across fork) compute the same ids */ +static int *compute_inner_gids(int primary_gid) +{ + size_t i; + int *inner_id; + int next_id = 1; + + inner_id = calloc(opts.num_additional_gids ?: 1, sizeof(int)); + if (!inner_id) + return NULL; + + for (i = 0; i < opts.num_additional_gids; i++) { + int gid = (int)opts.additional_gids[i]; + int candidate = gid; + bool used; + + if (gid == 0) { + do { + used = (candidate == 0 || candidate == primary_gid); + for (size_t j = 0; !used && j < opts.num_additional_gids; j++) + if ((int)opts.additional_gids[j] == candidate) + used = true; + for (size_t j = 0; !used && j < i; j++) + if (inner_id[j] == candidate) + used = true; + if (used) + candidate = next_id++; + } while (used); + } + + inner_id[i] = candidate; + } + + return inner_id; +} + +static char *build_group_gidmap(int primary_gid) +{ + size_t i, len = 0, pos = 0; + char *map; + int *inner_id; + + inner_id = compute_inner_gids(primary_gid); + if (!inner_id) + return NULL; + + len += snprintf(NULL, 0, "%d %d %d\n", 0, primary_gid, 1); + for (i = 0; i < opts.num_additional_gids; i++) + len += snprintf(NULL, 0, "%d %d %d\n", + inner_id[i], opts.additional_gids[i], 1); + + map = malloc(len + 1); + if (!map) { + free(inner_id); + return NULL; + } + + pos += snprintf(&map[pos], len + 1 - pos, "%d %d %d\n", 0, primary_gid, 1); + for (i = 0; i < opts.num_additional_gids; i++) + pos += snprintf(&map[pos], len + 1 - pos, "%d %d %d\n", + inner_id[i], opts.additional_gids[i], 1); + + free(inner_id); + return map; +} + static int apply_rlimits(void) { int resource; @@ -1712,13 +1875,53 @@ static void post_start_hook(void) free_and_exit(EXIT_FAILURE); /* use either cmdline-supplied user/group or uid/gid from OCI spec */ - get_jail_user(&pw_uid, &pw_gid, &gr_gid); - set_jail_user(opts.pw_uid?:pw_uid, opts.pw_gid?:pw_gid, opts.gr_gid?:gr_gid); + if (opts.ocibundle || opts.uidmap || !(opts.namespace & CLONE_NEWUSER)) { + get_jail_user(&pw_uid, &pw_gid, &gr_gid); + set_jail_user(opts.pw_uid?:pw_uid, opts.pw_gid?:pw_gid, opts.gr_gid?:gr_gid); + } else if (opts.user || opts.group || opts.pw_uid != -1 || opts.pw_gid != -1 || opts.gr_gid != -1) { + WARNING("user/group identity switch (-U/-G) is not re-applied under " + "CLONE_NEWUSER without an OCI bundle or explicit uidmap; the " + "process already has the correct identity via the uid_map\n"); + } - if (opts.additional_gids && - (setgroups(opts.num_additional_gids, opts.additional_gids) < 0)) { - ERROR("setgroups failed: %m\n"); - free_and_exit(EXIT_FAILURE); + if (opts.additional_gids) { + bool default_own_userns_map = !opts.uidmap && + (opts.namespace & CLONE_NEWUSER) && opts.setns.user == -1; + + if (default_own_userns_map) { + bool has_gr = (opts.gr_gid != -1); + int primary_gid = has_gr ? opts.gr_gid : + ((opts.pw_uid != -1) ? opts.pw_gid : 65534); + int *inner_id = compute_inner_gids(primary_gid); + + if (inner_id) { + gid_t *mapped = calloc(opts.num_additional_gids ?: 1, sizeof(gid_t)); + size_t i; + + if (mapped) { + for (i = 0; i < opts.num_additional_gids; i++) + mapped[i] = (gid_t)inner_id[i]; + if (setgroups(opts.num_additional_gids, mapped) < 0) { + ERROR("setgroups failed: %m\n"); + free(mapped); + free(inner_id); + free_and_exit(EXIT_FAILURE); + } + free(mapped); + } else { + ERROR("out of memory computing setgroups() list\n"); + free(inner_id); + free_and_exit(EXIT_FAILURE); + } + free(inner_id); + } else { + ERROR("out of memory computing setgroups() list\n"); + free_and_exit(EXIT_FAILURE); + } + } else if (setgroups(opts.num_additional_gids, opts.additional_gids) < 0) { + ERROR("setgroups failed: %m\n"); + free_and_exit(EXIT_FAILURE); + } } if (opts.set_umask) @@ -3257,6 +3460,12 @@ int main(int argc, char **argv) } + if (!opts.extroot && (opts.user || opts.group)) { + int precheck_uid, precheck_gid, precheck_grgid; + + get_jail_user(&precheck_uid, &precheck_gid, &precheck_grgid); + } + if (opts.tmpoverlaysize && strlen(opts.tmpoverlaysize) > 8) { ERROR("size parameter too long: \"%s\"\n", opts.tmpoverlaysize); ret=-1; @@ -3337,8 +3546,18 @@ int main(int argc, char **argv) for (size_t s = optind; s < argc; s++) opts.jail_argv[s - optind] = strdup(argv[s]); - if (opts.namespace & CLONE_NEWUSER) + if (opts.namespace & CLONE_NEWUSER) { get_jail_user(&opts.pw_uid, &opts.pw_gid, &opts.gr_gid); + + if (!opts.uidmap && opts.user) { + int primary_gid = (opts.gr_gid != -1) ? opts.gr_gid : opts.pw_gid; + + if (!resolve_jail_user_gids(primary_gid)) + WARNING("could not resolve supplementary groups for " + "user %s; jail will start with no supplementary " + "groups\n", opts.user); + } + } } if (!opts.extroot) { @@ -3595,17 +3814,65 @@ static void post_create_runtime(void) } if (!opts.uidmap) { bool has_gr = (opts.gr_gid != -1); - if (opts.pw_uid != -1) { - write_single_uid_gid_map(jail_process.pid, 0, opts.pw_uid); - write_single_uid_gid_map(jail_process.pid, 1, has_gr?opts.gr_gid:opts.pw_gid); + int primary_gid = has_gr ? opts.gr_gid : + ((opts.pw_uid != -1) ? opts.pw_gid : 65534); + int target_uid = (opts.pw_uid != -1) ? opts.pw_uid : 65534; + + if (write_single_uid_gid_map(jail_process.pid, 0, target_uid)) { + ERROR("failed to map uid %d into the jail's user " + "namespace (mapping a uid other than your own " + "requires CAP_SETUID, typically real root; use " + "-U with your own account, run as a privileged " + "user, or supply an explicit --uidmap)\n", + target_uid); + free_and_exit(-1); + } + + if (opts.additional_gids && opts.num_additional_gids) { + char *gidmap = build_group_gidmap(primary_gid); + + if (gidmap) { + if (write_uid_gid_map(jail_process.pid, 1, gidmap)) { + ERROR("failed to map supplementary groups for " + "%s into the jail's user namespace " + "(mapping a gid other than your own " + "requires CAP_SETGID, typically real " + "root)\n", opts.user); + free(gidmap); + free_and_exit(-1); + } + free(gidmap); + } else { + WARNING("failed to build supplementary group map for " + "%s; falling back to primary gid only\n", opts.user); + if (write_single_uid_gid_map(jail_process.pid, 1, primary_gid)) { + ERROR("failed to map gid %d into the jail's " + "user namespace (mapping a gid other " + "than your own requires CAP_SETGID, " + "typically real root)\n", primary_gid); + free_and_exit(-1); + } + } } else { - write_single_uid_gid_map(jail_process.pid, 0, 65534); - write_single_uid_gid_map(jail_process.pid, 1, has_gr?opts.gr_gid:65534); + if (write_single_uid_gid_map(jail_process.pid, 1, primary_gid)) { + ERROR("failed to map gid %d into the jail's user " + "namespace (mapping a gid other than your " + "own requires CAP_SETGID, typically real " + "root)\n", primary_gid); + free_and_exit(-1); + } } } else { - write_uid_gid_map(jail_process.pid, 0, opts.uidmap); - if (opts.gidmap) - write_uid_gid_map(jail_process.pid, 1, opts.gidmap); + if (write_uid_gid_map(jail_process.pid, 0, opts.uidmap)) { + ERROR("failed to write uidmap (check --uidmap values " + "and privileges)\n"); + free_and_exit(-1); + } + if (opts.gidmap && write_uid_gid_map(jail_process.pid, 1, opts.gidmap)) { + ERROR("failed to write gidmap (check --gidmap values " + "and privileges)\n"); + free_and_exit(-1); + } } ubuf[0] = 'O'; From f689b10fe33c4c7db983f44d6c41d5fc51b7d01c Mon Sep 17 00:00:00 2001 From: Joshua Covington Date: Sat, 1 Aug 2026 08:52:39 +0000 Subject: [PATCH 07/14] jail: tolerate EPERM on remount when flags already satisfied A remount used to enforce security flags (ro/nosuid/nodev/noexec) can return EPERM even when those flags already hold - for example on a host-owned bind source under CLONE_NEWUSER - and treating that as fatal breaks configurations that are already correctly locked down. Mountpoint comparisons for this check use a plain strcmp() against mountinfo fields; mountinfo(5) escapes space, tab, newline, and backslash as octal \NNN sequences, so any mountpoint containing one of those bytes never matches. Add mountinfo_unescape() and mountinfo_current_flags(), which unescape and read the kernel-enforced flags for a mountpoint from /proc/self/mountinfo using getline() (no line-length limit), keeping the last matching entry: with stacked mounts, the last entry is the topmost, currently-effective one. On EPERM from a remount, retry with those actual flags merged in; if that still fails, tolerate the EPERM only if the security-relevant flags already in effect satisfy the original request, otherwise fail for a critical mount and warn for a non-critical one. Signed-off-by: Joshua Covington --- jail/fs.c | 121 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 121 insertions(+) diff --git a/jail/fs.c b/jail/fs.c index 9cfeede..3934f14 100644 --- a/jail/fs.c +++ b/jail/fs.c @@ -154,6 +154,83 @@ int mask_path_now(const char *path) return 0; } +static void mountinfo_unescape(char *s) +{ + char *r = s, *w = s; + + while (*r) { + if (r[0] == '\\' && r[1] >= '0' && r[1] <= '7' && + r[2] >= '0' && r[2] <= '7' && r[3] >= '0' && r[3] <= '7') { + *w++ = (char)(((r[1] - '0') << 6) | ((r[2] - '0') << 3) | (r[3] - '0')); + r += 4; + } else { + *w++ = *r++; + } + } + *w = '\0'; +} + +static unsigned long mountinfo_current_flags(const char *path) +{ + unsigned long flags = MS_RELATIME; + bool found = false; + FILE *f; + char *line = NULL; + size_t linecap = 0; + + f = fopen("/proc/self/mountinfo", "re"); + if (!f) + return flags; + + while (getline(&line, &linecap, f) >= 0) { + char *mp, *opts, *save = NULL, *optsave = NULL; + char *tok; + unsigned long this_flags; + + strtok_r(line, " ", &save); + strtok_r(NULL, " ", &save); + strtok_r(NULL, " ", &save); + strtok_r(NULL, " ", &save); + mp = strtok_r(NULL, " ", &save); + opts = strtok_r(NULL, " ", &save); + if (!mp || !opts) + continue; + mountinfo_unescape(mp); + if (strcmp(mp, path)) + continue; + + this_flags = 0; + for (tok = strtok_r(opts, ",", &optsave); tok; + tok = strtok_r(NULL, ",", &optsave)) { + if (!strcmp(tok, "ro")) + this_flags |= MS_RDONLY; + else if (!strcmp(tok, "nosuid")) + this_flags |= MS_NOSUID; + else if (!strcmp(tok, "nodev")) + this_flags |= MS_NODEV; + else if (!strcmp(tok, "noexec")) + this_flags |= MS_NOEXEC; + else if (!strcmp(tok, "noatime")) + this_flags |= MS_NOATIME; + else if (!strcmp(tok, "relatime")) + this_flags |= MS_RELATIME; + else if (!strcmp(tok, "nodiratime")) + this_flags |= MS_NODIRATIME; + } + + /* last match wins: it's the topmost/effective entry */ + flags = this_flags; + found = true; + } + free(line); + fclose(f); + + if (!found) + return MS_RELATIME; + + return flags; +} + static int do_mount(const char *root, const char *orig_source, const char *target, const char *filesystemtype, unsigned long orig_mountflags, unsigned long propflags, const char *optstr, int error, bool inner) { @@ -231,6 +308,49 @@ static int do_mount(const char *root, const char *orig_source, const char *targe const char *hack_fstype = ((!filesystemtype || strcmp(filesystemtype, "cgroup"))?filesystemtype:"cgroup2"); if (mount(source?:(is_bind?new:NULL), new, hack_fstype?:"none", mountflags, optstr)) { + int mount_errno = errno; + + if ((mountflags & MS_REMOUNT) && mount_errno == EPERM) { + /* Not a heuristic: re-read the kernel-enforced flags from + * mountinfo and only proceed once the security-relevant + * ones (ro/nosuid/nodev/noexec) are confirmed in effect. */ + unsigned long retry_flags = mountflags | mountinfo_current_flags(new); + + if (retry_flags != mountflags && + !mount(source?:(is_bind?new:NULL), new, hack_fstype?:"none", retry_flags, optstr)) + goto mount_ok; + + unsigned long lockable_flags = MS_RDONLY | MS_NOSUID | MS_NODEV | MS_NOEXEC; + unsigned long wanted = orig_mountflags & lockable_flags; + unsigned long got = mountinfo_current_flags(new) & lockable_flags; + + if ((wanted & ~got) == 0) { + WARNING("remount(%s) to apply flags failed: %s (tolerated - " + "the flags actually in effect already satisfy the " + "request; expected under CLONE_NEWUSER for host-owned " + "bind sources)\n", new, strerror(mount_errno)); + goto mount_ok; + } + + if (error) { + errno = mount_errno; + ERROR("failed to enforce mount restrictions on %s %s: %m " + "(missing flags: %s%s%s%s)\n", source, new, + (wanted & ~got & MS_RDONLY) ? "ro " : "", + (wanted & ~got & MS_NOSUID) ? "nosuid " : "", + (wanted & ~got & MS_NODEV) ? "nodev " : "", + (wanted & ~got & MS_NOEXEC) ? "noexec " : ""); + ret = error; + goto free_source_out; + } + + WARNING("remount(%s) to apply mount restrictions failed and could not " + "be verified in effect; continuing best-effort since " + "this mount was not marked as critical\n", new); + goto mount_ok; + } + + errno = mount_errno; if (error) ERROR("failed to mount %s %s: %m\n", source, new); @@ -238,6 +358,7 @@ static int do_mount(const char *root, const char *orig_source, const char *targe goto free_source_out; } +mount_ok: DEBUG("mount %s%s %s (%s)\n", (mountflags & MS_BIND)?"-B ":"", source, new, (mountflags & MS_RDONLY)?"ro":"rw"); From f85a629bd6943574285da25bcf88b2ecd5d5e091 Mon Sep 17 00:00:00 2001 From: Joshua Covington Date: Mon, 3 Aug 2026 19:54:41 +0000 Subject: [PATCH 08/14] jail: reject non-read-only OCI bind mounts onto /proc,/sys An OCI bundle can bind-mount a writable host directory over /proc or /sys, undermining process/kernel-interface isolation regardless of any other mount hardening in place. Add is_proc_or_sys_path() and reject an OCI mount request whose destination is /proc, /sys, or a path under either, when the mount is a bind mount and not read-only (MS_RDONLY). Scope this to bind mounts specifically: the OCI runtime-spec default for a container's /proc, and what runc/Docker/podman emit, is a fresh procfs mount with no "ro" option and type "proc", not "bind"; maskedPaths/readonlyPaths are the spec's mechanism for restricting the sensitive parts of that mount, and ujail's own /proc mount is not MS_RDONLY either. Detect the bind mount via the parsed MS_BIND flag (set by parseOCImountopts() whenever "bind"/"rbind" appears in the mount's "options", independent of its "type"), not by string-comparing "type" against "bind": a spec using type:"none" with options:["bind","rw"] is a real-world OCI spelling that a type-string-only check misses entirely, since parseOCImountopts() sets MS_BIND from "options" alone. Still also check the type string directly, to keep catching a spec that sets type:"bind" without "bind" appearing in "options". Signed-off-by: Joshua Covington --- jail/fs.c | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/jail/fs.c b/jail/fs.c index 3934f14..b623897 100644 --- a/jail/fs.c +++ b/jail/fs.c @@ -629,6 +629,20 @@ static int parseOCImountopts(struct blob_attr *msg, unsigned long *mount_flags, return 0; } +static bool is_proc_or_sys_path(const char *path) +{ + if (!strcmp(path, "/proc") || !strcmp(path, "/sys")) + return true; + + if (!strncmp(path, "/proc/", 6)) + return true; + + if (!strncmp(path, "/sys/", 5)) + return true; + + return false; +} + int parseOCImount(struct blob_attr *msg) { struct blob_attr *tb[__OCI_MOUNT_MAX]; @@ -648,6 +662,18 @@ int parseOCImount(struct blob_attr *msg) return ret; } + if (is_proc_or_sys_path(blobmsg_get_string(tb[OCI_MOUNT_DESTINATION])) && + ((mount_flags & MS_BIND) || + (tb[OCI_MOUNT_TYPE] && !strcmp(blobmsg_get_string(tb[OCI_MOUNT_TYPE]), "bind"))) && + !(mount_flags & MS_RDONLY)) { + ERROR("OCI mount config requests a writable bind mount onto %s; " + "refusing to allow write access to /proc or /sys\n", + blobmsg_get_string(tb[OCI_MOUNT_DESTINATION])); + if (mount_data) + free(mount_data); + return EPERM; + } + ret = add_mount(tb[OCI_MOUNT_SOURCE] ? blobmsg_get_string(tb[OCI_MOUNT_SOURCE]) : NULL, blobmsg_get_string(tb[OCI_MOUNT_DESTINATION]), tb[OCI_MOUNT_TYPE] ? blobmsg_get_string(tb[OCI_MOUNT_TYPE]) : NULL, From d381289c2b35bc1e6459a08f681fffea0be59b5f Mon Sep 17 00:00:00 2001 From: Joshua Covington Date: Mon, 3 Aug 2026 22:59:38 +0000 Subject: [PATCH 09/14] jail: mask default sensitive /proc,/sys paths for plain jails Plain CLI jails do not mask sensitive /proc,/sys paths such as /proc/kcore, /proc/sysrq-trigger, /sys/firmware, /proc/sched_debug, etc.; this masking happens only along the OCI mount-spec path, leaving every non-OCI jail with unrestricted access to them. Add proc_mask_critical[], proc_mask_optional[] and sys_mask_critical[] path lists and mask_default_paths(), which masks each path with a 0-sized tmpfs or bind-mounted empty file via the existing do_mount() masking mechanism. Call it for non-OCI jails right after mounting /proc,/sys: failing to mask a critical path aborts startup, failing to mask an optional path only warns, since not all kernels/configs expose every optional path. Switch the masking mounts in do_mount() from hardcoded MS_NOATIME to MS_RELATIME, matching the kernel's actual atime default and avoiding an atime-class mismatch against the host's existing /proc,/sys instance. A jail that defers creating its own CLONE_NEWUSER applies this masking, and the separate always-on /proc/sys read-only self-bind, while still in the initial user namespace, so these mounts get copied into the jail's own mount namespace locked once it regains one of its own - a locked mount can't be replaced or detached by that same, now less-privileged process, since doing so needs privilege in the namespace that owns the mount. A locked mount under /proc also makes the kernel's mount-visibility check (mount_too_revealing()) refuse a nested runtime's own /proc mount inside the jail, since it requires every locked child under a candidate reference /proc to cover only a permanently-empty directory, which none of these masked paths are. Defer this masking and the /proc/sys self-bind to a second pass instead, for jails that defer their own CLONE_NEWUSER: mask_path_now() and remount_proc_sys_after_unshare() apply the exact same masks and lock directly, once the jail has regained a mount namespace owned by its own user namespace. Mounts created there aren't locked, since no privileged-to-unprivileged copy is involved, so a nested runtime's own /proc mount succeeds while the masked paths stay exactly as inaccessible as before. A jail that never creates its own user namespace has no second pass and no such concern, so it keeps applying this masking immediately, as before. UJAIL_NOAFILE is created host-side, pre-pivot_root(), so it is unreachable from the post-pivot context this second pass always runs in. A 0-mode empty file gives a stricter, self-diagnosing mask than /dev/null (open() fails outright, instead of quietly succeeding on every read/write); build_jail_noafile() creates a second instance at JAIL_NOAFILE, reachable from inside the jail, before the deferred CLONE_NEWUSER, and mask_path_now() binds that instead. The /proc/sys/net self-bind dance mirrors phase 1's ordering, so the closing MS_MOVE has a mountpoint to move from. An OCI bundle's own maskedPaths/readonlyPaths need the same deferral as the above whenever the bundle also defers its own CLONE_NEWUSER, for the same locked-mount reasoning. build_jail_noafile() anchors JAIL_NOAFILE in a locked, read-only tmpfs: ownership alone can't survive an explicit 0->0 uidMapping. Signed-off-by: Joshua Covington --- jail/fs.c | 42 ++++++++-- jail/fs.h | 1 + jail/jail.c | 225 +++++++++++++++++++++++++++++++++++++++++++++++++--- 3 files changed, 251 insertions(+), 17 deletions(-) diff --git a/jail/fs.c b/jail/fs.c index b623897..8ac7c73 100644 --- a/jail/fs.c +++ b/jail/fs.c @@ -26,6 +26,7 @@ #include #include #include +#include #include #include @@ -41,6 +42,8 @@ #include "log.h" #define UJAIL_NOAFILE "/tmp/.ujailnoafile" +#define JAIL_NOAFILE_DIR "/dev/.ujail" +#define JAIL_NOAFILE JAIL_NOAFILE_DIR "/noafile" /* * mnt_already_visible() requires a new mount's atime class to match @@ -131,8 +134,37 @@ int sys_mount_setattr(int dfd, const char *path, unsigned flags, struct ujail_mo struct avl_tree mounts; +/* ownership alone can't survive an explicit 0->0 uidMapping, or a + * directory-owner unlink; a locked, read-only superblock can. + * The directory keeps its execute bit: traversal to the one, known + * path is needed by mask_path_now() itself; read (listing) and write + * (creating/removing entries) stay denied. */ +int build_jail_noafile(void) +{ + int fd, old_fsuid; + + if (mkdir(JAIL_NOAFILE_DIR, 0111)) + return -1; + if (mount("none", JAIL_NOAFILE_DIR, "tmpfs", + MS_NOSUID | MS_NODEV | MS_NOEXEC, "size=4k,mode=111")) + return -1; + + old_fsuid = setfsuid(0); + fd = creat(JAIL_NOAFILE, 0000); + setfsuid(old_fsuid); + if (fd < 0) + return -1; + close(fd); + + return mount(NULL, JAIL_NOAFILE_DIR, NULL, + MS_REMOUNT | MS_RDONLY | MS_NOSUID | MS_NODEV | MS_NOEXEC, NULL); +} + /* same masking as do_mount()'s is_mask branch, applied immediately - * against an absolute path instead of queued through jail_root */ + * against an absolute path instead of queued through jail_root. + * + * UJAIL_NOAFILE lives under the pre-pivot root, gone by the time this + * runs; JAIL_NOAFILE (see build_jail_noafile()) is used instead. */ int mask_path_now(const char *path) { struct stat s; @@ -144,9 +176,9 @@ int mask_path_now(const char *path) if (mount("none", path, "tmpfs", MS_RDONLY | MS_NOSUID | MS_NOEXEC | MS_NODEV | MS_RELATIME, "size=0,mode=000")) return -1; } else { - if (mount(UJAIL_NOAFILE, path, "bind", MS_BIND, NULL)) + if (mount(JAIL_NOAFILE, path, "bind", MS_BIND, NULL)) return -1; - if (mount(UJAIL_NOAFILE, path, "bind", MS_REMOUNT | MS_BIND | MS_RDONLY | MS_NOSUID | MS_NOEXEC | MS_NODEV | MS_RELATIME, NULL)) + if (mount(JAIL_NOAFILE, path, "bind", MS_REMOUNT | MS_BIND | MS_RDONLY | MS_NOSUID | MS_NOEXEC | MS_NODEV | MS_RELATIME, NULL)) return -1; } @@ -262,14 +294,14 @@ static int do_mount(const char *root, const char *orig_source, const char *targe return 0; /* doesn't exists, nothing to mask */ if (S_ISDIR(s.st_mode)) {/* use empty 0-sized tmpfs for directories */ - if (mount("none", new, "tmpfs", MS_RDONLY | MS_NOSUID | MS_NOEXEC | MS_NODEV | MS_NOATIME, "size=0,mode=000")) + if (mount("none", new, "tmpfs", MS_RDONLY | MS_NOSUID | MS_NOEXEC | MS_NODEV | MS_RELATIME, "size=0,mode=000")) return error; } else { /* mount-bind 0-sized file having mode 000 */ if (mount(UJAIL_NOAFILE, new, "bind", MS_BIND, NULL)) return error; - if (mount(UJAIL_NOAFILE, new, "bind", MS_REMOUNT | MS_BIND | MS_RDONLY | MS_NOSUID | MS_NOEXEC | MS_NODEV | MS_NOATIME, NULL)) + if (mount(UJAIL_NOAFILE, new, "bind", MS_REMOUNT | MS_BIND | MS_RDONLY | MS_NOSUID | MS_NOEXEC | MS_NODEV | MS_RELATIME, NULL)) return error; } diff --git a/jail/fs.h b/jail/fs.h index 0023c78..18fa51b 100644 --- a/jail/fs.h +++ b/jail/fs.h @@ -24,6 +24,7 @@ int add_mount_inner(const char *source, const char *target, const char *filesyst int add_mount_bind(const char *path, int readonly, int error); int add_mount_fd(int fd, const char *target, int error); int mask_path_now(const char *path); +int build_jail_noafile(void); /* open_tree()/mount_setattr() wrappers - no glibc wrappers yet. * Fields must match the kernel's struct mount_attr layout exactly diff --git a/jail/jail.c b/jail/jail.c index 6655c06..12f1c34 100644 --- a/jail/jail.c +++ b/jail/jail.c @@ -151,6 +151,8 @@ static struct { bool set_oom_score_adj; struct mknod_args **devices; char *ocibundle; + char **oci_deferred_masked; + char **oci_deferred_readonly; bool immediately; struct blob_attr *annotations; int term_timeout; @@ -283,6 +285,19 @@ static void free_opts(bool parent) { free_hooklist(opts.hooks.startContainer); free_hooklist(opts.hooks.poststart); free_hooklist(opts.hooks.poststop); + + if (opts.oci_deferred_masked) { + char **p; + for (p = opts.oci_deferred_masked; *p; p++) + free(*p); + free(opts.oci_deferred_masked); + } + if (opts.oci_deferred_readonly) { + char **p; + for (p = opts.oci_deferred_readonly; *p; p++) + free(*p); + free(opts.oci_deferred_readonly); + } } static int mount_overlay(char *jail_root, char *overlaydir) { @@ -1122,6 +1137,8 @@ static void free_and_exit(int ret) static void post_jail_fs(void); static void enter_userns(void); +static void remask_after_unshare(void); +static void remount_proc_sys_after_unshare(void); static void enter_jail_fs(void) { char dirbuf[sizeof(jail_root) + 4]; @@ -1157,6 +1174,11 @@ static void enter_jail_fs(void) ERROR("create_devices() failed\n"); free_and_exit(-1); } + if ((opts.namespace & CLONE_NEWUSER) && opts.setns.user == -1 && + build_jail_noafile()) { + ERROR("build_jail_noafile() failed\n"); + free_and_exit(-1); + } if (opts.ronly) mount(NULL, "/", "bind", MS_REMOUNT | MS_BIND | MS_RDONLY, 0); @@ -1204,6 +1226,10 @@ static void enter_userns(void) ERROR("unshare(CLONE_NEWNS) failed: %m\n"); free_and_exit(-1); } + if (opts.namespace & CLONE_NEWNS) { + remask_after_unshare(); + remount_proc_sys_after_unshare(); + } if (setregid(0, 0) < 0) { ERROR("setgid\n"); @@ -1390,6 +1416,121 @@ static void set_jail_user(int pw_uid, int user_gid, int gr_gid) } } +static const char *proc_mask_critical[] = { + "/proc/kcore", + "/proc/sysrq-trigger", + NULL, +}; + +static const char *proc_mask_optional[] = { + "/proc/latency_stats", + "/proc/timer_list", + "/proc/timer_stats", + "/proc/sched_debug", + "/proc/scsi", + NULL, +}; + +static const char *sys_mask_critical[] = { + "/sys/firmware", + NULL, +}; + +static int mask_default_paths(const char **paths, bool critical) +{ + const char **p; + + for (p = paths; *p; p++) { + if (add_mount((void *)(-1), *p, NULL, 0, 0, NULL, critical ? -1 : 0)) { + if (critical) { + ERROR("failed to mask sensitive path %s\n", *p); + return -1; + } + WARNING("could not mask optional path %s; it may not " + "exist on this kernel/config\n", *p); + } + } + + return 0; +} + +/* deferred like mask_path_now(): locking a readonlyPath in phase 1 + * would stay locked past the jail's own unshare(CLONE_NEWNS). */ +static int remount_readonly_now(const char *path) +{ + struct stat s; + + if (stat(path, &s)) + return 0; /* doesn't exist, nothing to restrict */ + + if (mount(path, path, "bind", MS_BIND | MS_REC, NULL)) + return -1; + if (mount(path, path, "bind", MS_REMOUNT | MS_BIND | MS_RDONLY | MS_REC, NULL)) + return -1; + + DEBUG("read-only path %s\n", path); + return 0; +} + +/* re-apply masks inside the container's own mntns: the first pass is + * locked once copied in after unshare(CLONE_NEWNS); best-effort */ +static void remask_after_unshare(void) +{ + const char **p; + char **dp; + + if (!opts.ocibundle) { + if (opts.procfs) { + for (p = proc_mask_critical; *p; p++) + mask_path_now(*p); + for (p = proc_mask_optional; *p; p++) + mask_path_now(*p); + } + + if (opts.sysfs) { + for (p = sys_mask_critical; *p; p++) + mask_path_now(*p); + } + } + + /* same reason as the default masks above: locked in phase 1, an + * OCI bundle's own masks would outlive its unshare(CLONE_NEWNS). */ + if (opts.oci_deferred_masked) + for (dp = opts.oci_deferred_masked; *dp; dp++) + mask_path_now(*dp); + + if (opts.oci_deferred_readonly) + for (dp = opts.oci_deferred_readonly; *dp; dp++) + remount_readonly_now(*dp); +} + +/* /proc/sys is locked read-only for every procfs jail via a self-bind, + * separately from the mask_default_paths() lists above; a deferred- + * userns jail skips it in phase 1 for the same reason it skips the + * masks (see the call site in post_main()), so re-apply it here. */ +static void remount_proc_sys_after_unshare(void) +{ + struct stat s; + + if (!(opts.procfs || opts.ocibundle)) + return; + if (stat("/proc/sys", &s)) + return; + + /* the MS_MOVE below needs a mountpoint to move from, or it's a + * silent EINVAL. */ + if (opts.namespace & CLONE_NEWNET) + mount("/proc/sys/net", "/proc/self/net", "bind", MS_BIND, NULL); + + if (mount("/proc/sys", "/proc/sys", "bind", MS_BIND, NULL)) + return; + if (mount("/proc/sys", "/proc/sys", "bind", MS_REMOUNT | MS_BIND | MS_RDONLY, NULL)) + WARNING("could not remount /proc/sys read-only\n"); + + if (opts.namespace & CLONE_NEWNET) + mount("/proc/self/net", "/proc/sys/net", "bind", MS_MOVE, NULL); +} + static bool resolve_jail_user_gids(int primary_gid) { gid_t *groups = NULL; @@ -2837,6 +2978,30 @@ static const struct blobmsg_policy oci_linux_policy[] = { [OCI_LINUX_ROOTFSPROPAGATION] = { "rootfsPropagation", BLOBMSG_TYPE_STRING }, }; +static int append_deferred_path(char ***list, const char *path) +{ + size_t n = 0; + char **newlist; + + if (*list) + while ((*list)[n]) + n++; + + newlist = realloc(*list, (n + 2) * sizeof(char *)); + if (!newlist) + return ENOMEM; + + newlist[n] = strdup(path); + if (!newlist[n]) { + *list = newlist; + return ENOMEM; + } + newlist[n + 1] = NULL; + *list = newlist; + + return 0; +} + static int parseOCIlinux(struct blob_attr *msg) { struct blob_attr *tb[__OCI_LINUX_MAX]; @@ -2868,19 +3033,35 @@ static int parseOCIlinux(struct blob_attr *msg) return res; } - if (tb[OCI_LINUX_READONLYPATHS]) { - blobmsg_for_each_attr(cur, tb[OCI_LINUX_READONLYPATHS], rem) { - res = add_mount(NULL, blobmsg_get_string(cur), NULL, MS_BIND | MS_REC | MS_RDONLY, 0, NULL, 0); - if (res) - return res; + { + bool defer_userns = (opts.namespace & CLONE_NEWUSER) && opts.setns.user == -1; + + if (tb[OCI_LINUX_READONLYPATHS]) { + blobmsg_for_each_attr(cur, tb[OCI_LINUX_READONLYPATHS], rem) { + if (defer_userns) { + res = append_deferred_path(&opts.oci_deferred_readonly, blobmsg_get_string(cur)); + if (res) + return res; + continue; + } + res = add_mount(NULL, blobmsg_get_string(cur), NULL, MS_BIND | MS_REC | MS_RDONLY, 0, NULL, 0); + if (res) + return res; + } } - } - if (tb[OCI_LINUX_MASKEDPATHS]) { - blobmsg_for_each_attr(cur, tb[OCI_LINUX_MASKEDPATHS], rem) { - res = add_mount((void *)(-1), blobmsg_get_string(cur), NULL, 0, 0, NULL, 0); - if (res) - return res; + if (tb[OCI_LINUX_MASKEDPATHS]) { + blobmsg_for_each_attr(cur, tb[OCI_LINUX_MASKEDPATHS], rem) { + if (defer_userns) { + res = append_deferred_path(&opts.oci_deferred_masked, blobmsg_get_string(cur)); + if (res) + return res; + continue; + } + res = add_mount((void *)(-1), blobmsg_get_string(cur), NULL, 0, 0, NULL, 0); + if (res) + return res; + } } } @@ -3643,6 +3824,8 @@ static void post_main(struct uloop_timeout *t) add_mount(NULL, "/dev/pts", "devpts", MS_NOATIME | MS_NOEXEC | MS_NOSUID, 0, ptsopts, 0); } + bool defer_userns = (opts.namespace & CLONE_NEWUSER) && opts.setns.user == -1; + if (opts.procfs || opts.ocibundle) { add_mount("proc", "/proc", "proc", detect_atime_flag("/proc") | MS_NODEV | MS_NOEXEC | MS_NOSUID, 0, NULL, -1); @@ -3662,8 +3845,17 @@ static void post_main(struct uloop_timeout *t) * succeeds as the bind-mount of /proc/self/net is performed first and then * move-mount of /proc/sys/net follows because 'e' preceeds 'y' in the ASCII * table (and in the alphabet). + * + * A jail that defers its own CLONE_NEWUSER applies this (and all other + * default masking below) itself in phase 2, once it regains its own + * mount namespace: a mount locked here, while still privileged, can + * never be replaced by that same, now less-privileged phase, since a + * locked mount stays present underneath anything mounted over it and + * still counts against the kernel's mount-visibility check for any + * nested runtime's own /proc mount. */ - if (!add_mount(NULL, "/proc/sys", NULL, MS_BIND | MS_RDONLY, 0, NULL, -1)) + if (!defer_userns && + !add_mount(NULL, "/proc/sys", NULL, MS_BIND | MS_RDONLY, 0, NULL, -1)) if (opts.namespace & CLONE_NEWNET) if (!add_mount_inner("/proc/self/net", "/proc/sys/net", NULL, MS_MOVE, 0, NULL, -1)) add_mount_inner("/proc/sys/net", "/proc/self/net", NULL, MS_BIND, 0, NULL, -1); @@ -3673,6 +3865,15 @@ static void post_main(struct uloop_timeout *t) add_mount("sysfs", "/sys", "sysfs", detect_atime_flag("/sys") | MS_NODEV | MS_NOEXEC | MS_NOSUID | MS_RDONLY, 0, NULL, -1); + if (!opts.ocibundle && !defer_userns) { + if (opts.procfs && + (mask_default_paths(proc_mask_critical, true) || + mask_default_paths(proc_mask_optional, false))) + free_and_exit(-1); + if (opts.sysfs && mask_default_paths(sys_mask_critical, true)) + free_and_exit(-1); + } + } if (opts.setns.pid != -1) { From 5a7cc3028c6157cfe726a5c2e5f9fdfb0f2c5582 Mon Sep 17 00:00:00 2001 From: Joshua Covington Date: Mon, 3 Aug 2026 19:56:06 +0000 Subject: [PATCH 10/14] jail: read DT_RPATH/DT_RUNPATH when auto-discovering dependencies ujail's construction-time ELF-dependency walker (elf.c) resolves a scanned binary's DT_NEEDED entries only against a fixed global search list: /lib, /lib64, /usr/lib, plus /etc/ld.so.conf. It does not read a scanned object's own DT_RPATH/DT_RUNPATH tags. A binary that legitimately relies on RPATH/RUNPATH to select its own bundled library over a system library sharing the same SONAME needs no /etc/ld.so.conf entry to run outside a jail, but needs one anyway just so ujail's walker can find and stage the same library. For each object scanned, read its DT_RPATH/DT_RUNPATH tags in the same pass already used for DT_STRTAB, and register their directories ahead of the base search list before processing the object's own DT_NEEDED entries. Check the object's own DT_RUNPATH scope before the inherited (parent) DT_RPATH scope, matching runtime-linker precedence. DT_RUNPATH supersedes DT_RPATH entirely on the same object, per standard ELF semantics. "$ORIGIN"/"${ORIGIN}" expands to the directory containing the object that specified it; fs.c keeps each resolved dependency's real directory available for this rather than discarding it once the soname lookup completes, so $ORIGIN resolves correctly for every object in the tree. RPATH/RUNPATH directories are deduplicated against their own scope lists, since an object's own RPATH/RUNPATH takes priority over generic system paths. DT_RUNPATH is non-transitive: it affects resolving only the direct DT_NEEDED entries of the object that declares it. DT_RPATH is transitive: it stays visible for the rest of the declaring object's own subtree, popped again once that subtree finishes, and is never added to the permanent library search list. Two separate scope lists, rpath_scope and runpath_scope, are saved and restored around each object's own DT_NEEDED loop accordingly. This transitive-RPATH model matches glibc; musl's own ldso/dynlink.c stores DT_RPATH and DT_RUNPATH into a single field (DT_RUNPATH overwriting DT_RPATH when both are present) and does not implement a transitivity distinction between them, resolving both identically per object. The glibc-compatible behavior is used here, matching what most toolchains people build against do. Signed-off-by: Joshua Covington --- jail/elf.c | 507 +++++++++++++++++++++++++++++++++++++++++++++++------ jail/elf.h | 2 +- jail/fs.c | 12 +- 3 files changed, 458 insertions(+), 63 deletions(-) diff --git a/jail/elf.c b/jail/elf.c index 978fc6e..d343b15 100644 --- a/jail/elf.c +++ b/jail/elf.c @@ -14,11 +14,14 @@ #define _GNU_SOURCE #include +#include #include #include #include #include #include +#include +#include #include @@ -29,6 +32,29 @@ struct avl_tree libraries; static LIST_HEAD(library_paths); +/* DT_RPATH search dirs: transitive, inherited by dependencies-of-dependencies. */ +static LIST_HEAD(rpath_scope); + +/* DT_RUNPATH search dirs: NON-transitive (applies only to the defining + * object's direct DT_NEEDED entries), so it's reset and restored on every + * scan call regardless of whether that object defines its own. + */ +static LIST_HEAD(runpath_scope); + +/* Trailing-slash-stripped length of `path`, keeping the one slash of root + * ("/"), so stored dirs are canonical and path_exists_in() can dedup by + * plain length+content comparison (e.g. "/foo" == "/foo/"). + */ +static size_t normalized_len(const char *path) +{ + size_t len = strlen(path); + + while (len > 1 && path[len - 1] == '/') + len--; + + return len; +} + static void alloc_library_path(const char *path) { struct stat s; @@ -37,16 +63,145 @@ static void alloc_library_path(const char *path) struct library_path *p; char *_path; + size_t len = normalized_len(path); p = calloc_a(sizeof(*p), - &_path, strlen(path) + 1); + &_path, len + 1); if (!p) return; - p->path = strcpy(_path, path); + p->path = _path; + memcpy(p->path, path, len); + p->path[len] = '\0'; list_add_tail(&p->list, &library_paths); - DEBUG("adding ld.so path %s\n", path); + DEBUG("adding ld.so path %s\n", p->path); +} + +static bool path_exists_in(struct list_head *list, const char *path) +{ + struct library_path *p; + size_t len = normalized_len(path); + + list_for_each_entry(p, list, list) + if (strlen(p->path) == len && !memcmp(p->path, path, len)) + return true; + + return false; +} + +/* + * If `path` exists and isn't already in `target` or `own`, append it to + * `own` (list_add_tail, not list_add: preserves left-to-right RPATH/RUNPATH + * order once `own` is later spliced onto the front of `target`). + */ +static void alloc_library_path_front(struct list_head *own, struct list_head *target, const char *path) +{ + struct stat s; + struct library_path *p; + char *_path; + size_t len; + + if (stat(path, &s)) + return; + + if (path_exists_in(target, path) || path_exists_in(own, path)) + return; + + len = normalized_len(path); + + p = calloc_a(sizeof(*p), + &_path, len + 1); + if (!p) + return; + + p->path = _path; + memcpy(p->path, path, len); + p->path[len] = '\0'; + + list_add_tail(&p->list, own); + DEBUG("adding rpath/runpath dir %s\n", p->path); +} + +/* + * Expand an RPATH/RUNPATH string ($ORIGIN, empty-token==cwd, ":"-separated) + * and prepend its directories onto `target`, preserving left-to-right order. + * Returns a boundary marker usable with free_scope_dirs() to later remove + * exactly the entries added here. + */ +static struct list_head *add_rpath_dirs(struct list_head *target, const char *rpath, const char *binpath) +{ + struct list_head *boundary = target->next; + LIST_HEAD(own); + char origin_dir[PATH_MAX] = ""; + char cwd[PATH_MAX]; + bool have_cwd = false; + char *copy, *rest, *tok; + char *slash; + + if (!rpath || !*rpath) + return boundary; + + snprintf(origin_dir, sizeof(origin_dir), "%s", binpath); + slash = strrchr(origin_dir, '/'); + if (slash) + *slash = '\0'; + else + origin_dir[0] = '\0'; + + copy = strdup(rpath); + if (!copy) + return boundary; + + rest = copy; + while ((tok = strsep(&rest, ":")) != NULL) { + char resolved[PATH_MAX]; + size_t originlen = 0; + + if (!*tok) { + if (!have_cwd) { + if (!getcwd(cwd, sizeof(cwd))) + continue; + have_cwd = true; + } + alloc_library_path_front(&own, target, cwd); + continue; + } + + if (!strncmp(tok, "$ORIGIN", 7) && + (tok[7] == '\0' || tok[7] == '/')) + originlen = 7; + else if (!strncmp(tok, "${ORIGIN}", 9) && + (tok[9] == '\0' || tok[9] == '/')) + originlen = 9; + + if (originlen) { + snprintf(resolved, sizeof(resolved), "%s%s", origin_dir, tok + originlen); + alloc_library_path_front(&own, target, resolved); + } else { + alloc_library_path_front(&own, target, tok); + } + } + + free(copy); + + list_splice(&own, target); + + return boundary; +} + +/* + * Free every entry that was added to `target` in front of `boundary` + * (i.e. everything a matching add_rpath_dirs() call spliced in), restoring + * `target` to exactly the state it had before that call. + */ +static void free_scope_dirs(struct list_head *target, struct list_head *boundary) +{ + while (target->next != boundary) { + struct library_path *p = list_entry(target->next, struct library_path, list); + list_del(&p->list); + free(p); + } } /* @@ -79,12 +234,30 @@ int lib_open(char **fullpath, const char *file) *fullpath = NULL; + list_for_each_entry(p, &runpath_scope, list) { + snprintf(path, sizeof(path), "%s/%s", p->path, file); + fd = open(path, O_RDONLY|O_CLOEXEC); + if (fd >= 0) { + *fullpath = strdup(path); + return fd; + } + } + + list_for_each_entry(p, &rpath_scope, list) { + snprintf(path, sizeof(path), "%s/%s", p->path, file); + fd = open(path, O_RDONLY|O_CLOEXEC); + if (fd >= 0) { + *fullpath = strdup(path); + return fd; + } + } + list_for_each_entry(p, &library_paths, list) { snprintf(path, sizeof(path), "%s/%s", p->path, file); fd = open(path, O_RDONLY|O_CLOEXEC); if (fd >= 0) { *fullpath = strdup(path); - break; + return fd; } } @@ -102,16 +275,28 @@ const char* find_lib(const char *file) return l->path; } -static int elf64_find_section(const char *map, unsigned int type, unsigned long *offset, unsigned long *size, unsigned long *vaddr) +static int elf64_find_section(const char *map, unsigned long map_size, unsigned int type, unsigned long *offset, unsigned long *size, unsigned long *vaddr) { Elf64_Ehdr *e; Elf64_Phdr *ph; - int i; + unsigned long phoff, phnum, i; + + if (map_size < sizeof(Elf64_Ehdr)) + return -1; e = (Elf64_Ehdr *) map; - ph = (Elf64_Phdr *) (map + e->e_phoff); + phoff = e->e_phoff; + phnum = e->e_phnum; + + if (e->e_phentsize != sizeof(Elf64_Phdr)) + return -1; - for (i = 0; i < e->e_phnum; i++) { + if (phoff >= map_size || phnum > (map_size - phoff) / sizeof(Elf64_Phdr)) + return -1; + + ph = (Elf64_Phdr *) (map + phoff); + + for (i = 0; i < phnum; i++) { if (ph[i].p_type == type) { *offset = ph[i].p_offset; if (size) @@ -125,16 +310,28 @@ static int elf64_find_section(const char *map, unsigned int type, unsigned long return -1; } -static int elf32_find_section(const char *map, unsigned int type, unsigned long *offset, unsigned long *size, unsigned long *vaddr) +static int elf32_find_section(const char *map, unsigned long map_size, unsigned int type, unsigned long *offset, unsigned long *size, unsigned long *vaddr) { Elf32_Ehdr *e; Elf32_Phdr *ph; - int i; + unsigned long phoff, phnum, i; + + if (map_size < sizeof(Elf32_Ehdr)) + return -1; e = (Elf32_Ehdr *) map; - ph = (Elf32_Phdr *) (map + e->e_phoff); + phoff = e->e_phoff; + phnum = e->e_phnum; - for (i = 0; i < e->e_phnum; i++) { + if (e->e_phentsize != sizeof(Elf32_Phdr)) + return -1; + + if (phoff >= map_size || phnum > (map_size - phoff) / sizeof(Elf32_Phdr)) + return -1; + + ph = (Elf32_Phdr *) (map + phoff); + + for (i = 0; i < phnum; i++) { if (ph[i].p_type == type) { *offset = ph[i].p_offset; if (size) @@ -148,114 +345,301 @@ static int elf32_find_section(const char *map, unsigned int type, unsigned long return -1; } -static int elf_find_section(const char *map, unsigned int type, unsigned long *offset, unsigned long *size, unsigned long *vaddr) +static int elf_find_section(const char *map, unsigned long map_size, unsigned int type, unsigned long *offset, unsigned long *size, unsigned long *vaddr) { - int clazz = map[EI_CLASS]; + int clazz; + + if (map_size < 1 + EI_CLASS) + return -1; + + clazz = map[EI_CLASS]; if (clazz == ELFCLASS32) - return elf32_find_section(map, type, offset, size, vaddr); + return elf32_find_section(map, map_size, type, offset, size, vaddr); else if (clazz == ELFCLASS64) - return elf64_find_section(map, type, offset, size, vaddr); + return elf64_find_section(map, map_size, type, offset, size, vaddr); ERROR("unknown elf format %d\n", clazz); return -1; } -static int elf32_scan_dynamic(const char *map, unsigned long dyn_offset, unsigned long dyn_size, long load_offset) +/* + * Resolve a DT_* string-table offset into a NUL-terminated C string, + * rejecting anything (bad DT_STRSZ/d_val) that would read outside the + * mapped file. + */ +static const char *dyn_str(const char *map, unsigned long map_size, + const char *strtab, unsigned long str_size, + unsigned long d_val) { - Elf32_Dyn *dynamic = (Elf32_Dyn *) (map + dyn_offset); - const char *strtab = NULL; + unsigned long tab_off; + const char *s, *tab_end; - while ((void *) dynamic < (void *) (map + dyn_offset + dyn_size)) { - Elf32_Dyn *curr = dynamic; + if (!strtab) + return NULL; - dynamic++; - if (curr->d_tag != DT_STRTAB) - continue; + if (strtab < map) + return NULL; + + tab_off = (unsigned long) (strtab - map); + if (tab_off > map_size) + return NULL; + + if (str_size > map_size - tab_off) + str_size = map_size - tab_off; + + if (d_val >= str_size) + return NULL; - strtab = map + (curr->d_un.d_ptr - load_offset); - break; + s = strtab + d_val; + tab_end = strtab + str_size; + if (!memchr(s, '\0', (size_t) (tab_end - s))) + return NULL; + + return s; +} + +/* + * DT_RPATH/DT_RUNPATH are read and pushed onto their scope lists before the + * DT_NEEDED pass below, so this object's own dependencies resolve through + * them too, as a real runtime linker would. + */ +static int elf32_scan_dynamic(const char *map, unsigned long map_size, unsigned long dyn_offset, unsigned long dyn_size, long load_offset, const char *path) +{ + Elf32_Dyn *dyn_arr = (Elf32_Dyn *) (map + dyn_offset); + unsigned long dyn_count, di; + const char *strtab = NULL; + unsigned long str_size = 0; + unsigned long rpath_val = 0, runpath_val = 0; + bool have_rpath = false, have_runpath = false; + const char *rpath, *runpath; + struct list_head *rpath_boundary = NULL; + struct library_path *p, *ptmp; + int ret = 0; + + /* + * A well-formed PT_DYNAMIC segment is a whole array of Elf32_Dyn + * entries (ELF spec); reject anything else outright instead of + * silently truncating, and iterate by validated index rather than + * pointer comparison so a partial trailing entry can never be + * dereferenced past the segment/map bounds. + */ + if (dyn_size % sizeof(Elf32_Dyn) != 0) { + ERROR("PT_DYNAMIC size is not a multiple of Elf32_Dyn in %s\n", path); + return -1; + } + dyn_count = dyn_size / sizeof(Elf32_Dyn); + + for (di = 0; di < dyn_count; di++) { + Elf32_Dyn *curr = &dyn_arr[di]; + unsigned long strtab_off; + + switch (curr->d_tag) { + case DT_STRTAB: + strtab_off = (unsigned long) curr->d_un.d_ptr - load_offset; + if (strtab_off >= map_size) + continue; + strtab = map + strtab_off; + break; + case DT_STRSZ: + str_size = curr->d_un.d_val; + break; + case DT_RPATH: + rpath_val = curr->d_un.d_val; + have_rpath = true; + break; + case DT_RUNPATH: + runpath_val = curr->d_un.d_val; + have_runpath = true; + break; + default: + break; + } } if (!strtab) return -1; - dynamic = (Elf32_Dyn *) (map + dyn_offset); - while ((void *) dynamic < (void *) (map + dyn_offset + dyn_size)) { - Elf32_Dyn *curr = dynamic; + /* DT_RUNPATH tag presence alone suppresses DT_RPATH (ld.so(8)), + * regardless of whether DT_RUNPATH's value itself resolves. */ + rpath = (have_rpath && !have_runpath) ? dyn_str(map, map_size, strtab, str_size, rpath_val) : NULL; + runpath = have_runpath ? dyn_str(map, map_size, strtab, str_size, runpath_val) : NULL; + + LIST_HEAD(saved_runpath); + list_splice_init(&runpath_scope, &saved_runpath); + if (runpath) + add_rpath_dirs(&runpath_scope, runpath, path); + + if (rpath) + rpath_boundary = add_rpath_dirs(&rpath_scope, rpath, path); + + for (di = 0; di < dyn_count; di++) { + Elf32_Dyn *curr = &dyn_arr[di]; + const char *needed; - dynamic++; if (curr->d_tag != DT_NEEDED) continue; - if (add_path_and_deps(&strtab[curr->d_un.d_val], 1, -1, 1)) - return -1; + needed = dyn_str(map, map_size, strtab, str_size, curr->d_un.d_val); + if (!needed) { + ERROR("corrupt DT_NEEDED entry in %s\n", path); + ret = -1; + break; + } + + if (add_path_and_deps(needed, 1, -1, 1)) { + ret = -1; + break; + } } - return 0; + if (rpath) + free_scope_dirs(&rpath_scope, rpath_boundary); + + list_for_each_entry_safe(p, ptmp, &runpath_scope, list) + free(p); + INIT_LIST_HEAD(&runpath_scope); + list_splice_init(&saved_runpath, &runpath_scope); + + return ret; } -static int elf64_scan_dynamic(const char *map, unsigned long dyn_offset, unsigned long dyn_size, long load_offset) +static int elf64_scan_dynamic(const char *map, unsigned long map_size, unsigned long dyn_offset, unsigned long dyn_size, long load_offset, const char *path) { - Elf64_Dyn *dynamic = (Elf64_Dyn *) (map + dyn_offset); + Elf64_Dyn *dyn_arr = (Elf64_Dyn *) (map + dyn_offset); + unsigned long dyn_count, di; const char *strtab = NULL; + unsigned long str_size = 0; + unsigned long rpath_val = 0, runpath_val = 0; + bool have_rpath = false, have_runpath = false; + const char *rpath, *runpath; + struct list_head *rpath_boundary = NULL; + struct library_path *p, *ptmp; + int ret = 0; + + /* + * A well-formed PT_DYNAMIC segment is a whole array of Elf64_Dyn + * entries (ELF spec); reject anything else outright instead of + * silently truncating, and iterate by validated index rather than + * pointer comparison so a partial trailing entry can never be + * dereferenced past the segment/map bounds. + */ + if (dyn_size % sizeof(Elf64_Dyn) != 0) { + ERROR("PT_DYNAMIC size is not a multiple of Elf64_Dyn in %s\n", path); + return -1; + } + dyn_count = dyn_size / sizeof(Elf64_Dyn); - while ((void *) dynamic < (void *) (map + dyn_offset + dyn_size)) { - Elf64_Dyn *curr = dynamic; - - dynamic++; - if (curr->d_tag != DT_STRTAB) - continue; + for (di = 0; di < dyn_count; di++) { + Elf64_Dyn *curr = &dyn_arr[di]; + unsigned long strtab_off; - strtab = map + (curr->d_un.d_ptr - load_offset); - break; + switch (curr->d_tag) { + case DT_STRTAB: + strtab_off = (unsigned long) curr->d_un.d_ptr - load_offset; + if (strtab_off >= map_size) + continue; + strtab = map + strtab_off; + break; + case DT_STRSZ: + str_size = curr->d_un.d_val; + break; + case DT_RPATH: + rpath_val = curr->d_un.d_val; + have_rpath = true; + break; + case DT_RUNPATH: + runpath_val = curr->d_un.d_val; + have_runpath = true; + break; + default: + break; + } } if (!strtab) return -1; - dynamic = (Elf64_Dyn *) (map + dyn_offset); - while ((void *) dynamic < (void *) (map + dyn_offset + dyn_size)) { - Elf64_Dyn *curr = dynamic; + /* DT_RUNPATH tag presence alone suppresses DT_RPATH (ld.so(8)), + * regardless of whether DT_RUNPATH's value itself resolves. */ + rpath = (have_rpath && !have_runpath) ? dyn_str(map, map_size, strtab, str_size, rpath_val) : NULL; + runpath = have_runpath ? dyn_str(map, map_size, strtab, str_size, runpath_val) : NULL; + + LIST_HEAD(saved_runpath); + list_splice_init(&runpath_scope, &saved_runpath); + if (runpath) + add_rpath_dirs(&runpath_scope, runpath, path); + + if (rpath) + rpath_boundary = add_rpath_dirs(&rpath_scope, rpath, path); + + for (di = 0; di < dyn_count; di++) { + Elf64_Dyn *curr = &dyn_arr[di]; + const char *needed; - dynamic++; if (curr->d_tag != DT_NEEDED) continue; - if (add_path_and_deps(&strtab[curr->d_un.d_val], 1, -1, 1)) - return -1; + needed = dyn_str(map, map_size, strtab, str_size, curr->d_un.d_val); + if (!needed) { + ERROR("corrupt DT_NEEDED entry in %s\n", path); + ret = -1; + break; + } + + if (add_path_and_deps(needed, 1, -1, 1)) { + ret = -1; + break; + } } - return 0; + if (rpath) + free_scope_dirs(&rpath_scope, rpath_boundary); + + list_for_each_entry_safe(p, ptmp, &runpath_scope, list) + free(p); + INIT_LIST_HEAD(&runpath_scope); + list_splice_init(&saved_runpath, &runpath_scope); + + return ret; } -int elf_load_deps(const char *path, const char *map) +int elf_load_deps(const char *path, const char *map, unsigned long map_size) { unsigned long dyn_offset, dyn_size; unsigned long load_offset, load_vaddr; unsigned long interp_offset; - if (elf_find_section(map, PT_INTERP, &interp_offset, NULL, NULL) == 0) { - add_path_and_deps(map+interp_offset, 1, -1, 0); + if (elf_find_section(map, map_size, PT_INTERP, &interp_offset, NULL, NULL) == 0) { + if (interp_offset < map_size && + memchr(map + interp_offset, '\0', map_size - interp_offset)) + add_path_and_deps(map+interp_offset, 1, -1, 0); + else + ERROR("corrupt PT_INTERP entry in %s\n", path); } - if (elf_find_section(map, PT_LOAD, &load_offset, NULL, &load_vaddr)) { + if (elf_find_section(map, map_size, PT_LOAD, &load_offset, NULL, &load_vaddr)) { DEBUG("failed to load the .load section from %s\n", path); return 0; } - if (elf_find_section(map, PT_DYNAMIC, &dyn_offset, &dyn_size, NULL)) { + if (elf_find_section(map, map_size, PT_DYNAMIC, &dyn_offset, &dyn_size, NULL)) { DEBUG("failed to load the .dynamic section from %s\n", path); return 0; } + if (dyn_offset >= map_size || dyn_size > map_size - dyn_offset) { + ERROR("PT_DYNAMIC out of bounds in %s\n", path); + return -1; + } + int clazz = map[EI_CLASS]; if (clazz == ELFCLASS32) - return elf32_scan_dynamic(map, dyn_offset, dyn_size, load_vaddr - load_offset); + return elf32_scan_dynamic(map, map_size, dyn_offset, dyn_size, load_vaddr - load_offset, path); else if (clazz == ELFCLASS64) - return elf64_scan_dynamic(map, dyn_offset, dyn_size, load_vaddr - load_offset); + return elf64_scan_dynamic(map, map_size, dyn_offset, dyn_size, load_vaddr - load_offset, path); ERROR("unknown elf format %d\n", clazz); return -1; @@ -324,6 +708,15 @@ void free_library_search(void) list_for_each_entry_safe(p, ptmp, &library_paths, list) free(p); + /* rpath_scope/runpath_scope should already be empty here (every + * scoped push in elf32/64_scan_dynamic has a matching pop); free + * defensively in case that ever stops holding. */ + list_for_each_entry_safe(p, ptmp, &rpath_scope, list) + free(p); + + list_for_each_entry_safe(p, ptmp, &runpath_scope, list) + free(p); + avl_remove_all_elements(&libraries, l, avl, tmp) free(l); } diff --git a/jail/elf.h b/jail/elf.h index 11fd7e0..046b377 100644 --- a/jail/elf.h +++ b/jail/elf.h @@ -30,7 +30,7 @@ struct library_path { extern struct avl_tree libraries; void alloc_library(const char *path, const char *name); -int elf_load_deps(const char *path, const char *map); +int elf_load_deps(const char *path, const char *map, unsigned long map_size); const char* find_lib(const char *file); void init_library_search(void); int lib_open(char **fullpath, const char *file); diff --git a/jail/fs.c b/jail/fs.c index 8ac7c73..2ee8561 100644 --- a/jail/fs.c +++ b/jail/fs.c @@ -843,6 +843,7 @@ int add_2paths_and_deps(const char *path, const char *path2, int readonly, int e } char *map = NULL; + char *fullpath = NULL; int fd, ret = -1; if (path[0] == '/') { if (avl_find(&mounts, path2)) @@ -854,14 +855,11 @@ int add_2paths_and_deps(const char *path, const char *path2, int readonly, int e } else { if (avl_find(&libraries, path)) return 0; - char *fullpath; fd = lib_open(&fullpath, path); if (fd < 0) return error; - if (fullpath) { + if (fullpath) alloc_library(fullpath, path); - free(fullpath); - } } struct stat s; @@ -895,7 +893,10 @@ int add_2paths_and_deps(const char *path, const char *path2, int readonly, int e } if (map[0] == ELFMAG0 && map[1] == ELFMAG1 && map[2] == ELFMAG2 && map[3] == ELFMAG3) { - ret = elf_load_deps(path, map); + /* Pass the resolved fullpath, not the bare soname, so + * elf_load_deps() can expand a $ORIGIN-relative DT_RPATH/ + * DT_RUNPATH in this object against its real directory. */ + ret = elf_load_deps(fullpath ? fullpath : path, map, s.st_size); goto out; } @@ -906,6 +907,7 @@ int add_2paths_and_deps(const char *path, const char *path2, int readonly, int e close(fd); if (map) munmap(map, s.st_size); + free(fullpath); return ret; } From 285a3e7aae36db06a8acb86c35c08c7432e97fdc Mon Sep 17 00:00:00 2001 From: Joshua Covington Date: Mon, 3 Aug 2026 19:56:42 +0000 Subject: [PATCH 11/14] jail: tolerate EPERM on setgroups() when joining an external userns Joining an external user namespace (-j, opts.setns.user) always calls setgroups(0, NULL) to drop supplementary groups before root inside becomes usable. Writing a gid_map for a user namespace requires setgroups=deny to have been set for that namespace first - an unconditional kernel rule, independent of privilege level - and that denial is permanent for the lifetime of the namespace: no process, however privileged, can call setgroups() in that namespace again once a gid_map has been written this way. Any external namespace with a genuinely working gid_map (needed for setregid()/setreuid() to succeed at all) therefore has setgroups permanently denied, and the unconditional setgroups(0, NULL) call here always fails against it with EPERM, aborting the join. Tolerate EPERM specifically, with a warning, and continue without dropping supplementary groups in that case; any other error remains fatal as before. Signed-off-by: Joshua Covington --- jail/jail.c | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/jail/jail.c b/jail/jail.c index 12f1c34..fc47ac0 100644 --- a/jail/jail.c +++ b/jail/jail.c @@ -1946,8 +1946,14 @@ static int exec_jail(void *arg) free_and_exit(EXIT_FAILURE); } if (setgroups(0, NULL) < 0) { - ERROR("setgroups\n"); - free_and_exit(EXIT_FAILURE); + if (errno != EPERM) { + ERROR("setgroups\n"); + free_and_exit(EXIT_FAILURE); + } + WARNING("setgroups(0, NULL) denied by the joined " + "userns (setgroups=deny is permanent once a " + "gid_map is written); continuing without " + "dropping supplementary groups\n"); } } From 5e8ebe8058d2559f0dab95d6eee9b67fc93e7b1d Mon Sep 17 00:00:00 2001 From: Daniel Golle Date: Tue, 4 Aug 2026 00:39:40 +0100 Subject: [PATCH 12/14] initd: provide a read-only noafile for ujail path masking ujail masks file paths such as /proc/kcore by bind-mounting a 0-byte mode-0000 file over them, so any access fails with an error instead of being silently swallowed the way a /dev/null bind would. Jails which defer their own user namespace re-apply these masks from inside the container; a mask source file created by the jail child itself is owned by the very host uid container root maps to, since the child runs with euid root_map_uid, and container root can therefore chmod or unlink it. Create a single canonical mask source at boot instead, right after mounting /tmp: a 0-byte mode-0000 file on a dedicated 4k tmpfs at /tmp/.ujail, remounted read-only at superblock level. The superblock belongs to the initial user namespace, so no user namespace can ever remount it read-write; chmod, unlink and write fail with EROFS through every path, including fresh bind mounts created from inside a container, and that holds even when host root is mapped into the container. Creating the file once during early boot also means jails starting in parallel never need a concurrent create-if-missing dance. Signed-off-by: Daniel Golle --- container.h | 3 +++ initd/early.c | 19 +++++++++++++++++++ 2 files changed, 22 insertions(+) diff --git a/container.h b/container.h index dede696..63b5a8c 100644 --- a/container.h +++ b/container.h @@ -18,6 +18,9 @@ #include #include +#define PROCD_NOAFILE_DIR "/tmp/.ujail" +#define PROCD_NOAFILE PROCD_NOAFILE_DIR "/noafile" + static inline bool is_container() { struct stat s; int r = stat("/.dockerenv", &s); diff --git a/initd/early.c b/initd/early.c index aa164d7..2c952be 100644 --- a/initd/early.c +++ b/initd/early.c @@ -52,6 +52,24 @@ early_console(const char *dev) fcntl(STDERR_FILENO, F_SETFL, fcntl(STDERR_FILENO, F_GETFL) | O_NONBLOCK); } +static void +early_noafile(void) +{ + int fd; + + mkdir(PROCD_NOAFILE_DIR, 0000); + if (mount("tmpfs", PROCD_NOAFILE_DIR, "tmpfs", + MS_NOSUID | MS_NODEV | MS_NOEXEC, "size=4k,mode=000")) + return; + + fd = creat(PROCD_NOAFILE, 0000); + if (fd >= 0) + close(fd); + + mount(NULL, PROCD_NOAFILE_DIR, NULL, + MS_REMOUNT | MS_RDONLY | MS_NOSUID | MS_NODEV | MS_NOEXEC, NULL); +} + static void early_mounts(void) { @@ -73,6 +91,7 @@ early_mounts(void) early_console("/dev/console"); mount("tmpfs", "/tmp", "tmpfs", MS_NOSUID | MS_NODEV | MS_NOATIME, "mode=01777"); + early_noafile(); mkdir("/tmp/shm", 01777); mkdir("/tmp/run", 0755); From 18ed46faebb09f75dec4ec72a35344437cbfde6a Mon Sep 17 00:00:00 2001 From: Daniel Golle Date: Tue, 4 Aug 2026 00:41:27 +0100 Subject: [PATCH 13/14] jail: source deferred file masks from procd's read-only noafile Every deferred-userns jail currently constructs its own mask source in build_jail_noafile(): a directory under its /dev, a dedicated tmpfs mount, a setfsuid() dance to create the file as host root and a read-only remount to lock the superblock, repeated by every such jail and leaving each one an extra tmpfs superblock just to hold a single immutable empty file. Drop build_jail_noafile() and instead bind procd's canonical noafile, created once at boot on a read-only tmpfs superblock owned by the initial user namespace, to JAIL_NOAFILE inside the jail. The bind is queued through the regular mount list, so it is set up while still privileged, survives pivot_root, and arrives in the container's own mount namespace locked. Every modification attempt from inside then fails: chmod, unlink and write with EROFS on the read-only superblock no user namespace can remount, umount with EINVAL on the locked mount, and this holds for any uid mapping. The queued mount uses a fatal error level, so a jail which will re-apply masks in phase 2 refuses to start when the noafile is missing instead of silently degrading to unmasked files. Signed-off-by: Daniel Golle --- jail/fs.c | 32 ++------------------------------ jail/fs.h | 5 ++++- jail/jail.c | 9 ++++----- 3 files changed, 10 insertions(+), 36 deletions(-) diff --git a/jail/fs.c b/jail/fs.c index 2ee8561..1969425 100644 --- a/jail/fs.c +++ b/jail/fs.c @@ -26,7 +26,6 @@ #include #include #include -#include #include #include @@ -42,8 +41,6 @@ #include "log.h" #define UJAIL_NOAFILE "/tmp/.ujailnoafile" -#define JAIL_NOAFILE_DIR "/dev/.ujail" -#define JAIL_NOAFILE JAIL_NOAFILE_DIR "/noafile" /* * mnt_already_visible() requires a new mount's atime class to match @@ -134,37 +131,12 @@ int sys_mount_setattr(int dfd, const char *path, unsigned flags, struct ujail_mo struct avl_tree mounts; -/* ownership alone can't survive an explicit 0->0 uidMapping, or a - * directory-owner unlink; a locked, read-only superblock can. - * The directory keeps its execute bit: traversal to the one, known - * path is needed by mask_path_now() itself; read (listing) and write - * (creating/removing entries) stay denied. */ -int build_jail_noafile(void) -{ - int fd, old_fsuid; - - if (mkdir(JAIL_NOAFILE_DIR, 0111)) - return -1; - if (mount("none", JAIL_NOAFILE_DIR, "tmpfs", - MS_NOSUID | MS_NODEV | MS_NOEXEC, "size=4k,mode=111")) - return -1; - - old_fsuid = setfsuid(0); - fd = creat(JAIL_NOAFILE, 0000); - setfsuid(old_fsuid); - if (fd < 0) - return -1; - close(fd); - - return mount(NULL, JAIL_NOAFILE_DIR, NULL, - MS_REMOUNT | MS_RDONLY | MS_NOSUID | MS_NODEV | MS_NOEXEC, NULL); -} - /* same masking as do_mount()'s is_mask branch, applied immediately * against an absolute path instead of queued through jail_root. * * UJAIL_NOAFILE lives under the pre-pivot root, gone by the time this - * runs; JAIL_NOAFILE (see build_jail_noafile()) is used instead. */ + * runs; JAIL_NOAFILE, a locked bind of procd's read-only noafile + * queued for deferred-userns jails, is used instead. */ int mask_path_now(const char *path) { struct stat s; diff --git a/jail/fs.h b/jail/fs.h index 18fa51b..9b3ee8b 100644 --- a/jail/fs.h +++ b/jail/fs.h @@ -17,6 +17,10 @@ #include #include +#include "../container.h" + +#define JAIL_NOAFILE "/dev/.ujailnoafile" + int add_mount(const char *source, const char *target, const char *filesystemtype, unsigned long mountflags, unsigned long propflags, const char *optstr, int error); int add_mount_inner(const char *source, const char *target, const char *filesystemtype, @@ -24,7 +28,6 @@ int add_mount_inner(const char *source, const char *target, const char *filesyst int add_mount_bind(const char *path, int readonly, int error); int add_mount_fd(int fd, const char *target, int error); int mask_path_now(const char *path); -int build_jail_noafile(void); /* open_tree()/mount_setattr() wrappers - no glibc wrappers yet. * Fields must match the kernel's struct mount_attr layout exactly diff --git a/jail/jail.c b/jail/jail.c index fc47ac0..22419ef 100644 --- a/jail/jail.c +++ b/jail/jail.c @@ -1174,11 +1174,6 @@ static void enter_jail_fs(void) ERROR("create_devices() failed\n"); free_and_exit(-1); } - if ((opts.namespace & CLONE_NEWUSER) && opts.setns.user == -1 && - build_jail_noafile()) { - ERROR("build_jail_noafile() failed\n"); - free_and_exit(-1); - } if (opts.ronly) mount(NULL, "/", "bind", MS_REMOUNT | MS_BIND | MS_RDONLY, 0); @@ -3832,6 +3827,10 @@ static void post_main(struct uloop_timeout *t) bool defer_userns = (opts.namespace & CLONE_NEWUSER) && opts.setns.user == -1; + if (defer_userns) + add_mount(PROCD_NOAFILE, JAIL_NOAFILE, NULL, + MS_BIND | MS_RDONLY | MS_NOSUID | MS_NOEXEC | MS_NODEV, 0, NULL, -1); + if (opts.procfs || opts.ocibundle) { add_mount("proc", "/proc", "proc", detect_atime_flag("/proc") | MS_NODEV | MS_NOEXEC | MS_NOSUID, 0, NULL, -1); From debe8385837d81f7d600ea0e1ce3260c60dd4b75 Mon Sep 17 00:00:00 2001 From: Joshua Covington Date: Wed, 12 Aug 2026 17:16:14 +0000 Subject: [PATCH 14/14] initd: log if the noafile lock-down remount fails The final MS_REMOUNT | MS_RDONLY in early_noafile() was unchecked. A failure there leaves /tmp/.ujail silently read-write, so every jail then masks against a weaker, uid-mapping-dependent noafile with nothing logged anywhere. Signed-off-by: Joshua Covington --- initd/early.c | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/initd/early.c b/initd/early.c index 2c952be..dae6068 100644 --- a/initd/early.c +++ b/initd/early.c @@ -66,8 +66,9 @@ early_noafile(void) if (fd >= 0) close(fd); - mount(NULL, PROCD_NOAFILE_DIR, NULL, - MS_REMOUNT | MS_RDONLY | MS_NOSUID | MS_NODEV | MS_NOEXEC, NULL); + if (mount(NULL, PROCD_NOAFILE_DIR, NULL, + MS_REMOUNT | MS_RDONLY | MS_NOSUID | MS_NODEV | MS_NOEXEC, NULL)) + ERROR("failed to lock %s read-only: %m\n", PROCD_NOAFILE_DIR); } static void