Дисклеймер: Автор статьи не претендует на звание эксперта, и этот материал не является идеальным учебником. В первую очередь это попытка разобраться в вопросе, поделиться личными наблюдениями и, возможно, помочь тем, кто решил разобраться в этой теме.

Личный опыт и предпосылки

Работая в сфере технической поддержки сетевой безопасности, я часто коммуницировал с вендорами WAF. В мои трудовые будни иногда входило «челночество» между клиентами и техподдержкой WAF, поводом для тикетов были ложные срабатывания (False Positive, FP). Это отнимало время, которое можно потратить на иные рабочие задачи, а внесение изменений со стороны вендора не всегда проходили гладко. 

Именно поэтому я решил заглянуть под капот и разобраться, как именно WAF принимает решения. Назовем это интересом обывателя, который хочет понять принцип работы и как FP лечат. В этой статье я разберу 5 реальных ложных срабатываний на примере OWASP Coraza и OWASP Juice Shop.

1. Введение: WAF — требует отладки

Для кого-то это будет очевидным, но WAF не является щитом, который идеально отражает все атаки. Он может пропускать вредоносный трафик или ложно блокировать легитимные действия пользователей, даже если WAF использует эвристики или коммерческие механизмы обучения на трафике веб приложения(в случае с Open Source CRS обучение не предусмотрено, правила статичны).  Проблема ложных срабатываний особенно актуальна для современных SPA. Классические веб-приложения отправляют данные через HTML-формы, а современные приложения (на Angular, React, Vue) общаются с сервером в фоне через JSON, REST API и WebSockets. 

Самое коварное в FP на SPA — это то, что пользователь не видит классическую страницу 403 Forbidden. Приложение просто молча ломается: не грузятся аватарки, не работает чат, кнопка «Купить» ничего не делает. Именно поэтому для анализа я выбрал SPA-приложение.

2. Архитектура стенда

Для эксперимента я развернул стенд из официальных Docker-образов:

WAF: OWASP Coraza. Это современный движок в основном совместимый с синтаксисом ModSecurity (поддержку конкретных операторов и actions стоит проверять в документации к версии), но написанный на Go и более легковесный. Он бесплатный, имеет встроенные библиотеки правил OWASP CRS и прост в установке. 

Правила OWASP CRS (Core Rule Set) на минимальном уровне паранойи (Paranoia Level 1) создавались с оглядкой на веб-приложения прошлого поколения. Исторически метод PUT часто использовался злоумышленниками для загрузки вредоносных файлов (например, через WebDAV). Поэтому для WAF PUT-запросы, text/plain в WebSockets или специфичные названия API-эндпоинтов выглядят как потенциальная атака. Однако WAF не понимает контекста нашего приложения. В современных SPA метод PUT — это стандартный способ обновления данных. WAF действует по консервативным шаблонам и блокирует легитимный трафик. Именно это и называется ложным срабатыванием (False Positive), и именно поэтому мы будем «лечить» WAF, а не переписывать приложение.

Веб-сервер: Caddy. Выбран из-за лаконичного конфига (Caddyfile), который упрощает настройку WAF и передачу заголовков.

Мишень: OWASP Juice Shop. Это намеренно уязвимое SPA-приложение, которое отлично эмулирует работу современного интернет-магазина. Идеальный генератор «шумного» легитимного трафика.

Эмулятор: Python-скрипт с использованием Playwright. Бот выполняет незаурядные действия: заходит на сайт, логинится, в нашем случае под администратором из-за архитектуры приложения, ищет товар, добавляет его в корзину и пытается оформить заказ. 

Рис. 1. Схема архитектуры стенда: поток HTTP/WebSocket запросов от эмулятора пользователя (Playwright) через WAF OWASP Coraza к приложению OWASP Juice Shop.
Рис. 1. Схема архитектуры стенда: поток HTTP/WebSocket запросов от эмулятора пользователя (Playwright) через WAF OWASP Coraza к приложению OWASP Juice Shop.

Запускаем бота на «чистом» WAF (только OWASP CRS, без кастомных исключений). Смотрим в консоль:

