# DF-0055 — VERDICT

**Verdict: REPRODUCED** (deterministic kernel panic / local DoS; root-only trigger).

3/3 fresh-VM runs panicked in the exact cited path (`udev_dev_read →
udev_event_externalize → prop_object_release`). Two distinct panic
signatures (double-free caught by the malloc layer; refcount-underflow
caught by the proplib `_PROP_ASSERT`) — both are direct consequences of
the same use-after-free of the shared udev event dictionary.

---

## Mechanism (every hop cited `path:line`)

The udev event queue (`udev_evq`, a `TAILQ`) replicates every event to
**all** openers via a per-softc *marker* node spliced into the shared
queue. Each reader walks its own marker forward; an event is reaped only
once **every** reader's marker has advanced past it.

1. **Event enqueue** — `udev_event_insert()` `sys/kern/kern_udev.c:501`
   `prop_dictionary_copy()`s the dict (refcount = 1) and stores it in
   `ev->ev.ev_dict` (`:512`), then `TAILQ_INSERT_TAIL(&udev_evq, ev, link)`
   (`:516`).

2. **Reader read path** — `udev_dev_read()` `:811` finds the next event
   after the reader's marker, skipping `NULL`-dict nodes (`:838-840`), and
   calls `udev_event_externalize(ev)` (`:842`).

3. **The bug** — `udev_event_externalize()` `:548`:

   ```c
   :566  prop_dictionary_set(dict, "evdict", ev->ev.ev_dict)
              -> prop_object_retain(ev_dict)        /* refcount 1 -> 2 */
   :571  prop_object_release(ev->ev.ev_dict);       /* refcount 2 -> 1 */
   :575  prop_object_release(dict);   /* temp dict: releases its "evdict"
                                         child -> refcount 1 -> 0 -> FREED */
   ```

   After **reader 1** finishes, `ev->ev.ev_dict` is a **dangling, non-NULL**
   pointer and the event is **still on `udev_evq`**. The field is never
   `NULL`ed.

4. **Why the event is not reaped between readers** —
   `udev_clean_events_locked()` `:535` reaps from `TAILQ_FIRST` while
   `ev->ev.ev_dict != NULL` (`:540`). The loop stops at the first
   `NULL`-dict node. The trailing reader's **marker** (a `struct
   udev_event_kernel` whose `ev_dict` is `NULL`, inserted at `:829`)
   sits ahead of the just-read event in the other reader's view, so the
   clean loop halts at that marker and leaves the dangling event in
   place. Concretely: reader 1 cannot move reader 2's marker, so the
   event is provably still queued when reader 2 next reads.

5. **The UAF** — reader 2's `udev_dev_read()` finds the same event
   (dangling non-NULL `ev_dict` passes the `:839` skip loop and the
   `:540` predicate), calls `udev_event_externalize()` again, which at
   `:566` passes the **freed** pointer to `prop_dictionary_set()` ->
   `prop_object_retain(freed)` (`prop_object.c:992`,
   `atomic_inc_32_nv(&po->po_refcnt)` — a write to freed memory). The
   subsequent `prop_object_release()` at `:571` then drives the stale
   refcount to 0 and calls `_prop_dictionary_free()` -> `_kfree()` on
   memory the allocator already considers free.

This is **deterministic**, not a tight race: `udev_lk` serializes the two
reads, and reader 2's marker is *guaranteed* to still precede the event
because only reader 2 can advance its own marker.

## Evidence

See `panic.txt` for all three serial-console panic dumps. The decisive
stack frames in every run are:

```
udev_dev_read()           <- the reader's externalize call site (kern_udev.c:842)
prop_object_release()     <- :571 release of ev->ev.ev_dict
_prop_dictionary_free()
_kfree() -> chunk_mark_free()  <- "memory chunk ... is already free!"  (RUN 1)
   -- or --
