Коли ваш сервер робить запит за чужою адресою
SSRF (Server-Side Request Forgery) трапляється там, де код бере URL від людини й сам робить за ним запит: превʼю посилання, імпорт картинки за адресою, вебхук, перевірка сайту. Запит іде з вашого сервера, і для мережі він свій. Йому відкриті localhost, Redis і адмінки на внутрішніх портах, сервіси в приватній мережі 10.0.0.0/8 і на 169.254.169.254, де на старих налаштуваннях AWS звичайний GET віддає тимчасові ключі ролі сервера.
Якщо відповідь повертається людині, атакувальник читає внутрішні сторінки. Якщо ні, це , і він однаково отримує багато: перебирає порти за часом відповіді чи за статусом, викликає внутрішні адреси, які щось роблять на GET, і бачить, чи «посилання працює».
Перевірити рядок URL замало. Та сама адреса має багато записів: 127.0.0.1, localhost, 127.1, 2130706433, [::1]. Домен атакувальника може резолвитися в 10.0.0.5. Дозволений сайт може відповісти 302 на http://169.254.169.254/, і клієнт піде за : Http у Laravel (Guzzle під капотом) за замовчуванням іде за пʼятьма, file_get_contents за двадцятьма. А дає різні відповіді на перевірку й на сам запит.
Найкращий захист це : вебхуки лише на відомі хости, API на фіксований домен, а від людини береться тільки шлях чи ідентифікатор. Коли URL справді довільний, порядок такий: дозволити лише http і https з портами 80 і 443; резолвити хост самостійно; кожну адресу перевірити filter_var з FILTER_FLAG_GLOBAL_RANGE (PHP 8.2+), що відкидає приватні, loopback, link-local і 100.64.0.0/10; зʼєднатися саме з перевіреною адресою; редиректи вимкнути або перевіряти кожен; поставити короткі таймаути й ліміт розміру; не віддавати людині сире тіло відповіді й текст помилки.
Другий рубіж стоїть поза кодом. Вихідний трафік воркерів, які ходять у інтернет, пускають через проксі або firewall, що не випускає у внутрішні мережі. На AWS вмикають IMDSv2: метадані віддають токен лише на PUT із заголовком, тож простий GET через SSRF до них не дістанеться.
Три кроки від поля «URL вебхука» до ключів хмари
- Атакувальник створює URL вебхука.
- URL вебхука використовує Сервер застосунку (get()).
- Дані йдуть від Сервер застосунку до evil.example.
- Дані йдуть від evil.example до 169.254.169.254.
- 169.254.169.254 надсилає подію ключі, її отримує Атакувальник.
Резолвимо самі, перевіряємо кожну адресу
-
1
рядки 3–9
Підставний DNS. 2130706433 це 127.0.0.1 одним числом: так його прочитає і curl. multi.example має дві адреси, і одна з них внутрішня.
-
2
рядки 18–20
Лише http і https на стандартних портах. gopher:// і порт 6379 відпадають тут, ще до DNS.
-
3
рядки 22–26
Резолвимо самі. Адреса без запису в DNS теж відхиляється: краще не пустити, ніж пустити наосліп.
-
4
рядки 28–32
Перевіряємо кожну адресу, бо клієнт може взяти будь-яку. FILTER_FLAG_GLOBAL_RANGE відкидає приватні, loopback, link-local і 100.64.0.0/10.
-
5
рядки 34–34
Запит треба зробити саме на перевірену адресу. CURLOPT_RESOLVE підставляє її curl, і повторного резолву, на якому тримається DNS rebinding, не буде.
- Наведіть на пояснення, щоб підсвітити рядки.Торкніться пояснення, щоб підсвітити рядки.
Laravel і Symfony не перевіряють, куди йде запит
Http::get($request->input('url'));
Чому це дірка: Клієнт за замовчуванням іде за пʼятьма редиректами, чекає відповіді до 30 секунд і зʼєднання до 10. Для чужих адрес: withoutRedirecting(), timeout(5), connectTimeout(2) і перевірка IP перед запитом.
'url' => ['required', 'url']
Чому це дірка: Правило url перевіряє формат і без параметрів приймає gopher://, dict:// і ще сотню схем. url:http,https обмежує схему, але хост і IP не перевіряє ніщо з валідації.
str_starts_with($url, 'https://api.example.com')
Чому це дірка: Пройдуть https://api.example.com.evil.net і https://api.example.com@evil.net, де хост насправді evil.net. Розбирайте URL через parse_url і порівнюйте хост повністю, без str_contains чи str_ends_with.
FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE
Чому це дірка: Ці два прапорці пропускають 100.64.0.0/10, а там, наприклад, метадані Alibaba Cloud 100.100.100.200. FILTER_FLAG_GLOBAL_RANGE з PHP 8.2 строгіший і відкидає все, що не глобальне.
gethostbyname($host); Http::get($url);
Чому це дірка: Перевірка й запит резолвлять домен окремо, і з TTL 0 відповіді різні. Передайте перевірену адресу в запит: withOptions з curl і CURLOPT_RESOLVE.
file_get_contents($url)
Чому це дірка: Обгортки потоків відкривають file://, php://filter і phar://, а http:// іде за редиректами. Для URL від людини беріть HTTP-клієнт з обмеженими схемами, а не файлову функцію.
new NoPrivateNetworkHttpClient(HttpClient::create())
Чому це дірка: У Symfony є готовий декоратор, що блокує приватні мережі, включно з адресами після редиректу. У Laravel такого немає, перевірку пишуть самі або ставлять egress-проксі.
Де тут SSRF
Натисніть на рядки, у яких бачите вразливість, і перевірте. Тут три проблеми.
Вставте у свій шаблон пул-реквесту
- Якщо можна обійтися allow-list хостів, URL від людини не приймається: вона дає лише шлях чи ідентифікатор.
- Довільний URL дозволено лише зі схемою http або https і портами 80 чи 443.
- Хост резолвиться в коді, кожна адреса перевірена filter_var з FILTER_FLAG_GLOBAL_RANGE, запит іде саме на неї (CURLOPT_RESOLVE).
- Редиректи вимкнено (withoutRedirecting) або кожен перевіряється так само, як перша адреса.
- Таймаути короткі, розмір відповіді обмежено, тіло й текст помилки людині не віддаються.
- Feature-тест з Http::fake і Http::preventStrayRequests перевіряє, що внутрішні адреси й редирект на них відхилено.
Де цей сайт сам ходить за адресами людей
Сайт робить запити за адресами, які дали люди, у трьох місцях. ConfirmProject шукає код підтвердження на сторінці проєкту, у корені його сайту й у файлі на тому самому хості. CheckProjectLinks за розкладом перевіряє, чи посилання проєктів ще відповідають. VerifyCompany шукає код на сайті компанії. Кожен такий запит це GET з таймаутом 10 секунд, тіло обрізається до 512 К символів і людині не показується: код лише шукають у тексті. Адреса проєкту проходить ProjectLink ще на вводі: тільки http чи https і хост, схожий на доменне імʼя, тож IP-адреса й localhost у поле не пройдуть, а порт і логін з адреси відкидаються: збережений URL завжди https://хост/шлях.
src/Projects/Infrastructure/GithubRepositories.phpРепозиторії на GitHub читаються з фіксованих хостів api.github.com і raw.githubusercontent.com. Від людини береться лише owner і name, перевірені регуляркою в ProjectLink і закодовані rawurlencode.src/Projects/Infrastructure/HttpWebPages.phpАдаптер для сторінок проєктів: короткий GET з Accept text/html, будь-яка відповідь, крім 2xx, читається як «сторінки немає», тіло обрізається до 512 К символів.tests/Support/FakeProjectWeb.phpУ тестах сторінки й репозиторії існують лише тоді, коли тест їх поклав, тож жоден feature-тест не ходить у мережу.- Метадані хмари
- Внутрішній HTTP-сервіс віртуальної машини: AWS, GCP й Azure використовують 169.254.169.254, Alibaba Cloud 100.100.100.200. Віддає дані інстансу, а часто й тимчасові облікові дані ролі, з якими можна ходити в API хмари.
- Blind SSRF
- SSRF, де відповідь внутрішнього сервісу людині не повертається. Атакувальник судить за статусом, часом відповіді чи побічним ефектом, і цього досить для сканування портів.
- Редирект
- Відповідь 301, 302, 307 чи 308 із заголовком Location. HTTP-клієнт робить новий запит на Location, і перевірка першої адреси вже нічого не гарантує.
- DNS rebinding
- Домен атакувальника з дуже коротким TTL: на першу перевірку він резолвиться в публічну адресу, а коли HTTP-клієнт резолвить його вдруге, уже у внутрішню.
- Allow-list
- Список дозволеного: точні хости, схеми й порти. Усе, чого в ньому немає, відхиляється. Block-list з localhost і 127.0.0.1 завжди щось пропускає.
Питання зі співбесід
Сервер робить HTTP-запит за адресою, яку контролює атакувальник. Браузер атакувальника бачить лише інтернет, а сервер ще й localhost, приватну мережу й метадані хмари, і внутрішні сервіси часто довіряють йому без пароля. Тому той самий URL з сервера відкриває те, що ззовні закрито.
Block-list не перелічить усіх записів і всіх мереж: 127.1, 2130706433, [::1], 10.0.0.0/8, 169.254.169.254, 100.64.0.0/10. Домен атакувальника може резолвитися у внутрішню адресу, а дозволений сайт може віддати редирект. Треба резолвити хост, перевіряти кожну IP з FILTER_FLAG_GLOBAL_RANGE і не йти за редиректами без перевірки.
Домен з TTL 0 відповідає публічною адресою на перевірку й внутрішньою на справжній запит, бо резолвів два. Захист: резолвити один раз, перевірити адреси й передати саме їх у HTTP-клієнт, у curl це CURLOPT_RESOLVE (у Laravel через withOptions з curl). Для HTTPS імʼя хоста лишається в SNI і перевірці сертифіката, тож нічого не ламається. Надійніше ще й обмежити вихідний трафік на рівні мережі.
Окремий воркер для доставки, чий вихідний трафік іде через egress-проксі без доступу у внутрішні мережі. Без редиректів, короткі таймаути, ліміт розміру. У журналі доставки лише статус і час, без тіла відповіді. Повторна перевірка IP на кожній доставці, бо DNS змінюється. Підпис запиту HMAC, щоб клієнт міг перевірити, що вебхук від вас.