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).
Recommended fix
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)
PoC verification
Evidence pack
findings/poc/DF-2804 Β· 12 files| File | Type | Description | Size | |
|---|---|---|---|---|
| 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.
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)
udev_event_insert()(kern_udev.c:508-512):dict_copy = prop_dictionary_copy(dict)β the queued event owns one reference.- 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). udev_dev_close()of the last initiated reader removes its marker and callsudev_clean_events_locked(), which doesobjcache_put(udev_event_kernel_cache, ev)for head events β no release (kern_udev.c:541-543) β and leaves everything after the old marker position onudev_evqforever if no reader ever initiates again (udev_initiated_count == 0 β nothing consumes or reaps them).- 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
udevmalloc domain (event structs, objcache-backed) returns to baseline after close whilepropdict/prop_dictionarystay 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 releasingev->ev.ev_dictthere (readers borrow the dict;prop_dictionary_setretains 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-NULLev->ev.ev_dictbeforeobjcache_put, and whenudev_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(stallmode),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
- sys/kern/kern_udev.c:508-512 β the event's single dict reference
- sys/kern/kern_udev.c:571 β the ONLY release, on the read path
- sys/kern/kern_udev.c:534-545 β reap without release / stop at marker
- sys/kern/kern_udev.c:709-744 β close path (no walk of undelivered queue)
- sys/vfs/devfs/devfs_core.c:1428,1451 β producers (unpriv pty churn source)
Fix verification
not_testablefix.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
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.
No comments yet.