Hands-On Labs · Guided Drills · Container Escape Investigation

Drill — Container Escape Investigation

Three pods paged on-call this month. One had an attacker inside it with a stolen credential and a persistence backdoor already planted. One had a badly timed but entirely legitimate kubectl exec. One had an attacker running the exact technique behind a real, disclosed CVE — and never got past the first syscall. All three produced a Falco alert that looked, at first glance, almost identical: a shell where a shell doesn't usually run. This drill hands you the same evidence the on-call engineer actually had for each of the three — the Falco alert stream, the Kubernetes audit log, and, for the one where it mattered, the node's own kernel audit log — and asks you to reach a verdict before you read the reveal: real compromise, false positive, or attempt blocked. Work each ticket yourself first. By the end you should be able to name the specific piece of evidence that separates those three verdicts, and state precisely which of seccomp, AppArmor, running non-root, and a dropped capability set would have changed each outcome — and which wouldn't have.

☺ Explain it like I'm 10

Three phone calls, all sounding the same. A car alarm goes off outside your house. Call one: someone really is breaking a window. Call two: it's your neighbor, who has a spare key, setting the alarm off by accident while watering your plants like you asked her to. Call three: someone tried the door handle, found it locked, and walked away — the alarm caught the attempt, but there was never a break-in. From the sound of the alarm alone, all three calls are identical. Working out which call you're actually on is the entire job of a security investigation, and this page is three of those calls, back to back.

🦊🤖Your hosts for this drill: Foxy & Recon the Robot — Foxy never accepts an alert's first tidy explanation and keeps asking "but what actually happened" until the evidence closes the loop; Recon already walked the eBPF mechanics and Falco itself, and hands Foxy the alert stream each ticket below starts from.

The four questions, before you look at a single ticket

☺ Like you're 10: Before guessing who set off the alarm, you check the same four things every single time — who was near it, is there a record of them being let in, did anything actually get broken, and were the locks even good ones to begin with.

Every ticket below gets worked the same way, in the same order, because the order is the method — skip a step and you'll reach a verdict that happens to be right for the wrong reason, which is worse than not having a verdict at all. Foxy runs this exact sequence on every alert that lands, real incident or drill:

  1. Read the alert's own fields first, before touching anything else. proc.name, proc.pname, proc.cmdline, and container.image.repository already tell you what ran, what spawned it, and in which workload — the same fields Falco enriches onto every event before a rule ever sees it. A shell spawned by php-fpm and a shell spawned by containerd-shim (the process a legitimate kubectl exec attaches through) are two structurally different stories from the very first line.
  2. Cross-reference an identity that could explain it. The Kubernetes audit log — ingested by Falco's own k8saudit plugin, or read directly — records exactly who issued kubectl exec, kubectl cp, or any API-level action, tied to a real user or service account identity, not inferred from a process tree. If a human with a legitimate reason and a matching timestamp shows up here, that's evidence a syscall-only view can never produce on its own.
  3. Check whether the attempted action actually completed. A Falco alert proves a syscall was attempted — it says nothing about whether the kernel let it through. The node's own kernel audit log (auditd via ausearch, or a plain dmesg for AppArmor) shows whether a seccomp filter or an AppArmor profile denied the exact syscall or file access a moment later. That distinction — attempted versus completed — is the entire difference between "confirmed compromise" and "blocked attempt."
  4. Pull the pod's actual securityContext. A "blocked" verdict only means something once you know which control did the blocking — and an absent securityContext (Kubernetes defaults to root, every capability, and no seccomp profile if nobody sets one) is itself evidence, not a neutral finding.
Falco alert fires Process ancestry proc.pname · proc.cmdline container.image K8s audit log ka.user.name · ka.verb who actually did this? Node kernel logs auditd (SECCOMP) · dmesg attempted vs. completed securityContext was hardening even applied to begin with? Verdict Confirmed compromise False positive Blocked attempt Contain, rotate, harden Tune the rule, document Harden the rest anyway
⚠ Don't kubectl exec into a possibly-compromised pod to look around

