jail: don't let the jailed process outlive the jail - #40
Merged
Conversation
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>
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 Relevant to every daemon jailed by openwrt/openwrt#24558, which is why it is listed there as merging first. |
JuliusBairaktaris
force-pushed
the
jail-pdeathsig
branch
2 times, most recently
from
August 15, 2026 09:06
0bef127 to
9393e53
Compare
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>
dangowrt
force-pushed
the
jail-pdeathsig
branch
from
August 15, 2026 16:18
9393e53 to
b26c6bb
Compare
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>
dangowrt
force-pushed
the
jail-pdeathsig
branch
from
August 18, 2026 16:54
b26c6bb to
2c1834a
Compare
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 onSIGTERM (
service/instance.c).netifd_kill_processes()when it exits(
main.c), which is reached on a plain/etc/init.d/network restartwhenevera 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 rightbefore the
execve(), after the user/group and capability transitions(
set_jail_user(), the usernssetreuid()/setregid()andapplyOCIcapabilities()all clear the setting on credential change, so anearlier placement silently no-ops for
-U/-G/-fjails). Since the parentcan die between
fork()and theprctl, the child checksgetppid()andself-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 baresignal number. A supervisor that only reads
WEXITSTATUScannot tell asignal-killed process from one that exited with the signal number as its code;
pppd's exit table collides head-on (
EXIT_PEER_AUTH_FAILEDis 9,EXIT_CONNECT_TIMEis 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>hostapd,wpa_supplicant,ntpdanddnsmasqjails start and stop normallywith the patched ujail, including the
-U/-Guser jails whose setting theplacement change is what makes survive.