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

Завантаження файлівFile uploads

Форма чекає аватар, а отримує avatar.php або HTML-сторінку з картинкою на початку. Сервер кладе файл у публічну теку під імʼям, яке вибрав клієнт, і віддає його так, як скаже розширення.

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

Файл від людини це теж введення

Усе, що описує завантажений файл, у $_FILES пише браузер, тобто будь-хто з curl. Поле name це імʼя на компʼютері клієнта, type це MIME, який він заявив. У Laravel те саме читають getClientOriginalName(), getClientOriginalExtension() і getClientMimeType(): слово Client у назві методу означає, що перевіряти треба вам. Вірити можна лише байтам, які лежать у tmp_name.

Найгірший сценарій виникає, коли файл потрапляє у під своїм розширенням. Вебсервер віддасть avatar.php інтерпретатору PHP, і запит на /uploads/avatar.php виконає код атакувальника на вашому сервері. Laravel за правилом mimes чи extensions відхиляє файл з розширенням php, phtml чи phar, але інші розширення теж небезпечні: avatar.html, віддана як text/html з вашого домену, дає збережену XSS. Тому файли кладуть поза public/ і дають їм нове випадкове імʼя, а розширення ставить сервер за вмістом.

СЛОВНИКВеб-корінь. Тека, яку вебсервер віддає напряму за адресою, у Laravel це public/. Файл у ній доступний без контролера, а файл з розширенням .php вебсервер передасть PHP на виконання.

Перевірка вмісту теж має межу. Правило mimes бере MIME з перших байтів файлу через finfo, тож файл, що починається як PNG, буде PNG, навіть якщо далі в ньому HTML чи PHP. Такий пройде і mimes, і dimensions. Надійно прибирає зайве лише перекодування: прочитати пікселі через GD чи Imagick і записати новий файл. Заодно зникають з координатами зйомки й дивні chunk-и. GD метаданих не переносить зовсім, Imagick переносить, поки не викличете stripImage().

СЛОВНИКПоліглот. Файл, який одночасно є коректним файлом двох форматів: наприклад, справжній PNG з HTML чи PHP у текстовому chunk-у. Перевірку за сигнатурою він проходить, бо сигнатура справжня.
СЛОВНИКEXIF. Метадані фото: модель камери, дата, орієнтація, часто GPS-координати місця зйомки. Опублікований аватар з EXIF може показати, де людина живе.

Імʼя файлу небезпечне й тоді, коли воно стає шляхом. PHP і Symfony відрізають від імені в $_FILES усе до останньої косої, тож ../ з форми до вас не дійде. Але імʼя з параметра адреси чи з бази, склеєне зі storage_path(), дає : ?file=../../../../.env. Диски Storage кидають PathTraversalDetected, коли шлях виходить за корінь диска, а storage_path() і response()->file() нічого не перевіряють.

СЛОВНИКPath traversal. Вихід за межі дозволеної теки через ../ у шляху. Код хотів віддати storage/app/docs/report.pdf, а віддає .env з кореня проєкту.

Розмір обмежують у двох місцях. php.ini задає upload_max_filesize (за замовчуванням 2M) і post_max_size (8M): більший файл приходить з помилкою й не проходить правило file, а тіло понад post_max_size PHP відкидає, і Laravel відповідає 413. Правило max у Laravel рахує кілобайти: max:2048 це 2 МБ. Воно не піднімає ліміт PHP, тому значення в php.ini мають бути не меншими. І останнє: файл, який хтось може відкрити, віддають з правильним Content-Type, а документи від людей ще й з : attachment, щоб браузер зберіг їх, а не показав як сторінку.

СЛОВНИКContent-Disposition. Заголовок відповіді: inline просить браузер показати файл, attachment зберегти його з указаним filename. HTML чи SVG з attachment не виконаються на вашому домені.
02 · ЯК ДУМАЄ АТАКУВАЛЬНИК

Три кроки від аватара до виконання коду

ДІАГРАМА · УСЯ СХЕМА
Кроки: 3
type image/pngmove()імʼя клієнтаGETвиконуєАтакувальник:avatar.phpКонтролерpublic/uploads{ }Вебсервер
Атакувальник:avatar.phpКонтролерpublic/uploads{ }Вебсервер
Уся схема. Натисніть → або виберіть крок нижче, щоб пройти по ній частинами.
Клієнт Ключовий учасник Інфраструктура
  • Атакувальник створює avatar.php.
  • avatar.php використовує Контролер (move()).
  • Дані йдуть від Контролер до public/uploads.
  • Атакувальник надсилає подію GET, її отримує Вебсервер.
  • Вебсервер використовує public/uploads (виконує).
