kubestronaut-sim/cks/bank_sys.py
vumas3r f3d0e536b6 Exam simulator for CKA, CKAD and CKS with per-task hosts and a remote desktop
kubestronaut-sim runs each exam task on a host of its own - a kind (kubeadm)
cluster in rootless podman, or a QEMU/KVM Ubuntu VM for the CKS tasks that
need their own kernel (Falco, AppArmor) - and puts a browser desktop in front
of them, the way killer.sh and sailor.sh do.

- ksim, sim/: the engine (sessions, drills, study mode, grading, coverage,
  audit), host and VM lifecycle, the desktop
- images/: exam host images (common, CKS variant with its tools, VM guest)
- cka/ (131 tasks), ckad/ (105), cks/ (110): task banks, curricula with
  study notes, reference solutions and scripted solutions

Every domain of every track has at least 17 pool tasks. `./ksim audit` proves
a task three ways: it seeds cleanly on its own host, scores nothing before
anyone has worked on it, and reaches full marks when its scripted solution is
run on the host as `candidate`. All 346 tasks pass (CKA audited 2026-10-13,
CKAD and CKS 2026-10-12).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JZPd3GGWyxTgAkL6a4gAJz
2026-10-13 16:29:33 -06:00

888 lines
48 KiB
Python

"""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 <<EOF
[Unit]
Description=$2
[Service]
ExecStart=/usr/local/sbin/$2 -d -l -k $3
Restart=always
RestartSec=1
[Install]
WantedBy=multi-user.target
EOF
systemctl daemon-reload && systemctl enable --now $2 >/dev/null 2>&1" </dev/null || return 1
local i; for i in $(seq 1 15); do listening "$1" "$3" && return 0; sleep 1; done; return 1
}
# listening NODE PORT: something on NODE listens on TCP PORT
listening() { nx "$1" "ss -tlnH | grep -q ':$2 '" </dev/null; }
# reach FROM-NODE HOST PORT: a TCP connection from a node to HOST:PORT can be opened
reach() { nx "$1" "nc -z -w 3 $2 $3" </dev/null >/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 && ! nx "$CP" 'systemctl is-enabled --quiet %{name} 2>/dev/null' </dev/null \
&& sleep 3 && ! listening "$CP" %{port}"""),
C("the unit file and the binary are deleted", 3, r"""
nx "$CP" '! test -e /etc/systemd/system/%{name}.service && ! test -e /usr/local/sbin/%{name} && ! test -e /etc/systemd/system/multi-user.target.wants/%{name}.service' </dev/null"""),
],
"solution": """
sudo -i
ss -tlnp | grep %{port} # users:(("%{name}",pid=NNN,...))
ls -l /proc/NNN/exe # -> /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' </dev/null || { echo "vsftpd is not installed on the host" >&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 \
&& ! nx "$CP" 'test -x /usr/sbin/vsftpd' </dev/null"""),
C("its configuration is gone too (purged, not just removed)", 4, r"""
nx "$CP" '! test -e /etc/vsftpd.conf && ! dpkg-query -W vsftpd >/dev/null 2>&1' </dev/null"""),
],
"solution": """
sudo -i
ss -tlnp | grep ':21 ' # users:(("vsftpd",pid=NNN,...))
dpkg -S "$(readlink /proc/NNN/exe)" # vsftpd: /usr/sbin/vsftpd -> {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' </dev/null || exit 1
printf '/opt/tools/%{b}\n/usr/local/bin/%{a}\n' > "$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)" ] \
&& nx "$CP" 'test -f /usr/local/bin/%{a} && test -f /opt/tools/%{b}' </dev/null"""),
C("user tmpadmin and its home directory are gone", 3, r"""
nx "$CP" '! getent passwd tmpadmin >/dev/null && ! test -e /home/tmpadmin && ! getent group sudo | grep -qw tmpadmin' </dev/null"""),
],
"solution": """
sudo -i
find /usr/local /opt -xdev -type f -perm -4000 -ls # -perm -4000: the setuid bit is set
find /usr/local /opt -xdev -type f -perm -4000 > {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' </dev/null
""",
"checks": [
C("disable-protocols.conf blacklists sctp and dccp", 3, r"""
f=/etc/modprobe.d/disable-protocols.conf
nx "$CP" "grep -Eq '^[[:space:]]*blacklist[[:space:]]+sctp[[:space:]]*$' $f && grep -Eq '^[[:space:]]*blacklist[[:space:]]+dccp[[:space:]]*$' $f" </dev/null"""),
C("modprobe of either module would run /bin/false instead of loading it", 5, r"""
for m in sctp dccp; do nx "$CP" "modprobe -n -v $m 2>&1" </dev/null | grep -Eq '^install (/usr)?/bin/false' || exit 1; done"""),
C("sctp.txt shows it", 2, r"""grep -Eq 'install (/usr)?/bin/false' "$WS/q43/sctp.txt" 2>/dev/null"""),
],
"solution": """
sudo -i
cat > /etc/modprobe.d/disable-protocols.conf <<EOF
blacklist sctp
blacklist dccp
install sctp /bin/false
install dccp /bin/false
EOF
modprobe -n -v sctp | tee {WS}/q43/sctp.txt # install /bin/false
lsmod | grep -E 'sctp|dccp' # not loaded
# `blacklist` only stops automatic loading (by alias, when a program asks for the protocol). An
# explicit `modprobe sctp`, or another module that depends on it, still loads it - unless an
# `install` line replaces the loading with a command that fails.
""",
},
{
"id": 44, "domain": "SYS", "title": "A container runtime that tells everyone", "workers": 1,
"task": """
TCP port **1338** is open on node `{W1}`. It serves the internals of the node's container runtime —
the id of every container, its resource usage, the runtime's own state — to the whole network,
without authentication:
curl -s http://{W1}:1338/v1/metrics | head
1. Find out which service listens there and write the name of its systemd unit to
`{WS}/q44/unit.txt`.
2. Change that service's configuration so that the port is no longer reachable from other machines.
The service must keep working: the node stays `Ready` and Pods keep starting on it.
""",
"setup": API + r"""
rm -rf "$WS/q44"; mkdir -p "$WS/q44"
nx "$W1" 'grep -q "^\[metrics\]" /etc/containerd/config.toml || printf "\n[metrics]\n address = \"0.0.0.0:1338\"\n" >> /etc/containerd/config.toml
systemctl restart containerd' </dev/null || exit 1
for i in $(seq 1 30); do nx "$CP" "curl -s -m 3 -o /dev/null http://$W1:1338/v1/metrics" </dev/null && break; sleep 1; done
nx "$CP" "curl -s -m 3 -o /dev/null http://$W1:1338/v1/metrics" </dev/null && node_ready "$W1" 90
""",
"checks": [
C("unit.txt names the unit", 2, r"""u=$(tr -d ' \n\r' < "$WS/q44/unit.txt" 2>/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 && ! nx "$CP" "curl -s -m 3 -o /dev/null http://$W1:1338/v1/metrics" </dev/null \
&& ! nx "$W1" "ss -tlnH | grep ':1338 ' | grep -qv '127\.0\.0\.1:1338'" </dev/null"""),
C("the node is Ready and a new Pod starts on it", 4, API + r"""
! nx "$CP" "curl -s -m 3 -o /dev/null http://$W1:1338/v1/metrics" </dev/null && node_ready "$W1" 60 || exit 1
$K -n default delete pod ksim-probe --ignore-not-found --wait=true >/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
""",
"checks": [
C("deploy may run the two commands as root without a password", 3, r"""
t() { nx "$CP" "su -s /bin/sh deploy -c 'sudo -n -l $*'" </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 >/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 >/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' </dev/null"""),
],
"solution": """
sudo -i
cat /etc/sudoers.d/deploy # deploy ALL=(ALL) NOPASSWD:ALL
echo 'deploy ALL=(root) NOPASSWD: /usr/bin/systemctl restart kubelet, /usr/local/bin/crictl ps' > /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 || exit 1
sleep 1
nx "$W1" "sshd -T 2>/dev/null | grep -qx 'passwordauthentication yes'" </dev/null
""",
"checks": [
C("effective configuration: PermitRootLogin no, PasswordAuthentication no, AllowUsers candidate", 4, r"""
c=$(nx "$W1" 'sshd -T 2>/dev/null' </dev/null)
echo "$c" | grep -qx 'permitrootlogin no' && echo "$c" | grep -qx 'passwordauthentication no' && echo "$c" | grep -qx 'allowusers candidate'"""),
C("the running daemon offers key authentication only", 3, r"""
out=$(nx "$CP" "ssh -o BatchMode=yes -o PreferredAuthentications=none -o ConnectTimeout=5 nobody@$W1 true" </dev/null 2>&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 >/dev/null 2>&1 \
&& ! nx "$CP" "ssh -o BatchMode=yes -o ConnectTimeout=5 root@$W1 true" </dev/null >/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 <<EOF
PermitRootLogin no
PasswordAuthentication no
AllowUsers candidate
EOF
sshd -t && systemctl reload ssh # -t: refuse to reload a broken configuration
sshd -T | grep -Ei 'permitrootlogin|passwordauthentication|allowusers'
exit; exit
{SHELL_W1} true && echo still in
# Keep your session open and test from a second one before you leave: a typo in AllowUsers locks
# everybody out. `reload` re-reads the configuration without dropping connected sessions.
""",
},
{
"id": 47, "domain": "SYS", "title": "Admin kubeconfigs lying around",
"params": lambda r: {"f": r.choice(["cluster-access.yaml", "k8s-backup.cfg", "old-admin.txt"])},
"task": """
Least privilege on `{CP}`:
1. Copies of the cluster-admin kubeconfig (`/etc/kubernetes/admin.conf`) are lying around where
every local user can read them. Find every copy outside `/etc/kubernetes`, `/root` and
`/home/candidate`, write the paths to `{WS}/q47/copies.txt` (one per line) and delete the files.
They are not all called `admin.conf`.
2. `/etc/kubernetes/admin.conf` and the cluster CA's private key `/etc/kubernetes/pki/ca.key` must
be readable by root only (mode `600`).
3. Account `svc-backup` is used by a cron job and never logs in: lock its password and set its
login shell to `/usr/sbin/nologin`.
""",
"setup": r"""
rm -rf "$WS/q47"; mkdir -p "$WS/q47"
nx "$CP" 'id ops >/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' </dev/null || exit 1
printf '/home/ops/admin.conf\n/var/tmp/migration/%{f}\n' > "$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"""),
C("admin.conf and ca.key are mode 600, owned by root", 2, r"""
eq "$(nx "$CP" 'stat -c "%a %U" /etc/kubernetes/admin.conf /etc/kubernetes/pki/ca.key | sort -u' </dev/null)" "600 root" """),
C("svc-backup: password locked, shell /usr/sbin/nologin", 2, r"""
nx "$CP" 'getent passwd svc-backup | grep -q ":/usr/sbin/nologin$" && passwd -S svc-backup | grep -q "^svc-backup L"' </dev/null"""),
],
"solution": """
sudo -i
grep -rIl 'client-key-data' /home /tmp /var/tmp /opt /srv 2>/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" </dev/null || exit 1
else ! nx "$CP" "curl -s -m 3 -o /dev/null http://$ip:$np" </dev/null || exit 1; fi
done < "$PRIV/q48"
okr %{ns} probe http://admin-ui && okr %{ns} probe http://metrics && okr %{ns} probe http://cache"""),
],
"solution": """
k -n %{ns} get svc # TYPE NodePort, or something under EXTERNAL-IP
# admin-ui, metrics (NodePort), cache (externalIPs) -> {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 <node IP> 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); echo "$r" | grep -q -- '--dport 9400' && echo "$r" | grep -- '--dport 9400' | grep -q -- '-j \(DROP\|REJECT\)'"""),
C("a Pod on the node can no longer connect, the control-plane node still can", 5, UNIT + r"""
ip=$(j node "$W1" '{.status.addresses[?(@.type=="InternalIP")].address}')
listening "$W1" 9400 && reach "$CP" "$ip" 9400 || exit 1
for i in 1 2 3; do $K -n %{ns} exec probe -- nc -z -w 3 "$ip" 9400 >/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' </dev/null | grep -q -- '--dport 9400' || exit 1
nx "$W1" 'systemctl is-active --quiet node-debug' </dev/null && reach "$W1" 127.0.0.1 9400"""),
],
"solution": """
k get node {CP} -o wide # INTERNAL-IP of the control plane, say 10.89.0.5
{SHELL_W1}
sudo -i
iptables -A INPUT -i lo -p tcp --dport 9400 -j ACCEPT
iptables -A INPUT -p tcp --dport 9400 -s 10.89.0.5 -j ACCEPT
iptables -A INPUT -p tcp --dport 9400 -j DROP
iptables -S INPUT | grep 9400
exit; exit
k -n %{ns} exec probe -- nc -z -w 3 <IP of {W1}> 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 <<EOF | k apply -f -
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata: {name: cluster-only, namespace: %{ns}}
spec:
podSelector: {}
policyTypes: [Egress]
egress:
- to:
- namespaceSelector: {} # every Pod in every Namespace - and nothing that is not a Pod
EOF
k -n %{ns} exec worker -- wget -T 2 -qO- http://api # answers (DNS and the Service work)
k -n %{ns} exec worker -- wget -T 2 -qO- http://198.51.100.10/ # times out
# A namespaceSelector matches Pods. Nodes, the API server (it runs on the host network) and
# everything outside are not Pods, so they are not allowed. Traffic to a Service is judged by the
# Pod behind it. The same with addresses instead of selectors would be an ipBlock for the Pod
# network (k get nodes -o jsonpath='{.items[*].spec.podCIDR}').
""",
},
{
"id": 51, "domain": "SYS", "title": "Workloads on the node's own ports", "workers": 1,
"params": lambda r: {"ns": ns_pick(r, "edge")},
"task": """
Two Deployments in Namespace `%{ns}` are reachable on the address of the node they run on,
past every Service and NetworkPolicy: one publishes a port of the node, the other one runs in the
node's network namespace.
1. Write the names of the two Deployments to `{WS}/q51/exposed.txt`, one per line.
2. Change both so that their Pods no longer use the node's network or ports. Both must stay
available, and `http://gateway.%{ns}` (Service `gateway`, port 80) must keep answering inside
the cluster.
""",
"setup": PODS + r"""
mkns %{ns}; rm -rf "$WS/q51"; mkdir -p "$WS/q51"
$K apply -f - >/dev/null <<EOF
apiVersion: apps/v1
kind: Deployment
metadata: {name: gateway, namespace: %{ns}}
spec:
replicas: 1
strategy: {type: Recreate}
selector: {matchLabels: {app: gateway}}
template:
metadata: {labels: {app: gateway}}
spec:
containers:
- name: gateway
image: busybox:1.36
command: ["sh", "-c", "mkdir -p /www && echo gateway > /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" </dev/null && exit 0; sleep 2; done
echo "the node ports do not answer" >&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" </dev/null && ! nx "$CP" "curl -s -m 3 -o /dev/null http://$W1:8686" </dev/null"""),
],
"solution": """
k -n %{ns} get deploy -o yaml | grep -n -E 'hostNetwork|hostPort|^ name:'
# gateway has ports[].hostPort: 8080, debug-agent has hostNetwork: true -> {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} create deploy %{dep} --image=busybox:1.36 -- sleep 86400 >/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 | cut -d' ' -f1)" "$(sha256sum "$WS/q52/deny-chmod.json" | cut -d' ' -f1)" """),
C("the Deployment's Pod template asks for it", 3, PY + r"""
py -n %{ns} deploy %{dep} '
t = o["spec"]["template"]["spec"]
p = [t.get("securityContext", {}).get("seccompProfile")] + [c.get("securityContext", {}).get("seccompProfile") for c in t["containers"]]
any(x and x.get("type") == "Localhost" and x.get("localhostProfile") == "profiles/deny-chmod.json" for x in p)'"""),
C("in a running Pod chmod fails and everything else works", 3, SECCOMP + r"""
for i in $(seq 1 20); do [ "$(j -n %{ns} deploy %{dep} '{.status.updatedReplicas}')" = 1 ] && [ "$(j -n %{ns} deploy %{dep} '{.status.availableReplicas}')" = 1 ] && break; sleep 3; done
p=$($K -n %{ns} get pod -l app=%{dep} --field-selector status.phase=Running --sort-by=.metadata.creationTimestamp -o jsonpath='{.items[-1:].metadata.name}'); [ -n "$p" ] || exit 1
eq "$(sec %{ns} "$p")" 2 && inpod %{ns} "$p" "" 'touch /tmp/f && mkdir /tmp/d' && ! inpod %{ns} "$p" "" 'chmod 600 /tmp/f'"""),
C("chmod.txt holds the error", 1, r"""grep -qi 'not permitted' "$WS/q52/chmod.txt" 2>/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' </dev/null
nx "$CP" 'sha256sum /var/lib/kubelet/seccomp/profiles/no-mkdir.json' </dev/null | cut -d' ' -f1 > "$PRIV/q53.sha"
$K apply -f - >/dev/null <<EOF
apiVersion: v1
kind: Pod
metadata: {name: %{pod}, namespace: %{ns}}
spec:
securityContext:
seccompProfile: {type: Localhost, localhostProfile: no-mkdir.json}
containers:
- name: parser
image: busybox:1.36
command: [sleep, "86400"]
EOF
[ -s "$PRIV/q53.sha" ]
""",
"checks": [
C("the profile the Pod names exists on its node, with the security team's content", 3, r"""
n=$(j -n %{ns} pod %{pod} '{.spec.nodeName}'); lp=$(j -n %{ns} pod %{pod} '{.spec.securityContext.seccompProfile.localhostProfile}')
[ -n "$n" ] && [ -n "$lp" ] || exit 1
eq "$(nx "$n" "sha256sum /var/lib/kubelet/seccomp/$lp" </dev/null | cut -d' ' -f1)" "$(cat "$PRIV/q53.sha")" """),
C("the Pod is Running, same image and command, with a Localhost profile", 3, r"""
eq "$(j -n %{ns} pod %{pod} '{.status.phase}')" Running && eq "$(j -n %{ns} pod %{pod} '{.spec.containers[0].image}')" busybox:1.36 \
&& eq "$(j -n %{ns} pod %{pod} '{.spec.containers[0].command}')" '["sleep","86400"]' \
&& eq "$(j -n %{ns} pod %{pod} '{.spec.securityContext.seccompProfile.type}')" Localhost"""),
C("the profile is in force: mkdir fails in the container, other calls work", 4, SECCOMP + r"""
eq "$(sec %{ns} %{pod})" 2 && inpod %{ns} %{pod} "" 'touch /tmp/f' && ! inpod %{ns} %{pod} "" 'mkdir /tmp/d'"""),
],
"solution": """
k -n %{ns} describe pod %{pod} | tail -5 # ... cannot load seccomp profile ".../seccomp/no-mkdir.json": no such file
k -n %{ns} get pod %{pod} -o wide # it runs on {W1}
sudo ls -R /var/lib/kubelet/seccomp # on {CP}: profiles/no-mkdir.json
sudo cat /var/lib/kubelet/seccomp/profiles/no-mkdir.json | ssh {W1} 'sudo mkdir -p /var/lib/kubelet/seccomp/profiles && sudo tee /var/lib/kubelet/seccomp/profiles/no-mkdir.json >/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
""",
"checks": [
C("no-links.json on {W1}: allow by default, the four link calls return an error", 3, r"""
nx "$W1" 'cat /var/lib/kubelet/seccomp/profiles/no-links.json' </dev/null 2>/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 <<EOF
{
"defaultAction": "SCMP_ACT_ALLOW",
"syscalls": [
{"names": ["link", "linkat", "symlink", "symlinkat"], "action": "SCMP_ACT_ERRNO"}
]
}
EOF
scp /tmp/no-links.json {W1}:/tmp/
ssh {W1} 'sudo mkdir -p /var/lib/kubelet/seccomp/profiles && sudo cp /tmp/no-links.json /var/lib/kubelet/seccomp/profiles/'
cat <<EOF | k apply -f -
apiVersion: v1
kind: Pod
metadata: {name: %{pod}, namespace: %{ns}}
spec:
containers:
- name: app
image: busybox:1.36
command: [sleep, "86400"]
securityContext:
seccompProfile: {type: Localhost, localhostProfile: profiles/no-links.json}
- name: logger
image: busybox:1.36
command: [sleep, "86400"]
securityContext:
seccompProfile: {type: RuntimeDefault}
EOF
k -n %{ns} exec %{pod} -c app -- ln -s /etc/passwd /tmp/p # Operation not permitted
# A profile is a default action plus exceptions: SCMP_ACT_ALLOW with a deny list here; a strict
# profile is SCMP_ACT_ERRNO with an allow list. SCMP_ACT_LOG records calls instead (how you find
# out what a program needs). A container's seccompProfile overrides the Pod's.
""",
},
{
"id": 55, "domain": "SYS", "title": "seccomp by default", "workers": 1,
"params": lambda r: {"ns": ns_pick(r, "plain")},
"task": """
On this cluster a container that asks for no seccomp profile runs without one (`Unconfined`):
Pod `before` in Namespace `%{ns}` shows `Seccomp: 0` in `/proc/1/status`.
1. On node `{W1}`, make the kubelet apply the container runtime's default seccomp profile to every
container that does not ask for one. The kubelet must be running again and the node `Ready`.
2. Create Pod `plain` in `%{ns}` (image `busybox:1.36`, command `sleep 86400`) **without** any
`securityContext`, and write the number its `Seccomp:` line shows to `{WS}/q55/seccomp.txt`.
""",
"setup": NS + API + r"""
mkns %{ns}; rm -rf "$WS/q55"; mkdir -p "$WS/q55"
nx "$W1" 'yq -i "del(.seccompDefault)" /var/lib/kubelet/config.yaml && systemctl restart kubelet' </dev/null; node_ready "$W1" 60
$K -n %{ns} run before --image=busybox:1.36 --command -- sleep 86400 >/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 && node_ready "$W1" 60"""),
C("a new container that asks for nothing now gets the default profile", 4, SECCOMP + r"""
$K -n %{ns} delete pod ksim-probe --ignore-not-found --wait=true >/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.
""",
},
]