Uruchamianie programu w Linuksie

Note: this is the original paper as I wrote it a long time ago, when I still was a student. I’m publishing it here in Polish, hoping that someone will find it useful. English version is going to be released soon.

Po polsku

Poniższy wpis wyjaśnia jak uruchamiane są programy łączone dynamicznie w Linuksie, choć podaję też przykłady pochodzące z Solarisa. Grupą docelową nie są doświadczeni administratorzy, a raczej osoby dopiero zaczynające swoją przygodę z systemami wywodzącymi się od Uniksa, więc zostawiłem w nim potencjalnie trywialne fragmenty.

Wstęp

Pewnego razu znajomemu administratorowi przydarzyła się taka oto historia: próbując stworzyć sobie “system w systemie” (odseparowany przy pomocy chroota) zabrał się za tworzenie nowej struktury katalogowej dla zagnieżdżonego środowiska. Stworzył więc katalog /newroot, a następnie zaczął wypełniać go kopiami plików z systemu pierwotnego. O tyle niefortunnie, że zamiast skopiować (cp), przeniósł (mv) katalogi /bin oraz /lib (mv), skutecznie psując system. W efekcie tej pochopnej operacji, ważne programy zaczęły zachowywać się, na przykład, w następujący sposób:

host:/newroot/bin/# ./cp
-su: ./cp: Nie ma takiego pliku ani katalogu

Komunikat bardzo mylący, gdyż plik cp oczywiście istniał w katalogu /newroot/bin.

Jak się okazało, istnieje elegancki sposób wybrnięcia z tej sytuacji, lecz by wyjaśnić to zachowanie, musimy zrozumieć systemowe mechanizmy odpowiedzialne za obsługę bibliotek współdzielonych oraz ich rolę w działaniu całego środowiska. Na zakończenie, oprócz pokazania sposobu rozwiązania takiego problemu, pokażę alternatywną metodę częściowego zabezpieczenia się przed awariami związanymi z brakiem lub uszkodzeniem systemowych bibliotek.

Zakładam przy tym, że znasz pojęcia jądra, procesu, bibliotek współdzielonych, przestrzeni adresowej oraz mapowania plików do pamięci, gdyż ich znajomość jest potrzebna do zrozumienia dalszej części tekstu.

Struktura pliku programu

Aby program mógł zostać uruchomiony przez system operacyjny, musi być zapisany w pliku o odpowiedniej strukturze. Aktualnie w świecie systemów uniksowych króluje format ELF (Executable and Linking Format), więc poniższe informacje podane są w jego kontekście. Funkcjonujące dawniej formaty a.out i COFF pomijamy.

Zacznijmy od zbadania przykładowego pliku wykonywalnego (/bin/ls) przy pomocy narzędzia readelf, wypisującego w czytelnej dla człowieka formie informacje zgromadzone w nagłówku oraz sekcjach plików w formacie ELF.

Wynik działania programu powinien wyglądać tak:

% readelf -h /bin/ls
ELF Header:
  Magic:   7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00
  Class:                             ELF64
  Data:                              2's complement, little endian
  Version:                           1 (current)
  OS/ABI:                            UNIX - System V
  ABI Version:                       0
  Type:                              EXEC (Executable file)
  Machine:                           Advanced Micro Devices X86-64
  Version:                           0x1
  Entry point address:               0x4025d0
  Start of program headers:          64 (bytes into file)
  Start of section headers:          87432 (bytes into file)
  Flags:                             0x0
  Size of this header:               64 (bytes)
  Size of program headers:           56 (bytes)
  Number of program headers:         10
  Size of section headers:           64 (bytes)
  Number of section headers:         27
  Section header string table index: 26

Widzimy kolejno:

  1. “Magiczny numer” (Magic), dzięki któremu możliwe jest rozpoznanie formatu pliku
  2. Klasę (Class), określającą architekturę (32 lub 64 bity), do której przystosowany jest plik
  3. Interfejs binarny (OS/ABI), z którego korzysta plik wykonywalny
  4. Rodzaj maszyny (Machine), dla której przeznaczony jest kod
  5. Punkt wejścia (Entry point address), czyli adres, od którego zaczyna się wykonywanie programu
  6. Dane określające liczbę oraz rozmiar dodatkowych nagłówków i sekcji

