Znemožnění SQL Injection

Školení, která pořádám

Většina knihoven pro komunikaci s databází nabízí nějaký způsob vázání proměnných: PDO, Dibi, NotORM a mnohé další. Hlavním smyslem tohoto konceptu je ochrana před SQL Injection.

Problém nastane, pokud uživatel vázání proměnných z neznalosti nebo nevědomosti nepoužije a místo toho data uživatele vloží přímo do dotazu. Např. query("... LIMIT $_GET[limit]") místo query("... LIMIT ?", $_GET["limit"]). Jak se tomu knihovna může bránit?

  1. Mohla by SQL dotaz parsovat a varovat před konstantními hodnotami – to by ale znemožnilo i legitimní dotazy např. s "LIMIT 1".
  2. Další možností je na SQL zcela rezignovat a vytvořit si vlastní jazyk, který bude nejspíš podmnožinou SQL, uživatelé se ho budou muset učit a nakonec v něm stejně nevyjádří všechno. O to se snaží třeba Doctrine, i když její pojetí tento problém vůbec neřeší.
  3. A poslední možností je vynutit, aby dotaz neobsahoval žádná data od uživatele.

SafeQuery

Jak realizovat poslední možnost? Jedna z mála věcí, které jsou v PHP konstantní jsou … konstanty. A to ještě jen ty třídní, globální jdou vytvořit dynamicky: define("QUERY", "... LIMIT $_GET[limit]"). Můžeme tedy API navrhnout tak, že bude dotazy načítat výhradně z třídních konstant. Nemůžeme použít něco jako query(Article::SELECT_ONE), protože zevnitř funkce už samozřejmě nepoznáme, že jsme dostali konstantu a ne dynamicky sestavený řetězec. API tedy bude muset vypadat nějak takhle: query("Article", "SELECT_ONE"). Přijdeme o napovídání IDE a obecně o statickou analýzu, ale získáme neprůstřelný systém.

Kód může vypadat třeba takhle:

<?php
class SafeQuery {
    private $pdo;
    
    function __construct(PDO $pdo) {
        $pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
        $this->pdo = $pdo;
    }
    
    /** Execute constant query
    * @param string
    * @param string
    * @param mixed ... bound parameters
    * @return PDOStatement
    * @throws PDOException
    * @copyright Jakub Vrána, https://php.vrana.cz/
    */
    function execute($class, $constant) {
        $args = func_get_args();
        $args = array_slice($args, 2);
        $return = $this->pdo->prepare(constant("$class::$constant"));
        $return->setFetchMode(PDO::FETCH_ASSOC);
        foreach ($args as $i => $values) {
            if (!is_array($values)) {
                $values = array($i + 1 => $values);
            }
            foreach ($values as $key => $val) {
                $type =
                    (is_int($val) ? PDO::PARAM_INT :
                    (is_bool($val) ? PDO::PARAM_BOOL :
                    (is_null($val) ? PDO::PARAM_NULL :
                    (is_resource($val) ? PDO::PARAM_LOB :
                    PDO::PARAM_STR
                ))));
                $return->bindValue($key, $val, $type);
            }
        }
        $return->execute();
        return $return;
    }
    
}
?>

Nedostatky

Není moc pěkné, že se v kontruktoru nastavuje způsob ošetřování chyb PDO, ale ušetří nás to šílenosti při zpracování různých variant ošetřování. Třída se hodí pro přechodné období, kdy máme starý kód využívající PDO a nový kód využívající tuto třídu. Pokud se bez PDO obejdeme, tak by bylo lepší si jeho instanci vytvořit až vevnitř, aby naši třídu nikdo nemohl obejít přímým použitím PDO. Tím by se zároveň vyřešila připomínka ze začátku tohoto odstavce.

Asi největší nevýhodou třídy je to, že nedovoluje dynamické sestavování dotazu, něco takového:

<?php
$where = array();
$params = array();
if ($_GET["q"] != "") {
    $where[] = "q = ?";
    $params[] = $_GET["q"];
}
$sql = "SELECT * FROM t" . ($where ? " WHERE " . implode(" AND ", $where) : "");
?>

