Professional Linux Programming, R-12-01, Szablon dla tlumaczy


12. Bezpieczne programowanie

Czym jest bezpieczne programowanie?

W prostym rozumieniu, bezpieczeństwo to zdolność do sprawowania nadzoru nad wykorzystywaniem przez innych naszych zasobów komputerowych, czyli zdolność do powiedzenia ludziom nie (lub tak) i umiejętność wsparcia tego odpowiednim działaniem.

W świecie komputerowym bezpieczeństwo obejmuje wiele pojęć. Na jednej płaszczyźnie, bezpieczeństwo utożsamia się z niezawodnością — bezpieczny system to taki, który pozostaje dostępny pomimo starań innych, aby uczynić go niedostępnym. Na innej płaszczyźnie, bezpieczeństwo uwzględnia pewne formy nadzoru dostępu — tylko niektórzy ludzie powinni mieć dostęp do systemu i to w ściśle określony sposób, a wszystko to powinno być dokładnie ustalone przez administratora systemu. Inne zadanie bezpieczeństwa jest związane z zapobieganiem wyciekom informacji — w sytuacji, kiedy prawowity użytkownik uzyskuje legalny dostęp do informacji, nikt inny nie może uzyskać dostępu do tej samej informacji.

Bezpieczne programowanie wymaga świadomości wszystkich tych pułapek. Programista musi określić, które zagrożenia bezpieczeństwa są ważne, a następnie chronić przed nimi. Bezpieczny program powinien reagować w określony sposób na ataki, rozpoznane dzięki starannemu prowadzeniu rejestrów zdarzeń, ostrzegając, czy nawet podejmując środki zaradcze, zapobiegające powtarzaniu się ataków.

Bezpieczne programowanie polega w takim samym stopniu na wiedzy o tym, czego nie robić, jak i na wiedzy o tym, co zrobić. Zatem, ten rozdział zawiera informacje o pomyłkach i pułapkach, których należy unikać, jak też przyjęte nowe metody i wskazówki. Nie jest to wyczerpujące ujęcie — taka książka nie mogłaby być napisana, ponieważ agresorzy nieustannie wymyślają nowe sposoby osiągania swoich celów. Rozdział ten jest raczej pomyślany jako wprowadzenie do praktyki bezpiecznego programowania oraz jako przewodnik po najbardziej powszechnych i użytecznych wskazówkach dotyczących bezpieczeństwa oraz pułapkach tak, aby od zaraz zacząć pisanie bezpieczniejszego kodu.

Dlaczego jest trudno bezpiecznie programować?

Jeśli istnieje uniwersalna prawda o bezpiecznym programowaniu to brzmi ona: Bezpieczne programowanie jest trudne.

Najróżniejsze wykazy i zasoby online poświęcone katalogowaniu i ujawnianiu słabości zabezpieczeń obfitują w dowody na powyższe stwierdzenie. Gdyby było to odrobinę łatwiejsze, większość programistów wolałaby uniknąć publicznej kompromitacji, która towarzyszy nadużyciu (exploit).

Jednak, pomimo tej oraz innych motywacji, zagadnienie bezpiecznego programowania wydaje się być zasypane niepowodzeniami niezliczonej liczby systemów, część spośród których została zaprojektowana przez najtęższe umysły z tej branży. Dlaczego wdrożenie zabezpieczeń do programu jest o wiele trudniejsze niż wdrożenie innych cech ?

Błędy utajone

Wiele systemów zabezpieczeń jest dobrze zaprojektowanych na papierze. Niestety, muszą zostać wdrożone zanim zostaną wykorzystane, a skłonni do pomyłek niedoskonali programiści wprowadzają błędy w czasie implementacji systemu. Błędy w zabezpieczeniach (ang. security bugs) nie są wyjątkowe pod tym względem. Jednakże, wyjątkowość błędów w zabezpieczeniach polega na tym, że są trudniejsze do wykrycia niż inne błędy w oprogramowaniu.

Jeśli nie zostanie zaalokowane wystarczająco dużo miejsca w formularzu dla etykiety, lub przypadkowo zostanie podstawiony jeden operator zamiast innego w obliczeniach, to błąd łatwo rzuca się w oczy. Etykieta jest obcięta, lub wynik obliczeń jest zły. Wytropienie źródła problemu może być trudne, ale samo występowanie problemu jest zawsze bezdyskusyjne.

Z drugiej strony, cechy bezpieczeństwa są często kodowane tak, aby były niewidoczne dla użytkownika. Są one często trudne do zobaczenia nawet dla samego programisty. Wiele spośród znalezionych nieoczywistych błędów w zabezpieczeniach pojawia się w postaci efektu ubocznego normalnego algorytmu wykonania, w wyniku którego następuje przeciek ważnej informacji. Inne błędy wiążą się z zaniedbaniem porządkowania, które nie wpływa na wykonywanie programu, ale które mogłoby zapobiec naruszeniom bezpieczeństwa. Oznacza to, że program może poprawnie przejść każdy test oraz wykazać pełną swoją funkcjonalność, ale być przy tym całkowicie pozbawiony zabezpieczeń.

Na przykład, długie hasło (ang. pass phrase) po wprowadzeniu przez użytkownika może zostać zmieszane (ang. hashed) w celu utworzenia klucza szyfrowania sesji (ang. encryption session key). Jednak logika usuwania z pamięci nie zmieszanego długiego hasła może zawierać błąd, który to uniemożliwia. Program kontynuuje poprawnie swoje działanie, ponieważ więcej już nie potrzebuje długiego hasła. We wszystkich jego operacjach wykorzystywany jest zmieszany klucz sesji (ang. hashed session key). Taki program może przejść wyśmienicie przez wszystkie testy. Jednakże, agresor po zauważeniu tego przeoczenia, może całkowicie obejść zabezpieczenia programu. Agresor powodując krach programu po wprowadzeniu zdania hasłowego, może być w stanie utworzyć plik zrzutu pamięci (ang. crash dump file), zawierający dane programu, z którego wyizoluje długie hasło.

Nie wszystkie błędy w zabezpieczeniach muszą być tak subtelne. Czasami, prosty fakt braku sprawdzenia zakresu bufora pamięci może być katalizatorem do całkowitego złamania zabezpieczeń. Przykładowo, wiele spośród ostatnich błędów w przeglądarce Internet Explorer firmy Microsoft jest spowodowanych przez translator URL. Szczególny adres URL, który może powodować złamanie zabezpieczeń może być zablokowany za pomocą jednego uaktualnienia zabezpieczenia. Jednakże, ponieważ sprawdzenia działają na danym adresie URL zanim nastąpi zdekodowanie symboli sterujących ze znakiem %, agresorzy zdołali wykorzystać te same błędy w oprogramowaniu poprzez zakodowanie części swojego adresu URL (na przykład, kodując wszystkie znaki A za pomocą %41). Wirusy używały latami, z różnym skutkiem, podobnych metod, by umknąć uwadze programów antywirusowych (ang. virus scanners).

Zaleta paranoi

Wielu programistów, widząc złamania zabezpieczeń w programach napisanych przez szanowanych programistów (niektórych z wieloletnim doświadczeniem w implementowaniu bezpiecznych systemów) pogrąża się w rozpaczy. Jeśli „eksperci” nie mogą napisać bezpiecznego kodu to jak może temu podołać przeciętny programista? W istocie wśród obserwatorów środowiska internetowego panuje przekonanie, że bezpieczeństwo jest celem niemożliwym do osiągnięcia — żadna twierdza kodu nie oprze się długo przemyślanym atakom ciekawskiego nastolatka.

Takie mniemanie jest, w pewnym stopniu, prawdziwe. Zawsze była to raczej kwestia stopnia zabezpieczenia, niż absolutnego rozwiązania problemu bezpieczeństwa. Stąd też, większość fizycznych systemów zabezpieczeń dążyła raczej do uczynienia ewentualnej penetracji zbyt kosztowną, aniżeli do jej całkowitego uniemożliwienia. To samo tyczy się bezpieczeństwa cyfrowego (ang. digital security). Przy projektowaniu bezpiecznego systemu, trzeba zawsze mieć na uwadze określone zagrożenia, którym system powinien się oprzeć, a następnie wdrożyć środki zaradcze w celu zmniejszenia do akceptowalnego poziomu ryzyka infiltracji. Niekiedy, złamanie zabezpieczeń może być zwyczajnie wynikiem działań zdeterminowanego agresora, który naruszy system zabezpieczeń ponad ich zaprojektowaną tolerancję, podobnie jak obciążony most może się zawalić w godzinach szczytu komunikacyjnego, jeśli obciążenie mostu przekroczy jego dopuszczalną nośność.

Jednocześnie jednak, programiści przyzwyczajeni do pisania bezpiecznych kodów mają tendencję do ulegania w dużym stopniu paranoi. Wyróżnikiem bezpiecznego programowania jest zarządzaniem zaufaniem — czy to zaufaniem do określonego systemu kodowania (ang. cryptosystem), czy to zaufaniem do tego, że jakiś fragment kodu działa poprawnie, czy do tego, że użytkownik poda poprawne dane wejściowe, albo też zaufaniem do systemu, który autoryzuje dany fragment kodu do wykonania określonego zadania. Zatem metoda ograniczonego zaufania, ograniczonego tak bardzo jak to możliwe bez poświęcenia niezbędnego zestawu funkcji, jest pewną drogą sukcesu dla programistów do zmniejszenia ilości błędy w zabezpieczeniach.

Czy można zaufać kompilatorowi?

W swoim wystąpieniu z okazji otrzymania Nagrody Turinga w 1984, Ken Thompson — jeden z twórców UNIX-a — opisał jak stworzył niewykrywalną, nawet z dostępnym kodem źródłowym, słabość w zabezpieczeniu w programie narzędziowym.

Thompson opisał jak zdołał zmodyfikować kompilator języka C we wczesnej wersji UNIX-a tak, aby wykrywał fakt kompilowania kodu dla login(1) i wstawiał kod, który akceptowałby zawsze pewne hasło. Umożliwiało to zarejestrować się komuś, kto znał to hasło jako dowolny użytkownik. Następnie zmodyfikował kod źródłowy kompilatora C tak, aby wykrywał, kiedy kompilował samego siebie (wówczas, tak jak i teraz, sam kompilator języka C był napisany w C) i wstawił ów kod (do wykrywania login i wprowadzania konia trojańskiego) do kompilatora języka C. Potem usunął zmiany jakich dokonał na źródle.

Od tego momentu, kompilator C w UNIX zawsze dołączał jego konia trojańskiego, ilekroć kompilował program login(1). Żadna inspekcja (ang. auditing) kodu źródłowego, dokonana dla login, lub dla kompilatora C, nie pozwoliłby na wykrycie jakiegokolwiek problemu.

Przykłady takie jak ten, pomagają zilustrować, jak dalece współczesny programista ufa współczesnemu środowisku komputerowemu. Pokłada się zaufanie w wielu składnikach systemu — kompilatorze, programie ładującym, dynamicznym konsolidatorze (ang. dynamic linker), a nawet w dekoderze mikrokodu w CPU — w nadziei, że robią dokładnie to, o co się je prosi. Każdy z tych składników — zwłaszcza używanych w trakcie wykonywania, takich jak program ładujący czy dynamiczny konsolidator — może być ze swej strony źródłem słabości w zabezpieczeniach.

Ograniczanie zaufania może być zrealizowane na wiele sposobów. Oto kilka przykładów:

Wybierz komponenty dla swojego systemu, które są dostarczane z pełnym kodem źródłowym (najlepiej z otwartym dostępem do kodu źródłowego — ang. open source, OS) włącznie ze wszystkimi modułami od dostawcy oprogramowania. Jeśli to możliwe, dokonaj inspekcji wszystkich takich modułów (lub zapłać jakiemuś profesjonaliście, aby to zrobił), lub poszukaj w Internecie wyników publicznej inspekcji (ang. public audit results) na serwerach WWW poświęconych bezpieczeństwu. To może okazać się trudne do zrobienia, a co więcej, nie jest zawsze w praktyce możliwe do zrealizowania w pełni. Niemniej jednak, biblioteki od dostawcy oprogramowania oraz komponenty z otwartym dostępem do kodu źródłowego mogą być płodnym źródłem problemów z bezpieczeństwem. Jeśli dostawca odmawia udostępnienia kodu źródłowego, to należy rozważyć jego zamianę na takiego dostawcę, który je udostępni. Warto przynajmniej przeprowadzić inspekcję w taki stopniu, w jakim to możliwe, poprzez poddanie próbie wytrzymałości, przykładowo poprzez wprowadzanie nieprawidłowych danych do biblioteki i obserwowanie w jaki sposób ona zawodzi.

Uruchamiaj z najmniejszymi możliwymi przywilejami. Oddziel kod, który wymaga specjalnych przywilejów (taki jak kod otwierający uprzywilejowany port sieciowy) od reszty programu. Jeśli to możliwe, zakończ wszystkie uprzywilejowane zadania przy starcie programu, a następnie zrezygnuj ze wszystkich przywilejów na zasadniczą część przebiegu programu. Jeśli specjalne przywileje są wymagane na bieżąco, to warto rozważyć rozdzielenie tego fragmentu kodu na oddzielny proces, który porozumiewa się z resztą programu za pośrednictwem jakiejś metody komunikacji międzyprocesowej IPC (chociaż patrz następny punkt — ta metoda niesie ze sobą dodatkowe ryzyko).

Nie ufaj danym pochodzącym spoza programu — nawet wziętych z własnego kodu. Napisz procedury sprawdzające, które zapewnią poprawność danych spoza programu. Należy pamiętać, że dane spoza programu zawierają dane wygenerowane przez system operacyjny lub biblioteki systemowe. One także mogą być manipulowane.

Bezpieczeństwo systemu plików

Bezpieczeństwo w systemach UNIX (oraz systemach zbliżonych do UNIX-a, takich jak Linux) zasadza się na dwóch fundamentalnych pojęciach: przywilejach użytkownika (ang. user privileges) oraz uprawnieniach w systemie plików (ang. file system permissions). Zagadnienia związane z uprawnieniami w systemie plików są zdecydowanie częściej spotykane w codziennej praktyce statystycznego programisty.

Standardowe uprawnienia

Większość użytkowników i programistów UNIX-a jest zaznajomiona ze standardową macierzą bezpieczeństwa, opisaną w poprzednim tomie i zilustrowaną za pomocą polecenia ls -l:

user group world (uzytkownik grupa inni)

\ | /

rwx rwx rwx

| | |

read write execute (odczyt zapis wykonanie)

Powyższe atrybuty są reprezentowane w polu bitowym, które jest zachowane we wpisie katalogowym. Dostęp do tego pola bitowego zapewniają rodziny chmod(2) i stat(2) wywołań systemowych. Najczęściej odwołuje się do tego pola bitowego w postaci notacji ósemkowej (o podstawie 8). Notacja ta jest szczególnie wygodna w tym przypadku, ponieważ każdy zestaw uprawnień (dla użytkownika, grupy oraz innych) może być przedstawiony za pomocą pojedynczej cyfry w zapisie ósemkowym. Same uprawnienia mogą być wyliczone poprzez dodanie do siebie wartości ósemkowych dla każdego typu uprawnienia.

Odczyt (Read)

4

Zapis (Write)

2

Wykonanie (Execute)

1

Bit lepki

Oprócz bitów standardowych uprawnień, większość systemów UNIX (w tym Linux) posiada bit zwany „lepkim” (ang. sticky bit). Może on być zmieniany poprzez ustawianie lub usuwanie bitu w polu uprawnień odpowiadającym wartości 1000 w zapisie ósemkowym, lub przy pomocy polecenia chmod wraz z parametrem +t lub -t. Bit lepki jest wskazywany w wydruku polecenia ls -l poprzez literę t w kolumnie odpowiadającej użytkownikowi w polu określającym uprawnienia innych (ang. world permissions place).

Historycznie rzecz biorąc, bit lepki został zaprojektowany dla wskazywania plików programów, które „ugrzęzły” w pamięci lub obszarze pliku wymiany po zakończeniu działania, jako metoda optymalizacji wydajności w wolniejszych systemach. W Linuksie, programy z ustawionym bitem lepkim są przechowywane w przestrzeni pliku wymiany nawet po zakończeniu wykonywania. Ta cecha jest utrzymana głównie dla zachowania kompatybilności — obecnie niewiele systemów jest tak wolna lub wrażliwa, aby wymagała podobnych sztuczek.

Bit lepki zastosowany do katalogów przyjmuje nową i bardziej interesującą rolę. Jeśli bit lepki jest ustawiony dla katalogu, to pliki w tym katalogu nie mogą być usunięte przez żadnego użytkownika, z wyjątkiem administratora, właściciela pliku lub właściciela katalogu. W szczególności, w katalogach z uprawnieniami do zapisu przez grupę oraz innych, które mają ustawiony bit lepki, dowolny użytkownik (lub dowolny użytkownik we właściwej grupie) może utworzyć nowe pliki. Nie może on jednak usunąć plików innego użytkownika, chyba że jest właścicielem katalogu.

Tej cechy można używać we wszystkich sytuacjach, w których istnieje potrzeba wzajemnego oddziaływania użytkowników ze sobą lub ze wspólną usługą. Na przykład program może pozwalać użytkownikom na pozostawianie plików w obszarze publicznym (ang. staging area) dla późniejszego ich wykorzystania przez proces cron lub jakiś inny demon. Aby uniemożliwić użytkownikom usunięcie procesów należących do innych użytkowników (z rozmysłem lub przypadkowo) można ustawić bit lepki dla katalogu publicznego.

