Skip to content

dnsmasq in the net zone drops to nobody once its sockets are bound, and reads its servers' file itself when it changes - #257

Merged
DevomB merged 6 commits into
mainfrom
dnsmasq-unprivileged
Oct 9, 2026
Merged

DevomB merged 6 commits into
mainfrom
dnsmasq-unprivileged

Conversation

@DevomB

@DevomB DevomB commented Oct 9, 2026 •

Copy link
Copy Markdown
Owner

Stacked on #245 (dhcpcd-privsep): it uses the capabilities and the nobody mapping that PR gives the net zone. Once #245 merges, this PR's diff is only its own commit.

What was wrong. dnsmasq parses every answer from the uplink's resolvers, and every query from the routed zones, as the net zone's root. That puts CAP_NET_ADMIN, CAP_NET_RAW, the firewall, the Wi-Fi credentials and every root process in the zone within reach of a parser bug. It was started with --no-daemon, which is dnsmasq's debug mode and skips its own privilege drop whatever --user says.

What changed.

  • dnsmasq starts with --keep-in-foreground and without --no-daemon. It binds as the zone's root, then drops to the zone's nobody (--user=nobody --group=nogroup; host uid uid_base + 65534). It keeps at most CAP_NET_BIND_SERVICE, so it can bind fd19::1, which stays tentative until the bridge has a port. Every zone keeps that capability.
  • dnsmasq's drop calls capset (keep CAP_SETUID across setuid, then let it go). seccomp::CAP_CALLS now opens capset with setuid under CAP_SETUID, which only the nic zone may keep (caps::PRIVSEP, from dhcpcd in the net zone separates its privileges: its parsers run as a user of their own in an empty root, and a hook that trusts nothing writes the zone's resolv.conf #245). capset can only narrow or move capabilities the process already holds, so it reaches nothing past the zone's own set. Every other zone still gets EPERM.
  • The zone's root cannot signal nobody, so:
    • dnsmasq reads the file sync_upstream replaces by itself. Without --no-poll it checks the file before the next query, at most once a second.
    • --clear-on-reload drops what the last servers answered, as SIGHUP did.
    • The loop checks that dnsmasq is alive in /proc rather than with kill -0, which would fail with EPERM and restart dnsmasq every pass.
    • Cleanup no longer tries to kill dnsmasq; the zone's end ends it.
  • The servers' file is written mode 644, whatever the umask.
  • --log-facility=- keeps dnsmasq's own lines in the zone's log. --pid-file with no path writes no pid file; nothing read it, and the zone's root could not have chowned it.
  • Docs: net-zone.md (the resolver, the calls) and zone-policy-files.md.

Tests.

  • Unit: kept_capabilities_open_their_calls covers capset: it is opened by CAP_SETUID alone and still EPERM in the base program.
  • tools/tests/netzone-dns.sh:
    • dnsmasq is started as nobody, on the file, without --no-poll or --no-daemon.
    • The loop sends no signal.
    • The file is 644 under umask 077.
  • zones-check.sh:
    • New check dnsmasq-unprivileged: every dnsmasq is at host uid uid_base + 65534 for real, effective, saved and fs ids, has no capability but CAP_NET_BIND_SERVICE, and none runs as the zone's root.
    • dns-follows-lease is now stricter. It used to count the zone's own "told dnsmasq" lines. Now it queries dnsmasq until dnsmasq logs that it read the file again, then requires dnsmasq's own using nameserver 192.0.2.53#53 after the server is added, and its absence after the server is removed.

How the run proves it. Distro run 37879216831 on e60836b is green in every part, with zones 84/0:

  • dnsmasq-unprivileged: one dnsmasq at host uid 262142 (the net zone's nobody) in all four ids, with no capability but CAP_NET_BIND_SERVICE, and none as the zone's root.
  • dns-follows-lease: dnsmasq logged reading its file, used 192.0.2.53 once the lease named it, and stopped using it after the lease dropped it.
  • dhcpcd-separated, net-dns, routed-dns and dns-after-reload still pass.

CI 37875047166 is green on the same head. The first run, 37874542851 on 79e76c2, had the same zones result. It failed only the netzone-dns.sh check that e60836b fixes: that check had matched --no-daemon in a comment.

DevomB added 6 commits October 7, 2026 08:48
… user of their own in an empty root, and a hook that trusts nothing writes the zone's resolv.conf

dhcpcd parsed every lease, DHCPv6 reply and router advertisement as the net
zone's root, with that zone's capabilities, because the zone denied what its
privilege separation needs. Now the net zone's policy keeps CAP_SETUID,
CAP_SETGID and CAP_SYS_CHROOT (caps::PRIVSEP: kept together, by the nic zone
alone), and those open setuid, setgid, setgroups and chroot in its filter
(seccomp::CAP_CALLS) while every other zone's filter stays as it was. Its
user namespace maps a third id, 100, to uid_base + 100 and allows setgroups,
its passwd and group name dhcpcd there, and /var/empty is made on the root
before it is sealed. dhcpcd's own seccomp filter then confines the parsers.

The helper that stays root runs the hook with the environment the parsers
send, so netzone-init.sh starts dhcpcd with Kryptik's hook instead of
dhcpcd-run-hooks: it checks every value for form, keeps its state in
/run/lease-dns, and writes only nameserver lines. dhcpcd -x leaves cleanup:
the zone's root cannot signal the dhcpcd user, and the zone's end ends dhcpcd.

The design doc said the opposite: that three capabilities for the hostile
zone were a net loss with the zone as the sandbox. It now says what the
helper still does for the parsers and what a parser bug no longer reaches.
…igits

str.isdigit() is true for "²", which int() then refuses, so a lifetime like
that from the unprivileged side crashed the hook before it rebuilt
resolv.conf: nothing written, but a crash where a refusal was meant. The
suite now sends one and needs the hook to exit 0 with the file unchanged.
The one conflict is zones-check.sh's net-dns line, which main now reads
through the catch-all log's helpers; dhcpcd-separated follows it. With the
merge, zones-test.sh lists dhcpcd-separated, and the comment over the setuid
check says what stage 06 does since c431bf4: it fails the build on a bit the
allowlist does not justify, where it used to strip it.
CodeQL read the failure message of an assertion on passwd_for's output as
cleartext logging of sensitive information. The synthesized passwd holds no
secret, but the message adds nothing the assertion does not already say.
…nd reads its servers' file itself when it changes

It ran as the zone's root under --no-daemon, which skips dnsmasq's own
privilege drop whatever --user says. Now it binds as root and runs as the
zone's nobody with at most CAP_NET_BIND_SERVICE, kept for fd19::1, which
stays tentative until the bridge has a port. Its drop calls capset, which
CAP_SETUID now opens beside setuid for the nic zone. The zone's root cannot
signal nobody, so dnsmasq polls the file sync_upstream replaces, with
--clear-on-reload, and logs to the zone's pipe with --log-facility=-.
@DevomB
DevomB marked this pull request as ready for review October 9, 2026 04:22
@DevomB
DevomB changed the base branch from dhcpcd-privsep to main October 9, 2026 07:07
@DevomB
DevomB merged commit 55af2aa into main Oct 9, 2026
16 checks passed
@DevomB
DevomB deleted the dnsmasq-unprivileged branch October 9, 2026 07:07
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.

1 participant