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

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

sys/kern/sys_pipe.c:

*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_create leaks sizeof(struct pipe) + PIPE_SIZE bytes of kernel_map KVA permanently and corrupts open_count. Self-amplifying under kernel_map pressure (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, then pipe(2) repeatedly β€” the second pipespace failure 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.

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

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)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-0031 Β· 12 files
FileTypeDescriptionSize
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
README.md readme human reproduce doc
↓ download 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.

VERDICT.md verdict full narrative: latent bug confirmed, reachability analysis, fix validation
↓ download raw

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)

Fix verification

not_testable
baseline no→ patch + rebuild →patched clean

not_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).
↓ fix.diffDragonFly 6.5-DEVELOPMENT #1: Sun Jul 12 22:43:48 UTC 2026 (sha256 635a182de371ae97cf645669811dbd8d3cdf57f46f3580570edd2d2cf385a0db)

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.