Phoenix Pipeline
hackthebox
Task: Secure coding challenge - identify and patch two vulnerabilities in a PHP web application via a web-based IDE with Socket.IO save protocol. Solution: Fixed session puzzling (moved session assignment after validation) and file upload TOCTOU race condition (validate before move_uploaded_file).
$ ls tags/ techniques/
Phoenix Pipeline — HackTheBox
Description
"With the NecroNet defeated, the Citadel unveils the Phoenix Pipeline, rebirthing global services in a resilient web — ushering in a secure, enduring tomorrow."
Two ports provided:
154.57.164.67:30603— web application (HTB Editor + PHP app)154.57.164.67:32715— connection refused (unused)
This is a secure coding challenge: identify two vulnerabilities in a PHP web application, patch them via a web-based IDE (Socket.IO save protocol), and pass the automated verifier to get the flag.
Analysis
Reconnaissance
Port 30603 served an "HTB Editor" — a React-based web IDE (Vite + Monaco Editor + Socket.IO) with a file explorer, code editor, and a "Verify" button.
The IDE exposed a REST API at /api/*:
| Endpoint | Method | Description |
|---|---|---|
/api/directory | GET | File tree listing |
/api/file?path=... | GET | Read file content |
/api/verify | GET | Check if vulnerabilities are patched, returns flag on success |
/api/create-file | POST | Create new file (409 if exists) |
Socket.IO at /socket.io for real-time file saving with messages:
{"type": "save", "data": {"fileName": "...", "content": "...", "md5": "..."}}
The /challenge/ path served the actual PHP web application ("Phoenix Pipeline - Global Infrastructure Status") with login/register, operator dashboard, admin dashboard, and reports with photo upload.
Source Code Structure
The /api/directory endpoint revealed the full PHP application:
app/
Controllers/
AuthController.php # Authentication (login/register)
OperatorController.php # Operator dashboard, report submission, file upload
AdminController.php # Admin dashboard
ApiController.php # API endpoints
Models/
Database.php # SQLite database singleton
exploit/
exploit_session_puzzling.py # PoC for vulnerability 1
exploit_file_upload.py # PoC for vulnerability 2
The GET /api/verify endpoint initially returned:
{"error": "Vulnerability 1 is not patched."}
Vulnerability 1: Session Puzzling (Admin Auth Bypass)
In AuthController::register(), the session was set BEFORE checking if the username already existed:
// VULNERABLE CODE public static function register() { $username = $_POST['username'] ?? ''; $password = $_POST['password'] ?? ''; $area = $_POST['area'] ?? ''; // Session set BEFORE existence check — BUG! $_SESSION['username'] = $username; $_SESSION['area'] = $area; $stmt = $db->prepare('SELECT * FROM users WHERE username = ?'); $stmt->execute([$username]); if ($stmt->fetch()) { header('Location: /challenge/username-exists'); exit; } // ... insert user ... }
Attack flow (confirmed by exploit_session_puzzling.py):
- POST register with
username=admin(which already exists in DB) - Session gets set:
$_SESSION['username'] = 'admin' - Duplicate check fires → redirect to
/username-exists - But session already has admin username → navigate to
/admin→ full admin access
Root cause: Session state is modified before input validation completes. The redirect does NOT destroy the session.
Vulnerability 2: File Upload Race Condition (TOCTOU)
In OperatorController::submitReport(), the uploaded file was moved to a web-accessible directory with its original extension BEFORE validating MIME type and extension:
// VULNERABLE CODE — file moved BEFORE validation $ext = strtolower(pathinfo($original, PATHINFO_EXTENSION)); $tempfile = __DIR__ . '/../uploads/temp_' . $rand . '_' . $date . '.' . $ext; // File is now on disk with original extension (e.g., .php)! move_uploaded_file($tmp_name, $tempfile); // Validation happens AFTER — race condition window exists $finfo = finfo_open(FILEINFO_MIME_TYPE); $mime = finfo_file($finfo, $tempfile); if (strpos($mime, 'image/') !== 0) { unlink($tempfile); // Too late if attacker hit the file! // ... }
Attack flow (confirmed by exploit_file_upload.py):
- Upload
shell.phpwith PHP webshell content - Server moves it to
/uploads/temp_{md5(filename)}_{date}.php - Simultaneously request the predictable temp path before validation completes
- PHP executes the shell → RCE
- Server eventually validates, finds non-image MIME, calls
unlink()— but too late
Root cause: Time-of-check-to-time-of-use (TOCTOU) — the file exists on disk in executable form during the validation window.
Solution
Fix 1: Session Puzzling — Move Session Assignment After Validation
Move $_SESSION assignment to AFTER the username existence check and successful database INSERT:
// FIXED CODE public static function register() { $username = $_POST['username'] ?? ''; $password = $_POST['password'] ?? ''; $area = $_POST['area'] ?? ''; // Check existence FIRST $stmt = $db->prepare('SELECT * FROM users WHERE username = ?'); $stmt->execute([$username]); if ($stmt->fetch()) { header('Location: /challenge/username-exists'); exit; } // Insert user into database $hashedPassword = password_hash($password, PASSWORD_DEFAULT); $stmt = $db->prepare('INSERT INTO users (username, password, area) VALUES (?, ?, ?)'); $stmt->execute([$username, $hashedPassword, $area]); // Session set AFTER successful registration only $_SESSION['username'] = $username; $_SESSION['area'] = $area; header('Location: /challenge/operator'); exit; }
Fix 2: File Upload Race Condition — Validate Before Moving
Validate both file extension and MIME type from the PHP temp file ($tmp_name) BEFORE calling move_uploaded_file(). Skip the intermediate temp file entirely:
// FIXED CODE — validate BEFORE moving $ext = strtolower(pathinfo($original, PATHINFO_EXTENSION)); $allowed = ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp']; // Validate extension FIRST if (!in_array($ext, $allowed)) { // reject — never touches disk return; } // Validate MIME from PHP temp file (not yet in web root) $finfo = finfo_open(FILEINFO_MIME_TYPE); $mime = finfo_file($finfo, $tmp_name); if (strpos($mime, 'image/') !== 0) { // reject — never touches disk return; } // Only move after ALL validation passes $finalpath = __DIR__ . '/../uploads/' . $rand . '_' . $date . '.' . $ext; move_uploaded_file($tmp_name, $finalpath);
Patching via Socket.IO
The IDE used Socket.IO for file saving with an MD5-based conflict resolution protocol:
- Read current file content via
GET /api/file?path=... - Compute MD5 of current content
- Send Socket.IO message with patched content and server's current MD5
- Server responds with
save_successorsave_conflict
Python script to apply patches:
#!/usr/bin/env python3 """ Patch both vulnerabilities via Socket.IO save protocol. """ import hashlib import requests import socketio BASE = "http://154.57.164.67:30603" def get_file(path): r = requests.get(f"{BASE}/api/file", params={"path": path}) return r.json()["content"] def md5(content): return hashlib.md5(content.encode()).hexdigest() # Read current files auth_path = "app/Controllers/AuthController.php" operator_path = "app/Controllers/OperatorController.php" auth_content = get_file(auth_path) operator_content = get_file(operator_path) auth_md5 = md5(auth_content) operator_md5 = md5(operator_content) # Prepare patched content (replace vulnerable sections) patched_auth = auth_content # ... apply session puzzling fix patched_operator = operator_content # ... apply TOCTOU fix # Connect via Socket.IO and save sio = socketio.Client() @sio.on("connect") def on_connect(): sio.emit("message", { "type": "save", "data": { "fileName": auth_path, "content": patched_auth, "md5": auth_md5 } }) @sio.on("save_success") def on_save_success(data): if data.get("fileName") == auth_path: sio.emit("message", { "type": "save", "data": { "fileName": operator_path, "content": patched_operator, "md5": operator_md5 } }) else: # Both saved, verify r = requests.get(f"{BASE}/api/verify") print(r.json()) sio.disconnect() sio.connect(BASE, transports=["websocket"]) sio.wait()
Both saves succeeded, then GET /api/verify returned the flag.
Key Indicators
Session Puzzling — use when:
- PHP
$_SESSIONis set before validation completes - Registration/login sets session before duplicate check
- Redirect (
header('Location: ...')) does not destroy session - Role separation (admin/operator) is based on
$_SESSION['username']
File Upload Race Condition (TOCTOU) — use when:
move_uploaded_file()is called BEFORE MIME/extension check- Temp file lands in a web-accessible directory
- Temp filename is predictable (md5, timestamp)
- There's a window between file write and deletion (even milliseconds)
Defense (Best Practices)
Session Management
- Never set session before validation — session state should only change after ALL checks pass
- Destroy session on failed registration —
session_destroy()if validation fails - Use RBAC — don't rely on username matching for admin access
File Upload
- Validate before moving — check extension + MIME from
$_FILES['tmp_name'](not web-accessible) - Whitelist extensions —
['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp'] - Rename uploaded files — never preserve original filename/extension
- Store outside web root — serve via a controller, not direct access
- No intermediate temp files in web root — eliminates the race window entirely
Tools
- curl — HTTP reconnaissance, API interaction
- Python requests — API interaction, file reading
- python-socketio — Socket.IO client for file saving protocol
- Browser DevTools — JS bundle analysis, understanding frontend architecture (Vite + React + Monaco Editor)
Notes
This challenge combines two distinct vulnerability classes into a single "secure coding" task:
-
Session Puzzling is a lesser-known session management vulnerability. Unlike session fixation (attacker sets session ID) or session hijacking (attacker steals session ID), session puzzling exploits the application's own logic setting session variables at the wrong time. The key insight is that
header('Location: ...')in PHP sends a redirect but does NOT terminate the session — the session data persists. -
TOCTOU in file uploads is a classic race condition. The window between
move_uploaded_file()andunlink()may be tiny, but with concurrent requests it's reliably exploitable. The fix is simple: never let untrusted content touch the web root until fully validated.
The Socket.IO save protocol with MD5 conflict resolution added an interesting twist — you couldn't just POST the fix, you had to understand and implement the real-time save protocol.
$ cat /etc/motd
Liked this one?
Pro unlocks every writeup, every flag, and API access. $9/mo.
$ cat pricing.md$ grep --similar
Similar writeups
- [web][Pro]Powergrid Challenge— necronet
- [web][Pro]websrv1_cve1— web-kids20
- [web][free]Dark Runes— HackTheBox
- [web][free]Hydroadmin— hackthebox
- [forensics][free]Obscure— hackthebox