diff --git a/build/guest-tests/zones-check.sh b/build/guest-tests/zones-check.sh index 9bea55ad..ba7a1626 100755 --- a/build/guest-tests/zones-check.sh +++ b/build/guest-tests/zones-check.sh @@ -98,6 +98,47 @@ if [[ "$dns_dropped" -ge 1 && "$dns_root" -eq 0 ]]; then else fail "dnsmasq-unprivileged" "dropped ${dns_dropped}, as root ${dns_root}:${dns_seen:- no dnsmasq running}" fi +# The update fetcher and the SNTP client read what far hosts send, as the net +# zone's root: each gives up every capability before it reads a byte. In the +# net zone's user namespace, where that root holds them all, each is held +# waiting on what never answers and read: the fetcher on a broker socket that +# never replies, and the SNTP client, in the net zone's own network, whose +# loopback is up, on a time server bound there. +quiet="$(mktemp -d)"; chmod 755 "$quiet" +python3 -c 'import socket, sys, time +b = socket.socket(socket.AF_UNIX); b.bind(sys.argv[1]); b.listen(4); time.sleep(40)' "$quiet/broker" & +QB=$! +nsenter -t "${nz_init:-0}" -n python3 -c 'import socket, sys, time +u = socket.socket(socket.AF_INET, socket.SOCK_DGRAM); u.bind(("127.0.0.1", 0)) +open(sys.argv[1], "w").write("%d\n" % u.getsockname()[1]) +time.sleep(40)' "$quiet/udp-port" & +QU=$! +for _ in $(seq 1 20); do [[ -S "$quiet/broker" && -s "$quiet/udp-port" ]] && break; sleep 0.25; done +chmod 666 "$quiet/broker" 2>/dev/null +port="$(cat "$quiet/udp-port" 2>/dev/null)" +nsenter -t "${nz_init:-0}" -U python3 /usr/libexec/kryptik/update-fetch.py poll --broker "$quiet/broker" > "$quiet/fetch.out" 2>&1 & +QF=$! +nsenter -t "${nz_init:-0}" -U -n python3 /usr/libexec/kryptik/sntp-offset.py --timeout 30 --server "127.0.0.1:${port}" > "$quiet/sntp.out" 2>&1 & +QS=$! +caps_of() { awk '/^(CapInh|CapPrm|CapEff|CapAmb|NoNewPrivs):/ { printf "%s%s ", $1, $2 }' "/proc/$1/status" 2>/dev/null; } +none="CapInh:0000000000000000 CapPrm:0000000000000000 CapEff:0000000000000000 CapAmb:0000000000000000 NoNewPrivs:1 " +for _ in $(seq 1 40); do [[ "$(caps_of "$QF")" == "$none" && "$(caps_of "$QS")" == "$none" ]] && break; sleep 0.25; done +held_f="$(caps_of "$QF")"; held_s="$(caps_of "$QS")" +kill "$QF" "$QS" "$QB" "$QU" 2>/dev/null +wait "$QF" 2>/dev/null; rc_f=$?; wait "$QS" 2>/dev/null; rc_s=$?; wait "$QB" "$QU" 2>/dev/null +if [[ "$held_f" == "$none" && "$held_s" == "$none" ]]; then + pass "fetch-and-sntp-no-caps" "update-fetch.py and sntp-offset.py each hold no capability, with no new privileges, while they wait on the network" +else + # What a send to that server meets from where the client ran: a client that ends at once says nothing. + probe="$(nsenter -t "${nz_init:-0}" -U -n python3 -c 'import socket, sys +s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) +try: + print("sent", s.sendto(b"x", ("127.0.0.1", int(sys.argv[1])))) +except (OSError, ValueError) as e: + print("refused:", e)' "$port" 2>&1 | tr '\n' ' ')" + fail "fetch-and-sntp-no-caps" "update-fetch.py: ${held_f:-gone, exit $rc_f: $(head -c 300 "$quiet/fetch.out" | tr '\n' ' ')}| sntp-offset.py: ${held_s:-gone, exit $rc_s: $(head -c 300 "$quiet/sntp.out" | tr '\n' ' ')}| a send from there: ${probe:-nothing said}" +fi +rm -rf "$quiet" if ip link show eth0 >/dev/null 2>&1; then fail "zone0-nic" "eth0 is still in zone 0"; else pass "zone0-nic" "eth0 is not in zone 0 (moved into the net zone)"; fi if [[ -z "$(ip route show default 2>/dev/null)" ]]; then pass "zone0-no-route" "zone 0 has no default route"; else fail "zone0-no-route" "$(ip route show default)"; fi if ping -c1 -W2 10.0.2.2 >/dev/null 2>&1; then fail "zone0-offline" "zone 0 reached the VM gateway"; else pass "zone0-offline" "zone 0 cannot reach the VM gateway"; fi diff --git a/docs/design/net-zone.md b/docs/design/net-zone.md index 31d370c5..be67a373 100755 --- a/docs/design/net-zone.md +++ b/docs/design/net-zone.md @@ -163,6 +163,12 @@ query and the [update](update-channel.md) fetcher. Builds on nothing but `nameserver` lines (`tools/tests/netzone-hook.sh`). A bug in a parser no longer reaches the Wi-Fi credentials, the broker's socket, the firewall's netlink socket or a program to run. +- **The clock query and the update fetcher hold no capability.** + `sntp-offset.py` and `update-fetch.py` run as the zone's root and, before + they read anything from the network, set no new privileges and empty their + capability sets with `capset`, which the filter allows for the drops above. + What parses a time server's reply, TLS or HTTP keeps the zone root's files, + but not the firewall, the routes or a raw socket. - **Readiness**, printed again on any change: ```text @@ -358,6 +364,10 @@ as `SIGSYS` in the zone's log and a `wifi=connecting` that never changes. point, whose own address that zone is refused while it reaches an address the access point routes. It pings with an unprivileged ICMP socket (`build/guest-tests/icmp-echo.py`), since routed zones lack `CAP_NET_RAW`. +- The zones suite runs `sntp-offset.py` and `update-fetch.py` in the net + zone's user namespace, where its root holds every capability, keeps each + waiting on something that never answers, and reads that neither holds a + capability and both have no new privileges. ## Kernel requirements diff --git a/tools/image/zones-test.sh b/tools/image/zones-test.sh index fac4bce3..32004b51 100755 --- a/tools/image/zones-test.sh +++ b/tools/image/zones-test.sh @@ -69,7 +69,7 @@ for name in kernel-support policies net-ready net-dns dhcpcd-separated dnsmasq-u time-floor-ran time-clamp time-floor-forged time-claim-stepped time-claim-floor time-claim-consent pids-limit ephemeral-size-bound cpu-max-set lifecycle-repeat lifecycle-registry resolver-after-attach dns-cache-off dns-counters-hidden net-zone-sysfs-nics-only \ terminal-terminfo man-page text-browser tls-trust \ volume-init zone-source-pinned bridge-ports-closed encrypted-zone-start stop-closes-volume wrong-passphrase persist-reopen no-mapping-after ephemeral-gone concurrent-start-refused full-volume header-restore volume-destroy vault-offline vault-ping no-passphrase-leak \ - setuid-only-allowed no-file-capabilities sysctls-applied host-activity-hidden; do + setuid-only-allowed no-file-capabilities sysctls-applied host-activity-hidden fetch-and-sntp-no-caps; do grep -q "ZT PASS ${name}" <<<"$T2" && green "guest: ${name}" || red "guest: ${name} (not passed)" done diff --git a/tools/net/sntp-offset.py b/tools/net/sntp-offset.py index 7ff574a9..6c4e6c1a 100755 --- a/tools/net/sntp-offset.py +++ b/tools/net/sntp-offset.py @@ -7,6 +7,7 @@ decimals) and how many servers that is the median of; nothing, and exit 1, if none answered. A plain SNTP query (RFC 4330) that sets nothing. """ +import ctypes import select import socket import struct @@ -17,6 +18,17 @@ ERA = 1 << 32 +def drop_capabilities(): + """None of the net zone root's capabilities, CAP_NET_ADMIN among them, for what reads a far + host's bytes; no new privileges, so an exec cannot take them back.""" + libc = ctypes.CDLL(None, use_errno=True) + ulong = ctypes.c_ulong + header = (ctypes.c_uint32 * 2)(0x20080522, 0) # capability version 3, this process + # 38 is PR_SET_NO_NEW_PRIVS; capset with zeroed sets empties effective, permitted and inheritable. + if libc.prctl(38, ulong(1), ulong(0), ulong(0), ulong(0)) or libc.capset(header, (ctypes.c_uint32 * 6)()): + raise OSError(ctypes.get_errno(), "the net zone's capabilities could not be dropped") + + def to_ntp(t): secs = int(t) + NTP_EPOCH return struct.pack("!II", secs % ERA, int((t - int(t)) * ERA) % ERA) @@ -87,6 +99,10 @@ def measure(targets, timeout): def main(argv): + try: + drop_capabilities() + except OSError as e: + sys.exit("sntp-offset: %s" % e) timeout, targets = 8.0, [] it = iter(argv) for a in it: diff --git a/tools/net/update-fetch.py b/tools/net/update-fetch.py index 245403da..fd8969e6 100755 --- a/tools/net/update-fetch.py +++ b/tools/net/update-fetch.py @@ -10,6 +10,7 @@ verifies every piece. Keeps nothing: a release is larger than this zone's storage. """ import argparse +import ctypes import socket import ssl import sys @@ -21,6 +22,17 @@ ROUNDS = 8 # polls per run: the manifest, then the files, then idle +def drop_capabilities(): + """None of the net zone root's capabilities, CAP_NET_ADMIN among them, for what reads a far + host's bytes; no new privileges, so an exec cannot take them back.""" + libc = ctypes.CDLL(None, use_errno=True) + ulong = ctypes.c_ulong + header = (ctypes.c_uint32 * 2)(0x20080522, 0) # capability version 3, this process + # 38 is PR_SET_NO_NEW_PRIVS; capset with zeroed sets empties effective, permitted and inheritable. + if libc.prctl(38, ulong(1), ulong(0), ulong(0), ulong(0)) or libc.capset(header, (ctypes.c_uint32 * 6)()): + raise OSError(ctypes.get_errno(), "the net zone's capabilities could not be dropped") + + def ask(broker, header, payload=b""): """One request to zone 0's broker, one reply line.""" s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) @@ -122,6 +134,7 @@ def main(): ap.add_argument("--ca", default="/etc/ssl/certs/ca-certificates.crt") args = ap.parse_args() try: + drop_capabilities() return latest(args) if args.what == "latest" else poll(args) except (OSError, ValueError) as e: # urllib's errors are OSErrors # One line: the text can hold a host's own words, line ends among them.