RSS

Programowanie defensywne

Liczba odsłon: 146

Rozpowszechnienie komputerów i informatyki, rozwój wielkich sieci teleinformatycznych oraz wzrost poziomu skomplikowania graficznych interfejsów użytkownika spowodowały znaczące zmiany w sposobie programowania. Kiedyś dobry program wyróżniał się odpornością na błędy użytkownika; dzisiaj dobry program musi być dodatkowo jak najbardziej odporny na wszelkie celowe działania włamywaczy komputerowych. Niestety, cenę za to płacimy wszyscy, gdyż część mocy obliczeniowej komputerów tracona jest na sprawdzanie parametrów, które w idealnych warunkach nigdy nie powinny przybierać niepoprawnych wartości.

Programiści zmuszeni są dzisiaj do tworzenia kodu w nieco inny sposób. Najważniejsze nie jest już jak najsprawniejsze działanie programu, ale jego odporność na błędy oraz próby włamania i podglądnięcia danych.

Podglądanie zawartości pamięci

Podglądanie zawartości pamięci jest tematem może nieco mniej praktycznym, wymagającym rozważań i dywagacji, jednak nie mniej przez to ciekawym.

W czasach jednozadaniowego systemu operacyjnego MS-DOS każdy program miał dostęp do całej pamięci fizycznej komputera. Mechanizm TSR umożliwiał uruchomienie programu rezydentnego przeglądającego pamięć w poszukiwaniu ciekawych treści lub wręcz czyhającego na pojawienie się tajnego hasła w konkretnym dla podglądanego programu miejscu. Ta łatwość podglądania była jednak dosyć trudna do wykorzystania: komputery rzadko były połączone w większe sieci, a więc skuteczne podglądnięcie hasła wymagało w praktyce posiadania fizycznego dostępu do komputera. Nic dziwnego zatem, że programy faktycznie chroniące dostępu do przechowywanych danych (na przykład systemy obsługi sprzedaży) dbały przede wszystkim o ukrycie haseł zapisanych w pamięci masowej (też nie zawsze skutecznie), w momencie jednak odczytania hasła z dysku standardową praktyką było przechowywanie go w pamięci operacyjnej w postaci jawnej. Po prostu niczym poważnym to nie groziło.

Pojawienie się nowoczesnych systemów operacyjnych pracujących w trybie nadzorowanym jeszcze upewniło programistów, że pamięć należąca do ich aplikacji jest niemożliwa do przeglądnięcia. Owszem, sterowniki urządzeń najczęściej mają pełen dostęp do pamięci fizycznej, a procesy działające z uprawnieniami administratora systemu mogą przeglądać zawartość przestrzeni adresowej innych procesów. Jeżeli jednak narzuci się sensowną konfigurację systemu, w której żaden z użytkowników nie ma uprawnień administracyjnych, niemożliwe staje się zarówno podglądanie pamięci wirtualnej czy fizycznej, jak i instalowanie własnych szpiegowskich sterowników urządzeń.

Nawet jednak w tak zabezpieczonym systemie operacyjnym dane nadal mogą wyciekać. Jednym ze sposobów odczytania zawartości pamięci fizycznej jest przeglądanie zawartości pliku lub obszaru stronicowania (ang. swap file, swap area), do którego trafiają chwilowo zbędne w pamięci fizycznej anonimowe strony pamięci. Co prawda w czasie działania systemu operacyjnego obszar wymiany jest niedostępny dla zwykłego użytkownika, jednak wystarczy nacisnąć przycisk Reset i przeglądnąć zawartość dysku w innym komputerze, by odczytać część informacji przechowywanych w pamięci operacyjnej. Oczywiście, trudno mówić o bezpieczeństwie komputera, do którego włamywacz ma fizyczny dostęp, jednak problem był na tyle poważny, że firma Microsoft w systemie Windows NT (i kolejnych) wprowadziła opcję umożliwiającą kasowanie zawartości pliku wymiany podczas zamykania systemu operacyjnego (co jednak nie zlikwidowało możliwości niekontrolowanego wyłączenia maszyny i odczytania pliku wymiany).

