Lingua Diabolis

Aug 28, 2025

It's a Trap - Reliable Exploitation of CVE-2024-30084

To continue my journey in Windows kernel-land I started to process DEVCORE's epic series about Kernel Streaming (I, II, III). In their first post Angelboy provides an excellent explanation of CVE-2024-35250, an untrusted pointer dereference for which a public exploit has been available for some time. A limitation of the exploit is that it requires certain media devices which are not available by default either in Pwn2Own's setting or in my little virtual lab.

CVE-2024-30084 is a double fetch vulnerability that DEVCORE combined with the untrusted pointer dereference to win at P2O. Since I found no public combined exploit, I decided to implement it myself. Along the way I faced with the usual challenges of exploiting double fetches on Windows, so I decided to apply James Forshaw's memory access trapping technique to achieve deterministic exploitation.

Note: Tests were performed on Windows 10 22H2, so I didn't have to wrestle with KASRL like in the previous post.

N-day Fans

To give a quick summary, CVE-2024-35250 makes the kernel call a user-supplied pointer when a spicy KSPROPERTY object is passed down the Kernel Streaming interface. The untrusted call happens when a KSPROPSETID_DrmAudioStream property is included in the request, so the public exploit starts by looking up a Kernel Streaming device that accepts this property:

const GUID categories[] = {
  KSCATEGORY_DRM_DESCRAMBLE, // This is just a GUID
};
//...
KsOpenDefaultDevice(categories[i], GENERIC_READ | GENERIC_WRITE, &hDrmDevice);

Many virtualization software don't provide such devices. For testing, I attached an old webcamera to my VM host and used USB passthough to present it to the test VM. With this change in the environment the exploit works like a charm.

I also considered embedding https://tenor.com/view/animals-smoking-surprise-surprised-eating-out-gif-13256104, but that would look unprofessional

Every blog post is better with cute animals

Take One

If we can't rely on such cheap tricks, DEVCORE suggests to use CVE-2024-30084, that is a double fetch issue allowing us to replace the category GUID after the appropriate device was selected but before the untrusted call is to be made.

In order to do this we first have to find a way to get kernel-mode processing to the point when the second memory fetch happens. This requires a device to send the IOCTL to, and DEVCORE suggests to use MSKSSRV and its KSPROPSETID_Service property as the opening move.

To get a handle to this virtual device (present on all systems I tested) we can follow Synacktiv's presentation:

// These GUID's are already present in the public exploit...
DEFINE_GUIDSTRUCT("3C0D501A-140B-11D1-B40F-00A0C9223196", KSNAME_Server);
#define KSNAME_Server DEFINE_GUIDNAMED(KSNAME_Server)

DEFINE_GUIDSTRUCT("3C0D501B-140B-11D1-B40F-00A0C9223196", KSPROPSETID_Service);
#define KSPROPSETID_Service DEFINE_GUIDNAMED(KSPROPSETID_Service)

KsOpenDefaultDevice(KSNAME_Server, GENERIC_READ|GENERIC_WRITE, &hDrmDevice);

After we obtained the handle we can send a property setting request to the device for the KSPROPERTYID_Service property. It's important to set this identifier both in the input and output buffers sent with the I/O request, because UnserializePropertySet() will bail out before the second driver call is made if these values don't match.

We can verify the theoretical exploitability of the vulnerability in this new environment by breaking on the memmove operation and replacing the property GUID of the input buffer with KSPROPERTYID_DrmAudioDevice.

Faster than Light

As a proof-of-concept I first tried to win the double fetch race by simply using threads. While I experimented with more elegant implementations, only the most primitive one worked (somewhat):

  • A writer thread waited in a tight loop until a global variable was set.
  • The main thread set the global variable right before issuing the IOCTL.
  • The writer thread started a counter loop up to a user-defined value, then rewrote the GUID in the input buffer.

On my lab VM this worked about 50% of the time right after boot (no debugger attached), but success rate degraded quickly over time. It's worth noting that the exploit can be executed several times, so brute-force may just work. Nonetheless, I wanted something cooler, and the memory trapping technique published at Project Zero seemed like a perfect match.

A Window to the Clouds

