Блог

wp-json: WordPress сливает логины всех пользователей.

wp-json
Linux / Linux / Wordpress / Wordpress

wp-json: WordPress сливает логины всех пользователей.

Французское агентство недвижимости запустило новый сайт на WordPress. Через несколько часов его взломали. Разбирая логи, специалист нашёл простую цепочку: сначала GET на /wp-json/wp/v2/users — логины получены. Потом брутфорс через xmlrpc.php. Пароль оказался тем же что и логин.

Сайт лежал несколько дней. Владелец даже не знал что WordPress публично отдаёт список всех пользователей — это работает по умолчанию, без каких-либо настроек.

Добавь /wp-json/wp/v2/users к адресу любого WordPress-сайта и открой в браузере. Если никто не закрыл — увидишь примерно это:

[
  {
    "id": 1,
    "name": "admin",
    "slug": "admin",
    "link": "https://example.com/author/admin/",
    "url": "https://example.com"
  },
  {
    "id": 2,
    "name": "john.doe",
    "slug": "john-doe",
    "link": "https://example.com/author/john-doe/"
  }
]

Никакой авторизации. Просто curl — и у тебя список всех логинов.

WordPress это сделал специально. Не баг — фича. Именно поэтому все пропускают.

ЗАЧЕМ ВООБЩЕ СУЩЕСТВУЕТ ЭТОТ ENDPOINT

WordPress REST API получил инфраструктуру в версии 4.4 (декабрь 2015) — базовые классы и роутинг. Но content endpoints, включая /wp/v2/users, появились только в 4.7 (декабрь 2016). На этом API держится весь современный WordPress: блочный редактор Gutenberg, мобильные приложения, headless CMS интеграции.

Именно поэтому нельзя просто взять и отключить REST API целиком — сломается редактор, посты не будут сохраняться. Задача не отключить API, а закрыть конкретный endpoint который не нужен снаружи.

Среди десятков маршрутов есть один: /wp-json/wp/v2/users. Он возвращает всех пользователей у которых есть опубликованные посты. Без авторизации. Так задумано — для поддержки внешних приложений. Но для обычного блога или корпоративного сайта это лишнее.

ЧТО ИМЕННО ВИДИТ АТАКУЮЩИЙ

curl -s https://example.com/wp-json/wp/v2/users | python3 -m json.tool

Для каждого пользователя в ответе: id — числовой идентификатор в базе, name — отображаемое имя, slug — буквенно-цифровой идентификатор используемый в URL автора, link — прямая ссылка на страницу автора.

Тут важный нюанс который многие путают. Поле username — настоящий логин для входа — в официальной документации WordPress имеет context edit. Это значит что анонимам оно не видно. Видно только авторизованным пользователям с правами редактора.

Но поле slug имеет context view — оно открыто всем. И вот в чём проблема: WordPress по умолчанию генерирует slug из username при регистрации. Если администратор не менял slug вручную — slug и есть логин. Атакующий получает его напрямую.

Пароли и email не утекают. Но идентификатор пользователя в руках атакующего — это уже половина работы для брутфорса.

Дальше больше. Корневой endpoint /wp-json/ без каких-либо прав возвращает полную карту всего API:

# Какие плагины зарегистрировали свои endpoints
curl -s https://example.com/wp-json/ | grep -o '"namespace":"[^"]*"' | sort -u

По зарегистрированным namespace читается весь стек: WooCommerce, Yoast SEO, Contact Form 7. Атакующий сопоставляет с CVE базой и выбирает вектор.

ПОЧЕМУ ЭТО ОПАСНО

Вот реальная цепочка которую описывают pentest-отчёты:

GET /wp-json/wp/v2/users       → список логинов
GET /wp-json/                   → список установленных плагинов
→ сопоставить с CVE базой
→ брутфорс по известным логинам или целевой эксплойт

Весь цикл — меньше пяти минут с автоматизированными инструментами.

Отдельная история с плагинами защиты. CVE-2025-4302 показал: «Stop User Enumeration» до версии 1.7.3 блокировал /wp-json/wp/v2/users — но не /wp-json/wp%2Fv2%2Fusers. Простое URL-кодирование слешей обходило всю защиту. Плагин с 50 000 установок не защищал. Правильное решение — на уровне WordPress-кода или nginx, не плагин.

КАК ЗАКРЫТЬ

Способ 1: WordPress-фильтр (рекомендуется)

Три строки в mu-plugin. Gutenberg продолжает работать — он использует аутентифицированные запросы. Блокируется только анонимный доступ:

cat > /var/www/html/wp-content/mu-plugins/restrict-rest-users.php << 'EOF'
<?php
/**
 * Plugin Name: Restrict REST API Users Endpoint
 * Description: Blocks unauthenticated access to /wp-json/wp/v2/users
 */
add_filter('rest_endpoints', function($endpoints) {
    if (!is_user_logged_in()) {
        unset($endpoints['/wp/v2/users']);
        unset($endpoints['/wp/v2/users/(?P<id>[\d]+)']);
    }
    return $endpoints;
});
EOF

Проверить:

curl -s https://example.com/wp-json/wp/v2/users
# Ожидаемый ответ: {"code":"rest_no_route","message":"No route was found...","data":{"status":404}}