The instinct to shell in and poke around is exactly backwards for step three — you'd be adding your own process to the same evidence you're trying to read cleanly, and on a pod that might genuinely be compromised, you don't know what you'd be handing an attacker's session next to. Snapshot instead: crictl inspect and crictl logs from the node read state without attaching a new interactive process, and kubectl debug node/<node> -it --image=<toolbox> gives you a shell on the node to inspect from outside the suspect container entirely. If containment matters more than evidence, isolate first — an emergency deny-all NetworkPolicy or a node cordon — and preserve the pod rather than deleting it. See Incident response & forensics for the full chain-of-custody discipline this is a slice of.

Ticket 1 — the checkout pod's midnight shell

☺ Like you're 10: The alarm's going off, and this time — spoiler, since this is the ticket where it matters most — someone really is inside.

Same pod Container Runtime Security left mid-incident: checkout-7d9f-x8, namespace payments, image registry.acme.io/checkout:1.8.2. A deserialization bug in the checkout service let attacker-controlled input reach php-fpm, and thirty seconds later Falco's alert stream looked like this:

# Falco alert stream — checkout-7d9f-x8, namespace payments, 2026-08-15 16:42 UTC
# (json_output: true — one line per alert, reformatted here for reading)

{"time":"2026-08-15T16:42:11.884213908Z","priority":"Notice","rule":"Terminal shell in container",
 "output":"A shell was spawned in a container with an attached terminal (user=root container_name=checkout
 container_image=registry.acme.io/checkout:1.8.2 shell=sh parent=php-fpm cmdline=sh -c id terminal=34816
 k8s.ns=payments k8s.pod=checkout-7d9f-x8)"}

{"time":"2026-08-15T16:42:19.001552112Z","priority":"Warning","rule":"Unexpected Outbound Connection Destination",
 "output":"Unexpected outbound connection from a container (container_name=checkout k8s.pod=checkout-7d9f-x8
 connection=10.2.4.19:51322->203.0.113.9:4444 proc.name=sh)"}

{"time":"2026-08-15T16:42:24.552011400Z","priority":"Warning","rule":"Unexpected ServiceAccount Token Read",
 "output":"ServiceAccount token read by unexpected process (user=root command=sh -c 'cat
 /var/run/secrets/kubernetes.io/serviceaccount/token' container=checkout pod=checkout-7d9f-x8 ns=payments
 file=/var/run/secrets/kubernetes.io/serviceaccount/token)"}

{"time":"2026-08-15T16:43:02.114400291Z","priority":"Critical","rule":"Set Setuid or Setgid bit",
 "output":"Setuid or setgid bit set via chmod (user=root command=chmod u+s /bin/dash filename=/bin/dash
 container=checkout pod=checkout-7d9f-x8)"}

Read it the way step one of the framework says to: an interactive shell (sh, terminal attached) spawned directly from php-fpm — exactly the "living-off-the-land" pattern Container Runtime Security described, no new binary dropped, just a shell the image already ships used somewhere it never runs in forty days of normal traffic. Seventeen seconds later, an outbound connection to a classic reverse-shell port. Five seconds after that, the pod's own mounted ServiceAccount token gets read by a process that isn't on the expected-readers allowlist — the exact custom rule Falco walked through building. Thirty-eight seconds after that, the attacker sets the setuid bit on /bin/dash, planting a persistent root shell that survives even if the original RCE path gets patched.

Work steps two through four yourself before reading on: is there an identity that explains this? Did anything actually get blocked? What securityContext was this pod even running with?

Reveal the investigation & verdict

Step two — the identity check comes up empty, which is itself the finding. Nothing in the Kubernetes audit log around 16:42 shows a human or a CI identity issuing kubectl exec against checkout-7d9f-x8. What it does show, a few seconds after the token-read alert, is the stolen token trying to use itself:

