Clickjacking : comment cinq lignes de CSS peuvent voler un clic
Une attaque par iframe invisible qui fait cliquer l'utilisateur sur autre chose que ce qu'il croit, et comment s'en protéger.
Une attaque par iframe invisible qui fait cliquer l'utilisateur sur autre chose que ce qu'il croit, et comment s'en protéger.
Vous pensez accepter des cookies. En réalité, vous venez d’ajouter une star à un repo dont vous n’avez jamais entendu parler.
C’est ça le clickjacking, une attaque UI tellement simple qu’elle tient en quelques lignes de CSS.
Le principe est simple : il s’agit de faire cliquer l’utilisateur sur autre chose que ce qu’il croit cliquer et donc de détourner le clic de son intention initiale pour déclencher silencieusement une action sensible.
Vous vous demandez sûrement pourquoi les victimes tombent dans le panneau ? Parce qu’aujourd’hui, il est devenu banal de devoir accepter des cookies ou fermer une bannière avant d’accéder au contenu d’un site. Les attaquants n’ont qu’à imiter ces mêmes éléments intrusifs.
Le clickjacking, ou UI Redressing, est une attaque assez ancienne avec de nombreuses contre-mesures existantes aujourd’hui. Mais j’ai décidé d’écrire cet article parce que je la trouve toujours intéressante : elle est très visuelle, ce qui change des habituelles attaques de serveurs mal configurés.
Et puis des moyens de protection existent et sont utilisés, mais tout le monde n’en a pas vraiment conscience.
L’attaque se joue sur deux couches. L’attaquant héberge une page sur laquelle la victime navigue, par exemple avec une bannière de cookies affichée. Par-dessus cette page se trouve une iframe invisible chargée depuis la vraie cible.
<style>
iframe {
position: absolute;
opacity: 0.001;
z-index: 10;
top: 0;
left: 0;
}
</style>
<!-- Le leurre que la victime voit -->
<button>Accepter les cookies</button>
<!-- La vraie cible, invisible -->
<iframe src="https://github.com/some-repo/star-toggle"></iframe>Trois propriétés CSS font tout le travail :
| Propriété | Rôle |
|---|---|
opacity: 0.001 |
Rend l’iframe invisible (!= 0 car certains navigateurs laisseraient passer les clics à travers) |
z-index: 10 |
La place au-dessus pour que ce soit elle qui reçoive le clic |
position: absolute |
Aligne l’iframe au pixel près sur le bouton leurre |
La victime clique sur « Accepter les cookies ». Le navigateur envoie ce clic à l’iframe en dessous. GitHub enregistre une star venant d’un utilisateur authentifié. Envoyez ça à 10 000 personnes et vous pouvez mettre en avant un repo quelconque sur GitHub.
C'est exactement ce que voit la victime : une seule page avec un bouton « Accepter tous les cookies ». Faites glisser le curseur pour faire pivoter la scène et révéler ce qui est réellement empilé au-dessus.
Un clic ne fait que la moitié de l’attaque. Un attaquant malin a aussi besoin que la victime ne se rende pas immédiatement compte que quelque chose a mal tourné.
La solution est d’écouter le clic et de remplacer le contenu de la page au moment où il se produit :
window.addEventListener('blur', () => {
// L'iframe vient de voler le focus : la victime a cliqué
showFakeConfirmation();
});Quand la victime clique, le navigateur déplace brièvement le focus vers l’iframe. La page de l’attaquant détecte cet événement blur et remplace immédiatement le leurre par un écran naturel. La victime ferme l’onglet satisfaite. Mais le mal est déjà fait.
L’attaque nécessite que la victime soit connectée au site cible. Voici pourquoi cela arrive automatiquement, sans que la victime ne fasse quoi que ce soit de plus.
Quand vous vous connectez à GitHub, le serveur émet un cookie de session et le navigateur le stocke. À chaque requête suivante vers github.com, y compris celles émises par une iframe sur une page complètement différente, le navigateur attache ce cookie automatiquement. Il n’a aucun moyen de demander « est-ce que l’utilisateur voulait vraiment cette requête ? ». Il envoie juste le credential.
Donc quand la page de l’attaquant charge github.com dans une iframe cachée, GitHub voit une session authentifiée valide et affiche votre compte complet.
Les navigateurs passent désormais les cookies de session en SameSite=Lax par défaut, ce qui leur dit de ne pas attacher le cookie aux requêtes de sous-ressources cross-site, iframes comprises. Le site cible se charge dans la frame de l’attaquant comme une page déconnectée, ce qui casse l’attaque.
Set-Cookie: session=abc123; SameSite=Lax; Secure; HttpOnlyL’impact dépend de ce que le site cible permet en un seul clic. Les actions financières sont l’exemple spectaculaire, mais le spectre réel est plus large :
Le fil conducteur : tous les logs serveur, toutes les pistes d’audit paraissent parfaitement propres. Pas d’exploit, pas d’injection, pas d’anomalie. Un utilisateur légitime a cliqué sur un bouton légitime dans une session légitime.
Quelques cas réels des deux dernières décennies :
| Année | Cible | Ce qui s’est passé |
|---|---|---|
| 2008 | Adobe Flash Player | Accès micro et caméra accordé via un panneau de réglages Flash invisible |
| 2009 | Ver « Don’t Click » : tweets forcés envoyés à grande échelle depuis de vrais comptes | |
| 2010 | Campagne massive de likejacking, des millions de likes involontaires | |
| 2015 | Boutons d’action invisibles en CSS pur, sans iframe | |
| 2018 | Yelp | Carte bancaire enregistrée débitée via un bouton de réservation détourné |
Une attaque n’aboutit que si les trois conditions sont réunies en même temps :
1. La page cible peut être embarquée dans une iframe. Aucun header défensif en place (on les couvre juste après). Le navigateur charge la cible dans n’importe quelle frame contrôlée par l’attaquant sans broncher.
2. Une action sensible se déclenche en un seul clic. Pas de ré-authentification, pas de dialogue de confirmation, pas de CAPTCHA. La victime est déjà connectée et l’action s’exécute immédiatement au clic.
3. L’attaquant peut prédire la position du bouton. L’UI cible est publique et stable. L’attaquant l’inspecte une fois, mesure les décalages en pixels, puis positionne l’iframe jusqu’à ce que le leurre soit exactement au-dessus de la cible. Ce point n’est pas vraiment évitable, c’est pourquoi les prérequis 1 et 2 sont ceux qu’on va essayer d’empêcher.
En tant que propriétaire d’un site, vous devez configurer le serveur pour empêcher la page d’être embarquée dans une iframe. Pour cela, deux headers sont disponibles. Lors de la requête initiale vers la page, le serveur envoie un header de réponse au navigateur pour indiquer si la page peut être embarquée dans une iframe.
Le correctif d’origine, sorti en 2009 (doc MDN). Il est aujourd’hui déprécié mais reste utile.
# Bloquer tout framing
X-Frame-Options: DENY
# Autoriser le framing depuis la même origine uniquement
X-Frame-Options: SAMEORIGIN
# Autoriser des origines spécifiques (ne fonctionne pas dans Chrome ni Safari)
X-Frame-Options: ALLOW-FROM https://trusted-partner.comDENY bloque tout framing. SAMEORIGIN n’autorise que la même origine.
Une troisième valeur, ALLOW-FROM, devait permettre de lister des origines
spécifiques, mais elle n’a jamais été implémentée dans Chrome ni Safari et est
de fait laissée de côté.
Je pars du principe que vous savez ce qu’est une CSP et comment elle fonctionne. Sinon, la documentation MDN est un bon point de départ.
Le nouveau standard moderne, qui a remplacé XFO, est la directive frame-ancestors. Elle supporte plusieurs origines autorisées, les sous-domaines avec wildcard, et un mode report-only pour auditer avant d’appliquer :
# Bloquer tout framing
Content-Security-Policy: frame-ancestors 'none';
# Autoriser la même origine uniquement
Content-Security-Policy: frame-ancestors 'self';
# Autoriser des partenaires spécifiques
Content-Security-Policy: frame-ancestors 'self' https://trusted-partner.com;Quand les deux headers sont présents et se contredisent, c’est la CSP frame-ancestors qui prend
le dessus dans les navigateurs modernes. (⚠️ peut varier selon le navigateur)
Bonne pratique : envoyez les deux. XFO couvre les vieux navigateurs, la CSP couvre tout le reste. Le coût : deux lignes de config serveur.
X-Frame-Options: DENY
Content-Security-Policy: frame-ancestors 'none';Avant que les headers HTTP n’existent pour ça, les développeurs écrivaient des scripts côté client pour détecter l’imbrication dans une iframe et forcer une redirection :
if (window.top !== window.self) {
window.top.location = window.self.location;
}Cette technique a été cassée presque immédiatement après son apparition. L’attribut
HTML5 sandbox permet à un attaquant de désactiver la navigation top-level sur une
iframe :
<iframe src="https://target.com" sandbox="allow-forms allow-scripts">
<!-- allow-top-navigation est absent : le JS de framebusting ne peut pas rediriger -->
</iframe>Scannez les headers de réponse de votre application sur securityheaders.com.
Si X-Frame-Options ou Content-Security-Policy avec frame-ancestors manque,
vérifiez votre configuration serveur et ajoutez-les avant toute autre chose.
X-Frame-Options: DENY
Content-Security-Policy: frame-ancestors 'none';
Set-Cookie: session=…; SameSite=Lax; Secure; HttpOnlyLa OWASP Clickjacking Defense Cheat Sheet est une bonne ressource pour creuser un peu plus le sujet.
Voilà pour le clickjacking classique. Ces 2 headers de sécurité le tuent net.
Mais ce n’est pas fini. De nouvelles variantes modernes, qui n’utilisent pas d’iframe du tout, émergent.
Par exemple, le DoubleClickjacking et le DOM-based clickjacking via extensions contournent toutes les défenses couvertes ici, mais l’idée derrière reste la même : détourner le clic de son intention initiale pour déclencher une action sensible.
J’ai donné un talk sur ce sujet, donc si vous préférez regarder les slides, vous pouvez trouver ma présentation.