Liste de refus d'exécution PHP

PHP_DENY_PATHS bloque l'exécution directe des fichiers .php correspondant aux motifs glob configurés. Il cible une catégorie récurrente de vulnérabilités dans les applications PHP héritées : un attaquant téléverse un fichier PHP dans un répertoire public accessible en écriture (/uploads, /cache, répertoires temporaires de redimensionnement d'images) et l'atteint via une URI directe pour obtenir l'exécution de code.

La vérification s'exécute avant toute E/S disque, si bien que les chemins refusés renvoient la même réponse, que le fichier existe ou non sur le disque. Il n'existe aucun oracle d'existence permettant aux attaquants de sonder les répertoires de téléversement.

Quand elle s'applique

Les modes à correspondance directe, ceux où une URI se résout directement en un fichier .php sur le disque :

Mode de routage PHP_DENY_PATHS respecté ?
Traditionnel (sans ENTRY_FILE) Oui
SPA (ENTRY_FILE=index.html) Oui — le mode SPA exécute directement les fichiers .php existants, donc la liste de refus s'applique
Framework (ENTRY_FILE=index.php) Non — avertissement émis et ignoré
Worker (WORKER_MODE_ENABLED=true) Non — avertissement émis et ignoré

En mode Framework, chaque requête est réécrite vers le contrôleur frontal et aucun fichier .php arbitraire n'est jamais exécuté directement ; une liste de refus ne ferait que casser les routes applicatives qui se terminent par .php. En mode Worker, chaque requête non statique est dispatchée vers le script worker, si bien que la liste de refus n'a rien à refuser. Définir PHP_DENY_PATHS dans l'un ou l'autre de ces modes émet un avertissement au démarrage et désactive la vérification.

La liste de refus couvre également les scripts atteints indirectement : une requête vers /uploads/ qui se résoudrait en uploads/index.php via la recherche d'index de répertoire est refusée lorsque uploads/** figure dans la liste — c'est le chemin du script résolu qui est comparé aux motifs, pas seulement l'URI de la requête. Pour de tels refus, OXPHP_DENIED_PATH contient l'URI de requête assainie sans la barre oblique finale (/uploads/ est rapporté comme /uploads).

Configuration

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

Une requête vers /uploads/shell.php renvoie désormais 403 sans toucher le disque. Une requête vers /uploads/image.png est servie normalement — la liste de refus n'affecte que l'exécution .php, jamais le service de fichiers statiques.

Syntaxe des motifs

Les motifs sont comparés à l'URI assainie (le chemin de requête dont les segments .. et les contournements par encodage pourcent ont déjà été résolus) selon la syntaxe globset. La barre oblique initiale de chaque motif est facultative — /uploads/** et uploads/** sont équivalents.

Motif Correspond à Ne correspond pas à
/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 (un * simple ne traverse pas /)
/admin/legacy.php /admin/legacy.php /admin/legacy.php/x (PATH_INFO non couvert — voir ci-dessous)
/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

Plusieurs motifs sont combinés par un OU — une requête correspond à la liste de refus si elle correspond à au moins un motif.

Fichiers uniques ou répertoires

