Przejdź do treści

Shellshock: jak działający od 22 lat kod z dnia na dzień stał się dziurą

Jest teza, którą lubię i której bronię: legacy, które działa i się sprawdza, jest w porządku. Nie przepisuje się czegoś, co od dekad robi swoje, tylko dlatego, że jest nowsza moda. Na tym blogu mam to nawet spisane jako zasadę – stary kod zostaje nietknięty.

Ale każda dobra teza ma granicę, i warto ją znać. Granica tej brzmi: dopóki nikt nie każe temu kodowi zrobić czegoś, czego nie robił do tej pory. We wrześniu 2014 świat zobaczył tę granicę na przykładzie, którego nie da się zapomnieć – błędzie nazwanym Shellshock.

Co to jest bash i dlaczego jest wszędzie

bash (Bourne Again Shell) to powłoka – program, który przyjmuje polecenia i je wykonuje. Jest domyślną powłoką w większości dystrybucji Linuksa i przez lata był nią też w macOS. Siedzi praktycznie na każdym serwerze uniksowym na świecie. To jest infrastruktura tak podstawowa, że przestaje się ją zauważać – jak instalacja elektryczna w ścianie.

I właśnie dlatego błąd w niej był tak groźny. Nie chodziło o jedną aplikację, tylko o warstwę, na której stoi prawie wszystko inne.

Mechanizm: eksport funkcji przez zmienne środowiskowe

Żeby zrozumieć błąd, trzeba znać jedną funkcję basha, która brzmi niewinnie. Bash pozwala eksportować nie tylko zmienne, ale i funkcje do procesów potomnych. Robi to, przekazując je jako zmienne środowiskowe, których treść wygląda jak definicja funkcji. Proces potomny czyta taką zmienną i odtwarza z niej funkcję.

Mechanizm ten działał niezmiennie od 1992 roku – od wersji, w której go dodano. Ponad dwie dekady bez incydentu. Klasyczne „stare i sprawdzone”.

Problem był w tym, jak bash to parsował. Odczytując zmienną wyglądającą jak funkcja, wykonywał jej treść – i nie zatrzymywał się na samej definicji. Jeśli po definicji funkcji doklejono kolejne polecenia, bash wykonywał także je. A to znaczyło, że ktokolwiek mógł wstawić treść zmiennej środowiskowej, mógł wykonać dowolne polecenie.

Uproszczony obraz problemu wygląda tak:

# Zmienna udaje definicję pustej funkcji, ale po niej doklejono polecenie.
# Podatny bash wykonywał to, co po '};' – tutaj nieszkodliwe echo,
# w realnym ataku: cokolwiek.
env x='() { :; }; echo PODATNY' bash -c 'echo test'

Na załatanym systemie zobaczysz tylko test. Na podatnym – najpierw PODATNY, bo bash wykonał doklejone polecenie już przy samym starcie, zanim w ogóle doszedł do właściwej komendy.

Dlaczego CGI zamieniło to w katastrofę

Sama możliwość wstrzyknięcia polecenia przez zmienną środowiskową jest groźna dopiero wtedy, gdy atakujący potrafi tę zmienną ustawić zdalnie. I tu na scenę wchodzi wysłużone CGI – ta sama technologia z lat 90., od której sam zaczynałem przygodę z webem, pisząc skrypty w Perlu.

Skrypty CGI to najstarszy sposób generowania dynamicznych stron: serwer WWW uruchamia zewnętrzny program i oddaje mu żądanie. Sposób, w jaki mu je oddaje, jest kluczowy: nagłówki HTTP trafiają do programu jako zmienne środowiskowe. Nagłówek User-Agent staje się zmienną HTTP_USER_AGENT, i tak dalej. To standard z lat 90., działający do dziś.

Połącz teraz oba fakty. Jeśli skrypt CGI jest napisany w bashu albo uruchamia bash, to atakujący, który wpisze złośliwą treść w nagłówek User-Agent, doprowadza do jej wykonania na serwerze. Nie potrzeba logowania, nie potrzeba luki w samej aplikacji – wystarczy zwykłe żądanie HTTP z odpowiednio spreparowanym nagłówkiem.

User-Agent: () { :; }; /bin/some-evil-command

Z pozoru niegroźny, działający od dekady skrypt CGI stał się zdalnym wykonaniem kodu. Nie dlatego, że ktoś go zmienił – nikt go nie ruszył od lat. Dlatego, że warstwa pod nim okazała się mieć dziurę, której przez dwie dekady nikt nie zauważył.

„Ruszysz – rozsypie się”: pierwsza łata była dziurawa

Historia ma jeszcze jeden morał, celniejszy niż sam błąd. Pierwsza poprawka, wydana jako CVE-2014-6271, okazała się niepełna. W ciągu kilku dni badacze pokazali, że da się ją obejść, i trzeba było wydać kolejną (CVE-2014-7169), a po niej następne (CVE-2014-6277, 6278). Łatanie parsera 22-letniego kodu, którego nikt od lat nie dotykał, nie dało się zrobić jednym pociągnięciem – każda poprawka odsłaniała kolejny zakątek, o którym już nikt nie pamiętał.

To jest dokładnie ta granica tezy o legacy. Kod był bezpieczny nie dlatego, że był dobrze napisany, tylko dlatego, że nikt nie patrzył. W chwili, gdy świat musiał go ruszyć – bo wyszła na jaw dziura – okazało się, że rusza się cały splątany kłąb, a nie pojedyncza linijka.

Czego to uczy o starym kodzie

Nie wyciągam z tego wniosku „przepisujcie wszystko, co stare”. To byłby błąd w drugą stronę i zaprzeczenie mojej własnej tezy. Wnioski są ostrożniejsze i, moim zdaniem, ważniejsze:

  • „Działa od lat” to nie to samo co „jest bezpieczne”. To znaczy tylko tyle, że nikt jeszcze nie znalazł problemu – albo nie szukał. Wiek kodu jest miarą jego stabilności, nie odporności.
  • Ryzyko legacy nie jest w tym, że działa, tylko w tym, co je otacza. Skrypt CGI się nie zmienił. Zmieniło się to, co pod nim (bash) i to, kto wysyła do niego dane (cały internet). Stary kod żyje w środowisku, które się zmienia, nawet jeśli on sam nie.
  • Im dłużej coś leży nietknięte, tym trudniej to bezpiecznie ruszyć. Wiedza o kodzie wyparowuje razem z ludźmi i notatkami. Kiedy w końcu trzeba – bo wymusza to podatność, zmiana prawa albo koniec wsparcia – okazuje się, że nikt już nie pamięta, dlaczego to działało.

Stare i sprawdzone jest w porządku, dopóki stoi i robi swoje. Shellshock przypomina tylko, że „stoi i robi swoje” to stan, nie gwarancja – i że warto wiedzieć, co dokładnie trzyma nasze legacy przy życiu, zanim ktoś inny odkryje to za nas.

To samo pytanie – co się dzieje, gdy złożoność albo wiek kodu zamienia się w powierzchnię ataku – drążyłem od innej strony we wpisie o tym, kiedy złożoność staje się podatnością. Shellshock jest tej samej rodziny: różnica między „działa” a „jest bezpieczne” bywa cienka jak jeden nagłówek HTTP.