Vyřešit by se to dalo celkem snadno tak, že bychom si vytvořili metodu sestavující dotaz z fragmentů vyjádřených konstantou. Volala by se např takto:

<?php
$safeQuery->executeComposite(array(
    array("T", "SELECT_BASE"),
    array("T", "SELECT_WHERE_Q"),
), $_GET["q"]);
?>

Dalším nedostatkem je nemožnost dynamicky vložit sloupec – obvykle podle kterého se má řadit. To už by se řešilo hůř. Samozřejmě si můžeme vytvořit tolik konstant, podle kolika sloupců dovolujeme řadit. Pokud jde ale řadit cokoliv podle čehokoliv, tak brzo narazíme.

Protivná je nemožnost pro vázání proměnných použít pole, typicky v dotazu IN (). Když bych to měl vyřešit, tak bych asi úplně opustil vázání proměnných nabízené PDO a přešel k zápisu, který používá Dibi, případně něčemu podobnému. To by zároveň vyřešilo i předchozí nedostatek.

Použití

Jak se knihovna používá? Není to zase tak strašné:

<?php
class Article {
    const INSERT = 'INSERT INTO article (title, content) VALUES (:title, :content)';
    const SELECT_ONE = 'SELECT title, content FROM article WHERE id = :id';
    const SELECT_ALL = 'SELECT * FROM article ORDER BY id LIMIT ?';
}

$safeQuery = new SafeQuery($pdo);
$safeQuery->execute('Article', 'INSERT', array('title' => 'Test', 'content' => 'Hello World!'));
foreach ($safeQuery->execute('Article', 'SELECT_ALL', 2) as $comment) {
    print_r($comment);
}
?>

Závěr

Vytvořili jsme knihovnu, která se celkem pohodlně používá, dovoluje položit téměř všechny SQL dotazy a především je neprůstřelná proti SQL Injection – její uživatel nemůže nevědomě udělat chybu.

Přijďte si o tomto tématu popovídat na školení Bezpečnost PHP aplikací.

Jakub Vrána, Řešení problému, 2.5.2012, diskuse: 26 (nové: 0)

Diskuse

ikona Jakub Tománek:

Tohle už mi přijde přehnané. Házet vývojářům klacky pod nohy jen kvůli tomu, aby náhodou neudělali SQL injection? Docela by mě zajímalo, jestli by to byl někdo vůbec ochotný používat. Mnohem lepší je vylepšit proces nabírání nováčků ;)

I tenhle přístup není 100% neprůstřelný - *záměrnou* chybu pomocí buď eval() nebo komba file_put_contents()+include() - tj. vygeneruju si chybný kód - stejně neošetří.

ikona Miloslav Ponkrác:

Chyba se dá vyrobit úplně jednoduše. Podstrčit záměrně, omylem nebo překlepem jiná třída, ve které bude stejný název konstanty.

Kromě toho není nijak řečeno, že začátečník by se na tohle nevykašlal a neprováděl dotazy třeba přímo nad myssql, mysqli, pdo či jinde. A zcela to obešel.

Získat privátní handler pdo ze třídy jde pomocí reflexe celkem snadno. A to by bylo první co bych udělal, kdyby mě někdo chtěl k téhle věci nutit.

ReflexionProperty::setAccessible(true);

A pak bych dělal dotazy přímo.

Pokud by tohle někdo v týmu nutil kolegům, tohle bych jim rovněž poradil.

ikona Jakub Vrána OpenID:

Vtip je v tom, že do konstanty uživatelský vstup prostě nedostanu. Takže když předám jinou třídu, může samozřejmě dojít k chybě, ale nikoliv k SQL Injection.

O obejití zabezpečení také píšu – pokud se na třídu chceme v projektu spolehnout, tak si PDO vyrobíme až uvnitř a zvenku bude nedostupné. Sám si ho uživatel nevyrobí, protože nezná přihlašovací údaje k databázi.

Reflexi vám vy chytráci zakážu pomocí http://php.net/disable_classes :-). Teď vážně – chyba nejčastěji vznikne tak, že na ošetření zapomenu nebo si chci ušetřit práci. Takže hlavním smyslem řešení je zabránit tomuto případu. Když už si dám práci s reflexí, tak už se omylem nestřílím do nohy, ale vědomě hraji ruskou ruletu a nejspíš vím, co dělám.

