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

SQL-інʼєкціїSQL injection

Застосунок складає SQL-запит з рядків, і введення людини стає частиною цього запиту. Лапка в логіні закриває рядок, а все після неї база читає як команду.

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

Коли дані стають частиною запиту

SQL-інʼєкція трапляється, коли код складає текст запиту з рядків і вклеює в нього введення. База отримує один рядок і не знає, де закінчився ваш код і почалися дані. Логін з лапкою закриває рядковий літерал, далі OR '1'='1' робить умову правдивою для кожного рядка, а -- перетворює перевірку пароля на коментар. Залежно від запиту й прав атакувальник читає чужі таблиці через UNION, змінює дані або обходить вхід. Покроково це показує лабораторія на головній сторінці розділу «Безпека».

Захист дає . Текст з плейсхолдерами ? чи :name іде до бази окремо, значення окремо, як . База розбирає запит раніше, ніж бачить дані, тож лапка у значенні лишається просто символом. У PDO це prepare і execute, у Laravel DB::select('select * from users where login = ?', [$login]), а Eloquent і query builder самі привʼязують кожне значення в where, whereIn, insert і update. Ручне екранування через addslashes чи mysqli_real_escape_string залежить від кодування й лапок навколо, а число без лапок воно взагалі не прикриває.

СЛОВНИКПідготовлений запит. Prepared statement: база спершу отримує текст запиту з плейсхолдерами й розбирає його, а потім окремо значення. Значення вже не може змінити структуру запиту.
СЛОВНИКBindings. Значення, привʼязані до плейсхолдерів запиту. У Laravel це масив другим аргументом DB::select, whereRaw чи orderByRaw. Query builder збирає їх сам для кожного where.

pdo_mysql за замовчуванням використовує : PDO сам екранує значення й підставляє їх у текст, а до сервера йде готовий запит. Це безпечно, поки кодування задано в DSN, як charset=utf8mb4. Ламається, коли кодування міняють запитом SET NAMES повз PDO, особливо на багатобайтових кодуваннях на кшталт GBK. Laravel у своєму Connector ставить PDO::ATTR_EMULATE_PREPARES => false, тож запити йдуть справжніми prepares.

СЛОВНИКЕмульовані prepares. Режим PDO, де драйвер не готує запит на сервері, а сам екранує значення й підставляє в текст. За замовчуванням увімкнений у pdo_mysql, вимикається PDO::ATTR_EMULATE_PREPARES => false.

Плейсхолдер буває лише на місці значення. Назву таблиці чи колонки, напрямок ORDER BY і ключові слова через ? не передати. IN потребує стільки плейсхолдерів, скільки значень. У LIKE значення привʼязане, але % і _ з нього лишаються шаблонними знаками. Для всього цього потрібен : колонка з мапи, напрямок лише asc чи desc, id приведені до int, шаблонні знаки екрановані.

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

Eloquent захищає, поки ви не просите сирий SQL. DB::raw, whereRaw, orderByRaw, selectRaw, havingRaw і DB::statement вставляють рядок як є, DB::unprepared ще й не приймає bindings. У сирих методів є другий аргумент для bindings, ним і користуйтеся. Документація Laravel окремо попереджає: не давайте введенню обирати назви колонок, зокрема в orderBy.

Найважче помітити . Логін admin'-- записали в базу правильно, через bindings. Через місяць звіт чи cron-команда читає його з бази й вклеює в інший запит, бо «це ж наші дані». Дані з власної бази теж введення, якщо колись їх написала людина.

СЛОВНИКSecond-order інʼєкція. Інʼєкція другого порядку: шкідливе значення спершу безпечно зберігається, а спрацьовує пізніше, коли інший код читає його з бази й вклеює в запит без bindings.
02 · ЯК ДУМАЄ АТАКУВАЛЬНИК

Три кроки від поля логіну до входу без пароля

ДІАГРАМА · УСЯ СХЕМА
Кроки: 3
лапка в логінісклеєно в SQLзмінений запитперший рядокАтакувальник:ЛогінКод входуБаза:Сесія admin
Атакувальник:ЛогінКод входуБаза:Сесія admin
Уся схема. Натисніть → або виберіть крок нижче, щоб пройти по ній частинами.
Клієнт Ключовий учасник Інфраструктура
  • Атакувальник створює Логін.
  • Логін використовує Код входу (склеєно в SQL).
  • Дані йдуть від Код входу до База.
  • База надсилає подію перший рядок, її отримує Сесія admin.
03 · ВРАЗЛИВИЙ І ЗАХИЩЕНИЙ КОД

