Атаки по сторонним каналам

Почему шифр можно взломать, ничего не зная о ключе, — просто измеряя, сколько времени работает код.

Атака по стороннему каналу (side-channel) — извлечение секрета не из самих данных, а из побочных физических проявлений вычисления: времени работы, энергопотребления, электромагнитного излучения, звука.

Криптография обычно доказывает стойкость в идеальном мире, где у атакующего есть только вход и выход алгоритма. В реальности у программы есть ещё и поведение: она тратит время, потребляет ток, обращается к кэшу. Если это поведение зависит от секрета, секрет частично утекает наружу. Именно поэтому корректный по математике AES можно скомпрометировать, не атакуя сам шифр.

Зачем это знать защитнику

Side-channel — это класс уязвимостей, который не виден в обычном код-ревью и не ловится функциональными тестами: код даёт правильный ответ, проходит все проверки, но при этом «нашёптывает» секрет тому, кто умеет слушать. Защитник обязан понимать механизм, чтобы выбирать реализации, не зависящие от секретных данных по времени, и чтобы грамотно оценивать риск (например, серверный API, отвечающий на запросы из интернета, уязвимее изолированной офлайн-системы).

Тайминг-атаки: утечка через время

Классический пример — наивное сравнение секрета (токена, MAC, пароля) побайтно с ранним выходом. Как только встретился первый несовпадающий байт, функция возвращает false. Значит, для «почти правильного» значения, у которого совпадает больше первых байтов, проверка длится чуть дольше. Многократно измеряя это время, атакующий может подбирать секрет по одному байту, превращая перебор из астрономического в линейный.

Посмотрите на разницу между опасным и безопасным сравнением. Наивная версия выходит раньше, постоянная по времени — всегда проходит всю длину:

import secrets

# Наивно: ранний выход на первом несовпадении -> время зависит от секрета
def naive_equal(a: bytes, b: bytes) -> bool:
    if len(a) != len(b):
        return False
    for x, y in zip(a, b):
        if x != y:
            return False  # утечка: разное число итераций
    return True

# Постоянное по времени: копим разницу через XOR, обходим всю длину
def constant_time_equal(a: bytes, b: bytes) -> bool:
    if len(a) != len(b):
        return False
    result = 0
    for x, y in zip(a, b):
        result |= x ^ y
    return result == 0

real = b"super-secret-token"
print("naive верный токен:  ", naive_equal(real, b"super-secret-token"))
print("naive неверный токен:", naive_equal(real, b"wrong-token-aaaaaaa"))
print("CT верный токен:     ", constant_time_equal(real, b"super-secret-token"))
print("CT неверный токен:   ", constant_time_equal(real, b"wrong-token-aaaaaaa"))
# В реальном коде берите готовое:
print("secrets.compare_digest:", secrets.compare_digest(real, real))

Вывод:

naive верный токен:   True
naive неверный токен: False
CT верный токен:      True
CT неверный токен:    False
secrets.compare_digest: True

Функционально обе версии эквивалентны — отличается лишь профиль времени. Поэтому глазами баг не заметить: нужно знать о нём заранее и сразу выбирать безопасный примитив.

Атаки по энергопотреблению

На уровне железа (смарт-карты, аппаратные токены, IoT-устройства) применяют ещё более тонкие методы. Простой анализ потребления (SPA) различает операции по форме графика тока, а дифференциальный анализ (DPA) статистически коррелирует тысячи измерений с гипотезами о битах ключа. Аналогично работают электромагнитные и акустические каналы. Здесь защита — задача в первую очередь производителя устройства (маскирование, выравнивание, экранирование), но разработчику прикладного ПО важно понимать: если атакующий имеет физический доступ к устройству, угроза реальна.

Важная оговорка про модель угроз: не всякий тайминг-канал одинаково опасен. Различие в наносекунды через сеть тонет в шуме задержек, тогда как локальный атакующий на той же машине (другой процесс, виртуальная машина по соседству, разделяемый кэш) измеряет гораздо точнее. Но полагаться на «сеть зашумит утечку» нельзя: техники усреднения и статистики научились вытаскивать сигнал даже из удалённых измерений, а облачные среды сближают атакующего и жертву физически. Поэтому правильный подход — закрывать канал в коде, а не надеяться на внешний шум.

Где это встречается на практике

Тайминг-уязвимости живут в самых обыденных местах: проверка HMAC-подписи вебхука, сверка API-ключа или CSRF-токена, валидация кода сброса пароля, сравнение хеша сессии. Везде, где код сопоставляет присланное пользователем значение с секретом и где-то отвечает «да/нет», стоит спросить: «сравниваю ли я это constant-time?». Особенно коварны самописные «проверки подписи» в интеграциях — частый источник реальных утечек, потому что выглядят они безобидно.

Как это работает под капотом

Корень проблемы — ветвления и обращения к памяти, зависящие от секрета. Процессор выполняет if secret_bit: за разное число тактов в зависимости от значения бита; кэш отвечает быстрее на адрес, который недавно читали (отсюда кэш-тайминг-атаки на табличные реализации шифров). Любая величина, которая физически зависит от секрета и при этом наблюдаема снаружи, — это канал утечки. Цель constant-time-программирования — разорвать эту зависимость: время и шаблон доступа к памяти не должны меняться от секретных данных.

Как защититься

  • Используйте constant-time-примитивы. Сравнивайте секреты через secrets.compare_digest (Python), hmac.compare_digest, crypto.timingSafeEqual (Node.js), subtle.ConstantTimeCompare (Go). Никогда не сравнивайте токены и MAC обычным ==.
  • Не ветвитесь по секрету. Избегайте if, ранних return и индексации массива по секретным значениям в криптокоде. Маскируйте выбор арифметикой/битовыми операциями вместо переходов.
  • Берите проверенные библиотеки. libsodium, BoringSSL, реализации из cryptography (Python) уже написаны как constant-time. Свой AES «для скорости» почти наверняка получится уязвимым.
  • Снижайте observability. На сервере выравнивайте время ответа на ошибочные и успешные запросы, добавляйте rate-limiting — это поднимает стоимость измерений для атакующего.
  • Учитывайте модель угроз. Если возможен физический доступ (смарт-карты, токены) — нужны аппаратные контрмеры. Выбирайте сертифицированное железо там, где это критично.

Правовое напоминание: исследовать side-channel допустимо только на собственных или явно разрешённых системах. Несанкционированный доступ и нарушение работы чужих систем наказуемы (УК РФ ст. 272–274).

Итоги

  • Стойкий алгоритм не гарантирует стойкой реализации: секрет может утекать через время, ток, кэш.
  • Тайминг-атаки превращают перебор секрета в линейный, если сравнение выходит раньше на несовпадении.
  • Корень проблемы — ветвления и доступы к памяти, зависящие от секретных данных.
  • Защита: constant-time-сравнение, отказ от ветвлений по секрету, проверенные библиотеки, выравнивание времени ответа.
Проверьте себя
1. Почему наивное побайтовое сравнение токена через == опасно?
AОно может вернуть неверный результат при совпадении
BВремя выполнения зависит от того, сколько первых байтов совпало, и это утекает наружу
CОно медленнее, чем нужно для продакшена
DОно не работает с байтовыми строками
2. Что общего у constant-time-сравнения вроде secrets.compare_digest?
AОно всегда обходит всю длину и не ветвится по содержимому, поэтому время не зависит от секрета
BОно шифрует оба значения перед сравнением
CОно сравнивает только хеши, а не сами данные
DОно работает быстрее наивной версии