03 · ВРАЗЛИВИЙ І ЗАХИЩЕНИЙ КОД

Тип за байтами, імʼя від сервера, тека поза public

PHP 8.5
1<?php
2
3$upload = $_FILES['avatar'];
4
5// Було: тип перевіряємо за словом клієнта.
6if ($upload['type'] !== 'image/png') {
7 exit('Лише PNG');
8}
9
10// І кладемо файл під його ж іменем у публічну теку.
11move_uploaded_file($upload['tmp_name'], __DIR__.'/uploads/'.$upload['name']);
1<?php
2
31// Так виглядає запит від атакувальника: name і type він пише сам.
4$upload = [
5 'name' => 'avatar.php',
6 'type' => 'image/png',
7 'bytes' => "\x89PNG\r\n\x1a\n".'<?php /* код */ ?>',
8];
9
102const MAX_BYTES = 2 * 1024 * 1024;
11const SIGNATURES = ['png' => "\x89PNG\r\n\x1a\n", 'jpg' => "\xFF\xD8\xFF"];
12
13function sniff(string $bytes): ?string
14{
15 foreach (SIGNATURES as $extension => $magic) {
16 if (str_starts_with($bytes, $magic)) {
17 return $extension;
18 }
19 }
20
21 return null;
22}
23
24$extension = sniff($upload['bytes']);
25
263if ($extension === null || strlen($upload['bytes']) > MAX_BYTES) {
27 exit("Відхилено\n");
28}
29
304$name = bin2hex(random_bytes(16)).'.'.$extension;
31
32echo 'Клієнт назвав файл ', $upload['name'], ' і заявив ', $upload['type'], "\n";
33echo 'Сервер збереже його як storage/app/private/avatars/', $name, "\n";
345echo 'Сигнатура PNG справжня, але в байтах є <?php: ';
35echo str_contains($upload['bytes'], '<?php') ? "так\n" : "ні\n";
36echo "Тому зображення ще й перекодовують: новий файл збирається лише з пікселів.\n";
Перший запуск завантажить рушій PHP 8.5, приблизно 3,8 МБ Відкрити в пісочниці ↗
ВИВІД
Завантажуємо рушій PHP 8.5
Лише перший раз. Далі код запускається миттєво, рушій лишається в кеші браузера.
$ php main.php ▍
  1. 1 рядки 3–8

    name і type з $_FILES приходять від клієнта. Тут вони брешуть обидва: файл зветься avatar.php, а тип заявлено image/png.

  2. 2 рядки 10–22

    Тип визначаємо за першими байтами. У Laravel це робить правило mimes через finfo, тут вручну, щоб було видно, що саме перевіряється.

  3. 3 рядки 26–28

    Без відомої сигнатури або понад ліміт файл не приймаємо. Розмір перевіряє ще й php.ini через upload_max_filesize.

  4. 4 рядки 30–30

    Нове випадкове імʼя, а розширення з вмісту. Імʼя клієнта не потрапляє ні в шлях, ні в URL. У Laravel так працюють store() і hashName().

  5. 5 рядки 34–36

    Поліглот проходить перевірку сигнатури, бо вона справжня. Тому зберігаємо поза public/ і перекодовуємо зображення, а не довіряємо перевірці.

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

Laravel і Symfony перевіряють файл, крім цих місць

LARAVEL · EXTENSIONS
'avatar' => ['required', 'extensions:jpg,png']

Чому це дірка: Правило extensions дивиться лише на розширення в імені від клієнта. Ставте поруч mimes, яке читає вміст, або беріть File::types([...])->extensions([...]).

LARAVEL · ІМʼЯ КЛІЄНТА
$file->storeAs('avatars', $file->getClientOriginalName());

Чому це дірка: Розширення вибирає клієнт, а однакові імена перезаписують чужі файли. store() і hashName() дають випадкові 40 символів і розширення за вмістом. Оригінальне імʼя зберігайте в базі, якщо його треба показати.

LARAVEL · ПУБЛІЧНИЙ ДИСК
$request->file('passport')->store('docs', 'public');

Чому це дірка: Диск public лежить у storage/app/public, а storage:link робить його частиною public/. Будь-хто з адресою відкриє файл без входу. Документи кладіть на local (storage/app/private) і віддавайте через маршрут з перевіркою доступу.

LARAVEL · SVG
'logo' => ['required', 'image:allow_svg']