Jeszcze bardziej jest interesująca sprzętowa możliwość podglądnięcia zawartości pamięci operacyjnej. Przyjęło się uważać pamięć DRAM za niezwykle ulotną: wystarczy przez chwilę nie odświeżać zapisanej w postaci ładunków w kondensatorach informacji, by nieodwracalnie stracić zawartość układu pamięci. Tymczasem nie jest to prawda: nowoczesne pamięci DRAM przechowują informacje jeszcze przez kilkanaście sekund po odłączeniu zasilania. To co prawda nieco za mało czasu, by było możliwe przełożenie modułów pamięci do czytnika i odczytanie informacji, jednak wystarczy schłodzić moduł pamięci, by znacząco spowolnić proces rozładowywania się kondensatorów. Taką „kriogeniczną” metodą udaje się utrzymać pamięć w stanie gotowości do odczytu nawet przez kilkanaście minut — a to wystarczająco długo, by spokojnie wyjąć moduły pamięci z gniazd i przełożyć do czytnika, natychmiast rozpoczynającego odświeżanie ich zawartości.

Tak naprawdę jedyną w pełni skuteczną metodą zabezpieczenia programu przed podsłuchem pamięci jest nieumieszczanie tajnych informacji w pamięci. Brzmi to absurdalnie i oczywiście jest prawie niemożliwe w realizacji, nie trzeba jednak traktować tego zalecenia dosłownie.

Dla przykładu, program nigdy nie powinien przechowywać haseł w pamięci operacyjnej w postaci jawnej. Używana (i porównywana) powinna być postać zaszyfrowana lub skrót (ang. hash) hasła. Postać jawna (na przykład odczytana z klawiatury) powinna zostać nadpisana bajtami zerowymi gdy tylko zostanie już wyznaczona postać skrótowa lub zakodowana; w ten sposób czas istnienia jawnej informacji w pamięci zostanie sprowadzony do minimum.

Program powinien też zerować wszystkie wychodzące z użycia zmienne przechowujące informacje o znaczeniu dla bezpieczeństwa, a w szczególności zerować przed użyciem wszystkie struktury danych używane do komunikacji ze światem zewnętrznym. Zdarzały się programy umieszczające informacje tajne w plikach dyskowych tylko dlatego, że przy zapisie został ponownie wykorzystany blok pamięci wcześniej użyty na potrzeby informacji niejawnej (a nieużywane czy zarezerwowane na przyszłe potrzeby fragmenty struktur są normą).

Widać tutaj, że bezpieczne (defensywne) programowanie wymaga poświęcenia mocy obliczeniowej — już nawet na poziomie samego zagadnienia utrudniania podsłuchu pamięci operacyjnej. Podczas gdy dobra praktyka programistyczna nakazuje nie zerować zawartości zmiennych, które wychodzą z użycia (gdyż jest to po prostu strata czasu), zasady programowania defensywnego nakazują zerować każdą zmienną zawierającą informacje, które nie powinny być w żaden sposób podsłuchane — nawet, jeżeli zdrowy rozsądek mówi, że w tym samym miejscu pamięci za chwilę może zostać zapisane zupełnie co innego, i tak kasując poprzednią zawartość. Owszem, może, ale nie musi.

Zabezpieczenie przed błędami i włamaniami

Gdy programista tworzy podprogram sam dla siebie, może dokonać założeń na temat jego działania. Na przykład podprogram zwracający nazwę trybu pracy w najprostszej wersji (zoptymalizowanej pod kątem wydajności) może wyglądać następująco:

const char * NazwaTrybu(const unsigned int Tryb)
{
   static const char * Nazwy[3] = {
      "Tryb bezczynności",
      "Tryb aktywności",
      "Tryb końca pracy"
   };
   return Nazwy[Tryb];
}

Założeniem poczynionym tutaj przez programistę jest przyjmowanie przez parametr Tryb wartości z zakresu od 0 do 2. Faktycznie, kod programu może gwarantować, że powyższa funkcja nigdy nie zostanie wywołana z niepoprawną wartością parametru Tryb. Podprogram będzie działał doskonale, nie tracąc ani jednego cyklu zegara na zadania nie związane bezpośrednio z jego funkcją (dodatkowo, dzięki zastosowanej metodzie, jego stopień złożoności obliczeniowej jest równy O(1)).

