Skip to content

main: add -Z to drop privileges after binding sockets - #40

Open
JuliusBairaktaris wants to merge 1 commit into
openwrt:masterfrom
JuliusBairaktaris:uhttpd-drop-privs
Open

JuliusBairaktaris wants to merge 1 commit into
openwrt:masterfrom
JuliusBairaktaris:uhttpd-drop-privs

Conversation

@JuliusBairaktaris

@JuliusBairaktaris JuliusBairaktaris commented Aug 15, 2026 •

Copy link
Copy Markdown
Contributor

Adds -Z user. uhttpd needs root only to bind its listeners and read the TLS
key. With -Z it drops to that user after startup (initgroups(), setgid(),
setresuid()), before it daemonizes. Without -Z nothing changes.

The drop happens after uh_tls_init() and before the plugins are
initialised. The ubus plugin connects in its init, and ubusd decides whether a
connection is root when it is accepted. Connecting after the drop puts uhttpd's
ubus access under /usr/share/acl.d.

CGI children inherit the dropped uid, so a handler that needs root needs its
own fix (openwrt/cgi-io#6). openwrt/openwrt#24740 is the package side that
passes -Z.

Tested on an ipq807x router: an rpcd file exec with no session returned
uid=0(root) over a socket opened before the drop and Permission denied over
one opened after. I have not tested much else at the dropped uid yet.

Is before the plugin init the right place, or does a lua or ucode handler need
root during its init?

I used AI to help me write the change.

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>
Copilot AI lite review requested due to automatic review settings August 15, 2026 08: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

Copy link
Copy Markdown
Contributor Author

@jow- @hauke — this is the uhttpd side of running the daemon as a dedicated
non-root user on OpenWrt (openwrt/openwrt#24558).

The reason it is a uhttpd change rather than just a procd user parameter is
the TLS key: dropping inside the daemon keeps the key at root:root 0600,
because it is already read by the time the drop happens. Dropping before
execve() instead would mean chowning the key to the unprivileged user, and
on OpenWrt that path comes from uci — so it would be a chown of an
operator-supplied path, which is a worse trade than the one it buys.

26 lines, inert unless -Z is passed.

@JuliusBairaktaris

Copy link
Copy Markdown
Contributor Author

Amended: the drop now happens before the handler plugins are initialised rather than after them.

ubusd reads the peer uid from SO_PEERCRED when the connection is accepted and exempts uid 0 from every ACL it enforces, so the socket the ubus plugin opens in its init stayed a root socket for the life of the process. In that state — connected as root, uid dropped, no capabilities left — file exec on rpcd with no session id still comes back with uid=0(root); the same call from a connection opened after the drop is refused with Permission denied. 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 — and the TLS key is still read before the drop, so it keeps root:root 0600.

JuliusBairaktaris added a commit to JuliusBairaktaris/openwrt-ppe-nss that referenced this pull request Aug 19, 2026
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>
JuliusBairaktaris added a commit to JuliusBairaktaris/openwrt-ppe-nss that referenced this pull request Aug 21, 2026
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>
JuliusBairaktaris added a commit to JuliusBairaktaris/openwrt-ppe-nss that referenced this pull request Sep 7, 2026
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>
JuliusBairaktaris added a commit to JuliusBairaktaris/openwrt-ppe-nss that referenced this pull request Oct 2, 2026
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>
JuliusBairaktaris added a commit to JuliusBairaktaris/openwrt-ppe-nss that referenced this pull request Oct 8, 2026
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>
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.

2 participants