Cryptohack. Решение Flipping Cookie, Lazy CBC

от автора

Приветствую, Хабр! В новой статье я продолжаю рассказывать о слабых местах режима шифрования CBC и разбираю ещё парочку задач на CBC с Cryptohack. Конкретно сегодня поговорим о том почему использование ключа в качестве инициализирующего вектора может быть плохой идеей и ещё раз посмотрим на трюк из предыдущей статьи, где мы манипулировали шифротекстом чтобы изменить расшифрованный текст. Дабы сэкономить время, в данной статье я не буду возвращаться к описанию работы режима CBC и заново объяснять то, что, как я считаю, я достаточно подробно разобрал в предыдущей статье. Если в какой-то момент чтения вы обнаружите, что не понимаете о чём идёт речь, я советую обратиться к моим более старым публикациям. Если и после прочтения предыдущих публикаций ничего не понятно — пишите в комментарии, будем разбираться 🙂

Bit Flipping Attack

Итак, сначала мы вернёмся к теме предыдущей статьи. Я напомню, что там мы атаковали систему используя тот факт, что заменяя байты в зашифрованном тексте в блоке N — 1 мы можем предсказуемо повлиять на расшифровку блока N. Я это изображал на вот такой схеме:

Там я показывал как можно «угадывать» байты открытого текста, если CBC работает в связке c PKCS7. Но фундаментально уязвимость в том, что манипулируя зашифрованными данными мы можем предсказуемо управлять байтами открытого текста. Атаки, которые используют эту уязвимость часто обобщённо называют Bit (или Byte) Flipping Attack. Именно её мы по сути и проводили.

Сейчас рассмотрим немного другую ситуацию — теперь мы знаем конкретные значения расшифрованного текста (или по крайней мере его части) и хотим его изменить. Принципы от этого не меняются. Пусть у нас есть блок шифротекста C, который расшифровывается в текст P. Но мы хотим получить значение P’. Ну тогда давайте посчитаем значение \Delta

\Delta = P \oplus P'

Тогда чтобы C расшифровывался в P’ достаточно применить к предыдущему блоку такую же разность. Для простоты записи пусть C — единственный блок текста, перед ним есть только IV. Значит нужно заменить IV на IV’

IV' = IV \oplus \Delta = IV \oplus P \oplus P'

Собственно по теории на этом всё, давайте к практике.

Задача

Разберём задачу с 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="].

А чтобы провести замену следуем вышеприведенным формулам.

  1. Переводим admin=False и admin=True; в хекс (для этого на странице с заданием есть инструмент). Получаем 61646d696e3d46616c7365 и 61646d696e3d547275653b соответственно.

  2. Дальше ксорим их между собой (для этого тоже есть инструмент). Получаем значение 000000000000121319165e, и добавляем в конец ещё 5 нулевых байт, чтобы длина была равна 16, это наша \Delta

  3. Используем get_cookie() для получения зашифрованного текста.

  4. Отрезаем первые 16 байт полученного значения, это IV. Ксорим IV и \Delta, это IV’.

  5. В check_admin() передаём зашифрованный текст (то, что осталось когда отрезали IV) и IV’

  6. Флаг получен!


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"}

Функции у него такие:

  1. encrypt() — зашифровывает произвольный текст с помощью AES в режиме CBC

  2. receive() — принимает зашифрованный текст, расшифровывает его и проверяет что его можно декодировать в юникод. Если нельзя, то возвращает ошибку, которая включает расшифрованный текст. А если декодирование проходит успешно, то возвращает сообщение об успехе.

  3. get_flag() — принимает ключ, и если этот ключ равен ключу шифрования сервера, то возвращает флаг.

В общем, чтобы получить флаг надо найти ключ шифрования. А как получить ключ шифрования сейчас посмотрим что можно сделать. Самая важная деталь в задаче — при шифровании ключ используется в качестве инициализирующего вектора и этим будем пользоваться. Алгоритм атаки такой:

  1. зашифровываем 3 блока текста такого, который нельзя декодировать в юникод. Это нужно, чтобы функция receive() точно вернула нам расшифрованный текст.

  2. второй блок зашифровнного текста заменяем на нули, а третий блок меняем на первый. Вместо C_1,C_2,C_3 получаем C_1,0,C_1

  3. отдаём это в receive() и получаем расшифрованный текст.

  4. ксорим первый и третий блоки расшифрованного текста, чтобы получить ключ

Чтобы понять как это работает посмотрите на схему. Первый блок после расшифровки AES суммируется с ключом, то есть P_1 = D(C_1) \oplus K, на схеме D(C_1) я обозначаю как T. Так как третий блок мы заменили на первый, он тоже после расшифровки AES будет равен T. Однако ксорится он будет уже не с ключом, а со вторым блоком. Но второй блок мы заменили на нули, значит при расшифровке третьего блока мы получим P_1' = D(C_1) \oplus 0 = D(C_1). Ну и дальше вычисление ключа должно быть очевидным P_1 \oplus P_1' = D(C_1) \oplus K \oplus D(C_1) = K

Заключение

Вот и всё, что я хотел рассказать сегодня. Задачи оказались настолько просты, что нам не пришлось даже писать код для решения. Оставляйте свои комментарии, вопросы, если что-то осталось непонятным, и stay tuned for more 🙂


ссылка на оригинал статьи https://habr.com/ru/articles/868736/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *