Aller au contenu principal

Contrôle d'accès défaillant

Étudions la vulnérabilité Contrôle d'accès défaillant (Broken Access Control en anglais) est n°1 du Top 10 de l'OWASP (2025).

  • Comprendre comment une application peut laisser un utilisateur accéder à ce qui ne le concerne pas.
  • Exploiter les 3 failles Web suivantes : IDOR, accès direct à une page d'administration et élévation de privilèges.
  • Apprendre à les corriger par un contrôle d'autorisation côté serveur.
attention

Être connecté ne veut pas dire être autorisé. Si le serveur se contente de vérifier qu'un utilisateur est connecté, sans vérifier qu'il a le droit d'accéder à la ressource demandée, un simple changement dans l'URL ou dans un formulaire suffit pour lire, modifier ou administrer ce qui appartient à quelqu'un d'autre.


1. Création du projet vulnérable​

Créez un dossier tp-acces qui contient l'espace membre du club de randonnée (le même site que celui des TP sur le XSS et le CSRF), avec trois membres : Claire (administratrice), Marc et Julie.

Commencez par les fichiers d'infrastructure, à copier tels quels : ils gèrent les données et la connexion, et ne contiennent aucune faille à étudier.

Fichiers d'infrastructure : membres.json, outils.php, login.php, logout.php

membres.json (les trois membres ont le mot de passe rando2026) :

[
{
"id": 1,
"pseudo": "claire",
"nom": "Claire Fontaine",
"email": "claire@club-randonnee.fr",
"telephone": "06 11 22 33 44",
"role": "admin",
"mot_de_passe": "$2y$12$juhkvquaAtX5gmNGeagO9OwUSL/4xWFcELrrnNKZbC9AdXmNtbY22"
},
{
"id": 2,
"pseudo": "marc",
"nom": "Marc Dupuis",
"email": "marc@club-randonnee.fr",
"telephone": "06 55 66 77 88",
"role": "membre",
"mot_de_passe": "$2y$12$juhkvquaAtX5gmNGeagO9OwUSL/4xWFcELrrnNKZbC9AdXmNtbY22"
},
{
"id": 3,
"pseudo": "julie",
"nom": "Julie Martin",
"email": "julie@club-randonnee.fr",
"telephone": "06 99 88 77 66",
"role": "membre",
"mot_de_passe": "$2y$12$juhkvquaAtX5gmNGeagO9OwUSL/4xWFcELrrnNKZbC9AdXmNtbY22"
}
]

outils.php :

<?php
session_start();

const FICHIER_MEMBRES = __DIR__ . '/membres.json';

function lireMembres(): array
{
return json_decode(file_get_contents(FICHIER_MEMBRES), true);
}

function ecrireMembres(array $membres): void
{
file_put_contents(
FICHIER_MEMBRES,
json_encode($membres, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)
);
}

function trouverMembre(int $id): ?array
{
foreach (lireMembres() as $membre) {
if ($membre['id'] === $id) {
return $membre;
}
}
return null;
}

// Retourne le membre connecté, ou null si personne n'est connecté
function membreConnecte(): ?array
{
return isset($_SESSION['id']) ? trouverMembre($_SESSION['id']) : null;
}

login.php :

<?php
require 'outils.php';

$erreur = null;
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
foreach (lireMembres() as $membre) {
if ($membre['pseudo'] === ($_POST['pseudo'] ?? '')
&& password_verify($_POST['mot_de_passe'] ?? '', $membre['mot_de_passe'])) {
session_regenerate_id(true);
$_SESSION['id'] = $membre['id'];
header('Location: index.php');
exit;
}
}
$erreur = 'Identifiants incorrects.';
}
?>
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<title>Connexion - Club de randonnée</title>
</head>
<body>
<h1>Connexion au club de randonnée</h1>
<?php if ($erreur): ?>
<p style="color:red;"><?= htmlspecialchars($erreur) ?></p>
<?php endif; ?>
<form method="post" action="">
<p><label>Pseudo : <input type="text" name="pseudo" required></label></p>
<p><label>Mot de passe : <input type="password" name="mot_de_passe" required></label></p>
<button type="submit">Se connecter</button>
</form>
</body>
</html>

logout.php :

<?php
require 'outils.php';

session_destroy();
header('Location: login.php');
exit;

Créez ensuite les quatre pages du site, celles qui vont nous intéresser.

index.php : la page d'accueil, avec un menu. Le lien vers l'administration n'est affiché que pour les administrateurs.