$ kubectl -n kube-system logs deploy/audit-sink --since=15m \
    | jq 'select(.user.username=="system:serviceaccount:payments:checkout")'

{"kind":"Event","verb":"create","objectRef":{"resource":"pods","namespace":"kube-system"},
 "user":{"username":"system:serviceaccount:payments:checkout"},
 "responseStatus":{"code":403,"reason":"Forbidden",
   "message":"pods is forbidden: User \"system:serviceaccount:payments:checkout\" cannot create resource
   \"pods\" in API group \"\" in the namespace \"kube-system\""}}

The attacker used the stolen token to try creating a privileged pod in kube-system — the classic move to escape the pod boundary entirely by scheduling something with hostPID or a host mount. RBAC scoped to the checkout ServiceAccount's actual job returned 403. That pivot failed — but not because anything about this container stopped it.

Step three — nothing in the node's kernel logs shows a denial, because nothing here needed kernel-level permission it didn't already have. The container was running as root with a full default capability set, so chmod u+s /bin/dash is an entirely ordinary, permitted operation — no seccomp filter or AppArmor profile ever got a chance to object, because none was loaded.

$ kubectl -n payments get pod checkout-7d9f-x8 -o jsonpath='{.spec.containers[0].securityContext}{"\n"}'
{}
$ kubectl -n payments get pod checkout-7d9f-x8 -o jsonpath='{.spec.automountServiceAccountToken}{"\n"}'
true

Step four confirms it: an empty securityContext. No runAsNonRoot, no capability drop, no seccomp profile, and the ServiceAccount token auto-mounted by Kubernetes' own default — for a service that never once calls the Kubernetes API to do its job.

