Az SQL Injection elleni védelemnél sokan a legkevésbé hatékony eszközt használják, vagy túlzott bizalmat támasztanak a nyelv, vagy a használt keretrendszer megoldásai iránt. Ebben a blogbejegyzésben szeretném egy kicsit tágítani a képet és rámutatni pár olyan nem gyakran előforduló problémára, amit mindenképpen érdemes észben tartani. A meglévő eszközök ugyanis nem mindig nyújtanak megoldást ezekre a ritkán előforduló esetekre.

Kockázatról röviden

Általában az SQL injection támadást vagy sebezhetőséget szokták röviden SQL Injection néven nevezni én azonban az SQL injection kockázatról szeretnék beszélni. Egy-egy megoldás használata mindig rejt magában bizonyos veszélyt melyet, ha ismerünk képesek vagyunk kezelni. Ez a veszély akkor igazán nagy, ha nem is ismerjük. SQL injection kockázata akkor fog megjelenni, ha az SQL lekérdezéseket programozottan, változók felhasználásával rakjuk össze. Ha nincs ilyen lekérdezés, nincs ilyen veszély. Ha van ilyen, akkor mindent meg kell tennünk, hogy minimalizáljuk ezt a kockázatot. Nézzük meg mire kell figyelnünk.

Ami egyszer működött, ahhoz nem kell hozzányúlni

Nos nem akarok elkeseríteni senkit, de ez a hozzáállás a biztonság területén nem nagyon életképes. Most nézd is meg gyorsan, hogy mikor íródott ez a bejegyzés és ha nagyon régen, akkor kérlek kezeld fenntartásokkal az itt leírtakat! Nézzük meg, hogy miért nem dőlhetünk hátra, ha találtunk egy jó megoldást.

mysql_query(
"SELECT * FROM users WHERE name='$name' AND $pass='$pass'"
);

Az SQL Injection óvodás változatát mindenki ismeri, amikor egy string változóba a string elejét és végét jelző karaktert (pl. ‘) csempészünk be, így nem várt működésre bírva az adatbázis motorunkat.

-- $name: admin
-- $pass: ' or ''='
SELECT * FROM users WHERE name='admin' AND pass='' or ''=''

Nagyon egyszerű a védekezés ez ellen, egyszerűen escapelni kell ezeket a karaktereket. Erre van is egy függvény a PHP-ban addslashes()-nek hívják.

-- $pass: ' or ''=' => \' or \'\'=\'
SELECT * FROM users WHERE name='user' AND pass='\' or \'\'=\''

Igen ám, de ezzel van egy kis probléma akkor, ha a PHP értelmező és a MySQL különbözőképpen értelmezi a szöveget, vagyis más karakter kódolást használ.

