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

main: add -Z to drop privileges after binding sockets#40
JuliusBairaktaris wants to merge 1 commit into
openwrt:masterfrom
JuliusBairaktaris:uhttpd-drop-privs

Conversation

@JuliusBairaktaris

Copy link
Copy Markdown
Contributor

All the privileged work uhttpd does happens during startup: binding the
listeners, reading the TLS key, and dlopen'ing the handler plugins. Once
run_server() is reached, nothing the daemon does needs uid 0 — it serves
files, proxies ubus, and forks CGI children, all of which are happy as a
normal user.

-Z user drops to that user at exactly that point, right after
uh_tls_init() and the uh_plugin_init() calls and before the daemonizing
fork, using initgroups() + setgid() + setresuid() so no saved-set-uid
and no supplementary group survives the transition.

Doing the drop inside uhttpd rather than in the service manager is what keeps
the TLS private key at root:root 0600. A sandbox that changes the uid
before execve() forces the key to be readable by the unprivileged user
instead, and on OpenWrt the key path is a uci value, so handing it over means
chowning an operator-supplied path. Binding a privileged port stays free for
the same reason — it happens while the process is still root, so no ambient
CAP_NET_BIND_SERVICE has to be carried across the exec.

Opt-in and inert unless -Z is passed.

This is a prerequisite for openwrt/openwrt#24558, which runs uhttpd as a
dedicated non-root user.

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.

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>
@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-nss-edma 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>
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