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

fdcopy() failure in fork1() permanently leaks the child proc, nprocs, and the per-uid proc-count (system-wide fork DoS)

Field Value
ID DF-0032
Status new
Severity Medium
CVSS 3.1 CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:C/C:N/I:N/A:H
CWE CWE-401 Missing Release of Memory after Effective Lifetime; CWE-772
File sys/kern/kern_fork.c
Lines 491, 551-555, 724-732
Area kern
Confidence likely
Discovered 2026-06-29
Reported pending

Summary

When fdcopy() returns failure on the RFFDG path, fork1() branches to done:, which only releases tokens. But p2 was already kmalloc'd, placed on allproc in SIDL state (:491), given held references (reaper, uidpcpu, ucred, p_args, sigacts, textvp, textnch), and the global nprocs and per-uid chgproccnt were already charged β€” none of which is undone. fdcopy is the only fd-duplication primitive that can fail (it uses M_NULLOK at kern_descrip.c:2481-2486, unlike fdinit/fdshare). Default fork()/vfork() use RFFDG, so any unprivileged user can drive this under kernel malloc pressure: each failed fork() permanently consumes one system-wide maxproc slot and one per-uid RLIMIT_NPROC slot, and once nprocs == maxproc no process on the system (including root) can fork/ vfork/create threads β€” persisting until reboot.

Root cause

sys/kern/kern_fork.c:

proc_add_allproc(p2);                          /* :491  on allproc in SIDL  */
...
if (flags & RFFDG) {
    error = fdcopy(p1, &p2->p_fd);             /* :551  only failing fd op  */
    if (error != 0) {
        error = ENOMEM;
        goto done;                             /* :554  no teardown of p2   */
    }
    ...
}
...
done:                                           /* :724 */
    if (p2)
        lwkt_reltoken(&p2->p_token);
    lwkt_reltoken(&p1->p_token);
    if (plkgrp) { lockmgr(...LK_RELEASE); pgrel(plkgrp); }
    return (error);                            /* :732  p2 leaked */

nprocs (incremented earlier in fork1) is only ever decremented at kern_exit.c:1337, and chgproccnt only at kern_exit.c:1280 β€” both require a runnable/exiting lwp, which a SIDL orphan (no lwp, no parent linkage) never has. allproc scans skip SIDL procs (kern_proc.c), so nothing reclaims it.

Threat model & preconditions

  • Attacker position: any unprivileged local user.
  • Privileges gained or impact: permanent system-wide availability DoS. Inducing kernel malloc pressure (large mmap+touch, swap exhaustion) so the M_NULLOK kmalloc in fdcopy returns NULL, then looping fork(): each failure permanently consumes one maxproc slot + one per-uid proc slot
  • leaks struct proc/uidpcpu/ucred/sigacts and vnode/namecache refs. Once nprocs == maxproc, fork()/vfork()/thread-creation returns EAGAIN for all users (including root) until reboot. Survives the attacker's own process exit.
  • Required config or capabilities: none; default kernel. The trigger needs sustained memory pressure.
  • Reachability: fork(2)/vfork(2) (both RFFDG) under malloc pressure.

Proof of concept

PoC source: findings/poc/DF-0032/fork_leak.c

Build & run (unprivileged, disposable VM)

cc -o fork_leak findings/poc/DF-0032/fork_leak.c
./fork_leak

Expected output

Proc count climbs toward maxproc; once exhausted, fork EAGAIN for all users until reboot.

Impact

Permanent, system-wide fork/thread-creation exhaustion reachable by an unprivileged user under memory pressure β€” affects every user including root, persists across the attacker's own exit until reboot. Rated Medium (availability; the precondition is sustained memory pressure).

Make fdcopy use M_WAITOK|M_ZERO (matching fdinit), eliminating the only failure mode fork1 is unprepared to clean up:

