Приветствую, Хабр! В сегодняшней статье я расскажу про работу режима шифрования CBC (Cipher block chaining), паддинг PKCS7, а также атаку на их совместное использование на примере решения задачи Pad Thai с сервиса Cryptohack.
Режим шифрования CBC
Про режимы шифрования в блочных шифрах я уже говорил в своей предыдущей статье, поэтому не буду на этом останавливаться здесь. Тем, кто впервые слышит про режимы шифрования, я очень рекомендую сначала прочесть ту статью, там я рассказываю о них подробнее, а также показываю значительно более простую атаку на режим ECB (electronic codebook). Разобравшись с ней, вам будет легче понять магию происходящего здесь.
Итак, что такое CBC? CBC (Cipher block chaining) — режим шифрования, суть которого состоит в связке блоков шифротекста между собой таким образом, что каждый последующий блок текста зависит от всех предыдущих. Это позволяет избавиться от проблемы, которую мы видели у ECB — одинаковые блоки открытого текста не шифруются в одинаковые блоки шифротекста (при правильном применении режима, о чём я ещё расскажу).
Теперь давайте поподробнее про работу CBC. Схема такая:
Каждый блок открытого текста перед шифрованием суммируется (XOR) с зашифрованным предыдущим блоком. Первый блок, не имея никакого блока перед ним, суммируется с инциализирующим вектором (IV). Для IV выбирается блок случайных чисел, также IV рекомендуется выбирать разным для каждой сессии шифрования, повторяющиеся IV тоже могут быть источником проблем с безопасностью. Существует также вариант использования первого блока текста в качестве инициализирующего вектора, то есть к оригинальному открытому тексту добавляется префикс случайных значений длиной в один блок, который шифруется и используется как предыдущий блок для первого блока реального текста. Сам IV при этом не является секретным и обычно передаётся вместе с шифротекстом.
А теперь самое интересное. Расшифровка производится по следующей схеме:
Обратите внимание, на то, что на выходной текст каждого блока напрямую влияет предыдущий блок шифротекста (или IV). И изменения в блоке зашифрованного текста вполне предсказуемо влияют на выходящий текст. Это открывает массу возможностей для атак типа «человек посередине». Суть таких атак в том, что между источником и приёмником зашифрованного текста находится третья сущность, намеревающаяся совершить шалость, которая получает зашифрованное сообщение от источника, но не может его расшифровать. Зато может слегка поменять этот текст и отправить его дальше приёмнику. Вот такую шалость мы и будем сегодня осуществлять для решения задачи Pad Thai.
Паддинг PKCS#7
Но прежде чем мы перейдём к задаче необходимо поговорить о ещё одной составляющей режимов шифрования — паддинге. Говоря о режимах шифрования мы упоминаем блоки, на которые делится открытый текст в соответствии с возможностями алгоритма шифрования в центре этой всей схемы. Однако что делать, если длина текста не делится поровну на размер блока (такое в шифр не отдать)? Ну убрать мы из текста ничего не можем, а значит надо что‑то добавить! Вот это добавление и называют паддингом. А вот в зависимости от того, чем дополнять текст, паддинги различаются между собой. Самое простое — добавить просто нули — это Zero padding, но существует множество других схем.
Одна из них таких — схема PKCS#7 (или PKCS7). Она очень простая, но в то же время хитрая. Если до заполнения блока не хватает N байт, то по PKCS7 мы должны дополнить текст байтами N. Например, нам не хватает 4 байт, тогда дополнять надо повторяющимся значением 4, то есть 4 байта в конце блока все будут иметь значение 4. Если 10, то 10 байт в конце блока будут иметь значение 10.
А что делать если текст поровну делится на блоки?
Очевидный, но не правильный ответ — ничего. Если, используя схему PKCS7, мы сталкиваемся с такой ситуацией, то нам нужно добавлять целый блок текста, заполненный значениями, равными длине блока. Например, у AES длина блока 16, значит нужно добавлять 16 байт со значением 16.
Но почему так?
А потому что после расшифровки, принимающей стороне нужно как-то понять, какие значения являются паддингом и их можно выбросить, а какие — реальный текст. Если текст делится на блоки поровну, то в конце может быть байт с любым значением. Допустим там 1 — ну тогда принимающая сторона сотрёт этот 1 байт, считая его паддингом. А ведь это был реальный текст. Этого допустить нельзя, значит приходится добавлять целый блок.
Ну и приведу пару примеров для наглядности:
-
Имеем 6 байтов текста
11 22 33 44 55 66
. Пусть длина блока будет 8, значит нам не хватает 2 байтов. Тогда по PKCS7 дополненный текст будет выглядеть так:11 22 33 44 55 66 02 02
-
Теперь пусть у нас будет 8 байтов
11 22 33 44 55 66 77 88
, длина блока так же 8. В таком случае дополненный текст будет (чертой разделены блоки):11 22 33 44 55 66 77 88 | 08 08 08 08 08 08 08 08
Надеюсь, что с этим всё понятно, теперь можем переходить непосредственно к задаче.
Задача
Задача сформулирована в виде исходного кода сервера, а также адреса, по которому до этого сервера можно достучаться. Давайте изучать код.
#!/usr/bin/env python3
from Crypto.Util.Padding import unpad
from Crypto.Cipher import AES
from os import urandom
from utils import listener
FLAG = 'crypto{?????????????????????????????????????????????????????}'
class Challenge:
def __init__(self):
self.before_input = "Let's practice padding oracle attacks! Recover my message and I'll send you a flag.\n"
self.message = urandom(16).hex()
self.key = urandom(16)
def get_ct(self):
iv = urandom(16)
cipher = AES.new(self.key, AES.MODE_CBC, iv=iv)
ct = cipher.encrypt(self.message.encode("ascii"))
return {"ct": (iv+ct).hex()}
def check_padding(self, ct):
ct = bytes.fromhex(ct)
iv, ct = ct[:16], ct[16:]
cipher = AES.new(self.key, AES.MODE_CBC, iv=iv)
pt = cipher.decrypt(ct) # does not remove padding
try:
unpad(pt, 16)
except ValueError:
good = False
else:
good = True
return {"result": good}
def check_message(self, message):
if message != self.message:
self.exit = True
return {"error": "incorrect message"}
return {"flag": FLAG}
#
# This challenge function is called on your input, which must be JSON
# encoded
#
def challenge(self, msg):
if "option" not in msg or msg["option"] not in ("encrypt", "unpad", "check"):
return {"error": "Option must be one of: encrypt, unpad, check"}
if msg["option"] == "encrypt": return self.get_ct()
elif msg["option"] == "unpad": return self.check_padding(msg["ct"])
elif msg["option"] == "check": return self.check_message(msg["message"])
import builtins; builtins.Challenge = Challenge # hack to enable challenge to be run locally, see https://cryptohack.org/faq/#listener
listener.start_server(port=13421)
Исходя из кода, сервер умеет выполнять три команды, разберём их по порядку.
Команда encrypt
. Эта команда шифрует сообщение с помощьюу AES в режиме CBC и возвращает нам. Обратите внимание на то, что сообщение представляет из себя случайные 16 байт, закодированные в шестнадцатеричную запись. То есть суммарно это 32 байта и все они принадлежат множеству 0123456789abcdef
. Этот факт нам очень пригодится, когда мы будем проводить атаку на сервер.
Помимо фундаментальной уязвимости в системе, также важно обращать внимание на детали работы. Например, знание формата открытого текста, или его частей может значительно упростить криптоанализ.
Команда check_padding
. Эта команда расшифровывает переданное сообщение и пытается применить функцию unpad
к расшифрованному тексту. Если функция применяется успешно, то мы получаем ответ True
, а если возникает исключение, то False
. В этой команде находится уязвимость системы и на неё будет направлена наша атака, поэтому подробности рассмотрим позже.
Команда check_message
. Наконец эта команда принимает сообщение. Если оно равно message
, то есть тому что было зашифровано в encrypt
, то мы получаем флаг.
Уязвимость
К этому моменту, должно быть понятно, что по сути нам надо расшифровать зашифрованное сообщение. Давайте смотреть что с этой системой не так. Я уже говорил, что атаковать мы будем функцию check_padding
, поэтому рассмотрим её поподробнее.
Первые четыре строчки особого интереса для нас не представляют - это просто расшифровка сообщения:
ct = bytes.fromhex(ct)
iv, ct = ct[:16], ct[16:]
cipher = AES.new(self.key, AES.MODE_CBC, iv=iv)
pt = cipher.decrypt(ct) # does not remove padding
Важно, что зашифрованный текст предоставляем мы и мы можем подсунуть туда произвольные значения, это мы и будем делать.
Дальше идёт функция unpad
, по названию которой можно догадаться, что она удаляет паддинг из расшифрованного текста. Это тот самый паддинг, про который я говорил в разделе про PKCS7. И именно с PKCS7 она и работает по-умолчанию. Если функция успешно детектирует паддинг и удаляет его, то она вернёт текст без паддинга. А если паддинг неправильный, то функция бросит исключение.
try:
unpad(pt, 16)
except ValueError:
good = False
else:
good = True
Как паддинг может быть неправильным?
Посмотрите на PKCS7 паддинги: у них есть строгая структура. Например, если в тексте не хватает 6 байт до длины блока, то к нему должно прикрепиться 6 байт: 06 06 06 06 06 06
. Они обязаны быть именно такими, а малейшее отклонение считается неправильным паддингом. То есть, если паддинг будет 06 06 05 06 06 06
, то вот эта пятёрка испортит всё сообщение и функция unpad
бросит исключение.
В итоге функция check_padding
делает как раз то, о чём говорит - проверяет паддинг у зашифрованного сообщения и возвращает ответ о том правильный ли он там.
Внимательный читатель наверняка заметил, что в функции
get_ct
, нет кода для добавления паддинга к сообщению. И это не потому что он добавляется где-то в процессе шифрования. Паддинг там действительно не добавляется и если передать полученный шифротекст вcheck_padding
, то функция вернётFalse
.То что к сообщению не прикрепляется паддинг немного упростит нам задачу, но не имеет фундаментальной важности. Если бы паддинг был, мы всё равно смогли бы провести атаку просто считая паддинг частью сообщения.
Так как мы можем передавать в check_padding
произвольные значения, мы можем менять полученный от сервера шифротекст специальным образом, затем проверять паддинг, и в соответствии с результатом проверки делать выводы относительно оригинального сообщения.
Атака
Схема атаки во многом схожа с атакой на ECB из предыдущей статьи. Мы также будем перебирать байты в одной позиции, расшифровывая текст по одному байту начиная с конца. Разница в том, что в атаке на ECB мы определяли что подставили правильный байт, когда зашифрованные тексты совпадали, а в этой задаче индикатором правильно подобранного байта будет получение True
из функции check_padding
.
Идея в том, что если мы отправили какой-то текст в check_padding
и получили в ответ True
, то это значит, что отправленный нами текст расшифровывается в открытый текст, у которого в конце находится структура PKCS7 паддинга, а именно 01
, либо 02 02
, либо 03 03 03
, и т. д. Если получаем False
, то ничего подобного в расшифрованном тексте нет.
Если взять зашифрованное сообщение из функции encrypt
, и не изменяя, передать его в check_padding
, то мы скорее всего получим False
, потому что при шифровании паддинга нет, а вероятностью того, что случайно сгенерированное сообщение будет иметь в конце структуру PKCS7 мы можем пренебречь.
А теперь я утверждаю, что, изменяя полученный от сервера шифротекст, мы можем добиться того, что расшифрованное сообщение будет иметь в конце PKCS7 паддинг. И действительно, посмотрите на схему расшифровки. Если мы поменяем в шифротексте последний байт первого блока, то сам первый блок, конечно, расшифруется во что-то невнятное из-за лавинного эффекта. А вот второй блок изменится ровно в одном последнем байте.
Причём величина изменения нам известна. Пусть - последний байт первого блока шифротекста, - последний байт расшифрованного сообщения до суммирования с , а - результат суммирования и , то есть реальный последний байт расшифрованного сообщения.
Тогда, можно записать уравнение
Теперь изменим на величину , тогда
Суть этих махинаций в том, чтобы показать, что сейчас у нас есть уравнение с двумя неизвестными и , но перебирая значения , мы рано или поздно найдём такое, что . Как только такое произойдёт, функция check_padding
вернёт True
. И тогда мы с лёгкостью можем вычислить значение . А ведь это последний байт сообщения. Отсюда мы можем вычислить и , но он нам понадобится позже.
Стоит также отметить, что проверка паддинга может пройти успешно не только когда , но и когда , если . А также если , и . И так далее. Но я буду пренебрегать такими ситуациями, т. к. вероятность их возникновения невелика.
Итак, надеюсь что вы поняли как расшифровать последний байт сообщения - менять последний байт в первом блоке до тех пор, пока check_padding
не ответит True
. Перебирать на самом деле не много, всего существует 256 вариантов. Но если учесть, что сообщением является шестнадцатеричная строка, то существует всего 16 подходящих значений для и любого другого байта сообщения: 0 1 2 3 4 5 6 7 8 9 a b c d e f
. Значит мы можем оптимизировать запросы, и проверять только такие , что является одним из подходящих значений. В итоге вместо 256 запросов в худшем случае мы сделаем всего 16.
Хорошо, а как теперь расшифровать следующий байт ? Да также, только меняем теперь и теперь мы целимся получить значение такое, что и хотим получить паддинг 02 02
. Конечно, и нужно будет подставлять такое, чтобы получать , но это уже не проблема, ведь мы знаем значение . Значит, в надо подставлять значение .
Чтобы расшифровать третий байт мы формируем паддинг 03 03 03
, для четвёртого 04 04 04 04
и так далее.
Когда мы расшифруем весь второй блок текста, мы возвращаемся к паддингу 01
, но теперь меняем байт инициализирующего вектора , чтобы найти . А второй блок текста отбрасываем вовсе.
Вот и вся суть атаки. А краткий порядок такой:
Получаем от сервера зашифрованное сообщение.
Меняем в первом блоке сообщения последний байт () пока функция
check_padding
не выдастTrue
Расшифровываем последний байт сообщения
Меняем первого блока так, чтобы стал равен
02
по формуле и перебираем предпоследний байт первого блока () пока функцияcheck_padding
не выдастTrue
Расшифровываем предпоследний байт сообщения
И так далее.
Код
Код моего решения на Python выглядит так
import pwn
import json
import curses
address = ("socket.cryptohack.org", 13421)
connection = pwn.connect(address[0], address[1])
connection.recvline()
def createEncryptCommand() -> str:
return "{\"option\":\"encrypt\"}"
def createUnpadCommand(ct: str) -> str:
return "{\"option\":\"unpad\",\"ct\":\"" + ct + "\"}"
def createCheckCommand(message: str) -> str:
return "{\"option\":\"check\",\"message\":\"" + message + "\"}"
def splitBlocks(value: str, size: int):
blocks = []
for i in range(0, len(value), size):
blocks.append(value[i: min(i + size, len(value))])
return blocks
def joinToHex(array):
result = ""
for value in array:
result += "{0:02x}".format(value)
return result
def main(stdscr):
curses.resize_term(100, 300)
stdscr.refresh()
curses.start_color()
curses.curs_set(0)
curses.init_pair(1, curses.COLOR_RED, curses.COLOR_BLACK)
curses.init_pair(2, curses.COLOR_GREEN, curses.COLOR_BLACK)
curses.init_pair(3, curses.COLOR_CYAN, curses.COLOR_BLACK)
stdscr.clear()
stdscr.addstr(0, 0, "***************************************************{ AES CBC PKCS7 PADDING ORACLE ATTACK }***************************************************", curses.color_pair(2))
stdscr.addstr(3, 0, "[*] Getting ciphertext", curses.color_pair(1))
stdscr.refresh()
# Получаем зашифрованное сообщение
command = createEncryptCommand()
connection.sendline(command.encode())
ciphertext = json.loads(connection.recvline().decode())["ct"]
stdscr.addstr(4, 4, "Received ciphertext: " + ciphertext, curses.color_pair(2))
stdscr.refresh()
stdscr.addstr(6, 0, "[*] Preparing attack", curses.color_pair(1))
stdscr.refresh()
blocks = splitBlocks(ciphertext, 32)
ivHex = blocks[0]
ct1Hex = blocks[1]
ct2Hex = blocks[2]
ivBytes = bytes.fromhex(ivHex)
ct1Bytes = bytes.fromhex(ct1Hex)
ct2Bytes = bytes.fromhex(ct2Hex)
ivArr = []
ct1Arr = []
ct2Arr = []
for idx in range(0, 16):
ivArr.append(ivBytes[idx])
ct1Arr.append(ct1Bytes[idx])
ct2Arr.append(ct2Bytes[idx])
stdscr.addstr(7, 4, f"IV hex: {ivHex}, IV bytes: {ivArr}", curses.color_pair(2))
stdscr.addstr(8, 4, f"Block 1 hex: {ct1Hex}, Block 1 bytes: {ct1Arr}", curses.color_pair(2))
stdscr.addstr(9, 4, f"Block 2 hex: {ct2Hex}, Block 2 bytes: {ct2Arr}", curses.color_pair(2))
stdscr.addstr(11, 0, "[*] Attacking", curses.color_pair(1))
stdscr.refresh()
knownPlaintext = ""
decryptedArr = []
while len(decryptedArr) < 32:
knownLength = len(decryptedArr)
attackingIndex = knownLength
isFirstBlock = knownLength < 16
paddingSize = knownLength % 16 + 1
targetValue = paddingSize
knownPlaintextString = f"Knwon plaintext: {knownPlaintext}, "
stdscr.addstr(12, 4, knownPlaintextString, curses.color_pair(2))
stdscr.addstr(12, 4 + len(knownPlaintextString), f"Target padding: {targetValue}", curses.color_pair(2))
stdscr.refresh()
replacingValues = []
for idx in range(knownLength):
replacingValues.append(decryptedArr[idx] ^ targetValue)
replacedIvArr = ivArr.copy()
replacedCt1Arr = ct1Arr.copy()
for idx in range(knownLength):
if idx < 16:
if isFirstBlock:
replacedCt1Arr[len(replacedCt1Arr) - idx - 1] = replacingValues[idx]
else:
replacedIvArr[len(replacedIvArr) - (idx - 16) - 1] = replacingValues[idx]
stdscr.addstr(13, 4, "Sending values: ", curses.color_pair(2))
stdscr.refresh()
for attackingValue in range(256):
foundX = attackingValue ^ targetValue
if isFirstBlock:
plaintextChar = chr(foundX ^ ct1Arr[len(ct1Arr) - knownLength - 1])
else:
plaintextChar = chr(foundX ^ ivArr[len(ivArr) - (knownLength - 16) - 1])
if plaintextChar not in "0123456789abcdef": continue
stdscr.addstr(14, 0, " " * 1000)
if isFirstBlock:
replaceIndex = len(replacedCt1Arr) - attackingIndex - 1
replacedCt1Arr[replaceIndex] = attackingValue
lineOffest = 8
stringBeforeReplacedIdx = f"IV: {joinToHex(replacedIvArr)} Block 1: {joinToHex(replacedCt1Arr[:replaceIndex])}"
stdscr.addstr(14, lineOffest, stringBeforeReplacedIdx, curses.color_pair(2))
lineOffest += len(stringBeforeReplacedIdx)
replacedCt1ValueString = "{0:02x}".format(replacedCt1Arr[replaceIndex])
stdscr.addstr(14, lineOffest, replacedCt1ValueString, curses.color_pair(1))
lineOffest += len(replacedCt1ValueString)
stringAfterReplacedIdx = f"{joinToHex(replacedCt1Arr[replaceIndex + 1:])} Block 2: {joinToHex(ct2Arr)}"
stdscr.addstr(14, lineOffest, stringAfterReplacedIdx, curses.color_pair(2))
else:
replaceIndex = len(replacedIvArr) - (attackingIndex - 16) - 1
replacedIvArr[replaceIndex] = attackingValue
lineOffest = 8
stringBeforeReplacedIdx = f"IV: {joinToHex(replacedIvArr[:replaceIndex])}"
stdscr.addstr(14, lineOffest, stringBeforeReplacedIdx, curses.color_pair(2))
lineOffest += len(stringBeforeReplacedIdx)
replacedIvArrValueString = "{0:02x}".format(replacedIvArr[replaceIndex])
stdscr.addstr(14, lineOffest, replacedIvArrValueString, curses.color_pair(1))
lineOffest += len(replacedIvArrValueString)
stringAfterReplacedIdx = f"{joinToHex(replacedIvArr[replaceIndex + 1:])} Block 1: {joinToHex(ct1Arr)}"
stdscr.addstr(14, lineOffest, stringAfterReplacedIdx, curses.color_pair(2))
combinedValue = replacedIvArr + replacedCt1Arr
if isFirstBlock:
combinedValue += ct2Arr
combinedHex = joinToHex(combinedValue)
command = createUnpadCommand(combinedHex)
stdscr.addstr(15, 8, f"Command: {command}", curses.color_pair(2))
connection.sendline(command.encode())
answer = connection.recvline().decode()
result = json.loads(answer)["result"]
if result == True:
foundX = attackingValue ^ targetValue
if isFirstBlock:
knownPlaintext = chr(foundX ^ ct1Arr[len(ct1Arr) - knownLength - 1]) + knownPlaintext
else:
knownPlaintext = chr(foundX ^ ivArr[len(ivArr) - (knownLength - 16) - 1]) + knownPlaintext
decryptedArr.append(foundX)
break
stdscr.refresh()
command = createCheckCommand(knownPlaintext)
connection.sendline(command.encode())
answer = connection.recvline().decode()
flag = json.loads(answer)["flag"]
stdscr.addstr(16, 0, f"Flag: {flag}", curses.color_pair(3))
stdscr.refresh()
while True:
pass
curses.wrapper(main)
Я намеренно не делал никакого рефакторинга, не добавлял комментарием и не упрощал код, потому что настаиваю на том, чтобы читатель самостоятельно написал решение исходя из описания атаки. Но запуск этого кода может помочь разобраться в динамике атаки.
Результат работы кода:
Дополнения The Good, The Pad, The Ugly и Oracular Spectacular
На Cryptohack также есть пара заданий, в основе которых лежит та же атака, но с некоторым усложнением. Первое из них это задача The Good, The Pad, The Ugly с вот таким кодом:
#!/usr/bin/env python3
from Crypto.Util.Padding import unpad
from Crypto.Cipher import AES
from os import urandom
from random import SystemRandom
from utils import listener
FLAG = 'crypto{??????????????????????????????????????????}'
rng = SystemRandom()
class Challenge:
def __init__(self):
self.before_input = "That last challenge was pretty easy, but I'm positive that this one will be harder!\n"
self.message = urandom(16).hex()
self.key = urandom(16)
self.query_count = 0
self.max_queries = 12_000
def update_query_count(self):
self.query_count += 1
if self.query_count >= self.max_queries:
self.exit = True
def get_ct(self):
iv = urandom(16)
cipher = AES.new(self.key, AES.MODE_CBC, iv=iv)
ct = cipher.encrypt(self.message.encode("ascii"))
return {"ct": (iv+ct).hex()}
def check_padding(self, ct):
ct = bytes.fromhex(ct)
iv, ct = ct[:16], ct[16:]
cipher = AES.new(self.key, AES.MODE_CBC, iv=iv)
pt = cipher.decrypt(ct) # does not remove padding
try:
unpad(pt, 16)
except ValueError:
good = False
else:
good = True
self.update_query_count()
return {"result": good | (rng.random() > 0.4)}
def check_message(self, message):
if message != self.message:
self.exit = True
return {"error": "incorrect message"}
return {"flag": FLAG}
#
# This challenge function is called on your input, which must be JSON
# encoded
#
def challenge(self, msg):
if "option" not in msg or msg["option"] not in ("encrypt", "unpad", "check"):
return {"error": "Option must be one of: encrypt, unpad, check"}
if msg["option"] == "encrypt": return self.get_ct()
elif msg["option"] == "unpad": return self.check_padding(msg["ct"])
elif msg["option"] == "check": return self.check_message(msg["message"])
import builtins; builtins.Challenge = Challenge # hack to enable challenge to be run locally, see https://cryptohack.org/faq/#listener
listener.start_server(port=13422)
По сути это тот же Pad Thai, но с двумя различиями:
Появился счётчик на количество запросов в
check_padding
с ограничением в 12000. По достижении этого лимита сервер нас отключит.В
check_padding
поменялась выдача результата с{"result": good}
на{"result": good | (rng.random() > 0.4)}
.
То есть появился элемент случайности ответа. Если случайное число в пределах от 0 до 1 выданное функцией rng.random()
будет больше 0.4, то функция вернёт True
, даже если проверка паддинга не удалась.
Случайные числа из rng.random()
имеют равномерное распределение. Это значит, что в среднем 60% вызовов rng.random()
выдадут значение больше 0.4. То есть у функции check_padding
существует шанс ложного срабатывания в 60%.
Эта проблема легко обходится с помощью нескольких проверок. Если функция вернула True
, то давайте проверим ещё раз, а потом ещё, и ещё, до тех пор пока наша уверенность в том, что мы нашли правильное число не будет достаточной. Так как вероятность ошибки составляет 0.6, две проверки дадут вероятность ошибки три проверки , и т.д. Сколько проверок надо сделать?
Ну, мы могли бы сделать 100 проверок, чтобы получить вероятность ошибки в районе , но давайте не забывать, что у нас всего 12000 попыток. А расшифровать необходимо 32 символа. Для каждого символа существует 16 вариантов. Суммарно вариантов, которые надо проверить. Значит мы можем проверить каждое значение раза с вероятностью ошибки для каждого символа равной . Думаю, что такой уверенности в результате нам должно хватить.
Таким образом, чтобы решить The Good, The Pad, The Ugly, достаточно проверять каждое значение, для которого check_padding
выдало True
23 раза. Если все 23 раза мы получили True
, то считаем что оно правильное.
Oracular Spectacular
Ещё одна задача, которая строится на Pad Thai — Oracular Spectacular. Сразу признаюсь, что для это задачи я пока флаг получить не смог, поэтому ниже будут мои мысли, не подкреплённые реальным решением. Код у задачи такой:
#!/usr/bin/env python3
from Crypto.Util.Padding import unpad
from Crypto.Cipher import AES
from os import urandom
from random import SystemRandom
from utils import listener
FLAG = 'crypto{????????????????????????????????????????????????????}'
rng = SystemRandom()
class Challenge:
def __init__(self):
self.before_input = "That last challenge was pretty easy, but I'm positive that this one will be harder!\n"
self.message = urandom(16).hex()
self.key = urandom(16)
self.query_count = 0
self.max_queries = 12_000
def update_query_count(self):
self.query_count += 1
if self.query_count >= self.max_queries:
self.exit = True
def get_ct(self):
iv = urandom(16)
cipher = AES.new(self.key, AES.MODE_CBC, iv=iv)
ct = cipher.encrypt(self.message.encode("ascii"))
return {"ct": (iv+ct).hex()}
def check_padding(self, ct):
ct = bytes.fromhex(ct)
iv, ct = ct[:16], ct[16:]
cipher = AES.new(self.key, AES.MODE_CBC, iv=iv)
pt = cipher.decrypt(ct) # does not remove padding
try:
unpad(pt, 16)
except ValueError:
good = False
else:
good = True
self.update_query_count()
return {"result": good ^ (rng.random() > 0.4)}
def check_message(self, message):
if message != self.message:
self.exit = True
return {"error": "incorrect message"}
return {"flag": FLAG}
#
# This challenge function is called on your input, which must be JSON
# encoded
#
def challenge(self, msg):
if "option" not in msg or msg["option"] not in ("encrypt", "unpad", "check"):
return {"error": "Option must be one of: encrypt, unpad, check"}
if msg["option"] == "encrypt": return self.get_ct()
elif msg["option"] == "unpad": return self.check_padding(msg["ct"])
elif msg["option"] == "check": return self.check_message(msg["message"])
import builtins; builtins.Challenge = Challenge # hack to enable challenge to be run locally, see https://cryptohack.org/faq/#listener
listener.start_server(port=13423)
От The Good, The Pad, The Ugly он отличается только строчкой
{"result": good ^ (rng.random() > 0.4)}
Получается, что раньше check_padding
могла выдать ложный True
, но мы хотя бы были уверенны в знаении False
. А теперь мы можем получать как ложный True
, так и ложный False
. Посмотрите на таблицу
good |
rng.random() > 0.4 |
result |
False |
False |
False |
False |
True |
True |
True |
False |
True |
True |
True |
False |
То есть просто проверить значение True
несколько раз уже не вариант.
А рабочий (опять же, теоретически) подход — это проверять несколько раз любое значение и считать количество True
и False
, потому что их распределение будет смещено. Я имею в виду тот факт, что rng.random() > 0.4
будет равно True
с вероятностью 0.6. Тогда, если значение good
равно False
(то есть паддинг на самом деле неправильный), то check_padding
должен выдавать True
с вероятностью 0.6. И наоборот, если паддинг правильный, то check_padding
будет выдавать True
c вероятностью 0.4. Распределение вероятностей такое:
good |
rng.random() > 0.4 |
result |
False |
False, P = 0.4 |
False, P = 0.4 |
False |
True, P = 0.6 |
True, P = 0.6 |
True |
False, P = 0.4 |
True, P = 0.4 |
True |
True, P = 0.6 |
False, P = 0.6 |
Это означает, что чем ближе распределение True/False
в ответах check_padding
к 0.4 / 0.6
, тем больше вероятность того, что паддинг на самом деле правильный.
Таким образом, потенциально рабочее решение: проверять абсолютно все значения по несколько раз, считать количество True
и False
и выбирать то значение, которое ближе к распределению 0.4/0.6
(или у которого меньше значений True
). Проблема в том, что мы можем сделать не больше 23 запросов на символ и на такой выборке проявляется плохо, поэтому накапливается слишком много.
Заключение
На этом всё. Вот такая достаточно длинная у меня получилась статья в этот раз. Надеюсь что кто-то честно дочитал до конца и не умер со скуки да ещё и всё понял. В любом случае спасибо всем, кто прочёл, оставляйте обратную связь и вопросы в комментариях, и stay tuned for more.
NutsUnderline
а каков может иметь практический смысл применение rng.random() ? в умозрительной задаче это понятно, но по сути мы делаем сервер в работе которого запрограммирован регулярный сбой. можно же просто выдавать рандомные строки в ответ :)
awawa Автор
Конечно, никому не нужен сервер, который случайным образом неправильно отвечает :) Важно помнить, что задача - это упрощённая модель потенциально очень сложной системы. Серверов, которые просто проверяют паддинг на сообщениях, тоже по сути не делают. Но эти вещи могут быть скрыты в сложном поведении.
В голову приходит пример с немного противоположной разобранной задаче логикой. Пусть сервер выдаёт ошибку, если не может расшифровать сообщение. Это может возникать как из-за неправильного паддинга, так и из-за битых данных в первом блоке (напомню, что когда мы меняем один байт в первом блоке, весь этот блок расшифруется неправильно). В любом случае ошибка одна и та же. Но с некоторой вероятностью данные в первом блоке ломаются так, что сервер их примет и ошибку не пришлёт. То есть, если паддинг неправильный, то мы точно получаем ошибку. Но если паддинг правильный, то мы получаем ошибку с некоторой вероятностью. Получается, что сервер может работать вполне детерменированно, но со стороны атакующего его ответы будут иметь случайный характер и это необходимо учитывать при атаке.