Niestety, takie założenia często przestają obowiązywać w momencie wprowadzania w programie poważnych zmian. W przypadku dużych projektów trudno pamiętać, które fragmenty kodu zależne są od innych i w jakim stopniu. Dlatego przyzwoity programista przed instrukcją return wstawi jeszcze wyrażenie:

assert(Tryb < 3);

dzięki któremu w wersji uruchomieniowej programu każde odwołanie będzie weryfikowane za pomocą asercji. Zmiana ta nie wpłynie w żaden sposób na działanie wersji rynkowej programu, do której asercja nie zostanie wkompilowana (co pozwala rozważać problem prawdopodobieństwa wywołania tej funkcji z niepoprawną wartością parametru na skutek nie przetestowanej nigdy sekwencji wydarzeń).

Jeżeli jednak aplikacja ma być naprawdę niezawodna, konieczne jest stosowanie technik programowania defensywnego. Najprostszym rozwiązaniem jest wykrywanie nieprawidłowości parametrów:

if (Tryb > 2) return "Nieznany tryb!";

Bardziej skrajną postacią jest zgłaszanie błędu za każdym razem, gdy podany zostanie niepoprawny parametr:

if (Tryb > 2) throw NiepoprawnyTryb(Tryb);

Ta druga metoda może wydawać się bardziej drastyczna, jest jednak bardziej zgodna z duchem programowania defensywnego. Nie pozwala programowi ani na chwilę pracować w nieznanym stanie; zgłoszona przez podprogram sytuacja wyjątkowa albo zostanie obsłużona i program wróci do stabilnego stanu, albo zostanie zignorowana, zakańczając pracę. W tym przypadku zwrócenie tekstu Nieznany tryb! w niczym by co prawda nie przeszkadzało (poza ewentualnym wprowadzeniem w błąd użytkownika), jednak podprogramy, od których zależeć może dalsze działanie całego programu i jego maszyny stanu nie mogą „zgadywać” co może oznaczać niepoprawny parametr.

Zgłoszenie sytuacji wyjątkowej jest też lepszym rozwiązaniem w przypadku programów pisanych przez wielu programistów. Komunikat zapisany w logu albo dziwną treść zwróconego tekstu łatwo przeoczyć; gdy jednak program przerywa działanie z powodu zgłoszenia nieobsłużonej sytuacji wyjątkowej przez napisany przez innego człowieka podprogram, z jednej strony programista od razu jest zobligowany zlikwidować przyczynę błędu w swoim fragmencie kodu, z drugiej zaś pewnie uzupełni go blokami try...catch, by następnym razem błąd – mimo wystąpienia – nie spowodował załamania się programu.

Programowanie defensywne staje się szczególnie istotne w przypadku aplikacji sieciowych, głównie w postaci serwisów WWW. Nawet początkujący użytkownik komputera jest w stanie bez problemu zmodyfikować parametry przekazywane skryptowi w adresie URL (wprowadzając wartość spoza dopuszczalnego zakresu lub ewidentnie błędną), albo – zgodnie ze znalezionym gdzieś w Sieci poradnikiem – wpisać ' OR 1=1 w polu formularza na stronie, wykonując klasyczny atak SQL Injection. Nieco bardziej obeznani użytkownicy nie cofną się też przed modyfikowaniem danych przekazywanych za pomocą metody HTTP POST czy ręczną zmianą zapamiętanych przez przeglądarkę „ciasteczek” HTTP. Skrypty działające po stronie serwera muszą nieustannie weryfikować każdy otrzymywany fragment informacji. Nie wystarczy wykrywać wartości ewidentnie błędnych; trzeba odrzucać każdą wartość nie spełniającą założenia co do formatu i typu.

Programowanie defensywne a awarie

Jak dotąd programowanie defensywne jawi się jako czysta strata mocy obliczeniowej, traconej na upewnianie się, że inny programista pracujący nad projektem nie wprowadzi poważnego błędu lub – co gorsza – włamywacz nie podglądnie zawartości pamięci programu albo nie wpłynie na jego działanie przez przekazanie mu nieprawidłowych parametrów. Jest jednak jeszcze jeden aspekt programowania defensywnego, nieco łagodzący żal po utraconej wydajności programu.

