Дени-лист выполнения 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).
Конфигурация
# 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-статус
Любое значение в диапазоне 400–599 (по умолчанию 404). Сочетается с ERROR_PAGES_DIR для собственного HTML-тела:
PHP_DENY_PATHS="/uploads/**"
PHP_DENY_FALLBACK="403"
ERROR_PAGES_DIR="/var/www/errors" # serves errors/403.htmlPHP-скрипт
Путь URI с ведущим / к фолбэк-скрипту внутри DOCUMENT_ROOT:
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:
<?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:
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-псевдоним и выдаёт предупреждение при запуске:
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 сообщается как проигнорированная. Значения не объединяются.
См. также
- Маршрутизация — режимы маршрутизации и безопасность путей
- Страницы ошибок — собственные HTML-тела для ответов со статусным фолбэком
- Справочник конфигурации — полный список переменных окружения
- Метрики —
oxphp_php_deny_totalи родственные