Чому це дірка: image за замовчуванням не приймає SVG, а allow_svg вмикає. SVG це XML, у ньому може бути <script> чи onload, і відкритий напряму з вашого домену він виконається. Віддавайте такі файли з attachment, очищайте або переводьте в PNG.

LARAVEL · ШЛЯХ З ЗАПИТУ
response()->download(storage_path('app/'.$request->query('file')));

Чому це дірка: storage_path() склеює рядки, а ../ у параметрі виведе за межі теки. Storage::disk()->download() кидає PathTraversalDetected, але й він віддасть будь-який файл усередині диска. Шукайте файл за id у базі й перевіряйте власника через policy.

PHP.INI
upload_max_filesize = 2M

Чому це дірка: Правило max:10240 не підніме ліміт PHP. Файл понад upload_max_filesize приходить з помилкою, і замість вашого повідомлення про розмір людина бачить загальну помилку правила uploaded. post_max_size має бути більшим за upload_max_filesize, бо в тілі ще й інші поля.

LARAVEL · INLINE
return Storage::disk('local')->response($path);

Чому це дірка: response() віддає файл з disposition inline і типом, який визначив Flysystem. HTML чи SVG від людини браузер покаже як сторінку вашого сайту. Для документів беріть download(), він ставить attachment.

SYMFONY · UPLOADEDFILE
$file->getClientOriginalExtension()

Чому це дірка: У Symfony та сама пастка: getClientOriginalExtension() і getClientMimeType() від клієнта, guessExtension() і getMimeType() за вмістом. Для валідації є Assert\File з mimeTypes і extensions.

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

Де тут дірки в завантаженні

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

app/Http/Controllers/AvatarController.php натисніть на підозрілі рядки
Тут усе гаразд: рядок сам по собі нічого не довіряє клієнту.
Тут усе гаразд: рядок сам по собі нічого не довіряє клієнту.
Розмір обмежено правильно: для файлу max рахується в кілобайтах, 2048 це 2 МБ.
Тут усе гаразд: рядок сам по собі нічого не довіряє клієнту.
Тут усе гаразд: рядок сам по собі нічого не довіряє клієнту.
Пропущено. Так, тут дірка. getClientMimeType() повертає те, що написав клієнт. Тип треба перевіряти правилом mimes або image у validate, воно читає вміст.
Пропущено. Теж проблема. Імʼя й розширення вибирає клієнт: avatar.html чи чужий avatar.png, який перезапишеться. Генеруйте імʼя через store() або hashName().
Пропущено. І тут. public_path() це веб-корінь: файл стане доступним за адресою, і вебсервер віддасть його за розширенням. Кладіть на диск поза public/.
Тут усе гаразд: рядок сам по собі нічого не довіряє клієнту.
Тут усе гаразд: рядок сам по собі нічого не довіряє клієнту.
Тут усе гаразд: рядок сам по собі нічого не довіряє клієнту.
Тут усе гаразд: рядок сам по собі нічого не довіряє клієнту.
Тут усе гаразд: рядок сам по собі нічого не довіряє клієнту.
Тут усе гаразд: рядок сам по собі нічого не довіряє клієнту.
Тут усе гаразд: рядок сам по собі нічого не довіряє клієнту.
Тут усе гаразд: рядок сам по собі нічого не довіряє клієнту.
Пропущено. Path traversal. ?file=../../../../.env виведе з теки docs до кореня проєкту, і response()->file() віддасть .env. Шукайте документ за id у базі, перевіряйте власника й віддавайте через Storage.
Тут усе гаразд: рядок сам по собі нічого не довіряє клієнту.
06 · ЧЕКЛІСТ ДЛЯ PR

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

## Безпечне завантаження файлів
  • Тип файлу перевіряється за вмістом (mimes, mimetypes, image), а не за getClientMimeType() чи розширенням з імені.
  • Імʼя на диску генерує сервер (store, hashName), розширення береться з вмісту, оригінальне імʼя лише в базі.
  • Файли лежать поза public/, а приватні документи віддаються через маршрут з перевіркою доступу й Content-Disposition: attachment.
  • Шлях до файлу не склеюється з введенням: файл шукається за id у базі, читання йде через Storage.
  • Ліміти узгоджені: max у правилі не більший за upload_max_filesize, а post_max_size більший за обидва.
  • Зображення від людей перекодовуються або хоча б зменшуються на сервері, SVG не приймається без очищення.
  • Feature-тест з UploadedFile::fake() перевіряє, що файл з чужим розширенням і чужим типом відхилено.
