Tutorial: Odczyt zminifikowanego skryptu obcego, zanim trafi na stronę

Spis treści
Dostawca przysyła fragment kodu, ktoś wkleja go do menedżera tagów i od tej chwili na każdej stronie działa plik, którego nikt nie przeczytał, z pełnym dostępem do wszystkiego, co strona zawiera.
Porządne przeczytanie go jest pracą przeglądu bezpieczeństwa. Przeczytanie na tyle dobrze, by rozstrzygnąć, to sześć przeszukań sformatowanej kopii i kosztuje około dwudziestu minut.

Co formatowanie przywraca, a czego nie
Minifikacja usuwa końce linii i wcięcia, skraca nazwy zmiennych lokalnych do jednego lub dwóch znaków i wyrzuca komentarze. Formatowanie cofa pierwsze z tego i nic poza tym.
To wystarcza, bo części, na których zależy, zachowują nazwy. Właściwości obiektu przeglądarki – localStorage, fetch, navigator – nie da się przemianować, bo przeglądarka przestałaby ją rozpoznawać. Każda zdolność skryptu stoi więc nadal wypisana w pełni, otoczona zmiennymi o nazwach a, t i e.
# pobrac kopie i sformatowac
curl -s https://cdn.anbieter.example/widget.js -o widget.min.js
npx js-beautify widget.min.js -o widget.js
# rozmiar przed i po mowi, jak mocno bylo zageszczone
wc -c widget.min.js widget.js
Przeglądarka potrafi to samo bez narzędzia: panel źródeł ma przycisk formatowania rozwijający plik na miejscu – droga szybsza przy krótkim spojrzeniu i błędna przy przeglądzie dokumentowanym, bo nie zostaje żaden plik, z którym dałoby się porównać następnym razem.
Sześć rzeczy wartych wyszukania
Każde z tych przeszukań odpowiada na jedno pytanie, a razem opisują, co skrypt jest w stanie zrobić.
# 1 czy cos wysyla na zewnatrz?
grep -nE 'fetch\(|XMLHttpRequest|sendBeacon|new Image|WebSocket' widget.js
# 2 czy cos zapisuje trwale?
grep -nE 'localStorage|sessionStorage|document\.cookie|indexedDB' widget.js
# 3 czy doladowuje dalszy kod?
grep -nE "createElement\(['\"]script|\\.src\\s*=|import\\(" widget.js
# 4 czy podsluchuje wpisywanie?
grep -nE "addEventListener\\(['\"](input|keydown|keyup|change|submit)" widget.js
# 5 czy zbiera cechy urzadzenia?
grep -nE 'getContext|AudioContext|navigator\.(plugins|hardwareConcurrency)|RTCPeerConnection' widget.js
# 6 dokad to idzie?
grep -oE 'https?://[a-z0-9.-]+' widget.js | sort -u
Dwa pierwsze rozstrzygają, czy w ogóle potrzebna jest kategoria zgody. Skrypt, który nic nie przechowuje i nic nie wysyła, jest pomocą wyświetlającą; taki, który robi jedno i drugie, jest przetwarzaniem danych – a ta różnica należy do polityki prywatności, a nie do zgłoszenia.
Czwarty zasługuje na szczególną uwagę przy wszystkim osadzonym w pobliżu formularza. Nasłuch na input nie jest automatycznie rejestrowaniem klawiszy – okienko czatu słusznie słucha własnego pola – lecz sprawdzeniem jest to, do którego elementu został przypięty, a to stoi kilka linii niżej.
Piąty to grupa cech rozpoznawczych. Wywołanie canvas w widgecie, który coś rysuje, jest spodziewane; wywołanie canvas we fragmencie analitycznym, który nie wyświetla nic widocznego, ma jeden cel – a ten cel niesie skutki dla zgody, niezależnie od tego, jak nazywa go dostawca.
Odróżnienie zminifikowanego od zaciemnionego
To różne rzeczy, a różnica jest punktem rozstrzygnięcia, a nie subtelnością.
Minifikacja jest optymalizacją rozmiaru i zostawia czytelną strukturę. Zaciemnianie jest pomyślane tak, by uniemożliwić czytanie, i ma rozpoznawalne cechy: dużą listę ciągów zakodowanych szesnastkowo na początku pliku, funkcję indeksującą wyszukującą w niej, String.fromCharCode w pętli, atob wokół czegoś, co nie jest obrazem, a przede wszystkim eval albo new Function na złożonym ciągu znaków.
grep -nE 'eval\(|new Function\(|atob\(|String\.fromCharCode|\\\\x[0-9a-f]{2}' widget.js
Trafienie w tym miejscu nie dowodzi złej woli – część dostawców zaciemnia, by chronić własną logikę. Oznacza jednak, że sześć powyższych przeszukań przestaje odpowiadać na pytanie, bo zdolności dają się złożyć w czasie działania z ciągów, których w takiej postaci w pliku nie ma.
Skutek praktyczny: zaciemnionego skryptu obcego nie da się sprawdzić przez czytanie. Zostaje zachowanie – wczytać w stronie testowej, zapisać panel sieci i panel pamięci i porównać z tym, co podaje dostawca. Jeżeli to nie jest do przyjęcia, odpowiedzią nie jest „lepiej szukać”, lecz „inny dostawca”.
Wyciągnięcie listy hostów
Szóste przeszukanie daje wynik najbardziej użyteczny od razu: każdą domenę wymienioną w pliku. Ta lista należy do polityki bezpieczeństwa, a zbudowanie jej z pliku zamiast z dokumentacji wyłapuje punkty końcowe, o których dokumentacja zapomniała.
grep -oE 'https?://[a-z0-9.-]+' widget.js | sort -u
https://cdn.anbieter.example
https://api.anbieter.example
https://events.anbieter.example
https://fonts.gstatic.com
Dwa z czterech są tymi ciekawymi. Host pojawiający się wyłącznie w komentarzu albo w ciągu dokumentacyjnym jest szumem; host w wywołaniu fetch jest celem. Sprawdzenie, który jest który, kosztuje po jednym dodatkowym przeszukaniu na host i jest różnicą między polityką działającą a przepisaną z wpisu na blogu.
Content-Security-Policy:
script-src 'self' https://cdn.anbieter.example;
connect-src 'self' https://api.anbieter.example https://events.anbieter.example;
font-src 'self' https://fonts.gstatic.com;
Rozpoczęcie w trybie samego raportowania warte jest tego dodatkowego tygodnia. Zgłoszenia wymieniają każdy host przeoczony przez analizę – a taki zawsze się znajdzie, bo skrypt doładowujący drugi skrypt dziedziczy jego cele, a tych w pierwszym pliku nie ma.
Przypięcie i miejsce, w którym zawodzi
Wszystko powyższe opisuje jedną wersję jednego pliku. Dostawca może ją jutro wymienić i nic w witrynie tego nie zauważy.
Przy pliku statycznym lukę tę domyka Subresource Integrity: suma kontrolna w znaczniku skryptu, a przeglądarka odrzuca plik do niej niepasujący. Zmieniony plik przestaje się wtedy wczytywać, zamiast działać niesprawdzony – i to jest właściwa porażka.
<script src="https://cdn.anbieter.example/widget.js"
integrity="sha384-oqVuAfXRKap7fdgcCY5uykM6+R9GqQ8K/uxy9rx7HNQlGYl1kPzQho1wx4JwY8wC"
crossorigin="anonymous"></script>
Przy kontenerze tagów to nie działa, a powód jest zasadniczy: kontener ma się zmieniać, gdy tylko ktoś opublikuje. Suma kontrolna musiałaby być uzupełniana przy każdej publikacji, czego nikt nie robi – kontener wczytywany jest więc bez sprawdzania integralności albo wcale.
To, co wchodzi tam na jego miejsce, jest pytaniem o odpowiedzialność, a nie technicznym: kto może publikować i czy na zmianę patrzy druga osoba. To odpowiedź uczciwa i warto zapisać ją właśnie tak, zamiast udawać, że pokrywa ją nagłówek.
Co pokazuje dopiero obserwacja
Trzy rzeczy umykają czytaniu pliku, a wszystkie trzy pojawiają się w obserwacji, a nie w analizie.
Pierwszą jest skrypt doładowany. Ładowacz pobierający właściwą zawartość w czasie działania nie zawiera żadnej ze zdolności, które ta zawartość ma – sześć przeszukań wraca czysto z pliku, który nie robi nic poza pobraniem ciekawej części. Dyrektywa script-src bez symbolu wieloznacznego czyni to widocznym, bo drugi plik potrzebuje własnego wpisu.
Drugą jest zachowanie warunkowe. Skrypt zbierający dane tylko na określonych stronach, w określonych krajach albo od określonej daty jest w pełni czytelny i nie daje powodu, by zajrzeć akurat w rozstrzygającą gałąź. Znajduje to wyłącznie obserwacja w czasie, a cotygodniowe zestawienie z zachowaną kopią jest jej najtańszą postacią.
curl -s https://cdn.anbieter.example/widget.js | sha256sum
# porownac z zapisana wartoscia, co tydzien
Trzecią jest to, co dostawca robi z danymi po ich dotarciu, a na to nie odpowie żaden plik po tej stronie. To należy do umowy – a przegląd techniczny jest tym, co czyni umowę dostatecznie konkretną, by chcieć ją podpisać: wymienia punkty końcowe, klucze pamięci i zdarzenia, i zamienia ogólne zapewnienie w listę, do której da się kogoś przypiąć.
Pytania i odpowiedzi
Czy Subresource Integrity chroni także skrypt, który ładowacz doładowuje w czasie działania?
Nie. Suma kontrolna dotyczy tylko tego jednego znacznika skryptu, w którym się znajduje. Gdy ładowacz w czasie działania tworzy nowy element skryptu, przeglądarka sprawdza jego plik tylko wtedy, gdy sam ładowacz poda atrybut integrity, a zwykle tego nie robi. Przypięty ładowacz może więc pozostać bez zmian, podczas gdy zawartość, którą pobiera, może być każdego dnia inna.
Tę lukę domyka w tym miejscu polityka bezpieczeństwa, a nie suma kontrolna: dyrektywa script-src bez symbolu wieloznacznego pozwala wczytać drugi plik tylko z hosta wpisanego wprost. Sama zawartość jest wtedy osobnym plikiem: przechodzi przez te same sześć przeszukań co ładowacz, a jej suma kontrolna trafia do cotygodniowego porównania.
Do czego służy atrybut crossorigin w znaczniku skryptu z sumą kontrolną?
Plik z obcego hosta da się sprawdzić tylko wtedy, gdy przeglądarka pobiera go przez CORS; bez tego trybu jego zawartość pozostaje dla kontroli niedostępna, a przeglądarka odrzuca skrypt. crossorigin="anonymous" włącza ten tryb i nie wysyła przy tym ciasteczek. Warunkiem jest to, że dostawca serwuje plik z pasującym nagłówkiem Access-Control-Allow-Origin; jeśli go brakuje, skrypt nie wczytuje się mimo poprawnej sumy kontrolnej.
Co należy zrobić, gdy cotygodniowe porównanie sumy kontrolnej wykaże różnicę?
Inna suma kontrolna mówi tylko, że plik się zmienił, a nie co się zmieniło. Sensowna jest stała kolejność:
- Pobrać nową wersję, sformatować ją i odłożyć obok starej. Porównanie obu sformatowanych kopii linia po linii pomaga tylko w ograniczonym stopniu, bo nowy przebieg minifikacji może nawet przy drobnej zmianie na nowo rozdać krótkie nazwy zmiennych i w ten sposób zmienić niemal każdą linię.
- Powtórzyć sześć przeszukań i wyszukiwanie zaciemnienia na nowej wersji, a trafienia zestawić z trafieniami starej. Nowa zdolność, na przykład pojawiające się po raz pierwszy
sendBeaconalbo dostęp dodocument.cookie, jest ustaleniem, o które chodzi. - Ponownie wyciągnąć listę hostów i porównać ją z polityką bezpieczeństwa. Nowy host należy albo dopisać do polityki, albo wyjaśnić z dostawcą.
- Zapisać nową wartość i odnotować, kiedy i dlaczego została przyjęta.
Jeśli skrypt jest osadzony z Subresource Integrity, po zmianie i tak przestaje się wczytywać, dopóki suma kontrolna w znaczniku skryptu nie zostanie zaktualizowana. O tym, czy zostanie zaktualizowana, rozstrzyga wtedy właśnie ta kontrola.