Przejdź do treści

SQL Injection dziś – dlaczego trzeba się naprawdę postarać, żeby być podatnym

Trzy lata temu napisałem na tym blogu wpis o atakach SQL Injection: formularz logowania w PHP, zapytanie sklejane ze stringa i klasyczny atak – wpisujesz w pole „Username” ciąg ' OR 1=1# i logujesz się bez hasła. Wpis jest nadal aktualny co do mechanizmu i zostaje na swoim miejscu. Ale dziś chcę wrócić do tamtego kodu z innym pytaniem – nie „jak to zaatakować”, tylko: czy w 2026 roku dałoby się ten kod w ogóle tak napisać, nie wychodząc z siebie?

To pytanie w duchu serii o odruchach i całego tego bloga: coś, co kiedyś było domyślną drogą i wielką pułapką, dziś wymaga świadomego zejścia ze ścieżki. Przy okazji to naturalne przedłużenie właśnie zakończonej serii o testowaniu – bo test penetracyjny to nic innego jak testowanie z drugiej strony: nie „czy działa, jak zaplanowałem”, lecz „co da się z tym zrobić wbrew planowi”.

Kod, który wtedy było łatwo napisać

Wróćmy do sedna tamtego przykładu. Podatność brała się z jednej linijki:

<?php
// Dane z formularza wstawione wprost w treść zapytania
$username = $_POST['username'];
$password = $_POST['password'];

$sql = "SELECT * FROM users WHERE username = '$username' AND password = '$password'";
$result = mysqli_query($conn, $sql);

Interpolacja zmiennej w środek stringa SQL – i już. Wpisane w username ' OR 1=1# zamienia warunek w zawsze prawdziwy, a # wycina resztę zapytania. Rzecz w tym, że w epoce, z której ten kod pochodzi, tak się właśnie pisało. Podręczniki, tutoriale, przykłady z dokumentacji mysql_query – wszystkie sklejały zapytania ze stringów, bo to była najkrótsza droga od formularza do bazy. Bezpieczna wersja wymagała dopisania kodu; wersja podatna była tą domyślną, na wyciągnięcie ręki. Dlatego SQL Injection przez lata królował na szczytach list podatności: nie dlatego, że programiści byli niedbali, tylko dlatego, że najprostsza droga była zarazem najgroźniejsza.

Ta sama logika dziś: parametryzacja z pudełka

Teraz napiszmy to samo logowanie tak, jak pisze się je w 2026 roku – i zobaczmy, gdzie się podział problem. Nawet zostając przy czystym PHP, bez żadnego frameworka, domyślnym narzędziem jest dziś zapytanie parametryzowane w PDO:

<?php
$pdo = new PDO('mysql:host=localhost;dbname=app;charset=utf8mb4', $user, $pass);

// Zapytanie ze znacznikami, dane przekazane osobno
$stmt = $pdo->prepare('SELECT * FROM users WHERE username = ? AND password_hash = ?');
$stmt->execute([$username, $passwordHash]);
$user = $stmt->fetch();

Kluczowa różnica nie jest kosmetyczna. W wersji sklejanej dane i polecenie płyną do bazy jednym stringiem – silnik nie wie, gdzie kończy się zapytanie, a zaczyna to, co wpisał użytkownik. W wersji parametryzowanej struktura zapytania jedzie osobno, a dane osobno; to, co użytkownik wpisze w username, nigdy nie zostanie potraktowane jako składnia SQL, choćby wpisał tam całą książkę telefoniczną ze średnikami. ' OR 1=1# trafia do bazy jako dosłowna, poszukiwana nazwa użytkownika – i po prostu się nie znajduje. Atak nie tyle zostaje odparty, co przestaje istnieć jako kategoria.

A z frameworkiem trzeba się postarać jeszcze bardziej

W czystym PDO da się jeszcze wrócić na złą drogę – nikt nie broni skleić stringa i podać go do query(). Ale prawdziwa zmiana przyszła z warstwą wyżej, od tego, co autor pytania nazwał „obsługą formularzy z pudełka”. We współczesnym frameworku dane z żądania nie docierają do Ciebie jako surowy $_POST, tylko przez komponent formularza, który je waliduje i typuje, a do bazy trafiają przez warstwę dostępu, która parametryzuje każde zapytanie sama.

  • W Symfony formularz mapuje żądanie na obiekt, a Doctrine zapisuje go do bazy zapytaniem z powiązanymi parametrami – nie dotykasz SQL-a w ogóle.
  • W Laravelu Eloquent i query builder wiążą wartości automatycznie; Request daje zwalidowane dane.
  • W Django (żeby nie zostać przy samym PHP) formularz i ORM robią dokładnie to samo po stronie Pythona.

