| Door: Jillis ter Hove. | Categorie: Algemeen.

Veranderings detectie voor je website

security-maand-4Het beveiligen van een Joomla! website is geen makkelijke en/of eenmalige taak. Er moet constant geupdate worden (Joomla! core updates en gebruikte componenten) en er is veel verstand van zaken nodig (DB aanpassingen, UNIX rechten, etc). Daarnaast is er ook nog sprake van een inherente handicap voor beveiligers: een aanvaller hoeft namelijk maar 1 gat te vinden terwijl de verdediger zijn hele "omheining" in de gaten moet houden.

Het is daarom verstandig om rekening te houden met het feit dat er vroeg of laat een probleem zal optreden. Na een hack, worden er in 9 van de 10 gevallen, een of meer van de volgende aanpassingen aan een site gemaakt:

Al deze veranderingen hebben gemeenschappelijk dat de broncode van de site verandert. Als we dus dit soort veranderingen zouden kunnen detecteren dan is het mogelijk deze hacks tijdig te herkennen.

Hoe Werkt Het

De meest gebruikte manier om een bestand op veranderingen te controleren is de zogenaamde hash functie. Een hash functie is een speciale functie die als eigenschap heeft dat een kleine verandering in de input, een groot effect heeft op de output. Een andere eigenschap van een hash functie is dat deze maar een kleine waarde oplevert (de zogenaamde hash) ongeacht de lengte van de invoer.

Door de inhoud van een bestand in een hash-functie te stoppen, kunnen we dus een kleine waarde uitrekenen (een hash) die verandert als de inhoud verandert. Als we dus op 2 verschillende tijdstippen een hash uitrekenen, en deze vergelijken, dan kunnen we gemakkelijk zien of er veranderingen zijn aangebracht in de tussentijd. Immers een kleine verandering in de invoer (het bestand) heeft een groot effect op de uitvoer (de hash). Als er veranderingen zijn aangebracht in de tussentijd dan zijn de hashes verschillend.

Dit principe laat zich gemakkelijk generaliseren tot hele directories. Neem van elke bestand een hash en plak deze achter elkaar. Maak van deze lang string zelf weer een hash en sla deze op. Als 1 van de bestanden verandert zal de hash van dat bestand veranderen. Hierdoor verandert de grote string en dit zorgt ervoor dat de uiteindelijke hash ook weer verandert. Dit werkt, want een van de eigenschappen van de hash functie is dat een kleine verandering in de invoer een groot effect heeft op de uitvoer. Hierdoor zorgt een eventuele verandering voor een waterval van veranderingen die ervoor zorgt dat als er ook maar 1 teken verandert is, er een totaal andere hash uitkomt.

De scripts

monitoringEen simpel hash controle systeem, dat op de hierboven geschetste manier werkt, zou kunnen bestaan uit 2 losse scripts. Een "Check" script dat in de te controleren sites staat en een centraal "Monitoring" script dat voor ons alle sites afgaat en de check scripts uitvoert (zie afbeelding). Hierdoor kunnen we vanuit een centrale locatie, meerdere sites in de gaten houden.

Het monitor script loop alle sites na en vergelijkt hun huidige hash met de vorige hash en rapporteert eventuele veranderingen. Uiteindelijk slaat het "monitor" script de laatst ingelezen hashes op zodat we deze de volgende keer kunnen gebruiken voor de vergelijking.

Het check script werkt door alle bestanden van 1 site af te lopen en voor elk bestand een hash waarde te berekenen. Al deze hashes worden gecombineerd tot 1 uiteindelijke hash en dit wordt als antwoord teruggegeven. Het script zal gebruik maken van de standaard PHP hash functie sha1.

Het is belangrijk dat het monitor script zelf niet "in het bereik" van een check script staat (d.w.z. in een directory staat onder de directory waarin het check script draait). Er treedt dan een probleem op met de tijdelijke opslag van de vorige resultaten, immers dit verandert elke keer! De scripts mogen dus wel op dezelfde server staan maar moeten wel in gescheiden stukken van het bestandssysteem staan.