Víte co, já bych asi taky nebyl dvakrát nadšený, když by mě někdo nutil tohle používat. Jednak preferuji expresivnější API (jako má třeba NotORM) a jednak se kloním k lokálnímu psaní kódu: http://php.vrana.cz/lokalni-psani-kodu.php. Na druhou stranu vidět všechny dotazy (jednoho typu) pěkně pohromadě se někdy taky docela hodí.

ikona Miloslav Ponkrác:

S většinou Vašeho komentáře souhlasím. Jsou horší rozhraní, a abych řekl pravdu, potěšila mě originalita toho řešení. Rozhodně je kreativní.

Fakt je, že tyhle Vaše hrátky jsou zajímavé.

Ano, jsou daleko horší rozhraní, toto je ještě dobré.

Mít dotazy pohromadě se hodí, třeba při kódu přenositelném na více databázových strojů / více databázových struktur. Pak se to hodně blíží Vašemu řešení.

Problém ani není tak ve Vašem řešení, jako spíš v dřevěnosti PHP. Reflexi zakážete, ale globálně. Ne jen v jednom případě.

Já bych to, kdybych už chtěl být represivní, a počet SQL dotazů byl malý, tak bych z nich rovnou vyrobil objekty třídy mysql_stmt a držel jenom ty. Ale nemohou to být konstanty.

Já nevím k čemu inklinuji. De facto globální psaní konfigů mi často drhlo, protože často konfig nebyly jen jednoduché hodnoty, ale už jakýsi skript a program. Někdy se mi osvědčilo na globále netrvat. Pokud přijdete na nějaké elegantní řešení jak spravovat rozsáhlejší konfigy, nebo něco povíte jak to dělají ve facebooku, budu horlivým čtenářem.

Kalanis:

Tak... Má reakce, ač po dlouhé době od příspěvku, bude OffT i OnT, jelikož je věc, kterou lidsky bez elementární reflexe nezařídím.
Jde o zajištění modulu navázaného na rozhraní, který se ještě neinicializoval (tedy před new module()) a není dobrý nápad to páchat bez základní kontroly (třeba jde o měnitelný v rámci jádra). A to je případ pro reflexi. A já se ptám, jak pak se zakázanou reflexí zajistím (module instanceof interface)? Testovat existenci každé vlastnosti je otevřeně na pěst a navíc neřeší parametry funkcí v objektu. Hlavně mi jde o případy s jistým vstupem, jednotným zpracováním a následně "vyplivnutím" dat v různých formátech (html, json, xml, odvážlivci (s)ftp).
Jakmile dostanu takový modul s PDO, u kterého je potřeba reflexi zakázat, tak se mi vlastně naruší jádro. A zase bez zákazu reflexe nelze zajistit "nenacpání se" s dotazem napřímo a SqlIn.
Na druhou stranu, jestli si někdo hodlá "vadným" modulem danou kopii systému takto zrušit, je to ve finále jen jeho problém (ale milé je, že já za to dostanu vynadáno a měl bych to řešit).
P.S.: omluva za jakýkoliv předchozí výlev pro ignorelist N měsíců až roků zpět.

v6ak:

Napadá mě, dalo by se to použít v případě triku s labmdou? Třída by si nedržela PDO (či něco podobného), ale lambdu, která by znala spojení díky use. Nebo i z ní by to šlo nějak vytřískat? Předpokládám, že by to spojení neprozradila, ale rovnou používala.

(Fuj, to jsou hacky...)

ikona Jakub Vrána OpenID:

Řekl bych, že se bez lambdy obejdeme:

<?php
class SafeQuery {
    function execute($query) {
        static $pdo;
        if (!isset($pdo)) {
            $pdo = new PDO("mysql:dbname=test", "ODBC");
        }
        // ...
    }
}
?>

Jen bude připojení sdílené pro všechny objekty…

ikona Jakub Vrána OpenID:

A tys asi myslel tohle (což má samostatné připojení pro každý objekt):

<?php
class SafeQuery {
    private $execute;

    function __construct() {
        $pdo = new PDO("mysql:dbname=test", "ODBC");
        $this->execute = function ($class, $constant) use ($pdo) {
            // ...
        };
    }

