Jak zabezpieczyć się przed atakiem SQL injection?

Treść dodana: 18 listopada 2017.

Nie ma co ukrywać. Niefrasobliwość programisty to główna przyczyna włamań do systemów internetowych. Pomijając luki niezależne od nas, występujące w samym oprogramowaniu – mam na myśli projekty takie jak Apache, BIND, PHP, MySQL czy inne – to programista końcowy stwarza największe niebezpieczeństwo. Czytając choćby tematy na popularnym forum.php.pl aż włos się na głowie jeży czego ludzie nie wymyślą w swoich skryptach. Nie wynika to oczywiście ze złej woli ale zwyczajnie z niewiedzy. Jest jedna podstawowa kwestia którą trzeba zapamiętać zabierając się za programowanie webowe. Nigdy, przenigdy nie można ufać użytkownikom i danym przez nich wprowadzanym. Nawet jeżeli zdajemy sobie sprawę że nasza praca jest nadmiarowa, że nie musimy stosować aż tylu metod na raz aby dane zabezpieczyć, to lepiej dmuchać na zimne i jednak to zrobić. Zwłaszcza kiedy dopiero zaczynamy przygodę z programowaniem. Co mam tutaj na myśli? Trzy podstawowe rzeczy które powinniśmy zrobić zanim w ogóle pomyślimy o przekazaniu danych do bazy to:

- rzutowanie
- filtrowanie
- walidacja

Rzutowanie odpowiada za przekształcenie typu danych na docelowy format. Jeżeli spodziewasz się typu `integer`, a pozwalasz na `string` albo `boolean`, to już coś jest źle zrobione. Rzutowanie to podstawowa forma zabezpieczeń przed atakami w nierelacyjnych bazach danych typu MongoDB.

Filtrowanie odpowiada za usunięcie zakresu znaków które nie powinny znaleźć się w docelowej zmiennej. Przykładowo numer karty kredytowej podany w procesie płatności nie powinien zawierać tagów HTML. Po odfiltrowaniu powinny pozostać same cyfry.

Walidacja to sprawdzenie czy ciąg wejściowy spełnia kryteria poprawności. Przykładowo wiek użytkownika powinien być liczbą, nie mniejszą niż 1, i nie większą niż np. 150. Jakiekolwiek odstępstwo od tej reguły nie jest akceptowalne i powinno skutkować odrzuceniem danych.

Nowoczesne przeglądarki pozwalają na wstępną walidację danych bezpośrednio na polach formularza, trzeba jednak mieć na uwadze że jest to dodatek a nie docelowa forma zabezpieczenia. Zarówno sprawdzanie wartości w JavaScript jak i odpowiednik w HTML bardzo łatwo obejść lub wyłączyć. Sprawdzanie powinno odbywać się zawsze po stronie serwera. Tylko kodu generowanego przez skrypt serwerowy użytkownik nie jest w stanie zmodyfikować.

Przechodząc do baz danych należy pamiętać o dwóch sprawach.

- dane należy zawsze przekazywać do zapytania poprzez bindowanie. Dzięki temu sterownik będzie wiedział jak traktować zmienną i odpowiednio odseparuje ją od reszty zapytania
- należy bezwzględnie ustawić kodowanie znaków dla połączenia – najlepiej na UTF-8. Drugim najpopularniejszym atakiem, zaraz po wstrzyknięciu błędnych danych do zapytania, jest wykorzystanie nieprawidłowego kodowania celem przekazania szkodliwych danych

Gdy w swoim projekcie wykorzystujesz bazę MySQL zapomnij o rozszerzeniu mysql. Zostało usunięte z PHP7, nie jest dłużej wspierane i miałeś wystarczająco dużo czasu by przejść na PDO lub mysqli (mysql improved). Obiektowe podejście PDO wymaga oczywiście odrobinę więcej nauki na wstępie – nie jest to jednak żadne wytłumaczenie dla stosowania przestarzałego kodu.

Skoro podstawowe definicje mamy już za sobą to na czym właściwie polega atak SQL Injection? Najłatwiej wytłumaczyć na przykładzie – pamiętaj że taki kod to najgorsze zło i nie powinieneś nigdy w ten sposób przekazywać zmiennych:

$sql = 'SELECT * FROM uzytkownicy WHERE id =' . $_GET['id'] . ' LIMIT 1';

Jaki jest podstawowy błąd? Zmienna `$_GET[‘id’]` pochodzi bezpośrednio od użytkownika. Nie została w żaden sposób zwalidowana, odfiltrowana bądź rzutowana. Właśnie takie podstawianie zmiennych do zapytania stosuje wielu początkujących. Żeby sprawdzić co możemy z tym zrobić utworzymy podstawową tabelę w bazie MySQL:

CREATE TABLE `uzytkownicy` (
	`id` INT(11) NOT NULL AUTO_INCREMENT,
	`user` VARCHAR(50) NOT NULL,
	`pass` VARCHAR(60) NOT NULL,
	PRIMARY KEY (`id`)
)
ENGINE=InnoDB;

INSERT INTO `uzytkownicy` (`id`, `user`, `pass`) VALUES (1, 'Janek', 'zahashowane hasło 1');
INSERT INTO `uzytkownicy` (`id`, `user`, `pass`) VALUES (2, 'Ania', 'zahashowane hasło 2');

Tabela użytkowników z identyfikatorem i hasłem występuje w 99% systemów. Możemy teraz napisać kod PHP:

<?php
// $_GET['id'] = '1 OR 1=1--';
try {
    $conn = new PDO("mysql:host=localhost;port=3306;dbname=test", 'root', 'hasło', [
        PDO::MYSQL_ATTR_INIT_COMMAND => "SET NAMES utf8"
    ]);
    $conn->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
    $sql = 'SELECT * FROM uzytkownicy WHERE id =' . $_GET['id'] . ' LIMIT 1';
    $stmt = $conn->query($sql); 
    $stmt->execute();
    $result = $stmt->fetchAll(PDO::FETCH_ASSOC);
    var_dump($result);
} catch (PDOException $e) {
    echo $e->getMessage();
}

W wyniku trzymamy:

array(2) {
  [0]=>
  array(3) {
    ["id"]=>
    string(1) "1"
    ["user"]=>
    string(5) "Janek"
    ["pass"]=>
    string(20) "zahashowane hasło 1"
  }
  [1]=>
  array(3) {
    ["id"]=>
    string(1) "2"
    ["user"]=>
    string(4) "Ania"
    ["pass"]=>
    string(20) "zahashowane hasło 2"
  }
}

czyli wszystkie rekordy z bazy. Włamywacz nie dość że podstawił swoje zapytanie, to jeszcze komentarzem `—` zmodyfikował nasze – usuwając klauzulę `LIMIT 1`. Jest to jeden z najprostszych przykładów włamania ale zawsze niezwykle skuteczny. A uwierz mi że atakujący ma dużo bardziej skomplikowane metody, razem ze skryptami które w sekundę przetestują tysiące kombinacji. Co warto zauważyć, ponieważ zdarzyło mi się spotkać z twierdzeniem że samo użycie PDO jest wystarczającą ochroną, to fakt że tak nie jest. Podstawienie niezabezpieczonej zmiennej jest zawsze tak samo niebezpieczne.

Mówiłem wcześniej o bindowaniu parametrów. Jak powinien wyglądać prawidłowo napisany kod?

<?php
// $_GET['id'] = '1 OR 1=1--';
$id = (int) $_GET['id'];
try {
    $conn = new PDO("mysql:host=localhost;port=3306;dbname=test", 'root', 'hasło', [
        PDO::MYSQL_ATTR_INIT_COMMAND => "SET NAMES utf8"
    ]);
    $conn->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
    $sql = 'SELECT * FROM uzytkownicy WHERE id = ? LIMIT 1';
    $stmt = $conn->prepare($sql);
    $stmt->bindParam(1, $id, PDO::PARAM_INT);
    $stmt->execute();
    $result = $stmt->fetchAll(PDO::FETCH_ASSOC);
    var_dump($result);
} catch (PDOException $e) {
    echo $e->getMessage();
}

Zauważ co się zmieniło. Zmienną `$_GET[‘id’]` rzutowaliśmy na `integer`, następnie do zapytania podstawiliśmy `placeholder` czyli znak zapytania w stringu `$sql`. Jest to specjalna konstrukcja która wskazuje że w danym miejscu należy zbindować parametr. Sam parametr przekazaliśmy metodą `bindParam()` dodatkowo wskazując że jest typu INTEGER. Wynikiem takiego zapytania jest poprawna tablica:

array(1) {
  [0]=>
  array(3) {
    ["id"]=>
    string(1) "1"
    ["user"]=>
    string(5) "Janek"
    ["pass"]=>
    string(20) "zahashowane hasło 1"
  }
}

Jest jeszcze skromny bonus takiego podejścia – samo zapytanie stało się niezwykle czytelne, nie ma żadnego łączenia ciągów, tylko prosty znak zapytania.

Jakie zapytania należy bindować w ten sposób? Wszystkie, nie ma żadnego wyjątku. Nieważne czy jest to prosty SELECT, czy INSERT, UPDATE, DELETE, wywołania procedur itp. Wszystko co jest niepewne musi zostać zbindowane. Jest małe ALE. Bindować nie można nazw kolumn i tabel. Jeśli chcesz je podstawiać dynamicznie należy wcześniej sprawdzić ich poprawność.

Oczywiście dobrym sposobem jest zawsze walidacja danych. Dzięki tej metodzie nie będzie potrzeby wysyłać zbędnego zapytania do bazy, gdy warunki nie zostały spełnione. Dla naszego `$_GET[‘id’]` walidator mógłby opierać się na wyrażeniach regularnych:

$id = '1 OR 1=1--';
$is_valid = preg_match('/^\d+$/', $id) ? true : false;
var_dump($is_valid); // false

To tylko jedna z wielu metod sprawdzenia poprawności wprowadzonych danych. Można wykorzystać wyrażenia regularne, funkcję filter_var, czy wręcz całe gotowe klasy.

Na koniec dodam jeszcze że chociaż SQL injection może mieć bardzo poważne konsekwencje, to nie należy zapominać że to tylko jeden z wielu popularnych ataków (XSS, session fixation, Local File Inclusion, Remote File Inclusion). A większość z nich ma swój początek właśnie w przepuszczeniu dowolnych danych od użytkownika do systemu. Dlatego powtórzę jeszcze raz: walidacja, filtracja, rzutowanie!

Komentarze

Nie ma jeszcze żadnych komentarzy do wyświetlenia. Może chcesz zostać pierwszą osobą która podzieli się swoją opinią?

Dodaj komentarz

*
Nazwa zostanie wyświetlona wraz z komentarzem. Możesz też utworzyć nowe konto w serwisie, dzięki czemu uzyskasz dodatkową funkcjonalność.
*
Akceptowana jest ograniczona składnia Textile. Wszystkie tagi HTML zostaną usunięte.