Я ушёл с WordPress, открыл phpMyAdmin и обнаружил, что мой старый сайт взломали

Уйти с WordPress уже было одним из лучших технических решений, которые я принял для своего сайта. Я хотел получить больше контроля над кодом, меньше зависимостей, лучшую производительность, более надёжную безопасность, прямую интеграцию с OpenAI и систему управления контентом, которая действительно подстраивалась бы под мои потребности, а не заставляла бы меня устанавливать ещё один плагин каждый раз, когда я хотел что-то изменить.

Поэтому я создал собственную CMS и начал постепенно переносить некоторые старые статьи из WordPress. Процесс был довольно простым. Я открыл phpMyAdmin, начал просматривать старую базу данных WordPress, нашёл записи, которые хотел сохранить, и принялся всё приводить в порядок перед переносом содержимого в новую систему.

Затем я заметил кое-что интересное.

В базе данных были строки, которым там не место. По всей базе данных был разбросан бесполезный мусор. Там встречались странные записи и публикации, которые я точно не помнил. Чем внимательнее я просматривал базу данных, тем очевиднее становилось, что в какой-то момент кто-то, по всей видимости, проник в старую установку WordPress и решил сделать там косметический ремонт.


Великолепно.

Я не могу с уверенностью сказать, как именно была взломана старая установка WordPress, не проведя полного криминалистического анализа сервера. Это могла быть SQL-инъекция. Это мог быть уязвимый или устаревший плагин. Это могла быть уязвимость темы, украденные учётные данные администратора, скомпрометированные учётные данные FTP или хостинга, старый бэкдор на PHP, вредоносная загрузка, раскрытые учётные данные базы данных или какой-либо другой забытый компонент, который годами находился на сервере.

Важно понимать, что обнаружение мусорных записей в базе данных само по себе не доказывает наличие SQL-инъекции. Тот, кто видит странные строки в базе данных и сразу кричит: «SQL-ИНЪЕКЦИЯ!», не проверив сервер, просто гадает. Существует множество способов взломать установку WordPress. К сожалению, WordPress предлагает злоумышленникам очень большое меню вариантов, если за годы в установке накопилось достаточно плагинов, тем, пользователей, резервных копий и забытых файлов.

WordPress исчез, но урок безопасности остался

Мой текущий сайт вообще не использует WordPress. Он работает на самописной CMS, которую я создал специально под свои нужды. Она управляет страницами, публикациями, категориями, медиафайлами, SEO, переводами, инструментами работы с контентом на базе OpenAI, комментариями, платежами Stripe, административными функциями, пользовательскими плагинами, аудитом безопасности и другими функциями, которыми я действительно пользуюсь.

Отказ от WordPress устранил огромное количество ненужной поверхности атаки. Под сайтом больше нет гигантской экосистемы плагинов. Нет случайных тем, которые подключают библиотеки от десяти разных разработчиков. Нет забытых плагинов, переставших получать обновления безопасности пять лет назад. Если мне нужна какая-либо функция, я точно знаю, где она реализована и какой код за неё отвечает.

Это не означает, что пользовательское программное обеспечение автоматически безопасно. Пользовательское ПО может быть столь же уязвимым, а иногда и значительно более уязвимым, если разработчик предполагает, что никто не поймёт, как оно работает. Безопасность за счёт непонятности — это не безопасность. Разница в том, что в собственной CMS я могу проверить всю поверхность атаки, удалить ненужные функции, регистрировать подозрительное поведение и изменять архитектуру приложения, когда обнаруживаю что-то, что мне не нравится.

Обнаружение мусора в моей старой базе данных WordPress стало веской причиной ещё раз проверить новую CMS, на этот раз с гораздо более параноидальным настроем. В кибербезопасности разумная доля паранойи — это, по сути, профилактическое обслуживание.

Начинаем с базы данных

Одной из первых областей, которые я проверил, стала безопасность базы данных. Новая CMS использует подготовленные выражения PDO для значений, контролируемых пользователем, вместо того чтобы формировать SQL-запросы, напрямую объединяя случайные поля форм в строки запросов. Это значительно уменьшает традиционную поверхность атаки SQL-инъекций.

Выполнение нескольких операторов MySQL также отключено для обычных подключений приложения к базе данных. Учётная запись базы данных должна иметь только те разрешения, которые действительно нужны CMS. PHP-приложению, доступному из Интернета, не нужно предоставлять неограниченные административные права в базе данных просто потому, что так проще выдать все права.

