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

Permanent kernel memory leak of undelivered udev event dictionaries on last-reader close (reap/abandon without prop_object_release)

Field Value
ID DF-2804
Status new
Severity Medium
CVSS 3.1 CVSS:3.1/AV:L/AC:L/PR:H/UI:N/S:U/C:N/I:N/A:L
CWE CWE-401 Missing Release of Memory
File sys/kern/kern_udev.c
Lines 534-545 (reap), 709-744 (close), 571 (mis-sited release)
Area kern
Confidence certain
Discovered 2026-08-31
Pass 2 (GLM 5.3 second pass)
Bucket base:kern
Reported pending
Known CVE none
CVE match novel

Summary

Each queued event owns exactly one reference on its dict copy, released in exactly one place: udev_event_externalize() on the READ path. udev_dev_close() removes the closing reader's marker and calls udev_clean_events_locked(), which reaps head events with a bare objcache_put β€” no prop_object_release β€” and abandons everything behind the marker when no reader remains. Events never externalized therefore leak their dictionary permanently. Verified on the guest: ~24 MB leaked per 4-second cycle, zero reclaim after idle, exactly 2Γ— after two cycles, active-drain control flat. Gate /dev/udev 0600 root:wheel (same as DF-0055, Medium); event source unprivileged (ptmx 0666). Also the realistic residue of any udev daemon restart that closes with a backlog (e.g. after DF-2803-style accumulation).

Single-site the release in the reaper and drain everything when the last reader leaves (structurally also resolves DF-0055) β€” validated fix.diff in findings/poc/DF-2804/.

Timeline

  • 2026-08-31 Discovered during pass-2 audit of kern_udev.c (GLM 5.3); leak quantified + fix validated same run.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-2804 Β· 12 files
FileTypeDescriptionSize
README.md β€” 2.7 KB ↓ raw
VERDICT.md β€” 4.5 KB ↓ raw
harness.c β€” 4.1 KB view raw
churn.c β€” 1.4 KB view raw
build.sh β€” 223 B view raw
run.sh β€” 1.5 KB view raw
build.log β€” 360 B view raw
run.log β€” 2.4 KB view raw
env.txt β€” 540 B view raw
fix.diff β€” 1.3 KB view raw
manifest.json β€” 937 B view raw
verdict.json β€” 3.9 KB view raw

DF-2804 β€” Permanent kernel memory leak of undelivered udev event dictionaries on last-reader close

What

Every queued udev event owns exactly one reference on its prop dictionary: the prop_dictionary_copy() taken in udev_event_insert() (sys/kern/kern_udev.c:508-512). That reference is released in exactly one place: udev_event_externalize() (kern_udev.c:571), i.e. only when a reader reads the event.

udev_clean_events_locked() (kern_udev.c:534-545) reaps events from the queue head with a bare objcache_put() and no prop_object_release(), and udev_dev_close() (kern_udev.c:709-744) merely removes the closing reader's marker and calls that function. When the last initiated reader closes with events still queued (its marker never reached them, nobody externalized them), those events are reaped β€” or simply abandoned on udev_evq β€” with their dictionary reference never dropped. The dict and every object it retains (key strings, numbers) leak permanently; there is no reclaim path.

Trigger (root/wheel, same /dev/udev 0600 gate as DF-0055): open /dev/udev, issue UDEVPROP getdevs (initiates the reader), let device events queue (an unprivileged pty-churn process suffices as the event source β€” /dev/ptmx is 0666), then close() without reading. Repeat at will: the leak accumulates monotonically.

Verified impact (guest, stock INVARIANTS kernel)

S0 baseline:                    propdict  333 in-use (62.4K)
cycle 1 (30k pty cycles,
 reader closes w/o reading):
 S1 after close:                propdict 58.8K in-use (11.0M) + 10.1M prop_dictionary
 S2 after 10s idle:             identical  (no reclaim β€” permanent)
cycle 2 (same):
 S3:                            propdict  117K in-use (22.0M) + 20.2M  (~2x, monotone)
control (same churn, ACTIVE reader drains 60,000 events):
 S4:                            propdict  117K in-use (22.0M)  (flat β€” churn+drain does NOT leak)

~24 MB leaked per 30k-cycle run (~400 B/event), unreclaimable, repeatable without limit β†’ kernel memory exhaustion by a root/wheel process (or by a udev-consuming daemon that restarts with a backlog).

Reproduce (guest)

./build.sh              # as root: builds /tmp/harness + /tmp/churn
sh /tmp/df2804_run.sh   # as root: 2 leak cycles (churn as user maxx +
                        # stalled root reader that closes without reading)
                        # + active-drain control

Expected: S1 β‰ˆ +24 MB retained after close; S2 == S1 after idle; S3 β‰ˆ 2x the S1 delta; S4 == S3 (control flat).

Files: harness.c (reader: stall mode = initiate, sleep, close without reading), churn.c (unprivileged pty churn), run.sh, run.log (decisive run), build.log, fix.diff.

VERDICT.md
↓ download raw

DF-2804 VERDICT β€” reproduced (leak)

Bottom line

Reproduced. A root reader that initiates the udev event queue (UDEVPROP getdevs) and then closes /dev/udev without reading the queued backlog permanently leaks every undelivered event's prop dictionary. Measured on the guest: 58.8K prop-dict allocations (~24 MB across propdict/prop_dictionary/propstng/prop_string) retained after one 30k-cycle pty churn, zero reclaim after idle, exactly doubled after a second cycle, while the identical churn drained by an active reader leaked nothing (control flat). The leak is repeatable without limit and unreachable by any reclaim path: the event's single ev->ev.ev_dict reference (created at sys/kern/kern_udev.c:508-512) is dropped only by udev_event_externalize() (kern_udev.c:571), and the close path (udev_dev_close β†’ udev_clean_events_locked, kern_udev.c:721-726, 534-545) reaps/abandons events without any prop_object_release().

