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.