--- a/sys/kern/kern_descrip.c
+++ b/sys/kern/kern_descrip.c
@@ -2481
-   newfdp = kmalloc(sizeof(struct filedesc),
-            M_FILEDESC, M_WAITOK | M_ZERO | M_NULLOK);
-   if (newfdp == NULL) {
-       *fpp = NULL;
-       return (-1);
-   }
+   newfdp = kmalloc(sizeof(struct filedesc),
+            M_FILEDESC, M_WAITOK | M_ZERO);

Defense-in-depth: fork1's done: label should additionally gain a full teardown for a partially-built p2 (LIST_REMOVE from allproc, crfree, refcount_release on sigacts/p_args, vrele textvp, cache_drop textnch, reaper_drop, kfree uidpcpu, kfree p2, atomic_add_int(&nprocs,-1), chgproccnt(uid,-1,0)) so a future error path added after p2 allocation cannot reintroduce the same leak.

References

Timeline

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

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-0032 Β· 18 files
FileTypeDescriptionSize
exhaust.c trigger-source working unprivileged trigger: fd-table amplification (dup2 to high fds) drives M_FILEDESC to its ks_limit, fdcopy's M_NULLOK kmalloc returns NULL, fork() returns ENOMEM, leaking nprocs/struct-proc 4.9 KB view raw
exhaust_slow.c trigger-source slow variant used for parallel kernel-state sampling that caught M_FILEDESC hitting ~176M at the failure moment 1.4 KB view raw
forktest.c auxiliary single-fork errno reporter 773 B view raw
forktest_bomb.c auxiliary root fork-bomb proving root fork capacity collapsed to ~272 (from ~3890) and root is fork-blocked 1.2 KB view raw
fork_leak.c trigger-source original reviewer PoC (mmap pressure); does NOT trigger the bug - retained for provenance 2.2 KB view raw
build.sh build-script cc commands for all PoC binaries 549 B view raw
run.sh run-script runs exhaust and prints before/after malloc-type counts 1.5 KB view raw
run.log run-log decisive untrimmed run output (prior session): leak trigger + multi-uid accumulation to 91% maxproc exhaustion + root fork-blocked, with interpretation 7.3 KB view raw
baseline_run.log run-log Phase 8 BEFORE-half: clean re-confirmation of the leak on unpatched #0 (proc 24->1440, lwp/file_desc flat), 2026-07-02 2.1 KB view raw
dmesg.txt dmesg kernel 'maxproc limit exceeded by uid 0' (+ attacker uids) messages 1.3 KB view raw
env.txt environment uname, cc version, sysctls, M_FILEDESC ks_limit derivation 1.4 KB view raw
VERDICT.md verdict full narrative: mechanism, evidence, system-wide DoS, fix rationale, Phase 8 fix-validation before/after contrast 10.4 KB ↓ raw
README.md readme human-facing build/run/expected + caveats 3.2 KB ↓ raw
fix.diff suggested-fix git-apply-able (applied clean 3/3 hunks): full p2 teardown on the fdcopy-failure path in kern_fork.c + new proc_remove_allproc() helper in kern_proc.c + decl in proc.h. VALIDATED: built+booted #1 kernel, leak gone 3.1 KB view raw
fix_build.log build-log Phase 8: full nativekernel output of the single-fix kernel (35403 lines), rc=0, kern_fork.o/kern_proc.o clean with -Werror 5.6 MB ↓ download
fix_run.log run-log Phase 8 AFTER-half: SAME exhaust trigger on patched #1 -> proc Count returns to baseline 24 (was 1440 on #0), determinism across 3 runs, root fork capacity intact (2000/2000), no maxproc-limit msgs 3.8 KB view 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-facing build/run/expected + caveats
↓ download raw

DF-0032 β€” PoC

fdcopy() failure in fork1() permanently leaks the child struct proc, the system-wide nprocs counter, and the per-uid proc-count β†’ local unprivileged system-wide fork-DoS (permanent until reboot). Severity Medium, CWE-401/772.

Status

REPRODUCED. See VERDICT.md for the full narrative and run.log for the decisive evidence.

The original fork_leak.c (mmap-pressure trigger) does not actually fire the bug β€” anonymous mmap consumes user VM, not the kernel M_FILEDESC malloc pool. exhaust.c is the working trigger (fd-table amplification).

The bug

fork1() charges nprocs++ (kern_fork.c:415) and chgproccnt++ (:421), kmalloc's p2 (:444), gives it uidpcpu/ucred/sigacts/textvp refs, and proc_add_allproc(p2) (:491) β€” all before fdcopy (:551). fdcopy is the only fd op that can fail (its struct filedesc kmalloc is M_WAITOK|M_ZERO|M_NULLOK at kern_descrip.c:2481-2486; fdinit/fdshare and the fd_files[] array are plain M_WAITOK). On failure fork1 does goto done (:552-554) and done: (:724-732) only releases tokens β€” no teardown of p2, no nprocs--, no chgproccnt--, no crfree/vrele. The SIDL orphan (no lwp, no parent β€” lwp_fork1 is at :674, after fdcopy) never reaches exit, so nprocs/chgproccnt are never decremented. Each failure permanently consumes one system-wide maxproc slot + one per-uid RLIMIT_NPROC slot.

When fdcopy actually fails

Its M_NULLOK kmalloc returns NULL when M_FILEDESC's per-type ks_limit is exceeded (kern_slaballoc.c:863-879). ks_limit = kmem_lim_size()/10 = min(physmem, KvaSize)/10 (~195 MB on a 2 GB guest). Each fork() on the RFFDG path charges M_FILEDESC for a copy of the parent's fd_files[] table β€” so growing the parent's fd table (via dup2 to high fds) amplifies the charge: ~260 children with ~15000-fd tables push M_FILEDESC to its limit, and the next fdcopy returns NULL β†’ fork() returns ENOMEM β†’ leak.

Build & run (unprivileged; disposable VM)

./build.sh        # cc -o exhaust exhaust.c  (and the other PoC binaries)
./run.sh          # runs exhaust, prints before/after malloc-type counts

Expected (bug present)

[!!!] ENOMEM from fork() -- fdcopy failure leak TRIGGERED at child 259
[*] summary: ok=259 eagain=51 enomem=705 other=0

After the run the proc (M_PROC) malloc-type Count is permanently elevated by ~705, while lwp and file_desc stay flat and ps ax|wc -l is unchanged (leaked SIDL orphans are invisible). Repeating across ~4–6 unprivileged uids exhausts maxproc and fork-DoSes the whole system (root included) until reboot.

Expected (bug fixed)

fork() no longer returns ENOMEM (the per-type limit is never reached because the failed allocations are rolled back, and/or M_FILEDESC no longer climbs because the leak is gone); proc Count returns to baseline after the run.

CAUTION

Each ./exhaust run permanently consumes ~700 system-wide maxproc slots until reboot. The guest is left ~18 % fork-exhausted after a single run. Run vm.sh reset to clean up. Do not loop across many uids on a host you are not prepared to reboot β€” that is the full DoS.

VERDICT.md verdict full narrative: mechanism, evidence, system-wide DoS, fix rationale, Phase 8 fix-validation before/after contrast
↓ download raw

DF-0032 β€” VERDICT

Verdict: REPRODUCED. Real, unprivileged, local, system-wide fork-DoS (permanent until reboot). The reviewer-written PoC's trigger was wrong (mmap pressure), but the underlying bug is real and exploitable; a corrected trigger reproduces it reliably.

The bug (confirmed line-by-line in sys/)

In fork1() the irreversible steps happen before the only failing fd op:

kern_fork.c operation undone on fdcopy failure?
:415 atomic_add_int(&nprocs, 1) NO
:421 chgproccnt(uid, 1, RLIMIT_NPROC) (per-uid) NO
:444 p2 = kmalloc(sizeof(struct proc), M_PROC, M_WAITOK\|M_ZERO) NO
:475 p2->p_uidpcpu = kmalloc(..., M_SUBPROC, ...) NO
:491 proc_add_allproc(p2) (on allproc, SIDL) NO
:509 p2->p_ucred = crhold(...) NO
:521-529 sigacts (share-ref or kmalloc) NO
:536-542 p_textvp vref / p_textnch cache_copy NO
:551 error = fdcopy(p1, &p2->p_fd); ← only failing fd op β€”
:552-554 if (error) { error = ENOMEM; goto done; } β€”
:724-732 done: releases only p_token/p1_token/pglock β€”

nprocs is decremented only at kern_exit.c:1337 and chgproccnt only at kern_exit.c:1280 β€” both require a runnable/exiting lwp, which a SIDL orphan (no lwp, no parent linkage β€” lwp_fork1 is at :674, after fdcopy) never has. allproc scans skip SIDL procs, so ps/procstat never show them.

fdcopy is the only fd op that can fail because it is the only one whose struct filedesc kmalloc uses M_NULLOK:

sys/kern/kern_descrip.c:2481   newfdp = kmalloc(sizeof(struct filedesc),
sys/kern/kern_descrip.c:2482                    M_FILEDESC, M_WAITOK|M_ZERO|M_NULLOK);
sys/kern/kern_descrip.c:2483   if (newfdp == NULL) { *fpp = NULL; return (-1); }

fdinit (:2408) and fdshare use plain M_WAITOK (cannot fail), and fdcopy's own fd_files[] array (:2504) is M_WAITOK (no M_NULLOK), so it would panic on limit exhaustion rather than return NULL. The single clean failure mode is the M_NULLOK newfdp.

When does that M_NULLOK kmalloc actually return NULL?

kmalloc returns NULL with M_NULLOK when the per-type ks_limit is exceeded (kern_slaballoc.c:863-879):

ks_limit = kmem_lim_size() * 1MB / 10          (kern_slaballoc.c:371-372)
kmem_lim_size() = min(physmem, KvaSize)/1MB    (kern_slaballoc.c:255-263)

On this 2 GB guest: ks_limit(M_FILEDESC) = ~195 MB.

The original PoC tried to induce this with mmap+touch. That does not work: anonymous mmap consumes user VM and physical pages, not the kernel M_FILEDESC malloc pool. Live measurement showed M_FILEDESC unchanged (27 KB β†’ 31 KB) across an mmap-pressure run. So the original PoC never triggers the bug β€” it only causes userland OOM.

The working trigger (fd-table amplification)

Each successful fork() on the RFFDG path calls fdcopy, which allocates a copy of the parent's fd_files[] table (kern_descrip.c:2504) under M_FILEDESC. A process that has grown its fd table large (via dup2 to high fds) therefore forces every child's fdcopy to charge M_FILEDESC for a large (~hundreds-of-KB) fd_files array. With a ~15000-entry fd table, each child costs ~700 KB of M_FILEDESC; ~260 such children push M_FILEDESC to its ~195 MB limit. At that point the next fdcopy's M_NULLOK newfdp kmalloc returns NULL β†’ fdcopy returns -1 β†’ fork1 does goto done β†’ leak.

exhaust.c does exactly this and is fully unprivileged.

Evidence (all in this folder)

run.log is the decisive record. Highlights:

$ ./exhaust
[*] grew fd table to fd=14976 (fd_files[] ~234KB per fdcopy)
[!!!] ENOMEM from fork() -- fdcopy failure leak TRIGGERED at child 259
[*] summary: ok=259 eagain=51 enomem=705 other=0

Parallel root sampling during the slow variant caught the failure moment:

[t=57] file_desc=18.0M   proc=51
[t=59] file_desc=176M    proc=261     <- M_FILEDESC hit its ~195M ks_limit

The leak is confirmed by the kernel malloc-type counters (the leaked structs are never freed, so they persist):

