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

elf_getfiles() drops an unowned reference on the checkpoint file when fdalloc() fails: struct file refcount underflow, premature fo_close/vrele/ffree, UAF, success-masking, tempfp leak

Field Value
ID DF-2826
Status new
Severity High
CVSS 3.1 CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H
CWE CWE-672 / CWE-416
File sys/kern/kern_checkpoint.c
Lines 664-668 (the owned ref at :756, dropped again :762)
Area kern
Confidence certain
Discovered 2026-08-31
Pass 2 (GLM 5.3 second pass)
Bucket memcorrupt
Reported pending
Known CVE none
CVE match novel

Summary

On the fdalloc-failure path elf_getfiles() calls fp_close(fp) β€” fdrop() β€” on the checkpoint file whose only reference belongs to sys_sys_checkpoint()'s holdfp(), which dropfp() drops again. One fdrop too many underflows the file refcount with two observed death modes and leaves error==0 (CKPT_THAW reports success for an aborted restore) plus a leaked tempfp. Gated by kern.ckptgroup (default wheel-only; verified EPERM for non-wheel; kern.ckptgroup=-1 exposes it to all local users). fdβ‰₯3: file+vnode die mid-restore and elf_loadphdrs() UAF-reads the freed struct file (vref panic). fd<3: phantom fdcache reference β†’ dangling fd-table entry once evicted (classic exploitable file-UAF: M_FILE objcache LIFO reclaim aliases a foreign file), or fdrop-on-zero-count panic at exit. The full uid=0 grooming chain was implemented but blocked by an independent restore-path defect (SIGBUS at first instruction fetch β€” documented in VERDICT.md, recommended for separate audit).

Proof of contest

VERIFIED both modes on the stock INVARIANTS kernel incl. fresh-snapshot boots (findings/poc/DF-2826/): trigger_fd3 β†’ panic: vref: bad refcnt 00000000 1 (vref←vnode_pager_reference←vm_mmap←fp_mmap←mmap_phdr); trigger_fd0 β†’ CKPT_THAW returns 0, process exits β†’ panic: fdrop: invalid f_count 0 (fdrop←closef←fdfree←exit1←sigexit). Fix (do not touch fp on this path; EBADF + dispose tempfp only) authored.

See findings/poc/DF-2826/fix.diff.

Timeline

  • 2026-08-31 Discovered during pass-2 audit of kern_checkpoint.c (GLM 5.3); both panic modes reproduced same run.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-2826 Β· 16 files
FileTypeDescriptionSize
README.md β€” 2.9 KB ↓ raw
VERDICT.md β€” 6.6 KB ↓ raw
gen.c β€” 7.4 KB view raw
stage2.c β€” 3.2 KB view raw
trigger_fd3.c β€” 654 B view raw
trigger_fd0.c β€” 795 B view raw
build.sh β€” 801 B view raw
run.sh β€” 693 B view raw
build.log β€” 1.2 KB view raw
fix_run.log β€” 1.4 KB view raw
panic.txt β€” 2.4 KB view raw
panic_fd0.txt β€” 2.1 KB view raw
baseline_fresh_vref_panic.txt β€” 2.1 KB view raw
env.txt β€” 669 B view raw
fix.diff β€” 711 B view raw
verdict.json β€” 5.7 KB view raw

