Archive for the ‘Indata’ Category

Lita inte på indata från användaren

fredag, december 8th, 2017

Det absolut viktigaste att ha i åtanke om man vill skriva PHP-kod utan säkerhetshål är att aldrig lita på data som kommer utifrån, dvs. från användaren. Även om man förväntar sig att ett id-nummer från URL:en (t.ex. ”script.php?id=5”) skall vara ett heltal, får man aldrig lita på att det är det. Det är inga problem att byta ut femman i URL:en mot en annan siffra eller mot lite SQL. Detsamma gäller data som skickas över POST eller via cookies. Att byta ut indata så att skriptet kör en annan SQL-sats än den programmeraren tänkt kallas att göra en SQL-injektion. SQL-injektioner är troligtvis det största säkerhetproblemet i all webbprogrammering.

Hur förhindrar man detta då? Jo, genom att validera all data som kommer från användaren. Om man förväntar sig ett heltal större än 0 (t.ex. ett id-nummer) ser man till att denna variabel faktiskt är ett heltal. Man kontrollerar också, om möjligt, att detta heltal är rätt heltal. Vi börjar med att illustrera hur lite osäker PHP-kod skulle kunna se ut. Följande exempel skall ta bort en rad i databasen:

mysql_query(’DELETE FROM tabell WHERE id=’.$_GET[’id’]) or exit(mysql_error());

Det där kommer fungera jättebra så länge id från URL:en är ett heltal och det är rätt heltal. Vem som helst kan dock byta ut id i URL:en till något annat tal och på så sätt radera en helt annan rad än den det var tänkt. Något som är ännu värre är dock att man kan byta ut femman mot lite vanlig text. Vad hade t.ex. hänt om man anropat skriptet med ”script.php?id=id”? Då hade följande SQL-sats körts:

DELETE FROM tabell WHERE id=id

Dvs. radera alla rader där id=id. Det hade raderat alla rader i tabellen! Inte bra. För att förhindra att id är något annat än det man förväntar sig (i det här fallet ett heltal) kan man använda t.ex. intval(). Det intval() gör är att det försöker tyda heltalsvärdet av en variabel. Har en variabel värdet ’5’ returnerar intval() heltalet 5. Har en variabel värdet ’asdf’ returnerar intval() heltalet 0 då den inte kan tyda något heltal i ’asdf’. Exakt hur intval() fungerar går att läsa i PHP-manualen. Ovanstående osäkra kod skulle kunna bytas ut mot det här:

mysql_query(’DELETE FROM table WHERE id=’.intval($_GET[’id’])) or exit(mysql_error());

Om man nu hade anropat skriptet med id=id hade intval() konverterat $_GET[’id’] till 0 och SQL-satsen som körts hade då blivit:

DELETE FROM tabell WHERE id=0

Eftersom man vanligtvis aldrig har id-nummer som är mindre än 1 skulle ovanstående kod vara ofarlig. Det kan dock vara en god idé att utföra lite mer kontroll av id-variabeln än så. Det är vanligt att man först tar reda på heltalsvärdet av id och sen kontrollerar så att det har ett vettigt värde. Om man t.ex. förväntar sig ett heltal mellan 1 och 1000 kan man göra såhär:

$id = intval($_GET[’id’]);

if ($id < 1 || $id > 1000)
exit(’Aja baja!’);

mysql_query(’DELETE FROM table WHERE id=’.$id) or exit(mysql_error());

Med några enkla knep har vi säkrat vårt skript från SQL-injektioner. Vårt skript är dock långt ifrån säkert. Som jag nämnde inledningsvis kan man ju bara byta ut id mot ett annat heltal och på så sätt ta bort en annan rad i databasen. Hur löser man detta då? Det är lite knepigare. Det man får göra är att på något sätt kontrollera att den aktiva användaren har rättigheter att radera raden i fråga. Alltså, innan man kör databasfrågan som tar bort raden, kontrollerar man så att $id är ett id som får tas bort. Hur man gör detta tänker jag inte gå in på. Det finns nämligen så många sätt. Vanligast är väl att köra en SELECT-fråga och ta reda på om $id får tas bort av den aktiva användaren.

Ett annat klassiskt misstag är att inkludera PHP-skript enbart baserat på vad som anges i URL:en. Det kan ibland vara praktiskt att skicka med en parameter till ett skript och att låta denna parameter bestämma vilket skript eller vilken sida som skall visas för besökaren. Man skulle t.ex. kunna tänka sig att man har ett skript index.php och att detta skript visar olika sidor beroende på vad man anger för page i URL:en. Skriver man in index.php?page=about.php skall sidan about.php inkluderas. Skriptet index.php kanske då kör följande kod:

include $_GET[’page’];

En mycket dålig idé. En illvillig användare kan bara byta ut about.php mot något annat skript som han/hon kanske inte har tillgång till vanligtvis eller kanske t.o.m. en känslig fil någon annanstans på hårdisken på servern. Om man anropat skriptet med t.ex. page=/etc/passwd hade det skrivit ut innehållet i passwd-filen (om det är ett *NIX-system). Tro mig när jag säger att det är en dålig idé.

En vanligt förekommande åsikt är att problemet kan lösas genom att tvinga dit en filändelse på slutet av det som skall inkluderas. Såhär:

include $_GET[’page’].’.php’;

Detta löser dock inte problemet. Om magic_quotes_gpc är avstängt i php.ini (vilket det bör vara), kan man komma runt skyddet genom att anropa skriptet med t.ex. page=/etc/passwd%00 där %00 representerar tecknet NUL (vilket PHP tolkar som slutet på en sträng). Att bara smälla dit en filändelse på slutet är alltså en dålig idé det också. Tack och lov finns det en snygg och effektiv lösning. Skriptet index.php kan säkras från denna typ av attack med följande lilla kodsnutt:

switch ($_GET[’page’])
{
case ’about’:
require ’about.php’;
break;

    case ’contact’:
require ’contact.php’;
break;

    default:
require ’default.php’;
break;
}

Anropas skriptet med page=about inkluderas about.php. Anropas det med page=contact inkluderas contact.php. Försöker man anropa det med något annat inkluderas en standardsida (default.php). I exemplet ovan använder jag require() istället för include(). Den enda skillnaden mellan de två är att include genererar en varning om filen inte kan inkluderas och require genererar ett fel så att skriptet avbryts. Man bör alltså använda require om man inte vill att skriptet skall fortsätta exekvera om en filinkludering misslyckas. Jag kan inte tänka mig en situation när man vill det, så mitt tips är att ni håller er till require. Om man är osäker på om filen redan kan ha inkluderats tidigare, kan man välja att använda require_once() istället.