<? phpukraine PHP-ПЛАТФОРМА
⌕Пошук по платформі
БЕЗПЕКА · КРИТИЧНІСТЬ: ВИСОКА OWASP 2025 A01 · Broken Access Control

SSRFServer-Side Request Forgery

Застосунок сам іде за адресою, яку дала людина, щоб показати превʼю, надіслати вебхук чи перевірити сайт. Запит виходить з вашого сервера, тож бачить те, що зовні закрито: localhost, внутрішню мережу й метадані хмари.

11 хв читання Junior → Senior
01 · ЩО ЦЕ

Коли ваш сервер робить запит за чужою адресою

SSRF (Server-Side Request Forgery) трапляється там, де код бере URL від людини й сам робить за ним запит: превʼю посилання, імпорт картинки за адресою, вебхук, перевірка сайту. Запит іде з вашого сервера, і для мережі він свій. Йому відкриті localhost, Redis і адмінки на внутрішніх портах, сервіси в приватній мережі 10.0.0.0/8 і на 169.254.169.254, де на старих налаштуваннях AWS звичайний GET віддає тимчасові ключі ролі сервера.

СЛОВНИКМетадані хмари. Внутрішній HTTP-сервіс віртуальної машини: AWS, GCP й Azure використовують 169.254.169.254, Alibaba Cloud 100.100.100.200. Віддає дані інстансу, а часто й тимчасові облікові дані ролі, з якими можна ходити в API хмари.

Якщо відповідь повертається людині, атакувальник читає внутрішні сторінки. Якщо ні, це , і він однаково отримує багато: перебирає порти за часом відповіді чи за статусом, викликає внутрішні адреси, які щось роблять на GET, і бачить, чи «посилання працює».

СЛОВНИКBlind SSRF. SSRF, де відповідь внутрішнього сервісу людині не повертається. Атакувальник судить за статусом, часом відповіді чи побічним ефектом, і цього досить для сканування портів.

Перевірити рядок 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 за двадцятьма. А дає різні відповіді на перевірку й на сам запит.

СЛОВНИКРедирект. Відповідь 301, 302, 307 чи 308 із заголовком Location. HTTP-клієнт робить новий запит на Location, і перевірка першої адреси вже нічого не гарантує.
СЛОВНИКDNS rebinding. Домен атакувальника з дуже коротким TTL: на першу перевірку він резолвиться в публічну адресу, а коли HTTP-клієнт резолвить його вдруге, уже у внутрішню.

Найкращий захист це : вебхуки лише на відомі хости, API на фіксований домен, а від людини береться тільки шлях чи ідентифікатор. Коли URL справді довільний, порядок такий: дозволити лише http і https з портами 80 і 443; резолвити хост самостійно; кожну адресу перевірити filter_var з FILTER_FLAG_GLOBAL_RANGE (PHP 8.2+), що відкидає приватні, loopback, link-local і 100.64.0.0/10; зʼєднатися саме з перевіреною адресою; редиректи вимкнути або перевіряти кожен; поставити короткі таймаути й ліміт розміру; не віддавати людині сире тіло відповіді й текст помилки.

СЛОВНИКAllow-list. Список дозволеного: точні хости, схеми й порти. Усе, чого в ньому немає, відхиляється. Block-list з localhost і 127.0.0.1 завжди щось пропускає.

Другий рубіж стоїть поза кодом. Вихідний трафік воркерів, які ходять у інтернет, пускають через проксі або firewall, що не випускає у внутрішні мережі. На AWS вмикають IMDSv2: метадані віддають токен лише на PUT із заголовком, тож простий GET через SSRF до них не дістанеться.

02 · ЯК ДУМАЄ АТАКУВАЛЬНИК

Три кроки від поля «URL вебхука» до ключів хмари

ДІАГРАМА · УСЯ СХЕМА
Кроки: 3
https://evil.exampleget()302 LocationредиректключіАтакувальник:URL вебхукаСервер застосунку{ }evil.example169.254.169.254
Атакувальник:URL вебхукаСервер застосунку{ }evil.example169.254.169.254
Уся схема. Натисніть → або виберіть крок нижче, щоб пройти по ній частинами.
Клієнт Ключовий учасник Інфраструктура
  • Атакувальник створює URL вебхука.
  • URL вебхука використовує Сервер застосунку (get()).
  • Дані йдуть від Сервер застосунку до evil.example.
  • Дані йдуть від evil.example до 169.254.169.254.
  • 169.254.169.254 надсилає подію ключі, її отримує Атакувальник.
03 · ВРАЗЛИВИЙ І ЗАХИЩЕНИЙ КОД

Резолвимо самі, перевіряємо кожну адресу

