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

ELF ABI-note descriptor read out of bounds (note_overflow ignores n_descsz)

Field Value
ID DF-0020
Status new
Severity Low
CVSS 3.1 CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:U/C:L/I:N/A:L
CWE CWE-125 Out-of-bounds Read
File sys/kern/imgact_elf.c
Lines 1700-1707, 1780, 1866-1874
Area kern
Confidence likely
Discovered 2026-06-29
Reported pending

Summary

note_overflow() validates that a note's n_namesz fits in the remaining PT_NOTE segment but never validates n_descsz. A crafted, truncated .note.ABI-tag that matches the DragonFly brandnote (n_namesz=10, n_descsz=4, n_type=1, vendor "DragonFly") β€” but whose segment is too short to actually hold the 4-byte descriptor β€” passes note_overflow, matches, and causes bsd_trans_osrel() to dereference the descriptor at note + sizeof(Elf_Note) + roundup2(n_namesz, 4) = note + 24, 2–6 bytes past the end of the segment buffer. This is a kernel out-of-bounds read on a file supplied by any local user via execve(2).

Root cause

sys/kern/imgact_elf.c:1700-1707 β€” note_overflow:

static boolean_t
note_overflow(const Elf_Note *note, size_t maxsize)
{
    if (sizeof(*note) > maxsize)
        return TRUE;
    if (note->n_namesz > maxsize - sizeof(*note))   /* checks namesz only */
        return TRUE;
    return FALSE;                                    /* n_descsz never checked */
}

The note walk calls note_overflow(note, note_end - note) (:1780-1781). The match (:1786-1790) requires n_descsz == checknote->hdr.n_descsz (4 for the brandnote); on match, bsd_trans_osrel runs (:1791-1794):

static boolean_t
__elfN(bsd_trans_osrel)(const Elf_Note *note, int32_t *osrel)
{
    uintptr_t p;
    p = (uintptr_t)(note + 1);                       /* +12 (sizeof Elf_Note) */
    p += roundup2(note->n_namesz, sizeof(Elf32_Addr)); /* +12 (roundup2(10,4)) */
    *osrel = *(const int32_t *)(p);                  /* :1872  reads note+24 */
    return (TRUE);
}

For a segment of p_filesz=22 (12-byte Elf_Note + 10-byte name, no descriptor): note_overflow(note, 22) returns FALSE (12<=22, 10<=10), the brandnote matches, and the descriptor read at note+24 lands past the 22-byte segment buffer.

  • In the limited_to_first_page path, the buffer is image_header (PAGE_SIZE); placing the note near the page end makes the read escape the page.
  • In the cross-page path, the buffer is kmalloc(notesz) (the segment size); the read escapes the allocation.

Threat model & preconditions

  • Attacker position: any local unprivileged user. The image activator runs for every local execve(2), including of attacker-owned files; no privilege required and the binary need not be loadable (the header/note check runs before vmspace setup).
  • Privileges gained or impact: kernel OOB read of ≀4 bytes adjacent to the note buffer. The read value is stored in p_osrel, which is not exposed to userspace β€” so this is not a confirmed info leak. When the OOB read straddles an unmapped page boundary, the kernel faults β†’ panic (local DoS).
  • Required config or capabilities: none; default kernel.
  • Reachability: execve(2) of a crafted ELF with a truncated PT_NOTE .note.ABI-tag.

Proof of concept

PoC source: findings/poc/DF-0020/elf_note_oob.py

Emits a minimal ELF with one PT_NOTE whose brandnote header claims a 4-byte descriptor but whose segment truncates it, placed near the first-page boundary.

Build & run (unprivileged)

python3 findings/poc/DF-0020/elf_note_oob.py -o /tmp/oob_elf
chmod +x /tmp/oob_elf
/tmp/oob_elf

Expected output

Either nothing visible (OOB bytes land in mapped adjacent memory, value to p_osrel, not exposed) or β€” when the OOB read straddles an unmapped page β€” a kernel page-fault panic (local DoS).

Impact

