# теория · шаг 2 из 5
Две дыры php-локации и три слоя защиты
Коротко
Конфиг из прошлого урока рабочий. Убери из него две строки - и получишь две самые известные дыры хостинга на PHP.
- Каталог загрузок под общей регуляркой
\.php$- исполняемый. Загрузилиshell.php- открыли и выполнили. Проверено живьём: код 200. - Трюк
photo.jpg/x.phpсегодня останавливает сам php-fpm настройкойsecurity.limit_extensions. Ослабь её - и картинка выполняется как код. try_files $uri =404;держит оба случая на стороне nginx, независимо от настроек PHP.- Рубежей защиты три, в двух файлах: nginx (
deny,try_files), php-fpm (security.limit_extensions) иphp.ini(cgi.fix_pathinfo). Каждый закрывает то, что пропустил соседний.
Если знаешь, что делает 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 с телом от PHP - у тебя исполняемая папка загрузок.
Разбор: PATH_INFO и чужой скрипт
Классика с историей: запрос /uploads/photo.jpg/x.php. Регулярка совпадает -
адрес кончается на .php. Файла x.php нет, но при cgi.fix_pathinfo=1
интерпретатор отматывает путь назад, находит photo.jpg и выполняет его.
А внутри картинки может быть что угодно: загружать картинки-то разрешено.
Хорошая новость: сегодня это ловит сам php-fpm. У него есть
security.limit_extensions со значением по умолчанию .php .php3 .php4 .php5
.php7, и в лог попадает прямая строка:
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:
| Симптом | 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$: иначе загруженный shell.php выполняется, и это проверяется
одним curl. Трюк photo.jpg/x.php сегодня останавливает security.limit_extensions
в php-fpm, но cgi.fix_pathinfo по-прежнему включён, и одна ослабленная
настройка возвращает дыру - поэтому на стороне nginx держат try_files $uri
=404;, который не зовёт php-fpm для несуществующего скрипта вовсе. Служебные
файлы и резервные копии закрывают по расширению, вход в админку прикрывают
лимитом, а пределы размера и времени поднимают с обеих сторон сразу.
Комментарии
Пока нет комментариев. Будь первым!
Оставить комментарий