Obrana proti útoku SQL injection v PHP

PHP Bezpečnost Obrana proti útoku SQL injection v PHP

ONEbit hosting Unicorn College Tento obsah je dostupný zdarma v rámci projektu IT lidem. Vydávání, hosting a aktualizace umožňují jeho sponzoři.

SQL injection je častá bezpečnostní trhlina mnoha webů. Při tomto typu útoku útočník upraví SQL dotaz ve svůj prospěch. Nejčastěji se používá u dotazů typu SELECT, UPDATE, INSERT a podmínky WHERE. Ukážeme si, jak lze útok provést a jak se mu bránit. V příkladech využijeme databáze MySQL.

Příklad

Ukážeme si příklad s akcí, která je skoro na každém webu - přihlášení uživatele.

Mějme primitivní tabulku s uživateli:

Databázová tabulka uživatelů

Vytvoříme si formulář, do kterého uživatel zadá své přihlašovací jméno a heslo:

<form method="post">
        <table>
                <tr><th><label for="name">Jméno</th></tr>
                <tr><td><input type="text" name="name" id="name" /></td></tr>

                <tr><th><label for="password">Heslo</th></tr>
                <tr><td><input type="password" name="password" id="password" /></td></tr>

                <tr><td><input type="submit" value="Přihlásit se" /></td></tr>
        </table>
</form>

Pro představu může vypadat třeba takto:

Přihlašovací formulář

Při zpracování v PHP potom vložíme hodnoty z $_POST přímo do SQL kódu databázového dotazu pro výběr uživatele. Zpracování může vypadat následovně:

session_start();
$pdo = new PDO("přihlašovací údaje");

