Дени-лист выполнения PHP

PHP_DENY_PATHS блокирует прямое выполнение .php-файлов, совпадающих с заданными glob-шаблонами. Он нацелен на повторяющийся класс уязвимостей в legacy-приложениях на PHP: атакующий загружает PHP-файл в доступный для записи публичный каталог (/uploads, /cache, временные каталоги для ресайза изображений) и обращается к нему по прямому URI, получая выполнение кода.

Проверка выполняется до любого дискового ввода-вывода, поэтому для запрещённых путей возвращается один и тот же ответ независимо от того, существует файл на диске или нет. У атакующего нет оракула существования, с помощью которого можно было бы прощупывать каталоги загрузки.

Когда это применяется

В режимах прямого сопоставления — тех, где URI напрямую разрешается в .php-файл на диске:

Режим маршрутизации PHP_DENY_PATHS учитывается?
Традиционный (без ENTRY_FILE) Да
SPA (ENTRY_FILE=index.html) Да — SPA выполняет существующие .php-файлы напрямую, поэтому дени-лист применяется
Framework (ENTRY_FILE=index.php) Нет — выводится предупреждение и игнорируется
Воркер (WORKER_MODE_ENABLED=true) Нет — выводится предупреждение и игнорируется

В режиме Framework каждый запрос переписывается на фронт-контроллер, и произвольные .php-файлы никогда не выполняются напрямую; дени-лист лишь сломал бы маршруты приложения, которые случайно заканчиваются на .php. В режиме воркеров каждый нестатический запрос направляется в скрипт воркера, поэтому дени-листу нечего запрещать. Установка PHP_DENY_PATHS в любом из этих режимов выдаёт предупреждение при запуске и отключает проверку.

Дени-лист также охватывает скрипты, к которым обращаются косвенно: запрос к /uploads/, который через поиск индекса каталога разрешился бы в uploads/index.php, запрещается, когда uploads/** присутствует в списке — по шаблонам сопоставляется разрешённый путь скрипта, а не только URI запроса. Для таких запретов OXPHP_DENIED_PATH содержит очищенный URI запроса без завершающего слеша (/uploads/ сообщается как /uploads).

Конфигурация

bash
# Comma-separated glob patterns PHP_DENY_PATHS="/uploads/**,/cache/**,/tmp/**" # What to return on a match (default: 404) PHP_DENY_FALLBACK="403"

Запрос к /uploads/shell.php теперь возвращает 403, не обращаясь к диску. Запрос к /uploads/image.png обслуживается штатно — дени-лист влияет только на выполнение .php и никогда не затрагивает раздачу статических файлов.

Синтаксис шаблонов

Шаблоны сопоставляются с очищенным URI (путём запроса, в котором сегменты .. и обходы через процентное кодирование уже разрешены) с использованием синтаксиса globset. Ведущий / в каждом шаблоне необязателен — /uploads/** и uploads/** эквивалентны.

Шаблон Совпадает Не совпадает
/uploads/** /uploads/x.php, /uploads/a/b/c.php, /uploads/shell.php/extra /uploads.php, /public/uploads/x.php
/files/*.php /files/x.php /files/sub/x.php (одиночная * не пересекает /)
/admin/legacy.php /admin/legacy.php /admin/legacy.php/x (PATH_INFO не покрывается — см. ниже)
/admin/legacy.php{,/**} /admin/legacy.php, /admin/legacy.php/x /admin/other.php
/**/wp-config.php /wp-config.php, /site/wp-config.php /wp-config.txt

Несколько шаблонов объединяются по ИЛИ — запрос совпадает с дени-листом, если он совпадает с любым шаблоном.

Отдельные файлы против каталогов

