РАЗБОР На проверке

Обход ACL через парсинг URL на реверс-прокси

Сергей Попов
Сергей Попов Red Team · 6,5 тыс. сообщений
Подписаться
39
[ обложка статьи ]
Режим чтения
Разобранная плата реверс-прокси на чёрном столе, рядом ноутбук с подсвеченным синим payload-запросом и надписью об уязвимости парсера URL. Рука в перчатке держит щуп у перерезанной дорожки платы, с...


На пентесте API-сервиса, стоявшего за nginx, административная панель /admin была закрыта стандартным location /admin { deny all; }. Прямой запрос к /admin - 403. Ожидаемо. Отправляю curl --path-as-is "https://target/static/%2e%2e/admin" - бэкенд (Django за WSGI-сервером) возвращает 200 с полным интерфейсом управления. Обход ACL через парсинг URL - одна команда, три секунды, ноль аутентификации.

Что произошло: nginx не декодировал %2e при сопоставлении с location-блоком, а WSGI-сервер (uWSGI/Gunicorn) декодировал %2e%2e в .. и передал нормализованный PATH_INFO в Django, где URL resolver сопоставил его с /admin. Два парсера - две разные картины мира.

Этот класс уязвимостей - URL parser confusion - стабильно всплывает на проектах с reverse proxy в стеке. На русском языке с конкретными payload'ами тема практически не описана. Исправляем.

Зачем атакующему URL parser confusion​

URL parser confusion - прямой вектор Exploit Public-Facing Application (T1190, Initial Access по MITRE ATT&CK). Эксплуатация занимает минуты, не требует аутентификации и даёт доступ к ресурсам, которые разработчик считал защищёнными. Никакой теории ради теории.

Что получает атакующий при обходе ACL:
  • Доступ к admin-панелям - если ACL реверс-прокси обходится, бэкенд отдаёт административный интерфейс без проверки прав. Оттуда - privilege escalation, чтение данных, lateral movement
  • SSRF - обход allowlist-валидации URL перенаправляет серверный запрос на внутренний endpoint. Прямое попадание в OWASP A10:2021 - Server-Side Request Forgery
  • Cache poisoning - фронтенд кеширует ответ по сырому пути, бэкенд нормализует путь до другого ресурса. Результат: подмена кешированного контента для всех пользователей
  • Open redirect через OAuth logout - parser confusion в logout URL позволяет перенаправить пользователя на вредоносный сайт. Именно так сработала CVE-2021-32786 в mod_auth_openidc - open redirect из-за расхождения apr_uri_parse() и браузерного WHATWG-парсинга
Предусловия для эксплуатации:

ТребованиеДетали
АрхитектураРеверс-прокси (nginx, Apache, Envoy, HAProxy) перед бэкендом
ACL на проксиlocation-блоки с deny, allow или proxy-уровневая авторизация
Рассинхрон парсеровПрокси и бэкенд используют разные правила декодирования/нормализации пути
ДоступСетевой доступ к прокси без аутентификации
Версии ПОЗависит от конкретной пары; nginx любой версии + бэкенд с декодированием %2e

Различия парсинга URL между прокси и бэкендом: механика​

URL определён несколькими RFC: RFC 1738 (1994), RFC 2396 (1998), RFC 3986 (2005). Каждый менял правила. Параллельно браузеры следуют стандарту WHATWG, который отличается от RFC 3986 в обработке обратного слеша, userinfo и относительных путей. Точка с запятой (;) была допустима как параметр сегмента пути в RFC 2396, но deprecated в RFC 3986. Обратный слеш (\) - разделитель пути в WHATWG, но обычный символ в RFC-based парсерах.

Весь этот зоопарк стандартов - не академический курьёз. По данным совместного исследования Snyk и Claroty (Team82, «URL Parsing Confusion», 2022), проанализировавших URL-парсинг-библиотеки на разных языках, несоответствия укладываются в четыре категории.

Четыре категории рассинхрона парсеров​

КатегорияСутьPayload-пример
Scheme confusionПарсеры по-разному обрабатывают отсутствие схемыjavascript:alert(1) vs //host
Slash confusionКоличество слешей влияет на разбор authority vs path///evil.com - путь или хост?
Backslash confusion\ = / в WHATWG, но не в RFC-парсерахhttps://evil\@legit.com
URL-encoded confusionДекодирование %2F, %2E до или после нормализации/public/%2e%2e/admin