<?php
require 'outils.php';

$moi = membreConnecte();
if (!$moi) {
header('Location: login.php');
exit;
}
?>
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<title>Espace membre - Club de randonnée</title>
</head>
<body>
<h1>Espace membre du club de randonnée</h1>
<p>Bonjour <strong><?= htmlspecialchars($moi['nom']) ?></strong> (rôle : <?= htmlspecialchars($moi['role']) ?>)</p>

<ul>
<li><a href="profil.php?id=<?= $moi['id'] ?>">Mon profil</a></li>
<li><a href="modifier_profil.php">Modifier mes informations</a></li>
<?php if ($moi['role'] === 'admin'): ?>
<li><a href="admin.php">Administration du club</a></li>
<?php endif; ?>
<li><a href="logout.php">Se déconnecter</a></li>
</ul>
</body>
</html>

profil.php : affiche le profil d'un membre, désigné par le paramètre id de l'URL.

<?php
require 'outils.php';

$moi = membreConnecte();
if (!$moi) {
header('Location: login.php');
exit;
}

$membre = trouverMembre((int) ($_GET['id'] ?? 0));
if (!$membre) {
http_response_code(404);
exit('Membre introuvable.');
}
?>
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<title>Profil - Club de randonnée</title>
</head>
<body>
<h1>Profil de <?= htmlspecialchars($membre['nom']) ?></h1>
<ul>
<li>Email : <?= htmlspecialchars($membre['email']) ?></li>
<li>Téléphone : <?= htmlspecialchars($membre['telephone']) ?></li>
<li>Rôle : <?= htmlspecialchars($membre['role']) ?></li>
</ul>
<p><a href="index.php">Retour</a></p>
</body>
</html>

admin.php : la page d'administration, qui liste tous les membres.

<?php
require 'outils.php';

$moi = membreConnecte();
if (!$moi) {
header('Location: login.php');
exit;
}
?>
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<title>Administration - Club de randonnée</title>
</head>
<body>
<h1>Administration du club</h1>
<table border="1" cellpadding="6">
<tr><th>ID</th><th>Nom</th><th>Email</th><th>Téléphone</th><th>Rôle</th></tr>
<?php foreach (lireMembres() as $membre): ?>
<tr>
<td><?= $membre['id'] ?></td>
<td><?= htmlspecialchars($membre['nom']) ?></td>
<td><?= htmlspecialchars($membre['email']) ?></td>
<td><?= htmlspecialchars($membre['telephone']) ?></td>
<td><?= htmlspecialchars($membre['role']) ?></td>
</tr>
<?php endforeach; ?>
</table>
<p><a href="index.php">Retour</a></p>
</body>
</html>

modifier_profil.php : permet à un membre de modifier son email et son téléphone.

<?php
require 'outils.php';

$moi = membreConnecte();
if (!$moi) {
header('Location: login.php');
exit;
}

$message = null;
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$membres = lireMembres();
foreach ($membres as &$membre) {
if ($membre['id'] === $moi['id']) {
// On recopie tout ce que le formulaire a envoyé
$membre['email'] = $_POST['email'];
$membre['telephone'] = $_POST['telephone'];
$membre['role'] = $_POST['role'];
}
}
unset($membre);
ecrireMembres($membres);
$moi = membreConnecte();
$message = 'Informations mises à jour.';
}
?>
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<title>Modifier mon profil - Club de randonnée</title>
</head>
<body>
<h1>Modifier mes informations</h1>
<?php if ($message): ?>
<p style="color:green;"><?= htmlspecialchars($message) ?></p>
<?php endif; ?>
<form method="post" action="">
<p><label>Email : <input type="email" name="email" value="<?= htmlspecialchars($moi['email']) ?>" required></label></p>
<p><label>Téléphone : <input type="text" name="telephone" value="<?= htmlspecialchars($moi['telephone']) ?>" required></label></p>
<input type="hidden" name="role" value="<?= htmlspecialchars($moi['role']) ?>">
<button type="submit">Enregistrer</button>
</form>
<p><a href="index.php">Retour</a></p>
</body>
</html>
info

Toutes les sorties sont protégées par htmlspecialchars() (voir le TP sur le XSS) : ce site n'est pas vulnérable au XSS. Le jeton CSRF n'est pas repris ici pour ne pas alourdir le code. Les failles de ce TP sont d'un autre type : elles concernent les droits d'accès.


2. Démarrer le serveur Web​

Dans le dossier tp-acces, démarrez le serveur Web intégré à PHP :

php -S localhost:8000

Ouvrez http://localhost:8000/ : vous êtes redirigé vers la page de connexion. Connectez-vous en tant que Marc (marc / rando2026), un simple membre du club, puis parcourez son menu : consultez son profil et modifiez son email une première fois.

Question : Quels liens Marc voit-il dans son menu ? Quel lien est absent, par rapport à Claire (claire / rando2026, administratrice) ?

📌 Une réponse


3. Attaque n°1 : lire le profil d'un autre membre (IDOR)​

Connecté en tant que Marc, cliquez sur « Mon profil » : l'URL est http://localhost:8000/profil.php?id=2.

  1. Modifiez l'URL pour remplacer id=2 par id=1, puis par id=3.
  2. Essayez aussi id=4, puis id=99.

Vous lisez l'email et le téléphone de Claire et de Julie, alors que vous êtes connecté en tant que Marc ! Avec id=4 ou id=99, le site répond « Membre introuvable » : un attaquant peut ainsi deviner combien de membres existent en essayant les identifiants un par un.

Question : Le site vérifie pourtant que l'utilisateur est connecté. Pourquoi a-t-il accepté la requête de Marc pour le profil de Claire ?

📌 Une réponse

attention

C'est une faille IDOR (Insecure Direct Object Reference, « référence directe non sécurisée à un objet ») : l'application expose un identifiant d'objet (ici id) sans vérifier que l'utilisateur a le droit d'accéder à cet objet. C'est l'une des formes les plus courantes de Broken Access Control.


4. Attaque n°2 : accéder à la page d'administration sans en avoir le droit​

Toujours connecté en tant que Marc, tapez directement dans la barre d'adresse :

http://localhost:8000/admin.php

La liste de tous les membres (emails, téléphones, rôles) s'affiche, alors que le menu de Marc ne propose aucun lien vers l'administration.

Question : Pourquoi masquer le lien « Administration du club » dans le menu ne protège-t-il pas la page admin.php ?

📌 Une réponse

attention

Cette technique s'appelle le forced browsing (« navigation forcée ») : l'attaquant essaie directement des adresses qui ne sont pas proposées dans l'interface (/admin, /admin.php, /backup, /export...). Un site qui cache ses pages sensibles sans les protéger est vulnérable.


5. Attaque n°3 : devenir administrateur (élévation de privilèges)​

Toujours connecté en tant que Marc, ouvrez « Modifier mes informations ».

  1. Ouvrez les outils de développement du navigateur (touche F12), onglet Éléments (Inspecteur).
  2. Repérez la ligne <input type="hidden" name="role" value="membre"> dans le formulaire.
  3. Double-cliquez sur la valeur membre et remplacez-la par admin.
  4. Cliquez sur « Enregistrer », puis revenez à l'accueil.

Le menu de Marc affiche maintenant « Administration du club » : Marc est devenu administrateur ! Le fichier membres.json contient désormais "role": "admin" pour Marc.

Question : Qui contrôle les valeurs envoyées par un formulaire : le développeur du site ou l'utilisateur ? Que faut-il en conclure sur un champ « caché » (type="hidden") ?

📌 Une réponse

attention

Ici, le serveur recopie tout ce que le formulaire envoie dans la fiche du membre. On parle de mass assignment (« affectation de masse ») : l'attaquant peut modifier des champs qui ne devraient jamais être modifiables par lui, comme role.

info

Avant l'étape suivante, remettez le rôle de Marc à membre : ouvrez membres.json et remplacez "role": "admin" par "role": "membre" pour Marc (l'id 2).


6. Corriger les trois failles​

Les trois corrections suivent un même principe : le serveur vérifie les droits à chaque requête, sans faire confiance à ce que le navigateur envoie.

Correction n°1 : profil.php. Ajoutez, juste après la recherche du membre, une vérification d'autorisation : on peut voir un profil si c'est le sien, ou si l'on est administrateur.

$membre = trouverMembre((int) ($_GET['id'] ?? 0));
if (!$membre) {
http_response_code(404);
exit('Membre introuvable.');
}

// Contrôle d'autorisation : son propre profil, ou être administrateur
if ($membre['id'] !== $moi['id'] && $moi['role'] !== 'admin') {
http_response_code(403);
exit('Accès refusé.');
}

Correction n°2 : admin.php. Ajoutez, juste après la vérification de connexion, un contrôle du rôle :