Często również, z oczywistych powodów, wykorzystuje się bit lepki we współużytkowanych katalogach tymczasowych, takich jak /tmp i /var/tmp. Jeśli program tworzy dowolny katalog tymczasowy z dostępem dla wielu użytkowników, to można użyć bitu lepkiego dla tego katalogu.

Atrybuty setuid i setgid

W powszechnym użyciu znajdują się dodatkowe dwa zaawansowane atrybuty bezpieczeństwa plików: bity setuid (set user ID — ustaw identyfikator użytkownika) oraz setgid (set group ID — ustaw identyfikator grupy). Plik może być ustawiony setuid poprzez ustawienie lub usunięcie bitu reprezentowanego przez wartość 4000 w zapisie ósemkowym lub za pomocą symbolicznych argumentów u+s lub u-s dla polecenia chmod. Atrybut setgid może być ustawiony lub usunięty przy pomocy bitu dla wartości 2000 w zapisie ósemkowym, lub też przy pomocy symbolicznych argumentów g+s lub g-s. Można ustawić atrybuty setuid i setgid dla plików, bez ustawiania ich jako wykonywalne — w praktyce nie stosuje się tego prawie nigdy w odniesieniu do plików, zaś w przypadku katalogów spotyka się to niezmiernie rzadko.

Bity te mają dwojakie znaczenie, w zależności od tego, czy są użyte dla plików czy dla katalogów. Dla wykonywalnych plików, owe atrybuty zmieniają przywileje procesu, który nimi zarządza. Dla katalogów, powodują zmianę domyślnej własności nowo utworzonych plików.

Atrybuty setuid i setgid dla wykonywalnych plików

Kiedy wykonywalny plik, który ma ustawione atrybuty setuid i (lub) setgid, jest uruchomiony, to efektywne identyfikatory ID użytkownika i (lub) grupy są zmienione tak, aby pasowały do właściciela i (lub) grupy uruchomionego pliku. To daje aktualnemu użytkownikowi prawa tego właśnie właściciela i (lub) grupy w obrębie pojedynczego procesu — jednak inne procesy danego użytkownika nie zyskują dodatkowych przywilejów. Jeśli wykonywalny plik ma ustawiony atrybut setuid i jego właścicielem jest administrator (sytuacja powszechnie określana jako setuid root), to użytkownik wykonujący ten plik uzyska dla tego tylko procesu pełne przywileje superużytkownika.

Jest jeden wyjątek — atrybuty setuid i setgid są ignorowane w wielu systemach, włącznie z Linuksem, jeśli wykonywalny plik jest skryptem. Jest tak ponieważ, gdy pozwolić skryptom na uzyskanie atrybutu setuid lub setgid pojawia się problem bezpieczeństwa — zobacz wyjaśnienie w podrozdziale Ogólne wskazówki i techniki zabezpieczeń, dotyczące Warunku wyścigu.

Programiści mogą wykorzystać tę potężną zdolność do umożliwienia użytkownikom lub procesom zdobycia większych przywilejów niż te, które zwykle posiadają. Pozwala przy tym programiście na nadzór wykorzystywania tych przywilejów. Najczęściej realizuje się to za pomocą uprawnień plikowych (ang. file permissions). Polega to na nadaniu użytkownikowi tymczasowego prawa odczytu lub zapisu jakiegoś pliku lub katalogu, ale tylko poprzez określony program i w sposób przez ten program dozwolony.

Na przykład, programy zajmujące się pocztą elektroniczną mają często nadane atrybuty setgid dla grupy o nazwie mail (setgid mail), która jest określona jako właściciel plików z pocztą dla każdego użytkownika. Grupa ta uzyskuje również prawo do zapisu plików z pocztą. To gwarantuje, że żaden użytkownik nie może czytać poczty innego użytkownika w normalnej sytuacji, ale jednocześnie pozwala uruchomionemu przez użytkownika agentowi na dostarczenie poczty do innego użytkownika (poprzez dołączenie wiadomości do pliku pocztowego innego użytkownika).

Nieograniczona potęga superużytkownika może być zarządzana ściśle poprzez programy setuid root, pozwalając użytkownikom lub procesom na dostęp jedynie do pewnych funkcji superużytkownika, bez konieczności podawania im hasła administratora. Przykładowo, narzędzie su(1), spełnia swoją magiczną funkcję za pośrednictwem setuid root. W innym przykładzie, tylko superużytkownik może mieć bezpośredni dostęp do warstwy sprzętowej w systemie Linux, ale system X Window wymaga bezpośredniego dostępu do warstwy sprzętowej, aby móc w pełni zrealizować zakres funkcji karty graficznej (jak dla XFree86 3.x i Linux 2.2). Zatem, serwery X w dzisiejszych dystrybucjach Linuksa są bardzo często setuid root, co pozwala im właściwie działać. Serwer X jest także zaprojektowany tak, aby ściśle ograniczać zakres czynności użytkownika. Przykładowo, uniemożliwia użytkownikowi użycie serwera X do odczytu plików innego użytkownika.

Atrybut setgid dla katalogów

Atrybut setuid nie pełni żadnej roli dla katalogów. Atrybut setgid powoduje ustawienie przez system dla wszystkich nowych plików w katalogu takiej grupy właściciela, do jakiej przypisany jest właściciel tego katalogu, zamiast nadania im domyślnej grupy użytkownika. Jest to często używane udogodnienie, umożliwiające współużytkowanie katalogów, w których atrybuty nowych plików są automatycznie ustawiane tak, aby umożliwić dostęp do nich przez innych członków grupy. To udogodnienie jest przeznaczone bardziej dla użytkowników i administratorów niż programistów — większość programów, które polegają na właściwym prawie własności grupy dla nowych plików, powinny ustawiać ich atrybuty jawnie dla wyeliminowania popełnienia błędu przez operatora, przy zmianie uprawnień dla katalogu.

Trzeba tu podkreślić, że powyższe uwagi są prawdziwe dla większości systemów operacyjnych opartych o System V, lub podobnych w działaniu, w tym także dla Linuksa. Systemy oparte na rodzinie BSD ignorują bit setgid dla katalogów i traktują je wszystkie tak, jakby były setgid.

Bezpieczne używanie setguid i setgid

Uprawnienia setuid i setgid dla plików wykonywalnych dostarczają programiście bardzo potężnego narzędzia zarządzania przywilejami. Jednak są one przy tym także bardzo niebezpiecznymi narzędziami. Ich potęga wynika bezpośrednio z możliwości działania z większymi przywilejami. Jeśli zakłada się, że użytkownik nie może być obdarzony zaufaniem na tyle, aby korzystał z tych przywilejów poza programem, to nienaruszalność systemu zależy od stopnia, w jakim program może nałożyć nadzór na sposób wykorzystania przywilejów przez użytkownika. W szczególności jest to istotne dla programów setuid root, jako że złamanie zabezpieczeń może potencjalnie umożliwić użytkownikowi uzyskanie pełnych przywilejów superużytkownika.

Jako ilustrację rozważmy przykład z pocztą zaprezentowany powyżej. Agent setgid mail dostarczający pocztę może być napisany tak, aby jedynie umożliwić programowi dołączenie danych do już istniejącego pliku. Jednakże załóżmy, że programista włączył kod diagnostyczny do agenta pocztowego, który spowodował zapis w dzienniku zdarzeń do pliku nazwanego w określonym wierszu nagłówka pocztowego. Jeśli ten kod nie był usunięty w trakcie opracowania oprogramowania, to użytkownik mogłby przesłać informację z dziennika zdarzeń do dowolnego pliku, do którego miałby uprawnienia dostępu i ,w efekcie, zniszczyć pierwotną zawartość tego pliku.

Zwykle nie stanowi to problemu, ponieważ użytkownicy posiadają już zdolność niszczenia swoich własnych informacji. Ale w środowisku setgid mail, użytkownik nabywa prawo zapisu dowolnego pliku, do którego członkowie grupy mail mają prawo zapisu. Tak oto, jakiś złośliwy użytkownik mógłby skłonić agenta do użycia pliku skrzynki pocztowej innego użytkownika jako rejestru zdarzeń, a w efekcie spowodować usunięcie całej poczty tego użytkownika.

Jest to ekstremalny i cokolwiek wydumany przykład — większość rzeczywistych słabości zabezpieczeń jest znacznie mniej oczywista. Jest zatem szczególnie ważne postępowanie według, wspomnianej powyżej, zasady ograniczonego zaufania. Przykładowo, agent dostarczający pocztę mógłby być napisany jako więcej niż jeden program — zwyczajny program bez przywilejów, wykonujący większość wymaganych obowiązków, który w razie potrzeby wywołuje pomocniczy program, z uaktywnionym setgid mail. Znacznie łatwiej zabezpieczyć mały program wykonujący tylko jedno zadanie, niż złożony program realizujący wiele zadań.

Potwierdzanie tożsamości użytkowników

Mimo swej przydatności, uprawnienia dostępu do plików nie dają żadnej korzyści, jeśli użytkownicy mogą bez trudu przejmować nawzajem swoją tożsamość. Zatem, uwierzytelnianie — proces dowodzenia, że użytkownik jest tym za kogo się podaje — jest bardzo ważnym aspektem nadawania przywilejów.

Obecnie najbardziej powszechną w użyciu formą uwierzytelnienia jest proces zapytania użytkownika o nazwę (ang. username) i hasło (ang. password). Pomysł polegający na tym, że użytkownik przechowuje sekret, który dzieli z komputerem, dzięki czemu dowodzi kim jest, był znany odkąd pojawiły się pierwsze systemy wielodostępne. Standardy określające przechowywanie, porównywanie i przekazywanie haseł mogą ulegać zmianie, ale podstawowa idea pozostaje ta sama.

Tradycyjne uwierzytelnianie w UNIX-ie

Linux, czerpiący obficie ze swojego dziedzictwa UNIX-owego, przejął tradycyjne metody UNIX-a uwierzytelniania użytkowników. Owe metody są nadal używane jako standardowy sposób uwierzytelniania użytkownika. Omówimy pokrótce tę metodę, ponieważ pokaże ona zarówno jak został zaprojektowany dobry system, jak też i dlaczego ostatecznie ponosi on porażkę w zapewnieniu odpowiedniego poziomu bezpieczeństwa.

Podstawowe techniki

Standardowa informacja uwierzytelniająca w UNIX-ie jest przechowywana w dwóch plikach: /etc/passwd oraz /etc/group. Każdy plik zawiera zapisy, po jednym zapisie w wierszu, z polami oddzielonymi spacjami. Wpisy dla każdego pliku mogą być odczytane za pomocą funkcji getpw* i getgr*. Pierwsze i ostatnie pola są używane do uwierzytelnienia i zawierają nazwę użytkownika i hasło.

Hasła są przechowywane w wymieszanej postaci (ang. hashed form), uzyskanej za pomocą funkcji mieszającej (ang. hash function) crypt(3), z dołączoną na początku dwuznakową domieszką (ang. salt). Uwierzytelnienie jest dokonane poprzez żądanie nazwy użytkownika i hasła, sprawdzenie nazwy użytkownika z pomocą getpwnam(3), odzyskaniu wymieszanego hasła, zakodowania hasła podanego przez użytkownika z pomocą crypt(3)wraz z domieszką z przechowywanego hasła i porównania wyników. Jeśli zwrócony przez crypt(3) wymieszany łańcuch jest identyczny z przechowywanym, to hasła pasują do siebie i tak zostaje potwierdzona tożsamość użytkownika. (Zobacz poniżej, dla dokładniejszego wyjaśnienia uwierzytelnienia ręcznie podawanego hasła).

Ograniczenia

Tradycyjne uwierzytelnianie z hasłem ma jedną zaletę — jest wstecznie kompatybilne z prawie każdą odmianą UNIX-a. Oprócz tego jednego waloru, tradycyjne uwierzytelnianie jest niewystarczające niemal z każdego punktu widzenia.

Algorytm crypt(3) był uważany za bardzo dobry, kiedy go przyjęto po raz pierwszy we wczesnych latach 70-tych ubiegłego wieku. Jednakże postęp w technologii (szybsze procesory do łamania zabezpieczeń, metody o dużej pojemności przechowywania wstępnie przetworzonych list z hasłami, itd.) uczynił ten algorytm bardzo podatnym na liczne ataki. Co więcej, potrzeba uwierzytelniania poprzez sieć odsłoniła dodatkowe słabości, wśród których na uwagę zasługuje możliwość wyszperania haseł wprost z sieci, w postaci zwykłego tekstu lub haseł wymieszanych (ang. password hashes) w czasie ich przesyłania. Poza tym, większość implementacji UNIX-a ograniczało długość hasła w systemie do 8 lub 14 znaków. I wreszcie, przy użyciu prostych metod uwierzytelniania przystosowanie do metody uwierzytelniania innego systemu niż UNIX zwyczajnie nie jest możliwe.

Włączalne moduły uwierzytelniające PAM

Nawiązując do tych ograniczeń firma Sun Microsystems stworzyła nowy system do sprawdzania tożsamości na platformie UNIX-a i opublikowała ten system jako standard. Ten standard — włączalne moduły uwierzytelniające (PAM, Pluggable Authentication Modules) — jest zaimplementowany jako system modułowy, pozwalający administratorowi systemowemu na włączanie schematów uwierzytelniania w miarę rosnących potrzeb, bez potrzeby przebudowania całego systemu. PAM jest obecnie udostępniony jako standardowy system uwierzytelniania w prawie każdej dystrybucji Linuksa. Jego popularność na platformach Linux i Solaris spowodowała, że został także przejęty przez innych dostawców UNIX-a.

PAM w teorii

PAM jest zaimplementowany jako interfejs programowania aplikacji (API) w postaci szeregu modułów. Każdy moduł powinien dostarczyć czterech typów usług dla systemu PAM:

Kroki w sprawdzaniu tożsamości za pomocą PAM

Aplikacje używające PAM, zasadniczo postępują tak samo, z drobnymi tylko modyfikacjami. Nie ma tu miejsca na wyczerpujące omówienie całego interfejsu programowania aplikacji PAM, poprzestaniemy na opisie standardowych procedur używanych przez aplikacje, chcące korzystać z PAM.

Po pierwsze, aplikacje używające PAM powinny zawierać poprawne pliki w swoich programach w języku C lub C++ oraz łączyć się z właściwymi bibliotekami. Plik dołączany (ang. include file) to security/pam_appl.h. Wiersz konsolidacji powinien wyglądać jak jeden z poniższych:

... -lpam -ldl

... -lpam -lpam_misc -ldl

Pierwszy wiersz jest wywołany przez standard PAM i zadziała na dowolnej platformie, która wspiera PAM. Drugi wiersz zawiera pewne specyficzne dla Linuksa rozszerzenia PAM — te powinny być użyte, o ile przenośność oprogramowania do systemów innych niż Linux nie stanowi istotnej kwestii. (Jeśli funkcje te są potrzebne, program powinien zawierać także plik security/pam_misc.h.)

Następny krok to inicjalizacja PAM. Przeprowadza się to poprzez wywołanie pam_start(3):

int pam_start(const char *service_name, const char *user_name, const struct pam_conv *conversation, pam_handle_t **pamhandle);

Parametr service_name to zwykły łańcuch tekstowy, który identyfikuje usługę dla systemu PAM. Używany jest do znalezienia i załadowania konfiguracji PAM dla określonej usługi. Nazwa programu stanowi dobrą nazwę dla usługi. Niektóre nazwy usług, jakie można już znaleźć, mogą zawierać ssh, login lub su. Nigdy nie powinno się czytać tej nazwy z jakiegoś zewnętrznego źródła dla programu, takiego jak środowisko, argv[] lub zewnętrzny plik.

Parametr user_name jest nazwą użytkownika, którego tożsamość ma być sprawdzona. Zakłada się, że nazwa użytkownika została już uzyskana, czy to poprzez jej wprowadzenie przez użytkownika, plik konfiguracyjny, aktualną informację o bieżącym użytkowniku czy w inny sposób.

Parametr conversation zostanie omówiony dokładniej poniżej. Rejestruje funkcje wywołania zwrotnego w systemie PAM, która będzie wywołana, kiedy potrzebnych będzie więcej danych wprowadzonych przez użytkownika.

Parametr pamhandle zapewnia miejsce dla PAM do zwrócenia aplikacji uchwytu sesji.

Większość funkcji PAM zwraca kod błędu, zdefiniowany w pliku dołączanym PAM, którego nazwa rozpoczyna się od PAM_. Wskaźnikiem powodzenia jest zwrócenie PAM_SUCCESS — należy to zawsze sprawdzić, po każdym wywołaniu do funkcji PAM. Przyjazne dla użytkownika opisy błędów mogą być uzyskane za pomocą wywołania funkcji pam_strerror(3):

const char * pam_strerror(pam_handle_t *pamhandle, int error);

Następnie, aplikacja wywołuje pam_authenticate(3). Ta funkcja żąda od użytkownika informacji uwierzytelniającej (takiej jak hasło), sprawdza odpowiedź dla upewnienia się, że pasuje do tego czego oczekuje moduł. Funkcja ta jest zdefiniowana następująco:

int pam_authenticate(pam_handle_t *pamhandle, const int flags);

Pole znaczników flags udostępnia opcje, które wspólnie mogą używać alternatywy bitowej or. Powinno być ustawione na zero, jeśli żadna opcja nie jest potrzebna. Dwa znaczniki są obsługiwane: PAM_SILENT, który wstrzymuje oddziaływanie z użytkownikiem za pośrednictwem funkcji wywołania zwrotnego oraz PAM_DISALLOW_NULL_AUTHTOK, który sprawia, że jeśli żeton (ang. token) uwierzytelnienia użytkownika jest NULL (przykładowo, jeśli użytkownik ma puste pole hasła) to PAM kończy się nie, jak zwykle, sukcesem, a niepowodzeniem.