Удобство и безопасность никогда не были особенно хорошими соседями.

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

Я создал центр безопасности, потому что хочу знать, кто стучится

После того как я увидел, что произошло со старым сайтом, одной из главных функций, которые я хотел видеть в новой CMS, была прозрачность. Если кто-то проверяет мой сервер, многократно не проходит аутентификацию, ищет файлы конфигурации, обращается к поддельным конечным точкам WordPress, злоупотребляет формами или выполняет подозрительные административные действия, я хочу, чтобы это фиксировалось.

Теперь в CMS есть отдельный центр безопасности, который записывает действия, связанные с безопасностью. В зависимости от события в журнал могут заноситься IP-адрес источника, дата и время, метод запроса, запрошенный путь, пользовательский агент, аутентифицированный администратор, категория события, уровень серьёзности и частота обнаружения аналогичной активности.

Система безопасности может отслеживать успешные и неудачные входы администраторов, ошибки авторизации, подозрительные проверки URL, нарушения ограничения частоты запросов, административные POST-запросы, изменения содержимого, ошибки вебхуков Stripe, активность в формах контактов и комментариях, а также попытки доступа к поддельной системе входа WordPress.

В то же время регистратор намеренно не превращается в сундук с сокровищами для злоумышленника. Пароли не сохраняются. Файлы cookie не выгружаются в журнал. Заголовки авторизации, API-ключи, секреты Stripe, токены CSRF и аналогичные учётные данные исключаются или скрываются. Нет смысла создавать сложную систему безопасности, а затем услужливо хранить все секреты, которые понадобились бы злоумышленнику, в одной таблице базы данных.

администратор

Блокировка IP встроена в CMS

Если я замечаю особенно настойчивый сканер или кого-то, кто неоднократно отправляет подозрительные запросы, я могу напрямую заблокировать отдельный IPv4- или IPv6-адрес из Центра безопасности. При необходимости можно также блокировать диапазоны CIDR.

CMS может применять эти блокировки на уровне приложения, но также может создавать правила Apache для блокировки на уровне сервера. Это различие важно. Блокировка кого-либо внутри PHP означает, что веб-приложение всё равно должно получить и обработать часть запроса. Блокировать адрес в Apache, брандмауэре или на сетевой границе лучше, поскольку запрос можно отклонить до того, как приложению придётся тратить ресурсы на его обработку.

Если я выставляю кого-то из здания, я предпочитаю остановить его у входной двери, а не сначала позволять ему подняться на лифте наверх.

Я не позволяю посетителям выбирать собственный IP-адрес

Ещё одна удивительно распространённая проблема безопасности связана с такими заголовками, как X-Forwarded-For и X-Real-IP. Иногда плохо спроектированные приложения автоматически доверяют этим заголовкам и записывают любой IP-адрес, который отправляет клиент.

Это проблема, поскольку любой пользователь Burp Suite, curl, собственного скрипта или другого HTTP-клиента может передать поддельный заголовок пересылки. Злоумышленник может отправить X-Forwarded-For: 8.8.8.8, и внезапно ваша система безопасности решит, что на вас нападает Google.

Моя CMS доверяет заголовкам forwarded-client-IP только тогда, когда фактическое соединение устанавливается с адреса прокси, который я явно настроил как доверенный. В противном случае CMS использует фактический адрес удалённого соединения. Я не заинтересован в ведении журнала безопасности, в котором расследуемый человек может сам выбрать, какой IP-адрес будет указан рядом с его действиями.

Кажется, все в Интернете хотят заполучить мой файл .env

Вскоре после того, как я начал внимательнее следить за журналами безопасности, я заметил кое-что ещё. Автоматические сканеры просто обожают искать файлы окружения.

Они проверяют такие пути, как /.env, /.env.production, /.env.local, /.env.backup, /.env.old, /.git/config, /.aws/credentials, /database.sql, /backup.sql, а также различные файлы конфигурации и резервных копий.

Похоже, мои переменные окружения чрезвычайно популярны. Пожалуй, мне стоит начать взимать плату за вход.

Теперь конфигурация Apache отклоняет запросы к скрытым файлам, файлам конфигурации, резервным копиям, дампам баз данных, каталогам систем контроля версий, временным файлам, файлам журналов и другим конфиденциальным ресурсам ещё до того, как запрос попадёт в обычную систему маршрутизации CMS.

