Вредоносный код в базе данных сайта: где искать и как вычистить
Файлы почистили, а редиректы и спам остались. Разбираем инъекции в wp_options, посты и виджеты, поиск по дампу и безопасную замену.
Содержание
- Почему чистки файлов недостаточно
- Где именно в базе прячут код
- Как выглядит инъекция
- Поиск: дамп и grep
- Таблица находок и способов лечения
- Чистка без порчи сериализованных данных
- Ключи, соли и остальное после чистки
- Повторная проверка через неделю
- Чек-лист по шагам
- Сколько это стоит и когда звать человека
- Частые вопросы
Если файлы чистые, а редирект на казино остался, код сидит в базе. Прячут его в настройках, в содержимом записей, в виджетах и в мета-полях. Чистить надо аккуратно: часть значений хранится сериализованной, и прямая замена текста ломает настройки сайта.
Сценарий, который я разбираю несколько раз в год. Подрядчик залил чистые файлы CMS, антивирус хостинга отчитался нулём находок, а сайт по-прежнему кидает мобильных посетителей на казино и показывает в подвале ссылки на дженерики. Значит, вредоносный код на сайте сидит не в файлах, а в базе данных, куда сканеры файлов принципиально не заглядывают. Ниже: в каких таблицах он прячется, как его найти дампом и парой запросов, чем вычистить, не порвав сериализованные данные, и что сделать после, чтобы через неделю всё не вернулось. Основной пример на WordPress, но логика одинаковая для Битрикса, Joomla и OpenCart.