Van egy jó kis cikk ami pontosan leírja a támadást. Röviden a lényeg, hogy az aposztróf (') kódja 0x27, a visszaperjel kódja pedig 0x5c. Van két unicode karakter egy nem létező 0xbf27 és egy létező 0xbf5c. Amit meg kell figyelni, hogy a nem létező végén az aposztróf, míg a létező végén a visszaperjel szerepel. Nincs más dolgunk, mint a webszerver felé küldött csomagba betesszük a nem létező karaktert, amit az értelmező két létező, egy bájtos karakterre bont: 0xbf és 0x27. Mivel a szövegben van így egy aposztróf (0x27) az addslash() hozzá is adja a visszaperjelet(0x5c), az aposztróf elé, így lesz egy ilyen hármasunk: 0xbf, 0x5c, 0x27. A túloldalon a MySQL ezt már 0xbf5c, 0x27-ként értelmezi, vagyis egy valamilyen karakter és egy aposztróf. BUMM! A támadó már át is jutott.

Ha nem teljesen érthető a fenti, ne keseredj el, az egészet annak illusztrálásaként mutatom be, hogy egy valamikor jól működő védelem a szoftverek új verzióinak megjelenésével elavulttá válik. Ezért nagyon fontos, hogy ne támaszkodjunk ezen a területen túlzottan elavult információkra. (Nem teszteltem a dolgot, de simán lehet, hogy ezzel a támadással ma már nem lehet átmenni egy rendszeren, csak egy ehhez valamennyire hasonlóval.)

Prepared statement

Röviden a prepared statement azt jelenti, hogy a változó értékek helyett egy-egy placeholdert, egy kérdőjelet rakunk az sql query stringbe és az értékeket a query mellett, attól elválasztva küldjük el az adatbázis felé. Ekkor az adatbázis szervernek egy változókat nem tartalmazó, azoktól nem függő query stringet kell feldolgoznia, hisz az adatokat külön érkeznek. Ekkor esélye sincs a támadónak injektálni az SQL-be, mert mi se változókból rakjuk azt össze.

$sth = $dbh->prepare('SELECT * FROM users WHERE name=? AND pass=?');
$sth->execute(array($name, $pass));

Meg is vagyunk a tökéletes megoldással, amivel az összes kockázatot kiiktatjuk a kódból. Nincs változók segítségével összerakott query, nincs SQL Injection kockázat. Pezsgő.

Query builder és az intelligens szűrő

Sajnos a prepared statement csak a változó értékek jelzésére szolgálhat, nincs lehetőségünk arra, hogy ugyan ilyen módon mező vagy tábla neveket juttassunk az sql query stringbe. Márpedig a legtöbb query builder változóból jövő mező és táblaneveket rak bele a querybe. Ugyanígy, ha van egy intelligens szűrőnk, ahol a szűrendő mező neve is változóban érkezik akkor ott is olyan sql query stringet rakunk össze, amiben változónév szerepel. Ebben az esetben úgy tudjuk minimalizálni a kockázatot, hogy a tábla és változó neveket egy olyan statikus listából választjuk ki, amit előre rögzítettünk a kódba. Ezt a módszert Whitelist vagy Allow-list megoldásnak hívják. Ez lehet egy statikus tömb, amiben felsoroljuk a tábla- vagy mezőneveket, de lehet akár egy switch szerkezet is, ahol a felsorolt lehetőségeken kívül, a default ágban dobunk egy kivétel.

switch ($table) {
    case 'user':
    case 'roles':
        return "SELECT * FROM $table";
    default:
        throw new TableNotExists($table)
}

Felhasználótól jövő adat

Amennyiben sem a prepared statement, sem a listás út nem járható marad az, hogy escapeljük a felhasználótól jövő adatokat. Legalább is ez az elterjedt megfogalmazás ahelyett, hogy egyszerűen csak a változóból jövő adatokat mondanánk. Fontosnak tartom, hogy legalább fejben megváltoztassuk ezt a hozzáállást. Felesleges ugyanis időt fecsérelni arra, hogy végiggondoljuk egy változóról, hogy felhasználótól jött-e avagy sem, mivel egyrészt néha elég komplex ezért nehéz és hosszadalmas ez a folyamat, másrészt a kódunk életciklusa során ez bármikor megváltozhat. Én minden esetben escapelem a változóból jövő adatokat, még a whitelist-es megoldásnál is, ahol biztos lehetek benne, hogy a kód jelenlegi verziójában ez nem jelenthet sérülékenységet. Mert a hangsúly a “jelenlegi”-n van és nem tudhatom, hogy a későbbiekben nem fog-e bekerülni véletlenül mégis valami felhasználótól jövő adatot beengedő kódrészlet. Nem csinálok mást ilyenkor mint a kockázat minimalizálására törekszem, ha már megszüntetni úgy se tudom.

return "SELECT * FROM " . DB::escape_table($table);

Cipőt a cipőboltból

Fontos, hogy kitérjünk az escapelésre, mert sokan sokszor vakon hisznek egy-egy megoldásban, amik egyszer egy adott problémára beváltak. Az addslashes() különböző problémáira már fentebb kitértem, de hadd vegyek elő még egyet, hogy egy újabb problémakört is megvizsgálhassunk. Amennyiben a kódunk addslashes() függvényt használna és áttérünk MySql-ről, SQLite-ra döbbenten fogjuk tapasztalni, hogy ami eddig ment, most elromlik. Az SQLite-ban ugyanis nem visszaperjellel (\), hanem aposztróffal (') kell escapelni. Tehát nagyon nem mindegy, hogy az általunk előállított stringet mi fogja értelmezni. Legyinthetnénk, hogy ugyan miért is tennénk ilyet, de én még olyat is láttam, hogy htmlspecialchars() függvénnyel akarta valaki az SQL Injectiont elkerülni, hisz az “alkalmas volt” az XSS támadások kivédésére, akkor ide is biztos jó lesz.

Nade ne menjünk ilyen messzire, kanyarodjunk vissza, legyen MySQL és használjuk a mysqli_real_escape_string() függvényt mindenre, hisz az erre való. Minden rózsaszín lesz egészen addig, amíg nem hozunk létre a megrendelések számára egy order nevű táblát. Ugyanis az order az egy védett szó, így azt nem használhatjuk csak úgy sql mondatokban akárhol. Akkor hogyan lehet, hogy a felületről létre tudtuk hozni az order táblát, de az sql-ben már nem használhatjuk? Természetesen erre is van megoldás a backtick karakter, vagy magyarosan írógépes fordított félidézőjel: `

Semmi mást nem kell tennünk, csak szolgaian minden tábla és mező nevét körbevesszük ezzel. Adódik a kérdés, hogy mi van, ha a támadó valahogy bejuttat egy ` karaktert a tábla nevébe. Semmi gond nem lehet, hisz használjuk a mysqli_real_escape_string() függvényt. Nagyobbat nem is tévedhetnénk. A függvény ugyanis a ` jelet nem escapeli, és ha escapelné akkor se mennénk vele semmire, mert ilyen esetben nem a visszaperjel, hanem az írógés fordított félidézőjel(`) az escape karakter. Szóval erre majd kell egy megoldást találnunk, amit vagy nyújt az adatbázis rétegünk, vagy nekünk kell valamit sajátot gyártani rá.

class DB {
  function escape_table ($table) {
    // Code from: https://github.com/laravel/framework/blob/8.x/src/Illuminate/Database/Query/Grammars/MySqlGrammar.php#L275
    return '`'.str_replace('`', '``', $table).'`'
  }
}

Mellékhatás

Na de ne kalandozzunk el ennyire és térjünk vissza a prepared statement-hez. Találtunk egy olyan megoldást, ami minden körülmények között tökéletesen működik. Jó, jó, csak értékek-nél használhatjuk, tábla és mező neveknél nem, de akkor is tökéletes. Igen, igen, de mire is kínál megoldást nekünk? Arra, hogy az alkalmazás által küldött sql query string ne tartalmazhasson nem kívánt elemeket. Vagyis akkor amikor az sql query string feldolgozása történik, ne történjen probléma.

Kérdés, hogy amikor majd az átküldött értékek feldolgozás történik, kell-e számolnunk valamilyen kockázattal? Természetesen igen, hisz lehet ezekben a feldolgozókban olyan hiba, ami lehetővé tehet valamilyen támadást. Ezeket a kockázatokat, egy kivételével nem ismerjük, ezért kezelni se tudjuk. Egyet tehetünk, mindig frissen tartjuk az adatbázis szerverünket, hogy a felderített sebezhetőségeket ne lehesen kihasználni.

Kanyarodjunk vissza most ahhoz az egy kockázathoz, amit az alkalmazásunkban tudunk kezelni és kell is kezelnünk. Ez pedig a LIKE záradékban található szöveg.

$sth = $dbh->prepare('SELECT * FROM post WHERE body LIKE ?');
$sth->execute(array('%' . $query . '%'));

Azért is érdemes ezzel foglalkozni, mert ahány adatbázis annyi féle megvalósítása létezik a LIKE-nak. Az általában közös, hogy a százalékjel (%) bármennyi, az aláhúzás (_) egy darab bármilyen karaktert jelöl. Az MSSql tovább megy, hisz ott lehet karakter osztályokat is megadni a mintaillesztő kifejezésekhez (regular expressions) hasonló módon: [a-z].

Kérdés, hogy ezekre a karakterekre akkor hogyan fogunk keresni? Biztosak lehetünk benne, hogy kell léteznie escape karakternek. A MySql-nél van is, a visszaper (\). Az MSSql és Oracle adatbázis szervereknél viszont nincs alapértelmezett escape karakter azt a LIKE záradékhoz tartozó ESCAPE kulcsszó után kell megadnunk.

Ezt a kavalkádot! Biztos van rá megoldás a különböző adatbázis függetlenítő rétegekben. Lehet, én nem találtam. A PHPs Doctrine, Eloquent, de még a PDO sem tartalmaz rá megoldást, de a Javas Hibernate sem kényeztet el minket ebből a szempontból. Ezt bizony nekünk kell megoldani.

Azért ne keseredj el, lehet, hogy életed során sose fogsz találkozni olyan feladattal, ahol ez majd problémát okoz. A legnagyobb kockázatot úgy is az jelenti (híres utolsó mondatok), hogy a keresendő szöveg elejére csempész be valaki egy % jelet. Az ugyanis a lehető legkevésbé hatékony keresés, ami a legtöbb erőforrást fogja felemészteni az adatbázis szerveredből. Jellemzően azonban a LIKE-ot egyébként is így használják fittyet hányva a teljesítmény szempontokra.

Abból a szempontból azonban mindenképpen érdemes ezzel foglalkozni, hogy a felhasználóid értelmes találatokat kapjanak majd. Én például akkor találkoztam a problémával, amikor egy többfajta szerver környezetben futtatott alkalmazás, ami az adatbázisban tárolt fájl elérési útvonalakban keresett LIKE segítségével nem volt hajlandó működni a Windows és MySql kombináción. A javítás során természetesen kiderült, hogy nem csak ez a kombináció érintett a problémával, csak nagy valószínűséggel más felhasználók vagy nem használták ezt a funkciót, vagy nem vették észre a hibát.

Ennyire nem tragikus a helyzet. A napi munkában előforduló esetek 80-90 százalékában a prepared statement megoldást nyújt majd számodra. Ne feledd ebben az esetben az sql query string és az adatok külön utaznak az adatbázis szerver felé.

Amennyiben a témában további kérdésed lenne, vagy szeretnél a fejlesztő cégedben egy testreszabott oktatást a témában keress nyugodtan megtaláljuk a megoldást.

Borító: Myriams-Fotos képe a Pixabay -en.