Kernel OOB read on attacker-controlled input; the value is not exposed, so the realistic impact is a local DoS (panic on a faulting read) rather than an info leak. Rated Low. Defense-in-depth: an attacker controlling adjacent kernel memory (via a separate primitive) could combine, but standalone this is a robustness/correctness fix.

Validate n_descsz in note_overflow:

--- a/sys/kern/imgact_elf.c
+++ b/sys/kern/imgact_elf.c
@@ -1699,11 +1699,16 @@ static boolean_t
 note_overflow(const Elf_Note *note, size_t maxsize)
 {
+   size_t avail;
+
    if (sizeof(*note) > maxsize)
        return TRUE;
-   if (note->n_namesz > maxsize - sizeof(*note))
+   avail = maxsize - sizeof(*note);
+   if (note->n_namesz > avail)
+       return TRUE;
+   /* Ensure the claimed descriptor also fits within the segment. */
+   avail -= roundup2(note->n_namesz, sizeof(Elf32_Addr));
+   if (note->n_descsz > avail)
        return TRUE;
    return FALSE;
 }

References

Timeline

  • 2026-06-29 Discovered during automated file-by-file audit of sys/kern/imgact_elf.c.
  • pending Reported to DragonFlyBSD security contact.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-0020 Β· 16 files
FileTypeDescriptionSize
elf_note_oob.c trigger-source minimal C generator for the crafted ELF (rewritten from elf_note_oob.py because the guest has no python3) 6.2 KB view raw
elf_note_oob.py trigger-source original Python seed PoC (kept for reference) 3.7 KB view raw
build.sh build-script cc -O2 -Wall -o elf_note_oob elf_note_oob.c 179 B view raw
run.sh run-script craft ELF + chmod + execve 999 B view raw
VERDICT.md verdict full narrative: mechanism, why EI_OSABI=200, exploit chain (none), fix, fix validation 11.0 KB ↓ raw
fix.diff suggested-fix git-apply-able fix: validate n_descsz in note_overflow via underflow-proof need=a+b check 815 B view raw
build.log build-log final successful build, full output 22 B view raw
run.log run-log baseline #0 decisive run, full output 274 B view raw
run.2.log run-log baseline 5-run stress (OOB silent each time) 723 B view raw
fix_build.log build-log single-fix kernel build output (make -j6 nativekernel) 5.6 MB ↓ download
fix_run.log run-log patched #1 decisive run, full output 290 B view raw
env.txt environment uname, cc version, sysctl kern.elf64.fallback_brand 1.5 KB view raw
manifest.json manifest this file 3.3 KB view raw
README.md readme human reproduce doc 4.1 KB ↓ raw
../fix_build_combined.log build-log Combined 41-finding kernel build (rc=0, -Werror clean) 5.6 MB ↓ download
../fix_build_summary.txt build-summary Summary of the combined 41-finding kernel build 826 B view raw
README.md readme human reproduce doc
↓ download raw

DF-0020 β€” PoC

elf_note_oob.c (C; the seed was elf_note_oob.py β€” rewritten in C because the DragonFly guest has no python3) crafts a minimal ELF64 whose truncated .note.ABI-tag triggers a kernel OOB read in bsd_trans_osrel().

The bug

note_overflow() (sys/kern/imgact_elf.c:1700-1707) validates that a note's n_namesz fits in the remaining PT_NOTE segment but never validates n_descsz. A crafted PT_NOTE that matches the DragonFly brandnote (n_namesz=10, n_descsz=4, n_type=1, vendor "DragonFly") but is truncated (p_filesz=22, no descriptor) passes note_overflow (12<=22, 10<=10), matches, and bsd_trans_osrel() reads the 4-byte descriptor at note + sizeof(Elf_Note) + roundup2(n_namesz, 4) == note+24 β€” past the 22-byte segment, and with noteloc=4072, past the 4096-byte image_header page entirely. OOB read of adjacent kernel memory.

