
드라이버 리버스 & 익스플로잇
⚠️ 경고: 이 프로젝트는 엄격히 교육 및 데모용입니다. 악의적인 환경에서 사용하기 위한 것이 아닙니다. 목표는 Windows 드라이버의 리버스 엔지니어링 방법론과 익스플로잇 단계를 배우는 것입니다.
여기서는 d1rk(SaadAhla) https://github.com/SaadAhla가 제안한 과제를 해결하기 위해 제가 수행한 과정을 설명합니다. 이 과제는 서명되어 있고 블록리스트(HVCI, LOLBIN...)에 없는 합법적인 드라이버에 대해 리버스 엔지니어링과 익스플로잇을 수행하는 것입니다. 이 Kernel-mode Driver를 통해 시스템에서 실행 중인 임의의 프로세스를 종료할 수 있는 C 프로그램도 제공되며, 그 동작 방식은 아래에서 자세히 설명합니다.

📃 사용법: DriverKiller.exe <process_name.exe> [-d]
-d 옵션: 익스플로잇 후 시스템에서 서비스와 드라이버를 삭제합니다.
드라이버의 인증서가 만료되었으므로 대상 머신에서 테스트 서명 모드(Test Signing Mode)가 활성화되어 있어야 합니다.
1부 - 리버스 엔지니어링:
과제는 SHA-256 해시로 이름이 지정된 .sys 파일을 제공합니다.
첫 번째 단계는 이 파일을 IDA로 여는 것입니다.
IDA는 무료로 사용할 수 있습니다. Hex-Rays 웹사이트에서 라이선스를 생성하고 소프트웨어를 다운로드하면 됩니다.
드라이버의 IAT(Import Address Table)를 나열하고 관심 있는 API 호출인 ZwTerminateProcess를 찾는 것부터 시작합니다.
ZwTerminateProcess를 더블클릭하면 IDA가 이 함수의 컴파일된 코드로 이동합니다. 해당 항목을 선택한 다음 cross-references를 표시하면 이 함수를 호출하는 드라이버 함수 목록을 얻을 수 있습니다.
sub_12EF4 함수(오프셋 1CE)가 ZwTerminateProcess를 사용하는 것을 확인할 수 있습니다. 더블클릭하면 IDA가 컴파일된 코드를 표시합니다.
디컴파일된 코드는 ZwOpenProcess(대상 프로세스에 대한 핸들을 여는 호출)와 ZwTerminateProcess(이 핸들을 통해 프로세스를 종료하는 호출)를 보여줍니다.
ZwOpenProcess 문서(https://learn.microsoft.com/fr-fr/windows-hardware/drivers/ddi/ntddk/nf-ntddk-zwopenprocess)를 확인하면 ClientID 매개변수가 대상 프로세스의 PID를 가리키는 포인터임을 알 수 있습니다.
바로 위 줄에서 ClientId.UniqueProcess는 변수 v22로 초기화됩니다. 이 변수는 바로 위에서 정의됩니다:
v22 = (void )((_QWORD *)i + 10);
이 할당을 이해하려면 변수 i와 +10 필드를 식별해야 합니다.
이 함수의 앞부분에서 SYSTEM_PROCESS_INFORMATION 매개변수를 사용한 ZwQuerySystemInformation 호출을 볼 수 있습니다. 또한 변수 v6와 함께 i가 이 구조체 항목을 반복하는 반복자임을 알 수 있습니다.
ZwQuerySystemInformation 문서(https://learn.microsoft.com/en-us/windows/win32/sysinfo/zwquerysysteminformation)에 따르면 이 함수는 시스템에서 실행 중인 각 프로세스에 대한 항목을 포함하는 배열을 반환합니다.
SYSTEM_PROCESS_INFORMATION 구조체는 여기에 설명되어 있습니다: https://learn.microsoft.com/en-us/windows/win32/api/winternl/nf-winternl-ntquerysysteminformation
typedef struct _SYSTEM_PROCESS_INFORMATION {
ULONG NextEntryOffset;
ULONG NumberOfThreads;
BYTE Reserved1[48];
UNICODE_STRING ImageName;
KPRIORITY BasePriority;
HANDLE UniqueProcessId;
PVOID Reserved2;
ULONG HandleCount;
ULONG SessionId;
PVOID Reserved3;
SIZE_T PeakVirtualSize;
SIZE_T VirtualSize;
ULONG Reserved4;
SIZE_T PeakWorkingSetSize;
SIZE_T WorkingSetSize;
PVOID Reserved5;
SIZE_T QuotaPagedPoolUsage;
PVOID Reserved6;
SIZE_T QuotaNonPagedPoolUsage;
SIZE_T PagefileUsage;
SIZE_T PeakPagefileUsage;
SIZE_T PrivatePageCount;
LARGE_INTEGER Reserved7[6];
} SYSTEM_PROCESS_INFORMATION;
참고: Windows x64에서 일부 유형의 크기
typedef struct _UNICODE_STRING {
USHORT Length; -> 2
USHORT MaximumLength; -> + 2 = 4
PWSTR Buffer; -> + 8 = 12 (12 n'est pas un multiple de 8 donc padding de 4 ajouté en amont de Buffer) = 16
} UNICODE_STRING;UniqueProcessId의 오프셋 계산:
ULONG NextEntryOffset; -> 4
ULONG NumberOfThreads; -> + 4 = 8
BYTE Reserved1[48]; -> + 48 = 56
UNICODE_STRING ImageName; -> + 16 = 72
KPRIORITY BasePriority; -> + 4 = 76 (76 n'est pas un multiple de 8 donc padding de 4 ajouté) = 80
HANDLE UniqueProcessId; -> + 8 = 88
UniqueProcessId 멤버는 따라서 오프셋 0x50 (10진수 80)에 있습니다.
v22 변수의 할당을 살펴보면 i가 QWORD 포인터(8바이트)로 캐스팅되는 것을 알 수 있습니다:
v22 = (void )((_QWORD *)i + 10);
v22는 i의 주소 + 10 * 8 = 80바이트에 해당합니다. 따라서 이 변수는 SYSTEM_PROCESS_INFORMATION 구조체에서 가져온 PID를 담고 있습니다.
어떤 PID가 ZwTerminateProcess로 전달될지 알려면 이 할당을 둘러싼 조건을 분석해야 합니다.
프로세스 이미지 이름이 먼저 검색되는 것을 확인할 수 있습니다:
v9 = (wchar_t )((_QWORD *)i + 8);
v9 = i의 주소 + 8 × 8 = 64바이트이기 때문입니다. 이는 ImageName 멤버의 Buffer에 해당합니다. 해당 멤버는 오프셋 56 + 2(USHORT) + 2(USHORT) + 4(padding) = 64에 있기 때문입니다.
아래의 조작과 루프를 고려할 때, 인수(a2)로 전달된 프로세스 이름과 시스템의 활성 프로세스 v9/String 사이의 비교가 수행된다는 가설을 세울 수 있습니다.
sub_1C078(String, v9, (int)v13); v17 = strupr(a2); v18 = strupr(String);
따라서 ZwTerminateProcess를 통해 종료할 프로세스 이름을 담고 있는 것은 a2 매개변수입니다.
a2가 sub_12EF4 함수의 매개변수임을 알 수 있습니다. 더 진행하려면 이 함수의 참조를 살펴봐야 합니다 (가독성을 위해 ZwTerminateProcessCaller로 이름을 변경했습니다).
ZwTerminateProcessCaller가 오프셋 61A에서 sub_13624 함수에 의해 호출되는 것을 확인할 수 있습니다.
이 디컴파일된 코드를 분석하기 전에, UserMode에서 DeviceIoControl API 호출 후 이 코드가 실제로 사용되는지 확인하기 위해 sub_13624 함수(이름을 ZwTerminateProcessCallerCaller로 변경)의 참조를 찾아보겠습니다.
ZwTerminateProcessCallerCaller가 sub_14130 함수에 의해 호출되는 것을 확인할 수 있습니다 (이름을 ZwTerminateProcessCallerCallerCaller로 변경... 다행히도 엔트리 포인트 전 마지막 함수입니다 😅).
ZwTerminateProcessCallerCallerCaller가 오프셋 306에서 sub_1A4A8 함수에 의해 호출되는 것을 확인할 수 있습니다.
ZwTerminateProcessCallerCallerCaller 함수의 할당을 찾을 수 있습니다:
memset64(DriverObject->MajorFunction, (unsigned __int64)ZwTerminateProcessCallerCallerCaller, 0x1Cu);
sub_13624(일명 ZwTerminateProcessCallerCaller) 함수로 돌아가기 전에 Symbolic Name과 Device Name(여기서는 동일)을 가져옵니다: Viragtlt.
ZwTerminateProcessCallerCaller로 돌아가면, 두 번째 매개변수(즉 a2)가 MasterIrp->AssociatedIrp.SystemBuffer에 해당함을 알 수 있습니다.
ZwTerminateProcessCaller 호출 바로 위에서 IOCTL 코드를 찾을 수 있습니다: -2106392528 (16진수: 0x82730030).
이 정보를 통해 이 드라이버를 악용하려면 종료할 프로세스 이름을 SystemBuffer에 담아 드라이버에 DeviceIoControl API 호출을 보내야 한다는 것을 추론할 수 있습니다.
🔷 리버스 엔지니어링을 통해 얻은 정보:
0x82730030ViragtltViragtlt2부 - 익스플로잇
이 드라이버를 악용하려면 (대상 머신에 설치되어 있고 활성 상태인 경우) 드라이버에 대한 핸들을 연 다음, 종료하려는 프로세스 이름이 담긴 Buffer로 DeviceIoControl API 호출을 해야 합니다.
이 과제를 위해 다음을 수행하는 C 프로젝트를 개발했습니다:
또한 익스플로잇 후 시스템에서 서비스와 드라이버를 삭제할 수 있는 -d 옵션도 추가했습니다.
다음은 C 프로그램의 전체 실행 주기 동작입니다:
AV/EDR 회피
이 경우 DriverKiller.exe는 Microsoft Defender에서 정적으로도 동적으로도 탐지되지 않습니다. 악용되는 드라이버의 인증서가 만료되었기 때문에 실제 환경에서 사용하기 어려워 회피는 여기서 큰 의미가 없습니다. 하지만 더 나은 은밀성을 위해 다음과 같은 방법을 구현할 수 있었을 것입니다:
29/08/2025 기준 드라이버 탐지 현황 (이미 존재하는 결과이며, 명백한 이유로 VirusTotal에 아무것도 제출하지 않았습니다):
⚠️ 이 프로젝트는 학습 목적으로 제작되었습니다. 부정확하거나 오류가 포함될 수 있습니다. 제안, 수정 또는 논의는 언제나 환영합니다! 😃 d1rk(SaadAhla)에게 감사드립니다: https://github.com/SaadAhla !