Уязвимости PHP на старых проектах: как защитить сайт, который нельзя обновить
У каждого, кто давно делает сайты, есть такой проект. Ему лет десять, внутри Joomla 1.5 или WordPress времён темы Twenty Twelve, под капотом PHP 5.6, а на сервере стоит Ubuntu, которую последний раз обновляли, когда в моде были спиннеры. Клиент произносит волшебную фразу: «Работает — не трогай». Бюджета на переписывание, разумеется, нет.
Я слышу эту фразу с 2008 года. И могу сказать одно: «не трогать» в 2026 году — это тоже решение. Просто его последствия достаются не вам, а ботам, которые круглосуточно сканируют интернет и очень любят такие сайты.
Ниже — что именно делает старый PHP опасным и как защитить проект, который пока нельзя переписать. От мер на пять минут до таких, после которых коллеги начнут смотреть на вас с уважением и лёгкой тревогой.
Главное за 30 секунд
- PHP 5.6 не получает исправлений безопасности с конца 2018 года, вся ветка 7.x — с ноября 2022-го. 8.0 и 8.1 тоже уже вне поддержки.
- Взламывают чаще не сам PHP, а CMS и её расширения. Старый PHP и старая ОС превращают такой взлом из неприятности в катастрофу.
- Самое важное: запрет PHP в папках загрузок, изоляция сайтов друг от друга, бэкапы вне сервера и поддерживаемая операционная система.
- Настоящее лечение одно — миграция на PHP 8.4 или 8.5. Всё остальное — способ до неё дожить.
Что не так со старым PHP
У PHP чёткий жизненный цикл. Сейчас каждая ветка два года получает обычные исправления и ещё два — только исправления безопасности (php.net). После этого ветка объявляется End of Life, и разработчики перестают её чинить. Совсем. Даже если завтра найдут дыру, через которую можно выполнить любой код на сервере.
| Версия | Конец поддержки безопасности | Статус на октябрь 2026 |
|---|---|---|
| 5.6 | 31 декабря 2018 | EOL, последний релиз 5.6.40 — 10 января 2019 |
| 7.0 | 10 января 2019 | EOL |
| 7.1 | 1 декабря 2019 | EOL |
| 7.2 | 30 ноября 2020 | EOL |
| 7.3 | 6 декабря 2021 | EOL |
| 7.4 | 28 ноября 2022 | EOL |
| 8.0 | 26 ноября 2023 | EOL |
| 8.1 | 31 декабря 2025 | EOL |
| 8.2 | 31 декабря 2026 | Только безопасность, осталось два месяца |
| 8.3 | 31 декабря 2027 | Только безопасность |
| 8.4 | 31 декабря 2028 | Активная поддержка |
| 8.5 | 31 декабря 2029 | Активная поддержка |
Источник: php.net/eol и php.net/supported-versions.
Что это значит на практике? Любая уязвимость в ядре PHP, найденная после 2018 года, в 5.6 остаётся открытой навсегда. Два примера хорошо показывают масштаб.
CVE-2019-11043. Удалённое выполнение кода в PHP-FPM при вполне типовом конфиге nginx — с fastcgi_split_path_info и без проверки существования файла. Достаточно специально сформированного URL. Исправление вышло в 7.1.33, 7.2.24 и 7.3.11. Автор публичного эксплойта прямо пишет, что эксплойт рассчитан на PHP 7+, но сама ошибка есть и в более ранних версиях (phuip-fpizdam). Для 5.6 патча не было и не будет.
CVE-2024-2961. Это ошибка не в PHP, а в iconv из glibc. Исследователь Charles Fol показал, как через фильтры php://filter она превращает обычное чтение файла в выполнение кода (разбор Lexfo). Исправляется обновлением glibc, то есть операционной системы. Если ОС старая и не обновляется, дыра открыта при любой версии PHP.
Второй пример — хороший мостик к главному.
Настоящая проблема — цепочка
Если честно, сам интерпретатор ломают редко. Типичный взлом старого сайта выглядит скучно. Дальше всё зависит от того, что окружает сайт:
- Бот находит уязвимое расширение CMS или форму загрузки без проверки и заливает webshell — PHP-файл, через который можно выполнять команды.
- Если все сайты на сервере работают от одного пользователя, атакующий читает чужие
configuration.phpиwp-config.php— вместе с паролями от баз. - Если ОС старая, он берёт публичный эксплойт повышения привилегий и становится root.
- С правами root можно всё: майнер, спам-рассылка, шифровальщик, удаление бэкапов, если они лежат на том же сервере.
Пункт 3 — не теория. В гостиничной сети, которую я обслуживаю, был взлом, который пришёл не через PHP, а через уязвимость устаревшей операционной системы. Вывод оттуда простой: защищать старый PHP и оставлять под ним старую ОС — всё равно что ставить бронированную дверь в картонную стену.
Несколько уязвимостей, для которых на GitHub давно лежат готовые эксплойты:
- PwnKit (CVE-2021-4034) — polkit/pkexec, root для любого локального пользователя. Ошибка жила в коде с 2009 года и есть почти во всех дистрибутивах.
- Baron Samedit (CVE-2021-3156) — переполнение буфера в sudo, тоже root. В коде с 2011 года.
- CVE-2024-1086 — ошибка в nf_tables ядра Linux, затронуты ядра от 3.15 до 6.8-rc1. CISA внесла её в каталог эксплуатируемых уязвимостей в мае 2024 года, а в октябре 2025-го подтвердила, что её используют шифровальщики (BleepingComputer).
На обновлённой системе эти эксплойты не работают. На сервере, который не видел apt upgrade с 2020 года, срабатывают с первого раза. А таких серверов много: стандартная поддержка Ubuntu 20.04 закончилась в мае 2025 года, Debian 11 полностью снят с поддержки 31 августа 2026-го (debian.org).
Хорошая новость: большую часть цепочки можно разорвать, не трогая код сайта. Пойдём от простого к сложному. Перед любыми изменениями сохраните копию конфигов, после — проверяйте nginx -t и открывайте сайт.
Уровень 1. Что сделать прямо сейчас
Эти шаги занимают от пяти минут до часа, почти ничего не ломают и закрывают самые массовые сценарии атак.
1. Посмотрите, что у вас вообще стоит
Начните с инвентаризации: версия ОС, ядро, PHP и пришли ли патчи на две самые известные дыры. Номер версии pkexec или sudo сам по себе мало что скажет — дистрибутивы переносят исправления в старые версии. Надёжнее поискать номер CVE в changelog пакета.
cat /etc/os-release | grep PRETTY_NAME
uname -r
ls /etc/php/ # какие версии PHP установлены
# Debian/Ubuntu: 0 означает, что патч не пришёл
zgrep -c CVE-2021-4034 /usr/share/doc/policykit-1/changelog.Debian.gz
zgrep -c CVE-2021-3156 /usr/share/doc/sudo/changelog.Debian.gz
# CentOS/RHEL
rpm -q --changelog polkit | grep -c CVE-2021-4034
rpm -q --changelog sudo | grep -c CVE-2021-3156
# Что слушает сеть
ss -tulpn
2. Если ОС уже без поддержки — включите ESM
Для Ubuntu 18.04 и 20.04 есть Expanded Security Maintenance — исправления безопасности через подписку Ubuntu Pro: для 18.04 до 2028 года, для 20.04 до 2030-го (ubuntu.com). Для личного использования она бесплатна до пяти машин, для бизнеса — платная. Если PHP установлен из штатного репозитория Ubuntu, ESM патчит и его.
pro attach ВАШ_ТОКЕН
pro enable esm-infra esm-apps livepatch
apt update && apt upgrade
Если ESM недоступен (Debian 10–11, CentOS 7, Ubuntu 16.04), сделайте минимум. Уберите SUID-бит с pkexec — так PwnKit перестаёт работать. Закройте файрволом всё, кроме 80, 443 и SSH. И поставьте переезд в план, он описан в третьем уровне.
chmod 0755 /usr/bin/pkexec
3. Выключите Xdebug
Xdebug — отладчик, на боевом сервере ему не место. В Xdebug 2 с включённым xdebug.remote_connect_back любой желающий может заставить сервер подключиться к своему компьютеру и выполнять код через отладчик.
phpdismod -v 5.6 xdebug # или: apt remove php5.6-xdebug
systemctl reload php5.6-fpm
Проверять через php -m бесполезно: у CLI и FPM разные php.ini. Положите временный файл с phpinfo(), найдите в выводе секцию xdebug и сразу удалите файл.
4. Не светите версиями
Это не защита, а снижение заметности: по заголовку X-Powered-By: PHP/5.6.40 сканер сразу понимает, какие эксплойты пробовать первыми.
; php.ini
expose_php = Off
# nginx, блок http {}
server_tokens off;
fastcgi_hide_header X-Powered-By;
5. Запретите выполнение PHP в папках загрузок
Это самый важный шаг первого уровня. Даже если через дырявую форму на сервер попадёт shell.php, запустить его не получится.
# В server {} — ОБЯЗАТЕЛЬНО выше location ~ \.php$
location ~* ^/(?:images|media|cache|tmp|logs|uploads|files|administrator/cache|wp-content/uploads)/.*\.(?:php\d?|phtml|phar)$ {
return 403;
}
Порядок важен: nginx проверяет regex-локации сверху вниз. Если location ~ \.php$ окажется выше, запрос уйдёт в PHP раньше, чем сработает запрет. Если за nginx стоит Apache, продублируйте запрет файлом .htaccess в каждой папке загрузок:
<FilesMatch "\.(php\d?|phtml|phar)$">
Require all denied
</FilesMatch>
Проверка: положите в папку тестовый PHP-файл и запросите его.
echo '<?php echo 1;' > /var/www/site1/public/images/test.php
curl -sI https://site1.ru/images/test.php | head -1 # должно быть 403
rm /var/www/site1/public/images/test.php
6. Закройте служебные файлы и каталоги
Боты первым делом проверяют .git, .env, дампы баз и забытые установщики. Исключение для /.well-known/acme-challenge/ обязательно, иначе Let's Encrypt не сможет продлить сертификат.
location ^~ /.well-known/acme-challenge/ {
allow all;
}
# Скрытые файлы и каталоги: .git, .env, .svn, .htaccess
location ~ /\. {
return 444;
}
# Дампы и бэкапы в webroot (уберите zip, если сайт раздаёт архивы)
location ~* \.(?:sql|sql\.gz|bak|old|orig|swp|tar|tgz|zip|log)$ {
return 444;
}
# Забытые установщики
location ~* ^/(?:installation|install|setup)(?:/|$) {
return 444;
}
Код 444 — особенность nginx: соединение закрывается без ответа, и сканер не получает даже заголовков. А папку installation в Joomla лучше просто удалить.
7. Закройте CVE-2019-11043 двумя строками
Та самая уязвимость PHP-FPM лечится конфигом, даже если патча для вашей версии нет. Достаточно, чтобы nginx проверял существование файла до передачи запроса в FPM.
location ~ \.php$ {
try_files $uri =404;
fastcgi_pass unix:/run/php/php5.6-fpm-site1.sock;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
}
; php.ini
cgi.fix_pathinfo = 0
; пул FPM: исполнять только .php
security.limit_extensions = .php
Одна оговорка. Если сайт использует адреса вида /index.php/страница (в Joomla так бывает при выключенном mod_rewrite), после cgi.fix_pathinfo = 0 их нужно проверить.
8. Отключите опасные функции
; php.ini — строго одной строкой, переносы ломают директиву
disable_functions = exec,passthru,shell_exec,system,proc_open,popen,pcntl_exec,putenv,imap_open,dl,show_source,phpinfo
Почему именно эти:
putenvв паре сmail()— классический обход черезLD_PRELOAD: атакующий подкладывает свою библиотеку и выполняет код, даже еслиexecзапрещён.imap_open— CVE-2018-19518, выполнение команд через параметр подключения. Исправлено в 5.6.39, но на старых серверах часто стоят более ранние сборки 5.6.pcntl_execиdl— запуск произвольного бинарника и загрузка расширений на лету.
А вот curl_exec и parse_ini_file без анализа трогать не стоит. Через cURL работают оплата, доставка и любые API, а Joomla 3 через parse_ini_file читает языковые файлы. Ещё одна ловушка: PHPMailer в режиме sendmail использует popen, и почта с сайта может молча перестать уходить.
И честно: на PHP 5.6 disable_functions — это усложнение атаки, а не защита. Через ошибки работы с памятью в самом интерпретаторе этот список обходится. Поэтому дальше — меры, которые работают на уровне ОС.
9. Уберите phpMyAdmin и закройте MySQL
phpMyAdmin и Adminer на боевом сервере — любимая цель сканеров. Удалите их, а базой управляйте через SSH-туннель со своего компьютера.
apt remove phpmyadmin
rm -f /var/www/*/public/adminer*.php
# На своей машине: туннель к MySQL сервера
ssh -L 3307:127.0.0.1:3306 user@server
# и подключайтесь любым клиентом к 127.0.0.1:3307
# my.cnf — MySQL не должен слушать внешние интерфейсы
[mysqld]
bind-address = 127.0.0.1
-- Нет ли пользователей, доступных отовсюду, и с опасными правами
SELECT user, host FROM mysql.user WHERE host = '%';
SELECT user, host FROM mysql.user WHERE File_priv = 'Y' OR Super_priv = 'Y';
10. SSH — только по ключам
Сначала убедитесь, что вход по ключу работает, и держите открытой вторую сессию, пока не проверите новый конфиг.
# /etc/ssh/sshd_config.d/00-hardening.conf
PasswordAuthentication no
KbdInteractiveAuthentication no
PermitRootLogin prohibit-password
sshd -t && systemctl reload ssh # на CentOS: systemctl reload sshd
sshd -T | grep -Ei 'passwordauth|permitroot'
Имя файла с префиксом 00- выбрано не случайно. В sshd побеждает первое найденное значение, а на облачных образах Ubuntu рядом лежит 50-cloud-init.conf с PasswordAuthentication yes. Поэтому финальную проверку делайте через sshd -T, а не по содержимому файла.
Уровень 2. Серьёзная защита: день-два работы
Первый уровень снижает вероятность взлома. Второй ограничивает ущерб, если взлом всё-таки случился, и помогает быстро о нём узнать. Если у вас на сервере больше одного сайта, начинайте отсюда.
Изоляция сайтов друг от друга
Когда все сайты работают от www-data, взлом одного означает взлом всех. Решение — отдельный системный пользователь и отдельный пул PHP-FPM на каждый сайт. Структура каталогов в примерах такая: /var/www/site1/{public,tmp,sessions,logs}, корень сайта — public.
useradd --system --shell /usr/sbin/nologin --home-dir /var/www/site1 site1_usr
mkdir -p /var/www/site1/{tmp,sessions,logs}
; /etc/php/5.6/fpm/pool.d/site1.conf
[site1]
user = site1_usr
group = site1_usr
listen = /run/php/php5.6-fpm-site1.sock
listen.owner = www-data
listen.group = www-data
listen.mode = 0660
pm = ondemand
pm.max_children = 10
security.limit_extensions = .php
php_admin_value[open_basedir] = /var/www/site1/
php_admin_value[upload_tmp_dir] = /var/www/site1/tmp
php_admin_value[sys_temp_dir] = /var/www/site1/tmp
php_admin_value[session.save_path] = /var/www/site1/sessions
php_admin_value[error_log] = /var/www/site1/logs/php-error.log
php_admin_flag[allow_url_include] = off
php_admin_flag[display_errors] = off
Обратите внимание: в open_basedir нет /tmp. Общий /tmp — это дыра в изоляции, поэтому временные файлы и сессии у каждого сайта свои.
Теперь права. Принцип такой: код принадлежит отдельному пользователю deployer, PHP только читает его через группу, а писать может лишь туда, где PHP не исполняется. Без ACL nginx перестанет видеть CSS, JS и картинки, поэтому даём ему право на чтение явно.
chown -R deployer:site1_usr /var/www/site1/public
find /var/www/site1/public -type d -exec chmod 2750 {} +
find /var/www/site1/public -type f -exec chmod 0640 {} +
# nginx (www-data) должен читать статику и проходить в каталог сайта
setfacl -m u:www-data:x /var/www/site1
setfacl -R -m u:www-data:rX /var/www/site1/public
setfacl -R -d -m u:www-data:rX /var/www/site1/public
# Запись — только в папки, где PHP запрещён правилом из уровня 1
chown -R site1_usr:site1_usr /var/www/site1/{tmp,sessions,logs} \
/var/www/site1/public/{images,cache,administrator/cache}
Проверьте, что изоляция действительно работает:
sudo -u site1_usr cat /var/www/site2/public/configuration.php # Permission denied
sudo -u site1_usr touch /var/www/site1/public/index.php # Permission denied
sudo -u site1_usr touch /var/www/site1/tmp/test.txt # OK
У этой схемы есть цена. Обновить CMS или поставить расширение из админки больше не получится: обновления делаются от deployer или с временным открытием записи. Для старого проекта, который и так никто не обновляет, это скорее плюс.
Отдельный пользователь базы на каждый сайт
Сайту для работы не нужны права FILE (чтение файлов сервера через LOAD DATA INFILE), SUPER и доступ к чужим базам. Для обновлений CMS, которым нужны CREATE и ALTER, заведите второго пользователя и используйте его только на время обновления.
CREATE USER 'site1'@'localhost' IDENTIFIED BY 'длинный-случайный-пароль';
GRANT SELECT, INSERT, UPDATE, DELETE, CREATE TEMPORARY TABLES, LOCK TABLES
ON site1_db.* TO 'site1'@'localhost';
CREATE USER 'site1_admin'@'localhost' IDENTIFIED BY 'другой-пароль';
GRANT ALL PRIVILEGES ON site1_db.* TO 'site1_admin'@'localhost';
Ограничьте подбор паролей
Строгий лимит на весь сайт — плохая идея: посетители за одним NAT (офис, гостиница, B2B-клиент) делят один IP и получат ошибку на ровном месте. Ограничиваем только POST-запросы на страницы входа. Запросы с пустым ключом nginx не считает, поэтому GET-запросы лимит не затронет.
# http {}
map $request_method $auth_limit_key {
POST $binary_remote_addr;
default "";
}
limit_req_zone $auth_limit_key zone=auth:10m rate=10r/m;
limit_req_status 429;
# server {} — внутри обязательно полноценная передача в FPM,
# иначе nginx отдаст исходник PHP как статику
location = /administrator/index.php { # для WordPress: /wp-login.php
limit_req zone=auth burst=10 nodelay;
try_files $uri =404;
fastcgi_pass unix:/run/php/php5.6-fpm-site1.sock;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
}
CrowdSec: бан по поведению
CrowdSec — бесплатный open-source инструмент. Он читает логи, узнаёт сканирование, брутфорс и попытки эксплуатации известных CVE и банит IP на уровне файрвола. Плюс получает общий список атакующих адресов от сообщества.
curl -s https://packagecloud.io/install/repositories/crowdsec/crowdsec/script.deb.sh | bash
apt install crowdsec crowdsec-firewall-bouncer-nftables
cscli collections install crowdsecurity/nginx crowdsecurity/base-http-scenarios \
crowdsecurity/http-cve crowdsecurity/sshd crowdsecurity/linux
Если у каждого сайта свой лог, CrowdSec должен о них знать. Проверьте раздел Acquisition в cscli metrics: если вашего лога там нет, он не анализируется.
# /etc/crowdsec/acquis.d/nginx-sites.yaml
filenames:
- /var/log/nginx/*access.log
- /var/log/nginx/*error.log
labels:
type: nginx
Свои IP добавьте в белый список, чтобы не забанить себя или редактора, который активно правит сайт:
# /etc/crowdsec/parsers/s02-enrich/my-whitelist.yaml
name: local/my-whitelist
description: "Офис и админы"
whitelist:
reason: "свои"
ip:
- "203.0.113.10"
Для брутфорса админки Joomla можно написать свой сценарий. Поля событий лучше сверить на реальной строке лога командой cscli explain: сценарий с опечаткой в имени поля не выдаст ошибку, он просто никогда не сработает.
# /etc/crowdsec/scenarios/joomla-bf.yaml
type: leaky
name: local/joomla-bf
description: "Брутфорс /administrator/ в Joomla"
filter: "evt.Meta.log_type == 'http_access-log' && evt.Meta.http_verb == 'POST' && evt.Meta.http_path startsWith '/administrator/'"
groupby: evt.Meta.source_ip
capacity: 10
leakspeed: 30s
blackhole: 1m
labels:
remediation: true
cscli explain --log '<строка из access.log>' --type nginx --verbose
systemctl reload crowdsec
cscli decisions list
Параметр blackhole часто понимают неправильно. Это не длительность бана, а пауза, в течение которой сценарий не срабатывает повторно для того же IP. Сколько длится бан, задаётся в /etc/crowdsec/profiles.yaml, по умолчанию 4 часа.
Бэкапы, которые взломщик не сможет удалить
Если бэкап лежит на том же сервере или сервер сам пишет его в хранилище с полными правами, то получивший root атакующий удалит его вместе с сайтом. Шифровальщики делают это первым делом. Правильный бэкап — версионированный, вне сервера и такой, что боевой сервер может только добавлять данные, но не удалять их.
Простой вариант — restic плюс rest-server в режиме append-only на отдельной машине.
# На бэкап-сервере
rest-server --path /srv/restic --append-only --htpasswd-file /etc/restic/.htpasswd
# На боевом сервере: /etc/restic/env (права 600)
RESTIC_REPOSITORY=rest:https://site1:ПАРОЛЬ@backup.example.ru:8000/site1
RESTIC_PASSWORD_FILE=/etc/restic/password
#!/bin/bash
# /usr/local/bin/backup.sh
set -euo pipefail
set -a; . /etc/restic/env; set +a
mysqldump --single-transaction --all-databases \
| restic backup --stdin --stdin-filename all-databases.sql
restic backup /var/www /etc/nginx /etc/php
echo "0 3 * * * root /usr/local/bin/backup.sh >> /var/log/backup.log 2>&1" > /etc/cron.d/backup
# Чистку старых снапшотов запускайте НА бэкап-сервере, у боевого на это нет прав
restic -r /srv/restic/site1 forget --keep-daily 7 --keep-weekly 4 --keep-monthly 6 --prune
Вместо rest-server подойдёт S3-хранилище с Object Lock и ключом без права удаления. И раз в месяц проверяйте восстановление: бэкап, который ни разу не разворачивали, — это надежда, а не бэкап.
restic check
restic restore latest --target /tmp/restore-test --include /var/www/site1/public/configuration.php
Узнайте о взломе за пять минут, а не за месяц
Обычно о webshell узнают по жалобам на спам или по письму от хостера. Git умеет делать это быстрее: он видит каждый новый или изменённый файл. Главное, чтобы репозиторий лежал вне webroot и принадлежал root. Иначе атакующий просто закоммитит свой shell в эталон.
mkdir -p /var/lib/integrity && chmod 700 /var/lib/integrity
git init --bare /var/lib/integrity/site1.git
G="git -c safe.directory=* -c user.name=integrity -c user.email=root@localhost \
--git-dir=/var/lib/integrity/site1.git --work-tree=/var/www/site1/public"
# /var/lib/integrity/site1.git/info/exclude — шум не следим, но PHP в загрузках ловим
/cache/
/tmp/
/logs/
/administrator/cache/
/images/**
!/images/**/
!/images/**/*.php
$G add -A && $G commit -qm baseline
#!/bin/bash
# /usr/local/bin/check-integrity.sh
BOT_TOKEN="ваш_токен"
CHAT_ID="ваш_chat_id"
for SITE in site1 site2; do
OUT=$(git -c safe.directory='*' --git-dir=/var/lib/integrity/$SITE.git \
--work-tree=/var/www/$SITE/public status --porcelain 2>&1)
if [ -n "$OUT" ]; then
curl -s "https://api.telegram.org/bot${BOT_TOKEN}/sendMessage" \
--data-urlencode "chat_id=${CHAT_ID}" \
--data-urlencode "text=⚠️ $(hostname) / ${SITE}:
${OUT:0:3500}" > /dev/null
fi
done
chmod 700 /usr/local/bin/check-integrity.sh
echo "*/5 * * * * root /usr/local/bin/check-integrity.sh" > /etc/cron.d/integrity
Ошибки git не глотаются: если что-то сломается, вы тоже получите сообщение. После легального деплоя обновите эталон командой $G add -A && $G commit -qm deploy. Пока вы этого не сделали, бот будет напоминать каждые пять минут. Это не баг, а фича.
Для разовой проверки уже заражённого сервера пригодится и старый добрый grep:
grep -rl --include='*.php' -E 'eval\s*\(\s*(base64_decode|gzinflate|str_rot13)' /var/www/
find /var/www -name '*.php' -mtime -3 -not -path '*/cache/*'
Проверьте саму CMS
Повторю мысль из начала: ломают обычно не PHP, а расширения. Для старой Joomla сверьте список установленных расширений с Vulnerable Extensions List, для WordPress прогоните сайт через WPScan. Всё, что не используется, удаляйте полностью, а не просто выключайте. Заодно проверьте список администраторов: «забытый» аккаунт подрядчика пятилетней давности — классика жанра.
Уровень 3. Самые упоротые шаги
Здесь меры, которые требуют времени, стенда для тестов и некоторой любви к искусству. Зато после них взлом старого сайта превращается из катастрофы в рядовой инцидент.
Переезд на свежую ОС с сохранением PHP 5.6
Это мера, которая закрывает целый класс угроз ниже уровня приложения: ядро, glibc, OpenSSL, sudo, polkit. Цель — Ubuntu 24.04 LTS: стандартная поддержка до мая 2029 года, с Ubuntu Pro — до 2034-го (ubuntu.com). Старый PHP на неё ставится штатно из PPA Ondřej Surý — там есть сборки от 5.6 до 8.5 (инструкция Vultr). Ubuntu 26.04 уже вышла, но для legacy-PHP пока надёжнее 24.04 — под неё сборки 5.6 точно есть.
add-apt-repository ppa:ondrej/php
apt install php5.6-fpm php5.6-mysql php5.6-mbstring php5.6-xml php5.6-gd php5.6-curl
apt install unattended-upgrades && dpkg-reconfigure -plow unattended-upgrades
Переезжайте на новый сервер, а не через do-release-upgrade: так чище, а старая машина остаётся точкой отката. Порядок такой:
- Поднять новый VPS с Ubuntu 24.04 и сразу настроить на нём уровни 1 и 2.
- Перенести файлы и базы, проверить сайты через подмену адреса в hosts.
- Снизить TTL DNS заранее, переключить записи.
- Держать старый сервер выключенным, но не удалённым ещё 2–4 недели.
Главные подводные камни — в базе данных:
- PHP до 7.4 не умеет
caching_sha2_password— способ аутентификации по умолчанию в MySQL 8. В MySQL 8.4 старыйmysql_native_passwordвыключен по умолчанию, а в 9.x удалён. Проще всего взять MariaDB из репозитория Ubuntu: с ней этой проблемы нет. - Новые версии строже к SQL:
STRICT_TRANS_TABLESиONLY_FULL_GROUP_BYроняют запросы старых CMS. Если сайт посыпался ошибками, начните сsql_mode.
Настоящий WAF вместо регулярок
Регулярки в nginx ловят только самых тупых ботов: они не видят тело POST-запроса и пропускают URL-кодирование. Настоящий WAF разбирает запрос целиком. Вариантов два.
- ModSecurity + OWASP CRS — классика, тысячи сигнатур под SQLi, XSS, RCE и LFI. На старых CMS почти гарантированы ложные срабатывания, поэтому первые недели держите движок в режиме
SecRuleEngine DetectionOnly: только логи, без блокировок. Отключайте мешающие правила точечно и лишь потом включайте блокировку. - CrowdSec AppSec — WAF-компонент того же CrowdSec с virtual patching: правила под конкретные CVE приходят из хаба автоматически. Требует nginx-баунсер с поддержкой Lua. Схема настройки меняется от версии к версии, поэтому идите по официальной документации.
Запретите PHP ходить в интернет
Webshell почти всегда что-то скачивает или куда-то стучится: майнер, обратная оболочка, спам-рассылка. Если пользователям PHP-пулов запрещены исходящие соединения, половина «полезной нагрузки» просто не работает. В nftables это делается по UID процесса.
# /etc/nftables.d/php-egress.nft (UID смотрите через: id -u site1_usr)
table inet php_egress {
set allowed {
type ipv4_addr; flags interval;
elements = { 192.0.2.0/24 } # платёжный шлюз, API доставки и т.п.
}
chain output {
type filter hook output priority 0; policy accept;
meta skuid != { 998, 997 } accept # остальные процессы не трогаем
oifname "lo" accept # MySQL, локальная почта, DNS-резолвер
ct state established,related accept
ip daddr @allowed accept
log prefix "php-egress: " counter reject
}
}
Ограничение тут одно: у платёжных шлюзов и API адреса меняются. Если список IP поддерживать больно, поставьте исходящий прокси со списком разрешённых доменов (Squid, tinyproxy), пропишите его в CMS и разрешите PHP-пользователям ходить только к нему.
PHP 5.6 в контейнере с read-only файловой системой
Если на сервере нужно держать и современные проекты, и древний PHP, старое окружение можно запереть в контейнер. Код монтируется только для чтения, запись разрешена лишь в загрузки, кеш и сессии, у процесса нет никаких Linux capabilities.
# Dockerfile
FROM php:5.6-fpm
RUN docker-php-ext-install mysqli pdo_mysql
COPY php.ini /usr/local/etc/php/php.ini
# docker-compose.yml
services:
site1-php56:
build: .
user: "998:998" # UID/GID site1_usr на хосте
read_only: true
cap_drop: [ALL]
security_opt: ["no-new-privileges:true"]
tmpfs:
- /tmp:size=64m
volumes:
- /var/www/site1/public:/var/www/site1/public:ro
- /var/www/site1/public/images:/var/www/site1/public/images:rw
- /var/www/site1/public/cache:/var/www/site1/public/cache:rw
- /var/www/site1/sessions:/var/www/site1/sessions:rw
- /run/mysqld:/run/mysqld # сокет MariaDB с хоста
ports:
- "127.0.0.1:9056:9000" # nginx: fastcgi_pass 127.0.0.1:9056;
restart: unless-stopped
Несколько деталей, на которых спотыкаются:
- Пути внутри контейнера должны совпадать с хостовыми, иначе
SCRIPT_FILENAMEиз nginx не найдётся. - PHP в контейнере ищет сокет MySQL в
/tmp/mysql.sock. Пропишите в php.inimysqli.default_socketиpdo_mysql.default_socket=/run/mysqld/mysqld.sock. - Образ
php:5.6-fpmдавно не обновляется и собран на Debian 9. Для расширений с системными зависимостями вроде gd придётся переключить apt на archive.debian.org. - Контейнер делит ядро с хостом. Поэтому вся затея имеет смысл только на свежей ОС: старое пользовательское окружение заперто внутри, а ядро снаружи — новое и с патчами.
Платные патчи для EOL-версий
Если переезд невозможен, а риски нужно закрыть по-взрослому, существуют коммерческие сборки старого PHP с перенесёнными исправлениями безопасности. Например, TuxCare ELS for PHP поддерживает ветки от 5.2 до 7.4 на основных дистрибутивах. Это не замена миграции, но реальные патчи CVE на время подготовки к ней.
И всё-таки миграция
Все меры выше — это способ дожить до переезда на поддерживаемый PHP. Сам переезд сейчас проще, чем кажется. PHPCompatibility для PHP_CodeSniffer показывает несовместимые места, а Rector автоматически переписывает значительную часть кода. Целиться стоит сразу в 8.4 или 8.5: поддержка 8.2 заканчивается через два месяца.
composer require --dev phpcompatibility/php-compatibility
vendor/bin/phpcs -p --standard=PHPCompatibility --runtime-set testVersion 8.4 ./
Сводный план
| Уровень | Мера | Время | Риск что-то сломать |
|---|---|---|---|
| 1 | Проверить ОС и патчи, при EOL включить ESM | 30 минут | Нет |
| 1 | Выключить Xdebug, скрыть версии | 10 минут | Нет |
| 1 | Запрет PHP в папках загрузок | 30 минут | Низкий |
| 1 | Закрыть служебные файлы, try_files, cgi.fix_pathinfo | 30 минут | Низкий, проверить URL |
| 1 | disable_functions | 30 минут | Средний, нужен тест |
| 1 | Убрать phpMyAdmin, MySQL на localhost, SSH по ключам | 30 минут | Нет, если ключ уже работает |
| 2 | Изоляция сайтов: пользователи, пулы, права | 2–4 часа | Средний, нужен тест |
| 2 | Лимиты на вход, CrowdSec | 1–2 часа | Низкий |
| 2 | Бэкапы append-only вне сервера | 2–4 часа | Нет |
| 2 | Контроль целостности через git | 30 минут | Нет |
| 3 | Переезд на Ubuntu 24.04 | 1–2 дня | Плановый |
| 3 | WAF, запрет исходящих, контейнер | 1–3 дня | Нужен стенд |
| 3 | Миграция на PHP 8.4/8.5 | Индивидуально | Зависит от проекта |
Что НЕ поможет
Несколько мер, которые создают ощущение безопасности, но не саму безопасность.
«Сменим порт SSH». Сканеры перебирают все 65 535 портов за минуты. Количество мусора в логах уменьшится, количество атак — нет. Ключи вместо паролей работают, порт — нет.
«Спрячем версию PHP, и всё». Боты не читают заголовки, они пробуют все эксплойты подряд. Скрытие версии убирает вас из прицельных списков, но не закрывает ни одной дыры.
«У нас HTTPS». HTTPS шифрует канал между браузером и сервером. Эксплойту он ничуть не мешает: тот приходит по тому же зашифрованному каналу.
«Сайт маленький, кому он нужен». Никому лично. Ботам нужен ваш процессор для майнинга, ваш IP для рассылки спама и ваш домен для фишинга. Размер сайта значения не имеет.
«Заблокируем сканеры по User-Agent». sqlmap меняет User-Agent одним флагом. А под раздачу попадают Ahrefs, Semrush и прочие SEO-краулеры, которые вообще не инструменты атаки. Если вы сами делаете SEO-аудиты, заблокируете себя же.
«Антивирус на сервере найдёт». Найдёт — после заражения. Обнаружение после факта тоже ценно, но git-контроль из второго уровня делает это быстрее и точнее.
Чек-лист для админа
Эпилог
В 2008 году безопасность сайта выглядела так: поставить пароль подлиннее и не держать phpinfo.php в корне. В 2026 году я пишу правила nftables, чтобы PHP 5.6 не мог позвонить домой своему майнеру.
Прогресс налицо.
Но у этой истории хороший конец. Почти всё из первого уровня делается за вечер, второй — за пару дней, и после этого старый сайт перестаёт быть бомбой с часовым механизмом. Он становится тем, чем и должен быть: легаси, которое спокойно ждёт своей миграции.
Главное — не путать «ждёт миграции» с «не трогаем никогда». Первое — план. Второе — просто вопрос времени.
Узнали свой сервер? Давайте починим, пока не починили за вас
Если, пока вы читали, в голове всплыл конкретный сервер, — это он. Тот самый, где PHP 5.6, Ubuntu из прошлого десятилетия и «работает — не трогай». Боты о нём уже знают. Вопрос только в том, кто доберётся до него первым: вы или они.
Я занимаюсь сайтами с 2008 года и разгребал взломы, после которых от проекта оставались только бэкапы. Если, конечно, они были. Поэтому предпочитаю приходить до инцидента, а не после.
Что мы сделаем в Студии Павла Сайка:
- Аудит за пару дней. Проверим ОС, PHP, CMS и расширения и скажем честно, где у вас дыра и насколько всё плохо. Без «надо срочно всё переписать за миллион».
- Харденинг под ключ. Всё из этой статьи, от запрета PHP в загрузках до WAF и бэкапов, которые взломщик не удалит. Вам не придётся разбираться в nftables.
- Переезд на свежую ОС с сохранением старого PHP. Сайт продолжит работать как работал, а под ним будет система с патчами.
- Миграция на PHP 8.4/8.5, когда будете готовы закрыть вопрос навсегда.
Оставьте заявку в форме ниже: адрес сайта и пару слов о проблеме. Посмотрю и скажу, с какого уровня начинать именно вам. Это бесплатно и ни к чему не обязывает. В отличие от взлома.
Источники
- PHP: Supported Versions
- PHP: Unsupported Branches
- PHP 5 ChangeLog — исправление CVE-2018-19518 в 5.6.39
- phuip-fpizdam — эксплойт и описание CVE-2019-11043
- Lexfo: Iconv, set the charset to RCE (CVE-2024-2961)
- NVD: CVE-2021-4034 (PwnKit)
- NVD: CVE-2021-3156 (Baron Samedit)
- BleepingComputer: CVE-2024-1086 используют шифровальщики
- Debian: окончание LTS для Debian 11
- Ubuntu: жизненный цикл релизов
- Vultr: установка PHP 5.6 на Ubuntu 24.04
- TuxCare: Extended Lifecycle Support for PHP
- OWASP Core Rule Set
- Документация CrowdSec