Приветствую, Хабр! В новой статье я продолжаю рассказывать о слабых местах режима шифрования 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 :)

Комментарии (0)