# 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:

```c
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`):

```c
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:

```c
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)

- `sys/kern/imgact_elf.c:1700-1707` — `note_overflow` (does not check `n_descsz`).
- `sys/kern/imgact_elf.c:127-136` — DragonFly brandnote definition (`n_descsz = sizeof(int32_t) = 4`, `BN_TRANSLATE_OSREL`).
- `sys/kern/imgact_elf.c:1780-1781` — caller (`note_overflow(note, note_end - note)`).
- `sys/kern/imgact_elf.c:1786-1794` — match + `bsd_trans_osrel` invocation.
- `sys/kern/imgact_elf.c:1866-1875` — `bsd_trans_osrel` deref at `note + sizeof(Elf_Note) + roundup2(n_namesz, 4)`.
- `sys/kern/imgact_elf.c:534-599` — `get_brandinfo`'s 4 selection loops (why EI_OSABI=200 is needed).
- `sys/cpu/x86_64/misc/elf_machdep.c:58-68` — DragonFly brand `.brand = ELFOSABI_NONE`, `BI_BRAND_NOTE`.
- `sys/kern/kern_exec.c:850-867` — `exec_map_first_page` (image_header = single lwbuf page).
