Bu yazımızda kernel zafiyetlerinin IOCTL fuzzing ile nasıl bulunduğuna ufak bir giriş yapacağız. Makalenin .pdf versiyonunu indirmek için aşağıdaki butonu kullanabilirsiniz.
Öncelikle IOCTL kavramanı kabaca açıklamak gerekirse, IOCTL için aslında user-mode dediğimiz kullanıcı seviyesinde çalışan uygulamaların, kernel-mode yani çekirdek seviyesinde çalışan driverlar (sürücüler) ile konuşması için kullanılan bir haberleşme ve veri transfer yöntemi diyebiliriz.
IOCTL’ler çeşitli amaçlar için kullanıcı modunda çalışan servisler, uygulamalar ile haberleşme için driver’da tanımlanır. Daha somut ve basit iki örnek ile IOCTL kullanımının amacını şöyle anlatabiliriz;
Birinci örnekte, bir ses/müzik uygulaması aslında ilgili ses dosyasını çalmak için yorumluyor (parse) ediyor ve çıktıyı (output’u) Windows API’larını kullanarak, Windows Ses Motoruna gönderiyor. Bu noktadan sonra biz o sesi hoparlörümüzden duyabilmemiz için, ilgili veriler Ses sürücüsüne IOCTL kullanılarak gönderiliyor ve en nihayetinde ses sürücüsü doğrudan ses donanımını kontrol ederek, sesi çıkarmayı sağlıyor. Burada kullanıcı modundaki bir uygulamasının, ses donanımının driver’ına gerekli verileri aslında IOCTL ile gönderdiğini görüyoruz.
İkinci örnekte ise, işletim sisteminde kullanıcı modundaki bir güvenlik uygulamasının (Örn. AV, DLP, OS Firewall vb.), çekirdek seviyesindeki kendi driver’ı ile IOCTL kullanarak iletişime geçtiğini görüyoruz. Güvenlik uygulamasının kullanıcı modunda çalışan servisinin bazı user-mode hooklar ile bir processde şüpheli davranış sergilediğini tespit ettiğini varsayalım. Güvenlik uygulamasının böyle bir durumda ilgili işlemi sonlandırması veya erişimi engellemesi gibi bir aksiyon alması beklenir. Kullanıcı modundan process’i sonlandırmak yerine, güvenlik uygulaması bunu çekirdek seviyesinde yapmayı tercih edebilir. Kullanıcı seviyesinde çalışan güvenlik uygulamasının servisi, çekirdek seviyesinde çalışan driver’ına, bu aksiyonu alması için gerekli verileri IOCTL ile gönderir.
Aşağıda hayali bir güvenlik uygulamasının sürücüsünde örnek bir IOCTL tanımlaması pseudo-kod olarak gösterilmiştir;
…snip…
#define DEV_NAME L”\\Device\\AVFilter”
#define PROCESS_KILL_IOCTL CTL_CODE(FILE_DEVICE_UNKNOWN, 0x800, METHOD_NEITHER, FILE_ANY_ACCESS)
….
IoControlCode = IrpSp->Parameters.DeviceIoControl.IoControlCode;
…..
if (IrpSp) {
switch (IoControlCode) {
case PROCESS_KILL_IOCTL:
DbgPrint(“[Killing the process…..!\n”);
……….
//PID comes from DeviceIoControl’s InputBuffer
PsLookupProcessByProcessId((HANDLE)pInputBuffer, &pProcess);
KeAttachProcess(pProcess); //attach to process
ZwTerminateProcess(NULL,0); //terminate
……….
}
…snip…
|
Koda baktığımızda kabaca şunları anlıyoruz;
1-) Driver’in device(aygıt) ismi AVFilter
2-) PROCESS_KILL_IOCTL kodlu bir IOCTL tanımlanmış
3-) Driver bir IOCTL isteği aldığında, gelen IOCTL kodu “PROCESS_KILL_IOCTL” ise Case PROCESS_KILL_IOCTL ‘ e sıçrıyor
4-) PROCESS_KILL_IOCTL kodlu IOCTL isteğinde gelen pInputBuffer’ı, PID numarası olarak kabul ediyor.
5-) Process’e birkaç API yardımı ile attach olup, processi sonlandırıyor.
Aslında az önce yukarıda bahsettiğimiz güvenlik uygulaması senaryosuna dönersek, güvenlik uygulamasının kullanıcı-modunda çalışan servisi, şüpheli bir process’in sonlandırılması amacıyla, çekirdek seviyesinde çalışan sürücüsünün bu aksiyonu alması için komutu nasıl bildireceği driver’da bu şekilde tanımlanmış. (Tabi sadece bu şekilde belirli bazı erişim kısıtlaması ve kontroller olmadan tanımlanan bir IOCTL fonksiyonu başlı başına mantıksal bir zafiyet de oluşturur ancak bu durum yazının konusu o değil. Şu an kabaca bir örnek ile IOCTL yapısını anlamaya çalışıyoruz.)
Muhtemel güvenlik uygulamasının user-mode’daki servisi PID numarasını PROCESS_KILL_IOCTL kodlu bir IOCTL isteği ile drivera gönderirse, driver bu process’i terminate edecektir. Peki kullanıcı modundaki uygulama drivera bu isteği nasıl gönderebilir? Bunun için DeviceIoControl API ‘ i kullanılır. Öncelikle DeviceIoControl API ‘ na göz atalım;
DeviceIoControl( _In_ HANDLE hDevice, //IOCTL isteği gönderilecek aygıtın handle’ı _In_ DWORD dwIoControlCode, //Gönderilecek IOCTL kodu _In_opt_ LPVOID lpInBuffer, //Gönderilecek veri _In_ DWORD nInBufferSize, //Gönderilecek verinin boyutu _Out_opt_ LPVOID lpOutBuffer, //Dönen verinin tutulacağı yer _In_ DWORD nOutBufferSize, //Dönen veri için ayırdığımız yerin size’ı _Out_opt_ LPDWORD lpBytesReturned, //Dönen verinin boyutu _Inout_opt_ LPOVERLAPPED lpOverlapped //Null ); |
Gördüğünüz gibi ilgili aygıta DeviceIoControl ile IOCTL isteği göndermek için öncelikle ilgili aygıta bir handle açılması gerekiyor. İkinci parametre ise gönderilecek IOCTL kodu, üçüncü parametre ise gördüğünüz gibi gönderilecek girdi/veri (input buffer). Öyleyse, güvenlik uygulamasının user-mode’da çalışan servisi şüpheli process’i sonlandırmak için gerekli IOCTL isteğini şu şekilde gönderebilir;
DriverHandle = CreateFileA(“\\\\.\\AVFilter”, GENERIC_WRITE | GENERIC_READ, FILE_SHARE_READ | FILE_SHARE_WRITE, 0, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, NULL); DeviceIoControl(DriverHandle, PROCESS_KILL_IOCTL, &pid, sizeof(pid), &pResult, sizeof(pResult), &bytes, null)) |
Ufak birkaç örnekle ile User-Mode ve Kernel-Mode ‘da çalışan servislerin birbiriyle IOCTL kullanarak nasıl iletişime geçtiğini görmüş ve anlamış olduk.
Bildiğiniz gibi platform ister web, ister mobil, ister kernel olsun, zafiyet avcılığındaki ilk adım her zaman olası girdi (input) noktalarını tespit etmek ve sonrasında girdi (input) manipülasyonu yapmaktır. İşte driverların olası girdi noktalarından biri IOCTL istekleridir. Bu sebeple hedef sürücüde zafiyet aramaya başlamak için öncelikle o driverın kabul ettiği IOCTL kodlarını tespit etmeliyiz. Bundan sonra tespit edilen IOCTL kodlarıyla ister mantıksal kernel zafiyetleri arayabiliriz, ister IOCTL kodlarını “fuzz”/ “manipüle” ederek çeşitli kernel hafıza bozulması zafiyetleri bulmaya çalışabiliriz.
Peki hedef driverin kaynak kodu elimizde olmadığına göre, o driverda tanımlanmış IOCTL ‘ leri nasıl tespit edebiliriz? Bunun aslında birden fazla yolu mevcut diyebiliriz. Statik olarak ilgili driver dosyasını çeşitli disassembler yazılımlarıyla inceleyerek IOCTL kodlarını bulmaya çalışabiliriz. Aynı zamanda çeşitli araçlar kullanarak (WinDbg, IrpTracker vb.) dinamik olarak da IOCTL isteklerini sniffleyerek kontrol edebiliriz. Hatta kendi driver’imizi yazarak, çekirdek seviyesinde ZwDeviceIoControlFile‘ı kancalayarak (hook) , işletim sistemindeki bütün IOCTL trafiğini sniffleyebiliriz. Bu yazıda ise daha kolay olduğu için Windbg ve IrpTracker araçları ile hızlıca IOCTL isteklerini dinamik olarak monitör etmeyi ele alacağız.
İlk olarak kullanıcı-modunda debugging ile IOCTL tespitine bakalım. Bunun için öncelikle IOCTL iletişimi yaptığını düşündüğümüz bir kullanıcı modundaki servise debuggerı attach etmemiz gerekecek. Daha sonra attach olduğumuz process içerisinde kernel32!DeviceIoControl fonksiyonuna breakpoint koyarak , DeviceIoControl her çağırıldığında yığındaki fonksiyon argümanlarına bakarak aygıta gönderdiği IOCTL kodunu ve veriyi tespit edebiliriz. Tercihen Windbg ve aşağıda yazdığımız script bu işlemi otomatik olarak yapmak için kullanılabilir;
$$ usage: $$>a< c:/ioctltracker.wds bp kernel32!DeviceIoControl .while(1) { g r $t0 = dwo(esp+4) r $t1 = dwo(esp+8) r $t2 = dwo(esp+0x0c) pt .printf “hDevice=%p dwIoControlCode=%p lpInBuffer=%p\n”, @$t0, @$t1, @$t2 .printf “[+]lpInBuffer:\n” dc $t2 } |
Scripti, Windbg’a yükleyip çalıştırdığımızda, ilgili process her DeviceIoControl fonksiyonunu çağırdığında bize gönderilen IOCTL kodunu ve InputBuffer’ın içeriğini aşağıdaki gibi basacaktır;
Dinamik olarak kullanıcı modundan IOCTL isteklerini debugger vb. araçlarla tespit etmek çok sağlıklı değildir. Bunun yerine dinamik olarak çekirdek seviyesinde IOCTL isteklerini monitör etmek daha işlevsel ve sağlık bir çözümdür. Bu noktada hazır bazı araçları da kullanabiliriz. Bunlardan biri OSROnline tarafından geliştirilen IrpTracker aracıdır. Aşağıdaki örnekte Norman Security Suite uygulamasının driverına giden istekleri IrpTracker ile görüyoruz;
IrpTracker bize bu aygıta giden IOCTL isteklerinden birini, hangi process, hangi IOCTL kodu, hangi erişim methodu ve transfer tipi gibi detaylarla gösteriyor. Hali hazırda Norman Security Suite uygulamasının sürücüsüne giden bir IOCTL kodunu (0x22824e) tespit etmiş bulunuyoruz. Bu aşamadan sonra IOCTL kodunu birer birer arttırarak (enumerate) ve farklı inputlar göndererek fuzzing yapabiliriz.
IOCTL keşfi kısmını öğrendikten sonra artık basit bir IOCTL fuzzing işlemi gerçekleştirmeye geçebiliriz. Bu yazıda örnek olarak fuzzing yaparak gerçek bir ürün üzerinde (Norman Security Suite), null pointer dereference (null gösterici çağrımı) zafiyeti arayacağız. Genelde bu zafiyetler de diğerleri gibi programcı hatalarından kaynaklanır. Sürücüye gönderilen “NULL” (0x0) değerinde bir girdinin, sürücü tarafından kontrolsüz bir şekilde pointer çağırılmasından ve kullanılmasından kaynaklanır. Windows “NULL” bir hafıza alanına erişemediği için çökme (crash / bsod) meydana gelir. Yeni işletim sistemlerinde (Windows8+) artık exploit edilebilir bir zafiyet değildir.
Bunun için aşağıda yazdığımız basit bir fuzzer kodunu görüyoruz;
Source code: https://www.trapmine.com/codes/simple-ioctl-fuzzer.c.txt
Bu kodun tam olarak görevini şu şekilde açıklayabiliriz;
1-) Hedef uygulamamızın “nprosec” isimli sürücüsünde geçerli bir IOCTL kodu (0x22824e) tespit etmiştik. Base ioctl kodu olarak 0x220000 değerini bir değişkene atıyoruz ve sürücüye CreateFile API‘ i yardımıyla bir handle açıyoruz..
3-) Amacımız olası Null Pointer Dereference zafiyetlerini bulmak olduğu için sürücüye gönderdiğimiz IOCTL kodlarında argüman olarak “NULL” byte göndereceğiz.
4-) Bu sebeple for döngüsü içerisinde DeviceIoControl fonksiyonu tanımladık. IOCTL kodunu 0x220000‘dan başlayarak ve birer birer arttırarak (0x22FFFF‘e kadar), NULL byte olarak tanımladığımız argümanlarla IOCTL isteklerini sürücüye gönderiyoruz. Fuzzerı derleyip çalıştırdığımızda, bingo! İşletim sisteminde mavi ekran almayı başardık 😊
Kernel fuzzing yaparken tabiki de bu işlemi VM’de çalışan bir işletim sistemine kernel debugger attach ederek yapmalıyız. Bu sayede olası crash esnasında debugger’dan inceleme fırsatımız olabilir. Kernel debugger attach edilmiş haldeyken, fuzzerı tekrar çalıştırdığımızda windbg ekranın aşağıdaki gibi “Access Violation” hatasıyla karşılaşıyoruz.
WinDBG , bize crash’in nprosec.sys dosyasında ve hangi adreste meydana geldiğini gösteriyor. Crash noktasına baktığımızda, mov cx, word pt [eax] kodunu görüyoruz. EAX ‘ ın işaret (point) ettiği adresteki 16-bitlik (WORD) veriyi ecx’e kopyalamaya çalışıyor ancak EAX=0000000 olduğu ve hafıza bir NULL adres tahsisi olmadığından “hafıza erişim ihlali” hatası alıyoruz. Bu zafiyetlerin tam olarak exploiting yöntemi de zaten , zafiyeti tetiklemeden evvel hafızda NULL page tahsisi yapıp ve o alanı bizim istediğimiz verilerle (payload) doldurmaktan geçiyor.
Az önce yazdığımız basit bir ioctl fuzzer ile crash yakaladık ancak en son gönderilen hangi IOCTL isteğiyle BSOD aldığımızı bilmiyorduk. Fuzzer, 0x220000-0x22ffff aralığını IOCTL kodu olarak brute-force ediyordu. Kernel debugger ile crash esnasında call-stack gibi detaylara bakarak hangi IOCTL isteğinde zafiyetin tetiklendiğini görebiliriz.
Call stack’de son çağrılan fonksiyonlar ve parametrelerini görebiliyoruz. DeviceIoControl fonksiyonu gözümüze çarpıyor ve argüman olarak 002242a9 değerini görüyoruz. Dolayısıyla artık sadece 0x2242a9 koduyla gönderdiğimiz IOCTL isteğiyle zafiyeti tetikleyebiliriz.
Gördüğünüz gibi, IOCTL yapısını anlayarak çok basit bir şekilde eski bir güvenlik yazılımının sürücüsünde zafiyet keşfi yapabildik. Özetlemek gerekirse öncelikle sürücünün IRP iletişimini monitör ederek geçerli bir IOCTL kodu bulduk ve bu koddan yola çıkarak basit bir null pointer fuzzer’ı yazdık. Aslında fuzzerın en ilkel kısmı ise IOCTL kodlarını brute-force ederken doğrulama yapmamasıydı.
Daha akıllı bir IOCTL fuzzing yöntemi, geçerli bir IOCTL kodundan yola çıkarak DeviceIoControl ile her gönderilen IOCTL kodunda, input ve output buffer argümanlarını da METHOD BUFFERED, METHOD_IN_DIRECT vb. transfer tiplerine göre herhangi bir “malformed” data kullanmadan brute-force etmektir. Bu sayede geçerli IOCTL kodları ve transfer tipleri, DeviceIoControl‘in dönüş değerine (hata dönmüyorsa = geçerli) bakılarak tespit edilebilir. Fuzzing kısmına geçildğinde, malformed veri, tespit edilen IOCTL kodlarına driverin istediği transfer_type ile gönderilerek daha akıllı bir fuzzing yapılır.
Aksi takdirde birçok geçerli IOCTL kodunu belki tespit etmemize rağmen, DeviceIoControl ile istek yaparken tanımlanan inputbuffer veya outputbuffer argümanlarının yanlışlığından dolayı driver tarafından paketimiz kabul edilmeyebilir. Bu durumda kullanıcı-modunda DeviceIoControl 0x1 (INVALID_FUNCTION) hatası dönecektir. (Kernel tarafında bu hatayı STATUS_INVALID_DEVICE_REQUEST olarak görürüz.). Fuzzer’ı çalıştırdığımızda süreklik aldığımız “Error code 0x1” hatası bundan kaynaklanmaktadır, gönderilen bir çok IOCTL kodu driver tarafından kabul edilmemektedir.
Celil Ünüver / TRAPMINE