webfreemedium

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/
file_upload_race_conditionsession_puzzlingtoctou_exploitsocketio_protocolsource_code_reviewadmin_auth_bypass

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/*:

EndpointMethodDescription
/api/directoryGETFile tree listing
/api/file?path=...GETRead file content
/api/verifyGETCheck if vulnerabilities are patched, returns flag on success
/api/create-filePOSTCreate 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):

  1. POST register with username=admin (which already exists in DB)
  2. Session gets set: $_SESSION['username'] = 'admin'
  3. Duplicate check fires → redirect to /username-exists
  4. 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):

  1. Upload shell.php with PHP webshell content
  2. Server moves it to /uploads/temp_{md5(filename)}_{date}.php
  3. Simultaneously request the predictable temp path before validation completes
  4. PHP executes the shell → RCE
  5. 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:

  1. Read current file content via GET /api/file?path=...
  2. Compute MD5 of current content
  3. Send Socket.IO message with patched content and server's current MD5
  4. Server responds with save_success or save_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 $_SESSION is 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

  1. Never set session before validation — session state should only change after ALL checks pass
  2. Destroy session on failed registrationsession_destroy() if validation fails
  3. Use RBAC — don't rely on username matching for admin access

File Upload

  1. Validate before moving — check extension + MIME from $_FILES['tmp_name'] (not web-accessible)
  2. Whitelist extensions['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp']
  3. Rename uploaded files — never preserve original filename/extension
  4. Store outside web root — serve via a controller, not direct access
  5. 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:

  1. 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.

  2. TOCTOU in file uploads is a classic race condition. The window between move_uploaded_file() and unlink() 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