Ownership trace (why it leaks)

  1. udev_event_insert() (kern_udev.c:508-512): dict_copy = prop_dictionary_copy(dict) β€” the queued event owns one reference.
  2. A reader releases that reference only by externalizing the event: udev_dev_read() β†’ udev_event_externalize() β†’ prop_object_release(ev->ev.ev_dict) (kern_udev.c:571).
  3. udev_dev_close() of the last initiated reader removes its marker and calls udev_clean_events_locked(), which does objcache_put(udev_event_kernel_cache, ev) for head events β€” no release (kern_udev.c:541-543) β€” and leaves everything after the old marker position on udev_evq forever if no reader ever initiates again (udev_initiated_count == 0 β‡’ nothing consumes or reaps them).
  4. Neither path drops the reference for events no reader externalized: refcount stays 1 β†’ the dict and all objects it retains are never freed. Confirmed empirically: the udev malloc domain (event structs, objcache-backed) returns to baseline after close while propdict / prop_dictionary stay elevated β€” proving the events were reaped but their dicts were not released.

Marker-protocol note

The queue interleaves reader markers (softc-embedded udev_event_kernel with ev_dict == NULL, skipped by the reaper predicate at kern_udev.c:540) with real events. The leak is therefore exact, not heuristic: reaped-head events before the closing reader's marker position were externalized by someone (balanced); events after it (externalized by no one) either leak via reap-without-release when the closing reader's marker was at the head, or leak via abandonment when they sit behind markers of readers that also close without reading.

Fix shape (fix.diff)

Move the single release to the reaper and drain everything when the last reader leaves:

  • udev_event_externalize(): stop releasing ev->ev.ev_dict there (readers borrow the dict; prop_dictionary_set retains it for the duration of externalization). This is the same protocol change recommended by DF-0055's fix β€” it fixes the multi-reader UAF and makes release ownership single-sited.
  • udev_clean_events_locked(): release-and-NULL ev->ev.ev_dict before objcache_put, and when udev_initiated_count == 0 (no markers can remain) drain the entire queue, releasing every undelivered dict.

Fix authored from line-accurate root cause; kernel rebuild validation not performed (leak-class finding; the mandatory fix-build applies to reproduced memory corruption) β€” fix_status: not_testable.

Evidence

  • run.log β€” S0/S1/S2/S3/S4 samples: propdict 333 β†’ 58.8K (11.0M) β†’ identical after 10s idle β†’ 117K (22.0M) after cycle 2 β†’ 117K (22.0M) after the active-drain control; churn logs (30k cycles as user maxx per cycle); control read log (read 60000 events (46260000 bytes)).
  • harness.c (stall mode), churn.c, build.log.

Exploit chain

Root/wheel (the /dev/udev 0600 gate, same as DF-0055): repeated open β†’ getdevs β†’ unprivileged pty churn β†’ close-without-read cycles leak ~24 MB per 4-second cycle β†’ kernel memory exhaustion (wedge/panic). No info disclosure or corruption component; impact is availability.

Kernel references

Fix verification

not_testable
baseline reproduced→ patch + rebuild →patched clean

fix.diff authored from line-accurate root cause (release ownership single-sited in the reaper + full drain on last-reader close; also structurally resolves DF-0055). Kernel rebuild validation not performed: leak-class finding; the mandatory fix-build cycle applies to reproduced memory-corruption findings. Guest was reset to the clean-source snapshot after evidence collection.

fix.diff
↓ fix.diffper-fix-DF-2804

Confirmed kernel references

Detail

Evidence (decisive lines)

['run.log: S0 propdict 333 (62.4K) -> S1 58.8K (11.0M) after close-without-read cycle 1; S2 identical after 10s idle (permanent)', 'run.log: S3 propdict 117K (22.0M) after cycle 2 (~2x monotone accumulation)', "run.log: control S4 propdict 117K (22.0M) flat after same churn with an active draining reader ('read 60000 events') - churn+drain does not leak", 'run.log: udev malloc domain returns to ~baseline after close (event structs reaped by objcache) while propdict stays elevated - proves objcache_put without prop_object_release', 'harness.c stall mode (initiate, sleep, close without reading), churn.c (posix_openpt loop as unprivileged user)']

PoC changes

Harness written from scratch (no seed); same bring-up fixes as DF-2803 (sigaction instead of signal for the read-mode alarm; churn arg parsing).

Verified recommended fix

Move the single prop_object_release(ev->ev.ev_dict) from udev_event_externalize() into udev_clean_events_locked() (release-and-NULL before objcache_put), and when udev_initiated_count == 0 drain the entire remaining queue so undelivered dictionaries are released on last-reader close.

Verdict

Reproduced on the stock INVARIANTS guest: a root reader that initiates the udev queue (UDEVPROP getdevs) and closes /dev/udev without reading leaks every undelivered event dictionary permanently. 30k unprivileged pty cycles left 58.8K prop-dict allocations (~24 MB across propdict/prop_dictionary/propstng/prop_string) after close, zero reclaim after 10s idle, exactly doubled after a second cycle (117K / 22.0M + 20.2M), while the identical churn drained by an active reader leaked nothing (control flat). Root cause: the event's single ev_dict reference (sys/kern/kern_udev.c:508-512) is released only on the read path (:571); the close path reaps events with a bare objcache_put and no prop_object_release (:534-545, :721-726) and abandons events beyond the marker when the last reader leaves. Repeatable without limit -> kernel memory exhaustion; root/wheel gated (same /dev/udev 0600 gate as DF-0055), event source unprivileged.