Каталог .well-known намеренно исключён из общего ограничения для скрытых файлов, поскольку легитимным сервисам, таким как ACME и Let's Encrypt, может потребоваться доступ к нему. Со всем остальным обходятся значительно менее гостеприимно.

Я также предпочитаю возвращать общий ответ 404 для многих разведывательных запросов, а не подтверждать существование защищённого ресурса. Нет смысла сообщать автоматическому сканеру: «Поздравляю, вы нашли мой файл .env, но, к сожалению, я не позволю вам его прочитать». Простое сообщение «здесь ничего нет» предоставляет меньше информации.

Для дополнительного уровня защиты секреты рабочей среды можно полностью хранить за пределами общедоступного корневого каталога документов. Таким образом, даже если правило Apache будет случайно изменено или перезапись завершится ошибкой, внутри каталога веб-сайта не окажется файла среды, который веб-сервер сможет выдать.

Внутренние каталоги остаются внутренними

В CMS есть внутренние каталоги для конфигурации, подключаемых файлов, кода базы данных, хранилища, плагинов и документации. Эти каталоги существуют потому, что они нужны приложению. Это не общедоступные страницы, и у кого-либо в Интернете нет законных причин просматривать их содержимое.

Прямой веб-доступ к каталогам приложения, содержащим конфиденциальные данные, заблокирован. Индексация каталогов отключена, как и ненужное выполнение CGI. Статические ресурсы и приложения, намеренно сделанные общедоступными, по-прежнему могут обслуживаться в обычном режиме, но внутренние исходные файлы рассматриваются как внутренние исходные файлы.

Мне не нужно, чтобы Apache вежливо генерировал индекс структуры моего приложения для кого-то, кто занимается разведкой. Если им нужна экскурсия по моему дереву исходного кода, пусть подождут выхода книги.

Заголовки безопасности — ещё один уровень защиты

Веб-сервер также возвращает набор ориентированных на безопасность HTTP-заголовков. Среди них есть средства защиты от определения типа содержимого, фрейминга, утечки информации о реферере, ограничения разрешений браузера, принудительного использования HTTPS, поведения политики между доменами и исполняемого встроенного содержимого.

Конфигурация включает такие элементы управления, как X-Content-Type-Options, Referrer-Policy, X-Frame-Options, X-Permitted-Cross-Domain-Policies, Permissions-Policy, Strict-Transport-Security и Content-Security-Policy.

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

Сессии администраторов стали более защищёнными

Система аутентификации администраторов также прошла серьёзную проверку безопасности. Для новых паролей администраторов установлены более строгие минимальные требования, а для хеширования паролей используются современные алгоритмы, такие как Argon2id, если среда PHP их поддерживает. Старые совместимые хеши могут быть обновлены после успешной аутентификации.

Попытки входа ограничиваются, поэтому кто-либо не может бесконечно отправлять запросы через форму входа без последствий. При сбоях аутентификации используются общие ответы, не раскрывающие, существует ли указанное имя пользователя или адрес электронной почты. Идентификаторы сессий регенерируются на важных этапах аутентификации, а административные сессии имеют как ограничения по бездействию, так и абсолютные сроки истечения.

Файлы cookie сессий используют соответствующие настройки Secure, HttpOnly и SameSite. Включён строгий режим работы сессий, а дополнительные характеристики сессии могут проверяться, чтобы усложнить повторное использование украденной сессии.

Я по-прежнему реалистично смотрю на это. Если кто-то украдёт сам аккаунт хостинга, учётные данные базы данных или доступ на уровне сервера, безопасность сессий приложения перестанет быть моей главной проблемой. У безопасности есть уровни, и каждый уровень защищает от своего класса сбоев.

Установщик не должен превращаться в поверхность атаки

Во время аудита безопасности я также обнаружил кое-что в собственной CMS, что мне не особенно понравилось. Ранее в установщике был предусмотрен аварийный механизм, призванный помочь восстановиться после незавершённых установок.

Это может быть удобно при разработке программного обеспечения. Но после запуска сайта в рабочей среде это становится значительно менее привлекательным.

Принудительный обход установки был удалён. После того как CMS получает реальные рабочие данные, установщик блокируется. Если мне действительно нужно восстановить незавершённую установку, я должен явно включить функцию восстановления через конфигурацию сервера.

