Ограничители частоты запросов — тихие рабочие лошадки прикладной безопасности: они стоят перед эндпоинтами входа и превращают онлайн-подбор паролей из практичной атаки в непрактичную. Но ограничитель хорош ровно настолько, насколько надёжна идентичность, по которой он считает. Если эту идентичность выбирает атакующий, средство защиты вырождается в декорацию. Именно это происходит в панели Hermes: троттлинг перед
В этой статье разбирается первопричина, прослеживается путь данных от заголовка до двух средств защиты, которые он питает (ограничитель перебора и журнал аудита), приводится воспроизводимый proof-of-concept и формулируются меры устранения и обнаружения.
Краткое резюме
Панель Hermes защищает эндпоинт входа по паролю
Поскольку ключ корзины (bucket) троттлинга контролируется атакующим, неаутентифицированный удалённый вызывающий обходит ограничитель, отправляя уникальное значение
Почему это важно. Снятие троттлинга онлайн-перебора оставляет единственным тормозом стоимость
Базовая оценка — Средняя (6.4), с повышением до Высокой, когда панель доступна за пределами loopback и/или защищена слабым паролем.
Контекст
Затронутый продукт
Затронутый продукт — панель Hermes, веб-интерфейс управления проекта Hermes CLI. Широкий компонент —
Поверхность аутентификации панели
На рассматриваемой конфигурации поверхность аутентификации панели имеет следующие свойства:
Зачем существует ограничитель частоты
Эндпоинт входа по паролю по определению является оракулом для подбора учётных данных: каждый запрос возвращает различимый сигнал успеха/неудачи. Стандартная мера против онлайн-подбора — анти-автоматизация, чаще всего ограничение частоты, лимитирующее число попыток одного клиента в окне. Hermes реализует именно это: троттлинг 10 попыток за 60 секунд перед
Замысел проектирования верен. Дефект не в наличии ограничителя, а в идентичности, которую он использует, чтобы решить, какие попытки чьему клиенту принадлежат.
Зачем существует
Чтобы ограничивать «на клиента», серверу нужен ключ на клиента. Естественный ключ — IP-адрес источника. Функция
1. Как ключ корзины для ограничителя перебора.
2. Как IP источника, записываемый в журнал аудита.
Уязвимость целиком заключена в том, как
Обзор уязвимости
Первопричина
Класс уязвимости
Это ошибка конфигурации безопасности / неправильное управление аутентификацией, коренящаяся в использовании недоверенного HTTP-заголовка при принятии решения о безопасности (ограничение частоты и аудит). Сопоставленные слабости:
Поверхность атаки
Предусловия
1. Аутентификация не требуется — эндпоинт доступен до аутентификации.
2. Провайдер
3. Сетевая доступность — любой хост, способный достучаться до TCP/9119 на привязанном интерфейсе.
4. Нет фронтового прокси/WAF, удаляющего или нормализующего
Дополнительное, отдельное условие определяет, приведёт ли обход к компрометации аккаунта: стойкость настроенного пароля панели. Обход снимает троттлинг, но подбор всё ещё должен оказаться успешным против установленного пароля — это две независимые величины.
Анализ первопричины
Пересечённая граница доверия
Любое сетевое средство защиты должно решить, чему оно доверяет. Для ограничителя частоты на клиента единственная доверенная идентичность, доступная серверу без дополнительной инфраструктуры — это транспортный пир, IP-адрес на другом конце TCP-соединения (
Соответствующие места в коде (номера строк приблизительны):
Сбой валидации
Отсутствует проверка происхождения
Вторичный сбой валидации касается того, какой хоп читать, даже когда заголовок присутствует законно. Самый левый элемент — наименее доверенная позиция (значение, наиболее удалённое от сервера и наиболее близкое к исходному, возможно враждебному, клиенту). Корректная реализация читает самый правый хоп, добавленный самим доверенным прокси.
Логическая ошибка в ограничителе
Алгоритм ограничителя обычен: считать попытки на ключ, отказывать, как только счётчик превысит 10 за 60 секунд. Логическая ошибка не в подсчёте; она в выборе ключа. Ограничитель частоты хорош ровно настолько, насколько стабилен и аутентичен его ключ. Если ключ выбирается атакующим и неограничен по мощности множества, атакующий гарантирует свежую, пустую корзину для каждого запроса, и счётчик никогда не достигнет порога. Средство защиты вырождается в no-op против атакующего, который просто варьирует заголовок.
(Замечание на полях: неограниченное пространство ключей также подразумевает неограниченный рост состояния, если корзины не вытесняются агрессивно — потенциальный вторичный риск памяти, выходящий за рамки основной находки.)
Слабость аутентификации/авторизации
Важно точно сформулировать, что здесь слабо, а что нет:
Технический разбор
Шаг 1 — Приходит запрос
Клиент отправляет:
Тело выбирает провайдер
Шаг 2 — Сервер вычисляет идентичность клиента
Обработчик вызывает
Шаг 3 — Ограничитель проверяет корзину для этой идентичности
Шаг 4 — Выполняется проверка учётных данных (или пропускается)
Если ограничитель разрешает попытку,
Шаг 5 — Попытка записывается с подделанным IP
Почему эксплуатация удаётся
Эксплуатация удаётся, потому что пространство ключей, контролируемое атакующим, фактически неограниченно, а безопасность ограничителя целиком зависит от стабильности и аутентичности ключа. Атакующему не нужно ломать логику подсчёта, гнаться за окном или исчерпывать ресурс. Он просто обеспечивает, чтобы никакие две попытки не делили корзину. Порог троттлинга становится недостижимым по построению.
Выражено упрощённым псевдокодом (иллюстрация логики, не дословный исходник):
Proof of Concept
PoC структурирован как три наблюдения — контроль, обход и подтверждение — которые вместе изолируют заголовок как причину.
A. Контроль — троттлинг работает для одного источника
Повторяющийся
Почему это важно. Это базовая линия: когда ключ стабилен, ограничитель ведёт себя корректно и видимо троттлит. Без этого контроля последующее отсутствие
B. Обход — вращающийся заголовок никогда не троттлится
Повторение того же неверного запроса с уникальным значением
Высокоуровневая форма запроса:
Почему это работает. Каждое уникальное значение заголовка выбирает другую, пустую корзину троттлинга. Ни одна корзина не накапливает достаточно попыток, чтобы сработал порог, поэтому ограничитель никогда не отказывает. Атакующему фактически выдаётся свежий бюджет попыток на каждый запрос.
C. Подтверждение — ограничитель всё ещё жив для реального пира
Сразу после прогона обхода один запрос без
Почему это важно. Это ключевой контроль, исключающий гипотезу «ограничитель был просто выключен». Корзина реального пира — насыщенная ещё в наблюдении A — всё ещё над бюджетом, поэтому запрос без заголовка по-прежнему троттлится. Единственное, что изменилось между «троттлится» и «не троттлится» — наличие вращающегося
Сводная таблица
Демонстрационный скрипт
Скрипт ниже автоматизирует тот же эксперимент A/B/A. Он использует заведомо неверные, фиксированные учётные данные и измеряет отсутствие
Ожидаемый вывод против уязвимого экземпляра
Вердикт намеренно консервативен: VULNERABLE сообщается только когда фаза обхода не видела
Влияние на безопасность
Оценка CVSS v3.1 — 6.4 (Средняя),
Главное: включение высокоскоростного перебора учётных данных
При снятом троттлинге единственным оставшимся тормозом подбора является стоимость
Эскалация до удалённого выполнения кода
Линия к RCE чёткая, но условная:
1. Успешно угаданный пароль панели даёт полный контроль над панелью.
2. Полный контроль включает JSON-RPC шлюз по
3.
Именно поэтому серьёзность повышается до Высокой, когда панель доступна за пределами loopback и/или пароль слабый. RCE находится ниже по течению от успешного входа — оно недостижимо одним лишь обходом. Обход делает подбор практичным; сам по себе он код не выполняет.
Повышение привилегий
Косвенное: успешная аутентификация даёт полную операторскую роль одно-машинной панели. Отдельного примитива эскалации от локального к администратору нет — «эскалация» в том, что аутентифицированный доступ равен операторскому доступу в этом одно-машинном дизайне.
Целостность журнала аудита / отказуемость
Поскольку
Отказ в обслуживании
Нарушение сервиса — низкое / косвенное: неограниченный трафик входа стоит CPU на
Реалистичный сценарий атаки
1. Разведка. Атакующий достигает TCP/9119 на экземпляре, привязанном к
2. Обход троттлинга. Атакующий скриптует неверные-затем-кандидатные входы против
3. Подбор. Ограниченный лишь стоимостью
4. Вход → операторская роль. Верная догадка даёт полный контроль над панелью.
5.
6. Загрязнение журналов на всём пути. Каждый шаг логируется под выбранными атакующим IP источника.
Шаги 1–3 включены напрямую; шаги 4–5 условны при успешном подборе; шаг 6 идёт параллельно начиная с шага 2.
Обнаружение
Наиболее надёжные сигналы обнаружения поведенческие, а не сигнатурные — они выражаются последовательностями HTTP-статусов.
Основные поведенческие индикаторы
Самый отличительный сигнал — сочетание: большое число неудачных входов (
Примеры индикаторов журнала/HTTP
(Иллюстративные формы, не реальные дампы.)
Идеи для SIEM / мониторинга
Заключение
Это дефект использования недоверенного ввода (CWE-807) в троттлинге входа панели Hermes. Функция
Значимость не в новизне — доверие к
Оценённая как Средняя (6.4), проблема повышается до Высокой, когда панель доступна за пределами loopback и/или защищена слабым паролем — потому что успешный подбор даёт полный операторский контроль над панелью, включая возможность
Приоритеты устранения, по порядку: применить исправление
POST /auth/password-login завязан на HTTP-заголовок X-Forwarded-For — значение, которое клиент полностью контролирует.В этой статье разбирается первопричина, прослеживается путь данных от заголовка до двух средств защиты, которые он питает (ограничитель перебора и журнал аудита), приводится воспроизводимый proof-of-concept и формулируются меры устранения и обнаружения.
Краткое резюме
Панель Hermes защищает эндпоинт входа по паролю
POST /auth/password-login ограничителем частоты, который должен лимитировать перебор учётных данных значением 10 попыток за 60 секунд на одного клиента. Ограничитель определяет «клиента» по заголовку X-Forwarded-For (XFF) — части запроса, целиком формируемой отправителем.Поскольку ключ корзины (bucket) троттлинга контролируется атакующим, неаутентифицированный удалённый вызывающий обходит ограничитель, отправляя уникальное значение
X-Forwarded-For в каждом запросе. Каждый запрос попадает в новую корзину, счётчик на клиента никогда не насыщается, и 429 Too Many Requests не возвращается. То же недоверенное значение записывается в журнал аудита как IP-адрес источника, что позволяет атакующему подделывать произвольные адреса источника в записях безопасности.
КомпонентПанель Hermes (
web_server / dashboard-auth), POST /auth/password-loginУязвимая функция_client_ip() в hermes_cli/dashboard_auth/routes.pyВерсия0.17.0
Почему это важно. Снятие троттлинга онлайн-перебора оставляет единственным тормозом стоимость
scrypt на попытку — порядка миллисекунд. На экземпляре, привязанном к 0.0.0.0, с активным провайдером basic (логин/пароль) это превращает медленную, ограниченную задачу подбора в быструю. Успешный вход даёт полный контроль над панелью — включая JSON-RPC шлюз с возможностью shell.exec, то есть реальный путь к удалённому выполнению команд на хосте. Сама уязвимость не раскрывает учётные данные; она убирает основанную на частоте защиту, которая иначе сделала бы онлайн-подбор непрактичным.Базовая оценка — Средняя (6.4), с повышением до Высокой, когда панель доступна за пределами loopback и/или защищена слабым паролем.
Контекст
Затронутый продукт
Затронутый продукт — панель Hermes, веб-интерфейс управления проекта Hermes CLI. Широкий компонент —
web_server / dashboard-auth; конкретный код находится в hermes_cli/dashboard_auth/routes.py. Поведение подтверждено на версии 0.17.0.Поверхность аутентификации панели
На рассматриваемой конфигурации поверхность аутентификации панели имеет следующие свойства:
- Работает в «огороженном» (gated) режиме — доступ закрыт аутентификацией.
- Привязывается к
0.0.0.0, поэтому эндпоинт доступен из любой сети, к которой подключён хост, а не только из loopback. - Активный провайдер аутентификации —
basic— шлюз логин/пароль, реализованныйBasicAuthProvider(plugins/dashboard_auth/basic). - API входа по учётным данным —
POST /auth/password-login, обслуживающий форму входа панели.
0.0.0.0, в терминах конфигурации внешне доступен на TCP/9119, даже если конкретное тестирование выполняется через loopback (http://127.0.0.1:9119/).Зачем существует ограничитель частоты
Эндпоинт входа по паролю по определению является оракулом для подбора учётных данных: каждый запрос возвращает различимый сигнал успеха/неудачи. Стандартная мера против онлайн-подбора — анти-автоматизация, чаще всего ограничение частоты, лимитирующее число попыток одного клиента в окне. Hermes реализует именно это: троттлинг 10 попыток за 60 секунд перед
POST /auth/password-login.Замысел проектирования верен. Дефект не в наличии ограничителя, а в идентичности, которую он использует, чтобы решить, какие попытки чьему клиенту принадлежат.
Зачем существует
_client_ip()Чтобы ограничивать «на клиента», серверу нужен ключ на клиента. Естественный ключ — IP-адрес источника. Функция
_client_ip() в routes.py отвечает на вопрос «каков IP-адрес источника этого запроса?», и её возвращаемое значение используется в двух важных для безопасности местах:1. Как ключ корзины для ограничителя перебора.
2. Как IP источника, записываемый в журнал аудита.
Уязвимость целиком заключена в том, как
_client_ip() отвечает на этот вопрос.Рисунок 1. Возвращаемое значение одной функции питает два независимых средства защиты
Не удалось прогрузить медиа
Обзор уязвимости
Первопричина
_client_ip() выводит IP источника запроса из заголовка X-Forwarded-For. Этот заголовок является частью запроса и полностью контролируется тем, кто запрос отправляет. Уязвимая реализация принимает X-Forwarded-For от любого вызывающего, без проверки того, является ли непосредственный пир доверенным прокси, и читает самый левый разделённый запятыми элемент — общепринятую позицию «исходного клиента». Тем самым контролируемое клиентом значение трактуется как доверенный факт транспортного уровня, и клиент получает возможность выбирать собственную корзину троттлинга и собственный записываемый адрес источника.Класс уязвимости
Это ошибка конфигурации безопасности / неправильное управление аутентификацией, коренящаяся в использовании недоверенного HTTP-заголовка при принятии решения о безопасности (ограничение частоты и аудит). Сопоставленные слабости:
CWE
Название
Релевантность
CWE-807Использование недоверенных входов при принятии решения о безопасностиРешение об ограничении частоты завязано на контролируемый клиентом заголовок.CWE-290Обход аутентификации путём подделки (spoofing)Подделка идентичности источника обходит анти-автоматизацию, защищающую аутентификацию.CWE-117Неправильная нейтрализация вывода для журналовНедоверенное значение записывается в журнал аудита как IP источника, что позволяет подделку записей.
Поверхность атаки
- Точка входа:
POST /auth/password-login(API, обслуживающий форму входа панели). - Канал: HTTP/HTTPS по сети. При привязке к
0.0.0.0эндпоинт доступен из любой подключённой сети. - Доставка: единственный заголовок запроса —
X-Forwarded-For— установленный в уникальное значение на запрос, рядом с обычным JSON-телом входа. - Область: удалённая, доступная по сети, неаутентифицированная.
Предусловия
1. Аутентификация не требуется — эндпоинт доступен до аутентификации.
2. Провайдер
basic является активным шлюзом.3. Сетевая доступность — любой хост, способный достучаться до TCP/9119 на привязанном интерфейсе.
4. Нет фронтового прокси/WAF, удаляющего или нормализующего
X-Forwarded-For до того, как запрос достигнет сервиса.Дополнительное, отдельное условие определяет, приведёт ли обход к компрометации аккаунта: стойкость настроенного пароля панели. Обход снимает троттлинг, но подбор всё ещё должен оказаться успешным против установленного пароля — это две независимые величины.
Анализ первопричины
Пересечённая граница доверия
Любое сетевое средство защиты должно решить, чему оно доверяет. Для ограничителя частоты на клиента единственная доверенная идентичность, доступная серверу без дополнительной инфраструктуры — это транспортный пир, IP-адрес на другом конце TCP-соединения (
request.client.host). Это значение устанавливается сетевым стеком при установлении соединения и не может быть подделано полезной нагрузкой запроса прикладного уровня.X-Forwarded-For, напротив, живёт внутри запроса. Это подсказка, которую по соглашению добавляют обратные прокси, чтобы сохранить исходный IP клиента после терминирования и повторной инициации соединения прокси. Существенно, что:- Когда запрос приходит напрямую (без доверенного прокси спереди), любой присутствующий
X-Forwarded-Forпомещён туда клиентом и ничего не значит. - Даже за прокси значим только тот хоп, который добавил сам доверенный прокси; всё слева от него могло быть подставлено клиентом.
_client_ip() использует X-Forwarded-For, не устанавливая, является ли непосредственный пир доверенным прокси — то есть трактует подсказку прикладного уровня как факт транспортного уровня. Граница доверия между «что нам сообщила сеть» и «что нам сообщил вызывающий» стёрта.Рисунок 2. Граница доверия: когда X-Forwarded-For что-то значит, а когда нет. Схема границы доверия. Слева: прямой клиент → сервер, гдеX-Forwarded-Forзатенён как «контролируемый атакующим». Справа: клиент → доверенный прокси → сервер, где затенён как «доверенный» только самый правый хоп. Уязвимый код трактует левую схему так, как если бы это была правая.
Не удалось прогрузить медиа
Рисунок 3. Поток данных. Единственный загрязнённый источник (X-Forwarded-For) → два средства защиты.
Не удалось прогрузить медиа
Соответствующие места в коде (номера строк приблизительны):
Символ
Расположение
Роль
_client_ip()routes.py ~106–110Выводит IP источника из X-Forwarded-For. Первопричина._password_rate_limited()routes.py ~410–429Использует _client_ip() как ключ корзины троттлинга.auth_password_login()routes.py ~445–473Обработчик POST /auth/password-login.audit_log(..., ip=...)по всему модулюЗаписывает то же значение _client_ip() как IP источника (старт/успех/неудача входа, выход, события WS-тикетов).BasicAuthProviderplugins/dashboard_auth/basicАктивный провайдер учётных данных, против которого ведётся атака.
Сбой валидации
Отсутствует проверка происхождения
X-Forwarded-For: перед тем как принять заголовок, код должен подтвердить, что непосредственный транспортный пир — это явно доверенный прокси. Без этой проверки нет точки, в которой устанавливается доверенность заголовка, поэтому любое значение от любого вызывающего принимается за чистую монету.Вторичный сбой валидации касается того, какой хоп читать, даже когда заголовок присутствует законно. Самый левый элемент — наименее доверенная позиция (значение, наиболее удалённое от сервера и наиболее близкое к исходному, возможно враждебному, клиенту). Корректная реализация читает самый правый хоп, добавленный самим доверенным прокси.
Логическая ошибка в ограничителе
Алгоритм ограничителя обычен: считать попытки на ключ, отказывать, как только счётчик превысит 10 за 60 секунд. Логическая ошибка не в подсчёте; она в выборе ключа. Ограничитель частоты хорош ровно настолько, насколько стабилен и аутентичен его ключ. Если ключ выбирается атакующим и неограничен по мощности множества, атакующий гарантирует свежую, пустую корзину для каждого запроса, и счётчик никогда не достигнет порога. Средство защиты вырождается в no-op против атакующего, который просто варьирует заголовок.
(Замечание на полях: неограниченное пространство ключей также подразумевает неограниченный рост состояния, если корзины не вытесняются агрессивно — потенциальный вторичный риск памяти, выходящий за рамки основной находки.)
Слабость аутентификации/авторизации
Важно точно сформулировать, что здесь слабо, а что нет:
- Сама проверка учётных данных укреплена корректно: провайдер
basicиспользует хешированиеscrypt, сравнение имени и пароля за константное время с веткой dummy-хеша, сессии, подписанные HMAC, и не регистрирует учётных данных по умолчанию. - Слабость в слое анти-автоматизации, стоящем перед этой проверкой. Троттлинг, который должен делать онлайн-подбор непрактичным, можно убрать по желанию.
Технический разбор
Шаг 1 — Приходит запрос
Клиент отправляет:
HTTP:
POST /auth/password-login HTTP/1.1
Host: <target>:9119
Content-Type: application/json
X-Forwarded-For: <value>
{"provider":"basic","username":"<guess>","password":"<guess>","next":"/"}
basic и передаёт догадку логин/пароль. next — цель перенаправления после входа.Шаг 2 — Сервер вычисляет идентичность клиента
Обработчик вызывает
_client_ip(request). На уязвимой сборке это возвращает IP, выведенный из заголовка X-Forwarded-For, а не из транспортного пира. Что бы клиент ни поместил в этот заголовок, оно становится «IP источника» запроса до конца работы обработчика.Шаг 3 — Ограничитель проверяет корзину для этой идентичности
_password_rate_limited() ищет счётчик для ключа из шага 2 и спрашивает: совершил ли этот ключ 10 попыток за последние 60 секунд?- Для стабильного ключа (тот же источник в каждом запросе) счётчик растёт: попытка 1, 2, 3…, и как только он превысит бюджет, ограничитель возвращает
429 Too Many Requests. - Для вращающегося ключа (новый
X-Forwarded-Forв каждом запросе) каждый поиск нацелен на другой счётчик, каждый начинается с нуля. Порог никогда не достигается ни для одного ключа.
Шаг 4 — Выполняется проверка учётных данных (или пропускается)
Если ограничитель разрешает попытку,
auth_password_login() переходит к проверке через BasicAuthProvider. С неверными данными результат — 401 Unauthorized. С верными это был бы успешный вход. Поскольку во вращающемся случае ограничитель никогда не срабатывает, каждая попытка достигает проверки.Шаг 5 — Попытка записывается с подделанным IP
audit_log(..., ip=...) записывает событие, используя то же значение _client_ip(). Старт/успех/неудача входа (а также выход и события WS-тикетов) помечаются выбранным атакующим адресом, а не реальным пиром.Почему эксплуатация удаётся
Эксплуатация удаётся, потому что пространство ключей, контролируемое атакующим, фактически неограниченно, а безопасность ограничителя целиком зависит от стабильности и аутентичности ключа. Атакующему не нужно ломать логику подсчёта, гнаться за окном или исчерпывать ресурс. Он просто обеспечивает, чтобы никакие две попытки не делили корзину. Порог троттлинга становится недостижимым по построению.
Выражено упрощённым псевдокодом (иллюстрация логики, не дословный исходник):
Python:
# Уязвимая форма
def _client_ip(request):
# Принимает X-Forwarded-For от любого вызывающего, без проверки доверенного прокси.
xff = request.headers.get("x-forwarded-for")
if xff:
return xff.split(",")[0].strip() # самый левый == наименее доверенный
return request.client.host
def auth_password_login(request, body):
key = _client_ip(request)
if _password_rate_limited(key): # на ключ 10 / 60 с
return Response(429)
ok = BasicAuthProvider.verify(body.username, body.password)
audit_log("login_attempt", ip=key, success=ok) # логирует подделываемый ключ
return Response(200 if ok else 401)
Proof of Concept
PoC структурирован как три наблюдения — контроль, обход и подтверждение — которые вместе изолируют заголовок как причину.
A. Контроль — троттлинг работает для одного источника
Повторяющийся
POST /auth/password-login от одного источника без X-Forwarded-For возвращает 401 Unauthorized для первых попыток, затем 429 Too Many Requests, как только исчерпан бюджет 10 за 60 с.Почему это важно. Это базовая линия: когда ключ стабилен, ограничитель ведёт себя корректно и видимо троттлит. Без этого контроля последующее отсутствие
429 можно было бы ошибочно списать на сломанный или отсутствующий ограничитель, а не на обход.B. Обход — вращающийся заголовок никогда не троттлится
Повторение того же неверного запроса с уникальным значением
X-Forwarded-For на каждый запрос возвращает 401 для каждой попытки — 429 никогда не возникает, хотя корзина реального пира уже насыщена.Высокоуровневая форма запроса:
HTTP:
POST /auth/password-login HTTP/1.1
Host: <target>:9119
Content-Type: application/json
X-Forwarded-For: <уникальное-значение-на-запрос>
{"provider":"basic","username":"<guess>","password":"<guess>","next":"/"}
C. Подтверждение — ограничитель всё ещё жив для реального пира
Сразу после прогона обхода один запрос без
X-Forwarded-For всё ещё возвращает 429, доказывая, что ограничитель остаётся активным для реального пира и что один лишь заголовок его побеждает.Почему это важно. Это ключевой контроль, исключающий гипотезу «ограничитель был просто выключен». Корзина реального пира — насыщенная ещё в наблюдении A — всё ещё над бюджетом, поэтому запрос без заголовка по-прежнему троттлится. Единственное, что изменилось между «троттлится» и «не троттлится» — наличие вращающегося
X-Forwarded-For.Сводная таблица
СценарийЗаголовокРезультатОдин источник, повторнонет
Три наблюдения образуют чистый экспериментальный дизайн A/B/A: рабочий контроль, изменяемая переменная и возврат к контролю, показывающий, что нижележащее состояние не менялось. Именно это делает PoC убедительным, ни разу не нуждаясь в действительном учётном данном.401 ×9 → 429 (троттлится)Вращающийся подделанный заголовокX-Forwarded-For: <уникальный>401 на всех попытках (не троттлится)Контроль после обходанет429 (ограничитель всё ещё активен)
Демонстрационный скрипт
Скрипт ниже автоматизирует тот же эксперимент A/B/A. Он использует заведомо неверные, фиксированные учётные данные и измеряет отсутствие
429: он демонстрирует дефект контроля (обход троттлинга), это не инструмент подбора паролей, и он не содержит словаря.Спойлер: Демонстратор обхода троттлинга через X-Forwarded-For.
Python:
#!/usr/bin/env python3
"""
Демонстратор обхода троттлинга через X-Forwarded-For.
Использует фиксированные, заведомо НЕВЕРНЫЕ учётные данные. Демонстрирует отсутствие
троттлинга; НЕ подбирает пароли.
"""
import argparse
import sys
from collections import Counter
import requests # pip install requests
LOGIN_PATH = "/auth/password-login"
def unique_xff(i: int) -> str:
# 198.18.0.0/15 — диапазон для бенчмаркинга (RFC 2544), немаршрутизируемое тестовое пространство.
return f"198.{18 + ((i >> 16) & 1)}.{(i >> 8) & 0xFF}.{i & 0xFF}"
def attempt(session: requests.Session, base: str, xff: str | None) -> int:
headers = {"Content-Type": "application/json"}
if xff is not None:
headers["X-Forwarded-For"] = xff
body = {
"provider": "basic",
"username": "poc-invalid", # заведомо неверно — подбор не выполняется
"password": "poc-invalid",
"next": "/",
}
r = session.post(base + LOGIN_PATH, json=body, headers=headers, timeout=10)
return r.status_code
def phase(label: str, session, base, count, rotate: bool) -> Counter:
codes = Counter()
for i in range(count):
xff = unique_xff(i) if rotate else None
codes[attempt(session, base, xff)] += 1
shape = ", ".join(f"{c}x{n}" for c, n in sorted(codes.items()))
print(f"[{label}] {count} requests (rotate_xff={rotate}) -> {shape}")
return codes
def main() -> int:
ap = argparse.ArgumentParser(description="X-Forwarded-For throttle-bypass demo")
ap.add_argument("base", help="Базовый URL ВАШЕГО авторизованного экземпляра, напр. http://127.0.0.1:9119")
ap.add_argument("--budget", type=int, default=10, help="Бюджет троттлинга (попыток/окно)")
args = ap.parse_args()
base = args.base.rstrip("/")
with requests.Session() as s:
# A — насыщаем корзину реального пира (бюджет + пара лишних).
a = phase("A control ", s, base, args.budget + 2, rotate=False)
# B — тот же пир, но уникальный подделанный заголовок на каждый запрос.
b = phase("B bypass ", s, base, args.budget * 3, rotate=True)
# C — один запрос без заголовка; пир всё ещё над бюджетом из A.
c = phase("C confirmation", s, base, 1, rotate=False)
bypass_no_429 = b[429] == 0
limiter_alive = (a[429] > 0) and (c[429] > 0)
print("-" * 60)
if bypass_no_429 and limiter_alive:
print("VULNERABLE: вращение X-Forwarded-For обошло троттлинг "
"(нет 429 в фазе B), при этом ограничитель оставался активен для реального пира.")
return 1
if not limiter_alive:
print("INCONCLUSIVE: ограничитель не троттлил контрольные фазы "
"(скорректируйте --budget, либо прокси/WAF нормализует заголовок).")
return 2
print("NOT REPRODUCED: фаза B затроттлена — заголовок не принимается "
"(ожидаемо на исправленном _client_ip()).")
return 0
if __name__ == "__main__":
sys.exit(main())
0.17.0 (иллюстративно; числа зависят от --budget):
Code:
[A control ] 12 requests (rotate_xff=False) -> 401x10, 429x2
[B bypass ] 30 requests (rotate_xff=True) -> 401x30
[C confirmation] 1 requests (rotate_xff=False) -> 429x1
------------------------------------------------------------
VULNERABLE: вращение X-Forwarded-For обошло троттлинг (нет 429 в фазе B),
при этом ограничитель оставался активен для реального пира.
429 и реальный пир демонстративно всё ещё троттлился (фазы A и C обе сработали). Это условие «И» исключает объяснение «ограничитель был просто выключен». Против исправленного _client_ip() фаза B сама затроттлится, и скрипт сообщит NOT REPRODUCED — то есть скрипт служит одно-командной регрессионной проверкой исправления.Влияние на безопасность
Оценка CVSS v3.1 — 6.4 (Средняя),
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:L/A:N: доступная по сети, низкой сложности, неаутентифицированная, без взаимодействия проблема с низким влиянием на конфиденциальность (включённый онлайн-подбор) и низким влиянием на целостность (подделка журнала аудита).Главное: включение высокоскоростного перебора учётных данных
При снятом троттлинге единственным оставшимся тормозом подбора является стоимость
scrypt на попытку — порядка миллисекунд. Это реальная, но умеренная стоимость; она замедляет, но не предотвращает высокоскоростной подбор. Практический итог: онлайн-подбор пароля против панели становится осуществимым там, где троттлинг должен был сделать его непрактичным. Две оговорки: обход не раскрывает учётные данные (успех зависит от стойкости пароля), и стоимость scrypt продолжает применяться к каждой попытке.Эскалация до удалённого выполнения кода
Линия к RCE чёткая, но условная:
1. Успешно угаданный пароль панели даёт полный контроль над панелью.
2. Полный контроль включает JSON-RPC шлюз по
/api/ws (tui_gateway.server), который предоставляет возможность shell.exec.3.
shell.exec на хосте панели — реальный путь к удалённому выполнению команд.Именно поэтому серьёзность повышается до Высокой, когда панель доступна за пределами loopback и/или пароль слабый. RCE находится ниже по течению от успешного входа — оно недостижимо одним лишь обходом. Обход делает подбор практичным; сам по себе он код не выполняет.
Повышение привилегий
Косвенное: успешная аутентификация даёт полную операторскую роль одно-машинной панели. Отдельного примитива эскалации от локального к администратору нет — «эскалация» в том, что аутентифицированный доступ равен операторскому доступу в этом одно-машинном дизайне.
Целостность журнала аудита / отказуемость
Поскольку
_client_ip() также питает audit_log(..., ip=...), атакующий контролирует IP источника, записываемый для событий старта/успеха/неудачи входа, выхода и WS-тикетов. Последствия:- Подделка / выдача за чужой: попытки можно помечать произвольными адресами, включая адреса невинных третьих сторон или внутренних хостов.
- Сбивание расследования с пути: реагирующие на инцидент могут гнаться за подделанными адресами вместо реального пира.
- Отказуемость: целостность поля IP источника как доказательства подрывается.
I:L вектора CVSS.Отказ в обслуживании
Нарушение сервиса — низкое / косвенное: неограниченный трафик входа стоит CPU на
scrypt за попытку. Практического примитива DoS нет; вектор CVSS фиксирует A:N.Реалистичный сценарий атаки
1. Разведка. Атакующий достигает TCP/9119 на экземпляре, привязанном к
0.0.0.0, без фронтового прокси, удаляющего X-Forwarded-For.2. Обход троттлинга. Атакующий скриптует неверные-затем-кандидатные входы против
POST /auth/password-login, вращая X-Forwarded-For на запрос, чтобы 429 никогда не срабатывал.3. Подбор. Ограниченный лишь стоимостью
scrypt, атакующий ведёт онлайн-подбор. Успех зависит от стойкости пароля.4. Вход → операторская роль. Верная догадка даёт полный контроль над панелью.
5.
shell.exec → выполнение команд на хосте. Через JSON-RPC шлюз /api/ws атакующий достигает возможности shell.exec.6. Загрязнение журналов на всём пути. Каждый шаг логируется под выбранными атакующим IP источника.
Шаги 1–3 включены напрямую; шаги 4–5 условны при успешном подборе; шаг 6 идёт параллельно начиная с шага 2.
Рисунок 4. Цепочка атаки. Синие шаги (1–3, 6) включены уязвимостью напрямую; серые (4–5) условны при успешном подборе пароля.
Не удалось прогрузить медиа
Обнаружение
Наиболее надёжные сигналы обнаружения поведенческие, а не сигнатурные — они выражаются последовательностями HTTP-статусов.
Основные поведенческие индикаторы
Индикатор
Как выглядит
Устойчивые 401 без 429Всплеск ответов POST /auth/password-login, все 401, тогда как одно-источниковый паттерн дал бы 429 после 10 попыток.Высокая кардинальность X-Forwarded-ForМножество различных (часто уникальных на запрос) значений X-Forwarded-For от одного транспортного пира в коротком окне.IP источника в аудите не совпадают с транспортным пиромЛогируемые IP источника, расходящиеся с фактическим TCP-пиром, или быстро меняющиеся.
401) от одного реального пира, каждый с другим X-Forwarded-For, при заметном отсутствии 429. При нормальной работе за единым доверенным прокси X-Forwarded-For должен быть низкокардинальным и согласованным; взрыв уникальных значений аномален.Примеры индикаторов журнала/HTTP
(Иллюстративные формы, не реальные дампы.)
Code:
# Вращающийся XFF, все 401, нет 429:
POST /auth/password-login 401 xff=203.0.113.7 peer=198.51.100.50
POST /auth/password-login 401 xff=198.18.4.211 peer=198.51.100.50
POST /auth/password-login 401 xff=10.7.7.7 peer=198.51.100.50
... (десятки/сотни, все 401, разные xff, тот же peer, никогда не 429)
- Кардинальность XFF на пир. Тревога, когда число различных значений
X-Forwarded-Forот одного транспортного пира превышает низкий порог в скользящем окне. - Объём неудачных входов без троттлинга. Тревога на счётчики
401дляPOST /auth/password-login, превышающие настроенный бюджет, без соответствующего429. - Расхождение пира и логируемого IP. Если доступны и транспортный пир, и логируемый IP источника, тревога при их несовпадении на эндпоинте безопасности (это прямо вскрывает подделку CWE-117).
- Скачки IP в аудите. Помечать быстрые смены IP источника для одного и того же взаимодействия с формой входа.
Заключение
Это дефект использования недоверенного ввода (CWE-807) в троттлинге входа панели Hermes. Функция
_client_ip() выводит IP источника запроса из контролируемого клиентом заголовка X-Forwarded-For и подаёт это значение и в ограничитель перебора, и в журнал аудита. Вращая заголовок на запрос, неаутентифицированный удалённый вызывающий получает свежую корзину троттлинга каждый раз, нейтрализуя ограничение 10 попыток за 60 секунд (CWE-290), и может подделать логируемый IP источника (CWE-117). Дизайн PoC A/B/A — рабочий контроль, обход, возврат к контролю — чисто изолирует заголовок как причину.Значимость не в новизне — доверие к
X-Forwarded-For известный класс ошибок — а в размещении. Дефект сидит в слое анти-автоматизации, стоящем между неаутентифицированным атакующим и в остальном хорошо укреплённой системой учётных данных. Стойкое хеширование, сравнение за константное время, HMAC-сессии и отсутствие учётных данных по умолчанию — всё подрывается троттлингом, который можно отключить заголовком.Оценённая как Средняя (6.4), проблема повышается до Высокой, когда панель доступна за пределами loopback и/или защищена слабым паролем — потому что успешный подбор даёт полный операторский контроль над панелью, включая возможность
shell.exec JSON-RPC шлюза /api/ws, реальный путь к выполнению команд на хосте.Приоритеты устранения, по порядку: применить исправление
_client_ip() (завязать на транспортный пир; принимать X-Forwarded-For только за сконфигурированным доверенным прокси, самый правый хоп); добавить не-IP измерение троттлинга (счётчики на пользователя/глобальные с backoff или блокировкой); укрепить учётные данные (строгая политика, предвычисленный password_hash); и устранить доступность (привязка к loopback за аутентифицирующим обратным прокси, удаляющим входящий X-Forwarded-For, со списком доверенных прокси, согласованным с forwarded_allow_ips). До выхода исправления отслеживайте высокую кардинальность X-Forwarded-For на пир, всплески 401, не дающие 429, и расхождение между транспортным пиром и логируемым IP источника.
👍19
🔥6
😮3
😢1
LIMB