From 72657d135f019468e33f6cc44d628756538bb16f Mon Sep 17 00:00:00 2001 From: Joshua Covington Date: Fri, 18 Sep 2026 17:59:24 +0000 Subject: [PATCH 1/9] jail: drop MS_PRIVATE remount from the deferred userns path userns_wait_idmaps() remounts / MS_REC|MS_PRIVATE after the user namespace exists. On the clone() path the mount namespace is owned by that userns and the call succeeds. On the deferred path the mount namespace is still owned by the initial userns, so after unshare(CLONE_NEWUSER) the call returns EPERM and every jail joining a namespace by path fails with "private mount failed". isolate_mountns_and_detach_inherited() already makes the tree private on the deferred path while privileged. Move the remount to the clone() call site in exec_jail(). Signed-off-by: Joshua Covington --- jail/jail.c | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/jail/jail.c b/jail/jail.c index acae6dc..2e67a80 100644 --- a/jail/jail.c +++ b/jail/jail.c @@ -1602,12 +1602,6 @@ static int userns_wait_idmaps(void) return -1; } - if ((opts.namespace & CLONE_NEWNS) && - mount("none", "/", "none", MS_REC | MS_PRIVATE, NULL)) { - ERROR("private mount failed: %m\n"); - return -1; - } - return 0; } @@ -2894,9 +2888,16 @@ static int exec_jail(void *arg) false, recv_fds, nrecv, &extroot_idmap_fd, &overlay_idmap_fd); - if ((opts.namespace & CLONE_NEWUSER) && !userns_deferred() && - userns_wait_idmaps()) - return EXIT_FAILURE; + if ((opts.namespace & CLONE_NEWUSER) && !userns_deferred()) { + if (userns_wait_idmaps()) + return EXIT_FAILURE; + + if ((opts.namespace & CLONE_NEWNS) && + mount("none", "/", "none", MS_REC | MS_PRIVATE, NULL)) { + ERROR("private mount failed: %m\n"); + return EXIT_FAILURE; + } + } if (opts.setns.user != -1 && (opts.namespace & CLONE_NEWNS) && unshare(CLONE_NEWNS)) { From 4d3e3c3efaca3f1be3b37047dcfd916f770b94d9 Mon Sep 17 00:00:00 2001 From: Joshua Covington Date: Fri, 18 Sep 2026 17:59:24 +0000 Subject: [PATCH 2/9] jail: keep locked flags on phase-2 read-only remounts remount_proc_sys_after_unshare() and remount_readonly_now() run after the deferred unshare(CLONE_NEWNS), in a mount namespace that does not own the mounts copied into it. Those mounts carry MNT_LOCK_{NOSUID,NODEV,NOEXEC,ATIME}; a remount passing only MS_RDONLY asks to clear them and fails with EPERM. /proc/sys stayed writable in every deferred-userns jail, and an OCI readonlyPath would as well. do_mount() already ORs in the flags read back from mountinfo for phase 1. Add bind_remount_readonly() doing the same and use it in both phase-2 helpers. Signed-off-by: Joshua Covington --- jail/fs.c | 18 ++++++++++++++++++ jail/fs.h | 1 + jail/jail.c | 10 +++------- 3 files changed, 22 insertions(+), 7 deletions(-) diff --git a/jail/fs.c b/jail/fs.c index c0a1af7..4e8473b 100644 --- a/jail/fs.c +++ b/jail/fs.c @@ -424,6 +424,24 @@ static unsigned long mountinfo_current_flags(const char *path) return flags; } +/* + * Self-bind @path and remount it read-only, preserving the flags already in + * effect. Mounts copied in by unshare(CLONE_NEWNS) under a userns that does + * not own them are MNT_LOCK_{NOSUID,NODEV,NOEXEC,ATIME}; a remount clearing + * any of those fails with EPERM. + */ +int bind_remount_readonly(const char *path, unsigned long flags) +{ + if (mount(path, path, "bind", MS_BIND | (flags & MS_REC), NULL)) + return -1; + + flags |= MS_REMOUNT | MS_BIND | MS_RDONLY | mountinfo_current_flags(path); + if (mount(path, path, "bind", flags, NULL)) + return -1; + + return 0; +} + static bool fs_userns; void jail_fs_set_userns(bool enabled) diff --git a/jail/fs.h b/jail/fs.h index d87a209..6bed98c 100644 --- a/jail/fs.h +++ b/jail/fs.h @@ -44,6 +44,7 @@ int fs_mount_enable_idmap(const char *target, uint32_t uid, uint32_t gid); char *resolve_mount_source(const char *source); int add_mount_fd(int fd, const char *target, int error); int mask_path_now(const char *path); +int bind_remount_readonly(const char *path, unsigned long flags); /* 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 2e67a80..02090b4 100644 --- a/jail/jail.c +++ b/jail/jail.c @@ -1854,9 +1854,7 @@ static int remount_readonly_now(const char *path) 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)) + if (bind_remount_readonly(path, MS_REC)) return -1; DEBUG("read-only path %s\n", path); @@ -1913,10 +1911,8 @@ static void remount_proc_sys_after_unshare(void) 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 (bind_remount_readonly("/proc/sys", 0)) + WARNING("could not remount /proc/sys read-only: %m\n"); if (opts.namespace & CLONE_NEWNET) mount("/proc/self/net", "/proc/sys/net", "bind", MS_MOVE, NULL); From ffc4d697c400c4ca3283e13bcf89faddc93f2db2 Mon Sep 17 00:00:00 2001 From: Joshua Covington Date: Fri, 18 Sep 2026 18:37:00 +0000 Subject: [PATCH 3/9] jail: keep locked flags when remounting a noafile mask The file masks (/proc/kcore, /proc/sysrq-trigger, OCI maskedPaths) bind the noafile and remount it with a hard-coded MS_RELATIME. Under a userns from clone() the inherited mounts are MNT_LOCK_ATIME, the bind inherits that, and changing the atime mode is EPERM. procd mounts /tmp with MS_NOATIME, so on OpenWrt the remount fails for every -f -p jail: critical masks make mount_all() fail without a message, optional ones are left read-write. Not visible while the masks were applied privileged; exposed by 6aa23a8 ("jail: give the container's namespaces to its own user namespace"). Split the remount half of bind_remount_readonly() into remount_readonly() and use it for both noafile mask sites. Signed-off-by: Joshua Covington --- jail/fs.c | 17 ++++++++++------- jail/fs.h | 1 + 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/jail/fs.c b/jail/fs.c index 4e8473b..7864c9a 100644 --- a/jail/fs.c +++ b/jail/fs.c @@ -339,7 +339,7 @@ int mask_path_now(const char *path) } else { if (mount(JAIL_NOAFILE, path, "bind", MS_BIND, NULL)) return -1; - if (mount(JAIL_NOAFILE, path, "bind", MS_REMOUNT | MS_BIND | MS_RDONLY | MS_NOSUID | MS_NOEXEC | MS_NODEV | MS_RELATIME, NULL)) + if (remount_readonly(path, MS_NOSUID | MS_NOEXEC | MS_NODEV)) return -1; } @@ -430,16 +430,19 @@ static unsigned long mountinfo_current_flags(const char *path) * not own them are MNT_LOCK_{NOSUID,NODEV,NOEXEC,ATIME}; a remount clearing * any of those fails with EPERM. */ +int remount_readonly(const char *path, unsigned long flags) +{ + flags |= MS_REMOUNT | MS_BIND | MS_RDONLY | mountinfo_current_flags(path); + + return mount(NULL, path, NULL, flags, NULL); +} + int bind_remount_readonly(const char *path, unsigned long flags) { if (mount(path, path, "bind", MS_BIND | (flags & MS_REC), NULL)) return -1; - flags |= MS_REMOUNT | MS_BIND | MS_RDONLY | mountinfo_current_flags(path); - if (mount(path, path, "bind", flags, NULL)) - return -1; - - return 0; + return remount_readonly(path, flags); } static bool fs_userns; @@ -500,7 +503,7 @@ static int do_mount(const char *root, const char *orig_source, const char *targe 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_RELATIME, NULL)) + if (remount_readonly(new, MS_NOSUID | MS_NOEXEC | MS_NODEV)) return error; } diff --git a/jail/fs.h b/jail/fs.h index 6bed98c..c7e9379 100644 --- a/jail/fs.h +++ b/jail/fs.h @@ -44,6 +44,7 @@ int fs_mount_enable_idmap(const char *target, uint32_t uid, uint32_t gid); char *resolve_mount_source(const char *source); int add_mount_fd(int fd, const char *target, int error); int mask_path_now(const char *path); +int remount_readonly(const char *path, unsigned long flags); int bind_remount_readonly(const char *path, unsigned long flags); /* open_tree()/mount_setattr() wrappers - no glibc wrappers yet. From 2978235aa865b90afbb42fd8f1e799e147347eda Mon Sep 17 00:00:00 2001 From: Joshua Covington Date: Fri, 18 Sep 2026 17:59:24 +0000 Subject: [PATCH 4/9] jail: apply root_map_uid to a joined user namespace Several checks key on CLONE_NEWUSER, which -j never sets: the euid switched to before clone() so that files created for the jail belong to the uid its root maps to, the owner of staged device nodes, the overlay upper chown, the console hand-over, the securebits restore and the devpts gid. With -j these ran as host root and the jail's root saw nobody-owned files it could not write. root_map_uid was also never derived for a joined namespace. Have a helper setns() into it and report the mapping of uid 0 from its own uid_map; this also covers a namespace kept alive only by a bind-mounted nsfs file, as OCI runtimes hand them over, where no process exists to read /proc//uid_map from. Keep the default with a warning on failure. Add jail_has_userns() and use it in place of the CLONE_NEWUSER tests. Signed-off-by: Joshua Covington --- jail/jail.c | 79 ++++++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 72 insertions(+), 7 deletions(-) diff --git a/jail/jail.c b/jail/jail.c index 02090b4..51ff2f0 100644 --- a/jail/jail.c +++ b/jail/jail.c @@ -284,6 +284,12 @@ static inline bool userns_deferred(void) false; } +/* jail root maps to a host uid: own userns or a joined one */ +static inline bool jail_has_userns(void) +{ + return (opts.namespace & CLONE_NEWUSER) || opts.setns.user != -1; +} + static inline bool has_namespaces(void) { return ((opts.setns.pid != -1) || @@ -886,7 +892,7 @@ static struct mknod_args default_devices[] = { static int prepare_jail_dev(void) { struct mknod_args **cur, *curdef; - uid_t base = (opts.namespace & CLONE_NEWUSER) ? opts.root_map_uid : 0; + uid_t base = jail_has_userns() ? opts.root_map_uid : 0; mode_t oldmask = umask(0); char path[PATH_MAX], *tmp; int consfd; @@ -1329,7 +1335,7 @@ static int build_jail_fs(void) return -1; } - jail_fs_set_userns((opts.namespace & CLONE_NEWUSER) || (opts.setns.user != -1)); + jail_fs_set_userns(jail_has_userns()); if (mount_all(jail_root, jail_dev)) { ERROR("mount_all() failed\n"); @@ -3087,7 +3093,7 @@ static void post_start_hook(void) /* restore securebits back to normal (and lock them if not in userns) */ if (opts.capset.apply) { - if (prctl(PR_SET_SECUREBITS, (opts.namespace & CLONE_NEWUSER)?0: + if (prctl(PR_SET_SECUREBITS, jail_has_userns() ? 0 : SECBIT_KEEP_CAPS_LOCKED|SECBIT_NO_SETUID_FIXUP_LOCKED|SECBIT_NOROOT_LOCKED)) { ERROR("prctl(PR_SET_SECUREBITS) failed: %m\n"); free_and_exit(EXIT_FAILURE); @@ -3906,6 +3912,62 @@ static int jail_join_ns(char *arg) return 0; } +/* + * Set opts.root_map_uid from a joined userns. A helper enters it with + * setns() and reports what uid 0 maps to from its own uid_map; that works + * for a namespace kept alive only by a bind-mounted nsfs file, which has no + * process to read /proc//uid_map from. The default stays on failure. + */ +static void userns_root_map(int nsfd) +{ + uint32_t inside, outside, count, uid = 0; + bool found = false; + int pfd[2], status; + pid_t pid; + FILE *f; + + if (pipe2(pfd, O_CLOEXEC)) + return; + + pid = fork(); + if (pid < 0) { + close(pfd[0]); + close(pfd[1]); + return; + } + + if (!pid) { + close(pfd[0]); + if (setns(nsfd, CLONE_NEWUSER)) + _exit(1); + f = fopen("/proc/self/uid_map", "re"); + if (!f) + _exit(1); + while (fscanf(f, "%u %u %u", &inside, &outside, &count) == 3) { + if (inside == 0 && count >= 1) { + if (write(pfd[1], &outside, sizeof(outside)) == sizeof(outside)) + _exit(0); + break; + } + } + _exit(1); + } + + close(pfd[1]); + if (read(pfd[0], &uid, sizeof(uid)) == sizeof(uid)) + found = true; + close(pfd[0]); + waitpid(pid, &status, 0); + + if (found) { + opts.root_map_uid = uid; + DEBUG("root of the joined user namespace is uid %d\n", uid); + } else { + WARNING("cannot determine the root uid of the joined user namespace; " + "assuming %d\n", opts.root_map_uid); + } +} + static void get_jail_root_user(bool is_gidmap, uint32_t container_id, uint32_t host_id, uint32_t size) { if (container_id == 0 && size >= 1) @@ -6467,6 +6529,9 @@ int main(int argc, char **argv) } } + if (opts.setns.user != -1) + userns_root_map(opts.setns.user); + for (credidx = 0; credidx < n_cred_targets; credidx++) { ret = fs_mount_enable_idmap(cred_targets[credidx], opts.pw_uid > 0 ? (uint32_t)opts.pw_uid : 0, @@ -6749,7 +6814,7 @@ static void post_main(struct uloop_timeout *t) 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); { - const char *ptsopts = (opts.namespace & CLONE_NEWUSER) ? + const char *ptsopts = jail_has_userns() ? "newinstance,ptmxmode=0666,mode=0620,gid=0" : "newinstance,ptmxmode=0666,mode=0620,gid=5"; @@ -6830,7 +6895,7 @@ static void post_main(struct uloop_timeout *t) free_and_exit(EXIT_FAILURE); } - if (opts.namespace & CLONE_NEWUSER) { + if (jail_has_userns()) { if (opts.overlaydir) { if (chown(opts.overlaydir, opts.root_map_uid, opts.root_map_uid)) { ERROR("chown(%s, %d, %d) failed: %m\n", @@ -6881,12 +6946,12 @@ static void post_main(struct uloop_timeout *t) close(parent_master); } else { console_fd = parent_master; - if ((opts.namespace & CLONE_NEWUSER) && seteuid(0)) { + if (jail_has_userns() && seteuid(0)) { ERROR("seteuid(0) failed: %m\n"); free_and_exit(EXIT_FAILURE); } pass_console(console_fd); - if ((opts.namespace & CLONE_NEWUSER) && seteuid(opts.root_map_uid)) { + if (jail_has_userns() && seteuid(opts.root_map_uid)) { ERROR("seteuid(%d) failed: %m\n", opts.root_map_uid); free_and_exit(EXIT_FAILURE); } From cbf87d568d0291c94520fdad5475026856a1dfad Mon Sep 17 00:00:00 2001 From: Joshua Covington Date: Fri, 18 Sep 2026 17:59:24 +0000 Subject: [PATCH 5/9] jail: join an external userns after build_jail_fs() -j entered the user namespace at the top of exec_jail(), before any mount. Mounting procfs needs CAP_SYS_ADMIN in the userns owning the pid namespace; that pidns comes from clone() in the parent and belongs to the initial userns, which the joined one can never own. Every -j user jail with -p failed with EPERM on its own /proc mount. Treat the join as the deferred case: build the jail fs privileged, then setns() in enter_userns() where the deferred userns is created, and follow the same phase-2 path. Default masks, the read-only /proc/sys and OCI maskedPaths/readonlyPaths thereby leave the locked phase-1 mount list for these jails as well. Factor the credential drop shared by all three paths into userns_become_root(). Signed-off-by: Joshua Covington --- jail/jail.c | 99 ++++++++++++++++++++++++++--------------------------- 1 file changed, 49 insertions(+), 50 deletions(-) diff --git a/jail/jail.c b/jail/jail.c index 51ff2f0..2a74a00 100644 --- a/jail/jail.c +++ b/jail/jail.c @@ -266,10 +266,17 @@ static char console_slave_name[64]; * Joining a namespace by path needs privilege in the user namespace owning it, * which our own user namespace would take away, so in that case it is created * after the joins instead of by clone(). crun makes the same distinction. + * + * A userns joined with -j (opts.setns.user) drops privilege the same way and + * never owns the pidns clone() created, so procfs must be mounted before + * entering it. Both cases are entered in enter_userns(), after build_jail_fs(). */ static inline bool userns_deferred(void) { - if (!(opts.namespace & CLONE_NEWUSER) || opts.setns.user != -1) + if (opts.setns.user != -1) + return true; + + if (!(opts.namespace & CLONE_NEWUSER)) return false; return (opts.setns.pid != -1) || @@ -1232,8 +1239,7 @@ static int mountinfo_detach_children(const char *prefix) /* * 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(). + * enter_userns() drops privilege; see exec_jail(). */ static int isolate_mountns_and_detach_inherited(void) { @@ -1544,6 +1550,7 @@ static void free_and_exit(int ret) exit(ret); } +static int setns_open(unsigned long nstype); static void post_jail_fs(void); static void enter_userns(void); static int userns_wait_idmaps(void); @@ -1599,13 +1606,26 @@ static int userns_wait_idmaps(void) return -1; } + return 0; +} + +/* become root in the userns just entered */ +static int userns_become_root(void) +{ if (setregid(0, 0) < 0 || setreuid(0, 0) < 0) { - ERROR("cannot become root in our user namespace: %m\n"); + ERROR("cannot become root in the user namespace: %m\n"); return -1; } + if (setgroups(0, NULL) < 0) { - ERROR("setgroups: %m\n"); - return -1; + /* setgroups=deny is permanent once gid_map is written; only a + * joined userns can have it */ + if (errno != EPERM || opts.setns.user == -1) { + ERROR("setgroups: %m\n"); + return -1; + } + WARNING("setgroups(0, NULL) denied by the joined userns; " + "continuing without dropping supplementary groups\n"); } return 0; @@ -1618,12 +1638,25 @@ static void enter_userns(void) return; } - if (unshare(CLONE_NEWUSER)) { - ERROR("unshare(CLONE_NEWUSER) failed: %m\n"); - free_and_exit(-1); + if (opts.setns.user != -1) { + /* maps exist already, no handshake */ + int ret = setns_open(CLONE_NEWUSER); + + if (ret) { + ERROR("failed to join user namespace: %s\n", strerror(ret)); + free_and_exit(-1); + } + } else { + if (unshare(CLONE_NEWUSER)) { + ERROR("unshare(CLONE_NEWUSER) failed: %m\n"); + free_and_exit(-1); + } + + if (userns_wait_idmaps()) + free_and_exit(-1); } - if (userns_wait_idmaps()) + if (userns_become_root()) free_and_exit(-1); #ifdef CLONE_NEWTIME @@ -2851,24 +2884,17 @@ static int exec_jail(void *arg) } /* - * Joining an external userns drops privilege immediately, so this has - * to run before it. A userns of our own owns the mount namespace it - * was created with and locks everything inherited into it, so there - * the detach neither works nor is needed. + * Must run before enter_userns() drops privilege over the inherited + * mounts. A userns from clone() owns its mntns and has everything + * inherited MNT_LOCKED, so there the detach is neither possible nor + * needed. */ - if ((opts.namespace & CLONE_NEWNS) && - (userns_deferred() || opts.setns.user != -1) && + if ((opts.namespace & CLONE_NEWNS) && userns_deferred() && isolate_mountns_and_detach_inherited()) { ERROR("failed to detach inherited mounts\n"); return EXIT_FAILURE; } - ret = setns_open(CLONE_NEWUSER); - if (ret) { - ERROR("failed to join user namespace: %s\n", strerror(ret)); - return EXIT_FAILURE; - } - buf[0] = 'i'; if (write(pipes[1], buf, 1) < 1) { ERROR("can't write to parent\n"); @@ -2891,7 +2917,7 @@ static int exec_jail(void *arg) recv_fds, nrecv, &extroot_idmap_fd, &overlay_idmap_fd); if ((opts.namespace & CLONE_NEWUSER) && !userns_deferred()) { - if (userns_wait_idmaps()) + if (userns_wait_idmaps() || userns_become_root()) return EXIT_FAILURE; if ((opts.namespace & CLONE_NEWNS) && @@ -2901,12 +2927,6 @@ static int exec_jail(void *arg) } } - 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); @@ -2916,27 +2936,6 @@ static int exec_jail(void *arg) free_and_exit(EXIT_FAILURE); } - if (opts.setns.user != -1) { - if (setregid(0, 0) < 0) { - ERROR("setgid\n"); - free_and_exit(EXIT_FAILURE); - } - if (setreuid(0, 0) < 0) { - ERROR("setuid\n"); - free_and_exit(EXIT_FAILURE); - } - if (setgroups(0, NULL) < 0) { - 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"); - } - } - #ifdef CLONE_NEWTIME if ((opts.namespace & CLONE_NEWTIME) && opts.setns.time == -1 && !userns_deferred() && timens_create()) From 2bd2be075a0f34c8f7b7eba841e89990996833ea Mon Sep 17 00:00:00 2001 From: Joshua Covington Date: Fri, 18 Sep 2026 17:59:24 +0000 Subject: [PATCH 6/9] jail: fail on an unresolvable -j specification The return value of jail_join_ns() was ignored. A -j naming an exited pid or an unknown namespace type left every requested namespace unset and the jail started without them; a -j user jail whose target was gone ran as host root in the initial userns with nothing logged. Signed-off-by: Joshua Covington --- jail/jail.c | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/jail/jail.c b/jail/jail.c index 2a74a00..0882032 100644 --- a/jail/jail.c +++ b/jail/jail.c @@ -6307,9 +6307,18 @@ int main(int argc, char **argv) opts.namespace |= CLONE_NEWUTS; opts.hostname = strdup(optarg); break; - case 'j': - jail_join_ns(optarg); + case 'j': { + char *spec = strdup(optarg); + int err = jail_join_ns(optarg); + + if (err) { + ERROR("-j %s: %s\n", spec ?: optarg, strerror(err)); + free(spec); + return -1; + } + free(spec); break; + } case 'b': if (!opts.ocibundle) opts.namespace |= CLONE_NEWNS; From 858f9da449a47e9528c054f372341a781a95c895 Mon Sep 17 00:00:00 2001 From: Joshua Covington Date: Fri, 18 Sep 2026 17:59:24 +0000 Subject: [PATCH 7/9] jail: do not create a pid or ipc namespace -j joins CLONE_NEWPID and CLONE_NEWIPC were added to every non-OCI jail regardless of -j. For a joined pid namespace the parent has already called setns(CLONE_NEWPID), after which clone(CLONE_NEWPID) is EINVAL, so "-j :pid" never got past clone(). Only create the two when nothing is joined in their place. A jail joining another container's pid and user namespaces thereby runs in a pidns owned by its userns and can mount its own procfs, which a pidns created by the parent never allows. Signed-off-by: Joshua Covington --- jail/jail.c | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/jail/jail.c b/jail/jail.c index 0882032..41996e7 100644 --- a/jail/jail.c +++ b/jail/jail.c @@ -6463,8 +6463,17 @@ int main(int argc, char **argv) } } - if (opts.namespace && !opts.ocibundle) - opts.namespace |= CLONE_NEWIPC | CLONE_NEWPID; + /* + * Not for namespaces joined via -j: clone(CLONE_NEWPID) is EINVAL + * after setns(CLONE_NEWPID), and a new ipcns would shadow the joined + * one. + */ + if (opts.namespace && !opts.ocibundle) { + if (opts.setns.ipc == -1) + opts.namespace |= CLONE_NEWIPC; + if (opts.setns.pid == -1) + opts.namespace |= CLONE_NEWPID; + } /* * env import from cmdline is not available for OCI containers From 9962c0f4bbd085f0f84a92b10046b2d34dd45ae4 Mon Sep 17 00:00:00 2001 From: Joshua Covington Date: Fri, 18 Sep 2026 17:59:24 +0000 Subject: [PATCH 8/9] jail: mount sysfs in the parent when the jail lacks a netns Mounting sysfs needs CAP_SYS_ADMIN in the userns owning the network namespace. A jail with a userns from clone() that keeps the netns it was started in cannot mount one: -s, or an OCI mount of type sysfs, failed with EPERM and the jail did not start. This worked on the tip of #39, where every userns was entered after the mounts, and broke again with 6aa23a8 ("jail: give the container's namespaces to its own user namespace"). Create the mount in the parent before clone() with fsopen()/fsmount(), carrying the flags the mount queue asked for, and leave the fd on the queue for do_mount_fd() to move_mount() into place. The jail gets the sysfs it asked for, with nothing but its own masks beneath it. Signed-off-by: Joshua Covington --- jail/fs.c | 97 ++++++++++++++++++++++++++++++++++++++++++----------- jail/fs.h | 23 +++++++++++++ jail/jail.c | 9 +++++ 3 files changed, 110 insertions(+), 19 deletions(-) diff --git a/jail/fs.c b/jail/fs.c index 7864c9a..7dc5daf 100644 --- a/jail/fs.c +++ b/jail/fs.c @@ -126,6 +126,31 @@ unsigned long detect_atime_flag(const char *mountpoint) #define MOUNT_ATTR_NODIRATIME 0x00000080 #endif +/* MS_* -> MOUNT_ATTR_* for fsmount()/mount_setattr() */ +static unsigned mountflags_to_attr(unsigned long mountflags) +{ + unsigned attr = 0; + + if (mountflags & MS_RDONLY) + attr |= MOUNT_ATTR_RDONLY; + if (mountflags & MS_NOSUID) + attr |= MOUNT_ATTR_NOSUID; + if (mountflags & MS_NODEV) + attr |= MOUNT_ATTR_NODEV; + if (mountflags & MS_NOEXEC) + attr |= MOUNT_ATTR_NOEXEC; + if (mountflags & MS_NODIRATIME) + attr |= MOUNT_ATTR_NODIRATIME; + if (mountflags & MS_NOATIME) + attr |= MOUNT_ATTR_NOATIME; + else if (mountflags & MS_STRICTATIME) + attr |= MOUNT_ATTR_STRICTATIME; + else + attr |= MOUNT_ATTR_RELATIME; + + return attr; +} + int sys_openat2(int dfd, const char *path, struct open_how *how, size_t size) { return syscall(SYS_openat2, dfd, path, how, size); @@ -312,6 +337,21 @@ int sys_move_mount(int from_dfd, const char *from_path, int to_dfd, const char * return syscall(SYS_move_mount, from_dfd, from_path, to_dfd, to_path, flags); } +int sys_fsopen(const char *fsname, unsigned flags) +{ + return syscall(SYS_fsopen, fsname, flags); +} + +int sys_fsconfig(int fd, unsigned cmd, const char *key, const void *value, int aux) +{ + return syscall(SYS_fsconfig, fd, cmd, key, value, aux); +} + +int sys_fsmount(int fd, unsigned flags, unsigned attr_flags) +{ + return syscall(SYS_fsmount, fd, flags, attr_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); @@ -765,6 +805,42 @@ int add_mount_fd(int fd, const char *target, int error) return 0; } +/* + * Mounting sysfs requires CAP_SYS_ADMIN in the userns owning the netns. A + * child with a userns from clone() that stays in the parent's netns cannot + * do it. Called in the parent before clone(): fsmount() every queued sysfs + * entry with its requested flags and leave the fd for do_mount_fd(). + */ +int premount_sysfs(void) +{ + struct mount *m; + + list_for_each_entry(m, &mounts_order, list) { + int fsfd, mfd; + + if (!m->filesystemtype || strcmp(m->filesystemtype, "sysfs") || + m->source_fd >= 0) + continue; + + fsfd = sys_fsopen("sysfs", FSOPEN_CLOEXEC); + if (fsfd < 0) + return -1; + if (sys_fsconfig(fsfd, FSCONFIG_CMD_CREATE, NULL, NULL, 0)) { + close(fsfd); + return -1; + } + mfd = sys_fsmount(fsfd, FSMOUNT_CLOEXEC, mountflags_to_attr(m->mountflags)); + close(fsfd); + if (mfd < 0) + return -1; + + m->source_fd = mfd; + DEBUG("pre-mounted sysfs for %s as fd:%d\n", m->target, mfd); + } + + return 0; +} + int add_mount_volume(const char *source, const char *target, int error) { struct mount *m; @@ -1326,26 +1402,9 @@ static int idmap_tree_fd(const char *source, int source_fd, int userns_fd, unsig return -1; } - attr.attr_set = MOUNT_ATTR_IDMAP; - if (mountflags & MS_RDONLY) - attr.attr_set |= MOUNT_ATTR_RDONLY; - if (mountflags & MS_NOSUID) - attr.attr_set |= MOUNT_ATTR_NOSUID; - if (mountflags & MS_NODEV) - attr.attr_set |= MOUNT_ATTR_NODEV; - if (mountflags & MS_NOEXEC) - attr.attr_set |= MOUNT_ATTR_NOEXEC; - if (mountflags & MS_NODIRATIME) - attr.attr_set |= MOUNT_ATTR_NODIRATIME; - if (mountflags & (MS_NOATIME | MS_RELATIME | MS_STRICTATIME)) { + attr.attr_set = MOUNT_ATTR_IDMAP | mountflags_to_attr(mountflags); + if (mountflags & (MS_NOATIME | MS_RELATIME | MS_STRICTATIME)) attr.attr_clr |= MOUNT_ATTR__ATIME; - if (mountflags & MS_NOATIME) - attr.attr_set |= MOUNT_ATTR_NOATIME; - else if (mountflags & MS_STRICTATIME) - attr.attr_set |= MOUNT_ATTR_STRICTATIME; - else - attr.attr_set |= MOUNT_ATTR_RELATIME; - } attr.userns_fd = userns_fd; if (sys_mount_setattr(treefd, "", setattr_flags, &attr, sizeof(attr)) < 0) { diff --git a/jail/fs.h b/jail/fs.h index c7e9379..6f98eb4 100644 --- a/jail/fs.h +++ b/jail/fs.h @@ -58,6 +58,29 @@ int sys_open_tree(int dfd, const char *path, unsigned flags); int sys_move_mount(int from_dfd, const char *from_path, int to_dfd, const char *to_path, unsigned flags); int sys_mount_setattr(int dfd, const char *path, unsigned flags, struct ujail_mount_attr *attr, size_t size); +int sys_fsopen(const char *fsname, unsigned flags); +int sys_fsconfig(int fd, unsigned cmd, const char *key, const void *value, int aux); +int sys_fsmount(int fd, unsigned flags, unsigned attr_flags); +int premount_sysfs(void); + +#ifndef FSOPEN_CLOEXEC +#define FSOPEN_CLOEXEC 0x00000001 +#endif +#ifndef FSMOUNT_CLOEXEC +#define FSMOUNT_CLOEXEC 0x00000001 +#endif +#ifndef FSCONFIG_CMD_CREATE +#define FSCONFIG_CMD_CREATE 6 +#endif +#ifndef MOUNT_ATTR_NOSUID +#define MOUNT_ATTR_NOSUID 0x00000002 +#endif +#ifndef MOUNT_ATTR_NODEV +#define MOUNT_ATTR_NODEV 0x00000004 +#endif +#ifndef MOUNT_ATTR_NOEXEC +#define MOUNT_ATTR_NOEXEC 0x00000008 +#endif #ifndef OPEN_TREE_CLONE #define OPEN_TREE_CLONE 1 diff --git a/jail/jail.c b/jail/jail.c index 41996e7..22b8d19 100644 --- a/jail/jail.c +++ b/jail/jail.c @@ -6995,6 +6995,15 @@ static void post_main(struct uloop_timeout *t) } } + /* + * A userns from clone() cannot mount sysfs for a netns it does + * not own. Deferred/joined jails mount privileged anyway; a jail + * with its own netns can do it itself. + */ + if ((opts.namespace & CLONE_NEWUSER) && !userns_deferred() && + !(opts.namespace & CLONE_NEWNET) && premount_sysfs()) + WARNING("cannot mount sysfs for the jail: %m\n"); + prime_jail_mount(opts.extroot); prime_jail_mount(opts.overlaydir); for (size_t i = 0; i < (size_t)num_volume_sources; i++) From 8400ff00828d28d60581a92477a5350e31a1b79f Mon Sep 17 00:00:00 2001 From: Joshua Covington Date: Fri, 18 Sep 2026 18:33:04 +0000 Subject: [PATCH 9/9] jail: log a failing mask mount The mask branch of do_mount() returns error without a message when the tmpfs or noafile mount fails. A critical mask (/proc/kcore, /proc/sysrq-trigger, /sys/firmware) then only shows up as "mount_all() failed", with no path and no errno. Signed-off-by: Joshua Covington --- jail/fs.c | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/jail/fs.c b/jail/fs.c index 7dc5daf..3a1ef1c 100644 --- a/jail/fs.c +++ b/jail/fs.c @@ -532,19 +532,25 @@ static int do_mount(const char *root, const char *orig_source, const char *targe snprintf(new, sizeof(new), "%s%s", root, target?target:source); if (is_mask) { + int err; + if (stat(new, &s)) 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_RELATIME, "size=0,mode=000")) - return error; + err = mount("none", new, "tmpfs", MS_RDONLY | MS_NOSUID | MS_NOEXEC | MS_NODEV | MS_RELATIME, "size=0,mode=000"); } else { /* mount-bind 0-sized file having mode 000 */ - if (mount(UJAIL_NOAFILE, new, "bind", MS_BIND, NULL)) - return error; + err = mount(UJAIL_NOAFILE, new, "bind", MS_BIND, NULL); + if (!err) + err = remount_readonly(new, MS_NOSUID | MS_NOEXEC | MS_NODEV); + } - if (remount_readonly(new, MS_NOSUID | MS_NOEXEC | MS_NODEV)) - return error; + if (err) { + if (error) + ERROR("failed to mask %s%s: %m\n", new, + S_ISDIR(s.st_mode) ? "" : " with " UJAIL_NOAFILE); + return error; } DEBUG("masked path %s\n", new);