SSDT Hooking 의 이해
SSDT 후킹은 윈도우즈 API 가 커널 모드에서 서비스를 받기 위해 필요한 SSDT(System Service Descriptor Table) 의 내용을 조작하는 커널모드 후킹 방법 중에 하나 이다. SSDT 는 프로세스에 독립적이고 커널 주소 공간에 전역적으로 올라와 있으므로, 특별한 조작을 가하지 않는다면 모든 프로세스가 같은 SSDT 를 가지고 있다. 커널모드에서 전역적으로 윈도우즈 서비스 함수를 가로챌 필요가 있을때 주로 SSDT 후킹을 많이 사용한다. SSDT 를 후킹 하기에 앞서 윈도우즈 API 의 호출이 커널레벨까지 어떻게 흘러가는지 일련의 흐름에 대한 이해가 필요하다. 윈도우 환경 개발자라면 흔히 사용하는 CreateFile, CreateThread 같은 함수들은 kernel32.dll 에서 제공해주는 유저레벨 API 이다. 하지만 이 함수들은 걷껍데기 일뿐 실제 윈도우 API 들은 커널모드에서 구현되어 동작한다. 그렇다면 어플리케이션에서 CreateFile 함수를 호출할 경우 어떤 과정 거쳐 커널레벨의 CreateFile 함수 실제 코드를 실행할까? windbg 를 사용하여 그 과정에 대해 알아보자. 디버기로 사용된 PC 의 OS 는 Windows XP SP3 이다.
<목록1> CreateFileA 함수 디스어셈블
kd> u kernel32!CreateFileA
kernel32!CreateFileA:
7c801a28 8bff mov edi,edi
7c801a2a 55 push ebp
7c801a2b 8bec mov ebp,esp
7c801a2d ff7508 push dword ptr [ebp+8]
7c801a30 e8cfc60000 call kernel32!Basep8BitStringToStaticUnicodeString (7c80e104)
7c801a35 85c0 test eax,eax
7c801a37 741e je kernel32!CreateFileA+0x11 (7c801a57)
...
7c801a4e e89ded0000 call kernel32!CreateFileW (7c8107f0)
7c801a53 5d pop ebp
7c801a54 c21c00 ret 1Ch
7c801a57 83c8ff or eax,0FFFFFFFFh
7c801a5a ebf7 jmp kernel32!CreateFileA+0x30 (7c801a53)
CreateFIle 함수는 멀티바이트와 유니코드용 함수로 나뉘어 kernel32.dll 에서 각각 CreateFileA 함수와 CreateFileW 함수로 export 되어 있다. <목록1> 을 보면 첫번째 인자로 들어오는 pFileName 을 유니코드로 변환하여 CreateFileW 함수를 호출하게 된다. 이 같은 사실로, 다른 윈도우 API 도 마찬가지로 접미사로 A 가 붙은 함수는 넘겨진 인자들 중 문자열은 모두 유니코드로 변환하여 접미사 W 가 붙은 함수를 호출함을 알수 있었다. 그럼 CreateFileW 함수를 한번 살펴보자. 함수 내에서 디스어셈블한 내용중 call 명령만 추려서 살펴보았다.
<목록2> CreateFileW 함수 call 명령 정보
kd> uf -c kernel32!CreateFileW
Flow analysis was incomplete, some code may be missing
kernel32!CreateFileW (7c8107f0)
kernel32!CreateFileW+0x67 (7c810821):
call to ntdll!RtlInitUnicodeString (7c931295)
kernel32!CreateFileW+0x97 (7c81084e):
call to kernel32!BaseIsThisAConsoleName (7c810a08)
kernel32!CreateFileW+0xea (7c810865):
call to ntdll!RtlDosPathNameToNtPathName_U (7c9442d5)
kernel32!CreateFileW+0x359 (7c8109a0):
call to ntdll!ZwCreateFile (7c93d090)
kernel32!CreateFileW+0x372 (7c8109b9):
unresolvable call: call esi
kernel32!CreateFileW+0x384 (7c8109cb):
unresolvable call: call esi
kernel32!CreateFileW+0x3dd (7c8109ea):
call to kernel32!SetLastError (7c809342)
CreateFileW 함수에서는 일련의 과정을 거쳐 <목록 2> 에서 볼수 있듯이 ntdll.dll 의 ZwCreateFile 함수를 호출한다. ZwCreateFile 함수와 같이 앞에 Zw 나 Nt 같은 접두사가 붙은 함수를 Native API 라고 부른다. Natvie API(http://en.wikipedia.org/wiki/Native_API) 란 윈도우 내부에서 사용되는 즉 커널모드 서비스를 제공하는 함수를 말한다. Native API 는 함수이름에 2-3글자 정도의 접두사가 붙는데, 시스템 콜을 하는 API 는 Nt 와 Zw 접미사가 붙는다. ZwCreateFile 은 파일에 엑세스하는 시스템콜을 담당 하기 때문에 Zw 접두사가 붙는다. 그리고 Zw 접두사가 붙은 함수는 쌍으로 똑같은 이름의 Nt 접두사가 붙은 함수도 존재한다. 아래 <그림 1> 에서 확인해 볼수 있다.
<그림 1> Dependecy Walker 로 ntdll.dll 안에 export 함수 목록
여기서 눈여겨 볼 부분은 NtCreateFIle(), ZwCreateFile() 함수 둘다 entry point 로 0x0000D0AE 값을 가지고 있다. 똑같은 entry point 를 가르키고 있다는건 동일한 함수라는 것을 의미한다. <목록 3> 에 ZwCreateFile(), NtCreateFile() 함수를 디스어셈블한 내용을 보면 더욱 확실해 진다. 둘다 7c93d090 를 함수 주소를 갖고 있고 코드 또한 같다. 현재 디버깅 하는 시스템은 Nt* 함수와 Zw* 함수 모두 Zw* 함수를 호출 함을 볼수 있는데, 다른 시스템에서는 Nt* 함수를 호출 하기도 한다. 유저모드 API 에서 Nt* 함수와 Zw* 함수중 어떤 함수를 호출 할지는 설치된 윈도우 종류와 버전에 따라 다른것 같다.
<목록 3> ZwCreateFile, NtCreateFile
kd> uf ntdll!ZwCreateFile
ntdll!ZwCreateFile:
7c93d090 b825000000 mov eax,25h
7c93d095 ba0003fe7f mov edx,offset SharedUserData!SystemCallStub (7ffe0300)
7c93d09a ff12 call dword ptr [edx]
7c93d09c c22c00 ret 2Ch
kd> uf ntdll!NtCreateFile
ntdll!ZwCreateFile:
7c93d090 b825000000 mov eax,25h
7c93d095 ba0003fe7f mov edx,offset SharedUserData!SystemCallStub (7ffe0300)
7c93d09a ff12 call dword ptr [edx]
7c93d09c c22c00 ret 2Ch
디스어셈블한 내용을 보면 eax 에 0x25 를 저장한 후, 7ffe0300 주소가 가르키는 위치로 호출한다. 여기서 0x25 는 SSDT 상에 ZwCreateFile 함수의 인덱스를 의미 한다. 그리고 7ffe0300 주소는 <목록 4> 에 덤프 뜬 내용과 같이 KiFastSystemCall 함수를 가르킨다. KiFastSystemCall 함수는 현재 스택 프레임을 edx 레지스터에 저장하고 sysenter 명령을 호출 한다.
<목록 4> SharedUserData!SystemCallStub
kd> dd 7ffe0300
7ffe0300 7c93e4f0 7c93e4f4 00000000 00000000
kd> uf 7c93e4f0
ntdll!KiFastSystemCall:
7c93e4f0 8bd4 mov edx,esp
7c93e4f2 0f34 sysenter
7c93e4f4 c3 ret
sysenter 명령은 유저모드에서 사용되는 명령어로, 현재 스레드가 유저모드에서 커널모드로 진입 시켜주는 역할을 한다. 인텔 메뉴얼에 보면 SYSENTER_EIP_MSR 가 가르키는 위치를 eip 레지스터에 넣어 실행 한다고 나와 있는데, 이 값은 MSRs(MODEL-SPECIFIC REGISTERS) 의 0x176 주소에서 가져온다. MSRs 는 rdmsr 명령으로 읽어올수 있다.
<목록 5> sysenter
kd> rdmsr 0x176
msr[176] = 00000000`8053f540
kd> u 8053f540
nt!KiFastCallEntry:
8053f540 b923000000 mov ecx,23h
8053f545 6a30 push 30h
8053f547 0fa1 pop fs
8053f549 8ed9 mov ds,cx
8053f54b 8ec1 mov es,cx
8053f54d 8b0d40f0dfff mov ecx,dword ptr ds:[0FFDFF040h]
8053f553 8b6104 mov esp,dword ptr [ecx+4]
8053f556 6a23 push 23h
sysenter 명령은 nt!KiFastCallEntry 함수를 호출하고, 여기서부터 커널모드에서 코드가 실행된다. nt!KiFastCallEntry 함수에서는 SSDT 주소를 현재 스레드의 ETHREAD 구조체의 ServiceTable 필드값으로 부터 얻어오고, ZwCreateFile 함수의 서비스 번호가 저장된 eax 값을 참조하여 SSDT 에서 NtCreateFile 함수가 위치한 곳을 찾아 호출한다. 여태까지의 과정을 <그림 2> 를 통해 정리해 보자.
<그림 2> 윈도우즈 API 호출 흐름
이제 SSDT 에 대해 살펴보도록 하자. 윈도우즈 커널에는 기본적으로 2개의 서비스 테이블이 존재 한다. 하나는 Native API 정보를 가지고 있는 KeServiceDescriptorTable 이고, 나머지 하나는 Native API 정보와 더불어 GUI 함수 정보를 가지고 있는 KeServiceDescriptorTableShadow 이다. KeServiceDescriptorTable 은 일반적인 스레드에서 주로 사용하고, GUI 스레드일 경우는 KeServiceDescriptorTableShadow 를 사용한다. 두 서비스 테이블은 아래의 SERVICE_DESCRIPTOR_TABLE 구조체의 모습을 하고 있다. SERVICE_DESCRIPTOR_TABLE 구조체의 win32k 필드는 KeServiceDescriptorTable 에서는 사용하지 않고, KeServiceDescriptorTableShadow 에서만 사용한다.
<목록 6> 서비스 테이블 구조체 정의
typedef struct _SERVICE_DESCRIPTOR_ENTRY {
unsigned int *ServiceTableBase; // 함수 테이블 주소
unsigned int *ServiceCounterTableBase; // 함수 호출 개수를 저장하는 테이블 주소
unsigned int NumberOfServices; // 함수 테이블에 저장된 함수의 개수
unsigned char *ParamTableBase; // 각 함수의 인자 크기를 저장하는 테이블 주소
} SERVICE_DESCRIPTOR_ENTRY , *PSERVICE_DESCRIPTOR_ENTRY ;
typedef struct _SERVICE_DESCRIPTOR_TABLE {
SERVICE_DESCRIPTOR_ENTRY ntoskrnl; // Native API 테이블
SERVICE_DESCRIPTOR_ENTRY win32k; // GUI 서브시스템 테이블
SERVICE_DESCRIPTOR_ENTRY reserved[2]; // 예악된 테이블. 사용안함.
} SERVICE_DESCRIPTOR_TABLE, *PSERVICE_DESCRIPTOR_TABLE;
그렇다면 우리가 후킹을 하기 위해선 NtCreateFile 함수주소가 저장되어 있는 KeServiceDescriptorTable 주소를 알아야 한다. 이 주소는 ntoskrnl.exe 에서 export 해주고, 있으므로 후킹 코드에서 KeServiceDescriptorTable 심볼을 import 해오면 바로 알수있다. 이제 windbg 를 이용해서 KeServiceDescriptorTable 구조체 내부를 살펴보고, NtCreateFile 함수가 위치한 곳을 찾아 보도록 하겠다.
<목록 7> KeServiceDescriptorTable
kd> dd KeServiceDescriptorTable
80554fa0 80503b8c 00000000 0000011c 80504000
80554fb0 00000000 00000000 00000000 00000000
kd> dd 80503b8c
80503b8c 8059b948 805e8db6 805ec5fc 805e8de8
80503b9c 805ec636 805e8e1e 805ec67a 805ec6be
kd> dd 80503b8c + 0x25 * 4
80503c20 8057027c 8056fc5a 805cd888 805cd5c0
80503c30 8061c286 8057038a 8060f7be 805702b6
kd> u 8057027c
nt!NtCreateFile:
8057027c 8bff mov edi,edi
8057027e 55 push ebp
8057027f 8bec mov ebp,esp
80570281 33c0 xor eax,eax
KeServiceDescriptorTable 을 덤프한 내용을 살펴보면, 함수 테이블 주소는 80503b8c, 함수 테이블의 함수 개수는 0x11c 개, 각 함수의 인자 크기를 저장하는 테이블은 80504000 을 가르킴을 확인할수 있다. 이어서 NtCreateFile 함수를 찾기 위해80503b8c 을 살펴보니 커널 공간 주소를 가지고 있는 함수들이 함수포인터 배열 형태로 나열되고 있다. 여기서 NtCreateFile 함수의 위치를 찾기 위해선 위에서 봤던 서비스 번호 0x25 를 가지고 함수 주소의 크기가 4 바이트 이므로, 함수 테이블 주소를 기준으로 0x25 * 4 만큼 떨어진곳에 NtCreateFile 함수가 있는곳을 찾을수 있다. 찾은 위치인 8057027c 로 디스어셈블 해보면 NtCreateFile 함수 내용을 확인 할수 있다. NtCreateFile 함수 주소 8057027c 위치를 우리가 작성한 함수 주소로 덮어쓰고 사용자 어플리케이션에서 CreateFile 함수를 호출하면, 커널모드에서 우리가 작성한 함수 주소가 CreateFile 함수의 서비스 함수인줄 알고 호출하게 되는 것이다. 여기까지가 SSDT 후킹의 기본 개념이고, 다음에 SSDT 후킹이 실제 코드로 어떻게 구현이 되는지 살펴보도록 하겠다. |