Значення окремо, решта зі списку

PHP 8.5
1<?php
2
3$login = "' OR '1'='1' -- ";
4
5// Було: логін вклеєно в текст запиту.
6echo "SELECT * FROM users WHERE login = '".$login."'", "\n";
7
8// id зі списку склеєно через кому.
9$ids = ['3', '7', '7 OR 1=1'];
10echo 'SELECT * FROM jobs WHERE id IN ('.implode(', ', $ids).')', "\n";
11
12// Колонку сортування взято з адреси як є.
13$sort = 'name; DROP TABLE users';
14echo 'SELECT * FROM jobs ORDER BY '.$sort, "\n";
15
16// % від користувача знаходить усіх.
17$q = '%';
18echo "SELECT * FROM users WHERE name LIKE '%".$q."%'", "\n";
1<?php
2
31// Підготовлений запит: текст і значення йдуть до бази окремо.
4function prepared(string $sql, array $params): string
5{
6 return $sql."\n параметри: ".json_encode($params, JSON_UNESCAPED_UNICODE);
7}
8
92// Назву колонки параметром не передати, тому лише зі списку.
10function orderBy(string $sort, string $dir): string
11{
12 $columns = ['date' => 'published_at', 'salary' => 'salary_to'];
13 $column = $columns[$sort] ?? 'published_at';
14 $direction = strtolower($dir) === 'asc' ? 'ASC' : 'DESC';
15
16 return "ORDER BY {$column} {$direction}";
17}
18
193// % і _ у LIKE шукають самих себе, а не будь-що.
20function likeContains(string $text): string
21{
22 return '%'.strtr($text, ['!' => '!!', '%' => '!%', '_' => '!_']).'%';
23}
24
254$login = "' OR '1'='1' -- ";
26echo prepared('SELECT * FROM users WHERE login = ?', [$login]), "\n";
27
285$ids = array_values(array_unique(array_map('intval', ['3', '7', '7 OR 1=1'])));
29$marks = implode(', ', array_fill(0, count($ids), '?'));
30echo prepared("SELECT * FROM jobs WHERE id IN ({$marks})", $ids), "\n";
31
32echo orderBy('salary', 'asc'), "\n";
33echo orderBy('name; DROP TABLE users', 'desc'), "\n";
34
35echo prepared("SELECT * FROM users WHERE name LIKE ? ESCAPE '!'", [likeContains('100%')]), "\n";
Перший запуск завантажить рушій PHP 8.5, приблизно 3,8 МБ Відкрити в пісочниці ↗
ВИВІД
Завантажуємо рушій PHP 8.5
Лише перший раз. Далі код запускається миттєво, рушій лишається в кеші браузера.
$ php main.php ▍
  1. 1 рядки 3–7

    Так запит бачить база: текст з ? і окремо масив значень. У PDO це prepare і execute, у Laravel DB::select($sql, $bindings) або будь-який where.

  2. 2 рядки 9–17

    Колонка береться з мапи дозволених, напрямок лише ASC або DESC. Невідоме значення не потрапляє в запит, його замінює типове.

  3. 3 рядки 19–23

    Шаблонні знаки екрановано власним символом !, а в запиті стоїть ESCAPE '!'. Так однаково працює в MySQL, PostgreSQL і SQLite.

  4. 4 рядки 25–26

    Той самий пейлоад, що зламав вхід у «Було», тепер лише значення параметра. База шукає користувача з дивним логіном і не знаходить.

  5. 5 рядки 28–30

    intval прибирає хвіст «OR 1=1», дублікати зникають, і плейсхолдерів рівно стільки, скільки id. Так само робить whereIn у Laravel.

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

Eloquent привʼязує значення сам, крім цих місць

LARAVEL · WHERERAW
->whereRaw("email = '$email'")

Чому це дірка: whereRaw вставляє рядок у запит як є. Значення передавайте другим аргументом: whereRaw('email = ?', [$email]). Так само selectRaw, havingRaw і orderByRaw.

LARAVEL · DB::RAW
->addSelect(DB::raw($request->input('field')))

Чому це дірка: DB::raw створює Expression, який Laravel не бере в лапки й не привʼязує. Усе з запиту всередині стає SQL. Колонки для select беріть зі списку дозволених.

LARAVEL · ORDERBY
->orderBy($request->input('sort'), $request->input('dir'))

Чому це дірка: Напрямок Laravel 13 перевіряє сам: усе, крім asc і desc, кидає InvalidArgumentException. Колонку ні, і документація прямо радить не давати введенню її обирати. Беріть enum з tryFrom або мапу на кшталт ['date' => 'published_at'].

