Windbg - syscall 이후 실행되는 KiSystemCall64 함수 및 SSDT 디버깅
지난 글에서, ntdll.dll에서 제공하는 각종 함수들은 syscall로 인해 커널에서 제공하는 함수인 KiSystemCall64로 넘어오는 것을 설명했습니다.
Windbg - syscall 단계까지의 Win32 API 호출 (예: Sleep)
; https://www.sysnet.pe.kr/2/0/13856
즉, KiSystemCall64까지 진입한 경우라면 이미 Ring 3에서 Ring 0으로 특권 계층이 바뀐 상태가 된 것입니다. 이 부분에서 재미있는 점이 하나 있는데요, WinDbg를 이용해 KiSystemCall64 호출로 넘어가는 단계는 trace/bp 등으로 추적이 불가능하다는 점입니다.
가령, syscall 단계에서 F11 키를 눌러 trace into를 시도하면, 그 순간 Debugee 측의 운영체제는 (블루 스크린이 뜨거나) CPU 100% 현상을 보이며 호스트 측의 WinDbg는 더 이상 반응하지 않게 됩니다. (결국 Debugee 측을 재부팅해야 합니다.)
이에 대해 검색해 보면 다음의 동영상을 찾을 수 있는데요,
How To Step Into a SYSCALL With a Debugger (via Kernel Binary Patch) | Reverse Engineering Win10 x64
; https://youtu.be/uw0kIsHiG0k?t=926
Online x86 / x64 Assembler and Disassembler
; https://defuse.ca/online-x86-assembler.htm#disassembly
제목에도 나오지만, 디버깅 시
trampoline 방식으로 KiSystemCall64 함수의 내부에 직접 기계어 코드를 패치해 (BP가 아닌)
BA를 걸어 디버깅하는 방법을 소개하고 있습니다. 왜 그렇게 해야 하는가에 대해서는 동영상에서 자세하게 설명하고 있는데요, 대충 정리를 해보면 이렇습니다.
우선 KiSystemCall64 함수를 찾아,
3: kd> rdmsr 0xc0000082
msr[c0000082] = fffff802`3d62a000
// 또는, 심벌로 확인
3: kd> x nt!KiSystemCall64
fffff802`3d62a000 nt!KiSystemCall64 (KiSystemCall64)
그 위치의 기계어 코드를 역어셈블하면 도입부가 이런 식으로 나옵니다.
// syscall 호출 시 rcx 레지스터에 호출 IP가 들어가 있음
3: kd> u nt!KiSystemCall64 L6
nt!KiSystemCall64:
fffff802`3d62a000 0f01f8 swapgs // gs 레지스터 교환
fffff802`3d62a003 654889242510000000 mov qword ptr gs:[10h],rsp // 스택 레지스터 교환을 위해 현재의 rsp 값을 보관
fffff802`3d62a00c 65488b2425a8010000 mov rsp,qword ptr gs:[1A8h] // 커널 스택으로 변경 (DPL == 0인 selector로 변경)
fffff802`3d62a015 6a2b push 2Bh
fffff802`3d62a017 65ff342510000000 push qword ptr gs:[10h]
fffff802`3d62a01f 4153 push r11
syscall을 호출하면 저 함수로 진입한 이후, 초반에
swapgs를 통해 커널 모드를 위한 gs 레지스터까지 변경하는 절차를 거치는데요, 이로 인해 스레드별 문맥 정보에 해당하는 KPCR 구조체를 접근할 수 있게 됩니다.
이후 _KPCR.UserRsp (0x10) 위치에 현재 사용자 모드의 rsp 값을 백업한 다음, 0x1A8 위치에 보관해 두었던 커널 스택의 주소를,
5: kd> dt _KPCR UserRsp
nt!_KPCR
+0x010 UserRsp : Uint8B
3: kd> dt _KPCR Prcb..
nt!_KPCR
+0x180 Prcb :
+0x000 MxCsr : Uint4B
+0x004 LegacyNumber : UChar
+0x005 ReservedMustBeZero : UChar
+0x006 InterruptRequest : UChar
+0x007 IdleHalt : UChar
+0x008 CurrentThread :
+0x010 NextThread :
+0x018 IdleThread :
+0x020 NestingLevel : UChar
+0x021 ClockOwner : UChar
+0x022 PendingTickFlags : UChar
+0x022 PendingTick : UChar
+0x022 PendingBackupTick : UChar
+0x023 IdleState : UChar
+0x024 Number : Uint4B
+0x028 RspBase : Uint8B // _KPCR의 [0x1a8] 위치에 해당
...[생략]...
rsp에 덮어씁니다. 그러니까, 적어도 저 단계까지는 진행을 해야 사용자 모드에서 커널 모드로의 전환이 완료된 것이고, BP를 걸어도 WinDbg가 정상적으로 대응을 하게 됩니다.
따라서 그 이후의 코드, 위의 경우 fffff802`3d62a015부터 BP를 걸 수 있다는 건데요, 그런데 BP 자체도 여기서는 문제가 됩니다. 사실 BP의 의미가 해당 위치에 int 3(0xcc) 패치를 하는 것이므로 그렇게 되면 WinDbg 자체도 소프트웨어로서의 동작에 영향을 받게 돼 버립니다.
이로 인해 BP는 걸 수 없고, 대신
CPU 하드웨어에서 제공하는 breakpoint인 BA를 걸어야 합니다. 가령 위의 nt!KiSystemCall64 출력 결과에서는 "push 2Bh" 위치 이후부터 ba를 걸면 됩니다.
3: kd> ba e 1 fffff802`51429e15
하지만, 저렇게까지 했어도 여전히 현실적으로 문제가 있습니다. 즉, KiSystemCall64 함수 호출은 윈도우 전반적으로 사용되는 공통 길목이기 때문에 (이번 예제에서처럼) Sleep에서 이어지는 호출로 인해 ba가 걸렸다는 것을 보증할 수 없습니다. 운이 좋다면 몇 번의 'g' 키 입력으로 SleepEx 함수와 연결된 상황의 디버깅을 할 수도 있겠지만, ^^; 쉽지 않을 것입니다.
바로 이런 문제를 해결하기 위해 동영상에서 소개하는 것이 바로 trampoline 패치입니다. 자세한 사항은 동영상을 참고하시고 이 글에서는 그 부분은 생략하고 넘어가겠습니다. (어려워서가 아니라, 지루한 작업입니다. ^^;)
KiSystemCall64 함수의 초기 작업이 끝나고 나면, 이제 남은 중요한 작업은 eax 레지스터에 담긴 서비스 함수 번호(SSN: System Service Number)에 해당하는 커널 측 함수를 실행하는 역할이 있습니다.
그 시작으로 SSDT(System Service Descriptor Table)가 나오는데,
System Service Descriptor Table - SSDT
; https://www.ired.team/miscellaneous-reversing-forensics/windows-kernel-internals/glimpse-into-ssdt-in-windows-x64-kernel
A Gentle Introduction to Syscalls in Windows
; https://captain-woof.medium.com/a-gentle-introduction-to-syscalls-in-windows-7800e05acc8a
// x64 Windows 기준
// https://github.com/Faran-17/Windows-Internals/blob/main/System%20Architecture%20and%20Components/System%20Service%20Descriptor%20Table.md
typedef struct tagSERVICE_DESCRIPTOR_TABLE {
SYSTEM_SERVICE_TABLE nt; //effectively a pointer to Service Dispatch Table (SSDT) itself
SYSTEM_SERVICE_TABLE win32k;
SYSTEM_SERVICE_TABLE sst3; //pointer to a memory address that contains how many routines are defined in the table
SYSTEM_SERVICE_TABLE sst4;
} SERVICE_DESCRIPTOR_TABLE;
그런데 위의 경우 4개의 필드(nt, win32k, sst3, sst4)를 모두 SYSTEM_SERVICE_TABLE 타입으로 명시하고 있지만 실제로는 C/C++ struct 구문으로 대충 이렇게 표현하는 것이 맞습니다.
// https://github.com/Iolop/SSDT-Hook/blob/master/SSDT-Hook(Driver).c
typedef struct _SYSTEM_SERVICE_TABLE
{
PULONG nt;
PULONG win32k;
ULONG nEntries;
PULONG argumentTable;
} SYSTEM_SERVICE_TABLE,*PSYSTEM_SERVICE_TABLE;
위의 구조를 감안해 WinDbg에서 심벌 풀이를 해보면 이런 값이 나오고,
4: kd> dps nt!KeServiceDescriptorTable L4
fffff802`3e0018c0 fffff802`3d2cf090 nt!KiServiceTable
fffff802`3e0018c8 00000000`00000000
fffff802`3e0018d0 00000000`000001e6 // nt!KiServiceTable에 486(0x1e6)개의 서비스 함수가 있다는 것을 의미
fffff802`3e0018d8 fffff802`3d2cf82c nt!KiArgumentTable
출력 결과에도 나오지만, KiServiceTable은 그것 자체로 심벌이 등록돼 있으므로 직접 구하는 것도 가능합니다.
5: kd> ? KiServiceTable
Evaluate expression: -8786476732272 = fffff802`3d2cf090
바로 이 KiServiceTable에서 서비스 번호에 대응하는 함수의 주소를 구할 수 있는데요, 가령 Sleep의 ntdll!NtDelayExecution에서 eax에 설정한 값이 (Windows 11의 경우) 0x34이고, KiServiceTable 내의 개별 항목 크기가 4바이트이므로 다음과 같이 그 위치를 찾아갈 수 있습니다.
4: kd> dd /c1 KiServiceTable + 4*0x34 L1
fffff802`3d2cf160 05c76a00
// https://captain-woof.medium.com/a-gentle-introduction-to-syscalls-in-windows-7800e05acc8a
// SSNs are of 2 bytes each (WORD).
// 현재 2바이트로 할당돼 있어, 최대 65,536개의 서비스 함수를 가질 수 있습니다.
이때 KiServiceTable에 담긴 항목의 값(위의 경우 05c76a00)이 의미하는 것은, 해당 주소로부터 서비스 함수가 떨어진 위치 값인데 하위 4비트를 (부호 유지하며 우측 shift 연산으로) 잘라야 합니다. 따라서 다음과 같이 구하면,
// fffff802`3d2cf090 == nt!KiServiceTable 위치
4: kd> ? fffff802`3d2cf090 + (05c76a00 >>> 4)
Evaluate expression: -8786470672592 = fffff802`3d896730
// 엄밀히는 movsxd r11,dword ptr [r10+rax*4] 연산이기 때문에,
// 4바이트 DWORD 값을 8바이트 레지스터에 부호 확장해 변환하는 작업이므로,
// 05c76a00 값이 아니라 00000000`05c76a00 값으로 계산해야 합니다.
/*
? fffff802`3d2cf090 + (00000000`05c76a00 >>> 4)
*/
fffff802`3d896730 위치가 nt!NtDelayExecution 함수의 시작 부분이고, 간단하게 역어셈블로 확인할 수 있습니다.
4: kd> u KiServiceTable + (05c76a00 >>> 4) L1
nt!NtDelayExecution:
fffff802`3d896730 4883ec28 sub rsp,28h
// Nt... 함수는 주로 매개변수 확인 작업을 하며, 실제 처리는 이후 Zw... 함수에 전달해 수행
이참에
WinDbg의 수식 표현을 좀 공부하면 ^^ 위의 하드 코드 부분을 아래와 같이 수식으로 바꿀 수 있습니다.
// MASM Evaluator를 사용하는 경우 (dwo는 movsxd와 같은 부호 확장은 하지 않으므로 오프셋 값이 음수라면 다른 처리가 필요합니다.)
5: kd> u KiServiceTable + (dwo(KiServiceTable + 4*0x34) >>> 4) L1
nt!NtDelayExecution:
fffff802`3d896730 4883ec28 sub rsp,28h
// 또는 C++ Evaluator를 곁들여!
5: kd> ? @@c++(*(long *)(@@(nt!KiServiceTable) + 4*0x34)) >>> 4
int 0n6059680
5: kd> u KiServiceTable + (@@c++(*(long *)(@@(nt!KiServiceTable) + 4*0x34)) >>> 4) L1
nt!NtDelayExecution:
fffff802`3d896730 4883ec28 sub rsp,28h
참고로, 잘라낸 0~3비트는 해당 서비스 함수의 인자 개수를 나타낸다는 글이 있는데,
The Quest for the SSDTs
; https://www.codeproject.com/Articles/1191465/The-Quest-for-the-SSDTs
Bits 4-31 of 0x030b4fc7 correspond to the relative address to the base of the nt!KiServiceTable. Bits 0-3 are related to the number of arguments and will not be used here.
그렇다면 05c76a00의 경우 nt!NtDelayExecution의 인자는 원래 2개인데 0개라고 계산되고, 위의 글에서도 예를 든
NtCreateFile은 11개의 인자를 갖지만 030b4fc7라고 나오므로 7개의 인자를 갖는 것으로 계산되니 뭔가 맞지 않습니다.
한데, 아마도 처음 4개의 인자는 레지스터(rcx, rdx, r8, r9)를 통해 전달하므로 그것을 초과한 매개변수 개수로 인한 스택의 영향을 나타내는 것이 아닐까 싶습니다. 일례로, (Windows 11에서) 0x100번째 서비스 함수인 nt!NtGetNextProcess는 5개의 인자를 갖는데,
7: kd> dd /c1 KiServiceTable + 4*0x100 L1
fffff802`3d2cf490 0620b401
7: kd> u KiServiceTable + (06f8de01 >> 4) L1
nt!NtGetNextProcess:
fffff802`3d9c7e70 48895c2410 mov qword ptr [rsp+10h],rbx
typedef NTSTATUS (NTAPI * fNtGetNextProcess)(
_In_ HANDLE ProcessHandle,
_In_ ACCESS_MASK DesiredAccess,
_In_ ULONG HandleAttributes,
_In_ ULONG Flags,
_Out_ PHANDLE NewProcessHandle
);
따라서, 4개를 초과한 1개의 인자가 스택으로 전달된다는 것을 06f8de01 값에 의해 알 수 있습니다. (그렇게 계산하면, Windows의 서비스 함수는 최대 4 + 0xf == 19개의 매개변수로 제한이 됩니다.)
참고로, KiServiceTable의 모든 항목(KeServiceDescriptorTable의 offset 0x10에 위치한 0x1e6, 즉 486개)을 심벌과 함께 출력하려면 이런 명령어를 사용하면 됩니다.
// Finding Address of All SSDT Routines
// https://www.ired.team/miscellaneous-reversing-forensics/windows-kernel-internals/glimpse-into-ssdt-in-windows-x64-kernel#finding-address-of-all-ssdt-routines
.foreach /ps 1 /pS 1 ( offset {dd /c 1 nt!KiServiceTable L poi(nt!KeServiceDescriptorTable+10)}){ r $t0 = ( offset >>> 4) + nt!KiServiceTable; .printf "%p - %y\n", $t0, $t0 }
서비스 함수 관련해서 재미있는 github 링크를 하나 발견했는데요,
Windows System Call Tables
; https://github.com/j00ru/windows-syscalls
이를 이용하면 운영체제별 서비스 함수에 어떤 번호가 할당돼 있는지 쉽게 검색할 수 있습니다.
이 정도면 대충 살펴본 것 같죠? ^^
부가적으로, (Win32 API가 아닌) device driver와 통신하는 경우에는 CreteFile, WriteFile,
DeviceIoControl 등의 함수를 사용하게 되는데요, 이럴 때도 위의 원칙을 벗어나진 않습니다. 결국 ntdll!Nt...에서 syscall을 통해 nt!Nt...로 넘어가는 것은 동일하고, 단지 이후로는 Win32 API라면 마이크로소프트가 제공한 ntoskrnl.exe의 함수를 호출하고 말겠지만 device driver라면 그 중간 과정에서 I/O Manager가 관여한다는 점만 다를 뿐입니다.
그나저나, 보안(PatchGuard)의 이유로 KiSystemCall64 함수는 별도로 nt!KiSystemCall64Shadow 함수를 갖게 되는데요,
3: kd> u nt!KiSystemCall64Shadow LA
nt!KiSystemCall64Shadow:
fffff802`3dcf41c0 0f01f8 swapgs
fffff802`3dcf41c3 654889242510a00000 mov qword ptr gs:[0A010h],rsp
fffff802`3dcf41cc 65488b242500a00000 mov rsp,qword ptr gs:[0A000h]
fffff802`3dcf41d5 650fba242518a0000001 bt dword ptr gs:[0A018h],1
fffff802`3dcf41df 7203 jb nt!KiSystemCall64Shadow+0x24 (fffff802`3dcf41e4)
fffff802`3dcf41e1 0f22dc mov cr3,rsp
fffff802`3dcf41e4 65488b242508a00000 mov rsp,qword ptr gs:[0A008h]
fffff802`3dcf41ed 6a2b push 2Bh
fffff802`3dcf41ef 65ff342510a00000 push qword ptr gs:[0A010h]
fffff802`3dcf41f7 4153 push r11
"
How To Step Into a SYSCALL With a Debugger (via Kernel Binary Patch) | Reverse Engineering Win10 x64" 동영상에서는 오히려 KiSystemCall64Shadow를 호출하는 것으로 나오지만 일단 제가 테스트한 Windows 11에서는 언제나 KiSystemCall64로 호출이 되었습니다. 이게 랜덤으로 바뀌는 것인지, 아니면 특정 버전에서만 그런 것인지는 모르겠습니다.
어쨌든 저런 경우에도 ba를 걸어 디버깅을 할 수 있고 규칙은 동일하게 커널 스택이 바뀐 이후부터, 위의 경우라면 fffff802`3dcf41ed 주소부터 ba를 걸 수 있습니다.
win32k 서비스 함수의 경우에는, 아마도 _SYSTEM_SERVICE_TABLE의 필드에 "win32k"가 있는 걸로 봐서 과거에 함께 기록된 것으로 보이는데,
typedef struct _SYSTEM_SERVICE_TABLE
{
PULONG nt;
PULONG win32k;
ULONG nEntries;
PULONG argumentTable;
} SYSTEM_SERVICE_TABLE,*PSYSTEM_SERVICE_TABLE;
현재는 저 값이 0입니다. 이에 대해 검색해 보면 win32k 시스템 함수 테이블은 아예 프로세스 문맥 수준에서만 제공되는 것 같은데요,
// !process 0 0 notepad.exe
// .process /i ...[PROCESS 주소]...
// g; .reload
2: kd> dd /c1 win32k!W32pServiceTable L4
ffffe8d1`72e59000 ff8b8340
ffffe8d1`72e59004 ff8e1842
ffffe8d1`72e59008 ff8b75c0
ffffe8d1`72e5900c ff901f40
// 만약 위의 명령어를 프로세스 문맥 수준에서 실행하지 않으면 다음과 같은 결과가 나옵니다.
/*
2: kd> dd /c1 win32k!W32pServiceTable L4
ffffe8d1`72e59000 ????????
ffffe8d1`72e59004 ????????
ffffe8d1`72e59008 ????????
ffffe8d1`72e5900c ????????
*/
이로부터 서비스 함수를 구하는 방법은 nt!KiServiceTable과 원칙은 같습니다. 위의 테이블 출력이라면 0번째 서비스 함수가 ff8b8340으로 나오므로 4바이트 음수 값을 부호 확장해 8바이트로 ffffffff`ff8b8340 값이 됩니다. 마지막으로 저 값에서 4비트를 부호 유지하며 우측 시프트 하면 최종적으로 ffffffff`fff8b834 값이 나오고 이것을 Table 주소에 더하면,
2: kd> u win32k!W32pServiceTable + ffffffff`fff8b834 L1
win32k!NtUserGetThreadState:
ffffe8d1`72de4834 4883ec28 sub rsp,28h
이렇게 win32k 서비스 함수를 찾을 수 있습니다. 그런데, 실제로는 NtUserGetThreadState 함수를
표에서 찾아보면 서비스 함수 번호가 0x1000으로 나옵니다. 전체적인 win32k 함수표 내용을 보면 모든 숫자가 0x1000보다 높은 것을 볼 수 있는데요, 그렇다면 아마도 base가 0x1000이라는 것을 짐작할 수 있습니다.
따라서, 대충 이렇게 정리할 수 있습니다.
// 실습 환경: Windows 10 (버전 10.0.19045.5247)
//
// 함수표에서 NtGdiInvertRgn의 함수 번호는 Windows 10 환경에서 0x1064
// 0x1064 함수 번호에서 0x1000을 뺀 오프셋을 구하고,
2: kd> dd /c1 win32k!W32pServiceTable + 4*(0x1064 - 0x1000) L1
ffffe8d1`72e59190 ff934140
// 8바이트로 부호 확장
ff934140 ==> ffffffff`ff934140
// 4비트 signed 우측 시프트
ffffffff`ff934140 ==> ffffffff`fff93414
2: kd> u win32k!W32pServiceTable + ffffffff`fff93414 L1
win32k!NtGdiInvertRgn:
ffffe8d1`72dec414 4883ec28 sub rsp,28h
좀 복잡해 보이는데, 약간만 손보면
저걸 한 줄의 WinDbg 수식으로 바꿀 수 있습니다.
[이 글에 대해서 여러분들과 의견을 공유하고 싶습니다. 틀리거나 미흡한 부분 또는 의문 사항이 있으시면 언제든 댓글 남겨주십시오.]