ring0.info
Kancalama Sanatı - SSDT
Selamlar.
Bu yazıda elimden geldiğince Windows işletim sisteminde SSDT kancalama(Hook) nasıl yapılır onu göstermeye çalışacağım. Öncelikle şunu söylemeliyim ki oldukça geniş bir konu. Çünkü sistemin kendisi ile haşır neşir oluyoruz. Her yerden fışkıran veri yapıları, fonksiyonlar, mantıkı çıkarımlar vesaire oldukça uzuyor konu.. Sırf yazacağımız kernel-mode sürücüsünün derlenmesi için bile ayrı yazılar var. O nedenle dikkatli takip etmeye çalışın, ayrıca takıldığınız kısımlar için yorum yahut e-mail yolu ile lütfen iletişin. Son olarak yazı biraz aceleye geleceği için içerisinde yazım ve bilgi hataları olabilir, yakalayan arkadaşlar lütfen iletişin benimle. (İletişmek?)
SSDT ve SSDT kancalama nedir?
SSDT demek basitçe 4 adet SST(System Service Table) demektir. Bir sistem çağrısı yapıldığında işleyecek olan fonksiyon bu tablolara bakılarak bulunur. Bu tabloların kancalanması demek ise, bu tabloda bulunan bir fonksiyon adresinin, kernel modda yüklenmiş herhangi bir modül içinde aynı prototipe sahip başka bir fonksiyon adresi ile değiştirilmesidir. Bu sayede örneğin NtCreateFile API’si çağrıldığında sistemin kendi gerçek fonksiyonu değil de, sizin tanımladığınız kanca fonksiyon çağırılacak ve ardından sizin insafiyetinize göre asıl fonksiyon tekrar çağırılacaktır. Bu basit mantıki çıkarım, günümüz rootkitlerinin, koruma, izleme yazılımlarının temelidir. Mesela şöyle bir şey düşünün: sistemde dosya silen API’yi kancaladınız, ve bu sayede silinmek istenen dosyanın adı “bekiko.txt” ise dosyanın silinmesini engellediniz. Tebrikler! Böylece sistemde ilk velveleye verme maceranızı (hayali de olsa) yaşamış oldunuz. Bu örneğimiz biraz kötü niyetli gibi bir örnekti, bunun başka versiyonu da anti-virüs yazılımları için geçerli. Onlar da sisteminizi korumak, izlemek için bu kancalama işlemlerini gerçekleştiriyorlar.
Devam edersek, önceden yazdığım şu yazıda anlattığımız sistem çağrı mekanizmasının kalbi diyebileceğimiz tablo işte bu tablodur. Okumayanların bu yazıya devam etmeden önce, yukarıda link verdiğim yazıyı okuması faydalı olacaktır. Okuyanlar için kısa bir özetle devam edelim. Hatırlarsanız bu sistem çağrıları iki şekilde gerçekleşebiliyordu:
- SYSENTER yardımı ile (Windows XP ve sonrası, donanım olarak x86 Pentium II ve üzeri)
- 2e kesmesi ile (İlk Windows XP sürümleri ve daha eski sürümler)
Her ne kadar bu iki yöntemin çalışma biçimi birbirinden tamamen farklı olsa da, verdikleri sonuç aynıydı hatırlarsanız. Şimdi hızlıca bir özet geçelim, ayrıca bir iki yeni bilgi öğrenelim. Öncelikle NtCreateFile API’si çağrıldığında ne oluyordu? Hatırlayalım:
32 bit Windows 7 sistemimde NtCreateFile için 42 index numarası kullanılıyor. Şimdi bu index numarası hakkında birkaç şey öğrenelim. Dikkatinizi çekmek istediğim nokta şu, burada index değerimiz EAX yazmacına, yani 32 bitlik bir yazmaca alınıyor. 32 bitlik bir sayının index olarak kullanılması demek, SST tablosunun 4GB uzunlukta olabilmesi demektir, ki bu da kulağa doğru gelmiyor. İşin doğrusu bu index numarası da aşağıdaki grafikte göreceğiniz üzere ayrılarak kullanılıyor:
Grafikten anladığımız kadarıyla:
- 0-11 bitler -> Çağırılacak sistem servis numarası
- 12-13 bitler -> Kullanılacak SST
- 14-31 -> Kullanılmıyor
Yani, düşük seviyeli 12 bit sistem servis numarasını verdiğine göre tablonun uzunluğu 4096 byte
olur. Böylece az önce gördüğümüz yanlışlık (4GB olma durumu) düzeltilmiş oluyor. Ayrıca dikkat ederseniz, 2 bitlik bir alanı da kullanılacak SST’yi seçmek için kullanıyoruz. Yani bu demektir ki toplamda 4 (22) tane SST olabilir. Windows bunlardan ikisini şu isimlerle kullanıyor:
- KeServiceDescriptorTable -> SST bitleri 0x00, Fonk. Index 0x0 – 0xFFF arası bu tabloda
- KeServiceDescriptorTableShadow -> SST bitleri 0x01, Fonk. Index 0x1000 – 0x1FFF arası ise bu tabloda
Burada KeServiceDescriptorTable
ntoskrnl.exe tarafından ihraç edilmiş durumda fakat KeServiceDescriptorTableShadow
için aynısı geçerli değil. Bu tabloların yapısı SST olarak adlandırılan aşağıdaki şekilde tanımlanıyor.
struct _SYSTEM_SERVICE_TABLE{
PULONG ServiceTableBase; //Tablonun başlangıçı, fonksiyon adresleri bu adreste başlıyor
PULONG ServiceCounterTableBase; //Kullanılmıyor
ULONG NumberOfServices; //Toplam servis sayısı, tablo limiti
PUCHAR ParamTableBase; //System Service Parameter Table
}
SST yapılarının tanımlı bulunduğu SDT(Service Descriptor Table) yapısı ise aşağıdaki gibi oluyor:
struct _SERVICE_DESCRIPTOR_TABLE{
SYSTEM_SERVICE_TABLE ntoskrnl; //SSDT
SYSTEM_SERVICE_TABLE win32k; //SSSDT(Shadow system service descriptor table)
SYSTEM_SERVICE_TABLE Table3; //Sürücülerin kullanımı için ayırılmış
SYSTEM_SERVICE_TABLE Table4; //Sürücülerin kullanımı için ayırılmış
}
Şimdi kernel modda debugging yaparken SST yapısını kısaca gözlemleyelim. Bunun için nt!KeServiceDescriptor
ile başlayan sembollere bakarak SDT elemanlarını bulabiliriz.
kd> x nt!KeServiceDescriptor**
82bb0a00 nt!KeServiceDescriptorTableShadow = <no type information>
82bb09c0 nt!KeServiceDescriptorTable = <no type information>
dps
komutunu kullanarak nt!KeServiceDescriptorTable
içeriğine bakalım:
kd> dps nt!KeServiceDescriptorTable L4
82bb09c0 82ab76f0 nt!KiServiceTable
82bb09c4 00000000
82bb09c8 00000191
82bb09cc 82ab7d38 nt!KiArgumentTable
Gördüğünüz gibi üstteki _SYSTEM_SERVICE_TABLE
yapımızı elde ettik. 82ab76f0
adresindeki SSDT 191(401) limitine sahip ve bizi beklemekte. Örneğin tablonun tamamını görmek için semboller yardımıyla şöyle bir şey yapabilirsiniz.
kd> dps nt!KiServiceTable L poi(nt!KiServiceLimit)
82ab76f0 82ca80cb nt!NtAcceptConnectPort
82ab76f4 82b0122b nt!NtAccessCheck
82ab76f8 82c53e4e nt!NtAccessCheckAndAuditAlarm
...
...
...
Tahmin edebileceğiniz gibi nt!KiServiceLimit
bize tablonun limit değerini veriyor. Bu değeri L
parametresi ile kullanıp, dps
komutuna ne kadar uzunlukta bir veri çekeceğini belirtiyoruz. Aynı şekilde diğer SDT’yi de inceleyebilirsiniz. Örneğin diğer SDT’ye baktığınızda içinde 2 adet SST tanımlı olduğunu görebilirsiniz.
kd> dps KeServiceDescriptorTableShadow l8
82bb0a00 82ab76f0 nt!KiServiceTable
82bb0a04 00000000
82bb0a08 00000191
82bb0a0c 82ab7d38 nt!KiArgumentTable
82bb0a10 82445000 win32k!W32pServiceTable
82bb0a14 00000000
82bb0a18 00000339
82bb0a1c 8244602c win32k!W32pArgumentTable
Fakat dahası konuyu uzatacağı için ayrıntıya girmiyorum. Öğrenmenin en iyi yollarından biri de dibini kurcalamaktır. Kısacası: kurcalayın.. Şimdi asıl konumuza dönelim.
SSDT tablosu ve yazma koruması
SSDT kancalama yaparken karşınıza çıkacak sorunlardan biri tablonun bulunduğu alanın, yazmaya karşı korumalı olmasıdır. Kancalama yapmadan önce bu korumayı devre dışı bırakmamız gerekiyor. Özetlememiz gerekirse bunu yapabilmenin benim araştırken karşıma çıkan 3 yolu var:
- CR0 yazmacının WP bitini değiştirmek1
HKLM\SYSTEM\CurrentControlSet\Control\SessionManager\Memory\Management\EnforceWriteProtection
girdisini değiştirmek- MDL(Memory Descriptor List) kullanmak2
Bu korumayı aşmaya geçmeden önce SSDT’nin bulunduğu adresin nasıl korunduğuna kısaca göz atalım.
kd> !pte nt!KiServiceTable
VA 82ab86f0
PDE at C06020A8 PTE at C04155C0
contains 00000000001D1063 contains 0000000002AB8121
pfn 1d1 ---DA--KWEV pfn 2ab8 -G--A--KREV
Gördüğünüz üzere PDE için izinlerimiz ---DA--KWEV
, PTE için izinlerimiz ise -G--A--KREV
şeklinde. Önceki yazımızdan hatırlarsanız PDE yapısına _MMPTE_HARDWARE
ile ulaşabiliyorduk. Ayrıca PTE yapısına da _MMPTE_SOFTWARE
ile ulaşabilmekteyiz. Pekiyi bu izin değerleri ne anlama geliyor? Bazılarını özetlersek3
- Valid (V): Verinin fiziksel hafızada olduğunu gösterir
- Read/Write (R/W): Verinin yazılabilir veya sadece okunabilir olduğunu gösterir
- User/Kernel (U/K): Sayfanın sahibinin user-mode mu, kernel-mode mu olduğunu gösterir
- CacheDisable (N): Sayfanın önbellekleme yapılıp yapılmayacağını gösterir
- Accessed (A): Sayfaya yazma/okuma yapıldığında tanımlanır
- Dirty (D): Sayfadaki verinin değiştiğini gösterir
- LargePool (L): Sadece PDE için geçerli, tanımlıysa sayfa boyutu 4MB, tanımsızsa 4KB (Önceki yazıya bakınız)
- Executable (E): Sayfadaki veri çalıştırılabilir
Şimdi dikkat ederseniz bizim PDE’miz yazılabilir PTE’miz ise read-only modunda. Ayrıca her ikisinin de çalıştırılabilir olduğunu görüyoruz. Yani tablomuzun yalnızca okuma izni olan bir yerde olduğunu görüyoruz. İşte bu korumayı geçebilmek için biraz önce bahsettiğimiz yollardan birini kullanmalıyız. Bu yazıda hem MDL yöntemini, hem de CR0 yazmacı kullanarak bu korumayı geçmeyi gösterecem.
CR0 yazmacı kullanarak korumayı kaldırma
Öncelikle CR0 yazmacındaki WP bitinin hangi durumda nasıl bir etki yaptığını kısaca özetleyelim:
- 0: Çekirdek PDE ve PTE’deki R/W ve U/S bitlerinin ne olduğunu önemsemeden read-only alanlara yazmaya izin verir
- 1: Çekirdek read-only alanlara yazmaya izin vermez. Yazma kontrolünü PDE ve PTE’deki R/W ve U/S bitlerine bakarak kararlaştırır.
Örneğin benim sistemindeki CR0 yazmacının durumunu görelim. Bunun için .formats
komutunu kullanıyoruz bu sayede ikili formatta da çıktıyı görebiliriz.
kd> .formats cr0
Evaluate expression:
Hex: 80010031
Decimal: -2147418063
Octal: 20000200061
Binary: 10000000 00000001 00000000 00110001
Burada 16. bit gördüğünüz gibi tanımlanmış(1) durumda. Yani koruma açık. Eğer CR0 taktiğini kullanarak korumayı devre dışı bırakmak istiyorsak, bu yazmacın değerini değiştirip korumayı açık veya kapalı hale getiren iki adet küçük fonksiyonumuz var.
void SetWP(){
__asm{
push eax
mov eax, cr0
or eax, NOT 0xFFFEFFFF // not fffeffff = 00010000
mov cr0, eax
pop eax
}
}
void UnsetWP(){
__asm{
push eax
mov eax, cr0
and eax, 0xFFFEFFFF
mov cr0, eax
pop eax
}
}
Bu iki fonksiyondan SetWP()
korumayı aktif etmek için kullanıyoruz. Öncelikle EAX’i stacke yedekleyip, ardından CR0 yazmacının içeriğini EAX’e alıyoruz. Ardından bu değer üzerinde or 00010000
işlemi uyguluyoruz ardından yeni değerimizi tekrar CR0 yazmacına koyuyoruz. 00010000 ikili olarak 00000000 00000001 00000000 00000000
‘e eşit. Yani WP bitini tanımlı hale getiriyoruz.(1)
İkinci fonksiyon olan UnsetWP
ise korumayı kapatmak için. Önceki fonksiyonla oldukça benzer bir işlem yapıyor tek fark bu defa CR0 yazmacının içeriğini and FFFEFFFF
işlemine sokuyor. FFFEFFFF ikili olarak 11111111 11111110 11111111 11111111
‘e eşit. Yani WP bitini tanımsız hale getiriyoruz.(0)
Eğer MDL kullanmayacaksanız, bu şekilde yazma korumasını kapatıp, map edilen SSDT ile değil de, direk orjinali üzerinde işlem yaparak kancalamayı gerçekleştirebilirsiniz. Ben bu yazıda MDL kullanacağım için(ki bu yöntemi kullanmak daha sağlıklı) CR0 yöntemini uygulamalı olarak göstermeyeceğim. Fakat eğer bir kişi bile isteyen olursa, CR0 kullanan sürücünün kaynak kodlarını da yayınlarım. Şimdi diğer yönteme geçelim..
MDL kullanarak korumayı geçme
MDL kullanarak bir hafıza alanı tanımlayabiliyor, ayrıca hafıza alanının erişilebilirliğini de kendiniz belirleyebiliyorsunuz. Bu yazıda da bu özelliğin yardımıyla read-only olan tablomuza yazabiliyor olacağız. MDL hakkında daha detaylı bilgi almak isterseniz Vir Gnarus‘un yazdığı şu makaleye ve MSDN sayfasına bakabilirsiniz. Tabi ki ingıliçce.. Şimdi bizim programımızda kullandığımız InitMDL()
fonksiyonunu görelim:
//MDL kullanarak SSDT'yi "map" eden fonksiyon
INT InitMDL(void)
{
MdlSSDT = IoAllocateMdl(KeServiceDescriptorTable.ServiceTableBase,
KeServiceDescriptorTable.NumberOfServices * 4,
FALSE,
FALSE,
NULL);
if (MdlSSDT == NULL) {
return STATUS_UNSUCCESSFUL;
}
MmBuildMdlForNonPagedPool(MdlSSDT);
MdlSSDT->MdlFlags |= MDL_MAPPED_TO_SYSTEM_VA;
MapSSDT = MmMapLockedPagesSpecifyCache(MdlSSDT,
KernelMode,
MmNonCached,
NULL,
FALSE,
HighPagePriority);
if (MapSSDT == NULL) {
return STATUS_UNSUCCESSFUL;
}
return STATUS_SUCCESS;
}
Burada öncelikle IoAllocateMdl
fonksiyonunu kullanarak (MmCreateMdl de kullanılabilir) MDL oluşturmak istediğimiz hafıza alanını tanımlıyoruz. MSDN’deki fonksiyon bilgilendirmelerine bakarak kullandığımız fonksiyonlar hakkında daha ayrıntılı bilgi sahibi olabilirsiniz. Misal, örneğin IoAllocateMdl
fonksiyonuna bakalım:
PMDL IoAllocateMdl(
_In_opt_ PVOID VirtualAddress,
_In_ ULONG Length,
_In_ BOOLEAN SecondaryBuffer,
_In_ BOOLEAN ChargeQuota,
_Inout_opt_ PIRP Irp
);
Fonksiyon bizim için bir MDL oluşturacak ve dönüş değeri olarak da oraya işaretçi döndürecek. Burada bizim için önemli olan ilk iki parametre. İlk parametremiz MDL tanımlaması yapılacak adresin başlangıcını veriyor. İkinci olan ise ne kadar uzunlukta olduğunu. Biz fonksiyona tablomuza uygun biçimde başlangıç adresini ve uzunluğu verdik.(Tablonun tanımlandığı kısma geleceğiz) Ardından işlemin tamamlanıp/tamamlanmadığını kontrol ediyoruz. Daha sonra MmBuildMdlForNonPagedPool
fonksiyonunu kullanarak belirlediğimiz alanı tanımlaması için MDL‘mizi güncelliyoruz. Bu işlemin ardından MDL‘mizin izinlerini de güncelleyerek sürücümüz içinden erişilip değiştirilebilir şekilde ayarlıyoruz. Son olarak MmMapLockedPagesSpecifyCache
fonksiyonunu kullanarak MDL tarafından tanımlanan sayfaları sanal adrese atıyoruz. Bu fonksiyonun return değerini de saklıyoruz çünkü işte bu değer bizim tablomuza ulaşmamızı sağlayacak olan adresin ta kendisi olacak.. Bu dönüş değerini aşağıda sürücümüzü yükledikten sonra test edeceğiz.
Kancalamanın test edilmesi için hazırlık
Kancalama işlemine geçmeden önce ortamımızı hazırlamamız gerekiyor. Dönen olayları görmek için kernel debugging yapacağımızdan işletim sistemimizi de buna göre ayarlamamız gerekiyor. Ben kernel debugging yaparken işlemi hızlandırmak için VirtualKD kullanıyorum. Kendisini indirdikten sonra içerisindeki target klasöründen vminstall.exe
‘yi sanal makinemizde çalıştırarak işletim sisteminin debug için gereken ayarlarını otomatik yaptırıyoruz. Ardından kendi makinemizde vmmon.exe
‘yi çalıştırarak programın sanal makineleri izlemesini sağlıyoruz. Çalıştırdığımızda bizi şu şekilde bir görüntü karşılayacak:
İşletim sistemini açarken karşımıza çıkan VirtualKD seçeneğini seçtikten sonra eğer debugger yolunuz da doğru ayarlandıysa program otomatik olarak debuggerı başlatacaktır.
Debugger işletim sistemine bağlandıktan sonra aşağıdakine benzer bir görüntü ile karşılaşacaksınız. Artık g
komutu ile sistemin açılışını devam ettirebiliriz demektir.
Artık 0x80000000 - 0xFFFFFFFF
adres aralığındaki işlemleri de kontrol edebilir durumdayız. User-mode bir debugger kullandığınızda bu adreslere erişim izniniz yoktur, fakat artık bir kernel nincalığı peşindeyiz. Yani bu da demektir ki artık sistemin tamamına erişebilir durumdayız.
Kancalama sürücüsünün yazılması
Şimdi sıra geldi asıl işi yapacak olan kodları yazmaya. Kodların yazılmasını geçtim, derlenmesi kısmına bu yazıda hiç girmeyecem zira içtenlikle söylüyorum ki kodları derlemek benim için yapıyı anlayıp, kod yazmaktan daha zor. Neden bilmiyorum ama bu derleme işini bir türlü beceremiyorum. Zaten Visual Studio 2015 kurduğum için ortalık biraz karıştı, zar zor ayarlayabildim ehehe. Kodların yorum içeren halini şuradan indirebilirsiniz(Kodların kötüye kullanımını engellemek amacıyla kaynak kodları kaldırdım, görmek isteyenler lütfen mail ile ulaşşsınlar.). Hadi başlayalım o zaman!
Öncelikle programımızın ana dosyası olan bekkitcik.c
dosyasının içeriğini görelim:
#include "bekkitcik.h"
//Gerçek fonksiyonun yerine çalışacak olan kanca fonksiyon
NTSTATUS HookNtLoadDriver(PUNICODE_STRING DriverName)
{
//Fonksiyonu çağıran process ID'sini ve yüklenecek sürücünün adını kendimize bildiriyoruz
DbgPrint("NtLoadDriver cagirildi, process: %d. Driver ismi: %ws \n", PsGetCurrentProcessId(),
DriverName->Buffer);
//Asıl fonksiyonu çalıştırıyoruz
return RealNtLoadDriver(DriverName);
}
//Sürücünün silinmesi sırasında çalışacak olan fonksiyon
void Unload(PDRIVER_OBJECT pDriverObject)
{
//Sürücü yüklendiğinde yapılan kancayı kaldırıyoruz
SetHook((ULONG)ZwLoadDriver, (ULONG)RealNtLoadDriver);
//MDL'imizi siliyoruz
if (MdlSSDT != NULL)
{
MmUnmapLockedPages(MapSSDT, MdlSSDT);
IoFreeMdl(MdlSSDT);
}
DbgPrint("NtLoadDriver kancasi kaldirildi. \n");
}
//Sürücü yüklendiğinde çalışacak olan fonksiyon
NTSTATUS DriverEntry(PDRIVER_OBJECT pDriverObject, PUNICODE_STRING pRegistryPath)
{
//Sürücü silindiğinde çalışacak fonksiyonu tanımlıyoruz
pDriverObject->DriverUnload = Unload;
//Gerçek LoadDriver fonksiyonun adresini yedekliyoruz
RealNtLoadDriver = (ZWLoadDriver)GetFuncAddress((ULONG)ZwLoadDriver);
//MDL kullanarak SSDT'ye yazabilmek için SSDT'yi "map" ediyoruz
if (!InitMDL())
{
//SSDT'deki gerçek fonksiyonun adresini bizim kanca fonksiyonumuz ile değiştiriyoruz
SetHook((ULONG)ZwLoadDriver, (ULONG)HookNtLoadDriver);
}
DbgPrint("RealNtLoadDriver adresi: %#x \n", RealNtLoadDriver);
DbgPrint("HookNtLoadDriver adresi: %#x \n", HookNtLoadDriver);
DbgPrint("NtLoadDriver kancalandi. \n");
return STATUS_SUCCESS;
}
Programın ana dosyası oldukça anlaşılır ve kolay. Eğer daha önceden de Windows sürücüleri geliştirmeye aşina iseniz hemen anlayacaksınızdır. Fakat ilk defa karşı karşıyaysanız o zaman biraz kafanız karışabilir bu nedenle çok kısaca sürücülerden bahsedelim.
Sürücüler yapı olarak normal bir programla aynıdır. Her ikisi de bildiğimiz çalıştırılabilir dosya(EXE) formatındadır. Önemli olan fark ise sürücülerin kernel modunda dolaşıyor olması, yani kullanıcı modunda(user-mode) çalışan programlardan daha üst yetkiye sahip olmasıdır. Örneğin bir sürücü içerisinden donanımla erişime geçebilir, kullanıcıların ulaşamadığı hafıza bölgelerine erişebilirsiniz (yukarıdaki gibi yöntemler kullanarak).. Pekiyi sürücüler nasıl çalışmaya başlıyorlar? Bu konu sürücüleri normal programdan ayıran önemli bir noktadır. Örneğin C ile bir uygulama yazdığınızda programın genel itibariyle main
fonksiyonu ile başlayacağını biliyorsunuz veya bir DLL ise DllEntry
ile başlayacaktır.. Sürücüler ise DriverEntry
4 fonksiyonundan çalışmaya başlıyorlar. Bu fonksiyon DriverObject
ve RegistryPath
isimli iki parametre alıyor. İlki sürücümüzün DRIVER_OBJECT5 yapısına bir gösterici, diğeri ise sürücümüzün kayıt defteri girdisini gösteriyor..
Pekiyi sürücülerin çalışması dururken ne oluyor? Bu defa da bizim sürücümüzde Unload
isimli fonksiyon çalışıyor. İsminin ne olduğu size kalmış, yani illa Unload olmak zorunda değil. DriverEntry
fonksiyonunda DriverObject yapısının DriverUnload alanına bu fonksiyonu tanımladığınız sürece sıkıntı yok.. Mesela bu fonksiyonda girişte ne yaptıysanız onu tersine çeviriyorsunuz gibi düşünebilirsiniz. Örneğin ben bu fonksiyonda sürücüm yüklendiğinde kancaladığım fonksiyonu eski haline döndürüp, tanımladığım MDL‘yi siliyorum.. Bunu yaparken öncelikle MmUnmapLockedPages
fonksiyonunu kullanıp ilk parametre olarak map edilen adresi, ikinci parametre olarak ise MDL‘imizi vererek maplediğimiz alanı serbest bırakıyoruz. Ardından IoFreeMdl
fonksiyonunu kullanarak önceden tanımladığımız MDL‘yi de serbest bırakıyoruz, yani siliyoruz.
Şimdi programımızın nasıl çalıştığına gelelim.. DriverEntry
fonksiyonumuzda gördüğünüz gibi öncelikle kancalayacağımız fonksiyon olan NtLoadDriver
‘ın gerçek adresini yedekliyoruz. Bunu yapmamızın nedeni, bizim kanca fonksiyonumuzu çağırdıktan sonra onun içinden gerçek fonksiyonu da çağıracak olmamız. Ayrıca kancayı kaldırırken bu adresi tekrar yerine koyacağımız için saklamamız gerekiyor..(Aslında başka bir fonksiyonu kancalayacaktık, fakat eğer onu kancalarsak yazı çok uzayacak, işin içine çok fazla yapı girecek; o yüzden burada bırakıyoruz) Burada kullandığımız GetFuncAddress
fonksiyonu ve onun kullandığı GetServiceNumber
makrosu şöyle:
//Indexi veren makro
#define GetServiceNumber(ZWFunction)(*(PULONG)((PUCHAR)ZWFunction + 1));
//SSDT'den fonksiyon adresi alan fonksiyon
ULONG GetFuncAddress(ULONG Function)
{
//Önce fonksiyonun index değerini buluyoruz
ULONG FuncIndex = GetServiceNumber(Function);
//Ardından SSDT'den bu indexteki fonksiyon adresini döndürüyoruz
return *(PULONG)((ULONG)KeServiceDescriptorTable.ServiceTableBase + FuncIndex * 4);
}
Burada GetServiceNumber
makrosunun da yardımıyla kancalayacağımız fonksiyonun index değerini bulup, ardından SSDT tablomuzda bulunan fonksiyonun adresini geri döndürüyoruz. Şuna dikkat etmemiz gerekiyor, GetServiceNumber
makrosu her zaman Zw önekli fonksiyon ismini alıyor. Bunun nedeni Zw önekli fonksiyonların başlangıçlarında mov eax, xxx
şeklinde bir parça olması, bu güzel bilgi sayesinde fonksiyonun index değerini bulabiliyoruz. Örneğin ZwLoadDriver
‘a bakalım:
kd> u ZwLoadDriver L1
nt!ZwLoadDriver:
82a965b0 b89b000000 mov eax,9Bh
İşte bizim makromuz buradaki değerden 1 byteı(mov komutunu) atarak, geri kalan değeri unsigned long‘a çevirip bize index değerini veriyor, yani 9B‘yi. Ardından da bu index değeri sayesinde SSDT tablomuzdaki fonksiyonun adresini buluyoruz.
Gerçek fonksiyonu yedekledikten sonra InitMDL()
fonksiyonumuz MDL için gereken işlemleri gerçekleştiriyor. Bu fonksiyonun ne yaptığını zaten MDL bölümünde açıklamıştım o nedenle geçiyorum. Ardından ise kilit nokta olan, kancalamayı yapan fonksiyonumuz geliyor. Şimdi onu inceleyelim:
//Kancalama işlemini yapan fonksiyon
ULONG SetHook(ULONG SysFunc, ULONG NewFunc)
{
//Önce değiştirilecek fonksiyon göstericisinin yerini buluyoruz
PULONG SysAddr = (PULONG)(MapSSDT) + GetServiceNumber(SysFunc);
//InterlockedExchange ile fonksiyon adresini kanca fonksiyon ile değiştiriyoruz
return InterlockedExchange((PLONG)SysAddr, (ULONG)NewFunc);
}
//SSDT'deki gerçek fonksiyonun adresini bizim kanca fonksiyonumuz ile değiştiriyoruz
SetHook((ULONG)ZwLoadDriver, (ULONG)HookNtLoadDriver);
Fonksiyonumuz iki parametre alıyor ilki değiştireceğimiz fonksiyon, diğeri ise onun yerine geçecek olan kanca fonksiyon. Burada gördüğünüz gibi ZwLoadDriver‘ın yerine bizim kanca fonksiyonumuz olan HookNtLoadDriver geçiyor. SetHook fonksiyonuna bakarsak burada iki satırlık basit bir kod görüyoruz. İlk satırın yaptığı şey MDL kullanarak elde ettiğimiz adreste değiştirilecek alanı bulmak. İkinci satırda ise yeni bir fonksiyon gözümüze çarpıyor. InterlockedExchange6 fonksiyonu. Bu fonksiyon 32 bitlik bir değişkeni belirlediğimiz değere atomic olarak tanımlamaya yarıyor. Pekiyi bu atomic olayı nedir? Atomic işlemler tek seferde, kesmeye uğramadan çalışan işlemlere deniyor. Yani bu fonksiyonu kullandığınızda 32 bitlik değer diğer işlemciler tarafında kesilmeye uğramadan belirlenen yere yazılıyor. Genelde InterlockedXXX şeklinde tanımlı fonksiyonlar bu şekilde çalışır. Bu fonksiyonlarda lock
makine komutu kullanılır, bu komut kendinden sonra çalışacak olan makine kodunu çalışırken diğer işlemcileri bekletmektedir. Bu sayede örneğin dword’luk değer yazmak isterken diğer işlemci tarafında kesintiye uğrayıp wordluk değer yazmaktan sakınmış oluyoruz. Bu ihtimalin gerçekleşmesi ciddi sorunlara(BSOD(Ünlü mavi ekran) gibi) yol açabilir, o nedenle önlemimizi almak zorundayız.. Bu fonksiyonun nasıl kullanıldığına gelirsek prototipi şöyle:
LONG __cdecl InterlockedExchange(
LONG volatile *Target,
LONG Value
);
İsimlerinden de anlaşılacağı üzere ilk parametre değiştirilecek veriye işaretçi, ikincisi ise ilk parametrenin gösterdiği yere yazılacak olan verimiz. Buradaki kullanıma bakarsanız fonksiyona ilk verdiğimiz değer SysAddr yani kancalanacak fonksiyonun adresi, ikincisi ise bu adrese yazılacak olan yeni fonksiyonumuzun adresi.. Böylece tabloda asıl fonksiyonun adresini bizim adresimizle değiştirmiş oluyoruz.
Bir diğer fonksiyonumuz ise kancalama için kullandığımız HookNtLoadDriver
fonksiyonu. Bu fonksiyon artık NtLoadDriver her çağırıldığında çağırılacak olan fonksiyonumuz. Yani aslında yeni NtLoadDriver
fonksiyonumuz. Kendi yapacağını yaptıktan sonra ise işi tekrardan gerçek fonksiyona devrediyor. Karışık bir konu olmaması açısından tek yaptığımız şey fonksiyonu çağıran process’in ID’sini ve fonksiyona verilen DriverName yapısının Buffer elemanını, yani yüklenecek sürücünün yol bilgisini ekranına yazdırmak.
Son olarak fonksiyonun sonlarında DbgPrint
7 fonksiyonunu kullanarak faydalı olabilecek debug bilgilerini de yazdırıyoruz. DbgPrint
kullanılarak oluşturulan çıktıları Windbg ekranında yahut DebugView8 yardımı ile görebilirsiniz..
Ana dosyamızla ilgili açıklamaları yaptığımıza göre sıra bekkitcik.h
başlık dosyasının içerisindekilere geldi. Dosyamızın en başındaki tanımlamalara bakarsak:
#include <ntddk.h>
//MDL için kullandığımız değişkenler
PMDL MdlSSDT = NULL;
PVOID MapSSDT = NULL;
//Kancalanacak fonksiyonun prototipini tanımlıyoruz
NTKERNELAPI NTSTATUS ZwLoadDriver(PUNICODE_STRING DriverServiceName);
//Fonksiyonun gerçek adresini tutacak olan değişkeni tanımlıyoruz
typedef NTSTATUS(*ZWLoadDriver)(PUNICODE_STRING);
ZWLoadDriver RealNtLoadDriver;
Öncelikle tüm sürücülerde olması gereken birçok yapı ve sabiti tanımlayan ntddk.h dosyasını programımıza dahil ediyoruz. Ardından gelen iki değişken MDL için kullandığımız değişkenlerimiz. Sonrasında kancalayacağımız fonksiyonun prototipini ve gerçek fonksiyonumuzun adresini saklayacak değişkeni, kancalayacağımız fonksiyonun prototipine göre tanımlıyoruz.
Bu tanımlamaların ardından SST’ye ilişkin tanımlamalarımız geliyor. Kernel çekirdeğinde herhangi bir şey ihraç(export) edildiği zaman __declspec(dllimport)
bildirimini kullanarak bu ihraç edilen sembolü kullanabiliyoruz. dllimport kullanarak derleyiciye diyoruz ki: “kaarşim, bu kullandığım arkadaş kernel tarafından ihraç edildi ben bunu kullanırken ne olduğunu anlamayıp programda bir hata meydana geldi sanma, taam mı?”. Derleme işlemi bittikten sonra bizim kullandığımız ihraç edilen sembol, linkleme işlemi sırasında gerçek adres ile otomatik olarak değiştirilecek, böylece sorunsuz kullanılabilecek.
Şimdi.. KeServiceDescriptorTable
sembolünü kullanmak için programımıza şu satırları da ekliyoruz:
//SST yapısı
typedef struct _SYSTEM_SERVICE_TABLE {
PULONG ServiceTableBase; //SSDT adresi, fonksiyon adresleri bu adreste başlıyor
PULONG ServiceCounterTableBase; //Kullanılmıyor
ULONG NumberOfServices; //Toplam servis sayısı, tablo limiti
PUCHAR ParamTableBase; //System Service Parameter Table
} SST;
//ntoskrnl.exe'den ihraç edilen, SSDT'yi gösteren gösterici
__declspec(dllimport) SST KeServiceDescriptorTable;
Sanırım buraya ilişkin bir açıklama yapmamıza gerek yok değil mi? Tablo yapımızı tanımlayıp arından kernel’ın ihraç ettiği sembolü bu yapıyla tanımlıyoruz. bekkitcik.h
içerisindeki fonksiyonları da hemen hemen tamamen anlattığımıza göre şimdi test aşamasına geçebiliriz.
Sürücünün test edilmesi
Her şey hazırsa artık sürücümüzü derleyip, ardından sistemde çalıştırabiliriz. Derleme işlemini yaptıktan sonra oluşacak olan .sys uzantılı dosyamızı sanal makinemize atıp, ardından internette bulabileceğiniz bir driver loader kullanarak (örneğin bunu) sürücümüzü yükleyip çalıştırmak. Aşağıda hem sürücüyü ilk yüklediğimde hem de sistemde başka bir sürücü yüklendiğinde kancamız sayesinde oluşan mesajları görüyorsunuz.
Son olarak daha önceden hazırladığımız test ortamında Windbg ile SST tablomuzun 9B indexine bakarsak fonksiyonun kancalandığını göreceğiz.
kd> dds nt!KiServiceTable + 4*9B L1
82aa895c 962ca074 bekkitcik!HookNtLoadDriver
Burdan sonra dilerseniz bp bekkitcik!HookNtLoadDriver
yaparak bu fonksiyona breakpoint koyup, ardından başka bir sürücü yüklemesi yaparak bu breakpointi tetikleyebilir, ve fonksiyonun işleyişini gözlemleyebilirsiniz.
Şimdi son olarak dilerseniz debugger’da MapSSDT
de bulunan veriye bakabiliriz. Bunun için Windbg’de bekkitcik
isimli modülümüzün data kısmında tanımlanan değişkenlere bakmalıyız. Bu işlem için de x
komutunu /d
parametresi ile kullanabiliriz.
kd> x /d bekkitcik!*
9ce9a008 bekkitcik!RealNtLoadDriver = 0x82bbb279
...
9ce9a004 bekkitcik!MapSSDT = 0x805fc6f0
9ce9a000 bekkitcik!MdlSSDT = 0x84321450
...
Burada gördüğünüz gibi MapSSDT 0x805fc6f0
adresinde, MdlSSDT ise 0x84321450
adresinde. MDL yapısını dt nt!_MDL
ile görebilmekteyiz. Örneğin bizim MDL adresimizdeki yapıya bakalım:
kd> dt nt!_MDL 0x84321450
+0x000 Next : (null)
+0x004 Size : 0n32
+0x006 MdlFlags : 0n13
+0x008 Process : (null)
+0x00c MappedSystemVa : 0x805fc6f0 Void
+0x010 StartVa : 0x82a89000 Void
+0x014 ByteCount : 0x644
+0x018 ByteOffset : 0x6f0
Yapıda kodlarımızda üzerinde değişiklik yaptığımız, izinlerin durumunu belirten MdlFlags
alanını da görüyorsunuz. Ayrıca Next
alanı da ilgi çekici, demek ki MDL’ler birbirine bağlı.. Şimdilik bizi ilgilendiren kısım MappedSystemVa
kısmı. Dikkat ederseniz bu değer MapSSDT
‘nin değeri ile aynı. dps
kullanarak bu adresteki veriyi çekersek SSDT’nin map edilmiş halini göreceğinz.
kd> dps poi(bekkitcik!MapSSDT)
805fc6f0 82c7a0cb nt!NtAcceptConnectPort
805fc6f4 82ad322b nt!NtAccessCheck
805fc6f8 82c25e4e nt!NtAccessCheckAndAuditAlarm
805fc6fc 82a3e6e1 nt!NtAccessCheckByType
805fc700 82c9ae6e nt!NtAccessCheckByTypeAndAuditAlarm
805fc704 82b1748a nt!NtAccessCheckByTypeResultList
Gördüğünüz gibi map edilen SSDT karşımızda.. Son olarak pte
komutu ile map edilen SSDT’nin ve orjinal SSDT’nin izinlerine bakalım:
kd> !pte nt!KiServiceTable
VA 82a896f0
PDE at C06020A8 PTE at C0415448
contains 00000000001D1063 contains 0000000002A89121
pfn 1d1 ---DA--KWEV pfn 2a89 -G--A--KREV
kd> !pte poi(bekkitcik!MapSSDT)
VA 805fc6f0
PDE at C0602010 PTE at C0402FE0
contains 000000003D041863 contains 0000000002A89963
pfn 3d041 ---DA--KWEV pfn 2a89 -G-DA--KWEV
Dikkat ederseniz gerçek SSDT’nin PTE girdisi read-only modunda. Fakat “map” edilen SSDT’nin PTE girdisi W bitine sahip, yani yazılabilir durumda.
SSDT kancalama ile ilgili yazının sonuna geldik. Umarım bir şeyler katabilmiştir. Yazıda ve verilen bilgilerde elbette ki yanlışlıklar olabilir. Fark edenler lütfen yorumlarda yahut e-mail üzerinden geri bildirim yapabilirse ben de düzeltmiş olurum.
Sevgiler
- unknowncheats
- Windows Internals 6
- MDL üzerine güzel bir OSR makalesi
- Using MDLs
- Yazarken dinlediğim
© 2024 ring0.info ― Hiçbir hakkı saklı değildir, amme hizmetidir.