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

Częściowe obejście Same Origin Policy w Chromium, czyli „Google Roulette”

19 listopada 2022, 14:48 | Aktualności | 0 komentarzy

Wyobraźcie sobie, że odwiedzasz jakąś stronę webową, np. developers.google.com, która prosi o uruchomienie konsoli javascriptowej w przeglądarce i wykonanie w niej funkcji: magic(). Czy zrobilibyście to?

Oczywiście ta funkcja może mieć dostęp do wszystkiego w ramach originu https://developers.google.com, ale nie powinna mieć dostępu do danych z innych domen. A może jednak może?

W tej notce opisuję błąd, który zgłosiłem do Chromium, który dowodzi, że wpisanie kodu w konsoli JS może nieco szersze konsekwencje niż mogłoby się początkowo wydawać.

Odrobina tła

Wspomniany błąd ma numer #1069486, a zgłosiłem go w kwietniu 2020.

Na dzień 19 listopada 2022, błąd jest wciąż nienaprawiony, więc w zasadzie mówimy tutaj o 0-dayu. Dostałem jednak zgodę od ekipy Chromium, by błąd ujawnić. Poza tym, scenariusz ataku wymaga tutaj od ofiary wpisania kodu w konsoli JS, więc błąd nie jest łatwy w praktycznym wykorzystaniu.

Wciąż jednak mogą istnieć jakieś sposoby na dalszą eskalację błędu, których nie udało mi się znaleźć. Zachęcam, drodzy czytelnicy, do dalszych analiz 😃

Narzędzia w konsoli JS

Zacznijmy jednak od podstaw. Gdy wpisujemy jakiś kod w konsoli JS w przeglądarce, jest to mniej więcej równoznaczne z wykonaniem takiego samego kodu przez samą stronę www. Oznacza to, że z poziomu konsoli nie możemy złamać zasad Same Origin Policy czy innych fundamentalnych zasad bezpieczeństwa narzucanych przez przeglądarki.

Prawda jest jednak taka, że w konsoli JS można zrobić nieco więcej niż w „zwykłym” JS. Różnica tkwi w narzędziach konsolowych. Istnieje zestaw funkcji, które działają tylko i wyłącznie w konsoli, ale nie są dostępne dla „zwykłego” JS. Zobaczmy, co to znaczy.

Jako przykład weźmy funkcję $$, która jest krótszą formą document.querySelectorAll, dostępną tylko w konsoli.

Gdy spróbujemy wykonać ją z konsoli, funkcja po prostu działa.

Screenshot showing that you can execute $$ from the console
Funkcja $$ działa z konsoli

Jednak gdy spróbujemy przygotować fragment HTML z tą funkcją, np.

<script>
  $$("body");
</script>

To otrzymamy błąd ReferenceError

Screenshot showing that you cannot execute $$ from the HTML
Ale nie działa ze zwykłego JS

Myślę, że takie zachowanie jest oczekiwane. Można z niego wyciągnąć wniosek, że Chromium musi w jakiś sposób odróżniać, czy kod JS był uruchomiony z konsoli i na tej podstawie decyduje, czy pewne funkcje powinny być dostępne.

Zacząłem się zastanawiać, w jaki dokładnie sposób Chromium śledzi źródło wywołania funkcji. Wpadłem na pomysł, że utworzę tag <script> zawierający referencję do $$ i zobaczę co się stanie, gdy wywołam tak utworzoną funkcję z konsoli.

Mam więc następujący HTML:

<script>
  function magic() {
    console.log($$);
  }
</script>

W momencie wykonania magic() z konsoli, mogłem sobie wyobrazić dwa zachowania Chromium:

  1. Funkcja wykona się normalnie – jako że wykonałem ją z konsoli, to referencja do $$ istnieje.
  2. Funkcja wyrzuci ReferenceError, bo referencja do $$ de facto znajduje się w kodzie w „normalnym” JS.

Okazało się, że Chromium zachowywało się w pierwszy z wymienionych sposobów.

Screenshot showing that reference to $$ exists
Referencja do $$ istnieje

Ten przykład dowodzi, że „zwykły” JavaScript może zawierać referencje do narzędzi konsolowych; i zostaną one wykonane, jeśli tylko ta funkcja zostanie wykonana z konsoli.

Takie zachowanie sprawiło, że zacząłem się zastanawiać nad scenariuszem z początku tej notki: zakładamy, że odwiedzamy pewną stronę, która prosi nas o wpisanie czegoś w konsoli. I czy ta konsolowa funkcja może zrobić coś złego?

Okazuje się, że: tak, może! A konkretniej funkcją, której użyję będzie debug().

Zanim jednak do niej przejdziemy, pomówmy o jeszcze jednym istotnym aspekcie implementacyjnym w Chromium: Site Isolation.

Site Isolation i ponowne użycie procesu

Site Isolation to jeden z najistotniejszych mechanizmów bezpieczeństwa wprowadzonych w Chromium w ostatnich latach, którego znaczenie wzrosło jeszcze bardziej po odkryciu podatności Spectre. Na oficjalnej stronie Chromium można znaleźć dobre podsumowanie tego mechanizmu, jego zalet oraz kompromisów jakich wymaga.

To co jest ważne w kontekście tego artykułu to fakt, że różne witryny (sites) są uruchamiane w różnych procesach. W najprostszym ujęciu można powiedzieć, że ta sama witryna jest wtedy, gdy dwie domeny mają tę samą główną domenę, a ew. różne poddomeny. Czyli np. admin.google.com i test.google.com to ta sama witryna (same site).

