battc.sys AdapterClassInitializeDevice global pointer UAF
battc.sys, the battery class driver, keeps a single process-wide global
PowerAdapter that points at the one power-adapter device extension it
currently manages. Every later code path — battery IOCTL, PnP event, WMI
request — snapshots it under PowerAdapterMutex:
KeAcquireGuardedMutex(&PowerAdapterMutex);
PDEVICE_EXT adapter = PowerAdapter; // snapshot under the mutex
KeReleaseGuardedMutex(&PowerAdapterMutex);
if (adapter == NULL) return STATUS_DEVICE_DOES_NOT_EXIST;
adapter->... // dereference
That pattern is correct only while PowerAdapter never points at freed
memory, and the entire bug is that the init path can leave it doing exactly
that.
AdapterClassInitializeDevice runs the adapter through bring-up. The very
first thing it does inside PowerAdapterMutex is allocate the device
extension ext and publish it as PowerAdapter = ext — before a single
field of ext has been initialised. Only then does it release the mutex
and start the bring-up work: bulk-copy the miniport’s init data into
ext->InitData, allocate ext->WorkItem, initialise the two events, the
two list heads, the backup timer and DPC, and finally register and enable
the device interface stored at ext->InterfaceName. Any of those steps
can fail — IoAllocateWorkItem returns NULL, IoRegisterDeviceInterface
or IoSetDeviceInterfaceState returns a bad status — and on failure the
function jumps to a cleanup label that tears ext back apart but never
retracts the publish:
// AdapterClassInitializeDevice cleanup — vulnerable build
if (ext->InterfaceName.Buffer)
IoSetDeviceInterfaceState(&ext->InterfaceName, FALSE);
ExFreePoolWithTag(ext->InterfaceName.Buffer, 0);
if (ext->WorkItem)
IoFreeWorkItem(ext->WorkItem);
ExFreePoolWithTag(ext, 'Batt'); // PowerAdapter still == ext -> dangling
The publish was under the mutex; the free is not paired with an unpublish
under the mutex. After this label runs the global still names ext, which
is now pool on the free list. Any thread that snapshots PowerAdapter
once the free completes follows the mutex-protected read straight into
freed memory. The mutex serialises readers against each other — it cannot
help when the writer never publishes the “gone” state. This is a TOCTOU on
the global’s validity, and the only thing the lock guards is the snapshot,
not whether what gets snapshotted is still alive. The window is also
wider than the failure path alone suggests: because the publish happens
before init, a reader that snapshots PowerAdapter while bring-up is
still in flight gets an extension whose WorkItem, InterfaceName and
event/list/timer fields have not been written yet.
The patch
Retract the publish on the failure path, under the same mutex, before the free:
// AdapterClassInitializeDevice cleanup — patched
KeAcquireGuardedMutex(&PowerAdapterMutex);
PowerAdapter = NULL; // retract first
KeReleaseGuardedMutex(&PowerAdapterMutex);
if (ext->InterfaceName.Buffer)
IoSetDeviceInterfaceState(&ext->InterfaceName, FALSE);
ExFreePoolWithTag(ext->InterfaceName.Buffer, 0);
if (ext->WorkItem)
IoFreeWorkItem(ext->WorkItem);
ExFreePoolWithTag(ext, 'Batt'); // now no live global references ext
Any reader that arrives after the retraction sees NULL and bails with
STATUS_DEVICE_DOES_NOT_EXIST, and the free runs after the last
conceptual reference is gone. What the publish took under the lock, the
teardown must clear under the same lock — that is the entire contract the
vulnerable build missed.
Attack path
sequenceDiagram
participant Init as init thread
participant G as PowerAdapter under PowerAdapterMutex
participant T as IOCTL WMI or PnP thread
Init->>G: PowerAdapter = ext right after alloc, before any init field
Init->>G: work item or interface step fails, cleanup frees ext, PowerAdapter not cleared
T->>G: take mutex, adapter = PowerAdapter = freed ext
T->>G: release mutex, dereference adapter
Note over G: use-after-free on freed device extension
Triggering it needs the init to fail after the publish — a malicious or broken battery ACPI device, interface-registration resource exhaustion, or a registry-controlled simulation rate — and then a battery IOCTL, WMI, or PnP request that races into the freed extension. On systems where the attacker controls the battery device (a physical malicious pack, or a passed-through or VMBus battery in a guest) the failure is inducible and the resulting UAF is a clean local-EoP primitive.
The thing to grep for is the asymmetry itself: a global set under a lock
on the success path and freed without that lock on the failure path. The
companion CmBatt.sys fix this month ships the same shape one layer down
the stack, which is the signature of a bad idiom copied between a class
driver and its miniport — worth auditing every sibling ACPI/battery
miniport for the same lifecycle.