Row Level Security – luka w bezpieczeństwie?

RLS_Bug_00

Row Level Security był jedną z najbardziej wyczekiwanych funkcjonalności w SQL Server. Wraz z SQL Server 2016 dostaliśmy do dyspozycji ten mechanizm – wielu z nas po pierwszych demonstracjach wyobrażało sobie ogrom możliwości jakie on oferuje. Jednakże póki co posiada on kilka pomniejszych luk na które trzeba zwrócić uwagę – jedną z nich postaram się opisać w ramach niniejszego artykułu. Zapraszam do lektury.

Jak zapewne wiecie (a jeśli nie wiecie odsyłam do mojego artykułu gdzie opisuję Row Level Security – znajdziecie go tutaj) bohater niniejszego artykułu działa na podstawie funkcji użytkownika pełniących rolę predykatu filtrującego otrzymywany rezultat lub blokującego określone akcje. W momencie gdy użytkownik wysyła dane np. w postaci zapytania SELECT to SQL Server zgodnie z predykatem blokującym musi je odpowiednio przefiltrować i tego typu filtracja następuje dla każdego kolejnego wiersza w tabeli, którą użytkownik chce odpytać. Niesie to ze sobą pewne konsekwencje. Aby to zobrazować wykonajmy pewien przykład – na początku stwórzmy sobie bazę testową:

Następnie stwórzmy sobie schemat w którym umieścimy wszystkie funkcje związane z RLS:

Stwórzmy również dwie testowe tabele, które będą kopią 1:1 dwóch tabel z bazy WideWorldImportersDW:

Kolejnym krokiem będzie stworzenie użytkownika o loginie “Taj”, któremu nadamy prawo odczytu danych z obu nowo powstałych tabel oraz damy mu uprawnienie SHOWPLAN aby mógł podglądać plany wykonania:

Gdy mamy gotowe obiekty testowe przejdźmy do implementacji Row Level Security- po pierwsze stworzymy funkcję, która będzie predykatem filtrującym tj. będzie zwracać tylko te dane z tabeli Sales, które są odpowiednie dla odpytującego użytkownika. W skrócie można powiedzieć, że dane z tabeli Sales będą połączone z tabelą Employee i przefiltrowane aby pokazywać tylko tą sprzedaż z którą jest połączony aktualnie odpytujący użytkownik tj. w tabeli Employee kolumna Preferred Name będzie równa jego loginowi.Ponadto dodaliśmy warunek, że jeżeli użytkownik będzie należał do grupy sysadmin to wszystkie wiersze mają zostać zwrócone:

Następnie stwórzmy sobie Politykę Bezpieczeństwa i przypiszmy do niej naszą funkcję jako predykat blokujący od razu ją przy tym włączając:

Teraz przetestujmy nasz mechanizm zabezpieczeń – przełączmy się na użytkownika Taj i wykonajmy proste polecenie SELECT:

Wszystko działa w porządku i użytkownik nie ma dostępu do innych danych niż swoje – czy aby na pewno? Upewnijmy się wykonując zapytanie, które powinno zwrócić tylko nazwę naszego loginu czyli Taj:

Spójrzmy na plan zapytania SELECT * FROM Sales wykonywany jako użytkownik Taj. Po lewej stronie plan estymowany po prawej plan aktualny:

Estymowana liczba wierszy jest zdecydowanie inna niż aktualna ze względu na fakt, iż nasz plan został pobrany z cache gdzie trafił on wcześniej gdy wywołałem to zapytanie jako administrator i widziałem wszystkie wiersze. Możliwość podglądania planów zapytań daje nam pewne możliwości interpretacji wyników bo sama liczba wierszy pozwala wysnuć pewne wnioski. Co ciekawe sama filtracja wierszy przez predykat blokujący ma miejsce w Nested Loops:

Bazując na powyższym planie możemy zakładać, że odfiltrowanie danych zgodnie z regułami bezpieczeństwa może mieć miejsce po odczytaniu danych operatorem Index Scan. Jaki z tego wniosek? A no taki, że przed zastosowaniem reguły bezpieczeństwa możemy wstawić własny warunek! O co mi chodzi? Sprawdźmy na przykładzie! W ramach naszej testowej tabeli mamy kolumnę Profit, która zawiera informację o zysku transakcji – jak poznać tę wartość dla innych pracowników niż my sami? Wystarczy wywołać błąd! Tak nie przeczytaliście tego źle – wystarczy wywołać błąd dzielenia przez zero:

Jeżeli powyższe zapytanie zwróci błąd oznacza to, że któryś rekordów w kolumnie Profit posiadał wartość 8.5 ponieważ 8.5/0 wygeneruje nam właśnie ten błąd. Pojawia się tutaj pytanie czy to któryś z rekordów przypisanych do naszego bieżącego użytkownika? Otóż niekoniecznie!

Spójrzcie na plan wykonania i to gdzie nastąpiła filtracja w tym przypadku:

Została ona wykonana bardzo wcześnie bo już przy skanowaniu indeksu kolumnowego czyli tak jak zakładaliśmy przed zastosowaniem filtracji wg. reguł bezpieczeństwa. Oznacza to dla nas, że tak naprawdę możemy uzyskać informacje z jakim zyskiem zostały zrealizowane transakcje innych osób!

Aby tego dokonać wystarczy, że wykonamy zapytanie w kilku pętlach. Pierwsza pętla będzie przebiegała po identyfikatorach użytkowników – zakładamy, że są to liczby całkowite. Druga pętla będzie przebiegała po datach w zadanym okresie czasu, trzecia z kolei będzie sprawdzała wartość potencjalnego zysku. Jeżeli nastąpi błąd dzielenia przez zero oznacza to, że natrafiliśmy na właściwą kombinację daty, identyfikatora użytkownika oraz zysku. Aby nasza pętla nie przerwała swojego działania w momencie gdy natrafi na pierwszy błąd – przechwycimy ten błąd i bieżące wartości umieścimy w tabeli tymczasowej, którą będziemy mogli później odczytać. Dla szybkości działania sprawdzimy możliwe wpisy dla identyfikatorów od 0 do 10

Już w trakcie wykonywania zauważymy, że nasz algorytm działa i do naszej testowej tabeli trafiły jakieś wpisy:

Po wykonaniu naszej pętli wróćmy do naszego oryginalnego kontekstu i sprawdźmy czy nasza testowa tabela odpowiada rzeczywistym danym w tabeli Sales:

Wiecie co? Nasza tabela jest niemal identyczna z tym co znajduje się w oryginalnej tabeli Sales! Jedyna różnica polega na tym, że nasza pętla nie wyłapała sytuacji gdy ten sam SalesPerson w tym samym dniu zawarł transakcję z tym samym zyskiem – myślę, że gdybyśmy bardziej dopracowali nasz algorytm to moglibyśmy odwzorować całą tabelę sprzedażową! Czy jest to błąd ze strony Row Level Security? Jak najbardziej tak! Bo widać w jak łatwy sposób można obejść to ograniczenie.

Czy powyższa pętla spełnia swoją rolę tylko i wyłącznie w przypadku liczb? Oczywiście, że nie – możemy również stworzyć coś takiego na polach tekstowych i również uzyskać sztuczny błąd:

Jednakże nie we wszystkich scenariuszach uzyskamy dane – w przypadku długich pól tekstowych będzie to trudne zadanie (co nie znaczy niemożliwe). Jednakże mimo wszystko wydaje mi się, że błąd z dzieleniem przez zero jest poważną luką bezpieczeństwa i jedynym rozwiązaniem jakie na ten moment widzę jest przeniesienie filtracji rekordów na dalszą część planu wykonania co oczywiście wpłynie negatywnie na wydajność. Innym sposobem na załatanie tej luki byłoby automatyczne obsłużenie błędu 8134 w sekcji WHERE gdy włączony jest RLS tj. w przypadku jego wystąpienia rekord nie był by zwracany. Na ten moment mechanizm Row Level Security mimo ogromnego potencjału nie może być zastosowany w każdym przypadku i należy do niego podchodzić z ostrożnością.

Adrian Chodkowski
Follow me

Adrian Chodkowski

SQL geek, Data enthusiast, Consultant & Developer
Adrian Chodkowski
Follow me

Latest posts by Adrian Chodkowski (see all)

Leave a Comment

Your email address will not be published. Required fields are marked *