Skip to content

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

Merged
DevomB merged 4 commits into
mainfrom
dhcpcd-privsep
Oct 9, 2026

Conversation

@DevomB

@DevomB DevomB commented Oct 7, 2026 •

Copy link
Copy Markdown
Owner

Owner's decision: merge (2026-10-09). This reverses a choice the design doc recorded; the owner weighed both sides below and said yes.

What was wrong. dhcpcd parsed every DHCP lease, DHCPv6 reply and router advertisement as the net zone's root, with the zone's capabilities: CAP_NET_ADMIN, CAP_NET_RAW, the Wi-Fi credentials file, the broker's socket and the firewall's netlink socket all within reach of a parser bug. dhcpcd ships privilege separation, but the zone denied what it needs. The design doc called granting that a net loss: "the zone is the sandbox."

What changed.

kryptikd:

  • caps::PRIVSEP (CAP_SETUID, CAP_SETGID, CAP_SYS_CHROOT) is keepable, by the nic zone alone (NIC_ONLY), and kept together or not at all (policy::parse).
  • seccomp::CAP_CALLS: a kept capability opens the denied calls it serves (setuid; setgid, setgroups; chroot). widened_for adds them; every other zone's program is unchanged, and the denied list's doc names this as its one exception.
  • isolate::write_id_maps maps a third id, 100 (SERVICE_ID), to uid_base + 100, and leaves setgroups allowed, only on a root launch of a zone that keeps PRIVSEP.
  • rootfs: that zone's passwd and group name dhcpcd at 100, home /var/empty, made on the root tmpfs before it is sealed.
  • spawn passes the flag through, and seccomp-trace builds the same filter.

The net zone:

  • policy/net.seccomp keeps the three capabilities.
  • netzone-init.sh starts dhcpcd with -c /usr/libexec/kryptik/dhcpcd-hook, and drops dhcpcd -x from its cleanup (the zone's root cannot signal the dhcpcd user, and the zone's end ends dhcpcd).
  • tools/net/dhcpcd-hook.py replaces dhcpcd-run-hooks. dhcpcd's root helper runs the hook with the environment the parsers send, so it checks every value for form (an interface name, a protocol from a list, IP literals with a scope only on that interface). It keeps its state in /run/lease-dns, outside what the helper writes for dhcpcd, and writes only nameserver lines, by temp file and rename. Its paths come from the command line, which dhcpcd never fills.

Tests and docs:

  • Unit tests for the capability set, the policy rules, the widened filter and the passwd lines.
  • tools/tests/netzone-hook.sh feeds the hook hostile environments: words that are not addresses, a second line, a path as an interface, an unknown protocol, junk in its own state files, PYTHON* variables.
  • zones-check.sh's dhcpcd-separated: at least one dhcpcd process as host uid uid_base + 100 in an empty root, with no effective capability and two seccomp filters; a root helper; and a lease file.
  • net-zone.md, zone-policy-files.md and status.md say what this does and costs.

The trade.

  • Gained: a bug in a parser lands as an unprivileged user, chrooted to an empty directory, under dhcpcd's own filter. It no longer reaches the Wi-Fi credentials, the broker, the firewall's netlink socket or a program to run.
  • A dropped parser cannot climb back. Every zone process runs with no_new_privs, so a setuid binary grants nothing, and a chroot escape reaches only the zone's own tree.
  • Still reachable through the helper: addresses, routes and links over netlink, the net sysctls, and dhcpcd's own files under /run/dhcpcd and /var/lib/dhcpcd.
  • Cost: the hostile zone keeps three more capabilities and four more calls, and maps a third id. In the zone's own user and mount namespaces these reach only its mapped ids and its own tree. No other zone's filter or identity changes.

How the run proves it. Distro run 37681467342 on 42df7ff (main 68bf207 merged in) is green in every part: zones 83/0, with dhcpcd-separated (parsers as uid uid_base + 100 in an empty root, no capability, two filters, a root helper, a lease file), net-dns, routed-dns, net-lease-names-resolver, dns-follows-lease, wifi-lease and wifi-associated passing.

… 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.
Comment thread compartments/kryptikd/src/rootfs/tests.rs Fixed
…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.
@DevomB
DevomB marked this pull request as ready for review October 9, 2026 02:02
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.
@DevomB
DevomB merged commit 21f172e into main Oct 9, 2026
11 checks passed
@DevomB
DevomB deleted the dhcpcd-privsep 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.

2 participants