How Container Escapes Work: runc and Defenses

10 min read

A container is a set of Linux processes running on the host's own kernel, with namespaces, cgroups and security profiles around them. It is not a small virtual machine. A container escape is anything that lets a process inside those limits act on the host. The attacker might write to a host file, change a kernel setting or run code as host root. On 2025-11-05 the runc maintainers published three such bugs together: CVE-2025-31133, CVE-2025-52565 and CVE-2025-52881. Each has a CVSS 4.0 score of 7.3 (High) in the runc GitHub security advisories. runc is the low-level runtime underneath Docker, containerd and, depending on configuration, Podman.

This post explains how these container escapes work, from the defender's side. It then covers how to check a Debian 13 host for them and which hardening layers still limit the damage when the runtime itself is buggy. It contains no exploit code.

Why container escapes happen: one kernel, many tenants

Every container on a host makes system calls into the same kernel. What stops container root from being host root is a stack of separate controls:

  • Namespaces control what the process can see: mounts, PIDs, network and, optionally, users.
  • Capabilities split root's power into pieces. Docker starts containers with a reduced allowlist.
  • seccomp filters which system calls are allowed.
  • AppArmor (Debian's default LSM) or SELinux limits file access, including writes to /proc and /sys.
  • Masked and read-only paths: the runtime covers dangerous procfs files so the container cannot reach them.

The runtime builds this stack itself while it sets up the container, and it runs as root to do that. If an attacker can confuse the runtime during setup, the runtime can do something with root privileges that the container itself could never do. All three 2025 runc bugs work this way.

How the runc container escapes work

The two targets are the same in all three bugs. Both are procfs files that should never be writable from a container:

  • /proc/sys/kernel/core_pattern. If this value starts with |, the kernel treats the rest as a command to run whenever a process dumps core. The host kernel starts that command, so it runs outside the container.
  • /proc/sysrq-trigger. According to the kernel documentation, writes here are always allowed for an admin, whatever kernel.sysrq is set to. That includes an immediate reboot or crash, so it works as a denial-of-service button.

CVE-2025-31133: the fake /dev/null

To mask a sensitive file, runc bind-mounts the container's /dev/null over it. Vulnerable versions did not check that the /dev/null they used was a real null device inode. A container whose mounts are shared with another container that the attacker controls could swap /dev/null for a symlink during that window. runc then followed the symlink and bind-mounted its target, read-write, where the mask should be. That turns a mask into an arbitrary mount. The advisory also describes a quieter variant: delete /dev/null, and runc ignored the ENOENT error and skipped the masking.

CVE-2025-52565: the /dev/console bind mount

runc bind-mounts a pseudo-terminal from /dev/pts/$n to /dev/console. The same kind of symlink swap on the source redirects that mount. This mount happens before masked and read-only paths are applied, so procfs files that would later be protected can end up writable at /dev/console. It happens after pivot_root(2), so the attacker cannot write host files directly. The procfs targets above are enough.

CVE-2025-52881: redirected procfs writes and LSM bypass

runc writes to procfs during setup, for example to set sysctls and to apply the AppArmor or SELinux label via /proc/self/attr/. The fix for CVE-2019-19921 checked some of these paths, but only against fake tmpfs files. The new trick uses racing containers with shared mounts to point the write at a different real procfs file. The label write can then land somewhere harmless, so the container starts without its LSM profile. A sysctl write can be sent to core_pattern or sysrq-trigger instead of its intended file. The advisory notes that a write of the docker-default profile name redirected to /proc/sysrq-trigger crashes the host.

Who can actually trigger this

The disclosure is clear that every variant depends on starting containers with custom mount configurations. That means an untrusted image, compose file or pod spec, or an untrusted Dockerfile. RUN --mount=... is enough, so a docker buildx build of someone else's repository counts. A shell inside a well-configured container you started yourself is not enough on its own. CI runners, shared build hosts and anything that runs user-supplied images are the real risk.

Patching on Debian 13: what versions you have

Fixed upstream versions are runc 1.2.8, 1.3.3 and 1.4.0-rc.3. Docker Engine 28.5.2 (2025-11-05) updated its bundled runc to v1.3.3.

Package (Debian 13)Version in trixieNotes
runc1.1.15+ds1-2 (binary 1.1.15+ds1-2+b4)Listed as vulnerable to all three CVEs in the Debian security tracker when checked on 2026-09-24. Fixed in unstable in 1.3.3+ds1-2; forky/sid currently ship 1.3.6+ds1-1.
containerd1.7.24~ds1-6+deb13u1Depends on the Debian runc package.
docker.io26.1.5+dfsg1-9+deb13u1Depends on Debian runc and containerd.
podman5.4.2+ds1-2Depends on crun | runc. Check which one is actually in use.

Check what is installed and which runtime is actually used:

apt update
apt policy runc containerd docker.io podman crun
runc --version
docker info --format '{{.DefaultRuntime}}'
podman info --format '{{.Host.OCIRuntime.Name}} {{.Host.OCIRuntime.Version}}'

If you use Docker's own apt repository (docker-ce plus containerd.io), runc comes bundled from Docker, and docker version shows it. If you use Debian's docker.io, check the tracker page for runc before you assume a fix. At the time of writing, stable does not have one. Installing single packages from forky onto trixie is not a fix I would recommend on a production host. Until stable is patched, the hardening layers below matter more. The disclosure also says crun and youki had similar flaws, so Podman users should check crun on the tracker too.

How to detect container escape risk and attempts

Audit running containers with docker inspect

Start with the configurations that make any escape easier, or remove the need for one:

docker ps -q | xargs -r docker inspect --format '{{.Name}} priv={{.HostConfig.Privileged}} capadd={{.HostConfig.CapAdd}} secopt={{.HostConfig.SecurityOpt}} ro={{.HostConfig.ReadonlyRootfs}} user={{.Config.User}} apparmor={{.AppArmorProfile}} pid={{.HostConfig.PidMode}} net={{.HostConfig.NetworkMode}}'

Flag any container with priv=true, SYS_ADMIN or ALL in capadd, seccomp=unconfined or apparmor=unconfined in secopt, pid=host, or an empty user (runs as root). Docker's AppArmor docs say privileged containers without an explicit profile run unconfined.

Then look for mounted runtime sockets. Anything that can talk to docker.sock can start a privileged container with / mounted and does not need a runc bug:

docker ps -q | xargs -r docker inspect --format '{{.Name}}{{range .Mounts}} {{.Source}}:{{.Destination}}{{end}}' | grep -E 'docker\.sock|containerd\.sock|podman\.sock'
getent group docker

Members of the docker group have the same power over the host as root.

Check confinement from the host side

Don't trust the config alone. Look at the actual process:

pid=$(docker inspect --format '{{.State.Pid}}' web)
grep -E 'Seccomp|NoNewPrivs|CapEff' /proc/"$pid"/status
cat /proc/"$pid"/attr/current
docker info --format '{{json .SecurityOptions}}'

Seccomp: 2 means a filter is active. NoNewPrivs: 1 means the no-new-privileges flag is set. attr/current should show docker-default or your own profile, not unconfined. That last check matters here: CVE-2025-52881 can start a container without its label while everything looks normal in docker inspect.

Watch the targets in logs

Record a baseline for core_pattern and alert when it changes. On many hosts it points to systemd-coredump or a plain file name. A value that suddenly points to an unfamiliar command is a strong sign of an escape:

sysctl -n kernel.core_pattern
journalctl -k -g 'sysrq' --since '-7d'
journalctl -k -g 'apparmor="DENIED"' --since '-1d' | grep -E 'docker-default|containers-default'

The kernel always prints the header line of a sysrq command, whatever the log level. A sysrq entry you didn't trigger yourself needs investigating. Repeated AppArmor denials on /proc/sys or /proc/sysrq-trigger from a container profile show that something is trying to reach these targets.

auditd for runtime configuration

An attacker who gets partway in often tries to weaken the runtime config itself. auditctl(8) marks -w watches as deprecated, so use the syscall form:

-a always,exit -F arch=b64 -F dir=/etc/docker -F perm=wa -k container-cfg
-a always,exit -F arch=b64 -F dir=/etc/containers -F perm=wa -k container-cfg
-a always,exit -F arch=b64 -F path=/etc/subuid -F perm=wa -k container-cfg
-a always,exit -F arch=b64 -F path=/etc/subgid -F perm=wa -k container-cfg
augenrules --load
ausearch -k container-cfg -i

Also watch docker events --filter event=create and inspect every new container with the audit command above. Build hosts create a lot of them.

How to defend: layers that still work when runc is buggy

The advisories list which controls help against which bug. In short:

LayerEffect per the runc advisories
Rootless runtime (Podman, rootless Docker)Strong. runc runs as an unprivileged user and cannot write to these procfs files.
User namespaces, host root not mappedBlocks most of the serious impact through normal Unix permissions.
AppArmor default profileBlocks most /proc writes for CVE-2025-31133. Does not stop CVE-2025-52565 by default. CVE-2025-52881 bypasses it.
Non-root user + no-new-privilegesRecommended in the CVE-2025-31133 advisory to cut off setuid paths.
Not running untrusted images/DockerfilesRemoves the precondition for all three bugs.

Prefer rootless Podman

apt install podman uidmap passt
grep "$USER" /etc/subuid /etc/subgid
podman info --format '{{.Host.Security.Rootless}}'

If the user has no subordinate ranges, add them as root with usermod --add-subuids 100000-165535 --add-subgids 100000-165535 username. Pick a range that does not overlap existing entries.

Enable user namespace remapping in Docker

Warning: this change needs a dockerd restart, which stops running containers. Remapping is incompatible with --privileged, --pid=host and --network=host, and existing bind-mounted data will have the wrong ownership. Try it on a test host first.

{
  "userns-remap": "default"
}
systemctl restart docker
grep dockremap /etc/subuid

A per-container baseline

services:
  web:
    image: nginx:stable
    user: "101:101"
    read_only: true
    tmpfs:
      - /tmp
      - /var/cache/nginx
      - /run
    cap_drop:
      - ALL
    cap_add:
      - NET_BIND_SERVICE
    security_opt:
      - no-new-privileges:true
    pids_limit: 200

Never use privileged: true, seccomp=unconfined or apparmor=unconfined, and never mount the Docker socket. If a tool needs the API, put a filtering proxy in front of it or run the tool on a separate host. Run builds of untrusted Dockerfiles on a disposable VM, not on the host that runs production containers. The same thinking applies to the host's own services. See sandboxing systemd services on Debian 13 for the systemd side. For the reverse proxy in front of your containers, see web server security basics for self-hosters.

Remember the shared kernel

All of this assumes the kernel itself holds. A kernel privilege-escalation bug gets through namespaces, seccomp and AppArmor alike. Install kernel updates, reboot into them, and don't let untrusted workloads share a host with sensitive ones. When you really need isolation between tenants, give them separate VMs.

Takeaways: container escape checklist for Debian 13

  • Run apt policy runc containerd docker.io podman crun and compare with the Debian security tracker. Upstream fixes are runc 1.2.8 / 1.3.3 / 1.4.0-rc.3.
  • Confirm which runtime actually runs: docker info / podman info.
  • Audit all containers for privileged mode, added capabilities, unconfined profiles, pid=host and root users.
  • No mounted docker.sock. Review the docker group's members.
  • Check Seccomp, NoNewPrivs and attr/current for each container PID.
  • Keep a baseline of kernel.core_pattern. Alert on unexpected sysrq and AppArmor denials in journalctl -k.
  • Prefer rootless Podman, or enable userns-remap for Docker.
  • Default to cap_drop: ALL, read_only, no-new-privileges and a non-root user.
  • Build and run untrusted images only on isolated, disposable hosts.

Sources

Comments