Dobrze zrealizowane zabezpieczenia mogą bowiem zabezpieczyć program nie tylko przed prostymi błędami (takimi jak jawne wywołanie funkcji z niepoprawnymi parametrami), ale też przed skutkami niektórych błędów powstających przy długotrwałej pracy. Dla przykładu, sytuacja wyjątkowa zgłoszona przez podprogram zapisujący wiersz tekstu do pliku z powodu zbyt dużej długości tego wiersza może pomóc wykryć problem w działaniu funkcji generującej ten wiersz tekstu, w określonych okolicznościach na przykład nie kończącej tekstu wymaganym znakiem null (przy okazji też pozwoli uniknąć zapisania w pliku wielomegabajtowego ciągu przypadkowych znaków zamiast kilkunastuznakowego wiersza tekstu).

Z tego też powodu warto przy okazji ustalania stanu obiektu nie zakładać, że pewne pola tego obiektu powinny mieć konkretne wartości. Na przykład, jeżeli przy wychodzeniu z pewnego stanu obiektu pole x powinno mieć wartość zero, koniecznie wymaganą do prawidłowej pracy w kolejnym stanie, czasem lepiej jest poświęcić jedną instrukcję zapisu do pamięci i ustalić wartość w sposób bezwzględny, niż ryzykować, że na skutek zmian w programie założenie przestanie być prawdziwe i pojawi się trudny do wyszukania błąd.

Pewnym skutkiem ubocznym takiego sposobu programowania jest zmniejszenie zależności od błędów sprzętowych. Jeżeli na skutek przekłamania bitu w pamięci ulegnie uszkodzeniu jakaś wartość, program będzie w stanie wychwycić to i w sposób kontrolowany zakończyć pracę lub wycofać się z operacji. Niektóre (nieliczne niestety) błędy zostaną wręcz skorygowane przez nadmiarowe instrukcje ustalające stan obiektów. Oczywiście, taki dodatkowy poziom niezawodności może się odnosić tylko do obszarów pamięci przechowujących dane i tylko do niektórych niekontrolowanych zmian zawartości tych obszarów.

Programowanie defensywne a wydajność

Wszystkie dodatkowe instrukcje sprawdzające poprawność funkcjonowania programu oraz utrudniające podglądanie zawartości pamięci operacyjnej powodują zmniejszenie szybkości działania całego programu. Płacimy w ten sposób pewną cenę za niebywałe zwiększenie stopnia skomplikowania oprogramowania oraz za coraz częstsze próby wykorzystania niedociągnięć programowych do celów przestępczych.

Operacje ustalające stan (na przykład zerujące niektóre zmienne) nie spowalniają znacząco programu. Największy wpływ mają niestety instrukcje warunkowe, często powodujące powstawanie „bąbelków” w potokach wykonawczych procesorów, wprowadzając w efekcie opóźnienia liczone w dziesiątkach cykli zegarowych. Trudno jednak wyobrazić sobie weryfikowanie stanu programu czy wartości parametrów podprogramów bez wykorzystania instrukcji warunkowych.

Wpływ instrukcji warunkowych można w sporym zakresie zminimalizować, stosując wskazówki kompilatora. W języku C można na przykład zdefiniować makra likely()unlikely(), dające kompilatorowi wskazówkę, czy dane porównanie jest bardzo prawdopodobne, czy prawie nieprawdopodobne. Na przykład sprawdzenie, czy podany jako parametr podprogramu wskaźnik jest pusty można zrealizować następująco:

void Przetwarzaj(Dane *x)
{
   if (unlikely(x == 0)) throw NieprawidlowyParametr();
   ...

Dzięki takim wskazówkom kompilator może przenieść kod obsługujący spełnienie mało prawdopodobnego warunku na koniec kodu funkcji, dzięki czemu nie będzie on zaśmiecał pamięci podręcznej kodu. W przypadku niektórych mikroprocesorów kompilator może też dodatkowo umieścić w kodzie maszynowym wskazówkę dla bloku przewidywania skoków warunkowych, z góry informując układ, że rozpatrywany skok warunkowy powinien być raczej traktowany jako niebyły.

W przypadku krytycznych czasowo podprogramów może opłacać się stworzenie dwóch wersji podprogramu: jednej stosowanej wewnętrznie w ramach danego modułu i pozbawionej weryfikacji parametrów i drugiej, dostępnej publicznie, wyposażonej w kod diagnostyczny i dopiero po jego wykonaniu wywołującej wersję „wewnętrzną”. Ponieważ w ramach jednego modułu funkcjonalnego (często tworzonego przez jednego autora) możliwa jest rygorystyczna inspekcja kodu i dokładne testowanie, metoda ta nie zwiększa znacząco ryzyka pojawienia się błędu, a jednocześnie dalej istnieje zabezpieczenie przed błędnym wykorzystaniem podprogramu z poziomu kodu innych modułów.

Przyszłość programowania defensywnego

Od programowania defensywnego nie ma odwrotu. W erze powszechnej łączności programy muszą stawać się coraz bardziej niezawodne. Dzisiaj praktycznie nie ma już wolnostojących komputerów, narażonych co najwyżej na infekcję przyniesionym na dyskietce wirusem. Udostępnia się też coraz więcej usług internetowych, narażonych na ataki włamywaczy pragnących uzyskać jeszcze jeden komputer przydatny do rozsyłania spamu lub utrzymywania spamerskich serwisów WWW.

Pozostaje otwarta kwestia narzędzi i języków programowania umożliwiających programowanie defensywne przy jak najmniejszym nakładzie pracy i najmniejszym spadku wydajności. Klasyczne języki programowania, takie jak C++, umożliwiają programowanie defensywne, wymaga to jednak wysoce wykwalifikowanego programisty i polega na samodzielnym oprogramowaniu optymalnego rozwiązania. Rozwiązania oparte na koncepcji maszyny wirtualnej i nadzorowanego środowiska programowego (Java i .NET) zmniejszają nakład pracy dzięki istnieniu gotowych bibliotek klas przystosowanych do sprawdzania poprawności przekazywanych im parametrów, jednak sama weryfikacja przebiega identycznie jak w C czy C++: za pomocą wyrażeń warunkowych.

Ideałem byłby język programowania wyposażony we wbudowany (a nie zrealizowany bibliotekami pomocnicznymi) mechanizm sprawdzania wartości parametrów. Podczas kompilacji weryfikacje byłyby przenoszone na najwyższy poziom zapewniający sprawdzenie wszystkich parametrów. Oszczędność mogłaby być niezmierna. Wystarczy wyobrazić sobie pięć podprogramów przyjmujących jako parametr numer zgłoszenia, przy czym numer ten musi być większy od zera i mniejszy lub równy liczbie istniejących zgłoszeń. W językach C++ czy C# instrukcje warunkowe sprawdzające poprawność numery zgłoszenia musiałyby być umieszczone w każdym podprogramie, gdyż każdy z nich mógłby zostać wywołany niezależnie. W efekcie operacja wymagająca sekwencyjnego wywołania wszystkich pięciu poskutkuje pięciokrotnym sprawdzeniem parametrów.

Język programowania wyposażony w mechanizm weryfikacji parametrów nie umieszczałby instrukcji warunkowych wewnątrz podprogramu; same podprogramy nie sprawdzałyby parametrów, a jedynie były wyposażone w zestaw wskazówek co do warunków, które muszą spełniać te parametry. Kompilator budowałby wyrażenia warunkowe sprawdzające poprawność parametrów przed wywołaniem pierwszego podprogramu i już ich nie powielał: skoro raz były dobre, w kolejnych czterech przypadkach również będą. Weryfikacja mogłaby być przenoszona w górę aż do miejsca, w którym byłaby ustalana wartość przekazywanego parametru.

Takiego języka programowania jednak na razie nie ma i raczej prędko nie powstanie. Obecny trend w programowaniu polega raczej na tworzeniu parametrów obiektowych (które nie mogą przyjmować niepoprawnych wartości, nie trzeba zatem ich sprawdzać) oraz w pełni nadzorowanych środowisk programowych (w których w praktyce nie istnieje pojęcie wskaźnika, a więc znika cała gama błędów związanych z alokacją pamięci, używaniem nieprawidłowych wskaźników do danych oraz obsługą zmiennych łańcuchowych). Nawet języki nadzorowane są jednak wyposażone w mechanizmy obsługi sytuacji wyjątkowych, wywoływane za pomocą klasycznych wyrażeń warunkowych.