
Привет, Хабр. Меня зовут Аскар Добряков, ведущий эксперт направления защиты данных и приложений в К2 Кибербезопасность, занимаюсь WAF и цепочками обратных прокси. В одном из недавних проектов мы с коллегами натолкнулись на странную реакцию WAF: подставляешь в запрос X-Real-IP, а WAF принимает его как реальный адрес клиента, хотя не должен так делать.
Мы потянули за эту ниточку и размотали два кейса мисконфигурации. Обе проблемы нередкие и случаются, когда команды, настраивающие разные прокси, не договорились между собой. В обоих случаях злоумышленник, не входящий в белый список, может заставить WAF думать, что он в него входит и реализовать любые атаки.
Эта статья для тех, кто только вникает в тему обратных прокси и WAF: студентов, начинающих инженеров. Под катом я подробно разберу мисконфиг, чтобы вы точно не повторяли наших ошибок. Вас ждет три обратных прокси на стенде, два сценария обхода WAF, три обязательных правила для внутреннего ИБ-стандарта, плюс один анекдот.
Как устроена цепочка обратных прокси
В современных системах входящий трафик редко идет по прямой до корпоративного сервиса. Обычно перед ним стоит система защиты от DDoS, затем внутренний шлюз с SSL-офлоадингом и балансировкой, и только потом WAF. В рамках нашего рассказа будем считать их цепочкой обратных прокси на L7.
Ключевое отличие обратного прокси от межсетевого экрана в том, что обратный прокси инициирует новую сессию, то есть в src-адресе пакета, который видит следующий узел, стоит адрес прокси, а не клиента. В рамках нашего рассказа будем считать их цепочкой обратных прокси на L7. Таким образом, для каждого следующего звена источник — предыдущий прокси-сервер, а не первоначальный автор запроса.
Тогда как найти реальный адрес клиента? Для этого прокси добавляют в HTTP-запрос специальные заголовки. Два самых ходовых: X-Forwarded-For и X-Real-IP.
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
Следующий прокси должен знать две вещи. В каком заголовке лежит реальный адрес клиента, и каким отправителям этого заголовка доверять, а иначе злоумышленник может подставить в запрос любой удобный для него X-Forwarded-For.
set_real_ip_from 127.0.0.0/24; real_ip_header X-Forwarded-For;
X-Forwarded-For это, по сути, длинная портянка. Каждый прокси на пути запроса приписывает в этот заголовок реальный IP клиента (или адрес предыдущего прокси, зависит от настроек). В итоге на выходе в X-Forwarded-For оказывается зафиксирована цепочка адресов: откуда, куда, через что. Пример такой цепочки из четырех адресов мы рассмотрим ниже. В свою очередь X-Real-IP — это один адрес: тот, который видел прокси непосредственно перед собой.
По смыслу эти два заголовка близки, и на практике они часто используются вместе. Казалось бы, удобно: XFF нужен для полноты картины, а X-Real-IP дает быстрой и простой ответ на вопрос, откуда пришел запрос. Это достаточно надежный механизм, однако иногда за настройку разных звеньев цепочке прокси отвечают разные команды, и если эти люди не договорятся между собой, то начинаются проблемы. На практике важно то, что по умолчанию эти заголовки не включены, и порой в разных системах разные люди настраивают их совершенно по-разному. Например, один прокси берет из X-Forwarded-For первый адрес как «истинный», а другой — последний. А где разнобой, там и мисконфиг не за горами.
Собираем стенд
Чтобы посмотреть, как это работает на практике, соберем стенд из трех обратных прокси, целевого сервиса и nginx. Для нашей задачи (понять, как последняя прокси в цепочке определяет настоящий IP клиента) этого будет достаточно. Разворачивать настоящий WAF не будем, вместо этого имитируем блокировки в финальной точке через allow/deny.
Haproxy (внешний прокси):
backend myback mode http server bak1 127.0.0.2:80; frontend myfront bind 172.31.80.46:80 mode http # Добавляем X-Forwarded-For option forwardfor use_backend myback Балансировщик (nginx, 127.0.0.2): # Это балансировщик server { listen 127.0.0.2:80; location / { # а тут не настроено, какому адресу доверять #set_real_ip_from 127.0.0.0/24; real_ip_header X-Forwarded-For; real_ip_recursive on; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_pass http://127.0.0.3:80; proxy_bind 127.0.0.2; } }
Промежуточный прокси (127.0.0.2):
server { listen 127.0.0.2:80; location / { # а тут не настроено какому адресу доверять #set_real_ip_from 127.0.0.0/24; real_ip_header X-Forwarded-For; real_ip_recursive on; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_pass http://127.0.0.3:80; proxy_bind 127.0.0.2; } }
WAF (127.0.0.3):
server { listen 127.0.0.3:80; location / { # Доверяем адресам из нашей подсетки set_real_ip_from 127.0.0.0/24; real_ip_header X-Forwarded-For; real_ip_recursive on; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; # Блокируем по адресам allow 195.38.23.9; deny all; proxy_pass http://127.0.0.1:100; proxy_bind 127.0.0.3; } }
Целевой сервис:
server { listen 100; set_real_ip_from 127.0.0.0/24; real_ip_header X-Forwarded-For; real_ip_recursive on; location / { #ну и давайте посмотрим в ответе какие заголовки в итоге пришли return 200 "\nX-Forwarded-For: $http_x_forwarded_for\nX-Real-IP: $http_x_real_ip\nReal IP: $remote_addr\n\n"; } }
Haproxy в этой конфигурации играет роль условной внешней защиты и балансировщика. Второй nginx — промежуточное звено, скажем, внутренний балансировщик публикации. Третий nginx — наш условный WAF. Дальше условный целевой сервис на порту 100, он же эхо-сервис, который отдает в теле ответа все заголовки, которые долетели.
Для начала проверим базовый сценарий работы: отправим запрос с адреса 195.38.23.9, а в X-Forwarded-For руками пропишем заведомо неправильное значение 1.2.3.4. Пусть этот IP будет в белом списке WAF, как будто мы наивно пытаемся обмануть систему.
curl http://91.184.232.113/ -H "X-Forwarded-For: 1.2.3.4" X-Forwarded-For: 1.2.3.4, 195.38.23.9, 127.0.0.1, 195.38.23.9 X-Real-IP: Real IP: 195.38.23.9
Смотрим, какой X-Forwarded-For в итоге долетел до целевого сервиса:
1.2.3.4 — то, что мы сами подставили в запрос.
195.38.23.9 добавил первый прокси. Он не поверил нашему X-Forwarded-For и прописал реальный src в переменную.
127.0.0.1 — это второй прокси. У него закомменчено, каким адресам доверять (по-хорошему это недочет, но сейчас не имеет значения), поэтому он тоже не поверил и взял реальный src. Для нашего стенда это 127.0.0.1.
195.38.23.9 — третий прокси, играющий роль WAF. Он нас все равно узнал правильный адрес, потому что у него real_ip_recursive on;. В этом режиме прокси идет по X-Forwarded-For, пропускает доверенные адреса (set_real_ip_from 127.0.0.0/24;) и ищет первый недоверенный, им как раз оказывается реальный IP клиента.
Цепочка отражена правильно, и раз WAF правильно распознал адрес клиента, блокировка отработает как задумано. А теперь представьте, что коллеги, настраивающие балансировщики, друг с другом не общаются, я ведь не зря в начале писал, что правила по X-Forwarded-For и X-Real-IP задаются вручную. Допустим, безопасники решили взять за основу на WAF X-Real-IP, а их коллеги на вышестоящих прокси настроили только X-Forwarded-For, а про X-Real-IP просто не подумали. И что может пойти не так?
Сценарий 1. Можно из локальной сети
Итак, в белом списке на WAF указан конкретный адрес 1.2.3.4 и локальная сеть, а остальные адреса запрещены. Доверяет он локальной сети, а в качестве заголовка использует X-Real-IP. На стенде WAF такая конфигурация выглядит так:
set_real_ip_from 127.0.0.0/24; real_ip_header X-Real-IP; allow 1.2.3.4; allow 127.0.0.0/24; deny all;
Смотрим, как пойдет обычный запрос.
curl http://91.184.232.113/ X-Forwarded-For: 195.38.23.9, 127.0.0.1, 127.0.0.2 X-Real-IP: Real IP: 195.38.23.9
WAF смотрит в X-Real-IP, но не находит этот заголовок и вместо него берет обычный src-адрес пакета, который прислал второй прокси. А это 127.0.0.3 — локалка из разрешенного диапазона. Поэтому блокировка не срабатывает.
Это достаточно простой сценарий, который обычно быстро обнаруживают. Тогда команды садятся и все-таки договариваются. Однако, «обычно» — это не «всегда». К тому же чаще встречается другая похожая мисконфигруация, которая действительно может заставить поломать голову.
Сценарий 2. Добавить X-Real-IP, если его нет
Эта проблема всплыла, когда мой коллега тестировал WAF для одного из наших внутренних проектов. Он проводил обычный пул проверок: инъекции, попытки выхода за границы, простые подстановки заголовков и в какой то момент добавил в запрос X-Real-IP. Оказалось, что WAF отображает этот заголовок как реальный адрес клиента, хотя по идее должен его игнорировать.
Воспроизведем эту проблему. Для этого скорректируем настройки haproxy, так, чтобы он прописывал X-Real-IP.
http-request set-header X-Real-IP %[src] if !{ req.hdr(X-Real-IP) -m found }
При этом на WAF действует простое правило: пускать только один конкретный адрес:
set_real_ip_from 127.0.0.0/24; real_ip_header X-Real-IP; allow 1.2.3.4; deny all;
Проверяем на чистом запросе: клиент 195.38.23.9, без дополнительных заголовков.
curl http://91.184.232.113/ <html> <head><title>403 Forbidden</title></head> <body> <center><h1>403 Forbidden</h1></center> <hr><center>nginx/1.28.2</center> </body> </html>
Нас блокирует, а в логах появляется запись:
195.38.23.9 - - [04/Mar/2026:16:40:13 +0300] "GET / HTTP/1.0" 403 153 "-" "curl/8.12.1" "195.38.23.9, 127.0.0.1"
То есть адрес определился корректно, и если бы мы остановились здесь, никаких подозрений у нас бы не возникло.
Теперь подставляем в запрос X-Real-IP с адресом из белого списка WAF, 1.2.3.4:
curl http://91.184.232.113/ -H "X-Real-IP: 1.2.3.4" X-Forwarded-For: 195.38.23.9, 127.0.0.1, 1.2.3.4 X-Real-IP: 1.2.3.4 Real IP: 1.2.3.4
И мы внутри.
Теперь разберемся, что произошло. X-Forwarded-For на выходе выглядит так:
195.38.23.9 — первый прокси. Записан корректно.
127.0.0.1 — так как X-Real-IP в запросе отсутствует, второй прокси добавляет в X-Forwarded-For свой адрес. X-Real-IP с подставленным 1.2.3.4 он не трогает, и по умолчанию заголовок транзитом уходит дальше.
1.2.3.4 — третий прокси, WAF, считал X-Real-IP, не тот заголовок, который добавляет первый прокси.
Честно говоря, я до сих пор не уверен, что до конца понимаю, чего хотел автор этой настройки на haproxy. Возможно, он подразумевал внутренний маршалинг/сериализацию, читающую этот заголовок где-то дальше по цепочке. Или выше должен был расположен еще один комплекс прокси, например, защита от DDoS, которая как-то его обрабатывает. Если у кого-то в комментариях будет более умная гипотеза, буду рад услышать.
Что именно этим можно сломать
Отдельно остановимся на последствиях такой мисконфигурации. Обход на вид безобидный, но эффект достаточно масштабный, ведь для этого клиента WAF просто перестал проверять запрос. В результате любая атака из обширного хакерского арсенала беспрепятственно дойдет до конечного сервиса. Кроме того, возникнут проблемы с разделами сайта, ограниченными по IP.
Типичный кейс: в админку сервиса (или, например, во внутренний git-интерфейс) пускают только с конкретных корпоративных адресов, прописанных в WAF. Это удобно, но в нашем сценарии злоумышленник сможет без проблем получить доступ к странице входа в административную панель. К тому же с учетом специфики содержимого WAF обычно люто фолзит на такого рода сервисах. Конечно, дальше нужно будет пройти аутентификацию, но брутфорсить ее он сможет сколько угодно. WAF не заблокирует попытки, потому что обратившийся — доверенный IP из белого списка.
Почему в логах ничего нет
Это простая, но неприятная и, главное, незаметная ошибка. Nginx в логах зафиксирует только то, что пришел клиент с IP админа и больше ничего полезного. Заголовки по умолчанию не логируются, ни X-Forwarded-For, ни X-Real-IP. Конечно это можно настроить, но обычно этого не делают.
В результате картина обнаружения выглядит так. Чтобы увидеть подобный мисконфиг, нужно внимательно смотреть, что именно пролетает в цепочке, а это означает: залогиниться в три разных места (WAF, первый прокси, конечный nginx), свести в голове три лога и заметить расхождение.
Поэтому главный способ избежать подобных мисконфигов — регулярный контроль изменений WAF-конфигурации с тестовыми запросами. У нас это обязательный этап: проверяем работу прокси, каждый раз, когда вносим изменения.
Как проверить свою цепочку
Готовых скриптов для проверки прокси здесь нет по очевидной причине: мы заранее не знаем, как у конкретного заказчика договорились команды. Кто-то использует X-Real-IP, кто-то его игнорирует, кто-то удаляет, кто-то пропускает. Вариантов слишком много, чтобы покрыть их универсальным тестом. Чтобы обнаружить эту проблему, необходимо:
Включить Access log nginx и посмотреть, какой адрес nginx считает реальным для каждого запроса. Это тот, что ему сообщил доверенный прокси? Или обычный src пакета? Уже на этом шаге обычно видно, если что-то не так.
Кроме того, можно покидать в запросы разные сочетания адресов (фейковый X-Forwarded-For, фейковый X-Real-IP, и то и другое одновременно) и посмотреть, меняется ли реакция WAF, и что остается в логах.
Развернуть эхо-сервис. В его роли может выступить отдельный простой listen (например, на порту 100), который на любой запрос возвращает в теле ответа все полученные заголовки. Это позволяет точно увидеть, что именно долетело до конечного узла через всю цепочку.
Важная оговорка про эхо-сервис. Перед переводом в прод его следует отключать: если этот ответ увидит кто-нибудь извне, у него в руках окажется подробная карта вашей цепочки прокси со всем, что они добавляют. Это серьезно облегчает подготовку атаки. Поэтому лучше держать эхо-сервис только на стейдже, а если уж разворачиваете на проде, закрывать внутренним доступом и отключать сразу после проверки.
Почему люди не договариваются
В заключение напрашивается анекдот.
— Почему мужики часто молчат?
— Первое: «А че говорить, и так все ясно». Второе: «А че говорить, и так ничего не понятно».
С заголовками в цепочке прокси все так же. Вроде все ясно: хедер и хедер, что там может пойти не так, но если вы не проговорили детали, обязательно что-то пойдет не так. Мисконфиги на цепочке прокси почти всегда не столько техническая, сколько коммуникативная дыра.
Прокси-серверы договариваться не умеют, договариваться должны команды, которые их настраивают. Так что, если бы мне пришлось писать внутренний стандарт компании по настройке цепочки обратных прокси, я бы включил в него три обязательных правила:
Договориться о форматах взаимодействия между прокси. Это должен быть единый документ, а не пять разных мнений в разных чатах.
Четко распределить зоны ответственности: какой прокси-сервер удаляет заголовок, пришедший от клиента, а какой пишет новый. Какой принимает от доверенного соседа, и кого он считает доверенным, и так далее
Договориться о том, как и в какой последовательности вы смотрите логи при подозрении на проблему.
Отсюда вывод, который каждый раз после очередного подобного разбора я формулирую примерно одинаково: проверка конфигурации — это на самом деле диалог команд в рамках определенной процедуры. И пока этот диалог продолжается, вы защищены от мисконфигов.
