Wstrzykiwanie kodu SQL - podstawy. Jak się bronić?

2013-08-12 | Mirosław Zelent

W wolnej chwili postanowiłem napisać co nieco o SQL injection - dosyć popularnej metodzie ataku na serwisy www korzystające z baz danych. Jest to poradnik na poziomie podstawowym, ma on za zadanie przedstawić programistom rozpoczynającym swoją przygodę z PHP naturę zagrożenia oraz docelowo zaznajomić ich ze sposobami ochrony witryny. Nie ponoszę też żadnej odpowiedzialności za wykorzystanie zawartej tutaj wiedzy w niewłaściwy sposób. Jeżeli nie znasz w ogóle języka SQL, a masz ochotę poznać, to zapraszam tutaj.

Osobiście uważam, że najlepiej uczyć się poprzez przykłady, nawet jeśli nasza testowa witryna i baza danych będą jedynie uproszczonym na potrzeby prezentacji modelem. Załóżmy więc, że jesteśmy autorami witryny z grą przeglądarkową, w której gracze kierują osadą ludzką - coś na kształt Traviana, czy Settlersów. Na hipotetycznym serwerze tej gry znajdują się m.in. pliki:

  1. index.php → nasza strona główna z panelem logowania gracza do swojego konta.
  2. zalogowany.php → plik, który po otrzymaniu metodą POST wpisanego w index.php loginu i hasła, łączy się z bazą MySQL i sprawdza czy takie konto istnieje. Jeżeli znaleziono rekord w bazie, to zalogowany już do systemu gracz otrzyma na ekranie informacje o posiadanych surowcach (drewno, kamień, zboże) oraz inne szczegóły swojego konta: adres e-mail i informację ile dni premium mu pozostało. Jeżeli gracza o takim loginie i haśle nie ma w bazie, to wracamy do pliku index.php z informacją o nieudanym logowaniu.

Teraz pora przedstawić listingi plików PHP (składniowo poprawnych, lecz kompletnie nieodpornych na wstrzykiwanie SQL), a także pokazać bazę MySQL obsługującą serwis. Jeśli ktoś ma ochotę, to źródła te można pobrać na swój komputer klikając przycisk poniżej i zainstalować na swoim localhoście z użyciem pakietu XAMPP.

Pobierz pliki źródłowe i bazę danych MySQL

index.php

  1. <html>
  2. <head>
  3. <meta http-equiv="content-type" content="text/html; charset=utf-8" />
  4. </head>
  5.  
  6. <body>
  7. Osadnicy - logowanie<br />
  8. <form action="zalogowany.php" method="post">
  9. Login:<br /><input name="login" type="text"/><br />
  10. Hasło:<br /><input name="haslo" type="password"/><br /><br />
  11. <input type="submit" value="Zaloguj się"/>
  12. </form>
  13.  
  14. <?php
  15. session_start();
  16. if ((isset($_SESSION['blad']))&&($_SESSION['blad']==true))
  17. echo '<font color="red">Nieprawidłowy login lub hasło</font>';
  18. ?>
  19. </body>
  20. </html>

zalogowany.php

  1. <?php
  2. session_start();
  3. require_once "mysqlconnect.php";
  4.  
  5. $polaczenie = mysql_connect($host,$db_user,$db_password);
  6. mysql_query("SET CHARSET utf8");
  7. mysql_query("SET NAMES 'utf8' COLLATE 'utf8_polish_ci'");
  8. mysql_select_db("osadnicy");
  9.  
  10. $login = $_POST['login'];
  11. $haslo = $_POST['haslo'];
  12.  
  13. $rezultat = mysql_query("SELECT * FROM users WHERE user='$login' AND pass='$haslo'");
  14.  
  15. if(mysql_num_rows($rezultat)>0)
  16. {
  17. $row = mysql_fetch_assoc($rezultat);
  18. echo '<html><head><meta http-equiv="content-type"
  19. content="text/html; charset=utf-8" /></head><body>';
  20. echo "Witaj ".$row['user'].'! [ <a href="logoff.php">Wyloguj się ]<br />';
  21. echo "<b>Drewno</b>: ".$row['drewno'];
  22. echo " | <b>Kamień</b>: ".$row['kamien'];
  23. echo " | <b>Zboże</b>: ".$row['zboze']."<br />";
  24. echo "<b>Twój e-mail</b>: ".$row['email'];
  25. echo " | <b>Dni premium</b>: ".$row['dnipremium'];
  26. echo "</body></html>"; }
  27.  
  28. else {
  29. $_SESSION['blad'] = true;
  30. header('Location: index.php');
  31. }
  32.  
  33. mysql_close($polaczenie);
  34. ?>