    function execute($class, $constant) {
        return call_user_func_array($this->execute, func_get_args());
    }
}
?>

ikona Jakub Vrána OpenID:

Hele, mně to API zase tak strašné nepřijde, setkal jsem se s horšími. A chyby zdaleka nedělají jen nováčci, bez odpovídajících nástrojů se to stane i borcům.

Pomocí eval() nebo vložením vygenerovaného souboru chybu vyvolám, ale jak už jsem psal výše u reflexe, primárním smyslem je zabránit chybám vzniklým nepozorností nebo leností.

v6ak:

Tady se Peltzmanův efekt přímo nabízí - místo SQL injection dostaneme rovnou PHP injection :) A to vše kvůli dobrému úmyslu regulovat nebezpečné dotazy...

(Co je Peltzmanův efekt: http://kovanda.blog.tyden.cz/clanky/3162/…-tom-vi.html )

v6ak:

K vlastnímu jazyku: Doctrine není jediný takový pokus. Mohu-li opustit PHP, napadá mě třeba SQueryL. Ten se nesnaží programátora nějak zázračně odstínit od použité DB (snad kromě drobných rozdílů v syntaxi). Snaží se přinést DSL pro SQL.

BTW: V použitém jazyce (Scala, statický typovaný jazyk) byla sice implementace trošku drsná (umím si to ale představit i elegantněji), ale dokonce je type safe. Sice asi ne 100% typesafe, ale už se programátor musí snažit, aby napsal kód, který projde kompilátorem Scaly, ale shoří na DB.

SQueryL by šel napsat i v PHP, ale ztratil by něco z elegance a neměl by typovou kontrolu. Použitelné by to bylo kvůli lambda funkcím až v PHP 5.3, spíš ale v 5.4.

Připustím-li funkce mimo třídu, mohlo by to v PHP vypadat zhruba takto:
<?php
// klasický select z http://squeryl.org/selects.html

function songsInPlaylistOrder(){
    return from($this->playlistElements, songs)(function($ple, $s){
                where($ple->playlistId.equals($id) && $ple->songId->equals($s->id));
                select($s);
                orderBy($ple->songNumber->asc);
    });
}

// agregovaný dotaz z http://squeryl.org/introduction.html
$averageMathPercentage = from($this->grades)(function($g){
    where($g->subjectId->equals($mathId));
    compute(avg($g->scoreInPercentage));
});
?>

Nelpěl jsem tu na přesné podobě SQuerylu, ale šlo mi o to, jak by to mohlo vypadat v PHP. Myslím, že ty lambdy nejsou ve Squerylu imperativní, ale v PHP by to asi vypadalo trošku divně.

Zdenek:

Chvili mi trvalo pochopit vubec problem. Koho by dnes napadlo strkat vstup primo do query?!

Clovek, ktery nevi, co je SQL Injection, takovou vec stejne nebude pouzivat - je to moc "slozite". A ten, kdo v PHP dela alespon mesic uz si sam poradi s prepared statements, o kterych se uci snad uz i v materskych skolkach.

Jinak je to samozrejme hezky napad :) a zajimava ukazka alternativ k dnesnim bezne pouzivanym postupum. Kdybych ale vedel o nejakem zacatecnikovi, urcite bych mu neposlal odkaz na tento clanek.

mp:

mam rovnaky pocit

ikona Jakub Vrána OpenID:

Ale to víte, že to lidi napadne. Převážně kvůli nedokonalosti některých API. Co udělá následující kód?

<?php
$result
= $pdo->prepare("SELECT * FROM article LIMIT ?");
$result->execute(array(10));
// Syntax error or access violation: 1064 Syntax error near ''10''
?>

Nebo tohle?

<?php
$result
= $pdo->prepare("SELECT * FROM article WHERE id IN (?)");
$result->execute(array(array(1, 2)));
// Array to string conversion, evaluates as: id IN ('Array')
?>

Takže co udělají lidi?

<?php
$result
= $pdo->prepare("SELECT * FROM article WHERE id IN (" . implode(", ", $_GET["id"]) . ") LIMIT $_GET[limit]");
$result->execute();
// SQL Injection
?>