LARAVEL · LIKE
->whereLike('name', '%'.$q.'%')

Чому це дірка: Значення привʼязане, тож інʼєкції тут немає. Але whereLike не екранує % і _, і запит «%» поверне всю таблицю. Якщо пошук не має підтримувати шаблони, екрануйте їх самі.

PDO · IN
$pdo->prepare('select * from jobs where id in (?)')

Чому це дірка: Один ? не розгортається в список: execute([[3, 7]]) не спрацює, а склеювання через implode повертає інʼєкцію. Складіть плейсхолдери з array_fill за кількістю значень, у Laravel просто whereIn.

LARAVEL · UNPREPARED
DB::unprepared("update stats set hits = hits + 1 where page = '$page'")

Чому це дірка: unprepared не приймає bindings і передає рядок у PDO::exec як є. Він для міграцій і дампів. Для запитів з даними є DB::statement($sql, $bindings).

PDO · ЕМУЛЯЦІЯ
$pdo->exec("SET NAMES 'gbk'");

Чому це дірка: З емульованими prepares PDO екранує значення за кодуванням, яке знає з DSN. SET NAMES міняє кодування повз нього, і на GBK екранування можна обійти. Кодування задавайте в DSN: charset=utf8mb4.

PDO · LIMIT
$stmt->execute([$perPage]);

Чому це дірка: execute з масивом привʼязує все як рядки. З емуляцією MySQL отримає LIMIT '20' і помилку синтаксису, після чого ліміт часто вклеюють у текст. Замість цього bindValue(1, $perPage, PDO::PARAM_INT).

SYMFONY · DOCTRINE DQL
$em->createQuery("SELECT u FROM App\Entity\User u WHERE u.login = '$login'")

Чому це дірка: DQL теж розбирається з рядка, і склеювання в ньому дає таку саму інʼєкцію. Параметри: u.login = :login і setParameter('login', $login).

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

Де тут SQL-інʼєкція

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

app/Http/Controllers/JobController.php натисніть на підозрілі рядки
Тут усе гаразд: у цьому рядку немає введення, яке стало б частиною SQL.
Тут усе гаразд: у цьому рядку немає введення, яке стало б частиною SQL.
Інʼєкції немає: значення йде в bindings. Але % і _ з запиту працюють як шаблон, «%» знайде все. Екрануйте їх, якщо це важливо.
Пропущено. Так, тут інʼєкція. Місто вклеєно в рядок whereRaw між лапками, і лапка у значенні їх закриє. Потрібно whereRaw('city = ?', [$city]) або просто where('city', $city).
Тут усе гаразд: whereIn ставить окремий ? для кожного значення.
Пропущено. Теж вразливо. DB::raw кладе в select рядок з адреси як є: ?field= може містити підзапит до будь-якої таблиці. Колонки беріть зі списку дозволених.
Пропущено. І тут. Колонку сортування обирає адреса, а назву колонки не привʼязати параметром. Документація Laravel прямо радить так не робити. Enum з tryFrom або мапа дозволених.
Тут усе гаразд: integer() приводить значення до int, і limit отримує число.
Тут усе гаразд: у цьому рядку немає введення, яке стало б частиною SQL.
Пропущено. Класична інʼєкція: company_id без лапок, тож навіть екранування лапок не допомогло б, досить ?company=1 OR 1=1. Передайте значення через ? і масив bindings.
Тут усе гаразд: сирий фрагмент, але значення йде через ? і масив bindings.
06 · ЧЕКЛІСТ ДЛЯ PR

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

## Захист від SQL-інʼєкцій
  • Кожне значення з запиту, форми, cookie чи власної бази йде в SQL через bindings: Eloquent, query builder або ? у DB::select і *Raw-методах.
  • Колонки й напрямок сортування беруться з enum або мапи дозволених, а не прямо з $request.
  • Списки для IN ідуть через whereIn або плейсхолдери за кількістю значень, id приведено до int.
  • У LIKE шаблонні знаки % і _ з введення екрановано, якщо пошук не має їх підтримувати.
  • DB::raw і DB::unprepared не отримують жодного рядка ззовні.
  • Для чистого PDO: ERRMODE_EXCEPTION, кодування в DSN, а не через SET NAMES, текст помилки бази не показується користувачу.
  • Feature-тест передає лапку в кожен фільтр і перевіряє, що відповідь 200 або 422, а не 500.
07 · ЯК У PHP UKRAINE

Як цей сайт шукає вакансії