Les deux fonctionnent. /uploads/** bloque un sous-arbre entier ; /admin/legacy.php bloque un script précis. Pour bloquer un point d'entrée hérité unique ainsi que toutes ses invocations PATH_INFO (/admin/legacy.php/foo), utilisez la forme avec accolades : /admin/legacy.php{,/**}.

Sensibilité à la casse

La correspondance est sensible à la casse. Sur les systèmes de fichiers insensibles à la casse (HFS+/APFS par défaut sous macOS, NTFS par défaut sous Windows, ext4 avec casefold), une requête vers /uploads/Shell.PHP contournerait un motif /uploads/**/*.php. Utilisez un motif de répertoire large comme /uploads/** (qui correspond à toutes les extensions) lorsque vous servez depuis un tel système de fichiers, ou normalisez les téléversements en minuscules au moment de l'écriture.

Modes de repli

PHP_DENY_FALLBACK contrôle ce qui est renvoyé en cas de correspondance.

Statut HTTP

N'importe quelle valeur dans 400599 (par défaut 404). Se combine avec ERROR_PAGES_DIR pour un corps HTML personnalisé :

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

Script PHP

Un chemin d'URI préfixé par / vers un script de repli à l'intérieur de DOCUMENT_ROOT :

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

Le script est validé au démarrage — il doit exister, se canonicaliser à l'intérieur de DOCUMENT_ROOT, et ne doit pas lui-même correspondre à PHP_DENY_PATHS (prévention de boucle ; le démarrage échoue sinon). Le script s'exécute avec deux clés $_SERVER supplémentaires identifiant la requête d'origine :

Clé $_SERVER Valeur
OXPHP_DENIED_PATH URI d'origine assainie, avec une barre oblique initiale (même forme que PATH_INFO)
OXPHP_DENIED_PATTERN Le motif glob qui a correspondu

OXPHP_DENIED_PATTERN est stocké sans barre oblique initiale (normalisé glob), tandis que OXPHP_DENIED_PATH conserve le / de l'URI de requête. Si vous comparez le chemin au motif, appliquez d'abord ltrim($_SERVER['OXPHP_DENIED_PATH'], '/') afin que les deux soient dans la même forme.

Exemple de pot de miel :

/_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";

Cela vous permet de décider de la réponse requête par requête (renvoyer 404 aux attaquants, 403 aux administrateurs authentifiés, rediriger les scanners de sondage vers un puits) au lieu d'être limité à un seul statut statique.

Aucun oracle d'existence

Les replis Status et Script sont tous deux renvoyés sans toucher le système de fichiers. Une requête vers /uploads/never-uploaded.php et une requête vers /uploads/actually-on-disk.php produisent des réponses identiques — aucune différence de temps, aucune différence de corps. Un attaquant à la recherche de shells téléversés ne peut pas utiliser la liste de refus pour énumérer les noms de fichiers existants.

Le filtrage sur chemin résolu est la seule exception : il s'exécute nécessairement après la résolution de route, si bien que ses refus dépendent de l'existence. /uploads/ n'est refusé que lorsque uploads/index.php existe réellement sur le disque ; de même, avec un motif à étoile simple tel que /uploads/*.php, une requête PATH_INFO /uploads/shell.php/x n'est refusée que lorsque uploads/shell.php existe (l'URI complète ne correspond pas au motif — c'est le script résolu qui correspond). Le filtrage sur URI directe — le chemin que sondent les attaquants — reste exempt d'oracle.

Observabilité

Métrique Description
oxphp_php_deny_total Compteur incrémenté à chaque requête refusée

Chaque refus produit également un journal tracing::info :

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

Les journaux d'accès enregistrent le statut résultant (la valeur PHP_DENY_FALLBACK ou le http_response_code() du script de repli) — les requêtes refusées ne sont pas distinguées des requêtes normales au niveau de la journalisation des accès. Recoupez avec la métrique ou le journal structuré pour attribuer les pics.

Performance

La correspondance est une recherche globset::GlobSet — typiquement un seul passage SIMD sur les octets de l'URI. Une correspondance contourne également le cache de routes (les URI refusées proviennent d'un arrosage contrôlé par l'attaquant, à la cardinalité effectivement illimitée ; les mettre en cache permettrait à un attaquant d'évincer les entrées légitimes du LRU). Les chemins de correspondance comme de non-correspondance sont exempts d'allocation après le préchauffage.

Limitations

Ce que cette fonctionnalité ne fait pas :

  • Contournement PATH_INFO pour les motifs de fichiers littéraux. Un motif /admin/legacy.php ne correspond pas à /admin/legacy.php/extra. Utilisez /admin/legacy.php{,/**} pour couvrir les deux, ou utilisez un motif de répertoire.
  • Correspondance sensible à la casse (voir Sensibilité à la casse ci-dessus).
  • Pas de regex. Les motifs sont uniquement des globs — ancrés, avec les opérateurs */**/?/[abc]/{a,b}. Utilisez plusieurs motifs séparés par des virgules au lieu de (a|b).
  • Aucun effet sur include / require / eval. La liste de refus ne régit que l'exécution par URI directe. Un script vulnérable qui fait include $_GET['page'] peut toujours charger du PHP depuis n'importe quel emplacement lisible par le serveur.

Alias déprécié

L'ancienne variable PHP_DENY_DIRS est acceptée en tant qu'alias déprécié et émet un avertissement au démarrage :

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

Lorsque les deux sont définies, PHP_DENY_PATHS l'emporte et PHP_DENY_DIRS est signalée comme ignorée. Les valeurs ne sont pas fusionnées.

Voir aussi