Windbg - syscall 단계까지의 Win32 API 호출 (예: Sleep)
WinDbg로, Win32 API 호출 시 Ring 3에서 Ring 0로 바뀌는 syscall 단계까지의 호출을 확인해 보겠습니다. 디버깅을 돕기 위해 간단한 Sleep 함수를 호출하는 C++ Console 프로그램을 하나 작성하고,
// x64 + 디버그 모드로 빌드
#include <stdio.h>
#include <windows.h>
extern "C"
{
void FuncInThread1()
{
Sleep(1000);
}
int main()
{
while (true)
{
FuncInThread1();
}
}
}
커널 라이브 디버깅 상태에서 사용자 모드 문맥으로 연결해 FuncInThread1에 bp를 걸어둡니다.
// !process 0 0 ConsoleApplication1.exe
// .process /i ffffdd8c736f5080
// g
// .reload /user
1: kd> bp ConsoleApplication1!FuncInThread1
1: kd> g
Breakpoint 0 hit
ConsoleApplication1!FuncInThread1:
0033:00007ff7`28f11890 4055 push rbp
BP가 걸리면 Disassembly 창을 띄워 해당 함수의 시작 부분을 어셈블리 코드로 확인할 수 있고,
ConsoleApplication1!FuncInThread1:
00007ff7`03011890 4055 push rbp
00007ff7`03011892 57 push rdi
00007ff7`03011893 4881ece8000000 sub rsp, 0E8h
00007ff7`0301189a 488d6c2420 lea rbp, [rsp+20h]
00007ff7`0301189f 488d0d4e080100 lea rcx, [ConsoleApplication1!__A0E3E448_ConsoleApplication1@cpp (7ff7030220f4)]
00007ff7`030118a6 e8f2faffff call ConsoleApplication1!@ILT+920(__CheckForDebuggerJustMyCode) (7ff70301139d)
00007ff7`030118ab 90 nop
00007ff7`030118ac b9e8030000 mov ecx, 3E8h
00007ff7`030118b1 ff1549f70000 call qword ptr [ConsoleApplication1!__imp_Sleep (7ff703021000)]
00007ff7`030118b7 90 nop
00007ff7`030118b8 488da5c8000000 lea rsp, [rbp+0C8h]
00007ff7`030118bf 5f pop rdi
00007ff7`030118c0 5d pop rbp
00007ff7`030118c1 c3 ret
QWORD PTR[ConsoleApplication1!__imp_Sleep] 부분을 통해 call 호출 대상의 간접 참조하는 실제 주소를 다음과 같이 알아낼 수 있습니다.
// 대상 심벌이 가리키는 위치의 주소를 알아내도 되는데,
3: kd> x ConsoleApplication1!__imp_Sleep
00007ff7`03021000 ConsoleApplication1!_imp_Sleep = <no type information>
// 사실 위의 00007ff7`03021000 출력값은 디스어셈블리 창에 보이는
// "call qword ptr [ConsoleApplication1!__imp_Sleep (7ff703021000)]" 값과 같으므로 굳이 x 명령어를 사용하지 않아도 됩니다.
// 그 위치에 담긴 8바이트가 call 대상 주소를 담고 있습니다.
3: kd> dq 00007ff7`03021000 L1
00007ff7`03021000 00007ffd`af56b0e0
보다시피 "00007ffd`af56b0e0" 값이 나오는데요, 이 주소의 코드를 역어셈블하면 이런 결과가 나옵니다.
KERNEL32!SleepStub:
00007ffd`af56b0e0 48ff25398d0600 jmp qword ptr [KERNEL32!__imp_Sleep (7ffdaf5d3e20)]
00007ffd`af56b0e7 cc int 3
이번에는 (call이 아닌) jmp로 이동하지만 마찬가지로 대상 주소는 간접 참조를 하고 있으므로 다음과 같이 알아낼 수 있습니다.
3: kd> dq 7ffdaf5d3e20 L1
00007ffd`af5d3e20 00007ffd`adaa59e0
이어서 00007ffd`adaa59e0 주소를 역어셈블하면,
KERNELBASE!Sleep:
00007ffd`adaa59e0 33d2 xor edx, edx
00007ffd`adaa59e2 e909000000 jmp KERNELBASE!SleepEx (7ffdadaa59f0)
00007ffd`adaa59e7 cc int 3
Sleep 함수의 경우 비록 Win32 API로 등록된 것이긴 하지만 구현 자체는 커널 측 Sleep 함수로 1:1 대응이 이뤄지지 않고, 그저 사용자 모드 내의 SleepEx로 단순히 호출만 넘기는 식으로 처리되고 있습니다.
마지막으로, kernelbase.dll에서 제공하는 SleepEx 함수는 사용자 모드의 최종 목적지인 ntdll.dll로 호출을 넘기는데,
KERNELBASE!SleepEx:
00007ffd`adaa59f0 89542410 mov dword ptr [rsp+10h], edx
00007ffd`adaa59f4 53 push rbx
00007ffd`adaa59f5 56 push rsi
00007ffd`adaa59f6 57 push rdi
00007ffd`adaa59f7 4881ec80000000 sub rsp, 80h
...[생략]...
00007ffd`adaa5a87 48ff1512601900 call qword ptr [KERNELBASE!__imp_NtDelayExecution (7ffdadc3baa0)]
...[생략]...
00007ffd`adaa5ae8 4881c480000000 add rsp, 80h
00007ffd`adaa5aef 5f pop rdi
00007ffd`adaa5af0 5e pop
dll 간의 호출이기 때문에 이번에도 IAT(Import Adddress Table)을 통해 NtDelayExecution 함수의 주소를 간접 참조하고 있습니다.
3: kd> dq 7ffdadc3baa0 L1
00007ffd`adc3baa0 00007ffd`b018db60
그리고 마침내 마지막 syscall을 하는 ntdll!NtDelayExecution까지 오게 됐습니다.
ntdll!NtDelayExecution:
00007ffd`b018db60 4c8bd1 mov r10, rcx // 첫 번째 매개변수는 r10 레지스터에 백업
00007ffd`b018db63 b834000000 mov eax, 34h // NtDelayExecution의 커널 측 서비스 함수 번호
00007ffd`b018db68 f604250803fe7f01 test byte ptr [7FFE0308h], 1 // syscall을 지원하지 않는 CPU를 위해 int 2e 호출 제공
00007ffd`b018db70 7503 jne ntdll!NtDelayExecution+0x15 (7ffdb018db75)
00007ffd`b018db72 0f05 syscall
00007ffd`b018db74 c3 ret
00007ffd`b018db75 cd2e int 2Eh
00007ffd`b018db77 c3 ret
위의 첫 코드를 보면, Win32 API 호출 시 첫 번째 매개변수에 해당하는 rcx 값을 r10으로 복사하고 있는데요, 왜냐하면 syscall 호출 시 내부적으로 rcx 레지스터에 사용자 모드로 돌아갈 RIP(위의 예제라면 00007ffd`b018db74)를 보관하는 용도로 사용하기 때문입니다. (그나저나, 왜 AMD는 AMD64 아키텍처에서 syscall 명령어의 내부 연산에서 rcx를 굳이 사용했을까요? 기왕이면 새롭게 생긴 r8 ~ r15 레지스터 중 하나를 했다면 더 좋지 않았을까요? ^^)
이후, "mov eax, 34h"에서 0x34는 NtDelayExecution 함수를 대표하는 서비스 번호에 해당하고, syscall 이후 진입하는 커널 함수 측에서 저 번호를 이용해 커널 측 함수를 호출하는 데 사용됩니다. 이때 재미있는 점은, (예외가 있을 수도 있지만) 대부분의 ntdll 함수와 동일한 이름으로 nt 모듈에도 제공한다는 점입니다.
즉, 사용자 모드의 ntdll!NtDelayExecution 함수는 커널 모드의 nt!NtDelayExecution 함수 호출로 이뤄집니다.
이렇게 설정이 완료됐으면, 이제 커널 모드로 진입해야 하는데요, 이때 "test byte ptr [7FFE0308h], 1"은 syscall을 지원하는지 확인하는 역할을 합니다. 만약 syscall을 지원한다면 그것을 호출하고, 그렇지 않으면 예전처럼 (성능이 낮은) int 2E를 통해 커널로 진입합니다.
참고로, syscall에 대한 지원 여부는 MSR_EFER 레지스터를 통해서도 확인할 수 있는데요,
// MSR_EFER (Extended Feature Enable Register) 0xC0000080
3: kd> rdmsr 0xc0000080 // rdmsr == read MSR (Model Specific Register)
msr[c0000080] = 00000000`00000d01
d01 값을 2진수로 바꿔 각각의 필드에 의미를 해석하면 다음과 같습니다.
d01 == 00001101 00000001
1 SCE System Call Extensions (SYSCALL/SYSRET 명령어 사용 여부)
0000000 (Reserved)
1 LME Long Mode Enable (IA-32e 모드 활성화)
0 (Reserved?)
1 LMA Long Mode Active
1 NXE No Execute Enable
0 SVME Secure Virtual Machine Enable
0 LMSLE Long Mode Segment Limit Enable
0 FFXSR Fast FXSAVE/FXRSTOR
0 TCE Translation Cache Extension
16 ~ 63 (Reserved)
보는 바와 같이 SCE 비트가 1이므로 syscall을 지원한다는 것을 알 수 있습니다. 아마도 "test byte ptr [7FFE0308h], 1" 코드에서 [7FFE0308h] 주소가 참조하는 값은 저 SCE 비트를 보관해 둔 것으로 추정할 수 있습니다.
참고로, 위의 결과는 i9-12900K 인텔 CPU에서 확인한 것이며, 다른 CPU, 가령 AMD 라이젠 7 PRO 4750G 모델에서 확인하면 이런 결과가 나옵니다.
3: kd> rdmsr 0xc0000080
msr[c0000080] = 00000000`00004d01
이번엔 (d01이 아니라) "4d01 == 01001101 00000001" 값이 나왔는데요, 달라진 점은 FFXSR 지원 여부가 1로 바뀌었다는 점입니다.
1 FFXSR Fast FXSAVE/FXRSTOR
이것은
sysinternals의 coreinfo 유틸리티를 이용한 결과로도 알아낼 수 있는데요, 아래는 인텔 CPU에서 실행했을 때와 AMD Ryzen 7 PRO 4750G의 출력 결과에서 FFXSR 지원 여부를 출력한 결과입니다.
c:\temp> coreinfo
...[생략]...
12th Gen Intel(R) Core(TM) i9-12900K
Intel64 Family 6 Model 151 Stepping 2, GenuineIntel
...[생략]...
FFXSR - Supports optimized FXSAVE/FSRSTOR instruction
...[생략]...
c:\temp> coreinfo
...[생략]...
AMD Ryzen 7 PRO 4750G with Radeon Graphics
AMD64 Family 23 Model 96 Stepping 1, AuthenticAMD
...[생략]...
FFXSR * Supports optimized FXSAVE/FSRSTOR instruction
...[생략]...
ntdll!NtDelayExecution의 마지막 단계인 syscall이 어떤 일을 하는지 알아보겠습니다.
SYSCALL
; https://modoocode.com/en/inst/syscall
SYSCALL — Fast System Call
; https://www.felixcloutier.com/x86/syscall
Spinning in user-mode versus entering kernel - the cost of a SYSCALL in Windows.
- Entering Kernel From a User-Mode
; https://dennisbabkin.com/blog/?t=critical_section_vs_kernel_objects_in_windows#enter_kernel
위의 두 번째 문서에서 자세하게 pseduo code로 설명하고 있는데요,
IF (CS.L ≠ 1 ) or (IA32_EFER.LMA ≠ 1) or (IA32_EFER.SCE ≠ 1)
(* Not in 64-Bit Mode or SYSCALL/SYSRET not enabled in IA32_EFER *)
THEN #UD;
FI;
RCX := RIP; (* Will contain address of next instruction *)
RIP := IA32_LSTAR;
R11 := RFLAGS;
RFLAGS := RFLAGS AND NOT(IA32_FMASK);
CS.Selector := IA32_STAR[47:32] AND FFFCH (* Operating system provides CS; RPL forced to 0 *)
(* Set rest of CS to a fixed value *)
CS.Base := 0;
(* Flat segment *)
CS.Limit := FFFFFH;
(* With 4-KByte granularity, implies a 4-GByte limit *)
CS.Type := 11;
(* Execute/read code, accessed *)
CS.S := 1;
CS.DPL := 0;
CS.P := 1;
CS.L := 1;
(* Entry is to 64-bit mode *)
CS.D := 0;
(* Required if CS.L = 1 *)
CS.G := 1;
(* 4-KByte granularity *)
IF ShadowStackEnabled(CPL)
THEN (* adjust so bits 63:N get the value of bit N–1, where N is the CPU’s maximum linear-address width *)
IA32_PL3_SSP := LA_adjust(SSP);
(* With shadow stacks enabled the system call is supported from Ring 3 to Ring 0 *)
(* OS supporting Ring 0 to Ring 0 system calls or Ring 1/2 to ring 0 system call *)
(* Must preserve the contents of IA32_PL3_SSP to avoid losing ring 3 state *)
FI;
CPL := 0;
IF ShadowStackEnabled(CPL)
SSP := 0;
FI;
IF EndbranchEnabled(CPL)
IA32_S_CET.TRACKER = WAIT_FOR_ENDBRANCH
IA32_S_CET.SUPPRESS = 0
FI;
SS.Selector := IA32_STAR[47:32] + 8;
(* SS just above CS *)
(* Set rest of SS to a fixed value *)
SS.Base := 0;
(* Flat segment *)
SS.Limit := FFFFFH;
(* With 4-KByte granularity, implies a 4-GByte limit *)
SS.Type := 3;
(* Read/write data, accessed *)
SS.S := 1;
SS.DPL := 0;
SS.P := 1;
SS.B := 1;
(* 32-bit stack segment *)
SS.G := 1;
(* 4-KByte granularity *)
천천히 몇 개만 정리해 보겠습니다. ^^ 우선, 1) IP 레지스터의 값을 바꾸는 것부터 볼까요?
RCX := RIP; (* Will contain address of next instruction *)
RIP := IA32_LSTAR;
즉, syscall을 호출하면 현재 RIP 레지스터의 값을 RCX 레지스터로 복사하는데요, 왜냐하면 나중에 커널 모드의 함수 호출을 끝내고 사용자 모드로 돌아갈 때 실행할 위치를 알아야 하기 때문입니다. (보다시피 저 과정에서 RCX 레지스터를 사용하기 때문에, ntdll!NtDelayExecution 함수에서 미리 r10 레지스터에 백업해 둔 이유가 됩니다.)
그다음 RIP 레지스터에 OS에서 지정한 system call handler의 주소를 덮어쓰는데요, 해당 함수의 위치는 MSR_LSTAR(long mode SYSCALL target) 0xc0000082에 기록돼 있습니다. (당연히 OS가 미리 기록해 두어야 합니다.)
0: kd> rdmsr C0000082
msr[c0000082] = fffff806`49629e00
2) 두 번째로 syscall이 하는 작업은,
R11 := RFLAGS;
RFLAGS := RFLAGS AND NOT(IA32_FMASK);
rflags 레지스터의 값을 r11 레지스터에 복사한 후, IA32FMASK MSR (MSR address C0000084H) 값을 현재의 flags 값에 and 연산을 수행합니다.
0: kd> rdmsr C0000084H
msr[c0000084] = 00000000`00004700 (2진수 01000111 00000000)
위의 경우 0x4700인데요, 이것으로 마스킹 처리되는 flag 값은 TF(Trap Flag), IF(Interrupt Flag), DF(Direction Flag), NT(Nested Task Flag)입니다.
3) 세 번째로, DPL == 3 값을 가지고 있는
code segment selector를, Ring 0의 권한을 요구하는 명령어를 수행하기 위해 DPL == 0에 해당하는 segment selector로 교체합니다.
CS.Selector := IA32_STAR[47:32] AND FFFCH (* Operating system provides CS; RPL forced to 0 *)
(* Set rest of CS to a fixed value *)
CS.Base := 0;
(* Flat segment *)
CS.Limit := FFFFFH;
(* With 4-KByte granularity, implies a 4-GByte limit *)
CS.Type := 11;
(* Execute/read code, accessed *)
CS.S := 1;
CS.DPL := 0;
CS.P := 1;
CS.L := 1;
(* Entry is to 64-bit mode *)
CS.D := 0;
(* Required if CS.L = 1 *)
CS.G := 1;
게다가 단순히 selector만 바꾸는 것이 아니고, descriptor가 갖는 나머지 필드들도 새로운 고정값으로 설정하고 있습니다.
여기서 selector의 값을, IA32_STAR 레지스터의 값에서 47 ~ 32 비트를 가져와서 사용하고 있는데요,
// #define MSR_STAR 0xc0000081 /* legacy mode SYSCALL target */
0: kd> rdmsr 0xc0000081
msr[c0000081] = 00230010`00000000
// 2진수: 00000000 00100011 00000000 00010000 00000000 00000000 00000000 00000000
// 47 ~ 32 비트: 00000000 00010000 (0x10)
위의 계산에 따라 나온 값이 0x10이고, 그 값이 커널 모드에서의 cs selector가 되는 것입니다.
// 사용자 모드 디버깅 문맥에서 세그먼트 레지스터 출력
0: kd> rM 8
cs=0033 ss=002b ds=002b es=002b fs=0053 gs=002b efl=00000246
ntdll!NtDelayExecution+0x12:
0033:00007ffd`b018db72 0f05 syscall
// syscall 이후 세그먼트 레지스터 출력
0: kd> rM 8
cs=0010 ss=0018 ds=002b es=002b fs=0053 gs=002b efl=00000202
nt!DbgBreakPointWithStatus:
fffff806`4961f130 cc int 3
심심하니까, 위의 rM 명령어의 결과로 보이는 다른
세그먼트의 offset 값을 분해해 살펴볼까요? ^^
0x10 == offset 2 // 커널 모드의 cs
0x18 == offset 3 // 커널 모드의 ss
0x2b == offset 5 // ds, es, 사용자 모드의 ss, gs
0x33 == offset 6 // 사용자 모드의 cs
0x53 == offset a // fs
각각의 세그먼트에 해당하는 decsriptor를 GDT에서 찾아보면 이렇게 정리가 됩니다.
3: kd> dg 0 50
P Si Gr Pr Lo
Sel Base Limit Type l ze an es ng Flags
---- ----------------- ----------------- ---------- - -- -- -- -- --------
0000 00000000`00000000 00000000`00000000 <Reserved> 0 Nb By Np Nl 00000000
0008 00000000`00000000 00000000`00000000 <Reserved> 0 Nb By Np Nl 00000000
0010 00000000`00000000 00000000`00000000 Code RE Ac 0 Nb By P Lo 0000029b // 0x10 (커널 모드의 cs)
0018 00000000`00000000 00000000`00000000 Data RW Ac 0 Bg By P Nl 00000493 // 커널 모드의 ss
0020 00000000`00000000 00000000`ffffffff Code RE Ac 3 Bg Pg P Nl 00000cfb
0028 00000000`00000000 00000000`ffffffff Data RW Ac 3 Bg Pg P Nl 00000cf3 // 0x2b (ds, es, 사용자 모드의 ss, gs)
0030 00000000`00000000 00000000`00000000 Code RE Ac 3 Nb By P Lo 000002fb // 0x33 (사용자 모드의 cs)
0038 00000000`00000000 00000000`00000000 <Reserved> 0 Nb By Np Nl 00000000
0040 00000000`1a16e000 00000000`00000067 TSS32 Busy 0 Nb By P Nl 0000008b
0048 00000000`0000ffff 00000000`00009581 <Reserved> 0 Nb By Np Nl 00000000
0050 00000000`00000000 00000000`0000fc00 Data RW Ac 3 Bg By P Nl 000004f3 // 0x53 (fs)
4) 네 번째로, 스택 세그먼트의 값도 커널 모드의 코드 수행을 위한 전용 스택을 사용하기 위해 DPL == 0인 selector로 변경합니다.
SS.Selector := IA32_STAR[47:32] + 8;
(* SS just above CS *)
(* Set rest of SS to a fixed value *)
SS.Base := 0;
(* Flat segment *)
SS.Limit := FFFFFH;
(* With 4-KByte granularity, implies a 4-GByte limit *)
SS.Type := 3;
(* Read/write data, accessed *)
SS.S := 1;
SS.DPL := 0;
SS.P := 1;
SS.B := 1;
(* 32-bit stack segment *)
SS.G := 1;
(* 4-KByte granularity *)
보는 바와 같이 ss selector의 값은 커널 모드의 cs selector 값에 8을 더하고 있는데요,
0: kd> rM 8
cs=0010 ss=0018 ds=002b es=002b fs=0053 gs=002b efl=00000202
nt!DbgBreakPointWithStatus:
fffff806`4961f130 cc int 3
그래서 Windows에서는 커널 모드의 경우 언제나 저런 식으로 cs = 0x10이면 ss는 0x18이 됩니다.
간단하게 정리하면, syscall은 (MSR_LSTAR에 기록된) KiSystemCall64 함수를 호출하기 위해 전반적인 실행 환경을 Privilege Level 3에서 0으로 바꾸는 특수한 명령어입니다.
[이 글에 대해서 여러분들과 의견을 공유하고 싶습니다. 틀리거나 미흡한 부분 또는 의문 사항이 있으시면 언제든 댓글 남겨주십시오.]