Приветствую, Хабр! В новой статье я продолжаю рассказывать о слабых местах режима шифрования 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 в режиме CBCreceive()
- принимает зашифрованный текст, расшифровывает его и проверяет что его можно декодировать в юникод. Если нельзя, то возвращает ошибку, которая включает расшифрованный текст. А если декодирование проходит успешно, то возвращает сообщение об успехе.get_flag()
- принимает ключ, и если этот ключ равен ключу шифрования сервера, то возвращает флаг.
В общем, чтобы получить флаг надо найти ключ шифрования. А как получить ключ шифрования сейчас посмотрим что можно сделать. Самая важная деталь в задаче - при шифровании ключ используется в качестве инициализирующего вектора и этим будем пользоваться. Алгоритм атаки такой:
зашифровываем 3 блока текста такого, который нельзя декодировать в юникод. Это нужно, чтобы функция
receive()
точно вернула нам расшифрованный текст.второй блок зашифровнного текста заменяем на нули, а третий блок меняем на первый. Вместо получаем
отдаём это в
receive()
и получаем расшифрованный текст.ксорим первый и третий блоки расшифрованного текста, чтобы получить ключ
Чтобы понять как это работает посмотрите на схему. Первый блок после расшифровки AES суммируется с ключом, то есть , на схеме я обозначаю как T. Так как третий блок мы заменили на первый, он тоже после расшифровки AES будет равен T. Однако ксорится он будет уже не с ключом, а со вторым блоком. Но второй блок мы заменили на нули, значит при расшифровке третьего блока мы получим . Ну и дальше вычисление ключа должно быть очевидным
Заключение
Вот и всё, что я хотел рассказать сегодня. Задачи оказались настолько просты, что нам не пришлось даже писать код для решения. Оставляйте свои комментарии, вопросы, если что-то осталось непонятным, и stay tuned for more :)