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.
Ê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>
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.
- Modifiez l'URL pour remplacer
id=2parid=1, puis parid=3. - Essayez aussi
id=4, puisid=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
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
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 ».
- Ouvrez les outils de développement du navigateur (touche
F12), onglet Éléments (Inspecteur). - Repérez la ligne
<input type="hidden" name="role" value="membre">dans le formulaire. - Double-cliquez sur la valeur
membreet remplacez-la paradmin. - 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
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.
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']) ?>">
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.
profil.php?id=2s'affiche, maisprofil.php?id=1etprofil.php?id=3répondent « Accès refusé ».admin.phprépond « Accès refusé ».- 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. - Déconnectez-vous puis connectez-vous en tant que Claire : elle peut toujours ouvrir
admin.phpet 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
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.
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
| Notion | Question posée | Exemple dans le TP |
|---|---|---|
| Authentification | Qui êtes-vous ? | Marc saisit son pseudo et son mot de passe |
| Autorisation | Avez-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
| Faille | Principe | Exemple dans le TP |
|---|---|---|
| IDOR | Modifier un identifiant dans l'URL ou dans un formulaire pour accéder à l'objet d'un autre utilisateur | profil.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'interface | admin.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ôle | Le 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
ifrecopié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.
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é.
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
Résumé

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.
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(danssecurity.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
roleajouté par l'utilisateur est ignoré (contre le mass assignment).
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.