Objectifs
À la fin de ce cours, tu seras capable de :
- Identifier les fonctionnalités d'une application qui poussent le serveur à émettre une requête vers une destination fournie par l'utilisateur.
- Distinguer une SSRF avec réponse d'une SSRF aveugle, et choisir la bonne technique de détection pour chacune.
- Analyser pourquoi les services internes et le service de métadonnées cloud transforment une simple requête sortante en vulnérabilité critique.
- Démontrer l'impact d'une SSRF avec une preuve minimale, non destructive et conforme aux règles d'un programme.
- Évaluer la sévérité avec CVSS 3.1, en justifiant l'usage fréquent de
S:C. - Rédiger une recommandation de correction solide : allow-list, validation de l'adresse résolue, refus des redirections, segmentation et IMDSv2.
Pourquoi c'est important en bug bounty
La Server-Side Request Forgery (CWE-918) figure comme catégorie à part entière dans l'OWASP Top 10 2021 (A10) et dans l'OWASP API Security Top 10 2023 (API7). Sa présence dans ces classements s'explique par la généralisation des architectures cloud : presque chaque application moderne récupère des ressources distantes pour le compte de ses utilisateurs, et presque chaque serveur cloud a accès à des services internes qui n'ont jamais été conçus pour être joignables depuis Internet.
Les sévérités observées dans les rapports publics de HackerOne et Bugcrowd couvrent tout le spectre :
| Situation constatée | Sévérité typique |
|---|---|
| Interaction DNS ou HTTP sortante vers Internet uniquement | Informative à faible |
| SSRF aveugle permettant de sonder des ports internes | Faible à moyenne |
| Lecture de réponses de services internes (panneaux d'administration, API internes) | Élevée |
| Accès au service de métadonnées cloud exposant des identifiants | Critique |
Ce qui distingue un rapport payé d'un rapport fermé, c'est presque toujours la démonstration d'un franchissement de frontière. Un rapport qui dit « le serveur a contacté mon domaine » est souvent classé informatif : beaucoup d'applications sont conçues pour contacter des URL arbitraires sur Internet (un webhook, par définition, appelle l'URL que tu lui donnes). Un rapport payé montre que le serveur atteint une ressource que toi, depuis Internet, tu ne peux pas atteindre, et il le montre sans rien abîmer.
Le mécanisme
La cause racine est simple à énoncer : le serveur effectue une requête réseau vers une destination contrôlée, en tout ou en partie, par l'utilisateur, sans vérifier que cette destination fait partie de celles qu'il est censé contacter.
Le problème n'est pas la requête elle-même, mais la position réseau de celui qui l'émet. Ton navigateur est sur Internet ; le serveur applicatif, lui, se trouve derrière le pare-feu, dans un réseau privé (VPC), avec accès à des bases de données, des API internes sans authentification, des consoles d'administration et, dans le cloud, au service de métadonnées de l'instance. Quand tu fais émettre une requête par ce serveur, tu empruntes sa position réseau et la confiance implicite que l'infrastructure lui accorde. C'est un cas d'école de confused deputy : un intermédiaire privilégié agit pour le compte d'un demandeur qui ne l'est pas.
Pourquoi le service de métadonnées est si sensible
Les fournisseurs cloud exposent à chaque instance un service HTTP à une adresse link-local (169.254.169.254 chez AWS, GCP et Azure). Il fournit la configuration de la machine et, surtout, les identifiants temporaires du rôle attaché à l'instance. Ces identifiants permettent d'appeler les API du fournisseur avec les permissions de ce rôle : lire des buckets de stockage, des secrets, parfois modifier l'infrastructure. Une SSRF qui atteint ce service peut donc devenir une compromission du compte cloud, bien au-delà de l'application.
IMDSv1 et IMDSv2
Sur AWS, deux versions du service coexistent :
| IMDSv1 | IMDSv2 | |
|---|---|---|
| Modèle | Requête-réponse simple : un GET suffit | Orienté session : il faut d'abord obtenir un jeton |
| Obtention du jeton | Aucune | Requête PUT avec un en-tête précisant la durée de vie |
| Utilisation | Aucun en-tête requis | Chaque GET doit porter le jeton dans un en-tête dédié |
| Protection réseau | Aucune | La réponse au PUT a un TTL IP réglable (hop limit à 1 par défaut), ce qui empêche un conteneur ou un proxy intermédiaire de la recevoir |
IMDSv2 réduit fortement le risque parce que la plupart des SSRF ne donnent à l'attaquant que le contrôle de l'URL : le serveur vulnérable émet un GET sans en-têtes personnalisés. L'attaquant ne peut alors ni envoyer le PUT initial, ni ajouter l'en-tête de jeton aux requêtes suivantes. De plus, AWS refuse les requêtes PUT de jeton qui portent un en-tête X-Forwarded-For, ce qui neutralise une partie des scénarios passant par un proxy ouvert. GCP et Azure suivent une logique comparable en exigeant un en-tête spécifique (Metadata-Flavor: Google, Metadata: true). IMDSv2 n'est pas une correction de la SSRF, mais il transforme beaucoup de SSRF critiques en SSRF de sévérité moindre.
Question de réflexion : si IMDSv2 est obligatoire sur l'instance, la SSRF devient-elle inoffensive ?
Non. IMDSv2 protège uniquement le service de métadonnées. Le serveur conserve sa position réseau et peut toujours atteindre les API internes, les bases de données exposées en HTTP, les consoles d'administration ou les services d'orchestration du réseau privé. Par ailleurs, une SSRF qui permet de choisir la méthode HTTP et d'ajouter des en-têtes (cas plus rare mais réel) pourrait satisfaire le protocole IMDSv2. La sévérité baisse souvent, mais la vulnérabilité doit toujours être corrigée à la source.
Où chercher
Toute fonctionnalité où l'application va chercher quelque chose à partir d'une donnée que tu fournis est candidate :
- Webhooks : URL de notification configurable dans les paramètres d'un compte ou d'une intégration.
- Import par URL : avatar depuis une URL, import de fichiers CSV ou de flux RSS, synchronisation de calendriers.
- Aperçus de liens : messageries et réseaux sociaux qui récupèrent le titre et l'image Open Graph d'une URL collée.
- Génération de PDF ou de captures : un navigateur sans interface rend du HTML que tu influences ; les ressources qu'il charge (images, feuilles de style, iframes) sont autant de requêtes émises depuis le serveur.
- Traitement de documents : fichiers XML (lien avec les XXE), SVG, documents bureautiques contenant des références externes.
- Intégrations et connecteurs : « tester la connexion » à un serveur SMTP, LDAP, à un dépôt Git ou à une API tierce.
- Paramètres d'infrastructure : en-têtes interprétés par un proxy ou un équilibreur de charge pour choisir le serveur amont.
Les signaux faibles dans le trafic intercepté :
- des paramètres nommés
url,uri,link,src,dest,callback,webhook,feed,endpoint,host; - une valeur qui ressemble à une URL ou à un nom d'hôte, même encodée, même dans un JSON imbriqué ;
- des temps de réponse qui varient selon la destination fournie ;
- des messages d'erreur révélant un client HTTP côté serveur (délai de connexion dépassé, connexion refusée, erreur de certificat, nom de bibliothèque).
Méthode de test
Rappel préalable : vérifie que le programme autorise explicitement les tests de SSRF et s'il fournit un domaine ou une instance de test. Certains programmes interdisent de cibler le service de métadonnées ou les réseaux internes ; respecte ces règles à la lettre.
Étape 1 : cartographier
Avec ton proxy d'interception (Burp Suite, Caido) et un compte de test personnel, parcours toutes les fonctionnalités listées plus haut. Repère chaque requête qui transporte une URL ou un nom d'hôte.
Étape 2 : confirmer la requête sortante
Utilise un domaine d'interaction que tu contrôles (Burp Collaborator, interactsh ou ton propre serveur avec journalisation DNS et HTTP). Fournis une URL unique pointant vers ce domaine :
POST /api/integrations/webhook HTTP/1.1
Host: app.acme.test
Cookie: session=<ta-session-de-test>
Content-Type: application/json
{"name":"test-ssrf","url":"https://a1b2c3.oob.exemple.fr/hook"}Observe ensuite les interactions entrantes sur ton domaine :
- une résolution DNS seule prouve que le nom a été résolu, pas forcément qu'une connexion a eu lieu ;
- une requête HTTP te donne l'adresse IP source (souvent celle du serveur ou de sa passerelle NAT), le
User-Agentdu client HTTP et parfois des en-têtes internes intéressants pour ton rapport.
Étape 3 : qualifier le type de SSRF
- SSRF avec réponse (full response) : le contenu récupéré t'est renvoyé, directement ou indirectement (aperçu affiché, fichier importé, PDF généré).
- SSRF partielle : seuls quelques éléments filtrent (titre de page, code de statut, taille, type MIME).
- SSRF aveugle : rien ne revient dans la réponse. La détection repose alors sur l'interaction entrante sur ton domaine de test et, pour l'impact, sur des différences observables : message d'erreur, code de statut ou temps de réponse distincts selon que la destination répond, refuse ou ne répond pas.
Étape 4 : tester le franchissement de frontière
Compare le comportement de l'application pour trois catégories de destinations :
- ton domaine de test (destination publique qui répond) ;
- une destination publique qui ne répond pas ;
- une destination interne simple, comme l'interface de bouclage du serveur.
POST /api/preview HTTP/1.1
Host: app.acme.test
Cookie: session=<ta-session-de-test>
Content-Type: application/json
{"url":"http://127.0.0.1/"}Si la réponse pour la destination interne diffère nettement d'une destination injoignable (contenu, code, délai), tu as un indice fort que le serveur atteint son propre réseau. Limite-toi à quelques requêtes ciblées : un balayage massif de ports internes peut perturber la production et dépasse ce qu'un programme attend.
Étape 5 : documenter
Note pour chaque requête la date, la charge envoyée, la réponse obtenue et les journaux de ton domaine d'interaction. Ces éléments constitueront le cœur du rapport.
Variantes et défenses courantes
Variantes
- SSRF via redirection : l'application valide l'URL fournie, mais son client HTTP suit ensuite une redirection vers une autre destination.
- SSRF via moteur de rendu : un générateur de PDF charge les ressources référencées dans du HTML que tu contrôles.
- SSRF via analyseur de fichiers : références externes dans du XML, du SVG ou des documents bureautiques.
- SSRF de second ordre : l'URL est stockée puis utilisée plus tard par un traitement en arrière-plan (tâche planifiée, webhook déclenché par un événement).
- SSRF par en-tête : un proxy inverse construit l'URL amont à partir d'un en-tête comme
Host.
Pourquoi les listes de blocage par chaîne échouent
Une défense fréquente consiste à rejeter les URL contenant localhost, 127.0.0.1 ou 169.254.169.254. Elle est fragile pour deux raisons de principe :
- Une même destination possède de nombreuses représentations. Une adresse IP peut s'écrire de plusieurs façons que les bibliothèques réseau acceptent ; un nom de domaine que tu contrôles peut résoudre vers une adresse interne ; IPv6 offre ses propres équivalences. Comparer des chaînes revient à comparer des représentations, alors que la seule chose qui compte est l'adresse effectivement contactée.
- La destination finale peut changer après la validation. Une redirection HTTP mène ailleurs que l'URL validée, et un nom DNS peut résoudre différemment entre le moment de la vérification et celui de la connexion (DNS rebinding, problème de type TOCTOU).
S'ajoutent les divergences d'analyse : le validateur et le client HTTP n'interprètent pas toujours une URL ambiguë de la même façon. La conclusion est conceptuelle : valide l'adresse IP réellement utilisée pour la connexion, au moment de la connexion, et préfère une allow-list à une liste de blocage.
Question de réflexion : l'application vérifie que le nom d'hôte résout vers une IP publique, puis appelle sa bibliothèque HTTP avec l'URL d'origine. Où est la faille ?
La bibliothèque HTTP effectue sa propre résolution DNS. Entre les deux résolutions, la réponse peut changer : un enregistrement à durée de vie très courte peut renvoyer une IP publique lors du contrôle, puis une IP interne lors de la connexion. Il faut soit se connecter directement à l'adresse validée, soit effectuer la validation dans la fonction de résolution utilisée par le client lui-même, comme dans l'exemple de la section Corriger. Et si le client suit les redirections, chaque saut échappe aussi au contrôle.
Prouver l'impact sans nuire
La règle d'or : montre qu'une ressource interne répond, puis arrête-toi.
Ce qui est généralement accepté comme preuve :
- la réponse d'un service interne non sensible (page d'accueil d'un service de supervision, bannière de version, page d'état) ;
- pour le service de métadonnées, une information non secrète qui prouve l'accès : l'identifiant de l'instance, ou le nom du rôle attaché tel qu'il apparaît dans un listing, sans demander le niveau suivant qui contient les identifiants ;
- pour une SSRF aveugle, les journaux de ton domaine d'interaction montrant l'IP source du serveur, et une différence de comportement re productible entre une destination interne et une destination injoignable.
Ce qui est interdit ou fortement déconseillé :
- récupérer, afficher ou conserver des identifiants (clés, jetons, mots de passe), même pour « prouver » la criticité ;
- utiliser des identifiants obtenus, par exemple pour appeler l'API du fournisseur cloud ;
- balayer massivement les plages ou les ports internes ;
- envoyer à un service interne des requêtes qui modifient un état (création, suppression, redémarrage) ;
- poursuivre l'exploration une fois l'accès interne démontré.
Si tu tombes par accident sur une donnée sensible, ne la réutilise pas, masque-la dans ton rapport et signale-le explicitement au programme. Le triageur sait parfaitement ce que permet l'accès au service de métadonnées : un nom de rôle suffit à faire accepter la criticité, la clé elle-même n'ajoute rien à la preuve et beaucoup au risque.
Évaluer la sévérité
Le Scope (S) mesure si l'impact touche une autorité de sécurité différente de celle du composant vulnérable. Dans une SSRF, le composant vulnérable est l'application web ; les ressources touchées (services internes, service de métadonnées, compte cloud) relèvent d'autres autorités. C'est pourquoi S:C est généralement justifié dès que tu démontres l'accès à une ressource interne. Une SSRF qui ne touche qu'Internet ne franchit aucune frontière et ne justifie pas S:C.
| Scénario | Vecteur CVSS 3.1 | Score |
|---|---|---|
| SSRF aveugle authentifiée, révélant quels services internes répondent | AV:N/AC:L/PR:L/UI:N/S:C/C:L/I:N/A:N | 5.0 (Moyenne) |
| Même chose sans authentification | AV:N/AC:L/PR:N/UI:N/S:C/C:L/I:N/A:N | 5.8 (Moyenne) |
| SSRF avec réponse, lecture d'API internes contenant des données confidentielles | AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:N/A:N | 8.6 (Élevée) |
| SSRF authentifiée vers un service de métadonnées IMDSv1 exposant les identifiants d'un rôle étendu | AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:N | 9.6 (Critique) |
Justifie toujours C, I et A par ce que tu as réellement observé, et décris le pire cas plausible sans l'avoir exécuté. Si l'instance impose IMDSv2, le dernier scénario ne s'applique pas : ne le revendique pas.
Corriger
Code vulnérable (Node.js)
// VULNÉRABLE : l'URL de l'utilisateur est appelée telle quelle,
// et fetch suit les redirections par défaut.
app.post('/api/preview', async (req, res) => {
const response = await fetch(req.body.url);
res.send(await response.text());
});Code corrigé
import https from 'node:https';
import dns from 'node:dns';
import ipaddr from 'ipaddr.js';
// 1. Allow-list explicite des hôtes que l'application doit contacter.
const ALLOWED_HOSTS = new Set(['images.exemple.fr', 'cdn.acme.test']);
const MAX_BYTES = 2 * 1024 * 1024;
// 2. Seules les adresses unicast publiques sont acceptées : on refuse
// bouclage, plages privées, link-local (dont 169.254.0.0/16), etc.
function isPublicAddress(address) {
const ip = ipaddr.process(address); // normalise les IPv4 encapsulées en IPv6
return ip.range() === 'unicast';
}
// 3. La validation se fait DANS la résolution utilisée pour la connexion :
// l'adresse vérifiée est celle à laquelle on se connecte (pas de TOCTOU).
function safeLookup(hostname, options, callback) {
dns.lookup(hostname, { all: true }, (err, addresses) => {
if (err) return callback(err);
if (addresses.some((a) => !isPublicAddress(a.address))) {
return callback(new Error('Destination interdite'));
}
if (options.all) return callback(null, addresses);
callback(null, addresses[0].address, addresses[0].family);
});
}
export function fetchPreview(rawUrl) {
const url = new URL(rawUrl);
if (url.protocol !== 'https:') throw new Error('Schéma refusé');
if (url.port && url.port !== '443') throw new Error('Port refusé');
if (!ALLOWED_HOSTS.has(url.hostname)) throw new Error('Hôte non autorisé');
return new Promise((resolve, reject) => {
const req = https.get(url, { lookup: safeLookup, timeout: 5000 }, (res) => {
// 4. Aucune redirection n'est suivie.
if (res.statusCode >= 300 && res.statusCode < 400) {
res.resume();
return reject(new Error('Redirection refusée'));
}
let size = 0;
const chunks = [];
res.on('data', (chunk) => {
size += chunk.length;
if (size > MAX_BYTES) return req.destroy(new Error('Réponse trop volumineuse'));
chunks.push(chunk);
});
res.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
});
req.on('timeout', () => req.destroy(new Error('Délai dépassé')));
req.on('error', reject);
});
}Le module https natif ne suit pas les redirections, et la fonction lookup est appelée au moment de la connexion : le contrôle porte donc sur l'adresse réellement contactée. L'allow-list d'hôtes reste la première barrière ; la validation d'adresse protège contre un hôte autorisé qui viendrait à résoudre vers le réseau interne. Ne renvoie jamais à l'utilisateur la réponse brute ni les messages d'erreur réseau détaillés.
Défense en profondeur
- Allow-list de destinations dès que le besoin fonctionnel le permet (hôtes, schémas, ports). Pour les webhooks où l'URL est libre, la validation d'adresse à la connexion devient indispensable.
- Proxy de sortie dédié : toutes les requêtes sortantes passent par un proxy qui applique la politique de destinations et journalise, plutôt que de faire confiance à chaque service.
- Segmentation réseau : le service qui récupère des URL tourne dans un réseau isolé, sans route vers les services internes ni vers le service de métadonnées.
- IMDSv2 obligatoire sur toutes les instances (désactivation d'IMDSv1, hop limit à 1), et rôles attachés au strict minimum de permissions.
- Authentification des services internes : un réseau privé n'est pas une frontière de confiance suffisante.
- Surveillance : alerte sur les requêtes sortantes vers des plages privées ou link-local depuis les serveurs applicatifs.
Erreurs qui font rejeter un rapport
- Signaler une simple interaction DNS sans démontrer d'accès interne ni d'impact, sur une fonctionnalité conçue pour contacter des URL externes.
- Revendiquer l'accès aux identifiants cloud sans l'avoir constaté, ou alors qu'IMDSv2 est imposé.
- Récupérer ou utiliser des identifiants : c'est une violation des règles, souvent synonyme d'exclusion du programme.
- Balayer massivement le réseau interne, au risque de perturber la production.
- Fournir un CVSS gonflé (
C:H/I:H/A:Hpar défaut) sans justification liée aux observations. - Omettre les étapes de reproduction : charge exacte, horodatage, journaux du domaine d'interaction.
Checklist du hunter
- Périmètre et règles du programme relus (SSRF, métadonnées, réseaux internes autorisés ?).
- Toutes les fonctionnalités qui récupèrent une URL ou un hôte sont inventoriées.
- Requête sortante confirmée par une interaction sur ton propre domaine de test.
- Type de SSRF qualifié : avec réponse, partielle ou aveugle.
- Comparaison des réponses entre destination publique, injoignable et interne.
- Preuve minimale obtenue (bannière, identifiant d'instance, nom de rôle), puis arrêt.
- Aucun identifiant récupéré ni utilisé ; données sensibles masquées.
- CVSS justifié,
S:Cuniquement si une frontière est franchie. - Correction proposée : allow-list, validation à la connexion, pas de redirection, IMDSv2, segmentation.
Pour aller plus loin
- OWASP Top 10 2021 — A10:2021 Server-Side Request Forgery.
- OWASP Server-Side Request Forgery Prevention Cheat Sheet.
- OWASP API Security Top 10 2023 — API7:2023 Server Side Request Forgery.
- PortSwigger Web Security Academy — Server-side request forgery (SSRF).
- MITRE CWE-918 : Server-Side Request Forgery (SSRF).
- Documentation AWS — Use the Instance Metadata Service Version 2 (IMDSv2).