05-07-2018 / Od 0 do pentestera

Gitea 1.4.0 Zdalne wykonanie kodu

Wstęp

Ten dokument jest również dostępny na GitHub.

Jest to część 1 z 3 na temat błędów w Gitea oraz Gogs.

Gitea to prosty serwer gita napisany w języku Go.

Jest bardzo prosty w instalacji i posiada wiele interesujących opcji.

Zademonstrowany tutaj exploit składa się z kilku elementów, które połączone razem prowadzą do całkowitego przejęcia serwera.

Najpierw przy pomocy błędu w implementacji GIT LFS pobieramy treść pliku app.ini.

Z tego pliku odczytujemy SECRET, którym możemy podpisywać tokeny JWT.

Dzięki temu jesteśmy w stanie wysłać fałszywy plik sesji użytkownika.

Używając naszej nowo stworzonej sesji administratora tworzymy nowe repozytorium.

Konto administratora jest nam potrzebne ponieważ tylko administrator może modyfikować hoki gita.

Hook update będzie zawierał nasz złośliwy kod, który ma zostać wykonany na serwerze.

Następnie, wystarczy wysłać dowolne zmiany kodu źródłowego do repozytorium aby git automatycznie uruchomił nasz kod z uprawnieniami użytkownika, który uruchomił serwer.

Wiemy już jak w teorii będzie wyglądać atak na serwer. Teraz, przejdę do omawiania poszczególnych elementów w bardziej szczegółowy sposób.

Brakujący return

Funkcja PostHandler, jest odpowiedzialna za tworzenie nowych obiektów LFS.

Na pozór wszystko wydaje się być w porządku. Jeśli użytkownik nie posiada odpowiednich uprawnień wywoływana jest funkcja requireAuth, która to ustawia odpowiednik nagłówek WWW-Authenticate oraz status 401.

Jeśli jednak poszukamy w kodzie źródłowym odwołania do tej funkcji, okazuje się, że jej prawidłowe użycie wygląda nieco inaczej.

W podatnym kodzie brakuje bowiem wyrazu return, który zakończy działanie funkcji PostHandler w przypadku niepowodzenia.

Bez tego słowa, funkcja requireAuth wykona się a następnie program przystąpi do wykonania kolejnych akcji, w tym wypadku do stworzenia obiektu LFS.

W taki oto sposób ominęliśmy mechanizm sprawdzania uprawnień użytkownika.

Możemy teraz stworzyć dowolny obiekt lfs dla dowolnego repozytorium.

func PostHandler(ctx *context.Context) {
	if !setting.LFS.StartServer {
		writeStatus(ctx, 404)
		return
	}
	if !MetaMatcher(ctx.Req) {
		writeStatus(ctx, 400)
		return
	}
	rv := unpack(ctx)
	repository, err := models.GetRepositoryByOwnerAndName(rv.User, rv.Repo)
	if err != nil {
		log.Debug("Could not find repository: %s/%s - %s", rv.User, rv.Repo, err)
		writeStatus(ctx, 404)
		return
	}
	if !authenticate(ctx, repository, rv.Authorization, true) {
		requireAuth(ctx)
		# !!!!! MISSING RETURN HERE
	}
	meta, err := models.NewLFSMetaObject(&models.LFSMetaObject{Oid: rv.Oid, Size: rv.Size, RepositoryID: repository.ID})
	if err != nil {
		writeStatus(ctx, 404)
		return
	}
	ctx.Resp.Header().Set("Content-Type", metaMediaType)
	sentStatus := 202
	contentStore := &ContentStore{BasePath: setting.LFS.ContentPath}
	if meta.Existing && contentStore.Exists(meta) {
		sentStatus = 200
	}
	ctx.Resp.WriteHeader(sentStatus)
	enc := json.NewEncoder(ctx.Resp)
	enc.Encode(Represent(rv, meta, meta.Existing, true))
	logRequest(ctx.Req, sentStatus)
}

Arbitrary File Read

Funkcja getContentHandler odpowiedzialna jest za pobranie treści pliku z repozytorium LFS bazując na jego Oid.

Na początku sprawdza ona, czy bieżący użytkownik posiada uprawnienia do odczytu danego repozytorium.

Dlatego też korzystamy z repozytirum dostępnego publicznie, ponieważ wtedy każdy użytkownik (nawet niezalogowany) może pobrać z niego dowolny plik.

Następnie pobierana jest ścieżka do pliku przy pomocy ContentStore.

Tam łączony jest katalog LFS_CONTENT_PATH z parametrem oid.

Funkcja transformKey generuje nową ścieżkę do pliku.

func transformKey(key string) string {
	if len(key) < 5 {
		return key
	}
	return filepath.Join(key[0:2], key[2:4], key[4:])
}