In essence, the technique relies on the Cloud File API (of OneDrive fame) to implement placeholder files. Access to these placeholders are redirected to their corresponding user-mode handlers (that's us), that would normally download their contents from The Fog to hydrate the placeholders into full files locally. Combining this with memory-mapped files we can detect and delay accesses to the virtual memory of our user-mode processes which is very handy to exploit TOCTOU situations such as double fetches.

An implementation is available on GitHub too - only problem is integration, as it's written in C#, while our existing exploit is C++ (well, more like C). While I don't mind zoning in on a good porting marathon (and we even have LLM's to do the boring parts), at this point I wasn't sure if my plan would work at all, and I definitely didn't want to spend time on debugging porting errors. Fortunately, .NET now has NativeAOT - Native Ahead-of-Time compilation - that can produce native libraries from a C# source. I relied on these tutorials to adjust and compile the C# project to get a native CloudFilterTrap.dll that I can load in the exploit. But first I needed a new API that made the original executable usable as a library. I lobotomized the original entry point and created the following exports:

  • serve() - This is the original entry point, starts the server loop.
  • stop() - Flips a global variable to exit the server loop (again, ugly).
  • set_callback(void*) - Saves a (native) function pointer to be called on file (memory) access (see the warp() function in the exploit).
  • set_buffer(void*) - Saves a pointer to the contents of the file to be returned.

Data transfer requests are handled by the DoTransferCallback() C# function. This function allocates a zeroed memory buffer and has to be tweaked to return the contents we set via our new exports at the offset we desire. This is also the place to introduce any delays and call our native callback.

I have to add that while NativeAOT worked perfectly, compilation is very slow (about a minute on my not too beefy build VM) that slowed down development/debug iterations significantly.

Failed Strategies

As an initial test I made DoTransferCallback() return the buffer we set at the beginning of every transfer buffer, effectively spraying the KSPROPERTY input buffer over the 2GB memory section we monitor. I also made the native callback fire right after every transaction executed.

The first obervation I made is that AV's (Defender in particular) definitely mess up our game as they start to sniff through the file the moment it's mapped. Overcoming this annoyance is on my TODO list, now let's just focus on the exploit!

AV meddling

Getting unexpected accesses is especially bad since our callback is one-shot, meaning that any region of the mapped section is only requested at most once from our cloud filter. Getting back to our first test, immediately executing the native callback that will overwrite our property GUID will result in an early switch: UnserializePropertySet() won't be called at all, because the target device doesn't support the property set by our native callback (warp()). Late switches on the other hand would leave the GUID intact by the time of the memmove, making the kernel skip the call to our pointer.

Forshaw's post explains that access to different parts of structures can be detected by placing the structure across page boundaries (for more precise description please read the original). This screenshot shows how the first 16 bytes of a KSPROPERTY gets populated after the page fault was handled using our cloud filter, while the rest of the structure is still unpopulated:

I experimented with firing the native callback at each of the two requests, before and after the data transfer is actually executed (CfExecute()). The best result I got still required dumb, tight-loop based timing in the native callback but worked with even less effectively than the thread-based approach. For some time I was hoping that at least the sequence of log messages would help me tune delays more precisely, and while this "printf debugging" did give me some useful clues (keeping in mind the effects of threading, buffering, etc.) about whether I'm very late or very early, hitting a sweet spot was practically impossible due to jitter. I also tried to combine a kernel- and a user-mode debugger (very Hackerman!) to track the sequence of events, but not only did these interfere with timings, the user-mode debugger died all the time. It also tells a lot about the difficulty of timing that once I saw a breakpoint command in kd report old memory contents (KSPROPID_Service), while the VM dropped a SYSTEM shell!

Taking a closer look at the disassembly (which I should've done much earlier...) explained it all: The whole KSPROPERTY structure is read within 5 instructions, so there is really no difference in timing - the only thing we can do is to expand the too early period by delaying the load:

MOV        RDX,qword ptr [RSI + 0x20] ; KSPROPERTY* in RDX
MOVUPS     XMM0,xmmword ptr [RDX] ; XMM0 <- First 16 bytes 
MOVUPS     xmmword ptr [RSP + local_68[0]],XMM0
MOVSD      XMM1,qword ptr [RDX + 0x10] ; XMM1 <- Remaining 16 bytes

So is all lost? Was all this effort in vain? Fortunately, failures can often lead us to successes!

Outside the Camera Obscura

Remember how both the input and the output buffers had to be adjusted so the original exploit could get past UnserializePropertySet()? I remembered, because I spent considerable time debugging that problem and tracing execution. If I just did a s/KSPROPERTY_DrmAudioDevice/KSPROPERTY_Service/g on the original exploit I'd spared myself some time, but had probably not occured to me that by the time the output buffer is accessed, the Kernel Streaming component is guaranteed to be past any checks on the input buffer, so we are at just the right moment to make the switch with our native callback!

We don't even have to mess with splitting our data, and the exploit always works:

Takeaways

  • The Cloud Filter Trap technique is useful for exploiting real bugs
  • While it may seem tempting to aim our one shot at the "TOC" part of TOCTOU's, we can be better off looking for moments when checks are well behind us, aka. closest to the "TOU" part. This is especially true if we have to pass multiple consequtive checks.
  • This is another example when taking notes about what didn't work was crucial to find a solution. Failures are natural part of our journeys, don't be afraid to document and (if possible) share them too!

Code is on GitHub.