// Contrôle d'autorisation : réservé aux administrateurs
if ($moi['role'] !== 'admin') {
http_response_code(403);
exit('Accès refusé.');
}

Correction n°3 : modifier_profil.php. Ne recopiez plus que les champs explicitement autorisés (liste blanche), et supprimez le champ caché role du formulaire.

foreach ($membres as &$membre) {
if ($membre['id'] === $moi['id']) {
// Liste blanche : seuls l'email et le téléphone sont modifiables
$membre['email'] = $_POST['email'];
$membre['telephone'] = $_POST['telephone'];
}
}

Dans le formulaire, supprimez la ligne :

<input type="hidden" name="role" value="<?= htmlspecialchars($moi['role']) ?>">
Bonne pratique - Le serveur décide, jamais le navigateur

Le contrôle d'accès doit se faire côté serveur, à chaque requête, et s'appuyer sur des informations que l'utilisateur ne contrôle pas : l'identifiant du membre connecté en session, son rôle lu dans la base de données. L'identifiant reçu dans l'URL ou dans un formulaire n'est qu'une demande, à vérifier.


7. Vérifier que les attaques échouent désormais​

Reconnectez-vous en tant que Marc (le rôle de Marc doit être de nouveau membre), puis rejouez les étapes 3, 4 et 5.

  1. profil.php?id=2 s'affiche, mais profil.php?id=1 et profil.php?id=3 répondent « Accès refusé ».
  2. admin.php répond « Accès refusé ».
  3. Le formulaire « Modifier mes informations » ne contient plus de champ role. Ajoutez-en un avec les outils de développement (<input type="hidden" name="role" value="admin">) puis enregistrez : le rôle de Marc ne change pas.
  4. Déconnectez-vous puis connectez-vous en tant que Claire : elle peut toujours ouvrir admin.php et le profil de Julie (id=3). Les droits légitimes sont conservés.

Question : Pourquoi est-il important de tester aussi avec le compte de Claire, et pas seulement avec celui de Marc ?

📌 Une réponse

Bonne pratique - Refuser 403 ou 404 ?

Ici, le site répond 403 (« Accès refusé ») quand le profil existe mais n'appartient pas à l'utilisateur, et 404 quand l'identifiant n'existe pas : cela permet encore de deviner quels identifiants existent. Sur un site sensible, on répond le même message dans les deux cas (par exemple 404), pour ne rien révéler.


Notions théoriques​

Le contrôle d'accès (ou autorisation) décide qui a le droit de faire quoi sur une application. Il y a une faille de contrôle d'accès (Broken Access Control) quand un utilisateur peut agir en dehors des droits qui lui ont été accordés.

remarque

Cette catégorie est n°1 du Top 10 de l'OWASP (2025) (la liste de référence des risques les plus courants pour les applications Web).

Authentification et autorisation​

NotionQuestion poséeExemple dans le TP
AuthentificationQui êtes-vous ?Marc saisit son pseudo et son mot de passe
AutorisationAvez-vous le droit de faire cela ?Marc a-t-il le droit de lire le profil de Claire ?

L'authentification est nécessaire mais pas suffisante : un site peut très bien identifier correctement ses utilisateurs et rester vulnérable, s'il oublie de vérifier leurs droits.

Les trois failles rencontrées dans le TP​

FaillePrincipeExemple dans le TP
IDORModifier un identifiant dans l'URL ou dans un formulaire pour accéder à l'objet d'un autre utilisateurprofil.php?id=1 au lieu de id=2
Accès direct à une fonction (forced browsing)Demander directement l'adresse d'une page réservée, non proposée dans l'interfaceadmin.php tapé dans la barre d'adresse
Élévation de privilèges (mass assignment)Modifier une donnée du formulaire que le serveur recopie sans contrôleLe champ caché role passé de membre à admin

Quelle différence avec le CSRF ?​

  • Avec le CSRF (voir le TP sur le CSRF), l'utilisateur n'a pas voulu l'action : l'attaquant la déclenche à sa place, à son insu.
  • Avec le contrôle d'accès défaillant, l'utilisateur veut bien faire l'action, mais il n'en a pas le droit, et le serveur ne le vérifie pas.

Les 2 failles se corrigent ensemble : un jeton CSRF ne protège pas contre un IDOR, et un contrôle d'accès ne protège pas contre un CSRF.

