Pretensje do kodu

Im więcej znajduje się błędów w aplikacjach, tym większa presja wywierana jest na programistów, by ''pisali bezbłędnie''. Ponieważ w praktyce jest to niemożliwe, pojawiają się różne metody automatycznego korygowania kodu post factum. Niektóre z nich są może i ciekawe, ale ich ostateczna skuteczność w walce o bezpieczne aplikacje jest dyskusyjna.

Im więcej znajduje się błędów w aplikacjach, tym większa presja wywierana jest na programistów, by 'pisali bezbłędnie'. Ponieważ w praktyce jest to niemożliwe, pojawiają się różne metody automatycznego korygowania kodu post factum. Niektóre z nich są może i ciekawe, ale ich ostateczna skuteczność w walce o bezpieczne aplikacje jest dyskusyjna.

Ręczne przetestowanie skomplikowanej aplikacji jest technicznie możliwe, ale ekonomicznie całkowicie bezsensowne. Podstawową funkcjonalność można zbadać testami jednostkowymi, w trakcie których sprawdza się po prostu, czy dany fragment kodu działa zgodnie z oczekiwaniami. Przynajmniej częściowo można je generować automatycznie, ale pojawiły się również pewne rozszerzenia języków , pozwalające za pomocą atrybutów specyfikować warunki, jakie muszą spełniać dane wejściowe, i wynik działania poszczególnych funkcji.

W miarę automatycznie testować można również typowe zadania użytkownika - zwykle za pomocą programów, które wykonują ustalony ciąg operacji. Czasami praktykuje się określanie ścieżek wykorzystania aplikacji, testując założone kombinacje "sytuacji", co pozwala zagwarantować, że program w danym zakresie i w zadanych warunkach będzie działać bezbłędnie. Jak jednak przetestować program z uwzględnieniem wszystkich możliwych sposobów wykorzystania, a zwłaszcza przy założeniu, że tzw. "użyszkodnik" jest złośliwy? Odpowiedzmy sobie od razu - to po prostu nierealne.

Kontrola danych wejściowych

Najwięcej błędów w systemach wynika z braku (lub nieprawidłowej) walidacji informacji na wejściu. Najbardziej "popularnym" błędem tego rodzaju jest rodzina problemów zwanych zbiorczo injection. Polegają one na tym, że w wyniku dynamicznego składania łańcucha polecenia SQL powstaje pewien dodatkowy kod, np. instrukcja kasująca dane czy fraza warunku, który powoduje, że aplikacja działa niezgodnie z założeniami. Zwykle chodzi o to, że jako wynik zapytania użytkownikowi pokazują się wszystkie rekordy, a nie tylko te, do których ma uprawnienia.

Błędy SQL injection najczęściej wykrywane są w aplikacjach WWW, ale dotyczy to w równej mierze wszystkich architektur aplikacyjnych, w których polecenie wysyłane na serwer bazodanowy jest tworzone dynamicznie na podstawie danych wprowadzanych przez użytkownika. Sposobów radzenia sobie z tym problemem jest wiele. Najprostszym z nich jest walidacja każdego łańcucha znaków pod kątem niedozwolonych ciągów bądź symboli itp. Problem by nie istniał, gdyby taką operację można było wykonać zawsze. Niestety tak nie jest, ponieważ czasami system musi pozwolić wprowadzić np. apostrof. Specyficzne znaki można oczywiście odpowiednio przekształcać, ale nie zawsze wszytko da się zrobić stuprocentowo.

Jeśli chodzi o techniczną implementację walidacji, najlepiej wykorzystać do tego mechanizm procedur składowanych działających po stronie serwera bazy danych. Pozwoli to "przywiązać" przekazywane od klienta parametry do określonych zmiennych, co uniemożliwi powstanie błędu SQL injection. Część bibliotek ORM (Object Relational Mapping), nawet gdy buduje zapytania dynamicznie, i tak stosuje odpowiednią składnię, która w locie buduje procedurę, a dopiero potem przekazuje do niej parametry i wykonuje odpowiednią operację.

Procedury składowane pozwalają unikać błędów SQL injection, ale nie gwarantują ich automatycznej eliminacji. Wszystko zależy od tego, jak taka procedura jest zbudowana. Jeżeli w środku jest dynamicznie składany łańcuch znaków, problem pojawia się ponownie. Jeżeli ktoś już stosuje procedury składowane, to o przykrych z punktu widzenia bezpieczeństwa konsekwencjach dynamicznej budowy wyrażeń SQL zwykle dowiedział się na własnej skórze. W przypadku aplikacji WWW warto zastanowić się, czy nie ukryć tego co jest przekazywane pomiędzy przeglądarką a serwerem, np. aby pasek adresu URL nie zdradzał, jakie dane są przekazywane do serwera i w jaki sposób są parametryzowane.

