Shellshock i opowieść o środowisku

Grafika przedstawiająca czarną kulę w przestrzeni

Na przełomie września i października 2014 świat obiegła informacja o ukrytej przez lata luce bezpieczeństwa w powłoce GNU Bourne Again Shell (skr. Bash). Większość serwisów związanych z bezpieczeństwem IT już zdążyła na ten temat napisać i podać odpowiednie metody ochrony, jednak naszą uwagę przykuł edukacyjny aspekt luki – pod tym względem jest to „dobry błąd”, tzn. taki, którego można użyć do wyjaśnienia wielu ciekawych mechanizmów obecnych w systemach typu Unix.

Luka Shellshock (zwana też Shelldoor lub Bashbleed) to usterka bezpieczeństwa w uniksowej powłoce Bash, która pozwala na lokalne (LCE) i zdalne (RCE) wykonywanie dowolnego kodu z uprawnieniami uruchamiającego ją użytkownika. Jest niebezpieczna, ponieważ pośrednio z powłoki korzysta wiele powszechnie dostępnych usług sieciowych, np. serwery WWW wyposażone w interfejsy CGI, serwery DHCP, a nierzadko serwery poczty elektronicznej bądź aplikacje PHP, które posługują się powłoką, aby we właściwym środowisku uruchomić jakiś program.

Przyczyną zagrożenia wystąpieniem usterki jest błąd niewystarczającego filtrowania danych wejściowych pochodzących ze środowiska odziedziczonego po procesie nadrzędnym. Pozwala on na użycie tzw. eksportowanych funkcji powłokowych w celu przemycenia do uruchamianego procesu powłoki dowolnych komend. Wstrzyknięte w ten sposób polecenia zostaną wykonane przez powłokę tuż po uruchomieniu, w momencie wczytywania otrzymanego zestawu zmiennych środowiskowych.

W efekcie potencjalny napastnik może wpływać na działanie usług i modyfikować należące do nich zasoby. Oznacza to w najlepszym przypadku dyskredytację danego serwisu sieciowego, a w najgorszym przejęcie kontroli nad systemem (jeśli usługa działa z uprawnieniami administratora lub znalezione będą dodatkowe usterki).

Spróbujmy założyć, że nie wiemy wiele o systemach typu Unix, powłoka kojarzy nam się raczej z ubiorem lub emulsją do malowania budynków, a środowisko z protestami ekologów w Dolinie Rospudy. Aby dogłębnie zrozumieć temat, musimy sobie przypomnieć:

  • Czym charakteryzują się systemy typu Unix?
  • Czym jest uniksowa powłoka?
  • Czym różni się proces od programu i czym jest środowisko?
  • Jak Bash obsługuje środowisko i co to są zmienne środowiskowe?
  • Jakie są warunki wystąpienia usterki w Bashu?
  • Jakie usterki znaleziono przy okazji?
  • Jak rozwiązano problemy?

Jeżeli mechanizmy systemu operacyjnego związane z zarządzaniem środowiskiem procesów nie są Ci obce, wiesz co robią funkcje execve()fork(), a także zdajesz sobie sprawę, że środowisko może zawierać nie tylko zmienne, ale dowolne napisy, przejdź do opisów technicznych samych usterek.

Wprowadzenie

Dawno, dawno temu wszyscy użytkownicy systemów komputerowych byli programistami. Były to czasy środowisk, w których „rozmowę” z systemem operacyjnym użytkownik prowadził z użyciem zintegrowanego edytora kodu i debuggera. Uniksy nie zdążyły jeszcze się spopularyzować, a powszechne dziś relacyjne bazy danych, uruchamiane na osobnych komputerach, istniały jedynie w marzeniach specjalistów od sprzedaży tych ostatnich. Dane były kodem, kod danymi, a programista mógł sięgać po ich struktury bez zastanawiania się, gdzie są przechowywane. Czas mainframe’ów.

Bezpieczeństwo teleinformatyczne jako branża praktycznie nie istniało, systemy nie miały efektywnego podziału na użytkowników o różnych poziomach dostępu, a każdy, kto mógł skorzystać z terminalu lub jego emulatora miał nieograniczony dostęp do usług i przetwarzanych danych w obrębie systemu. Wydawać by się mogło, że z perspektywy IT security mieliśmy do czynienia z – mówiąc kolokwialnie – niezłą porażką. Być może, ale i model ochrony zasobów był nieco inny, a gdyby ewoluował wraz z przyjętym kierunkiem po dziś dzień, to usterek bezpieczeństwa byłoby nawet mniej, a z pewnością miałyby inną charakterystykę.

Myśląc o monolitycznych systemach przeszłości warto pamiętać, że były to bardziej odpowiedniki dzisiejszych maszyn wirtualnych, niż wieloużytkownikowych systemów operacyjnych. Oznacza to, że ochrona była możliwa, ale była to ochrona na poziomie dostępu do usługi lub struktur pamięciowych obsługiwanych przez interpreter języka lub metajęzyka stworzonego na potrzeby projektu.

Unix

Pojawienie się Uniksów wstrząsnęło branżą nowych technologii. Nie z powodu szczególnie zaawansowanych rozwiązań technicznych czy wygody obsługi, ale z powodu architektury, która pozwalała adaptować system do własnych potrzeb i tanim kosztem tworzyć klony dostosowane do konkretnych wymagań. Wiązało się to m.in. z uniezależnieniem systemu od sprzętu, jak również z możliwością współpracy jego nabywcy z wieloma dostawcami oprogramowania, a nie jedną instytucją, która produkuje zarówno system, jak i każdą aplikację działającą pod jego kontrolą.

Trudne do szybkiego zgłębienia przez początkujących języki funkcyjne czy interpretery zorientowane na rozwiązywanie konkretnych problemów biznesowych zastąpiły uniwersalne narzędzia tworzone w języku C i skrypty powłokowe. Te pierwsze były kompilowane do postaci kodu maszynowego, zoptymalizowanego pod kątem architektury sprzętowej. Z kolei architektoniczny podział systemu na małe, współpracujące ze sobą części pozwolił na obniżenie wymagań odnośnie kompetencji obsługi technicznej – administrator był operatorem maszyny, a nie specjalistą od wszystkiego, w tym od zarządzania danymi i programowania.

Unix to w istocie zestaw współpracujących ze sobą komponentów, z których każdy wykonuje jakieś specyficzne zadanie. Architekt systemowy może integrować ze sobą wybrane składniki w celu stworzenia środowiska pod konkretne potrzeby. Przekłada się to również na ostateczną cenę, co nie pozostało bez wpływu, jeśli chodzi o popularyzowanie się Uniksów. Warto też zaznaczyć, że pod względem promocji sprzedawcy Uniksów byli bardzo skuteczni. Po pewnym czasie gros absolwentów uczelni technicznych wchodziło na rynek pracy z praktycznymi umiejętnościami programowania głównie w języku C i korzystania z uniksowych komend oraz usług. Twórcom Uniksa – Dennisowi Ritchie’emuKenowi ThompsonowiBell Labs – udało się stworzyć uniwersalny, wieloużytkownikowy i wieloprocesowy, sieciowy system operacyjny z podziałem czasu, na który mogła pozwolić sobie niemal każda placówka.

Do „pogawędek” operatora z systemem użyto specjalnie w tym celu stworzonego oprogramowania. Naturalnie, można by kontynuować programistyczną tradycję i skorzystać na przykład z Lispu – jednego z najznakomitszych języków programowania, popularnego kiedyś, a teraz, za sprawą projektów takich jak Clojure, świętującego drugą młodość. Sprawdziłby się w roli systemowego interfejsu użytkownika, ale pod warunkiem, że ten użytkownik ma już doświadczenie w pracy z nim (jest de facto programistą). Dla kogoś, kto chce po prostu uruchomić czy zatrzymać usługę, skopiować plik bądź podejrzeć pliki raportów zdarzeń (logi), znacznie lepszym narzędziem będzie interpreter skrojony na miarę, którego komend i składni nauczyć można się w kilka godzin.

Powłoka

