C, PHP, VB, .NET

Дневникът на Филип Петров


* Форми за качване на картинки с PHP

Публикувано на 30 ноември 2016 в раздел ОСУП.

В тази статия ще се запознаем с основните проблеми при качването на файлове чрез уеб форми. Ще покажем проста галерия, в която ще искаме да качваме снимки във формат jpg, png или gif.

Да разгледаме следния пример:

gallery.php:

<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.1//EN" "http://www.w3.org/TR/xhtml11/DTD/xhtml11.dtd"> 
<html lang="bg-BG" xml:lang="bg-BG" xmlns="http://www.w3.org/1999/xhtml" >
<head>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8"/>
<meta http-equiv="Content-Language" content="bg"/>
<title>Галерия със снимки</title>
</head>
<body>
<form action="upload.php" method="post" enctype="multipart/form-data">
<p>Качете снимка<br />
<input type="file" name="fileToUpload" /><br />
<input type="submit" name="submit" value="Upload" /></p>
</form>
<p><?php
   $dirname = "./images/";
   $images = glob($dirname."*.jpg");

   foreach($images as $image) {
      echo '<a href="'.$image.'">'
      echo '<img src="'.$image.'" width="100px" height="100px" alt="img" />&nbsp;';
      echo '</a>';
   }
?></p>
</body>
</html>

upload.php – уязвима версия 1:

<?php
 if(!isset($_POST['submit']) || empty($_FILES)){
    header("Location: gallery.php");
    exit;
 }
 
 $path = $_FILES['fileToUpload']['name'];
 $ext = strtolower(pathinfo($path, PATHINFO_EXTENSION));
 $target = "./images/";
 $target .= md5(microtime().basename($path)).'.'.$ext;
 
 if(!move_uploaded_file($_FILES['fileToUpload']['tmp_name'], $target)){
    echo 'Error uploading file!';
    exit;
 }
?>
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.1//EN" "http://www.w3.org/TR/xhtml11/DTD/xhtml11.dtd"> 
<html lang="bg-BG" xml:lang="bg-BG" xmlns="http://www.w3.org/1999/xhtml" >
<head>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8"/>
<meta http-equiv="Content-Language" content="bg"/>
<title>Качване на снимка</title>
</head>
<body>
<p>Успешно качихте снимката<br /><br />
<a href="gallery.php">Върни се назад</a>
</p>
</body></html>

Това е класическата схема за качване на файл от потребителя в директория на сървъра. В gallery.php имаме multipart форма, в която потребителя избира файл от неговия компютър. В upload.php извикваме функцията move_uploaded_file, която премества файла, който сървъра е получил в своята temp директория чрез post заявка, в директорията и името дефинирани в променлива $target. Към името на файла добавяме резултата от функция microtime, за да не се „сблъскат“ два файла с едно и също име (с което стария да бъде презаписан). Прекарваме името на файла с md5 алгоритъм, защото в случая не се интересуваме от оригиналното – името на файла само по себе си е информация подадена от потребителя и за това не се грижим за нейното валидиране. Можем дори да не използваме оригиналното име, а просто да взимаме текущото време в милисекунди – няма да е проблем за сегашната реализация. Така впрочем още преди да сме започнали се справихме с една потенциална уязвимост, която е свързана с подаване на невалидни имена, прескачане директории нагоре в йерархията, презаписване на информация в други важни файлове и др. – не всички са възможни (сървърите обикновено предотвратяват атаки като например презаписване на .htaccess), но все пак потенциални. При всички случаи е важно или да НЕ използвате оригиналното име на файла, или да го валидирате с regex преди да го използвате.

Вероятно на повечето хора е ясно какъв е проблема в така създадената форма – абсолютно никъде не проверяваме дали файла, който потребителя е изпратил, е картинка. Ами ако ни изпратят файл с разширение .php, в който има злонамерен код, който чете информация от диска, свързва се и краде информация от база от данни, и т.н.? Да, в конкретния случай ние записваме файла не с оригиналното си име, а с MD5 хеш от него с долепено времето от Unix епохата в милисекунди, но ако хакера положи достатъчно усилия, ще успее да открие „схемата“ по която записваме имената на файловете и ще успее да разбере името на php скрипта, който е качил. Определено искаме да защитим формата така, че да може да се качват само jpg и png файлове!

