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
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 theM_NULLOKkmallocinfdcopyreturnsNULL, then loopingfork(): each failure permanently consumes onemaxprocslot + one per-uid proc slot - leaks
struct proc/uidpcpu/ucred/sigactsand vnode/namecache refs. Oncenprocs == maxproc,fork()/vfork()/thread-creation returnsEAGAINfor 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)(bothRFFDG) 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).
Recommended fix
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
sys/kern/kern_fork.c:491,551-555,724-732β leak path.sys/kern/kern_descrip.c:2481-2486βfdcopyM_NULLOK(only failing fd op).sys/kern/kern_descrip.c:2408βfdinitusesM_WAITOK|M_ZERO(pattern to match).sys/kern/kern_exit.c:1280,1337β the onlynprocs/chgproccntdecrements.- CWE-401; CWE-772.
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)
PoC verification
Evidence pack
findings/poc/DF-0032 Β· 18 files| File | Type | Description | Size | |
|---|---|---|---|---|
| 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 |
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 β anonymousmmapconsumes user VM, not the kernelM_FILEDESCmalloc pool.exhaust.cis 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.
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.cis the working trigger. - Full
maxprocexhaustion needs ~4β6 unprivileged uids (single user is capped at ~1009 leaked slots by its own leaked per-uidchgproccnt). On any multi-user system (or for any user able to raiseRLIMIT_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
ENOMEMleak, 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
fixedVALIDATED. 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.
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.
No comments yet.