Коли дані стають частиною запиту
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 залежить від кодування й лапок навколо, а число без лапок воно взагалі не прикриває.
pdo_mysql за замовчуванням використовує : PDO сам екранує значення й підставляє їх у текст, а до сервера йде готовий запит. Це безпечно, поки кодування задано в DSN, як charset=utf8mb4. Ламається, коли кодування міняють запитом SET NAMES повз PDO, особливо на багатобайтових кодуваннях на кшталт GBK. Laravel у своєму Connector ставить PDO::ATTR_EMULATE_PREPARES => false, тож запити йдуть справжніми prepares.
Плейсхолдер буває лише на місці значення. Назву таблиці чи колонки, напрямок ORDER BY і ключові слова через ? не передати. IN потребує стільки плейсхолдерів, скільки значень. У LIKE значення привʼязане, але % і _ з нього лишаються шаблонними знаками. Для всього цього потрібен : колонка з мапи, напрямок лише asc чи desc, id приведені до int, шаблонні знаки екрановані.
Eloquent захищає, поки ви не просите сирий SQL. DB::raw, whereRaw, orderByRaw, selectRaw, havingRaw і DB::statement вставляють рядок як є, DB::unprepared ще й не приймає bindings. У сирих методів є другий аргумент для bindings, ним і користуйтеся. Документація Laravel окремо попереджає: не давайте введенню обирати назви колонок, зокрема в orderBy.
Найважче помітити . Логін admin'-- записали в базу правильно, через bindings. Через місяць звіт чи cron-команда читає його з бази й вклеює в інший запит, бо «це ж наші дані». Дані з власної бази теж введення, якщо колись їх написала людина.
Три кроки від поля логіну до входу без пароля
- Атакувальник створює Логін.
- Логін використовує Код входу (склеєно в SQL).
- Дані йдуть від Код входу до База.
- База надсилає подію перший рядок, її отримує Сесія admin.
Значення окремо, решта зі списку
-
1
рядки 3–7
Так запит бачить база: текст з ? і окремо масив значень. У PDO це prepare і execute, у Laravel DB::select($sql, $bindings) або будь-який where.
-
2
рядки 9–17
Колонка береться з мапи дозволених, напрямок лише ASC або DESC. Невідоме значення не потрапляє в запит, його замінює типове.
-
3
рядки 19–23
Шаблонні знаки екрановано власним символом !, а в запиті стоїть ESCAPE '!'. Так однаково працює в MySQL, PostgreSQL і SQLite.
-
4
рядки 25–26
Той самий пейлоад, що зламав вхід у «Було», тепер лише значення параметра. База шукає користувача з дивним логіном і не знаходить.
-
5
рядки 28–30
intval прибирає хвіст «OR 1=1», дублікати зникають, і плейсхолдерів рівно стільки, скільки id. Так само робить whereIn у Laravel.
- Наведіть на пояснення, щоб підсвітити рядки.Торкніться пояснення, щоб підсвітити рядки.
Eloquent привʼязує значення сам, крім цих місць
->whereRaw("email = '$email'")
Чому це дірка: whereRaw вставляє рядок у запит як є. Значення передавайте другим аргументом: whereRaw('email = ?', [$email]). Так само selectRaw, havingRaw і orderByRaw.
->addSelect(DB::raw($request->input('field')))
Чому це дірка: DB::raw створює Expression, який Laravel не бере в лапки й не привʼязує. Усе з запиту всередині стає SQL. Колонки для select беріть зі списку дозволених.
->orderBy($request->input('sort'), $request->input('dir'))
Чому це дірка: Напрямок Laravel 13 перевіряє сам: усе, крім asc і desc, кидає InvalidArgumentException. Колонку ні, і документація прямо радить не давати введенню її обирати. Беріть enum з tryFrom або мапу на кшталт ['date' => 'published_at'].
->whereLike('name', '%'.$q.'%')
Чому це дірка: Значення привʼязане, тож інʼєкції тут немає. Але whereLike не екранує % і _, і запит «%» поверне всю таблицю. Якщо пошук не має підтримувати шаблони, екрануйте їх самі.
$pdo->prepare('select * from jobs where id in (?)')
Чому це дірка: Один ? не розгортається в список: execute([[3, 7]]) не спрацює, а склеювання через implode повертає інʼєкцію. Складіть плейсхолдери з array_fill за кількістю значень, у Laravel просто whereIn.
DB::unprepared("update stats set hits = hits + 1 where page = '$page'")
Чому це дірка: unprepared не приймає bindings і передає рядок у PDO::exec як є. Він для міграцій і дампів. Для запитів з даними є DB::statement($sql, $bindings).
$pdo->exec("SET NAMES 'gbk'");
Чому це дірка: З емульованими prepares PDO екранує значення за кодуванням, яке знає з DSN. SET NAMES міняє кодування повз нього, і на GBK екранування можна обійти. Кодування задавайте в DSN: charset=utf8mb4.
$stmt->execute([$perPage]);
Чому це дірка: execute з масивом привʼязує все як рядки. З емуляцією MySQL отримає LIMIT '20' і помилку синтаксису, після чого ліміт часто вклеюють у текст. Замість цього bindValue(1, $perPage, PDO::PARAM_INT).
$em->createQuery("SELECT u FROM App\Entity\User u WHERE u.login = '$login'")
Чому це дірка: DQL теж розбирається з рядка, і склеювання в ньому дає таку саму інʼєкцію. Параметри: u.login = :login і setParameter('login', $login).
Де тут 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.
Як цей сайт шукає вакансії
Пошук по сайту шукає кожне слово запиту в назві, описі вакансії й назві компанії. Сирий фрагмент тут потрібен заради LOWER(...), але склеюється в нього лише назва колонки, а колонки задає сам код кількома рядками вище. Слово з пошуку йде другим аргументом orWhereRaw, тобто в bindings, тож лапка в запиті залишиться лапкою.
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 не доходять.- Підготовлений запит
- 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.
Питання зі співбесід
Підготовлений запит передає текст і значення окремо: база розбирає структуру запиту ще до того, як побачить дані, тож значення не може її змінити. 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, ідентифікатори зі списку дозволених.