Един наивен, но за съжаление често срещан в практиката псевдоначин за защита, е да се гледа типа на файла чрез $_FILES[‘fileToUpload’][‘type’]. Тоест да имаме следната модификация на upload.php файла:

upload.php – уязвима версия 2:

<?php
 if(!isset($_POST['submit']) || empty($_FILES)){
    header("Location: gallery.php");
    exit;
 }
 if($_FILES['fileToUpload']['type'] != "image/jpeg"
	&&
    $_FILES['fileToUpload']['type'] != "image/png"
        &&
    $_FILES['fileToUpload']['type'] != "image/gif"){
		echo 'ONLY IMAGES ARE ALLOWED!';
		exit;
 }
 
 $path = $_FILES['fileToUpload']['name'];
 $ext = strtolower(pathinfo($path, PATHINFO_EXTENSION));
 $target = "./images/";
 $target .= md5(microtime().basename($path)).'.'.$ext;
 
 if(!move_uploaded_file($_FILES['fileToUpload']['tmp_name'], $target)){
    echo 'Error uploading file!';
    exit;
 }
?>
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.1//EN" "http://www.w3.org/TR/xhtml11/DTD/xhtml11.dtd"> 
<html lang="bg-BG" xml:lang="bg-BG" xmlns="http://www.w3.org/1999/xhtml" >
<head>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8"/>
<meta http-equiv="Content-Language" content="bg"/>
<title>Качване на снимка</title>
</head>
<body>
<p>Успешно качихте снимката<br /><br />
<a href="gallery.php">Върни се назад</a>
</p>
</body></html>

Ако тествате формата като нормален потребител, ще видите, че условието сработва – вече не може да качите файл, който не е картинка. Обаче не трябва да забравяме, че хакерите не са нормални потребители…

Проблемът с това решение е, че този type, който четем, е нещо, което самия хакер ни подава чрез хедърите на заявката! Тоест той няма проблем да ни изпрати файл с разширение „.php“ и да уведоми нашия PHP сървър, че е каквото си иска. Ще демонстрираме как става това с популярния инструмент BurpSuite. Стартирайте приложението и накарайте вашия браузър (вие ще влезете в ролята на хакера) да използва BurpSuite като прокси сървър на IP адрес 127.0.0.1 и порт 8080:

1

Когато качите вашия PHP файл на формата, ще видите вашата заявка в BurpSuite в режим на изчакване:

2

Просто променете content type на файла, който сте написали и натиснете „Forward“, за да изпратите POST заявката:

3

Ще видите, че файлът ще се качи успешно, а в директория „images“ на сървъра ще има .php файл – нещо, което не желаем да се случва. Поради тази причина запомнете едно важно и основно правило – никога не вярвайте на $_FILES[‘…’][‘type’] – това е информация, която идва от потребителя!

Много по-добър начин да се справите с този проблем е да гледате разширението на файла и да се подсигурите, че е едно от позволените от вас!

upload.php – работеща, но непълна версия 3:

<?php
 if(!isset($_POST['submit']) || empty($_FILES)){
    header("Location: gallery.php");
    exit;
 }
 
 $path = $_FILES['fileToUpload']['name'];
 $ext = strtolower(pathinfo($path, PATHINFO_EXTENSION));
 $allowedExts=array("jpg", "png", "gif");
 if(!in_array($ext, $allowedExts)){
    echo 'ONLY IMAGES ARE ALLOWED!';
    exit;
 }

 $target = "./images/";
 $target .= md5(microtime().basename($path)).'.'.$ext;
 
 if(!move_uploaded_file($_FILES['fileToUpload']['tmp_name'], $target)){
    echo 'Error uploading file!';
    exit;
 }
?>
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.1//EN" "http://www.w3.org/TR/xhtml11/DTD/xhtml11.dtd"> 
<html lang="bg-BG" xml:lang="bg-BG" xmlns="http://www.w3.org/1999/xhtml" >
<head>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8"/>
<meta http-equiv="Content-Language" content="bg"/>
<title>Качване на снимка</title>
</head>
<body>
<p>Успешно качихте снимката<br /><br />
<a href="gallery.php">Върни се назад</a>
</p>
</body></html>

С това почти ликвидирахме опасността от качване на скриптове вместо картинки, защото имената на файловете са гарантирано с разширение jpg, png или gif. Това, което не сме предотвратили все още е една хипотетична атака, в която:

  • Хакерът качва невалидна картинка – php файл с разширение jpg;
  • По някакъв начин хакерът успява да подмени mime type на .jpg файловете, например със следния .htaccess файл качен в директория images: AddType application/x-httpd-php .jpg

