Powergrid Challenge
Powergrid Challenge
Platform: necronet | Category: Web | Type: Challenge | Difficulty: Medium | OS: Linux | Author: XESXOR | Date: 2026-02-01 | Status: Solved Techniques: code_patching, crlf_injection, privilege_escalation, websocket_exploitation
Summary
Task: Patch a CRLF injection vulnerability in a flat-file user database that allows privilege escalation. Solution: Identify that the addUser() function lacks input validation on username, allowing pipe and newline injection to create admin entries in the pipe-delimited users.txt, then add isValidUsername() validation and deploy the fix via Socket.IO file save API.
Powergrid Challenge - NecroNet / HackTheBox
Description
"Blackouts spread under the NecroNet's command, and in the flicker of dying transformers, the white-hats race to reboot the power grid before darkness becomes permanent."
Target: 94.237.52.235:46442
Analysis
Reconnaissance
HTTP service running nginx 1.28.0 with "HTB Editor" - a React/Vite code editor application. The application uses Socket.IO WebSocket connection for real-time file editing.
Discovered API endpoints:
GET /api/directory- list filesGET /api/file?path=X- read file contentPOST /api/restart- restart serviceGET /api/verify- verify if vulnerability is patched- Socket.IO
messageevent withtype: savefor saving files
Application Structure
utils/db.js- User database module using flat text fileusers.txt- Stores users in format:username|password_hash|roleexploit/solver.py- Contained a working exploit demonstrating the vulnerability
Vulnerable Code
The addUser() function in utils/db.js lacks input validation:
javascript read-only • copy allowedexport function addUser(username, password, role = 'operator') { const users = readUsers(); // Check if username already exists if (users.find(user => user.username === username)) { return false; } // Add new user - NO VALIDATION OF USERNAME! const newUser = { username, password: hashPassword(password), role }; users.push(newUser); return writeUsers(users); }
Vulnerability
CRLF Injection / Privilege Escalation
The vulnerability allows an attacker to inject newline and pipe characters in the username field during registration:
Attack payload: attacker|hash|admin\nattacker
This creates two entries in users.txt:
attacker|hash|admin(admin role!)attacker|hash|operator(normal role)
When logging in as "attacker", the first matching entry grants admin access due to how findUserByUsername() works (returns first match).
Solution
Step 1: Create the Patch
Add input validation function and integrate it into addUser():
javascript read-only • copy allowed// Validate username - prevent injection attacks export function isValidUsername(username) { if (!username || typeof username !== 'string') { return false; } // Check for injection characters: pipe, newline, carriage return if (username.includes('|') || username.includes('\n') || username.includes('\r')) { return false; } return true; } // Add new user export function addUser(username, password, role = 'operator') { // Validate username to prevent injection if (!isValidUsername(username)) { return false; } // ... rest of function }
Step 2: Deploy the Fix via Socket.IO
python read-only • copy allowed#!/usr/bin/env python3 import socketio import time import hashlib sio = socketio.Client() @sio.event def connect(): print("[+] Connected!") @sio.event def disconnect(): print("[-] Disconnected") @sio.event def message(data): print(f"[*] Message received: {data}") # Fixed db.js content with validation fixed_db_js = """import fs from 'fs'; import crypto from 'crypto'; import path from 'path'; import { fileURLToPath } from 'url'; import { dirname } from 'path'; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); const USERS_FILE = path.join(__dirname, '..', 'users.txt'); // Hash password using SHA256 export function hashPassword(password) { return crypto.createHash('sha256').update(password).digest('hex'); } // Validate username - prevent injection attacks export function isValidUsername(username) { // Username must not contain pipe, newline, or carriage return if (!username || typeof username !== 'string') { return false; } // Check for injection characters if (username.includes('|') || username.includes('\\n') || username.includes('\\r')) { return false; } return true; } // Read users from text file export function readUsers() { try { if (!fs.existsSync(USERS_FILE)) { return []; } const data = fs.readFileSync(USERS_FILE, 'utf8'); const lines = data.trim().split('\\n').filter(line => line.trim()); const users = []; for (const line of lines) { try { const parts = line.split('|'); if (parts.length !== 3) continue; const [username, password, role] = parts; users.push({ username: username.trim(), password: password.trim(), role: role.trim() }); } catch (e) { continue; } } return users; } catch (error) { console.error('Error reading users file:', error); return []; } } // Write users to text file export function writeUsers(users) { try { const data = users.map(user => `${user.username}|${user.password}|${user.role}` ).join('\\n'); fs.writeFileSync(USERS_FILE, data + '\\n', 'utf8'); return true; } catch (error) { console.error('Error writing users file:', error); return false; } } // Find user by username export function findUserByUsername(username) { const users = readUsers(); return users.find(user => user.username === username); } // Authenticate user export function authenticateUser(username, password) { const user = findUserByUsername(username); if (!user) { return null; } const hashedPassword = hashPassword(password); if (user.password === hashedPassword) { return { username: user.username, role: user.role }; } return null; } // Add new user export function addUser(username, password, role = 'operator') { // Validate username to prevent injection if (!isValidUsername(username)) { return false; } const users = readUsers(); // Check if username already exists if (users.find(user => user.username === username)) { return false; } // Add new user const newUser = { username, password: hashPassword(password), role }; users.push(newUser); return writeUsers(users); }""" # Use server's current file MD5 (obtained from /api/file?path=utils/db.js) server_md5 = "70ea10aece4f55989df7e888ec112521" # Connect and deploy sio.connect('http://94.237.52.235:46442', transports=['websocket']) time.sleep(1) sio.emit('message', { 'type': 'save', 'data': { 'fileName': 'utils/db.js', 'content': fixed_db_js, 'md5': server_md5 } }) time.sleep(3) sio.disconnect()
Step 3: Restart and Verify
bash read-only • copy allowed# Restart the service to apply changes curl -X POST http://94.237.52.235:46442/api/restart # Verify the fix and get the flag curl http://94.237.52.235:46442/api/verify # Returns: {"flag": "HTB{REDACTED}"}
Lessons Learned
- Never trust user input - always validate before storing
- Flat-file databases are dangerous - delimiter-based formats are inherently vulnerable to injection
- Sanitize special characters -
|,\n,\rmust be blocked in structured text storage - The flag hints at the vulnerability - "legacy db with crlf to privilege escalation"
- WebSocket APIs can be exploited - Socket.IO provides powerful file manipulation capabilities
References
- CRLF Injection: https://owasp.org/www-community/vulnerabilities/CRLF_Injection
- Input Validation: https://cheatsheetseries.owasp.org/cheatsheets/Input_Validation_Cheat_Sheet.html