artykuły

Odkrycie: Integer overflow w funkcjach z rodziny scanf() w MinGW, Cygwin, Embarcadero C i innych środowiskach przy wczytywaniu liczby do char'a

23:48
czw, 23 luty 2017

Wstęp

Wgryzając się głęboko w błąd opisywany w ramach mojego ostatniego artykułu z cyklu „Język C: Czasochłonne błędy przez które ludzie programiści skaczą z mostów” zauważyłem pewną ciekawą rzecz, która powoduje, że w tej chwili wszystkie programy skompilowane pod systemem Windows na kompilatorach MinGW GCC, Cygwin GCC, Embarcadero/Borland C ORAZ ładujące liczbowe dane do typu char z użyciem funkcji z rodziny scanf są podatne na błąd integer overflow (!) :) Niemniej, dotyczy to jedynie tych programów, które wykorzystują w scanf określony specyfikator.

Po chwili paniki, możecie wrócić do artykułu :) w którym postaram się nieco rozjaśnić na czym ten problem polega.

Na czym ten błąd polega?

Problem nie leży tyle w samym kompilatorze (no może troszeczkę – o tym za chwilę) co w Windowsowskiej bibliotece MSVCRT, która zawiera implementację glibc, a więc funkcji takich jak scanf. Biblioteka nie jest zgodna ze standardem C99 (ISO 9899:1999) (a co najwyżej z C89) i nie implementuje ona wszystkich specyfikatorów formatu przyjmowanych przez funkcję scanf (dokładnie chodzi o specyfikatory zawarte w punkcie 11, na stronie 358 [wyświetlana w programie jako str. 370] specyfikacji standardu C99).

Kliknij tutaj, aby podejrzeć tę stronę specyfikacji


Całe sedno błędu polega na tym, że funkcje rodziny scanf dostając jako format ciąg ze specyfikatorami, których nie obsługują, nie pomijają danego składnika formatu, ale na siłę próbują go załadować. W przypadkach, w których te nieobsługiwane specyfikatory zmniejszają wielkość typu do załadowania (np. z int robi się short int) w każdym przypadku mamy integer overflow.

MSVCRT nie implementuje na przykład specyfikatora „h”, przez co możemy wprawdzie napisać „%hhu” (co oznacza pobranie 1 bajtowej wartości typu unsigned char), ale funkcja scanf w tych środowiskach i tak pobierze nam wtedy 4-bajtowy integer (a więc opuści wszystkie specyfikatory „h” w formacie i potraktuje go jako „%u”), co skończy się nadpisaniem dodatkowych 3 bajtów pamięci umieszczonych w pamięci nad naszą jednobajtową zmienną unsigned char. I w przypadku poniżej podanego przykładu (który format funkcji scanf() trzyma w osobnej zmiennej - to ważne) nawet nas nie poinformuje żadnym ostrzeżeniem o tym, że nie zna specyfikatora „h” (nawet jeśli włączymy parametry -Wall -Wextra). Aby kompilator MinGW nas informował, należy format podawać jako literał i kompilować z parametrem -Wall (kompilator Embarcadero/Borland nie poinformuje nas w żadnym przypadku, nawet przy kompilacji z parametrem -w). / co ciekawe, z mojej rozmowy z Embarcadero wynika, że ich kompilator nie korzysta z biblioteki MSVCRT /

Kiedy kompilator nas poinformuje o nieznajomości specyfikatora "h"
KompilatorKiedy nas poinformuje?
MinGWTylko wtedy kiedy parametr format funkcji scanf będzie literałem ORAZ włączymy "wszystkie ostrzeżenia" parametrem -Wall podawanym przy kompilacji.
CygwinTylko wtedy kiedy parametr format funkcji scanf będzie literałem ORAZ włączymy "wszystkie ostrzeżenia" parametrem -Wall podawanym przy kompilacji.
Borland/Embarcadero C/C++Nigdy. Nawet przy kompilacji z parametrem -w.

Smaczku dodaje fakt, że nawet jeśli przy kompilacji wymusimy (mogłoby się wydawać) kompilację w standardzie C99 pisząc:
gcc c:\main.c -std=c99 -o c:\main.exe
to i tak biblioteka MSVCRT ze standardem zgodna nie będzie (ta z której korzysta MinGW została wydana w 1998 r., jeszcze przed publikacją standardu C99) i podatność będzie nadal obecna. Niestety kompilator o tym nie ostrzega.

