I was reading through EDRSandblast's driver abstraction layer when a compile-time flag stopped me cold. Not a feature flag. Not a debug toggle. A flag called WriteMemoryPrimitiveIsAtomic, set to 0 for one driver and 1 for the other two.
That flag gates entire code paths. Functions wrapped in #if WriteMemoryPrimitiveIsAtomic simply do not exist if your driver cannot do a pointer-width write in a single operation. The project says it gives you arbitrary kernel read/write. This flag says that claim has fine print.
I spent a few days pulling on that thread, and what fell out was something I do not see discussed enough in offensive tooling. "Arbitrary kernel read/write" is not a single capability. It is a spectrum. The write size, atomicity, and speed of your primitive determine which kernel techniques you can safely use, and which ones will crash the machine. The choice of vulnerable driver is not a convenience decision. It constrains your entire attack surface.
The abstraction that hides the problem
EDRSandblast supports three BYOVD drivers. At build time, you pick one with a #define, and a set of macros in KernelMemoryPrimitives.h maps generic ReadMemoryPrimitive and WriteMemoryPrimitive calls to the driver-specific functions. Clean design. Swap a driver, recompile, everything works.
Except it does not all work. That header also defines the WriteMemoryPrimitiveIsAtomic flag, and the value depends on which driver you chose. The abstraction presents a uniform interface, but the thing behind the interface has properties the interface cannot hide. The flag is the project's way of being honest about that.
There is also a TestReadPrimitive() function that reads the first two bytes of ntoskrnl's base address in memory and checks for the MZ signature. A quick sanity check before you try anything ambitious. If your read primitive cannot correctly fetch two bytes from a known location, you should probably stop there.
Three drivers, three primitives
All three drivers implement the same basic safety checks. They reject addresses below 0x0000800000000000 (userland) and reject non-canonical addresses in the dead zone between user and kernel ranges. This prevents accidental BSODs from bad target addresses. What they do not agree on is the size of a single write operation.
RTCore64.sys ships with MSI Afterburner. It exposes a read IOCTL (0x80002048) and a write IOCTL (0x8000204c), each taking a 48-byte structure with Address, Offset, ReadSize/WriteSize, and Value fields. The Value field is a DWORD. Four bytes. That is all you get per IOCTL call.
To write an 8-byte pointer, you need two IOCTLs. Write the low four bytes, then write the high four bytes at offset +4. Between those two calls, the target location holds a half-written value.
// RTCore64: writing an 8-byte pointer takes two IOCTLs.
// Between them, the target holds garbage.
void WriteKernelPointer(HANDLE hDevice, ULONG64 where, ULONG64 value) {
DWORD lo = (DWORD)(value & 0xFFFFFFFF);
DWORD hi = (DWORD)(value >> 32);
// Write low 4 bytes
RTCore_WriteMemory(hDevice, where, lo, sizeof(DWORD));
// Right here, right now, the pointer is half-written.
// If anything in the kernel follows it, BSOD.
// Write high 4 bytes
RTCore_WriteMemory(hDevice, where + 4, hi, sizeof(DWORD));
}
gdrv.sys from Gigabyte is a different animal. It uses a single IOCTL (0xC3502808) for both reads and writes. The structure has Src, Dst, and Size fields. It is a kernel-mode memcpy. You tell it where to copy from, where to copy to, and how many bytes. Arbitrary size. A pointer write lands in one operation.
DBUtil_2_3.sys from Dell takes a third approach. Its write IOCTL (0x9B0C1EC8) accepts a variable-length buffer appended to a header structure. The driver allocates Size + header bytes, copies your data into the buffer, and writes it to the target address. Like gdrv, it handles arbitrary sizes in a single IOCTL, so pointer-width writes are atomic.
Three drivers. Three different write primitives. One abstraction layer trying to pretend they are the same thing.
The linked list problem
To understand why write size matters, you need to understand what these tools actually write to. Most EDR-blinding techniques involve unlinking entries from kernel data structures. These structures are usually doubly-linked lists, the same LIST_ENTRY pattern that shows up everywhere in the Windows kernel.
A LIST_ENTRY is two pointers. Flink points forward, Blink points backward. Every node in the list has one. Unlinking a node means updating the neighboring nodes to point around it.
// Textbook doubly-linked list unlink.
// We need to write two pointers: next->Blink and prev->Flink.
void UnlinkNode(ULONG64 prev_flink, ULONG64 next_blink,
ULONG64 prev_addr, ULONG64 next_addr) {
// Make the previous node's Flink skip over our target
WriteKernelPointer(prev_flink, next_addr);
// Make the next node's Blink skip back over our target
WriteKernelPointer(next_blink, prev_addr);
}
Each of those pointer writes is 8 bytes on x64. With gdrv or DBUtil, each write is a single IOCTL. With RTCore, each write is two IOCTLs, and we need two writes, so that is four IOCTLs total to unlink one node.
The problem is not the number of IOCTLs. The problem is what happens between them.
Four bytes of trouble
The Windows kernel does not stop running while you are poking at its data structures from usermode. File I/O happens constantly. Context switches happen constantly. The kernel is traversing these linked lists between every single one of your IOCTL calls, sometimes during them.
Consider what happens when RTCore writes a pointer. The first IOCTL writes the low four bytes of the new value. The high four bytes still contain the old value. For a brief window, the pointer is a chimera. The bottom half is where you want it to point. The top half is where it used to point. If the kernel follows that pointer during that window, it reads from an address that is neither the old destination nor the new one. It is garbage. The CPU faults. You get a bugcheck.
With minifilter callback unlinking, the race is particularly bad. Filter Manager maintains linked lists of _CALLBACK_NODE entries, and those lists get walked on every file I/O operation. Every file open, every read, every write to disk. On a busy system, the kernel is traversing these lists thousands of times per second. Your window between the two 4-byte writes might be microseconds, but the kernel is checking the list on a similar timescale.
Timeline (RTCore, writing an 8-byte Flink pointer):
Thread A (your exploit) Kernel (any file I/O thread)
───────────────────── ──────────────────────────────
IOCTL: write low DWORD ──┐
│
├──> Flink is now 0xOLDHIGH_NEWLOW
│
│ traverse list...
│ read Flink...
│ follow 0xOLDHIGH_NEWLOW...
│ BSOD: INVALID_PAGE_FAULT
│
IOCTL: write high DWORD ─┘
(too late)
This is not a theoretical concern. EDRSandblast's authors knew about it. The entire minifilter callback Remove and Restore function pair is wrapped in #if WriteMemoryPrimitiveIsAtomic. If you compile with RTCore selected, those functions do not exist. They are not stubbed out. They are not degraded. They are gone.
Object callback unlinking has the same problem. The kernel's object callback mechanism maintains a CallbackList for process and thread handle operations. To disable callbacks, you can make the list head's Flink and Blink point back to itself, effectively emptying the list. But writing each of those pointers with RTCore means four IOCTLs and multiple race windows. Handle creation and duplication for processes and threads is extremely frequent. The source code is blunt about it: there is a high risk of race condition, and it is likely to result in a crash.
The safe path
So what do you do when your primitive can only write four bytes at a time?
You find techniques that only need four-byte writes.
The object callback code in EDRSandblast has an alternative strategy. Instead of unlinking the callback entry from the list (which requires pointer-width writes), it finds the Enabled field inside the OB_CALLBACK_ENTRY structure and sets it to zero. One DWORD. One IOCTL. Even RTCore can do this atomically. The callback stays in the list but stops firing.
// Safe with any write primitive, including 4-byte-max ones.
// The callback stays linked but the kernel skips it.
void DisableCallbackSafe(HANDLE hDevice, ULONG64 callback_entry) {
ULONG64 enabled_field = callback_entry + ENABLED_OFFSET;
DWORD zero = 0;
// Single 4-byte write. No race. No BSOD.
WriteMemoryPrimitive(hDevice, sizeof(DWORD), enabled_field, &zero);
}
The trade-off is forensics. The callback stays in the list, so anything inspecting the CallbackList will still see it. But the machine stays up, and on an engagement that matters more than stealth in a kernel structure nobody is likely to dump.
The same logic applies to other kernel structures. If your write primitive caps at four bytes, look for DWORD-sized kill switches: Enabled flags, status fields, reference counts you can zero. Linked-list surgery is off the table, but a lot of kernel monitoring hangs on a single DWORD somewhere.
Bootstrapping a strong primitive from a weak one
There is an unimplemented TODO in KernelMemoryPrimitives.h that points to something interesting:
// TODO: design a way to make an atomic write given a non-atomic one.
// Idea: modify a PTE to mark a page userland-reachable and perform
// the write from the process.
The idea: x64 guarantees that an aligned 8-byte MOV is atomic. The problem with RTCore is not that atomic pointer writes are impossible, it is that the IOCTL interface breaks them into 4-byte chunks. So use the non-atomic primitive to patch a page table entry, marking a kernel page accessible from usermode. Then write to kernel memory directly with a normal MOV from your process. The CPU handles the atomicity.
The PTE modification itself is an 8-byte write, so it has the same race condition. But PTEs are not traversed on every file I/O the way callback linked lists are. The window exists, but the odds of losing the race drop by orders of magnitude. And once the page is mapped, every subsequent write is atomic for free.
I have not seen a public implementation of this. It would be worth building.
What matters when picking a driver
When evaluating a vulnerable driver for BYOVD, the max single-write size is the first thing to check. Anything under 8 bytes on x64 means non-atomic pointer writes. After that, look at the IOCTL input structure. RTCore's Value field is a DWORD, so the 4-byte limit is baked into the struct, not a range check you could bypass. And check whether the driver copies your buffer to a pool allocation before writing to the target, since that adds latency between usermode and the actual kernel write.
If you are unlinking callback nodes on a server doing heavy file I/O, you need a driver that can land a pointer write in one IOCTL. If you are toggling a DWORD flag, anything works. The technique you want to run picks the driver, not the other way around.
The source for all of this is EDRSandblast. The #if WriteMemoryPrimitiveIsAtomic guards are the quickest way to see which techniques depend on write atomicity.