실 전! 강 의 실 개발자를 위한 실전 64비트! 64비트 윈도우 커널 탐구 2 연 + 재 + 순 + 서 1회 2005. 9 64비트 시대를 향한 첫걸음, 커널모드 드라이버 포팅 2회 2005. 10 64비트 윈도우 커널분석 AMD64 3회 64비트 윈도우 커널분석 IA64 연 + 재 + 가 + 이 + 드 운영체제 64비트 윈도우 XP, 64비트 윈도우 2003 개발도구 윈도우 DDK 최신판 기반지식 윈도우 커널모드 드라이버 개발 응용분야 응용 프로그램 및 드라이버 64비트 포팅 김성현 shkim@ahnlab.com 안철수연구소에서 V3, SpyZero 개발에 참여하고 있는 커널모드 드라 이버 개발자이다. 우리나라에서도 평생 소트프웨어 엔지니어로 살아가 면서 식구들을 먹여 살릴 수 있는 날이 올 수 있다는 꿈을 안고 그 길을 닦아나가고 있다. 올해의 목표로 Code Complete 2nd Edition Windows Internals 4th Edition 읽기, 몸무게 10kg 증량하기, 당구 250 만들기, 외 국식당에서 영어로 원하는 거 주문하기 등을 가지고 있다. 개발자를 위한 실전 64비트! 64비트 윈도우 커널 탐구 2 64비트 윈도우 커널분석 AMD64 최근 판매되는 대부분의 PC에서는 64비트 CPU 장착 이라는 문구를 볼 수 있다. PC를 새로 구입하는 사람들은 당연히 최신 기술이라고 선전하는 64비트 CPU를 선택할 테니 64비트 컴퓨팅의 시대가 본격적으로 시작됐다고 해도 과언이 아니다. 일반 데스크탑 PC의 64비트 시대를 열어가는 CPU가 바로 AMD64 계열(인텔 EM64T 포함) CPU들이니 개발자들에게도 AMD64는 무시할 수 없는 개발 영역이 되었다. 어쩌면 지금은 이미 모든 기술을 파악하고 있어야 할 상황인지도 모르지만, 아직 준비가 되지 않았다면 이번 기사를 통해 준비운동을 해보자. 이번 호에서는 64비트 윈도우 x64 에디션의 커널에 대해 살펴보려고 한다. 이번에 다루게 될 내용은 AMD64용 NT 커널에서 서비스 디스크립터 테이블(Service Descriptor Table)의 동작방식 분석과 CPU에서 가상 메모리 주소가 물리 메모리 주소로 변환되는 과정인 가상 주소 변 환(Virtual Address Translation)에 대한 것이다. 우선 AMD64 CPU에 대해 이해하는 시간이 필요할 것 같다. 이번에 다룰 내용은 주로 디버거를 이용해서 디스어셈블된 코드를 보며 설명할 계획이므로 AMD64의 레지스터나 명령어에 대한 내용 을 기본적으로 알고 있어야 한다. x86에서 디스어셈블된 코드를 보는데 익숙하다면 크게 어려운 점 은 없을 것이다. 하지만 x86과의 차이점이 조금은 존재하므로 어떤 것들을 알고 있어야 하는지 먼저 정리하고 넘어가자. AMD64 레지스터 셋 AMD64는 x86을 64비트로 확장한 것이라고 설명했다. 레지스터 셋을 확인해보면 이것이 무슨 뜻 인지 확실히 이해할 수 있다. <그림 1>은 AMD64 Architecture Programmer s Manual 에나와 있는 AMD64 레지스터 셋이다. 대부분의 레지스터들이 과거의 32비트에서 64비트로 확장되면서 이름만 약간 바뀌었고 몇 개의 레지스터만 추가됐다. General-Purpose Registers(범용 레지스터)를 보면 EAX 레지스터가 RAX 레지스터로 확장된 것을 볼 수 있다. RAX에는 64비트 데이터를 저장할 수 있으며 하위 32비트는 여전히 EAX로 접근할 수 있다. RBX부터 RSP까지 모두 마찬가지로 확장됐다. 그 밑으로 R8부터 R15까지 64비트 범용 레지스터가 추가된 것을 확인할 수 있다. 대부분의 범용 레지스터 용도는 과 거와 거의 동일하다. RAX, RIP라는 용어가 나와도 생소해하지 말고 EAX, EIP와 같은 개념으로 이해하면 될 것이다. 마 이 크 로 소 프 트 웨 어 2
실 전! 강 의 실 개발자를 위한 실전 64비트! 64비트 윈도우 커널 탐구 2 AMD64 명령어 대부분의 AMD64 명령어(Instructions) 역시 x86 명령어들과 같다 고 보면 된다. 다음의 예제 코드를 보면서 얼마나 비슷한지 느껴보자. mov [rbp-0x80],rcx mov rdi,[rdi+0x18] movzx ecx,byte ptr [rax+rdi] sub rsp,rcx and rsp,0xfffffffffffffff0 mov rdi,rsp mov rsi,[rbp+0x100] add rsi,0x28 test byte ptr [rbp+0xf0],0x1 이 코드 샘플에서 보여주고 있는 명령어들은 mov, movzx, sub, and, add, test이다. 모두 x86 명령어에서 익히 보아왔던 것들이고 그 의미도 x86에서와 같다. Operand들이 약간 어색하게 보일 수 있 는데 앞에서 AMD64 레지스터 셋을 이해했다면 어렵지 않게 익숙해 질 수 있다. rbp, rcx, rdi, rax, rsp, rsi, 모두 ebp, ecx, edi, eax, esp, esi와 같은 개념인데 크기만 64비트라고 생각하면 된다. 이제 코 드들이 자연스럽게 보이는가? 세 번째 라인에서와 같이 ecx가 직접 보이는 경우도 있다. 이 역시 rcx 레지스터(64비트)를 ecx 영역(32비 트)만 사용하는 것이라고 해석하면 된다. 참고로 같은 명령어인데 AMD64에서 의미가 약간 달라지는 명령어에 대해 간단하게 살펴보 자. 다음 코드는 32비트에서의 코드이다. ff2500249bf0 jmp dword ptr [MyDrv!_imp_MyFunction] 디스어셈블리 코드 앞에 숫자들은 메모리 상에 존재하는 실제 코드 들로 opcode와 operand를 보여주고 있다. jmp dword ptr (ff25xxxxxxxx)은 32비트에서 절대주소 점프 명령으로 xxxxxxxx에 절대 주소 0xf09b2400이 들어 있다. 하지만 64비트에서는 상대 주소 점프로 해석되어 xxxxxxxx에는 상대 주소가 들어간다. 다음은 64비 트에서의 코드이다. <표 1> Encoding for RIP-Relative Addressing ModRM and SIB Meaning in Legacy and Meaning in Additional 64-bit Encodings Compatibility Modes 64-bit Mode Implications ModRM Byte: Disp32 RIP + Disp32 Zero-based (normal) mod=00 displacement addressing r/m = 101 (none) must use SIB form <그림 1> AMD64 레지스터 셋 General-Purpose Registers(GPRs) 64-Bit Media and Floating-Point Registers RAX MMX0/FPR0 RBX MMX1/FPR1 RCX MMX2/FPR2 RDX MMX3/FPR3 RBP MMX4/FPR4 RSI MMX5/FPR5 RDI MMX6/FPR6 RSP MMX7/FPR7 R8 R9 R10 Flags Register R11 0 EFLAGS RFLAGS R12 0 R13 Instruction Pointer R14 EIP RIP R15 0 0 Legacy x86 registers, supported in all modes Register extensions, supported in 64-bit mode 128-Bit Media Registers Application-programming register also include the 128-bit media control-and-status register and the x87 tag-word, control-word, and status-word registers ff2584120000 jmp qword ptr [MyDrv!_imp_MyFunction] 필자도 처음에는 이것을 dword만 qword로 확장된 절대 주 소 점프 명령으로 생각했다. 하지만 이 코드는 32비트에서와 다르게 동작하고 있었다. jmp qword ptr (ff25xxxxxxxx)을 자 세히 보면 이상한 점을 발견할 수 있는데 절대 주소 값을 담는 xxxxxxxx 부분이 여 전히 32비트라는 점이다. 또 하나는 실제 XMM0 xxxxxxxx에 담겨있는 값인 0x00001284는 XMM1 절대 주소 값으로 보기에는 터무니없이 작 XMM2 다는 점이다. 이 의문을 풀기 위해 한참을 XMM3 XMM4 헤매던 결과 AMD64 매뉴얼에서 <표 1>과 XMM5 같은 내용을 찾을 수 있었다. XMM6 XMM7 <표 1>에서 왼쪽 맨 위에 ModRM과 SIB XMM8 가 나오는데 이것들은 명령어 코드들 중에 XMM9 XMM10 서 특정한 위치에 들어 있는 값들이다. XMM11 ff25xxxx xxxx에서 ff는 jmp opcode이고 XMM12 25가 ModRM에 해당하여 이것이 jmp 명 XMM13 XMM14 령의 종류를 결정한다. <표 1>에 의하면 XMM15 ff25xxxxxxxx 명령어 코드는 32비트 하위 호환 모드(Legacy and Compatibility Modes)에서 xxxxxxxx(disp32)로 점프하 는 절대 점프로 해석된다. 하지만 64비트 264 2 0 0 5. 1 0
64비트 윈도우 커널분석 AMD64 (64bit Mode)에서는 ff는 같은 jmp opcode이지만 25(ModRM)의 의 <그림 2> Win32 API부터 커널 API까지의 흐름도 미가 RIP + Disp32로 현재 RIP + xxxxxxxx(disp32)로 해석된다. 다시 말하면 현재 실행중인 주소 + 32비트 범위의 값 으로 계산되 어 점프할 주소가 결정되는 상대 점프라는 뜻이다. 이렇게 동작하는 방식을 RIP-Relative Addressing이라고 하는데 어떤 opcode든지 ModRM에 의해 RIP-Relative Addressing이 가능하다. 이와 같이 어떤 경우에는 32비트 모드와 64비트 모드에서 동작이 달라지는 부분이 있을 수도 있다는 사실을 늘 염두에 두고 있어야 한 다. 하지만 대부분 그렇지 않은 코드이므로 너무 걱정하지 않아도 된 다(ModRM, SIB 등에 익숙하지 않은 독자들도 있을텐데, 이 내용을 이해하려면 CPU 매뉴얼에서 명령어(instruction) 구조에 해당하는 부분을 보기 바란다). SDT Application CreateFile() Kernel32.dll NtCreateFile() Ntdll.dll System Call User Mode Kernel Mode System Call Handler ZwCreateFile() Driver NtCreateFile() NT Kernel API NT 커널 서비스 테이블 NT 커널 모드 드라이버를 개발해 봤다면 NT 커널이 제공하는 서비 스 디스크립터 테이블(Service Descriptor Table, 이하 서비스 테이 블)을 들어본 적이 있을 것이다. 이를 간단히 설명하면 NT 커널이 응 용 프로그램 개발을 위해 제공하는 API들의 포인터를 담고 있는 테이 블이라고 할 수 있다. AMD64에서 NT 커널 서비스 테이블을 분석하 기 전에 32비트에서의 개념을 먼저 정리할 필요가 있다. 우리가 흔히 사용하는 CreateFile() API를 통해 서비스 테이블이 사용되는 예를 보면 <그림 2>와 같다. 어떤 응용 프로그램이 kernel32.dll에 존재하는 CreateFile() Win32 API를 호출하면 ntdll.dll이 제공하는 API인 NtCreateFile() 이 호출된다. ntdll.dll의 NtCreateFile()은 syscall로 커널 모드로 진 입하여 실제 NT 커널의 NtCreateFile()을 호출하게 하는 유저 모드 랩퍼 함수이다. 이것을 통해 커널 모드로 진입하면 System Call Handler는 서비스 테이블(SDT)에 등록된 실제 NtCreateFile() 커 널 함수의 포인터를 찾아서 이것을 직접 호출해 주게 된다. 커널 모드 드라이버에서도 실제 NtCreateFile()을 호출하려면 비슷한 과정을 거치게 되는데 커널 모드에서는 ZwCreateFile() API가 랩퍼 함수의 역할을 하고 있다. 서비스 테이블에는 NtCreateFile()뿐 아니라 수많은 NT 커널 API 의 포인터들이 들어 있다. 이것을 확인하기 위해 이제부터 WinDBG 를 이용해 NT 커널의 서비스 테이블을 살펴보자. NT 커널에서 익스 포트된 KeServiceDescriptorTable을 참조하면 서비스 테이블을 볼 수있다. KeServiceDescriptorTable의 내용을 파악하기 위해 구조체로 표 현하면 다음과 같다. struct ServiceDescriptorTable { PVOID ServiceTableBase; PVOID ServiceCounterTable; ULONG NumberOfServices; PVOID ParamTableBase; } 첫 번째 필드인 ServiceTableBase에는 서비스 테이블의 시작 주소 가 들어 있다. 이 주소부터 4바이트 단위로 NT 커널 서비스 API들의 주소가 들어가 있다. 두 번째 필드는 커널 서비스 API가 호출된 회수 를 기록하는 테이블인데 디버그 버전에서만 사용되고 릴리즈 버전에 서는 항상 0이므로 설명을 생략한다. 세 번째 필드인 NumberOfSer vices에는 SDT에 등록되어 있는 NT 커널 서비스 API의 개수가 적혀 있다. 앞에서 보면 0x11c개의 API가 등록되어 있음을 알 수 있다. ParamTableBase에는 파라미터 테이블의 시작 주소가 들어 있다. 파라미터 테이블이란 각 API마다 전달되는 파라미터가 몇 바이트인 지 기록된 테이블이다. 이 주소부터 1바이트 단위로 전달되는 파라미 터의 바이트 수가 기록되어 있다. 예를 들어 ServiceTableBase [3]에 있는 API에 해당하는 파라미터의 수는 ParamTableBase[3]에서 확 인할 수 있다. 첫 번째 필드인 ServiceTableBase에 해당하는 포인터 0x804e68b0은 nt!kiservicetable을 가리킨다. kd> dd keservicedescriptortable 805500 804e68b0 00000000 0000011c 805193e4 805510 00000000 00000000 00000000 00000000 kd> dd 804e68b0 804e68b0 80590f12 8057c3b1 80599012 805e11e6 804e68c0 80599099 80640008 80642199 806421e2 마 이 크 로 소 프 트 웨 어 265
실 전! 강 의 실 개발자를 위한 실전 64비트! 64비트 윈도우 커널 탐구 2 804e68d0 80582221 8064feb3 80f7cb 80598849 804e68e0 807ae2 805842d5 80597b44 8062e916 4바이트 단위로 함수 포인터들이 기록되어 있는 것을볼수있다. 첫 번째 함수와 두 번째 함수를 보면 다음과 같다. kd> u 80590f12 nt!ntacceptconnectport: 80590f12 689c000000 push 0x9c 80590f17 6848944f80 push 0x804f9448 80590f1c e8623ff5ff call nt!_seh_prolog (804e4e83) kd> u 8057c3b1 nt!ntaccesscheck: 8057c3b1 8bff mov edi,edi 8057c3b3 55 push ebp 8057c3b4 8bec mov ebp,esp [ WinDBG 웹심볼 설정 -.symfix 명령어 ] 웹심볼(WebSymbol)이란 마이크로소프트에서 웹으로 제공하는 OS 구성 파일들 에 대한 심볼 파일을 말한다. 본문 설명에서 kd> u 80590f12 명령어를 사용했을 때 nt!ntacceptconnectport 같은 내부 함수 이름이 정확히 나오는 이유는 WinDBG Symbol File Path에 웹심볼을 설정해놨기 때문이다. 웹심볼이 설정되지 않았다면 다음과 같은 형태로 나왔을 것이다. kd> u 80590f12 nt!rtlfindunicodeprefix+0xb54: 80590f12 689c000000 push 0x9c 80590f17 6848944f80 push 0x804f9448 80590f1c e8623ff5ff call nt!cisqrt+0x2d7 (804e4e83) 요즘은 WinDBG 사용자도 많아졌고 웹심볼에 대해서도 많이 알고 있어서 대부 분의 사용자들이 이미 Symbol File Path에 다음과 같이 웹심볼을 설정하고 사용하 고있을것이다. SRV*C:\OsSymbols*http://msdl.microsoft.com/download/symbols 하지만 심볼패스를 설정할 때마다 이와 같이 긴 포맷의 문자열을 외워서 입력하거 나 Copy & Paste하는 경우가 많은 것 같다. 앞으로는 그럴 필요 없이 WinDBG에 서 제공하는.symifx 명령어를 이용해보자. 간단하게 다운로드할 패스만 지정하여 다음과 같이 입력하면 웹심볼이 설정된다. kd>.symfix C:\OsSymbols kd>.sympath Symbol search path is: SRV*C:\OsSymbols*http://msdl.microsoft.com/download/ symbols 서비스 테이블에서 NtAcceptConnectPort()는 ServiceTableBase [0], 즉 Index 0이고 NtAccessCheck()은 ServiceTableBase[1], Index 1이다. 그렇다면 거꾸로 우리가 어떤 커널 API의 인덱스를 알 고 싶다면 어떻게 해야 할까? 앞에서 본 것처럼 ServiceTableBase에 서 함수 포인터를 일일이 쳐보면서 찾는 방법도 있지만 더 좋은 간단 한 방법이 있다. System Call Handler가 서비스 테이블에서 어떤 NT 커널 API의 함수 포인터를 참조할 수 있는 이유는 System Call Handler를 호출하는 쪽에서 API의 인덱스를 넘기기 때문이다. 그러 므로 System Call Handler를 호출하는 Zw 함수를 들여다보면 이 인 덱스를 찾을 수 있다. 예를 들어 NtOpenKey()의 인덱스를 찾고 싶다 면 ZwOpenKey()의 코드를 들여다보면 된다. kd> u ZwOpenKey nt!zwopenkey: 804e5bce b877000000 mov eax,0x77 804e5bd3 8d542404 lea edx,[esp+0x4] 804e5bd7 9c pushfd 804e5bd8 6a08 push 0x8 804e5bda e8f29bffff call nt!kisystemservice (804df7d1) 804e5bdf c20c00 ret 0xc KiSystemService를 호출하기 전에 상수를 사용하는 것을 찾으면 되는데 바로 첫 번째 라인에서 eax에 0x77을 넣는 것이 보인다. 따라 서 서비스 테이블에서 NtOpenKey()의 함수 포인터는 다음과 같이 ServiceTableBase[0x77]에서 확인할 수 있다. kd> dd 804e68b0 + (77*4) 804e6a8c 80573cbc 8057ddc7 80599d03 8057b0da 804e6a9c 80578c67 80578bbe 80580b3a 805e6ceb 804e6aac 8058a1d8 80597ef6 80576f57 80576e51 804e6abc 8065042b 805a0e11 805e6f26 805a110c kd> u 80573cbc nt!ntopenkey: 80573cbc 6894000000 push 0x94 80573cc1 6830a14e80 push 0x804ea130 80573cc6 e8b811f7ff call nt!_seh_prolog (804e4e83) 80573ccb 33f6 xor esi,esi 80573ccd 33db xor ebx,ebx 네 번째 필드인 ParamTableBase에 해당하는 포인터 805193e4은 nt!kiargumenttable을 가리킨다. kd> db 805193e4 805193e4 18 20 2c 2c 40 2c 40 44-0c 08 18 18 08 04 04 0c.,,@,@D... 805193f4 10 18 08 08 0c 04 08 08-04 04 0c 08 0c 04 04 20... 266 2 0 0 5. 1 0
64비트 윈도우 커널분석 AMD64 80519404 08 10 0c 14 0c 2c 10 0c-0c 1c 20 10 38 10 14 20...,....8.. 80519414 24 24 1c 14 10 20 10 34-14 08 0c 08 04 04 04 04 $$....4... kd> u fffff800`00a082f1 fffff800`00a082f1 8bc4??? 첫 번째 함수 NtAcceptConnectPort()의 파라미터는 0x18바이트 이다. 보통 하나의 파라미터는 4바이트를 차지하므로 6개의 파라미터 를 가진 함수임을 알 수 있다. 두 번째 함수 NtAccessCheck()는 0x20바이트이므로 8개의 파라미터를 가진 함수임을 알 수 있다. 나머 지도 마찬가지로 이 테이블에 의해 파라미터의 수를 파악할 수 있다. 그런데 이 테이블에 기록되는 내용들이 AMD64에서는 약간 다르게 설명되므로 잘 기억해두기 바란다. AMD64 서비스 테이블 분석 이제 본격적으로 AMD64에서 서비스 테이블에 대해 살펴보자. 분석 에 사용된 OS는 윈도우 XP x64 에디션 베타이다. 32비트에서와 같 은 개념으로 KeServiceDescriptorTable을 참조하면서 시작한다. kd> dq KeServiceDescriptorTable fffff800`00924400 fffff800`008eebd0 fffffadf`f4135570 fffff800`00924410 00000000`00000128 fffff800`008ef514 서비스 테이블에 등록되어 있는 함수 포인터들이 잘못 기록되어 있 는 것일까? 이상한 생각이 들어서 마지막 1을 0으로 생각하고 살펴봤 더니 모두 정확히 커널 서비스 함수의 시작 주소를 보여주고 있었다. 즉 다음과 같은 형태로 서비스 테이블이 구성되어 있다고 생각해야 한다는 것이다. kd> dq fffff800`008eebd0 fffff800`008eebd0 fffff800`00a082f0 fffff800`00a76110 fffff800`008eebe0 fffff800`00a7d350 fffff800`00a76180 fffff800`008eebf0 fffff800`00a7d3f0 fffff800`00a76200 fffff800`008eec00 fffff800`00a7d4b0 fffff800`00a7d570 fffff800`008eec10 fffff800`00ab24a0 fffff800`00ab3ad0 fffff800`008eec20 fffff800`00ab3ad0 fffff800`00a6ead0 fffff800`008eec30 fffff800`00a6e520 fffff800`00a4b220 fffff800`008eec40 fffff800`00a4b1b0 fffff800`00ab2b00 <리스트 1> 서비스 테이블의 함수 포인터를 호출하는 커널 코드 01: fffff800`008eafde 4c8b17 mov r10,[rdi] 이번에는 dd 명령어가 아니라 dq 명령어를 사용했다. dq는 Quadword values(8바이트)를 디스플레이(display)하라는 명령이다. AMD64는 64비트 CPU이므로 포인터들이 64비트(8바이트)로 저장 된다. 따라서 dq로 보아야 제대로 된 형태로 데이터를 볼 수 있다. 보 이는 바와 같이 WinDBG에서 64비트 값을 표현하는 방식은 32비트 이다. 다음으로 ServiceTableBase인 fffff800`008eebd0를 살펴보자. kd> dq fffff800`008eebd0 fffff800`008eebd0 fffff800`00a082f1 fffff800`00a76111 fffff800`008eebe0 fffff800`00a7d351 fffff800`00a76181 fffff800`008eebf0 fffff800`00a7d3f1 fffff800`00a76201 fffff800`008eec00 fffff800`00a7d4b1 fffff800`00a7d571 fffff800`008eec10 fffff800`00ab24a0 fffff800`00ab3ad0 fffff800`008eec20 fffff800`00ab3ad0 fffff800`00a6ead1 fffff800`008eec30 fffff800`00a6e521 fffff800`00a4b220 fffff800`008eec40 fffff800`00a4b1b0 fffff800`00ab2b00 32비트에서와 마찬가지로 64비트 함수 포인터들이 나열되어 있는 것을 볼 수 있다. 그런데 포인터들을 보면 마지막이 모두 0 또는 1로 끝난다. 어떤 함수들인지 파악하기 위해서 함수들을 하나씩 들여다보 면 0으로 끝나는 함수 포인터들은 아무런 문제가 없는데 1로 끝나는 함수들은 다음과 같이 함수의 내용이 제대로 보이지 않는다. 02: fffff800`008eafe1 4d8b14c2 mov r10,[r10+rax*8] 03: fffff800`008eafe5 490fbaf200 btr r10,0x0 04: fffff800`008eafea 7347 jnb nt!kidispatchinterrupt+0x1e (fffff 800008eb033) 05: fffff800`008eafec 48894d80 mov [rbp-0x80],rcx 06: fffff800`008eaff0 488b7f18 mov rdi,[rdi+0x18] 07: fffff800`008eaff4 0fb60c38 movzx ecx,byte ptr [rax+rdi] 08: fffff800`008eaff8 482be1 sub rsp,rcx 09: fffff800`008eaffb 4883e4f0 and rsp,0xfffffffffffffff0 10: fffff800`008eafff 488bfc mov rdi,rsp 11: fffff800`008eb002 488bb500010000 mov rsi,[rbp+0x100] 12: fffff800`008eb009 4883c628 add rsi,0x28 13: fffff800`008eb00d f685f000000001 test byte ptr [rbp+0xf0],0x1 14: fffff800`008eb014 740f jz nt!kidispatchinterrupt+0x1e55 (fffff 800008eb025) 15: fffff800`008eb016 483b352b860300 cmp rsi,[nt!mmuserprobeaddress (fffff 80000923648)] 16: fffff800`008eb01d 480f433523860300 cmovnb rsi,[nt!mmuserprobeaddress (fffff 80000923648)] 17: fffff800`008eb025 c1e903 shr ecx,0x3 18: fffff800`008eb028 f348a5 rep movsq 19: fffff800`008eb02b 4883ec20 sub rsp,0x20 20: fffff800`008eb02f 488b4d80 mov rcx,[rbp-0x80] 21: fffff800`008eb033 41ffd2 call r10 마 이 크 로 소 프 트 웨 어 267
실 전! 강 의 실 개발자를 위한 실전 64비트! 64비트 윈도우 커널 탐구 2 이와 같이 함수 포인터들이 들어있어야 정상일 것 같은데, 함수 포 인터에 1을 더하여 함수 포인터가 함수의 시작 위치를 정확히 가리키 지 못하게 만든 이유는 무엇일까? 또 왜 어떤 것들은 그런 처리를 하 지 않고 정확히 함수의 시작 주소를 나타내는 것일까? 필자가 처음 이것을 분석할 때는 무척이나 당황스러웠던 나머지 서비스 테이블 후 킹을 막기 위한 보안 수단이라는 억측까지 했었다. 하지만 이것이 억 측이라는 사실을 알기까지는 얼마 걸리지 않았다. 이 상황을 정확히 이해하기 위해서 서비스 테이블을 사용하는 코드를 분석하기로 한 것 이다. ntdll.dll에서 syscall을 호출하거나 커널에서 Zw 계열 함수를 호출 했을 때 진입하는 nt!kidispatchinterrupt + 0x1e0e 근처의 코드를 보면 서비스 테이블의 함수 포인터들이 어떻게 호출되는지 알 수 있 다(정확히 KiDisapatchInterrupt 함수 내부의 코드인지는 심볼이 없 어서 확실치 않다. OS가 베타버전이라 웹심볼에 심볼이 존재하지 않 았다). <리스트 1>은 함수 포인터의 비밀을 푸는데 핵심이 되는 주요 한 코드 부분만 뽑은 것이다. 이제 AMD64 어셈블리 코드가 본격적으로 나오기 시작했는데 한 눈에 분석이 가능한 독자들이 있을지 모르겠다. x86 어셈블리에 익숙 하다면 레지스터 이름들 때문에 약간은 어색하겠지만 대충 어떤 동작 을 하고 있는지 한눈에 파악할 수도 있을 것이다. 하지만 어셈블리를 분석하려면 눈을 부릅떠야 하는 필자와 같은 처지의 독자들을 위해서 한 라인씩 다시 보면서 자세하게 설명하기로 한다. 01: fffff800`008eafde 4c8b17 mov r10,[rdi] 이 시점에 rdi는 KeServiceDescriptorTable 주소를 가지고 있다. 따라서 [rdi]는 첫 번째 멤버필드인 ServiceTableBase이다. 02: fffff800`008eafe1 4d8b14c2 mov r10,[r10+rax*8] 이 시점에 rax는 서비스 테이블 인덱스이다. 테이블의 위치를 계산해서 함수 포인터를 r10에 저장한다. 03: fffff800`008eafe5 490fbaf200 btr r10,0x0 함수 포인터의 첫 번째 비트를 체크하고 클리어한다. xxxxxxxx`xxxxxxx1 형식의 함수 주소는 여기서 xxxxxxxx`xxxxxxx0 형식으로 바뀐다. 04: fffff800`008eafea 7347 jnb nt!kidispatchinterrupt+0x1e (fffff8000 08eb033) 마지막 비트가 0이었던 것들은 바로 r10의 함수 포인터를 콜하는 주소(21라인)로 점프! 05: fffff800`008eafec 48894d80 mov [rbp-0x80],rcx 함수 내에서 rcx 레지스터를 사용하기 위해 rcx에 존재하는 함수의 첫 번째 파라미터를 로컬스 택에 저장(rcx가 첫 번째 파라미터인 이유는 뒤의 AMD64 FASTCALL에서 설명한다). 06: fffff800`008eaff0 488b7f18 mov rdi,[rdi+0x18] KeServiceDescriptorTable의네번째멤버ParamTableBase 참조 07: fffff800`008eaff4 0fb60c38 movzx ecx,byte ptr [rax+rdi] ParamTableBase[rax] 로 파라미터 크기 구하기 08: fffff800`008eaff8 482be1 sub rsp,rcx 파라미터 크기만큼 rsp 감소하고 09: fffff800`008eaffb 4883e4f0 and rsp,0xfffffffffffffff0 16바이트 단위 절삭 10: fffff800`008eafff 488bfc mov rdi,rsp 현재 스택포인터(rsp)를 rdi (destination)로 설정한 후 11: fffff800`008eb002 488bb500010000 mov rsi,[rbp+0x100] rsi(source)는 전달된 파라미터(호출 당시 스택포인터로 추정)에서 가져오고 12: fffff800`008eb009 4883c628 add rsi,0x28 리턴 Address 8바이트, 상위 함수에서 사용하는 4개의 파라미터 저장공간 32바이트 스킵(역시 뒤의 AMD64 FASTCALL에서 설명한다) 13: fffff800`008eb00d f685f000000001 test byte ptr [rbp+0xf0],0x1 14: fffff800`008eb014 740f jz nt!kidispatchinterrupt+0x1e55 (fffff8000 08eb025) 15: fffff800`008eb016 483b352b860300 cmp rsi,[nt!mmuserprobeaddress (fffff800009 23648)] 16: fffff800`008eb01d 480f433523860300 cmovnb rsi,[nt!mmuserprobeaddress (fffff800 00923648)] 메모리 유효성 검사로 추정 17: fffff800`008eb025 c1e903 shr ecx,0x3 파라미터 사이즈를 8로 나누어 개수로 환산 18: fffff800`008eb028 f348a5 rep movsq rsi에서 rdi로 ecx 카운트만큼 복사. 즉 하위 함수를 호출하기 위해 파라미터를 스택에 저장 19: fffff800`008eb02b 4883ec20 sub rsp,0x20 레지스터로 전달하는 4개의 레지스터 파라미터를 저장할 스택공간 예약(뒤의 AMD64 FASTCALL에 서 설명한다) 20: fffff800`008eb02f 488b4d80 mov rcx,[rbp-0x80] 첫 번째 파라미터 복원 21: fffff800`008eb033 41ffd2 call r10 함수 포인터 호출 이 비밀을 푸는데 핵심은 마지막이 0으로 끝나는 함수 포인터는 그 대로 호출하고 1로 끝나는 함수 포인터는 1을 0으로 만들고 추가적인 작업을 한 후에 함수 포인터를 호출한다는 데에 있다. 추가적인 작업 은 파라미터 테이블을 참조해서 몇 개의 파라미터를 사용해야 하는지 확인하고 복사하는 작업이다. 이러한 작업이 왜 필요한 걸까? AMD64 함수호출규약 FASTCALL AMD64 컴파일러는 기본적으로 FASTCALL 함수호출규약(Calling Convention)을 사용한다. x86에서 FASTCALL은 함수의 첫 번째, 두 번째 파라미터는 ecx, edx 레지스터를 통해 전달하고 나머지는 스 택을 이용해서 전달하는 것이었다. 이와 비슷하게 AMD64에서는 앞 의 네 개의 파라미터를 레지스터 rcx, rdx, r8, r9로 전달하고 나머지 는 스택을 통해서 전달하는 것이 되었다. 이제 감이 좀 잡히는가? 필자는 DDK 도움말을 뒤져가면서 일일이 서비스 테이블에 있는 함수 포인터에 해당하는 커널 서비스 함수의 함수원형(프로토타입)을 살펴보았다. 여기서 확인된 사실은 함수 포 인터의 첫 번째 비트가 0으로 되어 있는 함수들은 파라미터가 4개 이 하라는 것이었다. 반면에 함수 포인터의 첫 번째 비트가 1로 되어 있 는 함수들은 파라미터가 5개 이상이었다. 268 2 0 0 5. 1 0
64비트 윈도우 커널분석 AMD64 파라미터가 4개 이하인 것들은 4개의 파라미터가 rcx, rdx, r8, r9 레지스터로 전달되기 때문에 KeServiceDescriptorTable.Param TableBase[Index]의 내용이 모두 0이다. 함수를 호출할 때 스택으로 전달되는 파라미터가 없기 때문이다. 32비트에서는 파라미터 테이블 에 0이 들어 있으면 파라미터가 없는 함수를 의미했지만 AMD64에서 는 0이 들어 있으면 파라미터가 4개 이하인 함수라는 뜻이 된다. <리 스트 1>에서 함수 포인터의 첫 번째 비트가 0으로 확인되면 곧바로 21라인의 call r10으로 점프한 이유는 함수의 파라미터는 이미 rcx, rdx, r8, r9에 들어 있고 추가로 파라미터 테이블을 참조할 필요가 없 는 함수라고 판단했기 때문이다. 이제 파라미터가 5개 이상인 것들에 대해 생각해보자. 4개의 파라미 터는 레지스터로 전달되지만 추가의 파라미터는 스택을 이용해야 하 기 때문에 KeServiceDescriptorTable.ParamTableBase[Index]에서 4개를 초과하는 파라미터의 크기를 참조해야 한다. 이것은 32비트에 서 파라미터 테이블에 있던 값들과 의미가 다르다. 32비트에서는 (함 수 파라미터 개수 4)에 대한 값이 들어 있었지만 AMD64의 경우에 는 ((함수 파라미터 개수 - 4) 8)에 대한 값이 들어 있는 것이다. 예를 들어 파라미터 테이블을 참조한 값이 8이었다면 8은 파라미터 하나를 스택에 저장할 수 있는 크기이므로 그 함수의 파라미터 수는 레지스터를 통해 전달되는 4개의 파라미터와 스택을 통해 전달되는 한 개를 더해서 5개라는 것을 알 수 있게 된다. <리스트 1>에서 함수 포인터의 첫 번째 비트가 1로 확인되면 파라미터 테이블을 참조해서 몇 개의 파라미터를 추가로 사용해야 하는지 확인하고 복사하는 작업 을 하는데, 이것을 명확히 이해하려면 스택프레임이 어떻게 구성되는 지를 이해해야 한다. <그림 3>에서 어떤 함수가 호출되었을 때 스택 영역의 구성을 보이 고 있다. 그냥 보면 x86에서의 스택 구성과 크게 다르지 않아 보인다. 하지만 반드시 알고 있어야 할 차이점은 FASTCALL임에도 불구하 고 첫 번째 파라미터부터 네 번째 파라미터까지의 스택영역(레지스터 파라미터 영역)이 존재한다는 것이다. 이 영역은 호출되는 함수에 의 해 사용되는 경우도 있고 그렇지 않는 경우도 있지만 함수를 호출할 때 대부분 이와 같이 구성된다. 스택프레임을 이해하고 다시 <리스트 1>을 보면 6~18라인에서 파라미터가 5개 이상인 경우에 복사해주는 과정을 수행하고 있음을 알 수 있다. 이중에서 6~10라인이 다섯 번 째 파라미터부터 N번째 파라미터까지의 스택 파라미터 영역 을 확 보해 주는 과정이다. <그림 3>에서 B 함수를 현재 함수로 가정하면 다섯 번째 파라미 터 부터 N번째 파라미터 까지의 자리에 C 함수의 파라미터들이 들 어갈 것이다. 11~12라인이 상위 함수의 스택 파라미터 영역 위치를 계산하는 과정이다. 12라인에서 설명했던 리턴 Address, 상위함수 <그림 3> AMD64의 스택프레임 구조 A 함수: B 함수 호출 B 함수가 사용하는 스택 영역 B 함수: C 함수 호출 C 함수 Return Address 지역변수 또는 특수용도 로 사용하는 스택 영역 N 번째 파라미터 다섯 번째 파라미터 네 번째 파라미터 세 번째 파라미터 두 번째 파라미터 첫 번째 파라미터 Return Address 지역변수 또는 C 함수 호출을 위한 스택 파라미터 영역 C 함수 호출을 위한 레지스터 파라미터 영역 에서 사용하는 4개의 파라미터 저장공간 스킵 이라는 의미는 상위 함 수의 Return Address 영역과 첫 번째부터 네 번째 파라미터 영역까 지를 스킵하고 그 위에 존재하는 5~N번째 파라미터를 참조하겠다는 뜻이다. 결국 18라인을 수행하게 되면 상위함수의 스택 파라미터 영 역 을 그대로 현재 함수의 스택 파라미터 영역 으로 복사하는 동작 을 수행하게 된다. 19라인에서는 레지스터 파라미터 영역 에 대해서 자리만 확보해주는 것을 보이고 있다. 이렇게 하고 나서 21라인을 수 행하면 호출되는 커널 서비스 함수는 모든 파라미터들을 정확히 전달 받아 동작하게 된다. 여기서 집중해서 봐야 할 부분은 파라미터가 4개 이하인 함수들과 파라미터가 5개 이상인 함수들의 처리 방법이 다르다는 것이다. FASTCALL의 특성 때문에 파라미터가 4개인 경우와 5개 이상인 경 우를 구분할 필요가 있었을 것으로 보인다. 하지만 이렇게 구분되는 함수들을 특별히(?) 표시하기 위한 방법으로 함수 포인터의 첫 번째 비트를 선택한 것은 솔직히 약간 어처구니가 없었다. 포인터의 일부 분을 특별한 목적을 위한 플래그로 사용하면서 포인터를 손상시키는 것은 사파의 무공에서나 볼 수 있는 일인데 정공만을 지향한다던 마 이크로소프트(이하 MS)에서 이런 일을 한 것이 필자에게는 얼른 납 득되지 않는 일이었다. 윈도우 서버 2003 SP1 x64 서비스 테이블 몇 달 전 필자가 윈도우 XP x64 에디션 베타를 가지고 앞의 내용을 분석하고 있을 무렵 어떤 기사를 보게 되었다. MS가 윈도우 서버 2003 SP1 x64 에디션에서는 서비스 테이블 후킹을 하지 못하도록 조 치를 한다는 내용이었다. 이제는 서비스 테이블 후킹이라는 기법이 마 이 크 로 소 프 트 웨 어 269
실 전! 강 의 실 개발자를 위한 실전 64비트! 64비트 윈도우 커널 탐구 2 각종 악성코드에서도 빈번하게 쓰이는 기술이 되었으니 당연한 조치 라고 생각만 했을 뿐 그 당시에는 필자도 아무런 생각이 없었다. 그 기사 한 줄이 몇 달 후에 필자를 상당히 당황스럽게 하리라고는 상상 도 하지 못했다. 최근에 이 글을 정리하면서 올해 정식 출시된 윈도우 XP x64 에디 <그림 4> x86 가상 주소 변환 10 32* Page Directory Directory Entry CR3(PDBR) 31 10 *32 bits aligned onto a 4-KByte boundary. <그림 5> IA32의 PDE, PTE Linear Address 22 21 12 11 Directory Table Offset Page Table Page-Table Entry 20 12 1024 PDE 1024 PTE = 2 20 Pages Page-Directory Entry (4-KByte Page Table) 0 4-KByte Page Physical Address 31 12 11 9 8 7 6 5 4 3 2 1 0 Page-Table Base Address Avail G S P 0 A C P P U R W /S / P D T W Available for system programmer s use Global Page (Ignored) Page size (0 indicates 4 KBytes) Reserved (Set to 0) Accessed Cache disabled Write-through User/Supervisor Read/Write Present Page-Table Entry (4-KByte Page) 31 12 11 9 8 7 6 5 4 3 2 1 0 Page Base Address Avail G A P DA P P U R C W /S / P T D T W Available for system programmer s use Global Page Page Table Attibute Index Dirty Accessed Cache disabled Write-through User/Supervisor Read/Write Present 션을 다시 분석해 보니 서비스 테이블의 내용이 앞서 필자가 설명한 내용과 완전히 달라진 것을 알게 되었다. 서비스 테이블의 기본 구조 에 무엇인가를 꼬아놓은 듯한 형태로 구성되어 있어서 일반적으로 알 려진 서비스 테이블에 대한 지식으로는 접근할 수 없는 상황이 되었 다. 아차 싶어서 윈도우 서버 2003 SP1 x64 에디션을 살펴보니 역시 같은 상황이었다. MS가 XP, 서버 2003 SP1 x64 에디션 정식 버전을 출시하면서 같은 개념을 적용한 것으로 보였다. 간단하게 분석을 시도해 봤는데 만만치 않은 작업이 되리라는 느낌 을 강하게 받았다. 해커, 크래커들을 막기 위한 방법일 테니 간단한 트 릭에 의해서 풀리지는 않을 것이다. 이 부분에 대한 비밀은 차후에 누 군가가 다시 풀어줘야 할 것 같다. 현재는 필자가 시간적인(또는 기술 적인) 한계에 의해서 이 부분을 진행할 계획을 가지고 있지는 않기 때 문이다. 이렇게 되면 사실 앞에서 필자가 설명했던 내용들은 쓸모없는 정보가 되었다고 볼 수도 있다. 하지만 AMD64 플랫폼에서 커널 코드 를 분석하고 연관된 지식을 얻을 수 있는 기회이기도 했으니 나름대로 의미를 부여해서 그렇게 헛된 작업을 아니었다고 결론 내리고 싶다. 가상 주소 변환 이제부터 다룰 내용은 프로그램에서 메모리를 사용할 때 가상 메모리 주소로부터 물리 메모리 주소로 변환되는 과정에 대한 것이다(다행히 도 이 부분은 XP x64 에디션 정식 버전에서도 동일하다). 32비트에서 는 4GB의 가상 메모리 주소를 사용했었다. 실제로 4GB가 되지 않는 물리 메모리를 가지고 4GB의 가상 메모리 사용을 가능하게 하려면 가상 주소 변환(virtual address translation)이라는 과정을 거쳐야 한다. 먼저 32비트의 가상 주소 변환에 대해서 간단히 알아보고 AMD64의 그것에 대해 알아보도록 하자. 정확히 설명하면 가상 주소를 선형 주소(linear address)로 변환하 는 세그멘테이션(segmentation)과 선형 주소를 물리 주소(physical address)로 변환하는 페이징(paging)을 나누어서 설명해야 한다. 하 지만 기사의 성격상 세그멘테이션은 생략하고 페이징에 대한 내용만 설명하도록 하고 용어도 인텔 매뉴얼의 선형주소 변환, AMD 매뉴얼 의 페이지 변환 등을 통칭하기 위해 이 기사에서는 가상 주소 변환으 로 정의하도록 하겠다. <그림 4>는 IA32 매뉴얼에서 참조한 그림이다. 기본적으로 CR3 레 지스터에 Page Directory Base를 가리키는 포인터가 존재한다. 이 포인터가 가리키는 Page Directory에는 Page Directory Entry(PDE)들이 나열되어 있는데 32비트 단위의 배열이라고 보면 된다. 이 PDE의 인덱스로 사용되는 것이 32비트 Linear Address에 서 상위 10비트이다. 10비트 인덱스를 가지므로 1024개의 PDE가 존 재함을 알 수 있다. 270 2 0 0 5. 1 0
64비트 윈도우 커널분석 AMD64 각각의 PDE에는 Page Table Base를 가리키는 포인터가 존재한 다. 이 포인터가 가리키는 Page Table에는 마찬가지로 Page Table Entry(PTE)들이 나열되어 있는데 역시 32비트 단위의 배열이다. 이 PTE의 인덱스로 사용되는 것이 32비트 Linear Address에서 중간 10비트이다. 이 역시 10비트이므로 하나의 Page Table에 1024개의 PTE가 존재함을 알 수 있다. 각각의 PTE에는 Physical Page의 주소가 들어 있고 이 Page 주소 에 Linear Address의 하위 12비트를 더하면 실제로 참조하려고 하는 곳의 물리 메모리 주소가 나온다. 12비트가 지시할 수 있는 범위는 4K이므로 x86에서 페이지 하나의 크기는 4KB이다. PDE와 PTE의 구조는 <그림 5>와 같다. <그림 5>에서 페이지 속성을 나타내는 각 비트들의 의미는 붙여놓 은 이름에 의해 대충 짐작할 수 있을 것이다. 여기서는 12비트부터 31 비트까지가 각 테이블의 Base Address를 나타낸다는 것만 눈여겨 보자. 이와 같은 4KB Page 방식 외에 4M-bytes Page, PAE 등 추가 적인 변형 동작이 있으나 역시 여기서는 설명을 생략하기로 한다. x86의 페이지 속성, 변형 동작들, 가상 주소 변환에 대한 더 자세한 내용은 참고자료를 통해 확인하기 바란다. AMD64 가상 주소 변환 64비트 주소를 나타내야 하는 AMD64에서 가상 주소 변환은 <그림 6>과 같이 동작한다. AMD64에서는 x86과 달리 4레벨로 페이지 테이블을 참조하도록 되어 있다. CR3 레지스터에는 Page-Map Level-4 Base를 가리키는 포인터가 존재한다. 이 포인터가 가리키는 Page-Map Level-4 Table 에는 Page-Map Level-4 Entry(PML4E)들이 나열되어 있는데 64비 트 단위의 배열이라고 보면 된다. 이 PML4E의 인덱스로 사용되는 것이 64비트 Virtual Address에서 39비트부터 47비트까지의 9비트 이다. 9비트 인덱스를 가지므로 512개의 PML4E가 존재함을 알 수 있다. 각각의 PML4E에는 Page-Directory Pointer Table을 가리키는 포인터가 존재한다. 이 포인터가 가리키는 테이블에는 마찬가지로 Page-Directory Pointer Entry(PDPE)들이 나열되어 있는데 역시 64비트 단위의 배열이라고 보면 된다. 이 PDPE의 인덱스로 사용되 는 것이 30비트부터 38비트까지의 9비트이다. 이 역시 9비트이므로 하나의 테이블에 512개의 PDPE가 존재함을 알 수 있다. 이후부터는 32비트에서 PDE부터 찾아가는 방법과 동일하게 처리 된다. 마지막 12비트가 Page Offset이므로 4KB Page를 가진다는 점 도 동일하다. 다른 점이라면 PDE, PTE가 64비트 크기를 가진다는 점과 인덱스로 9비트씩 사용하면서 테이블의 엔트리 수가 512개로 줄 <그림 6> AMD64 가상 주소 변환 Sign Extend Page-Map Level-4 Offset (PML4) 9 Page-Map Level-4 Table 52* PML4E Virtual Address 48 47 39 38 30 29 21 20 12 11 0 Page- Directory- Pointer Offset 9 9 9 9 Page- Directory- Page- 4 Kbyte Pointer Directory Page Physical Table Table Table Page 52* PDPE Page- Directory Offset *This is an architectural limit. A given processor implementation may support fewer bits. 51 12 Page-Map Level-4 CR3 Base Address 었다는 점이다. CR3, PML4E, PDPE, PDE와 PTE의 구조는 <그림 7> 과같다. <그림 7>에서 페이지 속성을 나타내는 0~11비트까지의 각 플래그 들에 대한 의미는 AMD64 매뉴얼에서 각자 확인하기 바란다. 여기서 는 12~51비트까지가 각 테이블의 Base Address를 나타낸다는 것만 알아두자. 가상 주소에서 물리 주소까지 앞의 내용을 알았으므로 가상 주소를 물리 주소로 쉽게 변환할 수 있 을 것 같다. 디버거로 수행해 보면 간단하게 확인해 볼 수 있다. 임의 로 선택한 가상메모리 주소 fffffadf`c806d892를 실제로 변환해보자. 이 주소는 사실 다음과 같이 Ntfs!LfsReleaseLch 함수의 주소이다 (이 함수는 Ntfs.sys의 내부 함수인데 우리가 이 함수 이름을 볼 수 있는 이유는 정식 버전 윈도우 XP x64 에디션의 심볼을 웹심볼 서버 에서 제공하기 때문이다). kd> u fffffadf`c806d892 Ntfs!LfsReleaseLch: fffffadf`c806d892 53 push rbx fffffadf`c806d893 4883ec20 sub rsp,0x20 fffffadf`c806d897 488bd9 mov rbx,rcx fffffadf`c806d89a 488b4938 mov rcx,[rcx+0x38] fffffadf`c806d89e ff15647a0100 call qword ptr [Ntfs!_imp_ExIsResourceAcquiredShared Lite (fffffadfc8085308)] fffffadf`c806d8a4 85c0 test eax,eax fffffadf`c806d8a6 7506 jnz Ntfs!LfsReleaseLch+0x17 (fffffadfc806d8ae) fffffadf`c806d8a8 4883c420 add rsp,0x20 PDE Page-Table Offset 52* PTE Physical- Page Offset 52* Physical Address 마 이 크 로 소 프 트 웨 어 271
실 전! 강 의 실 개발자를 위한 실전 64비트! 64비트 윈도우 커널 탐구 2 먼저 CR3 레지스터를 참조해서 12비트~51비트까지의 값을 취한 다. 이 값은 PML4의 Base를 가리키는 메모리 주소이다. 하지만 이 값은 물리 주소이기 때문에 가상 주소와 혼동해서는 안 된다. CR3, PML4E, PDPE, PDE와 PTE의 12비트~51비트까지 Base Address라는 부분은 이와 같이 모두 물리 주소이다. 또한 4KB Page 단위로만 다루기 때문에 4K로 나눠진 값이 들어가 있다. CR3 레지스 터의 12비트~51비트에서 참조한 값은 0x142b7이다. 물리 주소이므 로 d 명령어를 사용할 때 /p 옵션을 줘야 하고 4K로 나눠진 값이므로 4K(0x1000)만큼 곱해줘야 정상적인 주소가 된다. kd> dq /p 142b7000 00000000`142b7000 1f000000`131ae867 00000000`00000000 00000000`142b7010 00000000`00000000 00000000`00000000 00000000`142b7020 00000000`00000000 00000000`00000000 00000000`142b7030 00000000`00000000 00000000`00000000 00000000`142b7040 00000000`00000000 00000000`00000000 00000000`142b7050 00000000`00000000 00000000`00000000 00000000`142b7060 00000000`00000000 00000000`00000000 00000000`142b7070 00000000`00000000 1f600000`13351867 여기가 Page-Map Level-4 Table Base이다. 이제 가상 주소를 5 단계로 쪼갠 인덱스들을 이용할 차례이다. 가상 주소의 구조대로 쪼 <그림 7> AMD64 의 CR3, PML4E, PDPE, PDE, PTE 31 Reserved, MBZ 52 51 32 Page-Map Level-4 Table Base Address (This is an architectural limit. A given implementation may support fewer bits.) 12 11 5 4 3 2 0 Page-Map Level-4 Table Base Address Reserved, MBZ PCD PWT Reserved, MBZ Control Register 3 (CR3)-Long Mode 62 52 51 32 NX Available Page-Directory-Pointer Base Address (This is an architectural limit. A given implementation may support fewer bits.) 31 12 11 9 8 7 6 5 4 3 2 1 0 Page-Directory-Pointer Base Address AVL MBZ IGN A PCD PWT U/S R/W P 4-Kbyte PML4E-Long Mode 62 52 51 32 NX Available Page-Directory Base Address (This is an architectural limit. A given implementation may support fewer bits.) 31 12 11 9 8 7 6 5 4 3 2 1 0 Page-Directory Base Address AVL MBZ IGN A PCD PWT U/S R/W P 4-Kbyte PDPE-Long Mode NX 62 52 51 32 Available Page-Table Base Address (This is an architectural limit. A given implementation may support fewer bits.) 31 12 11 9 8 7 6 5 4 3 2 1 0 Page-Table Base Address AVL IGN 0 IGN A PCD PWT U/S R/W P 4-Kbyte PDE-Long Mode NX 62 52 51 32 Available Physical-Page Base Address (This is an architectural limit. A given implementation may support fewer bits.) 31 12 11 9 8 7 6 5 4 3 2 1 0 Physical-Page Base Address AVL G PAT D A PCD PWT U/S R/W P 4-Kbyte PTE-Long Mode 272 2 0 0 5. 1 0
64비트 윈도우 커널분석 AMD64 개보면 다음과 같이 나온다. 가상 주소: f f f f f a d f ` c 8 0 6 d 8 9 2 2진수표시: 1111 1111 1111 1111 1111 1010 1101 1111 1100 1000 0000 0110 1101 1000 1001 0010 인덱스: 1f5 17f 40 6d 892 ---------- ---------- ----------- ------------ ------------ 9bit 9bit 9bit 9bit 12bit PML4E Index PDPE Index PDE Index PTE Index Page Offset PML4 인덱스는 0x1f5이므로 PML4E(PML4 Entry)는 다음과 같 이구할수있다. 이므로 PTE 구조로부터 121이라는 속성을 가지는 페이지의 물리 주소 18dd를 구하게 된 것이다. 이제 이 페이지 주소에 가상 주소의 마지막 12비트인 페이지 옵셋을 더하면 물리 주소를 정확하게 구하게 된다. kd> dq /p 18dd000 + 892 00000000`018dd892 d98b4820`ec834853 7a6415ff`38498b48 00000000`018dd8a2 83480675`c0850001 384b8b48`c35b20c4 00000000`018dd8b2 eb900001`778015ff 0001808f`b60f41ed 00000000`018dd8c2 f8d34828`438b4800 cfb9e968`24448948 00000000`018dd8d2 0a542484`8b45ffff e95c2444`89440000 00000000`018dd8e2 0fff3883`ffff9842 40b70f00`00875184 00000000`018dd8f2 e8b70f44`c8ff6606 00fd8141`09e5c141 00000000`018dd902 ff90c384`0f000010 00002000`fd8141ff kd> dq /p 142b7000 + (1f5*8) 00000000`142b7fa8 00000000`074008 00000000`7ffe18 각 엔트리들이 8바이트의 크기를 가지므로 인덱스에 8바이트를 곱 해서 Base 주소에 더하면 8바이트짜리 PML4E를 볼 수 있다. PML4E 구조로부터 하위 0~11비트인 8은 속성을 나타낸다는 것 을 알 수 있고 12비트~51비트인 7400이 Page Directory Pointer Table Base를 나타낸다는 것을 알 수 있다. 따라서 PDPE는 다음과 같이 구할 수 있다. kd> dq /p 7400000 + (17f*8) 00000000`07400bf8 00000000`0f3f18 00000000`00000000 00000000`018dd892라는 물리 주소가 바로 가상 주소 fffffadf`c806 d892가 가리키는 실제 메모리 주소이다. 가상 주소 fffffadf`c806d892 를 디스플레이해 보면 그 내용이 정확히 일치하는 것을 확인할 수 있다. kd> dq fffffadf`c806d892 fffffadf`c806d892 d98b4820`ec834853 7a6415ff`38498b48 fffffadf`c806d8a2 83480675`c0850001 384b8b48`c35b20c4 fffffadf`c806d8b2 eb900001`778015ff 0001808f`b60f41ed fffffadf`c806d8c2 f8d34828`438b4800 cfb9e968`24448948 fffffadf`c806d8d2 0a542484`8b45ffff e95c2444`89440000 fffffadf`c806d8e2 0fff3883`ffff9842 40b70f00`00875184 fffffadf`c806d8f2 e8b70f44`c8ff6606 00fd8141`09e5c141 fffffadf`c806d902 ff90c384`0f000010 00002000`fd8141ff 역시 PDPE 구조에서 8은 속성이고 f3f1이 Page Directory Table Base임을 알 수 있다. 계속해서 PDE를 구해보면 kd> dq /p f3f1000 + (40*8) 00000000`0f3f1200 00000000`0f4329 00000000`0f4339 이므로 PDE 구조에서 9은 속성이고 f432가 Page Table Base인 것을 알 수 있다. 결국 PTE는 kd> dq /p f432000 + (6d*8) 00000000`0f432368 00000000`018dd121 00000000`018de121 <표 2> AMD64 페이지 테이블 맵핑 주소 테이블 이름 가상 주소 Page-Map Level-4 Table Base FFFFF6FB7DBED000 Page-Directory-Pointer Table Base FFFFF6FB7DA00000 Page-Directory Table Base FFFFF6FB40000000 Page Table Base FFFFF68000000000 프로그램으로 가상 주소에서 물리 주소까지 앞에서 수행한 예를 프로그램으로 작성한다면 어떻게 해야 할까? 이 것을 하려면 CR3 레지스터를 참조하는 것부터 시작해야 하는데 64비 트 컴파일러에서는 인라인 어셈블리를 사용하지 못하니 CR3 레지스 터에 접근할 방법이 없다. 또 그 안에 있는 주소를 따라가야 하는데 이것은 물리 주소이므로 프로그램에서 직접 참조하기가 쉽지 않다. 그래서 윈도우 커널에서는 물리 주소에 존재하는 각 테이블에 대해서 가상 주소로 맵핑한 주소를 제공하고 있다. AMD64에서 페이지 테이 블 맵핑 주소는 <표 2>와 같다. WinDbg에서 다음과 같이 확인할 수 있고 Windows Internals 4th Edition 424페이지에서 x64 address space layout도 확인할 수 있다. kd>!pte VA 0000000000000000 PXE @ FFFFF6FB7DBED000 PPE at FFFFF6FB7DA00000 PDE at FFFFF6FB40000000 PTE at FFFFF68000000000 마 이 크 로 소 프 트 웨 어 273
실 전! 강 의 실 개발자를 위한 실전 64비트! 64비트 윈도우 커널 탐구 2 <그림 8> 맵핑된 영역의 메모리 내용 kd> dq fffff6fb`7da00000 + (17f*8) FFFFF68000000000 (PTE Base 맵핑) FFFFF6FB40000000 (PDE Base 맵핑) FFFFF6FB7DA00000 (PDPE Base 맵핑) FFFFF6FB7DBED000 (PML4E Base 맵핑) PTE_0 PTE_1 PTE_511 for PML4E_0, PDPE_0, PDE_0 PTE_0 PTE_1 PTE_511 for PML4E_0, PDPE_0, PDE_1 PTE_0 PTE_1 PTE_511 for PML4E_0, PDPE_0, PDE_2 PDE_0 PDE_1 PDE...PDE_511 for PML4E_0, PDPE_0 PDE_0 PDE_1 PDE...PDE_511 for PML4E_0, PDPE_1 PDE_0 PDE_1 PDE...PDE_511 for PML4E_0, PDPE_2 PDPE_0 PDPE_1...PDPE_511 for PML4E_0 PDPE_0 PDPE_1...PDPE_511 for PML4E_1 PDPE_0 PDPE_1...PDPE_511 for PML4E_2 PML4E_0 PML4_1...PML4E_511 불행히도 그렇지 않다. FFFFF6FB7DA00000는 시스템에 존재하 는 모든 PDPE가 나열되는 공간의 시작 주소이다. 좀 더 자세히 설명 하면 이 주소에는 첫 번째 PML4E가 가리키는 PDP Table이 존재한 다. PDP Table은 PDPE 512개를 가지고 있다. 이 테이블 뒤에는 두 번째 PML4E가 가리키는 PDP Table이 역시 PDPE 512개를 가지고 존재하고 있다. 이런 식으로 512개의 PDP Table 영역을 가지고 있는 것이다. 따라서 이곳에 존재하는 PDPE의 개수는 512 512 = 262144이다. PDPE를 구하기 위해서는 PML4 인덱스까지 이용해서 다음과 같이 계산해야 한다. 여기서 PXE는 PML4E와 같고 PPE는 PDPE와 같다. 첫 번째로 보이는 Page-Map Level-4 Table Base의 주소 0xFFFFF6FB7 DBED000은 앞에서 디버거로 확인했을 때 CR3 레지스터에서 구한 Page-Map Level-4 Table Base 0x142b7000이 맵핑된 주소이다. 디 버거로 이 가상 주소를 확인해보면 물리 주소 142b7000과 같은 내용 임을 확인할 수 있다. kd> dq FFFFF6FB7DBED000 fffff6fb`7dbed000 1f000000`131ae867 00000000`00000000 fffff6fb`7dbed010 00000000`00000000 00000000`00000000 fffff6fb`7dbed020 00000000`00000000 00000000`00000000 fffff6fb`7dbed030 00000000`00000000 00000000`00000000 fffff6fb`7dbed040 00000000`00000000 00000000`00000000 fffff6fb`7dbed050 00000000`00000000 00000000`00000000 fffff6fb`7dbed060 00000000`00000000 00000000`00000000 fffff6fb`7dbed070 00000000`00000000 1f600000`13351867 따라서 다음과 같이 가상 주소로 맵핑된 PML4E를 확인할 수 있다. kd> dq fffff6fb`7dbed000 + (1f5*8) fffff6fb`7dbedfa8 00000000`074008 00000000`7ffe18 물리 주소를 통해서 봤던 내용하고 동일한 내용임을 알 수 있다. 이 것을 통해서 PDPE를 구할 수 있을까? 이 내용은 물리 주소를 나타내 고 있으므로 프로그램 상에서 직접 참조할 수 없다. 따라서 이 내용을 가지고 PDPE를 구할 수는 없다. PDPE를 구하기 위해서는 커널에서 Page-Directory-Pointer Table Base를 맵핑해 놓은 가상 주소 FFFFF6FB7DA00000을 이용해야 한다. 이번에도 다음과 같이 PDPE를 구할 수 있을까? kd> dq fffff6fb`7da00000 + (1f5*200*8) + (17f*8) fffff6fb`7dbf5bf8 00000000`0f3f18 00000000`00000000 표현식은 모두 16진수 값이므로 0x200은 512를 의미한다. PDPE 하나의 크기가 8바이트이므로 512와 8을 곱해야 PML4 인덱스 하나 가 포함하는 크기인 4KB가 나온다. 앞의 예를 설명하면 PML4 인덱 스가 1f5이므로 1f5개의 PDP 테이블은 건너뛰고 나온 주소에 PDPE 인덱스인 17f를 적용하여 계산한 것이다. 계산 결과 앞과 같이 가상 메모리 주소의 내용이 물리 주소를 참조했을 때와 동일하게 보였다. PDE를 찾을 때는 한 단계가 더 들어간다. PTE를 찾을 때는 여기에 또 한 단계가 더 들어간다. 이해를 돕기 위해 <그림 8>로 설명한다. <그림 8>에서 Page-Directory Table Base 주소인 FFFFF6FB 40000000을 보면 PDE들이 나열되어 있음을알수있다. 첫번째 PDE 512개는 첫 번째 PML4E, 첫 번째 PDPE에 의해 참조되는 것 들이다. 두 번째 PDE 512개는 첫 번째 PML4E, 두 번째 PDPE에 의 해서 참조되는 것들이다. 이렇게 PDE는 512개의 집합으로 PML4E, PDPE의 수만큼 존재하고 있다. 따라서 역시 PML4E 인덱스와 PDPE 인덱스를 포함하여 계산해야 올바른 PDE를 구할 수 있다. 그 림에서 확인할 수 있듯이 PTE 테이블도 PDE라는 단계가 하나 더 추 가된 PTE의 테이블임을 알 수 있다. 지금까지 설명은 복잡했지만 프로그램 상에서 가상 주소의 PTE를 구해서 물리 주소를 얻고 싶다면 앞에서 설명한 과정은 전혀 필요하 지 않다. 계산 과정에서 알 수 있듯이 PTE를 구하고 싶으면 직접 <표 2>의 Page Table Base로부터 PML4E 인덱스, PDP 인덱스, PDE 인덱스, PTE 인덱스로 계산하여 PTE의 주소를 구하면 된다. 프로그 램 코드로 예를 들면 다음과 같이 구현할 수 있다. #define AMD64_PTE_BASE 0xFFFFF68000000000 274 2 0 0 5. 1 0
64비트 윈도우 커널분석 AMD64 #define SIZE_PTE 8 // bytes #define SIZE_9BIT 512 // 0x200 npxe_index = GetPXEIndex( pvirtualaddr ); // pvirtualaddr에서 39~47비트를 취한다. nppe_index = GetPPEIndex( pvirtualaddr ); // pvirtualaddr에서 30~38비트를 취한다. npde_index = GetPDEIndex( pvirtualaddr ); // pvirtualaddr에서 21~29비트를 취한다. npte_index = GetPTEIndex( pvirtualaddr ); // pvirtualaddr에서 12~20비트를 취한다. npage_offset = GetPageOffset( pvirtualaddr ); // pvirtualaddr에서 0~11비트를 취한다. ppte = (AMD64_PTE_BASE + (npxe_index * SIZE_PTE * SIZE_9BIT * SIZE_9BIT * SIZE_9BIT) + (nppe_index * SIZE_PTE * SIZE_9BIT * SIZE_9BIT) + (npde_index * SIZE_PTE * SIZE_9BIT) + (npte_index * SIZE_PTE)); pphysicalpage = GetPhysicalPage( ppte ); // PTE에서 페이지의 물리 주소를 구한다. pphysicaladdr = pphysicalpage + npage_offset; // 페이지 주소 + 페이지 옵셋 IA64를 분석해보자 무언가를 분석한다는 것은 늘 알지 못하는 것에 대한 두려움과 함께 시작하는 일인 것 같다. 이 기사의 분석 내용은 AMD64를 처음 접하 면서 64비트에 대한 막연한 부담감을 가지고 파악했던 작업이었는데 다행히도 32비트와 비슷한 점이 많아서 별 무리 없이 원하는 결과를 얻을 수 있었다. 필자는 이것이 바로 AMD64가 개발자나 사용자에게 제공하는 가장 큰 매력이라고 생각한다. 64비트라는 새롭고 강력한 세계를 제공하면서 동시에 빠른 적응성까지 제공하기 때문이다. 아직 AMD64에 대한 준비를 하지 못했지만 궁금해 하던 독자들이 있었다 면 이 기사가 조금이라도 도움이 되었기를 바라며, 다음 시간에는 IA64에 대해 알아보겠다. m a s o 1년 후에도 내용이 살아있는 잡지 정리 김세미 semsem@imaso.co.kr 참 + 고 + 자 + 료 윈도우 구조와 원리 그리고 Codes, 가남사 Undocumented Windows 2000 Secrets, Addison-Wesley Windows Internals 4th Edition, Microsoft Press IA-32 Intel Architecture Software Developer s Manual, INTEL AMD64 Architecture Programmer s Manual, AMD 마 이 크 로 소 프 트 웨 어 275