Każdy system komputerowy, który obsługiwać ma człowiek niebędący programistą, zasługuje na interfejs komunikacyjny, umożliwiający przeglądanie zasobów tego komputera, uruchamianie aplikacji, wydawanie komend i oglądanie rezultatów ich pracy. Wspomniany interfejs może być graficzny (np. apple’owy Finder) lub tekstowy (np. COMMAND.COM z systemu MS-DOS, czy właśnie uniksowy bash). Z całą jednak pewnością będzie przyjmował informacje z użyciem klawiatury (i ew. myszki), a rezultaty wykonywanych czynności prezentował na monitorze komputera (lub zdalnego terminalu). Nazywamy to interaktywną powłoką systemową (ang. interactive shell), tzn. taką, dzięki której użytkownik może w czasie rzeczywistym komunikować się z OS-em.

Powłoka systemowa (ang. shell) to program komputerowy jak każdy inny. Jej funkcją jest stworzenie warunków do wykonywania podstawowych czynności użytkowych, takich jak dostęp do plików i katalogów czy możliwość uruchamiania programów.

Powłoki interaktywne są pierwszoplanowym procesem (uruchomionym programem), który na bieżąco realizuje wydawane komendy i wyświetla ich rezultaty, z kolei powłoki nieinteraktywne (ang. non-interactive shells) robią te same rzeczy, ale bez nadzoru ze strony operatora będącego człowiekiem. Powłoka interaktywna w systemach typu Unix jest uruchamiana na prawach uwierzytelnionego użytkownika przez oprogramowanie umożliwiające lokalne lub zdalne korzystanie z systemu (np. przez program login czy demona sshd).

Nazwę ścieżkową domyślnej powłoki przypisanej do danego użytkownika znajdziemy w pliku haseł /etc/passwd lub innej bazie (np. LDAP czy NSS, jeśli są używane). Najczęściej powłoka, która potrafi działać interaktywnie, posiada też możliwość pracy w trybie nieinteraktywnym.

Współczesne powłoki zawierają rozbudowany interpreter poleceń. Dzięki temu mogą nie tylko przyjmować pojedyncze komendy czy ich sekwencje, ale też interpretować i wykonywać skrypty – proste programy pisane z wykorzystaniem odpowiednich wewnętrznych i zewnętrznych poleceń.

Polecenia wewnętrzne (ang. internal commands) to takie komendy, których działanie realizowane jest bezpośrednio przez podprogramy powłoki w obrębie jej własnego programu, a polecenia zewnętrzne (ang. external commands) to inne programy użytkowe znajdujące się na nośniku, np. znane z Uniksów: ping, grep, awk, sed czy mc (Midnight Commander) – są one wywoływane, a w tym czasie powłoka oczekuje w tle na ich zakończenie i zapamiętuje tzw. kod zakończenia (ang. exit status) każdego.

Pierwszą powłoką w Uniksie (i w jego prekursorze, systemie Multics) był Thompson shell, który powstał w roku 1971. Napisany został przez – jak wskazuje nazwa – współtwórcę tego systemu. Był to prosty interpreter poleceń, pozbawiony możliwości wykonywania skryptów. Innowacją w stosunku do znanych w tamtych czasach OS-ów było potraktowanie powłoki jako samodzielnego programu użytkowego, a nie części jądra. Z punktu widzenia bezpieczeństwa wydaje się to rozsądnym posunięciem, ponieważ każda luka oznaczałaby przejęcie kontroli nad pracą całego systemu.

Wspominając shella Thompsona warto pamiętać o tym, że to właśnie w nim zastosowano po raz pierwszy operatory przekierowania strumienia <> oraz operator potoku | – konwencję tę zastosowano potem w większości znanych powłok.

Program i proces

Nadmieniliśmy wcześniej, że powłoka systemowa jest programem komputerowym. Oznacza to, że gdzieś na nośniku (np. dysku twardym) znajduje się zbiór danych mający początek, określoną długość, pewną nazwę i inne atrybuty (np. słowo trybu dostępu, które mówi o tym, kto może w jakim trybie – np. zapisu, odczytu czy uruchamiania – mieć do tych danych dostęp). Taki zbiór danych nazywamy plikiem (ang. file).

W systemach typu Unix dąży się do tego, aby wszystko było plikiem, a dokładniej, aby z większością obiektów systemowych dało się komunikować używając plików jako reprezentacji ich wejścia i wyjścia. Żeby było to możliwe, niektóre pliki to tzw. pliki specjalne (ang. special files). Ich zawartość nie znajduje się na dysku, lecz generowana jest dynamicznie i zależy od skojarzonego z plikiem specjalnym podprogramu obsługi (np. jakiegoś urządzenia).

Wracając do definicji programu. Będzie nim zbiór instrukcji maszynowych i danych potrzebnych do ich właściwego wykonania umieszczony w pamięci bądź w tzw. pliku wykonywalnym (ang. executable file). Plik wykonywalny to taki plik, który przeznaczono do uruchamiania i zawierający obraz programu. Czym jest obraz programu? To łańcuch instrukcji zrozumiałych przez CPU, ukształtowany zgodnie z zasadami jednego z formatów plików wykonywalnych (ang. executable formats). Popularne w Uniksach formaty to na przykład a.out (skr. od assembler output) i Executable and Linkable Format (skr. ELF).

Dzięki znanemu formatowi pliku wykonywalnego system wie, czego program potrzebuje, aby dobrze pracować. Przykładem mogą tu być np. biblioteki współdzielone, do których aplikacja musi się odwoływać – w odpowiednim, określonym standardem miejscu pliku wykonywalnego, zawarte są odwołania do potrzebnych bibliotek czy nawet do interpretera, który poprawnie załaduje i wykona kod (w przypadku formatu ELF segment PT_INTERP).

Proces to, jak wspomnieliśmy wcześniej, uruchomiony program, a nieco bardziej precyzyjnie pod względem językowym wykonywanie się programu. Jeśli więc procesor wykonuje umieszczony w pamięci operacyjnej kod maszynowy, to możemy mówić o procesie uruchamiania.

W perspektywie systemowej procesem nazwiemy nie tylko akt realizowania zadania przez system, ale także pewną bardziej namacalną jakość: strukturę danych w pamięci zarządzanej przez jądro, z użyciem której OS kontroluje prawidłowy przebieg procesu – m.in. dba o sprawiedliwy przydział czasu, rezerwowanie pamięci, dostęp do zasobów sprzętowych czy do mechanizmów wymiany danych z innymi procesami. Oznacza to, że każde wczytanie programu do pamięci w celu jego uruchomienia wiąże się z utworzeniem odpowiedniej struktury kontrolnej (np. w systemie GNU/Linux task_struct zdefiniowanej w pliku nagłówkowym sched.h), zawierającej np. identyfikatory związane z uprawnieniami (UID, GID, EUID, EGID, SUID, SGID, FSUID, FSGID), informacje o rodzicu i potomkach, dane dotyczące priorytetyzacji itd.

Sposobem na wczytanie programu z dysku i uruchomienie go jest zwrócenie się do jądra systemowego z prośbą o wykonanie takiej operacji. W tym celu uruchomiony program powinien skorzystać z funkcji systemowej (ang. system call) o nazwie execve, której deklaracja w języku C wygląda następująco:

int execve(const char *filename,
                 char *const argv[],
                 char *const envp[]);
int execve(const char *filename, char *const argv[], char *const envp[]);

Widzimy, że przyjmuje ona trzy argumenty: nazwę ścieżkową pliku do wczytania (w postaci wskaźnika do łańcucha znakowego zakończonego znakiem o kodzie 0), argumenty linii komend (w postaci stałego wskaźnika do tablicy łańcuchów znakowych) i środowisko (również stały wskaźnik do tablicy łańcuchów znakowych). Tablice przechowujące argumenty i środowisko powinny być zakończone elementem, który jest wskaźnikiem pustym (NULL), a każdy z łańcuchów znakowych (czyli tablic znakowych) podobnie jak w przypadku pierwszego argumentu powinien być zakończony znakiem o kodzie 0.

Kernel sprawdzi, czy proces ma prawo uruchomić program i spróbuje wczytać go z pliku do wolnego miejsca w pamięci. Jeśli ta operacja się powiedzie, bieżący proces (w rozumieniu struktury utrzymywanej przez jądro systemowe) zostanie skojarzony z nowo załadowanym kodem maszynowym, który zacznie być wykonywany. Oznacza to, że z chwilą pomyślnego wywołania execve() obraz programu realizowanego do tej pory w ramach bieżącego procesu przestaje istnieć, zwalniane są także związane z nim struktury pamięciowe (segment kodu, segment danych zainicjowanych, segment danych niezainicjowanych oraz segmenty stosu i sterty). Dziedziczony jest oczywiście należący do procesu identyfikator (ang. process identifier, skr. PID).

