Symlink, zip i upload plików – łatwo padniesz łupem hackera

Bardzo często ścieram się z sytuacjami gdzie ktoś kopiuje kod z Internetu do aplikacji bez zweryfikowania co ten kod do końca robi… Zresztą: Kto nie wykorzystał gotowca ze StackOverflow bez czytania opisu i komentarzy niech pierwszy rzuci kamień 😉 Czasem jednak pozornie niewinne fragmenty kodu mogą doprowadzić do katastrofy. Weźmy prosty przykład: Upload plików. Wszystko działa, wszędzie prawidłowa sanityzacja i obsługa błędów… ale czy na pewno? Przeoczenie jednego małego szczegółu sprawia, że aplikacja staje się celem dla hackerów. Oto prawdziwa historia o symlinkach, zipach i uploadzie plików.

Projekt

Pewien czas temu pracowałem nad projektem dla sporej firmy, której danych nie chcę w tym momencie ujawniać. Na potrzeby tego wpisu możesz sobie wyobrazić aplikację, która pozwała użytkownikom wrzucać i wyświetlać różnego rodzaju aktualności – w tym dane statystyczne i liczbowe. W tym celu zaimplementowaliśmy możliwość wrzucania na serwer plików. Pliki te z założenia (wymagania od klienta) miały być archiwami ZIP, w środku których znajdowały się wyłącznie pliki tekstowe. Nazwa pliku była identyfikatorem, a treść najczęściej miała format CSV, czyli były to różne liczby lub tekst oddzielone od siebie przecinkami. Po wgraniu takiego archiwum, system miał je automatycznie rozpakować, a następnie od razu wyświetlić użytkownikowi ich treść.

Potencjalne dziury

Spostrzegawczy czytelnik na pewno dostrzega kilka miejsc, w których może znajdować się potencjalny błąd bezpieczeństwa:

  • pliki w archiwum nie są tekstowe – wykonanie kodu?
  • nazwy plików wyświetlane na stronie – miejsce na XSS?
  • treść pliku wyświetlana na stronie – XSS?

Jednak te fragmenty były dobrze zabezpieczone, poprawna sanityzacja oraz zawsze wczytywanie pliku jako tekstowy załatwiło sprawę. Co poszło nie tak? Spójrz na kod poniżej.

Omówienie kodu

Jest to uproszczona wersja tego fragmentu aplikacji napisana w PHP ( 🙁 ). Pominąłem kilka nieistotnych operacji:

<?php

$uploaded_sheet_archive = $_FILES['sheet_file']['tmp_name'];

if ($uploaded_sheet_archive) {
	$sheets_dir = sys_get_temp_dir() . '/' . uniqid('archive', true);

	system('unzip -qq ' . escapeshellarg($uploaded_sheet_archive) . ' -d ' . $sheets_dir);

	$uploaded_sheets = array_values(array_diff(scandir($sheets_dir), array('.', '..')));

	foreach($uploaded_sheets as $key => $file) {
		echo '<section>';
		echo '<h2>File ' . ($key+1) . '</h2>';
		echo '<pre>' . htmlentities(file_get_contents($sheets_dir . '/' . $file)) . '</pre>';
		echo '</section>';
	}

	system('rm -rf ' . escapeshellarg($sheets_dir));
}

?>

<form method="post" enctype="multipart/form-data" action="upload.php">
	<input type="file" name="sheet_file">
	<input type="submit" value="Send" accept=".zip">
</form>

Zamiast system można równie dobrze użyć exec, a zamiast unziptar. Zależnie od tego co oferuje akurat serwer, warto spróbować zamiennie kilku opcji.

Omówię z grubsza co się tu dzieje:

  • 5: sprawdź czy był wgrywany plik
  • 6: wygeneruj ścieżkę do tymczasowego folderu o losowej nazwie, aby w nim rozpakować archiwum
  • 8: rozpakuj plik używając systemowego unzip
  • 10: pobierz listę plików z archiwum, pomiń . oraz ..
  • 12–17: dla każdego pliku, wyświetl jego treść
  • 19: skasuj rozpakowane archiwum
  • 24–17: formularz do wrzucania plików

Należy zwrócić uwagę, że argumenty są raczej poprawnie sanityzowane (escapeshellarghtmlentities). Tak wygląda skrypt po uruchomieniu:

Skrypt upload.php

Błąd

Na czym polega błąd? Częściowo na bezmyślnym skopiowaniu kodu ze StackOverflow („Jak rozpakować ZIP w PHP?”), częściowo na użyciu funkcji system, ale głównie na braku weryfikacji czy archiwum nie zawiera symlinków.