Baza MySQL o nazwie "osadnicy", tabela "users"

Poznajmy naturę zagrożenia

Na początku zapewne każdy zadaje samemu sobie pytanie: jak to w ogóle możliwe, że ktoś zdoła wywołać zapytanie SQL inne niż to, które zapisałem w oryginalnym pliku źródłowym (zalogowany.php)? Przecież skrypt PHP wykonuje się po stronie serwera, zasadniczo użytkownik strony nie ma żadnej możliwości nawet go zobaczyć, a co dopiero zmienić jego treści. To oczywiście prawda, ale mimo wszystko zostawiliśmy sprytnemu użytkownikowi furtkę - część naszego zapytania stanowi przecież tekst wprowadzony właśnie przez niego do pól: login i hasło. Znając składnię konstruowania zapytań SQL będziemy w stanie (i to w dość trywialny sposób) zmienić to zapytanie na własne potrzeby.

1. Znamy login pewnego gracza, czy można zalogować się bez znajomości jego hasła?

Niestety - nieodporny na wstrzykiwanie kod sprawia, że można. Znamy nick jednego z graczy: marek. Zauważ co się stanie, gdy jako login i hasło podamy następujące ciągi znaków:

login: marek' --   [marek, apostrof, spacja, dwa myślniki, spacja]
hasło: cokolwiekNie znając hasła gracza "marek", udaje nam się zalogować na jego konto:

Wykorzystaliśmy tutaj najzwyklejszy znak komentarza w MySQL, czyli dwa następujące po sobie myślniki. Jak w większości języków programowania część kodu znajdująca się po znaku komentarza jest ignorowana - stąd nie sprawdzono zgodności hasła (a jedynie loginu), bo zapytanie zmieniło się w następujący sposób:

2. Nie znamy żadnego loginu i żadnego hasła

Czy można dostać się do środka gry nie znając nawet loginu? Niestety - jest to również możliwe. Wejdziemy na konto pierwszego gracza w bazie:
login: dowolny
hasło: ' OR 1=1 --  [apostrof, spacja, OR, spacja, 1=1, spacja, dwa myślniki, spacja]
Udaje nam się zalogować na pierwsze z góry konto w bazie - jest to profil gracza "adam":

Tym razem do całego zapytania dorzuciliśmy klauzulę OR (ang. "lub") z warunkiem zawsze prawdziwym (bo przecież 1=1 to zdanie zawsze prawdziwe, niezależnie od wartości zmiennych $login i $haslo - jest to tzw. tautologia). Całe zapytanie jest teraz trójczłonowe, czytamy je od lewej do prawej strony i jako całość okazuje się być prawdziwe już dla pierwszego rekordu w bazie:

3. Przeszukiwanie bazy w poszukiwaniu wybranego nicka lub numeru id

Czy można przeszukać bazę dzięki wstrzyknięciu SQL? Wszystko zależy od tego jak wiele wiemy o bazie (to znaczy czy uda nam się odgadnąć nazwę kolumny w tabeli, która przechowuje login albo numer id). Jak o tym pomyśleć na spokojnie, to nie jest to aż takie trudne zadanie - w naszym przykładzie nie zajmie długo zorientowanie się metodą prób i błędów, że użytkownicy są zestawieni w kolumnie "user", zaś ich numery w kolumnie "id". Niestety istnieją też pewne techniki analizy odpowiedzi otrzymywanych z serwera na błędne zapytania, które pozwalają zdobyć część potrzebnych informacji. Dane do logowania byłyby takie:

a) sprawdźmy czy istnieje gracz o loginie "anna":
login: ' OR user='anna' -- 
hasło: dowolne
b) sprawdźmy który z graczy ma w bazie numer id=7:
login: ' OR id=7 -- 
hasło: dowolne

Jeśli tylko znaleziono gracza o nicku "anna", zostajemy zalogowani na jej profil. Jeśli gracz o numerze id=7 istnieje w bazie to zostajemy zalogowani na jego konto. I możemy się tak logować na każde istniejące w bazie id gracza. Będziemy w stanie przelewać istniejące w grze surowce wirtualne, burzyć budynki, albo co gorsza - zbierać dane mailowe (a potencjalnie osobowe) z rzeczywistego świata. Wszystko przez złe zabezpieczenia:

Oczywiście istnieją jeszcze inne, bardziej wyrafinowane metody ataku - m.in. z użyciem klauzuli LIKE (Czy hasło zaczyna się na literę "a"? Tak? A czy w takim razie pierwsze dwie litery to "aa"? Nie? To może "ab"? itd.), z podpięciem klauzuli UNION SELECT, z wykorzystaniem ORDER BY by znaleźć osobę z np. największą ilością drewna czy wykupionych dni premium. Kiedyś sytuacja była jeszcze gorsza, bo istnieje w MySQL operator średnika ; który pozwala do SELECT dopisać kolejne zapytanie i to zupełnie dowolne (np. UPDATE i konto przejęte, INSERT i konto stworzone, DROP i cała tabela zniszczona). Na szczęście PHP pozwala obecnie przesłać tylko jedno zapytanie wewnątrz funkcji mysql_query() Średnik zostanie zignorowany a drugie zapytanie się nie wykona.Tak czy inaczej, powinniśmy już mieć dobre pojęcie na czym polega natura ataku SQL injection. Pora pozmawiać o najważniejszym:

Jak bronić witrynę przed wstrzykiwaniem SQL?

Zobaczywszy powyższe przykłady już na pewno wiesz co nas zgubiło - zaufaliśmy ciągowi znaków przesłanemu przez użytkownika. A to niewybaczalny błąd. Już przy pisaniu pierwszych programów poznajemy tą prawdę - największym zagrożeniem dla programu jest człowiek. I niekoniecznie musi to być hacker. Często przez pomyłkę, niewiedzę lub przypadek ktoś wpisuje napis zamiast liczby czy próbuje założyć buty na głowę na ekranie ekwipunku postaci w grze RPG. To na nas, programistach spoczywa obowiązek dokonania walidacji (sprawdzenia poprawności) i sanityzacji (wyczyszczenia z potencjalnie groźnych zapisów) tworzonego przez nas kodu.

Wiele osób na pewno wykaże się tutaj dobrą intuicją i powie, że przed wykonaniem zapytania SQL należałoby sprawdzić czy przypadkiem nie użyto w nicku i haśle jakiegoś apostrofa albo myślnika. Zrobilibyśmy własną funkcję, która to sprawdzi i jeśli wykryje ich użycie powstrzyma wykonanie zapytania SQL albo zamieni znaki na kody specjalne HTML - to też jest apostrof: &#039; a to myślnik: &#045; Istnieje też funkcja addslashes() dopisującą do każdego apostrofa znak slasha: '. W założeniu ten zabieg ma sprawić, że znak po slashu pojawi się na ekranie i nie będzie zinterpretowany jako znak będący częścią kodu SQL.

Teraz może już lepiej zdajemy sobie sprawę, dlaczego wiele serwisów internetowych pozwala używać w loginach i hasłach jedynie małych i wielkich liter oraz cyfr :) Inna rzecz, że czasami użytkownik musi mieć możliwość wpisania znaków specjalnych: slasha, apostrofa, myślnika (np. w treści wiadomości przesyłanej pomiędzy dwoma graczami).Piszę to wszystko, żeby uzmysłowić Ci prosty fakt, że stworzenie dobrych funkcji zabezpieczających jest trudne i wymaga doświadczenia. Na szczęście ludzie tworzą witryny internetowe w PHP nie od dzisiaj i dysponujemy gotowymi funkcjami, których należy użyć w każdym miejscu gdzie dostajemy łańcuch od użytkownika. Bezpieczna wersja strony zalogowany.php wygląda tak (plik również znajduje się w archiwum do pobrania wyżej):