$errors = array();
if ($_POST) {
   if (empty($_POST["name"])) {
        $errors[] = "Nebylo vyplněno jméno.";
   }
   if (empty($_POST["password"])) {
        $errors[] = "Nebylo vyplněno heslo.";
   }

   if (empty($errors)) {
        $name = $_POST["name"];
        $password = hash("SHA512", $_POST["password"] . 'sůůůl');
        // Dotaz níže obsahuje nebezpečnou SQL injekci
        $idQuery = $pdo->query("
                SELECT `id`
                FROM `user`
                WHERE `name` = '{$name}' AND `password` = '{$password}'
                LIMIT 1
        ");
        $id = $idQuery->fetchColumn();

        if ($id !== FALSE) {
                $_SESSION["userId"] = $id;
                header("location:account.php");
                exit;
        } else {
                $errors[] = "Bylo zadáno špatné jméno nebo heslo.";
        }
   }
}

Nepoužíváte PDO, ale staré funkce mysql_*? Pak by vyhledání ID vypadalo takto:

// Dotaz níže obsahuje nebezpečnou SQL injekci
$idQuery = mysql_query("
        SELECT `id`
        FROM `user`
        WHERE `name` = '{$name}' AND `password` = '{$password}'
        LIMIT 1
");
$id = mysql_result($idQuery, 0);

Dáme tento kód do ostrého provozu. Přihlášení funguje - my jsme šťastní, uživatelé jsou šťastní. Bohužel jsou v tomto případě šťastní i útočníci.

SQL injection

MySQL databáze má určité speciální znaky. Pro tento případ jsou pro nás důležité tyto:

  • apostrof / uvozovky - ohraničení řetězce
  • pomlčka - dvě pomlčky znamenají komentář (jako např. v PHP dvě lomítka)

Když do našeho formuláře vyplníme například pavelco1998 a tajne_heslo, pak se pošle tento SQL dotaz:

SELECT `id`
FROM `user`
WHERE `name` = 'pavelco1998' AND `password` = '19c44a96a09dc0088f88d...'
LIMIT 1

Nebezpečí nastává v případě, kdy uživatel (jakožto útočník) zadá do jména tento řetězec:

' OR admin = 1--

Do databáze se poté pošle útočníkem upravený SQL dotaz:

SELECT `id`
FROM `user`
WHERE `name` = '' OR admin = 1-- AND `password` = '19c44a96a09dc0088f88d...'
LIMIT 1

Ukázka vstupu:

Vyplnění nebezpečné hodnoty

Všimněte si, že díky zadanému apostrofu se ukončí řetězec u sloupce name a za ním se vloží podmínka OR admin = 1. Díky dvěma pomlčkám se zbytek dotazu považuje za komentář.

Takže pokud napíšeme upravený dotaz slovy, bude vypadat následovně:

Vyber ID z tabulky user, kde jméno se rovná prázdnému řetězci nebo admin se rovná jedné. Zbytek dotazu ignoruj.

Dotaz samozřejmě vybere administrátora, za kterého aplikace útočníka následně přihlásí.

Pokud by chtěl útočník čistě jen někomu ukradnout účet, stačilo by do textového pole zadat následující řetězec: ' OR id = 153-- (či jakékoliv jiné číslo).

Je krádež účtu málo? Dobře, smažeme všechny uživatele:

'; TRUNCATE TABLE user;--

Pro ukázku by se poslal tento dotaz:

SELECT `id`
FROM `user`
WHERE `name` = ''; TRUNCATE TABLE user;-- AND `password` = '19c44a96a09dc0088f88d...'
LIMIT 1

K tomuto útoku již musí web používat ovladač, který podporuje více dotazů v jedné query. Podobně můžeme vymazat msíto tabulky rovnou celou databázi.

Útok lze aplikovat i u jiných dotazů, např. u INSERT:

$pdo->query("
        INSERT INTO `user` (`name`, `password`) VALUES ('$name', '$password')
");

Při zadání řetězce

', ''); TRUNCATE TABLE user;--

Jako jméno uživatele by se vytvořil tento dotaz:

INSERT INTO `user` (`name`, `password`) VALUES ('', ''); TRUNCATE TABLE user;--, 'nejake_heslo')

Někdo by možná argumentoval, že přece návštěvník nezná název naší databáze, tabulky, sloupců atd. Jenže 90% webů má tabulku s uživateli pojmenovanou buď users, uzivatele nebo maximálně user a uzivatel. Útočník má velmi vysokou šanci, že se strefí. Představte si, že uděláte SQL injekci v seriózní aplikaci, kterou si klienti platí. Nejspíše by nebyli spokojeni, kdyby někdo smazal všechna jejich data.

Jak aplikaci chránit?

Problém je samozřejmě v tom, že vkládáme přímo proměnnou do SQL dotazu. A je jedno, jestli je přímo od uživatele nebo ne, vždy je riziko, že může dotaz nějakým způsobem rozbít.

Zastaralý způsob ochrany je ošetřování proměnných (tzv. escapování), které ovšem v několika případech selhává. Jediná správná ochrana proti tomuto útoku je nevkládat proměnné do dotazů vůbec a používat tzv. Prepared Statements (parametrizované dotazy).

Parametrizované dotazy (prepared statements)

SQL dotazy nejprve připravíme a to tak, že místo hodnot napíšeme zástupné znaky. Hodnoty a dotaz předáme databázi úplně odděleně a ona si je tam sama automaticky vloží tak, aby to bylo bezpečné. Automat na rozdíl od lidí nechybuje a my si můžeme být jistí, že se nevystavujeme žádnému riziku.

PDO pro parametrizované dotazy nabízí dvě metody - PDO::prepare() a PDOStatement::e­xecute():

$name = $_POST["name"];
$password = hash("SHA512", $_POST["password"] . 'sůůůl');

$prepared = $pdo->prepare("
        SELECT `id`
        FROM `user`
        WHERE `name` = ? AND `password` = ?
        LIMIT 1
");

// Metoda prepare() vrací instanci třídy PDOStatement
$prepared->execute(array($name, $password));

Místo proměnných použijeme v dotazu otazníky. Proměnné předáme později najednou v poli. Zástupné znaky můžeme i pojmenovat:

$name = $_POST["name"];
$password = hash("SHA512", $_POST["password"]);

$prepared = $pdo->prepare("
        SELECT `id`
        FROM `user`
        WHERE `name` = :name AND `password` = :password
        LIMIT 1
");

$prepared->execute(array(
        ":name" => $name,
        ":password" => $password
));

Pojmenované zástupné znaky se mohou hodit jak pro přehlednost dotazu, tak i v případě, že jednu hodnotu chceme použít vícekrát (nemusíme psát tolik otazníků, kolikrát chceme hodnotu použít).

Některé databázové nadstavby (wrappery) umožňují zápis prepare() - execute() zkrátit. Naše tři metody pro získání ID uživatele bychom mohli vyměnit za jednu:

$id = $db->fetchColumn("
        SELECT `id`
        FROM `user`
        WHERE `name` = ? AND `password` = ?
        LIMIT 1
", $name, $password);

První parametr je SQL dotaz, ostatní jsou parametry, které se předají metodě PDOStatement::e­xecute().

Manuální escapování

Druhým, zastaralým a nebezpečným způsobem obrany proti SQL injekci je proměnné ručně ošetřovat (tzv. escapovat). Tento způsob nepoužívejte, ukážeme si ho jen pro úplnost.

Každá databáze má trochu jinou strukturu SQL dotazu, proto má také jiný algoritmus pro ošetření řetězce. Databáze MySQL pro to má funkci s názvem mysql_real_es­cape_string(). Funkce předsadí nebezpečné znaky jako uvozovky zpětným lomítkem, databáze je potom bere jako text a ne jako část dotazu.

$name = mysql_real_escape_string($_POST["name"]);
$password = mysql_real_escape_string((hash("SHA512", $_POST["password"] . 'sůůůl'));

$idQuery = mysql_query("
        SELECT `id`
        FROM `user`
        WHERE `name` = '{$name}' AND `password` = '{$password}'
        LIMIT 1
");

PDO ovladač má pro tyto účely metodu quote():

$name = $pdo->quote($_POST["name"]);
$password = $pdo->quote(hash("SHA512", $_POST["password"] . 'sůůůl'));

$idQuery = $pdo->query("
        SELECT `id`
        FROM `user`
        WHERE `name` = '{$name}' AND `password` = '{$password}'
        LIMIT 1
");

Jsme tedy zabezpečení? Ne, je to jen iluze. Představte si tento dotaz:

'delete * from user where id=' . mysql_real_escape_string($_GET['id'])

Útočník může do getu zadat řetězec:

1 OR 1=1

A hle, nejsou v něm žádné uvozovky ani jiné škodlivé znaky. Escapovací funkce tedy neprovede nic a útočník stejně vymaže všechny uživatele místo jednoho. Jak se tomu bránit? Zkusme dát hodnotu do uvozovek, i když je to číslo:

'delete * from user where id="' . mysql_real_escape_string($_GET['id']) . '"'

Dotaz funguje, databáze se s číslem zadaným jako text v tomto případě popere. Když bychom takto však zadali číslo v klauzuli LIMIT, máme problém. Jediné řešení je přetypovat parametry na int:

'select * from user LIMIT ' . (int)($_GET['id'])

Musíme přemýšlet nad typem dat a podle toho ručně ošetřovat. Máme spoustu prostoru k tomu, abychom vytvořili nějakou bezpečnostní chybu. Proto vždy používejte parametrizované dotazy, nikdy proměnné neošetřujte sami.

Databázové knihovny

Pro PHP existují knihovny, které dotazy automaticky generují pomocí volání metod. Takové knihovny v nitru obvykle používají parametrizované dotazy. Rozdíl je v tom, že sestaví SQL dotaz přesně podle druhu databáze. Pokud bychom dotaz psali ručně, museli bychom ho po změně databáze upravit (= práce navíc). Například ve frameworku Nette by šlo dotaz sestavit pomocí zřetězení metod:

$id = $db->table("user")->where(array("name" => $name, "password" => $password))->fetch()->id;

Závěrem

Každý vstup od uživatele znamená pro vaši aplikaci potenciální nebezpečí. Nikdy nevkládáme proměnné do SQL dotazu, jinak se vystavujeme bezpečnostnímu riziku. V SQL dotazech se smí vyskytovat pouze zástupné znaky. Proměnné předáme až ve druhém kroku a odděleně.


 

  Aktivity (1)

Článek pro vás napsal Martin Konečný (pavelco1998)
Avatar
Autor se o IT moc nezajímá, raději by se věnoval speciálním jednotkám jako jsou SEALs nebo SAS. Když už to ale musí být něco z IT, tak tvorba web. aplikací v PHP.

Jak se ti líbí článek?
Celkem (18 hlasů) :
4.888894.888894.888894.888894.88889


 


Miniatura
Všechny články v sekci
Bezpečnost webových aplikací v PHP

 

 

Komentáře
Zobrazit starší komentáře (2)

Avatar
asanos
Člen
Avatar
asanos:

Ohledně tohohle:

'; TRUNCATE TABLE user;--

Zkoušel jsi to?
__________________
Vím, že mysql_query() nepodporuje řetězení dotazů, čili tam by to nešlo, ale jak je na tom PDO::query?

Odpovědět 15.4.2014 21:44
Na světě je 10 typů lidí. Ti, kteří rozumí binárce a ti co nerozumí.
Avatar
FastNode
Redaktor
Avatar
FastNode:

Ještě jsem si rád zvykl na nastavování oprávnění pro každou webovou aplikaci, tzn. jedna databáze = jeden účet. Kromě toho také omezuji povolené příkazy na ty co reálně používám: většinou jen SELECT, INSERT a UPDATE, globální oprávnění pro jednotlivé účty nedávám.

 
Odpovědět 19.4.2014 12:32
Avatar
asanos
Člen
Avatar
Odpovídá na FastNode
asanos:

JJ, omezením počtu příkazů SELECT zabraňuješ útoku Brute force na přihlašovací formulář.
A ještě použít nějakou pomalou hashovací funkci například Blowfish, pomocí crypt() + salt $2y$, se sílou větší jak 10, nejen pro zesílení, ale především pro "zpomalení".

Odpovědět 19.4.2014 17:06
Na světě je 10 typů lidí. Ti, kteří rozumí binárce a ti co nerozumí.
Avatar
TrollKill
Člen
Avatar
TrollKill:

Dobry clanok :D

 
Odpovědět 15.9.2014 13:27
Avatar
Patrik Neumann:

Ahoj, zkouším to mysqli_real_es­cape_string, ale vrací to prázdný řetězec, co s tím?

Odpovědět 31.7.2016 14:10
Nic není nemožné, proto se snažím dál.
Avatar
Odpovědět 31.7.2016 14:33
Nic není nemožné, proto se snažím dál.
Avatar
mkub
Redaktor
Avatar
Odpovídá na Patrik Neumann
mkub:

este lepsie by hadam bolo PDO namiesto mysqli ;)

a keby si rozisal svoj problem a napisal riesenie problemu by tiez nezaskodilo pre inych, ktorym sa vyskytne podobny pripad...

 
Odpovědět 31.7.2016 16:52
Avatar
Odpovídá na mkub
Patrik Neumann:

špatný kód:

mysqli_real_escape_string(string $pole)

řešení:

mysqli_real_escape_string(string $connection, string $pole)
Odpovědět 31.7.2016 20:07
Nic není nemožné, proto se snažím dál.
Avatar
Adam Harajda
Člen
Avatar
Adam Harajda:

A nedalo by sa to ošetriť aj cez povolené znaky? napríklad takto:

if(preg_match('(^[A-Za-z0-9]{4,32}$)',$­_POST['name'])){
/*vstup je správny*/
}
else{
/*vstup je nesprávny*/
}

 
Odpovědět 5. ledna 13:49
Avatar
Odpovídá na Adam Harajda
Martin Konečný (pavelco1998):

Je to také možné, ale osobně doporučuji ošetřovat každý vstup. Je lepší ošetřit a nepotřebovat, než potřebovat a mít v aplikaci díru.
Větší jistotu máš, když použiješ knihovny, které escapování řeší za tebe. Viz třeba parametrizované dotazy v PDO, nějaká DB layery ve frameworcích atp.

 
Odpovědět  +1 5. ledna 14:10
Děláme co je v našich silách, aby byly zdejší diskuze co nejkvalitnější. Proto do nich také mohou přispívat pouze registrovaní členové. Pro zapojení do diskuze se přihlas. Pokud ještě nemáš účet, zaregistruj se, je to zdarma.

Zobrazeno 10 zpráv z 12. Zobrazit vše