Для обхода контроля доступа nginx через реверс-прокси самая опасная - четвёртая категория. Суть: прокси принимает решение о маршрутизации по пути до полного декодирования, а бэкенд декодирует до обработки запроса. Возникает differential parsing vulnerability - два компонента видят один HTTP-запрос как обращение к разным ресурсам.

Парсеры разных языков следуют разным стандартам (обобщение на основе документации и тестирования):

Язык / библиотекаСтандарт\ как /Примечание
Node.js (new URL)WHATWGДаСовместимость с браузерами
Python urllibRFC 3986 (с отклонениями)НетНетипичное поведение среди RFC-парсеров
Go net/urlRFC 3986 (строгий)Ошибка парсингаОтклоняет неоднозначный ввод
Ruby URIRFC 3986 (строгий)Ошибка парсингаОтклоняет неоднозначный ввод
PHP parse_urlКастомныйНетСвоя реализация, со своими сюрпризами
Java URIRFC 2396НетСтарый стандарт

Когда nginx (C-based парсер, RFC-ориентированный) стоит перед Node.js-бэкендом (WHATWG) - рассинхрон практически гарантирован. Go-прокси перед Python-бэкендом, Apache перед Tomcat - та же история. Каждая комбинация - свой набор edge cases, и на каждом проекте приходится проверять заново.

Реверс-прокси уязвимости: конфигурация nginx proxy_pass​

Misconfiguration reverse proxy в nginx чаще всего связана с директивой proxy_pass. Ключевой момент - указан ли URI (путь) в директиве. Разница в одном символе полностью меняет поведение нормализации путей URI.
NGINX:
# ПОВЫШЕННЫЙ РИСК: proxy_pass с путём (trailing slash)
location /api/ {
    proxy_pass http://backend:8080/;
}

# МЕНЕЕ РИСКОВАННО: proxy_pass без пути (не гарантирует полную защиту)
location /api/ {
    proxy_pass http://backend:8080;
}
Когда URI указан (даже просто /), nginx подставляет в проксируемый запрос декодированный и нормализованный путь - переменную $uri. Без URI nginx передаёт оригинальный сырой запрос. Один слеш - и поведение диаметрально противоположное.

Что происходит при обработке закодированного пути​

Запрос: GET /api/%2e%2e/secret

С trailing slash (уязвимый конфиг):
  1. nginx декодирует %2e%2e в .. при построении $uri
  2. nginx нормализует путь: /secret
  3. location /api/ совпало на этапе matching - deny-правил нет
  4. Путь после отрезки /api/: /../secret → нормализуется до /secret
  5. Бэкенд получает: GET /secret - ACL обойден
Замечание про %2F: encoded slash обрабатывается nginx иначе, чем %2e (encoded dot). В большинстве конфигураций nginx либо блокирует %2F, либо трактует его буквально, не декодируя в /. Основной вектор обхода - именно декодирование %2e в . и последующая нормализация ...

Без trailing slash (менее рискованный конфиг, но не серебряная пуля - location-matching в nginx всё равно выполняется по нормализованному URI):
  1. nginx передаёт сырой URI: /api/%2e%2e/secret
  2. Бэкенд получает закодированный путь без нормализации на стороне прокси
  3. Дальнейшая обработка - ответственность бэкенда
Location-matching в nginx выполняется по декодированному и нормализованному URI (это поведение документировано в исходном коде nginx и подтверждается экспериментально). Запрос /admin/%2e%2e/admin будет нормализован до /admin при matching - location /admin сработает и ACL отработает. Но если ACL стоит на /admin, а запрос идёт к /public/%2e%2e/admin, то при proxy_pass с trailing slash nginx нормализует путь до /admin на этапе matching, и deny сработает. Обход возможен, когда нормализованный путь не совпадает с location, на котором стоит ACL - например, при частичном декодировании или когда nginx не декодирует конкретную последовательность, но бэкенд декодирует.

Конфигурации с trailing slash массово копируются из документации, StackOverflow и туториалов. Уязвимости reverse proxy настройки такого типа - одна из самых частых находок на проектах с API-сервисами. Копипаста из туториала - и контроль доступа превращается в декорацию.

Payload'ы для обхода ACL через нормализацию URL​

Конкретные payload'ы, которые проверяю на каждом пентесте с reverse proxy. Все отправляю через curl --path-as-is - без этого флага curl нормализует .. на стороне клиента и payload не дойдёт до прокси в сыром виде.