Естествено последното само по себе си е доста брутално хакване на хостинга ни, но все пак ще доведе до следния възможен резултат – jpg файловете да се изпълняват като php скриптове. Създайте текстов файл test.jpg със съдържание <?php phpinfo(); ?> и изпробвайте:

4

Дори тази атака да ни се струва като прекалено фантастична (а повярвайте, не е нещо, което да не е срещано – вижте например как може да се случи при включване на определен модул на Apache от страна на администраторите, който позволява повече от един handler за един и същ mime type), трябва да се замислим и над още нещо – а защо въобще позволяваме да се качи нещо, което не е картинка, претендирайки че е? Та нали тъй или иначе после тази картинка ще изглежда „счупена“ в нашата галерия?

Типът на файла стандартно се определя чрез т.нар. „mime type“. Отново, както в предишния пример, можем да използваме $_FILES[‘fileToUpload’][‘mime’], но по абсолютно същия начин на тази променлива НЕ трябва да се вярва, защото тя идва от потребителя!

В PHP има вградени функции за проверка на mime type на файл – такива са mime_content_type(file) и finfo_open(FILEINFO_MIME_TYPE). Въпреки, че би било полезна добавка към защитата на формата ни, тук няма да го разглеждаме с пример. Причината за това е, че отново ефектът ще е незадоволителен. Функциите за проверка на mime type всъщност правят нещо много простичко – отварят файла и поглеждат дали първите няколко символа от хедъра им отговарят на познат за тях хедър. Никой не ви спира да изпратите файл, който не е картинка, но с хедър на картинка – това ще премине mime type проверката, а после отново картинката ще изглежда „счупена“.

upload.php – работеща, но не напълно сигурна версия 4:

<?php
 if(!isset($_POST['submit']) || empty($_FILES)){
    header("Location: gallery.php");
    exit;
 }

 if (@getimagesize($_FILES["fileToUpload"]["tmp_name"]) == false) {
    echo 'YOUR IMAGE IS BROKEN!';
    exit;
 }
 
 $path = $_FILES['fileToUpload']['name'];
 $ext = strtolower(pathinfo($path, PATHINFO_EXTENSION));
 $allowedExts=array("jpg", "png", "gif");
 if(!in_array($ext, $allowedExts)){
    echo 'ONLY IMAGES ARE ALLOWED!';
    exit;
 }
 $target = "./images/";
 $target .= md5(microtime().basename($path)).'.'.$ext;
 
 if(!move_uploaded_file($_FILES['fileToUpload']['tmp_name'], $target)){
    echo 'Error uploading file!';
    exit;
 }
?>
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.1//EN" "http://www.w3.org/TR/xhtml11/DTD/xhtml11.dtd"> 
<html lang="bg-BG" xml:lang="bg-BG" xmlns="http://www.w3.org/1999/xhtml" >
<head>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8"/>
<meta http-equiv="Content-Language" content="bg"/>
<title>Качване на снимка</title>
</head>
<body>
<p>Успешно качихте снимката<br /><br />
<a href="gallery.php">Върни се назад</a>
</p>
</body></html>

В случая използваме популярния метод да пуснем файла през функцията на GD библиотеката getimagesize, която проверява размера на картинката. Ако тази функция върне грешка, качения файл няма да е картинка.

Тази техника… почти работи. Проблемът при нея е, че продължава да е заобиколима, защото хакерът може да вкара PHP код в EXIF на снимката. Нека видим как става това с програмата EXIF Jpeg Header Manipulation Tool. Вземете която и да е картинка, аз ще си избера тази:

Pepi

Нека името е pepi.jpg. Изпълнете следните команди:

jhead -purejpg pepi.jpg
jhead -ce pepi.jpg

Първата команда изтрива ненужни вече записани хедъри във файла. С втората команда ще се отвори notepad, в който на един ред трябва да запишете кода си. Например:

jhead

Запишете и затворете Notepad. CSS кода е сложен, за да може символите, които ще се принтират на екрана от съдържанието на картинката, да станат невидими в браузъра. Готово – вече си имате jpeg файл, който е с image/jpeg mime type и който:

  • Ако се отвори като картинка, ще бъде изобразена снимката;
  • Ако се отвори с PHP компилатор, ще бъде… изпълнен PHP код.

