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.