Oprócz tych ogólnych informacji, w wykonywalnym pliku w formacie ELF znajdują się jeszcze metadane opisujące dokładne wymagania programu, takie jak adresy pamięci, pod którymi mają znaleźć się sekcje pliku oraz wymagane biblioteki wraz z importowanymi z nich symbolami. Przyjrzymy się im w dalszej części wpisu.

Uruchamianie

Uruchamiania łączonego dynamicznie programu jest dość skomplikowanym procesem, który zaczyna się w momencie wywołania funkcji z rodziny exec, zapewniającej różnorodne interfejsy do systemowego wywołania execve, którego rolą jest załadowanie do przestrzeni adresowej procesu kodu nowego programu oraz przygotowanie środowiska do jego wykonania. W przypadku Linuksa, proces ten można przedstawić skrótowo jako listę następujących kroków:

  1. Sprawdzenie formatu pliku wykonywalnego i, jeśli jest on obsługiwany, rozpoczęcie jego przetwarzania
  2. Odczyt części nagłówka pliku, opisującego segmenty programu
  3. Zwolnienie zasobów związanych z poprzednim kontekstem wykonania (m.in. usunięcie przyporządkowania stron pamięci, aktualizacja procedur obsługi sygnałów)
  4. Alokacja pamięci na stos trybu użytkownika, zmienne środowiskowe, argumenty podane z linii poleceń oraz kod i część danych uruchamianego programu
  5. Mapowanie segmentów pliku pod odpowiednie adresy w przestrzeni procesu
  6. Powtórzenie kroku 6 dla interpretera programu (wybranego na podstawie nagłówka)
  7. Zakończenie wykonania wywołania systemowego – proces zaczyna wykonywać kod interpretera, który po zakończeniu pracy wykonuje skok do punktu wejścia programu, rozpoczynając wykonanie właściwego kodu

Powyższy wykaz operacji jest bardzo uproszczony. Pełen opis z dokładnym uwzględnieniem operacji wykonywanych w trybie jądra znaleźć można m.in. w książce “Linux kernel” [1], oraz oczywiście w kodzie jądra.

Skorzystajmy ponownie z narzędzia readelf, by obejrzeć zawartość nagłówka programu (skrócona wersja poniżej):

rogram Headers:
  Type           Offset             VirtAddr           PhysAddr
                 FileSiz            MemSiz              Flags  Align
  PHDR           0x0000000000000040 0x0000000000400040 0x0000000000400040
                 0x0000000000000230 0x0000000000000230  R E    8
  INTERP         0x0000000000000270 0x0000000000400270 0x0000000000400270
                 0x000000000000001c 0x000000000000001c  R      1
      [Requesting program interpreter: /lib64/ld-linux-x86-64.so.2]
  LOAD           0x0000000000000000 0x0000000000400000 0x0000000000400000
                 0x0000000000013f4c 0x0000000000013f4c  R E    200000
  LOAD           0x0000000000014df0 0x0000000000614df0 0x0000000000614df0
                 0x00000000000006c0 0x0000000000000c60  RW     200000
  DYNAMIC        0x0000000000014e18 0x0000000000614e18 0x0000000000614e18
                 0x00000000000001c0 0x00000000000001c0  RW     8
  NOTE           0x000000000000028c 0x000000000040028c 0x000000000040028c
                 0x0000000000000020 0x0000000000000020  R      4
  GNU_EH_FRAME   0x0000000000011f3c 0x0000000000411f3c 0x0000000000411f3c
                 0x0000000000000694 0x0000000000000694  R      4
  GNU_STACK      0x0000000000000000 0x0000000000000000 0x0000000000000000
                 0x0000000000000000 0x0000000000000000  RW     8
  GNU_RELRO      0x0000000000014df0 0x0000000000614df0 0x0000000000614df0
                 0x0000000000000210 0x0000000000000210  R      1
  PAX_FLAGS      0x0000000000000000 0x0000000000000000 0x0000000000000000
                 0x0000000000000000 0x0000000000000000         8