Tworzona jest ona na podstawie dwóch pierwszych znaków, potem backslash, potem kolejne dwa znaki znowu backslash a następnie reszta identyfikatora.

abcdefgh -> ab\cd\efgh

Podstawiając pod to nasz parametr oid z kropkami otrzymujemy:

gitea\data\lfs\..\..\custom\conf\app.ini

W systemie Windows ../ oznacza przejdź katalog wyżej. Dzięki temu możemy odczytać treść pliku app.ini

Podrabianie tokenów JWT

W pliku konfiguracyjnym znajduje się LFS_JWT_SECRET.

APP_NAME = Gitea: Git with a cup of tea
RUN_USER = root
RUN_MODE = prod
[security]
INTERNAL_TOKEN = eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
INSTALL_LOCK   = true
SECRET_KEY     = 79jOlo4qSO
[database]
DB_TYPE  = sqlite3
HOST     = 127.0.0.1:3306
NAME     = gitea
USER     = gitea
PASSWD   = 
SSL_MODE = disable
PATH     = data/gitea.db

Jego wartość jest używana jako klucz służący do podpisywania tokenów JWT.

Tokeny te są sprawdzane podczas wysyłania plików GIT LFS do serwera.

Dzięki temu, że znamy jego wartość, możemy wysłać dowolny plik do dowolnego repozytorium z dowolną nazwą oid.

Tworzymy nowy obiekt LFS ponownie wykorzystując sztuczkę z 4 kropkami. Tym razem jako oid podajemy ścieżkę do katalogu sessions.

....data/sessions/1/1/11naszasesja

W katalogu tym znajdują się pliki z informacjami o aktualnie zalogowanych użytkownikach.

Korzystając z Gitea wysyłamy do serwera ciasteczko o nazwie i_like_gitea.

Serwer sprawdza czy plik o nazwie z ciasteczka istnieje w tym katalogu. Jeśli tak, odczytuje zapisane w sesji informacje o bieżącym użytkowniku.

My, wyślemy tutaj własny plik sesji z fałszywym kontem administratora.

Dlaczego? Jeśli popatrzymy na funkcję umożliwiającą zapisywanie plików na serwerze, działa ona dokładnie tak samo jak funkcja służąca do pobierania plików.

Jest między nimi tylko jedna różnica. Do nazwy tworzonego pliku dodawany jest ciąg .tmp.

Dla nas atakujących oznacza to że możemy wysłać plik w dowolne miejsce.

Będzie on jednak zawsze miał rozszerzenie .tmp.

Race condition

Niestety okazuje się, że nie możemy użyć wysłanej przez nas sesji ponieważ jest ona natychmiast usuwana z serwera.

Odpowiedzialne jest za to słowo kluczowe defer, które usuwa stworzony plik tak szybko jak funkcja Put zakończy swoje działanie.

Aby ominąć to ograniczenie wykorzystamy race condition.

Podczas wysyłania requestu POST do serwera razem z danymi, które chcemy przesłać przekazywany jest nagłówek Content-Length.

Informuje on serwera ile danych użytkownik zamierza wysłać. Dzięki temu serwer wie na jakim etapie przesyłania danych jest obecnie użytkownik.

Cały trik polega na tym, aby ustawić nagłówek na dużą wartość.

Dane, które serwer otrzymał od użytkownika są zapisywane w pliku od razu.

Funkcja czeka jednak na swoje zakończenie aż ich wielkość będzie równa liczbie podanej w nagłówku.

Dzięki czemu plik nie zostaje usunięty od razu.

Otwiera to nasze kilkudziesięcio sekundowe okno, w którym możemy wykorzystać naszą sesje.

Git hooks

Dalsza część jest już bardzo prosta.

Tworzymy nowe repozytorium wykorzystując nasze fałszywe konto administratora.

Następnie przechodzimy do ustawień repozytorium. Ponieważ jesteśmy administratorem, możemy przejść do opcji Hooki Git.

Hooki to skrypty umieszczone w katalogu .git/hooks każdego repozytorium.

Są one uruchamiane podczas akcji wykonywanych na repozytorium.

Dla przykładu skrypt update jest wykonywany przez Gita w reakcji na komendę git push.

W treści hooka update wpisujemy naszą komendę do wykonania.

Jej wynik jest przekazywany do pliku objects/info/exploit.

Teraz wystarczy tylko dodać nowy plik do naszego repozytorium i wysłać go na serwer wykorzystując komendę git push.

W tym momencie serwer wykona hook update i zapisze wynik naszej komendy w pliku exploit.

Możemy wyświetlić wynik wykonania komendy pobierając obiekt:

http://localhost:3000/root/test/objects/info/exploit

Wykorzystując kilka małych podatności zdołaliśmy wykonać kod na zdalnym serwerze bez posiadania loginu i hasła do konta.

POC

Gitea 1.4.0 Zdalne wykonanie kodu