SQL Injection to podatność, która powinna już dawno zniknąć, a wciąż znajduję ją w audytach: we własnych modułach sklepów, starszych wtyczkach, panelach administracyjnych i szybko pisanych integracjach. Skutki są zawsze poważne, od wycieku danych klientów po przejęcie konta administratora. Dobra wiadomość jest taka, że ochrona jest dobrze znana i nie wymaga skomplikowanych narzędzi. W tym wpisie pokazuję, jak zabezpieczam aplikacje przed SQL Injection w praktyce i jak sprawdzam, czy zabezpieczenia działają.
Gdzie powstaje luka
Przyczyna jest zawsze ta sama: dane z zewnątrz są doklejane do treści zapytania SQL, przez co mogą zmienić jego sens. Najczęściej znajduję to w miejscach, o których programista nie pomyślał jak o danych od użytkownika:
- formularze logowania, wyszukiwarki i filtry produktów,
- parametry w adresie, np.
?id=,?kategoria=,?sort=, - nazwy kolumn do sortowania i kierunek sortowania przekazywane z interfejsu,
- ciasteczka i nagłówki, np. identyfikator języka czy waluty,
- importy plików CSV w panelu administracyjnym,
- dane zapisane wcześniej w bazie i użyte później w innym zapytaniu, czyli tzw. SQL Injection drugiego rzędu.
Typowy podatny kod wygląda niewinnie:
// PODATNE – wartość z adresu trafia wprost do zapytania
$id = $_GET['id'];
$result = $db->query("SELECT * FROM products WHERE id = $id");
Wystarczy zmienić wartość parametru tak, żeby zawierała fragment SQL, a baza wykona zapytanie, którego autor nigdy nie planował.
Zapytania parametryzowane jako podstawa
Jedyną naprawdę skuteczną ochroną jest oddzielenie treści zapytania od danych. W zapytaniu parametryzowanym baza najpierw dostaje szablon zapytania, a wartości osobno, więc nigdy nie zinterpretuje ich jako kodu SQL. W PHP z PDO wygląda to tak:
$pdo = new PDO($dsn, $user, $pass, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_EMULATE_PREPARES => false,
]);
$stmt = $pdo->prepare('SELECT * FROM products WHERE id = :id AND status = :status');
$stmt->execute(['id' => $_GET['id'], 'status' => 'active']);
$product = $stmt->fetch();
W WordPressie ten sam efekt daje $wpdb->prepare(), a w Node.js biblioteki do baz danych przyjmują wartości jako osobną tablicę
parametrów. Zasada jest jedna: żadna wartość pochodząca spoza aplikacji nie jest sklejana z treścią zapytania.
Ręczne „czyszczenie” danych, np. usuwanie apostrofów albo dodawanie ukośników, traktuję jako błąd, a nie zabezpieczenie. Zawsze znajdzie się przypadek, którego filtr nie przewidział, a przy okazji psuje on poprawne dane, takie jak nazwisko O'Connor.
Gdzie parametry nie wystarczą
Parametrami można przekazywać wartości, ale nie nazwy kolumn, tabel czy słowa kluczowe SQL. Dlatego sortowanie, wybór tabeli czy kierunek sortowania zabezpieczam listą dozwolonych wartości:
$allowedSort = ['price' => 'price', 'name' => 'name', 'newest' => 'created_at'];
$column = $allowedSort[$_GET['sort'] ?? ''] ?? 'created_at';
$direction = (($_GET['dir'] ?? '') === 'asc') ? 'ASC' : 'DESC';
$stmt = $pdo->prepare("SELECT * FROM products WHERE category_id = ? ORDER BY $column $direction");
$stmt->execute([$categoryId]);
Do zapytania trafia wyłącznie wartość z mojej listy, nigdy tekst od użytkownika. Podobnie postępuję z listami w warunku IN,
generując odpowiednią liczbę znaczników zapytania, oraz z wyszukiwaniem przez LIKE, gdzie dodatkowo zabezpieczam znaki
% i _, żeby użytkownik nie mógł wymusić bardzo kosztownego przeszukiwania całej tabeli.
ORM i surowe zapytania
Frameworki takie jak Laravel czy Symfony z Doctrine domyślnie używają zapytań parametryzowanych, więc większość kodu jest bezpieczna bez dodatkowej pracy. Luki pojawiają się w miejscach, gdzie programista sięga po surowy SQL dla wygody lub wydajności:
// PODATNE
Product::whereRaw("name LIKE '%$search%'")->get();
DB::select("SELECT * FROM orders WHERE user_id = $userId");
// BEZPIECZNE
Product::where('name', 'like', '%' . $search . '%')->get();
DB::select('SELECT * FROM orders WHERE user_id = ?', [$userId]);
W przeglądzie kodu zawsze wyszukuję metody z „Raw” w nazwie i zapytania budowane przez łączenie tekstu. To zwykle kilka miejsc w całym projekcie, często dopisanych właśnie przy optymalizacji, gdy komunikacja z bazą danych okazała się zbyt wolna i ktoś napisał zapytanie ręcznie.
Ograniczenie skutków ataku
Zakładam, że gdzieś w dużym projekcie może zostać przeoczona luka, dlatego dodatkowo ograniczam to, co atakujący mógłby z nią zrobić:
- Osobne konto bazy dla aplikacji z uprawnieniami tylko do jej bazy i tylko do potrzebnych operacji. Nigdy konto administratora serwera bazy danych.
- Brak komunikatów błędów SQL na stronie. Błędy trafiają do logów, a użytkownik widzi ogólny komunikat. Szczegółowy błąd bazy to dla atakującego mapa struktury tabel, a programiście przy debugowaniu kodu PHP w zupełności wystarczy log.
- Hasła jako hash, a nie tekst. Jeśli dane wyciekną, poprawnie przechowywane hasła są dla atakującego dużo mniej warte.
- Zapora aplikacyjna (WAF) jako dodatkowa warstwa, która blokuje typowe próby ataku. Nie zastępuje jednak poprawnego kodu, bo da się ją obejść.
- Kopie zapasowe i monitoring, które pozwalają szybko wykryć nietypowe zapytania i przywrócić dane.
Jak sprawdzam, czy aplikacja jest odporna
- Przegląd kodu – wyszukuję zapytania budowane przez łączenie tekstu, metody z „Raw” w nazwie i miejsca, gdzie nazwy kolumn pochodzą z danych wejściowych.
- Testy ręczne – w pola formularzy i parametry adresu wpisuję apostrof i znaki specjalne. Błąd bazy danych albo zmiana zachowania strony to sygnał, że dane trafiają do zapytania bez ochrony.
- Narzędzia automatyczne – skanery aplikacji webowych i narzędzia takie jak sqlmap uruchamiam wyłącznie na własnych aplikacjach lub za pisemną zgodą właściciela, najlepiej na środowisku testowym, bo potrafią mocno obciążyć bazę.
- Logi – regularna analiza logów serwera pokazuje próby ataku na konkretne parametry, zanim któraś się powiedzie.
Głośne przypadki SQL Injection
To, że podatność jest znana od ponad 20 lat, nie znaczy, że przestała być groźna. Kilka głośnych przypadków:
- Heartland Payment Systems (2008) – SQL Injection był punktem wejścia do jednego z największych wycieków danych kart płatniczych w historii, liczonego w dziesiątkach milionów kart.
- TalkTalk (2015) – brytyjski operator stracił dane około 157 tysięcy klientów przez podatność w starszej części serwisu.
- MOVEit Transfer (2023) – luka SQL Injection w popularnym oprogramowaniu do przesyłania plików została masowo wykorzystana do kradzieży danych z tysięcy organizacji na całym świecie.
We wszystkich tych przypadkach problemem nie był brak wiedzy o zabezpieczeniach, tylko pojedyncze miejsce w kodzie, w którym ich zabrakło. Dlatego zapytania parametryzowane traktuję jako bezwzględny standard, a przegląd zapytań jako stały punkt każdego audytu kodu.