<feed xmlns='http://www.w3.org/2005/Atom'>
<title>procd/jail, branch master</title>
<subtitle>OpenWrt service / process manager</subtitle>
<id>https://git.openwrt.org/project/procd/atom?h=master</id>
<link rel='self' href='https://git.openwrt.org/project/procd/atom?h=master'/>
<link rel='alternate' type='text/html' href='https://git.openwrt.org/project/procd/'/>
<updated>2026-08-18T16:54:26Z</updated>
<entry>
<title>jail: report signal deaths as 128+signal exit codes</title>
<updated>2026-08-18T16:54:26Z</updated>
<author>
<name>Julius Bairaktaris</name>
</author>
<published>2026-08-06T12:26:06Z</published>
<link rel='alternate' type='text/html' href='https://git.openwrt.org/project/procd/commit/?id=2c1834abae171261f2d9161e5a28c36cbcc394a4'/>
<id>urn:sha1:2c1834abae171261f2d9161e5a28c36cbcc394a4</id>
<content type='text'>
ujail's own exit code is all its supervisor can observe, and one that
only reads WEXITSTATUS cannot tell a jailed process killed by a signal
apart from one that exited with the signal number as its code. pppd's
exit table collides head-on: EXIT_PEER_AUTH_FAILED is 9 and
EXIT_CONNECT_TIME is 11, so a segfaulted pppd under ujail reads as an
auth failure and, with "option authfail 1", blocks the WAN link until
manual intervention. Without ujail, netifd recorded 0 for a signal
death (WEXITSTATUS on a non-exited status) and the link simply retried.

Follow the 128+signal convention shells use: the value then sits
outside every consumer's exit-code table instead of inside pppd's.

Assisted-by: Claude:claude-opus-5
Signed-off-by: Julius Bairaktaris &lt;julius@bairaktaris.de&gt;
</content>
</entry>
<entry>
<title>jail: don't let the jailed process outlive the jail</title>
<updated>2026-08-18T16:54:26Z</updated>
<author>
<name>Julius Bairaktaris</name>
</author>
<published>2026-08-06T12:25:58Z</published>
<link rel='alternate' type='text/html' href='https://git.openwrt.org/project/procd/commit/?id=0d42f5820616654829ca980f9d6a782e7e48eafd'/>
<id>urn:sha1:0d42f5820616654829ca980f9d6a782e7e48eafd</id>
<content type='text'>
ujail forwards SIGTERM to the jailed process and escalates to SIGKILL
after the term timeout, but there is no equivalent for ujail itself
being SIGKILLed: the jailed process keeps running with nothing left
that can stop it, and whoever supervises ujail sees it exit and starts
a second instance of the daemon.

Both of procd's own supervisors do send that SIGKILL. instance_timeout()
escalates to it when an instance does not stop on SIGTERM, and netifd
SIGKILLs every child in netifd_kill_processes() when it exits.

Set PR_SET_PDEATHSIG in the child after the user/group and capability
transitions, so the setting survives them (commit_creds() zeroes
pdeath_signal on any credential change), and before the OCI seccomp
filter, which then need not permit prctl() to reach the execve(). The
parent can die between fork() and the prctl, leaving the death signal
bound to the process the child is reparented to and never delivered, so
the child self-terminates when it detects the parent's exit on a pidfd
inherited from it; getppid()==1 cannot serve as that detection in a jail
that creates a PID namespace, where the parent is not visible and
getppid() reads 0 either way.

Assisted-by: Claude:claude-opus-5
Signed-off-by: Julius Bairaktaris &lt;julius@bairaktaris.de&gt;
</content>
</entry>
<entry>
<title>jail: source deferred file masks from procd's read-only noafile</title>
<updated>2026-08-12T09:05:51Z</updated>
<author>
<name>Daniel Golle</name>
</author>
<published>2026-08-03T23:41:27Z</published>
<link rel='alternate' type='text/html' href='https://git.openwrt.org/project/procd/commit/?id=18ed46faebb09f75dec4ec72a35344437cbfde6a'/>
<id>urn:sha1:18ed46faebb09f75dec4ec72a35344437cbfde6a</id>
<content type='text'>
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 &lt;daniel@makrotopia.org&gt;
</content>
</entry>
<entry>
<title>jail: tolerate EPERM on setgroups() when joining an external userns</title>
<updated>2026-08-03T23:00:27Z</updated>
<author>
<name>Joshua Covington</name>
</author>
<published>2026-08-03T19:56:42Z</published>
<link rel='alternate' type='text/html' href='https://git.openwrt.org/project/procd/commit/?id=285a3e7aae36db06a8acb86c35c08c7432e97fdc'/>
<id>urn:sha1:285a3e7aae36db06a8acb86c35c08c7432e97fdc</id>
<content type='text'>
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 &lt;joshuacov@gmail.com&gt;
</content>
</entry>
<entry>
<title>jail: read DT_RPATH/DT_RUNPATH when auto-discovering dependencies</title>
<updated>2026-08-03T23:00:21Z</updated>
<author>
<name>Joshua Covington</name>
</author>
<published>2026-08-03T19:56:06Z</published>
<link rel='alternate' type='text/html' href='https://git.openwrt.org/project/procd/commit/?id=5a7cc3028c6157cfe726a5c2e5f9fdfb0f2c5582'/>
<id>urn:sha1:5a7cc3028c6157cfe726a5c2e5f9fdfb0f2c5582</id>
<content type='text'>
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 &lt;joshuacov@gmail.com&gt;
</content>
</entry>
<entry>
<title>jail: mask default sensitive /proc,/sys paths for plain jails</title>
<updated>2026-08-03T22:59:38Z</updated>
<author>
<name>Joshua Covington</name>
</author>
<published>2026-08-03T22:59:38Z</published>
<link rel='alternate' type='text/html' href='https://git.openwrt.org/project/procd/commit/?id=d381289c2b35bc1e6459a08f681fffea0be59b5f'/>
<id>urn:sha1:d381289c2b35bc1e6459a08f681fffea0be59b5f</id>
<content type='text'>
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-&gt;0 uidMapping.

Signed-off-by: Joshua Covington &lt;joshuacov@gmail.com&gt;
</content>
</entry>
<entry>
<title>jail: reject non-read-only OCI bind mounts onto /proc,/sys</title>
<updated>2026-08-03T22:03:20Z</updated>
<author>
<name>Joshua Covington</name>
</author>
<published>2026-08-03T19:54:41Z</published>
<link rel='alternate' type='text/html' href='https://git.openwrt.org/project/procd/commit/?id=f85a629bd6943574285da25bcf88b2ecd5d5e091'/>
<id>urn:sha1:f85a629bd6943574285da25bcf88b2ecd5d5e091</id>
<content type='text'>
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 &lt;joshuacov@gmail.com&gt;
</content>
</entry>
<entry>
<title>jail: tolerate EPERM on remount when flags already satisfied</title>
<updated>2026-08-03T22:03:16Z</updated>
<author>
<name>Joshua Covington</name>
</author>
<published>2026-08-01T08:52:39Z</published>
<link rel='alternate' type='text/html' href='https://git.openwrt.org/project/procd/commit/?id=f689b10fe33c4c7db983f44d6c41d5fc51b7d01c'/>
<id>urn:sha1:f689b10fe33c4c7db983f44d6c41d5fc51b7d01c</id>
<content type='text'>
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 &lt;joshuacov@gmail.com&gt;
</content>
</entry>
<entry>
<title>jail: fix identity resolution under CLONE_NEWUSER</title>
<updated>2026-08-03T21:54:55Z</updated>
<author>
<name>Joshua Covington</name>
</author>
<published>2026-08-01T08:52:20Z</published>
<link rel='alternate' type='text/html' href='https://git.openwrt.org/project/procd/commit/?id=835aed0085bbc0e96b7406fd9d63d992fe652f03'/>
<id>urn:sha1:835aed0085bbc0e96b7406fd9d63d992fe652f03</id>
<content type='text'>
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 &lt;joshuacov@gmail.com&gt;
</content>
</entry>
<entry>
<title>jail: validate and bind-mount devices via open_tree and move_mount</title>
<updated>2026-08-01T09:20:28Z</updated>
<author>
<name>Joshua Covington</name>
</author>
<published>2026-08-01T09:20:28Z</published>
<link rel='alternate' type='text/html' href='https://git.openwrt.org/project/procd/commit/?id=e90164d76d5c8b0a09a3a48adefb401913298e46'/>
<id>urn:sha1:e90164d76d5c8b0a09a3a48adefb401913298e46</id>
<content type='text'>
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 &lt;joshuacov@gmail.com&gt;
</content>
</entry>
</feed>
