The per-Pod mitigation in this post is still the pinned docs' own first security example and is no longer sufficient, the rule name in its global one survives in one line of the whole tree, and the project's answer was to make the post's premise false and then stop checking¶
Post — Runc and CVE-2019-5736, 11 February 2019, by the Kubernetes Product Security Committee. 101 lines, 6,399 bytes.
The only post in this year's walk that announces no feature. It is an incident bulletin, published the morning an embargo lifted, and it is the one kind of post where being out of date is the intended outcome: the fix shipped, the distributions patched, the advisory links went stale on purpose. What is worth walking is not the vulnerability. It is the fact that a security bulletin has to give advice in the API of the day, and this one gave three pieces, and seven years later they have come apart in three different directions.
The post's own frontmatter records that the project already noticed. Line :4 reads
evergreen: false # mentions PodSecurityPolicy — a machine-readable admission, in the file itself,
that one of the three no longer holds.
As written — runc is "the low-level tool which does the heavy lifting of spawning a Linux
container" (:13), Docker, Containerd and CRI-O sit on top of it, and "while no part of Kubernetes
itself is vulnerable, most Kubernetes installations are using runc under the hood" (:15).
The vulnerability, still embargoed at the time, is stated as a premise rather than a mechanism
(:19):
when running a process as root (UID 0) inside a container, that process can exploit a bug in runc to gain root privileges on the host running the container. This then allows them unlimited access to the server as well as any other containers on that server.
Then the boundary conditions (:21): the vulnerability does not apply if the process is trusted or
"is not running as UID 0", and "It can also be prevented by SELinux, if an appropriate policy has
been applied." The stated source of risk (:23) is "attacker-controller container images, such as
unvetted images from public repositories" — the typo is the post's.
The advice (:27-61) is upgrade runc, or mitigate. Three mitigations, in the order the post gives
them. First, per Pod, because "the exploit requires UID 0 within the container" (:29):
---
apiVersion: v1
kind: Pod
metadata:
name: run-as-uid-1000
spec:
securityContext:
runAsUser: 1000
# ...
Second, the same thing globally — "This can also be enforced globally using a
PodSecurityPolicy" (:43):
---
apiVersion: policy/v1beta1
kind: PodSecurityPolicy
metadata:
name: non-root
spec:
privileged: false
allowPrivilegeEscalation: false
runAsUser:
# Require the container to run without root privileges.
rule: 'MustRunAsNonRoot'
with a sentence of advocacy after it (:59): "Setting a policy like this is highly encouraged given
the overall risks of running as UID 0 inside a container." Third (:61), vet your images, "by
building all your images yourself, or by vetting the contents of an image and then pinning to the
image version hash", and the post writes the form inline as
image: external/someimage@sha256:7832659873hacdef.
The rest (:63-101) is the perishable half: safe package versions for seven platforms, four
provider bulletins, and contact addresses.
As it runs now — take the three mitigations in the post's order, because they have not aged at the same rate.
The first one still applies, and more than applies: it is the pin's own opening example of a
security context. content/en/examples/pods/security/security-context.yaml — the file that
security-context.md:61 renders as the first code sample on the task page — sets
securityContext.runAsUser: 1000 at :7, exactly the post's field with exactly the post's value,
and allowPrivilegeEscalation: false at :22, which is the post's second fence's field. Between
them the post's two mitigations supply two of the four fields in the pin's canonical example, and
security-context.md:63 still explains runAsUser in the present tense.
And it is no longer sufficient, on a field the post never names. The successor to the post's global
mechanism validates namespaces against the Pod Security Standards, and the restricted profile's
Running as Non-root control at pod-security-standards.md:396-414 restricts
spec.securityContext.runAsNonRoot with an Allowed Values list of exactly one entry, true.
Undefined is not in that list. A separate control, Running as Non-root user (v1.23+) at
:417-431, is the one that governs runAsUser, and its allowed values are "any non-zero value"
and undefined/null. So the post's Pod satisfies the control about the field it sets and violates
the control about the field it does not, and the violated one is the control whose name is the
post's own argument.
The post did name the concept, once, and only in the global fence: rule: 'MustRunAsNonRoot'. At
the pin that string occurs in the entire content/en/docs tree exactly once, at
psp-to-pod-security-standards.md:143, and it occurs there as the left-hand side of a translation:
the runAsUser row of a table mapping PodSecurityPolicySpec fields onto profiles, whose
Restricted cell reads "rule is MustRunAsNonRoot". The rule name in the post survives only
inside the document written to translate it away.
The second mitigation is a dead API, and this walk has already been there. policy/v1beta1
PodSecurityPolicy has not been served since v1.25, and the removal, the migration table, and the
tense the deprecation guide uses about it are walked in
2018 against a post that carried its own PodSecurityPolicy
fragment. Nothing here re-reads those pages. What is this post's own is narrower and stranger: this
bulletin's fence is the reason the tree still contains a MustRunAsNonRoot, and step 1 goes and
finds it.
The third mitigation is the one that has become more documented rather than less. The post writes
digest pinning as a parenthesis; the pin gives it a section. images.md:40-43 distinguishes tags
from digests — "Tags can be moved to point to different images, but digests are fixed" — :52-53
names the format, and :59-68 lists six forms of image name, of which two are the post's and one is
a form the post does not mention at all:
registry.k8s.io/pause:3.5@sha256:1ff6c18fbef2045af6b9c16bf034cc421a29027b800e4f9b68ae9b1cb3e9ae07,
described as "Image name with tag and digest. Only the digest will be used for pulling." Seven
years on, the only one of the three mitigations that needs no translation is the one the post threw
in at the end.
The boundary condition has aged best of all. The post's SELinux clause is the single sentence of
this bulletin that the pinned documentation still repeats, and it repeats it with the same CVE
number: linux-kernel-security-constraints.md:215-219, a note under the table of Linux kernel
security modules, says "Mechanisms like AppArmor and SELinux can provide protection that extends
beyond the container. For example, you can use SELinux to help mitigate" — and then links
CVE-2019-5736. That is the only occurrence of the string CVE-2019-5736 anywhere in
content/en/docs.
And here is the part the post could not have written. The project's eventual answer to root in
a container is root on the host was not to enforce non-root harder. It was to make the premise
false, and then to stop checking. user-namespaces.md:73 gives the opt-in as
pod.spec.hostUsers, and :255-273 states the consequence for admission: for Linux Pods that
enable user namespaces, Kubernetes "relaxes the application of Pod Security Standards in a
controlled way", and the eight fields it then lists as "not checked" under Baseline or
Restricted are the four runAsNonRoot fields and the four runAsUser fields. Both of the post's
fields. The justification (:260-263) is the negation of the post's :19:
This behavior does not present a security concern because
rootinside a Pod with user namespaces actually refers to the user inside the container, that is never mapped to a privileged user on the host.
The post says UID 0 in a container is UID 0 on the host, so do not be UID 0. The pin says if UID 0 in the container is not UID 0 on the host, we will not ask whether you are UID 0. Same sentence, opposite direction, and the second one is why the first one is optional.
What this exercise does not cover, and where it lives. The removal of PodSecurityPolicy, the
field-by-field mapping table read as a whole, and the tense the deprecation guide uses about it are
covered in 2018, which is the exercise that owns them;
step 1 opens that mapping table at one line and reads no further. The PodSecurity and
UserNamespacesSupport ladders, and the reframing of non-root advice that the second one implies,
are covered in 2016; the ladder below
is a third gate that neither of them names. The runtime layer the post's :13 describes — what sits
on top of runc, and what the pin documents in its place — is covered in
2017. And the labelling scheme that
turns a namespace into an enforced profile is described in both of those security exercises; steps 3
onward use kubectl label as a tool and do not re-explain it.
The diff, and why — this is a bulletin, so the interesting cases are not the ones about features.
Mitigation two broke, in the ordinary way an API version breaks, and its breakage is the one the post's own frontmatter flags. Nothing subtle happened: the kind was withdrawn, the manifest no longer resolves, and a reader who applies it gets an error naming the kind.
Mitigation one is still right and no longer enough, which is the case this walk sees least
often. It is not that the field changed meaning or that the advice was wrong. The advice was
correct, was adopted, became a docs example, and was then joined by a second field expressing the
same intent more precisely — and the enforcement mechanism that replaced the post's was written
against the second field. The post's Pod is admitted by baseline and rejected by restricted, and
the rejection is not a disagreement about security; it is a disagreement about which field carries
the claim. runAsUser: 1000 asserts a number. runAsNonRoot: true asserts a property. The
successor checks the property, because a number in a Pod spec can be overridden by an image whose
USER directive the API server cannot see, and the property cannot.
Mitigation three is still right, unchanged, and better documented, which in this corpus is rare enough to be worth stating plainly. The post's throwaway parenthesis is now a six-item list with a documented precedence rule.
And the whole post was retired by being agreed with, twice over, at two different layers. Its recommendation became a namespace-level default that nobody has to switch on — the gate for that is already laddered in 2016. Then the premise it argues from was itself dismantled, and the dismantling came with its own gate, which is the one below, and which was deleted for the most interesting reason in the whole gate directory.
The ladder — one gate, and it is the only gate in this year's set that never reached beta.
UserNamespacesPodSecurityStandards
| stage | default | locked | releases |
|---|---|---|---|
| alpha | false |
— | v1.29 – v1.34 |
The file declares removed: true. Six releases at alpha, no beta row, no stable row, and gone. Its
body is the relaxation described above — "Enable Pod Security Standards policies relaxation for pods
that run with namespaces. You must set the value of this feature gate consistently across all nodes
in your cluster, and you must also enable UserNamespacesSupport to use this feature." — and then,
unusually, the file explains its own deletion:
This feature gate is not part of Kubernetes v1.35 (and onwards) because, for those versions of Kubernetes, all supported kubelet versions will fail to create a pod if it requests a user namespace, but the node does not.
Read that against the four-column table and it is a fourth kind of ending. The other retirements in
this year are switches deleted because the answer became unconditional. This one is a switch deleted
because the hazard it guarded became impossible: the gate existed so that admission would not
relax a check on the strength of a hostUsers: false that the node might silently ignore, and once
every supported kubelet fails closed on that request instead of ignoring it, there is nothing left
for the gate to guard. The relaxation did not go away — user-namespaces.md:255-273 still describes
it, ungated. The doubt went away.
There is no PodSecurityPolicy gate file to ladder: the mechanism the post's second fence uses was
an admission controller and an API version, never a feature gate, so its removal leaves no
four-column trace at all. That absence is worth noticing before step 8 goes looking for it.
Topology — solo: one node at 10.10.10.180. Every
check below is either a read of the pinned checkout or an admission decision, and admission decisions
are the API server's, so one node carries all of them; step 7 is the only one that touches the
kubelet, and it wants a node whose /etc/subuid you are willing to look at and leave alone. Bring
it up with the five provision steps, substituting
topology=solo, then ssh zain@10.10.10.180. Steps 1, 2, 5, 8 and 9 read the checkout and need no
cluster.
Do
- Find the post's rule name in the tree, and stop where it stops:
W=/path/to/pinned/website/content/en/docs
grep -rn 'MustRunAsNonRoot' $W | wc -l
grep -rn 'MustRunAsNonRoot' $W
sed -n '139,145p' $W/reference/access-authn-authz/psp-to-pod-security-standards.md
One line in the whole tree. Read the seven lines around it and note which column the post's string is in, and which column the reader is being sent to. Do not read further down that table; the table as a whole belongs to another exercise.
- Read the pin's own first security example against the post's two fences:
E=/path/to/pinned/website/content/en/examples
cat $E/pods/security/security-context.yaml
sed -n '61p;63,66p' $W/tasks/configure-pod-container/security-context.md
Write down which fields of the post's two YAML fences appear in the example, and which field of the example appears in neither fence.
- Build three namespaces, one per profile, and use the post's Pod as the probe. Nothing is created: every apply here carries the flag from the exercise before this one.
for L in privileged baseline restricted; do
kubectl create ns psa-$L
kubectl label ns psa-$L pod-security.kubernetes.io/enforce=$L
done
cat > /tmp/post-pod.yaml <<'YAML'
apiVersion: v1
kind: Pod
metadata:
name: run-as-uid-1000
spec:
securityContext:
runAsUser: 1000
containers:
- name: c
image: busybox:1.28
command: ["sleep", "3600"]
YAML
for L in privileged baseline restricted; do
echo "--- $L"
kubectl -n psa-$L apply --dry-run=server -f /tmp/post-pod.yaml; echo "exit $?"
done
The # ... in the post's fence is where a container list has to go, so the file above is the
post's Pod with the smallest container that will make it a valid Pod. Three answers, and the third
is the finding: read which field the message names.
- Now the pin's own example, into the same three namespaces:
for L in privileged baseline restricted; do
echo "--- $L"
kubectl -n psa-$L apply --dry-run=server -f $E/pods/security/security-context.yaml
done
Count the violations in the restricted message. Every one of them is a control the docs' first
security example does not satisfy, on a page that page's own reader has no reason to distrust.
- Read the four controls that produced those messages, in the order the profile lists them:
for N in 380 396 417 435 459; do
echo "--- $N"
sed -n "${N}p" $W/concepts/security/pod-security-standards.md | sed 's/<[^>]*>//g;s/^[ \t]*//'
done
sed -n '406,414p' $W/concepts/security/pod-security-standards.md | sed 's/<[^>]*>//g;s/^[ \t]*//' | grep -v '^$'
sed -n '427,431p' $W/concepts/security/pod-security-standards.md | sed 's/<[^>]*>//g;s/^[ \t]*//' | grep -v '^$'
Five control names, then the Allowed Values of the two that concern the post. One of the two lists has a single entry and does not include undefined; the other includes undefined explicitly. That asymmetry is the whole of step 3's third answer.
- Add the field the post never names, and find out what is still missing:
cp /tmp/post-pod.yaml /tmp/nonroot-pod.yaml
sed -i 's/ runAsUser: 1000/ runAsUser: 1000\n runAsNonRoot: true/' /tmp/nonroot-pod.yaml
kubectl -n psa-restricted apply --dry-run=server -f /tmp/nonroot-pod.yaml
cat >> /tmp/nonroot-pod.yaml <<'YAML'
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop: ["ALL"]
seccompProfile:
type: RuntimeDefault
YAML
kubectl -n psa-restricted apply --dry-run=server -f /tmp/nonroot-pod.yaml; echo "exit $?"
Two more rounds. The first message is shorter than step 4's and still not empty. The second should be silent — and if it is not, read what is left against the list from step 5 before changing anything.
- The premise, removed. Look at the node first, because admission and the kubelet answer different questions:
cat /etc/subuid; echo "exit $?"
cp /tmp/post-pod.yaml /tmp/userns-pod.yaml
sed -i 's/^spec:/spec:\n hostUsers: false/' /tmp/userns-pod.yaml
kubectl -n psa-restricted apply --dry-run=server -f /tmp/userns-pod.yaml; echo "exit $?"
kubectl -n psa-restricted apply -f /tmp/userns-pod.yaml
kubectl -n psa-restricted get pod run-as-uid-1000 -o wide
kubectl -n psa-restricted describe pod run-as-uid-1000 | sed -n '/Events:/,$p'
The Pod is the post's Pod — still no runAsNonRoot — with one field added. Admission is one
verdict and the kubelet is another; record both, and do not predict either from the other. If
/etc/subuid has no entry for the runtime's user, the second verdict is the one the gate below
used to exist for.
- The gate, and the gate that is not there:
G=$W/reference/command-line-tools-reference/feature-gates
cat $G/UserNamespacesPodSecurityStandards.md
ls $G | grep -ic 'podsecuritypolicy'; true
grep -c 'PodSecurityPolicy' $W/reference/command-line-tools-reference/kube-apiserver.md; true
One file that explains its own deletion, then two searches that come back empty. The post's global
mitigation was never a feature gate, so there is no ladder to read and no removed: true to find;
the only trace of it in this directory is the absence.
- Ask the running server which admission plugins it actually has:
kubectl get --raw /metrics \
| grep '^apiserver_admission_controller_admission_duration_seconds_count' \
| grep -o 'name="[^"]*"' | sort -u
metrics.md:21-26 describes this histogram as STABLE, labelled by name, operation, rejected
and type, and exported by kube-apiserver. Find PodSecurity in the list. Then look for the
post's plugin, and note that a plugin the binary does not have cannot appear here whatever any
page says about it.
-
The mitigation that needed no translation. Read the pin's list, then pin an image by both tag and digest and ask the cluster what it pulled:
sed -n '40,43p;52,53p;59,68p' $W/concepts/containers/images.md kubectl -n psa-privileged run digest --image=busybox:1.28 --restart=Never \ --command -- sleep 3600 kubectl -n psa-privileged wait --for=condition=Ready pod/digest --timeout=90s kubectl -n psa-privileged get pod digest -o jsonpath='{.spec.containers[0].image}'; echo kubectl -n psa-privileged get pod digest \ -o jsonpath='{.status.containerStatuses[0].imageID}'; echoThe first command is the six forms and the precedence sentence. The last two are the string you asked for and the digest the node resolved it to. The post's advice is to write the second in place of the first, and the difference between those two outputs is why.
-
The clause that outlasted the rest of the post:
sed -n '215,219p' $W/concepts/security/linux-kernel-security-constraints.md grep -rn 'CVE-2019-5736' $W | wc -lOne note, one CVE, and a count of one. Read it against the post's
:21and say which half of that sentence the docs kept.
Expect
Step 1: 1, then a single line, psp-to-pod-security-standards.md:143. The seven-line window shows
a table row whose first cell is runAsUser, whose second says Mutating & Validating, and whose
third gives Baseline: Anything and Restricted: "rule is MustRunAsNonRoot". The post's
string is the input to that mapping, and the output is a profile name.
Step 2: the example sets runAsUser: 1000, runAsGroup: 3000, fsGroup: 2000 and
supplementalGroups: [4000] at the Pod level and allowPrivilegeEscalation: false on the
container. The post's first fence supplies runAsUser; the post's second fence supplies
allowPrivilegeEscalation. The field in the example that appears in neither fence is fsGroup, and
the field in neither the example nor the fences is runAsNonRoot.
Step 3: psa-privileged and psa-baseline accept the Pod and report it as created; psa-restricted
refuses it, and the message names runAsNonRoot — a field the file does not contain — together with
seccomp and capabilities. The post's Pod is refused by the profile that its own global fence
translates to.
Step 4: privileged accepts, baseline accepts, restricted refuses with three violations —
runAsNonRoot, seccompProfile, and capabilities.drop. The docs' canonical example fails the
docs' strictest profile, and nothing on either page says so.
Step 5: Privilege Escalation (v1.8+), Running as Non-root, Running as Non-root user (v1.23+),
Seccomp (v1.19+), Capabilities (v1.22+). Then Running as Non-root's Allowed Values: one entry,
true. Then Running as Non-root user's: any non-zero value and undefined/null. Undefined is
allowed for the number and forbidden for the property.
Step 6: the first message drops runAsNonRoot and keeps seccomp and capabilities. The second is
accepted with exit 0. Four fields to satisfy a profile that the post's one field was aimed at, and
three of them post-date the post.
Step 7: /etc/subuid either exists with a mapping or does not, and its exit code is the honest
answer. Admission accepts the Pod despite the missing runAsNonRoot, because hostUsers: false puts
both of the post's fields on the not-checked list at user-namespaces.md:266-273. Whether it then
runs is the kubelet's answer, and on a node with no subuid range for the runtime it will not: the
Pod stays out of Running and the events name the user-namespace configuration, which is the failure
mode the deleted gate was there to prevent from being silent.
Step 8: the gate file, with removed: true, one alpha row, and the paragraph explaining why v1.35
does not have it. Then 0 from the directory listing and 0 from the API-server help. The post's
global mitigation leaves no trace in the feature-gate directory at all.
Step 9: a sorted list of admission plugin names containing PodSecurity, and no PodSecurityPolicy
in it. The list is the server's, not a page's.
Step 10: six image forms, the sentence "Tags can be moved to point to different images, but digests
are fixed", and the note that with both a tag and a digest only the digest is used. Then an
imageID containing an @sha256: digest, and a spec.containers[0].image that is still
busybox:1.28. The node resolved the tag to a digest and recorded both; the post's advice is to
write the resolved form yourself so that nobody else can change what the tag resolves to.
Step 11: the note, the CVE link, and the count 1. The docs kept the SELinux half of the post's
:21 and dropped the "is not running as UID 0" half into the profile machinery of steps 3 to 6.
Read on
- The 11-ways exercise — for PodSecurityPolicy's removal and the mapping table read in full. It closes with an open question about why the deprecation guide writes about the PodSecurityPolicy admission controller in the future tense. Step 9's output is from the server rather than a page: does it settle that question, or only narrow it?
- The security best practices exercise
— for the
PodSecurityandUserNamespacesSupportladders, and for a 2016 post whose advice was the same as this one's. Given that itsUserNamespacesSupportladder endslocked: true, what is the earliest release in which step 7'shostUsers: falsecould not be switched off by a cluster operator? - The dry-run exercise — for the flag that every apply
in steps 3, 4, 6 and 7 uses. Those steps ask admission a question about a Pod that is never
created; read what that exercise measures about defaulting, and say whether a
--dry-run=serververdict from therestrictednamespace would still hold if a mutating webhook were installed. - The containerd exercise — for the
layer this post's
:13describes. The post names Docker, Containerd and CRI-O as the things that sit on runc; read what the pin documents incontainer-runtimes.mdand say which of the three the kubelet can still talk to without a shim.
Teardown
kubectl delete ns psa-privileged psa-baseline psa-restricted
rm -f /tmp/post-pod.yaml /tmp/nonroot-pod.yaml /tmp/userns-pod.yaml
Leave the guest up; the next exercise in this year reuses it. Everything created lived in the three
namespaces, so the deletion covers the one Pod from step 7 and the one from step 10. Nothing in this
exercise changed the node: step 7 reads /etc/subuid and does not write it, and if you want a node
that can actually run a user-namespaced Pod, that is a node change and belongs in a topology, not in
a walk.