W związku z powyższym potrzebujemy jeszcze mechanizmu, który pozwoliłby uruchomionemu programowi na „powołanie do życia” dodatkowego procesu, aby to ten drugi wczytał program kończąc własną, dotychczasową pracę. W przeciwnym razie w systemie moglibyśmy mieć uruchomioną tylko jedną aplikację w tym samym czasie – w obrębie pojedynczego procesu zmieniałyby się po prostu obrazy programów i dane.

Na szczęście istnieje odpowiednia funkcja systemowa, a nosi ona nazwę fork. Wywołujący ją proces staje się procesem macierzystym (ang. parent process) względem nowo powołanego, współdzieli z nim też kod i większość parametrów. Proces potomny (ang. child process) może rozpoznać, że jest właśnie nim, ponieważ w jego wątku wykonywania fork() zwróci wartość 0. Nowy proces może już bezpiecznie wywołać execve() i wczytać „w siebie” program. Z kolei proces macierzysty może nadzorować wykonywanie się potomka w oczekiwaniu na jego zakończenie – właśnie w ten sposób zachowuje się powłoka, gdy wydamy jakieś polecenie zewnętrzne.

Komunikacja międzyprocesowa

Programy komunikujące się wyłącznie z użytkownikiem i okazjonalnie zwracające się do kernela o dostęp do zasobów są nudnymi programami, a gdyby mogły marzyć, to najprawdopodobniej ich jedyną fantazją byłoby się zawiesić. Najlepiej czują się „rozmawiając” ze sobą. Właśnie dlatego architekci systemowi wpadli kiedyś na pomysł zaimplementowania metod komunikacji międzyprocesowej (ang. inter-process communication, skr. IPC), czyli wymiany informacji między uruchomionymi programami.

Wyróżnić możemy komunikację lokalną (w obrębie tego samego systemu operacyjnego) i zdalną (różne systemy, np. połączone przez sieć). Przykładami tej pierwszej są:

  • sygnały (ang. signals),
  • semafory (ang. semaphores),
  • pamięć dzielona (ang. shared memory),
  • kolejki komunikatów (ang. message queues),
  • łącza komunikacyjne (ang. pipes),
  • łącza nazwane (ang. named pipes),
  • gniazda dziedziny Uniksa (ang. Unix domain sockets),
  • argumenty linii komend (ang. command-line arguments),
  • środowisko (ang. environment),
  • zwykłe plikipliki mapowane w pamięci (ang. memory-mapped files).

Z kolei przykładem zdalnej komunikacji są m.in. gniazda sieciowe (ang. sockets).

Obiekty IPC tworzone są i usuwane przez kernel po wywołaniu specyficznych funkcji systemowych przez oprogramowanie, które ich potrzebuje. Niektóre z nich pozwalają na dwustronną komunikację (gniazda, pamięć dzielona, pliki), a niektóre tylko na przekazanie wiadomości bądź zasygnalizowanie jakiegoś stanu, bez uzyskiwania informacji zwrotnej z użyciem tego samego kanału (sygnały, argumenty, środowisko, komunikaty, pojedyncze łącza komunikacyjne).

Z usterką Shellshock związany jest mechanizm z tej drugiej grupy, a dokładniej środowisko.

Środowisko procesu

Środowisko (ang. environment) to mechanizm komunikacji międzyprocesowej, który pozwala w sposób jednokierunkowy przekazać do wczytywanego programu pewien zbiór informacji w postaci zestawu łańcuchów znakowych. Środowisko może być ustawione przez jeden uruchomiony program podczas ładowania do pamięci innego programu. Gdy ten ostatni zacznie pracę będzie dysponował przekazanym mu środowiskiem.

Środowisko służy zwykle do sterowania zachowaniem programu – oczywiście pod warunkiem, że uruchamiany program zrobi użytek z danych środowiskowych. Można więc zapytać: po co korzystać ze środowiska, jeżeli mamy argumenty linii komend, które również to umożliwiają?

Ważną cechą, która odróżnia komunikację z użyciem środowiska od tej, w której używany argumentów wywoływania, jest dziedziczenie. Realizowane jest ono automatycznie przez system podczas wydzielania procesu potomnego, a także na zasadzie często stosowanej konwencji, kiedy w wywołaniu execve(), czy odwołujących się do niej execle()execvpe(), do wczytywanego programu przekazywana jest kopia bieżącego środowiska. Również często używane do ładowania programów funkcje – jak np. execl(), execlp(), execv()execvp(), system() czy popen() – przekazują zastane środowisko.

Rozmiar przekazywanego środowiska jest ograniczony. W nowszych kernelach Linux zależy od wartości RLIMIT_STACK, którą może ustawić administrator dla pewnych grup procesów i wynosi maksymalnie 14 rozmiaru stosu (aby stos nie był w całości zajęty przez środowisko). System dba też o to, aby mimo limitów rozmiar nie był nigdy mniejszy niż 32 strony pamięciowe (co przy stronie o wielkości 4 KB daje limit na poziomie 128 KB). Z kolei limit pojedynczego łańcucha znakowego określany jest stałą kernela MAX_ARG_STRLEN i wynosi również 32 strony (w jądrze Linux 2.6.23). Maksymalna liczba przekazywanych łańcuchów to z kolei 0x7FFFFFFF (dziesiętnie 2 147 483 647) – wynika to z zakresu typu int32_t (32-bitowa wartość całkowita ze znakiem).

Na poziomie systemowym jedynym wymogiem jest, aby środowisko było tablicą łańcuchów znakowych (czyli tablicą znakowych tablic) zakończoną wskaźnikiem pustym (NULL), a każdy z umieszczonych w niej wskaźników odwoływał się do tablicy znakowej zakończonej znakiem o kodzie 0. Nie muszą być to nawet zgodne z konwencją pary nazwa–wartość, lecz dowolne dane, na przykład:

Zupełnie poprawne
środowisko przekazywane
do uruchamianego programu
A=1
B=2
Zupełnie poprawne środowisko przekazywane do uruchamianego programu A=1 B=2

Podczas ładowania nowego programu z użyciem execve() uruchamianej aplikacji można przekazać środowisko, czyli wspomnianą tablicę. W systemie GNU/Linux będzie po skopiowaniu przechowywane w strukturze mm_struct (pola env_startenv_end), do której pole wskaźnikowe nazwane mm znajdziemy we wspomnianej wcześniej strukturze kontrolnej task_struct określającej wątek zadania wykonywanego przez system.

Spróbujmy stworzyć prosty program, który utworzy odpowiednie środowisko i przekaże je do wywołanej powłoki /bin/bash. Powłoce podamy odpowiednią opcję, dzięki której wyświetli włsne środowisko i zakończy pracę.

Stwórzmy plik podaj-srodowisko.c o następującej zawartości:

#include <unistd.h>

int main(int argc, char *argv[])
{
  /* tablica środowiska */
  char *const srodowisko[] = {
    "calkiem poprawne",
    "srodowisko przekazywane",
    "do uruchamianego programu",
    "A=1",
    "B=2",
    (char *) NULL
  };

  /* argumenty wywołania */
  char *const argumenty[] = {
    "sh",
    "-c",
    "tr '\\0' '\\n' < /proc/$$/environ",
    (char *) NULL
  };

  return execve( "/bin/bash", argumenty, srodowisko );
}
#include &lt;unistd.h&gt; int main(int argc, char *argv[]) { /* tablica środowiska */ char *const srodowisko[] = { &#34;calkiem poprawne&#34;, &#34;srodowisko przekazywane&#34;, &#34;do uruchamianego programu&#34;, &#34;A=1&#34;, &#34;B=2&#34;, (char *) NULL }; /* argumenty wywołania */ char *const argumenty[] = { &#34;sh&#34;, &#34;-c&#34;, &#34;tr &#39;\\0&#39; &#39;\\n&#39; &lt; /proc/$$/environ&#34;, (char *) NULL }; return execve( &#34;/bin/bash&#34;, argumenty, srodowisko ); }

Skompilujmy go używając GCC, a następnie uruchommy:

gcc -Wall ./podaj-srodowisko.c -o ./podaj-srodowisko
./podaj-srodowisko
gcc -Wall ./podaj-srodowisko.c -o ./podaj-srodowisko ./podaj-srodowisko

Naszym oczom powinna ukazać się lista przyporządkowań podobna do poniższej:

