1. 1
  2. 2
  3. 3
  4. 4
  5. 5

# теория · шаг 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
↳  выводcurl -s http://shop.local/uploads/shell.php
INDEX script=/var/www/html/uploads/shell.php uri=/uploads/shell.php

Код 200. Регулярка честно совпала, файл честно ушёл в php-fpm, тот честно его выполнил. Дальше зависит от прав процесса, но обычно это конец: конфиги, доступ к базе, закрепление.

На что это похоже

Пропускной пункт с правилом: «всех, у кого бейдж, пускать в цех». Правило работает, пока бейджи выдаёт отдел кадров. В тот день, когда рядом с проходной поставили автомат, печатающий бейджи по заявке любого желающего, правило осталось прежним - и перестало что-либо значить.

Каталог загрузок и есть такой автомат. Регулярка \.php$ не спрашивает, откуда взялся файл, - она смотрит только на имя.

Механизм: два рубежа защиты

/uploads/x.php nginx отдельный location, try_files php-fpm limit_extensions, fix_pathinfo рубежи независимы: настройки PHP правит один человек, конфиг nginx - другой, и переезд сайта переносит только один из них поэтому держат оба, а не выбирают
Ни один рубеж не отменяет второй: они настраиваются в разных файлах разными людьми.

Правило

/etc/nginx/conf.d/shop.local.confhttp server
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, и в лог попадает прямая строка:

↳  выводdocker compose logs php
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.

/etc/nginx/conf.d/shop.local.confhttp server
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:

/etc/nginx/conf.d/shop.local.confhttp server location = /wp-login.php
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 для несуществующего скрипта вовсе. Служебные файлы и резервные копии закрывают по расширению, вход в админку прикрывают лимитом, а пределы размера и времени поднимают с обеих сторон сразу.

Комментарии

Пока нет комментариев. Будь первым!

Оставить комментарий

Комментарий появится после проверки. Email не публикуется. Войти, чтобы не вводить имя каждый раз.