Перейти к содержимому
Noroxi
Исследования10 мин

Отсутствие авторизации в WPS Hide Login (CWE-862)

AJAX-обработчик WPS Hide Login не проверяет права, и любой подписчик может записывать site-transient WordPress. Разбор первопричины, патч и защита.

Команда NoroxiТестирование на проникновение и консалтинг

Аннотация. В плагине WPS Hide Login (2 000 000+ активных установок) аутентифицированный AJAX-обработчик проверяет CSRF-nonce, но ни разу не проверяет права вызывающего. Из-за этого любой залогиненный пользователь, вплоть до Подписчика, может записывать произвольные site-transient WordPress — хрестоматийное отсутствие авторизации (CWE-862). Это защитный разбор: первопричина, концептуальное объяснение, почему до уязвимой записи добирается низкопривилегированная учётная запись, и рекомендации по предотвращению и обнаружению для владельцев сайтов и разработчиков. На момент написания патча нет, поэтому по принципу согласованного раскрытия работоспособный PoC остаётся закрытым до выхода исправления.

Ключевые слова: WordPress, отсутствие авторизации, нарушение контроля доступа, CWE-862, проверка прав, nonce, site transient, минимальные привилегии.

1. Введение

WPS Hide Login переносит стандартную точку входа wp-login.php на произвольный slug и делает это более чем на 2 000 000 сайтов. Именно охват и делает ошибку любопытной. Значительная часть этих установок — это сайты членства, магазины WooCommerce и площадки с открытой регистрацией, то есть среды, где низкопривилегированных учётных записей много и заводятся они легко. Ошибка, которой достаточно залогиненной учётной записи, попадает как раз сюда. И есть своя ирония в том, что нарушение контроля доступа обнаруживается внутри плагина, который продаётся как средство усиления входа.

Отсутствие авторизации, оно же нарушение контроля доступа, — один из самых частых и самых разрушительных классов ошибок в экосистеме плагинов WordPress и при этом один из самых невзрачных. Ни повреждения памяти, ни SQL-инъекции, ни удалённого выполнения кода. Привилегированное изменение состояния просто остаётся без защиты, и учётная запись, которой никогда не следовало это уметь, получает возможность. WPS Hide Login даёт чистый, конкретный образец этого шаблона для разбора по коду.

2. Затронутый компонент

ПлагинWPS Hide Login — slug wps-hide-login
Протестированная версия1.9.19 (актуальная/последняя на момент тестирования)
Активные установки2 000 000+
Тип уязвимостиОтсутствие авторизации / нарушение контроля доступа
CWECWE-862 Missing Authorization
Требуемые праваЛюбой аутентифицированный пользователь — достаточно Подписчика (право read)
Взаимодействие с пользователемНет
CVSS 3.14.3 (низкая) – 5.4 (средняя) — AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:L/A:L (специалист сортировки может выставить A:N → 4.3; воздействие ограничено характером хранимого значения)
СтатусСвежая (0-day); подтверждена в коде и воспроизведена в изолированной localhost-лаборатории на актуальной версии; без патча на момент написания

3. Первопричина

Чтобы запоминать закрытие собственного админ-уведомления, WPS Hide Login регистрирует аутентифицированное AJAX-действие wp_ajax_dismiss_admin_notice. Обработчик проверяет только CSRF-nonce и больше ничего. А ведь nonce и проверка прав отвечают на разные вопросы. Nonce подтверждает, что запрос пришёл из сессии самого залогиненного пользователя, — это защита от межсайтовой подделки запроса (CSRF). Что пользователю разрешено выполнять действие, он не устанавливает никогда.

Действие привязано к обработчику напрямую, без какой-либо обёртки авторизации:

php
add_action( 'wp_ajax_dismiss_admin_notice', array( $this, 'dismiss_admin_notice' ) );// (нет варианта wp_ajax_nopriv_ — значит, доступ только для аутентифицированных, и это ЕДИНСТВЕННЫЙ барьер)

Сам обработчик проверяет nonce, а затем передаёт подконтрольное злоумышленнику имя опции прямо в запись site-transient:

php
public function dismiss_admin_notice() {    check_ajax_referer( 'wps-hide-login-dismissible-notice' );   // только CSRF-nonce — НЕ проверка прав
    $option_name        = sanitize_text_field( $_POST['option_name'] );        // полностью под контролем вызывающего, без allow-list    $dismissible_length = sanitize_text_field( $_POST['dismissible_length'] );    $transient          = 0;    if ( 'forever' != $dismissible_length ) {        $dismissible_length = ( 0 == absint( $dismissible_length ) ) ? 1 : $dismissible_length;        $transient          = absint( $dismissible_length ) * DAY_IN_SECONDS;        $dismissible_length = strtotime( absint( $dismissible_length ) . ' days' );    }    set_site_transient( $option_name, $dismissible_length, $transient );        // СТОК: запись произвольного site-transient    wp_die();}

Два дефекта усиливают друг друга. Первый: нет барьера current_user_can(). Запись site-transient — операция для доверенных ролей, но обработчик вообще не спрашивает, есть ли у вызывающего право вроде manage_options. Проверка nonce проходит у каждого залогиненного пользователя, поэтому разделения привилегий она не даёт никакого. Второй: для option_name нет allow-list. Даже обработчик, открытый только администратору, обязан ограничивать, какой transient он трогает; здесь имя берётся из запроса дословно, и запись не удерживается в пределах собственных ключей уведомлений плагина.

Правильный шаблон виден на фоне того, как плагин сохраняет собственные настройки: через ядровый options.php WordPress, который требует и check_admin_referer('siteoptions'), и право manage_options. Дефект — ровно в том, что второй из этих контролей в AJAX-обработчике отсутствует.

4. Как достигается запись (концептуально)

Короткая трассировка на учебном уровне, без воспроизводимой последовательности запросов, показывает, почему до уязвимой записи добирается низкопривилегированная учётная запись.

Начнём с регистрации. Действие существует только в аутентифицированной форме (wp_ajax_…); парного wp_ajax_nopriv_… нет. Значит, действующий барьер сводится к «быть залогиненным», а на сайтах членства, в магазинах WooCommerce и везде, где открыта регистрация, учётные записи уровня Подписчик раздают свободно. Подписчик и без того может открывать доступные его роли страницы wp-admin — например, собственный профиль.

Дальше nonce. Nonce закрытия уведомления полагалось бы выводить только на том экране администратора, где уведомление и показывается. Но в этом плагине функция, подключающая скрипт закрытия, безусловно привязана к событию загрузки админ-скриптов, а единственная строка, которая ограничила бы её экраном настроек самого плагина, закомментирована. В итоге nonce, открывающий обработчик, встраивается в страницу для любого пользователя, способного загрузить любой экран администратора, — Подписчиков в том числе.

Оба предусловия складываются без усилий, и у Подписчика оказывается всё, чего требует обработчик: действительная сессия для регистрации «только для аутентифицированных» и действительный nonce для CSRF-проверки. После этого обработчик выполняет привилегированную запись от его имени. Для защитников суть концептуальна, а не процедурна: единственное, что стоит между недоверенной учётной записью и этим изменением состояния, — проверка, которую так и не написали. Ни особого инструментария, ни тайминга, ни подбора полезной нагрузки — право просто оставили без проверки.

5. Оценка воздействия

Хранимое значение ограничено: либо целочисленная временная метка, либо буквальная строка forever. Об этом ограничении стоит сказать прямо — оно удерживает вывод честным: это не инъекция объекта, не SQL-инъекция и не выполнение кода. Но низкопривилегированный пользователь всё равно получает возможность сделать общесайтовое изменение состояния, закреплённое за администраторами. На практике аутентифицированный Подписчик может:

  • Подавить уведомления об ожидающих обновлениях. Если перезаписать ядровые site-transient update_plugins, update_themes или update_core скалярным значением, механизм обновлений WordPress не найдёт ожидаемую нагрузку ->response. Тогда администраторам до следующего планового обновления не покажут ни одного ожидающего обновления плагинов, тем или ядра — включая обновления безопасности, — а поскольку запись можно повторять, эту слепую зону можно держать открытой сколь угодно долго. Сокрытие того, что исправления уже доступны, заметно облегчает последующую атаку.
  • Повредить или вытеснить произвольные site-transient ядра WordPress или других плагинов — кэши, счётчики ограничения частоты, флаги функций, кэши лицензий и валидности — вплоть до функциональных сбоев и лавин перезаполнения кэша.

В обоих случаях нарушается граница привилегий, которая должна держаться: Подписчик обычно не может записывать site-transient вовсе. Вектор AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:L/A:L описывает сетевую, низкосложную атаку с низкими правами и без взаимодействия, без потери конфиденциальности и с ограниченным воздействием на целостность и доступность. Оценку между 4.3 (низкая) и 5.4 (средняя) двигает только то, засчитывает ли специалист сортировки воздействие на доступность.

6. Исправление

Корневое исправление — это та самая недостающая проверка: закройте обработчик барьером, чтобы до записи добирался только пользователь с достаточными правами.

php
public function dismiss_admin_notice() {    if ( ! current_user_can( 'manage_options' ) ) {        wp_die( -1, 403 );    }    check_ajax_referer( 'wps-hide-login-dismissible-notice' );    // ... существующая логика ...}

Для эшелонированной защиты добавьте ещё allow-list. Ограничьте option_name собственными ключами уведомлений плагина — хватит фиксированного префикса вроде wps-hide-login-*, — чтобы даже авторизованный вызывающий не дотянулся до посторонних ядровых или сторонних transient.

Владельцам сайтов, которые не могут дождаться исправления от вендора:

  • Следите за журналом изменений плагина и ставьте исправленный выпуск сразу после публикации.
  • Держите число низкопривилегированных учётных записей небольшим и проверяйте, у кого они есть.
  • Опирайтесь на эшелонированное усиление ниже — оно сокращает радиус поражения любого единичного непроверяемого обработчика.

7. Обнаружение и усиление для владельцев сайтов

Единственное реальное предусловие — работающий вход, поэтому практические вопросы такие: кто может войти и как отслеживается состояние обновлений.

  • Минимальные привилегии. Держите набор учётных записей Подписчика (и прочих низкопривилегированных) настолько малым, насколько позволяет работа сайта. Там, где регистрация открыта или самообслуживаемая (членство, электронная коммерция), считайте каждую такую запись возможным субъектом для аутентифицированных ошибок вроде этой.
  • Ловите несогласованное состояние обновлений. Самое ценное злоупотребление здесь — сокрытие обновлений. Если экран «Обновления» в админке сообщает, что всё актуально, а независимый источник — внешняя инвентаризация или запущенная вне контекста проверка wp-cli — показывает более новый выпуск плагина, темы или ядра, воспринимайте расхождение как повод для расследования. Ядровый transient обновления, содержащий голый скаляр вместо объектной структуры, аномален уже сам по себе.
  • Отслеживайте привилегированные изменения состояния вне панели. Не полагайтесь на значок обновления в самой панели как на единственный источник истины о том, актуальны ли компоненты; второй, внеконтекстный источник полностью нейтрализует этот способ сокрытия.
  • Снижайте доступность функции. Если уровень усиления или WAF позволяет виртуально пропатчить — потребовав права администратора для конкретного AJAX-действия или отклоняя запросы с неожиданным option_name, — это закрывает брешь до выхода кодового исправления. Любое такое правило выводите только из описанной здесь механики.
  • Держите всё обновлённым. Ирония ошибки, подавляющей обновления, в том, что лекарство — само обновление; проследите, чтобы плагин обновили, как только выйдет исправленная версия.

8. Более широкий урок

Принцип в основе всего этого стар и его постоянно забывают, поэтому его стоит повторить: nonce — это не проверка авторизации. Они отвечают на разные вопросы. Nonce отвечает на вопрос «действительно ли запрос пришёл из сессии данного пользователя?» и защищает от CSRF. Авторизация отвечает на вопрос «разрешено ли данному пользователю это действие?» и решает вопрос о правах. Обработчик, который проверяет первое и пропускает второе, настежь открыт любой учётной записи, способной получить действительный nonce, — а в WordPress это каждый залогиненный пользователь.

Сделайте это правилом для каждой точки, меняющей состояние, будь то AJAX-действие, REST-маршрут или обработчик admin-post: явная проверка прав под операцию (current_user_can( … )), стоящая сама по себе и не опирающаяся ни на какой nonce. А затем везде, где сток принимает переданный вызывающим идентификатор — имя опции, ключ transient, путь к файлу, идентификатор записи, — ограничивайте его allow-list-ом вместо доверия к тому, что пришло в запросе. Аутентификация, защита от CSRF и авторизация — три отдельных механизма; реализовав один или два, остальные вы не реализуете.

9. Ответственное раскрытие

Это исследование проведено и раскрывается в рамках согласованного раскрытия. Патча нет. Вендор плагина уведомлён в частном порядке, а полный работоспособный PoC — точная последовательность запросов и значения полезной нагрузки, воспроизводящие запись на действующем сайте, — намеренно не включён в эту публичную статью до выхода исправления. Здесь остались только код первопричины, предлагаемое исправление и концептуальное объяснение механизма — ровно столько, чтобы разработчики и владельцы сайтов могли понять, предотвратить и обнаружить этот класс ошибок, не получив готового к запуску оружия.

Вся проверка выполнялась в собственной изолированной, привязанной к localhost лаборатории исследователя против публичного GPL-плагина. К сторонним и рабочим системам доступа не было. Тестовый плагин и тестовую учётную запись затем удалили, а все изменённые при тестировании transient восстановили.

Ссылки


Опубликовано в защитных и образовательных целях. Noroxi LLC проводит только авторизованные исследования безопасности; операционные детали эксплуатации удерживаются до выхода исправления.

Эксплуатируется на этой неделе · по понедельникам

Новые записи KEV, свежие PoC, разбор WordPress и что сделать на неделе. Без аккаунта.

Адрес используется только для этой рассылки и никому не передаётся.

Следующая статья

301 Redirects 2.85: отсутствие авторизации (CWE-862)