SQL-инъекции и подготовленные запросы в PDO
Вопрос, на котором собеседование либо продолжается, либо заканчивается.
SQL-инъекция — уязвимость, при которой данные пользователя попадают в текст запроса и меняют его смысл. Лечится не экранированием, а разделением кода и данных: подготовленным запросом.
Вопрос 1: «Что не так с этим кодом?»
$id = $_GET['id'];
$sql = "SELECT * FROM users WHERE id = " . $id;
$rows = $pdo->query($sql)->fetchAll();
Посмотрим, во что превращается запрос, если пользователь передаст не число:
<?php
$id = "1 OR 1=1 --";
echo "SELECT * FROM users WHERE id = " . $id, "\n";
$login = "admin' --";
echo "SELECT * FROM users WHERE login = '" . $login . "' AND pass = 'x'", "\n";
Вывод:
SELECT * FROM users WHERE id = 1 OR 1=1 -- SELECT * FROM users WHERE login = 'admin' --' AND pass = 'x'
Первый запрос вернёт всю таблицу, второй — авторизует под администратором без пароля: два дефиса начинают комментарий, и проверка пароля исчезает. Проверим на живой таблице, что делает подставленное условие:
CREATE TABLE users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
login TEXT,
is_admin INTEGER
);
INSERT INTO users (login, is_admin) VALUES
('anna', 0),
('admin', 1),
('petr', 0);
-- то, что видит база после подстановки "1 OR 1=1"
SELECT * FROM users WHERE id = 1 OR 1=1;
Вместо одной строки возвращаются все три. Именно так утекают базы пользователей.
Вопрос 2: «Как правильно?»
Подготовленный запрос отправляется в СУБД отдельно от значений: сервер разбирает структуру запроса заранее, а данные подставляются на уровне протокола и уже никогда не могут стать частью синтаксиса.
<?php
$dsn = 'mysql:host=localhost;dbname=shop;charset=utf8mb4';
$pdo = new PDO($dsn, $user, $password, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, // ошибки как исключения
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false, // настоящие prepared statements
]);
// именованные плейсхолдеры
$stmt = $pdo->prepare('SELECT * FROM users WHERE login = :login AND active = :active');
$stmt->execute(['login' => $login, 'active' => 1]);
$user = $stmt->fetch();
// позиционные плейсхолдеры
$stmt = $pdo->prepare('INSERT INTO orders (user_id, total) VALUES (?, ?)');
$stmt->execute([$userId, $total]);
echo $pdo->lastInsertId();
Три настройки в конструкторе — то, что интервьюер хочет услышать обязательно:
ERRMODE_EXCEPTION— иначе ошибки запроса молча возвращаютfalse, и код продолжает работать с мусором.EMULATE_PREPARES => false— по умолчанию для MySQL PDO эмулирует подготовку: подставляет значения в строку сам, на стороне PHP. Это работает, но настоящие серверные prepared statements надёжнее и корректнее возвращают типы столбцов.charset=utf8mb4прямо в DSN — кодировка должна задаваться соединением, а не запросомSET NAMES.
Вопрос 3: «Что нельзя передать плейсхолдером?»
Любимый уточняющий вопрос. Плейсхолдер — это значение, а не кусок синтаксиса. Нельзя биндить имя таблицы, имя столбца, направление сортировки, LIMIT в эмулированном режиме и список значений для IN.
// НЕ РАБОТАЕТ: имя столбца и ORDER BY — не значения
$stmt = $pdo->prepare('SELECT * FROM users ORDER BY :column :dir');
// правильно: белый список
$allowed = ['name', 'created_at', 'total'];
$column = in_array($_GET['sort'] ?? '', $allowed, true) ? $_GET['sort'] : 'name';
$dir = ($_GET['dir'] ?? '') === 'desc' ? 'DESC' : 'ASC';
$stmt = $pdo->prepare("SELECT * FROM users ORDER BY $column $dir");
// список для IN: генерируем плейсхолдеры по числу элементов
$ids = [3, 7, 12];
$in = implode(',', array_fill(0, count($ids), '?'));
$stmt = $pdo->prepare("SELECT * FROM users WHERE id IN ($in)");
$stmt->execute($ids);
Отдельно стоит сказать про PDO::quote() и mysqli_real_escape_string(): они существуют, но полагаться на них не нужно. Экранирование зависит от кодировки соединения и легко ломается при ошибке в настройке — исторические обходы через многобайтовые кодировки именно так и работали. Подготовленный запрос лишён этого класса проблем.
Типичные ошибки кандидатов
- Считают, что
mysqli_real_escape_string()илиaddslashes()— достаточная защита. - Используют
prepare(), но всё равно склеивают строку внутри запроса:prepare("... WHERE id = $id"). - Не знают про эмуляцию подготовки в PDO и уверены, что она всегда серверная.
- Забывают включить
ERRMODE_EXCEPTIONи не понимают, почему ошибки запросов не видны. - Пытаются биндить имя таблицы или
ORDER BYвместо белого списка. - Считают ORM абсолютной защитой:
DB::raw()и сырые фрагменты в query builder уязвимы так же, как обычная конкатенация.
Как ответить кратко
«SQL-инъекция возникает, когда данные пользователя попадают в текст запроса. Защита — подготовленные запросы:
prepare()с плейсхолдерами иexecute()с массивом значений, при этом данные уходят в СУБД отдельно от кода. PDO настраиваю сERRMODE_EXCEPTION,EMULATE_PREPARES=falseиcharset=utf8mb4. Плейсхолдером нельзя передать имя таблицы, столбца или направление сортировки — для них белый список допустимых значений».