10-02-2020 / Od 0 do pentestera

Atakowanie środowiska testowego - debugger we Flask

Podczas tworzenia nowych aplikacji nie zawsze wszystko działa jak należy.

Czasami trzeba coś przeanalizować, poprawić lub przetestować.

Na środowisku testowym mamy do dyspozycji wiele opcji – między innymi dostęp do debuggera.

Ale co jeśli przez pomyłkę na produkcji uruchomimy system z włączonymi opcjami służącymi do rozwiązywania problemów?

Dzisiaj na przykładzie języka Python i framework Flask pokaże jak używając wbudowanego debuggera można przejąć pełną kontrolę nad serwerem.

Cała historia zaczyna się niecałe pięć lat temu kiedy to serwis Patreon został zaatakowany1.

Okazało się, że wszystko przez funkcjonalność Werkzeug Debugger – która była dostępna dla wszystkich osób.

W skrócie. Jeżeli na stronie wystąpił jakiś błąd – nie zwracano zwykłego komunikatu error 500.

Zamiast tego wyświetlał się traceback oraz kawałek kodu, który akurat w tym momencie nie zadziałał prawidłowo.

Dodatkowo – użytkownik ma dostęp do konsoli języka Python.

Teoretycznie chodzi o to, że w przypadku wystąpienia błędu – można na żywym organizmie sprawdzić co poszło nie tak.

Tylko, że dostęp do języka nie jest limitowany w żaden sposób.

Najlepiej pokazać to na przykładzie – dlatego zobaczymy jak zachowuje się stara, podatna wersja.

from flask import Flask, request
app = Flask(__name__)
@app.route('/')
def hello_world():
    username = request.args.get('username')
    return 'Hello '+username

Mam tutaj prosty kod, który próbuje pobrać i wyświetlić parametr username od użytkownika.

Tylko, że w przypadku jego braku – skrypt zatrzyma swoje działanie.

Mam więc dostęp do konsoli – mogę tutaj podać dowolną komendę.

Chociażby wypisać na konsole dowolny tekst.

Mogę także zaimportować dodatkowe moduły.

Chociażby moduł os a następnie wykonać dowolną komendę systemową.

W moim wypadku wypisać listę plików w obecnym katalogu.

Tylko, że dla programistów nie powinno to być wielkie zaskoczenie.

W instrukcji2 znajduje się wielka czerwona ramka, mówiąca o tym że funkcji tej nie powinno się używać na maszynach produkcyjnych.

Ale to nie wszystko.

Postanowiono zmienić standardowe zachowanie debuggera – aby był bezpieczniejszy.

Począwszy od wersji 0.11 nie można od razu wykonywać komend.

Najpierw trzeba podać kod PIN.

Wyświetla się on podczas uruchamiania projektu i składa się z 9 cyfr.

Popatrzmy na aktualną wersje frameworka.

Dalej otrzymujemy stacktrace.

Próbując jednak wykonać komendę jesteśmy proszeni o podanie kodu.

Musimy go zlokalizować w linii komend i wkleić w odpowiednie miejsce.

Dopiero teraz całość działa jak poprzednio.

Jeżeli jesteś pentesterem to w Twojej głowie może teraz zagościć pomysł – 9 cyfr to nie tak dużo, można spróbować wszystkich możliwych kombinacji.

Ale i o to zadbano.

Po kilku błędnych próbach – debugger nie będzie działał nawet jeśli w którymś momencie podamy prawidłowy kod PIN.

Konieczny będzie restart aplikacji – a my jako atakujący nie mamy przecież dostępu do serwera.

I na tym mógłbym zakończyć dzisiejszy odcinek.

Był problem, użytkownicy nie czytali dokumentacji, czasami udostępniali interfejs na produkcji.

Stworzono więc rozwiązanie, które ich chroni.

I tutaj przechodzimy do sedna dzisiejszego odcinka.

Podczas niedawnych zawodów TetCTF zawodnicy mieli do rozwiązania dokładnie ten sam problem.

A wiec przykładową stronę stworzoną we Flasku, która miała włączony debugger.

W opisie zadania podano informację, że metoda Brute Force nie jest potrzebna do jego rozwiązania.

Zatem jak to możliwe?

Strona działa w prosty sposób.

Wyświetlała ona pliki z dowolnego katalogu podanego przez użytkownika jako parametr.

Jest to więc podatność arbitrary file read pozwalająca na odczyt dowolnego pliku z dowolnego katalogu.

Problem w tym, ze musimy znać nazwę pliku o którego prosimy.

Poza tym nadal nie mamy możliwości wykonywania zdalnego kodu na serwerze.

Jak więc rozwiązać to zadanie?

Aby to zrozumieć musimy lepiej popatrzyć na to, jak generowany jest kod PIN.

Na początku założyłem bowiem, że kod jest generowany losowo – to znaczy, że po każdorazowym uruchomieniu aplikacji – wykonywana jest funkcją generująca losowy ciąg, który jest potem zamieniany na cyfry.

Jak się okazuje – nie jest to prawda.

Za generowanie kodu odpowiedzialna jest funkcja get_pin_and_cookie_name3.

Już sam opis podpowiada nam, że generowany tutaj kod powinien być ten sam dla każdej maszyny, nawet po jej restarcie.

Po co? Chodzi o to aby programista nie musiał za każdym razem sprawdzać kodu.