calkiem poprawne
srodowisko przekazywane
do uruchamianego programu
A=1
B=2

Co się stało? Wczytaliśmy powłokę, przekazując jej odpowiednio stworzone środowisko, zawierające dwie pary nazwa–wartość oraz kilka napisów w języku polskim. Powłoce przekazaliśmy też opcję (-c) w postaci argumentu, który instruuje ją, że nie ma oczekiwać na interaktywnie wprowadzane polecenia, lecz wykonać podaną sekwencję komend (c od command), którą można czytelniej przedstawić jako:

tr '\0' '\n' < /proc/$$/environ
tr &#39;\0&#39; &#39;\n&#39; &lt; /proc/$$/environ

Osoby zaznajomione z poleceniem wewnętrznym set powłoki i programami typu env oraz printenv mogą zapytać dlaczego nie skorzystaliśmy właśnie z nich. W przypadku dwóch ostatnich powodem jest fakt, że wywołane byłyby w procesie potomnym, ponieważ są zewnętrznymi komendami, zaś jeśli chodzi o set, to wyświetlone zostałyby wszystkie zmienne (i funkcje) powłokowe, a nie środowisko, nawet jeśli niektóre z tych zmiennych pochodziłyby ze środowiska bądź były przeznaczone do tego, aby się tam znaleźć. W każdym z przypadków środowisko bieżącego procesu powłoki nie byłoby dobrze odwzorowane.

Użycie pseudosystemu plikowego procfs pozwala nam na dostęp do pseudopliku /proc/[PID]/environ, którego zawartość odzwierciedla środowisko procesu dla podanego jako [PID] identyfikatora procesu (ang. process identifier, skr. PID). Aby poznać własny identyfikator procesu powłoki, korzystamy ze zmiennej specjalnej $$.

Uzyskane dane są w „surowej”, nieprzetworzonej formie i konieczna jest zamiana wszystkich znaków o kodzie zero na znaki nowej linii. Tu z pomocą przychodzi polecenie zewnętrzne tr, którym można łatwo tego dokonać.

W rezultacie na ekranie pojawi się dokładnie takie środowisko, jakiego byśmy oczekiwali, zawierające nawet umieszczone przez nas napisy. Czy Bash korzysta z nich, czy też nie, to już inna sprawa. Można to sprawdzić nieco modyfikując nasz program:

#include <unistd.h>

int main(int argc, char *argv[])
{
  /* tablica środowiska */
  char *const srodowisko[] = {
    "calkiem poprawne",
    "srodowisko przekazywane",
    "do uruchamianego programu",
    "A=1",
    "B=2",
    (char *) NULL
  };

  /* argumenty wywołania */
  char *const argumenty[] = {
    "sh",
    "-c",
    "exec printenv",
    (char *) NULL
  };

  return execve( "/bin/bash", argumenty, srodowisko );
}
#include &lt;unistd.h&gt; int main(int argc, char *argv[]) { /* tablica środowiska */ char *const srodowisko[] = { &#34;calkiem poprawne&#34;, &#34;srodowisko przekazywane&#34;, &#34;do uruchamianego programu&#34;, &#34;A=1&#34;, &#34;B=2&#34;, (char *) NULL }; /* argumenty wywołania */ char *const argumenty[] = { &#34;sh&#34;, &#34;-c&#34;, &#34;exec printenv&#34;, (char *) NULL }; return execve( &#34;/bin/bash&#34;, argumenty, srodowisko ); }

W rezultacie otrzymamy:

A=1
B=2
PWD=/home/randomseed
SHLVL=0

Bystre oko wprawnego czytelnika zauważy, że wśród wyświetlonych informacji brakuje niektórych przekazanych łańcuchów. Hańba! Skandal! Kto za to odpowiada?

W poprzednim przykładzie wykluczyliśmy usuwanie naszych fraz ze środowiska przez sam proces powłoki, pozostaje zatem możliwość ignorowania ich przez Basha. Zauważmy, że wywołanie /bin/bash -c exec printenv zastępuje proces powłoki wczytywanym programem (odpowiada za to wewnętrzne polecenie exec). Wyświetlona zawartość nie jest więc odzwierciedleniem środowiska oryginalnie uruchomionego programu powłoki, ale zastępującego go printenv.

Skąd więc znajdują się tam wpisy zarówno pochodzące z pierwotnie utworzonego środowiska (A=1, B=2), jak i nowe, nieznanego pochodzenia (PWD=/home/randomseed, SHLVL=0)? Okazuje się, że powłoka skopiowała własne środowisko i dodając do niego pewne dane, użyła tak skonstruowanego zbioru do zainicjowania środowiska dla ładowanego programu printenv.

Wniosek: powłoka ignoruje wpisy w środowisku, jeśli nie potrafi ich przetworzyć (są dla niej niezrozumiałe), nie są one również kopiowane do środowisk procesów uruchamianych przez powłokę, jednak nie usuwa ich z własnej pamięci środowiska.

Warto zapamiętać, że korzystając z Basha lub podobnej powłoki uniksowej nie operujemy bezpośrednio na środowisku procesu, nawet jeśli w podręcznikach obsługi powtarzają się słowa dotyczące dodawania lub usuwania ze środowiska pewnych danych. Używając wewnętrznych poleceń, takich jak set (typeset), unset czy export, wpływamy na struktury danych powłoki, lecz nie na obszar pamięci faktycznie związany ze środowiskiem. Jest on zmieniany i odpowiednio ustawiany leniwie, tzn. w momencie, gdy jest to konieczne – na przykład, kiedy ładowany jest inny program (w bieżącym procesie lub nowo powstałym procesie potomnym).

Powyższe można zaobserwować kompilując i uruchamiając następujący kod:

#include <unistd.h>

int main(int argc, char *argv[])
{
  /* tablica środowiska */
  char *const srodowisko[] = {
    "A=1",
    "B=2",
    (char *) NULL
  };

  /* argumenty wywołania */
  char *const argumenty[] = {
    "sh",
    "-c",
    "export E='NOWA' ; pi=$$ ; "
    "echo 'Proces Basha:'  ; "
    "cat /proc/$pi/environ  | tr '\\0' '\\n' ; "
    "echo ; "
    "echo 'Proces potomny:' ; "
    "cat /proc/self/environ | tr '\\0' '\\n' ; "
    "echo ; "
    "echo 'Ten sam proces, ale inny program:' ; "
    "exec printenv",
    (char *) NULL
  };

  return execve( "/bin/bash", argumenty, srodowisko );
}
#include &lt;unistd.h&gt; int main(int argc, char *argv[]) { /* tablica środowiska */ char *const srodowisko[] = { &#34;A=1&#34;, &#34;B=2&#34;, (char *) NULL }; /* argumenty wywołania */ char *const argumenty[] = { &#34;sh&#34;, &#34;-c&#34;, &#34;export E=&#39;NOWA&#39; ; pi=$$ ; &#34; &#34;echo &#39;Proces Basha:&#39; ; &#34; &#34;cat /proc/$pi/environ | tr &#39;\\0&#39; &#39;\\n&#39; ; &#34; &#34;echo ; &#34; &#34;echo &#39;Proces potomny:&#39; ; &#34; &#34;cat /proc/self/environ | tr &#39;\\0&#39; &#39;\\n&#39; ; &#34; &#34;echo ; &#34; &#34;echo &#39;Ten sam proces, ale inny program:&#39; ; &#34; &#34;exec printenv&#34;, (char *) NULL }; return execve( &#34;/bin/bash&#34;, argumenty, srodowisko ); }

Wiemy już jak środowisko jest przekazywane, warto więc nadmienić o sposobach dostępu do własnego środowiska przez uruchomiony program. Pierwszy sposób wykorzystuje argumenty funkcji głównej:

#include <stdio.h>

int main(int argc, char *argv[], char *envp[])
{
  char **v;

  for(v = envp; *v != NULL; ++v)
    printf("%s\n",*v);

  return(0);
}
#include &lt;stdio.h&gt; int main(int argc, char *argv[], char *envp[]) { char **v; for(v = envp; *v != NULL; ++v) printf(&#34;%s\n&#34;,*v); return(0); }

Drugi zmienną zewnętrzną environ, którą definiuje standardowa biblioteka języka C:

#include <stdio.h>