Symlink

Co to jest symlink? Skrót od symbolic link – jest to wskazanie na inny plik lub folder. Można powiedzieć „skrót”, choć technicznie rzecz biorąc to różne pojęcia. W każdym razie, symlink wskazuje na inny plik. Gdy próbujemy otworzyć symlink, to tak jakbyśmy otwierali ten inny plik – super, prawda? 🙂

Symlink do /etc/passwd

Co się stanie, jeśli w archiwum umieścimy symlink wskazujący na przykład na plik /etc/passwd? Sprawdźmy to! Jeśli chcesz sama/sam przetestować działanie skryptu, skopiuj powyższy kod do pliku i w tym samym folderze uruchom serwer PHP poleceniem php -S localhost:8081. Następnie odwiedź stronę http://localhost:8081/upload.php .

Na początek poprawne archiwum zawierające dwa pliki tekstowe. W drugim pliku umieściłem dodatkowo fragment HTML-a, aby pokazać, że znaczniki są poprawnie ignorowane:

Skrypt upload.php po wrzuceniu pliku

Teraz preparuję złośliwe archiwum z symlinkiem do /etc/passwd:

  • ln -s /etc/passwd ./moj-link
  • zip --symlinks -r -X archiwum.zip inny-plik.txt plik1.txt moj-link

Następnie taki plik wrzucam na stronie. Oto efekt:

Atak ujawnia zawartość pliku /etc/passwd

Widoczna jest treść plik /etc/passwd, a w nim – nawet konto roota oraz wiele innych ciekawych rzeczy…

Inne zastosowania?

Właściwie możliwe jest teraz odczytanie dowolnego pliku z dysku. Oczywiście, poprawna konfiguracja użytkowników i uprawnień powinna to uniemożliwić. Czy wtedy atak jest bezużyteczny? Ależ nie! Nadal możemy przecież, metodą prób i błędów, albo bazując na jakichś innych przesłankach, odczytać dowolne pliki źródłowe aplikacji – w tym potencjalnie np. hasła do bazy danych.

Przykładowo, jeśli wiesz, że ten aplikacja na hostingu od MyDevil.net to najprawdopodobniej ścieżka do głównego folderu to /home/moj-login/domains/moja-domena.com/public_html – łatwo można się o tym dowiedzieć jeśli po prostu założy się tam konto lub poczyta dokumentację. A wtedy spreparowanie odpowiedniego symlinka nie jest trudne i po wgraniu archiwum odczytujemy np. kod źródłowy pliku upload.php:

Ujawniony kod źródłowy

Jak naprawić błąd?

Zrezygnować z wywołań funkcji system na rzecz innych, wbudowanych. Wpuszczenie niepowołanego kodu do funkcji system może mieć katastrofalne skutki. W tym przypadku do rozpakowania archiwum można skorzystać z klasy ZipArchive:

$zip = new ZipArchive;
$res = $zip->open($uploaded_sheet_archive);
$zip->extractTo($sheets_dir);
$zip->close();

Ale przede wszystkim w tym przypadku: sprawdzać czy plik nie jest przypadkiem symlinkiem zanim się go otworzy – np. przez funkcję is_link().

Podsumowanie

Chciałem napisać o tym ciekawym wektorze ataku, gdyż naprawdę natrafiliśmy na taki błąd, a korzystanie z funkcji system('unzip …') jest nagminne – nie tylko w PHP, ale też z jej odpowiedników w wielu innych językach (również NodeJS!). Mam nadzieję, że i Ciebie zainteresował ten sposób atakowania aplikacji.

Ale przede wszystkim morał z tego jest nieco inny: Nie kopiuj bezmyślnie kodu z Internetu 🙂 Czytaj dokumentację, sprawdzaj dokładnie co robi dany kod i staraj się przewidzieć wszelkie konsekwencje.

  • Ciekawy wpis! Morał niestety słuszny, ja też bardzo często spotykam się z bezmyślnie kopiowanym kodem 🙂

    • No niestety, w szczególności gdy chodzi o bezpieczeństwo. Nigdy nie należy kopiować kodu bez sprawdzenia 😉

  • Kodu bezmyślnie na szczęście nie kopiuję już od lat, ale symlinki w Zipach – nie miałem pojęcia, że się da i w ogóle kto wpadł na pomysł by dać taką funkcjonalność D:

    • Jest to co najmniej głupie. Żeby bylo ciekawiej na moim hostingu akurat unzip nie wypakuje symlinków, ale tar już tak 😀 Podobnie — nie działa system, ale działa exec… Także sporo tych kombinacji.