Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions build/guest-tests/zones-check.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 10 additions & 0 deletions docs/design/net-zone.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion tools/image/zones-test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
16 changes: 16 additions & 0 deletions tools/net/sntp-offset.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -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:
Expand Down
13 changes: 13 additions & 0 deletions tools/net/update-fetch.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -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.
Expand Down
Loading