udfs.sys UdfMountVolume logical volume integrity descriptor out of bounds read
UDF (Universal Disk Format) is the filesystem used on optical media and on the
ISO images that ship as installation media and VM templates. udfs.sys is its
Windows kernel driver, and it activates whenever the system mounts an optical
disc or attaches an .iso — both reachable from an unprivileged user, since
Windows 10 and later let a standard user mount an ISO by double-clicking it.
UDF volumes are described by a chain of descriptors read off the disc during
mount or volume verification. One of those is the Logical Volume Integrity
Descriptor (LVINT), which records integrity information about the logical
volume — including NumberOfPartitions, LengthOfImplementationUse, and a
set of fixed-size implementation-use fields. The LVINT is part of the
mount-time parsing path, so its contents are entirely attacker-controlled:
whatever bytes the image author placed there are what udfs.sys reads.
The pointer to the implementation-use fields inside the LVINT is derived
from NumberOfPartitions through an arithmetic expression, and a later table
entry count in UdfUpdateVcbPhase0 is derived from a subtract against a
stream size. Both derivations are where the bug lives. The partition-field
pointer is computed as:
part = lvid + ((NumberOfPartitions * 2 + 0x14) << 2)
The fixed fields (the implementation identifier, NumberOfFiles,
NumberOfDirectories, and the LVINT unique id) are then read at fixed
offsets from that pointer. Separately, UdfUpdateVcbPhase0 parses the UDF
Virtual Allocation Table (VAT) and derives its entry count from
(VatStreamSize - Vat->Length). The vulnerable build did both with no
width-correct overflow guard on either:
/* UdfMountVolume (and UdfVerifyVolume, same expression) - vulnerable build */
struct UDF_LVID_INTEGRITY* lvid = Vcb->Lvid; /* Vcb+0x590 */
struct UDF_LVID_IMPL_USE* part = (struct UDF_LVID_IMPL_USE*)
((PUCHAR)lvid + ((lvid->NumberOfPartitions * 2 + 0x14) << 2));
/* lvid->NumberOfPartitions is the
* u32 at lvid+0x48; the * 2 happens
* in 32-bit and wraps */
/* Only bound check: pointer ordering plus a 64-bit wrap test. A 32-bit
multiply wrap keeps the offset small, positive and in-buffer, so all
three conditions pass and control falls through to the reads. */
if ((PUCHAR)part < (PUCHAR)lvid || (PUCHAR)part + 0x30 < (PUCHAR)part ||
(PUCHAR)part + 0x30 >= (PUCHAR)lvid + sector_aligned(Vcb->SectorSize))
return STATUS_DISK_CORRUPT_ERROR; /* Vcb->SectorSize at Vcb+0x44 */
Vcb->LvidImplId0 = part->ImplId0; /* Vcb+0x598 = *(part + 0x00), 16 bytes */
Vcb->LvidImplId1 = part->ImplId1; /* Vcb+0x5a8 = *(part + 0x10), 16 bytes */
Vcb->NumberOfFiles = part->NumberOfFiles; /* Vcb+0x5b8 = *(part + 0x20) */
Vcb->NumberOfDirs = part->NumberOfDirs; /* Vcb+0x5bc = *(part + 0x24) */
Vcb->LvIntUniqueId = lvid->UniqueId; /* Vcb+0x5c0 = lvid->UniqueId at lvid+0x28 */
/* UdfUpdateVcbPhase0 - vulnerable build, VAT parse. var_120 is the
mapped UDF 2.x VAT header; VatStreamSize is Vcb->VatScb->FileSize. */
UINT32 vatLen = Vat->Length; /* Vat+0; binary reads the low
* 16 bits and zero-extends */
Vcb->VatOffset = vatLen; /* Vcb+0x1d8 */
Vcb->VatEntries = (Vcb->VatScb->FileSize - vatLen) >> 2;
/* Vcb+0x1dc; unsigned subtract
* underflows when Vat->Length
* exceeds the VAT stream size */
Vcb->NumberOfFiles = Vat->NumberOfFiles; /* Vcb+0x5b8, re-populated */
Vcb->NumberOfDirs = Vat->NumberOfDirs; /* Vcb+0x5bc */
The LVINT compute multiplies NumberOfPartitions by two, adds 0x14, and
shifts left by two — with the multiply performed as a 32-bit operation. For
a hostile NumberOfPartitions near 0x80000000, the multiply wraps to a
small value, so part lands somewhere inside the LVINT buffer even
though the real arithmetic intent placed it well past the end. The bound
check that follows only tests pointer ordering (part < lvid), 64-bit
wrap (part + 0x30 < part), and an in-buffer ceiling (part + 0x30 within
the sector-aligned capacity). None of those catches a 32-bit multiply wrap
that produces a small positive offset, so all three pass and the
fixed-offset reads return attacker-chosen bytes from inside the LVINT into
VCB fields the driver later treats as file counts, directory counts and an
implementation identifier. UdfUpdateVcbPhase0 does the companion thing on
a different structure: it derives the VAT entry count as
(VatStreamSize - Vat->Length) >> 2. If the on-disc Vat->Length exceeds
the actual VAT stream size — both values live in attacker-controlled
metadata — the unsigned subtract underflows to a huge VatEntries, and
that count later drives table-walking loops that walk off the end of the VAT
mapping into whatever pool neighbour sits there. The two channels are
independent: the same image can stage a hostile LVINT, a hostile VAT, or
both, and a single mount triggers whichever the attacker staged.
The LVINT side is an info-leak: fixed-offset reads off a wrapped pointer return attacker bytes into VCB fields the driver later consults. The VAT side is a pool over-read driven by the underflowed entry count. The trigger — mount an ISO — is unprivileged, and both effects are deterministic, not racy. A reliable kernel bugcheck is the floor; whether the corrupted VCB state converts to something worse depends on what the attacker can drive through it afterward.
The patch
Every LVINT and VAT entry point is patched to compute the derived value in 64-bit, to validate it against the buffer that carries the structure, and to reject the descriptor at the boundary when either check fails:
/* UdfMountVolume / UdfVerifyVolume - patched, LVINT parse */
if (lvid->LengthOfImplementationUse < 0x2e) /* lvid+0x4c; the impl-use
* header itself must fit */
return STATUS_DISK_CORRUPT_ERROR;
UINT64 bytes = (UINT64)lvid->NumberOfPartitions << 3; /* lvid+0x48, widened to 64-bit:
* (N * 2 + 0x14) << 2 == N << 3 + 0x50 */
if (bytes > 0xffffffff || bytes + 0x50 < 0x50)
return STATUS_DISK_CORRUPT_ERROR;
UINT32 total = lvid->LengthOfImplementationUse
+ (UINT32)bytes + 0x50;
if (total < (UINT32)bytes + 0x50 ||
total > sector_aligned(Vcb->SectorSize)) /* fits the carrying buffer? */
return STATUS_DISK_CORRUPT_ERROR;
/* only after these guards is the partition pointer computed and the
fixed-offset read of part->NumberOfFiles / part->NumberOfDirs /
part->ImplId0 / part->ImplId1 permitted */
/* UdfUpdateVcbPhase0 - patched, VAT parse */
if (Vat->Length + 4 > Vcb->VatScb->FileSize) /* Vat+0; length must fit
* the stream before subtract */
continue; /* reject this descriptor */
Vcb->VatOffset = Vat->Length; /* Vcb+0x1d8 */
Vcb->VatEntries = (Vcb->VatScb->FileSize - Vat->Length) >> 2;
/* Vcb+0x1dc, now safe */
Failure returns STATUS_DISK_CORRUPT_ERROR (0xC0000032). Every value
derived from on-disc data through arithmetic is now computed at width-correct
precision, validated against the buffer that carries the structure, and
rejected at the boundary when either check fails. Subtracts used to compute
sizes require length <= capacity first; multiplies require 64-bit
intermediates and an overflow check.
Attack path
flowchart TD
A["attacker crafts a UDF image with a hostile LVINT NumberOfPartitions or VAT Length"] --> B["user mounts the ISO, UdfMountVolume or UdfVerifyVolume parses the LVINT"]
B --> C["partition-field pointer via a 32-bit multiply wraps inside the buffer"]
C --> D["coarse pointer-ordering check passes because the wrap stays in-buffer, or UdfUpdateVcbPhase0 stream-size minus VAT Length underflows"]
D --> E["fixed-offset reads return attacker bytes into VCB fields, or later VAT loop walks off the table"]
E --> F["pool OOB read used as info-leak or to drive further corruption"]
A pointer derived from an on-disc count through a multiply has to be computed
in 64-bit and bounds-checked against the buffer that carries the structure.
The wrap here moves the pointer inside the buffer — so a coarse
part < buffer_end check still passes — while the real arithmetic intent
placed it far beyond, exactly the shape a 32-bit multiply on an attacker
count produces. The companion shape, in the same code drop, is
(capacity - length) without length <= capacity first: an unsigned
subtract used to compute a size underflows to a huge value that then drives a
later loop. Optical and filesystem-image parsers — UDF, ISO, FAT, NTFS-on-disk —
repeat this pattern, and the recurring bug is a count from the header used to
compute a pointer or a loop bound without a width-correct multiply and a
buffer-bound check.