zalogowany_wersja_bezpieczna.php

  1. <?php
  2. session_start();
  3. require_once "mysqlconnect.php";
  4.  
  5. $polaczenie = mysql_connect($host,$db_user,$db_password);
  6. mysql_query("SET CHARSET utf8");
  7. mysql_query("SET NAMES 'utf8' COLLATE 'utf8_polish_ci'");
  8. mysql_select_db("osadnicy");
  9.  
  10. $login = $_POST['login'];
  11. $haslo = $_POST['haslo'];
  12. $username=htmlentities($username, ENT_QUOTES, "UTF-8");
  13. $password=htmlentities($password, ENT_QUOTES, "UTF-8");
  14. $zapytanie=sprintf("SELECT * FROM uzytkownicy WHERE user='%s' AND pass='%s'",
  15. mysql_real_escape_string($username),
  16. mysql_real_escape_string($password));
  17. $rezultat=mysql_query($zapytanie);
  18.  
  19. if(mysql_num_rows($rezultat)>0)
  20. {
  21. $row = mysql_fetch_assoc($rezultat);
  22. echo '<html><head><meta http-equiv="content-type"
  23. content="text/html; charset=utf-8" /></head><body>';
  24. echo "Witaj ".$row['user'].'!
  25. [ <a href="logoff.php">Wyloguj się</a> ]<br />';
  26. echo "<b>Drewno</b>: ".$row['drewno'];
  27. echo " | <b>Kamień</b>: ".$row['kamien'];
  28. echo " | <b>Zboże</b>: ".$row['zboze']."<br />";
  29. echo "<b>Twój e-mail</b>: ".$row['email'];
  30. echo " | <b>Dni premium</b>: ".$row['dnipremium'];
  31. echo "</body></html>";
  32.  
  33. } else { $_SESSION['blad'] = true;
  34. header('Location: index.php');
  35. }
  36.  
  37. mysql_close($polaczenie);
  38. ?>

Zmieniony fragment znajduje się w liniach 12-20. Najpierw w liniach 12 i 13 przepuszczamy łańcuchy otrzymane od usera przez funkcję o nazwie htmlentities() - zobacz manual, która zamienia możliwe do konwersji znaki na kody specjalne HTML (tzw. encje; z ang. entities). Przykład działania poniżej:

  1. <?php
  2. $napis = "Jakiś 'cytat' jest <b>pogrubiony</b>";
  3. echo htmlentities($napis, ENT_QUOTES, "UTF-8");
  4. //Otrzymamy jako wyjście:
  5. //Jakiś &#039;cytat&#039; jest &lt;b&gt;pogrubiony&lt;/b&gt;
  6. ?>

Zwróć uwagę na dodatkowy argument "UTF-8" - oczywiście ma on związek z użytym na stronie kodowaniem i jest opcjonalny. Teraz pora na najważniejsze linie w kodzie: 15-18. Warto dobrze zrozumieć ten fragment - dzięki temu będziesz w stanie pisać odporny na wstrzykiwanie SQL kod. Użyto tutaj dwóch funkcji:

mysql_real_escape_string( ) - zobacz manual to jest funkcja stricte dedykowana do walki ze wstrzykiwaniem kodu SQL - dodaje ona znaki unikowe w łańcuchu (czyli te, które sprawią, że niebezpieczne znaki nie zostaną przeczytane jako część kodu). Możemy założyć, że otrzymanego łańcucha można bezpiecznie użyć w funkcji mysql_query(). Programiści PHP nazywają ten zabieg "escaping" - funkcja poradzi sobie z wykryciem i unieszkodliwieniem znaków stosowanych we wstrzykiwaniu: x00, n, r, , ', ", x1a.

Jeszcze jedna ważna zasada: żeby użyć tej funkcji należy być połączonym z bazą danych, czyli umieścić jej wywołanie pomiędzy: mysql_connect() i mysql_close(). Inaczej możemy otrzymać błąd w stylu: mysql_real_escape_string [function.mysql-real-escape-string]: Access denied for user ‘user’@'localhost’ (using password: NO) in /home/www/…

sprintf( ) - zobacz manual to pomocnicza funkcja, która po pierwsze pilnuje typu przesyłanej zmiennej (np. %s oznacza, że ma to być string, czyli łańcuch; %d oznaczałby integer, czyli liczbę całkowitą). Po drugie sprawia, że kod jest czytelniejszy dla oka, bo zmienne są po kolei wypisane na końcu zapytania:

Jeżeli doczytałeś/aś do tego miejsca to gratuluję → teraz już wiesz, jak bronić się przed tą metodą ataku. Wkrótce może opowiem co nieco o wstrzykiwaniu kodu javascript, ale i tak cieszy mnie fakt, że znasz już najważniejszą zasadę bezpieczeństwa, którą ja lubię nazywać zasadą ograniczonego zaufania - trochę jak na drodze, tylko że tu mamy do czynienia z cyfrowym highwayem.