5. Защита от replay атак
Архитектура защиты: от одноразовых номеров к криптографическим подписям
В теории компьютерных сетей существует фундаментальный постулат: шифрование гарантирует конфиденциальность и целостность данных в канале, но само по себе не защищает от фактора времени.
Представьте физическую модель: злоумышленник перехватывает зашифрованный сетевой пакет с командой: «Списать $1 000 со счета Алисы в пользу Боба». Даже если злоумышленник не знает закрытых ключей и не может расшифровать или подделать содержимое пакета, что мешает ему просто сохранить этот легитимный пакет и отправить его на сервер еще 500 раз подряд? Если сервер наивно обрабатывает каждый валидный пакет, Боб станет богачом, а счет Алисы опустеет.
Эта угроза называется Replay-атакой (Атакой повторного воспроизведения).
В распределенных API, интеграционных вебхуках (Payment Webhooks, GitHub Webhooks) и системах межсервисного взаимодействия (M2M) защита от повторов строится на строгой комбинации трех криптографических примитивов:
- Временного окна (Timestamp) — ограничивает срок годности сообщения в физическом времени.
- Одноразового номера (Nonce) — гарантирует абсолютную уникальность каждого запроса.
- Криптографической привязки (HMAC Signature) — делает timestamp и nonce неделимой частью подписанной полезной нагрузки.
flowchart TD
subgraph Client["Клиентская система (Sender)"]
A["1 - Клиент формирует запрос"] --> B["2 - Генерация timestamp и nonce"]
B --> C["3 - Подпись: HMAC timestamp nonce payload"]
C --> D["4 - Отправка заголовков X-Timestamp<br/>X-Nonce X-Signature"]
end
subgraph Middleware["Go Ingress (Anti-Replay Middleware)"]
D --> E["5 - Middleware извлекает значения"]
E --> F["6 - Проверка временного окна<br/>(допустим дрейф 5 минут)"]
F -->|"Окно валидно"| G["7 - Проверка уникальности nonce<br/>Redis SET NX или LRU кэш"]
G -->|"Nonce уникален"| H["8 - Верификация подписи<br/>subtle.ConstantTimeCompare"]
end
subgraph Decision["Маршрутизация"]
H -->|"Подпись верна"| I["9 - Разрешение запроса<br/>(в бизнес-логику)"]
F -->|"Просрочено"| J["10 - Отказ 400/401"]
G -->|"Дубликат"| J
H -->|"Не совпало"| J
end
style Client fill:#0f172a,stroke:#38bdf8,stroke-width:1px,color:#f8fafc
style Middleware fill:#1e293b,stroke:#10b981,stroke-width:1px,color:#f8fafc
style Decision fill:#0f172a,stroke:#6366f1,stroke-width:1px,color:#f8fafc
style C fill:#1e293b,stroke:#4ecdc4,stroke-width:2px,color:#f8fafc
style F fill:#1e293b,stroke:#ffd93d,stroke-width:2px,color:#f8fafc
style J fill:#1e293b,stroke:#ff6b6b,stroke-width:2px,color:#f8fafc
Механизмы защиты и их комбинация
Эффективная защита от повторного воспроизведения невозможна, если исключить хотя бы один компонент триады:
- Временное окно (Timestamp):
Клиент прикладывает текущее время Unix в секундах или миллисекундах. Сервер сверяет его со своим системным временем черезtime.Now(). Если разница между часами клиента и сервера превышает допустимый порог (обычно 1–5 минут), запрос мгновенно отклоняется. Это сужает временное окно жизни перехваченного сетевого пакета. - Одноразовый номер (Nonce — Number used ONCE):
Криптографически стойкий случайный идентификатор высокой энтропии (UUIDv4 или 128 бит случайных байт), генерируемый клиентом на каждый отдельный запрос. Сервер сохраняет этот идентификатор во временном хранилище ровно на длительность временного окна (5 минут). Попытка повторно использовать уже встречавшийсяnonceпресекается как атака. - Криптографическая подпись (Signature):
Timestamp и nonce включаются в строку подписи, по которой вычисляется дайджест HMAC-SHA256 от тела запроса. Это лишает злоумышленника возможности манипулировать временем: изменить timestamp или сгенерировать свежий nonce невозможно без знания общего секрета, так как итоговая подпись станет невалидной.
Почему разделение критично?
- Без nonce атакующий может повторить запрос сотни раз внутри разрешенного 5-минутного окна.
- Без timestamp сервер был бы вынужден хранить все nonce за всю историю существования системы, что приведет к бесконечному расходу оперативной памяти.
- Без подписи атакующий может просто подставить текущее время и новый nonce, обойдя все фильтры.
Идиоматичная реализация в Go
В Go защита от replay-атак оформляется в виде Middleware. Он извлекает заголовки, проверяет допустимый дрейф часов, атомарно регистрирует nonce и сверяет HMAC-подпись за константное время:
package replay
import (
"context"
"crypto/hmac"
"crypto/sha256"
"crypto/subtle"
"net/http"
"strconv"
"time"
)
// NonceStore определяет интерфейс атомарного сохранения одноразовых номеров с TTL
type NonceStore interface {
Store(ctx context.Context, nonce string, ttl time.Duration) (bool, error)
}
// AntiReplayMiddleware проверяет входящий сетевой запрос на попытку повтора
type AntiReplayMiddleware struct {
secret []byte
window time.Duration
maxDrift time.Duration
nonceStore NonceStore // интерфейс для сохранения nonce с TTL
}
func NewAntiReplayMiddleware(secret []byte, window, maxDrift time.Duration, store NonceStore) *AntiReplayMiddleware {
return &AntiReplayMiddleware{
secret: secret,
window: window,
maxDrift: maxDrift,
nonceStore: store,
}
}
func (m *AntiReplayMiddleware) ServeHTTP(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
tsStr := r.Header.Get("X-Timestamp")
nonce := r.Header.Get("X-Nonce")
signature := r.Header.Get("X-Signature")
if tsStr == "" || nonce == "" || signature == "" {
http.Error(w, "missing anti-replay headers", http.StatusBadRequest)
return
}
// 1 - Парсинг и проверка допустимого временного окна
reqTime, err := strconv.ParseInt(tsStr, 10, 64)
if err != nil {
http.Error(w, "invalid timestamp", http.StatusBadRequest)
return
}
now := time.Now().Unix()
// Проверяем как устаревание запроса, так и забегание часов клиента вперед
if now-reqTime > int64(m.window.Seconds()) || now-reqTime < -int64(m.maxDrift.Seconds()) {
http.Error(w, "request expired or clock skew too large", http.StatusUnauthorized)
return
}
// 2 - Атомарная проверка nonce. SET NX гарантирует, что только первый запрос пройдёт.
ok, err := m.nonceStore.Store(r.Context(), nonce, m.window)
if err != nil {
http.Error(w, "nonce check failed", http.StatusInternalServerError)
return
}
if !ok {
// Повторное появление того же самого nonce сигнализирует о Replay-атаке
http.Error(w, "replay attempt detected", http.StatusConflict)
return
}
// 3 - Верификация подписи: привязываем timestamp и nonce к полезной нагрузке
mac := hmac.New(sha256.New, m.secret)
mac.Write([]byte(tsStr))
mac.Write([]byte(nonce))
// В реальном коде здесь также хешируется тело запроса r.Body
expected := mac.Sum(nil)
// 🔒 Сравнение в константном времени для исключения атак по времени
if subtle.ConstantTimeCompare([]byte(signature), expected) != 1 {
http.Error(w, "invalid signature", http.StatusUnauthorized)
return
}
next.ServeHTTP(w, r)
})
}
Под капотом: время, атомарность и давление на память
Реализация антиповтора затрагивает самые низкоуровневые механизмы ядра ОС и рантайма Go:
- Быстрое время ядра Linux через VDSO:
Функцияtime.Now()в современном Linux не совершает полноценного переключения контекста ядра. Она обращается к механизму VDSO (Virtual Dynamic Shared Object), вызываяclock_gettime(CLOCK_REALTIME)прямо из пространства пользователя. Это занимает считанные 10–30 наносекунд. Однако разработчик обязан учитывать рассинхронизацию часов (Clock Skew / NTP Drift) между серверами клиентов и шлюза, закладывая безопасный допуск в 30–60 секунд. - Атомарность сохранения Nonce в распределенном кластере:
В распределенных системахNonceStoreреализуется через Redis с помощью командыSET key value NX PX <ttl>. ФлагNX(Not eXists) атомарно записывает ключ только в том случае, если его еще нет в базе. Вся операция выполняется в одном потоке Redis без риска гонки состояний. На уровне Go это один вызов сокета (syscall write/read). При 10 000 RPS это генерирует 10 000 сетевых запросов в секунду. Для оптимизации применяют локальный кэш (sync.Mapилиristretto) с TTL-инвалидацией на стороне Go-процесса. - Память и сборщик мусора (
GC):
Хранение nonce в оперативной памяти без строгой очистки гарантированно приводит к линейной утечке памяти: Go-рантайм никогда автоматически не удаляет старые ключи из мапы. Решение: использование LRU-кэшей с ограниченной емкостью или фоновых горутин с тикером, очищающих устаревшие слоты, что защищает процесс от срабатыванияOOM Killer. - Константное время и конвейер процессора:
Функцияcrypto/subtle.ConstantTimeCompareпобайтово выполняет побитовые инструкцииXORиOR, не используя условных переходов (JNE/JE). Конвейер процессора исполняет строго фиксированное количество инструкций независимо от входных данных, стабилизируя предсказатель ветвлений (Branch Predictor) и полностью блокируя атаки по измерению времени ответа.
Итог
- Триада защиты от Replay: Надежная защита требует одновременного присутствия Timestamp (ограничение времени), Nonce (уникальность) и Signature (криптографическая привязка к телу).
- Атомарность превыше всего: Регистрация Nonce обязана быть атомарной (
SET NXв Redis). Разделение проверки и записи черезRWMutexсоздает окно гонки TOCTOU. - Механическое сочувствие к рантайму: Получение времени через
time.Now()опирается на быстрый VDSO в Linux (~10–30 нс). Учитывайте рассинхронизацию NTP между хостами. - Управление памятью: При локальном хранении nonce используйте структуры с жестким лимитом емкости (LRU) и TTL-инвалидацией во избежание деградации сборщика мусора и
OOM. - Константное время: Сверка криптографических подписей всегда должна выполняться через
crypto/subtle.ConstantTimeCompare, исключая утечки информации по времени выполнения инструкций.
Как обеспечить браузерную защиту на уровне транспортных заголовков HTTP и предотвратить MIME-сниффинг, встраивание во фреймы и утечки реферера?
В следующей лекции мы разберем: 6. Secure headers.