"""CKS tasks 40-55: System Hardening - the pool tasks that take the domain from 1 to 17. 3.1 Host footprint 40 what listens on that port? 41 a service nobody needs 42 setuid files and a left-over account 43 kernel modules that must not load 44 a container runtime that tells everyone 3.2 Least privilege 45 sudo for two commands, not for all 46 sshd that lets root and passwords in 47 admin kubeconfigs lying around 3.3 Network access 48 Services open on the nodes 49 a port for the control plane only 50 Pods that may only talk to the cluster 51 workloads on the node's own ports 3.4 Kernel hardening 52 a seccomp profile for a Deployment 53 a profile the node does not have 54 write a profile, for one container 55 seccomp by default These are tasks on the nodes themselves: every exam host is a full system with systemd, apt, sshd and a firewall, reached as `candidate` with `sudo -i`. What is asked for is tested for real - a port that must be closed is connected to, a sudo rule is tried as the user, a seccomp profile is tried in the container. AppArmor is missing here on purpose: container nodes share this machine's kernel, which has none; those tasks need the VM backend (see the README). Every task has a scripted solution in solve.py. """ from cks.common import API, C, EXT, NET, PODS, PY, NS, ns_pick, name_pick # A service of a node's own: a systemd unit that listens on a TCP port, run from its own binary. UNIT = r""" # listener NODE NAME PORT: unit NAME.service on NODE runs /usr/local/sbin/NAME, listening on PORT listener() { nx "$1" "install -m 755 \"\$(readlink -f \"\$(command -v nc)\")\" /usr/local/sbin/$2 && cat > /etc/systemd/system/$2.service </dev/null 2>&1" /dev/null 2>&1; } # same LIST-FILE EXPECTED-FILE: the candidate's list names exactly the expected items (order and blanks ignored) same() { [ -s "$1" ] && diff <(tr -d ' \r' < "$1" | grep . | sort -u) <(sort -u "$2") >/dev/null; } """ SECCOMP = r""" SD=/var/lib/kubelet/seccomp # sec NS POD [CONTAINER]: the seccomp mode of a container's first process (0 off, 2 filtering) sec() { $K -n "$1" exec "$2" ${3:+-c "$3"} -- grep '^Seccomp:' /proc/1/status 2>/dev/null | tr -dc '0-9'; } # inpod NS POD CONTAINER 'COMMAND': run a shell command in a container inpod() { $K -n "$1" exec "$2" ${3:+-c "$3"} -- sh -c "$4" >/dev/null 2>&1; } """ QUESTIONS = [ # ============================================================ 3.1 Host footprint ==== { "id": 40, "domain": "SYS", "title": "What listens on that port?", "params": lambda r: {"port": r.choice([9200, 7411, 8686, 6000]), "name": r.choice(["metrics-relay", "node-helper", "sys-cached", "kube-agentd"])}, "task": """ Something listens on TCP port **%{port}** on `{CP}`, and nobody knows what it is. 1. Find the process, the binary it runs and the systemd unit that starts it. Write the full path of the binary to `{WS}/q40/binary.txt` and the name of the unit to `{WS}/q40/unit.txt`. 2. Get rid of it for good: the unit stopped and disabled, its unit file and the binary deleted. Nothing may listen on port %{port} — now, or after a reboot. """, "setup": UNIT + r""" rm -rf "$WS/q40"; mkdir -p "$WS/q40" listener "$CP" %{name} %{port} """, "checks": [ C("binary.txt holds the path of the binary", 2, r"""eq "$(tr -d ' \n\r' < "$WS/q40/binary.txt" 2>/dev/null)" /usr/local/sbin/%{name}"""), C("unit.txt names the unit", 2, r"""u=$(tr -d ' \n\r' < "$WS/q40/unit.txt" 2>/dev/null); [ "${u%.service}" = %{name} ]"""), C("nothing listens on port %{port}, and the unit is neither running nor enabled", 3, UNIT + r""" ! listening "$CP" %{port} && ! nx "$CP" 'systemctl is-active --quiet %{name}' /dev/null' /usr/local/sbin/%{name} -> {WS}/q40/binary.txt systemctl status NNN | head -3 # the unit that owns the pid -> {WS}/q40/unit.txt systemctl disable --now %{name} rm /etc/systemd/system/%{name}.service /usr/local/sbin/%{name} systemctl daemon-reload ss -tlnp | grep %{port} # nothing # Killing the process is not enough: the unit has Restart=always and is enabled, so it is back # within a second and after every reboot. `disable --now` stops it and removes the boot link. # `dpkg -S /usr/local/sbin/%{name}` says "no path found": no package owns it, so nothing to purge. """, }, { "id": 41, "domain": "SYS", "title": "A service nobody needs", "task": """ A file-transfer service is running on `{CP}` and listening on TCP port **21**. It is not needed on a Kubernetes node. 1. Find the package the service belongs to and write its name to `{WS}/q41/package.txt`. 2. Stop the service and remove the package **together with its configuration files**. Port 21 must be closed afterwards. """, "setup": UNIT + r""" rm -rf "$WS/q41"; mkdir -p "$WS/q41" nx "$CP" 'dpkg -s vsftpd >/dev/null 2>&1 || exit 1 sed -i "s/^listen=NO/listen=YES/; s/^listen_ipv6=YES/listen_ipv6=NO/" /etc/vsftpd.conf systemctl enable --now vsftpd >/dev/null 2>&1' &2; exit 1; } for i in $(seq 1 15); do listening "$CP" 21 && exit 0; sleep 1; done echo "nothing listens on port 21" >&2; exit 1 """, "checks": [ C("package.txt names the package", 2, r"""eq "$(tr -d ' \n\r' < "$WS/q41/package.txt" 2>/dev/null)" vsftpd"""), C("port 21 is closed and the package is no longer installed", 4, UNIT + r""" ! listening "$CP" 21 && ! nx "$CP" "dpkg-query -W -f='\${Status}' vsftpd 2>/dev/null | grep -q 'ok installed'" /dev/null 2>&1' {WS}/q41/package.txt systemctl disable --now vsftpd apt-get purge -y vsftpd # purge = remove + configuration files dpkg -l vsftpd # "no packages found"; `rc` in the first column would mean config left behind ss -tlnp | grep ':21 ' # `apt-get remove` leaves /etc/vsftpd.conf and the package in state "rc" (removed, config # remains). `purge` - or `dpkg -P` afterwards - takes the configuration with it. """, }, { "id": 42, "domain": "SYS", "title": "setuid files and a left-over account", "params": lambda r: {"a": r.choice(["backup-shell", "sysdiag", "repair"]), "b": r.choice(["ffind", "netprobe", "quickfix"])}, "task": """ Housekeeping on `{CP}`: 1. Under `/usr/local` and `/opt` there are files with the **setuid** bit that belong to root — whoever runs them is root. Write their full paths to `{WS}/q42/setuid.txt`, one per line, and remove the setuid bit from each. Keep the files. 2. User `tmpadmin` is left over from a migration: delete the account together with its home directory. """, "setup": r""" rm -rf "$WS/q42"; mkdir -p "$WS/q42" nx "$CP" 'mkdir -p /opt/tools && install -m 4755 /bin/bash /usr/local/bin/%{a} && install -m 4755 /usr/bin/find /opt/tools/%{b} \ && install -m 755 /bin/ls /opt/tools/lister && install -m 755 /bin/cat /usr/local/bin/viewer id tmpadmin >/dev/null 2>&1 || useradd -m -s /bin/bash -G sudo tmpadmin' "$PRIV/q42.setuid" """, "checks": [ C("setuid.txt names the two setuid files", 3, r""" [ -s "$WS/q42/setuid.txt" ] && diff <(tr -d ' \r' < "$WS/q42/setuid.txt" | grep . | sort -u) <(sort "$PRIV/q42.setuid") >/dev/null"""), C("no file under /usr/local or /opt is setuid any more, and both files are still there", 4, r""" [ -z "$(nx "$CP" 'find /usr/local /opt -xdev -type f -perm -4000 2>/dev/null' /dev/null && ! test -e /home/tmpadmin && ! getent group sudo | grep -qw tmpadmin' {WS}/q42/setuid.txt chmod u-s $(cat {WS}/q42/setuid.txt) userdel -r tmpadmin # -r: home directory and mail spool too id tmpadmin; ls /home # A setuid-root copy of bash (`-p` keeps the privileges) or of find (`-exec`) is a root shell for # every local user - the usual way an intruder keeps access. `find / -xdev -perm -4000` over the # whole system lists the legitimate ones as well (su, sudo, passwd, mount, ...). """, }, { "id": 43, "domain": "SYS", "title": "Kernel modules that must not load", "task": """ Rarely used network protocols are a common source of kernel vulnerabilities. On `{CP}`, make sure the kernel modules **sctp** and **dccp** can never be loaded: 1. Create `/etc/modprobe.d/disable-protocols.conf` that blacklists both modules **and** makes an explicit `modprobe` of either one fail by running `/bin/false` instead of loading it. 2. Save the output of `modprobe -n -v sctp` (a dry run) after your change to `{WS}/q43/sctp.txt`. """, "setup": r""" rm -rf "$WS/q43"; mkdir -p "$WS/q43" nx "$CP" 'rm -f /etc/modprobe.d/disable-protocols.conf; command -v modprobe >/dev/null' &1" /dev/null"""), ], "solution": """ sudo -i cat > /etc/modprobe.d/disable-protocols.conf <> /etc/containerd/config.toml systemctl restart containerd' /dev/null); [ "${u%.service}" = containerd ]"""), C("port 1338 of {W1} no longer answers from another node, and containerd runs", 4, r""" nx "$W1" 'systemctl is-active --quiet containerd' /dev/null 2>&1 $K -n default run ksim-probe --image=busybox:1.36 --overrides='{"spec":{"nodeName":"'"$W1"'"}}' --command -- sleep 600 >/dev/null 2>&1 $K -n default wait --for=condition=Ready pod/ksim-probe --timeout=90s >/dev/null 2>&1; rc=$? $K -n default delete pod ksim-probe --wait=false >/dev/null 2>&1; exit $rc"""), ], "solution": """ {SHELL_W1} sudo -i ss -tlnp | grep 1338 # users:(("containerd",...)) systemctl status containerd | head -3 # the unit -> {WS}/q44/unit.txt grep -n -A2 '^\\[metrics\\]' /etc/containerd/config.toml vi /etc/containerd/config.toml # delete the [metrics] section (or: address = "127.0.0.1:1338") systemctl restart containerd && systemctl is-active containerd ss -tlnp | grep 1338 # nothing, or 127.0.0.1 only exit; exit k get nodes # The kubelet talks to containerd through /run/containerd/containerd.sock and needs no TCP port # at all. Metrics, debug and gRPC-over-TCP listeners are all optional - and all unauthenticated # unless TLS is configured for them. Same idea as dockerd with -H tcp://0.0.0.0:2375. """, }, # =========================================================== 3.2 Least privilege ==== { "id": 45, "domain": "SYS", "title": "sudo for two commands, not for everything", "task": """ On `{CP}`: 1. User `deploy` may run **anything** as root without a password (`/etc/sudoers.d/deploy`). Restrict the account to exactly these two commands, still without a password: /usr/bin/systemctl restart kubelet /usr/local/bin/crictl ps 2. User `intern` is a member of group `sudo`. Take the account out of that group; the account itself stays. The sudo configuration must remain valid (`visudo -c`). """, "setup": r""" nx "$CP" 'id deploy >/dev/null 2>&1 || useradd -m -s /bin/bash deploy; id intern >/dev/null 2>&1 || useradd -m -s /bin/bash -G sudo intern echo "deploy ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/deploy; chmod 440 /etc/sudoers.d/deploy; visudo -c >/dev/null' /dev/null 2>&1; } t /usr/bin/systemctl restart kubelet && t /usr/local/bin/crictl ps && ! t /usr/bin/id"""), C("and nothing else: no shell, no other systemctl or crictl command", 4, r""" t() { nx "$CP" "su -s /bin/sh deploy -c 'sudo -n -l $*'" /dev/null 2>&1; } t /usr/local/bin/crictl ps || exit 1 ! t /usr/bin/id && ! t /bin/bash && ! t /usr/bin/systemctl stop kubelet && ! t /usr/bin/systemctl restart containerd && ! t /usr/local/bin/crictl rm -a && ! t /usr/bin/cat /etc/shadow \ && nx "$CP" 'visudo -c' /dev/null 2>&1"""), C("intern still exists but is no longer in group sudo", 3, r""" nx "$CP" 'getent passwd intern >/dev/null && ! id -nG intern | tr " " "\n" | grep -qx sudo && ! getent group sudo | cut -d: -f4 | tr "," "\n" | grep -qx intern' /etc/sudoers.d/deploy visudo -c # parsed OK sudo -l -U deploy gpasswd -d intern sudo # or: deluser intern sudo id intern # A command in sudoers with its arguments allows exactly that invocation: `systemctl restart # kubelet` yes, `systemctl stop kubelet` no. Always full paths - and never an editor, a pager or # a shell, which are all ways to a root prompt. A syntax error in a sudoers file disables sudo for # everyone: check with `visudo -c` (or edit with `visudo -f`). """, }, { "id": 46, "domain": "SYS", "title": "sshd that lets root and passwords in", "workers": 1, "task": """ The ssh daemon on node `{W1}` accepts password logins and lets root log in. Harden it: - root may not log in over ssh at all; - password authentication is off — keys only; - only user `candidate` may log in. The running daemon must use the new settings, and `{SHELL_W1}` from `{CP}` must still work for you. sshd reads `/etc/ssh/sshd_config`, which includes `/etc/ssh/sshd_config.d/*.conf` at its top; for each option the **first** value it meets wins. """, "setup": r""" nx "$W1" 'printf "PermitRootLogin yes\nPasswordAuthentication yes\n" > /etc/ssh/sshd_config.d/00-legacy.conf && sshd -t && systemctl reload ssh' /dev/null | grep -qx 'passwordauthentication yes'" /dev/null' &1) echo "$out" | grep -q 'Permission denied (publickey)'"""), C("candidate still logs in with the key, root does not", 3, r""" nx "$CP" "su candidate -c 'ssh -o BatchMode=yes -o ConnectTimeout=5 $W1 true'" /dev/null 2>&1 \ && ! nx "$CP" "ssh -o BatchMode=yes -o ConnectTimeout=5 root@$W1 true" /dev/null 2>&1"""), ], "solution": """ {SHELL_W1} sudo -i sshd -T | grep -Ei 'permitrootlogin|passwordauthentication|allowusers' # what is in force grep -rn -Ei 'permitrootlogin|passwordauthentication|allowusers' /etc/ssh/sshd_config /etc/ssh/sshd_config.d/ # 00-legacy.conf says yes/yes and is read first: a later file cannot override it cat > /etc/ssh/sshd_config.d/00-legacy.conf </dev/null 2>&1 || useradd -m -s /bin/bash ops; id svc-backup >/dev/null 2>&1 || useradd -m -s /bin/bash svc-backup echo "svc-backup:backup123" | chpasswd install -m 644 /etc/kubernetes/admin.conf /home/ops/admin.conf mkdir -p /var/tmp/migration && install -m 644 /etc/kubernetes/admin.conf /var/tmp/migration/%{f} chmod 644 /etc/kubernetes/admin.conf /etc/kubernetes/pki/ca.key' "$PRIV/q47.copies" """, "checks": [ C("copies.txt names the two copies", 3, r""" [ -s "$WS/q47/copies.txt" ] && diff <(tr -d ' \r' < "$WS/q47/copies.txt" | grep . | sort -u) <(sort "$PRIV/q47.copies") >/dev/null"""), C("both are deleted; the original and your own kubeconfig are still there", 3, r""" nx "$CP" '! test -e /home/ops/admin.conf && ! test -e /var/tmp/migration/%{f} && test -s /etc/kubernetes/admin.conf && test -s /home/candidate/.kube/config && test -s /root/.kube/config' /dev/null # kubeconfigs with an embedded key # /home/ops/admin.conf, /var/tmp/migration/%{f} (and /home/candidate/.kube/config, which stays) # confirm they are copies of the admin one: grep -c kubernetes-admin FILE printf '/home/ops/admin.conf\\n/var/tmp/migration/%{f}\\n' > {WS}/q47/copies.txt rm /home/ops/admin.conf /var/tmp/migration/%{f} chmod 600 /etc/kubernetes/admin.conf /etc/kubernetes/pki/ca.key usermod -L -s /usr/sbin/nologin svc-backup; passwd -S svc-backup # L = locked # admin.conf is a client certificate in group system:masters (through kubeadm:cluster-admins): it # cannot be revoked short of rotating the CA. With ca.key anyone can mint such certificates. # Search by content, not by name - a copy is renamed more often than not. """, }, # ============================================================ 3.3 Network access ==== { "id": 48, "domain": "SYS", "title": "Services open on the nodes", "params": lambda r: {"ns": ns_pick(r, "portal")}, "task": """ Namespace `%{ns}` exposes more on the nodes' addresses than it should. Only Service `public-web` is meant to be reachable from outside the cluster. 1. Write the names of all **other** Services in `%{ns}` that are reachable through a node address — by a NodePort or by `externalIPs` — to `{WS}/q48/exposed.txt`, one per line. 2. Make those cluster-internal: type `ClusterIP`, no external addresses. Do not delete them; they must keep working inside the cluster. """, "setup": PODS + r""" mkns %{ns}; rm -rf "$WS/q48"; mkdir -p "$WS/q48" for s in public-web admin-ui metrics cache internal-api; do srv %{ns} $s; done cli %{ns} probe for s in public-web admin-ui metrics; do $K -n %{ns} patch svc $s -p '{"spec":{"type":"NodePort"}}' >/dev/null; done $K -n %{ns} patch svc cache -p '{"spec":{"externalIPs":["198.51.100.20"]}}' >/dev/null ready %{ns} || exit 1 for s in admin-ui metrics cache internal-api public-web; do echo "$s $(j -n %{ns} svc $s '{.metadata.uid}') $(j -n %{ns} svc $s '{.spec.ports[0].nodePort}')"; done > "$PRIV/q48" printf 'admin-ui\ncache\nmetrics\n' > "$PRIV/q48.exposed" """, "checks": [ C("exposed.txt names the three Services", 3, r""" [ -s "$WS/q48/exposed.txt" ] && diff <(tr -d ' \r' < "$WS/q48/exposed.txt" | sed 's#.*/##' | grep . | sort -u) "$PRIV/q48.exposed" >/dev/null"""), C("they are ClusterIP Services without external addresses - the same objects as before", 4, r""" while read -r s uid np; do eq "$(j -n %{ns} svc $s '{.metadata.uid}')" "$uid" || exit 1 [ "$s" = public-web ] && { eq "$(j -n %{ns} svc $s '{.spec.type}')" NodePort || exit 1; continue; } eq "$(j -n %{ns} svc $s '{.spec.type}')" ClusterIP && [ -z "$(j -n %{ns} svc $s '{.spec.externalIPs}')" ] && [ -z "$(j -n %{ns} svc $s '{.spec.ports[0].nodePort}')" ] || exit 1 done < "$PRIV/q48" """), C("their old node ports no longer answer; public-web's does, and all still work inside the cluster", 3, NET + r""" ip=$(j node "$CP" '{.status.addresses[?(@.type=="InternalIP")].address}') while read -r s uid np; do [ -n "$np" ] || continue if [ "$s" = public-web ]; then nx "$CP" "curl -s -m 4 -o /dev/null http://$ip:$np" {WS}/q48/exposed.txt k -n %{ns} patch svc admin-ui -p '{"spec":{"type":"ClusterIP"}}' k -n %{ns} patch svc metrics -p '{"spec":{"type":"ClusterIP"}}' k -n %{ns} patch svc cache --type=json -p '[{"op":"remove","path":"/spec/externalIPs"}]' k -n %{ns} get svc # `patch` to ClusterIP drops the allocated node port for you; with `kubectl edit` you have to # delete the nodePort lines yourself. externalIPs makes every node accept traffic for that address # on the Service's port - an exposure that the TYPE column does not show. """, }, { "id": 49, "domain": "SYS", "title": "A port for the control plane only", "workers": 1, "params": lambda r: {"ns": ns_pick(r, "apps")}, "task": """ Service `node-debug` on node `{W1}` listens on TCP port **9400** on every address, and it cannot be reconfigured. Today any Pod on that node can connect to it (Pod `probe` in Namespace `%{ns}`, for example: `nc -z -w 3 9400`). With `iptables` on `{W1}`, allow connections to TCP port 9400 only - from the node itself (the loopback interface), and - from the control-plane node's IP address; everything else to that port is dropped. Leave every other rule on the node as it is, and leave `node-debug` running. """, "setup": UNIT + PODS + r""" mkns %{ns} listener "$W1" node-debug 9400 || exit 1 cli %{ns} probe; ready %{ns} """, "checks": [ C("the INPUT chain of {W1} has rules for TCP port 9400", 2, r""" r=$(nx "$W1" 'iptables -S INPUT' /dev/null 2>&1 || exit 0; sleep 2; done; exit 1"""), C("the node itself still reaches it on the loopback address, and node-debug runs", 3, UNIT + r""" ip=$(j node "$W1" '{.status.addresses[?(@.type=="InternalIP")].address}') nx "$W1" 'iptables -S INPUT' 9400; echo $? # 1: dropped # Rules are read top to bottom and the first match decides, so the two ACCEPTs must come before # the DROP: -A appends, in this order. A Pod's packet to its own node's address arrives on the # node's INPUT chain like one from outside. To survive a reboot the rules would go into # /etc/iptables/rules.v4 (iptables-persistent) - not asked for here. """, }, { "id": 50, "domain": "SYS", "title": "Pods that may only talk to the cluster", "workers": 1, "params": lambda r: {"ns": ns_pick(r, "batch")}, "task": """ The Pods of Namespace `%{ns}` process sensitive data and must not be able to send it anywhere outside the cluster. Today Pod `worker` reaches `http://198.51.100.10/` (a host on the internet, as far as this cluster is concerned) and the nodes' own addresses. Create NetworkPolicy `cluster-only` in `%{ns}` that applies to **every** Pod of the Namespace and allows outgoing traffic to Pods of this cluster only — in any Namespace, which includes cluster DNS. No node address, nothing outside the cluster. Do not restrict incoming traffic. """, "setup": EXT + PODS + r""" mkns %{ns} extsvc internet-host 198.51.100.10 index.html 'updates-mirror' || exit 1 cli %{ns} worker; srv %{ns} api ready %{ns} || exit 1 for i in $(seq 1 20); do $K -n %{ns} exec worker -- wget -T 3 -qO- http://198.51.100.10/ 2>/dev/null | grep -q updates-mirror && exit 0; sleep 2; done echo "198.51.100.10 does not answer from a Pod" >&2; exit 1 """, "checks": [ C("cluster-only selects every Pod, for Egress only", 2, r""" eq "$(j -n %{ns} netpol cluster-only '{.spec.podSelector}')" '{}' && eq "$(j -n %{ns} netpol cluster-only '{.spec.policyTypes}')" '["Egress"]'"""), C("worker still reaches Service api by name, but not 198.51.100.10", 4, NET + r""" has -n %{ns} netpol cluster-only && okr %{ns} worker http://api.%{ns}.svc.cluster.local && blocked %{ns} worker http://198.51.100.10/"""), C("nor a node's address", 4, NET + r""" has -n %{ns} netpol cluster-only && okr %{ns} worker http://api || exit 1 ip=$(j node "$CP" '{.status.addresses[?(@.type=="InternalIP")].address}') for i in 1 2 3; do $K -n %{ns} exec worker -- nc -z -w 3 "$ip" 10250 >/dev/null 2>&1 || exit 0; sleep 2; done; exit 1"""), ], "solution": """ cat </dev/null < /www/index.html && exec httpd -f -p 80 -h /www"] ports: [{containerPort: 80, hostPort: 8080}] --- apiVersion: apps/v1 kind: Deployment metadata: {name: debug-agent, namespace: %{ns}} spec: replicas: 1 strategy: {type: Recreate} selector: {matchLabels: {app: debug-agent}} template: metadata: {labels: {app: debug-agent}} spec: hostNetwork: true containers: - name: agent image: busybox:1.36 command: ["sh", "-c", "mkdir -p /www && echo agent > /www/index.html && exec httpd -f -p 8686 -h /www"] --- apiVersion: apps/v1 kind: Deployment metadata: {name: catalog, namespace: %{ns}} spec: replicas: 1 selector: {matchLabels: {app: catalog}} template: metadata: {labels: {app: catalog}} spec: containers: - name: catalog image: busybox:1.36 command: ["sh", "-c", "mkdir -p /www && echo catalog > /www/index.html && exec httpd -f -p 80 -h /www"] ports: [{containerPort: 80}] EOF $K -n %{ns} expose deploy gateway --port=80 >/dev/null cli %{ns} probe for d in gateway debug-agent catalog; do $K -n %{ns} rollout status deploy $d --timeout=240s >/dev/null || exit 1; done ready %{ns} || exit 1 for i in $(seq 1 20); do nx "$CP" "curl -s -m 3 -o /dev/null http://$W1:8080 && curl -s -m 3 -o /dev/null http://$W1:8686" &2; exit 1 """, "checks": [ C("exposed.txt names the two Deployments", 3, r""" [ -s "$WS/q51/exposed.txt" ] && diff <(tr -d ' \r' < "$WS/q51/exposed.txt" | sed 's#.*/##' | grep . | sort -u) <(printf 'debug-agent\ngateway\n') >/dev/null"""), C("neither uses a hostPort or the host network any more, and both are available", 4, PY + r""" for d in gateway debug-agent; do py -n %{ns} deploy $d ' t = o["spec"]["template"]["spec"] not t.get("hostNetwork") and not any(p.get("hostPort") for c in t["containers"] for p in c.get("ports", [])) and o.get("status", {}).get("availableReplicas") == 1 and o["status"].get("updatedReplicas") == 1' || exit 1 done"""), C("ports 8080 and 8686 of {W1} no longer answer; gateway still does inside the cluster", 3, NET + r""" okr %{ns} probe http://gateway.%{ns} || exit 1 ! nx "$CP" "curl -s -m 3 -o /dev/null http://$W1:8080" {WS}/q51/exposed.txt k -n %{ns} patch deploy gateway --type=json -p '[{"op":"remove","path":"/spec/template/spec/containers/0/ports/0/hostPort"}]' k -n %{ns} patch deploy debug-agent --type=json -p '[{"op":"remove","path":"/spec/template/spec/hostNetwork"}]' k -n %{ns} rollout status deploy gateway; k -n %{ns} rollout status deploy debug-agent curl -s -m 3 http://{W1}:8080; curl -s -m 3 http://{W1}:8686 # no answer # A hostPort opens that port on the node for the Pod; hostNetwork puts the Pod into the node's # network namespace altogether - every port it opens is a port of the node, it sees the node's # traffic, and NetworkPolicy does not apply to it. Both are forbidden by the baseline Pod # Security Standard. """, }, # =========================================================== 3.4 Kernel hardening ==== { "id": 52, "domain": "SYS", "title": "A seccomp profile for a Deployment", "workers": 1, "params": lambda r: {"ns": ns_pick(r, "render"), "dep": name_pick(r, "thumbs")}, "task": """ Deployment `%{dep}` in Namespace `%{ns}` must run under the seccomp profile in `{WS}/q52/deny-chmod.json`, which makes every attempt to change file permissions fail. 1. Install the profile on node `{W1}` as `profiles/deny-chmod.json` in the kubelet's seccomp directory. 2. Make the Deployment's Pods use it (a `Localhost` profile). 3. Show that it is in force: in a Pod of the Deployment run `chmod 600 /etc/hostname` and save the error message to `{WS}/q52/chmod.txt`. """, "setup": NS + r""" mkns %{ns}; rm -rf "$WS/q52"; mkdir -p "$WS/q52" cat > "$WS/q52/deny-chmod.json" <<'EOF' { "defaultAction": "SCMP_ACT_ALLOW", "architectures": ["SCMP_ARCH_X86_64", "SCMP_ARCH_X86", "SCMP_ARCH_X32"], "syscalls": [ {"names": ["chmod", "fchmod", "fchmodat", "fchmodat2"], "action": "SCMP_ACT_ERRNO"} ] } EOF nx "$W1" 'rm -rf /var/lib/kubelet/seccomp/profiles' /dev/null $K -n %{ns} rollout status deploy %{dep} --timeout=180s >/dev/null """, "checks": [ C("the profile is on {W1} as profiles/deny-chmod.json, unchanged", 3, SECCOMP + r""" eq "$(nx "$W1" "sha256sum $SD/profiles/deny-chmod.json" /dev/null"""), ], "solution": """ scp {WS}/q52/deny-chmod.json {W1}:/tmp/ ssh {W1} 'sudo mkdir -p /var/lib/kubelet/seccomp/profiles && sudo cp /tmp/deny-chmod.json /var/lib/kubelet/seccomp/profiles/' k -n %{ns} patch deploy %{dep} -p '{"spec":{"template":{"spec":{"securityContext":{"seccompProfile":{"type":"Localhost","localhostProfile":"profiles/deny-chmod.json"}}}}}}' k -n %{ns} rollout status deploy %{dep} k -n %{ns} exec deploy/%{dep} -- chmod 600 /etc/hostname 2>&1 | tee {WS}/q52/chmod.txt # Operation not permitted k -n %{ns} exec deploy/%{dep} -- grep Seccomp /proc/1/status # 2 = filtering # localhostProfile is a path relative to the kubelet's seccomp directory (/var/lib/kubelet/seccomp) # on the node where the Pod runs - the file must be on every node the Pod can land on. # docs: Tutorials -> "Restrict a Container's Syscalls with seccomp". """, }, { "id": 53, "domain": "SYS", "title": "A profile the node does not have", "workers": 1, "params": lambda r: {"ns": ns_pick(r, "ingest"), "pod": name_pick(r, "parser")}, "task": """ Pod `%{pod}` in Namespace `%{ns}` does not start. It is meant to run under the seccomp profile `no-mkdir.json` that the security team wrote and installed — on `{CP}` only, as it turns out. Get the Pod running **with that profile in force**. Do not weaken it: same image, same command, still a `Localhost` profile, the profile's content unchanged. """, "setup": NS + r""" mkns %{ns} for n in "$CP"; do nx "$n" 'mkdir -p /var/lib/kubelet/seccomp/profiles && cat > /var/lib/kubelet/seccomp/profiles/no-mkdir.json' <<'EOF' { "defaultAction": "SCMP_ACT_ALLOW", "architectures": ["SCMP_ARCH_X86_64", "SCMP_ARCH_X86", "SCMP_ARCH_X32"], "syscalls": [ {"names": ["mkdir", "mkdirat"], "action": "SCMP_ACT_ERRNO"} ] } EOF done nx "$W1" 'rm -rf /var/lib/kubelet/seccomp/profiles /var/lib/kubelet/seccomp/no-mkdir.json' "$PRIV/q53.sha" $K apply -f - >/dev/null </dev/null' k -n %{ns} get pod %{pod} -o yaml > /tmp/pod.yaml vi /tmp/pod.yaml # localhostProfile: profiles/no-mkdir.json k replace --force -f /tmp/pod.yaml # the field is immutable: delete and create k -n %{ns} exec %{pod} -- mkdir /tmp/x # Operation not permitted # Two mistakes in one: the profile was only on the node the Pod never runs on, and the Pod named # it without the `profiles/` directory it lives in. The path is relative to # /var/lib/kubelet/seccomp on the Pod's own node. """, }, { "id": 54, "domain": "SYS", "title": "Write a profile, for one container", "workers": 1, "params": lambda r: {"ns": ns_pick(r, "media"), "pod": name_pick(r, "encoder")}, "task": """ Create Pod `%{pod}` in Namespace `%{ns}` with two containers, both `busybox:1.36` running `sleep 86400`: - container `app` runs under a seccomp profile of your own, `profiles/no-links.json`: every system call is allowed, except that `link`, `linkat`, `symlink` and `symlinkat` fail with an error (`SCMP_ACT_ERRNO`); - container `logger` runs under the container runtime's default profile. Write the profile and install it on `{W1}`, where the Pod will run. Set the profiles per container, not for the whole Pod. """, "setup": NS + r""" mkns %{ns} nx "$W1" 'rm -rf /var/lib/kubelet/seccomp/profiles' /dev/null | python3 -c ' import json, sys p = json.load(sys.stdin) names = {n for s in p.get("syscalls", []) if s.get("action") == "SCMP_ACT_ERRNO" for n in s.get("names", [])} sys.exit(0 if p.get("defaultAction") == "SCMP_ACT_ALLOW" and {"link", "linkat", "symlink", "symlinkat"} <= names else 1)'"""), C("app uses that Localhost profile and logger RuntimeDefault, each set on its container", 3, PY + r""" py -n %{ns} pod %{pod} ' c = {x["name"]: x.get("securityContext", {}).get("seccompProfile", {}) for x in o["spec"]["containers"]} c.get("app") == {"type": "Localhost", "localhostProfile": "profiles/no-links.json"} and c.get("logger") == {"type": "RuntimeDefault"}'"""), C("in app a symlink cannot be made, in logger it can; both are filtered", 4, SECCOMP + r""" eq "$(j -n %{ns} pod %{pod} '{.status.phase}')" Running && eq "$(sec %{ns} %{pod} app)" 2 && eq "$(sec %{ns} %{pod} logger)" 2 || exit 1 inpod %{ns} %{pod} app 'touch /tmp/f' && ! inpod %{ns} %{pod} app 'ln -s /tmp/f /tmp/l' && ! inpod %{ns} %{pod} app 'ln /tmp/f /tmp/h' \ && inpod %{ns} %{pod} logger 'touch /tmp/f && ln -s /tmp/f /tmp/l'"""), ], "solution": """ cat > /tmp/no-links.json </dev/null $K -n %{ns} wait --for=condition=Ready pod/before --timeout=180s >/dev/null """, "checks": [ C("the kubelet on {W1} has seccompDefault: true, runs, and the node is Ready", 3, API + r""" eq "$(kcfg "$W1" .seccompDefault)" true && nx "$W1" 'systemctl is-active --quiet kubelet' /dev/null 2>&1 $K -n %{ns} run ksim-probe --image=busybox:1.36 --command -- sleep 600 >/dev/null 2>&1 $K -n %{ns} wait --for=condition=Ready pod/ksim-probe --timeout=90s >/dev/null 2>&1 s=$(sec %{ns} ksim-probe); $K -n %{ns} delete pod ksim-probe --wait=false >/dev/null 2>&1 eq "$s" 2"""), C("Pod plain has no securityContext and runs filtered", 2, SECCOMP + PY + r""" py -n %{ns} pod plain 'not o["spec"].get("securityContext") and not any(c.get("securityContext") for c in o["spec"]["containers"])' && eq "$(sec %{ns} plain)" 2"""), C("seccomp.txt says 2", 1, r"""eq "$(tr -dc '0-9' < "$WS/q55/seccomp.txt" 2>/dev/null)" 2"""), ], "solution": """ k -n %{ns} exec before -- grep Seccomp: /proc/1/status # 0 {SHELL_W1} sudo -i vi /var/lib/kubelet/config.yaml # add at top level: seccompDefault: true systemctl restart kubelet && systemctl is-active kubelet exit; exit k -n %{ns} run plain --image=busybox:1.36 --command -- sleep 86400 k -n %{ns} exec plain -- grep Seccomp: /proc/1/status # 2 -> {WS}/q55/seccomp.txt # 0 = no filter, 2 = a filter is active. seccompDefault only changes what "no profile requested" # means, and only for containers created after the kubelet restart on that node: `before` keeps # running unconfined until it is re-created. kube-bench lists it as check 4.2.14. """, }, ]