int main()
{
  extern char **environ;
  char **v;

  for(v = environ; *v != NULL; ++v)
    printf("%s\n",*v);

  return(0);
}
#include &lt;stdio.h&gt; int main() { extern char **environ; char **v; for(v = environ; *v != NULL; ++v) printf(&#34;%s\n&#34;,*v); return(0); }

Zmienne środowiskowe

Zmienna środowiskowa (ang. environment variable) to zmienna (ang. variable), czyli konstrukcja posiadająca symboliczną nazwę oraz wartość, której miejscem przechowywania jest środowisko uruchomionego programu. We wcześniejszych przykładach staraliśmy się unikać tego pojęcia, aby podkreślić, że dla systemu operacyjnego nie jest istotne, czy w środowisku będą przechowywane zmienne czy inne konstrukty.

Bardziej konserwatywnie do środowiska podchodzą programiści powłok, wielu aplikacji czy nawet standardowej biblioteki języka C. Dla nich środowisko to repozytorium par nazwa–wartość, np.:

A=1
B=2
C=TEKST
D=
A=1 B=2 C=TEKST D=

Zauważmy, że nie mamy tu zdefiniowanych typów danych dla podanych wartości (wszystkie to pierwotnie łańcuchy znakowe). Typizowanie jest więc kwestią konwencji lub detekcji i zależy od otrzymującego informacje procesu.

Wspomniana już standardowa biblioteka języka C definiuje odpowiednie funkcje, ułatwiające zarządzanie zmiennymi środowiskowymi: getenv(), setenv(), unsetenv(), putenv()clearenv(). Wszystkie poza ostatnią służą do zarządzania wyłącznie zmiennymi, które wykrywane są w ten sposób, że muszą zawierać znak =. Jeśli w środowisku umieszczony byłby łańcuch znakowy inny niż określający zmienną, to nie będzie możliwe jego wybiórcze usunięcie czy nadpisanie (zastąpienie zmienną o takiej nazwie) z użyciem tych właśnie funkcji.

Środowisko w Bashu

W przypadku Basha mamy do czynienia z ciekawą sytuacją – zaimplementowano tam własne wersje funkcji odpowiedzialnych za obsługę zmiennych środowiskowych. Zmiany polegają głównie na tym, że zapisy i odczyty zmiennych są buforowane, tzn. nie operują na środowisku, lecz na wewnętrznych strukturach. Z nich dopiero na bieżąco konstruowane są środowiska przekazywane procesom potomnym lub procesowi zastępującemu powłokę (polecenie wewnętrzne exec).

Gdy przyjrzymy się, w jaki sposób odbywa się rozruch systemów typu Unix – pamiętając, że jedynym sposobem wytworzenia nowego procesu jest rozwidlenie wykonywania bieżącego z użyciem funkcji fork() – dostrzeżemy zaletę wynikającą z dziedziczności środowiska. Skrypty startowe systemu mogą ustawiać pewne zmienne środowiskowe, przydatne dla wszystkich działających programów, które zostaną potem rozpropagowane i trafią do uruchamianych skryptów konkretnych usług, a te z kolei mogą dołączać do środowisk zmienne specyficzne dla nich. Podobnie rzecz się ma z ustawieniami środowiskowymi aplikacji uruchamianych przez użytkownika – dziedziczą środowiska po procesach powłoki, które z kolei przyjmują je od usług zapewniających dostęp do systemu (np. login czy sshd) i tak dalej.

Niektóre aplikacje umożliwiają zarządzanie zmiennymi z ich środowiska bezpośrednio przez użytkownika (z użyciem interaktywnego interfejsu, komunikacji międzyprocesowej lub plików konfiguracyjnych). Właściwość ta przydaje się wtedy, gdy trzeba ustawić odpowiednie zmienne, mające wpływ na pracę programów, lecz nie można tego zrobić z poziomu powłoki (będącej procesem rodzicielskim), bo np. program nie jest uruchamiany przez powłokę lub startuje ona zbyt późno (a to jej środowisko chcemy przygotować). Przykładem mogą być tu tabele demona wykonywania cyklicznych zadań Cron, ale też sytuacje, w których chcemy wpłynąć na środowisko procesu (powłoki lub innego programu) w momencie nawiązania zdalnego połączenia SSH (wysyłając środowisko przez sieć).

Zmienne i funkcje

Interpreter powłoki Bash pozwala korzystać ze zmiennych i funkcji. Te pierwsze służą do przechowywania danych, a drugie do tworzenia podprogramów i operowania na tych pierwszych. Zmienna w Bashu może mieć ustalony zasięg widoczności (ang. scope), który określa gdzie w treści skryptu może być ona wykorzystana (widoczna). Domyślnie zmienne są globalne (ang. global), to znaczy zdefiniowane będą widoczne w całym skrypcie realizowanym przez bieżący proces. Można to zachowanie zmienić z użyciem modyfikatora zasięgu local umieszczanego przed nazwą zmiennej w trakcie jej pierwszego użycia. Taka zmienna będzie widoczna tylko w obrębie funkcji, w której się pojawiła.

Częstym błędem początkujących jest zakładanie, że zmienne globalne będą współdzielone z procesami potomnymi powłoki. Chodzi tu konkretnie o polecenia, które z racji przyjmowania wyjścia z innych poleceń zmuszają Basha do tworzenia dodatkowego wątku wykonywania. Przykładem może być tu komenda read, która wczytuje dane pochodzące ze standardowego wyjścia polecenia echo:

1
2
3
4
5
#!/bin/bash

l="pusta"
echo "123" | read l
echo "${l}"
#!/bin/bash l=&#34;pusta&#34; echo &#34;123&#34; | read l echo &#34;${l}&#34;

Po uruchomieniu, zamiast spodziewanego 123, wartością zmiennej l będzie napis pusta. Dlaczego? Na potrzeby obsługi przekierowanego wyjścia z echo (z użyciem operatora potoku, który tworzy łącze komunikacyjne) powłoka utworzyła proces potomny i to w nim wykonywane było polecenie wewnętrzne read nadające wartość l. Wszystkie dane tego dodatkowego procesu przestały istnieć, gdy zakończył on pracę, włączając w to zmienną l, która ma co prawda globalny zasięg, ale w obrębie danego procesu, do którego danych proces macierzysty nie ma bezpośredniego dostępu. Rozwiązaniem podobnych problemów może być rezygnacja z przekierowania i użycie zmiennej przechowującej wynik wcześniej wykonanego polecenia:

1
2
3
4
5
#!/bin/bash

l="pusta"
l=$(echo -e "123\n456")
echo "${l}"
#!/bin/bash l=&#34;pusta&#34; l=$(echo -e &#34;123\n456&#34;) echo &#34;${l}&#34;

Inny sposób to przekierowanie standardowego wyjścia podpowłoki (ang. subshell), czyli procesu potomnego realizującego osobne polecenie, do standardowego wejścia komendy read. W tym przypadku również powstanie proces potomny, lecz nie będzie on próbował wpływać na zawartość zmiennej l – będzie to zadaniem procesu macierzystego:

1
2
3
4
5
#!/bin/bash

l="pusta"
read l < <(echo "123")
echo "${l}"
#!/bin/bash l=&#34;pusta&#34; read l &lt; &lt;(echo &#34;123&#34;) echo &#34;${l}&#34;

Więcej przykładów radzenia sobie z subshellami można znaleźć na stronach Bash Hackers Wiki.

Zmienne eksportowane

Istnieje jeszcze jeden rodzaj zmiennych w Bashu, jeśli chodzi o zasięg – zmienne środowiskowe powłoki, które można też nazywać zmiennymi eksportowanymi (ang. exported variables). Są to zmienne o zasięgu globalnym, które zostaną umieszczone w środowisku każdego procesu potomnego powłoki i każdego programu zastępującego powłokę (w przypadku użycia wbudowanego polecenia exec).

Termin „eksportowane” nie jest zbyt popularny, lecz dobrze określa różnicę między rzeczywistymi zmiennymi środowiskowymi (przechowywanymi w środowisku danego procesu), a zmiennymi globalnymi powłoki, które są tylko oznaczone jako te, które powinny się tam znaleźć.

Do oznaczenia zmiennej jako przeznaczonej do umieszczenia w środowisku używa się modyfikatora export, który może być zastosowany w dowolnym momencie, np.:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
export C
A=1
C=3
export B=2
export A
echo "------------------------------"
echo "Rzeczywiste srodowisko biezace:"
tr '\0' '\n' < /proc/$$/environ
echo
echo "----------------------------"
echo "Srodowisko procesu potomnego:"
printenv
export C A=1 C=3 export B=2 export A echo &#34;------------------------------&#34; echo &#34;Rzeczywiste srodowisko biezace:&#34; tr &#39;\0&#39; &#39;\n&#39; &lt; /proc/$$/environ echo echo &#34;----------------------------&#34; echo &#34;Srodowisko procesu potomnego:&#34; printenv