Ale celý článek je dobré chápat hlavně jako ukázku posunu na této škále:

1. Udělat chybu je extrémně snadné – ruční skládání dotazu.
2. Chybu můžeme udělat – vázání proměnných.
3. K chybě dojít nemůže – tohle.

Stejnou škálu bychom si mohli udělat pro obranu proti XSS:

1. Ruční ošetřování dat – <?php echo htmlspecialchars($data); ?>.
2. Šablony s automatickým ošetřováním – {$data}, ale pořád jde napsat {!$data} (např. v Nette Latte).
3. XSS je znemožněno – https://github.com/facebook/xhp/wiki.

Franta:

Přiznám se, že občas používám něco podobného – veškeré SQL mám v XML souborech – pro každou třídu jeden, třída obsahuje enum se seznamem dotazů. Ale nikdy mě nenapadlo to dělat jako ochranu před SQL injection – smyslem bylo, že SQL bude zvlášť a bude se na něj moci podívat a případně ho upravovat i administrátor (není k tomu potřeba programátor, který bude zasahovat do tříd, které by následně bylo potřeba kompilovat).

Ad „Problém nastane, pokud uživatel vázání proměnných z neznalosti nebo nevědomosti nepoužije“

Uživatel? Kód přece píše programátor a ten má vědět, že nemá lepit SQL z kousků textu. …

ikona Jakub Vrána OpenID:

Hlavně, aby to XML nebylo dostupné zvenku :-). Kód SQL dotazů by se taky dal uložit do databáze, ne?

Ano, uživatelem naší knihovny je samozřejmě programátor a programátoři mají vědět (a často i ví), co mají a nemají dělat. Ale jak už jsem psal, systém je mnohem robustnější, pokud v něm chyba prostě nejde udělat.

Franta:

Dostupné zvenku? To by nemělo vadit (zvlášť když jsou dostupné zdrojáky), protože bezpečnost by neměla stát na tom, že útočník neví, jak se tabulky jmenují nebo jak vypadají dotazy. Pokud jde o možnost zápisu, ta nehrozí – XML jsou ve .war/.jar archivu vedle zkompilovaných tříd, které rovněž nejsou přístupné přes web, natož aby je šlo přes web měnit.

To uložení do DB je možné, fajn mi na tom přijde, že se líp udrží konzistence mezi danou verzí DB a verzí dotazů (oboje se aktualizuje stejným způsobem). A je to stejné jako u těch souborů – je potřeba dotazy načíst při nasazení aplikace, ne při každém dotazu.

S tím, že chyba by neměla jít udělat, resp. neměl by k tomu framework/knihovna navádět, celkem souhlasím, ale tohle tvoje řešení mi přijde už trochu přehnané.

ikona Jakub Vrána OpenID:

Napadlo mě, jak by v jiné verzi tohoto řešení šla chyba udělat:

<?php
class Sql {
    const INJECTION = "SELECT '?'";
}
$safeQuery->execute('Sql', 'INJECTION', "UNION ...");
?>

Pokud by otazník prostě vložil ošetřený řetězec (tedy např. 'McDonald''s' včetně apostrofů) a měl by se tedy v dotazu používat bez apostrofů, tak je chyba na světě. PDO dotaz parsuje, takže otazník v tomto kontextu nenahradí, ale pokud by si to někdo psal sám, tak je na to potřeba myslet.

3b-fly:

Musím řīci,  že mi přijde vhodné všechny data od uživatele před jakýmkoliv zpracováním validovat nebo sanitizovat a problém se začátečníy bych raději řešil kontrolou toho, zada k validaci nebo sanitizaci došlo.

Franta:

Validace ti nepomůže – jména klidně můžou obsahovat apostrofy, názvy firem můžou obsahovat ještě rostodivnější znaky. Nějaká entita (kterou uživatel popisuje ve formuláři) se klidně může jmenovat DELETE FROM user; – to je naprosto legitimní vstup a ty do něj nemáš co zasahovat. Důležité akorát je, abys s tímto vstupem pracoval jako s daty, nikoli jako s programem (vykonat ho jako SQL).