PHP 8.5
1<?php
2
3// Було: превʼю посилання, URL від людини йде в запит як є.
4$url = $_POST['url'];
5
6// file_get_contents відкриє і file://, і php://, а за http піде через 20 редиректів.
7$html = file_get_contents($url);
8
9echo substr($html, 0, 500);
1<?php
2
31// У бойовому коді тут gethostbynamel() або dns_get_record(). На сторінці мережі немає, тож відповіді DNS підставні.
4$dns = [
5 'example.com' => ['93.184.215.14'],
6 'localhost' => ['127.0.0.1'],
7 '2130706433' => ['127.0.0.1'],
8 'multi.example' => ['93.184.215.14', '10.0.0.5'],
9];
10
11function check(string $url, array $dns): string
12{
13 $parts = parse_url($url);
14 $scheme = strtolower($parts['scheme'] ?? '');
15 $host = strtolower($parts['host'] ?? '');
16 $port = $parts['port'] ?? ($scheme === 'https' ? 443 : 80);
17
182 if (! in_array($scheme, ['http', 'https'], true) || ! in_array($port, [80, 443], true)) {
19 return 'відхилено: схема чи порт';
20 }
21
223 $ips = filter_var($host, FILTER_VALIDATE_IP) !== false ? [$host] : ($dns[$host] ?? []);
23
24 if ($ips === []) {
25 return 'відхилено: імʼя не резолвиться';
26 }
27
284 foreach ($ips as $ip) {
29 if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_GLOBAL_RANGE) === false) {
30 return "відхилено: {$ip} не публічна адреса";
31 }
32 }
33
345 return "дозволено, CURLOPT_RESOLVE {$host}:{$port}:{$ips[0]}";
35}
36
37$urls = [
38 'https://example.com/hook',
39 'http://169.254.169.254/latest/meta-data/',
40 'http://localhost:6379/',
41 'http://2130706433/',
42 'https://multi.example/',
43 'gopher://example.com/',
44];
45
46foreach ($urls as $url) {
47 printf("%-42s %s\n", $url, check($url, $dns));
48}
Перший запуск завантажить рушій PHP 8.5, приблизно 3,8 МБ Відкрити в пісочниці ↗
ВИВІД
Завантажуємо рушій PHP 8.5
Лише перший раз. Далі код запускається миттєво, рушій лишається в кеші браузера.
$ php main.php ▍
  1. 1 рядки 3–9

    Підставний DNS. 2130706433 це 127.0.0.1 одним числом: так його прочитає і curl. multi.example має дві адреси, і одна з них внутрішня.

  2. 2 рядки 18–20

    Лише http і https на стандартних портах. gopher:// і порт 6379 відпадають тут, ще до DNS.

  3. 3 рядки 22–26

    Резолвимо самі. Адреса без запису в DNS теж відхиляється: краще не пустити, ніж пустити наосліп.

  4. 4 рядки 28–32

    Перевіряємо кожну адресу, бо клієнт може взяти будь-яку. FILTER_FLAG_GLOBAL_RANGE відкидає приватні, loopback, link-local і 100.64.0.0/10.

  5. 5 рядки 34–34

    Запит треба зробити саме на перевірену адресу. CURLOPT_RESOLVE підставляє її curl, і повторного резолву, на якому тримається DNS rebinding, не буде.

  6. Наведіть на пояснення, щоб підсвітити рядки.Торкніться пояснення, щоб підсвітити рядки.
04 · ПАСТКИ ФРЕЙМВОРКУ

Laravel і Symfony не перевіряють, куди йде запит

LARAVEL · HTTP
Http::get($request->input('url'));

Чому це дірка: Клієнт за замовчуванням іде за пʼятьма редиректами, чекає відповіді до 30 секунд і зʼєднання до 10. Для чужих адрес: withoutRedirecting(), timeout(5), connectTimeout(2) і перевірка IP перед запитом.

LARAVEL · ПРАВИЛО URL
'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.

PHP · FILTER_VAR
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 строгіший і відкидає все, що не глобальне.

DNS REBINDING
gethostbyname($host); Http::get($url);

Чому це дірка: Перевірка й запит резолвлять домен окремо, і з TTL 0 відповіді різні. Передайте перевірену адресу в запит: withOptions з curl і CURLOPT_RESOLVE.

PHP · FILE_GET_CONTENTS
file_get_contents($url)

Чому це дірка: Обгортки потоків відкривають file://, php://filter і phar://, а http:// іде за редиректами. Для URL від людини беріть HTTP-клієнт з обмеженими схемами, а не файлову функцію.

SYMFONY · HTTPCLIENT
new NoPrivateNetworkHttpClient(HttpClient::create())

Чому це дірка: У Symfony є готовий декоратор, що блокує приватні мережі, включно з адресами після редиректу. У Laravel такого немає, перевірку пишуть самі або ставлять egress-проксі.

05 · ЗНАЙДИ ПРОБЛЕМУ

Де тут SSRF

Натисніть на рядки, у яких бачите вразливість, і перевірте. Тут три проблеми.