Funkcje środowiskowe

Funkcja środowiskowa (ang. environmental function) to pomysł programistów Basha na propagowanie podprogramów wśród potomnych procesów powłokowych. Przypomina ona znaną z dowcipów świnkę morską, która w rzeczywistości nie jest ani świnką, ani tym bardziej morską. Spójrzmy:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
#!/bin/bash

# definiujemy funkcję
funkcja () {
  echo "jestem funkcja" ; a=2;
}

# eksportujemy funkcję
export -f funkcja

# sprawdzamy czy w środowisku
# procesu potomnego
# istnieje funkcja
printenv | grep -A2 funkcja
#!/bin/bash # definiujemy funkcję funkcja () { echo &#34;jestem funkcja&#34; ; a=2; } # eksportujemy funkcję export -f funkcja # sprawdzamy czy w środowisku # procesu potomnego # istnieje funkcja printenv | grep -A2 funkcja

Po wykonaniu skryptu otrzymamy rezultat podobny do poniższego:

funkcja=() {  echo "jestem funkcja";
a=2
}
funkcja=() { echo &#34;jestem funkcja&#34;; a=2 }

Co zrobiliśmy? Stworzyliśmy funkcję Basha, a następnie oznaczyliśmy ją jako eksportowalną z użyciem export -f. W środowisku procesu potomnego (printenv) pojawiła się jako zmienna środowiskowa o nazwie funkcja i zawartości dokładnie takiej, jak ciało naszej funkcji.

Warto zauważyć, że interpreter nie miał kłopotów z przeniesieniem do środowiska znaku =; jego wewnętrzny parser rozpoznaje wartości zmiennych środowiskowych jako łańcuchy umieszczone między pierwszym wystąpieniem = a znakiem o kodzie 0, oznaczającym koniec tekstu.

Tego typu zmienne środowiskowe (przenoszące funkcje Basha) propagowane są do wszystkich procesów potomnych, jednak tylko Bash (ew. inne powłoki) są w stanie ich potem użyć. Przypominają się dawne czasy, ale też obecne języki programowania, w których dzięki instrukcji eval dane mogą stawać się kodem.

Automatyczne definiowanie funkcji

Obecne w środowisku zmienne, które w rzeczywistości zawierają funkcje Basha, są rozpoznawalne przez parser po umieszczonej na początku wartości parze okrągłych nawiasów i otwierającym nawiasie klamrowym. Jeśli powłoka napotka taki wzorzec, to łańcuch znaków ze środowiska będzie przekazany do interpretera w celu zdefiniowania funkcji w bieżącym procesie. Na przykład:

1
2
3
4
#!/bin/bash

export funkcja="() {  echo 'jestem funkcja'; }"
bash -c funkcja
#!/bin/bash export funkcja=&#34;() { echo &#39;jestem funkcja&#39;; }&#34; bash -c funkcja

Uwaga: ten przykład nie zadziała w nowych, poprawionych wydaniach Basha.

W powyższym przykładzie wywołaliśmy w procesie potomnym funkcję funkcja(), która nawet nie istniała w procesie macierzystym, lecz została w nim zapisana jako zmienna przeznaczona do umieszczenia w środowisku. Wywoływana raz jeszcze powłoka Bash (bash -c funkcja) sprawdziła otrzymane środowisko i wykryła, że ma do czynienia z eksportowaną funkcją. Została więc ta ostatnia zdefiniowana pod nazwą funkcja, a następnie wywołana (z powodu przekazanej jako argument opcji -c funkcja).

Niedoskonałości

Parser środowiska obecny w Bashu nie jest tak doskonały, jak mogłoby się wydawać. Okazuje się, że gdy trafi na odpowiednio skonstruowaną zmienną środowiskową, zawierającą w domyśle funkcję do zdefiniowania, to może też wykonywać dodatkowe komendy. Przypatrzmy się następującemu przykładowi:

1
2
3
4
#!/bin/bash

export funkcja="() { echo 'jestem funkcja'; } ; echo 'jestem poza'"
bash -c funkcja
#!/bin/bash export funkcja=&#34;() { echo &#39;jestem funkcja&#39;; } ; echo &#39;jestem poza&#39;&#34; bash -c funkcja

Przykład jest podobny do poprzedniego, lecz tym razem oprócz ciała funkcji ujętego w klamry umieściliśmy po separatorze komend (średniku) polecenie echo. W efekcie na ekranie ukazały się dwa napisy:

jestem poza
jestem funkcja

Uwaga: ten przykład nie zadziała w nowych, poprawionych wydaniach Basha.

Okazuje się, że proces interpretowania zmiennej środowiskowej jako kodu definiującego funkcję nie zatrzymuje się na zamykającym nawiasie klamrowym, lecz trwa do osiągnięcia końca łańcucha z wartością. Dlatego w momencie startu powłoka wyświetliła napis jestem poza (komenda echo została wydana w momencie odczytu środowiska), a dopiero potem pojawił się rezultat wywołania funkcji funkcja().

Programiści Basha szybko poprawili powyższy błąd, eliminując możliwość dodawania poleceń dopisanych za definicją funkcji. Okazuje się jednak, że nie chroni to przed wszystkimi zagrożeniami…

Luka Shellshock

W dniu 24 września 2014 roku świat IT obiegła sensacyjna informacja: w popularnej powłoce Bash od blisko 15 lat istniała luka (CVE–2014–6271), dzięki której można było lokalnie, a w pewnych warunkach również zdalnie wykonywać dowolne polecenia z uprawnieniami usługi. Jedynym warunkiem jest możliwość kontrolowania środowiska procesu powłoki. Jej odkrywcą okazał się Stéphane Chazelas – kierownik działu IT z brytyjskiej spółki SeeByte Ltd, który pasjonuje się otwartym oprogramowaniem, w szczególności takim, które działa na systemach Unix i GNU/Linux.

Usterka polegała na możliwości przemycania dowolnych komend przez doklejenie ich do zmiennych środowiskowych zawierających definicje funkcji powłokowych. Dokładnie ten mechanizm pokazaliśmy w ostatnim przykładzie, chociaż w Sieci można znaleźć skróconą wersję kodu obrazującego błąd w postaci:

env x='() { :;}; echo PODATNA' bash -c exit
env x=&#39;() { :;}; echo PODATNA&#39; bash -c exit

Dlaczego administratorzy zareagowali paniką, a przygotowujący pakiety oprogramowania dystrybutorzy GNU/Linuksa oraz innych Uniksów masowo zaczęli odwiedzać specjalistów ds. zdrowia psychicznego i gabinety masażu? Przecież usterkę można wykorzystać tylko, gdy już kontroluje się środowisko powłoki, a więc – zgodnie z wcześniejszymi przykładami – użytkownik musiałby sabotować sam siebie!

Niestety okazuje się, że przez lata twórcy różnych narzędzi i usług sieciowych zaufali zmiennym środowiskowym jako mechanizmowi dobrze poznanemu i takiemu, który wydaje się zbyt prosty, aby można w nim było znaleźć jakieś poważniejsze słabe punkty. Samo środowisko (z systemowego punktu widzenia) z pewnością takie jest, problem powstaje wtedy, gdy wszędobylski Bash czyni z niego niewłaściwy użytek i zamienia dane w instrukcje.

Dlaczego wszędobylski? Wiele funkcji bibliotecznych i podprogramów skryptowych najprzeróżniejszych interpreterów, gdy chce uruchomić jakiś proces, to nie ładuje go bezpośrednio, lecz jako komendę powłoki przeznaczoną do wykonania. Dla przykładu skrypt webowy może używać wywołanego przez powłokę polecenia convert do zmiany rozmiarów obrazków:

/bin/sh -c "convert %f -resize 1024x1024 "
/bin/sh -c &#34;convert %f -resize 1024x1024 &#34;

Dzięki takiemu podejściu programista nie musi tworzyć osobnych mechanizmów ustawiania zmiennych przeszukiwania (tj. PATH czy LD_LIBRARY_PATH), plików konfiguracyjnych, które pozwolą uruchomić odpowiednie programy, dbające o przygotowanie środowiska pracy itd. Wystarczy scedować te nudne i żmudne czynności na powłokę wraz z jej globalnymi i należącymi do użytkownika plikami konfiguracyjnymi.

Przykłady, gdy środowisko uruchamianego programu jest kontrolowane przez niezaufanego klienta, lub gdy jest on zaufany, ale może niebezpiecznie zwiększyć własne uprawnienia do wykonywania pewnych operacji obejmują:

  • niektóre serwery poczty elektronicznej, w szczególności czyniące użytek z powłoki podczas doręczania wiadomości do zewnętrznych filtrów (np. Procmaila) czy bazujące na krótkich, elastycznych skryptach (qmail);

  • serwer WWW Apache z włączonym mechanizmem CGI (moduł mod_cgi lub mod_cgid), jeśli uruchamiane skrypty stworzone są w Bashu lub wywołują go, np. z użyciem wspomnianych wcześniej funkcji system() czy popen();

  • serwer OpenSSH, gdy włączona jest opcja ForceCommand, której funkcją jest ograniczanie możliwości wykonywanych poleceń, a także gdy użytkowane są klucze publiczne z klauzulą command (wstrzyknięcie komend omija ograniczenie);

  • klienty DHCP, gdy wykonując skrypty przekazują do nich parametry używając zmiennych środowiskowych stworzonych na podstawie danych otrzymanych od serwerów – tu w wielu przypadkach możliwe jest wykonanie szkodliwego kodu z uprawnieniami administratora;

  • pliki wykonywalne z włączonym ustawianiem identyfikatora użytkownika podczas wykonania (ang. set user ID upon execution, skr. setuid) lub z ustawianiem identyfikatora grupy podczas wykonania (ang. set group ID upon execution, skr. setgid) gdy dziedziczą środowisko – możliwe jest wtedy rozszerzanie uprawnień i wykonywanie poleceń jako inny użytkownik czy nawet administrator, jeśli aplikacja uruchamia powłokę, np. z użyciem system() lub popen().

Przykład podatnego serwera WWW

Spójrzmy w jaki sposób zdalny napastnik może wykorzystać usterkę Shellshock atakując podatny na zagrożenie serwer WWW. Warunkiem koniecznym jest tu przekazywanie nagłówków protokołu HTTP pochodzących od klienta do serwera aplikacyjnego w postaci zmiennych środowiskowych. Serwerem aplikacyjnym może być na przykład skrypt CGI uruchamiany za każdym razem, gdy konieczna jest obsługa żądania.

Załóżmy, że klient ustawił np. nagłówek Cookie w taki sposób:

Cookie:() { :; }; ping -c 3 randomseed.pl

Po nawiązaniu połączenia HTTP trafi on do serwera WWW, który chcąc przekazać nagłówek aplikacji CGI doda zmienną środowiskową:

HTTP_COOKIE=() { :; }; ping -c 3 ransomseed.pl

Wywołany interpreter (np. PHP) przyjmie środowisko i chcąc przeskalować obrazek wykona:

system("convert -size 300x300 img.jpg -resize 200x200 img_s.jpg");
system(&#34;convert -size 300x300 img.jpg -resize 200x200 img_s.jpg&#34;);

Użyta tu funkcja PHP o nazwie system jest odpowiednikiem tej samej funkcji ze standardowej biblioteki języka C. Ta ostatnia z kolei nie uruchomi programu convert bezpośrednio, lecz wywoła powłokę przekazując jej polecenie i argumenty. Powłoka odziedziczy środowisko i jeśli jest to Bash, to zacznie importować funkcje, których definicje znajdzie w wartościach zmiennych.

W przypadku podatnej na lukę wersji interpretera z uprawnieniami serwera aplikacyjnego wykonane zostanie polecenie ping -c 3 randomseed.pl, które wyśle trzy żądania echo-request i w ten sposób poinformuje drugą stronę o tym, że serwer korzysta z zagrożonego wydania Basha.

Pierwsza łata

Programiści Basha szybko wydali odpowiednią łatkę (ang. patch), określoną identyfikatorem bash43-025, która eliminowała usterkę.

Poprawka polegała na wprowadzeniu dwóch trybów przetwarzania do funkcji parse_and_execute() – identyfikowanych przez SEVAL_FUNCDEFSEVAL_ONECMD. W przypadku wywołania w tym pierwszym trybie, parser pomija komendy inne niż definicje funkcji. Z kolei drugi tryb zapobiega uruchamianiu więcej niż jednej znalezionej komendy. W efekcie przedstawiony wcześniej exploit już nie zadziała, ponieważ nie będą zinterpretowane polecenia wprowadzone za ciałem funkcji.

Usterka bufora

Ulga użytkowników i administratorów nie trwała długo. Tavis Ormandy, współpracujący z koncernem Google specjalista zajmujący się usterkami w oprogramowaniu, znalazł podobną lukę. W bazie usterek musiano więc zarejestrować kolejną podatność na zagrożenie – otrzymała ona identyfikator CVE–2014–7169.

Szkodliwy kod powłokowy demonstrujący błąd wyglądał następująco:

env X='() { (a)=>\' sh -c "echo date"; cat echo
env X=&#39;() { (a)=&gt;\&#39; sh -c &#34;echo date&#34;; cat echo

Efekt wywołania tego polecenia na podatnym interpreterze spowoduje utworzenie pliku o nazwie echo, zawierającego rezultat wykonania komendy date, a następnie wyświetlenie jego zawartości na ekranie. Użyta metoda wprowadzenia Basha w błąd też wykorzystuje słabość parsera przy wczytywaniu zawartości środowiska, lecz metoda jest zgoła inna. Mamy w niej do czynienia z celowym zaburzeniem pracy bufora służącego do przechowywania wykonywanych poleceń.

Aby wyjaśnić, co dokładnie się dzieje, posłużymy się bardziej przystępnym przykładem:

1
2
3
4
#!/bin/bash

export X='() { function a a>\'
bash -c 'echo date'
#!/bin/bash export X=&#39;() { function a a&gt;\&#39; bash -c &#39;echo date&#39;

Wiemy, że podczas uruchamiania nowego procesu powłoki (bash) analizowane są zmienne środowiskowe. Zawartość jednej z nich z powodu początkowych nawiasów okrągłych i otwierającej klamry traktowana jest jak początek definicji eksportowanej funkcji powłokowej. W tym momencie warto zauważyć, że Bash nie wykonuje poleceń niezwłocznie, lecz trafiają one wcześniej do specjalnego bufora. Jest on pomocny, ponieważ niektóre elementy składniowe wymagają analizy konstrukcji sąsiadujących z nimi. Jaskrawym przykładem jest zapis:

1
2
3
4
5
#!/bin/bash

echo raz \
dwa \
trzy
#!/bin/bash echo raz \ dwa \ trzy

Parser nie realizuje komendy echo, lecz umieszcza ją w buforze, ponieważ odwrócony ukośnik na końcu linii informuje, że wyrażenie będzie miało dalszy ciąg.

W naszym przykładzie, podczas analizy środowiska, bufor przedstawia się następująco:

function a a>\

Dla interpretera zrozumiała jest tylko część function a a (definicja funkcji zagnieżdżonej) i ona jest od razu przetworzona. W buforze zostaje więc:

>\

Celem pozostawienia sekwencji >\ jest próba wpłynięcia na sposób, w jaki potraktowane będzie kolejne polecenie (echo date), które też trafi do bufora. Teraz, „zanieczyszczony” poprzednią operacją, będzie wyglądał tak:

>\
echo date

Zauważmy, że po odwróconym ukośniku mamy znak nowej linii, która zgodnie z syntaktyką Basha oddziela kolejne polecenia. W normalnych warunkach potraktowany byłby on właśnie jako separator, jednak przemycony backslash sprawia, że powstaje sekwencja unikowa (ang. escape sequence) i specjalne znaczenie nowej linii zostaje unieważnione. W efekcie bufor zawiera wyrażenie równoważne zapisowi:

>echo date

Jest to z kolei gramatyczny odpowiednik wyrażenia:

date > echo

Rezultatem będzie więc wywołanie polecenia date i przekierowanie wyjścia do pliku o nazwie echo.

Kolejny patch (bash43-026) eliminuje tę lukę wyłączając w pewnych warunkach zachłanne buforowanie napływających danych (eol_ungetc_lookahead = 0;).

Konflikty nazw

Sytuację dobrze podsumował w prowadzonym przez siebie blogu Michał Zalewski (lcamtuf) – pochodzący z Polski i również współpracujący z Google’em badacz bezpieczeństwa IT. Zwrócił on uwagę na to, że dotychczas proponowane poprawki są raptem obejściami, których funkcją jest zapobieganie zdalnemu wykonywaniu kodu (RCE), a nie rozwiązują problemu u źródła.

Zdaniem Zalewskiego, poza oczywistym uszczelnieniem parsera należałoby również wprowadzić wyraźną separację składowanych w środowisku funkcji i zmiennych. Proponowane przez niego podejście wiązałoby się z wydzieleniem osobnej przestrzeni nazw (ang. namespace), czyli umieszczeniu funkcji w środowisku w taki sposób, aby nie były one nigdy pomylone z innymi przechowywanymi tam informacjami, tzn. aby nie występowały konflikty nazw. Zauważmy, że nawet stosując ochronę w postaci poprawek niedopuszczających do wykonania komend Basha w trakcie wczytywania środowiska, wciąż istnieje ryzyko, że pochodząca np. z serwera WWW zmienna środowiskowa będzie miała taką samą etykietę jak zmienna powłokowa sterująca wykonywaniem się skryptu lub wywoływanego przez shella polecenia zewnętrznego.

Lcamtuf zasugerował też wprowadzenie opcji przekazywanej jako argument linii komend, której funkcją byłoby wybiórcze włączanie możliwości eksportowania funkcji. Bez stosownego parametru wykonywanie takich operacji byłoby niedopuszczalne. Problemem okazała się jednak konieczność uwzględnienia wstecznej kompatybilności tak „uzbrojonego” interpretera – niektóre skrypty wykorzystywane przez użytkowników czy narzędzia do testowania dostarczanych pakietów z oprogramowaniem czynią użytek z funkcji powłokowych przekazywanych między procesami.

Użycie osobnych przestrzeni nazw wprowadza stworzony przez Floriana Weimera z Red Hata patch bash43-027. Definiuje on stałą FUNCDEF_PREFIX zawierającą napis "BASH_FUNC_", a następnie ogranicza interpretowanie wartości zmiennych środowiskowych jako definicji funkcji do takich, których nazwa rozpoczyna się od tego napisu. Dodaje też stałą FUNCDEF_SUFFIX, ustawioną na parę nawiasów okrągłych, dzięki której sprawdzane jest czy nazwa kończy się właśnie nimi.

Oto przykład eksportowanych funkcji obecnych w środowisku procesu potomnego Basha z zaaplikowaną poprawką:

    BASH_FUNC_druga_funkcja()=() {  echo
    }
    BASH_FUNC_funkcja()=() {  echo
    }
BASH_FUNC_druga_funkcja()=() { echo } BASH_FUNC_funkcja()=() { echo }

Problemy z tablicami parsera

Wspomniany wcześniej Weimer zgłosił w międzyczasie dwie poprawki dotyczące błędów w pliku źródłowym parse.y, które niezależnie odkryte zostały też przez Todd Sabina z VMware’a.

Jedna z usterek (CVE–2014–7187) dawała o sobie znać, gdy do pojedynczej komendy dołączono wiele dokumentów miejscowych (ang. here-documents). W efekcie pojawiał się błąd dostępu do danych poza zakresem tablicy (ang. out-of-bounds index), prowadzący do awaryjnego przerwania pracy interpretera.

Podatność można sprawdzić wydając sekwencję poleceń:

1
2
3
4
5
6
#!/bin/bash

bash -c 'true <<EOF <<EOF <<EOF <<EOF \
              <<EOF <<EOF <<EOF <<EOF <<EOF \
              <<EOF <<EOF <<EOF <<EOF <<EOF' || \
echo "Podatność na CVE-2014-7186"
#!/bin/bash bash -c &#39;true &lt;&lt;EOF &lt;&lt;EOF &lt;&lt;EOF &lt;&lt;EOF \ &lt;&lt;EOF &lt;&lt;EOF &lt;&lt;EOF &lt;&lt;EOF &lt;&lt;EOF \ &lt;&lt;EOF &lt;&lt;EOF &lt;&lt;EOF &lt;&lt;EOF &lt;&lt;EOF&#39; || \ echo &#34;Podatność na CVE-2014-7186&#34;

Drugą lukę (CVE-2014-7187) można było wykorzystać korzystając z dużej liczby zagnieżdżonych pętli. Przyczyna była podobna jak w pierwszej – dostęp do elementu tablicy poza zakresem, z tym że wywołany błędem pomyłki o jeden (ang. off-by-one).

Podatność na występowanie usterki można sprawdzić uruchamiając poniższy skrypt:

1
2
3
4
5
#!/bin/bash

(for x in {1..200} ; do echo "for x$x in ; do :"; done; \
 for x in {1..200} ; do echo done ; done) | bash || \
echo "Podatność na CVE-2014-7187"
#!/bin/bash (for x in {1..200} ; do echo &#34;for x$x in ; do :&#34;; done; \ for x in {1..200} ; do echo done ; done) | bash || \ echo &#34;Podatność na CVE-2014-7187&#34;

Łata, oznaczona jako bash43-028, eliminuje te problemy przez wprowadzanie liczników wywołań.

Finał

Pierwszego października 2014 wspomniany już wcześniej Michał Zalewski opublikował szczegóły dotyczące trzymanych przez niego dotąd w tajemnicy usterek bezpieczeństwa Basha (CVE–2014–6277 i CVE–2014–6278).

Pierwszy problem dotyczył błędu niezainicjowanego wskaźnika w funkcji make_redirect() obsługującej strukturę REDIR. Jest to usterka podobna do CVE–2014–7186, wykorzystująca również problemy parsera dokumentów miejscowych. W zależności od flag kompilacji Basha luki tej można użyć albo do ataków typu DoS (wywołując awaryjne przerwanie pracy interpretera), albo (w sprzyjających warunkach) do przemycenia i wykonania dowolnego kodu w postaci przekazywanych parametrów i wpływania na wartości lokalnych zmiennych.

A sprawdzić podatność na występowanie usterki można posłużyć się poniższym skryptem:

1
2
3
4
#!/bin/bash

export X="() { x() { _; }; x() { _; } <<`perl -e'{print "A"x999}'`;}"
bash -c exit || echo "Podatność na CVE-2014-6277"
#!/bin/bash export X=&#34;() { x() { _; }; x() { _; } &lt;&lt;`perl -e&#39;{print &#34;A&#34;x999}&#39;`;}&#34; bash -c exit || echo &#34;Podatność na CVE-2014-6277&#34;

Kolejny błąd, znaleziony przez lcamtufa z użyciem zestawu „magicznych fuzzerów” jego autorstwa, polega na niepożądanej zmianie zawartości bufora interpretera przez użycie sekwencji zagnieżdżonych symboli $. Problem występuje najprawdopodobniej w funkcji xparse_dolparen() i zmianach wprowadzonych w Bashu 4.2 (patch level 12).

Podatność na występowanie usterki można sprawdzić uruchamiając poniższy skrypt:

1
2
3
4
#!/bin/bash

export X="() { _; } >_[$($())] { echo Podatność na CVE-2014-6278 ; }"
bash -c exit
#!/bin/bash export X=&#34;() { _; } &gt;_[$($())] { echo Podatność na CVE-2014-6278 ; }&#34; bash -c exit

Poprawki i aktualizacje

Większość dystrybucji systemu GNU/Linux wydało już stosowne poprawki i uaktualnienia eliminujące opisywane tu problemy. Nieco w tyle pozostaje koncern Apple, którego systemy Mac OS X z opóźnieniem otrzymują aktualizacje. Użytkownicy spekulują, że chodzi tu prawdopodobnie o licencję: nowy Bash wydawany jest zgodnie z zasadami ponownego wykorzystania zdefiniowanymi w licencji GNU GPL w wersji 3, która jest bardziej restrykcyjna niż GNU GPL w wersji 2, a to właśnie tej ostatniej stara trzymać się koncern Apple, włączając do wydawanych przez siebie systemów pakiety wolnego oprogramowania.

Jeśli chodzi o dwie ostatnie luki, to nie są one jeszcze w pełni zamknięte (w wersji Basha 4.3 patch level 27), ale przed ich wykorzystaniem chronią poprawki Weimera.

Jesteś w sekcji

comments powered by Disqus