vhdmp.sys VhdmpiQueueIoRequest prefetch rundown UAF

vhdmp.sys is the kernel miniport behind every virtual hard disk format Windows supports natively — VHD and VHDX. Attaching a .vhd or .vhdx (Hyper-V, Windows Backup, mount-vhd, WIM deployment) opens the file, parses its header and block-allocation tables, and builds an in-memory tree of objects that downstream I/O travels through. Attach is unprivileged: any local user can mount an image they own, which makes every field the parser reads off the image — BAT size, backing-store chain, geometry, type tags — attacker input. The classic malicious-VHD local EoP vector is to craft an image whose fields send those kernel paths into corrupted state, and vhdmp has been a recurring source of exactly that.

Each queued I/O carries a back-pointer to the virtual disk it targets (Request->VirtualDisk). That disk object owns the rundown ref at +0xd8 that pins its whole allocation, and a pointer at +0xc8 to a parent object — the active surface that hosts the prefetch machinery. The parent carries its own inner rundown at +0x610 and a prefetch-state pointer at +0x5f8; a single byte at +0xac inside that prefetch state marks the request recordable. The public PDB is stripped, so these offsets come from the vulnerable build.

The Windows rundown idiom (ExAcquireRundownProtection / ExReleaseRundownProtection) is how a kernel component says “I am about to dereference this object; do not free it under me.” A teardown path that wants to free first calls ExWaitForRundownProtectionRelease and blocks until every acquirer has released. The contract has one nesting rule that matters here: the object you used to reach an inner object must stay pinned for as long as you hold the inner one. Take only the inner rundown and nothing stops the outer object from being torn down — and the pointer you followed to get inside it lives in the outer object’s memory, so it goes away with it. “Lock the parent before the child” is the lock analogue; rundown has the same nesting rule.

VhdmpiQueueIoRequest is the helper that decides whether a queued request should be recorded for the prefetch worker. The vulnerable build walked from the request up to its virtual disk and into the parent’s prefetch state, taking only the parent’s inner rundown:

// VhdmpiQueueIoRequest - vulnerable build
VHD_PARENT* Parent = Request->VirtualDisk->Parent;                         /* +0xc8 - read with no disk rundown held */
if (Parent != NULL && ExAcquireRundownProtection(&Parent->RundownRef)) {   /* +0x610 - inner rundown only */
    VHD_PREFETCH_STATE* PrefetchState = Parent->PrefetchState;             /* +0x5f8 */
    if (PrefetchState->Ready)                                              /* +0xac */
        VhdmpiRecordForPrefetchWorker(Request, PrefetchState);
    ExReleaseRundownProtection(&Parent->RundownRef);
}

The function reaches the parent by following Request->VirtualDisk->Parent, then takes the rundown on the parent alone. Between that deref and the rundown acquire there is no hold on the virtual disk itself, so nothing prevents the disk from being torn down in that window. When the disk goes, the +0xc8 slot the parent pointer was read from is freed with it, and the rundown acquire plus the prefetch-state read then operate on reclaimed memory. A subsequent detach on the same VHD drains the disk rundown while a worker is still recording, the inner rundown acquire writes into reclaimed pool, and VhdmpiRecordForPrefetchWorker then walks the prefetch state from the same freed allocation. EX_RUNDOWN_REF is a small, frequently allocated kernel object; reclaiming the freed slot with a controlled allocation before the acquire turns the write into a corrupted-kernel-object primitive. The same user that mounted the VHD can drive the detach, so the trigger is local and self-staged, and the freed allocation sits in a predictable pool bucket — plausibly winnable for elevation rather than just a bugcheck.

The patch

The fix reorders the acquires so the virtual disk’s rundown is taken before any access to the parent, and held until after the parent’s rundown is released:

// VhdmpiQueueIoRequest - patched
if (ExAcquireRundownProtection(&Request->VirtualDisk->RundownRef)) {           /* +0xd8 - pin the disk FIRST */
    VHD_PARENT* Parent = Request->VirtualDisk->Parent;                     /* +0xc8 - safe under the disk rundown */
    if (Parent != NULL && ExAcquireRundownProtection(&Parent->RundownRef)) {   /* +0x610 */
        VHD_PREFETCH_STATE* PrefetchState = Parent->PrefetchState;         /* +0x5f8 */
        if (PrefetchState->Ready)                                          /* +0xac */
            VhdmpiRecordForPrefetchWorker(Request, PrefetchState);
        ExReleaseRundownProtection(&Parent->RundownRef);
    }
    ExReleaseRundownProtection(&Request->VirtualDisk->RundownRef);             /* +0xd8 - release LAST */
}

Outer-before-inner, release in reverse. Once the disk’s rundown is held, the parent pointer at +0xc8 cannot be freed until that rundown is released, which makes the parent access and everything below it safe.

Attack path

sequenceDiagram
    participant U as local user
    participant K as VhdmpiQueueIoRequest
    participant D as virtual disk object
    K->>D: IO queued, reads VirtualDisk then Parent with no disk rundown held
    U->>D: concurrent detach drains disk rundown, frees disk object
    K->>D: parent rundown acquire touches freed pool
    Note over D: UAF on rundown, reclaim for EoP

The shape to grep for is acquire(B->Inner); ...; release(B->Inner) with no matching acquire(A->Rundown) around it — every instance is a free UAF on whatever teardown path can drain the outer object in between. Rundown is the object-lifetime equivalent of “lock the parent before the child”: if object A is how you reached object B, B’s rundown must be taken under A’s, or the pointer to B may be dangling by the time you use it.