Глава 6.1: SQL-инъекции — как работают, как защититься, практические примеры атак
🎯 Что ты узнаешь
- Как устроены SQL-инъекции и почему они так опасны
- Реальные примеры атак и их последствия
- Как защититься: prepared statements, валидация, экранирование
- Практические сценарии: от простых до сложных атак
- Как думать как хакер, чтобы защитить свой код
📖 Теория
Что такое SQL-инъекция?
SQL-инъекция — это уязвимость, при которой злоумышленник может вставить свой SQL-код в запрос приложения. Это происходит, когда данные пользователя попадают в SQL-запрос без должной обработки.
Почему это критично:
- Полный доступ к базе данных
- Кража данных пользователей
- Удаление данных
- Обход авторизации
- Выполнение команд на сервере (в некоторых случаях)
Историческая справка: SQL-инъекции входят в OWASP Top 10 (список самых опасных уязвимостей веб-приложений) с момента создания рейтинга. Миллионы сайтов были взломаны через эту уязвимость.
Как работает атака: анатомия инъекции
Уязвимый код (НИКОГДА ТАК НЕ ДЕЛАЙ!)
<?php
// ❌ ОПАСНО! Конкатенация пользовательского ввода
$username = $_POST['username'];
$password = $_POST['password'];
$query = "SELECT * FROM users WHERE username = '$username' AND password = '$password'";
$result = $pdo->query($query);Что пойдёт не так?
Нормальное использование:
username: john
password: secret123
Результирующий SQL:
SELECT * FROM users WHERE username = 'john' AND password = 'secret123'Атака #1: Обход авторизации
username: admin' --
password: (любой)
Результирующий SQL:
SELECT * FROM users WHERE username = 'admin' -- ' AND password = ''
↑
Всё после -- это комментарий!Злоумышленник войдёт как admin без знания пароля!
Атака #2: UNION-based инъекция
username: ' UNION SELECT null, username, password FROM users --
password: (любой)
Результирующий SQL:
SELECT * FROM users WHERE username = ''
UNION SELECT null, username, password FROM users -- ' AND password = ''Результат: злоумышленник получит все логины и пароли из базы!
Типы SQL-инъекций
1. Classic SQL Injection (самая простая)
// Уязвимый поиск
$search = $_GET['search'];
$query = "SELECT * FROM products WHERE name LIKE '%$search%'";Атака:
search: %' OR 1=1 --
SQL:
SELECT * FROM products WHERE name LIKE '%%' OR 1=1 --%'Вернёт ВСЕ товары, потому что 1=1 всегда истина.
2. Blind SQL Injection (атака вслепую)
Когда приложение не показывает результаты запроса, но меняет поведение.
// Уязвимая проверка
$id = $_GET['id'];
$query = "SELECT * FROM articles WHERE id = $id AND published = 1";
$result = $pdo->query($query);
if ($result->rowCount() > 0) {
echo "Статья существует";
} else {
echo "Статья не найдена";
}Атака (извлечение данных по символу):
id: 1 AND SUBSTRING((SELECT password FROM users WHERE id=1), 1, 1) = 'a'
Если пароль начинается с 'a' → "Статья существует"
Если нет → "Статья не найдена"Перебирая символы, можно извлечь весь пароль!
3. Time-based Blind SQL Injection
Когда даже поведение не меняется, используем задержки:
// Уязвимый код
$id = $_GET['id'];
$query = "SELECT * FROM products WHERE id = $id";Атака:
id: 1 AND IF(SUBSTRING((SELECT password FROM users WHERE id=1), 1, 1) = 'a', SLEEP(5), 0)
Если первый символ пароля 'a' → сайт зависнет на 5 секунд
Если нет → ответ мгновенный4. Second Order SQL Injection
Инъекция происходит в два этапа:
// Этап 1: Регистрация (уязвимая)
$username = $_POST['username']; // admin'--
$sql = "INSERT INTO users (username) VALUES ('$username')";
// В БД сохранится: admin'--
// Этап 2: Использование (позже в другом месте)
$user = getUser($userId); // Получаем admin'-- из БД
$sql = "SELECT * FROM posts WHERE author = '$user'";
// SELECT * FROM posts WHERE author = 'admin'--'🛡️ Защита: Как правильно
✅ Метод 1: Prepared Statements (ЛУЧШЕЕ РЕШЕНИЕ)
<?php
// ✅ ПРАВИЛЬНО
$username = $_POST['username'];
$password = $_POST['password'];
$stmt = $pdo->prepare("SELECT * FROM users WHERE username = ? AND password = ?");
$stmt->execute([$username, $password]);
$user = $stmt->fetch();Почему это безопасно:
- PDO отправляет SQL и данные отдельно
- Данные никогда не интерпретируются как код
- Даже если пользователь введёт
' OR 1=1 --, это будет искаться как строка
Named parameters (ещё читабельнее):
<?php
$stmt = $pdo->prepare("
SELECT * FROM users
WHERE username = :username AND password = :password
");
$stmt->execute([
':username' => $username,
':password' => $password
]);✅ Метод 2: Валидация и фильтрация
Даже с prepared statements, валидируй данные!
<?php
// Проверка типов данных
$id = filter_input(INPUT_GET, 'id', FILTER_VALIDATE_INT);
if ($id === false) {
die("ID должен быть числом!");
}
$stmt = $pdo->prepare("SELECT * FROM products WHERE id = ?");
$stmt->execute([$id]);Белый список для динамических частей:
<?php
// ❌ ОПАСНО: ORDER BY из пользовательского ввода
$order = $_GET['order']; // можно вставить подзапрос!
$query = "SELECT * FROM products ORDER BY $order";
// ✅ ПРАВИЛЬНО: белый список
$allowedColumns = ['name', 'price', 'created_at'];
$order = $_GET['order'] ?? 'name';
if (!in_array($order, $allowedColumns)) {
$order = 'name'; // значение по умолчанию
}
// Теперь безопасно использовать напрямую
$query = "SELECT * FROM products ORDER BY $order";✅ Метод 3: Экранирование (последний резерв)
Используй ТОЛЬКО если prepared statements невозможны:
<?php
// Для MySQL через PDO
$username = $pdo->quote($_POST['username']);
$password = $pdo->quote($_POST['password']);
$query = "SELECT * FROM users WHERE username = $username AND password = $password";
// quote() добавит кавычки и экранирует опасные символы⚠️ Внимание: quote() НЕ идеален, есть редкие способы обойти. Используй prepared statements!
🎭 Практические сценарии атак и защиты
Сценарий 1: Форма поиска
<?php
// ❌ УЯЗВИМО
$search = $_GET['q'];
$results = $pdo->query("SELECT * FROM articles WHERE title LIKE '%$search%'");
// ✅ БЕЗОПАСНО
$search = $_GET['q'];
$stmt = $pdo->prepare("SELECT * FROM articles WHERE title LIKE ?");
$stmt->execute(["%$search%"]); // % внутри параметра, не в SQL!Сценарий 2: Динамические WHERE условия
<?php
// Фильтры от пользователя
$filters = [
'category' => $_GET['category'] ?? null,
'min_price' => $_GET['min_price'] ?? null,
'max_price' => $_GET['max_price'] ?? null
];
// Строим запрос безопасно
$sql = "SELECT * FROM products WHERE 1=1"; // хак для AND
$params = [];
if ($filters['category']) {
$sql .= " AND category = ?";
$params[] = $filters['category'];
}
if ($filters['min_price']) {
$sql .= " AND price >= ?";
$params[] = (int)$filters['min_price'];
}
if ($filters['max_price']) {
$sql .= " AND price <= ?";
$params[] = (int)$filters['max_price'];
}
$stmt = $pdo->prepare($sql);
$stmt->execute($params);Сценарий 3: LIMIT и OFFSET
<?php
// ❌ ОПАСНО (LIMIT нельзя параметризовать как ?)
$limit = $_GET['limit'];
$offset = $_GET['offset'];
// $query = "SELECT * FROM posts LIMIT ? OFFSET ?"; ← НЕ сработает!
// ✅ ПРАВИЛЬНО: валидация + приведение к int
$limit = max(1, min(100, (int)($_GET['limit'] ?? 10))); // от 1 до 100
$offset = max(0, (int)($_GET['offset'] ?? 0));
$query = "SELECT * FROM posts LIMIT $limit OFFSET $offset";
// Безопасно, потому что (int) гарантирует числоСценарий 4: IN (список значений)
<?php
// Выбрать несколько ID
$ids = $_GET['ids']; // "1,2,3,4"
// ❌ ОПАСНО
$query = "SELECT * FROM products WHERE id IN ($ids)";
// ✅ ПРАВИЛЬНО
$idArray = explode(',', $ids);
$idArray = array_map('intval', $idArray); // приводим к int
$idArray = array_filter($idArray); // убираем 0
if (empty($idArray)) {
die("Не выбрано ни одного ID");
}
// Создаём плейсхолдеры: ?, ?, ?, ?
$placeholders = str_repeat('?,', count($idArray) - 1) . '?';
$stmt = $pdo->prepare("SELECT * FROM products WHERE id IN ($placeholders)");
$stmt->execute($idArray);🧪 Тестирование на уязвимости
Простой чек-лист для самопроверки:
<?php
// Найди в своём коде такие паттерны:
// 🚨 КРАСНЫЙ ФЛАГ
$pdo->query("SELECT * FROM table WHERE column = '$userInput'");
$pdo->exec("INSERT INTO table VALUES ('$userInput')");
mysqli_query($conn, "UPDATE table SET column = '$userInput'");
// ✅ БЕЗОПАСНО
$stmt = $pdo->prepare("SELECT * FROM table WHERE column = ?");
$stmt->execute([$userInput]);Инструменты для тестирования:
- SQLMap — автоматизированный инструмент для поиска SQL-инъекций
- Burp Suite — перехват и модификация HTTP-запросов
- Manual testing — попробуй сам:
' OR 1=1 --'; DROP TABLE users; --' UNION SELECT null, null, null --
💻 Практические упражнения
Упражнение 1: Найди уязвимость
<?php
// Исправь этот код
function searchUsers($name) {
global $pdo;
$query = "SELECT id, username, email FROM users WHERE username LIKE '%$name%'";
$result = $pdo->query($query);
return $result->fetchAll();
}
// Твоя задача: переписать безопасно✅ Решение
<?php
function searchUsers($name) {
global $pdo;
$stmt = $pdo->prepare("SELECT id, username, email FROM users WHERE username LIKE ?");
$stmt->execute(["%$name%"]);
return $stmt->fetchAll();
}Упражнение 2: Динамическая сортировка
<?php
// Пользователь выбирает сортировку: price, name, created_at
// Направление: ASC или DESC
// Напиши безопасную функцию
function getProducts($sortBy, $direction) {
global $pdo;
// Твой код здесь
}✅ Решение
<?php
function getProducts($sortBy, $direction) {
global $pdo;
// Белый список для сортировки
$allowedSort = ['price', 'name', 'created_at'];
$sortBy = in_array($sortBy, $allowedSort) ? $sortBy : 'name';
// Белый список для направления
$direction = strtoupper($direction) === 'DESC' ? 'DESC' : 'ASC';
// Теперь безопасно вставлять в запрос
$query = "SELECT * FROM products ORDER BY $sortBy $direction";
return $pdo->query($query)->fetchAll();
}Упражнение 3: Сложный фильтр
<?php
// Создай функцию поиска товаров с фильтрами:
// - Поиск по названию (LIKE)
// - Категория (точное совпадение)
// - Диапазон цен (от и до)
// - Сортировка
// Всё должно быть безопасно!
function searchProducts($searchTerm, $category, $minPrice, $maxPrice, $sortBy) {
// Твой код
}✅ Решение
<?php
function searchProducts($searchTerm, $category, $minPrice, $maxPrice, $sortBy) {
global $pdo;
$sql = "SELECT * FROM products WHERE 1=1";
$params = [];
// Поиск по названию
if (!empty($searchTerm)) {
$sql .= " AND name LIKE ?";
$params[] = "%$searchTerm%";
}
// Фильтр по категории
if (!empty($category)) {
$sql .= " AND category = ?";
$params[] = $category;
}
// Минимальная цена
if ($minPrice !== null && $minPrice !== '') {
$sql .= " AND price >= ?";
$params[] = (float)$minPrice;
}
// Максимальная цена
if ($maxPrice !== null && $maxPrice !== '') {
$sql .= " AND price <= ?";
$params[] = (float)$maxPrice;
}
// Сортировка (белый список)
$allowedSort = ['name', 'price', 'created_at'];
$sortBy = in_array($sortBy, $allowedSort) ? $sortBy : 'name';
$sql .= " ORDER BY $sortBy";
$stmt = $pdo->prepare($sql);
$stmt->execute($params);
return $stmt->fetchAll();
}🎯 Реальный проект: Безопасная система комментариев
Задание
Создай систему комментариев с полной защитой от SQL-инъекций:
- Добавление комментария
- Просмотр комментариев к статье
- Поиск по комментариям
- Модерация (удаление по ID)
База данных
CREATE TABLE comments (
id INT PRIMARY KEY AUTO_INCREMENT,
article_id INT NOT NULL,
author_name VARCHAR(100) NOT NULL,
content TEXT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
INDEX(article_id)
);Требования безопасности
- Все взаимодействия с БД через prepared statements
- Валидация всех входных данных
- Экранирование HTML в выводе (защита от XSS)
Стартовый код
<?php
// config.php
$pdo = new PDO('mysql:host=localhost;dbname=comments_db', 'user', 'pass');
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
// comments.php - твоя реализация
class CommentSystem {
private $pdo;
public function __construct($pdo) {
$this->pdo = $pdo;
}
// Добавить комментарий
public function addComment($articleId, $authorName, $content) {
// Твой код
}
// Получить комментарии к статье
public function getComments($articleId, $limit = 50, $offset = 0) {
// Твой код
}
// Поиск по комментариям
public function searchComments($searchTerm) {
// Твой код
}
// Удалить комментарий
public function deleteComment($commentId) {
// Твой код
}
}
// Использование
$comments = new CommentSystem($pdo);
// Обработка формы
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
// Твой код обработки
}✅ Полное решение
<?php
// config.php
$pdo = new PDO('mysql:host=localhost;dbname=comments_db;charset=utf8mb4', 'user', 'pass');
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$pdo->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
// comments.php
class CommentSystem {
private $pdo;
public function __construct($pdo) {
$this->pdo = $pdo;
}
public function addComment($articleId, $authorName, $content) {
// Валидация
$articleId = (int)$articleId;
if ($articleId <= 0) {
throw new InvalidArgumentException("Некорректный ID статьи");
}
$authorName = trim($authorName);
if (empty($authorName) || strlen($authorName) > 100) {
throw new InvalidArgumentException("Имя автора должно быть от 1 до 100 символов");
}
$content = trim($content);
if (empty($content)) {
throw new InvalidArgumentException("Комментарий не может быть пустым");
}
// Безопасная вставка
$stmt = $this->pdo->prepare("
INSERT INTO comments (article_id, author_name, content)
VALUES (?, ?, ?)
");
$stmt->execute([$articleId, $authorName, $content]);
return $this->pdo->lastInsertId();
}
public function getComments($articleId, $limit = 50, $offset = 0) {
// Валидация
$articleId = (int)$articleId;
$limit = max(1, min(100, (int)$limit)); // от 1 до 100
$offset = max(0, (int)$offset);
$stmt = $this->pdo->prepare("
SELECT id, article_id, author_name, content, created_at
FROM comments
WHERE article_id = ?
ORDER BY created_at DESC
LIMIT $limit OFFSET $offset
");
$stmt->execute([$articleId]);
return $stmt->fetchAll();
}
public function searchComments($searchTerm) {
// Валидация
$searchTerm = trim($searchTerm);
if (empty($searchTerm)) {
return [];
}
$stmt = $this->pdo->prepare("
SELECT id, article_id, author_name, content, created_at
FROM comments
WHERE content LIKE ? OR author_name LIKE ?
ORDER BY created_at DESC
LIMIT 100
");
$searchPattern = "%$searchTerm%";
$stmt->execute([$searchPattern, $searchPattern]);
return $stmt->fetchAll();
}
public function deleteComment($commentId) {
$commentId = (int)$commentId;
if ($commentId <= 0) {
throw new InvalidArgumentException("Некорректный ID комментария");
}
$stmt = $this->pdo->prepare("DELETE FROM comments WHERE id = ?");
$stmt->execute([$commentId]);
return $stmt->rowCount() > 0; // true если удалён
}
}
// index.php
session_start();
$comments = new CommentSystem($pdo);
// Обработка добавления комментария
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['action'])) {
try {
if ($_POST['action'] === 'add') {
$commentId = $comments->addComment(
$_POST['article_id'],
$_POST['author_name'],
$_POST['content']
);
$_SESSION['message'] = "Комментарий добавлен!";
} elseif ($_POST['action'] === 'delete') {
$comments->deleteComment($_POST['comment_id']);
$_SESSION['message'] = "Комментарий удалён!";
}
header("Location: " . $_SERVER['PHP_SELF']);
exit;
} catch (Exception $e) {
$_SESSION['error'] = $e->getMessage();
}
}
// Получение комментариев
$articleId = (int)($_GET['article_id'] ?? 1);
$searchTerm = $_GET['search'] ?? '';
$commentsList = $searchTerm
? $comments->searchComments($searchTerm)
: $comments->getComments($articleId);
?>
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Комментарии</title>
<style>
.comment { border: 1px solid #ddd; padding: 10px; margin: 10px 0; }
.error { color: red; }
.success { color: green; }
</style>
</head>
<body>
<h1>Комментарии к статье #<?= $articleId ?></h1>
<?php if (isset($_SESSION['message'])): ?>
<div class="success"><?= htmlspecialchars($_SESSION['message']) ?></div>
<?php unset($_SESSION['message']); ?>
<?php endif; ?>
<?php if (isset($_SESSION['error'])): ?>
<div class="error"><?= htmlspecialchars($_SESSION['error']) ?></div>
<?php unset($_SESSION['error']); ?>
<?php endif; ?>
<!-- Форма поиска -->
<form method="GET">
<input type="hidden" name="article_id" value="<?= $articleId ?>">
<input type="text" name="search" value="<?= htmlspecialchars($searchTerm) ?>" placeholder="Поиск...">
<button type="submit">Искать</button>
<a href="?article_id=<?= $articleId ?>">Сбросить</a>
</form>
<!-- Форма добавления -->
<form method="POST">
<input type="hidden" name="action" value="add">
<input type="hidden" name="article_id" value="<?= $articleId ?>">
<input type="text" name="author_name" placeholder="Ваше имя" required><br>
<textarea name="content" placeholder="Ваш комментарий" required></textarea><br>
<button type="submit">Добавить комментарий</button>
</form>
<!-- Список комментариев -->
<?php foreach ($commentsList as $comment): ?>
<div class="comment">
<strong><?= htmlspecialchars($comment['author_name']) ?></strong>
<small><?= $comment['created_at'] ?></small>
<p><?= nl2br(htmlspecialchars($comment['content'])) ?></p>
<form method="POST" style="display:inline;">
<input type="hidden" name="action" value="delete">
<input type="hidden" name="comment_id" value="<?= $comment['id'] ?>">
<button type="submit" onclick="return confirm('Удалить?')">Удалить</button>
</form>
</div>
<?php endforeach; ?>
</body>
</html>📝 Чек-лист безопасности
Перед деплоем проверь:
- [ ] Все SQL-запросы используют prepared statements
- [ ] Динамические части (ORDER BY, LIMIT) валидируются через белый список
- [ ] Числовые параметры приводятся к
(int)или(float) - [ ] Нет конкатенации пользовательского ввода в SQL
- [ ] HTML-вывод экранируется через
htmlspecialchars() - [ ] Ошибки БД не показываются пользователю в production
- [ ] Логи содержат попытки атак для анализа
❓ Вопросы для самопроверки
- Почему конкатенация пользовательского ввода в SQL опасна?
- Как работают prepared statements "под капотом"?
- Можно ли параметризовать название таблицы или колонки? Почему?
- Что делать, если нужна динамическая сортировка?
- В чём разница между позиционными
?и именованными:nameплейсхолдерами? - Защищают ли prepared statements от всех видов атак?
- Что такое Second Order SQL Injection?
- Как тестировать свой код на SQL-инъекции?
🎓 Что дальше?
Ты освоил самую критичную уязвимость веб-приложений. Теперь ты:
- Понимаешь механизм SQL-инъекций
- Умеешь защищать код через prepared statements
- Знаешь как валидировать динамические части запросов
- Можешь думать как атакующий, чтобы защищаться
Следующая глава: Глава 6.2: XSS и CSRF — защита от межсайтового скриптинга и подделки запросов.
💡 Золотое правило
НИКОГДА не доверяй пользовательскому вводу. ВСЁ, что приходит извне — потенциально опасно.
Пользовательский ввод — это:
$_GET,$_POST,$_COOKIE,$_FILES- Данные из базы (да, они могли быть заражены раньше!)
- Данные из API
- Загруженные файлы
- Заголовки HTTP
Защищайся всегда. По умолчанию. Без исключений. 🛡️