Wartość zwracana, o ile nie jest to PAM_SUCCESS, powinna być jedną z następujących:

PAM_AUTH_ERR

wskazuje porażkę uwierzytelnienia, taką jak niepoprawne hasło;

PAM_CRED_INSUFFICIENT

wskazuje, że sama aplikacja nie posiada wystarczających praw dostępu do sprawdzenia uwierzytelnienia użytkownika; w prawie wszystkich przypadkach wynika to z błędu administratora w konfiguracji systemu;

PAM_AUTHINFO_UNAVAIL

ten problem wskazuje, że system uwierzytelnienia jest niedostępny z jakiegoś powodu; na przykład, system może używać sieciowego systemu uwierzytelniania, gdy sieć nie działa;

PAM_USER_UNKNOWN

wskazuje, że nazwa użytkownika nie może być odnaleziona;

PAM_MAXTRIES

wskazuje, że jeden z modułów uwierzytelniających sygnalizuje osiągnięcie maksymalnej liczby ponowionych prób; jeśli aplikacja otrzymuje taki błąd, to powinna zaprzestać prób sprawdzenia tożsamości.

Parę słów ostrzeżenia. Przekazywanie informacji użytkownikowi o niepowodzeniu uwierzytelnienia niesie ze sobą pewne konsekwencje dla bezpieczeństwa. Na przykład, są znane przypadki kiedy agresorzy podają prawdopodobne nazwy użytkownika do usługi sprawdzającej tożsamość i obserwują jej różne odpowiedzi aby ocenić czy „trafili” właściwą nazwę, czy też nie. Zatem najlepiej z punktu widzenia zabezpieczenia, traktować PAM_AUTH_ERR i PAM_USER_UNKNOWN przynajmniej jednakowo, chyba że jest jakiś dobry powód, aby postąpić inaczej. Podobnie pozostałe błędy powinny być jednakowo traktowane, przynajmniej z punktu widzenia użytkownika usiłującego się zarejestrować się do systemu. Informacja prawdopodobnie powinna być gdzieś indziej zapisana tak, aby można było do niej sięgnąć i diagnozować problemy.

Jeśli pam_authenticate(3) zwraca PAM_SUCCESS, oznacza to potwierdzenie tożsamości użytkownika. Jednakże inne czynniki poza tożsamością użytkownika mogą rządzić dostępem do systemu. Aby je sprawdzić, program powinien w dalszej kolejności wywołać pam_acct_mgmt(3):

int pam_acct_mgmt(pam_handle_t *pamhandle, const int flags);

Znaczniki są tutaj takie same jak dla pam_authenticate(3). Możliwe wartości zwracane, oprócz PAM_SUCCESS, PAM_AUTH_ERR i PAM_USER_UNKNOWN (które mają takie same znaczenie jak dla pam_authenticate(3)) są następujące:

PAM_ACCT_EXPIRED

konto użytkownika wygasło (ang. expired); wskazuje to na trwalszy stan, taki jak po dezaktywacji konta użytkownika;

PAM_PERM_DENIED

użytkownik nie ma pozwolenia na zarejestrowanie się w systemie; w odróżnieniu od PAM_ACCT_EXPIRED, wskazuje to na tymczasowy stan, taki jak po nałożeniu ograniczeń na rejestrację poza określonymi godzinami;

PAM_AUTHTOKEN_REQD

używany dla wskazania, że żeton uwierzytelniający użytkownika jest poprawny, ale już nieaktualny; aplikacja powinna zabronić dalszego dostępu do czasu jego zmiany.

Aplikacja może w razie potrzeby (na przykład, gdy otrzyma błąd PAM_AUTHTOKEN_REQD) wywołać pam_chauthtok(3), aby zmienić żetony uwierzytelniające dla użytkownika. Funkcja ta jest zdefiniowana następująco:

int pam_chauthtok(pam_handle_t *pamhandle, const int flags);

Poza PAM_SILENT (poprawny, choć trochę sprzeczny znacznik dla tej funkcji), tylko jeden znacznik jest obsługiwany: PAM_CHANGE_EXPIRED_AUTHTOK. Mówi on PAM, aby podjąć próbę zmiany żetonów jedynie, gdy ich ważność wygasła — domyślnie są zmieniane bez względu na wszystko.

Funkcja ta może zwrócić kilka błędów. Oprócz PAM_SUCCESS i PAM_USER_UNKNOWN, są to:

PAM_AUTHTOK_ERR

z jakiegoś powodu nowy żeton uwierzytelniający nie może być otrzymany; przykładowo, użytkownik mógł podjąć próbę anulowania procesu;

PAM_AUTHTOK_RECOVERY_ERR

system nie może otrzymać starego żetonu uwierzytelniającego; na przykład, moduł uwierzytelniający zanim poprosi o nowe hasło, mógłby poprosić użytkownika o wprowadzenie starego hasła, które mogło być wpisane niepoprawnie;

PAM_AUTHTOK_LOCK_BUSY

system nie może uaktualnić żetonów uwierzytelniających z powodu blokady — przykładem jest blokada zapisu na bazie danych;

PAM_AUTHTOK_DISABLE_AGING

jeden z modułów uwierzytelniających nie obsługuje wygasania ważności żetonów uwierzytelniających;

PAM_PERM_DENIED

użytkownik nie posiada uprawnień do zmiany swojego żetonu uwierzytelniającego;

PAM_TRY_AGAIN

jeden z modułów uwierzytelniających w użyciu zgłosił błąd, który przerwał wykonywanie całego procesu; aplikacja powinna ponowić próbę.

Po tym wszystkim, tożsamość użytkownika może być uznana za w pełni sprawdzoną przez system. Jednakże, pozostają nadal pewne zadania o charakterze porządkowym, które mogą być niezbędne do wykonania przed przejściem do kolejnego etapu. Zarządzanie sesją jest tu najważniejsze — obejmuje to zadania, takie jak udostępnianie katalogu macierzystego dla użytkowników, konfigurowanie środowiska, rejestracja dostępu użytkownika, rejestracja użytkownika w bazie danych utmp i wtmp i inne.

Obsługa sesji jest realizowana za pomocą następujących dwu funkcji:

int pam_open_session(pam_handle_t *pamhandle, const int flags);

int pam_close_session(pam_handle_t *pamhandle, const int flags);

Pierwsza z funkcji powinna być wywołana dla otwarcia sesji, a druga dla zamknięcia sesji (jak już użytkownik wyrejestruje się z systemu). Obie funkcje akceptują tylko znacznik PAM_SILENT i obie zwracają po prostu albo sukces (PAM_SUCCESS), albo niepowodzenie (PAM_SESSION_ERR).

Inna opcja jest związana z ustawianiem uwierzytelnień. Są to specjalne żetony, które mogą być rejestrowane przez system uwierzytelniający dla umożliwienia dodatkowego dostępu. Dwa powszechne przykłady uwierzytelnień to bilety Kerberos (ang. Kerberos tickets) i informacja o przynależności do grup࠮

Uwierzytelnienia są ustawiane za pomocą funkcji pam_setcred(3):

int pam_setcred(pam_handle_t *pamhandle, const int flags);

Znaczniki (alternatywa bitowa or przeprowadzona z parametrem flags) są używane dla określenia niezbędnego działania:

PAM_ESTABLISH_CRED

ustal uwierzytelnienia;

PAM_DELETE_CRED

usuń informację uwierzytelniającą;

PAM_REINITIALIZE_CRED

ponów inicjalizację uwierzytelnień;

PAM_REFRESH_CRED

odśwież uwierzytelnienia, aby zapobiec ich wygaśnięciu.

Funkcja ta może zwrócić jeden spośród kilku błędów. Oprócz PAM_SUCCESS i PAM_USER_UNKNOWN są nimi:

PAM_CRED_UNAVAIL

z jakiegoś powodu uwierzytelnienia użytkownika są niedostępne;

PAM_CRED_EXPIRED

wygasła ważność uwierzytelnień użytkownika;

PAM_CRED_ERR

jeden z modułów uwierzytelniających miał jakiś inny błąd w ustawieniu uwierzytelnień.

Ostatni krok w pracy z PAM to czyste, bezbłędne zamknięcie sesji PAM, co może być zrobione po zamknięciu każdej sesji, wykonane za pomocą pam_end(3):

int pam_end(pam_handle_t *pamhandle, const int pam_status);

Parametr pam_status powinien zawierać ostatnio zwróconą wartość z funkcji PAM. Ostrożność nie zawadzi zgłosić przy pomocy pam_strerror(3) każdą wartość, inną niż PAM_SUCCESS, z powrotem do użytkownika. W przeciwnym razie wartość zwrócona może zostać zignorowana.

Rejestracja wywołań zwrotnych

Proces uwierzytelniania czasami wymaga informacji od rejestrującego się użytkownika. Aby to umożliwić, funkcja pam_start(3) prosi o strukture konwersacji (ang. conversation structure). Podstawowym celem tej struktury jest wskazanie PAM funkcji wywołania zwrotnego dostarczonej przez aplikację. Ta funkcja konwersacji (ang. conversation function) pozwala PAM na zachęcanie użytkownika do udzielenia informacji i odebranie jego odpowiedzi.

Oto definicja struktury konwersacji:

struct pam_conv {

int (*conv) (int num_msg,

const struct pam_message **msg,

struct pam_response **resp,

void *appdata_ptr);

void *appdata_ptr;

};

Pierwsze pole w tej strukturze jest wskaźnikiem do funkcji konwersacji, która musi być zadeklarowana zgodnie z podanym pierwowzorem. Drugie pole może przyjąć wartość NULL lub może wskazywać na dowolne dane. Niezależnie od jego wartości, zostaje przekazane bez zmian do funkcji konwersacji, jako czwarty parametr.

Każde wywołanie funkcji konwersacji zawiera zestaw komunikatów i oczekiwanych odpowiedzi. Komunikaty są zawarte w parametrze msg, podczas gdy ich całkowita liczba jest zachowana w parametrze num_msg. Każdy komunikat powinien być pokazany użytkownikowi we właściwy dla aplikacji sposób (na wyjście stdout lub stderr, w rozwijalnym popup menu w systemie X, itd.). Jeśli typ komunikatu wymaga reakcji ze strony użytkownika, to funkcja powinna zaakceptować dane wprowadzane przez użytkownika i przechować je w tablicy o strukturze pam_response (z wpisami num_msg), wskazywanej przez resp. Wpisy w resp odpowiadają wpisom w msg, zatem odpowiedź uzyskana na message[n] powinna być zachowana w resp[n]. Wreszcie, funkcja w normalnych okolicznościach powinna zwrócić PAM_SUCCESS. Jeśli pojawia się błąd, to funkcja powinna zwrócić PAM_CONV_ERR i nie zmieniać żadnej informacji zawartej w resp.

Struktury pam_message i pam_response są zdefiniowane następująco:

struct pam_message {

int msg_style;

const char *msg;

};

struct pam_response {

char *resp;

int resp_retcode;

};

Element msg struktury pam_message jest wskaźnikiem do łańcucha komunikatu, który ma być pokazany użytkownikowi. Pole resp struktury pam_response wskazuje na bufor do wypełnienia odpowiedzią użytkownika. Bufor ma długość PAM_MAX_MSG_SIZE bajtów, zatem nie więcej niż tyle informacji powinno być skopiowane przez funkcję do bufora. (Pole msg także nie powinno być nigdy dłuższe niż PAM_MAX_MSG_SIZE bajtów, ale takiego zachowania aplikacja nie powinna oczekiwać, bo nie jest ono wymuszone przez PAM.) Pole resp_retcode powinno być ustawione na zero — nie używa się go obecnie.

Pozostaje pole msg_style struktury pam_message. Wartość tego pola określa, jaki to rodzaj komunikatu, oraz czy odpowiedź użytkownika jest oczekiwana. Może przyjmować następujące wartości:

PAM_PROMPT_ECHO_OFF

komunikat powinien być pokazany i użytkownik powinien wprowadzić odpowiedź; potwierdzenie poprawności (ang. echoing) odpowiedzi powinno być wyłączone podczas wpisywania przez użytkownika; PAM nie ma wymagań co do tego, co pokazywać zamiast echa, tak więc aplikacja ma swobodę wyboru odpowiedniej konwencji (bez odzewu, echo z gwiazdek, itd.);

PAM_PROMPT_ECHO_ON

komunikat powinien być pokazany, a użytkownik powinien wprowadzić odpowiedź; echo może być włączone;

PAM_ERROR_MSG

komunikat powinien być pokazany w sposób właściwy dla komunikatów o błędzie; nie oczekuje się wprowadzenia żadnych danych;

PAM_TEXT_INFO

nie oczekuje się wprowadzenia żadnych danych; komunikat powinien być pokazany; komunikat nie dotyczy błędu, tak więc może być pokazany w jakikolwiek sposób, przyjęty za właściwy;

PAM_BINARY_PROMPT

ten typ komunikatu jest rozszerzeniem udostępnianym przez Linux; zawiera komunikat binarny z przeznaczeniem dla protokołów uwierzytelniania klient-serwer oraz oczekuje odpowiedzi; w większości przypadków zachęta i odpowiedź zależą w dużym stopniu od określonych protokołów uwierzytelniania; toteż określenie, czy zachęta powinna być pokazana lub jak odebrać odpowiedź, zależy w znacznym stopniu od aplikacji i protokołu;

PAM_BINARY_MSG

ten typ komunikatu, jak poprzedni, jest specyficzny dla Linuksa; zawiera także binarny komunikat, dla którego odpowiedź nie jest oczekiwana.

Duża liczba aplikacji oddziałuje poprzez sesje tekstowe bez specjalnej potrzeby formatowania danych wprowadzanych i wyprowadzanych (na przykład ncurses). Dla tych aplikacji, biblioteka pam_misc udostępnia funkcję misc_conv. Jest to w pełni działająca funkcja konwersacji, która realizuje cały zakres swoich możliwości poprzez prosty odczyt i zapis do strumieni pliku stdio. Pozwala to zaoszczędzić wysiłek potrzebny do napisania własnych funkcji konwersacji, z których każda najprawdopodobniej implementowałaby dokładnie takie same działanie.

Przykład

Załóżmy, że pojawia się potrzeba przechowania poufnych plików, dostępnych jedynie dla prawowitych użytkowników systemu (z ewentualnie innymi ograniczeniami, które można określić w przyszłości). Załóżmy dalej, że istnieje obawa związana z pracującymi bez nadzoru terrminalami. W związku z tym chcemy, aby użytkownicy ponownie wprowadzili swoje hasło, zanim będą mogli mieć dostęp do tych plików.

Cel ten można osiągnąć w znacznej mierze pisząc przeglądarkę pliku (ang. file viewer), która wymaga uwierzytelnienia ze strony użytkownika przed przystąpieniem do przeglądania pliku. Następnie można utworzyć nowego użytkownika, przypisać mu prawo własności owych poufnych plików, nadać uprawnienia do odczytu jedynie ich właścicielowi oraz ustawić atrybut setuid przeglądarki plików dla naszego użytkownika. To zapewnia, że jedyną metodą odczytu plików jest użycie naszej przeglądarki.

Na początek, użyjemy PAM dla uwierzytelniania. Dla zaoszczędzenia sobie pracy zostanie wykorzystana funkcja konwersacji misc_conv. Biorąc pod uwagę czynności do wykonania poza PAM, potrzeba będzie następujących plików nagłówkowych:

#include <stdio.h>

#include <stdlib.h>

#include <sys/types.h>

#include <pwd.h>

#include <syslog.h>

#include <security/pam_appl.h>

#include <security/pam_misc.h>

#include <security/_pam_types.h>

Nie potrzebne będzie wyrafinowane sprawdzanie błędów — można poprzestać na traktowaniu jako błędy wygasłych haseł i temu podobnych. Tak więc, na każdym etapie wystarczy sprawdzać, czy występuje PAM_SUCCESS. Nie trzeba też udostępniać możliwości ponawiania prób, ponieważ w razie niepowodzenia można uruchomić aplikację ponownie. Dla uniknięcia żmudnego powtarzania sprawdzeń, utwórzmy funkcję testowania błędu, która wyświetla użytkownikowi ogólny komunikat, oraz rejestruje rzeczywisty błąd w syslog(3):

void test_pam_error(pam_handle_t *ph, int pam_retval)

{

if (pam_retval != PAM_SUCCESS)

{

fputs("auth error\n", stderr);

syslog(LOG_ERR, pam_strerror(ph, pam_retval));

exit(EXIT_FAILURE);

}

}

Następnie zostaje zainicjalizowane main, zadeklarowane zmienne oraz sprawdzone argumenty wiersza poleceń:

int main(int argc, char *argv[])