Этот способ закрывает и /wp-json/wp/v2/users и альтернативный путь /?rest_route=/wp/v2/users — оба обрабатываются одним фильтром на уровне PHP.

Способ 2: nginx

Если нужна блокировка до PHP — быстрее и без нагрузки на сервер:

location ~* ^/wp-json/wp/v2/users {
    deny all;
    access_log off;
    log_not_found off;
}

# Альтернативный путь через ?rest_route=
# if работает только в server{} контексте, не внутри location{}
# Размещать до блоков location:
if ($arg_rest_route ~* "^/wp/v2/users") {
    return 403;
}
nginx -t && systemctl reload nginx
curl -I https://example.com/wp-json/wp/v2/users
# Ожидаемый ответ: HTTP/1.1 403 Forbidden

Важно: nginx-блокировка режет endpoint полностью — включая авторизованные запросы. Для большинства блогов это нормально. Если используешь плагины которые работают с пользователями через REST API — лучше способ 1.

Закрыть корневой endpoint для анонимов

/wp-json/ отдаёт карту всех плагинов без авторизации. Закрыть:

add_filter('rest_authentication_errors', function($result) {
    if (!is_user_logged_in() && isset($_SERVER['REQUEST_URI']) &&
        rtrim(parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH), '/') === '/wp-json') {
        return new WP_Error('rest_forbidden', 'REST API root restricted.', ['status' => 403]);
    }
    return $result;
});

Или в nginx:

location = /wp-json/ {
    deny all;
    access_log off;
    log_not_found off;
}

Убрать REST API discovery link из HTML

WordPress добавляет в каждую страницу:

<link rel='https://api.w.org/' href='https://example.com/wp-json/' />

Боты читают это как сигнал что REST API активен. Убрать:

// Добавить в remove-legacy-links.php
remove_action('wp_head', 'rest_output_link_wp_head', 10);

ВТОРОЙ ВЕКТОР: ?author=1

REST API — не единственный способ. WordPress по умолчанию редиректит ?author=1 на /author/username/, и slug пользователя оказывается прямо в URL. Боты перебирают числа по порядку:

curl -I "https://example.com/?author=1"
# HTTP/1.1 301 Moved Permanently
# Location: https://example.com/author/admin/  ← логин раскрыт

Закрыть через nginx:

location / {
    if ($args ~* "^author=\d+$") {
        return 403;
    }
}

Или через WordPress:

add_action('template_redirect', function() {
    if (is_author() && isset($_GET['author'])) {
        wp_redirect(home_url(), 301);
        exit;
    }
});

ТИПИЧНЫЕ ОШИБКИ

«У меня нет публичных авторов — endpoint пустой.» Сейчас пустой. Добавишь автора с постом — данные появятся. Лучше закрыть сразу.

«Сменил отображаемое имя — логин не виден.» name и slug — разные поля. Display name можно менять сколько угодно. Slug в URL автора и в endpoint остаётся.

«Закрыл endpoint — Gutenberg сломался.» Фильтр rest_endpoints с is_user_logged_in() не трогает авторизованные запросы. Если сломалось — проверь что блок применяется только к анонимам.

«Поставил плагин Stop User Enumeration.» CVE-2025-4302 — обходится URL-кодированием пути. Плагин не решение.

ПРОВЕРЬ СВОЙ САЙТ

# Endpoint открыт для анонимов?
curl -s -o /dev/null -w "%{http_code}" https://example.com/wp-json/wp/v2/users
# 200 = открыт, 403/404 = закрыт

# Одиночный пользователь по ID
curl -s -o /dev/null -w "%{http_code}" https://example.com/wp-json/wp/v2/users/1

# Альтернативный путь закрыт?
curl -s -o /dev/null -w "%{http_code}" "https://example.com/?rest_route=/wp/v2/users"

# author enumeration работает?
curl -sI "https://example.com/?author=1" | grep Location

# Что видно в namespace — какие плагины раскрыты?
curl -s https://example.com/wp-json/ | grep -o '"namespace":"[^"]*"' | sort -u

# REST API link в HTML?
curl -s https://example.com/ | grep "api.w.org"

ИТОГ

/wp-json/wp/v2/users открыт по умолчанию на большинстве WordPress-сайтов. Не потому что разработчики допустили ошибку — так задумано для внешних интеграций. Но для обычного сайта это лишнее: атакующий получает логины, namespace плагинов и готовую карту для атаки.

Небольшая оговорка: WordPress 6.9.1 (февраль 2026) ввёл дополнительное ужесточение REST API — по данным исследователей, в связке с плагинами безопасности это снижает успешность enumeration примерно на 80%. Но endpoint по-прежнему возвращает данные по дизайну — hardening ограничивает злоупотребление, но не закрывает доступ. Фильтр в mu-plugin нужен в любом случае.

Фильтр в mu-plugin закрывает проблему без побочных эффектов. Сверху — nginx для блокировки до PHP. И убрать discovery link из HTML чтобы не светиться в автоматических сканах.

Проверь прямо сейчас. Скорее всего endpoint открыт.

Leave your thought here

Ваш адрес email не будет опубликован. Обязательные поля помечены *

Поддержать автора

Если вам понравилась статья — вы можете поддержать автора.

Crypto donation button by NOWPayments