07 · ЯК У PHP UKRAINE

Як цей сайт приймає фото профілю

Фото профілю спершу обробляє браузер: resources/js/profile/photo.js читає картинку через createImageBitmap, обрізає до квадрата не більше 512 пікселів і перекодовує canvas у JPEG. Файл важить менше, а EXIF з місцем зйомки на сервер не їде. Сервер на цей скрипт не покладається, бо форму можна відправити й без нього, і перевіряє файл сам: mimes дивиться на вміст, max обмежує 2 МБ, dimensions відсікає крихітні й гігантські картинки.

php ProfileController.php фрагмент
1public function uploadPhoto(Request $request): RedirectResponse
2{
3 $user = $this->developer($request);
4 $profile = $this->own($user);
5 $back = route('profile.edit').'#photo';
6 $request->validate(
7 ['photo' => ['required', 'file', 'mimes:png,jpg,jpeg,webp', 'max:2048', 'dimensions:min_width=96,min_height=96,max_width=6000,max_height=6000']],← тип за вмістом, розмір, розміри
8 ['photo.mimes' => 'Потрібен PNG, JPG або WebP.', 'photo.max' => 'Файл понад 2 МБ.', 'photo.dimensions' => 'Від 96 пікселів по меншій стороні.'],← повідомлення українською
9 ['photo' => 'фото'],
10 );
resources/js/profile/photo.jsОбрізання й перекодування в браузері: canvas.toBlob у JPEG з якістю 0.88. Картинку, яку браузер не прочитав, скрипт відправляє як є, і відповідь дає сервер.
app/Http/Controllers/ProfileController.phpРезюме приймається з mimes:pdf, лягає на приватний диск під випадковим імʼям з розширенням .pdf, яке ставить сервер. serveFile віддає його через Storage::download, тобто з attachment, а resumeOf спершу перевіряє, хто питає.
app/Http/Middleware/SecurityHeaders.phpВідповіді застосунку несуть X-Content-Type-Options: nosniff, тож браузер не вгадує тип файлу всупереч Content-Type.
08 · СЛОВНИК
Веб-корінь
Тека, яку вебсервер віддає напряму за адресою, у Laravel це public/. Файл у ній доступний без контролера, а файл з розширенням .php вебсервер передасть PHP на виконання.
Поліглот
Файл, який одночасно є коректним файлом двох форматів: наприклад, справжній PNG з HTML чи PHP у текстовому chunk-у. Перевірку за сигнатурою він проходить, бо сигнатура справжня.
EXIF
Метадані фото: модель камери, дата, орієнтація, часто GPS-координати місця зйомки. Опублікований аватар з EXIF може показати, де людина живе.
Path traversal
Вихід за межі дозволеної теки через ../ у шляху. Код хотів віддати storage/app/docs/report.pdf, а віддає .env з кореня проєкту.
Content-Disposition
Заголовок відповіді: inline просить браузер показати файл, attachment зберегти його з указаним filename. HTML чи SVG з attachment не виконаються на вашому домені.
09 · ПЕРЕВІР СЕБЕ

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

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

Це значення з поля type у multipart-запиті, його пише клієнт. curl чи DevTools підставлять image/png до будь-якого файлу. Тип визначають за вмістом: у Laravel правила mimes, mimetypes чи image, вони беруть MIME через finfo.

mimes дивиться на байти, а розширення лишається клієнтське. PNG-поліглот з іменем avatar.html пройде перевірку, ляже в публічну теку, і вебсервер віддасть його як text/html з вашого домену: збережена XSS. Розширення php Laravel блокує, але html, svg, xhtml ні. Імʼя має генерувати сервер, з розширенням за вмістом, як у store().

PHP обрізає раніше за Laravel: upload_max_filesize за замовчуванням 2M, і більший файл приходить з помилкою UPLOAD_ERR_INI_SIZE, тож правило file падає. Тіло понад post_max_size PHP відкидає цілком, і Laravel відповідає 413. Ліміти в php.ini мають бути не меншими за правило, а post_max_size з запасом на інші поля.

Найнадійніше з окремого домену без cookies, як роблять великі сервіси з usercontent-доменами. На тому самому домені: зберігати поза веб-коренем, віддавати через контролер з перевіркою доступу, Content-Type з вмісту, X-Content-Type-Options: nosniff і Content-Disposition: attachment для всього, крім перекодованих зображень. Для зображень, які показуються inline, перекодування на сервері, щоб у файлі лишились тільки пікселі. Плюс CSP sandbox на відповіді з файлами.