{

int retval;

struct passwd *myinfo

struct pam_conv myconv;

pam_handle_t *pamhandle = NULL;

FILE *secure_file;

char buf[256];

openlog("authcat", LOG_PID, LOG_AUTHPRIV);

if (argc != 2)

{

fputs("usage: authcat filename\n", stderr);

exit(EXIT_FAILURE);

}

W tym momencie, zanim można będzie zainicjalizować PAM, potrzebna jest nazwa użytkownika. Ponieważ następuje ponowne sprawdzenie tożsamości bieżącego użytkownika, można uzyskać jego nazwę poprzez wywołanie getuid(2) dla rzeczywistego UID (należy pamiętać, że uruchamiamy setuid, tak więc efektywne UID będzie zawsze odpowiadało użytkownikowi, do którego należą te pliki), a następnie wywołanie getpwuid(3) dla odzyskania nazwy użytkownika skojarzonego z tym UID:

myinfo = getpwuid(getuid());

if (myinfo == NULL)

{

fputs("authcat: cannot determine the current user\n", stderr);

exit(EXIT_FAILURE);

}

Teraz, gdy właściwa nazwa użytkownika jest już znana, można rozpocząć PAM:

myconv.conv = misc_conv;

myconv.appdata_ptr = NULL;

retval = pam_start("authcat", myinfo->pw_name, &myconv, &pamhandle);

test_pam_error(pamhandle, retval);

Jedna ważna uwaga. Mogliśmy dla przekazania nazwy usługi użyć argv[0]. Nie zrobiliśmy tego dlatego, że argv[0] nie jest bezpiecznym źródłem nazwy aplikacji. W tym przypadku agresor mógłby osłabić ustawienia zabezpieczające dla programu poprzez utworzenie twardego lub symbolicznego dowiązania (ang. hard or symlink) do naszego programu, nazywając to dowiązanie tak samo, jak jakiś inny program oparty na PAM, z mniej obostrzoną konfiguracją. Nasza aplikacja wówczas przekazałaby nazwę dowiązania poprzez argv[0] i PAM załadowałby i używałby ustawienia z większą liczbą uprawnień.

Następnie można przeprowadzić faktyczne uwierzytelnienie. Nie dbamy o uprawnienia (ang. credentials), ani o zarządzanie sesją, ponieważ jest to jednorazowe uwierzytelnienie. Wszystko czego potrzeba, to uwierzytelnienia i usługi dla konta:

retval = pam_authenticate(pamhandle, 0);

test_pam_error(pamhandle, retval);

retval = pam_acct_mgmt(pamhandle, 0);

test_pam_error(pamhandle, retval);

Jeśli dotarliśmy do tego punktu żywi, bez egzekucji ze strony funkcji testującej błędy, to oznacza żeśmy użytkownikami z prawowitego łoża. Teraz można pokazać plik:

secure_file = fopen(argv[1], "r");

if (secure_file == NULL)

{

fputs("authcat: cannot open file\n", stderr);

exit(EXIT_FAILURE)

}

while (!feof(secure_file))

{

if (fgets(buf, 256, secure_file))

fputs(buf, stdout);

}

fclose(secure_file);

To już wszystko. Teraz można zamknąć PAM i zwyczajnie zakończyć program:

retval = pam_end(pamhandle, retval);

test_pam_error(pamhandle, retval);

return EXIT_SUCCESS;

}

Należy zwrócić uwagę, że nie ma tu implementacji części specyfikacji programu mówiącej o „innych ograniczeniach w miarę potrzeb”. Powód jest prosty — ponieważ jest używany PAM, to dodatkowe ograniczenia mogą być ustawione za pomocą pliku konfiguracyjnego PAM. Zatem, dodawanie ograniczeń staje się zadaniem dla administratora, a nie programisty.

Na przykład, poniżej znajduje się względnie prosty plik konfiguracyjny PAM, którego można by użyć dla powyższego programu narzędziowego. W tym przypadku plik z programem narzędziowym nazywa się authcat.c. Należy więc koniecznie przypisać nazwę authcat naszemu plikowi konfiguracyjnemu i zapisać go w katalogu /etc/pam.d/. Być może okaże się, że brak pliku pam_unix.so. W takiej sytuacji należy użyć w poniższym kodzie odpowiednio, pam_unix_auth.so i pam_unix_acct.so.

auth required pam_unix.so

account required pam_unix.so

Jednakże administrator systemu może zadecydować, że program powinien tylko być uruchamiany na określonych, „bezpiecznych” terminalach w odpowiednim ustawieniu. To dodatkowe obostrzenie łatwo jest dodać bez konieczności programowania:

auth required pam_listfile.so onerr=fail item=tty sense=allow \

file=/etc/authcat-ttys

auth required pam_unix.so

account required pam_unix.so

W podobny sposób, bez potrzeby dokonywania jakichkolwiek zmian w naszym programie, mogą być dodane do programu inne obostrzenia, czy inne metody uwierzytelnienia.

Binarna wersja programu jest skompilowana w następujący sposób:

$ gcc authcat authcat.c -lpam -ldl -lpam_misc

Teraz można uruchomić program na dowolnym pliku i wprowadzić hasło po pojawieniu się zachęty, jak pokazano poniżej:

$ authcat temp.asc

Password:

Na tym etapie plik będzie zapisany na stdout i może być skierowany w potoku na wejście less lub do jakiegoś odpowiednika. Jeśli pojawi się problem, to użytkownik go dostrzeże:

$ authcat temp.asc

Password:

authpam: auth error

Administrator może odnaleźć błąd, który wystąpił, przeglądając zapis systemowych rejestrów zdarzeń, gdzie przechowywana jest informacja uwierzytelniająca o rzeczywistym błędzie (typowo /var/log/secure lub /var/log/auth.log).

Zarządzanie przywilejami

Przywileje superużytkownika są szerokie. Superużytkownik powinien móc zrobić w systemie dosłownie wszystko: odczytać i zapisać dowolny plik, przeformatować dysk twardy, nadużyć warstwy sprzętowej, zamknąć system i wiele innych rzeczy. Z tego powodu, jedynie zaufani administratorzy powinni mieć możliwość pracy jako superużytkownicy. Jednak przywileje superużytkownika są także wymagane do wykonywania innych usług systemowych jak: przyłączenie do uprzywilejowanego portu, bezpośredni dostęp do warstwy sprzętowej, czy nawet rejestracja w systemie.

Dla złagodzenia możliwości wystąpienia błędu w zabezpieczeniu w programie, który musi działać jako użytkownik root (ang. root), system umożliwia procesom wydanie przywilejów użytkownika wtedy, kiedy jest to niezbędne. Można nawet tymczasowo wydać przywileje superużytkownika i potem je odebrać. W ten sposób, demon mógłby zebrać razem wszystkie operacje z przywilejami superużytkownika (ang. root-privileged) w jeden, odrębny od głównego demona, proces pomocniczy. Inna możliwość, to wykonanie wszystkich uprzywilejowanych operacji przy uruchamianiu, a natychmiast potem porzucenie specjalnych przywilejów.

Porzucanie i odzyskiwanie przywilejów

Jest osiem różnych identyfikatorów ID związanych z każdym procesem Linuksa: po dwa (dla użytkownika i grupy) dla każdego spośród czterech różnych typów. Sześć z nich ma właściwość łatwego przenoszenia do większości współczesnych systemów UNIX-a, podczas gdy pozostałe dwa nie mają tej właściwości. Identyfikatorami są:

Przy zmianie identyfikatorów, należy przestrzegać podstawowej zasady: jeśli żaden z identyfikatorów rzeczywisty, efektywny lub zapisany nie równa się 0, to rzeczywisty ID może być tylko zmieniony na bieżącą wartość efektywnego ID, a efektywny ID może być tylko zmieniony na bieżącą wartość rzeczywistego ID lub zapisanego ID. Superużytkownik może zmienić dowolny identyfikator na dowolną poprawną wartość.

System będzie zmieniał zapisany ID okresowo, w miarę zmian innych identyfikatorów. Jest on ustawiony na efektywny ID, ilekroć rzeczywisty ID się zmienia oraz ilekroć efektywny ID jest zmieniony na inną wartość niż rzeczywisty ID. Dodatkowo, może być odręcznie zmieniony za pomocą wywołań setresuid(2) i setresgid(2), opisanych poniżej.

Funkcje get*id i set*id

Jest dostępnych wiele funkcji do odzyskiwania i ustawiania identyfikatorów. Zasadniczo mogą być one podzielone na kilka rodzin, każda z odmianami dla uzyskiwania i ustawiania identyfikatorów i oddzielnymi zestawami funkcji dla ustawiania identyfikatorów użytkownika lub grupy. Wszystkie one są dostarczone przez POSIX, chyba że zaznaczono inaczej i powinny być obecne w każdym nowoczesnym UNIX-ie. Rodziny są następujące:

uid_t getuid(void);

int setuid(uid_t uid);

int setreuid(uid_t realuid, uid_t effectiveuid);

int getresuid(uid_t *realuid, uid_t *effectiveuid, uid_t *saveuid);

int setresuid(uid_t realuid, uid_t effectiveuid, uid_t saveuid);

setfs{u,g}id. Funkcje te pozwalają aplikacjom ustawić identyfikator systemu plików. Są one zdefiniowane identycznie, jak funkcje z rodziny setuid. Zwykle nie używa się ich. Są specyficzne dla Linuksa.

Strategie zarządzania przywilejami

Celem w zarządzaniu przywilejami użytkowników jest uniemożliwienie agresorowi zwiększenia przywilejów, wskutek infiltracji, w pewnym stopniu, programu, przy jednoczesnym umożliwieniu mu korzystanie z tychże samych przywilejów.

Dla demonów uruchomionych przy starcie systemu, lub programów, które mają setuid lub setgid użytkownika root, najbardziej oczywistym sposobem realizacji tego zadania jest wykonanie wszystkich uprzywilejowanych operacji przy starcie, a następnie natychmiastowe zredukowanie przywilejów. To sytuacja najprostsza do obsługi — usługa może zacząć jako użytkownik root, wypełnić wszelkie niezbędne zadania, a zaraz potem wykonać następujące kroki:

/* newuid to nowe uid dla uzytkownika na czas wykonywania,

newgid to nowe gid dla grupy na czas wykonywania */

if (setgid(newgid))

handle_error();

if (setuid(newuid))

handle_error();

Funkcje setuid i setgid, kiedy są wywołane przez użytkownika root wykazują specjalne działanie, powodujące, że wszystkie identyfikatory ID zostają ustawione dla nowego użytkownika. Należy zwrócić uwagę, że przywileje dla grupy są porzucone jako pierwsze — to zachowuje właściwe działanie setgid(2) w czasie wykonywania jako użytkownik root, zapewniając również ustawienie zapisanego identyfikatora grupy GID.

Programy, które mają ustawione atrybuty setuid lub setgid, ale nie dla użytkownika root, niewiele się różnią. Dla zachowania przenośności kodu, który pracuje na większości systemów UNIX, jest niemożliwe bezpośrednie ustawienie zapisanego ID. Może nastąpić załamanie w systemie bezpieczeństwa, jeśli program zakłada, że przywileje zostały całkowicie porzucone, gdyż agresor może odzyskać przywileje setuid lub setgid poprzez bezpośrednie ustawienie efektywnego ID. Aby usunąć zapisany ID, najlepiej jest zamienić rzeczywisty z efektywnym identyfikatorem, a potem ustawić rzeczywisty ID na efektywny ID. Tym sposobem efektywny ID zostaje ustawiony na rzeczywisty ID, z dodatkową przy tym korzyścią ustawienia zapisanego ID:

if (setreuid(geteuid(), getuid()))

handle_error();

if (setreuid(geteuid(), -1))

handle_error();

Oczywiście, jeśli przenośność oprogramowania nie jest istotnym zagadnieniem i ma się do dyspozycji setresuid (setresgid), to można z ich pomocą całkowicie porzucić przywileje:

int myuid = getuid();

if (setresuid(-1, myuid, myuid))

handle_error();

Jeśli zwiększone przywileje są niezbędne na dłuższy okres, istnieją też sposoby, aby zredukować odkrycie na ataki. Dzięki użyciu zapisanego ID, program może działać bez przywilejów, aż do określonego momentu, kiedy wyższy poziom przywilejów jest potrzebny. Jeśli agresor potrafi infiltrować aplikację tak, aby skłonić ją do działania na swoje potrzeby, ale nie potrafi jej przekonać, aby uruchomiła dowolny kod, to pozwala na znaczne ograniczenie szkód jakie mogą być uczynione.

Ale znowu może się to okazać trudne, jeśli program nie ma przywilejów użytkownika root oraz wymagana jest przenośność oprogramowania. Jest ważne, aby zapisać efektywny ID przed jego zmianą, ponieważ nie można odnaleźć zapisanego ID w sposób, który jest przenośny:

int oldeuid;

oldeuid = geteuid();

if (seteuid(getuid()))

handle_error();

/* tutaj wykonuj niebezpieczne rzeczy */

if (seteuid(oldeuid))

handle_error();

Bezpieczne korzystanie z kryptografii

Zabezpieczenie plików i użytkowników jest bardzo przydatne, ale często niewystarczające. Na przykład, uprawnienia dostępu do plików nie ochronią danych przed superużytkownikiem, a bezpieczeństwo tylko na poziomie użytkownika nie daje ochrony poprzez sieć. W takich sytuacjach kryptografia (ang. cryptography) jest często niezbędna. Kryptografia to w praktyce szyfrowanie (ang. encryption) danych lub ich kodowanie (ang. encoding) za pomocą takiego algorytmu, który wymaga klucza do odszyfrowania. Kryptografia obejmuje także inne praktyki bezpiecznego przekazu danych.

W przeszłości mocna kryptografia była wyłączną domeną zastosowań militarnych oraz wielkiej finansjery. We współczesnej sieci publicznych połączeń cyfrowych techniki kryptografii są absolutnie niezastąpione w dziedzinie zapewnienia bezpieczeństwa danych. Jak w przypadku reszty branży bezpieczeństwa (ang. security trade), programowanie kryptograficzne nie toleruje żadnego błędu — jedna pomyłka może spowodować całkowitą klęskę zabezpieczeń produktu.

Krótkie wprowadzenie do kryptografii

Kryptografia przez znaczną część swojej historii miała postać, znaną dziś pod nazwą „kryptografii symetrycznej”. To oznacza zaszyfrowanie (ang. scrambling), za pomocą algorytmu z użyciem jakiegoś klucza, wiadomości zapisanej zwykłym tekstem. Odczytanie powstałego szyfrogramu (ang. ciphertext) wymaga użycia tego samego klucza (lub innego klucza, który łatwo da się wyprowadzić z klucza oryginalnego).

Nawet w konfrontacji z nowoczesnymi wynalazkami, symetryczna kryptografia pozostaje ważną dziedziną. Prawie wszystkie szyfry obecnie, na pewnym etapie, wykorzystują symetryczny algorytm. Wynika to ze zwiększonej szybkości i wydajności współczesnych metod symetrycznej kryptografii.

Niektóre przykłady symetrycznych szyfrów (ang. ciphers) to DES i jego kuzyn Triple DES, IDEA (używana w PGP), Blowfish oraz RC4.

Kryptografia z kluczem publicznym

Kryptografia z kluczem publicznym (ang. public-key cryptography) (lub asymetryczna kryptografia) była wprowadzona po raz pierwszy w latach 70-tych XX wieku. Systemy te wykorzystują wielokrotne klucze, zwykle dwa, które są powiązane następująco: wiadomości zaszyfrowane przy użyciu jednego z kluczy mogą być odszyfrowane tylko przy użyciu drugiego klucza. Zasadnicze jest to, że klucz deszyfrujący nie może być łatwo wyprowadzony z klucza szyfrującego. W przeciwnym bowiem razie, system stanowi szczególny przypadek symetrycznej kryptografii, jako że oba klucze muszą być przechowywane w tajemnicy.

W systemie asymetrycznym klucz szyfrujący może zostać opublikowany, aby każdy mógł zaszyfrować wiadomość (stąd nazwa „klucz publiczny”). To znacznie ułatwia problem dystrybucji klucza. Można umieścić klucze publiczne w Internecie lub przesłać je wprost na oczach przeciwników.

O ile dystrybucja klucza nie stanowi większego problemu, to uwierzytelnienie klucza jest bardziej kłopotliwe. Kryptografia z kluczem publicznym jest bardziej podatna na ataki, w których napastnik przechwytuje klucz publiczny i przesyła swój w jego miejsce. Zatem ważne jest sprawdzenie w jakiś sposób poprawności klucza publicznego dla upewnienia się, iż klucz istotnie należy do tego, kto tak twierdzi.

Z praktycznych powodów (wynikających z bezpieczeństwa oraz innych problemów z czysto asymetrycznymi systemami kryptografii), większość systemów kryptografii z „kluczami publicznymi” w rzeczywistości używa asymetrycznego szyfru jedynie do zaszyfrowania jakiegoś klucza dla tradycyjnego symetrycznego szyfru, używanego dla określonej transakcji (ang. transaction) lub zestawu transakcji (określa się to często mianem klucza sesji — ang. session key). Daje to faktycznie taki sam wynik, zakładając że wybrany szyfr symetryczny jest co najmniej tak bezpieczny, jak ów asymetryczny.

Niektóre przykłady szyfrów z kluczem publicznym to DSA, RSA i szyfry ElGamal.

Algorytm bezpiecznego mieszania

Dla przypomnienia, „algorytm mieszania” (ang. hash algorithm) pobiera dane wejściowe i produkuje wynik odpowiedni dla indeksowania w tablicy przeglądowej (ang. lookup table). Opiera się to na idei, że jest mało prawdopodobne (do pewnego stopnia), aby dwie dane wejściowe dały w wyniku taką samą postać zmieszaną (ang. hash). Dzięki temu wyszukiwanie za pośrednictwem wartości klucza mieszającego (ang. hash value) są bardziej wydajne, niż wyszukiwania oryginalnej wartości.

Algorytm bezpiecznego mieszania (ang. secure hash algorithm) ma poniższe dodatkowe własności:

Jest trudno znaleźć otwarty tekst (ang. plaintext), produkujący daną postać zmieszaną.

Jest trudno znaleźć dwa otwarte teksty, które produkują tę samą wartość klucza mieszającego.