Допустим, ACL запрещает доступ к /admin:

📚 Часть контента скрыта. Этот материал доступен участникам сообщества с рангом One Level или выше
Получить доступ просто — достаточно зарегистрироваться и проявить активность на форуме

Bash:
# Базовый тест encoded traversal
curl -v --path-as-is "https://target/public/%2e%2e/admin"
# Backslash confusion
curl -v --path-as-is "https://target/public/..%5c/admin"
# Double encoding (если бэкенд декодирует дважды)
curl -v --path-as-is "https://target/public/%252e%252e/admin"
# Path parameter для Tomcat-бэкендов
curl -v --path-as-is "https://target/admin;test=1"

Backslash confusion: WHATWG против RFC​

Обратный слеш \ (URL-encoded: %5C) - недооценённый вектор для нормализации URL атаки. WHATWG-совместимые парсеры (браузеры, Node.js new URL()) трактуют \ как /. RFC-based парсеры (Apache apr_uri_parse(), Python urllib) - нет.

Отсюда конкретная path confusion атака: если прокси использует RFC-парсер, а бэкенд или итоговый клиент (браузер) - WHATWG, путь /safe\..\/admin будет интерпретирован по-разному на каждом уровне. Прокси видит один хост или путь, браузер - другой. Красота.

Semicolon: специфика Tomcat​

Apache Tomcat трактует ; в пути как начало path parameters (поведение из RFC 2396, deprecated в RFC 3986). Nginx этого не делает.

Запрос /admin;jsessionid=xxx - nginx сопоставляет с location, которого может не быть (нет location для /admin;jsessionid=xxx). Tomcat отрезает ;jsessionid=xxx и обрабатывает /admin. Различия парсинга URL прокси и бэкенда здесь работают как bypass access control без какого-либо декодирования. Просто точка с запятой - и ACL мимо.

CVE: path confusion атака в реальных продуктах​

CVE-2007-0450: Tomcat directory traversal через прокси​

CVE-2007-0450 (CWE-22) затрагивала Apache Tomcat 5.x до 5.5.22 и 6.x до 6.0.10 при использовании proxy-модулей (mod_proxy, mod_rewrite, mod_jk). Комбинации /, \ и %5C позволяли читать произвольные файлы через path traversal - эти символы валидные разделители пути в Tomcat, но не в Apache, что и создаёт path confusion между прокси-модулем и бэкендом. Для уязвимости есть подтверждённый эксплойт (EDB-29739, автор D. Matscheko).

Именно после CVE-2007-0450 Tomcat ввёл параметр ALLOW_ENCODED_SLASH (по умолчанию false), а Apache - директиву AllowEncodedSlashes. 2007 год, а на проектах до сих пор встречаю конфиги, где ALLOW_ENCODED_SLASH=true включено «для совместимости».

CVE-2021-32786: backslash в mod_auth_openidc​

Уязвимость в mod_auth_openidc до версии 2.4.9 (CVSS 4.7, MEDIUM, вектор CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:C/C:N/I:L/A:N). CWE-601 (Open Redirect).

Функция oidc_validate_redirect_url() использовала apr_uri_parse(), парсящую URL по RFC 2396/3986. Браузеры следуют WHATWG. Атакующий отправлял https://evil.com\@legit.com/ в параметре logout - модуль Apache видел evil.com\ как userinfo (часть до @), а host считал legit.com. Валидация проходила. Браузер же интерпретировал \ как / по WHATWG и перенаправлял пользователя на evil.com.

Фикс оказался до смешного простым: замена всех backslash на forward slash перед валидацией.

Аналогичная уязвимость - CVE-2019-3877 в mod_auth_mellon до v0.14.2 (CWE-601): backslash в logout URL проходил валидацию как относительный путь, но браузер трактовал его как абсолютный URL. Один и тот же паттерн, разные модули, разные годы.

CVE-2022-0691: control characters в url-parse​

Уязвимость в NPM-пакете url-parse до версии 1.5.9 (CVSS 9.8, CRITICAL, вектор CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H). CWE-639 (Authorization Bypass Through User-Controlled Key).

Regex для обрезки whitespace не покрывал ASCII control characters \x00–\x1f. Атакующий отправлял \x00http://evil.com - url-parse возвращал некорректный host, обходя allowlist. Результат: SSRF или bypass аутентификации.

