main: add -Z to drop privileges after binding sockets - #40
JuliusBairaktaris wants to merge 1 commit into
Conversation
|
@jow- @hauke — this is the uhttpd side of running the daemon as a dedicated The reason it is a uhttpd change rather than just a procd 26 lines, inert unless |
uhttpd currently requires root for the whole lifetime of the process, even though all privileged work (binding the listeners, reading the TLS key) happens during startup. Add -Z to drop to an unprivileged user once that startup is done, using initgroups/setgid/setresuid so no saved-root or supplementary-group state survives. Service managers that cannot use a sandbox (plain procd, systemd) can then run the daemon unprivileged on privileged ports. The drop precedes the handler plugin initialization because the ubus plugin connects to ubusd there, and ubusd reads the peer credentials once, when the connection is accepted: a socket opened before the drop stays a uid 0 connection for the life of the process, and ubusd exempts uid 0 from every ACL it enforces. Connecting afterwards is what puts the daemon's ubus access under /usr/share/acl.d. The plugins need no privilege of their own - they dlopen a module and read a handler script - whereas the TLS key is read before the drop and keeps its root ownership. Assisted-by: Claude:claude-opus-5 Signed-off-by: Julius Bairaktaris <julius@bairaktaris.de>
f979adf to
9751afb
Compare
|
Amended: the drop now happens before the handler plugins are initialised rather than after them. ubusd reads the peer uid from The plugins need no privilege of their own — they dlopen a module and read a handler script — and the TLS key is still read before the drop, so it keeps |
uhttpd drops to user uhttpd (uid 456, created via USERID) with -Z, which it applies itself once the listeners are bound and the TLS key is read. The jail it runs in holds CAP_NET_BIND_SERVICE for the 80/443 bind and CAP_SETUID + CAP_SETGID for the drop, under PR_SET_NO_NEW_PRIVS; none of the three survive it, since a uid change away from root clears the permitted and effective sets without SECBIT_KEEP_CAPS. Ambient and inheritable stay empty: nothing has to cross an execve() here, because the bind still happens as root. Dropping inside the daemon rather than with procd's user parameter is what keeps the key at root:root 0600. The key path is a uci value, so dropping before execve() would mean chowning an operator-supplied path to the unprivileged user, and would need CAP_NET_BIND_SERVICE ambient to reach the bind. The jail is opt-in like the odhcpd one: applied when /sbin/ujail exists and /etc/capabilities/uhttpd.json is installed, and procd_add_jail with no flags keeps the instance out of a mount namespace. ubusd denies every non-root uid by default, so /usr/share/acl.d/uhttpd.json grants uid 456 what LuCI reaches: the ubus proxy connects to ubusd after the drop, so the grant is what gates it, and so do the CGI handlers on their own connections. Objects rpcd owns (file, uci, rc, iwinfo, luci, luci-rpc, luci.*, network.rrdns, rpc-sys) are granted with a method wildcard: rpcd re-checks the session ACL for non-root callers, so the grant alone does not authorise anything. The objects other daemons own - system, network, network.device, network.interface, service, hostapd.* and log from procd, netifd, hostapd and logd, dsl from ltq-vdsl-vr9-app and fingerprint from ufp where those are installed - are gated by ubusd alone and are granted method-explicit: system/reboot and hostapd.*/wps_start + del_client remain reachable without a session token, a residual DoS/proximity surface rather than privilege escalation, and still a large net reduction from running as root. session/create, session/grant and session/revoke are withheld so a compromised uhttpd cannot mint itself a privileged session. -Z does not exist in the pinned uhttpd, so it rides along as a package patch, openwrt/uhttpd#40 unchanged, until the next PKG_SOURCE_VERSION bump picks it up from upstream and the patch goes away. Assisted-by: Claude:claude-opus-5 Signed-off-by: Julius Bairaktaris <julius@bairaktaris.de>
uhttpd drops to user uhttpd (uid 456, created via USERID) with -Z, which it applies itself once the listeners are bound and the TLS key is read. The jail it runs in holds CAP_NET_BIND_SERVICE for the 80/443 bind and CAP_SETUID + CAP_SETGID for the drop, under PR_SET_NO_NEW_PRIVS; none of the three survive it, since a uid change away from root clears the permitted and effective sets without SECBIT_KEEP_CAPS. Ambient and inheritable stay empty: nothing has to cross an execve() here, because the bind still happens as root. Dropping inside the daemon rather than with procd's user parameter is what keeps the key at root:root 0600. The key path is a uci value, so dropping before execve() would mean chowning an operator-supplied path to the unprivileged user, and would need CAP_NET_BIND_SERVICE ambient to reach the bind. The jail is opt-in like the odhcpd one: applied when /sbin/ujail exists and /etc/capabilities/uhttpd.json is installed, and procd_add_jail with no flags keeps the instance out of a mount namespace. ubusd denies every non-root uid by default, so /usr/share/acl.d/uhttpd.json grants uid 456 what LuCI reaches. It ships with uhttpd-mod-ubus rather than the base package, which links no ubus library and opens no connection to grant: the ubus proxy connects to ubusd after the drop, so the grant is what gates it, and so do the CGI handlers on their own connections. Objects rpcd owns (file, uci, rc, iwinfo, luci, luci-rpc, luci.*, rpc-sys) are granted with a method wildcard: rpcd re-checks the session ACL for non-root callers, so the grant alone does not authorise anything. network.rrdns is an exec plugin with no session ACL of its own, so it is named by its single method instead. The objects other daemons own - system, network, network.device, network.interface, service, hostapd.* and log from procd, netifd, hostapd and logd, dsl from ltq-vdsl-vr9-app and ltq-vdsl-vr11-app, and fingerprint from ufp where those are installed - are gated by ubusd alone and are granted method-explicit: system/reboot and hostapd.*/wps_start + del_client remain reachable without a session token, a residual DoS/proximity surface rather than privilege escalation, and still a large net reduction from running as root. session/create, session/grant and session/revoke are withheld so a compromised uhttpd cannot mint itself a privileged session. -Z does not exist in the pinned uhttpd, so it rides along as a package patch, openwrt/uhttpd#40 unchanged, until the next PKG_SOURCE_VERSION bump picks it up from upstream and the patch goes away. Assisted-by: Claude:claude-opus-5 Signed-off-by: Julius Bairaktaris <julius@bairaktaris.de>
uhttpd drops to user uhttpd (uid 456, created via USERID) with -Z, which it applies itself once the listeners are bound and the TLS key is read. The jail it runs in holds CAP_NET_BIND_SERVICE for the 80/443 bind and CAP_SETUID + CAP_SETGID for the drop, under PR_SET_NO_NEW_PRIVS; none of the three survive it, since a uid change away from root clears the permitted and effective sets without SECBIT_KEEP_CAPS. Ambient and inheritable stay empty: nothing has to cross an execve() here, because the bind still happens as root. Dropping inside the daemon rather than with procd's user parameter is what keeps the key at root:root 0600. The key path is a uci value, so dropping before execve() would mean chowning an operator-supplied path to the unprivileged user, and would need CAP_NET_BIND_SERVICE ambient to reach the bind. The jail is opt-in: applied when /sbin/ujail exists and /etc/capabilities/uhttpd.json is installed, and procd_add_jail with no flags keeps the instance out of a mount namespace. ubusd denies every non-root uid by default, so /usr/share/acl.d/uhttpd.json grants uid 456 what LuCI reaches. It ships with uhttpd-mod-ubus rather than the base package, which links no ubus library and opens no connection to grant: the ubus proxy connects to ubusd after the drop, so the grant is what gates it, and so do the CGI handlers on their own connections. Objects rpcd owns (file, uci, rc, iwinfo, luci, luci-rpc, luci.*, rpc-sys) are granted with a method wildcard, which openwrt/rpcd#38 puts behind the session ACL for non-root callers; without it rc, rpc-sys and iwinfo carry no session check at all. network.rrdns is an rpcd plugin with a single method, which the grant names rather than wildcarding. The objects other daemons own are gated by ubusd alone and are granted method-explicit: system/reboot and hostapd.*/wps_start + del_client remain reachable without a session token, a residual DoS/proximity surface rather than privilege escalation. The session object is granted only what LuCI's login flow uses, so a compromised uhttpd cannot mint itself a privileged session. -Z does not exist in the pinned uhttpd, so it rides along as a package patch, openwrt/uhttpd#40 unchanged, until the next PKG_SOURCE_VERSION bump picks it up from upstream and the patch goes away. Assisted-by: Claude:claude-opus-5 Signed-off-by: Julius Bairaktaris <julius@bairaktaris.de>
uhttpd itself needs root only during startup, to bind the listeners and to
read the TLS key, and then keeps it for the process lifetime.
-Z userdropsit once startup is done; without
-Znothing changes. Read before the drop,the key stays
root:root 0600. CGI children inherit the dropped uid, so ahandler needing root needs its own fix (openwrt/cgi-io#6).
The drop is these three calls in this order, so no saved root uid and no root
supplementary group survives it, and it runs before the daemonizing fork:
It sits after
uh_tls_init()and before theuh_plugin_init()calls, becausethe ubus plugin connects in its init:
My understanding is that ubusd reads the peer uid from SO_PEERCRED when the
connection is accepted and exempts uid 0 from its ACLs, so a socket opened
before the drop stays a root socket for the life of the process. Connecting
afterwards is what puts the daemon's ubus access under
/usr/share/acl.d.This has run on an ipq807x router.
file execon rpcd with no session idanswered
uid=0(root)through a ubus socket opened before the drop, andPermission deniedthrough one opened after, on a single before/after run.Beyond that I have not exercised the daemon at the dropped uid. I used AI to
help me write this change.
openwrt/openwrt#24740 is the package side that passes
-Z.Is before
uh_plugin_init()the right place, or does a lua or ucode init needroot for anything?