Пошук по сайту шукає кожне слово запиту в назві, описі вакансії й назві компанії. Сирий фрагмент тут потрібен заради LOWER(...), але склеюється в нього лише назва колонки, а колонки задає сам код кількома рядками вище. Слово з пошуку йде другим аргументом orWhereRaw, тобто в bindings, тож лапка в запиті залишиться лапкою.

php EloquentSearchEngine.php фрагмент, скорочено
1foreach ($terms as $term) {
2 $like = '%'.$term.'%';
3
4 $builder->where(function ($w) use ($columns, $like): void {
5 foreach ($columns as $column) {← колонки задає код
6 // Column names are code constants above, never user input; the term is a bound parameter.
7 $w->orWhereRaw('LOWER('.$column.') LIKE ?', [$like]);← слово в bindings
8 }
9 });
10}
src/Hiring/Application/Catalog/JobFilter.phpКожен фільтр каталогу з адреси проходить через tryFrom свого enum, невідоме значення просто відкидається. Сортування так само: JobSort::tryFrom або типове Fresh.
src/Hiring/Infrastructure/Catalog/EloquentJobCatalog.phporderByRaw для сортування за зарплатою містить сталий рядок, який обирає match по JobSort. З адреси в нього не потрапляє жоден символ.
src/Hiring/Infrastructure/Catalog/EloquentCompanyDirectory.phpПошук компанії за назвою спершу нормалізує запит: PlainText::normalizeKey лишає тільки літери, цифри й пробіли, тож % і _ до LIKE не доходять.
08 · СЛОВНИК
Підготовлений запит
Prepared statement: база спершу отримує текст запиту з плейсхолдерами й розбирає його, а потім окремо значення. Значення вже не може змінити структуру запиту.
Bindings
Значення, привʼязані до плейсхолдерів запиту. У Laravel це масив другим аргументом DB::select, whereRaw чи orderByRaw. Query builder збирає їх сам для кожного where.
Емульовані prepares
Режим PDO, де драйвер не готує запит на сервері, а сам екранує значення й підставляє в текст. За замовчуванням увімкнений у pdo_mysql, вимикається PDO::ATTR_EMULATE_PREPARES => false.
Список дозволених
Allow-list: заздалегідь відомі значення, з яких обирає введення. Усе, чого немає в списку, відкидається або замінюється типовим. Для частин запиту, які не стають параметрами, це єдиний надійний захист.
Second-order інʼєкція
Інʼєкція другого порядку: шкідливе значення спершу безпечно зберігається, а спрацьовує пізніше, коли інший код читає його з бази й вклеює в запит без bindings.
09 · ПЕРЕВІР СЕБЕ

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

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

Підготовлений запит передає текст і значення окремо: база розбирає структуру запиту ще до того, як побачить дані, тож значення не може її змінити. addslashes лише ставить зворотну скісну риску перед лапками. Він не знає кодування зʼєднання, не допомагає, якщо значення стоїть без лапок (where id = 1 OR 1=1), і на деяких багатобайтових кодуваннях обходиться.

Плейсхолдер означає значення, а не ідентифікатор. ORDER BY ? з рядком 'name' сортує за однаковим для всіх рядків значенням, тобто ніяк. Тому колонку обирають зі списку дозволених: enum з tryFrom, мапа «ключ з адреси → колонка» або in_array зі строгим порівнянням. Напрямок так само зводять до asc чи desc. Laravel 13 перевіряє напрямок у orderBy сам, але колонку ні.

У режимі емуляції PDO не готує запит на сервері, а сам екранує значення й підставляє їх у текст. pdo_mysql робить так за замовчуванням. Це безпечно, коли кодування задано в DSN (charset=utf8mb4): PDO знає його й екранує правильно. Ризик зʼявляється, якщо кодування змінили через SET NAMES, особливо на GBK. Побічні ефекти: усі значення з execute([...]) стають рядками, тож LIMIT '20' ламає MySQL. Laravel вимикає емуляцію через PDO::ATTR_EMULATE_PREPARES => false.

Значення спершу записують безпечно, через bindings, а інʼєкція спрацьовує пізніше, коли інший код читає його з бази й вклеює в запит: звіт, cron-команда, динамічна назва таблиці з налаштувань, збережений фільтр. На рев'ю шукаю кожне склеювання в SQL (крапка чи {$...} у *Raw, DB::statement, DB::unprepared, DQL) незалежно від того, звідки змінна: з запиту, з бази чи з конфігурації, яку редагує адмін. Правило одне: значення через bindings, ідентифікатори зі списку дозволених.