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.