Podatne są wszystkie funkcje z rodziny scanf: fscanf, sscanf, vscanf, vfscanf, vsscanf w kompilatorach MinGW, Cygwin, Embarcadero BCC32C (tak naprawdę wszystkie te funkcje korzystają z funkcji vsscanf która jest funkcją właściwą, przechowującą całą logikę tego co scanf i inne funkcje dla nas robią). Nie są podatne natomiast środowiska Visual Studio.

Dodatkową ciekawostką jest fakt, że specyfikacja ISO/IEC 9899:1999 (C99) (a więc już ta specyfikacja, która definiuje specyfikatory „h” i inne) jasno podaje, że w przypadku podania błędnego formatu działanie funkcji jest nieokreślone. Problem w tym, że jednak powinno być określone jako pomijanie argumentów funkcji odpowiadających składnikom formatu, które nie są w całości obsługiwane.

W mojej rozmowie z Gynvael'em Coldwindem, legendą polskiej sceny hackerskiej, również potwierdził on, że:
scanf nie powinien upierać się przy wypełnianiu tagu w takim przypadku.

Przykładowy exploit

Weźmy na warsztat następujący kod: #include <stdio.h> #include <stdbool.h> typedef volatile unsigned char uint8_t; int main() { printf("Podaj liczbe mieszczaca sie w przedziale 0-255: "); bool allowAccess = false; uint8_t userNumber; char format[] = "%hhu"; scanf(format, &userNumber); if (allowAccess) { printf("Zezwolono na dostep do tajnych danych: Lech Walesa to Bolek\n"); } printf("Wprowadzona liczba to: %d\n", userNumber); return 0; }

Jak widzimy, zmienna allowAccess jest ustawiona na false, a nigdzie dalej nie zmieniamy explicite (jawnie) jej wartości na true. Tajne dane nigdy nie powinny więc zostać wyświetlone (i tak będzie dopóki będziemy grzecznie wprowadzać wartość z określonego zakresu). Co się stanie jednak, gdy "przepełnimy" naszą zmienną userNumber tak aby zmienna allowAccess została nadpisana dowolną, niezerową liczbą (zmienna zostanie potraktowana przez procesor jako true kiedy będzie w niej dowolna wartość nie będąca zerem). Spróbujmy się zatem włamać do programu tak, aby uzyskać tajne dane. W tym celu uruchamiamy program i - zapytani - wprowadźmy dowolną liczbę, która nie zmieści się w jednym bajcie - np. 256. Wynik działania programu będzie następujący:

Podaj liczbe mieszczaca sie w przedziale 0-255: 256
Zezwolono na dostep do tajnych danych: Lech Walesa to Bolek
Wprowadzona liczba to: 0

Przez odpowiednie machinacje na danych wejściowych, uzyskaliśmy dostęp do tajnych danych, do których nigdy nie powinniśmy mieć dostępu.

Inne funkcje rodziny scanf(): sscanf()

Funkcja sscanf od scanf różni się jedynie tym, że pobiera dane z bufora tekstowego, a nie ze standardowego wejścia (klawiatury). Obie funkcje odwołują się do wspomnianej już wcześniej funkcji vsscanf, więc również i sscanf jest podatna - sprawdźmy:
#include <stdio.h> #include <stdbool.h> typedef volatile unsigned char uint8_t; int main() { bool allowAccess = false; uint8_t userNumber; char format[] = "%hhu"; char buffer[] = "257\n"; sscanf(buffer, format, &userNumber); if (allowAccess) { printf("Zezwolono na dostep do tajnych danych: Lech Walesa to Bolek\n"); } printf("Wprowadzona liczba to: %d\n", userNumber); return 0; }

Inny przykład: Null character overwritting

AKTUALIZACJA 11.02.2017

Innym ciekawym przykładem zastosowania naszego błędu jest możliwość nadpisania bajtu 0, który oznacza koniec ciągu znakowego. Przyjmijmy, że mamy aplikację żądającą PINu (w zakresie 0-255 - spokojnie, wiadomo, że nikt tak krótkich pinów nie stosuje, ale chodzi przecież o przykład), a następnie żądającą hasła (którego nie znamy). Nadpisując jednobajtową zmienną przechowującą PIN jesteśmy w stanie tak zatruć znak końcowy ciągu znajdującego się w pamięci w linijce nad deklaracją naszej zmiennej, że przy wypisywaniu tego zatrutego ciągu program wypisze nam również hasło wzorcowe do którego porównuje to wprowadzone przez użytkownika. Spójrzmy: #include <stdio.h> #include <stdbool.h> #include <string.h> #define PASSWORD_MAX_LENGTH 64 typedef volatile unsigned char uint8_t; void debugPrintMemoryNearVariable(uint8_t* pointer) { for (long long int i=((long long int)pointer)-10; i<((long long int)pointer)+10; i++) { printf("%x (%c)\t", *((uint8_t*)i),*((uint8_t*)i) ); } printf("\n"); } int main() { char strProvidePin[] = "Please provide PIN number [0-255]: "; char strProvidePass[] = "Please provide password: "; char passPhraseToCompare[] = "very secret password to compare which can't leak\n"; char lang[1] = "E"; uint8_t userPIN; char userPass[PASSWORD_MAX_LENGTH+1]; char formatPIN[] = "%hhu"; /* printf("Memory before:\n"); debugPrintMemoryNearVariable(&userPIN); */ printf("%s", strProvidePin); scanf(formatPIN, &userPIN); /* printf("Memory after:\n"); debugPrintMemoryNearVariable(&userPIN); */ printf("%s", strProvidePass); getchar(); fgets (userPass, PASSWORD_MAX_LENGTH, stdin); printf("\n\nSelected language: %s\n", lang); // Password comparing if (strncmp(userPass, passPhraseToCompare, PASSWORD_MAX_LENGTH)==0) { printf("\nYou're logged in.\n"); // authorized operations }else{ printf("\nWrong password.\n"); } return 0; } Po uruchomieniu programu, wprowadźmy do niego jako PIN liczbę: 1094795520. Zauważymy, że w miejscu gdzie miał zostać wyświetlony wybrany język pojawiło nam się całe sekretne hasło (bez pierwszych dwóch liter) z którym program miał porównywać to wprowadzone przez użytkownika.
Dzieje się tak dlatego, że nadpisaliśmy znak terminujący ciąg zawarty w zmiennej lang (jest to bajt o wartości 0), przez co funkcja wypisująca tekst nie zauważyła jego końca (bo nie mogła) i wypisywanie znaków zakończyła dopiero na znaku kończącym następny ciąg, który był nad nim w pamięci - w tym przypadku na sekretnym wzorcu hasła.
Jest jedna ciekawostka związana z tym kodem. O ile w MinGW faktycznie nadpisują się wszystkie cztery bajty, o tyle w Embarcadero/Borland C (wersja najnowsza, 7.20, na czas pisania artykułu) nadpisane zostają jedynie dwa bajty ciągu tekstowego lang (akurat na szczęście dla prezentowanej luki jest w nich również ostatni, terminujący ciąg lang bajt, więc powyższy przykład działa).

Podatności w poszczególnych środowiskach

Podatne środowiska
Nazwa środowiskaPodatność
Środowiska dla Windows
Środowiska 32-bitowe
MinGW GCC 4.4.1 (32-bit) Tak
MinGW GCC 5.3.0-3 (32-bit)
[najnowsza na dzień 19.02.2017]
Tak
Cygwin GCC 4.4.1 (32-bit)
[najnowsza na dzień 19.02.2017]
Tak
Embarcadero C++ 7.20 for Win32 / bcc32c version 3.3.1 Tak
VisualStudio 2015 (14.0) 32-bit Nie
VisualStudio 2005 (8.0) 32-bit Nie
Środowiska 64-bitowe
MinGW 6.3.0 for i686
[najnowsza na dzień 19.02.2017]
Tak
MinGW 6.3.0 for x86_64 (posix-seh)
[najnowsza na dzień 19.02.2017]
Tak
Cygwin64 GCC 5.3.0 (64-bit) Tak
VisualStudio 2015 (14.0) 64-bit Nie
Środowiska dla Unix
RedHat GCC 4.8.5 (na Linuxsie) Nie

Dlaczego VisualStudio nie jest podatne?

W pierwszej chwili myślałem, że wykryli omawiany problem i go załatali (przy próbie użycia funkcji scanf dostajemy ostrzeżenie, żeby korzystać z funkcji scanf_s która jednak nie mieści się w standardzie C99). Jednak sprawa wygląda inaczej. VisualStudio korzysta już z nowszych bibliotek run-time (pochodnych od MSVCRT), które implementują znacznie większą część standardu C99 - w tym specyfikator "h". Dlatego to tam nie działa.

