pipe->open_count underflow on pipe_create partial failure leaks kernel KVA and pipe struct
| Field | Value |
|---|---|
| ID | DF-0031 |
| Status | new |
| Severity | Low |
| CVSS 3.1 | CVSS:3.1/AV:L/AC:H/PR:L/UI:N/S:U/C:N/I:N/A:L |
| CWE | CWE-401 Missing Release of Memory after Effective Lifetime |
| File | sys/kern/sys_pipe.c |
| Lines | 433-445, 286-290, 1272 |
| Area | kern |
| Confidence | likely |
| Discovered | 2026-06-29 |
| Reported | pending |
Summary
pipe_create sets *pipep = pipe before the pipespace() calls and only sets
open_count = 2 after both succeed. If the second pipespace() fails
(vm_map_find ENOMEM on kernel_map), pipe_create returns with
open_count still 0. kern_pipe's error path then calls pipeclose()
twice; each pipeclose does atomic_fetchadd_int(&open_count, -1) and frees
only when the old value is 1. With open_count = 0 the sequence underflows
0 β 0xFFFFFFFF β 0xFFFFFFFE, never == 1, so the pipe struct and
bufferA's KVA are leaked permanently and open_count is corrupted. This is a
self-amplifying KVA/struct leak (each open pipe holds kernel_map KVA, so
holding many pushes the system toward the failing precondition), reachable by
any unprivileged user β a local availability amplifier.
Root cause
*pipep = pipe; /* :433 pipe handed out */
if ((error = pipespace(pipe, &pipe->bufferA, pipe_size)) != 0)
return (error); /* :434-435 */
if ((error = pipespace(pipe, &pipe->bufferB, pipe_size)) != 0)
return (error); /* :437-438 open_count still 0 */
...
pipe->open_count = 2; /* :445 reached only on full success */
kern_pipe (:286-290):
if (pipe_create(&pipe)) {
pipeclose(pipe, &pipe->bufferA, &pipe->bufferB); /* :287 */
pipeclose(pipe, &pipe->bufferB, &pipe->bufferA); /* :288 */
return (ENFILE);
}
pipeclose (:1272):
if (atomic_fetchadd_int(&pipe->open_count, -1) == 1) {
/* free/cache the pipe + buffers */
}
With open_count = 0 at the first pipeclose, fetchadd returns 0
(!= 1 β no free), leaves open_count = 0xFFFFFFFF; the second pipeclose
returns 0xFFFFFFFF (!= 1), leaves 0xFFFFFFFE. Neither frees, so the pipe
struct + bufferA KVA leak, and open_count is left corrupted.
Threat model & preconditions
- Attacker position: any unprivileged local user.
- Privileges gained or impact: availability. Each failed
pipe_createleakssizeof(struct pipe)+PIPE_SIZEbytes ofkernel_mapKVA permanently and corruptsopen_count. Self-amplifying underkernel_mappressure (each held pipe consumes KVA, pushing toward the failing precondition). No confidentiality/integrity impact. - Required config or capabilities: none; default kernel.
- Reachability: hold many pipes to exhaust
kernel_map, thenpipe(2)repeatedly β the secondpipespacefailure triggers the leak.
Proof of concept
PoC source: findings/poc/DF-0031/pipe_leak.c
Build & run (unprivileged, disposable VM)
cc -o pipe_leak findings/poc/DF-0031/pipe_leak.c ./pipe_leak
Expected output
Cumulative kernel_map free-space shrinkage and pipe-zone struct growth
(vmstat -z / vmstat -m) that never reclaims, worsening the OOM. No
standalone panic.
Impact
Low β KVA/struct leak amplification, availability only. A maintainer- actionable error-path correctness fix.
Recommended fix
Set open_count = 2 before the pipespace() calls (so the pipeclose cleanup
on failure decrements cleanly to 0), or have pipe_create's failure path
free directly without relying on pipeclose's refcount gate:
--- a/sys/kern/sys_pipe.c
+++ b/sys/kern/sys_pipe.c
@@ -432,6 +432,7 @@
}
*pipep = pipe;
+ pipe->open_count = 2; /* set before pipespace() so error-path
+ pipeclose() cleanup decrements cleanly */
if ((error = pipespace(pipe, &pipe->bufferA, pipe_size)) != 0) {
return (error);
}
@@ -444,7 +445,6 @@
pipe->bufferB.atime = pipe->ctime;
pipe->bufferB.mtime = pipe->ctime;
- pipe->open_count = 2;
return (0);
(Verify the cached-pipe path at :421-424 resets open_count to 0 on
pop so the new open_count = 2 is correct there too.)
References
sys/kern/sys_pipe.c:433-445βopen_count = 2set after the failingpipespace.sys/kern/sys_pipe.c:286-290βkern_pipedouble-pipecloseerror cleanup.sys/kern/sys_pipe.c:1272βpipeclosefetchadd == 1free gate.- CWE-401 Missing Release of Memory after Effective Lifetime.
Timeline
- 2026-06-29 Discovered during automated file-by-file audit of
sys/kern/sys_pipe.c. - pending Reported to DragonFlyBSD security contact.
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-0031 Β· 12 files| File | Type | Description | Size | |
|---|---|---|---|---|
| pipe_leak.c | trigger-source | minimal trigger PoC (does not reach bug path on default config) | 2.6 KB | view raw |
| build.sh | build-script | cc -o pipe_leak pipe_leak.c | 306 B | view raw |
| run.sh | run-script | timeout 25 ./pipe_leak | 353 B | view raw |
| fix.diff | suggested-fix | move open_count=2 before pipespace() calls (git-apply-able) | 752 B | view raw |
| VERDICT.md | verdict | full narrative: latent bug confirmed, reachability analysis, fix validation | 5.5 KB | β raw |
| run.log | run-log | PoC on unpatched #0 kernel: EMFILE failures, no leak | 1.5 KB | view raw |
| fix_build.log | build-log | single-fix kernel build (nativekernel rc=0) | 5.6 MB | β download |
| fix_run.log | run-log | PoC + regression test on patched #1 kernel | 2.1 KB | view raw |
| env.txt | environment | uname, cc, fd limits, kvm_size | 317 B | view raw |
| README.md | readme | human reproduce doc | 1.4 KB | β 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-0031 β PoC
pipe_leak.c β pipe->open_count underflow on pipe_create partial failure
leaks kernel KVA + pipe struct (memory-pressure amplification DoS).
The bug
pipe_create (sys/kern/sys_pipe.c): *pipep = pipe (:433) is set before
the pipespace() calls (:434/:437), and open_count = 2 (:445) only
after both succeed. If pipespace(&bufferB) fails (vm_map_find ENOMEM on
kernel_map), pipe_create returns with open_count still 0. kern_pipe's
error path (:287-288) then calls pipeclose() twice; each pipeclose
(:1272) does atomic_fetchadd_int(&open_count, -1) == 1 to gate the free.
With open_count = 0 the sequence underflows 0 β 0xFFFFFFFF β 0xFFFFFFFE,
never == 1, so the pipe struct + bufferA's KVA are leaked permanently and
open_count is corrupted.
Reachability
Unprivileged. Each open pipe holds ~2*pipe_size of kernel_map KVA; holding
many pushes kernel_map toward exhaustion, after which new pipe_create
calls hit the second-pipespace failure and leak further β self-amplifying.
Pure availability (KVA/struct leak); no confidentiality/integrity impact.
Build & run (unprivileged, disposable VM)
cc -o pipe_leak findings/poc/DF-0031/pipe_leak.c ./pipe_leak
Expected output (bug present)
Cumulative kernel_map free-space shrinkage and pipe-zone struct growth
(observed via vmstat -z / vmstat -m) that never reclaims, worsening the
OOM. No standalone panic.
DF-0031 β VERDICT
Verdict: NOT REPRODUCED (latent code bug confirmed; PoC cannot trigger it)
The code bug is real but not reachable by an unprivileged user on the default-config guest. The PoC does not reproduce the claimed leak because its precondition (kernel_map exhaustion) is impossible given default fd limits.
The code bug (confirmed by source trace)
pipe_create (sys/kern/sys_pipe.c:414-448) sets *pipep = pipe (:433) and
calls pipespace() for bufferA (:434) and bufferB (:437) before setting
pipe->open_count = 2 (:445). The pipe is allocated with M_ZERO (:426),
so on a partial failure (bufferA succeeds, bufferB's vm_map_find returns
ENOMEM), pipe_create returns non-zero with open_count still 0.
kern_pipe's error path (sys/kern/sys_pipe.c:286-290) then calls pipeclose()
twice. pipeclose (sys/kern/sys_pipe.c:1272) gates the free on
atomic_fetchadd_int(&pipe->open_count, -1) == 1. With open_count = 0:
| call | fetchadd returns | new open_count | frees? |
|---|---|---|---|
| 1st pipeclose | 0 | 0xFFFFFFFF | no (0 != 1) |
| 2nd pipeclose | 0xFFFFFFFF | 0xFFFFFFFE | no (0xFFFFFFFF != 1) |
β the pipe struct + bufferA's KVA leak permanently; open_count is corrupted
(underflow). This is a genuine correctness bug on the failure path.
Why it is NOT reproducible here (reachability)
The failure path requires pipespace(&bufferB) to fail, i.e. vm_map_find on
kernel_map to return ENOMEM. That needs kernel_map exhaustion. On this guest:
| Fact | Value |
|---|---|
vm.kvm_size (kernel KVA) |
8,795,004,596,224 (~8.0 TB) |
vm.kvm_free |
~8,790,277,971,276 (~8.0 TB free) |
kern.maxfiles (system-wide fds) |
130,112 |
kern.maxfilesperproc |
32,528 |
| max pipes system-wide | ~65,056 (= maxfiles/2) |
| KVA per pipe | ~64 KB (2 Γ pipe_size 32 KB) |
| max KVA an unpriv user can pin via pipes | ~4 GB (0.00005 % of kvm) |
To exhaust kernel_map and trip the 2nd pipespace ENOMEM would need ~145
million pipes β impossible given fd limits. Direct measurement: exhausting
the process fd table, pipe() fails with EMFILE (errno 24, "Too many open
files") after 16,263 pipes. That is falloc() failing at
sys/kern/sys_pipe.c:292-297, not pipe_create()'s pipespace(). The bug
path is never entered.
The PoC's own HOLD = 200000 is itself impossible (> maxfiles).
Run evidence (unpatched #0 kernel)
[*] holding 32526 pipe fds to build kernel_map pressure <- hit fd limit, not kvm [*] opened=0 failures(likely underflow-leak path)=1000000 <- all EMFILE, not the bug vm.kvm_free before = 8790279712768 vm.kvm_free after = 8790279712768 <- IDENTICAL (zero leak)
So: not_reproduced, category (d) genuinely not reachable on this kernel via
the unprivileged PoC path β but not a false positive (the code bug is real
and would manifest if kernel_map were ever exhausted, e.g. by root loading huge
modules, or on a system with a much smaller KVA window).
Impact ceiling
Pure availability, and only latent: a self-amplifying KVA/struct leak if the precondition can be met. On a default-config DragonFlyBSD guest the precondition is unreachable for an unprivileged user, so the realistic impact is none. This matches the finding's Low severity (CVSS A:L).
No escalation (resource leak class, non-corruption)
Even if the leak fired, it is a resource leak (KVA + struct), not a memory-
corruption primitive β there is no write/UAF/double-free, so there is no path to
uid=0. The ceiling is DoS via KVA exhaustion, which is itself unreachable here.
Fix (authored in fix.diff, validated)
Move pipe->open_count = 2; from after the two pipespace() calls to before
them (right after *pipep = pipe;). Then on a partial failure, kern_pipe's
double-pipeclose cleanup decrements 2 β 1 β 0; the second call sees old==1
and frees/caches the pipe (and pipe_free_kmem releases bufferA's KVA). No
underflow, no leak.
The cached-pipe path is unaffected: a cached pipe already has open_count = 0
(decremented to 0 when cached in pipeclose), and the early open_count = 2
overwrites it correctly on success.
Fix validation (Phase 8)
| step | result |
|---|---|
git apply --check -p1 |
passes |
in-guest patch -p1 |
both hunks succeeded |
make -j6 nativekernel |
rc=0, no errors/warnings |
| boot | #1 (Sun Jul 12 22:43:48 UTC 2026), new BuildID 949f92ee⦠|
| regression: 5000 pipe write/read round-trips | OK (no functional regression) |
| PoC on patched kernel | same EMFILE behavior, no panic, guest up |
fix_status = not_testable: the leak precondition is unreachable on default
config, so there is no observable bad behavior on either kernel to contrast. The
fix is validated by apply + compile + boot + no-regression + source inspection of
the closed error path.
PoC changes
None to pipe_leak.c (it builds and runs as-is; it simply cannot reach the bug
path). Added: build.sh, run.sh, fix.diff, VERDICT.md, manifest.json,
full logs (run.log, fix_build.log, fix_run.log, env.txt).
kernel_refs (confirmed)
sys/kern/sys_pipe.c:433β*pipep = pipehanded out before pipespace.sys/kern/sys_pipe.c:434,437β the twopipespace()calls.sys/kern/sys_pipe.c:445βopen_count = 2set only after both succeed (the bug).sys/kern/sys_pipe.c:286-290βkern_pipedouble-pipecloseerror cleanup.sys/kern/sys_pipe.c:1272βpipeclosefetchadd == 1free gate (underflow site).
Fix verification
not_testablenot_testable: bug unreachable (kernel_map exhaustion precondition). Fix validated by apply+compile+boot+no-regression+source inspection.
baseline #0: EMFILE at falloc, kvm_free stable. patched #1: identical behavior + 5000-pipe regression PASS. No behavioral before/after (path unreachable).
Confirmed kernel references
Detail
Exploit chain
none -- non-corruption class (resource leak). Even if triggered, KVA/struct leakage, not write/UAF. Ceiling is DoS via KVA exhaustion, itself unreachable.
Evidence (decisive lines)
PoC on #0: 'opened=0 failures=1000000' (EMFILE at falloc), not the 2nd-pipespace path. vm.kvm_free before=8790279712768, after=8790279712768 (IDENTICAL). Guest up, no panic.
PoC changes
Added build.sh, run.sh, fix.diff (move open_count=2 before pipespace calls), VERDICT.md, manifest.json, full logs. No changes to pipe_leak.c.
Verified recommended fix
Move 'pipe->open_count = 2;' from after the two pipespace() calls (sys/kern/sys_pipe.c:445) to immediately after '*pipep = pipe;' (:433). Matches finding proposal. Full git-apply-able diff in findings/poc/DF-0031/fix.diff.
Verdict
NOT REPRODUCED (latent code bug confirmed; PoC cannot trigger it). pipe_create sets open_count=2 only AFTER both pipespace() calls succeed. On partial failure, open_count stays 0 (M_ZERO); cleanup underflows 0->0xFFFFFFFF. BUT the precondition (kernel_map exhaustion: 8.8 TB) is unreachable given fd limits (32K/proc, 130K system -> ~4 GB max KVA = 0.00005% of kvm). PoC hits EMFILE at falloc before reaching the bug path. vm.kvm_free identical before/after. NOT a false positive.
No comments yet.