Verdict: confirmed compromise. Real RCE, a real reverse-shell callback, a real credential theft, and a real persistence mechanism already planted. The pivot into kube-system failed on RBAC scoping — a control that happened to exist for an unrelated reason, not because this workload was hardened. Treat that distinction as the whole lesson of this ticket: the team got lucky once, on a control nobody put there specifically to stop this. Rotate the token immediately regardless of its remaining TTL (a projected, auto-rotating token still narrows the exfiltration window, but "narrower" isn't "safe" — see Secrets management), and treat the image as burned: redeploy from a rebuilt, unmodified image rather than trusting the running one, since the setuid backdoor persists in the container's writable layer.

What would have changed the outcome: runAsNonRoot: true (with a fixed, non-zero runAsUser) is the control that actually stops this exact call. chmod's permission check passes automatically the moment the calling UID matches the file's own owning UID, and root — UID 0 — trivially matches /bin/dash's owner, also UID 0, with no capability check involved at all. Once the process runs as a UID that doesn't own the file, that same call needs CAP_FOWNER to override the ownership mismatch — which is exactly what capabilities.drop: ["ALL"] removes, closing the one bypass a misconfigured UID might otherwise leave open. allowPrivilegeEscalation: false is the backstop underneath both: it sets the kernel's no_new_privs flag, so even a setuid bit that somehow did get set is ignored at exec time — the persistent-root-shell trick stops working regardless of how the bit got there. And automountServiceAccountToken: false handles the earlier step: with it set, the token read in step three has nothing to read, because checkout never needed API access to begin with.

Ticket 2 — the 2am shell that wasn't an attacker

☺ Like you're 10: The alarm went off, and it really was just your neighbor watering the plants — but "false alarm" doesn't automatically mean "nothing to fix."

Different pod, different night: inventory-sync-6c88f, namespace warehouse. One alert, at an hour that makes everyone's stomach drop a little:

{"time":"2026-08-12T02:14:47.220091003Z","priority":"Notice","rule":"Terminal shell in container",
 "output":"A shell was spawned in a container with an attached terminal (user=root container_name=inventory-sync
 container_image=registry.acme.io/inventory-sync:2.3.0 shell=bash parent=containerd-shim terminal=3
 cmdline=bash k8s.ns=warehouse k8s.pod=inventory-sync-6c88f)"}

Notice the one field that already looks different from ticket 1 before you've checked anything else: parent=containerd-shim, not a service process reacting to attacker input. A shell spawned directly by the container runtime's own shim is exactly what a kubectl exec attach looks like from inside the container — the runtime opens the interactive session itself, rather than an application process spawning one in response to something it received. That's a lead worth chasing before assuming the worst, not proof of innocence on its own; work steps two through four before reading the reveal.

Reveal the investigation & verdict

Step two — the Kubernetes audit log has the answer. The k8saudit plugin (or a direct pull from wherever your cluster ships its audit log — a webhook backend, a control-plane export, a sidecar) shows exactly who opened this session:

$ kubectl -n kube-system logs deploy/audit-sink --since=20m \
    | jq 'select(.objectRef.name=="inventory-sync-6c88f" and .objectRef.subresource=="exec")'

{"kind":"Event","verb":"create",
 "objectRef":{"resource":"pods","subresource":"exec","name":"inventory-sync-6c88f","namespace":"warehouse"},
 "user":{"username":"alex@acme.com","groups":["system:authenticated","break-glass-oncall"]},
 "requestURI":"/api/v1/namespaces/warehouse/pods/inventory-sync-6c88f/exec?command=bash&container=inventory-sync&stdin=true&stdout=true&tty=true"}

alex@acme.com, authenticated, through a break-glass-oncall group scoped specifically for exactly this. Cross-referencing the on-call ticketing system: INC-4471, "inventory-sync stuck, investigating," opened by the same engineer six minutes earlier — timestamps line up.

Steps three and four — nothing to check, and that's fine. No syscall was denied because nothing malicious was attempted; the pod's securityContext is relevant here only for what it means for what Alex's session could accidentally do, not for what stopped an attack, because there wasn't one.

Verdict: false positive for compromise — Falco did its job correctly by alerting on genuinely anomalous behavior; the anomaly just turned out to be a human, not an attacker. Closing the ticket as "no action" is not the same as closing it as "no finding," though. A regular bash exec straight into a running production container means every command Alex ran carried the container's real permissions against live state — an accidental rm or a typo'd config edit would have hit production exactly as hard as an attacker's would have. The fix isn't a Falco tuning change; it's a process one: prefer kubectl debug inventory-sync-6c88f -it --image=busybox --target=inventory-sync --copy-to=inventory-sync-debug, an ephemeral debug container attached alongside the real one, which gives Alex the tools to look around without executing anything inside the production container's own process namespace and writable layer.

What would have changed the outcome: nothing here needed to be prevented — the session was legitimate. But notice what a distroless build (see Container & supply-chain security) would have done to this exact ticket: with no bash binary in the image at all, this specific exec simply isn't possible, and the ephemeral-debug-container pattern stops being optional advice and becomes the only path in — the same tradeoff that page already named as the cost of going shell-less. Runtime hardening and image hygiene are different levers pulling toward the same outcome here.

Ticket 3 — the escalation attempt that never left the kernel

☺ Like you're 10: Someone tried the door handle, and this time the lock actually held — three separate locks, in fact, and any one of them alone would have been enough.

Third pod: batch-reporting-x92k1, namespace analytics, image registry.acme.io/batch-reporting:4.2.0. An attacker already has code execution inside it — assume the same shape of RCE as ticket 1, a different vulnerable dependency this time — and is running a technique this course has named before: the cgroup v1 release_agent escape, the mechanism behind CVE-2022-0492. The first alert to reach on-call:

{"time":"2026-08-16T03:58:02.771930442Z","priority":"Warning","rule":"Contact cgroupfs release_agent File",
 "output":"Write to cgroupfs release_agent file from a container (user=root
 command=sh -c 'echo /tmp/x > /sys/fs/cgroup/rdma/release_agent' file=/sys/fs/cgroup/rdma/release_agent
 container=batch-reporting pod=batch-reporting-x92k1 ns=analytics)"}

That's a real, disclosed escalation technique, mid-attempt, not a hypothetical — this is the same alert shape a genuinely successful CVE-2022-0492 exploitation would produce. Work the framework before reading the reveal: did it work?

Reveal the investigation & verdict

Step three is where this ticket turns. The node's own kernel audit log, pulled directly from the worker the pod was scheduled on, shows the syscall this technique depends on getting refused before it could do anything:

$ ssh node-worker-14
$ sudo ausearch -m SECCOMP -ts recent

type=SECCOMP msg=audit(1755316682.771:48291): auid=4294967295 uid=10004 gid=10004 ses=4294967295
 pid=48211 comm="sh" exe="/bin/dash" sig=31 arch=c000003e syscall=165 compat=0
 ip=0x7f2a1c2a3f5b code=0x0

$ sudo dmesg | grep -i apparmor | tail -3
[884213.221004] audit: type=1400 audit(1755316682.900:9931): apparmor="DENIED" operation="mkdir"
 profile="cri-containerd.apparmor.d" name="/sys/fs/cgroup/rdma/w/" pid=48211 comm="sh"
 requested_mask="c" denied_mask="c" fsuid=10004 ouid=0

Read the seccomp line precisely: syscall=165 is mount on x86_64 — the release_agent technique needs a fresh, attacker-controlled cgroup hierarchy mounted to plant its own release_agent path, and code=0x0 means the seccomp filter killed the call outright before it returned to the process. The AppArmor line, a fraction of a second later, shows a second, independent denial — a write into the cgroupfs hierarchy the container's profile never grants, which would have stopped this specific write even if the mount call had somehow gotten through some other way.

Step four explains why both fired. This pod's securityContext, unlike ticket 1's, was actually filled in:

securityContext:
  runAsNonRoot: true
  runAsUser: 10004
  allowPrivilegeEscalation: false
  seccompProfile: { type: RuntimeDefault }
  capabilities: { drop: ["ALL"] }
# AppArmor — annotation form (pre-1.30) shown; see the native field below
# container.apparmor.security.beta.kubernetes.io/batch-reporting: runtime/default

Three layers, and the attacker needed all three to fail to succeed: capabilities were already dropped, so the process never held CAP_SYS_ADMIN — the capability the release_agent technique needs even to attempt a fresh cgroup mount, meaning this attack was already dead on arrival before seccomp ever got involved. Seccomp's RuntimeDefault profile blocks the mount syscall outright regardless of capability, an independent line of defense. And AppArmor's default profile denies the write path into cgroupfs on top of both, a third layer working on a different axis entirely — file paths and operations, not syscall numbers or capability bits.

Verdict: real attempt, blocked. This is a genuine, targeted CVE-2022-0492 exploitation attempt — not a misconfigured test, not a scanner false positive — that produced exactly one Warning-priority alert and two kernel-level denials, and never got further. Still worth an incident ticket and a rotation of whatever credentials that RCE could reach — a blocked escalation doesn't mean the initial code execution wasn't real — but the container escape itself never had a chance.

What made the difference: this is the one ticket in the drill where the hardening was already live, and it's the reason "attempt" and "attempt that worked" are different rows in an incident log instead of the same row.

The four controls, side by side

☺ Like you're 10: Four different kinds of locks, each stopping a different kind of trick — which is exactly why a determined attacker needs to beat all four, not just pick one.

Across the three tickets, one control's absence explains ticket 1, and three controls' presence together explain ticket 3. None of the four is redundant with any other — each restricts a genuinely different thing, which is precisely why stacking all four is materially stronger than any one alone:

ControlWhat it actually restrictsDeciding factor inKubernetes field
seccompWhich syscalls a process may even issue — a kernel-level filter that runs before the container runtime's own permission checks doTicket 3 — RuntimeDefault denies mount outright, independent of capabilitiessecurityContext.seccompProfile.type
AppArmorWhich files, paths, and network operations a profile permits — Linux Security Module (LSM) mandatory access control layered on top of, not instead of, the syscall filterTicket 3 — denies the write into /sys/fs/cgroup/*/release_agent even assuming a syscall got throughsecurityContext.appArmorProfile.type (native field, GA in Kubernetes 1.30; the container.apparmor.security.beta.kubernetes.io/<name> annotation is the form to use on older clusters — check which your cluster's version supports)
non-rootWhether the process runs as UID 0, which is the free pass most discretionary (file-permission) checks grant automatically regardless of capabilitiesTicket 1 — running as root is exactly why chmod u+s on a root-owned binary succeeded without any kernel-level objectionsecurityContext.runAsNonRoot + runAsUser
dropped capabilitiesThe specific root-equivalent kernel powers (CAP_SYS_ADMIN, CAP_FSETID, CAP_CHOWN, CAP_SYS_PTRACE, and more) a process holds independent of its UIDTicket 1 (their absence let the setuid bit get set) and ticket 3 (their absence meant CAP_SYS_ADMIN was never available for the cgroup technique to begin with)securityContext.capabilities.drop

Put together, the hardened shape — the one ticket 3's pod was actually running — looks like this. allowPrivilegeEscalation: false earns a place alongside the four the drill brief named, because it's the specific field that sets the kernel's no_new_privs flag, and it's the reason a stray setuid bit or file capability can't grant anything back even if one somehow ends up set:

apiVersion: v1
kind: Pod
metadata:
  name: batch-reporting-x92k1
  namespace: analytics
spec:
  containers:
    - name: batch-reporting
      image: registry.acme.io/batch-reporting:4.2.0
      securityContext:
        runAsNonRoot: true
        runAsUser: 10004
        allowPrivilegeEscalation: false
        seccompProfile:
          type: RuntimeDefault      # or Localhost + localhostProfile for a tighter, custom filter
        capabilities:
          drop: ["ALL"]             # add back only what the workload actually needs, e.g.:
          # add: ["NET_BIND_SERVICE"]
      # Kubernetes 1.30+: native field, no annotation needed
      # (older clusters: container.apparmor.security.beta.kubernetes.io/batch-reporting: runtime/default)
  # securityContext.appArmorProfile is set per-container as of the 1.30 native API; check your
  # cluster's version before assuming which form applies
🤖 Recon's drill · 15 min

Run two Pods side by side on a scratch cluster — one with no securityContext at all (ticket 1's shape), one with the hardened block above (ticket 3's shape). kubectl exec into each and try the exact same command: chmod u+s /bin/dash. Watch it succeed silently on the first and fail with Operation not permitted on the second. That one-line difference in outcome, for the identical command, is everything this section describes, felt instead of read about.

None of this should depend on every team remembering to fill in four fields correctly on every Pod, by hand, forever — the same argument Kubernetes Security Deep Dive makes about admission control generally applies directly here. A Kyverno policy or the Pod Security Admission restricted profile can reject ticket 1's empty securityContext at admission time, before the Pod is ever scheduled, instead of relying on this drill's exact investigation happening after the fact.

🎬 At the Shift-Left Squad
🦊

Foxy: Three alerts, same shape on paper. One's a real attacker with a persistence backdoor already planted, one's Alex debugging at 2am, one's a CVE that never got past the kernel. If I only read the Falco output, they're identical.

🤖

Recon the Robot: Which is exactly why the alert was never supposed to be the whole answer. It's the first question, not the last one.

🐢

Timmy the Turtle: Ticket 3 is the one I keep coming back to. Three separate controls, and the attacker needed all three to fail. That's not luck — Benny built that Pod spec on purpose.

🦫

Benny the Beaver: Ticket 1's Pod spec was mine too, four months ago, before I'd internalized any of this. {} for a securityContext isn't neutral. It's every door left unlocked at once, and I didn't even know I'd done it.

🦝

Rocky the Raccoon: For what it's worth — cgroup release_agent is not some exotic technique I'd save for a hard target. It's page one of "container's running as root with CAP_SYS_ADMIN." Dropping capabilities isn't a nice-to-have, it's the thing standing between "annoying" and "in."

🦊

Foxy: And ticket 2 stays a finding even though nobody attacked anything. Alex needed a real shell inside a real container to debug a stuck job — which means the day someone's credentials get phished, that same door is sitting right there for them too.

✓ Checkpoint

1. Name the four questions this drill's investigative framework asks of every alert, in order, and explain why the order matters. 2. In ticket 1, what specifically stopped the attacker's pivot into kube-system, and why does the drill insist that's not the same thing as the workload being hardened? 3. What single field in the Falco alert's own output was the first clue in ticket 2 that this deserved a different read than ticket 1 — and what evidence source actually confirmed it? 4. Walk through why ticket 3's escalation attempt needed all three of dropped capabilities, seccomp, and AppArmor to fail before it could succeed, and name which specific syscall or file operation each one independently denied. 5. What does allowPrivilegeEscalation: false actually set at the kernel level, and why does that make the setuid backdoor from ticket 1 impossible even if capabilities were somehow still intact?

Check your answers
  1. Read the alert's own process and container fields first; cross-reference an identity that could explain it (Kubernetes audit log or a ticketing system); check whether the attempted action actually completed by pulling node-level kernel logs; and pull the pod's actual securityContext to know what was even in place. The order matters because each step either narrows or rules out a verdict category before you spend effort on the next one — jumping straight to "was this blocked" without first checking for a legitimate identity would waste time chasing a false positive as if it were a real incident.
  2. Kubernetes RBAC scoped to the checkout ServiceAccount's actual permissions returned a 403 when the stolen token tried to create a Pod in kube-system. The drill insists this isn't "the workload being hardened" because that RBAC scoping exists for an unrelated reason — least-privilege IAM design, not a control anyone put there specifically to stop this attack — while the container itself had an empty securityContext: no capability drop, no non-root enforcement, no seccomp profile. The team was protected by a control that happened to exist, not by anything defending this specific workload.
  3. The alert's parent field: ticket 1's shell was spawned by php-fpm (a service process reacting to input), while ticket 2's was spawned by containerd-shim (what a kubectl exec attach looks like from inside the container) — a lead worth chasing, not proof on its own. The Kubernetes audit log confirmed it, showing an authenticated exec subresource request from alex@acme.com through a scoped break-glass group, with timestamps matching an open incident ticket.
  4. Dropped capabilities meant the process never held CAP_SYS_ADMIN, so the cgroup release_agent technique had no capability to build on in the first place. Seccomp's RuntimeDefault profile independently denied the mount syscall (syscall 165 on x86_64) the technique needs to set up a fresh cgroup hierarchy, regardless of capabilities. AppArmor's profile independently denied the write into the cgroupfs release_agent path itself, on the file-and-path axis rather than the syscall-number axis — a third, structurally different layer that would have stopped the write even if the mount had somehow gotten through.
  5. allowPrivilegeEscalation: false sets the kernel's no_new_privs flag on the process. With that flag set, the kernel ignores setuid and setgid bits, and file capabilities, at exec time — so even if an attacker somehow still managed to set the setuid bit on a binary (which running non-root should already prevent, since chmod's ownership check fails once the process's UID no longer matches the file's, and dropped capabilities removes the CAP_FOWNER bypass for that mismatch), executing that binary would no longer grant the elevated privilege the bit is supposed to confer. It closes the specific loophole the other controls leave open if any one of them is misconfigured.

This drill picked up exactly where Container Runtime Security and Falco left off — reading an alert all the way through to a defensible root cause instead of stopping at "something fired." If credential theft specifically is the half of this drill you want more reps on, the leaked credential triage drill is the companion exercise. For the write-time side of the same four controls — rejecting ticket 1's empty securityContext before it's ever scheduled, rather than investigating it after — see Kubernetes Security Deep Dive and Kyverno. And if runtime and container security specifically is the exam you're studying toward, CKS — Kubernetes Security Specialist tests precisely this kind of hardening.