Я предпочту доставить себе неудобство на тридцать секунд, чем оставить путь к установщику на общедоступном сервере на следующие десять лет.

Редактор PHP-плагинов обычно отключён

В моей CMS есть собственная система плагинов. Это полезно, потому что я могу расширять функциональность, не превращая основное приложение в спагетти-код. Но это также означает, что CMS потенциально может содержать функциональность, способную изменять PHP-код.

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

То же общее правило применяется к доверенным административным полям, способным хранить неограниченный HTML или JavaScript. Эти функции доступны только пользователям с очень высокими привилегиями, поскольку с точки зрения безопасности возможность внедрить исполняемый JavaScript на веб-сайт — это совсем не то же самое, что изменить цвет кнопки.

К загруженным изображениям относятся как к незнакомцам

Загрузка файлов также получила дополнительные меры защиты. CMS не слепо верит браузеру лишь потому, что браузер утверждает, будто файл является изображением. Фактическое содержимое проверяется и проходит валидацию.

Загруженные изображения публикаций декодируются и заново кодируются в новые файлы WebP. Их приводят к размерам, используемым CMS, а этот процесс помогает удалить из исходного файла ненужные встроенные данные. Приложение также проверяет расширения файлов, фактическую MIME-информацию, размеры изображения, размер файла и возможные проблемы, связанные с распаковкой.

Исполняемые расширения файлов отклоняются, а каталоги загрузки настроены так, чтобы загруженные файлы внезапно не решили превратиться в PHP-приложения.

Если кто-то загрузит файл с именем totally-not-a-webshell.php, я предпочту, чтобы сервер не отвечал: «Похоже на семейную фотографию».

Ограничение частоты запросов предотвращает засорение базы данных без какого-либо взлома

Взломанная база данных WordPress также заставила меня задуматься о том, что технически вообще не требует эксплойта. Легитимной общедоступной функцией иногда можно злоупотреблять настолько, что практический результат будет таким же, как при атаке.

На моём сайте доступны платные функции связи и комментирования. Чтобы перенаправить кого-либо на Stripe Checkout, CMS может создать временную ожидающую запись до завершения оплаты. Без средств контроля бот мог бы тысячи раз подряд запускать этот процесс, ни разу ничего не оплачивать и постепенно заполнять базу данных бесполезными ожидающими записями.

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

Не каждая атака требует сложного эксплойта. Иногда кто-то может просто нажать на совершенно легитимную кнопку десять тысяч раз и превратить вашу базу данных в свалку.

Административные действия защищены от CSRF

Административные изменения также защищены от межсайтовой подделки запросов. Сам факт входа в CMS не должен позволять другому веб-сайту обманом заставить браузер отправить административный запрос за спиной пользователя.

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

Ведение журналов может показаться избыточным, когда есть только один основной администратор. Но оно становится гораздо менее избыточным, когда что-то меняется, а вы смотрите на экран и думаете: «Я совершенно точно этого не делал».

Затем я создал самую увлекательную функцию безопасности: фальшивый WordPress

Вероятно, это моя любимая часть всей системы безопасности.

Мой сайт больше не работает на WordPress. У обычного посетителя нет абсолютно никаких законных причин запрашивать /wp-admin или /wp-login.php. Обычные читатели не просыпаются утром и случайно не вводят /wp-admin после имени моего домена.

А вот боты обожают эти URL.

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

Если кто-то посещает /wp-admin или /wp-login.php, CMS показывает фальшивый экран входа WordPress. Он достаточно похож на WordPress, чтобы удовлетворить автоматические проверки и любопытных посетителей, но за ним нет настоящей установки WordPress.

Они могут ввести имя пользователя. Могут ввести пароль. Могут нажать «Войти». Могут просто смотреть на него. Могут попробовать снова. Могут начать сомневаться в своих жизненных решениях. Страница продолжает оставаться совершенно бесполезной для них, а Центр безопасности тем временем незаметно записывает интересные детали попытки.

Ловушка записывает такие данные, как IP-адрес, временная отметка, запрошенная конечная точка, пользовательский агент, количество обращений и введённое имя пользователя. Она намеренно не сохраняет отправленный пароль. Мне не нужен чей-либо пароль. Мне нужно лишь знать, что IP-адрес потратил двадцать минут на попытки аутентификации в установке WordPress, которой не существует.

По сути, это цифровая дверь, нарисованная на кирпичной стене.

Ловушка WordPress — это не просто шутка

