← Back to Writeups
CUSTOMMediumWeb

Powergrid Challenge

XESXOR9/2/20265 min read
#web#custom#medium#code_patching#crlf_injection#privilege_escalation#websocket_exploitation

Powergrid Challenge

Platform: CUSTOM | Category: Web | Type: Challenge | Difficulty: Medium | OS: N/A | **Author: XESXOR Techniques: code_patching, crlf_injection, privilege_escalation, websocket_exploitation

Summary

Walkthrough of the CUSTOM Web Powergrid Challenge. 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 pi

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 files
  • GET /api/file?path=X - read file content
  • POST /api/restart - restart service
  • GET /api/verify - verify if vulnerability is patched
  • Socket.IO message event with type: save for saving files ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Application Structure

  • utils/db.js - User database module using flat text file
  • users.txt - Stores users in format: username|password_hash|role
  • exploit/solver.py - Contained a working exploit demonstrating the vulnerability

Vulnerable Code

The addUser() function in utils/db.js lacks input validation:

javascript
export 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:

  1. attacker|hash|admin (admin role!)
  2. 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
// 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
#!/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
# 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

  1. Never trust user input - always validate before storing
  2. Flat-file databases are dangerous - delimiter-based formats are inherently vulnerable to injection
  3. Sanitize special characters - |, \n, \r must be blocked in structured text storage
  4. The flag hints at the vulnerability - "legacy db with crlf to privilege escalation"
  5. WebSocket APIs can be exploited - Socket.IO provides powerful file manipulation capabilities ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

References

Key Takeaways / Lessons

  • Mastering code_patching, crlf_injection, privilege_escalation, websocket_exploitation was key to this challenge.
  • Always check for common misconfigurations and verify assumptions.

Crafted by XESXOR Research • 2026-02-01 • CUSTOM • Powergrid Challenge • Difficulty: Medium