Приветствую, Хабр! Я к вам возвращаюсь с новой статьёй о режимах шифрования и решении задачи с Cryptohack. Сегодня в центре внимания будет режим CFB-8 и уязвимость CVE-2020–1472.
СFB
Режим CFB устроен так:

Текст на самом зашифровывается с помощьюу операции XOR. А гамма для шифрования генерируется блочным шифром. Для получения первого блока гаммы зашифровывается инициализирующий вектор (IV), а для последующих блоков используется зашифрованный текст из предыдущих блоков. По сути CFB, как и CTR (о CTR я рассказывал тут) можно использовать чтобы превратить блочный шифр в потоковый.
Но для CFB помимо приведённой выше схемы также описаны другие режимы работы - CFB-s, где s - числовое значение в битах от 1 до длины блока шифра. В таких режимах у каждого блока используется только s старших бит выхода из блочного шифра для шифрования текста. Например, если мы будем использовать режим CFB-1 с AES, то из 128 бит зашифрованного блока для шифрования текста будет использоваться только 1 бит, остальные 127 - отбрасываются. Для формирования второго блока гаммы будет использоваться уже не только зашифрованный текст (т.к. там только 1 бит), но и предыдущий IV, таким образом для формирования второго блока гаммы мы будем использовать 127 бит инициализирующего вектора + 1 бит зашифрованного текста. В задаче мы будем иметь дело с режимом CFB-8. Его схема такая выглядит так

А полная схема режима CFB выглядит вот так