Bezpieczne funkcje mieszania (ang. hash functions) mają wiele zastosowań. Przykładowo, hasła mogą być przechowywane w zmieszanej postaci — to zapobiega złamaniu hasła, a jednocześnie pozwala na stwierdzenie poprawności hasła. Bezpieczne zmieszane dane mogą być również użyte jako znaczniki identyfikacyjne (ang. identification tags) dla danych. Jeśli podany zestaw danych produkuje taką samą postać zmieszaną, jest bardzo prawdopodobne, że te same dane były poprzednio zmieszane. Tym sposobem dane w postaci bezpiecznie zmieszanej są podobne do sum kontrolnych (ang. checksums), a przy tym bardziej solidne. Stąd też mogą być używane również w tej roli. Jednakże tradycyjne algorytmy sum kontrolnych zwykle nie udostępniają gwarancji takich, jak powyżej i mogą być łatwo zmylone przez jakaś zorganizowaną akcję.

Przykłady bezpiecznych funkcji mieszania obejmują MD5 i SHA. Algorytm crypt(3) także usiłuje rywalizować o rolę bezpiecznej funkcji mieszania, choć jego poziom bezpieczeństwa jest znacznie niższy.

O pisaniu swoistych algorytmów

Wiele programów zamiast używać dobrze przetestowanych i znanych standardów chętniej implementuje swoje własne zastrzeżone techniki kryptograficzne. To niemal zawsze jest poważnym błędem.

Prawie wszystkie znane bezpieczne algorytmy kryptograficzne są szeroko publikowane i analizowane. W przeciwieństwie do popularnego poglądu, to zapewnia lepszy, a nie gorszy, poziom zabezpieczenia. Tajemne algorytmy rzadko pozostają bezpieczne na długo. Wszak implementacje komputerowe algorytmów zapewnia analitykom (i agresorom) roboczy przykład, który mogą rozpracować rozkładając na części i przekształcić kod na instrukcje asemblera (ang. disassemble).

Otwarte opublikowane systemy mają tę przewagę, że renomowani naukowcy zajmujący się bezpieczeństwem mogą badać, krytykować i łamać systemy w miarę możliwości. Ich analiza może być wykorzystana dla ulepszenia systemu lub zaprojektowania jakiegoś nowego rozwiązania. Jeśli algorytm potrafi oprzeć się próbom przenikliwej analizy najtęższych umysłów świata przez długie lata, to najprawdopodobniej będzie więcej niż odpowiedni do niemal każdego zastosowania.

Ponadto algorytmy domowej roboty napisane przez niedoświadczonych kryptografów często wpadają w te same pułapki. Półki są pełne systemów z „nowymi zabezpieczeniami nie do złamania”, które okazały się być ponownymi odkryciami systemów, których zabezpieczenia były łamane od lat. Przykładowo, dawno temu szyfrowanie w kilku produktach Microsoft Office było tak łatwe do złamania, że w jednym narzędziu do łamania zabezpieczeń (ang. cracking tool) zaimplementowano pętlę opóźniającą tak, aby wyglądało na to, że ciężko pracuje. Wszystko po to, aby zaoszczędzić nieco wstydu firmie Microsoft.

Tak więc, podczas implementacji rozwiązań kryptograficznych, najlepszą taktyką jest użycie dobrze znanych i dobrze przetestowanych systemów. W miarę możliwości należy przeprowadzać możliwie dużo operacji szyfrowania w zewnętrznych bibliotekach o dobrze udokumentowanym pochodzeniu.

Niektóre powszechnie stosowane techniki

Badacze opracowali kilka aplikacji dla kryptografii, które wykraczają poza jedyne tradycyjne zastosowanie chronienia wiadomości przed wzrokiem ciekawskich. Ogromny postęp, jaki dokonał się ostatnio w tej dziedzinie potwierdza słuszność takiego podejścia. Poniżej jest wyszczególnionych kilka bardziej powszechnie stosowanych protokołów kryptograficznych.

Cyfrowe podpisy

Cyfrowe podpisy (ang. digital signatures) są zaimplementowane przy pomocy kryptografii z kluczem publicznym. Zasadniczo działają w trybie odwrotnym do używanego przez kryptografię z kluczem publicznym — wiadomość (ang. message) jest „zaszyfrowana” przy pomocy tajnego klucza i „odszyfrowana” przy pomocy klucza publicznego. Jeśli klucz publiczny może odszyfrować wiadomość stanowi to dowód, że wiadomość była zaszyfrowana kluczem prywatnym.

W pełni dojrzałe szyfrowanie i deszyfrowanie nie jest, w gruncie rzeczy, niezbędne, gdyż sama wiadomość nie jest prywatna. Wymagane jest jedynie, żeby wiadomość „zaszyfrowana” przy pomocy klucza prywatnego nie mogła być „odszyfrowana” niczym innym, poza kluczem publicznym. Ponadto ze względu na szybkość i bezpieczeństwo sama wiadomość jest rzadko używana. Jest natomiast zmieszana za pomocą bezpiecznej funkcji mieszania, połączona z informacją identyfikującą (taką jak bieżąca data i czas), a następnie „zaszyfrowana” z pomocą klucza prywatnego. Odbiorca może zmieszać swoją kopię wiadomości, odszyfrować podpis przy użyciu klucza publicznego (a zatem potwierdzić źródło), porównać wersje zmieszane i upewnić się co do tego, że są identyczne (a zatem potwierdzić, która wiadomość została podpisana) oraz odnotować pozostałe informacje.

Uwierzytelnianie hasłem

Problem z uwierzytelnianiem hasłem to w istocie dwa problemy w jednym. Pierwotny problem tkwi w przechowywaniu hasła — jak hasła mogą być bezpiecznie przechowywane? Kiedy tylko ten problem jest rozwiązany, pojawia się kolejna trudność — w jaki sposób informacja uwierzytelniająca może być przekazana poprzez potencjalnie niezabezpieczoną sieć?

Standardowe rozwiązanie pierwszego problemu to użycie bezpiecznej funkcji mieszania. Hasła są przechowywane w postaci zmieszanej, nie jako prosty łańcuch tekstowy. Potencjalny agresor, czytając postać zmieszaną, niewiele się dowie, ponieważ trudno jest znaleźć łańcuch, który wygeneruje tę samą postać zmieszaną. Procedury potwierdzania mogą następnie akceptować hasło od użytkownika, zmieszać je, a następnie porównać obie postaci zmieszane. Jeśli są identyczne, to oznacza, że użytkownik dostarczył właściwe hasło.

Większość systemów używa dodatkowej techniki, zwanej domieszkowaniem (ang. salting) hasła. W tym celu wylicza się liczbę przypadkowych bitów (domieszka — ang „salt”) i dołącza do prostego łańcucha tekstowego hasła przed jego zmieszaniem. Domieszka przechowuje się wraz z postacią zmieszaną w miejscu hasła. Jest ona dołączona w ten sam sposób do hasła dostarczonego przez użytkownika w czasie procesu uwierzytelniania. Domieszka w istocie nie przyczynia się do zwiększonego tajności samego hasła, ponieważ jest przechowywana w postaci zwykłego tekstu. Zapewnia jednak temu samemu hasłu różną postać zmieszaną, w zależności od użytych domieszek. Chroni to użytkowników, którzy używają tego samego hasła na systemach wielu komputerów. Dzięki temu, ataki oparte na sprawdzaniu słownika stają się trudniejsze — wstępnie opracowane słowniki musiałyby być obliczone dla każdego możliwego klucza mieszającego, co mogłoby okazać się zbyt kosztowne dla odpowiednio dużej domieszki.

Dotąd zakładaliśmy, że użytkownicy mogą bezpiecznie przekazać swoje hasła do systemu. Jest to prawda, jeśli rejestracja do systemu odbywa się bezpośrednio poprzez konsolę. Z pewnością tak nie jest jeśli rejestracja do systemu odbywa się poprzez sieć, w której łatwo można założyć podsłuch. Mieszanie haseł nieco poprawia sytuację, gdyż otwarty tekst haseł jest trzymany z dala od sieci. Jednakże proste systemy, które zwyczajnie przekazują postać zmieszaną zamiast hasła, nadal są narażone na ataki ze strony zmodyfikowanego oprogramowania klienta, które odtwarza zmieszane hasła, wyszperane w sieci.

Istnieje wiele rozwiązań tego problemu, ze zmiennym stopniem bezpieczeństwa. Przykładowo, protokoły wyzwanie-odpowiedź (ang. challenge-response protocols) traktują typowo zmieszane hasło jako klucz w symetrycznym algorytmie. Obie strony przesyłają pomiędzy sobą otwarty tekst i zaszyfrowane dane. Użytkownik potwierdza swą tożsamość, jeśli niezaszyfrowane dane pasują do danych odszyfrowanych ze zmieszanym hasłem. Protokoły te działają, ale są podatne na wyrafinowane ataki. Microsoft powszechnie używa protokołów wyzwanie-odpowiedź dla uwierzytelniania wielu spośród swoich protokołów, włącznie z uwierzytelnieniem domen w Windows NT oraz PPTP.

Cyfrowe podpisy mogą zapewnić inne rozwiązania. Jeden sposób polega na powierzeniu hostowi do przechowania kluczy publicznych, zamiast zmieszanych haseł dla każdego użytkownika. Kiedy użytkownik chce się zarejestrować do systemu, host wysyła przypadkowy łańcuch, a użytkownik podpisuje ten łańcuch i odsyła z powrotem. Użytkownik jest uwierzytelniony, jeśli prawdziwość podpisu jest sprawdzona przy pomocy właściwego klucza publicznego. Tak jak w przypadku protokołu wyzwanie-odpowiedź, możliwy jest wyrafinowany atak. W dodatku, jest jak zawsze obecny, problem kryptografii klucza publicznego w sytuacji ataku pośrednika wpół drogi (ang. man-in-the-middle), jeśli klucze publiczne nie są zweryfikowane właściwie. Jest to metoda używana przez SSL do uwierzytelniania serwerów (i użytkowników tam, gdzie to jest udostępnione) — ssh także używa odmiany cyfrowych podpisów w trybie uwierzytelniania RSA.

Szyfrowanie sesji

Problem sprawdzania tożsamości staje się łatwiejszy, jeśli kanał do przesyłu danych pomiędzy hostem i użytkownikiem może być zabezpieczony. W takim przypadku wystarczy zwyczajnie przesłać hasło w postaci otwartego tekstu za pomocą zabezpieczonego kanału i zmieszać je po stronie hosta.

Szyfrowanie sesji obejmuje typowo pewne formy uwierzytelnienia, po których następuje wymiana bezpiecznego klucza dla symetrycznego algorytmu. Trzeba wymienić dwa klucze, jeden dla każdego kierunku, w celu uniknięcia pewnych ataków. Następnie, a jeszcze przed transmisją przez łącza, zawartość każdego pakietu jest zaszyfrowana.

Kilka systemów używa tej metody. Większość rozwiązań z włączonym protokołem bezpiecznej transmisji danych SSL (Secure Socket Layer) używa pewnej odmiany szyfrowania sesji. A mianowicie, metoda z hasłem zapisanym otwartym tekstem jest realizowana poprzez zaszyfrowane połączenie ustanowione przez SSL. Podobnie działa ssh użyty w trybie prostego hasła. Wreszcie, każda zaszyfrowana wirtualna sieć prywatna VPN (Virtual Private Network), taka jak standard IPSec, umożliwia to bez żadnych ceregieli z sesją — pakiety są zaszyfrowane w naturalnym etapie ich marszrutowania, poprzez Internet, do ich punktów docelowych.

Generowanie liczb losowych w Linuksie

Liczby losowe (ang. random numbers) są często wymagane w aplikacjach kryptograficznych do takich celów jak generowanie klucza, dopełnianie wiadomości (dla ukrycia długości wiadomości), i tym podobne. „Randomizacja” jest tutaj względnym pojęciem — większość komputerów, poza militarnymi zastosowaniami, ma dostęp jedynie do lepszych lub gorszych przybliżeń przypadkowości (wielu ludzi zastanawia się nawet czy i wojskowi mają dostęp do czegoś takiego jak prawdziwa przypadkowość).

Normalne programy nie mają trudności w generowaniu liczb pseudolosowych przy użyciu funkcji takich jak rand(3). Jednakże aplikacje nastawione na bezpieczeństwo nigdy nie powinny ich używać. Jeśli agresor potrafi zgadnąć sekwencję liczb generowanych przez generator liczb losowych systemu zabezpieczeń, to może on użyć tej sekwencji do wydobycia haseł, kluczy prywatnych oraz innej informacji z tego systemu. Niestety, funkcja rand(3) z biblioteki GNU libc nie była zaprojektowana, aby być tak solidną funkcją. Bardzo niewielu dostawców udostępnia solidne implementacje tej funkcji.

Linux zapewnia alternatywne źródło liczb losowych, dla sytuacji, kiedy bezpieczeństwo jest istotnym czynnikiem. Jest to urządzenie „losowych” znaków (ang. „random” character device). Urządzenie to działa jako interfejs do wewnętrznego generatora liczb losowych w jądrze systemowym. Generuje on „pulę entropii” (ang. „entropy pool”) z tła zdarzeń przypadkowych w różnych sterownikach urządzenia i innych źródłach znajdujących się poza kontrolą systemu.

Są dwa pliki urządzeń, które podczepiają się do sterownika losowego: /dev/random i /dev/urandom. Urządzenie /dev/random zwraca losowe bajty jedynie z puli entropii. Jeśli pula jest opróżniona, to urządzenie będzie zablokowane do momentu, aż więcej bajtów będzie dostępnych. Natomiast /dev/urandom, nigdy się nie blokuje. Jeśli pula entropii się wyczerpie, to będzie generować losowe bity ze swojego własnego generatora liczb. Pula entropii jest uważana za lepsze źródło „prawdziwej przypadkowości”. Jest także faworyzowana dla aplikacji długo żyjących, takich jak generator klucza dla użytkownika. Jednakże generator liczb losowych uważany jest za bezpieczny kryptograficznie i może spełniać rolę „drugiego pod względem jakości” źródła dla „tymczasowych” zastosowań, takich jak klucze sesji.

Czytanie z tych urządzeń odbywa się, tak jak czytanie z innych urządzeń — zwracają one strumień losowych bajtów. Informacja dla nich może być zapisana w blokach o długości 512 bajtów — ta informacja jest traktowana jako ziarno losowe dla generatora. Ogólnie rzecz ujmując, Linux dba o inicjalizację tego ziarna podczas uruchomienia systemu.

Przykładowo, poniższy kod w C zwróci liczbę odpowiednią do użycia jako klucz sesji dla 128 bitowego algorytmu:

/* Pamietaj, to nie jest lancuch! */

unsigned char key[16];

int randomfile;

ssize_t bytes;

randomfile = open("/dev/urandom", O_RDONLY);

bytes = read(randomfile, key, 16);

if (bytes != 16)

{

/* Potrzebujemy 16 bajtow; wszystko inne jest bledem. */

handle_error();

}

Należy zapamiętać, że większość aplikacji powinna uzyskać liczby losowe z bardziej tradycyjnych źródeł. Bezpieczeństwo kryptograficzne nie jest potrzebne do, na przykład, potasowania talii kart w samotniku. To zachowuje pulę entropii dla aplikacji, które jej potrzebują (takie jak generatory klucza). W dodatku programy używające /dev/[u]random w zasadzie nie są przenośnie do innych systemów operacyjnych.

Zarządzanie kluczem

Innym poważnym źródłem troski o bezpieczeństwo jest niezabezpieczone zarządzanie kluczem. Jakakolwiek informacja o kluczu, która ma być trzymana w tajemnicy (symetryczne klucze lub klucze prywatne) musi być traktowana ostrożnie. Chodzi o to, aby mimowolnie nie doszło do przecieku.

Oczywiście powszechnym źródłem są proste pomyłki. Tajne akta z kluczami powinny być przechowywane w plikach z odpowiednimi ustawieniami uprawnień systemowych. W szczególności, wszystkie uprawnienia dla innych powinny być wyłączone (uprawnienia grupy zaleca się także wyłączyć, chyba że są niezbędne). Bardzo poufne klucze powinny być trzymane na usuwalnych nośnikach, dołączanych jedynie wtedy, gdy klucz jest potrzebny. Oczywiście nie zawsze jest to praktyczne. Powinno się to jednak rozważyć, zwłaszcza w sytuacji, kiedy tajny klucz jest potrzebny tylko od czasu do czasu.

Ostrożność nakazuje rozważenie też takich zagadnień jak bezpieczeństwo kopii zapasowych — niezabezpieczone kopie zapasowe stanowią szczególnie łatwy cel infiltracji. Lepiej nie przechowywać kluczy we współużytkowanej pamięci, ani w miarę możliwości nie przekazywać ich za pośrednictwem żadnej innej metody IPC. Oczywiście gniazda sieciowe są szczególnie niebezpieczne w tym względzie.

Ponadto klucze nigdy nie powinny być wbudowywane do kodu. Zawsze powinny być generowane przez użytkownika programu lub przynajmniej dostarczone w pliku konfiguracyjnym. Jeśli niemożliwy do zmiany klucz ulegnie zdekonspirowaniu, to program oparty na tym kluczu nie może być używany bezpiecznie. Klucze pochodzące od dostawców oprogramowania są także atrakcyjnym celem ataków. Zdekonspirowanie takiego jednego klucza może otworzyć całą bazę danych o użytkowniku w programie. Wyjątkowo jest dopuszczalne (a nawet wskazane), aby dostawca dostarczył klucz publiczny dla potrzeb bezpiecznej komunikacji z nim. Jednakże nawet w tym przypadku, klucz powinien dać się wymienić w razie potrzeby.

