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
888 lines
48 KiB
Python
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.
|
|
""",
|
|
},
|
|
]
|