Oprócz wpisów określających części pliku oraz odpowiadające im adresy w przestrzeni adresowej procesu, interesuje nas linia INTERP, która określa interpreter programu, który ma zostać załadowany i uruchomiony przez jądro na końcu wywołania execve. Wpis ten ma znaczenie tylko w przypadku plików wykonywalnych (choć może pojawić się też w plikach obiektów). Oczywiście, jeśli interpreter nie zostanie znaleziony, lub jądro nie będzie mogło umieścić go w przestrzeni adresowej procesu, uruchomienie programu się nie powiedzie.

Dygresja: Interpreter znajduje się zazwyczaj w katalogach /lib lub /libexec, dlatego operacje na nich należy wykonywać z odpowiednią ostrożnością – uszkodzenie go może doprowadzić od poważnej awarii systemu.

Wiązanie dynamiczne

Skompilowane (łączone dynamicznie) programy, które można znaleźć w katalogach /bin, /usr/bin, itd., nie nadają się do bezpośredniego uruchomienia (załadowania kodu do pamięci i rozpoczęcie jego wykonywania przez procesor) z dwóch powodów:

  • Ich kod jest niezależny od miejsca w pamięci, w którym się znajduje (PIC – Position Independent Code), a co za tym idzie – początkowo część referencji do pamięci nie wskazuje na jej poprawne w danym kontekście fragmenty. Muszą one zostać odpowiednio ustawione.
  • W przestrzeni adresowej procesu nie jest dostępny wymagany kod, należący do bibliotek współdzielonych, a referencje do symboli (nazwanych adresów, pod którymi znajduje się kod lub obiekty) nie posiadają poprawnych wartości.

Nim sterowanie zostanie przekazane pod adres wskazany przez punkt wejściowy programu, powyższe elementy muszą zostać odpowiednio zainicjowane przez linker dynamiczny. Wykonywanie kodu właściwego programu może rozpocząć się dopiero po przeprowadzeniu odpowiedniego mapowania oraz serii relokacji. Chcąc skupić się na przedstawionym na początku problemie, omówimy tylko kwestię ładowania bibliotek.

Jak już wcześniej wspomniałem, w przestrzeni adresowej procesu musi znaleźć się mapowany kod. Przy pomocy narzędzia strace, śledzącego wywołania systemowe wykonywane przez proces, pokażemy, że linker faktycznie odpowiednio wypełnia przestrzeń adresową przy pomocy funkcji mmap. Wykonajmy strace /bin/ls 2>&1 | less i znajdźmy w wyniku poniższy fragment:

open("/lib/libc.so.6", O_RDONLY)        = 3
read(3, "\177ELF\2\1\1\3>\1\220\334\1"..., 832) = 832
fstat(3, {st_mode=S_IFREG|0755, st_size=1293456, ...}) = 0
mmap(NULL, 4096, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x7fdab4233000
mmap(NULL, 3399928, PROT_READ|PROT_EXEC, MAP_PRIVATE|MAP_DENYWRITE, 3, 0) = 0x7fdab38fd000
mprotect(0x7fdab3a33000, 2093056, PROT_NONE) = 0
mmap(0x7fdab3c32000, 20480, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_FIXED|MAP_DENYWRITE, 3, 0x135000) = 0x7fdab3c32000
mmap(0x7fdab3c37000, 16632, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_FIXED|MAP_ANONYMOUS, -1, 0) = 0x7fdab3c37000
close(3)                                = 0

Co robi linker?

  • open() – otwiera plik biblioteki w trybie tylko do odczytu; będzie on dostępny pod deskryptorem o numerze 3
  • read() – odczytuje jego nagłówek, by zdobyć informacje o formacie pliku (czy to na pewno biblioteka?) oraz rozmieszczeniu danych
  • fstat() – sprawdza, czy otwarty plik jest zwykłym plikiem, co jest istotne z punktu widzenia późniejszych wywołań mmap(); również sprawdza jego rozmiar
  • mmap() – drugie wywołanie mapuje cały plik w trybie tylko do odczytu z ustawioną flagą pozwalającą na wykonanie – domyślnie kod nie jest zapisywalny; mapowanie jest prywatne, więc ewentualne zmiany nie zostaną wprowadzone do pliku
  • mmap() – trzecie wywołanie mapuje część pliku zezwalając na zapis do tego obszaru pamięci; można się spodziewać, że w tej części znajdują się statyczne dane używane przez bibliotekę
  • close() – zamyka plik

Pozostałe wywołania nie są w tym momencie istotne. Podobne fragmenty pojawią się w wyniku działania strace jeszcze kilka razy, kolejno dla wszystkich ładowanych bibliotek. Wprowadzane zmiany odniesień do pamięci w programie oczywiście nie są widoczne, bo przebiegają bez udziału jądra systemu operacyjnego. Po zakończeniu pracy linkera, zaczyna się wykonywanie kodu programu.

Dygresja: Rozmieszczenie wszystkich segmentów w przestrzeni adresowej procesu można w Linuksie odczytać z pliku /proc/PID/maps, gdzie PID oznacza identyfikator interesującego nas procesu.

Słowo o środowisku

Nim przystąpimy do rozwiązywania postawionego na początku problemu, musimy zdać sobie sprawdę z jednego faktu: linker dynamiczny wykonuje się w przestrzeni adresowej przeznaczonej dla nowo uruchamianego programu. Ma zatem dostęp do jego argumentów oraz zmiennych środowiskowych. Dzięki temu mamy możliwość ingerowania w jego działanie przy pomocy odpowiednich zmiennych środowiskowych. Dwie z nich są szczególnie interesujące:

  • LD_LIBRARY_PATH – określa listę ścieżek, które mają być dodatkowo przeszukiwane pod kątem potrzebnych bibliotek; umożliwia korzystanie z bibliotek znajdujących się w miejscach nie przeszukiwanych standardowo oraz nie określonych przez konfigurację linkera (/etc/ld.so.conf).
  • LD_PRELOAD – określa listę bibliotek, które mają być załadowane przed wszystkimi innymi, określonymi w nagłówkach programu; umożliwia zastępowanie symboli (mówiąc potocznie, umożliwia “podmianę” bibliotecznych funkcji oraz obiektów, gdyż symbole dopasowywane są tylko raz)

Rozwiązanie problemu

Znamy już mechanizm uruchamiania programów w systemach uniksowych, możemy więc przystąpić do analizy przedstawionego na początku wpisu problemu.

Możemy się już domyślić, że dziwnie brzmiący komunikat wyniknął z faktu, że jądro systemu nie odnalazło pliku zawierającego potrzebny mu interpreter, określony w nagłówku pliku programu. Gdyby problem sprowadzał się wyłącznie do braku biblioteki współdzielonej, zostalibyśmy o tym poinformowani w bardziej czytelny sposób, a rozwiązanie byłoby trywialne. Co można zrobić w takiej sytuacji?

  • Przenieść (mv, cp) plik linkera do pierwotnego katalogu
    Jeśli programy mv i cp są łączone dynamicznie, a tak było w tym przypadku, nie można ich użyć do tego celu (nie da się ich uruchomić!)
  • Stworzyć kopię linkera wyłącznie przy użyciu wewnętrznych poleceń powłoki
    Polecenia wewnętrzne, jak nazwa wskazuje, nie uruchamiają zewnętrznych programów. O ile w sytuacji awaryjnej mamy uruchomioną powłokę, można próbować z nich korzystać. Przykładowe zastosowanie poleceń wewnętrznych (read i echo) oraz mechanizmu przekierowań:

    % read tresc < plik
    % echo $tresc > plik2

    Niestety, jądro odmawia załadowania linkera z pliku, który nie ma ustawionych praw wykonania. Z kolei, by je ustawić potrzebny jest program chmod, którego nie możemy uruchomić z powodu braku interpretera. Powyższy przykład ma też dość poważną wadę polegającą na tym, że z dużym prawdopodobieństwem uszkodzi “kopiowany” plik.

Pech. W ogólnym przypadku, z poziomu działającego systemu nie jesteśmy w stanie zaradzić takiej sytuacji – potrzebne jest środowisko ratunkowe (np. płyta CD z możliwym do uruchomienia systemem). Rozwiązanie to, oczywiście, nie jest dobre – w przypadku maszyny pełniącej istotną funkcję nie chcemy powodować dodatkowych przestojów. Co jeszcze nam zostało?

  • Posiadać zestaw podstawowych narzędzi linkowanych statycznie
    Rzeczywiście, jeśli podstawowe narzędzia systemowe skompilowano statycznie, powyższy problem w ogóle się nie pojawi. Jest to doskonałe zabezpieczenie przed problemami związanymi z obsługą ważnych bibliotek współdzielonych.
  • Poinstruować system, gdzie szukać linkera dynamicznego
    Pomysł dobry, ale niewykonalny – taki mechanizm po prostu nie istnieje. Jednak użytkownicy GNU/Linuksa oraz Solarisa mogą skorzystać z dość specyficznej cechy dostępnych w ich systemach linkerów. Pamiętamy, że jest to w gruncie rzeczy normalny program (uruchamiany zazwyczaj przez jądro systemu) z odpowiednim punktem wejściowym. Okazuje się, że w tych systemach istnieje możliwość uruchomienia /lib/ld-linux*.so lub /lib/ld.so.1 z podaniem programu, który ma zostać załadowany i obsłużony przez linker! Zatem mając interesujące nas pliki w katalogu /newroot możemy użyć tej możliwości w następujący sposób:

    % export LD_LIBRARY_PATH=/newroot/lib
    % /newroot/lib/ld-2.3.6.so /newroot/bin/mv /newroot/lib /

    Czyli: ustawiamy niestandardową ścieżkę, pod którą linker ma szukać bibliotek (potrzebna, bo wskazuje miejsce, gdzie znajdują się wymagane przez mv pliki), a następnie uruchamiamy mv, przenosząc potrzebne pliki na ich miejsce! Voila! Przywróciliśmy system do stanu pełnej sprawności!

Do zastanowienia się

Zachęcam do zastanowienia się nad implikacjami faktów przedstawionych w tym wpisie. Pozwolę sobie pomóc, ale przy użyciu metody sokratejskiej:

  • Badając przy pomocy narzędzia ldd najważniejsze programy narzędziowe w systemie – jaka biblioteka pojawia się na liście najczęściej i co z tego wynika?
  • Kod biblioteczny mapowany jest do przestrzeni adresowej procesu z pliku. Czy nadpisanie pliku zawierającego kod biblioteki współdzielonej to dobry pomysł?
  • Zakładając, że powyższe to zły pomysł, to jak zaktualizować bibliotekę współdzieloną jeśli jest ona używana przez działający proces?
  • Rozważmy sytuację, w której program /bin/secret używa funkcji strcmp ze standardowej biblioteki języka C do porównania skrótu wprowadzonego hasła z zakodowanym wzorcem i na podstawie wyniku tej operacji zapewnia dostęp do ściśle tajnych informacji. Stwórz bibliotekę zawierającą funkcję strcmp, która zawsze zwraca wartość wskazującą identyczność argumentów, a następnie spraw, by /bin/secret akceptował każde wprowadzone hasło.
  • Jakie implikacje dla bezpieczeństwa systemu ma powyższy punkt? Czy linker dynamiczny honoruje ustawienia zmiennych LD_PRELOAD i LD_LIBRARY_PATH jeśli wykonywany jest program z ustawionym bitem SUID?

Bibliografia

  • [1] (Daniel P. Bovet, Marco Cesati, O’Reilly, Wydawnictwo RM, Warszawa 2001

Advertisements

One thought on “Uruchamianie programu w Linuksie

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out / Change )

Twitter picture

You are commenting using your Twitter account. Log Out / Change )

Facebook photo

You are commenting using your Facebook account. Log Out / Change )

Google+ photo

You are commenting using your Google+ account. Log Out / Change )

Connecting to %s