Paweł Łukasiewicz
2016-03-01
Paweł Łukasiewicz
2016-03-01
Wprowadzenie
W tym artykule dowiemy się czym jest SQL Injection, w jaki sposób wpływa na bezpieczeństwo naszych stron internetowych oraz jakie kroki powinniśmy podjąć tworząc aplikację ASP.NET, aby była odporna na takie ataki.
Jako programiści aplikacji webowych często mamy potrzebę przygotowania dynamicznych zapytań w celu wykonania pewnych operacji na bazie danych. Te dynamiczne zapytania SQL mogą być tworzone poprzez łączenie łańcuchów tekstowych z danymi wprowadzanymi przez użytkownika. Jeżeli takie dane nie są walidowanie, narażamy się na poważne ataki SQL Injection.
Ataki takie mają miejsce, kiedy użytkownik jako wejście wprowadza zapytanie SQL, które może spowodować utworzenie i wykonania zapytania, którego nie przewidział programista. Tak przygotowane instrukcje mogą skutkować nieautoryzowanym dostęp, ujawnieniem poufnych informacji czy nawet wymazaniem wszystkich danych znajdujący się w bazie danych.
Czym jest SQL Injection?
Przejdźmy do omówienia złych praktyk kodowania, które uczynią aplikacje bardziej podatną na ataki SQL Injection. W tym celu utworzymy prostą tabelę, która będzie zawierała login i hasło użytkownika niezbędne do przeprowadzenia procesu logowania:
Adnotacja:
Hasło nigdy nie powinno być przechowywane w tabeli jako zwykły tekst. Nasza tabela zawiera hasła w takiej postaci tylko i wyłączenie dla uproszczenia tego artykułu.
Następnie, po stronie aplikacji, przygotujemy najprostszy możliwy model do przechowywania wymaganych danych:
public class LoginModel
{
[Required(ErrorMessage = "To pole jest wymagane")]
public string Login { get; set; }
[Required(ErrorMessage = "To pole jest wymagane")]
public string Password { get; set; }
}
Kolejny krok to przygotowanie widoku pozwalającego na dokonanie logowania:
Przechodzimy do przygotowani kontrolera, który będzie odpowiedzialny za obsługę logowania do naszej aplikacji webowej. Poniżej kod tego kontrolera wraz z objaśnieniem:
using SQLInjectionExample.Models;
using System;
using System.Configuration;
using System.Data;
using System.Data.SqlClient;
using System.Web.Mvc;
namespace SQLInjectionExample.Controllers
{
public class SQLInjectionController : Controller
{
// GET: SQLInjection
public ActionResult Login()
{
return View();
}
[HttpPost]
public ActionResult Login(LoginModel _objMailModel)
{
// Sprawdzamy czy wymagane dane zostały przekazane
if(ModelState.IsValid)
{
// W tym miejscu wywołamy naszą metodę do sprawdzenia danych logowania
bool validation = this.IsUserAuthenticated(_objMailModel.Login, _objMailModel.Password);
if (validation)
return View("OK");
else
return View("WRONG");
}
else
{
return View(_objMailModel);
}
}
private bool IsUserAuthenticated(string login, string password)
{
DataTable result = null;
try
{
// Ustalamy połączenie do bazy danych
using (SqlConnection con = new SqlConnection(ConfigurationManager.ConnectionStrings["SQLInjectionsTutorialEntities"].ConnectionString))
{
// Sprawdzamy stan naszego połączenia, jeżeli zamknięty to otwieramy połączenie
// Takie podejście jest rozwiązaniem problemu:
// ServerVersion throws 'System.InvalidOperationException'
if (con.State == ConnectionState.Closed)
con.Open();
// A następnie definiujemy polecenie do wykonania
using (SqlCommand cmd = con.CreateCommand())
{
cmd.CommandType = CommandType.Text;
cmd.CommandText="select UserId from Users where UserId = '" + login + "' and password = '" + password + "'";
using (SqlDataAdapter da = new SqlDataAdapter(cmd))
{
result = new DataTable();
da.Fill(result);
// jeden zwrócony rekord oznacza poprawne zalogowanie
if(result.Rows.Count==1)
return true;
}
}
}
}
catch (Exception ex)
{
// Obsługa wyjątku
throw;
}
return false;
}
// Widok zwracany w momencie poprawnego logowania
public ActionResult OK()
{
return View();
}
// // Widok zwracany w momencie niepoprawnego logowania
public ActionResult WRONG()
{
return View();
}
}
}
Dla wszystkich normlanych użytkowników ten kod będzie wyglądał poprawnie. W przypadku podania poprawnych danych logowania zostaniemy poinformowaniu o pomyślnym logowaniu. W przeciwnym wypadku zostanie zwrócona flaga mówiąca o niepoprawnym przebiegu logowania.
Poniżej wygenerowane zapytanie SQL dla poprawnych danych logowania:
select UserId from Users where UserId = 'sampleuser' and Password = 'samplepassword'
Spróbujmy teraz wstrzyknąć trochę SQL do przygotowanej wcześniej strony. Jako login naszego użytkownika podamy: hacker’ or 1=1-- a hasło może być teraz dowolnym ciągiem znaków ponieważ wygenerowane zapytanie wygląda w następujący sposób:
select UserId from Users where UserId = 'hacker' or 1=1--' and Password = ''
Za każdym razem, gdy zostanie wykonana klauzula 1=1 zostanie zwrócona flaga true. Niezależnie od tego co poda nasz użytkownik, w przypadku zastosowania takiego obejścia, zostanie poprawnie zalogowany do naszej aplikacji. To co teraz umożliwiśmy to nieautoryzowany dostęp do naszej strony internetowej.
O sposobie zabezpieczenia się przed tym problemem opowiem później. Przygotujemy jeszcze jeden przykład, który lepiej pokaże istotę SQL Injection.
W drugim scenariuszu zakładamy, że nasz złośliwy użytkownik wszedł jakimś sposobem w posiadanie naszego schematu bazy danych i chce uzyskać dostęp do poufnych informacji. Załóżmy, że mamy przygotowaną stronę, która odpowiada za wyświetlenie wszystkich samochodów przypisanych do konkretnych użytkowników.
Tak prezentuje się nasza tabela:
Oraz przykładowe dane w niej zawarte:
Kolejny krok to przygotowanie kontrolera i widoku, które pozwolą nam na pobranie tych danych:
public class GetCarsDataController : Controller
{
// GET: GetCarsData
public ActionResult GetData(string userId)
{
DataTable data = this.ReturnData(userId);
return View(data);
}
private DataTable ReturnData(string userId)
{
DataTable result = null;
try
{
// Ustalamy połączenie do bazy danych
using (SqlConnection con = new SqlConnection(ConfigurationManager.ConnectionStrings["SQLInjectionsTutorialEntities"].ConnectionString))
{
// Sprawdzamy stan naszego połączenia, jeżeli zamknięty to otwieramy połączenie
// Takie podejście jest rozwiązaniem problemu:
// ServerVersion throws 'System.InvalidOperationException'
if (con.State == ConnectionState.Closed)
con.Open();
// A następnie definiujemy polecenie do wykonania
using (SqlCommand cmd = con.CreateCommand())
{
cmd.CommandType = CommandType.Text;
cmd.CommandText = "select * from Cars where AssignedTo = '" + userId + "'";
using (SqlDataAdapter da = new SqlDataAdapter(cmd))
{
result = new DataTable();
da.Fill(result);
}
}
}
}
catch (Exception)
{
// Obsługa wyjątku
throw;
}
return result;
}
}
A tak prezentują się pobrane przez nas dane:
Wykonany w tle Select przyjął postać:
select * from Cars where AssignedTo = ‘sampleuser’
Spróbujmy teraz użyć innego ciągu znaków zamiast naszego użytkownika: ' UNION SELECT 0 AS Expr1, password, userID FROM Users -- . Kiedy tylko takie polecenie zostanie wykonane wyświetlą się wszystkie zdefiniowane nazwy użytkowników oraz hasła z bazy danych. Taki rezultat będzie czytelny jak tylko spojrzymy na wykonane zapytanie:
select * from Cars where AssignedTo = '' UNION SELECT 0 AS Expr1, Password, UserId FROM Users --
Oraz rezultat wykonania takiego zapytania:
W powyższych przykładach mogliśmy zobaczyć jak dynamiczne tworzenie zapytań SQL przez łączenie kolejnych składowych wpływa na podatności na ataki typu SQL Injection. Wyobraźmy sobie scenariusz, w którym wstrzyknięto zapytanie powodujące usunięcie wszystkich tabel z bazy danych. Skutki takich działań mogłyby być katastrofalne.
Jak zapobiegać SQL Injection?
Należy przestrzegać pewnych zasad, które zostały wymienione poniżej:
- Nigdy nie wolno ufać danym wprowadzanym przez użytkownika. Zawsze powinny zostać poddane walidacji.
- Dynamiczne zapytania nigdy nie powinny być tworzone za pomocą łączenia kolejnych składowych zapytania.
- Należy używać procedur składowanych.
- Jeżeli wymagane jest używanie dynamicznych zapytań, powinny być one tworzone przy pomocy parametryzowanych poleceń.
- Wszystkie poufne informacje powinny być przechowywane w postaci zaszyfrowanej.
- Aplikacja nigdy nie powinna mieć dostępu do bazy danych z uprawnieniami administratora.
Nigdy nie wolno ufać danym wprowadzanym przez użytkownika. Zawsze powinny zostać poddane walidacji.
Zasada ta mówi, iż nie wolno ufać danym wprowadzanym przez użytkownika. Na każde z pól wejściowych powinny zostać nałożone filtry. Jeżeli pole ma służyć do wprowadzania numerów nie powinniśmy pozwolić na możliwość dodania znaków alfabetu. Ponadto, wszystkie dane wejściowe powinny być sprawdzane za pomocą wyrażeń regularnych tak, aby żadne znaki SQL nie zostały przekazane do bazy danych.
Zarówno ustawienie filtrów jak i walidacja powinny być wykonane po stronie klienta przy użyciu JavaScript. Będzie to wystarczające dla normlanego użytkownika. Złośliwy użytkownik może być w stanie obejść zabezpieczenia po stronie klienta, należy wówczas podobną walidację wykonać po stronie serwera.
Dynamiczne zapytania nigdy nie powinny być tworzone za pomocą łączenia kolejnych składowych zapytania.
Jeżeli w naszej aplikacji znajdują się dynamiczne zapytania SQL, nie należy ich łączyć ze sobą przez tradycyjne sklejanie kolejnych składowych tego zapytania. Takie działania skutkują narażeniem naszej aplikacji na ataki SQL Injection. Wskazane jest, aby uniknąć takich połączeń całkowicie.
Należy używać procedur składowanych.
Procedury składowane są najlepszym sposobem wykonywania operacji na bazie danych. Zawsze będziemy pewni, że po zastosowaniu procedur składowanych nie zostanie wygenerowany błędne zapytanie do bazy danych. Utworzymy procedurę składowaną, która będzie wymagała loginu i hasła do przeprowadzenia procesu weryfikacji użytkownika:
CREATE PROCEDURE dbo.CheckUser (@UserId varchar(20), @Password varchar(16))
AS
select UserId from SQLInjectionsTutorial.dbo.Users where
UserId = @UserId and Password = @Password
RETURN
A wywołanie takiej procedury w środowisku SQL Server przyjmuje postać:
EXEC dbo.CheckUser 'sampleuser', 'samplepassword'
W naszym kodzie przygotujemy nową metodę, która weryfikację użytkownika będzie opierała na przygotowanej procedurze składowanej:
private bool IsAuthenticatedProcedure(string login, string password)
{
DataTable result = null;
try
{
using (SqlConnection con = new SqlConnection(ConfigurationManager.ConnectionStrings["SQLInjectionsTutorialEntities"].ConnectionString))
{
if (con.State == ConnectionState.Closed)
con.Open();
using (SqlCommand cmd = con.CreateCommand())
{
cmd.CommandType = CommandType.StoredProcedure;
cmd.CommandText = "CheckUser";
cmd.Parameters.Add(new SqlParameter("@UserId", login));
cmd.Parameters.Add(new SqlParameter("@Password", password));
using (SqlDataAdapter da = new SqlDataAdapter(cmd))
{
result = new DataTable();
da.Fill(result);
if (result.Rows.Count == 1)
return true;
}
}
}
}
catch (Exception ex)
{
throw;
}
return false;
}
Jeżeli wymagane jest używanie dynamicznych zapytań, powinny być one tworzone przy pomocy parametryzowanych poleceń.
Jeżeli wciąż zachodzi potrzeba pisania dynamicznych zapytań wówczas parametryzowane polecenia są najlepszym sposobem do wykonywania tego typu poleceń. Stosując takie podejście możemy być pewni, że żadne złe zapytanie SQL nie zostanie wygenerowane. W ramach testu takiego podejścia przygotujemy kolejną metodę, która tym razem wykorzysta parametryzowane polecenia:
private DataTable ReturnDataParametrized(string login)
{
DataTable result = null;
try
{
using (SqlConnection con = new SqlConnection(ConfigurationManager.ConnectionStrings["SQLInjectionsTutorialEntities"].ConnectionString))
{
if (con.State == ConnectionState.Closed)
con.Open();
using (SqlCommand cmd = con.CreateCommand())
{
cmd.CommandType = CommandType.Text;
cmd.CommandText = "select * from Cars where AssignedTo = @UserId";
cmd.Parameters.Add(new SqlParameter("@UserId", login));
using (SqlDataAdapter da = new SqlDataAdapter(cmd))
{
result = new DataTable();
da.Fill(result);
}
}
}
}
catch (Exception ex)
{
throw;
}
return result;
}
Wszystkie poufne informacje powinny być przechowywane w postaci zaszyfrowanej
Korzyść z takiego podejścia wynika z faktu, że jeżeli osoba nieuprawiona wejdzie w posiadanie poufnych informacji będzie w stanie wyświetlić je tylko w postaci zaszyfrowanej. Dla osób niezaznajomionych z technikami szyfrowania odszyfrowanie takich danych może być bardzo skomplikowane. Dodatkowo, jeżeli nasze dane zostaną zaszyfrowane przy pomocy odpowiednich metod, może się okazać, że nawet osoba obeznana w takich technikach nie będzie w stanie odszyfrować naszych danych.
Aplikacja nigdy nie powinna mieć dostępu do bazy danych z uprawnieniami administratora
W takim podejściu mamy pewność, że nawet w momencie przekazania do naszej bazy danych złośliwych zapytań nie dojdzie do takich działań jak usunięcie tabel – baza danych nie pozwoli na takie działania.
Podsumowanie
Jest to bardzo prosty artykuł poświęcony SQL Injection. Zostało w nim zaprezentowane podejście użycia technologii ADO.NET w webowej aplikacji. Artykuł został przygotowany dla osób, które chciały dowiedzieć się co nieco o takich atakach oraz jak w prosty i szybki sposób można im zapobiegać.