Рис. 2. Консоль при работе с «чистым» WAF. Видно множество блокировок (403) фоновых запросов: WebSockets (socket.io), профиля пользователя (whoami) и корзины (BasketItems). Логика приложения сломана, хотя интерфейс SPA частично отрисовывается из кэша.
Рис. 2. Консоль при работе с «чистым» WAF. Видно множество блокировок (403) фоновых запросов: WebSockets (socket.io), профиля пользователя (whoami) и корзины (BasketItems). Логика приложения сломана, хотя интерфейс SPA частично отрисовывается из кэша.

Мы видим множественные блокировки. WAF заблокировал довольно обычные действия нашего «пользователя». Работа сайта нарушена, бот не может выполнить сценарий. Давайте разберем каждую блокировку индивидуально.

4. Анатомия ложных срабатываний (5 кейсов)

Как OWASP CRS принимает решение о блокировке (Anomaly Scoring)

Прежде чем перейти к разбору конкретных кейсов, важно понять логику работы OWASP CRS. На скриншотах логов вы наверняка заметите, что почти всегда финальным правилом, которое блокирует запрос, является 949110 (Inbound Anomaly Score Exceeded). Это не случайность, а архитектурная особенность CRS.

В отличие от простых WAF, которые блокируют запрос сразу при совпадении с сигнатурой, CRS использует систему накопления баллов (Anomaly Scoring). Каждое правило (например, 920420 за неверный Content-Type или 932260 за Unix-команду) имеет свой «вес» (severity):

  • Правила с уровнем CRITICAL (например, явные сигнатуры атак) дают 5 баллов.

  • Правила с уровнем WARNING (как 920350 за числовой IP-адрес) дают 3 балла.

Все эти баллы суммируются. И если итоговая сумма превышает заданный порог (по умолчанию для Paranoia Level 1 это 5 баллов), срабатывает финальное правило 949110, которое и возвращает пользователю 403 Forbidden.

Именно поэтому в логах мы видим цепочку: сначала срабатывает специфичное правило (например, 911100 или 920420), начисляет баллы, а затем 949110 выносит окончательный вердикт.

Понимание этой механики важно при настройке. Отключая одно правило, мы не всегда снимаем блокировку, если другие правила продолжают начислять баллы за тот же запрос. Нужно всегда смотреть на итоговую сумму.

Как читать JSON-логи на скриншотах ниже

На скриншотах терминала с логами audit.log вы увидите длинные строки в формате JSON. Чтобы не запутаться, памятка, на что смотреть:

  • "id" — ID правила, которое сработало. В начале лога вы увидите специфичное правило (например, 920420), а в конце — итоговое 949110 (превышение баллов).

  • "msg" — краткое описание причины блокировки (например, Method is not allowed).

  • "is_interrupted": true — маркер того, что WAF полностью заблокировал запрос (пользователь получит 403 Forbidden).

Именно по этим полям мы и будем искать причину ложного срабатывания в каждом кейсе.

Кейс 1: Socket.IO и HTTP polling (Правило 920420)

Проблема: Приложение «зависает», чат не работает. В консоли бота появляется ошибка:
[!] BLOCKED: .../socket.io/... - Status: 403

Почему WAF сработал: Правило 920420 (Request content type is not allowed by policy), требует, чтобы POST-запросы имели разрешенный тип контента (обычно application/json, x-www-form-urlencoded и некоторые другие, в зависимости от версии CRS) . Библиотека Socket.IO отправляет служебные запросы с Content-Type: text/plain. WAF видит text/plain и блокирует их, так как исторически этот тип контента часто использовался злоумышленниками для обхода фильтров в старых приложениях. WAF блокирует не сами WebSocket-кадры (после upgrade), а HTTP-запросы Socket.IO (handshake/polling) с Content-Type: text/plain.

В логе мы увидим срабатывание нужного правила:

"error_message": "[client \"192.168.56.40\"] Coraza: Warning. Request content type is not allowed by policy [file \"/opt/coraza/owasp-crs/rules/REQUEST-920-PROTOCOL-ENFORCEMENT.conf\"] [line \"2826\"] [id \"920420\"] [rev \"\"] [msg \"Request content type is not allowed by policy\"] [data \"|text/plain|\"] ..."

Отфильтрованная выжимка из терминала сервера:

Рис. 3. Лог блокировки WebSocket-соединения. Правило 920420 срабатывает из-за Content-Type: text/plain, который Socket.IO использует для служебных запросов. Итоговая блокировка происходит по правилу 949110 (превышение порога аномалий).
Рис. 3. Лог блокировки WebSocket-соединения. Правило 920420 срабатывает из-за Content-Type: text/plain, который Socket.IO использует для служебных запросов. Итоговая блокировка происходит по правилу 949110 (превышение порога аномалий).

Кейс 2: Добавление в корзину (Правило 911100)

Проблема: Клиент не может купить товар. В консоли бота появляется ошибка:
[!] BLOCKED: /api/BasketItems/1 - Status: 403

Почему WAF сработал: Правило 911100 («Method is not allowed by policy») ограничивает список разрешенных HTTP-методов до базовых: GET, POST, HEAD и OPTIONS.

В современных SPA для частичного обновления данных (например, изменения количества товара в корзине) используется метод PUT. WAF, следуя консервативным настройкам безопасности, блокирует PUT (так как исторически этот метод применялся для загрузки файлов и считался потенциально опасным). В результате этого запроса клиент не может положить товар в корзину, и процесс оформления заказа полностью ломается.

В логе мы увидим срабатывание нужного правила:

"error_message": "[client \"192.168.56.40\"] Coraza: Warning. Method is not allowed by policy [file \"/opt/coraza/owasp-crs/rules/REQUEST-911-METHOD-ENFORCEMENT.conf\"] [line \"1706\"] [id \"911100\"] [rev \"\"] [msg \"Method is not allowed by policy\"] [data \"PUT\"] ..."

Отфильтрованная выжимка из терминала сервера:

Рис. 4. Лог блокировки метода PUT. Правило 911100 срабатывает из-за недопустимого HTTP-метода, после чего итоговое правило 949110 фиксирует превышение порога аномалий.
Рис. 4. Лог блокировки метода PUT. Правило 911100 срабатывает из-за недопустимого HTTP-метода, после чего итоговое правило 949110 фиксирует превышение порога аномалий.

Кейс 3: Названия API эндпоинтов (Правило 932260)

Проблема: Не загружается профиль пользователя. В консоли бота:
[!] BLOCKED: /rest/user/whoami - Status: 403

Почему WAF сработал: Правило 932260 («Remote Command Execution: Direct Unix Command Execution») ищет в URL системные команды. Эндпоинт /rest/user/whoami содержит слово whoami, которое в Linux является командой для получения имени текущего пользователя. WAF не понимает контекст REST API и блокирует легитимный запрос.

В логе мы увидим срабатывание нужного правила:

"error_message": "[client \"192.168.56.40\"] Coraza: Warning. Remote Command Execution: Direct Unix Command Execution [file \"/opt/coraza/owasp-crs/rules/REQUEST-932-APPLICATION-ATTACK-RCE.conf\"] [line \"5666\"] [id \"932260\"] [rev \"\"] [msg \"Remote Command Execution: Direct Unix Command Execution\"] [data \"Matched Data: /rest/user/whoami found within REQUEST_FILENAME\"] ..."

Отфильтрованная выжимка из терминала сервера:

Рис. 5. Лог блокировки эндпоинта /rest/user/whoami. Правило 932260 находит совпадение с Unix-командой whoami в URL, что приводит к итоговой блокировке по правилу 949110.
Рис. 5. Лог блокировки эндпоинта /rest/user/whoami. Правило 932260 находит совпадение с Unix-командой whoami в URL, что приводит к итоговой блокировке по правилу 949110.

Кейс 4: SQL-инъекция в поиске (Правило 942100)

Бот Playwright отлично эмулирует работу в браузере, но интерфейс Angular скрывает момент отправки поискового запроса. Поэтому для чистоты эксперимента я проверил этот кейс через прямой API-запрос.