Het Check Script

Het check script werkt door alle bestanden in een Joomla! instantie af te lopen en hiervan een hash te berekenen. Deze deel-hashes worden tot een eind-hash gecombineerd en dit wordt als antwoord opgeleverd. Niet alle bestanden en directories zijn interessant om mee te nemen in de check, bv. de logs en cache mappen zijn elke keer anders. We willen ook niet elke keer dat er een nieuwe plaatje is geüpload een melding krijgen. Het script moet dus de mogelijkheid hebben om bepaalde mappen en bestanden te negeren.

Een andere eigenschap die bepalend is voor het script is de zogenaamde open_basedir restrictie die vaak aanstaat. Deze restrictie zorgt ervoor dat een script alleen maar mag lezen in de directory waarin het is opgestart, en in de directories eronder. Het script zal dus moeten werken vanuit de root van de site die we in de gaten willen gaan houden.

Het check script begint daarom als volgt:

    // lijst van bestand/directory namen die niet moeten worden meegenomen
// NB als bv. de vermelding cache word opgenomen en deze zou 2x voorkomen
// als '/cache' en '/admin/cache' dan zouden deze allebij worden uitgesloten!
// NB2 '.', en '..' moeten worden opgenomen zodat de functie "omhoog" loopt en
// "vader" directories meeneemt
$except = array(
'.',
'..',
'images',
'cache',
'logs',
'tmp'
);
// de directory waarin dit script wordt aangeroepen
$root = dirname(__FILE__).'/';
// deze constante bepaalt hoe groot de tussentijdse hash mag worden
$MAX_HASH_SIZE = 2048;

We definiëren allereerst een array met daarin bestands- en directory- namen die niet hoeven worden meegenomen in de check ($except), hierna lezen we de huidige directory in ($root) en definiëren we een maximale tussentijdse hash size ($MAX_HASH_SIZE). Vervolgens definiëren we een hulp-functie die de lijst met bestandsnamen gaat uitrekenen door recursief alle directories en sub-directories af te lopen.

    // een recursieve functie die alle bestands namen oplevert in een directory en zijn
// subdirectories
function listFiles($directory, $except = array('.','..')) {
// de tussentijdse lijst met bestanden
$result = array();
// open de aangeleverde directory
$handle = opendir($directory);
// lees de directory in
while($resource = readdir($handle)) {
if($resource === false)
// deze directory is volledig ingelezen
break;
// als de bestands naam NIET voorkomt in de except array
if(!in_array(strtolower($resource), $except)) {
// verwerk en let niet op evt. meldingen (openbasedir)
if(@is_dir($directory . $resource . '/'))
// lees recursief de subdirectory in en voeg deze
// samen met de huidge tussentijdse lijst
$result = array_merge($result, listFiles($directory . $resource . '/', $except));
else
// sla de directory + bestandsnaam op in de tussentijdse lijst
$result[] = $directory . $resource;
}
}
// sluit de geopende directory weer netjes af
closedir($handle);
return $result;
}

De recursieve listFiles functie gebruikt standaard PHP functies om de meegegeven directory ($directory) door te lopen. Voor elk item, dat niet in de $except array voorkomt, wordt er gekeken of het een file of een directory is: is het een file dan wordt het opgeslagen in de tussentijdse lijst met bestandsnamen, is het een directory dan wordt de listFiles functie (recursief) aangeroepen op de sub-directory. Daarna wordt het resultaat van de recursieve aanroep (alle bestanden in die subdirectory en eronder) meteen samengevoegd met de tussentijdse lijst. Als alle bestanden en sub-directories opgesomd zijn dan wordt de lijst opgeleverd als het eindresultaat.

Nu zijn we toe aan het maken van de uiteindelijke hash. Eerst vragen we de lijst met bestanden in de huidige directory op ( $files) en lopen hier vervolgens doorheen. We lezen dan voor elke file de inhoud in en rekenen hier een sha1 hash van uit. Deze hash wordt achter de tussentijdse hash geplakt.

    // lees alle bestanden in