Les principes d'un bon contrôle d'accès​

  • Refuser par défaut (deny by default) : tant qu'un droit n'a pas été accordé explicitement, l'accès est refusé.
  • Contrôler côté serveur, à chaque requête : ce que le navigateur affiche ou cache n'est jamais une protection.
  • Ne jamais faire confiance aux données du client : identifiants, champs cachés, cookies modifiables, en-têtes. Utiliser une liste blanche des champs modifiables.
  • Appliquer le moindre privilège : chaque compte ne dispose que des droits dont il a besoin.
  • Centraliser les contrôles : une fonction ou un composant unique vérifie les droits, plutôt que des if recopiés dans chaque page, ce qui évite les oublis.
  • Journaliser les refus : une série de « Accès refusé » depuis un même compte est le signe d'une tentative d'attaque.
attention

Des identifiants difficiles à deviner (par exemple un UUID comme profil.php?id=3f2b8c1e-...) rendent l'énumération plus difficile, mais ne remplacent jamais le contrôle d'autorisation : quiconque connaît un identifiant (lien partagé, journal, historique) peut toujours l'utiliser. Sécuriser par l'obscurité n'est pas de la sécurité.

info

Les frameworks proposent des outils pour centraliser ces contrôles : par exemple les rôles et les Voters de Symfony (voir la séance sur la sécurité avancée avec les voters), ou l'attribut #[IsGranted] pour protéger une route.


Test de mémorisation/compréhension​


Quelle est la différence entre authentification et autorisation ?


Dans le TP, Marc remplace id=2 par id=1 dans l'URL et lit le profil de Claire. De quelle faille s'agit-il ?


Pourquoi profil.php laisse-t-il Marc lire le profil de Claire, alors que le site vérifie que l'utilisateur est connecté ?


Un développeur masque le lien vers admin.php pour les non-administrateurs. Que peut-on en conclure ?


Comment appelle-t-on le fait de saisir directement l'adresse d'une page non proposée dans l'interface ?


Pourquoi un champ <input type="hidden"> ne doit-il jamais contenir une donnée dont dépend la sécurité ?


Dans modifier_profil.php, quelle est la cause de l'élévation de privilèges de Marc ?


Quelle correction empêche Marc de modifier son propre rôle ?


D'où doit provenir l'identité de l'utilisateur utilisée pour décider d'un droit d'accès ?


Quelle est la principale différence entre CSRF et contrôle d'accès défaillant ?


Des identifiants de type UUID (très difficiles à deviner) dans l'URL suffisent-ils à protéger les profils ?


Que signifie le principe « refuser par défaut » (deny by default) ?


Quels comptes faut-il tester après avoir corrigé une faille de contrôle d'accès ?



Résumé​

Schéma


Bonnes pratiques​

  • Vérifier l'autorisation côté serveur, à chaque requête, pour chaque page et chaque action.
  • Refuser par défaut : n'accorder un droit que lorsqu'il est explicitement prévu.
  • Ne jamais se fier aux données du client : identifiant dans l'URL, champ caché, cookie modifiable. L'identité et le rôle viennent de la session et de la base de données.
  • Utiliser une liste blanche des champs qu'un utilisateur peut modifier (contre le mass assignment).
  • Appliquer le principe du moindre privilège à chaque compte.
  • Centraliser les contrôles d'accès plutôt que de les répéter dans chaque page.
  • Journaliser les accès refusés pour repérer les tentatives d'attaque.
  • Tester avec plusieurs comptes : un compte sans droit, un compte légitime, un administrateur.
attention

Masquer un bouton, un lien ou un champ dans l'interface n'est jamais un contrôle d'accès. C'est seulement du confort d'affichage : la vraie protection se situe toujours côté serveur.


Ce que fait Symfony​

Symfony fournit des outils pour centraliser le contrôle d'accès, mais il ne devine pas vos règles :

  • access_control (dans security.yaml) et l'attribut #[IsGranted('ROLE_ADMIN')] protègent une page ou une route par rôle (contre le forced browsing).
  • Les Voters vérifient le droit d'accéder à un objet précis, par exemple $this->denyAccessUnlessGranted('VIEW', $membre) (contre l'IDOR).
  • Les formulaires ne recopient que les champs qu'ils déclarent et refusent les champs en trop : un role ajouté par l'utilisateur est ignoré (contre le mass assignment).
attention

Une route sans #[IsGranted], sans access_control ni Voter reste ouverte à tous les utilisateurs connectés : la protection existe, mais c'est à vous de l'activer.