Efekt jest taki, że przeciętny programista, idąc domyślną ścieżką frameworka, pisze kod odporny na wstrzyknięcie, nie wiedząc nawet, że go przed czymś chroni. Bezpieczeństwo z opcji, którą trzeba było świadomie dołożyć, stało się stanem domyślnym, z którego trzeba świadomie zrezygnować. To dokładnie ten sam ruch, który opisywałem przy platformie, która dogoniła biblioteki: rzecz, dla której kiedyś sięgało się po dodatkowy wysiłek, jest teraz wbudowana w domyślną drogę.

Sekcja uczciwości: gdzie wstrzyknięcie wciąż jest łatwe

Gdybym skończył na „frameworki załatwiły SQL Injection”, popełniłbym błąd, przed którym przestrzegam w każdym wpisie – ogłosił koniec problemu, który po prostu przeprowadził się w inne miejsce. SQL Injection wciąż siedzi w czołówce list podatności, i to nie z rozpędu. Zejść z bezpiecznej ścieżki nadal jest łatwo, tylko dziś trzeba to zrobić umyślnie – a to znaczy: w miejscach, gdzie człowiek świadomie omija ORM. Kilka realnych:

  • Surowy SQL dla wydajności albo dziwnego zapytania. Prędzej czy później trafia się zapytanie, którego ORM nie umie ładnie wyrazić, więc pisze się je ręcznie. I jeśli w tym momencie wróci stary odruch sklejania stringa – podatność wraca z nim, tyle że teraz w kodzie, który wygląda na „zaawansowany”.
  • To, czego parametryzować się nie da. Znacznik ? podstawia wartość, nie fragment składni. Nazwa kolumny w ORDER BY, kierunek sortowania, nazwa tabeli – tego nie zwiążesz parametrem. Dynamiczne ORDER BY $_GET['sort'] to klasyczna dziura w skądinąd nowoczesnym kodzie; takie identyfikatory wolno wstawiać wyłącznie z własnej białej listy, nigdy wprost z żądania.
  • Wstrzyknięcie drugiego rzędu. Dane wchodzą bezpiecznie, zostają zapisane, a wybuchają później – gdy odczytany z bazy string ktoś skleja w kolejne zapytanie, już bez czujności, „bo to przecież nasze dane”.
  • LIKE i znaki wieloznaczne. Nawet w zapytaniu parametryzowanym % i _ wewnątrz wzorca LIKE mają znaczenie i bez escapowania potrafią zmienić sens wyszukiwania.
  • Konteksty spoza weba. Skrypt migracyjny, zadanie w tle, import z pliku CSV – miejsca, gdzie „to tylko wewnętrzne narzędzie” usypia czujność, a dane i tak bywają cudze.

Wspólny mianownik jest ten sam, co w całej serii o testowaniu: narzędzie chroni Cię na ścieżce, którą przewidziano. Wstrzyknięcia szuka się dziś dokładnie tam, gdzie ktoś z tej ścieżki zszedł – i to jest robota dla testu penetracyjnego, czyli dla kogoś, kto pyta „co się stanie, jeśli”, zamiast „czy działa”.

Puenta

Kod z mojego wpisu sprzed trzech lat nie był głupi – był typowy dla swojej epoki, w której najkrótsza droga od formularza do bazy wiodła przez sklejony string. Dziś ta sama najkrótsza droga – PDO z parametrami, a tym bardziej formularz i ORM frameworka – prowadzi do kodu, w którym ' OR 1=1# jest po prostu nieudaną próbą logowania. Nie dlatego, że nauczyliśmy się uważać. Dlatego, że domyślne narzędzia zaczęły uważać za nas.

Ale „trudniej” to nie „niemożliwe”, i o tym jest sekcja uczciwości. SQL Injection nie umarł – przeniósł się z głównej drogi na jej pobocza: surowe zapytania, dynamiczne identyfikatory, dane drugiego rzędu. Różnica jest taka, że kiedyś podatność była stanem domyślnym, a dziś jest świadomym odstępstwem. A skoro tak, to i szuka się jej inaczej – nie czytając happy path, lecz właśnie tam, gdzie ktoś zszedł ze ścieżki, którą framework wybrukował mu na bezpieczno.