# теория · шаг 2 из 5
Две дыры php-локации и три слоя защиты
Коротко
Конфиг из прошлого урока рабочий. Убери из него две строки - и получишь две самые известные дыры хостинга на PHP.
- Каталог загрузок под общей регуляркой
\.php$- исполняемый. Загрузилиshell.php- открыли и выполнили. Проверено живьём: код 200. - Трюк
photo.jpg/x.phpсегодня останавливает сам php-fpm настройкойsecurity.limit_extensions. Ослабь её - и картинка выполняется как код. try_files $uri =404;закрывает на стороне nginx только трюк с несуществующимx.php, независимо от настроек PHP. Загруженныйshell.phpсуществует, и от него спасает отдельный запрет исполнения в каталоге загрузок.
Если знаешь, что делает try_files $uri =404 в php-локации и почему каталог загрузок закрывают отдельным блоком - листай до «Теперь сам».
Сначала ответь сам
Сайт разрешает загружать аватарки в /uploads/, проверяя тип по расширению.
Кто-то отправил файл shell.php - проверка пропустила. Конфиг обычный:
location ~ \.php$ с fastcgi_pass, каталог загрузок ничем не выделен.
Что вернёт curl http://shop.local/uploads/shell.php?
curl -s http://shop.local/uploads/shell.php
INDEX script=/var/www/html/uploads/shell.php uri=/uploads/shell.php
Код 200. Регулярка честно совпала, файл честно ушёл в php-fpm, тот честно его выполнил. Дальше зависит от прав процесса, но обычно это конец: конфиги, доступ к базе, закрепление.
На что это похоже
Пропускной пункт с правилом: «всех, у кого бейдж, пускать в цех». Правило работает, пока бейджи выдаёт отдел кадров. В тот день, когда рядом с проходной поставили автомат, печатающий бейджи по заявке любого желающего, правило осталось прежним - и перестало что-либо значить.
Каталог загрузок и есть такой автомат. Регулярка \.php$ не спрашивает, откуда
взялся файл, - она смотрит только на имя.
Механизм: два рубежа защиты
Правило
location ~ ^/uploads/.*\.php$ {
deny all;
}
location ~ \.php$ {
try_files $uri =404;
include fastcgi_params;
fastcgi_pass php:9000;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
}
Запрещающий блок объявляется выше общего: из двух регулярок побеждает
первая совпавшая (четвёртый раздел). Тот же приём годится для любого каталога,
куда пишет приложение: /upload/, /files/, /cache/, /tmp/.
Проверяется одной командой:
curl -sI http://shop.local/uploads/shell.php | head -1
head -1 оставляет первую строку ответа - строку статуса. 403 подтверждает запрет. При 200 выполни GET без -I и проверь тело: один
код не доказывает, что PHP исполнился. Используй только свой безвредный тестовый файл.
Разбор: PATH_INFO и чужой скрипт
Классика с историей: запрос /uploads/photo.jpg/x.php. Регулярка совпадает -
адрес кончается на .php. Файла x.php нет, но при cgi.fix_pathinfo=1
интерпретатор отматывает путь назад, находит photo.jpg и выполняет его.
А внутри картинки может быть что угодно: загружать картинки-то разрешено.
Хорошая новость: сегодня это ловит сам php-fpm. У него есть
security.limit_extensions: по умолчанию в документации PHP это .php .phar
(в образце www.conf встречается и более длинный список), и в лог попадает
прямая строка:
NOTICE: Access to the script '/var/www/html/uploads/photo.jpg' has been denied
(see security.limit_extensions)
Клиент при этом получает 403 и короткое Access denied. (проверено на php-fpm
8.3 с настройками по умолчанию).
Новость похуже: cgi.fix_pathinfo в PHP 8.3 по-прежнему включён, то есть механизм
на месте - его удерживает одна настройка. Ослабим её на стенде до .php .jpg,
как делают ради «правильной работы с картинками», и повторим запрос. Проверено:
при ослабленном списке photo.jpg/x.php вернул код=200 и GIF89aPWNED from
photo.jpg, а в логе php-fpm script=/var/www/html/uploads/photo.jpg:
| конфиг nginx | limit_extensions по умолчанию |
ослаблен до .php .jpg |
|---|---|---|
без try_files |
403 от php-fpm | 200, PWNED from photo.jpg |
с try_files $uri =404 |
404 от nginx | 404 от nginx |
Вот зачем нужна строка, которая на первый взгляд ничего не делает.
try_files $uri =404; проверяет, что запрошенный скрипт есть на диске, и при
его отсутствии php-fpm не зовётся вовсе. Этот рубеж держит независимо от того,
что написано в конфиге PHP.
Третий слой - cgi.fix_pathinfo=0 в php.ini. Держат все три: они лежат в
разных файлах, и переезд сайта на новый сервер переносит обычно только один.
Разбор: что открыто, пока ты смотришь на .php
Служебные файлы приложения лежат в корне сайта и отдаются как обычная статика
(двенадцатый раздел): composer.json, .env, wp-config.php.bak,
readme.html.
location ~ /\. { deny all; }
location ~* \.(bak|old|sql|log|ini|sh)$ { deny all; }
Вход в админку прикрывают лимитом частоты запросов (раздел про
лимитыРазбирается в разделе 12, глава «limit_req и limit_conn»: Как nginx считает частоту); сама зона login объявляется там же
директивой limit_req_zone в http, и без этого объявления блок ниже не
пройдёт nginx -t:
limit_req zone=login burst=3 nodelay;
limit_req_status 429;
include fastcgi_params;
fastcgi_pass php:9000;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
Параметры пришлось повторить целиком: location = /wp-login.php - отдельный
точный блок, он не вложен в ~ \.php$, и наследовать параметры ему неоткуда.
Забыть их здесь - та же белая страница с кодом 200 из прошлого урока.
Что ломается без этого
Проверь три ограничения: одно в nginx, два в PHP:
| Симптом | nginx | php.ini / php-fpm |
|---|---|---|
| 413 при загрузке файла | client_max_body_size 20m; |
upload_max_filesize, post_max_size |
| 504 на долгом отчёте | fastcgi_read_timeout 120s; |
max_execution_time |
| 502 после логина | fastcgi_buffer_size 16k; |
размер сессии в куках |
nginx ограничивает транспорт, PHP ограничивает выполнение. Поднимать надо обе стороны: одна упрётся в другую, и симптом не изменится, а время уйдёт на споры, «у кого лимит».
Зачем это в работе
Проверка занимает две минуты и делается на любом чужом сайте, который ты принял в поддержку:
curl -sI http://shop.local/uploads/test.php
curl -sI http://shop.local/.env
curl -sI http://shop.local/uploads/photo.jpg/x.php
Три ответа 403 или 404 - конфиг закрыт. Любая двухсотка - находка, которую надо
чинить сегодня. Отдельно стоит посмотреть, как слушает php-fpm: строка
listen = 0.0.0.0:9000 в конфиге пула вместе с открытым портом означает, что
попросить выполнить произвольный файл может любой из интернета. Авторизации в
FastCGI нет вовсе.
ss -ltnp | grep 9000
Теперь сам
В конфиге стоит location ~ \.php$ с try_files $uri =404, но запрещающего
блока на /uploads/ нет. Злоумышленник загрузил shell.php. Сработает ли
try_files и почему?
Нет. try_files проверяет существование файла, а shell.php существует -
его только что загрузили. Строка защищает от несуществующего скрипта в конце
пути, то есть от трюка с PATH_INFO, и ничего не знает о происхождении файла.
Каталог загрузок закрывают отдельным блоком - это другая дыра и другой рубеж.
Главное
Отдели каталоги пользовательских загрузок от маршрутов исполнения PHP. Проверка существования скрипта закрывает один класс ошибок, но не запрещает запуск загруженного файла. Проверь оба случая: отсутствующий скрипт и существующий PHP-файл в каталоге загрузок; ни один не должен выполняться.
Комментарии
Пока нет комментариев. Будь первым!
Оставить комментарий