app/Http/Controllers/LinkPreviewController.php натисніть на підозрілі рядки
Тут усе гаразд: рядок не вирішує, куди піде запит.
Тут усе гаразд: рядок не вирішує, куди піде запит.
Формат перевірено. gopher:// чи dict:// правило url пропустить, але Http-клієнт Laravel відкриває лише http і https. Явне url:http,https усе одно краще.
Тут усе гаразд: рядок не вирішує, куди піде запит.
Розбирати URL через parse_url правильно. Біда в тому, що з хостом роблять далі.
Тут усе гаразд: рядок не вирішує, куди піде запит.
Пропущено. Так, тут SSRF. Block-list не працює. Пройдуть 169.254.169.254, 10.0.0.5, 2130706433, [::1] і домен, що резолвиться в 127.0.0.1. Резолвте хост і перевіряйте кожну адресу з FILTER_FLAG_GLOBAL_RANGE.
Тут усе гаразд: рядок не вирішує, куди піде запит.
Тут усе гаразд: рядок не вирішує, куди піде запит.
Тут усе гаразд: рядок не вирішує, куди піде запит.
Пропущено. І тут. Клієнт піде за редиректом з дозволеного сайту на внутрішню адресу, а 60 секунд на запит дають тримати воркери. withoutRedirecting(), короткий timeout і запит на перевірену IP.
Тут усе гаразд: рядок не вирішує, куди піде запит.
Тут усе гаразд: рядок не вирішує, куди піде запит.
Статус теж підказує, чи живий внутрішній порт, але головний витік у рядку нижче.
Пропущено. Повне тіло відповіді йде людині, тож blind SSRF стає повним: внутрішні сторінки й метадані видно в JSON. Повертайте лише те, що потрібно для превʼю, наприклад title.
Тут усе гаразд: рядок не вирішує, куди піде запит.
Тут усе гаразд: рядок не вирішує, куди піде запит.
06 · ЧЕКЛІСТ ДЛЯ PR

Вставте у свій шаблон пул-реквесту

## Захист від SSRF
  • Якщо можна обійтися allow-list хостів, URL від людини не приймається: вона дає лише шлях чи ідентифікатор.
  • Довільний URL дозволено лише зі схемою http або https і портами 80 чи 443.
  • Хост резолвиться в коді, кожна адреса перевірена filter_var з FILTER_FLAG_GLOBAL_RANGE, запит іде саме на неї (CURLOPT_RESOLVE).
  • Редиректи вимкнено (withoutRedirecting) або кожен перевіряється так само, як перша адреса.
  • Таймаути короткі, розмір відповіді обмежено, тіло й текст помилки людині не віддаються.
  • Feature-тест з Http::fake і Http::preventStrayRequests перевіряє, що внутрішні адреси й редирект на них відхилено.
07 · ЯК У PHP UKRAINE

Де цей сайт сам ходить за адресами людей

Сайт робить запити за адресами, які дали люди, у трьох місцях. ConfirmProject шукає код підтвердження на сторінці проєкту, у корені його сайту й у файлі на тому самому хості. CheckProjectLinks за розкладом перевіряє, чи посилання проєктів ще відповідають. VerifyCompany шукає код на сайті компанії. Кожен такий запит це GET з таймаутом 10 секунд, тіло обрізається до 512 К символів і людині не показується: код лише шукають у тексті. Адреса проєкту проходить ProjectLink ще на вводі: тільки http чи https і хост, схожий на доменне імʼя, тож IP-адреса й localhost у поле не пройдуть, а порт і логін з адреси відкидаються: збережений URL завжди https://хост/шлях.

php ProjectLink.php фрагмент
1public static function parse(string $raw): self
2{
3 $raw = trim($raw);
4
5 if ($raw === '') {
6 throw new InvalidArgumentException('Додайте посилання.');
7 }
8
9 if (preg_match('#^[a-z][a-z0-9+.-]*://#i', $raw) !== 1) {
10 $raw = 'https://'.ltrim($raw, '/');
11 }
12
13 $parts = parse_url($raw);
14 $scheme = strtolower((string) ($parts['scheme'] ?? ''));
15 $host = strtolower((string) ($parts['host'] ?? ''));
16
17 if (! in_array($scheme, ['http', 'https'], true) || preg_match('/^(?:[a-z0-9-]+\.)+[a-z]{2,}$/', $host) !== 1 || mb_strlen($raw) > 300) {← лише http(s) і доменне імʼя
18 throw new InvalidArgumentException('Посилання не схоже на адресу сайту чи репозиторію.');
19 }
20
21 $path = rtrim((string) ($parts['path'] ?? ''), '/');
22 $query = isset($parts['query']) ? '?'.$parts['query'] : '';
23
24 return new self('https://'.$host.$path.$query);← порт і логін відкинуто
25}
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-тест не ходить у мережу.
08 · СЛОВНИК
Метадані хмари
Внутрішній 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 завжди щось пропускає.
09 · ПЕРЕВІР СЕБЕ

Питання зі співбесід

Усі питання з безпеки →

Сервер робить 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, щоб клієнт міг перевірити, що вебхук від вас.