$files = listFiles($root, $except);
// de uiteindelijke hash
$hash = '';
foreach($files as $file) {
// lees het bestand in zijn geheel in en hash de inhoud
// mocht dit mislukken zeur er dan niet over
$hash .= sha1(@file_get_contents($file));
// als de tussentijdse hash groter is geworden dan MAX_HASH_SIZE
if(strlen($hash) >= MAX_HASH_SIZE) {
// hash de tussentijdse hash zelf
$hash = sha1($hash);
}
}
// hash de tussentijdse hash zelf naar een hash
$hash = sha1($hash);
// geef de hash terug
echo $hash;

Omdat elke hash uit 40 tekens bestaat wordt de tussentijdse hash al snel erg groot. Als de totale hash langer wordt dan $MAX_HASH_SIZE dan rekenen we hier zelf weer een hash van uit. Hierdoor wordt de totale hash 40 tekens lang en gebruiken we niet teveel geheugen. Als we alle bestanden gehashed hebben, dan hashen nog 1 keer de tussentijdse hash (om er netjes 40 tekens van te maken) en sturen deze als antwoord op.

Het Monitor Script

Het centrale monitor script stelt ons in staat om eenvoudige meerdere websites te checken en bespaart ons het nodige administratieve werk. We beginnen met het definiëren van een lijst met alle sites die we zouden willen checken. Deze lijst bevat simpelweg het URL (van de root, waar dus het check.php script staat) van de websites die we willen checken. Vervolgens definiëren we een functie die via de cURL bibliotheek het antwoord van het check script ophaalt en teruggeeft.

<html>
<head>
<title>Website Integriteits Check</title>
</head>
<body>
<?php
// deze array bevat een lijst met sites die moeten worden geverifieerd
// NB deze urls moeten eindigen met een '/'
$sites = array(
...
);

// deze functie haalt met curl een 'check.php' hash op
function check($url) {
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $url . 'check.php');
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_FAILONERROR, true);
$result = curl_exec($ch);
$error = curl_errno($ch);
if($error != CURL_OK)
return null;
curl_close($ch);
return $result;
}

Vervolgens lezen we de vorige hashes van de harde schijf en lezen deze in ($previous).

    // lees de vorige hashes in
$hashes = file_get_contents('hashes.dat');
// hebben we al eens eerder een verificatie gedaan?
if($hashes == '')
// er is nog nooit een check uitgevoerd, geef dit aan met null
// zodat we straks een database zullen opbouwen
$previous = null;
else
// lees de opgeslagen php array in en maak er een echte php array van
$previous = unserialize($hashes);

Deze stap wordt bemoeilijkt door het feit, dat als het script voor het eerst draait, er nog geen hashes database is opgebouwd. We signaleren dit door $previous null te maken. Vervolgens lopen we langs alle sites in de $sites lijst en vragen de hash van die site op ($current).

    // de huidige hash resultaten
$current = array();
// haal voor alle sites een hash op
foreach($sites as $site) {
$hash = check($site);
if($hash == null)
// de hash word niet opgeslagen omdat er iets fout ging
// NB hierdoor word hij ook niet in het eindresultaat meegenomen
echo "Error: $site<br/>";
else
// sla de huidige hash op onder het url van de site
$current[$site] = $hash;
}

Nu kunnen we de vorige resultaten vergelijken met de huidige. Wat weer wordt bemoeilijkt door het feit dat de 1e keer dat het script draait, er nog geen database is. De variabele die de vorige resultaten zou moeten bevatten is dan null gemaakt. Als we merken dat er geen resultaten zijn ($previous == null), dan slaan we de huidige resultaten op alsof het allemaal goed is gegaan. In dit geval zijn we meteen klaar.

    // is dit de eerste keer?
