spacedump.sys SDB_RECORD GetHeader integer truncation

Storage Spaces is Windows’ software RAID and storage-pool layer; a “space” lives on a pool of disks and each member carries a copy of the pool metadata that describes the layout, the configured spaces, and the resync state. The kernel component that reads and writes that metadata is spacedump.sys, invoked when the pool is discovered at boot, when a disk is added or removed, and during resync and dump capture. The metadata lives on disk, so every byte spacedump.sys parses is attacker-influenceable through whichever vector lets the attacker place or replace a pool disk — a maliciously prepared physical disk hot-added to a machine, a virtual disk image attached to a VM, an iSCSI target the operator points the host at. “The disk is the attacker” is the on-disk threat model, and every size field parsed off that disk has to be treated as hostile until validated.

The structure that carries per-record sizing on the read path is SDB_RECORD. Each record is preceded by a header that tells the consumer how big the record is; consumers allocate and copy based on that header, and the header writer is SDB_RECORD::GetHeader. The header field — header->RecordSize /* +0x4 */ — is a 16-bit slot, and the total is the sum of two addends that both trace back to data on the pool disk: a per-record size returned by a virtual call (*(src->Vtable /* +0x0 */ + 0x50))(src) on the record source object, and SC_FORMAT::Size(...), the byte size of the record’s serialisation format. Both can be large, the format size in particular is data-driven. The vulnerable build accumulated the sum directly in the 16-bit destination:

// SDB_RECORD::GetHeader - vulnerable build (spacedump.sys)
//   long GetHeader(SS_RECORD_HEADER* header, UCHAR srcIndex)
SS_RECORD_HEADER* header = arg2;
SDB_SOURCE* src = srcIndex ? this->AltSource /* +0x28 */
                           : this->Source    /* +0x20 */;

header->Kind /* +0x0 */ = this->Kind /* +0x1c */;   // propagate the record kind
header->Type /* +0x1 */ = src->Type   /* +0x18 */;  // propagate the source type

header->RecordSize /* +0x4, USHORT */ =
    (*(src->Vtable /* +0x0 */ + 0x50))(src);         // (1) per-record size: a 64-bit
                                                    //     vtable return written straight
                                                    //     into a 16-bit slot, high bits
                                                    //     dropped on the store

USHORT fmt = SC_FORMAT::Size(&g_RecordFmt /* data_140038e00 */, 1, this);
header->RecordSize += fmt;                          // (2) 16-bit add - wraps past
                                                    //     0xffff, no check, no reject

return STATUS_SUCCESS;

Step (1) already truncates: a per-record size wider than 16 bits is silently narrowed when it is written into the slot. Step (2) makes it worse — the add wraps, and a total that legitimately exceeds 0xffff ends up stored as total & 0xffff. A 0x1ffff becomes 0xffff; a 0x21344 becomes 0x1344.

The destination field is too narrow for the range of legitimate inputs, but the bug is not really about the field width — it is that the truncation is implicit. header->RecordSize is the contract between SDB_RECORD::GetHeader and every consumer that sizes off it; allocators, copiers, validators all trust it to be the real total. When it is the wrapped total, those consumers undersize their buffers, and the subsequent copy of the record — sized off the real length the caller computed separately — overruns the buffer it was just given. The overflow length is exactly the total - (total & 0xffff) bytes that were silently dropped, which the attacker picks by picking the record dimensions; they also pick the undersized allocation bucket and therefore what neighbour object the overflow reaches. Mounting a malicious pool disk is the unprivileged-on-the-pool entry, the buffer that gets hit sits in a predictable kernel pool bucket, and pool discovery calls SDB_RECORD::GetHeader on its own — the floor is a bugcheck, the ceiling is corruption of a neighbour object whose contents the attacker can then drive a follow-on operation through.

The patch

The patch computes the total in a 64-bit local, validates it against the field width, and stores the explicitly-narrowed value only when it fits:

// SDB_RECORD::GetHeader - patched build (spacedump.sys)
//   long GetHeader(SS_RECORD_HEADER* header, UCHAR srcIndex)
SS_RECORD_HEADER* header = arg2;
SDB_SOURCE* src = srcIndex ? this->AltSource /* +0x28 */
                           : this->Source    /* +0x20 */;

header->Kind /* +0x0 */ = this->Kind /* +0x1c */;   // propagate the record kind
header->Type /* +0x1 */ = src->Type   /* +0x18 */;  // propagate the source type

UINT64 total = (*(src->Vtable /* +0x0 */ + 0x50))(src); // accumulate in a 64-bit local,
                                                    // full width preserved

USHORT fmt = SC_FORMAT::Size(&g_RecordFmt /* data_140038e00 */, 1, this);
total += fmt;                                       // 64-bit add, cannot wrap

if (total > 0xffff)                                 // does it fit the 16-bit field?
    return 0xc0e70029;                              // storage-spaces error: reject
                                                    // the record at the boundary

header->RecordSize /* +0x4, USHORT */ = (UINT16)total;  // explicit narrow, only
                                                    // after the check passed

return STATUS_SUCCESS;

Compute in a type wider than anything that can contribute, validate against the destination width, narrow explicitly only after the check. The truncation is never implicit; if the record does not fit the field, the record is rejected at the boundary.

Attack path

flowchart TD
    A["attacker-prepared pool disk record, total larger than 0xffff"] --> B["spacedump parses metadata, calls SDB_RECORD GetHeader"]
    B --> C["per-record size stored truncated to 16 bits, SC_FORMAT Size added in 16-bit and wraps past 0xffff"]
    C --> D["consumer allocates RecordSize bytes, undersized for the real record"]
    D --> E["record copy overruns the adjacent kernel pool"]

The shape to remember is dest += b where dest is a narrow field — two bugs in one, since the addition may overflow the field’s type and the store may truncate a value the caller never intended to narrow. The safe shape is always wide = a + b; if (wide > NARROW_MAX) reject; dest = (narrow)wide;. Audit every place a length or size is narrowed on its way into a structure field, especially fields named Length, Size, or RecordSize — a single dropped bit turns a 70000-byte structure into a 4464-byte one, and every consumer that sizes off the header then overflows.