Работает и то, и другое. /uploads/** блокирует целое поддерево; /admin/legacy.php блокирует один конкретный скрипт. Чтобы заблокировать одну legacy-точку входа и любые её вызовы с PATH_INFO (/admin/legacy.php/foo), используйте форму с фигурными скобками: /admin/legacy.php{,/**}.

Чувствительность к регистру

Сопоставление чувствительно к регистру. На файловых системах, нечувствительных к регистру (по умолчанию macOS HFS+/APFS, по умолчанию Windows NTFS, ext4 с casefold), запрос к /uploads/Shell.PHP обошёл бы шаблон /uploads/**/*.php. При обслуживании с такой файловой системы используйте широкий шаблон каталога вроде /uploads/** (который совпадает со всеми расширениями) или приводите имена загружаемых файлов к нижнему регистру во время записи.

Режимы фолбэка

PHP_DENY_FALLBACK управляет тем, что возвращается при совпадении.

HTTP-статус

Любое значение в диапазоне 400599 (по умолчанию 404). Сочетается с ERROR_PAGES_DIR для собственного HTML-тела:

bash
PHP_DENY_PATHS="/uploads/**" PHP_DENY_FALLBACK="403" ERROR_PAGES_DIR="/var/www/errors" # serves errors/403.html

PHP-скрипт

Путь URI с ведущим / к фолбэк-скрипту внутри DOCUMENT_ROOT:

bash
PHP_DENY_PATHS="/uploads/**" PHP_DENY_FALLBACK="/_security/denied.php"

Скрипт проверяется при запуске — он должен существовать, канонизироваться внутри DOCUMENT_ROOT и сам не должен совпадать с PHP_DENY_PATHS (предотвращение цикла; иначе запуск прерывается). Скрипт выполняется с двумя дополнительными ключами $_SERVER, идентифицирующими исходный запрос:

Ключ $_SERVER Значение
OXPHP_DENIED_PATH Исходный очищенный URI с ведущим / (в той же форме, что и PATH_INFO)
OXPHP_DENIED_PATTERN Glob-шаблон, который совпал

OXPHP_DENIED_PATTERN хранится без ведущего / (в glob-нормализованном виде), тогда как OXPHP_DENIED_PATH сохраняет / из URI запроса. Если вы сравниваете путь с шаблоном, сначала выполните ltrim($_SERVER['OXPHP_DENIED_PATH'], '/'), чтобы оба были в одной форме.

Пример honeypot:

/_security/denied.php
<?php // /_security/denied.php — runs in place of any matched .php request. error_log(sprintf( "PHP execution denied: path=%s pattern=%s ip=%s ua=%s", $_SERVER['OXPHP_DENIED_PATH'] ?? '', $_SERVER['OXPHP_DENIED_PATTERN'] ?? '', $_SERVER['REMOTE_ADDR'] ?? '', $_SERVER['HTTP_USER_AGENT'] ?? '-', )); http_response_code(404); echo "Not Found";

Это позволяет вам решать, каким будет ответ, для каждого запроса (возвращать 404 атакующим, 403 аутентифицированным администраторам, перенаправлять сканеры-пробировщики в sinkhole) вместо ограничения одним статичным статусом.

Отсутствие оракула существования

Оба фолбэка — Status и Script — возвращаются без обращения к файловой системе. Запрос к /uploads/never-uploaded.php и запрос к /uploads/actually-on-disk.php дают идентичные ответы — ни разницы во времени, ни разницы в теле. Атакующий, сканирующий в поисках загруженных шеллов, не может использовать дени-лист, чтобы перечислить, какие имена файлов существуют.

Единственное исключение — проверка разрешённого пути: она неизбежно выполняется после разрешения маршрута, поэтому её запреты зависят от существования. /uploads/ запрещается только тогда, когда uploads/index.php действительно существует на диске; точно так же с шаблоном с одиночной звездой вроде /uploads/*.php запрос с PATH_INFO /uploads/shell.php/x запрещается только тогда, когда uploads/shell.php существует (полный URI не совпадает с шаблоном — совпадает разрешённый скрипт). Проверка прямого URI — та, которую прощупывают атакующие, — остаётся свободной от оракула.

Наблюдаемость

Метрика Описание
oxphp_php_deny_total Счётчик, увеличивающийся при каждом запрещённом запросе

Каждый запрет также порождает лог tracing::info:

text
PHP execution denied by PHP_DENY_PATHS path=uploads/shell.php pattern=uploads/**

Access-логи фиксируют результирующий статус (значение PHP_DENY_FALLBACK или результат http_response_code() фолбэк-скрипта) — на уровне access-логов запрещённые запросы не отличаются от обычных. Сопоставляйте с метрикой или структурированным логом, чтобы объяснить всплески.

Производительность

Сопоставление — это поиск по globset::GlobSet, обычно один SIMD-проход по байтам URI. При попадании также обходится кэш маршрутов (запрещённые URI приходят из контролируемого атакующим спреинга с фактически неограниченной кардинальностью; их кэширование позволило бы атакующему вытеснять легитимные записи из LRU). И путь попадания, и путь промаха после прогрева не выполняют аллокаций.

Ограничения

Чего эта возможность не делает:

  • Обход через PATH_INFO для шаблонов с литеральными файлами. Шаблон /admin/legacy.php не совпадает с /admin/legacy.php/extra. Используйте /admin/legacy.php{,/**}, чтобы покрыть оба варианта, или используйте шаблон каталога.
  • Сопоставление чувствительно к регистру (см. Чувствительность к регистру выше).
  • Нет регулярных выражений. Шаблоны — только globs: заякоренные, с операторами */**/?/[abc]/{a,b}. Используйте несколько шаблонов через запятую вместо (a|b).
  • Нет влияния на include / require / eval. Дени-лист управляет только выполнением по прямому URI. Уязвимый скрипт, делающий include $_GET['page'], всё ещё может загрузить PHP из любого места, читаемого сервером.

Устаревший псевдоним

Устаревшая переменная PHP_DENY_DIRS принимается как deprecated-псевдоним и выдаёт предупреждение при запуске:

text
WARN PHP_DENY_DIRS is deprecated, use PHP_DENY_PATHS instead — the alias will be removed in a future release

Когда заданы обе, PHP_DENY_PATHS побеждает, а PHP_DENY_DIRS сообщается как проигнорированная. Значения не объединяются.

См. также