prop_object_release(): _PROP_ASSERT(ocnt != 0) at prop_object.c:1085    (RUNs 2,3)
```

Both signatures name `udev_dev_read` as the entry point — i.e. the panic
is **this bug**, not an unrelated one.

## Privilege / threat model (matches finding)

- `/dev/udev` is `0600 root:wheel` (`kern_udev.c:1039-1041`); `maxx`
  (uid 1001, not in wheel) gets `Permission denied` and cannot trigger
  this. Confirmed on the guest.
- This is a **root/wheel-local** trigger. Realistic impact ceiling:
  **reliable kernel panic (local DoS)**.
- CVSS 3.1: `AV:L/AC:L/PR:H/UI:N/S:U/C:N/I:N/A:H` (Medium), matching the
  finding.

## Exploit chain assessment (memory-corruption class)

The primitive is a **UAF + double-free** on a kernel `prop_dictionary`
object, with the freed pointer immediately handed to
`prop_object_retain`/`prop_object_release`, which dereference
`po->po_refcnt` and `po->po_type` from the freed memory. On a
non-INVARIANTS kernel the proplib object (`struct _prop_dictionary`,
allocated via `prop_dictionary_create` → `kmalloc`) lives in a general
`kmalloc` bucket and could in principle be reclaimed with a
victim object containing attacker-controlled bytes; the subsequent
`pot_free`/`pot_lock` indirect calls through `po->po_type` would then be
hijackable function-pointer targets.

**However**, two facts bound the realistic ceiling at DoS for *this*
finding, and I did not develop a full LPE chain:

1. The trigger already requires **root**. A root attacker can already
   `kldload` arbitrary code, write `/dev/mem` (when securelevel permits),
   or reboot the box — so "root → kernel code execution" is not a
   meaningful privilege-escalation boundary, and the finding correctly
   rates the impact as DoS.
2. The demonstrated panics (double-free / refcount-underflow) fire on
   the **very first** event the second reader touches, before any
   attacker has a chance to reclaim the freed object — so without first
   defeating the allocator's double-free detection (or racing the
   reclamation before `prop_object_release` re-derives the type), the
   bug expresses as a panic, not a controlled write.

A determined attacker who could arrange a reclaimed victim object in the
same slab between the two reads (the `udev_lk` serialization leaves a
window while reader 1 holds the lock releasing the dict and reader 2
blocks acquiring it) *might* convert this to a controlled kernel write,
but the root prerequisite makes the effort disproportionate to the gain.
The honest, defensible classification is **reliable local kernel DoS
from root**, exactly as filed.

## PoC changes (vs. the initial seeded source)

The seeded `udev_uaf.c` was a sketch: it opened two fds and read in a
busy loop with `usleep`, but (a) never generated any device events, so
the readers would block forever on an empty queue, and (b) put both fds
in one process — which works (each open gets its own softc/marker) but
makes the deterministic nature non-obvious. I rewrote it to:

- `fork()` two reader children, each `open("/dev/udev")` (own
  softc/marker) and block in `read()` — auto-initiating both markers
  at `TAILQ_HEAD` before any event exists.
- Parent waits for both to be ready, then generates a stream of udev
  events via `ifconfig tap<N> create` / `destroy` (each `make_dev`/
  `destroy_dev` on `tap_ops` calls `udev_event_attach`/`_detach` →
  `udev_event_insert`; confirmed at `sys/net/tap/if_tap.c:289,491` and
  `sys/vfs/devfs/devfs_core.c:1428,1451`).
- The deterministic UAF fires on the **first** event the second reader
  reaches; 80 iterations guarantee it.

One compile fix: a stray `/* temp */` inside the header block comment
prematurely closed the outer comment (fixed to `(temp dict)`).

Build: `cc -O2 -o udev_uaf udev_uaf.c` (clean, no deps).
Run: `./udev_uaf` as root on a disposable VM.

## Recommended fix

Authored in `fix.diff` (standalone, `git apply`-able, verified with
`git apply --check`). **Matches** the finding markdown's proposal:

1. In `udev_clean_events_locked()` (`:535`), add
   `prop_object_release(ev->ev.ev_dict); ev->ev.ev_dict = NULL;` before
   the existing `TAILQ_REMOVE`/`objcache_put` — so the shared dict is
   released **exactly once**, when the event is reaped (after all readers
   have advanced past it).
2. Remove the `prop_object_release(ev->ev.ev_dict)` at
   `udev_event_externalize():571`, so each reader merely **borrows** the
   dict (the temp `dict`'s set/release is net-zero on `ev_dict`).

Net effect on the correct path: dict refcount is 1 (held by the event)
for the life of the event; each reader's externalize momentarily bumps
it to 2 and back to 1; the final reader's clean reap drops it 1→0 and
frees it. No dangling pointer, no double-free.
