"""CKS tasks 25-39: Cluster Hardening - the pool tasks that take the domain from 2 to 17. 2.1 RBAC 25 one Secret instead of all of them 26 a group that is cluster-admin 27 bind, escalate, impersonate, exec 28 who can read this Secret? 2.2 ServiceAccounts 29 a token for something else than the API 30 tokens that never expire 31 a ServiceAccount of its own 2.3 API access 32 the API answers without credentials 33 anonymous for health checks only 34 AlwaysAllow and no NodeRestriction 35 the API on a NodePort 36 a client certificate for an auditor 2.4 Upgrade 37 a worker on a vulnerable kubelet 38 upgrade the control plane 39 prepare an upgrade Permissions are tested with `kubectl auth can-i` as the subject, tokens and certificates by calling the API with them, and the API server's settings by what an unauthenticated or a kubelet's request gets. 37 and 38 are real upgrades: CKS hosts carry Kubernetes as apt packages (images/node-cks), so a node can be held back on an older patch release and upgraded with apt and kubeadm. Every task has a scripted solution in solve.py. """ from cks.common import API, C, PY, NS, ns_pick, name_pick from sim.bank import CAN # noqa: F401 (bank_runtime imports it from here) # code URL-PATH [CURL-ARGS..]: the HTTP status the API server gives a request made on the control # plane node - without credentials unless the arguments supply some. CURL = r""" code() { local p="$1"; shift; nx "$CP" "curl -sk -m 8 -o /dev/null -w '%{http_code}' $* https://localhost:6443$p" /dev/null; } """ # The apt repository of Kubernetes only (the node image has no package lists), then a patch release # older than the node's for the three packages, held as kubeadm's documentation asks. APT = r""" K8S_LIST='-o Dir::Etc::sourcelist=/etc/apt/sources.list.d/kubernetes.list -o Dir::Etc::sourceparts=- -o APT::Get::List-Cleanup=0' # hold_back NODE VERSION: put NODE's kubelet, kubeadm and kubectl on VERSION and restart its kubelet hold_back() { nx "$1" "apt-get update -qq $K8S_LIST >/dev/null 2>&1 DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --allow-downgrades --allow-change-held-packages -o Dpkg::Options::=--force-confold kubelet=$2-1.1 kubeadm=$2-1.1 kubectl=$2-1.1 >/dev/null 2>&1 || exit 1 apt-mark hold kubelet kubeadm kubectl >/dev/null; systemctl daemon-reload; systemctl restart kubelet" " as dpkg knows them pkgs() { nx "$1" "dpkg-query -W -f='\${Version} ' kubelet kubeadm kubectl" /dev/null $K -n %{ns} create secret generic api-key --from-literal=key=k-%{sa} >/dev/null $K -n %{ns} create secret generic db-root --from-literal=password=root-%{sa} >/dev/null $K -n %{ns} create role app-reader --verb=get,list,watch --resource=secrets,configmaps >/dev/null $K -n %{ns} create rolebinding app-reader --role=app-reader --serviceaccount=%{ns}:%{sa} >/dev/null """, "checks": [ C("%{sa} can still get Secret api-key", 3, CAN + r""" S=system:serviceaccount:%{ns}:%{sa} can --as=$S -- get secret/api-key -n %{ns} && cannot --as=$S -- list secrets -n %{ns}"""), C("it cannot get another Secret, nor list or watch Secrets", 4, CAN + r""" S=system:serviceaccount:%{ns}:%{sa} can --as=$S -- get secret/api-key -n %{ns} && cannot --as=$S -- get secret/db-root -n %{ns} \ && cannot --as=$S -- list secrets -n %{ns} && cannot --as=$S -- watch secrets -n %{ns}"""), C("ConfigMaps are still readable, through Role and RoleBinding app-reader", 3, CAN + r""" S=system:serviceaccount:%{ns}:%{sa} cannot --as=$S -- list secrets -n %{ns} || exit 1 for v in get list watch; do can --as=$S -- $v configmaps -n %{ns} || exit 1; done eq "$(j -n %{ns} rolebinding app-reader '{.roleRef.name}')" app-reader && [ -n "$(j -n %{ns} role app-reader '{.metadata.name}')" ]"""), ], "solution": """ k -n %{ns} get role app-reader -o yaml cat </dev/null """, "checks": [ C("group dev is no longer cluster-admin", 3, CAN + r""" A="--as=%{user} --as-group=dev" cannot $A -- '*' '*' && cannot $A -- list nodes && cannot $A -- get secrets -n kube-system"""), C("in %{ns}-dev the group has edit, through RoleBinding dev-edit", 3, CAN + r""" A="--as=%{user} --as-group=dev" eq "$(j -n %{ns}-dev rolebinding dev-edit '{.roleRef.kind}/{.roleRef.name}')" ClusterRole/edit || exit 1 can $A -- create deployments -n %{ns}-dev && can $A -- delete pods -n %{ns}-dev && cannot $A -- create rolebindings -n %{ns}-dev"""), C("in %{ns}-prod the group has view, through RoleBinding dev-view", 3, CAN + r""" A="--as=%{user} --as-group=dev" eq "$(j -n %{ns}-prod rolebinding dev-view '{.roleRef.kind}/{.roleRef.name}')" ClusterRole/view || exit 1 can $A -- list pods -n %{ns}-prod && cannot $A -- delete pods -n %{ns}-prod && cannot $A -- get secrets -n %{ns}-prod"""), C("and nothing in any other Namespace", 1, CAN + r""" A="--as=%{user} --as-group=dev" can $A -- list pods -n %{ns}-prod && cannot $A -- list pods -n default && cannot $A -- list pods -n kube-system"""), ], "solution": """ k get clusterrolebinding dev-team -o yaml # subjects: Group dev -> ClusterRole cluster-admin k delete clusterrolebinding dev-team k -n %{ns}-dev create rolebinding dev-edit --clusterrole=edit --group=dev k -n %{ns}-prod create rolebinding dev-view --clusterrole=view --group=dev k auth can-i '*' '*' --as=%{user} --as-group=dev # no k -n %{ns}-dev auth can-i create deploy --as=%{user} --as-group=dev # yes k -n %{ns}-prod auth can-i delete pods --as=%{user} --as-group=dev # no # A RoleBinding may refer to a ClusterRole: the rules then hold in the RoleBinding's Namespace only. # That is how the built-in roles (view, edit, admin) are meant to be handed out. # `view` leaves out Secrets on purpose; `edit` leaves out Roles and RoleBindings. """, }, { "id": 27, "domain": "HARDEN", "title": "bind, escalate, impersonate, exec", "params": lambda r: {"ns": ns_pick(r, "release"), "user": r.choice(USERS)}, "task": """ Role `release-manager` in Namespace `%{ns}` is bound to user `%{user}`, who rolls out Deployments. The Role carries permissions with which its holder can become anything in the Namespace. Edit the Role in place (do not delete it, and leave the RoleBinding alone) and remove: - every permission on Roles and RoleBindings — with `bind` and `escalate`, a user can grant themselves whatever they like; - `impersonate`; - `pods/exec`; - every permission on Secrets. Keep: `get`, `list`, `watch`, `create`, `update`, `patch` on Deployments, and `get`, `list`, `watch` on Pods. """, "setup": NS + r""" mkns %{ns} $K apply -f - >/dev/null </dev/null j -n %{ns} role release-manager '{.metadata.uid}' > "$PRIV/q27.uid"; [ -s "$PRIV/q27.uid" ] """, "checks": [ C("no permission on Roles and RoleBindings: not bind, not escalate, not create", 3, CAN + r""" A="--as=%{user}" for v in get create update bind escalate; do cannot $A -- $v roles.rbac.authorization.k8s.io -n %{ns} || exit 1; done cannot $A -- create rolebindings.rbac.authorization.k8s.io -n %{ns}"""), C("no impersonate, no pods/exec", 2, CAN + r""" cannot --as=%{user} -- impersonate serviceaccounts -n %{ns} && cannot --as=%{user} -- create pods/exec -n %{ns}"""), C("no access to Secrets", 2, CAN + r"""cannot --as=%{user} -- get secrets -n %{ns} && cannot --as=%{user} -- list secrets -n %{ns}"""), C("the same Role still manages Deployments and reads Pods", 3, CAN + r""" A="--as=%{user}" eq "$(j -n %{ns} role release-manager '{.metadata.uid}')" "$(cat "$PRIV/q27.uid")" && cannot $A -- escalate roles.rbac.authorization.k8s.io -n %{ns} || exit 1 for v in get list watch create update patch; do can $A -- $v deployments.apps -n %{ns} || exit 1; done for v in get list watch; do can $A -- $v pods -n %{ns} || exit 1; done"""), ], "solution": """ k -n %{ns} edit role release-manager # delete the four rules; what stays: # rules: # - {apiGroups: [apps], resources: [deployments], verbs: [get, list, watch, create, update, patch]} # - {apiGroups: [""], resources: [pods], verbs: [get, list, watch]} k -n %{ns} auth can-i --list --as=%{user} # Why each one is dangerous: # bind create a RoleBinding to a role you do not hold yourself (to admin, say) # escalate write rules into a Role that you do not hold yourself # impersonate act as another user or ServiceAccount, with its permissions # pods/exec a shell in any Pod - and with it that Pod's ServiceAccount token and Secrets # Without bind and escalate the API server refuses to let anyone hand out more than they have. """, }, { "id": 28, "domain": "HARDEN", "title": "Who can read this Secret?", "params": lambda r: {"ns": ns_pick(r, "hr")}, "task": """ Secret `payroll` in Namespace `%{ns}` must be readable by as few identities as possible. The Namespace has six ServiceAccounts: `builder`, `deployer`, `viewer`, `auditor`, `ingest` and `reporter`. 1. Find out which of them can `get` Secret `payroll`, and write their names to `{WS}/q28/can-read.txt`, one per line. 2. `auditor` has left the project: remove what gives **that** ServiceAccount its access to Secrets — in every Namespace — without changing what the others can do. """, "setup": NS + r""" mkns %{ns}; rm -rf "$WS/q28"; mkdir -p "$WS/q28" $K -n %{ns} create secret generic payroll --from-literal=iban=XX00-%{ns} >/dev/null for s in builder deployer viewer auditor ingest reporter; do $K -n %{ns} create sa $s >/dev/null; done $K -n %{ns} create role cfg-reader --verb=get,list --resource=configmaps >/dev/null $K -n %{ns} create rolebinding builder --role=cfg-reader --serviceaccount=%{ns}:builder >/dev/null $K -n %{ns} create rolebinding deployer --clusterrole=edit --serviceaccount=%{ns}:deployer >/dev/null $K -n %{ns} create rolebinding viewer --clusterrole=view --serviceaccount=%{ns}:viewer >/dev/null $K create clusterrole %{ns}-secret-audit --verb=get,list --resource=secrets >/dev/null $K create clusterrolebinding %{ns}-secret-audit --clusterrole=%{ns}-secret-audit --serviceaccount=%{ns}:auditor >/dev/null $K -n %{ns} create role ingest --verb=get --resource=secrets --resource-name=ingest-key >/dev/null $K -n %{ns} create rolebinding ingest --role=ingest --serviceaccount=%{ns}:ingest >/dev/null $K -n %{ns} create role payroll-reader --verb=get --resource=secrets --resource-name=payroll >/dev/null $K -n %{ns} create rolebinding reporter --role=payroll-reader --serviceaccount=%{ns}:reporter >/dev/null printf 'auditor\ndeployer\nreporter\n' > "$PRIV/q28.can" """, "checks": [ C("can-read.txt names exactly the ServiceAccounts that could read payroll", 5, r""" [ -s "$WS/q28/can-read.txt" ] && diff <(tr -d ' \r' < "$WS/q28/can-read.txt" | sed 's#.*:##' | grep . | sort -u) "$PRIV/q28.can" >/dev/null"""), C("auditor can no longer read Secrets, here or in any other Namespace", 3, CAN + r""" S=system:serviceaccount:%{ns}:auditor cannot --as=$S -- get secret/payroll -n %{ns} && cannot --as=$S -- list secrets -n %{ns} && cannot --as=$S -- get secrets -n kube-system"""), C("the others are as they were: deployer and reporter can, the rest cannot", 2, CAN + r""" P=system:serviceaccount:%{ns} cannot --as=$P:auditor -- get secret/payroll -n %{ns} || exit 1 can --as=$P:deployer -- get secret/payroll -n %{ns} && can --as=$P:reporter -- get secret/payroll -n %{ns} \ && can --as=$P:deployer -- create deployments -n %{ns} && can --as=$P:viewer -- list pods -n %{ns} \ && cannot --as=$P:viewer -- get secret/payroll -n %{ns} && cannot --as=$P:ingest -- get secret/payroll -n %{ns} && cannot --as=$P:builder -- get secret/payroll -n %{ns}"""), ], "solution": """ for sa in builder deployer viewer auditor ingest reporter; do echo "$sa $(k -n %{ns} auth can-i get secret/payroll --as=system:serviceaccount:%{ns}:$sa)" done # yes: deployer, auditor, reporter -> {WS}/q28/can-read.txt k get rolebinding,clusterrolebinding -A -o wide | grep -w auditor # where its access comes from k delete clusterrolebinding %{ns}-secret-audit # Why each answer: deployer has `edit` (which includes Secrets), auditor a ClusterRoleBinding (all # Namespaces), reporter a Role naming payroll. viewer has `view`, which leaves Secrets out; ingest # may only get the Secret named ingest-key. # Bindings are found by subject, not by name - `-o wide` prints the subjects. """, }, # ========================================================== 2.2 ServiceAccounts ==== { "id": 29, "domain": "HARDEN", "title": "A token for something else than the API", "params": lambda r: {"ns": ns_pick(r, "pay"), "pod": name_pick(r, "signer")}, "task": """ Pod `%{pod}` in Namespace `%{ns}` authenticates to an external vault with a ServiceAccount token. That token must be short-lived and useless against the Kubernetes API. Create ServiceAccount `vault-client` and Pod `%{pod}` (image `busybox:1.36`, command `sleep 86400`) in `%{ns}`: - the Pod runs as `vault-client`; - the usual API token is **not** mounted; - a projected ServiceAccount token is mounted at `/var/run/secrets/vault/token`, with audience `vault` and a lifetime of 600 seconds. The Pod must be Running. """, "setup": NS + r"""mkns %{ns}""", "checks": [ C("the Pod runs as vault-client and mounts no API token", 3, r""" eq "$(j -n %{ns} pod %{pod} '{.spec.serviceAccountName}')" vault-client && eq "$(j -n %{ns} pod %{pod} '{.status.phase}')" Running || exit 1 ! $K -n %{ns} exec %{pod} -- test -e /var/run/secrets/kubernetes.io/serviceaccount/token 2>/dev/null"""), C("a projected token volume: audience vault, 600 seconds, path token", 3, PY + r""" py -n %{ns} pod %{pod} ' src = [s["serviceAccountToken"] for v in o["spec"]["volumes"] for s in v.get("projected", {}).get("sources", []) if "serviceAccountToken" in s] any(s.get("audience") == "vault" and s.get("expirationSeconds") == 600 and s.get("path") == "token" for s in src)'"""), C("the token in the Pod is for audience vault only and lives 10 minutes", 4, JWT + r""" t=$($K -n %{ns} exec %{pod} -- cat /var/run/secrets/vault/token 2>/dev/null); [ -n "$t" ] || exit 1 eq "$(echo "$t" | jwt aud)" '["vault"]' && eq "$(echo "$t" | jwt sub)" system:serviceaccount:%{ns}:vault-client || exit 1 [ "$(( $(echo "$t" | jwt exp) - $(echo "$t" | jwt iat) ))" -le 660 ]"""), ], "solution": """ k -n %{ns} create sa vault-client cat </dev/null; echo # The API server only accepts tokens whose audience is its own, so a token for "vault" cannot be # replayed against it. 600 seconds is the minimum; the kubelet replaces the file before it expires. # docs: Configure Service Accounts for Pods -> "ServiceAccount token volume projection". """, }, { "id": 30, "domain": "HARDEN", "title": "Tokens that never expire", "params": lambda r: {"ns": ns_pick(r, "build")}, "task": """ Secret-based ServiceAccount tokens never expire: whoever copies one keeps their access for as long as the Secret exists. Namespace `%{ns}` still has some. 1. Write the names of all Secrets of type `kubernetes.io/service-account-token` in `%{ns}` to `{WS}/q30/legacy.txt`, one per line. 2. Delete them — and only them. 3. ServiceAccount `ci` still needs a token for tonight's job: issue one that is valid for 30 minutes and save it to `{WS}/q30/ci.token`. """, "setup": NS + r""" mkns %{ns}; rm -rf "$WS/q30"; mkdir -p "$WS/q30" $K -n %{ns} create sa ci >/dev/null $K -n %{ns} create role pod-reader --verb=get,list --resource=pods >/dev/null $K -n %{ns} create rolebinding ci --role=pod-reader --serviceaccount=%{ns}:ci >/dev/null $K -n %{ns} create secret generic ci-settings --from-literal=registry=registry.internal >/dev/null $K apply -f - >/dev/null < "$PRIV/q30.old"; printf 'ci-token-2023\ndefault-token-legacy\n' > "$PRIV/q30.legacy" """, "checks": [ C("legacy.txt names the two token Secrets", 3, r""" [ -s "$WS/q30/legacy.txt" ] && diff <(tr -d ' \r' < "$WS/q30/legacy.txt" | sed 's#.*/##' | grep . | sort -u) "$PRIV/q30.legacy" >/dev/null"""), C("both are deleted, Secret ci-settings is not, and the old token is refused", 3, CURL + r""" [ -z "$(j -n %{ns} secret ci-token-2023 '{.metadata.name}')" ] && [ -z "$(j -n %{ns} secret default-token-legacy '{.metadata.name}')" ] \ && [ -n "$(j -n %{ns} secret ci-settings '{.metadata.name}')" ] || exit 1 eq "$(code /api/v1/namespaces/%{ns}/pods -H "'Authorization: Bearer $(cat "$PRIV/q30.old")'")" 401"""), C("ci.token is a token for ServiceAccount ci, valid for 30 minutes, that the API accepts", 4, JWT + CURL + r""" t=$(tr -d ' \n\r' < "$WS/q30/ci.token" 2>/dev/null); [ -n "$t" ] || exit 1 eq "$(echo "$t" | jwt sub)" system:serviceaccount:%{ns}:ci && eq "$(( $(echo "$t" | jwt exp) - $(echo "$t" | jwt iat) ))" 1800 || exit 1 eq "$(code /api/v1/namespaces/%{ns}/pods -H "'Authorization: Bearer $t'")" 200"""), ], "solution": """ k -n %{ns} get secret --field-selector type=kubernetes.io/service-account-token k -n %{ns} get secret --field-selector type=kubernetes.io/service-account-token -o name | cut -d/ -f2 > {WS}/q30/legacy.txt k -n %{ns} delete secret $(cat {WS}/q30/legacy.txt) k -n %{ns} create token ci --duration=30m > {WS}/q30/ci.token cut -d. -f2 {WS}/q30/ci.token | base64 -d 2>/dev/null; echo # exp - iat = 1800 # Deleting the Secret revokes the token at once: the API server checks that the Secret still exists. # `create token` asks the TokenRequest API for a signed, time-limited token that is stored nowhere. # Since v1.24 no token Secret is created for a ServiceAccount automatically - any you find was made # by hand or is left over. """, }, { "id": 31, "domain": "HARDEN", "title": "A ServiceAccount of its own", "params": lambda r: {"ns": ns_pick(r, "ops")}, "task": """ Deployment `reporter` in Namespace `%{ns}` runs as the Namespace's `default` ServiceAccount, which somebody bound to ClusterRole `edit` (RoleBinding `default-edit`). All `reporter` does with the API is read ConfigMaps and create Events. 1. Create ServiceAccount `reporter`, Role `reporter` and RoleBinding `reporter` in `%{ns}` with exactly that: `get`, `list`, `watch` on ConfigMaps and `create` on Events. 2. Make the Deployment run as `reporter`. 3. Take the `default` ServiceAccount's rights away, and stop it from mounting its token into Pods automatically. """, "setup": NS + r""" mkns %{ns} $K -n %{ns} create rolebinding default-edit --clusterrole=edit --serviceaccount=%{ns}:default >/dev/null $K -n %{ns} create deploy reporter --image=busybox:1.36 -- sleep 86400 >/dev/null $K -n %{ns} rollout status deploy reporter --timeout=180s >/dev/null """, "checks": [ C("the Deployment's Pods run as ServiceAccount reporter", 3, PY + r""" eq "$(j -n %{ns} deploy reporter '{.spec.template.spec.serviceAccountName}')" reporter || exit 1 for i in $(seq 1 20); do py -n %{ns} pod -l app=reporter ' p = [x for x in o["items"] if "deletionTimestamp" not in x["metadata"]] len(p) == 1 and p[0]["spec"]["serviceAccountName"] == "reporter" and p[0]["status"].get("phase") == "Running"' && exit 0 sleep 3 done; exit 1"""), C("reporter may read ConfigMaps and create Events, and nothing more", 3, CAN + r""" S=system:serviceaccount:%{ns}:reporter for v in get list watch; do can --as=$S -- $v configmaps -n %{ns} || exit 1; done can --as=$S -- create events -n %{ns} && cannot --as=$S -- get secrets -n %{ns} && cannot --as=$S -- delete configmaps -n %{ns} \ && cannot --as=$S -- create pods -n %{ns} && cannot --as=$S -- list configmaps -n default"""), C("the default ServiceAccount has no rights left", 2, CAN + r""" S=system:serviceaccount:%{ns}:default [ -n "$(j -n %{ns} sa reporter '{.metadata.name}')" ] && cannot --as=$S -- list configmaps -n %{ns} && cannot --as=$S -- get pods -n %{ns} && cannot --as=$S -- get secrets -n %{ns}"""), C("and no longer mounts its token automatically", 2, r"""eq "$(j -n %{ns} sa default '{.automountServiceAccountToken}')" false"""), ], "solution": """ k -n %{ns} create sa reporter k -n %{ns} create role reporter --verb=get,list,watch --resource=configmaps k -n %{ns} edit role reporter # add: - {apiGroups: [""], resources: [events], verbs: [create]} k -n %{ns} create rolebinding reporter --role=reporter --serviceaccount=%{ns}:reporter k -n %{ns} set serviceaccount deploy reporter reporter k -n %{ns} delete rolebinding default-edit k -n %{ns} patch sa default -p '{"automountServiceAccountToken": false}' k -n %{ns} auth can-i --list --as=system:serviceaccount:%{ns}:reporter # `create role` takes one set of verbs for all its resources, so two rules with different verbs # need an edit (or a YAML file). The default ServiceAccount is what every Pod gets when nobody # says otherwise: it should be able to do nothing, and need not hand out a token. """, }, # ======================================================= 2.3 Restrict API access ==== { "id": 32, "domain": "HARDEN", "title": "The API answers without credentials", "params": lambda r: {"ns": ns_pick(r, "payroll")}, "task": """ Anyone who can reach this cluster's API server can read it without logging in. On `{CP}`: curl -k https://localhost:6443/api/v1/namespaces/%{ns}/secrets 1. Save the response of that request to `{WS}/q32/before.json`. 2. Find every ClusterRoleBinding and RoleBinding that grants something to user `system:anonymous` or to group `system:unauthenticated`. Write the names of those that are **not** part of Kubernetes itself to `{WS}/q32/bindings.txt`, one per line. 3. Delete them. Kubernetes' own bindings (their names start with `system:`) stay: `/version` and the health endpoints must keep answering anonymous requests. """, "setup": NS + r""" mkns %{ns}; rm -rf "$WS/q32"; mkdir -p "$WS/q32" $K -n %{ns} create secret generic salaries --from-literal=total=%{ns}-4711 >/dev/null $K create clusterrole all-secrets-reader --verb=get,list --resource=secrets >/dev/null $K create clusterrolebinding monitoring-anon --clusterrole=all-secrets-reader --user=system:anonymous >/dev/null $K create clusterrolebinding legacy-discovery --clusterrole=view --group=system:unauthenticated >/dev/null $K -n %{ns} create rolebinding guest-access --clusterrole=edit --group=system:unauthenticated >/dev/null printf 'guest-access\nlegacy-discovery\nmonitoring-anon\n' > "$PRIV/q32.bindings" """, "checks": [ C("before.json shows the Secrets an anonymous request could list", 2, r"""grep -q '"salaries"' "$WS/q32/before.json" 2>/dev/null && grep -q SecretList "$WS/q32/before.json" """), C("bindings.txt names the three bindings", 3, r""" [ -s "$WS/q32/bindings.txt" ] && diff <(tr -d ' \r' < "$WS/q32/bindings.txt" | sed 's#.*/##' | grep . | sort -u) "$PRIV/q32.bindings" >/dev/null"""), C("anonymous requests for Secrets and Pods now get 403", 3, CURL + r""" eq "$(code /api/v1/namespaces/%{ns}/secrets)" 403 && eq "$(code /api/v1/namespaces/%{ns}/pods)" 403 && eq "$(code /api/v1/namespaces/default/configmaps)" 403"""), C("while /version and /readyz still answer them", 2, CURL + r""" eq "$(code /api/v1/namespaces/%{ns}/secrets)" 403 && eq "$(code /version)" 200 && eq "$(code /readyz)" 200"""), ], "solution": """ curl -sk https://localhost:6443/api/v1/namespaces/%{ns}/secrets > {WS}/q32/before.json k get clusterrolebinding,rolebinding -A -o wide | grep -E 'system:anonymous|system:unauthenticated' # monitoring-anon, legacy-discovery (cluster-wide), guest-access (in %{ns}) -> {WS}/q32/bindings.txt # system:public-info-viewer is Kubernetes' own: it lets anyone read /version, /healthz, /livez, /readyz k delete clusterrolebinding monitoring-anon legacy-discovery k -n %{ns} delete rolebinding guest-access curl -sk https://localhost:6443/api/v1/namespaces/%{ns}/secrets | grep -E 'code|message' # 403 Forbidden curl -sk https://localhost:6443/version | head -3 # still answers # A request without credentials is not rejected: it becomes user system:anonymous in group # system:unauthenticated, and RBAC decides. 403 means "I know who you are (nobody), and you may # not"; 401 would mean anonymous requests are switched off altogether. """, }, { "id": 33, "domain": "HARDEN", "title": "Anonymous access for health checks only", "difficulty": "hard", "task": """ Anonymous requests to this cluster's API server are to be accepted for the health endpoints only. Switching them off altogether (`--anonymous-auth=false`) is not an option: the kubelet probes the API server's static Pod at `/livez` and `/readyz` without credentials. On `{CP}`: 1. Write an `AuthenticationConfiguration` (API group `apiserver.config.k8s.io`) to `/etc/kubernetes/auth/authn.yaml` that enables anonymous requests for the paths `/livez`, `/readyz` and `/healthz`, and for nothing else. 2. Make the API server use it (`--authentication-config`); the file has to be visible inside the static Pod. 3. When the API server is back, save the HTTP status code an anonymous request to `https://localhost:6443/version` gets to `{WS}/q33/version.txt`. """, "setup": r""" rm -rf "$WS/q33"; mkdir -p "$WS/q33" nx "$CP" 'rm -rf /etc/kubernetes/auth' /dev/null)" 401"""), ], "solution": """ sudo -i mkdir -p /etc/kubernetes/auth cat > /etc/kubernetes/auth/authn.yaml < "Anonymous Authenticator Configuration". The file and --anonymous-auth # exclude each other: with both, the API server refuses to start. If it does not come back, the # reason is in `crictl logs $(crictl ps -a --name kube-apiserver -q | head -1)`. """, }, { "id": 34, "domain": "HARDEN", "title": "AlwaysAllow, and kubelets that may change anything", "workers": 1, "difficulty": "hard", "task": """ The API server of this cluster authorises every request (`--authorization-mode=AlwaysAllow`), and nothing restricts what a kubelet may change. 1. Set the authorisation mode back to `Node,RBAC`. 2. Enable the `NodeRestriction` admission plugin. 3. When the API server is back, prove it: with the kubelet's own credentials on `{W1}` (`/etc/kubernetes/kubelet.conf` there), try to label the **control-plane** node: kubectl --kubeconfig /etc/kubernetes/kubelet.conf label node {CP} audit=failed Save the error message to `{WS}/q34/denied.txt`. """, "setup": API + r""" rm -rf "$WS/q34"; mkdir -p "$WS/q34" medit kube-apiserver.yaml '(.spec.containers[0].command[] | select(. == "--authorization-mode=Node,RBAC")) = "--authorization-mode=AlwaysAllow" | .spec.containers[0].command |= map(select(test("^--enable-admission-plugins=") | not))' || exit 1 wait_proc kube-apiserver '--authorization-mode=AlwaysAllow' && wait_api """, "checks": [ C("the API server runs with --authorization-mode=Node,RBAC", 3, API + r""" eq "$(flag kube-apiserver authorization-mode)" Node,RBAC && runs kube-apiserver '--authorization-mode=Node,RBAC' && wait_api 90"""), C("and with the NodeRestriction admission plugin", 3, API + r""" flag kube-apiserver enable-admission-plugins | tr ',' '\n' | grep -qx NodeRestriction \ && runs kube-apiserver '--enable-admission-plugins=[^ ]*NodeRestriction' && wait_api 90"""), C("the kubelet of {W1} can update its own Node but not label another one", 3, API + r""" runs kube-apiserver '--authorization-mode=Node,RBAC' && wait_api 60 || exit 1 KC='kubectl --kubeconfig /etc/kubernetes/kubelet.conf' nx "$W1" "$KC get node $W1 -o name" /dev/null 2>&1 || exit 1 # its credentials work at all out=$(nx "$W1" "$KC label node $CP ksim-probe=1 --overwrite" &1); rc=$? $K label node "$CP" ksim-probe- >/dev/null 2>&1 [ $rc -ne 0 ] && echo "$out" | grep -qi forbidden"""), C("denied.txt holds the refusal", 1, r"""grep -qi 'forbidden' "$WS/q34/denied.txt" 2>/dev/null"""), ], "solution": """ sudo cp /etc/kubernetes/manifests/kube-apiserver.yaml /root/ sudo vi /etc/kubernetes/manifests/kube-apiserver.yaml # - --authorization-mode=Node,RBAC # - --enable-admission-plugins=NodeRestriction watch sudo crictl ps # until kube-apiserver is back ssh {W1} sudo kubectl --kubeconfig /etc/kubernetes/kubelet.conf label node {CP} audit=failed 2>&1 | tee {WS}/q34/denied.txt # Node authorisation lets a kubelet read what the Pods on its node need; NodeRestriction (an # admission plugin - it goes in --enable-admission-plugins, not in the authorisation modes) lets # it modify only its own Node object and the Pods bound to it. Without it, a compromised node can # relabel other nodes and attract their workloads. Order matters in Node,RBAC only for speed. """, }, { "id": 35, "domain": "HARDEN", "title": "The API server on a NodePort", "task": """ The API server of this cluster is published on port **31000** of every node: Service `kubernetes` in Namespace `default` is of type `NodePort`. 1. Find what makes it so and remove it from the API server's configuration. 2. Service `kubernetes` must be of type `ClusterIP` again. (The API server maintains that Service itself — if you delete it, it is created anew.) """, "setup": API + r""" medit kube-apiserver.yaml '.spec.containers[0].command += ["--kubernetes-service-node-port=31000"]' || exit 1 wait_proc kube-apiserver '--kubernetes-service-node-port=31000' && wait_api || exit 1 # the flag only takes effect when the API server creates its Service: let it create it again $K -n default delete svc kubernetes >/dev/null 2>&1 for i in $(seq 1 30); do [ "$(j -n default svc kubernetes '{.spec.type}')" = NodePort ] && exit 0; sleep 2; done echo "Service kubernetes did not become a NodePort" >&2; exit 1 """, "checks": [ C("the API server no longer runs with --kubernetes-service-node-port, and is healthy", 4, API + r""" [ -z "$(flag kube-apiserver kubernetes-service-node-port)" ] && ! runs kube-apiserver '--kubernetes-service-node-port' && wait_api 90 \ && pod_ready kube-system "kube-apiserver-$CP" 90"""), C("Service kubernetes is of type ClusterIP, without a node port", 4, API + r""" ! runs kube-apiserver '--kubernetes-service-node-port' || exit 1 for i in $(seq 1 10); do [ "$(j -n default svc kubernetes '{.spec.type}')" = ClusterIP ] && break; sleep 3; done eq "$(j -n default svc kubernetes '{.spec.type}')" ClusterIP && [ -z "$(j -n default svc kubernetes '{.spec.ports[0].nodePort}')" ]"""), C("port 31000 of the node no longer answers, port 6443 does", 2, r""" eq "$(j -n default svc kubernetes '{.spec.type}')" ClusterIP && eq "$(nx "$CP" "curl -sk -m 5 -o /dev/null -w '%{http_code}' https://$CP:6443/version" /dev/null $K delete csr %{user} --ignore-not-found >/dev/null 2>&1 """, "checks": [ C("CertificateSigningRequest %{user}: client signer, client auth, 86400 seconds, approved", 2, PY + r""" py csr %{user} ' s = o["spec"] s["signerName"] == "kubernetes.io/kube-apiserver-client" and "client auth" in s.get("usages", []) and s.get("expirationSeconds") == 86400 and any(c["type"] == "Approved" for c in o.get("status", {}).get("conditions", []))'"""), C("%{user}.crt: CN %{user}, O auditors, signed by the cluster's CA, for one day, matching the key", 3, r""" d=/opt/course/q36 nx "$CP" "openssl verify -CAfile /etc/kubernetes/pki/ca.crt $d/%{user}.crt" /dev/null 2>&1 || exit 1 s=$(nx "$CP" "openssl x509 -in $d/%{user}.crt -noout -subject -nameopt RFC2253" %{user}.crt k -n %{ns} create rolebinding auditors-view --clusterrole=view --group=auditors openssl x509 -in %{user}.crt -noout -subject -dates # CN becomes the user name, O the group. The request field is the base64 of the whole PEM file on # one line (-w0). There is no revoking a client certificate: its short life and the RoleBinding # of the group are what you control. docs: "Certificates and Certificate Signing Requests". """, }, # ================================================================= 2.4 Upgrade ==== { "id": 37, "domain": "HARDEN", "title": "A worker on a vulnerable kubelet", "workers": 1, "difficulty": "hard", "params": lambda r: {"ns": ns_pick(r, "web"), "old": r.choice(["1.35.4", "1.35.5", "1.35.6"])}, "task": """ Node `{W1}` still runs Kubernetes **v%{old}**, a release with known vulnerabilities. The control plane is newer (`k get nodes`). Upgrade `{W1}` to the control plane's version, the documented way: 1. Drain `{W1}`. 2. On `{W1}`: upgrade the `kubeadm` package, run `kubeadm upgrade node`, upgrade `kubelet` and `kubectl`, and restart the kubelet. 3. Make the node schedulable again. The three packages are held (`apt-mark`) and must be held again when you are done. Deployment `web` in `%{ns}` must be available afterwards. """, "setup": NS + API + APT + r""" mkns %{ns} hold_back "$W1" %{old} || { echo "could not put $W1 on v%{old}" >&2; exit 1; } $K -n %{ns} create deploy web --image=nginx:1.27 --replicas=2 >/dev/null $K -n %{ns} rollout status deploy web --timeout=240s >/dev/null && node_ready "$W1" 60 """, "checks": [ C("{W1} runs the control plane's Kubernetes version and is Ready", 4, API + r""" v=$(j node "$CP" '{.status.nodeInfo.kubeletVersion}'); [ -n "$v" ] || exit 1 for i in $(seq 1 10); do [ "$(j node "$W1" '{.status.nodeInfo.kubeletVersion}')" = "$v" ] && break; sleep 3; done eq "$(j node "$W1" '{.status.nodeInfo.kubeletVersion}')" "$v" && node_ready "$W1" 30"""), C("kubelet, kubeadm and kubectl packages on {W1} are at that version", 2, APT + r""" v=$(j node "$CP" '{.status.nodeInfo.kubeletVersion}'); v=${v#v}; [ -n "$v" ] || exit 1 eq "$(pkgs "$W1")" "$v-1.1 $v-1.1 $v-1.1 " """), C("and are held again", 2, APT + r""" v=$(j node "$CP" '{.status.nodeInfo.kubeletVersion}'); eq "$(pkgs "$W1" | cut -d' ' -f1)" "${v#v}-1.1" && held "$W1" """), C("{W1} is schedulable and Deployment web is available", 2, r""" eq "$(j node "$W1" '{.status.nodeInfo.kubeletVersion}')" "$(j node "$CP" '{.status.nodeInfo.kubeletVersion}')" || exit 1 [ "$(j node "$W1" '{.spec.unschedulable}')" != true ] || exit 1 for i in $(seq 1 20); do [ "$(j -n %{ns} deploy web '{.status.availableReplicas}')" = 2 ] && exit 0; sleep 3; done; exit 1"""), ], "solution": """ k get nodes # {W1}: v%{old}; the control plane's version is the target, say 1.35.X k drain {W1} --ignore-daemonsets --delete-emptydir-data {SHELL_W1} sudo -i apt-get update && apt-cache madison kubeadm | head -5 # the package versions on offer apt-mark unhold kubeadm && apt-get install -y kubeadm=1.35.X-1.1 && apt-mark hold kubeadm kubeadm upgrade node # a worker: `node`, not `apply` apt-mark unhold kubelet kubectl && apt-get install -y kubelet=1.35.X-1.1 kubectl=1.35.X-1.1 && apt-mark hold kubelet kubectl systemctl daemon-reload && systemctl restart kubelet exit; exit k uncordon {W1} k get nodes # both on the same version, Ready # docs: "Upgrading kubeadm clusters" -> "Upgrade worker nodes". kubeadm first, because it rewrites # the kubelet's configuration for the new version; the kubelet package last, then the restart - # `k get nodes` shows the version of the running kubelet, not of the package. """, }, { "id": 38, "domain": "HARDEN", "title": "Upgrade the control plane", "workers": 0, "difficulty": "hard", "task": """ A vulnerability in the API server was fixed in a newer patch release of Kubernetes v1.35. Upgrade this cluster — a single control-plane node, `{CP}` — to the **newest v1.35 patch release** that the node's package repository offers: 1. Upgrade the `kubeadm` package, look at `kubeadm upgrade plan`, and apply the upgrade. 2. Drain the node, upgrade `kubelet` and `kubectl`, restart the kubelet, make the node schedulable again. The three packages are held (`apt-mark`) and must be held again when you are done. """, "setup": API + APT + r""" nx "$CP" "apt-get update -qq $K8S_LIST >/dev/null 2>&1; apt-cache madison kubeadm | head -1" "$PRIV/q38.target" t=$(cat "$PRIV/q38.target"); cur=$(pkgs "$CP" | cut -d' ' -f1) [ -n "$t" ] && [ -n "$cur" ] || { echo "no package versions on $CP" >&2; exit 1; } [ "$t" != "$cur" ] || { echo "the repository has nothing newer than $cur: there is no upgrade to do" >&2; exit 1; } """, "checks": [ C("the API server runs the newest v1.35 patch release", 4, API + r""" t=$(cat "$PRIV/q38.target"); wait_api 60 || exit 1 eq "$($K version -o json 2>/dev/null | python3 -c 'import json,sys; print(json.load(sys.stdin)["serverVersion"]["gitVersion"])')" "v${t%%-*}" """), C("the node's kubelet does too, and the node is Ready and schedulable", 3, API + r""" t=$(cat "$PRIV/q38.target") for i in $(seq 1 10); do [ "$(j node "$CP" '{.status.nodeInfo.kubeletVersion}')" = "v${t%%-*}" ] && break; sleep 3; done eq "$(j node "$CP" '{.status.nodeInfo.kubeletVersion}')" "v${t%%-*}" && node_ready "$CP" 30 && [ "$(j node "$CP" '{.spec.unschedulable}')" != true ]"""), C("kubelet, kubeadm and kubectl packages are at that version and held", 2, APT + r""" t=$(cat "$PRIV/q38.target"); eq "$(pkgs "$CP")" "$t $t $t " && held "$CP" """), C("controller manager and scheduler run the new images", 1, r""" t=$(cat "$PRIV/q38.target"); v="v${t%%-*}" j -n kube-system pod "kube-controller-manager-$CP" '{.spec.containers[0].image}' | grep -q ":$v$" \ && j -n kube-system pod "kube-scheduler-$CP" '{.spec.containers[0].image}' | grep -q ":$v$" """), ], "solution": """ sudo -i apt-get update && apt-cache madison kubeadm | head -3 # newest first, say 1.35.X-1.1 apt-mark unhold kubeadm && apt-get install -y kubeadm=1.35.X-1.1 && apt-mark hold kubeadm kubeadm version -o short kubeadm upgrade plan # what would change, and to which version kubeadm upgrade apply v1.35.X # a few minutes; answers y kubectl drain {CP} --ignore-daemonsets --delete-emptydir-data apt-mark unhold kubelet kubectl && apt-get install -y kubelet=1.35.X-1.1 kubectl=1.35.X-1.1 && apt-mark hold kubelet kubectl systemctl daemon-reload && systemctl restart kubelet kubectl uncordon {CP} kubectl get nodes; kubectl version # docs: "Upgrading kubeadm clusters". `upgrade apply` replaces the static Pod manifests of the # API server, controller manager and scheduler, and upgrades CoreDNS and kube-proxy; the kubelet # is a package and stays old until you install and restart it. On a cluster with more nodes: the # other control planes with `kubeadm upgrade node`, then the workers one at a time. """, }, { "id": 39, "domain": "HARDEN", "title": "Prepare an upgrade", "workers": 2, "params": lambda r: {"old": r.choice(["1.35.4", "1.35.5", "1.35.6"]), "n": r.choice([1, 2])}, "task": """ A CVE affects every kubelet older than the version this cluster's control plane runs. Prepare the upgrade — do **not** upgrade anything yet. 1. Write the names of the nodes whose kubelet is older than the control plane's to `{WS}/q39/outdated.txt`, one per line. 2. Cordon those nodes, so that no new Pod lands on a vulnerable kubelet. Do not drain them, and leave the other nodes schedulable. 3. Write the newest v1.35 package version of `kubeadm` that the repository on `{CP}` offers (as apt prints it, for example `1.35.2-1.1`) to `{WS}/q39/target.txt`. 4. Save the output of `kubeadm upgrade plan` on `{CP}` to `{WS}/q39/plan.txt`. """, "setup": API + APT + r""" rm -rf "$WS/q39"; mkdir -p "$WS/q39" old=$([ %{n} = 1 ] && echo "$W1" || echo "$W2") hold_back "$old" %{old} || { echo "could not put $old on v%{old}" >&2; exit 1; } echo "$old" > "$PRIV/q39.outdated" nx "$CP" "apt-get update -qq $K8S_LIST >/dev/null 2>&1; apt-cache madison kubeadm | head -1" "$PRIV/q39.target" [ -s "$PRIV/q39.target" ] && node_ready "$old" 60 """, "checks": [ C("outdated.txt names the node with the old kubelet", 3, r""" [ -s "$WS/q39/outdated.txt" ] && diff <(tr -d ' \r' < "$WS/q39/outdated.txt" | sed 's#.*/##' | grep . | sort -u) "$PRIV/q39.outdated" >/dev/null"""), C("that node is cordoned and not drained; the others are schedulable", 3, r""" old=$(cat "$PRIV/q39.outdated") eq "$(j node "$old" '{.spec.unschedulable}')" true || exit 1 for n in "$CP" "$W1" "$W2"; do [ "$n" = "$old" ] || [ "$(j node "$n" '{.spec.unschedulable}')" != true ] || exit 1; done eq "$(j node "$old" '{.status.nodeInfo.kubeletVersion}')" v%{old}"""), C("target.txt is the newest kubeadm package version on offer", 2, r"""eq "$(tr -d ' \n\r' < "$WS/q39/target.txt" 2>/dev/null)" "$(cat "$PRIV/q39.target")" """), C("plan.txt is the output of kubeadm upgrade plan", 2, r""" grep -q 'kube-apiserver' "$WS/q39/plan.txt" 2>/dev/null && grep -qi 'upgrade' "$WS/q39/plan.txt" && grep -q 'kubelet' "$WS/q39/plan.txt" """), ], "solution": """ k get nodes # the VERSION column is each node's kubelet k get nodes -o custom-columns=NAME:.metadata.name,KUBELET:.status.nodeInfo.kubeletVersion # -> the node on v%{old} goes into {WS}/q39/outdated.txt k cordon sudo apt-get update && apt-cache madison kubeadm | head -1 # third column -> {WS}/q39/target.txt sudo kubeadm upgrade plan | tee {WS}/q39/plan.txt # cordon only marks the node unschedulable: running Pods stay. drain = cordon + evict. # `upgrade plan` reads the cluster and changes nothing: it lists the current and the target version # of each component and which kubelets it found. The kubelet may be up to three minor versions # behind the API server, never ahead - but an old patch release still carries its bugs. """, }, ]