Опитайте – качете файла в галерията. Ще видите, че се изобразява нормално. След това влезте в директория images и го преименувайте на .php и го отворете в браузъра. Ще видите, че php кода се изпълнява! При това уязвимостта я има не само за jpeg картинки, но също така и за gif, и за png.

Казано с други думи, дотук имаме една адекватна защита, която обаче все още НЕ гарантира 100% липса на уязвимост. Продължаваме да зависим от външни фактори, като например администратора на нашия уеб сървър.

Едно решение в този случай е да се лишим напълно от EXIF данните в картинката. Това става най-лесно като направим копие само на графичната част на тази картинка. При GIF и PNG нещата са по-сложни, защото там данните се вмъкват по по-различен начин вътре в самата картинка, но техниката ще е валидна и при тях, защото реално ще преформатираме съдържанието на файла, а както знаем дори един променен символ би направил PHP кода невалиден:

upload.php – почти напълно сигурна версия 5:

<?php
 if(!isset($_POST['submit']) || empty($_FILES)){
    header("Location: gallery.php");
    exit;
 }
 if (@getimagesize($_FILES["fileToUpload"]["tmp_name"]) == false) {
    echo 'YOUR IMAGE IS BROKEN!';
    exit;
 }
 
 $path = $_FILES['fileToUpload']['name'];
 $ext = strtolower(pathinfo($path, PATHINFO_EXTENSION));
 $allowedExts=array("jpg", "png", "gif");
 if(!in_array($ext, $allowedExts)){
    echo 'ONLY IMAGES ARE ALLOWED!';
    exit;
 }

 if($ext == "jpg"){
    $cleanImage = imagecreatefromjpeg($_FILES['fileToUpload']['tmp_name']);
    imagejpeg($cleanImage, $_FILES['fileToUpload']['tmp_name'], 90);
 }
 elseif($ext == "png"){
   $cleanImage = imagecreatefrompng($_FILES['fileToUpload']['tmp_name']);
   imagepng($cleanImage, $_FILES['fileToUpload']['tmp_name'], 0);
 }
 else{
   $cleanImage = imagecreatefromgif($_FILES['fileToUpload']['tmp_name']);
   imagegif($cleanImage, $_FILES['fileToUpload']['tmp_name']);
 }
 $target = "./images/";
 $target .= md5(microtime().basename($path)).'.'.$ext;
 
 if(!move_uploaded_file($_FILES['fileToUpload']['tmp_name'], $target)){
    echo 'Error uploading file!';
    exit;
 }
?>
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.1//EN" "http://www.w3.org/TR/xhtml11/DTD/xhtml11.dtd"> 
<html lang="bg-BG" xml:lang="bg-BG" xmlns="http://www.w3.org/1999/xhtml" >
<head>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8"/>
<meta http-equiv="Content-Language" content="bg"/>
<title>Качване на снимка</title>
</head>
<body>
<p>Успешно качихте снимката<br /><br />
<a href="gallery.php">Върни се назад</a>
</p>
</body></html>

Защо написахме, че е „почти напълно сигурна“? Никой не може да гарантира, че парсърите на картинки – функциите imagejpeg, imagepng и imagegif – не могат да бъдат заобиколени. Потенциално е възможно да се вмъкне PHP или друг код по такъв начин, че парсера да го помисли за валидна image информация. Естествено тук вече нещата за хакера са неимоверно сложни. Недостатък на този метод е, че реално променяте качеството на картинката, когато е jpeg. Едва ли ще бъде видимо за обикновено човешко око, но все пак е фактор, който трябва да се отчете.

Eдна добра допълнителна мярка е да изключите php engine за images директорията напълно! Това се получава лесно с htaccess файл със следния код:

php_flag engine off

По този начин си гарантирате, че дори някак да успеят да качат хакната картинка, тя няма да може да се изпълни като php код.

Заключение

Справянето с проблемите с Image uploads е трудно, защото се изисква дисциплина както от разработчика, така и от администраторите на хостинга. При всички положения, както и при повечето други уязвимости досега, е важно да спазвате правилото – никога не се доверявайте на информация подадена от потребителя!

Допълнителна задача

Добавете лимит за големината на файла както от страна на потребителя (формата на gallery.php), така и от страна на сървъра (проверка в upload.php и отказ ако е подаден по-голям файл).

 



Добави коментар

Адресът на електронната поща няма да се публикува


*