if($previous == null) {
// sla onze huidige resultaten op, we hebben nog niks om mee te vergelijken
file_put_contents('hashes.dat', serialize($current));
// meld het resultaat van deze check
echo "First Time: hash database built";
return;
} else {
// controleer onze huidige resultaten met die van de vorige keer
$ok = 0; // er zijn 0 sites hetzelfde gebleven
$new = 0; // er zijn 0 nieuwe sites (waar nog geen hash waarde voor is)
// controleer de hash waarde voor alle site, hash paren die ingelezen konden worden
foreach($current as $site => $hash) {
// als de site nog niet bestond dan hebben we een nieuwe site erbij gekregen
if(!isset($previous[$site])) {
$new++;
echo "New Site: $site - $hash<br/>";
} else {
if($hash != $previous[$site]) {
// er is iets veranderd
echo "**ALERT** Change Detected: $site<br/>";
} else {
// site is hetzelfde gebleven
$ok++;
}
}
}
}

Als we al wel een keer uitgevoerd zijn, en er dus resultaten zijn om te vergelijken, dan lopen we door alle huidige resultaten heen. We kiezen voor de huidige (en niet de oude) lijst want op die manier zullen nieuwe sites die zijn toegevoegd sinds de laatste keer, automatisch worden meegenomen.

Vervolgens kijken we voor elke hash die we opgehaald hebben of het een nieuwe of een oude site is. Als de site de vorige keer ook al bestond dan vergelijken we het huidige resultaat en het vorige. Als de 2 hashes overeenkomen dan is alles ok en zijn er geen veranderingen aangebracht in de broncode. Is de hash wel anders dan is er iets veranderd en geven we meteen een melding. Was de site nog niet bekend de vorige keer, dan hebben we nog niks om te vergelijken en slaan we de site simpelweg over.

Tot slot slaan we de huidige hashes op en geven we alle resultaten nog een keer netjes weer in een klein overzichtje.

    // bewaar de huidige resultaten voor de volgende keer
file_put_contents('hashes.dat', serialize($current));
// toon een simpel raport
echo "Ok: $ok<br/>";
echo "New: $new<br/>";
?>
</body>
</html>

Verbeteringen

De scripts vormen de basis van een heel simpel check systeem dat op veel verschillende manieren kan worden uitgebreid. Er kan een wachtwoord aan het check script worden toegevoegd zodat niet iedereen zomaar een check kan uitvoeren. Het check script zou ook sommige database tabellen kunnen meenemen zoals bv. jos_users. Ook is het mischien interresant om niet een eind antwoord uit te rekenen maar alle tussentijdse hashes gewoon op te slaan, hierdoor wordt het mogelijk om te zien in welk bestand precies een verandering is opgetreden.

Het monitor script kan natuurlijk naar believen worden uitgebreid: meer user interface, meer geschiedenis, etc. Ook zou het monitor script met een al bestaand management console kunnen worden geintegreerd. Het monitor script kan natuurlijk ook middels een cron job periodiek worden uitgevoerd.

Ook kan het systeem uitgebreid worden met een simpel protocol dat het mogelijk maakt om fouten in het check.php script op te sporen. Zo is het in het huidige check script mogelijk dat wegens rechten problemen, geen enkele file echt ingelezen kan worden. Door het gebruik van @ is dit moeilijk te detecteren want het script zal wel degelijk een hash opleveren (allemaal hashes van een lege string). Een soortgelijk probleem kan zich ook voordoen bij de directories. Een simpel protocol en wat extra veiligheidschecks zouden het mogelijk maken deze problemen naar het monitoring script te sturen, die deze dan zou kunnen rapporteren.

Tot slot, stel ik alle code in dit artikel vrijelijk beschikbaar, maar het gebruik ervan is op eigen verantwoordelijkheid.

Download het monitor script. Download het check script.

Jillis ter Hove is mens sinds 1981 en programmeur sinds 1999. Als hij niet oude vrouwtjes de straat helpt over steken dan is hij plannen aan het maken om de wereld te veroveren. Volg Jillis op twitter of bezoek zijn website.