[{"content":"Ко мне обратились с сайтом на WordPress, который отдавал 403 Forbidden на любой адрес, включая админку. Хостинг блокировку не ставил, разработчика у сайта нет. Из доступов только личный кабинет хостинга: файловый менеджер, phpMyAdmin и логи.\nОказалось что сайт взломали через уязвимость в ядре WordPress, поставили бэкдор в mu-plugins, и этот бэкдор сам отдавал 403 всем, у кого нет пароля атакующего. То есть страница выше нарисована не веб-сервером, а самим взломщиком. Ниже как я до этого дошёл и что сделал.\nВсе пути, домены и префиксы таблиц в статье условные.\nОтсекаем очевидное 403 на весь сайт это обычно права на файлы, правило в .htaccess или блокировка хостингом. Права оказались нормальными (644 и 755), в .htaccess только стандартные правила WordPress, хостинг блокировку не ставил.\nЗацепка была в другом, 403 прилетал и на robots.txt, и на картинки. Статику отдаёт веб-сервер напрямую, WordPress к ней отношения не имеет. Раз конфигурацию Apache я уже исключил, значит запрос перехватывает PHP до того, как дело доходит до отдачи файла.\nПрофилирование логов Логи в таких случаях читать подряд бессмысленно, сначала нужна структура, сколько записей, за какой период, с каких адресов, какие ошибки повторяются.\n1 2 3 4 5 6 7 8 9 10 11 wc -l error_log # кто стучался и сколько раз grep -oP \u0026#39;\\[client \\K[0-9.]+\u0026#39; error_log | sort | uniq -c | sort -rn | head -20 # какие типы ошибок grep -oP \u0026#39;(?\u0026lt;=\\] )(WordPress database error [^f]{0,60}|PHP [A-Za-z ]+error[^:]*)\u0026#39; \\ error_log | cut -c1-90 | sort | uniq -c | sort -rn | head -20 # распределение по часам grep -oP \u0026#39;\\[Wed Aug 12 \\d{2}\u0026#39; error_log | sort | uniq -c Больше 180 записей за сутки, из них 161 однотипная, три адреса, один сделал 155 запросов за десять минут ночью. Практически весь лог состоял из ошибок базы данных.\nТочка входа У всех 161 записи один и тот же стек вызовов:\n1 2 3 rest_api_loaded → WP_REST_Server-\u0026gt;serve_request → serve_batch_request_v1 → serve_batch_request_v1 (вложенно, несколько раз) → WP_REST_Posts_Controller-\u0026gt;get_items → WP_Query-\u0026gt;get_posts Бьют в REST API, в эндпоинт /wp-json/batch/v1, внутрь которого вложены запросы к /wp/v2/posts. Итоговый SQL выглядел так:\n1 WHERE 1=1 AND wp_posts.post_author NOT IN (1) UNION SELECT ... Инъекция сидит в параметре author__not_in. Уязвимость оказалась в самом ядре WordPress. Это связка двух багов:\nSQL-инъекция в author__not_in. Если значение приходит строкой, а не массивом, проверка типа пропускается и значение уезжает прямо в SQL. Обычный REST-обработчик валидирует параметр author_exclude как массив целых чисел, строка до WP_Query не доходит. Рассинхронизация в /batch/v1. Запрос валидируется по схеме одного эндпоинта, а выполняется обработчиком другого. Проверка проходит там, где параметр вроде как безобиден, а исполняется там, где он попадает в SQL. Отсюда и вложенный serve_batch_request_v1 в стеке. Вместе два бага дают анонимному запросу выполнение кода.\nЗатронуты версии 6.9.0 до 6.9.4 и 7.0.0 до 7.0.1 (полная цепочка до RCE), исправлено в 7.0.2.\nИнъекция не отбита, она работает Видно, что именно пытались подсунуть HTML с подписью hacked by trenggalek6etar. Свою заглушку на главную он поставить не успел, но если загуглить ник trenggalek6etar, то гугл выдаст много сайтов, которые были взломаны и так и висят с надписью hacked by trenggalek6etar на главной.\nДругие попытки из лога: веб-шелл через UNION SELECT UNHEX(...) INTO OUTFILE с перебором типовых путей вроде /var/www/html/ (мимо, реальный путь на шареде выглядит как /home/c/userXXXX/site_ab12/public_html/, да и привилегия FILE там обычно не выдана), запись в customize_changeset для закрепления и запрос хеша из таблицы wp_duplicator_packages, чтобы скачать архив с полной копией сайта и базы по прямой ссылке.\nСама по себе инъекция только читает данные, писать в базу через неё нельзя. Но она позволяет подсунуть WordPress поддельные объекты записей, и дальше он сохраняет их в базу уже сам. По той же цепочке он в какой-то момент повторно выполняет запрос на создание пользователя, который до этого отклонил, но уже с правами администратора. Так у атакующего и появляется свой админ.\nСледы этого видно прямо в админке: девять пустых записей с заголовком x, автором admin и датой 01.01.2020.\nКто и когда зашёл Дальше я посмотрел таблицу пользователей.\nТридцать с лишним аккаунтов администратора, все со случайными суффиксами. Первый, 1ae9367587c9, зарегистрирован 22 июля, дальше по несколько штук в неделю вплоть до 11 августа. Настоящий владелец на сайте один, admin от 2022 года.\nЛогины говорят сами за себя: w2s_..., wp2shell_..., admins.... Тот же wp2shell мне попался в access-логе как User-Agent.\nТеперь понятно, почему сайт вообще попал под удар. Патч вышел 17 июля, первый чужой аккаунт появился 22 июля. Уязвимость опубликовали, PoC выложили на GitHub, начались массовые сканы и сайт нашли скриптом.\nПричина, по которой обновление не приехало, нашлась в wp-config.php: там стоял DISABLE_WP_CRON, а системный крон вместо него никто не настроил. Планировщик отвечает в том числе за установку обновлений, и сайт просто перестал обновляться.\nОткуда взялся 403 В логе причины 403 не было. Записи client denied by server configuration относились только к xmlrpc.php, это старая защита самого владельца. Раз не Apache, остаётся PHP.\nВ wp-content/mu-plugins/ лежал файл с невнятным именем, датированный днём инцидента, must-use плагины WordPress подгружает при каждом запросе, раньше обычных плагинов, и отключить их через админку нельзя, они идут отдельной вкладкой без кнопки деактивации.\nВнутри заголовок LiteSpeed Cache - Purge Handler и строка eval(gzinflate(base64_decode('...'))). Распаковал ту же конструкцию вручную, заменив eval на file_put_contents, и получил 875 строк PHP. В начале лежало это:\n1 2 3 4 5 6 7 8 9 10 $_pw_hash = \u0026#39;xxxxxxxxxxxx\u0026#39;; $inputPw = isset($_GET[\u0026#39;password\u0026#39;]) ? $_GET[\u0026#39;password\u0026#39;] : \u0026#39;\u0026#39;; ... if ($inputPw !== $_pw_hash) { http_response_code(403); echo \u0026#39;\u0026lt;!DOCTYPE html\u0026gt;\u0026lt;html\u0026gt;\u0026lt;head\u0026gt;\u0026lt;meta charset=\u0026#34;utf-8\u0026#34;\u0026gt;\u0026lt;title\u0026gt;403\u0026lt;/title\u0026gt;\u0026#39; . \u0026#39;\u0026lt;style\u0026gt;body{background:#0a0a0f;color:#ef5350;...}\u0026lt;/style\u0026gt;\u0026lt;/head\u0026gt;\u0026#39; . \u0026#39;\u0026lt;body\u0026gt;\u0026lt;h1\u0026gt;403 Forbidden\u0026lt;/h1\u0026gt;\u0026lt;/body\u0026gt;\u0026lt;/html\u0026gt;\u0026#39;; exit; } А в конце файла безусловный вызов Purge::run();.\nНет параметра ?password=, значит 403 и exit, запрос до WordPress не доходит вообще. Отсюда и 403 на статику. Атакующий со своим паролем при этом работал с сайтом спокойно.\nЭто та самая страница, с которой начинается статья, длина заглушки ровно 310 байт, и во всех 403-х в access-логе в поле размера ответа стоит 310.\nУстранение Снять сайт с публики заглушкой на уровне хостинга, чтобы не работать по живому.\nНайти всех администраторов. Тут есть грабля. ключ wp_capabilities содержит префикс таблиц конкретного сайта, и на нестандартном префиксе запрос по точному имени вернёт пустой результат.\n1 2 3 4 5 SELECT u.ID, u.user_login, u.user_email, u.user_registered, m.meta_value FROM wp_users u JOIN wp_usermeta m ON u.ID = m.user_id WHERE m.meta_key LIKE \u0026#39;%capabilities\u0026#39; AND m.meta_value LIKE \u0026#39;%administrator%\u0026#39;; Удалять точечно DELETE FROM wp_users WHERE ID != 1 снесёт покупателей, редакторов и второго настоящего админа вместе с привязкой заказов. Сначала разбивка по ролям, потом удаление по конкретным ID:\n1 2 SELECT meta_value, COUNT(*) FROM wp_usermeta WHERE meta_key LIKE \u0026#39;%capabilities\u0026#39; GROUP BY meta_value; Вычистить файлы и контент: бэкдор из mu-plugins, записи с заголовком x, проверка wp-content/uploads на PHP (его там быть не должно), сверка контрольных сумм ядра, осмотр wp-config.php и .htaccess на посторонние вставки.\nОбновить ядро до возврата сайта в онлайн. Иначе всё вернётся тем же путём, проверял явно через wp core version.\nСменить всё: пароли администраторов, пароль базы, доступы FTP и панели, соли в wp-config.php (это разлогинит всех, включая атакующего).\nЧто настроили, чтобы не повторилось Список начинается не с паролей, потому что вход был анонимный, через дыру в ядре.\nАвтообновления. В wp-config.php:\n1 define(\u0026#39;WP_AUTO_UPDATE_CORE\u0026#39;, true); И системный крон на wp-cron.php в панели хостинга.\nЗапрет PHP в загрузках. Файл wp-content/uploads/.htaccess:\n1 2 3 \u0026lt;FilesMatch \u0026#34;\\.(?i:php|phtml|php[0-9]|phar|shtml)$\u0026#34;\u0026gt; Require all denied \u0026lt;/FilesMatch\u0026gt; Редактор кода из админки убрать:\n1 define(\u0026#39;DISALLOW_FILE_EDIT\u0026#39;, true); Даже с угнанной админкой нельзя будет вписать код в тему через браузер.\nЗакрыть batch-эндпоинт как временную меру, пока не обновились. Отдельным файлом в mu-plugins, чтобы не отключили из админки:\n1 2 3 4 5 6 7 8 add_filter(\u0026#39;rest_endpoints\u0026#39;, function ($endpoints) { foreach (array_keys($endpoints) as $route) { if (strpos($route, \u0026#39;/batch/v1\u0026#39;) === 0) { unset($endpoints[$route]); } } return $endpoints; }); Удалить неиспользуемые плагины и темы. Неактивный плагин лежит на диске и уязвим ровно так же, многие атаки бьют прямо в его файл, минуя WordPress.\nПроверить соседние сайты на том же аккаунте хостинга по тому же чеклисту, начиная с версии ядра.\nИтог Сайт вернули в работу тем же вечером. На разбор ушло несколько часов, из них большая часть на логи.\nСсылки CVE-2026-63030, путаница маршрутов в batch-эндпоинте CVE-2026-60137, SQL-инъекция в author__not_in Разбор от нашедших цепочку Хронология эксплуатации после выхода патча, Patchstack ","date":"0001-01-01T00:00:00Z","image":"/p/wordpress-403-backdoor/4.jpg","permalink":"/p/wordpress-403-backdoor/","title":"403 Forbidden длиной 310 байт: разбор взломанного WordPress"},{"content":"Небольшая статья о том как устроен этот блог под капотом. Есть приватный git-репозиторий с текстовыми файлами и бакет в Яндекс облаке. Сайт статический, то есть каждая страница лежит в хранилище готовым HTML-файлом, и когда читатель её открывает, хранилище просто отдаёт то, что там уже есть.\nСобирает страницы Hugo, генератор статических сайтов на Go.\nЧто происходит по шагам Шаг 1. Я пишу статью. Создаю папку в content/post, кладу туда index.md и картинки.\nШаг 2. Смотрю, что получилось. Команда hugo server -D поднимает копию сайта на localhost с горячей перезагрузкой.\nШаг 3. Делаю коммит и пуш в main. На этом моё ручное участие заканчивается.\nШаг 4. GitHub Actions поднимает чистую виртуалку и выкачивает на неё репозиторий вместе с темой.\nШаг 5. Ставится Hugo и собирается сайт. Команда hugo --minify превращает markdown в HTML и по дороге сжимает разметку, результат складывается в папку public.\nШаг 6. Результат синхронизируется с бакетом. Заливаются только изменившиеся файлы, а флаг --delete подчищает в хранилище то, чего больше нет в сборке, иначе удалённая статья продолжала бы открываться по старому адресу.\nШаг 7. Статья размещена на сайте.\nПайплайн Всё описано в одном файле .github/workflows/deploy.yaml, триггером служит любой пуш в ветку main:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 name: Deploy on: push: branches: [main] jobs: deploy: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 with: submodules: recursive fetch-depth: 0 - uses: peaceiris/actions-hugo@v3 with: hugo-version: \u0026#39;latest\u0026#39; extended: true - run: hugo --minify - env: AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} AWS_DEFAULT_REGION: ru-central1 run: | aws s3 sync ./public s3://iplotnikov.com \\ --endpoint-url=https://storage.yandexcloud.net --delete Весь прогон занимает около десяти секунд, ключи доступа лежат в секретах GitHub и принадлежат в Яндекс облаке сервисному аккаунту с ролью storage.editor, который умеет только читать и писать объекты в этом хранилище и больше ничего, так что при утечке ключа ущерб ограничен содержимым одного бакета.\nДомен и сертификат Доменом управляет Yandex Cloud DNS, то есть зона iplotnikov.com целиком живёт в облаке и обслуживается серверами ns1.yandexcloud.net и ns2.yandexcloud.net.\nHTTPS обеспечивает сертификат от Let\u0026rsquo;s Encrypt, выпущенный через Yandex Certificate Manager и привязанный к бакету. Сертификаты Let\u0026rsquo;s Encrypt живут 90 дней, Certificate Manager запрашивает обновление за 30 дней до истечения и раскатывает новую версию на все привязанные ресурсы.\nИз чего складывается счёт В Object Storage никакой абонентской платы за сам факт существования бакета нет, счётчик крутится только тогда, когда данные лежат или их кто-то читает. Хранение считается пропорционально времени, вплоть до часов и мегабайт, операции делятся на типы по методам S3 API (загрузка объекта PUT, скачивание GET, получение метаданных HEAD), и оплачивается фактическое их количество, а удаление объектов не тарифицируется вообще. Входящий трафик тоже бесплатен, так что деплой ничего не стоит сам по себе.\nПоверх этого работает бесплатный лимит, не тарифицируются первый гигабайт хранения, первые десять тысяч операций записи и сто тысяч операций чтения, а также первые сто гигабайт исходящего трафика в месяц. Блог с несколькими десятками мегабайт статики и парой тысяч посещений в такие рамки укладывается целиком, поэтому в детализации напротив всех строк Object Storage стоит ноль: и хранение, и одиннадцать тысяч операций GET, и сто двадцать операций PUT от последних деплоев, и весь исходящий трафик.\nИ получается что хостинг сайта раскатанный таким образом не стоит ничего. Все 44 рубля в счёте это Cloud DNS, где 41,95 рубля приходится на саму DNS-зону, которая тарифицируется по часам её существования, и ещё 82 копейки на публичные запросы к ней. Сертификат от Let\u0026rsquo;s Encrypt в Certificate Manager отдельных денег тоже не стоит.\nИнтересно посмотреть, что будет при росте. Ставки в стандартном хранилище такие: примерно 2,4 рубля за гигабайт хранения в месяц, 46 копеек за десять тысяч операций GET и около 1,7 рубля за гигабайт исходящего трафика сверх бесплатных ста. Если читателей станет в десять раз больше, операции вылезут за лимит на одиннадцать тысяч штук, и счёт вырастет на пятьдесят копеек. При стократном росте, то есть больше миллиона запросов в месяц, добавится сорок шесть рублей.\nСколько стоила бы виртуалка Для сравнения я собрал в калькуляторе Yandex Cloud самую минимальную конфигурацию, два ядра с гарантированной долей 5%, 0.5 гб памяти, 5 гб диска и публичный IP-адрес. Вышло 612 рублей в месяц, и это сверх тех же 42 рублей за DNS, которые никуда не денутся. Почти 200 рублей из этой суммы приходится не на вычислительные ресурсы, а на публичный IP-адрес, ещё 17 на диск. Если довести машину до вменяемого состояния, скажем до 20% доли ядра и двух гигабайт памяти, счёт уходит в район полутора тысяч.\nРазница тут не в том, что виртуалки у Яндекса такие дорогие, а в модели оплаты, в случае с Compute Cloud придётся платить за зарезервированные ресурсы. Ядра, память, диск и адрес будут капать в счёт круглые сутки независимо от того, зашёл на сайт хоть кто-нибудь или нет. Больше того, диск и IP тарифицируются даже когда машина выключена, так что 200 рублей в месяц набегают на полностью простаивающем сервере. Блог, который читают полтора человека в день, простаивает практически всё время, но платить приходится за все двадцать четыре часа.\nПришёл читатель и скачал 100 файлов, значит в счёт попали 100 операций GET и несколько сотен килобайт трафика. Не пришёл никто, значит и потребления нет. А поскольку блог такого размера целиком помещается в бесплатный лимит, фактический счёт за хостинг равен нулю, тогда как виртуалка выставляла бы свои шестьсот рублей каждый месяц независимо ни от чего.\nПлюс к деньгам сюда добавляется всё, за что не приходится платить временем: не надо накатывать обновления безопасности, следить за сертификатами, чинить упавший nginx и держать в голове, что на машине вообще-то крутится веб-сервер, который по всем законам подлости обязательно рано или поздно отвалится.\n","date":"0001-01-01T00:00:00Z","image":"/p/%D0%BA%D0%B0%D0%BA-%D1%83%D1%81%D1%82%D1%80%D0%BE%D0%B5%D0%BD-%D1%8D%D1%82%D0%BE%D1%82-%D1%81%D0%B0%D0%B9%D1%82/cover.jpg","permalink":"/p/%D0%BA%D0%B0%D0%BA-%D1%83%D1%81%D1%82%D1%80%D0%BE%D0%B5%D0%BD-%D1%8D%D1%82%D0%BE%D1%82-%D1%81%D0%B0%D0%B9%D1%82/","title":"Как устроен этот сайт"}]