Uwagi wymaga także wybór miejsca, w którym ma się odbywać walidacja danych wejściowych. W aplikacji, która oprócz interfejsu WWW ma także interfejs EDI, nie można stosować wyłącznie walidacji na poziomie formatek interfejsu użytkownika, bowiem błędy mogą ujawnić się też podczas transportu za pośrednictwem motoru EDI, który tworzy zupełnie nowy kontekst. Tym bardziej, że w wielu przypadkach interfejs EDI pojawia się zwykle później - jako rozszerzenie już działającej aplikacji. Jeśli komunikacja klienta z serwerem odbywa się przez usługi Web, to pojawia się dodatkowy problem związany z "legalnym" atakiem typu DoS - wystarczy, żeby złośliwy kod cyklicznie odpytywał usługę. A ponieważ usługa Web korzysta zwykle z bazy danych, w konsekwencji na atak narażony jest serwer bazodanowy.

Gaszenie pożarów

W dziedzinie automatycznego unikania błędów sporo mogą zrobić producenci narzędzi i bibliotek programistycznych. Na przykład wersja "debug" bibliotek może dodawać kod sprawdzający, czy obszary pamięci są spójne, czy nie został zamazany obszar rozdzielający dwa bloki pamięci itp. Dodatkowo, w standardzie C/C++ pojawił się ostatnio nowy zestaw funkcji pozwalający określić np. długość bufora przy operacjach na ciągach znaków.

Kompilator (np. Visual C++ 2005) sprawdza, czy jest używana starsza, potencjalnie "niebezpieczna", funkcja i ostrzega programistę, sugerując mu użycie "bezpiecznego" odpowiednika. Pojawiła się także nowa opcja kompilacji (/GS), która powoduje, że w prologu i epilogu funkcji umieszczane są dodatkowe instrukcje asemblera sprawdzające obecność specjalnego "znacznika" wkładanego do pamięci. Jeśli jego obecność nie zostanie stwierdzona, będzie to oznaczać, że wystąpił błąd w dostępie do pamięci.

Innym pomysłem jest dostępna w Linuxie biblioteka libsafe. Dzięki niej "niebezpieczne" operacje wykonywane przy użyciu API glibc są dodatkowo obudowywane kodem sprawdzającym - podobnie jak w przypadku kompilacji /GS, ale nie na poziomie generowania kodu, tylko wybranych funkcji. Dużą zaletą libsafe jest to, że nie wymaga ona zmian w kodzie, a tylko linkowania do odpowiedniego pliku. Istnieje możliwość uruchamiania libsafe wraz z każdym uruchomieniem dowolnej aplikacji (standardowe API jest wtedy podmieniane). Nie jest to panaceum na wszystkie usterki związane z bezpieczeństwem, ale na pewno pozwala wyłączyć proces, gdy ten wykona niedozwoloną operację.

Drugim ciekawym rozwiązaniem jest specjalna "łata" na jądro Linuxa - grsecurity, a zwłaszcza element PaX. Grsecurity to zestaw funkcji pozwalający np. na wykorzystanie list kontrolnych w oparciu o role (RBAC - Role-Based Access Control), zmiany w chroot, audyt itp. Tak naprawdę trudno zrozumieć dlaczego jest to łata, a nie część jądra. PaX pozwala podzielić obszar adresowy oddzielnie na dane i kod, używając tzw. executable bit. Podobny mechanizm pojawił się w Windows XP SP2 jako opcja. W Windows dodatkowo została dodana warstwa emulująca takie zachowanie na poziomie kernela. PaX także zabezpiecza pewne struktury wewnątrz jądra Linuxa, aby elementy const były naprawdę stałymi, albo żeby tabele deskryptorów mogły działać w trybie "tylko do odczytu".

PaX zmienia także sposób alokacji pamięci - zamiast na podstawie przewidywalnego algorytmu, kolejne strony pamięci dobierane są losowo. W efekcie dwa kolejno adresowane bloki z bardzo małym prawdopodobieństwem będą fizycznie umieszczone jeden za drugim. Zwiększa to szansę na wykrycie sytuacji, w której wskaźnik wskaże niewłaściwy obszar, będącą zazwyczaj rezultatem próby ataku przez przepełnienie bufora. Na platformie OpenBSD dostępny jest projekt W^X realizujący podobną funkcjonalność.

Opisane mechanizmy nie mają charakteru prewencyjnego, lecz są leczeniem objawowym, swoistym gaszeniem pożaru, który powstał bez kontroli, ale który zazwyczaj da się opanować. Z punktu widzenia całości infrastruktury jest to podejście do zaakceptowania, bo nie doszło do poważnego zagrożenia, ale z punktu widzenia biznesu rezultat jest taki, że aplikacja czy usługa przestaje działać.

W poszukiwaniu praprzyczyn

Szukanie przyczyn błędów wymaga analizy kodu źródłowego pod kątem specyficznych ciągów lub konstrukcji. Do tego celu powstały oczywiście wyspecjalizowane narzędzia. Najpowszechniej używanym jest Insure++ firmy Parasoft. Jest to narzędzie, które automatycznie analizuje działający kod, badając błędy inicjalizacji, alokacji pamięci, konflikty we wskaźnikach itp. Gdy błąd zostanie wykryty, Insure++ stara się wskazać miejsce ostatniego "dostępu" do danego obiektu oraz miejsce utworzenia (alokacji) danego bloku pamięci.


TOP 200