# Un formulaire de contact qui exécutait du code : anatomie d’un upload piégé

> Un champ d'upload de formulaire de contact acceptait des fichiers PHP déguisés en images. Diagnostic complet de la faille et correctif par validation stricte du type réel.

- Auteur : Clément Hadrot
- Publié le : 2020-12-12
- Mis à jour le : 2020-12-12
- Catégorie : Sécurité
- URL : https://wpmoderne.dev.wordpress-developpement.fr/securite/formulaire-contact-upload-piege-anatomie/

## L’essentiel

- L'extension .jpg ne prouve rien sur le contenu réel
- Le MIME envoyé par le navigateur n'est pas fiable
- Seule la lecture des octets du fichier tranche

Le ticket arrive un dimanche soir : « le site charge des publicités bizarres, on ne comprend rien ». Le site en question est une vitrine d'agence immobilière, avec un formulaire de contact permettant de joindre une pièce jointe — pratique pour recevoir un plan de financement ou une photo de bien à évaluer. Rien d'exotique. Sauf qu'en investiguant, un fichier `photo-plan.php` apparaît dans `wp-content/uploads/2020/12/`, daté de la veille, et son contenu ne ressemble à aucune photo.

Le fichier contenait un petit script PHP qui, une fois accédé directement via son URL publique, exécutait des commandes système passées en paramètre GET. Un webshell minimaliste, de la variété la plus basique mais la plus efficace : quelques lignes suffisent pour donner à un attaquant un accès complet à tout ce que peut faire le compte système du serveur web.

## Reconstituer le chemin de l'attaque

La première question à trancher est toujours la même : comment un fichier `.php` a-t-il pu atterrir dans un dossier destiné aux pièces jointes d'un formulaire de contact ? En reprenant le code du plugin de formulaire (une extension tierce peu connue, installée depuis longtemps et jamais mise à jour), la fonction de traitement de l'upload apparaît :

```
// Code fautif retrouvé dans le plugin
$fichier = $_FILES['piece_jointe'];
$extension_autorisee = array( 'jpg', 'jpeg', 'png', 'pdf' );
$ext = pathinfo( $fichier['name'], PATHINFO_EXTENSION );

if ( in_array( strtolower( $ext ), $extension_autorisee ) ) {
    move_uploaded_file(
        $fichier['tmp_name'],
        WP_CONTENT_DIR . '/uploads/' . $fichier['name']
    );
}
```

Le contrôle ne porte que sur l'extension déclarée dans le nom de fichier envoyé par le navigateur, une information entièrement contrôlée par le client. L'attaquant a simplement renommé son script en `photo-plan.php.jpg`… non, en réalité plus subtil encore : certains serveurs Apache mal configurés avec des règles `AddHandler` historiques exécutent du PHP sur des fichiers portant une double extension comme `photo.php.jpg` si le module ne s'arrête pas à la dernière extension. Mais dans ce cas précis, plus simple : le nom envoyé était directement `photo-plan.php`, et la vérification `in_array( strtolower( $ext ), ... )` comparait `'php'` à la liste — sauf qu'un bug de casse dans une version antérieure de la liste autorisée laissait passer `'Php'` avec une majuscule mal filtrée par un `strtolower()` appliqué à la mauvaise variable dans une branche de code parallèle du plugin, utilisée uniquement pour les formulaires « avec conditions ». Un cas d'école de logique dupliquée qui diverge avec le temps.

## Pourquoi vérifier l'extension ne suffit jamais

> L'essentiel à retenir : L'extension .jpg ne prouve rien sur le contenu réel ; Le MIME envoyé par le navigateur n'est pas fiable ; Seule la lecture des octets du fichier tranche

Même sans ce bug de casse, la faille de fond restait entière : l'extension déclarée dans `$_FILES['piece_jointe']['name']` et le type MIME envoyé dans `$_FILES['piece_jointe']['type']` sont tous deux fournis par le client et falsifiables à volonté avec un simple outil comme `curl` ou une extension de navigateur. Aucun des deux ne garantit quoi que ce soit sur le contenu réel du fichier. Un attaquant peut nommer son script `image.jpg` et déclarer un en-tête `Content-Type: image/jpeg` sans que le fichier contienne un seul octet d'image valide.

La seule vérification fiable consiste à lire les premiers octets du fichier pour en déduire sa nature réelle, indépendamment de son nom. WordPress fournit justement cette fonction, que l'extension fautive n'utilisait jamais : `wp_check_filetype_and_ext()`.

## Le correctif : valider le type réel avant d'accepter le fichier

```
$fichier = $_FILES['piece_jointe'];

// Vérifie l'extension déclarée ET la signature binaire réelle du fichier
$verification = wp_check_filetype_and_ext(
    $fichier['tmp_name'],
    $fichier['name']
);

$extensions_autorisees = array( 'jpg', 'jpeg', 'png', 'pdf' );

if ( empty( $verification['ext'] ) || empty( $verification['type'] )
    || ! in_array( $verification['ext'], $extensions_autorisees, true ) ) {
    wp_die( esc_html__( 'Type de fichier non autorisé.', 'moncpt' ) );
}

// Renommage systématique : jamais le nom fourni par le client, pour éviter
// toute exécution même en cas de mauvaise configuration serveur.
$nouveau_nom = wp_unique_filename(
    wp_upload_dir()['path'],
    uniqid( 'piece-jointe-', true ) . '.' . $verification['ext']
);

move_uploaded_file(
    $fichier['tmp_name'],
    trailingslashit( wp_upload_dir()['path'] ) . $nouveau_nom
);
```

`wp_check_filetype_and_ext()` compare l'extension déclarée à la signature réelle du fichier (les premiers octets, ou « nombre magique », qui identifient un JPEG, un PNG ou un PDF de façon fiable) et renvoie un tableau vide ou incohérent si les deux ne correspondent pas. C'est exactement la fonction que `wp_handle_upload()` utilise en interne pour les uploads du cœur de WordPress, mais que de nombreuses extensions de formulaire réinventent maladroitement, souvent en moins bien.

## Une seconde barrière : interdire l'exécution dans le dossier d'upload

Même avec une validation de contenu parfaite, une défense en profondeur reste indispensable : le dossier `wp-content/uploads` ne devrait, dans l'idéal, jamais exécuter de PHP, quel que soit le fichier qui s'y trouve. Un fichier `.htaccess` dédié dans ce dossier (sur Apache) referme cette porte même en cas d'oubli de validation ailleurs :

```
# wp-content/uploads/.htaccess
<FilesMatch "\.(php|phtml|php\d)$">
    Require all denied
</FilesMatch>
```

Sur ce projet, l'ajout de cette règle a été la correction déployée en urgence dès la découverte du webshell, avant même le correctif du plugin, parce qu'elle neutralise instantanément la menace sans dépendre du code applicatif.

> Sur nos audits de formulaires tiers, la question que nous posons systématiquement est : « que se passe-t-il si je renomme un fichier PHP en .jpg et que je force le Content-Type ? ». Si la réponse n'est pas un refus immédiat, le formulaire ne passe pas la revue.

## En résumé

Cette faille n'avait rien de sophistiqué : une confiance mal placée dans des informations fournies par le client a suffi à ouvrir un accès complet au serveur. Le correctif ne l'était pas davantage : s'appuyer sur les fonctions de vérification que WordPress expose déjà, plutôt que de réinventer un contrôle d'extension approximatif, aurait évité l'incident dès la conception du plugin.