Impact: kernel OOB read of ≀4 bytes; value goes to p_osrel (not exposed to userspace) β†’ no info leak, no escalation. Realistic ceiling is a silent OOB read on every local execve (robustness / defense-in-depth) or, if the OOB straddles an unmapped page, a local DoS via page-fault panic.

Why EI_OSABI = 200

The PoC sets e_ident[EI_OSABI] = 200 so the brand can ONLY be selected via the PT_NOTE match path (loop 1 in get_brandinfo, sys/kern/imgact_elf.c:550-561). With EI_OSABI=0 (the seed value), loop 2's hdr->e_ident[EI_OSABI] == bi->brand match would select the DragonFly brand regardless of whether the note check passed, masking the bug. With EI_OSABI=200 and kern.elf64.fallback_brand=-1, the unfixed kernel accepts the binary (note match + OOB), while the fixed kernel rejects it with ENOEXEC β€” a clean before/after contrast.

Why noteloc = 4072 (not 4074)

The seed Python PoC used noteloc = 4096 - 22 = 4074, but 4074 % 4 = 2, and the note walk breaks immediately on !aligned(note, Elf32_Addr) (sys/kern/imgact_elf.c:1778). With noteloc=4072 (4-aligned), the walk proceeds past the alignment check, the truncated note matches, and bsd_trans_osrel performs the OOB read.

Build (unprivileged)

cc -O2 -Wall -o elf_note_oob elf_note_oob.c

(or just ./build.sh)

Run (unprivileged)

./run.sh
# equivalent to:
./elf_note_oob /tmp/df0020_oob_elf
chmod +x /tmp/df0020_oob_elf
/tmp/df0020_oob_elf

Expected output

