SSDT(System Service Descriptor Table) Hooking Written by 백구 Contack Me : whiteexplod@naver.com 목차 가. 이문서의목적... 2 나. 유저모드와커널모드... 2 다. Windows API 흐름... 2 1. User Level... 2 2-1. 소프트웨어인터럽트 0x2E에의한커널모드짂입방식... 4 2-2. Fast System Call에의한커널모드짂입방식... 6 3. Kernel Level... 8 라. Write Protection 제거... 11 1. CR0 레지스터를이용하는방법... 11 2. MDL을이용하는방법... 12 마. SSDT Hooking... 14 1. 매크로함수... 14 2. Native API 변경... 15
가. 이문서의목적 Kernel 기반의 Hooking 을위한 Windows 의내부구조를살펴보고, 그중 SSDT Hooking 방법에 대해알아보고자한다. 나. 유저모드와커널모드 대부분의개발자들이생각하는것과다르게, x86 CPU에는 " 커널모드 " 라고불리는모드가없다. 대싞에수행중인프로그램의권한을결정하기위한현재실행중인코드세그먼트의특권레벨이있다. 보호모드에서는 8Byte의세그먼트디스크립터 (Segment Descriptor) 라는것을별도로가지고있으며 Base address, Type, DPL 등여러가지정보를저장하고있다. 이러한디스크립터중현재실행중에있는코드세그먼트디스크립터가가지는 DPL 값을특별히 CPL(Current Privilege Level) 이라부르며, 이값이바로현재실행되고있는코드의권한이된다. CPL 3 을가지는코드세그먼트에서실행되는코드는유저모드에서실행된다고말하고 CPL 0 을 가지는코드세그먼트에서실행되는코드는커널모드에서실행된다고말한다. 즉, 커널모드 (Ring 0) 와유저모드 (Ring 3) 은 CPU 가아닌코드의속성이다. 이러한특권레벨의권한은, 특권명령과 I/O 명령의사용과세그먼트와세그먼트디스크립터접 근을통제하게된다. 다. Windows API 흐름 1. User Level 각각의서브시스템 (Win32, OS/2, Wow, POSIX) 들은커널모드에서동작하는서비스 ( 시스템서비스 ) 들을이용하여하드웨어장치나메모리에접근하게되며, 이때사용되는커널모드서비스들은 Native API 라고불리는함수들을호출하여이러한작업들을수행하게된다. 유저모드에서동작하는프로그램이 Native API 를이용하려면 ntdll.dll 파일을통해야맊하며 ntdll.dll 파일은모든시스템서비스들의짂입점을포함하고있다.
[ 그림 1] User Level 에서 CreateFile() 함수호출과정 2K 01001DE6 FF15 B4100001 CALL DWORD PTR DS:[<&KERNEL32.CreateFile>; CreateFileW 77E89752 FF15 2410E877 CALL DWORD PTR DS:[<&NTDLL.NtCreateFile>; ntdll.zwcreatefile ntdll.zwcreatefile : 77F95238 B8 20000000 MOV EAX,20 77F9523D 8D5424 04 LEA EDX,DWORD PTR SS:[ESP+4] 77F95241 CD 2E INT 2E 77F95243 C2 2C00 RETN 2C 2K, XP 01002653 FF15 04110001 CALL DWORD PTR DS:[<&KERNEL32.CreateFile>; CreateFileW 7C810910 FF15 0810807C CALL DWORD PTR DS:[<&ntdll.NtCreateFile>; ntdll.zwcreatefile ntdll.zwcreatefile: 7C93D682 B8 25000000 MOV EAX,25 7C93D687 BA 0003FE7F MOV EDX,7FFE0300 7C93D68C FF12 CALL DWORD PTR DS:[EDX] ; ntdll.kifastsystemcall 7C93D68E C2 2C00 RETN 2C ntdll.kifastsystemcall: 7C93EB8B 8BD4 MOV EDX,ESP 7C93EB8D 0F34 SYSENTER 7C93EB8F 90 NOP User Level 의 CreateFile() 함수가 Kernel Level 로들어서기위해 KERNEL32.CreateFile(), NTDLL.NtCreateFile() 함수를차례로호출하고 Int 2e 나 SYSENTER 명령어를실행하는것을볼수 있다.
(2K) ntdll.zwcreatefile은 Win32API인 CreateFile 호출시 NTDLL의 native API로불려지는함수로이함수의코드내용은 Windows 개발자들이정한각 Native 함수별서비스번호를 EAX 레지스터에넣어주고 EDX 레지스터에는스택프레임의포인터를저장시켜놓은후소프트웨어인터럽트 0x2e를호출하고있음을알수있다. (2K, XP) Pentium II 이후의 x86 에서는 sysenter와 sysexit 를통해서 System Service Dispatching을수행되도록한다. EAX에는 Service Table의 Index를 EDX에는 Parameter의 Pointer를넣어줌으로 System Service Routine을수행한다. 2-1. 소프트웨어인터럽트 0x2E 에의한커널모드짂입방식 아래 [ 그림 2] 는 'int 2e' 명령으로인한인터럽트디스크립터테이블과젂역디스크립터테이블 엔트리그리고목적지코드세그먼트에있는인터럽트서비스루틴사이의관계를나타낸다. [ 그림 2] Int 2e 의 Interrupt Service Routine 구하기 Ntddk.h에정의되어있는셀렉터값들 #define KGDT_NULL 0 #define KGDT_R0_CODE 8 #define KGDT_R0_DATA 16 #define KGDT_R3_CODE 24 #define KGDT_R3_DATA 32 #define KGDT_TSS 40
[ 표 1] Ntddk.h 에정의된셀렉터값들 kd>!descriptor IDT 2e ------------------- Interrupt Gate Descriptor -------------------- IDT base = 0x80036400, Index = 0x2e, Descriptor @ 0x80036570 80036570 cd 15 08 00 00 ee 46 80 Segment is present, DPL = 3, System segment, 32-bit descriptor Target code segment selector = 0x0008 (GDT Index = 1, RPL = 0) Target code segment offset = 0x804615cd ------------------- Code Segment Descriptor -------------------- GDT base = 0x80036000, Index = 0x01, Descriptor @ 0x80036008 80036008 ff ff 00 00 00 9a cf 00 Segment size is in 4KB pages, 32-bit default operand and data size Segment is present, DPL = 0, Not system segment, Code segment Segment is not conforming, Segment is readable, Segment is not accessed Target code segment base address = 0x00000000 Target code segment size = 0x000fffff 이인터럽트 2E 에서 DPL 값이 3 인것을확인함으로써우리는이인터럽트가어플리케이션레벨 Ring 3 에서도수행될수있는권한을가지고있음을확인할수있으며셀렉터값이 8 인것을통 하여 Windows 에서정의하고있는 Ring 0 코드세그먼트를사용하고있음을확인할수있다. 위 [ 그림 2] 와같이, int 2e 명령어를통해 ISR(Interrupt Service Routine) 이위치한곳까지따라가보자. ㄱ. IDT(Interrupt Descriptor Table) 에서 Index 2e 맊큼에위치한 Interrupt Gate Descriptor를찾는다. (IDT base) 0x80036400 + (Index) 2e * (Descriptor Size) 8Byte = 0x80036570 ㄴ. Interrupt Gate Descriptor에서 Target Code offset(0x804615cd) 과 Target code segment selector를구한다. ㄷ. Target code segment selector로인해 GDT Index = 1의 Global Descriptor를찾는다. (GDT base) 0x80036000 + (Index) 1 * (Descriptor Size) 1Byte = 0x80036008 ㄹ. Global Descriptor에서 Target code segment base address(0x00000000) 를구한다. ㅁ. Global Descriptor의 Target code segment base address와 Interrupt Gate Descriptor에서 Target Code offset을더하여 ISR를구한다. 0x00000000 + 0x804615cd = (ISR) 0x804615cd kd>!idt 2e: 804615cd (nt!kisystemservice) kd> u 804615cd nt!kisystemservice:
804615cd 6a00 push 0 804615cf 55 push ebp 804615d0 53 push ebx 804615d1 56 push esi 804615d2 57 push edi 804615d3 0fa0 push fs 804615d5 bb30000000 mov ebx,30h 804615da 668ee3 mov fs,bx int 2e 의 Interrupt Service Routine 의주소를구하고 WinDbg 에서확인해보면 nt!kisystemservice 커널함수와같다는것을볼수있다. ** 인터럽트발생과스택변화 x86 보호모드홖경에서각각의특권레벨은자싞의스택을가짂다. CPU가커널모드코드세그먼트에있는 ISR을실행하기젂에, 커널모드스택으로젂홖되어야한다. 맊약실행되어야하는인터럽트루틴이현재실행되고있는코드의특권레벨과같을경우현재의스택을그대로사용하여 EFLAGS, CS, EIP, Error Code 등을저장하게된다. 하지맊인터럽트루틴의특권레벨이현재의특권레벨보다높아야할경우에는 TSS에저장되어있는특권레벨별스택세그먼트 (SS) 와스택포인터 (ESP) 를참조하여스택스위칭이발생하게되며, 이때바뀌게된스택에는이젂의스택세그먼트 (SS) 와스택포인터 (ESP) 가추가적으로저장되어있게된다. 2-2. Fast System Call 에의한커널모드짂입방식 Fast System Call 은인텔펜티엄 2(Pentiup II+) 이상에서출현한것으로어플리케이션레벨에서커 널레벨로의짂입을보다빠르게할수있도록제공해주는기능이며, Windows 2000 이상에서는 부분적으로이기능을사용함으로써보다효율적인코드를맊들고있다.
[ 그림 3] Fast System Call SYSENTER는어플리케이션레벨 (Ring 3) 에서사용되는명령어로마이크로프로세서에있는 MSRs(MODEL-SPECIFIC REGISTERS) 로부터짂입하고자하는커널의 CS, EIP의레지스터정보와커널레벨짂입후사용하게될스택정보 SS, ESP를가져와서세팅한후커널레벨로짂입이이루어지도록한다. MSR SYSENTER_CS_MSR SYSENTER_ESP_MSR SYSENTER_EIP_MSR ADDRESS 0x174 0x175 0x176 [ 표 2] MSRs(MODEL-SPECIFIC REGISTERS) lkd> rdmsr 176 msr[176] = 00000000`80542770 lkd> u 80542770 nt!kifastcallentry:
80542770 b923000000 mov ecx,23h 80542775 6a30 push 30h 80542777 0fa1 pop fs 80542779 8ed9 mov ds,cx 8054277b 8ec1 mov es,cx 8054277d 648b0d40000000 mov ecx,dword ptr fs:[40h] 80542784 8b6104 mov esp,dword ptr [ecx+4] 80542787 6a23 push 23h 0x80542770 이주소의내용은 ntosknl의 KiFastCallEntry 커널함수임을확인할수있으며, 이코드에서는이커널코드가종료한후어플리케이션으로복귀할때사용할데이터들을초기화하고, 어플리케이션레벨에서넘겨준서비스번호를사용하여 KiSystemService 함수에의해해당커널함수를호출하여주게된다. 3. Kernel Level 이제 Kernel Level 로넘어와서전체흐름을살펴보면아래그림과같다. [ 그림 4] Kernel Level 에서 Native API 호출과정 kd> dd KeServiceDescriptorTable 8046ab80 804704d8 00000000 000000f8 804708bc ServiceDescriptor[0] 8046ab90 00000000 00000000 00000000 00000000 ServiceDescriptor[1]
8046aba0 00000000 00000000 00000000 00000000.. 8046abb0 00000000 00000000 00000000 00000000.. 8046abc0 804704d8 00000000 000000f8 804708bc.. 8046abd0 a01859f0 00000000 0000027f a0186670.. kd> d 804704d8 (SSDT Base) 804704d8 804ab3bf 804ae86b 804bdef3 8050b034 804704e8 804c11f4 80459214 8050c2ff 8050c33f 804704f8 804b581c 80508874 8049860a 804fc7e2 kd> u 804ab3bf nt!ntacceptconnectport: 804ab3bf 55 push ebp 804ab3c0 8bec mov ebp,esp 804ab3c2 6aff push 0FFFFFFFFh 804ab3c4 6880324080 push offset nt!`string'+0x208 (80403280) 804ab3c9 682ccc4580 push offset nt!_except_handler3 (8045cc2c) kd> u 804ae86b nt!ntaccesscheck: 804ae86b 55 push ebp 804ae86c 8bec mov ebp,esp 804ae86e 33c0 xor eax,eax 804ae870 50 push eax 804ae871 ff7524 push dword ptr [ebp+24h] (SSDT Base) 0x804704d8 + (ntdll.zwcreatefile의 EAX) 0x20 * (Size) 4Byte = 0x80470558 kd> d 80470558 80470558 804a0c2d 804c18f5 804fc8b8 f3eaa3d0 kd> u 804a0c2d nt!ntcreatefile: 804a0c2d 55 push ebp 804a0c2e 8bec mov ebp,esp 804a0c30 33c0 xor eax,eax 804a0c32 50 push eax 804a0c33 50 push eax kd> db 804708bc (SSPT Base) 804708bc 18 20 2c 2c 40 2c 40 44-0c 18 18 08 04 04 0c 10.,,@,@D... kd> u nt!ntcreatefile 확인 nt!ntcreatefile: 804a0c2d 55 push ebp
804a0c2e 8bec mov ebp,esp 804a0c30 33c0 xor eax,eax 804a0c32 50 push eax 804a0c33 50 push eax ㄱ. User Level 에서 SYSENTER 를통해커널모드로짂입했을경우에는 KiFastCallEntry() 를거치고, int 2e 로짂입했을경우에는 KiFastCallEntry() 를거치치않고바로 KiSystemService() 로넘어가 게된다. ㄴ. KiSystemService() 에서하는가장중요한작업은 SSDT 의주소값을얻어오고어플리케이션에 서호출한 API 에맞는 Native API 의주소를찾아내서호출하는것이다. KiSystemService() 함수에서는먼저 SSDT를찾기위해서 KeServiceDescriptorTable에접근한다. KeServiceDescriptorTable의구성요소는 4가지로이루어져있다. 1 ServiceTableBase는 SSDT(KiServiceTable) 의주소를담고있고, 2 ServiceCounterTableBase에는이함수가얼마나맋이호출되어졌는지를나타내는함수의호출개수를저장하는테이블의포인터를저장하고있으나, 이는 Windows의 Checked 빌드버젂에서맊사용되어지게된다. 3 NumberOfService는뜻그대로풀어쓰면서비스의개수가된다. 여기서말하는서비스는 Native API를지칭하기때문에결국세번째요소는 Native API 의총개수를말한다. 4 ParamTableBase는 KiArgumentTable의주소값을담고있다. KiArgumentTable은 SSPT(System Service Parameter Table) 라고도불리는데, 이들각각은 SSDT의 Native API와 1:1대응을한다. 이것들은대응되는 Native API 함수의파라미터총크기를바이트단위로써나타낸다. ㄷ. Native API를찾기위해서, Ntdll.dll에서 EAX 레지스터에인덱스의형태로값을저장한다는것을이젂에보았다. 이제이것과 SSDT 주소값을이용해서 Native API 함수의엔트리주소값을얻어오게된다. SSDT주소 (KiServiceTable)+[EAX인덱스*4] 를한다면갂단하게 Native API 함수주소를얻어올수있는데, 이것은실제로 KiSystemService() 가하는코드와도같다. ㄹ. 이제원하는 Native API 함수로짂입하게된다. ** Windows의커널모듈 ntoskrnl에는 KeServiceDescriptorTable 이라는젂역변수가있으며, 여기에는서비스 ID별함수의호출정보들이저장되어있고소프트웨어인터럽트 2E에의하여호출되는 KiSystemService() 함수에서는이변수를참조하여해당서비스와매핑되는함수정보를알아내게된다.
라. Write Protection 제거 XP 이상에서는 SSDT 는 Read Only 로되어있다. 이것은 Windows 의메모리보호를위해사용된기법으로써 Write Protection 이라고한다. 이부분을제거해야맊 SSDT 에바꿔칠주소를쓰는것이가능해짂다. Write Protection 기법을우회하는방법으로 CR0 레지스터를이용하는방법과 MDL(Memory Descriptor List) 을이용하는방법에대해설명한다. 1. CR0 레지스터를이용하는방법 SSDT 의 Write Protection 을제거하기위해관심가질부분은 CR0 레지스터의 WP(Write Protection) 비트부분이다. [ 그림 5] x86 의 Control registers #define CR0_WP_MASK 0x0FFFEFFFF VOID ClearWriteProtect(VOID) { asm {
} } push eax; mov eax, cr0; and eax, CR0_WP_MASK; // WP Clear mov cr0, eax; pop eax; VOID SetWriteProtect(VOID) { asm { push eax; mov eax, cr0; or eax, not CR0_WP_MASK; // WP 비트 Setting mov cr0, eax; pop eax; } } 2. MDL 을이용하는방법 ** 사용자버퍼접근방법사용자모드스레드가 I/O 요청을하면사용자모드의데이터버퍼주소가디바이스드라이버에게젂달된다. 정확하게말하면 I/O Manager에게젂달된다. 사용자모드의주소는페이지테이블의하위 2GB( 커널영역 ) 로참조되기때문에드라이버입장에서는 I/O요청이완료되기이젂에가상페이지테이블이변할수도있다는가능성을고려해야한다. 디바이스드라이버프로그램개발에서는 2가지방법을이용해서유저 ( 사용자 ) 모드메모리에접근한다. ㄱ. Buffered I/O I/O Manager가사용자모드의버퍼를시스템이지정된메모리에복사해서사용하는방법이다. 주의할점은 I/O Manager가할당하는메모리영역이 Physical Memory라는점이다. ㄴ. Direct I/O 디바이스드라이버가 Physical Memory 수준에서사용자버퍼를직접적으로접근해서 I/O Manager를통한버퍼의복사를피하는방식이다. 디바이스드라이버가 I/O 요청 ( 작업 ) 을수행할때 I/O Manager는사용자버퍼를메모리내에서잠가 (Locked) 두고, 그메모리블록이스와핑 (Swapping) 되어페이지폴트가발생하는것을막는다.
MDL(Memory Descriptor List) 는 User-Provided Data Buffer 의데이터를 Buffered I/O 처럼복사하지 않고물리주소를직접제공해줌으로써직접적인주기억장치에 Access 가가능하게해주는것이다. ->(Direct I/O) MDL 을이용하는방법은 Nonpaged pool 에 MDL 을생성하여그것이 SSDT 를가리키게하는것이 다. 생성된 MDL 은쓰기가능하게설정되어있으므로해당 MDL 이가리키는실제물리적메모리 영역, 즉원래 SSDT 에도쓰는것이가능하게되는것이다. { PMDL g_pmdlsystemcall; PVOID *MappedSystemCallTable; g_pmdlsystemcall = MmCreateMdl(NULL, KeServiceDescriptorTable.ServiceTableBase, KeServiceDescriptorTable.NumberOfServices*4); if(!g_pmdlsystemcall) KdPrint( ("[%s] : SetHook UnSuccessful\n", FUNCTION ) ); MmBuildMdlForNonPagedPool(g_pmdlSystemCall); g_pmdlsystemcall->mdlflags = g_pmdlsystemcall->mdlflags MDL_MAPPED_TO_SYSTEM_VA; } MappedSystemCallTable = MmMapLockedPages(g_pmdlSystemCall, KernelMode); MDL을생성하기위해서는 MmCreateMdl 함수를위와같이이용한다. 이함수는아래와같은프로토타입을가지고있다. PMDL MmCreateMdl( IN PMDL MemoryDescriptorList OPTIONAL, IN PVOID Base, IN SIZE_T Length ); MemoryDescriptorList 에서는기존 MDL 배열을넣어주면되는데, 여기서는 NULL 을넣어준다. Base 에는해당메모리주소값을넣어주고 Length 는해당메모리의바이트수를넣어주면된다. 후킹하고자하는 SSDT는 KeServiceDescriptorTable.ServiceTableBase가가리키는메모리에존재한다. ServiceTableBase는 NumberOfServices 개수맊큼함수포인터를가지고있는배열이므로, 젂체테이블길이는아래와같이구할수있다. KeServiceDescriptorTable.NumberOfServices*4 MmCreateMdl() 함수로맊들어짂새로운 MDL 을 nonpaged pool 메모리영역에생성하기위해
MmBuildMdlForNonPagedPool() 함수를사용한다. 여기까지오면 nonpaged pool 메모리영역에 SSDT 를가리키는, 마치심볼릭링크같은것이하 나생성되게된다. 그리고, 이주소는시스템주소라는것을알리기위해 MdlFlage 을 MDL_MAPPED_TO_SYSTEM 로 고치면서쓰기도가능해짂다. 그리고마지막으로 MmMapLockedPages 를호출해서사용할수있는메모리포인터를반홖받으 면된다. 여기까지오면 MDL 을통해시스템의 SSDT 를읽고쓰기가가능하게된다. 마. SSDT Hooking 1. 매크로함수 lkd> u nt!zwcreatefile nt!zwcreatefile: 80501538 b825000000 mov eax,25h 8050153d 8d542404 lea edx,[esp+4] 80501541 9c pushfd nt!zwcreatefile 함수의헥사코드를살펴보면위와같다. B8 은 mov eax 이고, 뒤에 4바이트즉, 25 00 00 00 이서비스인덱스번호인것이다. Little Endian 이므로 0x00000025 값이바로 nt!zwcreatefile 함수의인덱스번호가된다. 즉, System call 의서비스인덱스번호는 System call 의두번째바이트에 4바이트형태로존재한다. 앞에서 KeServiceDescriptorTable.ServiceBase 필드는각서비스별매핑함수포인터들의목록이저장된테이블의포인터라고했는데, 다시말하면서비스인덱스별서비스콜의주소의배열이란얘기가된다. 즉 KeServiceDescriptorTable.ServiceBase[0x25] == nt!zwcreatefile 란것이다. declspec(dllimport) ULONG NtBuildNumber; declspec(dllimport) ServiceDescriptorTableEntry_t KeServiceDescriptorTable; #define SYSTEMSERVICE(_function) KeServiceDescriptorTable.ServiceTableBase[ *(PULONG)((PUCHAR)_function+1)] #define SYSTEMSERVICEIDX(_index) KeServiceDescriptorTable.ServiceTableBase[_index]
SYSTEMSERVICE와 SYSTEMSERVICEIDX는얻고자하는값이같지맊쓰이는상황이다르다. SYSTEMSERVICE와같은경우는 Native API의이름으로서비스콜의주소를구해오고자할때쓰이며, SYSTEMSERVICEIDX와같은경우는 Service Number로서서비스콜의주소를얻어오고자할때쓰인다. Exported by Name이되지않은몇몇의 Native API들은 SYSTEMSERVICEIDX를어쩔수없이써야하는데이때운영체제의빌드버젂에따라서 Service Number가다를수있다. NtBuildNumber를통해현재운영체제의빌드넘버를구하고, NtBuildNumber로 switch처리를해주어야한다. 2. Native API 변경 oldzwcreatefile = (ZWCREATEFILE)SYSTEMSERVICE(ZwCreateFile); oldzwcreatefile = (ZWCREATEFILE)InterlockedExchange((PLONG)&SYSTEMSERVICE(ZwCreateFile), (LONG)newZwCreateFile); NTSTATUS newzwcreatefile( OUT PHANDLE FileHandle, IN ACCESS_MASK DesiredAccess, IN POBJECT_ATTRIBUTES ObjectAttributes, OUT PIO_STATUS_BLOCK IoStatusBlock, IN PLARGE_INTEGER AllocationSize OPTIONAL, IN ULONG FileAttributes, IN ULONG ShareAccess, IN ULONG CreateDisposition, IN ULONG CreateOptions, IN PVOID EaBuffer OPTIONAL, IN ULONG EaLength) { NTSTATUS status; PUCHAR szprocessname; PEPROCESS CProcess; CProcess = (PEPROCESS)PsGetCurrentProcess(); szprocessname = CProcess->ImageFileName; status = oldzwcreatefile(
FileHandle, DesiredAccess, ObjectAttributes, IoStatusBlock, AllocationSize OPTIONAL, FileAttributes, ShareAccess, CreateDisposition, CreateOptions, EaBuffer OPTIONAL, EaLength); KdPrint(("ZwCreateFile was Called by [%s]\n", szprocessname)); return status; } InterlockedExchange API 의경우인수로젂달받은두값을비교하여같지않다면첫번째인수 Target 의값을두번째인수 value 로바꾼다. 그리고리턴값으로 Target 의값을반홖한다.