Кроме CVE-2022-0691, url-parse был подвержен path traversal (GHSA-9m6j-fcg5-2442), некорректному парсингу @ (GHSA-8v38-pw62-9cw2) и недостаточной валидации (GHSA-46c4-8wrp-j99v). Библиотека с 7+ миллионами скачиваний в неделю, и в ней - целый букет проблем парсинга. Классический пример уязвимого компонента по OWASP A06:2021 - Vulnerable and Outdated Components.

Fingerprinting парсеров и защита от обхода ACL​

Recon: определение парсера на каждом уровне​

Перед эксплуатацией нужен fingerprinting стека - определить, как каждый уровень обрабатывает encoded path. Это фаза Vulnerability Scanning (T1595.002, Reconnaissance по MITRE ATT&CK). Отправляю серию запросов к известному endpoint и сравниваю ответы:

ЗапросОтвет 200Интерпретация
GET /known/%2e./knownДаПрокси декодирует %2e и нормализует ..
GET /known/%2e./known400 / 404Прокси не декодирует или блокирует encoded dots
GET /known/..%5c/knownДаПрокси обрабатывает backslash traversal
GET /known;param/200 (контент /known)Бэкенд вырезает path parameters
GET /%6b%6e%6f%77%6e200 (контент /known)Полная декодировка пути на бэкенде

Если на первый запрос ответ 200 - перед вами уязвимая конфигурация. Дальше определяем, какие location-блоки имеют ACL, и строим payload для конкретного обхода.

Чеклист защиты и детектирования​

На стороне nginx:
  1. Не указывайте путь (URI) в proxy_pass - proxy_pass http://backend; вместо proxy_pass http://backend/;
  2. Блокируйте запросы с encoded traversal-последовательностями: добавьте if ($request_uri ~* "%2[eE]") { return 403; } в server-блок. Осторожно: правило сработает на %2e/%2E в любом месте URI, включая query string - высок риск false positives. Перед применением в продакшне протестируйте на реальном трафике и рассмотрите WAF-правило с учётом позиции в path
  3. Используйте $request_uri (сырой путь) для логирования и security-проверок вместо $uri (декодированный)
  4. Проверьте, что merge_slashes on; включено (по умолчанию - да, но стоит убедиться)
На стороне бэкенда:
  1. Tomcat: оставьте ALLOW_ENCODED_SLASH=false (по умолчанию). Параметр введён после CVE-2007-0450
  2. Node.js: не используйте url-parse ниже 1.5.9 для security-решений. Предпочитайте встроенный new URL() (WHATWG)
  3. Django/Flask: валидируйте путь после нормализации, не до. Middleware нормализации должен отрабатывать раньше ACL
  4. Нормализуйте URL до канонической формы до применения ACL - единственный надёжный подход при нескольких парсерах в цепочке
Детектирование в логах:

Ищите в access-логах запросы с закодированными traversal-символами:
Bash:
grep -iE '%2[eEfF]|%5[cC]' /var/log/nginx/access.log
Логируйте $request_uri и $uri рядом. Если они различаются для одного запроса и запрос прошёл на бэкенд - нормализация произошла, и это потенциальный вектор path traversal через прокси.

Значительная часть проектов с nginx перед бэкендом, которые я тестировал за последние два года, имела хотя бы одну конфигурацию proxy_pass с trailing slash. Разработчики копируют этот паттерн из туториалов, не задумываясь, что за ним стоит полная декодировка и нормализация пути. ACL на уровне location-блока nginx в этом случае - иллюзия контроля.

Проблема глубже: индустрия привыкла думать об ACL как о чём-то, что «ставится один раз и работает». На практике два компонента в цепочке с разными URL-парсерами - это не вопрос «если», а вопрос «когда» desync атака HTTP приведёт к обходу. Go-прокси перед Python-бэкендом, nginx перед Tomcat, Envoy перед Express - каждая комбинация со своими вариациями. Стандарт WHATWG и RFC 3986 не сойдутся никогда - слишком много legacy, слишком много обратной совместимости. URL parser confusion как класс уязвимостей будет жить ещё долго.

Единственная рабочая стратегия - не доверять ни одному уровню по отдельности: проверять ACL и на прокси, и на бэкенде, тестировать рассинхрон при каждом изменении стека. На HackerLab лежит сценарий, где подобный primitive нужно собрать в полную цепочку - от encoded traversal до доступа к закрытым endpoint'ам за proxy.
Полезно

Комментарии

0