Проблема: Пользователь не может найти товар с апострофом в названии (например, apple'--). При прямом GET-запросе WAF возвращает: HTTP/1.1 403 Forbidden

Почему WAF сработал: Правило 942100 использует движок LibInjection для поиска сигнатур SQLi. Строка apple'-- содержит апостроф и два дефиса (комментарий в SQL), поэтому LibInjection ошибочно классифицирует это как попытку атаки, хотя на самом деле это просто текст.

В логе мы увидим срабатывание нужного правила:

"error_message": "[client \"192.168.56.40\"] Coraza: Warning. SQL Injection Attack Detected via libinjection [file \"/opt/coraza/owasp-crs/rules/REQUEST-942-APPLICATION-ATTACK-SQLI.conf\"] [line \"9990\"] [id \"942100\"] [rev \"\"] [msg \"SQL Injection Attack Detected via libinjection\"] [data \"Matched Data: sc found within ARGS:q: apple'--\"] ..."

Отфильтрованная выжимка из терминала сервера:

Рис. 6. Лог блокировки поискового запроса. Правило 942100 (LibInjection) ошибочно распознает апостроф и дефисы в строке apple'-- как сигнатуру SQL-инъекции.
Рис. 6. Лог блокировки поискового запроса. Правило 942100 (LibInjection) ошибочно распознает апостроф и дефисы в строке apple'-- как сигнатуру SQL-инъекции.

Кейс 5: Штраф за IP-адрес (Правило 920350)

Проблема: WAF начисляет 3 балла аномалии (Anomaly Score) за каждый запрос.

Почему WAF сработал: Правило 920350 («Host header is a numeric IP address») считает подозрительным, если сайт открывают по IP-адресу (192.168.56.40), а не по доменному имени. Само по себе оно не блокирует, но в сочетании с другими предупреждениями легко превышает порог блокировки (5 баллов).

В логе мы увидим срабатывание нужного правила:

"error_message": "[client \"192.168.56.40\"] Coraza: Warning. Host header is a numeric IP address [file \"/opt/coraza/owasp-crs/rules/REQUEST-920-PROTOCOL-ENFORCEMENT.conf\"] [line \"2597\"] [id \"920350\"] [rev \"\"] [msg \"Host header is a numeric IP address\"] [data \"192.168.56.40:8080\"] ..."

Отфильтрованная выжимка из терминала сервера:

Рис. 7. Правило 920350 срабатывает на числовой IP-адрес в заголовке Host и начисляет 3 балла аномалии. Само по себе оно не блокирует запрос, но в сочетании с другими правилами (как в Кейсе 2) легко превышает порог.
Рис. 7. Правило 920350 срабатывает на числовой IP-адрес в заголовке Host и начисляет 3 балла аномалии. Само по себе оно не блокирует запрос, но в сочетании с другими правилами (как в Кейсе 2) легко превышает порог.

5. Лечение

Все исключения настраиваются в файле coraza-rules/exclusions.conf.

Рис. 8. Итоговый файл coraza-rules/exclusions.conf. Все исключения используют точечный подход, чтобы отключать проверки только для конкретных параметров и URL, не снижая общую защиту приложения.
Рис. 8. Итоговый файл coraza-rules/exclusions.conf. Все исключения используют точечный подход, чтобы отключать проверки только для конкретных параметров и URL, не снижая общую защиту приложения.

Не используйте SecRuleRemoveById для глобального отключения правил. Если вы отключите правило 942100 (SQLi) целиком, злоумышленник сможет беспрепятственно атаковать любые формы на сайте. Вместо этого мы используем ctl:ruleRemoveTargetById. Это точечная настройка. Мы говорим WAF: «Продолжай проверять это правило для всего сайта, но проигнорируй конкретный параметр или URL».

История одного провала (Правило 942100)

Одна из самых частых вещей при настройке FP — это исправление последствий неправильного лечения. Для поиска с апострофом я сначала написал простое правило, которое отключало проверку параметра q по правилу 942100:

SecRule REQUEST_URI "@beginsWith /rest/products/search" \
    "id:1003,phase:1,pass,nolog,\
    ctl:ruleRemoveTargetById=942100;ARGS:q"

Пришлось переписать правило, добавив проверку на опасные SQL-паттерны через chain:

SecRule REQUEST_URI "@beginsWith /rest/products/search" \
    "id:1003,phase:1,pass,nolog,chain"
    SecRule ARGS:q "!@rx (?i)(union|select|or\s+1=1|and\s+1=1|sleep|benchmark)" \
        "ctl:ruleRemoveTargetById=942100;ARGS:q"

Теперь логика такая: «Отключи проверку 942100 для параметра q, только если в нем нет слов union, select или or 1=1». Ложное срабатывание на апостроф ушло, а реальная атака снова блокируется. Это показывает, что тюнинг WAF — это всегда поиск баланса между удобством и безопасностью.

Важное ограничение этого решения: Приведенный гейт !@rx (?i)(union|select...) — это упрощенный пример. В реальной эксплуатации злоумышленник может использовать обфускацию (URL-кодирование, изменение регистра, SQL-комментарии), которую регулярное выражение не поймает. В продакшене подобные исключения должны либо использовать нормализацию данных (трансформации t:urlDecodeUni, t:lowercase), либо быть максимально узкими (разрешать только конкретные безопасные символы, например апостроф, но блокировать все остальное). Либо стоит полагаться на то, что другие правила группы 942xxx поймают атаку. Этот кейс показывает логику безопасного тюнинга, но не является готовым production-ready правилом.

(Остальные исключения для Socket.IO, Корзины, Whoami и Host Header настроены аналогично — через точечное отключение конкретного правила для конкретного URL).

6. Проверка

После внесения всех исправлений в exclusions.conf, запускаем нашего бота еще раз:

Рис. 9. Успешный прогон эмулятора после внесения правил. Все фоновые запросы SPA проходят без блокировок, сценарий выполняется до конца.
Рис. 9. Успешный прогон эмулятора после внесения правил. Все фоновые запросы SPA проходят без блокировок, сценарий выполняется до конца.

В консоли — ни одного [!] BLOCKED. Бот успешно залогинился, добавил товар и дошел до этапа оформления заказа (Checkout button visible). Это значит, что логика приложения восстановлена. Для полной уверенности проверяем логи OWASP Coraza:

Рис. 10. Проверка audit.log: поиск по всем пяти правилам (911100, 920420, 932260, 942100, 920350) не выдает ни одного заблокированного запроса.
Рис. 10. Проверка audit.log: поиск по всем пяти правилам (911100, 920420, 932260, 942100, 920350) не выдает ни одного заблокированного запроса.

Логи чистые, задача выполнена. Но возникает закономерный вопрос: а не снизили ли мы уровень защиты?

Ведь мы отключили проверку для параметра поиска q и разрешили метод PUT для корзины. Чтобы доказать, что мы не оставили «черный ход» для злоумышленников, проведем регрессионное тестирование. Отправим curl-запросы с реальными сигнатурами атак (SQLi, RCE, XSS):

Рис. 11. Регрессионное тестирование WAF. Запросы с реальными сигнатурами SQLi, RCE, XSS, Path Traversal и запрещенного метода DELETE по-прежнему возвращают 403 Forbidden.
Рис. 11. Регрессионное тестирование WAF. Запросы с реальными сигнатурами SQLi, RCE, XSS, Path Traversal и запрещенного метода DELETE по-прежнему возвращают 403 Forbidden.

Как видно на скриншоте, WAF по-прежнему надежно блокирует вредоносные запросы, возвращая 403 Forbidden.

Наша точечная настройка (ctl:ruleRemoveTargetById + chain) сработала: мы пропустили легитимный трафик, но не открыли дверь для атак.

7. Заключение

Вот и всё. Безусловно, здесь показаны не все возможные FP, где-то проблему можно было решить иначе, а где-то она недожата. Но план выполнен: мы прошлись от поиска и получения ложных срабатываний до их исправления и проверки, что WAF всё ещё работает.

Как я уже написал в начале, WAF — это не «поставил и забыл», а инструмент, который требует точной, иногда неочевидной настройки под конкретное приложение со своей логикой. 

Ограничения этого стенда: Важно понимать, что это лабораторный эксперимент. В реальной эксплуатации (в отличие от нашей песочницы) тюнинг WAF обрастает дополнительными процессами: версионированием исключений, ревью изменений, мониторингом ложных срабатываний и автоматизацией регрессионных тестов. Кроме того, в продакшене исключения обычно стараются сужать до связки метод + URL + параметр, а не просто по URL. В рамках этого стенда я фокусировался именно на технической механике работы правил, чтобы понять суть проблемы.

Прикрепляю ссылку на мой GitHub-репозиторий с проектом, по которому писалась эта статья.

Стенд собран из официальных Docker-образов. На момент написания статьи использовались актуальные версии OWASP Coraza (с CRS 4.25.2) и Caddy. Полную конфигурацию стенда, включая используемые образы и настройки WAF, можно посмотреть в файле docker-compose.yml в репозитории.

А как вы тюните WAF в продакшене? Какие подходы или алгоритмы используете для поиска баланса между безопасностью и удобством?