Приветствую, Хабр! В новой статье я продолжаю рассказывать о слабых местах режима шифрования CBC и разбираю ещё парочку задач на CBC с Cryptohack. Конкретно сегодня поговорим о том почему использование ключа в качестве инициализирующего вектора может быть плохой идеей и ещё раз посмотрим на трюк из предыдущей статьи, где мы манипулировали шифротекстом чтобы изменить расшифрованный текст. Дабы сэкономить время, в данной статье я не буду возвращаться к описанию работы режима CBC и заново объяснять то, что, как я считаю, я достаточно подробно разобрал в предыдущей статье. Если в какой-то момент чтения вы обнаружите, что не понимаете о чём идёт речь, я советую обратиться к моим более старым публикациям. Если и после прочтения предыдущих публикаций ничего не понятно — пишите в комментарии, будем разбираться 🙂
Bit Flipping Attack
Итак, сначала мы вернёмся к теме предыдущей статьи. Я напомню, что там мы атаковали систему используя тот факт, что заменяя байты в зашифрованном тексте в блоке N — 1 мы можем предсказуемо повлиять на расшифровку блока N. Я это изображал на вот такой схеме:
Там я показывал как можно «угадывать» байты открытого текста, если CBC работает в связке c PKCS7. Но фундаментально уязвимость в том, что манипулируя зашифрованными данными мы можем предсказуемо управлять байтами открытого текста. Атаки, которые используют эту уязвимость часто обобщённо называют Bit (или Byte) Flipping Attack. Именно её мы по сути и проводили.
Сейчас рассмотрим немного другую ситуацию — теперь мы знаем конкретные значения расшифрованного текста (или по крайней мере его части) и хотим его изменить. Принципы от этого не меняются. Пусть у нас есть блок шифротекста C, который расшифровывается в текст P. Но мы хотим получить значение P’. Ну тогда давайте посчитаем значение
Тогда чтобы C расшифровывался в P’ достаточно применить к предыдущему блоку такую же разность. Для простоты записи пусть C — единственный блок текста, перед ним есть только IV. Значит нужно заменить IV на IV’
Собственно по теории на этом всё, давайте к практике.
Задача
Разберём задачу с Cryptohack под названием Flipping Cookie (название как бы намекает). Условие следующее: мы можем взаимодействовать с сервером, у которого вот такой исходный код:
from Crypto.Cipher import AES import os from Crypto.Util.Padding import pad, unpad from datetime import datetime, timedelta KEY = ? FLAG = ? @chal.route('/flipping_cookie/check_admin/<cookie>/<iv>/') def check_admin(cookie, iv): cookie = bytes.fromhex(cookie) iv = bytes.fromhex(iv) try: cipher = AES.new(KEY, AES.MODE_CBC, iv) decrypted = cipher.decrypt(cookie) unpadded = unpad(decrypted, 16) except ValueError as e: return {"error": str(e)} if b"admin=True" in unpadded.split(b";"): return {"flag": FLAG} else: return {"error": "Only admin can read the flag"} @chal.route('/flipping_cookie/get_cookie/') def get_cookie(): expires_at = (datetime.today() + timedelta(days=1)).strftime("%s") cookie = f"admin=False;expiry={expires_at}".encode() iv = os.urandom(16) padded = pad(cookie, 16) cipher = AES.new(KEY, AES.MODE_CBC, iv) encrypted = cipher.encrypt(padded) ciphertext = iv.hex() + encrypted.hex() return {"cookie": ciphertext}
Из кода видим, что сервер умеет выполнять два действия:
1) get_cookie()
— эта функция зашифровывает сообщение cookie
и возвращает нам.
2) check_admin(cookie, iv)
— расшифровывает переданное сообщение cookie
и проверяет есть ли в расшифрованном сообщение значение admin=True
и в таком случае возвращает нам флаг, а в противном случае отдаёт ошибку.
Ну то есть очевидно, что в зашифрованном сообщении у нас записано False
, а мы хотим чтобы было True
. Заметьте, что в слове False
больше букв, чем в True
, поэтому мы будем заменять False
на True;
. Тогда начало расшифрованного текста изменится вот так:
admin=False;expiry=
-> admin=True;;expiry=
Лишняя точка с запятой не будет помехой, т.к. после расшифровке делается сплит по ней и изменится только количество элементов в массиве, то есть unpadded.split(b";")
отдаст не ["admin=False", "expiry="]
, а ["admin=True", "", "expiry="]
.
А чтобы провести замену следуем вышеприведенным формулам.
-
Переводим
admin=False
иadmin=True;
в хекс (для этого на странице с заданием есть инструмент). Получаем61646d696e3d46616c7365
и61646d696e3d547275653b
соответственно. -
Дальше ксорим их между собой (для этого тоже есть инструмент). Получаем значение
000000000000121319165e
, и добавляем в конец ещё 5 нулевых байт, чтобы длина была равна 16, это наша -
Используем
get_cookie()
для получения зашифрованного текста. -
Отрезаем первые 16 байт полученного значения, это IV. Ксорим IV и , это IV’.
-
В
check_admin()
передаём зашифрованный текст (то, что осталось когда отрезали IV) и IV’ -
Флаг получен!
Lazy CBC
А сейчас мы посмотрим что будет если полениться и использовать вместо инициализирующего вектора ключ шифрования. Ну действительно, удобно же — не передавать IV каждый раз, а просто использовать ключ. Кажется, что ничего плохого произойти не может. Ключ всё ещё никто не знает, а мы сэкономили себе 16 байт трафика и усилия по генерации вектора каждый раз.
Однако, как минимум, инициализирующий вектор нужно менять при каждой новой сессии, т.к. их повторение — это нехорошо. Но сегодня я на этом останавливаться не буду. Гораздо большая проблема возникает, когда в системе есть возможность шифровать и расшифровывать сообщения. Да, задача, которую я сейчас разберу будет выглядеть как сферический конь в вакууме (таких систем якобы не существует). Но я напоминаю, что задача — это простая модель. В сложных системах бывают чудеса и не такого уровня.
Задача
Как обычно, есть код сервера:
from Crypto.Cipher import AES KEY = ? FLAG = ? @chal.route('/lazy_cbc/encrypt/<plaintext>/') def encrypt(plaintext): plaintext = bytes.fromhex(plaintext) if len(plaintext) % 16 != 0: return {"error": "Data length must be multiple of 16"} cipher = AES.new(KEY, AES.MODE_CBC, KEY) encrypted = cipher.encrypt(plaintext) return {"ciphertext": encrypted.hex()} @chal.route('/lazy_cbc/get_flag/<key>/') def get_flag(key): key = bytes.fromhex(key) if key == KEY: return {"plaintext": FLAG.encode().hex()} else: return {"error": "invalid key"} @chal.route('/lazy_cbc/receive/<ciphertext>/') def receive(ciphertext): ciphertext = bytes.fromhex(ciphertext) if len(ciphertext) % 16 != 0: return {"error": "Data length must be multiple of 16"} cipher = AES.new(KEY, AES.MODE_CBC, KEY) decrypted = cipher.decrypt(ciphertext) try: decrypted.decode() # ensure plaintext is valid ascii except UnicodeDecodeError: return {"error": "Invalid plaintext: " + decrypted.hex()} return {"success": "Your message has been received"}
Функции у него такие:
-
encrypt()
— зашифровывает произвольный текст с помощью AES в режиме CBC -
receive()
— принимает зашифрованный текст, расшифровывает его и проверяет что его можно декодировать в юникод. Если нельзя, то возвращает ошибку, которая включает расшифрованный текст. А если декодирование проходит успешно, то возвращает сообщение об успехе. -
get_flag()
— принимает ключ, и если этот ключ равен ключу шифрования сервера, то возвращает флаг.
В общем, чтобы получить флаг надо найти ключ шифрования. А как получить ключ шифрования сейчас посмотрим что можно сделать. Самая важная деталь в задаче — при шифровании ключ используется в качестве инициализирующего вектора и этим будем пользоваться. Алгоритм атаки такой:
-
зашифровываем 3 блока текста такого, который нельзя декодировать в юникод. Это нужно, чтобы функция
receive()
точно вернула нам расшифрованный текст. -
второй блок зашифровнного текста заменяем на нули, а третий блок меняем на первый. Вместо получаем
-
отдаём это в
receive()
и получаем расшифрованный текст. -
ксорим первый и третий блоки расшифрованного текста, чтобы получить ключ
Чтобы понять как это работает посмотрите на схему. Первый блок после расшифровки AES суммируется с ключом, то есть , на схеме я обозначаю как T. Так как третий блок мы заменили на первый, он тоже после расшифровки AES будет равен T. Однако ксорится он будет уже не с ключом, а со вторым блоком. Но второй блок мы заменили на нули, значит при расшифровке третьего блока мы получим . Ну и дальше вычисление ключа должно быть очевидным
Заключение
Вот и всё, что я хотел рассказать сегодня. Задачи оказались настолько просты, что нам не пришлось даже писать код для решения. Оставляйте свои комментарии, вопросы, если что-то осталось непонятным, и stay tuned for more 🙂
ссылка на оригинал статьи https://habr.com/ru/articles/868736/
Добавить комментарий