DF-2826 β€” elf_getfiles() drops an unowned reference on the checkpoint file (refcount underflow β†’ UAF / panics)

  • File: sys/kern/kern_checkpoint.c
  • Bug lines: elf_getfiles() fdalloc-failure path, kern_checkpoint.c:664-668 (fp_close(fp) where fp is the caller's holdfp() reference), plus the missing error = (syscall reports success) and the leaked tempfp.
  • Gate: sys_checkpoint is gated by kern.ckptgroup (default 0 = wheel). An unprivileged user not in wheel gets EPERM before any parsing (verified: maxx (uid 1001, no wheel) β†’ EPERM). Root / wheel-group users reach the parser; hosts setting kern.ckptgroup=-1 expose it to every user.

Build (inside the guest, as root)

sh build.sh          # compiles gen, stage2 (freestanding), triggers,
                     # generates df2826.ckpt (nfiles=1, fd-steal image)
                     # and df2827.ckpt (e_phnum=0 image)

The generator must run in the same boot as the trigger: the image embeds a file handle of /etc/passwd whose fsid is boot-dependent on this HAMMER2 root (stale fh β†’ ckpt_fhtovp fails β†’ the bug path is not reached).

Run (expect: kernel panic)

cd /root/df2826
./trigger_fd3            # checkpoint image on fd 3  (fd >= 3 variant)
#  panic: vref: bad refcnt 00000000 1
#  vref <- vnode_pager_reference <- vm_mmap <- fp_mmap <- mmap_phdr

./gen df2826.ckpt stage2.bin normal 0x600000f0 1   # freshen fh
./trigger_fd0            # checkpoint image on fd 0  (fd < 3 variant)
#  panic: fdrop: invalid f_count 0
#  fdrop <- closef <- fdfree <- exit1 <- sigexit

./trigger_fd3 df2827.ckpt   # DF-2827: e_phnum == 0
#  Fatal trap 12: page fault while in kernel mode, fault VA = 0x18
#  Stopped at sys_sys_checkpoint+0x2f0: movq 0x20(%rax),%rax

Expected output (decisive lines)

fd>=3 variant β€” the stolen reference makes fdrop() run fo_close() + vrele() on the still-in-use checkpoint file inside the syscall; by the time elf_loadphdrs() maps the LOAD segments, the vnode's usecount is 0 and vnode_pager_reference()'s vref() panics:

panic: vref: bad refcnt 00000000 1
vref() at vref+0x36
vnode_pager_reference() at vnode_pager_reference+0x1d
vm_mmap() at vm_mmap+0x3a7
fp_mmap() at fp_mmap+0x14a
mmap_phdr() at mmap_phdr+0x54
Debugger("panic")

fd<3 variant β€” the stolen reference becomes a phantom per-thread fdcache reference (dropfp() returns it to the cache without dropping); the syscall reports success, the process dies on the restored context, and at exit fdfree()'s closef() fdrops the zero-count file:

panic: fdrop: invalid f_count 0
fdrop() at fdrop+0x10e
closef() at closef+0x32
fdfree() at fdfree+0x15e
exit1() at exit1+0x13f
sigexit() at sigexit+0x4c

Fix

fix.diff (validated in-guest: patched kernel built, both panics gone, CKPT_THAW returns a clean error instead).

VERDICT.md
↓ download raw

DF-2826 VERDICT β€” elf_getfiles() unowned fdrop() β†’ file/vnode lifetime corruption

Bottom line

REPRODUCED (certain). elf_getfiles() (sys/kern/kern_checkpoint.c:664-668) calls fp_close(fp) β€” i.e. fdrop(fp) (sys/kern/kern_fp.c:559-562) β€” on the checkpoint file when fdalloc() cannot allocate the descriptor requested by a restored ckpt_fileinfo. elf_getfiles() does not own any reference on fp: the reference belongs to sys_sys_checkpoint()'s holdfp() (kern_checkpoint.c:756), which is dropped again by dropfp() at kern_checkpoint.c:762. One fdrop() too many β‡’ struct file refcount underflow with two deterministic death modes, both observed on the stock guest kernel:

Variant A β€” checkpoint fd >= 3 (trigger_fd3)

  1. holdfp(td, fd, FREAD): file count = 2 (fd table + holdfp; the holdfp reference is tracked by the per-thread fdcache, mode "lent").
  2. elf_getfiles()'s close-loop (kern_checkpoint.c:611-612) runs kern_close(fd) for all fds >= 3, which drops the fd-table reference (count = 1) and detaches the fdcache entry (fclearcache detaches a lent entry without a drop β€” kern_descrip.c:246-263).
  3. The crafted cfi (valid fhandle from getfh("/etc/passwd"), cfi_index = 0x7fffffff) makes ckpt_fhtovp()+fp_vpopen() succeed, then fdalloc() fail (want >= lim, kern_descrip.c:1745) β‡’ the buggy fp_close(fp) takes the count 1 β†’ 0 β‡’ last-reference path: fo_close() = vn_close() = VOP_CLOSE + vrele(vnode), then ffree(fp) β€” the checkpoint file and its vnode reference die mid-syscall.
  4. elf_getfiles() returns error == 0 (the path never sets error) β€” CKPT_THAW reports success β€” and leaks the tempfp it had opened.
  5. ckpt_thaw_proc() continues with the freed fp: elf_loadphdrs(fp, ...) (kern_checkpoint.c:258) reads fp->f_type / fp->f_data out of the freed chunk (UAF read that happens to be intact in the objcache) and calls fp_mmap() β†’ vm_mmap() β†’ vnode_pager_reference() β†’ vref(vp) on the vnode whose usecount the premature vrele() took to 0: panic: vref: bad refcnt 00000000 1 (observed twice, incl. from a fresh with-src snapshot boot; see panic.txt, baseline_fresh_vref_panic.txt).

Variant B β€” checkpoint fd < 3 (trigger_fd0)

  1. The close-loop does not touch fds 0-2; the fdcache entry for the holdfp reference stays tied to fdnode 0 in "lent" mode.
  2. The buggy fp_close(fp) takes count 2 β†’ 1, stealing the cache-owned reference; dropfp() then returns the phantom reference to the cache (mode 2 β†’ 0, no drop; kern_descrip.c:540-552). The file is now at count 1 with two logical owners (fd table + phantom cache ref) β€” a silent zero-integrity state.
  3. The syscall returns 0 (success-masking). The restored register state (this image restores cs=0x2b/ss=0x33/rflags=0x202, rip into a mapped LOAD segment) starts executing; this particular restored image dies on SIGBUS at the first fetch (see "Anomaly" below), so the process exits.
  4. exit1() β†’ fdfree() β†’ closef(fd0) β†’ fdrop() on the phantom-owned file: panic: fdrop: invalid f_count 0 (fdrop ← closef ← fdfree ← exit1 ← sigexit; see panic_fd0.txt).

If the process had instead stayed alive, the phantom reference is dropped the moment the thread's fdcache entry is evicted (kern_descrip.c:233-234 atomic_add_int(&fp->f_count, -1)), producing a dangling fd-table entry (freed struct file still installed at fd N) β€” the textbook exploitable file-UAF primitive (reclaim via any falloc() on the same per-CPU objcache magazine β‡’ fd aliases a foreign file; close() of either descriptor prematurely frees a live victim). That stage was not driven to uid=0 in this run (see Blockers).

Threat model

  • Reach: sys_checkpoint(2 /*CKPT_THAW*/, fd, -1, 0) (syscall 467), gated by kern.ckptgroup (default 0 β†’ wheel). Verified: unprivileged maxx (uid 1001, not in wheel) gets EPERM before parsing; root triggers both panics. Any kern.ckptgroup=-1 host exposes the bug to all local users.
  • Attacker input: a crafted checkpoint image β€” full control of every field; the only requirement for the fdrop-steal is one ckpt_fileinfo whose cfi_index fails fdalloc() after a successful fhandle open.

PoC artifacts

  • gen.c β€” image generator (uses guest headers via _KERNEL_STRUCTURES so all kernel struct sizes match exactly); build.sh drives it.
  • stage2.c β€” freestanding restored-program blob (proves register/vmspace restore works and would carry the post-restore exploitation stage).
  • trigger_fd3.c / trigger_fd0.c β€” the two variants.
  • panic.txt (variant A), panic_fd0.txt (variant B), run.log β€” serial console captures.

Fix validation

  • fix.diff removes the unowned fp_close(fp), sets error = EBADF, and disposes the owned tempfp reference (also fixing the leak); it additionally rejects e_phnum == 0 (DF-2827).
  • Patched kernel built in-guest (make nativekernel KERNCONF=X86_64_GENERIC), installed, rebooted; both triggers re-run: no panic β€” see fix_run.log. The syscall now returns a clean error and the kernel stays up.

Anomaly (unresolved, does not affect this finding)

A fully valid crafted checkpoint (nfiles=0) restores registers and mappings, but the restored program reliably receives SIGBUS at the first instruction fetch of the LOAD mapping (fault VA == rip == mapping base; core shows cs/ss/rflags/rsp perfect and the mapping present). The same mmap+offset+protection from an ordinary userland program executes fine (mt.c/mt2.c/mt4.c controls). The mapping is created after vmspace_exec()/pmap_replacevm() inside the syscall; vm_fault returns KERN_PROTECTION_FAILURE (trap.c:1015 maps that to SIGBUS). This looks like an independent defect in the ckpt restore path (fresh-vmspace mmap not executable/faultable), worth a separate look; it truncated our ability to run in-process exploitation stages.

Blockers on the uid=0 chain (honest accounting)

The memory-corruption primitive is real and demonstrated (premature fo_close/vrele/ffree of a live file; UAF reads of the freed chunk inside the same syscall). Driving it to uid=0 requires the restored program to survive long enough to (a) evict the phantom fdcache ref on demand, (b) reclaim the freed struct file, and (c) groom a victim β€” step (a)/(b) were implemented in stage2.c but never executed because of the SIGBUS anomaly above (the restored process dies at its first instruction). With the anomaly understood/fixed, the chain is standard M_FILE objcache reclamation (per-CPU LIFO) β€” no hardening on this guest blocks it (no SMAP/SMEP/KASLR).

Fix verification

fixed
baseline reproduced→ patch + rebuild →patched clean

fix.diff applied to /usr/src/sys/kern/kern_checkpoint.c, kernel rebuilt in-guest (make nativekernel, 4299 compile units, RC=0) and installed. On the patched kernel both DF-2826 variants run without any kernel panic (fd>=3: no vref panic; fd<3: no fdrop panic; guest stays up - see fix_run.log) and DF-2827 returns a clean EINVAL instead of the fatal trap.

findings/poc/DF-2826/fix.diff; findings/poc/DF-2826/fix_run.log (baseline vs patched); findings/poc/DF-2826/VERDICT.md
↓ fix.diffDragonFly dfbsd 6.5-DEVELOPMENT DragonFly 6.5-DEVELOPMENT #1: Tue Sep 1 20:00:55 UTC 2026 root@dfbsd:/usr/obj/usr/src/sys/X86_64_GENERIC x86_64

Confirmed kernel references

Detail

Exploit chain

crafted ckpt image (valid notes/vminfo/siginfo + one normal ckpt_fileinfo with valid fhandle and cfi_index=0x7fffffff) -> sys_checkpoint(CKPT_THAW, fd, -1, 0) -> elf_getfiles: fhtovp+fp_vpopen succeed, fdalloc fails (want >= lim) -> fp_close(fp) steals holdfp's reference -> [fd>=3] file+vnode die mid-syscall; elf_loadphdrs UAF-reads the freed fp and panics in vref -> [fd<3] dropfp returns a phantom ref to the thread fdcache; CKPT_THAW returns 0 (success-masking); process exits -> fdfree/closef -> fdrop on zero-count file -> panic. Full uid=0 chain (evict phantom -> falloc() reclaims freed M_FILE chunk -> fd aliases foreign file -> premature-free a live victim) is implemented in stage2.c but blocked from executing by the restored-context SIGBUS anomaly.

Evidence (decisive lines)

["panic.txt: 'panic: vref: bad refcnt 00000000 1' with trace vref<-vnode_pager_reference<-vm_mmap<-fp_mmap<-mmap_phdr (fd>=3 variant)", "panic_fd0.txt: 'panic: fdrop: invalid f_count 0' with trace fdrop<-closef<-fdfree<-exit1<-sigexit (fd<3 variant)", 'baseline_fresh_vref_panic.txt: same vref panic reproduced from a fresh with-src snapshot boot', 'fix_run.log: patched kernel (#1 Sep 1 20:00:55) - both variants: no kernel panic, guest stays up', 'VERDICT.md: full root-cause accounting incl. fdcache modes and the error==0 success mask', 'gen.c/stage2.c/trigger_fd3.c/trigger_fd0.c/build.sh/run.sh: reproduction sources']

PoC changes

Seed sketch rebuilt entirely: image generator uses guest headers (_KERNEL_STRUCTURES) for exact kernel struct sizes; fixed note-stream file placement (fseek to reserved header region), page-congruent stack p_offset, correct _start entry offset (0x600000f0) taken from nm; triggers print errno; image must be generated in the same boot as the run (HAMMER2 fsid in the embedded fhandle is boot-dependent).

Verified recommended fix

In elf_getfiles()'s fdalloc-failure path do not touch fp: set error = EBADF, dispose the owned tempfp reference; additionally reject e_phnum == 0 in ckpt_thaw_proc (fix.diff, validated).

Verdict

elf_getfiles() calls fp_close(fp) (== fdrop) on the checkpoint file when fdalloc() fails, dropping a reference owned by sys_sys_checkpoint()'s holdfp(). Confirmed twice on the stock kernel: with the checkpoint fd >= 3 the premature fo_close/vrele/ffree kills the file and its vnode mid-restore and elf_loadphdrs() (operating on the freed struct file) panics in vref() via vnode_pager_reference ('panic: vref: bad refcnt 00000000 1'); with the fd < 3 the stolen reference becomes a phantom per-thread fdcache reference, the syscall reports success (error is never set), and the process exit panics with 'fdrop: invalid f_count 0' (fdrop<-closef<-fdfree<-exit1<-sigexit). The same path also leaks tempfp. The underlying primitive is a struct-file refcount underflow (dangling fd table entry once the phantom cache ref is evicted); the in-process reclamation/exploitation stage (implemented in stage2.c) could not execute because the restored program reliably SIGBUSes at its first instruction fetch - an apparently independent defect in the ckpt restore path (documented in VERDICT.md) - so escalation stopped at demonstrated lifetime corruption + deterministic panics. Gate: kern.ckptgroup defaults to wheel; non-wheel user verified to receive EPERM.