Skip to content

jail: fix -j user namespace join, deferred userns, masks and sysfs - #46

Merged
openwrt-bot merged 10 commits into
openwrt:mainfrom
joshuacov1:jail-fixes
Sep 29, 2026
Merged

openwrt-bot merged 10 commits into
openwrt:mainfrom
joshuacov1:jail-fixes

Conversation

@joshuacov1

@joshuacov1 joshuacov1 commented Sep 19, 2026 •

Copy link
Copy Markdown
Contributor

This is a follow up to #39: the -j /proc EPERM left open there, plus what testing surfaced beyond it. All nine patches are on c230ad8. In details:

  • deferred userns path died with "private mount failed": MS_PRIVATE remount after unshare(CLONE_NEWUSER) against a host-owned mntns (1)
  • phase-2 read-only remounts passed bare MS_RDONLY and hit MNT_LOCK_*; /proc/sys stayed writable in every deferred jail (2)
  • noafile mask remount hard-codes MS_RELATIME; with /tmp MS_NOATIME (procd) the bind is MNT_LOCK_ATIME and the remount is EPERM. Since 6aa23a8: every -f -p jail fails mount_all() silently, every OCI container with maskedPaths has them rw. Keep the flags in effect (3)
  • -j never set root_map_uid or took the CLONE_NEWUSER-only paths for euid/device/overlay/devpts; derive the map via a setns() helper (also for a process-less nsfs bind mount) and key on jail_has_userns() (4)
  • -j user: setns(CLONE_NEWUSER) ran before build_jail_fs(); pidns from clone() is host-owned, so /proc (and sethostname) failed with EPERM. Join deferred to enter_userns() like a userns created after other joins (5)
  • jail_join_ns() return ignored: stale pid ran the jail as host root with no userns, silently (6)
  • CLONE_NEWPID|CLONE_NEWIPC forced even when -j joins them: -j <pid>:pid failed with clone3 EINVAL (7)
  • sysfs under a clone-time userns without a netns is EPERM since 6aa23a8 (worked on jail: harden CLONE_NEWUSER handling, fix dependency RPATH/RUNPATH support #39 tip): -f -s without -N, OCI bundles without a network namespace. Parent fsmount()s sysfs and hands the fd to the child; real sysfs, no host mounts beneath (8)
  • mask failures log path and errno instead of bare "mount_all() failed" (9)

Clone-time userns path (uidMappings, uxc/runc/crun parity) is unchanged except in 3 and 8, which turn EPERM into a working mount; namespace ownership is left untouched, the container mounts its own procfs, nested runtimes are ok. The following behaviour changes worth knowing of: on -j user the cgroupns is now host-owned like on the deferred-create path; a netifd-triggered restart whose -j target has exited now fails instead of starting unconfined. Previously masks that were silently left rw, now become ro.

A side note:
The error I hit is more-or-less /tmp's noatime being remapped as relatime by ujail's hard-coded flag, and this only becomes a kernel-enforced failure once a userns is involved. Own, deferred, or joined, doesn't matter which but what matters is that the process is no longer the owner of the original /tmp mount. That's exactly why in my case /etc/init.d/radiusd's -f -p (own userns) hits it: -f is precisely the condition that turns the pre-existing flag mismatch into an enforced EPERM.

Assisted-by: Claude Fable 5.1

@dangowrt since you wrote 6aa23a8 ("jail: give the container's namespaces to its own user namespace"), can you look at this?

@joshuacov1

Copy link
Copy Markdown
Contributor Author

Since main HEAD moved since this PR was posted I rebased it on the current HEAD a6f9ffd. The series is unchanged in intent but three patches needed a rework.

  • deferred userns fix (1) is rewritten as a pure deletion: the private mount is now applied early unconditionally, by an independent fix upstream (previously ordinary jails weren't getting a private mntns at all, so procd's own peer mounts like iproute2's /run/netns were getting destroyed on jail start). Nothing is now left to relocate the original fix, so it's just gone now.
  • headline -j fix (5) was updated for the renamed helper that was introduced by the same upstream (isolate_mountns_and_detach_inherited() is now split into isolate_mountns() + detach_inherited_mounts()). It's the same behaviour as well as the same condition but new names.
  • sysfs-in-parent (8) conflict was a minor one (an inline atime block duplicating what mountflags_to_attr() already computes); while being on this anyway, drop an attr_clr guard that no longer gates anything, since mountflags_to_attr() always sets an atime bit now.

Everything else still applies clean and is unchanged.

@dangowrt dangowrt left a comment •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Most of the patches can go in as-is, they fix real issues in a straight forward way.
Patch 4/8 and 8/8 need to be discussed. If you prefer I can pick all the others first, but I'd prefer the whole PR fixed.

Nit: patch 7 (d69f17b) is a long-standing bug from 6f3dbd2 rather than 6aa23a8 fallout, and its commit message does not say so.

Comment thread jail/jail.c Outdated
Comment thread jail/fs.c
Comment thread jail/fs.c Outdated
@joshuacov1

Copy link
Copy Markdown
Contributor Author

Most of the patches can go in as-is, they fix real issues in a straight forward way. Patch 4/8 and 8/8 need to be discussed. If you prefer I can pick all the others first, but I'd prefer the whole PR fixed.

Nit: patch 7 (d69f17b) is a long-standing bug from 6f3dbd2 rather than 6aa23a8 fallout, and its commit message does not say so.

I've reworked the series into 10 patches. The body of 7/10 now names 6f3dbd2 (unconditional CLONE_NEWPID/IPC), with -j from c482c5d making it reachable. Your points on patches 4 and 8 should now be addressed. The details are in the inline threads (old 8 is now 8/10 and 9/10). Or did you have anything else in mind?

joshuacov1 and others added 10 commits September 28, 2026 11:05
userns_wait_idmaps() remounts / with mountns_propagation() once the
user namespace exists. On the deferred path the mount namespace is
still owned by the initial user namespace at that point, so the call
returns EPERM and the jail dies with "mount propagation failed".
isolate_mountns() applies the same propagation to every CLONE_NEWNS
jail before any user namespace handling, so drop the second call.

Fixes: 6aa23a8 ("jail: give the container's namespaces to its own user namespace")
Signed-off-by: Joshua Covington <joshuacov@gmail.com>
Signed-off-by: Daniel Golle <daniel@makrotopia.org>
remount_proc_sys_after_unshare() and remount_readonly_now() run in a
mount namespace that does not own the mounts copied into it, where
MNT_LOCK_{NOSUID,NODEV,NOEXEC} are set. A remount passing only
MS_RDONLY asks to clear them and fails with EPERM, so /proc/sys and
any OCI readonlyPath stay writable. Add bind_remount_readonly(),
which ORs in the flags read back from mountinfo. The atime class is
left out of that: path_mount() preserves it on a remount that names
no atime flag, which needs no mountinfo lookup and cannot pick the
wrong class when the path is not found.

Fixes: d381289 ("jail: mask default sensitive /proc,/sys paths for plain jails")
Signed-off-by: Joshua Covington <joshuacov@gmail.com>
Signed-off-by: Daniel Golle <daniel@makrotopia.org>
The file masks for /proc/kcore, /proc/sysrq-trigger and the OCI
maskedPaths bind the noafile and remount it with a hard-coded
MS_RELATIME. procd mounts /tmp with MS_NOATIME and a user namespace
from clone() locks the atime class of everything inherited into the
jail's mount namespace, so the remount is EPERM: a critical mask
fails mount_all() and an optional one is left read-write. Split the
remount half of bind_remount_readonly() into remount_readonly() and
use it for both noafile mask sites, naming no atime flag so the
kernel keeps the class the bind inherited.

Fixes: 6aa23a8 ("jail: give the container's namespaces to its own user namespace")
Signed-off-by: Joshua Covington <joshuacov@gmail.com>
Signed-off-by: Daniel Golle <daniel@makrotopia.org>
The euid taken before clone(), the owner of the staged device nodes,
the overlay upper chown, the console hand-over and the devpts gid all
key on CLONE_NEWUSER, which -j does not set, and root_map_uid is never
derived for a joined namespace. The jail's root then finds the files
prepared for it owned by nobody. Derive the mapping by reading
/proc/<pid>/uid_map of the pid -j named: uid_m_show() renders the
outside column through the reader's user namespace, so reading it from
here yields a host uid at any nesting depth. The namespace fd pins the
namespace but not that pid, so the map counts only while
/proc/<pid>/ns/user still names the namespace ujail holds.

Those checks key on jail_has_userns(), false for a namespace given as
a path, where the map cannot be read and the ownership decisions would
act on a guess. The securebits restore instead keys on the namespace
being present, so one invocation always gets one securebits set.

Fixes: acf36f2 ("jail: seteuid before clone(CLONE_NEWUSER)")
Signed-off-by: Joshua Covington <joshuacov@gmail.com>
Signed-off-by: Daniel Golle <daniel@makrotopia.org>
Mounting procfs needs CAP_SYS_ADMIN in the user namespace owning the
pid namespace. A jail joining a user namespace by -j entered it at
the top of exec_jail(), before any mount, while its pid namespace
comes from clone() in the parent and belongs to the initial user
namespace, so such a jail with -p failed with EPERM on its own /proc.
Build the jail fs privileged and join in enter_userns(), where a
deferred user namespace is created, so the join takes the same
phase-2 path.

That path applied its masks without looking at the result, so make
remask_after_unshare() report them and keep the critical ones fatal,
as mask_default_paths() does in phase 1. The /proc/sys read-only
self-bind follows it into phase 2 on the same terms: fatal, and left
alone when the bundle defines /proc/sys itself.

Fixes: c482c5d ("jail: add support for referencing existing namespaces")
Signed-off-by: Joshua Covington <joshuacov@gmail.com>
Signed-off-by: Daniel Golle <daniel@makrotopia.org>
jail_join_ns() reports a stale pid or an unknown namespace type, but
the return value is discarded, so the jail starts with none of the
requested namespaces and one that asked for a user namespace runs as
host root in the initial one. Report the error and refuse to start.
Two of the names it accepts never resolved: "mount" and "network"
were used verbatim in /proc/<pid>/ns/<name>, where the links are
"mnt" and "net", and "mnt" was not accepted at all, so the mount
namespace could not be joined under any spelling. Keep the accepted
names and their links in one table.

Fixes: c482c5d ("jail: add support for referencing existing namespaces")
Signed-off-by: Joshua Covington <joshuacov@gmail.com>
Signed-off-by: Daniel Golle <daniel@makrotopia.org>
Every non-OCI jail has CLONE_NEWPID and CLONE_NEWIPC added to its
namespace set, and a join refused any namespace that set already
named. procd appends -j after the flags that populate it, so a jail
combining procfs, sysfs or netns with a -j entry for the same
namespace was rejected, and clone(CLONE_NEWPID) after
setns(CLONE_NEWPID) is EINVAL, so "-j <pid>:pid" never got past
clone() either. Clear the create bit of every joined namespace once
both the options and the bundle have been read, and fail when the pid
namespace cannot be entered.

A jail root has to be built in a mount namespace ujail owns, so an
entry naming one is refused where a jail filesystem was asked for,
and linux.namespaces entries keep their per-type uniqueness.

Fixes: c482c5d ("jail: add support for referencing existing namespaces")
Signed-off-by: Joshua Covington <joshuacov@gmail.com>
Signed-off-by: Daniel Golle <daniel@makrotopia.org>
Move the mount flag translation of idmap_tree_fd() into
mountflags_to_attr().

No functional change: MOUNT_ATTR_RELATIME is 0 and __ATIME is only
cleared when an atime flag is requested, so a clone with MNT_LOCK_ATIME
keeps its atime mode.

Signed-off-by: Joshua Covington <joshuacov@gmail.com>
Mounting sysfs needs CAP_SYS_ADMIN in the userns owning the netns. A
jail with a userns from clone() and no netns of its own gets EPERM for
-s or an OCI sysfs mount and does not start. fsmount() sysfs in the
parent before clone() with the queued flags and leave the fd for
do_mount_fd() to move_mount().

The mount is not MNT_LOCK_READONLY: only lock_mnt_tree() on a copy into
a less privileged userns sets it, and a clone-time userns makes no such
copy. A jail keeping CAP_SYS_ADMIN can remount /sys read-write.

Fixes: 6aa23a8 ("jail: give the container's namespaces to its own user namespace")
Signed-off-by: Joshua Covington <joshuacov@gmail.com>
Signed-off-by: Daniel Golle <daniel@makrotopia.org>
The mask branch of do_mount() returns the caller's error code without
a message, so a failure to mask a critical path such as /proc/kcore
or /sys/firmware surfaces only as "mount_all() failed", and an
optional one leaves no trace at all even though the path it was meant
to hide stays visible. Report both, with the path and the errno.

Signed-off-by: Joshua Covington <joshuacov@gmail.com>
Signed-off-by: Daniel Golle <daniel@makrotopia.org>
@dangowrt

Copy link
Copy Markdown
Member

I've reworked the whole PR because testing showed several regressions introduced, incl. security-relevant ones. I hope it's ok, but I thought to make things move fast, I'll just fix everything and let you re-test if you use-case is now still satisfied. Please report back, then we can get this merged asap.

@joshuacov1

Copy link
Copy Markdown
Contributor Author

I'm absolutely fine with your approach and it definitely got things moving much faster. Thank you.

I re-tested the 10 patches on my router: freeradius (procfs + userns, own user/group, capabilities, no_new_privs, /tmp noatime) starts cleanly again and authentication works. Masks and /proc/sys are read-only as expected.

I also ran the -j cases in a test VM (nested user namespace, pid+user, stale pid, mnt/net/network, -N/-p combined with -j), and they all behave as your commit messages describe.

I see that a user namespace given as a path no longer gets a root mapping, so the staged device nodes stay nobody-owned inside. That's fine for me.

From my side this is good to merge.

@openwrt-bot
openwrt-bot merged commit 4021843 into openwrt:main Sep 29, 2026
0 of 2 checks passed
@joshuacov1
joshuacov1 deleted the jail-fixes branch September 29, 2026 21:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants