Skip to content

jail: don't let the jailed process outlive the jail - #40

Merged
openwrt-bot merged 2 commits into
openwrt:masterfrom
JuliusBairaktaris:jail-pdeathsig
Aug 18, 2026
Merged

jail: don't let the jailed process outlive the jail#40
openwrt-bot merged 2 commits into
openwrt:masterfrom
JuliusBairaktaris:jail-pdeathsig

Conversation

@JuliusBairaktaris

@JuliusBairaktaris JuliusBairaktaris commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

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 send that SIGKILL:

  • instance_timeout() escalates to SIGKILL when an instance does not stop on
    SIGTERM (service/instance.c).
  • netifd SIGKILLs every child in netifd_kill_processes() when it exits
    (main.c), which is reached on a plain /etc/init.d/network restart whenever
    a jailed proto handler has not finished tearing down within the one second the
    init script sleeps after ifdown -a.

prctl(PR_SET_PDEATHSIG, SIGKILL) in the child closes it. It is set right
before the execve(), after the user/group and capability transitions
(set_jail_user(), the userns setreuid()/setregid() and
applyOCIcapabilities() all clear the setting on credential change, so an
earlier placement silently no-ops for -U/-G/-f jails). Since the parent
can die between fork() and the prctl, the child checks getppid() and
self-terminates when the parent is already gone — the death signal would
otherwise be bound to init's death and never be delivered.

The second commit changes how a signal death of the jailed process is reported:
ujail exits with 128 + WTERMSIG (the shell convention) instead of the bare
signal number. A supervisor that only reads WEXITSTATUS cannot tell a
signal-killed process from one that exited with the signal number as its code;
pppd's exit table collides head-on (EXIT_PEER_AUTH_FAILED is 9,
EXIT_CONNECT_TIME is 11), so a segfaulted pppd read as an auth failure and,
with option authfail 1, blocked the WAN link until manual intervention.

Testing

On an ipq807x router with a PPPoE uplink, pppd wrapped in
ujail -C … -c -- /usr/sbin/pppd … and started by netifd:

kill -9 <ujail>
before pppd survives; netifd starts a second pppd, two of them on one link
after pppd is reaped with the jail; netifd restarts a single clean pair

hostapd, wpa_supplicant, ntpd and dnsmasq jails start and stop normally
with the patched ujail, including the -U/-G user jails whose setting the
placement change is what makes survive.

Copilot AI lite review requested due to automatic review settings August 6, 2026 12:26

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

JuliusBairaktaris added a commit to JuliusBairaktaris/openwrt-nss-edma that referenced this pull request Aug 6, 2026
Backport of openwrt/procd#40, in lockstep with the upstream commits so
the openwrt tree behaves identically whether the bump lands before or
after; drop both patches on the next PKG_SOURCE_VERSION bump.

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. netifd does exactly that in
netifd_kill_processes() on exit, which a plain /etc/init.d/network
restart reaches whenever pppd has not finished tearing down within the
second the init script sleeps after "ifdown -a". With ujail SIGKILLed,
pppd survived and netifd started a second pppd on the same link.

PR_SET_PDEATHSIG is set right before the execve(), after the user/group
and capability transitions that would clear it, and the child
self-terminates if the parent died in between (getppid() already 1).

The second patch reports signal deaths as 128+signal exit codes. ujail
exits with the bare signal number, which collides with pppd's exit
table: EXIT_PEER_AUTH_FAILED is 9, EXIT_CONNECT_TIME is 11, so a
segfaulted pppd read as an auth failure and, with "option authfail 1",
blocked the link until manual intervention.

Assisted-by: Claude:claude-opus-5
Signed-off-by: Julius Bairaktaris <julius@bairaktaris.de>
JuliusBairaktaris added a commit to JuliusBairaktaris/openwrt-nss-edma that referenced this pull request Aug 6, 2026
Backport of openwrt/procd#40, in lockstep with the upstream commits so
the openwrt tree behaves identically whether the bump lands before or
after; drop both patches on the next PKG_SOURCE_VERSION bump.

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. netifd does exactly that in
netifd_kill_processes() on exit, which a plain /etc/init.d/network
restart reaches whenever pppd has not finished tearing down within the
second the init script sleeps after "ifdown -a". With ujail SIGKILLed,
pppd survived and netifd started a second pppd on the same link.

PR_SET_PDEATHSIG is set right before the execve(), after the user/group
and capability transitions that would clear it, and the child
self-terminates if the parent died in between (getppid() already 1).

The second patch reports signal deaths as 128+signal exit codes. ujail
exits with the bare signal number, which collides with pppd's exit
table: EXIT_PEER_AUTH_FAILED is 9, EXIT_CONNECT_TIME is 11, so a
segfaulted pppd read as an auth failure and, with "option authfail 1",
blocked the link until manual intervention.

Assisted-by: Claude:claude-opus-5
Signed-off-by: Julius Bairaktaris <julius@bairaktaris.de>
@JuliusBairaktaris

Copy link
Copy Markdown
Contributor Author

@dangowrt @nbd168 — review ping.

When ujail itself is SIGKILLed the jailed process keeps running with nothing left that can stop it, and the supervisor sees ujail exit and starts a second instance of the daemon. Both of procd's own supervisors send that SIGKILL, and netifd's is reached on a plain /etc/init.d/network restart.

Relevant to every daemon jailed by openwrt/openwrt#24558, which is why it is listed there as merging first.

@JuliusBairaktaris
JuliusBairaktaris force-pushed the jail-pdeathsig branch 2 times, most recently from 0bef127 to 9393e53 Compare August 15, 2026 09:06
JuliusBairaktaris added a commit to JuliusBairaktaris/openwrt-nss-edma that referenced this pull request Aug 15, 2026
pppd drops to user ppp (uid 454, created via USERID) with CAP_NET_ADMIN
(/dev/ppp, PPPIOCNEWUNIT, SIOCSIF*) and CAP_NET_RAW (pppoe PF_PACKET),
both ambient so ip-up/ip-down scripts keep them, with
PR_SET_NO_NEW_PRIVS. The wrapper covers every proto that routes through
ppp_generic_setup() and is skipped when /sbin/ujail or the
capabilities file is missing.

pppd's euid != 0 gate (EXIT_NOT_ROOT) would stop the drop, so 502
carries ppp-project/ppp#608 (ppp_privileged: euid 0 or CAP_NET_ADMIN
via capget, Solaris keeps the euid check); drop the patch on the next
PKG_SOURCE_VERSION bump. The same patch grants the privileged option
flag on the capability, which proto_pppoe_setup() needs for its
OPT_PRIV "plugin pppoe.so", but only when the euid was not gained
through setuid and the binary itself carries no file capabilities (502's
ppp_file_cap_priv()), so setuid-root and setcap installs keep refusing
an unprivileged invoker's privileged options.

The wrapper also hands /dev/ppp to the ppp user. The node is opened at
link setup (generic_establish_ppp), not only in the (stubbed) kernel
support check, and ships 0600 root:root. chown keeps it reachable for
uid 454 alone; the kernel's ppp_open() gates on
ns_capable(CAP_NET_ADMIN) either way.

pppd mkdir_recursive()s PPP_PATH_VARRUN and writes its <ifname>.pid
and pppd2.tdb there, which is /var/run/pppd with OpenWrt's
--localstatedir=/var; nothing in this package creates that directory
today, and uid 454 cannot do it in a root-owned /var/run, so the
wrapper pre-creates and chowns it. A failure to write the pid file is
a logged error and a failed tdb open a warning (multilink drops),
neither fatal.

Interposing ujail also changes what netifd reads when pppd dies by a
signal: ujail exits with the bare signal number, which collides with
pppd's own exit codes (a SIGSEGV reads as EXIT_PEER_AUTH_FAILED) until
procd reports signal deaths as 128+signal as openwrt/procd#40 does.

The ip-up/ip-down scripts run inside the jail and report back to
netifd over ubus; ubusd's ACL default is deny for every non-root uid,
so the jailed notify_proto call failed silently and the interface
never completed setup while the PPP session sat connected. Ship
/usr/share/acl.d/pppd.json granting uid 454 access to network.interface
notify_proto and network add_dynamic (ppp6-up's dynamic-interface
registry call).

Assisted-by: Claude:claude-opus-5
Signed-off-by: Julius Bairaktaris <julius@bairaktaris.de>
JuliusBairaktaris added a commit to JuliusBairaktaris/openwrt-nss-edma that referenced this pull request Aug 15, 2026
pppd drops to user ppp (uid 454, created via USERID) with CAP_NET_ADMIN
(/dev/ppp, PPPIOCNEWUNIT, SIOCSIF*) and CAP_NET_RAW (pppoe PF_PACKET),
both ambient so ip-up/ip-down scripts keep them, with
PR_SET_NO_NEW_PRIVS. The wrapper covers every proto that routes through
ppp_generic_setup() and is skipped when /sbin/ujail or the
capabilities file is missing.

pppd's euid != 0 gate (EXIT_NOT_ROOT) would stop the drop, so 502
carries ppp-project/ppp#608 (ppp_privileged: euid 0 or CAP_NET_ADMIN
via capget, Solaris keeps the euid check); drop the patch on the next
PKG_SOURCE_VERSION bump. The same patch grants the privileged option
flag on the capability, which proto_pppoe_setup() needs for its
OPT_PRIV "plugin pppoe.so", but only when the euid was not gained
through setuid and the binary itself carries no file capabilities (502's
ppp_file_cap_priv()), so setuid-root and setcap installs keep refusing
an unprivileged invoker's privileged options.

The wrapper also hands /dev/ppp to the ppp user. The node is opened at
link setup (generic_establish_ppp), not only in the (stubbed) kernel
support check, and ships 0600 root:root. chown keeps it reachable for
uid 454 alone; the kernel's ppp_open() gates on
ns_capable(CAP_NET_ADMIN) either way.

pppd mkdir_recursive()s PPP_PATH_VARRUN and writes its <ifname>.pid
and pppd2.tdb there, which is /var/run/pppd with OpenWrt's
--localstatedir=/var; nothing in this package creates that directory
today, and uid 454 cannot do it in a root-owned /var/run, so the
wrapper pre-creates and chowns it. A failure to write the pid file is
a logged error and a failed tdb open a warning (multilink drops),
neither fatal.

Interposing ujail also changes what netifd reads when pppd dies by a
signal: ujail exits with the bare signal number, which collides with
pppd's own exit codes (a SIGSEGV reads as EXIT_PEER_AUTH_FAILED) until
procd reports signal deaths as 128+signal as openwrt/procd#40 does.

The ip-up/ip-down scripts run inside the jail and report back to
netifd over ubus; ubusd's ACL default is deny for every non-root uid,
so the jailed notify_proto call failed silently and the interface
never completed setup while the PPP session sat connected. Ship
/usr/share/acl.d/pppd.json granting uid 454 access to network.interface
notify_proto and network add_dynamic (ppp6-up's dynamic-interface
registry call).

Assisted-by: Claude:claude-opus-5
Signed-off-by: Julius Bairaktaris <julius@bairaktaris.de>
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 <julius@bairaktaris.de>
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 <julius@bairaktaris.de>
@openwrt-bot
openwrt-bot merged commit 2c1834a into openwrt:master Aug 18, 2026
JuliusBairaktaris added a commit to JuliusBairaktaris/openwrt-nss-edma that referenced this pull request Aug 19, 2026
pppd drops to user ppp (uid 454, created via USERID) with CAP_NET_ADMIN
(/dev/ppp, PPPIOCNEWUNIT, SIOCSIF*) and CAP_NET_RAW (pppoe PF_PACKET),
both ambient so ip-up/ip-down scripts keep them, with
PR_SET_NO_NEW_PRIVS. The wrapper covers every proto that routes through
ppp_generic_setup() and is skipped when /sbin/ujail or the
capabilities file is missing.

pppd's euid != 0 gate (EXIT_NOT_ROOT) would stop the drop, so 502
carries ppp-project/ppp#608 (ppp_privileged: euid 0 or CAP_NET_ADMIN
via capget, Solaris keeps the euid check); drop the patch on the next
PKG_SOURCE_VERSION bump. The same patch grants the privileged option
flag on the capability, which proto_pppoe_setup() needs for its
OPT_PRIV "plugin pppoe.so", but only when the exec did not raise the
privileges itself - AT_SECURE, so a setuid or setgid bit or file
capabilities (502's ppp_secure_exec()) - which keeps setuid-root and
setcap installs refusing an unprivileged invoker's privileged options.

The wrapper also hands /dev/ppp to the ppp user. The node is opened at
link setup (generic_establish_ppp), not only in the (stubbed) kernel
support check, and ships 0600 root:root. chown keeps it reachable for
uid 454 alone; the kernel's ppp_open() gates on
ns_capable(CAP_NET_ADMIN) either way.

pppd mkdir_recursive()s PPP_PATH_VARRUN and writes its <ifname>.pid
and pppd2.tdb there, which is /var/run/pppd with OpenWrt's
--localstatedir=/var; nothing in this package creates that directory
today, and uid 454 cannot do it in a root-owned /var/run, so the
wrapper pre-creates and chowns it. A failure to write the pid file is
a logged error and a failed tdb open a warning (multilink drops),
neither fatal.

Interposing ujail also changes what netifd reads when pppd dies by a
signal: ujail exits with the bare signal number, which collides with
pppd's own exit codes (a SIGSEGV reads as EXIT_PEER_AUTH_FAILED) until
procd reports signal deaths as 128+signal as openwrt/procd#40 does.

The ip-up/ip-down scripts run inside the jail and report back to
netifd over ubus; ubusd's ACL default is deny for every non-root uid,
so the jailed notify_proto call failed silently and the interface
never completed setup while the PPP session sat connected. Ship
/usr/share/acl.d/pppd.json granting uid 454 access to network.interface
notify_proto and network add_dynamic (ppp6-up's dynamic-interface
registry call).

Assisted-by: Claude:claude-opus-5
Signed-off-by: Julius Bairaktaris <julius@bairaktaris.de>
JuliusBairaktaris added a commit to JuliusBairaktaris/openwrt-nss-edma that referenced this pull request Aug 21, 2026
pppd drops to user ppp (uid 454, created via USERID) with CAP_NET_ADMIN
(/dev/ppp, PPPIOCNEWUNIT, SIOCSIF*) and CAP_NET_RAW (pppoe PF_PACKET),
both ambient so ip-up/ip-down scripts keep them, with
PR_SET_NO_NEW_PRIVS. The wrapper covers every proto that routes through
ppp_generic_setup() and is skipped when /sbin/ujail or the
capabilities file is missing.

pppd's euid != 0 gate (EXIT_NOT_ROOT) would stop the drop, so 502
carries ppp-project/ppp#608 (ppp_privileged: euid 0 or CAP_NET_ADMIN
via capget, Solaris keeps the euid check); drop the patch on the next
PKG_SOURCE_VERSION bump. The same patch grants the privileged option
flag on the capability, which proto_pppoe_setup() needs for its
OPT_PRIV "plugin pppoe.so", but only when the exec did not raise the
privileges itself - AT_SECURE, so a setuid or setgid bit or file
capabilities (502's ppp_secure_exec()) - which keeps setuid-root and
setcap installs refusing an unprivileged invoker's privileged options.

The wrapper also hands /dev/ppp to the ppp user. The node is opened at
link setup (generic_establish_ppp), not only in the (stubbed) kernel
support check, and ships 0600 root:root. chown keeps it reachable for
uid 454 alone; the kernel's ppp_open() gates on
ns_capable(CAP_NET_ADMIN) either way.

pppd mkdir_recursive()s PPP_PATH_VARRUN and writes its <ifname>.pid
and pppd2.tdb there, which is /var/run/pppd with OpenWrt's
--localstatedir=/var; nothing in this package creates that directory
today, and uid 454 cannot do it in a root-owned /var/run, so the
wrapper pre-creates and chowns it. A failure to write the pid file is
a logged error and a failed tdb open a warning (multilink drops),
neither fatal.

Interposing ujail also changes what netifd reads when pppd dies by a
signal: ujail exits with the bare signal number, which collides with
pppd's own exit codes (a SIGSEGV reads as EXIT_PEER_AUTH_FAILED) until
procd reports signal deaths as 128+signal as openwrt/procd#40 does.

The ip-up/ip-down scripts run inside the jail and report back to
netifd over ubus; ubusd's ACL default is deny for every non-root uid,
so the jailed notify_proto call failed silently and the interface
never completed setup while the PPP session sat connected. Ship
/usr/share/acl.d/pppd.json granting uid 454 access to network.interface
notify_proto and network add_dynamic (ppp6-up's dynamic-interface
registry call).

Two things the unprivileged uid cannot reach on its own. A serial link is
handed its TTY as pppd's first argument and a TTY is root:dialout 0660,
which CAP_NET_ADMIN does not override, so the wrapper grants the device
the same way it already grants /dev/ppp. And the secrets pppd
authenticates from ship 0600 root:root - chap-secrets here, the RADIUS
server list in ppp-mod-radius - so a postinst regrades them to 0640
root:ppp, which also covers a file preserved from an install that
predates the uid.

Assisted-by: Claude:claude-opus-5
Signed-off-by: Julius Bairaktaris <julius@bairaktaris.de>
JuliusBairaktaris added a commit to JuliusBairaktaris/openwrt-nss-edma that referenced this pull request Aug 22, 2026
pppd drops to user ppp (uid 454, created via USERID) with CAP_NET_ADMIN
(/dev/ppp, PPPIOCNEWUNIT, SIOCSIF*) and CAP_NET_RAW (pppoe PF_PACKET),
both ambient so ip-up/ip-down scripts keep them, with
PR_SET_NO_NEW_PRIVS. The wrapper covers every proto that routes through
ppp_generic_setup() and is skipped when /sbin/ujail or the
capabilities file is missing.

pppd's euid != 0 gate (EXIT_NOT_ROOT) would stop the drop, so 502
carries ppp-project/ppp#608 (ppp_privileged: euid 0 or CAP_NET_ADMIN
via capget, Solaris keeps the euid check); drop the patch on the next
PKG_SOURCE_VERSION bump. The same patch grants the privileged option
flag on the capability, which proto_pppoe_setup() needs for its
OPT_PRIV "plugin pppoe.so", but only when the exec did not raise the
privileges itself - AT_SECURE, so a setuid or setgid bit or file
capabilities (502's ppp_secure_exec()) - which keeps setuid-root and
setcap installs refusing an unprivileged invoker's privileged options.

The wrapper also hands /dev/ppp to the ppp user. The node is opened at
link setup (generic_establish_ppp), not only in the (stubbed) kernel
support check, and ships 0600 root:root. chown keeps it reachable for
uid 454 alone; the kernel's ppp_open() gates on
ns_capable(CAP_NET_ADMIN) either way.

pppd mkdir_recursive()s PPP_PATH_VARRUN and writes its <ifname>.pid
and pppd2.tdb there, which is /var/run/pppd with OpenWrt's
--localstatedir=/var; nothing in this package creates that directory
today, and uid 454 cannot do it in a root-owned /var/run, so the
wrapper pre-creates and chowns it. A failure to write the pid file is
a logged error and a failed tdb open a warning (multilink drops),
neither fatal.

Interposing ujail also changes what netifd reads when pppd dies by a
signal: ujail exits with the bare signal number, which collides with
pppd's own exit codes (a SIGSEGV reads as EXIT_PEER_AUTH_FAILED) until
procd reports signal deaths as 128+signal as openwrt/procd#40 does.

The ip-up/ip-down scripts run inside the jail and report back to
netifd over ubus; ubusd's ACL default is deny for every non-root uid,
so the jailed notify_proto call failed silently and the interface
never completed setup while the PPP session sat connected. Ship
/usr/share/acl.d/pppd.json granting uid 454 access to network.interface
notify_proto and network add_dynamic (ppp6-up's dynamic-interface
registry call).

Two things the unprivileged uid cannot reach on its own. A serial link is
handed its TTY as a pppd argument - first by proto_ppp_setup, last by
comgt's proto_3g_setup - and a TTY is root:dialout 0660, which
CAP_NET_ADMIN does not override, so the wrapper grants whichever
argument is a character device, the same way it already grants /dev/ppp.
And the secrets pppd authenticates from ship 0600 root:root -
chap-secrets here, the RADIUS server list in ppp-mod-radius - so a
uci-defaults script regrades them to 0640 root:ppp. That has to run on
the device: a postinst runs on the build host with IPKG_INSTROOT set,
where chgrp resolves the group name through the host's /etc/group and
not the target's, so the image would ship them root:root regardless.
Running from uci-defaults also covers a file preserved from an install
that predates the uid.

Assisted-by: Claude:claude-opus-5
Signed-off-by: Julius Bairaktaris <julius@bairaktaris.de>
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