Postfix "_s" sugerowanych przez VisualStudio funkcji tyczy się czegoś innego: W przeciwieństwie do wersji mniej bezpiecznej - tj. sscanf, funkcje z postfixem "_s" obsługują dodatkowy parametr rozmiaru bufora, ale tylko w przypadku użycia specyfikatorów typu c, C, s, S (a nie w przypadku naszego "%hhu"). Rozmiar bufora jest podawany jako parametr następujący po parametrze-referencji do zmiennej (więcej o specyficznych dla VisualStudio funkcjach znajdziesz tutaj) - spójrzmy:
wchar_t buffer[10]; // buffer size is 10, width specification is 9 swscanf_s(input_string, L"%9s", buffer, (unsigned)_countof(buffer)); Wersje VisualStudio przed numerem 4.0 i od numeru 7.0 do 13.0 używają różnie nazwanych bibliotek DLL dla każdej z wersji (MSVCR20.DLL, MSVCR70.DLL, MSVCR71.DLL, MSVCR80.DLL [VS 2005], MSVCR90.DLL [VS 2008], MSVCR100 [VS 2010], MSVCP110.DLL, etc.). Wraz z 14.0 wersja VisualStudio (2015), biblioteka została przeniesiona do nowego pliku DLL nazwanego UCRTBASE.DLL (ale programy zobowiązane są do linkowania do pożądanej wersji biblioteki nazwanej "VCRUNTIME140.DLL" - z numerkami zmieniajacymi się w takt kolejnych wersji - istne piekło na Ziemii).
To instalatory aplikacji maja zadbać o to aby odpowiednia wersja MSVCRT była obecna w systemie (do tego służą właśnie paczki nazwane "Visual C++ Redistributable Package" instalowane wraz z niektórymi programami). Przy czym z systemem Windows jest domyślnie instalowana jedna wersja tej biblioteki.

Gynvael Coldwind celnie zauważył:

"hh" nie występuje nigdzie w specyfikacji [Microsoftu dla Visual Studio C/C++ - dop. Lukas], więc nie można się spodziewać, że będzie działać. (...) Ciekawe natomiast jest coś innego - to, że działa na nowszych wersjach biblioteki C Microsoftu poprawnie. A jest to ciekawe, ponieważ wg dokumentacji "hh" nie jest to obsługiwane: https://msdn.microsoft.com/en-us/library/tcxf1dw6(v=vs.140).aspx

"he hh, j, z, and t length prefixes are not supported."

Natomiast co by tu nie mówić, %hhu działa poprawnie ^_-

Co robić, jak żyć?

Rozwiązanie problemu dla środowiska MinGW

Najlepszym rozwiązaniem byłoby oczywiście podlinkowanie nowszych bibliotek. Jednak na dzień dzisiejszy po kilkugodzinnym posiedzeniu nie udało mi się zmusić MinGW do użycia innej biblioteki MSVCRT niż tej do której domyślnie linkuje (być może komuś z Was się uda). AKTUALIZACJA 07.03.2017: Okazuje się, że GCC/G++ w środowisku MinGW (niestety nie działa to w Cygwin, ani Embarcadero C) ma specjalny parametr kompilacji ( -D__USE_MINGW_ANSI_STDIO ), który sprawia, że domyślna, stara biblioteka MSVCRT zostaje zastąpiona inną - nowszą.
Aby więc skompilować program bez podatności, można użyć następującego polecenia:

gcc main.c -o main.exe -D__USE_MINGW_ANSI_STDIO

Dodatkowe ostrzeżenia dla większego bezpieczeństwa

AKTUALIZACJA 05.03.2017

(podziękowania dla Andrewa Pinskiego z GNU GCC Project): Można zmusić kompilator do ostrzeżenia o niemożności sprawdzenia parametru format (będącego wskaźnikiem na bufor znakowy) w kodzie przytoczonym powyżej. Istnieje taki parametr kompilacji jak -Wformat-nonliteral, którego można użyć, ale najlepiej skorzystać z parametru, który włącza całą grupę ostrzeżeń o bezpieczeństwie, mianowicie -Wformat=2. Można więc kompilować tak:

gcc main.c -o main.exe -Wall -Wextra -Wformat=2
Dzięki takiej kompilacji, kompilator ostrzeże nas przed tym, że format nie jest literałem i że nie sprawdził jego poprawności.
A najlepiej kompilować z obiema flagami:
gcc main.c -o main.exe -Wall -Wextra -Wformat=2 -D__USE_MINGW_ANSI_STDIO

