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.