Заметка вторая. О сервисе на PHDays CTF
Продолжение истории о необычном поведении юникода и CSV в питоне
Для тех, кому лень читать предыдущую заметку или вспоминать, о чём там было
Если использовать стандартные питоновские codecs.open, csv.DictWriter и csv.DictReader, то можно столкнуться с интересным поведением. Создаём в программе файл в кодировке UTF-8, пишем туда с помощью DictWriter, а затем читаем через DictReader. Если в данных встречались юникодные символы Pararaph separator или Line separator, то мы считаем больше записей, чем было записано, а их CSV-структура будет поломана.
Читайте первую заметку для подробностей.
Как из этого получился сервис для классического CTF
В апреле мы с ребятами из Хакердома как раз готовили онлайновый CTF для форума PHDays. Темой был выбран интернет вещей, а сервисами были умный чайник, термометр, дверной замок, телевизор и даже холодильник. Мне достался последний, потому что я слишком люблю еду.
Чтобы органично встроить уязвимость в сервис, мне нужно было сделать так, чтобы кто-то писал в CSV-файл, а кто-то другой — читал. В итоге сервис состоял из двух частей — веб-приложения для удобного управления человеком и API для других умных кухонных гаджетов, которым нужна информация от холодильника.
Веб-интерфейс позволял пользователям регистрироваться и создавать холодильные камеры или просто холодильники. У каждого холодильника был владелец, и увидеть чужие просто так нельзя. В холодильники можно добавлять еду. Для этого сначала было необходимо зарегистрировать новый продукт (например, картошку или молоко) и указать, в чём он измеряется — в граммах, пачках или литрах. Выглядело это так:
Кроме холодильников можно было создавать рецепты. Про каждый ингредиент рецепта известно, сколько и какого продукты нужно положить, а также что с ним надо сделать и сколько после этого подождать.
Рецепты были очень важны для функционирования сервиса, ведь в том самом API было всего две команды — получить список твоих холодильников и получить список рецептов, которые можно приготовить из еды, лежащей в одном из твоих холодильников. Звучит сложно, но идея очень простая — если у вас есть умная мультиварка, то она хочет узнать, какие блюда можно сегодня приготовить из продуктов, лежащих у вас в холодильнике.
Принадлежность выдаваемых рецептов владельцу холодильника в API не проверялась, но должно было работать само: в рецепте должен присутствовать хотя бы один продукт из холодильника, а в холодильник мы можем добавлять только принадлежащие нам продукты. Моё молоко и молоко Васи — два разных продукта, я могу добавить в холодильники и рецепты только первое, а про второе даже не смогу узнать.
Уязвимость
Итак, где же тут наши подозреваемые — юникод и CSV?
Сервис API не имеет доступа к базе данных, поэтому информация для него складывалась веб-приложением в специальные CSV-файлы, откуда считывались API-приложением раз в секунду. Вот пример такого файла:
id,recipe_id,food_type_id,what_to_do,count,pause_after
1,10,15,Yhws aqx vpjhtlyhw ruv tbcyis,11,7
2,11,16,Ok jbso gtpzs ndgoz udeksmvk,10,16
3,12,17,Y ap mculltvedfwwabbnnnco um,6,19
4,13,18,cniyvdjsyuctaamupp zm qj nwvm,10,9
5,14,19,Kfdvm ref wtb pdtitb,14,15
В нём хранятся ингредиенты рецептов. Столбцы, соответственно — id ингредиента, id рецепта (сами рецепты лежат в другом файле), id продукта, что нужно сделать, сколько взять продукта и какую выдержать после этого паузу.
Давайте посмотрим, что будет, если в поле what_to_do добавить тот самый Paragraph separator:
6,15,20,blablabla
13503,14,20
При чтении этот файл будет выглядеть для DictReader’а как
6,15,20,blablabla
13503,14,20
То есть так, будто мы добавили продукт №20 в рецепт №14. Продукт №20 принадлежит нам, значит, мы смогли добавить нашу еду в чужой рецепт №14! Теперь API сможет вывести этот рецепт, если в каком-нибудь из наших холодильников будет лежать продукт №20.
Для полноты картины покажу, как выглядели сохранение и загрузка данных в CSV.
Сохранение:
def dump_model_to_file(model, filename):
# Use Django API to get all model fields
columns = [field.attname for field in model._meta.fields]
with codecs.open(filename, 'w', encoding='utf-8') as opened_file:
# Open csv writer and dump header: special row with columns names
writer = csv.DictWriter(opened_file, fieldnames=columns)
writer.writeheader()
# Iterate over all objects of the model
for obj in model.objects.all():
object_dict = {}
for column in columns:
# Don't worry about newlines (\n and \r): csv.DictWriter will enclose such strings in quotes (")
# So I think there is no vulnerability here
object_dict[column] = str(getattr(obj, column, ''))
# Dump dictionary for current object
writer.writerow(object_dict)
Загрузка:
class Model:
def __init__(self, dictionary):
for key, value in dictionary.items():
if value is None:
value = '0'
if value.isnumeric():
value = int(value)
setattr(self, key, value)
def load_models_from_file(filename):
objects = {}
with codecs.open(filename, 'r', encoding='utf-8') as opened_file:
reader = csv.DictReader(opened_file)
for row in reader:
objects[model.id] = Model(row)
return objects
Ещё две уязвимости
В Холодильнике мной была заложена ещё одна уязвимость. Но так получилось, что в итоге уязвимостей оказалось не две, а три. Так бывает на CTF, и в этом нет ничего страшного. Иногда бывает обидно, что ты заложил сложную уязвимость, а случайно оставил простую. Но в данном случае всё случилось удачно: незапланированная уязвимость была проще первой, а запланированная — совсем элементарной (правда, позволяла украсть только 20% флагов). Так как в целом соревнование получилось очень сложным, то появление одной незапланированной уязвимости средней сложности сыграло нам на руку.
Итак, сначала о запланированной уязвимости. Каждый сервис находился в своём докер-контейнере. Докер (docker) — это удобный способ изолировать своё приложение от других, в линуксе работает за счёт его фирменных LXC-контейнеров и ограничений в cgroup. Подробнее о докере можно почитать на официальном сайте.
Мой сервис состоял из четырёх докер-контейнеров: для веб-приложения, для API-приложения, для базы данных и для веб-сервера nginx. Во время старта первого накатывались миграции, собиралась статика и выполнялись другие служебные команды. В том числе такая:
# DON'T RUN IT IN PRODUCTION. SOME EVIL GUYS CAN BRUTEFORCE PASSWORD AND WHO KNOW WHAT HAPPENS...
echo "[+] [DEBUG] Django setup, executing: add superuser"
PGPASSWORD=${POSTGRES_PASSWORD} psql -U ${POSTGRES_USER} -h ${POSTGRES_HOST} -c "INSERT INTO auth_user (password, last_login, is_superuser, username, first_name, last_name, email, is_staff, is_active, date_joined) VALUES ('pbkdf2_sha256\$36000\$k36V24q60mNo\$v5og9qcgc2sqkVwGjZDKNK+wcJy60ix8DIt9E8Yg48c=', '1970-01-01 00:00:00.000000', true, 'admin', 'admin', 'admin', 'admin@admin', true, true, '1970-01-01 00:00:00.000000') ON CONFLICT (username) DO NOTHING"
Эта команда добавляет напрямую в базу данных супер-пользователя с логином admin. Его пароль мы не знаем, так что сразу зайти под ним не можем, но доступен хеш от пароля: ‘pbkdf2_sha256$36000$k36V24q60mNo$v5og9qcgc2sqkVwGjZDKNK+wcJy60ix8DIt9E8Yg48c=’. Подбор пароля не занимает много времени, так как он словарный и встречается во всех списках самых частых паролей.
Попрактикуйтесь — сможете ли вы подобрать пароль? pbkdf2_sha256 — это тип хеша и подписи, а 36000 — количество итераций. Справиться с задачей поможет hashcat, john the ripper или любой другой подборщик прообразов хешей.
Незапланированная уязвимость
Последняя уязвимость нашлась в функции добавлении продукта в холодильник. Здесь не проверялось, что вы являетесь владельцем добавляемого продукта. Можно было создать супер-холодильник, содержащий все продукты с номерами от 1 до 1000, а затем попросить API выдать рецепты, содержащие хотя бы какой-нибудь продукт из этого холодильника. Конечно, он находил и чужие рецепты, а вместе с ними выдавал и флаги, хранящиеся в описаниях этих рецептов.
Выводы
Никогда не используйте csv.DictReader/csv.DictWriter вместе с файлами, открытыми модулем codecs. В новых питонах открывайте CSV-файлы с помощью стандартной функции open, передавая ей аргументы encoding и newline.
Бонус для дочитавших до конца
Все исходники сервиса, докер-файлы, чекер для проверяющей системы и мой авторский эксплойт для первой уязвимости можно найти в репозитории разработки, который мы открыли сразу после окончания соревнования.