type baseline after one run meaning
proc (M_PROC) 25 744 +719 struct proc permanently leaked
subproc (uidpcpu) 48 1450 +1402 p_uidpcpu leaked
lwp 34 34 unchanged β†’ leak is before lwp_fork1 (:674)
file_desc 28 28 unchanged β†’ newfdp returned NULL, no filedesc made
ps ax \| wc -l 146 146 leaked SIDL orphans are invisible to ps

lwp/file_desc being flat is the fingerprint that pins the leak to the exact point the code trace predicts: fdcopy failure (:551) after p2 was put on allproc (:491) but before lwp_fork1 (:674). If the leak were anywhere else, one of those counters would move.

System-wide impact (DoS demonstrated)

nprocs is a global counter; the leaked slots reduce fork capacity for every user, including root. Because the per-uid chgproccnt is also leaked (never decremented), one unprivileged uid can permanently burn ~700–1000 system-wide maxproc slots before self-capping at its own RLIMIT_NPROC. ~4–6 unprivileged uids exhaust all of maxproc=4036.

Multi-uid staged attack (proc Count β‰ˆ global nprocs; ps ax frozen at 146):

baseline → 25   maxx→732   u1002→1.41K   u1003→2.10K   u1004→2.79K
u1005β†’3.48K   u1006β†’3.68K (+203; system nprocs check now pre-blocks fdcopy)

Then, with nprocs permanently ~3680/4036, a root fork-bomb:

$ /root/forktest_bomb
forktest_bomb: root fork() EAGAIN after 272 children (errno=35 Resource temporarily unavailable)
forktest_bomb: ROOT RESULT ok=272 eagain=3  (clean system would allow ~4036)

Root can fork only ~272 children (vs ~3890 on a clean system) β€” a ~93 % collapse β€” and is itself fork-blocked. dmesg corroborates with maxproc limit exceeded by uid 0. The leaked slots are permanent (they do not recover after the attackers exit); only a reboot clears them.

Caveats / precision

  • The original PoC (fork_leak.c) is not a valid trigger (mmap β‰  M_FILEDESC). It is retained for provenance; exhaust.c is the working trigger.
  • Full maxproc exhaustion needs ~4–6 unprivileged uids (single user is capped at ~1009 leaked slots by its own leaked per-uid chgproccnt). On any multi-user system (or for any user able to raise RLIMIT_NPROC / run from several accounts) full system-wide fork-DoS is straightforward. Even a single user permanently destroys ~18–25 % of system fork capacity and permanently fork-blocks their own uid.
  • No kernel panic occurred at any point; the failure is a clean ENOMEM leak, exactly the path cited.

Files in this folder

file purpose
exhaust.c / exhaust working trigger β€” fd-table amplification β†’ fdcopy failure β†’ leak
exhaust_slow.c slow variant for parallel kernel-state sampling
forktest.c, forktest_bomb.c prove root fork capacity collapses / root fork-blocked
fork_leak.c original reviewer PoC (mmap pressure; does not trigger)
build.sh, run.sh exact build / run commands
run.log decisive untrimmed run output + interpretation
dmesg.txt kernel maxproc limit exceeded messages (incl. uid 0)
env.txt guest uname, sysctls, ks_limit derivation
fix.diff git-apply-able fix (verified git apply --check clean)
manifest.json machine-readable artifact catalog

Fix

fix.diff adds a full teardown of the partially-built p2 on the fdcopy-failure path (reversing every acquisition from :491 back through :415/:421), plus a new symmetric proc_remove_allproc() helper in kern_proc.c (the inverse of proc_add_allproc()). This supersedes the finding markdown's primary proposal (drop M_NULLOK from fdcopy): dropping M_NULLOK would convert the leak into a panic("malloc limit exceeded") at kern_slaballoc.c:877 (worse for availability). The teardown keeps fdcopy's clean ENOMEM failure mode and makes fork1 correctly clean up after it β€” fixing the root cause and adding defense-in-depth for any future error path after p2 allocation.