Tak więc o ile różne witryny są uruchamiane w różnych procesach, tak okazuje się, że różne domeny z tej samej witryny mogą zostać uruchomione w tym samym procesie.

Można to potwierdzić w następujący sposób:

  1. Uruchom menadżer zadań z Chrome.
  2. Wejdź na https://support.google.com i sprawdź jaki jest ID procesu (w przykładzie poniżej, jest to 50476).
Screenshot showing Task Manager of Chromium

3. W pasku adresu wpisz: https://workspace.google.com

4. Zauważ, że ID procesu się nie zmienia (w przykładzie, wciąż 50476)

Screenshot showing Task Manager of Chromium

Widzimy więc, że pomimo faktu, że uruchomiliśmy dwie rożne domeny (ale w jednej witrynie), to pozostały one w tym samym procesie. To się przyda już niebawem.

Poznajmy debug()

debug() to bardzo przydatne narzędzie w konsoli Chromium; używam go w trakcie szukania błędów bardzo często (choć to może być temat na inny tekst).

Ogólna idea jest taka, że używając debug() możemy założyć breakpoint wywołany, gdy funkcja przekazana jako pierwszy argument zostanie wykonana. Drugi argument do debug() jest opcjonalny i jest ciągiem znaków zawierającym kod JS z warunkiem logicznym – breakpoint zostanie zatrzymany, tylko jeśli ten kod zwróci wartość prawdziwą.

Poniższy fragment kodu wyjaśnia użycie debug():

function foobar(arg) {
  /* ... */
}
// Przykład #1
debug(foobar); // breakpoint założony, gdy tylko `foobar()` zostanie wywołane
// Przykład #2
debug(foobar, 'arguments[0] === "test"'); // breakpoint założony, tylko gdy pierwszy argument będzie równy "test"

Charakterystyczną cechą debug() jest to, że można też zestawiać breakpointy na wbudowanych funkcjach JS! Na przykład: chcecie zatrzymać kod w momencie wywołania document.appendChild? debug() przychodzi z pomocą!

debug(document.appendChild); // zatrzyma kod w momencie wywołania document.appendChild

Co ciekawe, można nadużyć drugiego argumentu do debug(), by wywołać inną funkcję, bez wywoływania faktycznego breakpointu. Na przykład console.log() zwraca undefined, więc kod nie zostanie zatrzymany, ale dane zostaną zalogowane.

Na przykład spróbuj wejść na www.google.com i wpisz poniższy kod w konsoli:

debug(document.appendChild, `console.log("appendChild called")`);

Gy wykonasz następnie jakiekolwiek wyszukiwanie, w konsoli pojawi się bardzo dużo komunikatów o wywołaniu appendChild.

Screenshot showing Task Manager of Chromium

I teraz zaczyna się ta zaskakująca część. Po odświeżeniu strony komunikaty "appendChild called" będą się nadal wyświetlać!

Takie zachowanie było dla mnie dość zaskakujące; założyłem wcześniej, że odświeżenie strony usunie wszystkie breakpointy i proces będzie „wyczyszczony”. Tak się jednak nie dzieje.

Na tym jednak nie koniec – wcześniej weryfikowaliśmy, że Chromium może uruchamiać dwie różne domeny w tym samym procesie, o ile są w tej samej witrynie, nawet jeśli są to inne domeny. Okazuje się, że jeśli ustawimy breakpoint w jednej domenie, a następnie przeniesiemy się do innej domeny (będąc w tym samym procesie), breakpoint nadal działa!

Na poniższym filmiku udowadniam to, wpisując najpierw kod w www.google.com a po przekierowaniu do developers.google.com on wciąż działa.

Takie zachowanie ma oczywiste skutki bezpieczeństwa: można wpisać kod w jednej domenie, a on wykona się w innej. Jest to więc ominięcie Same Origin Policy, choć ograniczone tylko do jednej witryny.

„Google Roulette”

Przejdźmy więc do tytułu tego postu, tj. „Google Roulette”. Był to mój pomysł na pokazanie skutków tej podatności. Zakładamy, że napastnik ma XSS-a w developers.google.com i prosi o wpisanie w konsoli po prostu słowa magic. I nagle – mamy XSS-a na wielu domenach Google! Pokazuje to poniższy filmik.

A oto kod:

function xss() {
  if (window.__alerted__) return false;
  // I could use more origins but you get the idea
  const ORIGINS = [
    "https://developers.google.com",
    "https://www.google.com",
    "https://support.google.com",
    "https://careers.google.com",
    "https://images.google.com",
    "https://workspace.google.com",
    "https://firebase.google.com",
  ];
  alert(`XSS on ${origin}!`);
  // Just redirect to the next origin from the list
  const nextOrigin = ORIGINS[ORIGINS.indexOf(origin) + 1];
  window.__alerted__ = 1;
  if (!nextOrigin) return false;
  location = nextOrigin;
  return false;
}

window.__defineGetter__("magic", () => {
  // Call xss() after document.appendChild is called
  debug(document.appendChild, `(${xss})()`);
  location.reload();
});

Wniosek

W artykule opisałem nienaprawiony błąd w Google Chrome, który pozwala na obejście Same Origin Policy w ramach jednej witryny. O ile skutki błędu są oczywiście poważne, o tyle jestem w stanie zrozumieć, że ekipie Chrome nie spieszy się z jego naprawą: wykorzystanie błędu wymaga wpisania kodu w konsoli DevTools, więc trudno ją wykorzystać w praktyce.

Niemniej, błąd nadal występuje i jest obecny w najnowszych wersjach Chromium.

— Michał Bentkowski

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



Komentarze

Odpowiedz