Pisanie bezpiecznych aplikacji w .NET
Autor: Michał Sobieraj
Artykuł ten jest wstępem do omówienia zagadnień bezpieczeństwa aplikacji tworzonych na platformie .NET. W dzisiejszych czasach użytkownicy systemu Windows są narażeni na wiele zagrożeń, poczynając od wirusów i koni trojańskich, a kończąc na zdalnych atakach wykorzystujących luki w systemie bądź aplikacjach. Wszystkie te zagrożenia powodują, że zarówno administratorzy systemów, jak i twórcy oprogramowania powinni zwrócić szczególną uwagę na autoryzację użytkownika, kontrolę dostępu do określonych zasobów czy funkcji oprogramowania. Może to ochronić nas przed skutkami błędnego wykorzystania aplikacji, niekompetencji pracowników lub kradzieży cennych danych, a także powstrzymać złośliwe oprogramowanie przed dokonaniem zniszczeń w systemie.
Omawianie środków bezpieczeństwa zaczniemy od przedstawienia funkcji zwanej bezpieczeństwem opartym na rolach (role based security − RBS). RBS umożliwia kontrolowanie, do czego ma dostęp użytkownik w związku ze swą nazwą i przynależnością do grupy. Innymi słowy będziemy sprawdzać tożsamość użytkownika i weryfikować jego prawa do korzystania z zasobów. Możemy to zrobić na dwa sposoby:
- deklaratywny (declarative security), polegający na dodawaniu atrybutów do klas, metod, bloków kodu, które będą wchodzić w skład metadanych,
- programowy (imperative security), polegający na dynamicznym tworzeniu zestawu uprawnień, które są sprawdzane na etapie wykonywania kodu.
Aby wykorzystać klasy, które umożliwią nam identyfikację i autoryzację użytkownika, musimy dodać do projektu przestrzeń nazw System.Security.Principal. Podstawową klasą reprezentującą użytkownika w systemie Windows jest klasa WindowsIdentity, która daje nam dostęp do jego tożsamości. Klasa nie odpowiada za identyfikację użytkownika, ponieważ tym zajmuje się system. WindowsIdentity przechowuje tylko rezultaty tej identyfikacji. Informacje o użytkowniku możemy uzyskać za pomocą następującego kodu:
WindowsIdentity current = WindowsIdentity.GetCurrent();
Console.WriteLine(current.Name);
Gdy wiemy już, jak pobrać informacje o użytkowniku, kolejnym etapem jest określenie, do jakich grup należy. Do tego posłuży nam klasa WindowsPrincipal. Obiekt klasy jest tworzony na podstawie klasy WindowsIdentity. Do uzyskania informacji o przynależności użytkownika do poszczególnych grup wykorzystamy funkcję IsInRole. Podany niżej kod wykona funkcję pewnej klasy tylko w przypadku, gdy użytkownik jest administratorem, w przeciwnym razie zostanie powiadomiony o braku uprawnień do wywołania funkcji.
WindowsIdentity currentI = WindowsIdentity.GetCurrent();
WindowsPrincipal currentP = new WindowsPrincipal(currentI);
if(currentP.IsInRole(WindowsBuiltInRole.Administrator))
{
Critical.execute();
}
else
{
Console.WriteLine("No execute permission, not enough rights");
}
W ten sposób możemy kontrolować, kto wykonuje nasz kod, i w zależności uprawnień, jakie ma w systemie, zezwalać lub nie na wykonanie ważnych dla bezpieczeństwa części kodu. Do sprawdzenia uprawnień posiadanych przez użytkownika wykorzystamy klasę PrincipalPermission, która dziedziczy po IPermission. Jeśli przekażemy nazwę użytkownika lub rolę do konstruktora klasy, możemy w sposób deklaratywny lub programowy żądać sprawdzenia uprawnień lub przynależności do określonej roli.
Autoryzacja deklaratywna pozwala na określenie niezbędnych uprawnień dla całej klasy lub metody. Jeśli użyjemy tego sposobu sprawdzania uprawnień, kontrola odbędzie się przy ładowaniu aplikacji przez CLR, zanim jakikolwiek zostanie wykonany. Autoryzację deklaratywną stosujemy wówczas, gdy z góry wiemy, którzy użytkownicy lub jakie role mają prawo wywołać nasz kod. Aby wykorzystać tego typu autoryzację, musimy dodać atrybut, skojarzony z obiektem typu Permission, do wybranej przez nas klasy lub jej metody. Podany niżej kod pokazuje, jak sprawdzić, czy użytkownik wywołujący metodę ma do tego uprawnienia. Warto dodać, że zabezpieczona klasa zawsze powinna być wywoływana w blokach try/catch, ponieważ w przypadku próby nieautoryzowanego dostępu do kodu pojawi się wyjątek SecurityException. Jeśli nie obsłużymy go poprawnie, grozi nam utrata danych.
class Manager
{
[PrincipalPermission(SecurityAction.Demand, Role="Managers")]
static public void GetDocs()
{
Console.WriteLine("Manager operation");
}
}
…
try
{
Manager.GetDocs();
}
catch (System.Security.SecurityException e)
{
Console.WriteLine("Only for managers");
Console.WriteLine(e.PermissionState);
}
Zanim nastąpi wywołanie metody Demand, musimy umieścić w naszym kodzie następującą linię kodu:
AppDomain.CurrentDomain.SetPrincipalPolicy(PrincipalPolicy.WindowsPrincipal);
Ustawia ona główną politykę bezpieczeństwa (ang. Principal security policy) na WindowsPrincipal dla domeny aplikacji. Domyślnie polityka bezpieczeństwa jest ustawiona na UnauthenticatedPrincipal i jeśli jej nie zmienimy, funkcja Demand nie zwróci poprawnego wyniku.
Niedogodnością tego sposobu weryfikacji jest to, że możemy ograniczać dostęp do całych metod lub klas. Problem ten eliminuje autoryzacja programowa, która pozwala wejść do wnętrza metod i żądać autoryzacji dostępu do konkretnych partii kodu. W tym przypadku sami musimy stworzyć obiekt PrincipalPermission, w konstruktorze którego możemy określić, jaką nazwę powinien mieć użytkownik, do jakiej roli należeć lub czy powinien być uwierzytelniony. Gdy zamiast nazwy jako parametr podamy null, przyznamy uprawnienia użytkownikowi o dowolnej nazwie lub roli.
try
{
PrincipalPermission pp =
new PrincipalPermission(null, System.Environment.MachineName +
@"\Administrators" , true);
pp.Demand();
//restricted operations
}
catch (System.Security.SecurityException e)
{
Console.WriteLine("Access denied");
}
I tym razem kod powinniśmy opatrzeć blokami try/catach, ponieważ w przypadku braku odpowiednich uprawnień zostanie zwrócony wyjątek SecurityException. Warto zauważyć, że typ autoryzacji programowej można łączyć z różnego typu warunkami, np. kończącymi się zasobami w systemie. W przypadku wystąpienia takiego warunku ograniczamy dostęp do pewnych funkcji, przyznając go tylko administratorom. Zwykli użytkownicy zostaną pozbawieni możliwości wykorzystania resztek pamięci dostępnej w systemie. Właśnie w takich przypadkach sprawdzać się będzie autoryzacja programowa.
Kolejnym mechanizmem platformy .NET, służącym do polepszania bezpieczeństwa pisanych przez nas aplikacji, jest mechanizm bezpieczeństwa opartego na uprawnieniach do korzystania z kodu (code access security − CAS). Podczas gdy RBS pozwalał na identyfikację i autoryzację użytkownika, CAS pomoże nam w kontrolowaniu użytkownika, by nie użył niewłaściwie kodu, do wykorzystania którego ma uprawnienia. Bezpieczeństwo oparte na uprawnieniach umożliwia nam kontrolowanie dostępu do takich zasobów jak pliki systemowe, rejestr, drukarki, logi zdarzeń, a także zapewnia kontrolowanie, czy aplikacja może łączyć się z Internetem. Za pomocą CAS będziemy się upewniać, czy nasza aplikacja ma potrzebne prawa oraz czy nie posiada większych uprawnień niż są jej konieczne, aby ograniczyć możliwości złośliwego wykorzystania kodu.
Ponieważ CAS identyfikuje podzespoły, a nie użytkowników, nie może korzystać z takich danych jak nazwa użytkownika czy przynależność do roli. Zamiast tego, podczas uruchamiania podzespołu (ang. evidence) zbierane są identyfikujące go informacje, na podstawie których CLR przydziela prawa do wykonania kodu. Dane o podzespole zawierają między innymi katalog, w którym znajduje się podzespół, silną nazwę, która jednoznacznie określa przestrzeń nazw podzespołu, oraz hash, który określa konkretną wersję podzespołu. Tak samo jak w przypadku RBS, dostęp do zasobów możemy weryfikować w sposób deklaratywny i programowy.
W .NET Framework 4.0 dokonano kilku znaczących zmian w architekturze CAS. W najnowszej wersji framework CAS nie korzysta już z polityki bezpieczeństwa (ang. security policy). Zrezygnowano z niej, ponieważ zapewniała tylko kontrolę zarządzanego kodu, pomijając całkowicie natywne aplikacje. Prawa dostępu określane są na podstawie uprawnień i tak zwanej przejrzystości (ang. transparency), która określa, jaki fragment kodu może wykonywać krytyczne ze względów bezpieczeństwa operacje, a jaki nie. Zadaniem modelu przejrzystości jest zapewnienie prostego i efektywnego mechanizmu, który będzie izolować od siebie różne grupy kodów. Jako przestarzałe zostały oznaczone żądania uprawnienia takie jak RequestMinimum, RequestOptional, RequestRefuse i Deny.
Kolejny fragment kodu pokaże, jak w sposób deklaratywny ograniczyć dostęp do wybranej gałęzi rejestru, uniemożliwiając dostęp do pozostałych.
[RegistryPermission(SecurityAction.PermitOnly, Read = @"HKEY_CURRENT_USER\Software")]
static void Main(string[] args)
{
try
{
Console.WriteLine("Directory: ");
string d;
d = Console.ReadLine();
RegistryKey key = Registry.CurrentUser.OpenSubKey(d);
}
catch (System.Security.SecurityException e)
{
Console.WriteLine("Access Denied");
}
}
Do ustawienia uprawnień została wykorzystana klasa RegistryPermission, która zapewnia kontrolę dostępu do rejestru. SecurityAction.PermitOnly ogranicza uprawnienia tylko do wskazanych. W tym wypadku jest to możliwość odczytania klucza ze ścieżki HKEY_CURRENT_USER\Software. Jeśli wybierzemy inny podkatalog niż Software, zostanie zwrócony SecurityException informujący o braku uprawnień.
Kolejną wartą uwagi funkcją, którą możemy wywołać w deklaratywny sposób, jest Demand. Wymaga ona, aby wywołujący (aż do szczytu stosu wywołań) posiadał odpowiednie uprawnienia. Zastosowanie funkcji pokażemy na przykładzie kodu, który wymaga dostępu do systemowego folderu.
class Sys
{
[FileIOPermission(SecurityAction.Demand, Read=@"C:\Windows")]
public static void ReadFile()
{
StreamReader r = new StreamReader(@"C:\Windows\win.ini");
}
}
Do określenia uprawnień do możliwości czytania lub edytowania folderów służy klasa FileIOPermission. W tym przypadku każdy użytkownik używający metody ReadFile klasy Sys będzie musiał mieć dostęp do folderu C:\Windows. Te same zadania możemy wykonać w sposób programowy. Wówczas kod będzie wyglądać tak:
class Sys
{
public static void ReadFile()
{
try
{
FileIOPermission p = new FileIOPermission(FileIOPermissionAccess.Read, @"C:\Windows");
p.Demand();
StreamReader r = new StreamReader(@"C:\Windows\win.ini");
}
catch (System.Security.SecurityException e)
{
Console.WriteLine("Better exception handling");
}
}
}
Żądanie uprawnień w sposób programowy ma tę zaletę, że sami musimy obsłużyć wyjątek, który może wystąpić. Dzięki temu mamy pewność, że zostanie obsłużony i to prawidłowo. W deklaratywnej wersji sprawdzania uprawnień w klasie Sys nie zyskujemy tej pewności.
Do nakładania ograniczeń na wykonywany przez nas kod oprócz CAS możemy wykorzystać technikę zwaną sandboxing. Polega ona na uruchamianiu podzespołów lub obiektów w bezpiecznym środowisku, które ma ograniczone uprawnienia, tzw. Sandbox. Dzięki niej możemy uruchamiać kod, któremu nie ufamy, lub testować go, jako obdarzony częściowym zaufaniem, przyznając mu określone uprawnienia. Wraz z odejściem od polityki bezpieczeństwa CAS Framework 4.0 oferuje ujednolicone i uproszczone api do tworzenia sandboksów. Do budowy takiego bezpiecznego środowiska wykorzystamy klasę AppDomain. Obiekt klasy utworzymy za pomocą funkcji AppDomain.CreateDomain, która wymaga przygotowania co najmniej trzech parametrów. Pierwszym są uprawnienia, jakie przyznamy naszemu sandboksowi. Do ich ustalenia użyjemy klasy PermissionSet. Podany fragment kodu ustala minimalne uprawnienia wymagane do uruchomienia kodu w sandboksie:
PermissionSet ps = new PermissionSet(PermissionState.None);
ps.AddPermission(new SecurityPermission(SecurityPermissionFlag.Execution));
Kolejnym etapem jest ustawienie parametrów klasy AppDomain za pomocą klasy AppDomainSetup. Najprościej możemy to zrobić, wykorzystując następujący fragment kodu:
AppDomainSetup aps = AppDomain.CurrentDomain.SetupInformation;
Jednak zaleca się, aby ścieżka podzespołu, który uruchamiamy w sandboksie, była różna od ścieżki naszej aplikacji. Wtedy ładowany podzespół nie ma dostępu do aplikacji hostującej. Trzecim parametrem jest nazwa środowiska, która może zostać określona bezpośrednio w konstruktorze. Oto fragment kodu przedstawiający utworzony obiekt:
AppDomain newDomain = AppDomain.CreateDomain("Test", null, aps, ps);
Gdy już mamy prosty sandbox, kolej na utworzenie instancji klasy, którą chcemy wywołać wewnątrz newDomain. Posłuży nam do tego funkcja CreateInstanceAndUnwrap, która przyjmuje dwa argumenty: nazwę podzespołu, w którym znajduje się nasz obiekt, i nazwę obiektu.
PartialTrust pt = newDomain.CreateInstanceAndUnwrap(typeof(PartialTrust).Assembly.FullName,
typeof(PartialTrust).FullName) as PartialTrust;
Oto przykład zawierający naszą klasę PartialTrust, która musi dziedziczyć po MarshalByRefObject, aby można było stworzyć jej instancję w nowej domenie:
namespace Sandbox
{
class PartialTrust : MarshalByRefObject
{
public void execute()
{
try
{
StreamReader r = new StreamReader(@"C:\Windows\win.ini");
Console.WriteLine("It works!!!");
}
catch (SecurityException e)
{
Console.WriteLine("Access denied");
}
}
}
class Program : MarshalByRefObject
{
static void Main(string[] args)
{
PermissionSet ps = new PermissionSet(PermissionState.None);
ps.AddPermission(new SecurityPermission(SecurityPermissionFlag.Execution));
StrongName fullTrustAssembly =
typeof(Program).Assembly.Evidence.GetHostEvidence<StrongName>();
AppDomainSetup aps = AppDomain.CurrentDomain.SetupInformation;
AppDomain newDomain = AppDomain.CreateDomain("Test", null, aps, ps);
PartialTrust pt = newDomain.CreateInstanceAndUnwrap(typeof(PartialTrust).Assembly.FullName,
typeof(PartialTrust).FullName) as PartialTrust;
pt.execute();
}
}
}
Ponieważ nasz sandbox daje prawa tylko do uruchomienia kodu, obiekt nie będzie mógł odczytać pliku win.ini i zwróci wyjątek SecurityException. Aby nie wystąpił wyjątek, musimy dodać następującą linię:
ps.AddPermission(new FileIOPermission(FileIOPermissionAccess.Read, "C:\\Windows"));
Po zapoznaniu się ze środkami bezpieczeństwa, jakie zapewnia platforma .NET, możemy śmiało stwierdzić, że Microsoft oferuje szeroką gamę rozwiązań, dzięki którym będziemy pisać bezpieczne programy. RBS pozwala sprawdzać, kto uruchamia nasz kod, i na tej podstawie określać jego prawa, natomiast CAS umożliwia ograniczanie dostępu do zasobów. Platforma .NET pomaga także w kontrolowaniu kodu, który nie jest napisany przez nas. Dzięki sandboxingowi mamy możliwość uruchamiania potencjalnie niebezpiecznego kodu w środowiskach o ograniczonych prawach, dzięki czemu żadna niebezpieczna operacja nie będzie wykonana w naszym systemie. Mnogość rozwiązań zapewnia środki do pisania bezpiecznych aplikacji, pozostaje tylko wierzyć w wiedzę programistów, którzy je faktycznie wykorzystają do stworzenia bezpiecznego oprogramowania.