В математических терминах это можно записать следующим образом
Здесь - входное значение для шифрования с помощью блочного шифра,
- размер блока,
- параметр размера из CFB-s,
- гамма для шифрования открытого текста (выход после шифрования
),
- младшие x бит от значения,
- старшие x бит значения.
Задача
Как это обычно и бывает, в задаче нам предоставлен код сервера
#!/usr/bin/env python3
from Crypto.Cipher import AES
from Crypto.Util.number import bytes_to_long
from os import urandom
from utils import listener
FLAG = "crypto{???????????????????????????????}"
class CFB8:
def __init__(self, key):
self.key = key
def encrypt(self, plaintext):
IV = urandom(16)
cipher = AES.new(self.key, AES.MODE_ECB)
ct = b''
state = IV
for i in range(len(plaintext)):
b = cipher.encrypt(state)[0]
c = b ^ plaintext[i]
ct += bytes([c])
state = state[1:] + bytes([c])
return IV + ct
def decrypt(self, ciphertext):
IV = ciphertext[:16]
ct = ciphertext[16:]
cipher = AES.new(self.key, AES.MODE_ECB)
pt = b''
state = IV
for i in range(len(ct)):
b = cipher.encrypt(state)[0]
c = b ^ ct[i]
pt += bytes([c])
state = state[1:] + bytes([ct[i]])
return pt
class Challenge():
def __init__(self):
self.before_input = "Please authenticate to this Domain Controller to proceed\n"
self.password = urandom(20)
self.password_length = len(self.password)
self.cipher = CFB8(urandom(16))
def challenge(self, your_input):
if your_input['option'] == 'authenticate':
if 'password' not in your_input:
return {'msg': 'No password provided.'}
your_password = your_input['password']
if your_password.encode() == self.password:
self.exit = True
return {'msg': 'Welcome admin, flag: ' + FLAG}
else:
return {'msg': 'Wrong password.'}
if your_input['option'] == 'reset_connection':
self.cipher = CFB8(urandom(16))
return {'msg': 'Connection has been reset.'}
if your_input['option'] == 'reset_password':
if 'token' not in your_input:
return {'msg': 'No token provided.'}
token_ct = bytes.fromhex(your_input['token'])
if len(token_ct) < 28:
return {'msg': 'New password should be at least 8-characters long.'}
token = self.cipher.decrypt(token_ct)
new_password = token[:-4]
self.password_length = bytes_to_long(token[-4:])
self.password = new_password[:self.password_length]
return {'msg': 'Password has been correctly reset.'}
import builtins; builtins.Challenge = Challenge # hack to enable challenge to be run locally, see https://cryptohack.org/faq/#listener
listener.start_server(port=13399)
Сперва в коде определяется класс CFB8
, который является просто реализацией шифрования с помощью режима CFB8
без каких-либо интересных деталей.
Дальше описывается сам сервер, у которого есть несколько опций:
authenticate
- команда, которая принимает пароль, и если пароль соответствует ранее установленному в программе паролю, сервер отдаёт флаг. Пароль изначально генерируется случайным образом при запуске программы (подключении к серверу).reset_connection
- создаёт новый инстанс шифраCFB8
с новым случайным ключом.reset_password
- получает токен, который должен быть длиной хотя бы в 28 байт, расшифровывает токен с помощью инстансаCFB8
, использует 4 последних байта расшифрованного токена в качестве длины пароля, и срезает с начала токена соответствующее количество байт в качестве нового пароля.
Для того, чтобы получить флаг, нам нужно отправить на сервер правильный пароль. Пароль мы не знаем, но можем его сбросить. При этом, даже когда мы сбрасываем пароль, он всё ещё остаётся случайным и нам неизвестным. Или нет?
Обратите внимание, что мы можем отправить произвольный токен для сброса пароля. Этот токен расшифровывается, и кажется что после этого должно получится достаточно случайное значение, которое нам не угадать. На самом деле некоторые токены будут выдавать вполне не случайный результат. Например, если отправить токен, состоящий из одинаковых значений, то и пароль будет состоять из одинаковых значений.
Действительно, каждый символ нового пароля вычисляется этими строчками:
b = cipher.encrypt(state)[0]
c = b ^ ct[i]
pt += bytes([c])
state = state[1:] + bytes([ct[i]])
b = cipher.encrypt(state)[0]
- вычисляет байт гаммы, с которым нужно просуммировать байт шифротекста для получения нового байта пароля. Результат этого выражения зависит от ключа шифрования и значения state
в этой итерации. Ключ очевидно не меняется.
Но если токен, который мы отправили в reset_password
состоит из одинаковых значений, то строка state = state[1:] + bytes([ct[i]])
также не изменит state
потому что все символы state
и ct
равны и сколько их не переставляй общее значение будет одинаковым.
Несмотря на то, что все символы при расшифровке будут одинаковые, мы всё ещё не знаем конкретного значения этих символов. Это очень легко обходится. Вероятность того, что токен расшифруется в какое-то значение составляет
. Если мы будем последовательно отправлять токен состоящий из всех нулей, потом всех единиц, всех двоек и т.д., рано или поздно (но не позднее чем после 256 попыток) наш токен расшифруется во все нули. Когда это произойдёт, пароль фактически сбросится до пустой строки, потому что длина пароля также определяется из расшифрованного токена и она станет равной 0.
Выходит, что нам просто нужно сбрасывать пароль с помощью различных токенов, состоящих из одинаковых символов и отправлять пустой пароль на проверку пока пароль всё таки не сбросится и мы не получим флаг.
Код
import json
from pwn import *
token = b"\x00" * 28
address = ("socket.cryptohack.org", 13399)
conn = remote(address[0], address[1])
print(conn.recvline().decode())
authCommand = json.dumps({"option": "authenticate", "password": ""}).encode()
resetCommand = json.dumps({"option": "reset_connection"}).encode()
for i in range(256):
token = bytes([i]) * 28
resetPasswordCommand = json.dumps({"option": "reset_password", "token": token.hex()}).encode()
conn.sendline(resetPasswordCommand)
conn.recvline().decode()
conn.sendline(authCommand)
ans = conn.recvline().decode()
if "crypto" in ans:
print(ans)
conn.close()
exit(0)