Jeśli korzysta z narzędzia często – w którymś momencie po prostu go zapamięta.

Co dzieje się dalej?

Pin można wyłączyć – lub też podać swój własny.

Zakładam jednak że nie o to chodziło w zadaniu.

Dalej dowiadujemy się, ze kod pin składa się z dwóch części – publicznej i prywatnej.

Publiczna część nie zawiera żadnych tajnych danych i z góry założono, że atakujący może te dane znać.

Na początku mamy wiec nazwę użytkownika, który uruchomił dany skrypt.

Wykorzystuje się tutaj standardowy moduł getpass4 i funkcję getuser.

Ponieważ możemy odczytać dowolny plik z dysku możemy również zapoznać się z zawartością /etc/passwd – w którym to znajduje się lista użytkowników danego serwera.

Oczywiście nie będziemy wiedzieli który użytkownik dokładnie uruchomił daną aplikację – ale to znacząco zawęża nasze poszukiwania.

Dalej mamy nazwę modułow oraz nazwę klasy.

W znakomitej większości przypadków będą to flask.app oraz Flask.

Możesz to sprawdzić samemu.

Zedytuj plik debug/init.py i wyświetl zawartość zmiennej probably_public_bits w swojej aplikacji.

Na końcu nazwa wykonywanego pliku – tą wartość możemy odczytać z konsoli błędów.

Jest to po prostu plik, który jest widoczny w logu – czasami jednak będzie konieczne dodanie do rozszerzenia literki c – jeżeli aplikacja wykonuje się ze skompilowanego kodu pyc.

Mamy wiec wszystkie publiczne dane – co nie jest wielkim zaskoczeniem.

Pora na trudniejszą część.

Potrzebujemy wartości uuid.getnode()5.

Patrząc w dokumentację widzimy, że uuid zwraca unikalny identyfikator.

Czyżby ślepa pułapką?

Nie do końca.

Funkcja getnode() bowiem zwraca adres MAC przypisany do karty sieciowej.

A ta wartość jest stała i możemy ją odczytać.

W Linuxie bowiem większość rzeczy to pliki.

Możemy więc wyświetlić zawartość pliku /etc/network/inferfaces w którym znajduje się lista wszystkich interfejsów sieciowych.

Komputer zazwyczaj będzie zawierał jeden, góra dwa inferfejsy nie ma więc tu zbyt wielkich możliwości.

Adres MAC możemy zatem odczytać korzystając z innego pliku:

/sys/class/net/%nazwainferfejsu%/address

Pora na ostatni element układanki get_machine_id().

Ona to stara się zwrócić unikalny identyfikator komputera.

W przypadku linuxów jest to wartość pliku /etc/machine-id lub też /proc/sys/kernel/random_boot_id.

My możemy odczytać dowolny plik – a więc i zawartość tych dwóch.

W przypadku Windowsów – nie jest tak różowo.

Debugger próbuje bowiem korzystać z wartości MachineGuid z rejestru systemowego.

A więc mam już wszystkie kawałki układanki.

Pora połączyć je w jedną całość i wygenerować kod PIN – a więc trzeba wziąć kod debuggera i powstawiać wszystkie odczytane wartości w prawidłowe miejsca.

Ja zrobię to korzystając ze skryptu – jednego z teamów CTFowych6.

import hashlib
from itertools import chain
probably_public_bits = [
	'web3_user',# username
	'flask.app',# modname
	'Flask',# getattr(app, '__name__', getattr(app.__class__, '__name__'))
	'/usr/local/lib/python3.5/dist-packages/flask/app.py' # getattr(mod, '__file__', None),
]
private_bits = [
	'94558041547692',# str(uuid.getnode()),  /sys/class/net/ens3/address # 56:00:02:7a:23:ac
	'd4e6cb65d59544f3331ea0425dc555a1'# get_machine_id(), /etc/machine-id
]
h = hashlib.md5()
for bit in chain(probably_public_bits, private_bits):
	if not bit:
		continue
	if isinstance(bit, str):
		bit = bit.encode('utf-8')
	h.update(bit)
h.update(b'cookiesalt')
cookie_name = '__wzd' + h.hexdigest()[:20]
num = None
if num is None:
	h.update(b'pinsalt')
	num = ('%09d' % int(h.hexdigest(), 16))[:9]
rv =None
if rv is None:
	for group_size in 5, 4, 3:
		if len(num) % group_size == 0:
			rv = '-'.join(num[x:x + group_size].rjust(group_size, '0')
						  for x in range(0, len(num), group_size))
			break
	else:
		rv = num
print(rv)

Jako rezultat otrzymaliśmy kod PIN – który jak widać jest identyczny z tym wyświetlanym na konsoli.

Tak oto uzyskaliśmy możliwość wykonania dowolnego kodu na komputerze chociaż jeszcze przed chwilą wydawało się to niemożliwe.

To pokazuje, że połączenia kilku podatności są groźne – tak jak tutaj.

Sam dostęp do debuggera nic nam nie dawał – ale w połączeni z możliwości odczytywania niektórych plików z serwera – stał się potężną bronią.

Warto też zwrócić uwagę, że mówimy tutaj o Linuxie.

W przypadku windowsa odczytanie adresu mac przy użyciu pliku czy też rejestru systemowego mogło by nie być takie proste.