Почему чистки файлов недостаточно
Файловый сканер сравнивает содержимое wp-includes, wp-admin и ядра с эталоном, ищет сигнатуры в PHP и подозрительные права на папках. База данных для него чёрный ящик: там лежит контент, и любой текст в постах формально легитимен.
При этом WordPress сам исполняет часть содержимого базы. Тема выводит <script> из опции виджета, шорткод в теле поста дергает функцию, значение опции подставляется в <head> через хук. Атакующему не обязательно оставлять бэкдор в файлах: достаточно записать пару строк в wp_options, и они будут отдаваться на каждой странице. Файлы можно перезаливать хоть каждый день, поведение не изменится.
Второй момент, про который забывают. Инъекция в базе часто это не сам взлом, а его последствие. Точка входа была в файлах (уязвимый плагин, шелл в загрузках, украденный пароль FTP), и её надо закрывать отдельно, иначе базу вычистят и через сутки перезапишут. Про поиск входа отдельно писал в статье про то, как найти точку взлома по логам, а общий порядок лечения файловой части разбирал в материале про то, как очистить WordPress от вирусов.
Где именно в базе прячут код
За годы работы список любимых мест почти не меняется. Идти по нему стоит именно в этом порядке: сверху то, что срабатывает чаще.
wp_options и автозагрузка
Главная цель. Опции с autoload = yes подтягиваются на каждый запрос, поэтому вставка отработает на любой странице сайта, включая 404.
Что там встречается: посторонний JS в опциях темы, левый домен в siteurl или home (сайт при этом тянет скрипты с чужого хоста), опция с бессмысленным именем вроде wp_cd_code или class_generate_cache, куда положили base64-блоб на 40 килобайт. Опция active_plugins с записью о плагине, которого вы не ставили, тоже отсюда.
Побочный эффект: раздутая автозагрузка тормозит сайт, и иногда владелец приходит сначала с жалобой на скорость. Разбор этой механики есть в статье про раздутую автозагрузку в базе WordPress.
wp_posts и wp_postmeta
Скрытый <div style="display:none"> со ссылочной помойкой в конце текста статьи. Iframe нулевого размера. Ссылка на чужой домен, вшитая в старую запись двухлетней давности, которую никто не открывает. В wp_postmeta то же самое прилетает через поля SEO-плагина или конструктора страниц, там текст никто глазами не читает.
Отдельный подвид: атакующий создаёт запись со статусом draft или private, кладёт туда PHP-код и вызывает его через шорткод из плагина-исполнителя. Без плагина код мёртв, но при повторном заражении оживает.
Виджеты и меню
Опции вида widget_text, widget_custom_html, widget_block. Классика: текстовый виджет в неиспользуемой области сайдбара, куда положили счётчик с левым скриптом. Область не выводится в шаблоне, а опция всё равно грузится, и на некоторых темах виджет всё-таки рендерится в футере мобильной версии.
wp_users и wp_usermeta
Лишняя учётка с ролью администратора, часто с правдоподобным именем: wpsupport, admin2, backup, seo_manager. Второй вариант тоньше: существующему пользователю с ролью подписчика в wp_usermeta переписывают wp_capabilities на администраторские. В списке пользователей он выглядит как обычный клиент, пока не откроете его профиль.
wp_comments и таблицы плагинов
Спам в комментариях со скриптами в теле, если тема выводит комментарии без фильтрации. Плюс собственные таблицы плагинов: кэш, логи, очереди рассылок. В них вставка живёт дольше всего, потому что искать её никто не догадывается.
Как выглядит инъекция
Полезно знать в лицо. Обфускация у массовых кампаний однотипная, авторских шедевров я почти не встречал.
- Открытый скрипт со стороннего домена:
<script src="https://cdn-analytics-tag.top/js/stat.js"></script>. Домен маскируется под аналитику, шрифты или CDN. - Base64-блоб внутри
eval(base64_decode('...'))или просто длинная строка из букв и цифр без пробелов в опции, где ожидается пара слов. - Шестнадцатеричные строки:
x3cx73x63x72x69x70x74вместо<script. - Скрытый блок:
<div style="position:absolute;left:-9999px">с двумя сотнями ссылок. - Условный редирект: скрипт проверяет
navigator.userAgentна мобильный и реферер из поиска, и только тогда уводит на чужой сайт. Владелец с десктопа не видит ничего, а с телефона по ссылке из Яндекса улетает на витрину.
Последний пункт объясняет, почему заражение находят поздно. Хозяин заходит на сайт напрямую и с рабочего ноутбука, для него всё в порядке. Другие симптомы, по которым заражение опознаётся раньше, я собирал в статье про признаки взлома сайта.
Поиск: дамп и grep
Самый быстрый способ понять масштаб. Снимаем дамп и ищем по нему обычным grep, потому что глазами через phpMyAdmin вы будете листать таблицы до вечера.
# снимаем дамп (сначала сохраните его копию как улику)
mysqldump -u dbuser -p --single-transaction --skip-lock-tables dbname > /home/user/dump.sql
cp /home/user/dump.sql /home/user/dump-evidence.sql
# ищем типовые маркеры
grep -c -o -i -E 'eval(|base64_decode|<iframe|document.write|\x3c' /home/user/dump.sql
# смотрим, какие внешние домены встречаются в базе
grep -o -E 'https?://[a-zA-Z0-9._-]+' /home/user/dump.sql | sort | uniq -c | sort -rn | head -40
Последняя команда даёт больше всего пользы. У нормального сайта в топе свой домен, домены Яндекса, schema.org, WordPress.org и пара сервисов, которые вы сами подключали. Всё остальное с ненулевым счётчиком отправляется на разбор. Домены в зонах .top, .xyz, .club, .icu, .cc и свежие .ru с именем в стиле js-cdn-fast подозрительны почти всегда.
Если SSH нет, а есть только phpMyAdmin, экспортируйте базу в SQL, скачайте и ищите тем же способом локально в редакторе с поиском по регулярным выражениям.
Запросы прицельно по таблицам
Когда маркер найден, локализуем его запросами.
-- тяжёлые опции в автозагрузке: смотрим топ-20 по размеру
SELECT option_name, LENGTH(option_value) AS len, autoload
FROM wp_options
WHERE autoload = 'yes'
ORDER BY len DESC
LIMIT 20;
-- опции с явными маркерами
SELECT option_id, option_name, LEFT(option_value, 120)
FROM wp_options
WHERE option_value LIKE '%<script%'
OR option_value LIKE '%base64_decode%'
OR option_value LIKE '%eval(%';
-- посты со скриптами и скрытыми блоками
SELECT ID, post_type, post_status, post_title
FROM wp_posts
WHERE post_content LIKE '%<script%'
OR post_content LIKE '%display:none%'
OR post_content LIKE '%<iframe%';
-- администраторы: сверяем список с тем, что вы ожидаете увидеть
SELECT u.ID, u.user_login, u.user_email, u.user_registered
FROM wp_users u
JOIN wp_usermeta m ON m.user_id = u.ID
WHERE m.meta_key = 'wp_capabilities'
AND m.meta_value LIKE '%administrator%';
Префикс таблиц у вас может быть не wp_, посмотрите его в wp-config.php в переменной $table_prefix.
Ещё один приём, который экономит часы: сравнение с чистой копией. Если есть бэкап месячной давности, когда сайт точно был здоров, разверните его в отдельную базу и сравните списки опций.
mysql -u dbuser -p -N -e "SELECT option_name FROM wp_options ORDER BY option_name" dbname > now.txt
mysql -u dbuser -p -N -e "SELECT option_name FROM wp_options ORDER BY option_name" dbname_clean > clean.txt
diff clean.txt now.txt
Появившиеся за месяц опции видны сразу. Половина из них будет легитимной (плагины плодят транзиенты пачками), но остальное разбирается за пять минут.
Таблица находок и способов лечения
| Где нашли | Как обычно выглядит | Чем чистить |
|---|---|---|
wp_options, autoload | JS в опции темы, base64-блоб, левый siteurl | wp option delete, правка значения через wp option update |
wp_options, виджеты | widget_text со скриптом в неиспользуемой области | удалить виджет в админке, затем проверить опцию |
wp_posts | скрытый div, iframe, ссылки в конце текста | wp search-replace по точной строке |
wp_postmeta | вставка в поля SEO-плагина и конструктора | wp search-replace, затем сброс кэша плагина |
wp_users | лишний администратор | wp user delete <ID> --reassign=<ID владельца> |
wp_usermeta | переписанные wp_capabilities | понизить роль в админке, проверить запросом повторно |
wp_comments | спам со скриптами | массовое удаление по статусу, затем проверка одобренных |
| Таблицы плагинов | кэш и логи с сохранённой вставкой | очистить таблицу через интерфейс плагина |
Чистка без порчи сериализованных данных
Главная ошибка при самостоятельном лечении. Половина значений в wp_options и wp_postmeta хранится в сериализованном виде PHP, где перед каждой строкой записана её длина: s:25:"текст на двадцать пять";. Если вы вырежете кусок из такой строки обычным поиском и заменой по дампу или запросом UPDATE ... REPLACE(), длина перестанет совпадать с содержимым. PHP не сможет распаковать значение, и вы получите слетевшие настройки темы, пустые виджеты и потерянные поля вместо вылеченного сайта.
Правильный инструмент это wp search-replace, он распаковывает сериализованные значения, меняет содержимое и пакует обратно с пересчётом длины.
# всегда сначала бэкап и всегда сначала dry-run
wp db export /home/user/before-clean.sql
wp search-replace '<script src="https://cdn-analytics-tag.top/js/stat.js"></script>' ''
--all-tables --precise --report-changed-only --dry-run
# если число замен совпало с ожидаемым, выполняем без --dry-run
wp search-replace '<script src="https://cdn-analytics-tag.top/js/stat.js"></script>' ''
--all-tables --precise --report-changed-only
Правила, которые я соблюдаю без исключений:
- Сначала
--dry-run, всегда. Отчёт показывает таблицы и количество замен. Если ожидали 12 вхождений, а инструмент нашёл 400, строка подобрана слишком широко, и надо уточнять шаблон. - Строка для замены берётся максимально точная, целиком со скриптом и его атрибутами. Замена по фрагменту
<scriptвычистит вам и легитимную Метрику. --all-tablesнужен, чтобы задеть таблицы плагинов, иначеwp search-replaceпройдёт только по стандартным.- Опции с мусорным именем не чистим, а удаляем целиком:
wp option delete wp_cd_code. - После каждой пачки замен открываем сайт и админку. Ловить поломку через десять шагов дороже, чем через один.
Если WP-CLI на хостинге нет, а есть только phpMyAdmin, простой вариант такой: удалить заражённые опции целиком (они пересоздадутся плагином с дефолтами) и вручную отредактировать тела постов через админку. Медленнее, но безопаснее ручного UPDATE по сериализованному полю.
Ключи, соли и остальное после чистки
Вычистить вставки мало. Если атакующий имел доступ к базе, у него есть хеши паролей и, вероятно, действующие сессии.
Порядок, который я прохожу на каждом инциденте:
- Меняю соли и ключи в
wp-config.php. Новые генерируются наhttps://api.wordpress.org/secret-key/1.1/salt/, замена разлогинивает всех, включая атакующего с украденной сессией. - Меняю пароли всех администраторов и редакторов, пароль пользователя базы данных (с правкой
wp-config.php), пароль FTP или SSH и панели хостинга. Хранить их надо в менеджере паролей, а не в браузере на компьютере, с которого лечили сайт. - Удаляю лишних администраторов, переназначив их записи на владельца. Просто удалять нельзя: потеряете посты.
- Обновляю ядро, тему и плагины. Заодно сношу всё отключённое: неактивный плагин так же уязвим, как активный, файлы лежат на месте.
- Проверяю ключи API в опциях: токены платёжных модулей, ключи Метрики, доступы к SMTP. Их могли не только прочитать, но и подменить на свои (например, чтобы письма с заявками уходили мимо вас).
- Сбрасываю все виды кэша: плагин кэширования, OPcache, кэш CDN. Иначе вылеченная страница ещё сутки будет отдаваться заражённой.
Повторная проверка через неделю
Считать инцидент закрытым в день чистки рано. Реинфекция происходит по двум причинам: не закрыта точка входа, либо в системе остался планировщик, который восстанавливает вставку.
Смотрю в первую очередь на wp_options с именем cron, там иногда прописано задание с несуществующим хуком, которое дергает функцию бэкдора. И на mu-plugins, куда любят класть загрузчик: эта папка не показывается в обычном списке плагинов и не отключается из админки.
Простой сторож, который ставится за пять минут и ловит возврат раньше клиента:
# эталонный слепок автозагрузки после чистки
mysql -u dbuser -p -N -e "SELECT option_name, MD5(option_value) FROM wp_options WHERE autoload='yes' ORDER BY option_name" dbname > /home/user/autoload.base
# ежедневная проверка по крону
mysql -u dbuser -p -N -e "SELECT option_name, MD5(option_value) FROM wp_options WHERE autoload='yes' ORDER BY option_name" dbname > /home/user/autoload.now
diff /home/user/autoload.base /home/user/autoload.now || echo "Автозагрузка изменилась, проверить сайт"
Ложные срабатывания будут на транзиентах и счётчиках плагинов, их имена добавляются в исключения после первой недели наблюдений. Дальше файл молчит месяцами и оживает ровно тогда, когда что-то произошло.
Через семь дней после чистки повторяю поиск по дампу теми же командами и проверяю сайт с мобильного через поиск, а не по прямой ссылке. Если сайт успел попасть в чёрные списки браузеров и поисковиков, отдельно нужна процедура снятия метки: заявка на перепроверку в Вебмастере и в Search Console, обе рассматриваются от суток до недели.
Чек-лист по шагам
- [ ] Снял дамп и сохранил отдельную копию как улику до любых правок.
- [ ] Прогнал grep по дампу на маркеры и собрал список внешних доменов.
- [ ] Проверил
wp_options(в том числе autoload),wp_posts,wp_postmeta, виджеты, пользователей, комментарии, таблицы плагинов. - [ ] Сверил список опций с чистым бэкапом, если он есть.
- [ ] Чистил через
wp search-replaceс--dry-runи точной строкой, а не UPDATE по сериализованному полю. - [ ] Удалил лишних администраторов с переназначением записей.
- [ ] Сменил соли, пароли админов, пароль базы, доступы FTP и панели.
- [ ] Обновил ядро, тему, плагины, снёс неактивные.
- [ ] Проверил
cronв опциях и папкуmu-plugins. - [ ] Сбросил кэш плагина, OPcache и CDN.
- [ ] Закрыл точку входа в файлах, иначе всё вышеперечисленное впустую.
- [ ] Поставил сторож на автозагрузку и назначил повторную проверку на седьмой день.
Сколько это стоит и когда звать человека
Разовая чистка базы с поиском точки входа у меня занимает от трёх до восьми часов в зависимости от размера сайта и того, сколько раз его уже пытались лечить до меня. По рынку разовое лечение взломанного сайта в 2026 году идёт в районе 15 000 — 40 000 рублей, повторные заражения дороже, потому что там уже надо разбирать чужие следы лечения.
Своими силами имеет смысл лезть, если сайт небольшой, бэкап есть, и вы понимаете, что делает каждая команда выше. Отдавать сразу стоит в двух случаях: заражение возвращается после чистки (значит, вход не найден) или сайт принимает оплату и работает с персональными данными, где цена ошибки выше стоимости работы.
Дешевле всего это не лечить, а не ловить: обновления по расписанию, бэкапы вне хостинга, ограничение админки и еженедельный контроль автозагрузки. Именно это лежит в основе абонентки, состав и цены смотрите в тарифах. Если сайт заражён прямо сейчас и вы не понимаете, куда лезть, напишите через форму на главной: по дампу базы обычно становится понятно за полчаса, что именно происходит и сколько будет стоить это вычистить.
Частые вопросы
Где в базе прячут вредоносный код
В таблице настроек, в содержимом записей, в виджетах, в мета-полях и в задачах планировщика. Реже создают свои таблицы с безобидными именами.
Как искать
Снять дамп базы и поискать в нём типовые признаки: кодированные строки, вызовы выполнения кода, чужие домены. Дамп удобнее тем, что искать можно обычными текстовыми средствами.
Как чистить, не сломав сайт
Сначала бэкап. Для сериализованных значений использовать замену, которая понимает их формат. Записи, назначение которых непонятно, не удалять, а отключать и наблюдать.
Что делать после чистки
Сменить ключи и соли, разлогинив всех, включая злоумышленника. Сменить пароли, обновить всё до актуальных версий и проверить сайт повторно через неделю.