Fix VALIDATION (Phase 8 β€” built + booted a single-fix kernel)

Verified on a patched kernel built and booted in the guest.

step result
fix.diff applies to /usr/src 3/3 hunks clean (kern_fork.c:550, kern_proc.c:1052, proc.h:536)
make -j6 nativekernel KERNCONF=X86_64_GENERIC rc=0, kern_fork.o + kern_proc.o compiled clean with -Werror (full log fix_build.log, 35403 lines)
install + reboot /boot/kernel/kernel sha256 f1edcb8e…79596; kern.version = 6.5-DEVELOPMENT #1 (built Thu Jul 2 12:52:04 UTC 2026)

Before/after contrast (the SAME ./exhaust trigger)

counter unpatched #0 after patched #1 after verdict
proc (M_PROC) live 1.44K (+1416 leaked) 24 (baseline) leak gone
subproc (uidpcpu) live 2.88K (+2834 leaked) 46 (baseline) leak gone
lwp live 33 (flat) 33 (flat) fingerprint intact
file_desc live 25 (flat) 25 (flat) fingerprint intact
ps ax \| wc -l 197 197 SIDL orphans no longer created
exhaust eagain 51 (per-uid cap hit by leaked chgproccnt) 0 per-uid count no longer leaked

The decisive point: on #1 the SAME trigger that leaked +1416 struct proc on #0 now leaves proc Count exactly at baseline (24) β€” every acquisition the teardown reverses (p2 kmalloc, uidpcpu, crhold, sigacts ref, textvp vref, textnch cache_copy, reaper hold, proc_add_allproc, nprocs++, chgproccnt++) is released. The "Allocs" column climbs across runs (8.68K after 3 runs) but the live count stays flat at 24 β€” the leaked objects are now freed, not orphaned.

Determinism: 3 consecutive ./exhaust runs on #1, each triggering thousands of fdcopy failures; after every run proc/subproc/lwp/file_desc return to baseline. Root fork capacity on #1: forktest 2000 fork+wait β†’ ok=2000 eagain=0 enomem=0, no maxproc limit in dmesg β€” system-wide fork capacity fully intact.

Fix verdict: fixed. Bad behavior present on #0, gone on the single-fix #1 kernel. fix.diff closes the root cause.

(fix-validation evidence: fix_build.log = full nativekernel output, fix_run.log = before/after contrast + determinism + root capacity.)

Fix verification

fixed
baseline reproduced→ patch + rebuild →patched clean

VALIDATED. fix.diff applied 3/3 hunks clean to /usr/src and built a single-fix kernel (make -j6 nativekernel rc=0, kern_fork.o/kern_proc.o clean with -Werror). On unpatched #0 the SAME ./exhaust trigger leaked +1416 struct proc (Count 24->1440, subproc 46->2880, lwp/file_desc flat). On the single-fix #1 kernel the SAME trigger leaves proc Count exactly at baseline 24 and subproc at 46 (leak gone), deterministically across 3 runs (Allocs climbs 8.68K, live stays 24); exhaust's eagain dropped 51->0 (per-uid chgproccnt no longer leaked); root forktest ok=2000/2000 with no maxproc-limit in dmesg. => fix closes the bug.

baseline #0 AFTER exhaust: proc=1.44K subproc=2.88K (leaked ~1452); patched #1 AFTER same exhaust: proc=24 subproc=46 (baseline); determinism run 2 AFTER: proc=24 subproc=46; root forktest #1: ok=2000 eagain=0; nativekernel build rc=0.
↓ fix.diffDragonFly 6.5-DEVELOPMENT #1: Thu Jul 2 12:52:04 UTC 2026 (sha256 f1edcb8ee03282c37334ef83133c479c449dcb59712a72e6012031fd66279596)

Confirmed kernel references

Detail

Exploit chain