Często warto przechowywać klucze zaszyfrowane algorytmem symetrycznym, używając jakiejś bezpiecznej postaci zmieszanej długiego hasła (ang. pass phrase) wprowadzonego przez użytkownika, jako klucza do tego celu. To zapobiega natychmiastowemu użyciu skradzionych plików z kluczami. Przy odpowiednio złożonym zdaniu hasłowym i silnym algorytmie, klucz może być bezpieczny, nawet jeśli został skradziony. Używanie słabych technik szyfrowania dla zabezpieczenia kluczy lub wbudowywanie „klucza uniwersalnego” (ang. „master key”) gdzieś bez zabezpieczenia, daje niewiele większy poziom zabezpieczenia, niż zwykłe przechowywanie klucza w otwartym formacie tekstowym. Może dawać również fałszywe poczucie bezpieczeństwa.

Klucze powinny być wystarczająco długie, by oprzeć się atakom opartym na zasadzie pełnego przeglądu (ang. brut-force attacks). W ogólności, algorytmy symetryczne z kluczami o długości 128 bitów są uważane za bezpieczne dla większości zastosowań. Wszystkie bieżące algorytmy asymetryczne są podatne na łatwiejsze ataki, niż te oparte na zasadzie pełnego przeglądu — wynika to ze sposobu w jaki działają. Polecana długość klucza jest zależna od algorytmu. Dla RSA, ElGamal i DSA (to trzy najbardziej teraz popularne algorytmy asymetryczne), długość klucza równa 2048 bitów powinna być wystarczająca.

Należy pamiętać, że w chwili pisania tej książki, kilka państw wprowadza ograniczenia użycia lub eksportu technologii szyfrowania z długimi kluczami. Należy zatem sprawdzić stronę prawną użycia i eksportu szyfrowania przy projektowaniu aplikacji.

Bezpieczne programowanie sieciowe

Tradycyjne systemy oparte o hosta są względnie łatwe do zabezpieczenia. Jeśli całe zarządzanie procesami i danymi przebiega wewnątrz hosta, to wtedy trudno założyć u hosta „podsłuch” haseł, czy też ingerować w wewnętrzne kanały komunikacji. Administrator systemu mógłby radzić sobie całkiem dobrze z ochroną systemu wypatrując jedynie podejrzanych uszkodzeń linii łączących terminale i zamykać na klucz drzwi do pokoju komputerowego.

Praca w sieci zmieniła drastycznie tę idealną sytuację. Zakładanie podsłuchu (ang. tapping) nie wymaga już tajemniczych operacji na fizycznych kablach. Dowolny komputer w sieci o współdzielonej przepustowości (ang. shared-bandwidth), takiej jak niekomutowany Ethernet — (ang. unswitched Ethernet), może ustawić znacznik sprzętowy i otrzymać pełną kopię przepływającej informacji w sieci, nawet pomiędzy dwoma niespokrewnionymi systemami. Przełączane (komutowane) sieci są trochę bezpieczniejsze — przy pomocy odrobiny manipulacji odpowiednimi protokołami, są możliwe ataki pośrednika wpół drogi (ang. man-in-the-middle). Taki atak polega na tym, że agresor ustawia się pomiędzy dwiema stronami w kanale komunikacyjnym i podsłuchuje przechodzące sygnały.

Protokoły zapisu

Ważne jest zatem takie projektowanie protokołów sieciowych, aby były łatwe do zabezpieczenia. Nawet jeśli bezpieczeństwo nie jest bezpośrednim celem, projektowanie łatwych do zabezpieczenia protokołów ma sens w dłuższej perspektywie. W trakcie realizacji projektu mogą ulec zmianie jego cele, a protokoły mogą znaleźć zastosowanie daleko poza pierwotnymi intencjami twórców.

Stosowanie standardów tam, gdzie tylko to możliwe

Często pierwszym krokiem w projektowaniu jakiegoś protokołu jest jego zaniechanie. Wiele standardowych protokołów już istnieje, a wiele renomowanych protokołów ma dobrze znane konsekwencje dla bezpieczeństwa. Dotyczy to w szczególności popularnych, rozszerzalnych protokołów. Należy rozważyć, w jakim stopniu któryś ze standardowych protokołów może wypełnić określone zadanie i czy protokół może pracować z jakimiś rozszerzeniami.

HTTP jest szczególnie atrakcyjnym protokołem. Jego konsekwencje dla bezpieczeństwa są dobrze poznane, współpracuje dobrze ze wszystkimi zaporami sieciowymi (ang. firewalls) i wszystkimi serwerami pośredniczącymi (ang. proxy servers) i jest łatwy do zabezpieczenia przy pomocy SSL. Wiele wysokiej jakości serwerów — włącznie z Apache, który z pewnością jest najbardziej popularną poza DNS aplikacją serwerową w Internecie — może obsłużyć szczegóły niskiego poziomu protokołu. Oprócz tego, interfejsy programowania API tych serwerów są dobrze ukształtowane (CGI, PHP, mod_perl, itd.). Protokół ten obsługuje całościowe operacje dwukierunkowego (w stylu zapytanie-odpowiedź, ang. query-request) żądania, jak również bardziej trwałe transakcje poprzez sesje oparte na cookies i podtrzymywanie stanu aktywnego.

Czy to HTTP, czy jakiś inny protokół — jest szansa, że gdzieś znajdzie się taki protokół, który zrobi to czego się oczekuje. Poza oczywistymi korzyściami płynącymi z poniechania wyważania otwartych drzwi, próba czasu wykazuje, że standardowe protokoły są dobre i bezpieczne.

Przyjazne zapory sieciowe

Protokoły, które w zamierzeniu mają być użyte pomiędzy zaufanymi domenami lub protokoły używane w Internecie, nieuchronnie wejdą w kontakt z systemami bezpieczeństwa opartymi na zaporach sieciowych lub serwerach pośredniczących.

Jest kilka rodzajów systemów bezpieczeństwa:

Idealnie projekty protokołów powinny wziąć pod uwagę wszystkie te rozważania. W miarę możliwości, następujące wytyczne powinny być uwzględnione:

Zagadnienia bezpieczeństwa aplikacji WWW

Rozbudowa Internetu ogniskuje się wokół sieci serwerów WWW, z pewnością najpopularniejszej usłudze w Internecie. Usługi WWW w większości przypadków wysunęły się znacznie poza pierwotny cel hiperłączonej informacji, by objąć wirtualne witryny sklepowe (ang. storefronts) i złożone środowiska aplikacji.

Wraz z rozwojem sieci WWW, rozwijały się powiązane z tym zagadnienia bezpieczeństwa. Przeglądarki i serwery powiększyły swoje możliwości, dostarczając agresorom wiele punktów do zaczepienia. Twórcy przeglądarek w wielu przypadkach wydają się niewzruszeni problemami, jakie powodują wprowadzone przez nich nowe punkty zaczepienia. Obecnie w sytuacji, kiedy krążą w sieci WWW takie poufne informacje jak numery kart kredytowych, prywatna informacja śledząca i inne identyfikujące szczegóły, agresorzy mają bardzo silną motywację do znalezienia punktów zaczepienia.

W większości przypadków, użytkownicy nie są zwyczajnie w stanie zarządzać swoim bezpieczeństwem. Zagadnienia są zbyt złożone, środowisko zbyt elastyczne, a większość przeglądarek (na dobre i na złe) nie pozwala użytkownikom na dostęp do informacji o zabezpieczeniach. Większość odpowiedzialności za bezpieczeństwo musi zatem spoczywać na programistach usług sieci WWW, którzy muszą programować zachowawczo, dla zapobieżenia problemom z bezpieczeństwem.

Zagadnienia związane z zarządzaniem sesją

Często sterowanie poufnymi danymi odbywa się poprzez zarządzanie sesją na serwerze WWW. Po uwierzytelnieniu (albo przy użyciu uwierzytelnienia HTTP, albo systemu opartego na formularzach - forms-based systems), odpowiedni żeton jest dołączony do sesji i w jakiś sposób wbudowany w każdą stronę WWW. Serwer WWW poprzez sprawdzenie żetonu określa, czy konkretne działania lub dane są dostępne. (Cookies i ukryte pola formularza — hidden form fields to dwa najpopularniejsze sposoby stosowane w takich sytuacjach).

Uwierzytelnienie jest poważnym problemem sieci WWW. Najprostsze metody — proste formularze HTML lub podstawowe uwierzytelnienie HTTP — są również najmniej bezpieczne, ponieważ przekazują hasła wyraźnym tekstem poprzez Internet. HTTP obsługuje uwierzytelnianie Digest, które używa MD5 do zmieszania hasła przed jego wysłaniem. Jednak niewiele to pomaga, gdyż agresor może zwyczajnie użyć zmodyfikowanego oprogramowania klienta i wyszperać z sieci postać zmieszaną, by przesłać ją ponownie.

Użycie protokołu SSL szyfrowania sesji jest obowiązkowe dla bezpieczeństwa sesji przesyłania poufnej informacji (zobacz poniżej jak używać SSL). W tych okolicznościach, uwierzytelnienie wyraźnym tekstem nie jest poważnym problemem, gdyż hasła nie mogą być wyszperane z sieci. Jednak w sytuacjach, w których SSL nie może być udostępnione lub nie jest uzasadnione (z jakiegokolwiek powodu), są dostępne inne środki dla poprawy bezpieczeństwa procesu uwierzytelniania.

Na przykład, formularz rejestracji w systemie mógłby użyć prostego protokołu wyzwanie-odpowiedź (ang. challenge-response) dla uniemożliwienia przesyłania haseł. Z takim systemem, formularz mógłby mieć losowy bajt wyzwania (ang. challenge byte) zachowany w ukrytym polu formularza. Przycisk „dostarcz” (ang. submit button) nie powodowałby bezpośredniego przedłożenia formularza. Zamiast tego, mógłby złączyć bajt wyzwania i hasło, zmieszać je za pomocą bezpiecznej funkcji mieszania, wyczyścić pole hasła w żądaniu, a następnie przedłożyć jedynie formularz ze zmieszaną postacią hasła. Z uwagi na wymagania dla przechowywania hasła na serwerze, hasło mogłoby być zmieszane dwa razy — raz do wygenerowania postaci zmieszanej hasła do przechowania na serwerze, a drugi raz, aby włączyć bajt wyzwania.

Problem skryptów w witrynach przeplatanych (cross-site scripting)

Większe możliwości współczesnych przeglądarek doprowadziły do nowego i niespotykanego dotąd problemu z bezpieczeństwem WWW. Pojęcie wykonywania skryptów w witrynach przeplatanych (ang. „cross-site scripting”) jest odrobinę mylące, gdyż faktycznie obejmuje coś więcej niż ataki za pomocą skryptów. Niemniej jednak jest to powszechna nazwa dla całej klasy ataków, które usiłują wykorzystać zaufanie między użytkownikiem a witryną.

Problem powstaje, kiedy, na ogół zaufana, witryna dołącza dynamiczne dane dostarczone jej przez użytkowników, bez pełnej weryfikacji wprowadzonych danych. Złośliwi użytkownicy mogą wykorzystać ten problem, dostarczając do witryny dane, które, jeśli wyświetlone, mają nieoczekiwane skutki uboczne. Te efekty zwykle wiążą się z przesyłaniem danych do agresora za pośrednictwem innej, już mniej zaufanej witryny. Zdarza się (choć rzadko), że sama zaatakowana witryna może być użyta do przesłania informacji.

Przykład już jest gotowy. Załóżmy, że witryna z aktualnościami zbiera komentarze od użytkowników na temat prezentowanych tam wiadomości i wyświetla je jako część każdej wiadomości. Jednym sposobem implementacji tego (w Perlu) byłby poniższy kod:

# OSTRZEZENIE: to nie jest bezpieczne! Uzywac ostroznie!

# Tablica @komentarze zawiera tablice asocjacyjne z atrybutami

# komentarza dla kazdej z nich.

foreach $komentarz (@komentarze)

{

print "<p>Autor komentarza ", $$komentarz{"nazwisko"}, "</p>\n";

print "</p>\n";

print $$komentarz{"tekst"}, "\n";

print "</p>\n";

}

Ten kod wygląda całkowicie rozsądnie i, sam w sobie, nie jest w gruncie rzeczy niezabezpieczony. Ale rozważmy co by się stało, jeśli użytkownik przedłoży poniższy komentarz:

Wiecie co mysle o tym artykule? Jego autor to

</p>

<img src="http://www.przyklad.com/inwektywy.jpg">

<p>

Jeśli witryna nie zrobiła nic, aby zatwierdzić komentarze, to obrazek spoza witryny zostanie pokazany jako część strony. W rzeczywistości, dla niewprawnego oka to mogłoby wydać się częścią samej opowieści, zatwierdzonej przez witrynę.

Choć nieco humorystyczny, przykład ten nie oddaje w pełni dostępnych możliwości. Znacznik <IMG> mógłby być całkiem spokojnie znacznikiem <SCRIPT>. Dysponując pewną wiedzą o witrynie, ów skrypt mógłby zebrać inną informację z witryny i przekazać ją do serwera kontrolowanego przez agresora. W obrębie znacznika <FORM>, dane wprowadzone przez agresora mogły zmienić zachowanie formularza, nawet do tego stopnia, że spowodują przekazanie informacji do innego źródła. Ewentualnie, znacznik <IMG> mógłby być przeźroczystym obrazkiem 1x1w formacie GIF z dołączonym, zamiast obraźliwego obrazka, cookie. Podrzucając do witryn z aktualnościami komentarze w tym stylu, agresor mógłby zbudować imponujące profile użytkowników, a nawet połączyć je z konkretnymi osobami. Taka informacja mogłaby być wykorzystana do naruszenia ich prywatności, czy oszukania ich w jakiś sposób.

Są kroki, które można podjąć, by przeciwdziałać tego rodzaju atakom:

Zawsze należy zatwierdzać poprawność danych wprowadzanych z zewnętrznego źródła. Obejmuje to dokumenty pobrane z innych witryn, takie jak RSS-RDF streszczenia aktualności, każde dane z formularzy (nawet z ukrytych pól formularza — dopisanie za pomocą POST dowolnej wartości do adresu URL jest dziecinnie łatwe), cookies, czy też pobrania plików.

Należy ściśle definiować dopuszczalne typy danych. Odrzucając wszystko z wyjątkiem poprawnych danych wejściowych (zamiast odrzucania tylko znanych niewłaściwych danych wejściowych), uniemożliwia się agresorom zastosowanie nowatorskich sposobów, wymyślonych dla obejścia procedur zatwierdzania.

Zawsze należy zatwierdzać poprawność danych wejściowych po ich zdekodowaniu, jeśli nie przedtem. To zapobiega przemyceniu przez agresorów kodów nadużywających, w postaci wariantów zakodowanych w URL.

Zawsze należy określić charset dla każdej strony dynamicznej (zalecane właściwie dla każdej strony). Przeglądarki używają różnych domyślnych zestawów charset w zależności od wielu różnych czynników. Zostały napisane nadużywające programy do przemycenia niewinnie wyglądającego tekstu do witryny, wykorzystujące niejednoznaczność charset. Taki tekst, kiedy jest pokazany w określonym charset, powoduje w efekcie aktywację witryny przeplatanej.

Standardowe sieciowe narzędzia kryptograficzne

W zapewnieniu bezpieczeństwa aplikacji sieciowych często niemałą rolę odgrywa kryptografia. W związku z tym powstało kilka standardów dla wspomagania programistów w używaniu kryptografii poprzez sieć. Standardy te są zwykle łatwe w implementacji, dobrze współdziałają i są w większości niewidoczne dla aplikacji.

SSL-TLS

SSL był jednym z pierwszych systemów szyfrowania ogólnego przeznaczenia dla Internetu i do dziś pozostaje również systemem najbardziej popularnym. Początkowo był rozwinięty dla ułatwienia bezpiecznych transakcji WWW, a przy odrobinie wysiłku może być zastosowany do dowolnego protokołu opartego na TCP.

ssh

Narzędzie ssh (i stowarzyszona usługa sshd) zapewnia w pełni funkcjonalny i bezpieczny zamiennik usług rsh. Wiele aplikacji używa usługi rsh dla zaimplementowania aplikacji sieciowych, gdyż współgra ona korzystnie z filozofią narzędzi UNIX-a. Jednak rsh jest w dużym stopniu niezabezpieczone. Dzięki wymianie usługi rsh na ssh, aplikacje natychmiast wzbogacają się o szyfrowane sesje, dwukierunkowe uwierzytelnianie hosta oraz uwierzytelnianie opcjonalnym kluczem publicznym użytkownika.

Co więcej, ssh wykazuje się przydatną zdolnością wykonywania operacji przekazywania portu (ang. port forwarding) poprzez dowolne bezpieczne połączenie. Pozwala to na przekazywanie niezabezpieczonych protokołów poprzez bezpieczny kanał ssh, zapobiegając przesłaniu ich zawartości poprzez Internet jawnym tekstem. Jakikolwiek prosty protokół oparty na TCP może być przekazany przez ssh w taki sposób.

Użycie ssh dla zrealizowania usługi sieciowej zwykle wymaga wykonania polecenia ssh i odczytania standardowych danych wyjściowych (oraz wyjścia standardowego dla błędu w celu odczytania błędów). Przykładowo, zawartość katalogu na zdalnym systemie może być otrzymana w sposób następujący:

/* Bądź tutaj ostrozny wobec niesprawdzonych danych wejsciowych! */

int retval;

char cmd[256];

FILE *result;

snprintf(cmd, 256, "ssh -l %s ls %d", remuser, dirpath);

result = popen(cmd, "r");

if (result == NUL)

handle_error();

/* Odczyt wyniku. */

pclose(result);

Ogólne wskazówki i techniki zabezpieczeń

Ilekroć dwa zdarzenia zakładają pewien wzajemny związek, niekoniecznie prawdziwy występuje warunek wyścigu (ang. race condition). Takie sytuacje bywają pospolitymi błędami w aplikacjach przetwarzania wielowątkowego (ang. multi-threaded) i wielozadaniowego (ang. multi-tasking), gdzie dwa wątki, jeśli nie są wyraźnie zsynchronizowane, mogą próbować pochwycić jakieś zasoby w tej samej chwili (z losowo wybranym wątkiem „wygrywającym wyścig”).