Sanitizace prováděná příliš brzo je taky chyba. Naopak se musí dělat co nejpozději, těsně před vložením někam (SQL, HTML, JavaScript, LaTeX, matematická rovnice, …), protože dřív nevíš, před čím se vlastně bráníš – nelze ošetřit vstup, aby šel bezpečně vložit do všech potenciálních výstupních formátů a zároveň nebyl rozbitý.

Co se týče té kontroly, že ošetření proběhlo – jde to, ale je to poměrně náročné. A opět musíš vědět, proti čemu vstup ošetřuješ – nelze ho zneškodnit tak nějak obecně.

3b-fly:

Rád bych to probral podrobněji, ale je zbytečný to tu Jakubovi zaplácávat. Ozvi se mi prosím na jannejedly@3b-fly.eu .

ikona Jakub Vrána OpenID:

Jen to klidně zaplácejte. Franta to vysvětluje naprosto přesně.

msx:

Ja mám podobný problém vyriešený tak, že mám dve funkcie:
1. čítanie z tabuľky (select)
2. zápis do tabuľky podľa id (update, insert)

Je mi jasné, že hore uvedené riešenie je univerzálnejšie, ale 90 % otázok do databázy (dotaz je česky) je len jednoduchý select. To znamená, že väčšinu otázok mám tým pokrytých. Ak potrebujem niečo "rozsiahlejšie" join alebo niečo iné, tak si môžem spraviť ďalšiu funkciu alebo to zapíšem priamo.

Priznám sa, že uvedenému riešeniu od autora blogu som nepochopil naplno. Je mi jasné, že jeho riešenie sa dá rozširovať (tak ako to moje), ale v uvedenom rozsahu je dosť o dosť zložitejšie na pochopenie.

Moje funkcie sú jednoduché funkcie, ktoré prijímajú:
1. čítanie - tabuľku, hodnotu id, prípadne stĺpec, pre ktorý to platí a dostanem jediný záznam (dá sa upraviť aj na viac záznamov)
2. zápis - tabuľku, pole hodnôt a id, kam zapísať, ktorý ak je nulový, tak sa záznam vloží.

Tieto funkcie samozrejme ukladajú čas vytvorenia a čas úpravy záznamu automaticky (priamo cez PHP, default hodnotu pre timestamp nezvyknem používať).

Na pochopenie je to jednoduché, funkcia si dáta spracuje pomocou addslashes (mysql_real_escape_string na serveri nefunguje správne (vyhadzuje úvodzovky)) a ak chcem niečo zložitejšie, môžem si spraviť funkciu. Autor tu tiež hovorí o možnosti pridať ďalšie typy otázok (dotazov), ale ak bude programátor lenivý, takisto si tam praskne čisté SQL ako to môže urobiť aj v mojom prípade. V čom je toto lepšie oproti môjmu riešeiu?

Autora chcem poprosiť aby to nebral ako kritiku, nakoľko jeho príklad som nepochopil úplne na 100 %, ale princíp mi zrejme je jasný. Skôr by som poprosil o akési vysvetlenie, resp. porovnanie môjho a jeho riešenia.

ikona Jakub Vrána OpenID:

Cílem bylo vytvořit řešení, které dovolí položit jakýkoliv dotaz a přitom v něm nikdy nebude moci dojít k SQL Injection.

Jakékoliv „zapisování přímo“ k SQL Injection náchylné je.

trestná smradlavice:

Jakube, díky za článek. Je to zajímavý úhel pohledu.
Btw. použil jsi konkrétně toto řešení v nějakém svém projektu ty sám?

ikona Jakub Vrána OpenID:

Ne. V praxi se mi nejlíp osvědčilo funkcím předávat konstantní řetězce a mít lint-rule, které to kontroluje.

Diskuse je zrušena z důvodu spamu.

avatar © 2005-2024 Jakub Vrána. Publikované texty můžete přetiskovat pouze se svolením autora. Ukázky kódu smíte používat s uvedením autora a URL tohoto webu bez dalších omezení Creative Commons. Můžeme si tykat. Skripty předpokládají nastavení: magic_quotes_gpc=Off, magic_quotes_runtime=Off, error_reporting=E_ALL & ~E_NOTICE a očekávají předchozí zavolání mysql_set_charset. Skripty by měly být funkční v PHP >= 4.3 a PHP >= 5.0.