Ewentualny workaround

Istnieje jeszcze oczywisty "workaround": nawet jeśli chcemy wczytać wartość 0-255, która spokojnie zmieściłaby się w naszej jednobajtowej zmiennej unsigned char, to powinniśmy użyć do tego celu jednak zmiennej typu integer (a nie typu char). W innym przypadku, jak widać, nasza aplikacja będzie podatna na błąd integer overflow.

Możliwe występowanie błędów w aplikacjach

Gynvael Coldwind napisał:
Zrobiłem grep u mnie na dysku w katalogu z programami po %hhu i wyskoczyło jakieś 400 plików, z czego większość była zlinkowana z nowymi msvc*.dll, lub nie miała scanf (tylko np printf). Natomiast fakt faktem jakieś z msvcrt.dll się znalazły, więc pewnie możnaby na to rzucić okiem (do czego Cię zachęcam)
Tak też zrobię i w chwili wolnego czasu opiszę ewentualne znaleziska.

Zakończenie

Gynvael Coldwind bardzo mądrze napisał o tego typu błędach:
Na pewno jest to pewne zagrożenie / coś na co warto zwrócić uwagę przy przeglądaniu kodu aplikacji. Ostateczne zdecydowanie czy coś jest "błędem" czy "błędem bezpieczeństwa" w takich przypadkach (błędów w API bibliotek) zawsze sprowadzi się do konkretnego przypadku konkretnej aplikacji, w której było to źle użyte.
Życzę Wam jak najmniejszej ilości tego typu błędów. Choć, jak ktoś kiedyś mądrze powiedział:
Ekspert to osoba która popełniła wszystkie możliwe błędy w swojej działce.
Mam wielką prośbę do osób, które przebrnęły przez artykuł o kliknięcie poniżej na gwiazdki w celu oceny materiału. Dzięki!
12345
Odkrycie: Integer overflow w funkcjach z rodziny scanf() w MinGW, Cygwin, Embarcadero C i innych środowiskach przy wczytywaniu liczby do char'a Autor opinii: Czytelnicy, data przesłania: 5

Skomentuj

Aby zamieścić komentarz, proszę włączyć JavaScript - niestety roboty spamujące dają mi niezmiernie popalić.






Komentarze czytelników

    • ffrg
    • nie, 5 marzec 2017, 16:18
    • Ciekawy art. czy zglosiles ten blad dla ludzi z gcc i mingw? Przy okazji zapytam czy lepiej stosowac -Wextra czy -pedantic? co sam stosujesz?

      Odp: Witam. Dziękuję. Zgłosiłem dzisiaj do GNU C (#79879) - powiedzieli, żebym zgłosił do MinGW. Zgłosiłem do MinGW (#2339) - powiedzieli, że jest to nienaprawialne bo błąd leży w zamkniętej bibliotece Microsoftu (MSVCRT), a w sprawie ostrzeżeń, które mógłby zgłaszać kompilator jeśli nie może nic zrobić odesłali mnie spowrotem do GNU C :) Tak więc sprawa jest ciekawa :) Człowiek z projektu GNU C poinformował mnie, że to nie powinno się dziać w Cygwinie (ale się dzieje!), bo on nie korzysta z MSVCRT tylko z zastępującej go biblioteki `newlib` (o której piszę z resztą w artykule - nie wiedziałem o tym). Być może biblioteka `newlib` jest również podatna.

      Zgłaszanie czegokolwiek do Cygwina jest katorgą [pół godziny spędziłem próbując się dowiedzieć pod jaki adres to wysłać, wysłałem po cygwin@sourceware.org ale do końca pewien nie jestem czy to ta lista - żadne potwierdzenie nie przyszło].

      Aktualizacja 10.03.2017: Przyszło oficjalne podziękowanie od Embarcadero/Borlanda. Wcześniej dopytywali się szczegółów. Powiedzieli, że zostanie to naprawione w następnej wersji.

      P.S. Na co dzień stosuję połączenie -Wall z -pedantic. Ale ISO też nie jest przecież rozwiązaniem wszystkich bolączek. W niniejszym artykule jest przecież przedstawiony właśnie błąd w specyfikacji ISO (polegający na niedokładnym zdefiniowaniu zachowania kompilatora) co może skutkować podobnymi bugami w przyszłości (muszę się zorientować jak jest z nowszymi wersjami specyfikacji).

      Pozdrawiam!
Dexter