W kontekście bezpieczeństwa, warunki wyścigu prawie zawsze wiążą się z wykonywaniem operacji przez agresora w tym samym czasie, kiedy jest wykonywany bezpieczny program. Celem ataku jest małe okno czasowe pomiędzy chwilą, kiedy program testuje pewien warunek, a chwilą kiedy działa w oparciu o wynik tego testu. Jeśli warunek może być zmieniony w tym małym oknie czasowym, to program może zachować się niepoprawnie, a to z kolei może doprowadzić do złamania zabezpieczeń.

Przykładowo, bity setuid i setgid dla skryptów są pomijane z powodu warunku wyścigu w większości systemów UNIX, w tym także w Linuksie. Skrypty są wykonywane jako dwuczęściowy proces. Najpierw jądro systemowe (ang. kernel) sprawdza skrypt i otwiera go, aby określić jakiego interpretera użyć dla jego uruchomienia, a potem uruchamia ten interpreter, który otwiera skrypt po raz drugi. Sprawdzenie atrybutów setuid i setgid musi być dokonane w pierwszym kroku, gdyż jądro systemowe musi ustawić efektywny identyfikator ID przed uruchomieniem interpretera.

Agresor może wykorzystać ten moment, wykonując jakiś skrypt setuid (setgid) za pomocą dowiązania symbolicznego symlink, a następnie zmienić dowiązanie tak, aby wskazywało na jakiś inny skrypt zaraz po tym, jak uprawnienia zostały sprawdzone przez jądro systemowe. Interpreter, kiedy działa, podąża następnie za zmienionym symlink i wykonuje skrypt inny, niż tego oczekiwano, ale z przywilejami nadanymi przez jądro systemowe dla pierwszego właściwego skryptu.

Warunki wyścigu są prawdopodobne, ilekroć program testuje spełnienie jakiś warunków przed wykonaniem jakiegoś działania. O ile to możliwe, zasób poddawany testowaniu powinien być najpierw zablokowany, a blokada powinna być zwolniona nie wcześniej, niż w momencie, kiedy zmiana warunku jest już uważana za bezpieczną. Przykładowo, program może otwierać plik, ale tylko wtedy kiedy uprawnienia są poprawne. Dla uniknięcia pojawienia się warunku wyścigu, program mógłby otworzyć plik ustawiając O_EXCL przed sprawdzeniem uprawnień dostępu do niego. Jeśli uprawnienia są niepoprawne, program zwyczajnie mógłby zamknąć plik i kontynuować swoje działanie. Jeśli uprawnienia są poprawne, to agresor nie może już podstawić innego pliku po sprawdzeniu uprawnień, gdyż sprawdzany plik jest już otwarty. Jest wiele innych mechanizmów realizowania blokad, takich jak semafory Systemu V, blokady plików, oraz wywołań systemowych lockf(3), flock(2) i fcntl(2).

Problemy ze środowiskiem

Zmienne środowiskowe są wygodnym sposobem przekazywania informacji przez system (lub użytkowników) do procesów potomnych (ang. child processes). Niestety, zasadniczo muszą być traktowane jako dane wejściowe niepewne. Nie ma sposobu uwierzytelnienia źródła zmiennych środowiskowych, ponieważ każdy proces wywołujący może manipulować zmiennymi przekazywanymi do procesu potomnego.

Programy setuid i setgid (oraz ich procesy potomne) są szczególnie podatne na manipulacje zmiennymi środowiskowymi, ponieważ zarządzają zwiększonymi przywilejami w imieniu użytkownika. Program może, na przykład, używać pliku wskazywanego przez zmienną środowiskową TMPFILE jak pliku tymczasowego, usuwając w wyniku tego każdy już istniejący plik. Zwykle jest to bezpieczne (z punktu widzenia zabezpieczeń), ponieważ to narzędzie działa ze zwykłymi przywilejami. Ale jeśli program setuid użytkownika root wywołuje ów program narzędziowy dla wykonania pewnych zadań, a agresor może wykonać program setuid ze zmienną TMPFILE ustawioną na /etc/passwd, to wtedy agresor zyskuje możliwość usunięcia /etc/passwd, albo wskutek aktu destrukcji, albo też przygotowując się do wymiany tego pliku innym, słabiej zabezpieczonym.

Wobec tego, przy uruchamianiu procesu potomnego programy nastawione na bezpieczeństwo powszechnie używają execve(2). Zapewnia to dostarczenie środowiska, które zostało całkowicie wysterylizowane. Najlepiej jest, aby żadna z wartości w zapasowym środowisku nie pochodziła ze starego środowiska. Wszystkie wartości powinny być wbudowane z powszechnych systemowych wartości domyślnych lub ze źródeł zaufanych pod innym względem.

Specjalne zmienne środowiskowe

Poza ryzykiem związanym z nadmiernym zaufaniem pokładanym w zmiennych środowiskowych, trzeba uwzględnić dodatkowe ryzyko, związane z użyciem niektórych zmiennych, potencjalnie powodujących nieoczekiwane efekty uboczne.

LD_*

Dynamiczny konsolidator (ang. dynamic linker) w Linuksie (i kilku innych systemach operacyjnych) używa kilku zmiennych środowiskowych dla sterowania swoim działaniem. W szczególności, zmienna LD_PRELOAD mówi dynamicznemu konsolidatorowi, aby ten załadował zawarty w niej wykaz bibliotek przed załadowaniem innych potrzebnych bibliotek. Zmienna LD_LIBRARY_PATH określa zapasową ścieżkę, używaną przy poszukiwaniu bibliotek do załadowania.

Agresorzy mogą wykorzystać te ustawienia do przejęcia kontroli nad dynamicznie konsolidowanymi programami. Poprzez wskazanie swoistej biblioteki za pomocą LD_PRELOAD, agresor może dostarczyć własną wersję wywołań bibliotek, używanych przez program, powodujących uruchamianie kodu, wybranego przez samego agresora. Podobnie, używając LD_LIBRARY_PATH, agresor może umieścić swoje własne specjalnie dostosowane biblioteki przed bibliotekami standardowymi w ścieżce poszukiwania i uzyskać podobny efekt.

Wiele systemów UNIX (także Linux) łagodzi ten problem do pewnego stopnia , pomijając LD_PRELOAD przy uruchamianiu programów setuid-setgid, oraz poprzez ładowanie bibliotek z LD_LIBRARY_PATH jedynie w pewnych okolicznościach. To tylko zmniejsza nieznacznie skalę problemu. Jeśli program setuid-setgid wywołuje inny program z normalnymi uprawnieniami, to wtedy te ograniczenia nie są wymuszone. Zatem, do dobrego zwyczaju należy każdorazowe usunięcie zmiennych środowiskowych przed uruchomieniem dowolnego programu w poufnym środowisku.

IFS

Wiele powłok, włącznie z powłoką bash (domyślna powłoka systemowa w większości dystrybucji Linuksa), używa zmiennej środowiskowej IFS dla określenia podziału argumentów wiersza poleceń. Oczekuje się, że zawiera wszelkie możliwe znaki, potrzebne do oddzielania argumentów. Domyślnie używane są znaki odstępu (ang. whitespace characters). Konfiguracja tej zmiennej może mieć interesujące reperkusje, gdyż powłoka jest zwykle wywołana za pośrednictwem wywołań systemowych (na przykład przy wywołaniu system(2)).

Przykładowo, program może wykonać w pewnych okolicznościach ls dla uzyskania wykazu zawartości katalogu. Nawet jeśli program zatwierdza przekazany łańcuch katalogowy, to mógłby być oszukany za pomocą poniższego wiersza:

/tmp*&&*rm*-f*/etc/passwd

Dla niewprawnego oka, wygląda to na niezwykle dziwną, aczkolwiek poprawną, nazwę ścieżki dostępu. Jeśli jednak zmienna IFS jest ustawiona na *, to wiersz zostanie zinterpretowany w sposób następujący:

/tmp && rm -f /etc/passwd

Jeśli wywołanie systemowe wygląda podobnie do poniższego:

snprintf(buf, buflen, "ls %s", dir);

system(buf);

to agresor właśnie skłonił program do usunięcia /etc/passwd.

Zatem, ze względu na bezpieczeństwo, ta zmienna powinna być zawsze ustawiona na wartość domyślną (ang. unset) przed wykonaniem zewnętrznego programu.

PATH

Należy pamiętać, że zmienna PATH także nie jest bezpieczna. Jeśli PATH jest ustawiona na .:/bin:/usr/bin i program usiłuje wykonać ls, to wtedy wszystko czego potrzebuje agresor do przejęcia kontroli to wykonywalny program w bieżącym katalogu o nazwie ls. Zmienna PATH zawsze powinna być ustawiona na jakąś rozsądna wartość przed uruchomieniem innych programów. Zwykle powinna zawierać tylko zaufane katalogi i nie zawierać nigdy bieżącego katalogu. Dodatkowo, można się w tym miejscu zabezpieczyć, wywołując zewnętrzne programy, w miarę możliwości, z bezwzględnymi ścieżkami dostępu.

Użycie plików tymczasowych

Użycie plików tymczasowych może nieoczekiwanie stać się przyczyną kłopotów z bezpieczeństwem. Dotyczy to zwłaszcza sytuacji, kiedy uprzywilejowane programy używają wspólnych katalogów tymczasowych, takich jak /tmp.

Problem polega na tym, że grupa innych użytkowników ma prawo zapisu we współużytkowanych katalogach. Tak więc, agresorzy mogą zastawić pułapki w tych katalogach na nieprzygotowane na to programy. Na przykład, jedna powszechnie stosowana sztuczka to zgadnięcie nazwy pliku tymczasowego, który będzie użyty w przyszłości przez jakiś program i utworzenie symbolicznego dowiązania do jakiegoś innego pliku. Kiedy program usiłuje utworzyć plik tymczasowy, to wtedy podąży za symbolicznym dowiązaniem i zapisze istniejący plik na który to dowiązanie wskazuje. Jeśli to przydarzy się użytkownikowi root, to dowolny plik w systemie może być usunięty w ten sposób. Celem ataku może tu być odmowa usługi lub zniszczenie jakiś ważnych danych. Ewentualnie, agresor może zyskać sposobność do podmiany zniszczonego pliku jakimś wybranym przez siebie plikiem, dzięki czemu ułatwi sobie dostęp do systemu.

W pracy z plikami tymczasowymi poniższe wytyczne będą użyteczne:

Należy rozważyć ewentualność pracy bez pomocy plików tymczasowych. Większość innych mechanizmów IPC jest bardziej zalecana dla przekazywania danych pomiędzy procesami. Tymczasowe przechowywanie danych dla zminimalizowania użycia pamięci jest niekiedy konieczne. Z drugiej jednak strony, warto by było przeprowadzić charakterystykę aplikacji w celu zbilansowania oszczędności pamięci wobec kosztów związanych z bezpieczeństwem i rozważenie potrzeby takich oszczędności.

Należy rozważyć takie ukształtowanie programu, aby nie używać współużytkowanych obszarów. O ile to możliwe, należy utworzyć katalog w obrębie katalogu macierzystego użytkownika dla przechowywania plików tymczasowych.

Jeśli więcej niż jeden użytkownik może mieć jednocześnie dostęp do katalogu tymczasowego, powinien być ustawiony dla tego katalogu bit „lepki” (patrz powyżej „Bezpieczeństwo systemu plików”). To uniemożliwia agresorom usuwanie lub inne manipulacje na plikach tymczasowych innych użytkowników, jak również unieszkodliwia niektóre zastawione przez nich pułapki.

Nie należy nigdy podążać za symbolicznymi dowiązaniami w czasie tworzenia tymczasowych plików we współużytkowanych obszarach. W systemie Linux 2.2 lub nowszym zabezpieczyć przed podążaniem za symbolicznymi dowiązaniami może użycie znacznika O_NOFOLLOW. We wcześniejszych wersjach systemu (lub w innym UNIX-ie) można użyć stat dla sprawdzenia symbolicznych dowiązań przed otwarciem pliku. Należy jednak pamiętać, że ta metoda jest podatna na warunek wyścigu.

Należy unikać ponownego zapisu plików, które już istnieją we współużytkowanych katalogach tymczasowych (chyba, że ich obecność jest tam spodziewana). Zapewnić to może tworzenie plików z O_CREAT | O_EXCL, które zakończy się niepowodzeniem, jeśli plik już istnieje.

Należy utworzyć podkatalog we współużytkowanym obszarze tymczasowym, a następnie umieścić tam wszystkie swoje pliki tymczasowe. Katalog powinien być utworzony tak, aby nie miała do niego dostępu ani grupa, ani inni użytkownicy (tryb 0700). Wywołanie mkdir(2) zakończy się niepowodzeniem, jeśli plik istnieje. Jeśli tak się zdarzy należy użyć innej nazwy pliku, zamiast usiłować usunąć plik.

Tworząc katalogi lub pliki, należy używać przypadkowych nazw. Nie należy opierać nazw wyłącznie na godzinie, identyfikatorze ID procesu, nazwie użytkownika, czy jakimś innym, łatwym do przewidzenia, parametrze. Wszystkie nazwy powinny zawierać jakiś trudny do odgadnięcia składnik, taki jak wynik wywołania rand(3). Przypadkowość kryptograficzna nie jest potrzebna — chodzi o to, aby zapobiec zgadnięciu nazwy pliku przy próbie zastawienia pułapki.

Należy korzystać ze zmiennej środowiskowej TMPDIR, jeśli taka istnieje. Wielu administratorów i użytkowników ustawia TMPDIR, aby wskazywała alternatywny obszar tymczasowy dla wprowadzenia lepszego zabezpieczenia. Niekoniecznie należy ufać TMPDIR. Wszystkie wyżej wymienione uwagi tak samo się stosują do TMPDIR, jak do /tmp. W szczególności przed próbą użycia TMPDIR, należy sprawdzić czy ma się uprawnienia do korzystania z tego obszaru oraz czy został ustawiony bit „lepki”, jeśli uprawnienia do zapisu tego obszaru mają inni użytkownicy.

Użycie „chroot”

UNIX dostarcza wygodnego narzędzia zabezpieczającego w postaci systemowego wywołania chroot(2). To wywołanie zmienia lokalizację głównego katalogu (ang. root directory) w obrębie systemu plików, która jest widoczna dla bieżącego procesu i wszystkich procesów potomnych. Nie może to być odwrócone, nawet przez użytkownika root (chociaż użytkownik root może w pewnych okolicznościach sięgnąć poza wirtualny główny system plików). Może to być wykorzystane jako ostatnia deska ratunku w powstrzymaniu agresorów. Jeśli najgorsze się już wydarzy i ktoś jest w stanie uzyskać nieuprawniony dostęp za pośrednictwem jakiegoś programu lub usługi, to agresor nie będzie mógł dotknąć niczego poza więzieniem, określonym przez chroot.

Ważne, aby zdać sobie sprawę z tego, że wszystkie pliki na zewnątrz są niedostępne dla procesów pochwyconych w więzieniu chroot. Zatem wirtualny katalog główny musi zawierać pewne pliki, aby system mógł działać. Następujące pliki powinny być dostarczone dla utworzenia minimalnego środowiska:

/etc/passwd

ten plik powinien zawierać jak najmniej informacji: tylko identyfikatory ID użytkowników używanych wewnątrz więzienia i ewentualnie użytkownik root; nie powinno być w pliku żadnych zaszyfrowanych haseł; pola haseł mogą być pozostawione puste lub oznaczone znakiem „X”,

/etc/group

stosują się te same zasady, co dla /etc/passwd,

/dev/null

to powinien być plik dla pseudourządzenia (ang. null device file), z tym samym dużym (ang. major) i małym (ang. minor) numerem jak prawdziwy /dev/null.

/lib/libc.so.* i /lib/ld*

to jest kopia standardowej biblioteki języka C i dynamicznego konsolidatora; inne biblioteki również mogą okazać się potrzebne; alternatywnie, wszystkie programy w więzieniu chroot mogłyby być statycznie skonsolidowane, co jest odrobinę bezpieczniejsze.

Ponadto trzeba, aby każdy plik wymagany przez aplikację był skopiowany gdzieś do wirtualnego katalogu. Można załadować informację z plików spoza wirtualnego katalogu głównego przed wywołaniem chroot(2), ale nie wolno pozostawić żadnych otwartych plików po wywołaniu, gdyż mogą być użyte do ucieczki się na zewnątrz.

Programy ważne na tyle, aby wymagać chroot(2) powinny użyć także syslog(3), aby rejestrować informację. Niestety, syslogd w Linuksie działa za pośrednictwem gniazda UNIX-a w /dev/log, które nie będzie dostępne w obrębie więzienia chroot. Ostatnie wersje syslogd rozwiązują ten problem udostępniając opcję -a. Opcja ta mówi syslogd, aby otworzyć dodatkowe gniazda UNIX-a i odebrać za ich pośrednictwem informacje przeznaczoną do zarejestrowania. Należy to wskazać w ścieżce dostępu /dev/log w obrębie wirtualnego katalogu głównego. Kiedy aplikacja wywołuje syslog(3), wywołanie dalej używa /dev/log, które wskaże nowe gniazdo w obrębie więzienia.

Dla dodatkowego efektu, można porzucić przywileje po wejściu do więzienia chroot, przełączając się na unikatowy identyfikator ID ustawiony specjalnie dla tego programu. Niektóre metody wymykania się z więzienia uwzględniają dostęp do użytkownika root, to więc czyni więzienie jeszcze bardziej bezpiecznym. Co więcej, ważne pliki w więzieniu mogłyby zostać ustawione w trybie tylko do odczytu dla użytkownika, co powstrzymałoby agresora od dokonania szkód w obrębie więzienia.