Bug present (unpatched #0 kernel)

execve succeeds: the crafted binary is loaded via the OOB brand match. Since the binary has no PT_LOAD, control jumps to e_entry=0 and the new process dies with SIGSEGV:

--- attempting execve ---
Segmentation fault (core dumped)            # execve succeeded

The shell does NOT print "Exec format error". (5/5 reproducible; OOB read happens silently each time β€” adjacent lwbuf-pool page is mapped.)

Fixed kernel (this fix.diff applied)

execve fails with ENOEXEC. The shell prints:

--- attempting execve ---
./run.sh: /tmp/df0020_oob_elf: Exec format error

No SIGSEGV, no OOB read. (3/3 reproducible.)

Files

File Purpose
elf_note_oob.c minimal C generator for the crafted ELF
elf_note_oob.py original Python seed (kept for reference)
build.sh exact build command (cc -O2 -Wall ...)
run.sh exact run invocation (craft + chmod + exec)
VERDICT.md full narrative (mechanism, fix, validation)
fix.diff git apply-able fix to sys/kern/imgact_elf.c
build.log full final build output
run.log baseline (#0) decisive run, full output
run.2.log baseline 5-run stress (OOB silent each time)
fix_build.log full single-fix kernel build output
fix_run.log patched (#1) decisive run, full output
env.txt guest environment (uname, cc, sysctl)
manifest.json machine-readable catalog
VERDICT.md verdict full narrative: mechanism, why EI_OSABI=200, exploit chain (none), fix, fix validation
↓ download raw

DF-0020 β€” ELF ABI-note descriptor read out of bounds (note_overflow ignores n_descsz)

Verdict

REPRODUCED. The bug is real and confirmed by both source-level tracing and an empirical before/after contrast on the running kernel. The fix (fix.diff) closes the bug deterministically (validated on a built-and- booted single-fix kernel). Impact is a kernel OOB read of ≀4 bytes adjacent to a per-exec scratch buffer; the read value goes to p_osrel and is not exposed to userspace, so the realistic impact ceiling is a silent OOB read on every local execve(2) of a crafted binary β€” i.e. defense-in-depth / robustness β€” with a possible local DoS (page-fault panic) if the OOB read straddles an unmapped page boundary. No privilege escalation is derivable (no write primitive, value not exposed).

Mechanism (trigger β†’ primitive β†’ effect)

The image activator walks an ELF's PT_NOTE program header looking for the DragonFly ABI brandnote (sys/kern/imgact_elf.c:1670-1693, check_note β†’ check_PT_NOTE). For each candidate note it calls note_overflow(note, maxsize) (:1700-1707) to validate that the note header + name fit in the remaining segment:

static boolean_t
note_overflow(const Elf_Note *note, size_t maxsize)
{
    if (sizeof(*note) > maxsize)
        return TRUE;
    if (note->n_namesz > maxsize - sizeof(*note))   /* checks namesz only */
        return TRUE;
    return FALSE;                                    /* n_descsz never checked */
}

n_descsz is never validated. The note-walk match (:1786-1790) requires note->n_descsz == checknote->hdr.n_descsz (4 for the DragonFly brandnote, :131), so a truncated note whose 12-byte header claims n_descsz=4 but whose segment truncates the descriptor passes note_overflow, matches, and reaches the BN_TRANSLATE_OSREL callback bsd_trans_osrel (:1791-1794):

static boolean_t
__elfN(bsd_trans_osrel)(const Elf_Note *note, int32_t *osrel)
{
    uintptr_t p;
    p = (uintptr_t)(note + 1);                          /* +12 (sizeof Elf_Note) */
    p += roundup2(note->n_namesz, sizeof(Elf32_Addr));  /* +12 (roundup2(10,4))  */
    *osrel = *(const int32_t *)(p);                     /* :1872  reads note+24   */
    return (TRUE);
}

The dereference at note + 24 reads 4 bytes (note+24..note+28) β€” but the segment buffer ends at note + 22 (12-byte header + 10-byte name, no descriptor), so the read lands 2–6 bytes past note_end and, in this PoC's placement, past the entire 4096-byte mapped lwbuf page.

Buffer placement (this PoC)

noteloc = 4072, p_filesz = 22 β†’ endbyte = 4094 < PAGE_SIZE, so limited_to_first_page is TRUE and the note pointer resolves into imgp->image_header (a single 4096-byte page mapped by exec_map_first_page β†’ exec_map_page β†’ lwbuf, sys/kern/kern_exec.c:850-867). With note = image_header + 4072:

Address (image_header + X) Contents / Region
4072..4083 Elf_Note header (12 bytes) β€” in buffer
4084..4093 "DragonFly\0" name (10 bytes) β€” in buffer
4094..4095 (only 2 bytes left before page end)
4096..4099 OOB read β€” bsd_trans_osrel reads 4 bytes
straddling the page boundary into adjacent KVA

Why the OOB is silent on this guest

image_header is mapped by lwbuf, which on DragonFly uses a per-CPU sf_buf-style KVA window. The virtual page adjacent to the lwbuf mapping is usually another mapped page, so the 4-byte OOB read silently returns adjacent kernel memory rather than faulting. The read value lands in p_osrel (sys/kern/imgact_elf.c:867) and is never copied to userspace via auxargs, so there is no info leak as a direct consequence. Depending on guest memory layout / system load, the OOB read may instead fault on an unmapped page β†’ kernel page-fault panic (local DoS).

Why the PoC uses EI_OSABI = 200

get_brandinfo (sys/kern/imgact_elf.c:534-599) tries four selection paths in order: (1) PT_NOTE brand match, (2) EI_OSABI / OLD_EI_BRAND match, (3) interpreter-path match, (4) default fallback brand. The DragonFly brand has .brand = ELFOSABI_NONE (0) and .flags = BI_CAN_EXEC_DYN | BI_BRAND_NOTE (not MANDATORY) (sys/cpu/x86_64/misc/elf_machdep.c:58-68).

If the PoC set EI_OSABI = 0 (the original elf_note_oob.py value), loop (2) would also match the DragonFly brand regardless of whether the PT_NOTE check passed β€” masking the bug behaviorally. With EI_OSABI = 200 (an obscure value no registered brand matches) and no PT_INTERP, the only path that can select a brand is loop (1) β€” the PT_NOTE match. Combined with kern.elf64.fallback_brand = -1 (default, verified), this yields a clean, deterministic contrast:

Kernel state Brand loop (1) result execve result
Unfixed (#0) match (OOB read happens) succeeds β†’ child SIGSEGV at e_entry=0 (no PT_LOAD)
Fixed (#1, this fix) note_overflow returns TRUE fails with ENOEXEC β€” shell prints "Exec format error"

The "execve succeeds on unfixed, fails on fixed" difference is the decisive before/after evidence: the only thing that changed is whether the truncated PT_NOTE passes note_overflow β€” i.e. whether the OOB descriptor read happens.

Exploit chain

None β€” this is a read-only primitive. The leaked bytes go to p_osrel, which is not exposed to userspace through auxargs or any other copyout path. No write primitive is derivable; no privilege escalation is possible from this bug alone. The realistic impact ceiling is:

  1. Silent kernel OOB read (4 bytes) on every local execve of a crafted binary β€” defense-in-depth / robustness concern; the OOB value is attacker-influenced only via the position of the note within the segment, not its content (the descriptor bytes are read from adjacent kernel memory, which the attacker does not control).
  2. Local DoS via page-fault panic if the OOB read straddles an unmapped kernel VA page. Reproducibility of this depends on guest memory layout (lwbuf-pool adjacency) and is not deterministic.

A secondary combinator: if an attacker already has a separate primitive that places attacker-controlled bytes adjacent to the lwbuf pool, this read could be used as a controlled info-leak side channel. Standalone, no escalation.

PoC changes (vs. the seed elf_note_oob.py)

  1. Rewrote the generator in C (elf_note_oob.c) because the guest has no python3. The C version emits byte-identical bytes to the Python PoC for the same parameters and is self-contained.
  2. Changed EI_OSABI from 0 to 200 so brand selection can ONLY happen via the PT_NOTE path (see "Why the PoC uses EI_OSABI = 200" above). This turns an ambiguous silent OOB into a clean before/after contrast (execve succeeds vs ENOEXEC).
  3. Changed noteloc from 4074 to 4072 (4-byte aligned) so the aligned(note, Elf32_Addr) check at imgact_elf.c:1778 does not immediately break the note walk before the match. (4074 % 4 = 2, which would silently break the walk and make the PoC ineffective.)
  4. Added build.sh / run.sh repro scripts and full untrimmed logs (build.log, run.log, run.2.log, fix_build.log, fix_run.log, env.txt).

Fix

fix.diff (this folder) β€” sys/kern/imgact_elf.c:1700 note_overflow. Add an n_descsz-aware bounds check that avoids underflow:

static boolean_t
note_overflow(const Elf_Note *note, size_t maxsize)
{
    size_t avail, need;

    if (sizeof(*note) > maxsize)
        return TRUE;
    avail = maxsize - sizeof(*note);
    if (note->n_namesz > avail)
        return TRUE;
    /*
     * The descriptor (and rounded name) must also fit inside the
     * remaining segment, otherwise a caller that dereferences the
     * descriptor (e.g. bsd_trans_osrel()) would read past the end of
     * the validated note buffer.
     */
    need = roundup2(note->n_namesz, sizeof(Elf32_Addr)) +
        roundup2(note->n_descsz, sizeof(Elf32_Addr));
    if (need > avail)
        return TRUE;
    return FALSE;
}

need is computed by addition (no underflow) and compared against avail, which is the post-header bytes remaining. For the PoC's truncated note (n_namesz=10, n_descsz=4, maxsize=22): need = roundup2(10,4) + roundup2(4,4) = 12 + 4 = 16 > avail=10 β‡’ returns TRUE, breaking the walk before the match β€” no OOB.

This supersedes the finding markdown's proposed fix. The markdown's version used avail -= roundup2(note->n_namesz, sizeof(Elf32_Addr)) which, when roundup2(n_namesz,4) > avail (true for n_namesz=10, avail=10: rounded is 12), underflows the size_t to a huge value and the subsequent n_descsz > avail check silently passes β€” i.e. the markdown's proposed fix would NOT close the bug for this PoC. The first single-fix kernel I built with that shape confirmed this: the PoC still triggered the OOB (Segmentation fault, execve succeeded). The corrected need = a + b; need > avail form is underflow-proof and was re-validated from scratch on a freshly-built #1 kernel.

Fix validation (Phase 8)

Built a single-fix kernel with make -j6 nativekernel KERNCONF=X86_64_GENERIC from the with-src snapshot + this fix.diff applied. Booted it as /boot/kernel/kernel (the bare name the loader boots). Confirmed kern.version = DragonFly 6.5-DEVELOPMENT #1: Sun Jul 12 16:48:55 UTC 2026 (build timestamp today, #1 vs the unpatched #0).

Ran the same PoC on both kernels:

Kernel PoC execve result OOB?
#0 unpatched baseline succeeds; child SIGSEGV at e_entry=0 YES
#1 patched (this fix) fails: sh: /tmp/df0020_oob_elf: Exec format error (ENOEXEC) NO

5/5 baseline runs succeeded (OOB silent each time, no panic); 3/3 patched runs failed cleanly with ENOEXEC. Deterministic before/after.

Kernel references (confirmed during verification)

Fix verification

fixed
baseline reproduced→ patch + rebuild →patched clean

VALIDATED. PoC on unpatched #0: execve SUCCEEDS (OOB brand match), 5/5 runs. On single-fix #1: execve REJECTED with ENOEXEC, 3/3 runs. The finding markdown's proposed fix shape (avail -= roundup2) was tested first and did NOT close the bug (size_t underflow); the corrected need=a+b form is validated.

baseline #0: Segmentation fault (execve succeeded via OOB), x5 runs. patched #1: Exec format error (ENOEXEC), x3 runs.
↓ fix.diffDragonFly 6.5-DEVELOPMENT #1: Sun Jul 12 16:48:55 UTC 2026 root@dfbsd:/usr/obj/usr/src/sys/X86_64_GENERIC x86_64

Confirmed kernel references

Detail

Exploit chain

none -- read-only primitive. The 4-byte OOB read returns into kernel-local p_osrel (never copied to userspace). No info leak, no write, no escalation.

Evidence (decisive lines)

baseline #0: execve SUCCEEDS via OOB brand match (child SIGSEGV at entry=0), 5/5 runs, no panic (OOB silent). patched #1: execve REJECTED with ENOEXEC, 3/3 runs, no OOB.

PoC changes

Rewrote generator in C (guest has no python3). Changed EI_OSABI from 0 to 200 so brand can ONLY be selected via PT_NOTE path. Changed noteloc to 4072 (4-aligned). Added build.sh/run.sh, VERDICT.md, fix.diff, full logs.

Verified recommended fix

Add n_descsz-aware bounds check to note_overflow at sys/kern/imgact_elf.c:1700: compute need = roundup2(n_namesz,4) + roundup2(n_descsz,4) by ADDITION and return TRUE if need > avail. SUPERSIDES finding markdown's proposed fix (avail -= roundup2 form underflows size_t for the PoC's exact parameters; empirically verified insufficient). Full git-apply-able diff in findings/poc/DF-0020/fix.diff.

Verdict

REPRODUCED. note_overflow() at sys/kern/imgact_elf.c:1700-1707 validates n_namesz against the remaining PT_NOTE segment but never n_descsz; when a truncated .note.ABI-tag matches the DragonFly brandnote, bsd_trans_osrel() dereferences (int32_t)(note + sizeof(Elf_Note) + roundup2(n_namesz,4)) == note+24 -- 2..6 bytes past note_end. Proof: with EI_OSABI=200 (brand can ONLY be selected via PT_NOTE path), the unpatched #0 kernel ACCEPTS the crafted binary (execve succeeds), 5/5 runs; the same PoC on single-fix #1 kernel is REJECTED with ENOEXEC, 3/3 runs. OOB read is silent on this guest (adjacent lwbuf-pool page mapped); panic is the realistic ceiling.