Unprivileged permanent system-wide fork/thread-creation DoS via fdcopy() failure leaking nprocs + per-uid proc-count + struct proc/uidpcpu. Each failed fork permanently consumes one maxproc slot and one per-uid RLIMIT_NPROC slot (never decremented -- the SIDL orphan has no lwp and never reaches kern_exit.c:1280/1337). ~4-6 unprivileged uids exhaust maxproc=8132 and block ALL fork/vfork/thread creation system-wide (root included) until reboot. No memory-corruption primitive (pure resource-count leak); not a privesc chain.

Evidence (decisive lines)

BASELINE #0: exhaust -> 'ENOMEM from fork() -- fdcopy failure leak TRIGGERED at child 519', 'summary: ok=519 eagain=51 enomem=1452'. AFTER (#0): proc 24->1.44K, subproc 46->2.88K, lwp 33(flat), file_desc 25(flat), ps 197(flat). PATCHED #1 (same exhaust): 'ok=519 eagain=0 enomem=3481'. AFTER (#1): proc 24 (back to baseline), subproc 46, lwp 33, file_desc 25, ps 197; forktest 5 ok; root forktest ok=2000 eagain=0 enomem=0; dmesg has no maxproc-limit msgs. Determinism: 3 runs on #1, proc Count stays 24 each time (Allocs climbs to 8.68K, live stays flat).

PoC changes

No source changes needed (exhaust.c trigger unchanged). fix.diff (pre-existing) applied cleanly 3/3 hunks to /usr/src (kern_fork.c:550 teardown + new proc_remove_allproc() in kern_proc.c:1052 + decl in proc.h:536) and compiled clean with -Werror; no refinement required. Added baseline_run.log, fix_build.log (full nativekernel), fix_run.log, and updated VERDICT.md + manifest.json with the Phase 8 before/after contrast.

Verified recommended fix

fix.diff adds a full teardown of the partially-built p2 on the fdcopy()-failure path in fork1() (kern_fork.c:552): stopprofclock, cache_drop(textnch), vrele(textvp), sigacts refcount_release+kfree, args refcount_release+kfree, crfree(ucred) under p_spin, proc_remove_allproc(p2) [new helper in kern_proc.c -- inverse of proc_add_allproc, does LIST_REMOVE from allproc + pid domain update, no p_sibling removal since the SIDL orphan has none], reaper_drop, kfree(uidpcpu), kfree(p2), p2=NULL, atomic_add_int(&nprocs,-1), chgproccnt(-1). This supersedes the finding markdown's primary proposal (drop M_NULLOK from fdcopy) -- dropping M_NULLOK would convert the leak into a panic('malloc limit exceeded') at kern_slaballoc.c:877 (worse for availability); the teardown preserves fdcopy's clean ENOMEM and fixes the root cause. Full git-apply-able diff at findings/poc/DF-0032/fix.diff.

Verdict

REPRODUCED + FIX VALIDATED. On unpatched 6.5-DEVELOPMENT #0 the fdcopy()-failure leak in fork1() (kern_fork.c:551-554 -> goto done at :724 with no teardown) is confirmed: ./exhaust (uid 1001) grows its fd table to 14976 entries via dup2, then forks RFFDG children each charging ~234KB to M_FILEDESC; once M_FILEDESC hits its ~195MB ks_limit, fdcopy's M_NULLOK kmalloc (kern_descrip.c:2481-2486) returns NULL, fork() returns ENOMEM, and each failure permanently leaks one struct proc + nprocs + per-uid chgproccnt + uidpcpu. Baseline run: 1452 ENOMEM failures; proc Count 24->1440 (+1416 leaked), subproc 46->2880, while lwp (33) and file_desc (25) stay FLAT -- the exact fingerprint pinning the leak to fdcopy-failure after proc_add_allproc (:491) but before lwp_fork1 (:674); ps ax|wc -l stays 197 (SIDL orphans invisible). The leaked slots are permanent (survive attacker exit), exhausting system-wide maxproc and fork-DoSing all users incl. root until reboot.