Wreszcie, faktyczny kod dokonujący przejścia w tryb chroot (i zmiany identyfikatorów ID) jest prosty:

int uid, gid; /* ID uzytkownika i grupy po przelaczeniu */

char path; /* Sciezka dostepu do wiezienia chroot */

int retval;

if (chroot(path))

handle_error();

if (setregid(gid, gid))

handle_error();

if (setreuid(uid, uid))

handle_error();

W tym momencie, program wykonuje się w więzieniu chroot jako podany użytkownik i grupa.

Zagadnienia specyficzne dla języka

Bez wątpienia zagrożeniem numer jeden związanym z C i C++ jest problem przepełnień bufora. W rzeczywistości wielu ekspertów od bezpieczeństwa uważa zagadnienie przepełnienia bufora za zagadnienie najwyższej rangi, a to ze względu na popularność języka C jako języka systemów oraz języka implementacji dla interpreterów innych języków programowania.

Jak działa przepełnienie bufora

Sytuacja przepełnienia bufora występuje, kiedy program kopiuje jakąś informację do bufora o ustalonej długości. Jeśli informacja jest obszerniejsza niż pojemność bufora, to standardowe procedury w języku C dla pamięci i łańcucha tego nie zauważą i beztrosko wyrzucą wszelką informację, która nie zmieści się w buforze. Może to być wykorzystane przez agresora do zastąpienia informacji o powrotach wywołań funkcji, co spowoduje takie oszukanie program, że będzie on wykonał niewłaściwy kod.

Przepełnienie bufora można wykorzystać na wiele sposobów. Przykładowo rozważmy, co się wydarzy. kiedy przepełnienie bufora pojawi się na systemie opartym na architekturze Intela. Zwykle kiedy to się zdarza, następuje krach programu z sygnałem SIGSEGV w systemie UNIX. Użytkownicy systemu Windows widzą tę samą sytuację wraz z oknem dialogu GPF (General Protection Fault — ogólny błąd ochrony) lub niesławnym „niebieskim ekranem śmierci” (ang. blue screen death).

Rozważmy, ile pamięci jest zarezerwowane dla wywołania funkcji w C na platformie Intela. Zwykle wszystkie zmienne w zakresie funkcji, jak też parametry funkcji są przechowane na stosie — tymczasowym miejscu do krótkoterminowego przechowywania w pamięci. Stos zwykle jest zarządzany przez sam procesor i jest zorganizowany według kolejki LIFO (Last-In-First-Out, „ostatni na wejściu, pierwszy na wyjściu”). Zwykle jest tak ustawiony, że górny adres bloku pamięci jest użyty najpierw, a bieżące położenie na stosie przesuwa się w stronę niższych adresów w miarę dodawania doń nowych elementów.

Przy wykonywaniu wywołania funkcji, najpierw każdy z jej parametrów jest przekazany na stos. Potem wywołanie jest wykonane. Procesor realizuje wywołanie funkcji poprzez przekazanie na stos także bieżącej lokalizacji wskaźnika instrukcji (IP, Instruction Pointer). Pierwsze zadanie funkcji, po tym jak przejmuje kontrolę, to zaalokowanie na stosie więcej miejsca dla swoich zmiennych lokalnych.

Tak więc, wywołanie następującej funkcji:

void foo(char *bar)

{

char baz[16] = "quux"

[...]

}

w efekcie na stosie wygląda tak:

baz ---------->return addr->bar---->

quux\0---------[void *]-----[char *]

Teraz powinno być jasne, gdzie tkwi problem. W powyższym przykładzie, jeśli dane dłuższe niż 16 bytów byłyby skopiowanie do bufora baz, to przepełnienie zostanie zapisane na wskaźniku powrotu do wywołującej procedury. Ten nowy zapis zostanie zinterpretowany jako adres powrotu kodu, kiedy funkcja zakończy działanie. To zwykle powoduje sygnał SIGSEGV, ponieważ na ogół wskaźnik zostanie zapisany przypadkowymi danymi, które wskazują niepoprawną lokalizację w pamięci.

Rozważmy co się stanie, jeśli kopiowane dane są dostarczone przez agresora. Jeśli tak się złoży, że łańcuch zapisywany do bufora zawiera poprawny adres, to zamiast zakończyć się krachem program przeskoczy do tej właśnie lokalizacji i stamtąd rozpocznie dalsze wykonywanie. Ponieważ sam bufor jest przechowywany w poprawnym adresie w pamięci, to adres zawarty w buforze mógłby wskazywać na, powiedzmy, początek bufora.

Gdyby bufor zawierał poprawny kod maszynowy, to program wykonałby go w sposób niezamierzony wraz ze wszystkimi przywilejami, jakie program sam posiada. Powszechnie stosowaną praktyką jest włączenie kodu wywołującego exec(2) do uruchomienia powłoki. Jeśli uruchomiony program miałby uprawnienia użytkownika root, to powstała powłoka systemowa miałaby pewne przywileje użytkownika root.

Są to podstawowe mechanizmy powstawania przepełnienia bufora, opartego na stosie, na systemach o architekturze Intela. Prawie takie same mechanizmy mogą być wyindukowane na innych platformach sprzętowych. Podobne ataki są także możliwe przy użyciu bufora stosu (ang. heap buffer). Więcej informacji na temat aspektów niskiego poziomu w semantyce wywołań, włącznie ze śladami stosu i tym podobnych, można znaleźć w rozdziałach 6 i 11.

Jak unikać problemów z przepełnieniem bufora

Nadużycia przepełnienia bufora są powodowane przez bezmyślne kopiowanie niepewnych danych, to znaczy danych, które nie pochodzą z zaufanego źródła. Zatem, oczywiste rozwiązanie problemu sprowadza się do sprawdzania, czy niepewne dane nie są dłuższe niż rozmiar bufora do którego są kopiowane.

Najprostszy sposób, aby to zrealizować polega na unikaniu używania procedur bibliotecznych, które przy otrzymywaniu danych nie akceptują argumentu o maksymalnej długości,. Na przykład, strcpy(3) nie powinna nigdy być używana w ważnych sytuacjach — zamiast niej trzeba użyć strncpy(3).

Niebezpieczna

Bezpieczna

strcpy

strncpy

strcat

strncat

sprintf

snprintf

gets

fgets

Inne funkcje biblioteczne, mimo że na wskroś bezpieczne, mogą być użyte w sposób niebezpieczny. Na przykład, rodzina funkcji scanf udostępnia specyfikator formatu %s, który określa, że na wejściu jest łańcuch rozdzielony odstępami. Ponieważ każdy odnaleziony łańcuch jest kopiowany do bufora, należy zadbać o podanie maksymalnej długości pola wczytywanego przy danych wejściowych pochodzących z niepewnego źródła. To zapewni, że agresor nie będzie mógł zwyczajnie dostarczyć długich danych wejściowych bez spacji rozdzielających i przepełnić bufor.

Oprócz tego, należy zachować ostrożność w bezpośrednich operacjach na buforach. Jeśli bufor jest traktowany jako łańcuch zakończony zerowym znakiem (ang. null-terminated string), to zawsze należy się upewnić, że zerowy terminator jest na miejscu. W szczególności, niektóre spośród powyższych „bezpiecznych” funkcji nie dodają zerowego bajtu, jeśli maksymalna długość bufora jest osiągnięta. Jeszcze lepiej jest wyraźnie wyzerować bufory za pomocą memset(3) lub innej podobnej metody. Kiedykolwiek to możliwe, najlepiej posługiwać się długością buforów zamiast polegać wyłącznie na terminatorze NULL.

Sprawdzanie błędów i wyjątków

Poprzednie rozdziały omawiały obsługę błędów, ale warto powtórzyć płynące z tych rozdziałów wnioski. Warunki przepełnienia bufora są jedynie jednym popularnym przypadkiem, w którym agresorzy mogą wykorzystać niestaranne sprawdzanie błędów. W programowaniu w C jest zjawiskiem dość powszechnym pomijanie kodów błędów powrotu lub ślepe założenie, że wywołanie funkcji zakończyło się powodzeniem. Powtarzanie sprawdzania błędów przy każdym wywołaniu może być uciążliwe. Tę niedogodność można znacząco ograniczyć, projektując jako element składowy programu, narzędzie do wszechstronnej obsługi błędów, które potrafi bez zarzutu potraktować błędy.

Programiści C++ mają przewagę w tym względzie — zapewniają im to wyjątki (ang. exceptions). Mając do dyspozycji wyjątki, już nie trzeba sprawdzać, czy każde wywołanie funkcji zakończyło się powodzeniem. Jest tak, gdyż kompilator przerwie normalny tok programu, kiedy coś pójdzie źle, dla wskazania potrzeby dokładniejszego sprawdzenia wyników. Co więcej, standardowe biblioteki C++ dostarczają kompleksową obsługę wyjątków dla większości operacji bibliotecznych.

Zaleca się, aby pisząc własny kod C++, implementować obsługę wyjątków już na wczesnym etapie pracy. Lepiej jest w miarę możliwości wprowadzić obsługę wyjątku, kiedy coś nie działa właściwie, niż przekazywać z powrotem kod powrotu, który ma być sprawdzony.

Perl

Perl ma specjalny tryb zwany trybem skażenia (ang. taint mode) do obsługi niepewnych danych wejściowych użytkownika. Można włączyć ten tryb używając opcji -T, albo w wierszu poleceń albo w wierszu skryptu zaczynającym się od #!.

W trybie skażenia, dane z niepewnego źródła, takie jak argumenty wiersza poleceń, zmienne środowiskowe, informacja lokalna, dane wejściowe z pliku oraz pewne wywołania systemowe są oznaczone jako „skażone”. Skażone dane nie mogą być użyte do wywołania polecenia podpowłoki (za wyjątkiem wywołania poprzez system lub exec), ani do modyfikacji katalogu, pliku lub procesu. Próby wykonania takich operacji kończą się błędem wykonania (ang. runtime error). Skażenie jest „zaraźliwe” i dane utworzone z danych skażonych też są skażone.

Zmienne zawierające skażone dane mogą być oczyszczone ze skażenia poprzez wyczyszczenie danych skażonych i ustawienie zmiennych jako odkażone. W szczególności działa to, z pewnymi wyjątkami, dla zmiennych środowiskowych. Dla przykładu, Perl odkazi $ENV{"PATH"}, jeśli ustawi się tę zmienną tak, aby włączyć dowolny katalog, do zapisu którego mają uprawnienia inni użytkownicy.

Perl nie przekazuje skażenia poprzez dopasowanie łańcuchów cząstkowych, przy użyciu nawiasów i parametrów pozycyjnych. To jest mechanizm do odkażania danych. Jest to działanie zamierzone — chodzi o to, aby można używać regularnych wyrażeń dla wydobycia „bezpiecznej” informacji ze skażonych danych. Zatem, przy odkażaniu danych najlepiej napisać wyrażenie regularne, które testuje dane dla potwierdzenia, że wynikowe dane są naprawdę bezpieczne.

Tryb skażenia włącza także inne przydatne zabezpieczenia. Dla uzyskania pełniejszego opisu, zobacz strony dokumentacji systemowej dla perlsec(3).

Skrypty setuid i setgid w Perlu

Perl obsługuje skrypty setuid i setgid na prawie wszystkich UNIX-ach, włącznie z Linuksem. Obsługa działa poprzez wykrycie uprawnień setuid (setgid) dla skryptów po załadowaniu, a następnie użycie otoczki programowej (ang. wrapper program) dla bezpiecznego wykonania skryptu ze zwiększonymi przywilejami. To pozwala uniknąć warunku wyścigu obecnego w metodzie wykonywania skryptów przez jądro systemowe. Dla zapewnienia dodatkowej ochrony, Perl automatycznie włącza tryb skażenia, kiedy skrypt jest uruchomiony z atrybutem setuid lub setgid.

Python

Python udostępnia klasę zwaną Rexec, która pozwala na podobny zakres możliwości jakie mają eval, execfile, exec i import. Jednakże kod wykonany za pośrednictwem Rexec nie ma pozwolenia na importowanie modułów lub wywoływanie funkcji, za wyjątkiem tych, uważanych za bezpieczne.

Rexec dostarcza dwóch zestawów funkcji: r_funkcji, które zabraniają dostępu do standardowych strumieni plikowych (sys.stdin, sys.stdout i sys.stderr) oraz s_funkcji, które umożliwiają ograniczony dostęp do standardowych strumieni plikowych.

W obrębie każdej grupy, udostępnione są te same funkcje (z wyjątkiem r_open, która nie ma odpowiednika s_open). Są to (na przykładzie r_wersji):

r_eval(kod)

wyznacza wartość wyrażenia w kodzie Pythona i zwraca wartość zwracaną przez to wyrażenie,

r_exec(kod)

wyznacza wartość wyrażenia w kodzie Pythona i nie zwraca żadnej wartości,

r_execfile(plik)

wyznacza wartość kodu Pytona przechowywanego w podanym pliku,

r_import(modul, globalne, lokalne, lista_od)

importuje moduł Pythona; każdy z argumentów, z wyjątkiem pierwszego, jest opcjonalny, choć musi być zachowany ich porządek,

r_open(plik, tryb, buf_rozmiar)

otwiera plik, zwracając obiekt plikowy; pliki mogą być otwarte do odczytu, ale nie do zapisu; każdy z argumentów, z wyjątkiem pierwszego, jest opcjonalny, choć musi być zachowany ich porządek,

r_reload(modul)

ładuje ponownie moduł,

r_unload(modul)

usuwa moduł z pamięci.

PHP

Ponieważ PHP jest platformą programowania aplikacji WWW, jest czuły na wszystkie niepokoje związane z bezpieczeństwem WWW, a w szczególności bezpieczne zarządzanie sesją i zagadnienia skryptów w witrynach przeplatanych (ang. cross-site scripting issues). PHP udostępnia kilka funkcji dla celów związanych z bezpieczeństwem, w tym funkcje do szyfrowania (kiedy jest skompilowany z libmcrypt), bezpieczne mieszanie, podstawowe uwierzytelnianie HTTP, cookies itd.

Zasoby informacji

Bruce Schneier „Applied Cryptography”, John Wiley & Sons, 1996 (ISBN 0-471-11709-9).

William R. Cheswick i Steven M. Bellovin „Firewalls and Internet Security”, Addison-Wesley Publishing Company, 1994 (ISBN 0-201-63357-4).

Informacja w Internecie

Szybko rozwijająca się dziedzina bezpieczeństwa w Internecie przyczyniła się do powstania pewnej liczby witryn internetowych, list korespondencyjnych i grup dyskusyjnych,które obejmują swym zainteresowaniem kwestie bezpieczeństwa. Oto kilka z nich.

Hierarchia grup dyskusyjnych comp.security zawiera ogólne informacje o bezpieczeństwie dla pewnej liczby systemów.

Grupa dyskusyjna comp.risks omawia ryzyko używania technologii informacyjnej, w tym ryzyko bezpieczeństwa. Mimo, że nie omawia bezpieczeństwa w szczególności, to jest świetnym źródłem nabawienia się zdrowej paranoi na punkcie bezpieczeństwa własnego kodu.

SecurityFocus (www.securityfocus.com) jest wszechstronną witryną WWW z artykułami, dokumentacją, bazą danych poświęconą punktom podatnym na ataki oraz innymi ważnymi informacjami bibliograficznymi dla programisty od zabezpieczeń. SecurityFocus jest również kolebką listy korespondencyjnej bugtraq, która jest pierwszorzędną listą korespondencyjną powiadamiania i dyskusji problemów z bezpieczeństwem, w miarę ich odkrywania w różnych produktach.

Counterpane Systems to firma doradcza w zakresie bezpieczeństwa, której przewodzi Bruce Schneier, autor „Applied Cryptography”. Schneier publikuje za pośrednictwem WWW biuletyn-dwumiesięcznik pod nazwą Crypto-Gram, który jest dostępny na witrynie WWW firmy Counterpane pod adresem www.counterpane.com.

Witryna CERT (Computer Emergency Response Team— Komputerowy Zespół Nagłego Reagowania) pod adresem www.cert.org zawiera historyczne ostrzeżenia dotyczące bezpieczeństwa, aktualne ogłoszenia na temat bezpieczeństwa i inne związane z tym informacje. Mimo, że CERT jest niekiedy odrobinę spóźniony względem innych źródeł w ogłaszaniu punktów podatnych na ataki, to ich badania dotyczące bezpieczeństwa i informacje dostawców są doskonałe.

Podsumowanie

W tym rozdziale zbadaliśmy trudności spotykane przy bezpiecznym programowaniu. Rozpoczęliśmy omawiając podstawowe zabezpieczenia plików, zbadaliśmy ich słabości i przyjrzeliśmy się narzędziom i metodom, które pozwalają na udoskonalenie procesu uwierzytelnienia.

Następnie przeszliśmy do omówienia narzędzi kryptograficznych i zasad bezpiecznego programowania sieciowego, ze szczególnym uwzględnieniem ssh.

Zanim podsumowaliśmy zagadnienia specyficzne dla języków programowania, omówiliśmy zagrożenia niezależne od języka programowania, wynikające z warunków wyścigu, a także omówiliśmy użycie więzienia chroot.

Rysunek Pana Pingwina z fajką, str.448

Dyskusja online: http://www.p2p.wrox.com

2 Część I Podstawy obsługi systemu WhizBang (Nagłówek strony)

2 C:\Robert\Helion\plp12_e.doc



Wyszukiwarka