Ловушка забавна, но она также создаёт полезный сигнал безопасности. Поскольку сайт на самом деле не использует WordPress, запросы к ресурсам, специфичным для WordPress, сразу становятся интересными.

Если кто-то начинает запрашивать /wp-admin, /wp-login.php, /wp-config.php или /xmlrpc.php, я уже знаю, что этот посетитель, вероятно, не читает ни одну из моих статей. Запрос может быть всего лишь частью автоматизированного сканирования уязвимостей по всему Интернету, но также может представлять собой разведку конкретного сайта.

В любом случае посетитель любезно обозначил себя как нечто, что стоит записать.

Есть что-то приятное в том, как сканер пытается определить WordPress, пока настоящая CMS тихо наблюдает из-за кулис.

Сам WordPress не обязательно был виновником

Я должен прояснить одну вещь. Я не могу однозначно утверждать, что именно WordPress стал причиной взлома. WordPress работает на огромной части Интернета, и его вполне можно надёжно защищать при правильном обслуживании.

Чаще всего большая проблема заключается в том, что со временем разрастается вокруг WordPress. Зрелая установка может включать ядро WordPress, пользовательскую тему, двадцать или тридцать плагинов, учётные записи администраторов, о создании которых никто не помнит, архивы резервных копий, системы кэширования, инструменты миграции, заброшенные плагины, старые PHP-файлы, утилиты для работы с базой данных и разрозненный код, накапливавшийся много лет.

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

Я предпочитал упростить проблему. Мне хотелось точно знать, какое программное обеспечение обслуживает мой веб-сайт. Если сейчас что-то ведёт себя странно, мне не приходится гадать, виноваты ли WordPress, тема, Elementor, WooCommerce, плагин номер 47, расширение совместимости плагина номер 47 или какой-нибудь плагин, разработанный парнем, который исчез из Интернета в 2018 году.

Я знаю, где находятся маршруты. Я знаю, где выполняются SQL-запросы. Я знаю, где происходит аутентификация. Я знаю, куда загружаются файлы. Я знаю, какие конечные точки принимают POST-запросы. Я знаю, как работает моя интеграция со Stripe. Я знаю, откуда отправляются запросы к OpenAI. Я знаю, какие каталоги никогда не должны быть общедоступными.

Такую степень прозрачности трудно оценить.

Безопасность никогда не бывает завершённой

Самое важное, что я вынес из обнаружения старого взлома WordPress, — безопасность не является чем-то, что устанавливают один раз, а затем забывают. Не существует флажка с надписью «Безопасность веб-сайта: да».

Журналы безопасности необходимо просматривать. Программное обеспечение необходимо обновлять. Учетные данные время от времени необходимо менять. Разрешения на доступ к файлам необходимо проверять. Неожиданную активность в базе данных необходимо расследовать. Резервные копии должны существовать, но желательно не в виде /backup.zip, лежащего внутри общедоступного веб-сайта и ожидающего, пока сканер его обнаружит.

Старые файлы необходимо удалять. Инструменты разработки необходимо отключать в рабочей среде. Администраторам необходима надежная аутентификация. Загружаемые файлы необходимо проверять. Запросы к базе данных необходимо параметризовать. Доступ к конфиденциальным каталогам необходимо защищать. Подозрительные запросы должны быть видимыми.

А если кому-то хочется потратить вечер на попытки перебора паролей для поддельной установки WordPress на веб-сайте, который на самом деле не работает на WordPress, иногда остается только восхититься этой самоотдачей.

Почему я по-прежнему рад, что ушел с WordPress

Изначально я ушел с WordPress, потому что хотел более быстрый, чистый и управляемый веб-сайт. Я хотел CMS, построенную вокруг моего собственного рабочего процесса публикации, вместо того чтобы тратить время на установку плагинов для обхода ограничений других плагинов.

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

Моя нынешняя CMS не непобедима. Ничто, открытое для Интернета, таковым не является. Разница в том, что теперь я контролирую приложение. Я контролирую уровень базы данных. Я контролирую аутентификацию. Я контролирую процесс загрузки файлов. Я контролирую маршрутизацию. Я контролирую журналы безопасности. Я контролирую, какие административные функции существуют и когда они включены.

И самое главное — я действительно могу видеть, когда кто-то трясет двери.

А если эти сканеры отчаянно настаивают на поиске WordPress?

Ладно.

Я оставил им один.

В некотором роде.