2009-08-21:

PHP getimagesize internals (część 1)

php:security:easy
Funkcja getimagesize jest, moim zdaniem oczywiście, jedną z bardziej interesujących funkcji wchodzącej w skład standardowego zestawu funkcji PHP (tak, standardowego, mimo iż jej dokumentacja leży w sekcji pełnej funkcji GD). Czemu ta funkcja jest taka interesująca? Po pierwsze, jej implementacja jest długa, a jak wiadomo, dużo kodu = dużo mniejszych lub większych bugów. Po drugie, funkcja ta bardzo często jest wykorzystywana w sposób niewłaściwy, przez co do kodu wprowadzane są pewne interesujące bugi.

Ale najpierw krótki wstęp teoretyczny.

Funkcja getimagesize, zaimplementowana w ext/standard/image.c, służy do "ustalenia wielkości dowolnego pliku graficznego, i zwrócenia rozmiarów, razem z typem pliku i stringiem zawierającym width i height który można wkleić do tagu <IMG> [...]" (cytat z dokumentacji w wolnym tłumaczeniu). Prototyp funkcji wygląda następująco:

array getimagesize ( string $filename  [, array &$imageinfo  ] )

Pierwszy parametr jest oczywisty, drugi natomiast jest opcjonalny, i służy do zwrócenia pewnych dodatkowych informacji o plikach JPEG (konkretniej zwraca chunki APP w tablicy asocjacyjnej).
Jak widać funkcja zwraca array, który wygląda następująco:

array(7) {
 [0]=>
 int(WYSOKOSC)
 [1]=>
 int(SZEROKOSC)
 [2]=>
 int(TYP_PLIKU)
 [3]=>
 string(24) "width="WYSOKOSC" height="SZEROKOSC""
 ["bits"]=>
 int(BPP)
 ["channels"]=>
 int(ILOSC_KANAŁÓW)
 ["mime"]=>
 string(9) "TYP_MIME"
}


I tutaj zaczynają się pierwsze nieścisłości. Mianowicie w dokumentacji napisane jest:

Returns an array with 7 elements.
[...]
For some image types, the presence of channels and bits values can be a bit confusing. As an example, GIF always uses 3 channels per pixel, but the number of bits per pixel cannot be calculated for an animated GIF with a global color table.

On failure, FALSE is returned.


Natomiast w kodzie (korzystam ze źródeł 5.3.0) możemy znaleźć:

if (result->bits != 0) {
 add_assoc_long(return_value, "bits", result->bits);
}
if (result->channels != 0) {
 add_assoc_long(return_value, "channels", result->channels);
}


Czyli z kodu wynika iż pola bits i channels w zwracanym array'u są opcjonalne. W związku z powyższym, programista PHP może dostać niespodziewane notice'y (ofc niekoniecznie muszą się one pokazać użytkownikowi, jest to zależne od konfiguracji PHP) w przypadku niektórych image'ów. Np.:

<?php
// $nazwa - tutaj mamy nazwe obrazka
$arr = @getimagesize($nazwa);
echo 'bits    : ' . $arr['bits'] . "<br/>\n";
echo 'channels: ' . $arr['channels'] . "<br/>\n";


Gdy powyższy kod dostanie obrazek z bits i channels ustawionym na 0, efekt będzie następujący:

Notice: Undefined index: bits in /.../php_getimagesize/test2.php on line 4
bits    :

Notice: Undefined index: channels in /.../test2.php on line 5
channels:


Tak że pierwsze uwaga do programistów w tym miejscu: odwołania do bits i channels obwarowujcie isset lub lepiej, array_key_exists, tak na wszelki wypadek :)
(Ah, jak znam życie kilka osób zarzuci mi tutaj że przejmuje się notice'ami które są defaultowo wyłączone, albo które samemu można wyłączyć. Cóż, poprawne programowanie nie polega na wyłączaniu/ukrywaniu komunikatów o błędach, tylko na ich poprawnym obsługiwaniu, nawet jeśli defaultowo nie pojawiają się na "ekranie")

Skoro już przy warningach jesteśmy, w dokumentacji napisane jest:

If accessing the filename  image is impossible, or if it isn't a valid picture, getimagesize() will generate an error of level E_WARNING. On read error, getimagesize() will generate an error of level E_NOTICE.

Czyli oprócz zwrócenia FALSE przez funkcje, możemy się również w kilku przypadkach spodziewać warning'a lub notice'a (warto więc zainwestować w set_error_handler jeśli chcemy wiedzieć co było nie tak z obrazkiem, albo, jeśli nas to nie interesuje, użyć @ (w tym miejscu jest to dopuszczalne, ponieważ warning/notice stanowi tutaj tylko dodatkową informację o błędzie który nastąpił)).

Ponieważ post jest o 'internalsach', to sprawdźmy w których miejscach getimagesize może rzucić warning/notice. Najpierw notice'y (będę podawał od razu ciąg bajtów który generuje komunikat):

1. Wstępne sprawdzanie typu pliku za pomocą php_getimagetype (1):
if((php_stream_read(stream, filetype, 3)) != 3) {
 php_error_docref(NULL TSRMLS_CC, E_NOTICE, "Read error!");
 return IMAGE_FILETYPE_UNKNOWN;
}

W tym wypadku wystarczy podać jak dane obrazka ciąg (polecam stream data:// do takich eksperymentów) krótszy niż 3 znaki, np.:
$data = "Hi";
getimagesize("data://text/plain;base64," . base64_encode($data));



2. Wstępne sprawdzanie typu pliku za pomocą php_getimagetype (2):
} else if (!memcmp(filetype, php_sig_png, 3)) {
 if (php_stream_read(stream, filetype+3, 5) != 5) {
   php_error_docref(NULL TSRMLS_CC, E_NOTICE, "Read error!");
   return IMAGE_FILETYPE_UNKNOWN;
 }

W tym wypadku ciąg musi zawierać przynajmniej trzy pierwsze bajty sygnatury PNG (89 50 4e), i być krótszy niż 8 znaków, np.:
$data = "\x89\x50\x4eHi!";

3. Wstępne sprawdzanie typu pliku za pomocą php_getimagetype (3):
if (php_stream_read(stream, filetype+3, 1) != 1) {
 php_error_docref(NULL TSRMLS_CC, E_NOTICE, "Read error!");
 return IMAGE_FILETYPE_UNKNOWN;
}

Powyższy kod jest wywoływany gdy żadna 3 bajtowa sygnaturka pliku bajtowego nie będzie się zgadzać, i gdy trzeba zacząć sprawdzać czterobajtowe sygnaturki. Wystarczy więc podać dowolne 3 bajty które nie są poprawną sygnaturką pliku graficznego (np. ABC), i nic więcej:
$data = "ABC";

4. Wstępne sprawdzanie typu pliku za pomocą php_getimagetype (4):
if (php_stream_read(stream, filetype+4, 8) != 8) {
 php_error_docref(NULL TSRMLS_CC, E_NOTICE, "Read error!");
 return IMAGE_FILETYPE_UNKNOWN;
}

Taak, zgadliście, znowu ta sama funkcja. Tym razem skończyły się 4ro bajtowe sygnaturki, i czas zacząć sprawdzać 12 bajtowe, trzeba więc wczytać dodatkowe 8 bajtów. Oczywiście, wystarczy w tym wypadku podać dowolny ciąg dłuższy niż 3 bajty, a krótszy niż 12 bajtów, np. "ABCDEF":
$data = "ABCDEF";

5. Jest jeszcze jeden notice dotyczący plików SWC, natomiast jest pojawienie się zależy od tego czy PHP jest skompilowane ze statycznie zlinkowanym zlib'em, czy nie. Jeżeli nie ma statycznie skompilowanego zlib'a, wtedy PHP wypisuje że getimagesize nie obsługuje plików SWC.
#if HAVE_ZLIB && !defined(COMPILE_DL_ZLIB)
 result = php_handle_swc(stream TSRMLS_CC);
#else
 php_error_docref(NULL TSRMLS_CC, E_NOTICE, "The image is a compressed SWF file, but you do not have a static version of the zlib extension enabled");
#endif

Aby sprawdzić czy PHP obsługuje SWC wystarczy podać ciąg:
$data = "CWS";

Czas na warningi:

1. Funkcja php_handle_jpc, sprawdzanie czy pierwszy marker to JPEG2000_MARKER_SIZ aka 0x51.
if (first_marker_id != JPEG2000_MARKER_SIZ) {
 php_error_docref(NULL TSRMLS_CC, E_WARNING, "JPEG2000 codestream corrupt(Expected SIZ marker not found after SOC)");
 return NULL;
}

Aby dojść do tego miejsca w kodzie funkcja php_getimagetype musi zwrócić IMAGE_FILETYPE_JPC, a żeby to zrobiła pierwszy trzy bajty muszą być równe FF 4F FF, a następny bajt (o ile będzie istnieć w ogóle) musi być różny od 0x51.
$data = "\xff\x4f\xffHi!";

2. Funkcja php_handle_jp2, sprawdzanie czy udało się uzyskać wielkość (sam koniec funkcji):
if (result == NULL) {
 php_error_docref(NULL TSRMLS_CC, E_WARNING, "JP2 file has no codestreams at root level");
}

W zasadzie aby dojść do tego miejsca wystarczy podać poprawną sygnaturkę JP2, czyli 00 00 00 0c 6a 50 20 20 0d 0a 87 0a (12 bajtów):
$data = "\x00\x00\x00\x0c\x6a\x50\x20\x20\x0d\x0a\x87\x0a";

3. Wstępne sprawdzanie typu pliku za pomocą php_getimagetype, uszkodzona sygnatura PNG:
} else if (!memcmp(filetype, php_sig_png, 3)) {
 if (php_stream_read(stream, filetype+3, 5) != 5) {
   php_error_docref(NULL TSRMLS_CC, E_NOTICE, "Read error! (2)");
   return IMAGE_FILETYPE_UNKNOWN;
 }
 if (!memcmp(filetype, php_sig_png, 8)) {
   return IMAGE_FILETYPE_PNG;
 } else {
   php_error_docref(NULL TSRMLS_CC, E_WARNING, "PNG file corrupted by ASCII conversion");
   return IMAGE_FILETYPE_UNKNOWN;
}

Ciąg musi mieć nie mniej niż 8 bajtów, z czego 3 pierwsze muszą być identyczne jak 3 pierwsze bajty sygnatury PNG, a pozostałe 5 może być dowolne, ale różne od poprawnej sygnatury PNG:
$data = "\x89\x50\x4eALAMAKOTA";

I to by było na tyle jeśli chodzi o warningi zależne od danych obrazka, generowane przez tą funkcje. W każdym innym wypadku funkcja po cichu wychodzi zwracając FALSE, i nie emitując żadnego komunikatu.

Osoby testujące aplikacje napisane w PHP mogą stworzyć sobie kilka przykładowych "obrazków" (na podstawie powyższych ciągów), tak aby przetestować czy żadne warningi/notice'y nie są przez aplikacje faktycznie rzucane. Szczególnie warto sprawdzić okolice uploadowania awatarków, fotek, etc :)

I to by było na tyle jeśli chodzi o dzisiejszy post. Zapraszam po weekendzie na część drugą getimagesize internals :)

Comments:

2009-08-21 17:10:03 = Malcom
{
Niezle ;) Mimo wszystko chyba getimagesize() jest jedynym prostym i sensownym sposobem sprawdzenia mime-type obrazka, przy uploadzie przegladarki czasem rozne dziwne rzeczy wysylaja ;p

Skoro masz pod reka zrodelka php (bo mi sie nie chce ciagnac repo ;p) mozesz zerknac na implementacje funkcji count? Zawsze mnie zadziwialy wszelkie materialy o optymalizacjach, gdzie odradzano uzywanie count() w wielokrotnych wywolaniach np. w petlach (cos a'a przypadek strlen w for dla C). Mam wrazenie ze jest to proste zwrocenie trzymanej gdzies wartosci dla danego typu (struktury), tak przynajmniej nakazywalaby logika. Watpie zeby w ogole to liczyl przy wywolaniu.
}
2009-08-22 12:13:50 = coldpeer
{
http://google.com/codesearch/p?hl=pl&sa=N&cd=1&ct=rc#VO0L3v5h1Bg/php-5.1.0/ext/standard/array.c&q=php5%20%22proto%20int%20count%22%20mixed%20lang:c&l=304

;)
}
2009-08-24 09:58:04 = Gynvael Coldwind
{
@Malcom
Fakt faktem w 99,99% przypadków getimagesize() spełnia swoje zadanie świetnie ;> Pozostaje 0,01% ;>

Co do count (thx coldpeer za linka) to jest jak mówisz, mianowicie count() dla arraya wywołuje php_count_recursive(), który wywołuje zend_hash_num_elements(), który wygląda z kolei następująco:

ZEND_API int zend_hash_num_elements(const HashTable *ht)
{
IS_CONSISTENT(ht);

return ht->nNumOfElements;
}

Makro IS_CONSISTENT to wywołanie funkcji _zend_is_inconsistent, która dla poprawnego arraya sprowadza się do:

if (ht->inconsistent==HT_OK) {
return;
}

Czyli w zasadzie jest to odczytanie stałej (nNumOfElements) obwarowane kilkoma if'ami.
Gorzej jest w przypadku gdy wywołamy count($arr, COUNT_RECURSIVE), wtedy php_count_recursive() enumeruje elementy tablicy, i dla każdego arraya wywołuje samą siebie.

Natomiast domyślam się (nie testowałem) że dostęp do zmiennej jest szybszy niż wywołanie funkcji (php->c) + parsing jej parametrów + wywołanie kolejnej funkcji (c) + i kolejnej (c), stąd pewnie te zalecenia optymalizacyjne.

Natomiast szczerze, to trzeba by sprawdzić co jest szybsze pisząc jakieś proste testy ;>
}
2009-08-24 23:37:45 = Malcom
{
Juz przeanalizowalem kod, ledwo co pojawil sie link coldpeera ;)

Tak, jak prawie wszedzie da sie znalezc ten 0,01%, ktory przy odpowiednim wykorzystaniu moze wywolac wiele zamieszania ;p

Nawiazujac do tematu, wydaje mi sie, ze obecnie popularne przegladarki sa juz "inteligentne" i nie wykonuja kodu z obrazka - kiedys popularne bylo pakowanie js do obrazkow.
}
2009-08-25 08:10:42 = Gynvael Coldwind
{
@Malcom
Zgadzam się co do JS (chociaż jakieś 2 miesiące temu czytałem że IE nadal wykonywał JS z GIF'a przy bezpośrednim odwołaniu) ;>
Natomiast to chyba nie wszystko co można popsuć ;>
}

Add a comment:

Nick:
URL (optional):
Math captcha: 8 ∗ 9 + 9 =