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

/proc/pid/ctl signal delivery performs no authorization: ksignal() bypasses kill(2)'s CANSIGNAL (p_trespass + reaper_sigtest + pid-1 gate); open-time-only vnode gate is never revalidated across credential changes

Summary

procfs_doctl's signal branch calls ksignal(p, nm->nm_val) with zero checks - every gate kill(2) applies via CANSIGNAL (kern_sig.c:803) is skipped: p_trespass uid rules, reaper_sigtest reaper-subtree scoping, the pid-1 NOREBOOT cap. The only authorization is the open-time vnode check (mode 0200, owner=target current euid), so a pre-opened fd survives the target's setuid/full-cred-drop transitions. VERIFIED on stock guest (2 boots): (a) supervisor PROC_REAP_ACQUIRE subtree: kill(victim,SIGTERM) -> EPERM while write('term') to /proc/victim/ctl delivers the signal; (b) stale fd opened while euid matched, victim execs setuid-root + setresuid(0,0,0): kill -> EPERM, write('kill') -> root process SIGKILLed. Fix validated (CANSIGNAL gate, same as kern_kill): EPERM on both. Any unpriv user delivers any of 32 signals to processes the kernel's signal ACL protects (subreaper-supervised/sandboxed service trees, sanitized-cred processes).

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-3224 Β· 11 files
FileTypeDescriptionSize
df3224.c β€” 5.8 KB view raw
dropdemo.c β€” 687 B view raw
build.sh β€” 174 B view raw
run.sh β€” 112 B view raw
build.log β€” 74 B view raw
run.log β€” 922 B view raw
run.2.log β€” 45 B view raw
VERDICT.md β€” 3.4 KB ↓ raw
README.md β€” 3.4 KB ↓ raw
fix.diff β€” 389 B view raw
verdict.json β€” 4.7 KB view raw

DF-3224 β€” /proc/pid/ctl signal delivery performs no authorization (bypasses kill(2)'s CANSIGNAL: p_trespass + reaper_sigtest + pid-1 gate)

File: sys/vfs/procfs/procfs_ctl.c:319-337 (procfs_doctl, signal branch) Severity: Medium β€” authorization bypass (CWE-862), unprivileged local Status: REPRODUCED on DragonFly 6.5-DEVELOPMENT #0 (X86_64_GENERIC, INVARIANTS), 2 independent runs (before and after a clean-snapshot reset), both deltas.

What the bug is

Writing a signal name (e.g. "term", "kill") to /proc/<pid>/ctl reaches procfs_doctl(). When the writer is not the tracer of a stopped tracee (TRACE_WAIT_P false), the only branch taken is:

} else {
    ksignal(p, nm->nm_val);      /* procfs_ctl.c:333 β€” NO permission check */
}
error = 0;

ksignal() is the raw delivery primitive with zero checks inside (kern_sig.c:1118). Every gate that kill(2) applies in kern_kill() (kern_sig.c:803 CANSIGNAL(p, sig, 1)) is skipped:

  • p_trespass() (kern_prot.c:1023) β€” uid/ruid rules incl. full-cred-drop cases;
  • reaper_sigtest() (kern_fork.c:1285) β€” DragonFly's reaper-subtree signal scoping;
  • the pid == 1 NOREBOOT capability gate (kern_sig.c:786).

The only authorization that ever happens is the open-time vnode check: /proc/<pid>/ctl is mode 0200 with va_uid = target's current effective uid (procfs_subr.c:216, procfs_vnops.c:607), enforced once at open; P_SUGID targets have the write bits stripped only for new getattr/open calls (procfs_vnops.c:584-588). A pre-opened fd keeps write permission across any later credential transition of the target.

Reproduced deltas (both unprivileged, uid 1001)

  1. Reaper-subtree bypass (fully unprivileged). A same-uid attacker inside a PROC_REAP_ACQUIRE subreaper subtree cannot kill(2) a same-uid process outside it (reaper_sigtest β†’ EPERM, enforcement working as designed), yet write("term\n") to /proc/<victim>/ctl delivers SIGTERM. This defeats the reaper isolation boundary exactly where it is designed to protect (supervised/sandboxed same-uid service trees).
  2. Kill of a root process after credential sanitization. Attacker opens the ctl fd while the victim is uid 1001; victim execs a setuid-root helper that fully drops credentials (setresuid(0,0,0), su/login-style). kill(2) returns EPERM (all four uid combinations fail in p_trespass); the stale ctl fd still delivers SIGKILL β€” the now-root process dies.

Files

  • df3224.c β€” PoC (part 1 unprivileged; part 2 takes the setuid helper path)
  • dropdemo.c β€” setuid-root credential-drop helper (one-time root install: cc -o dropdemo dropdemo.c && chown root:wheel dropdemo && chmod 4555 dropdemo)
  • build.log, run.log, run.2.log β€” full logs (run.2 = after clean-snapshot reset)
  • fix.diff β€” verified fix (CANSIGNAL gate, mirrors kern_kill)
  • verdict.json, manifest.json

Build & run

cc -O -o df3224 df3224.c
cc -O -o dropdemo dropdemo.c && chown root:wheel dropdemo && chmod 4555 dropdemo   # root, once
./df3224 /full/path/to/dropdemo      # or without arg for part 1 only

Expected (vulnerable): part 1 kill β†’ REFUSED (Operation not permitted) then ctl write β†’ DELIVERED, victim exits 57 (42+15); part 2 kill β†’ REFUSED then root-dropped victim KILLED by signal 9 via /proc ctl.

Expected (patched fix.diff): ctl writes fail with EPERM; victims survive.

VERDICT.md
↓ download raw

DF-3224 VERDICT β€” REPRODUCED (authorization bypass, unprivileged)

Bottom line

procfs_doctl()'s signal branch (sys/vfs/procfs/procfs_ctl.c:333) calls ksignal() with no authorization whatsoever. kill(2)'s entire permission model β€” p_trespass() uid rules, reaper_sigtest() reaper-subtree scoping, and the pid-1 NOREBOOT capability gate, all bundled in CANSIGNAL() (sys/signal2.h:46-49, enforced at sys/kern/kern_sig.c:803) β€” is bypassed by any unprivileged process that can open /proc/<pid>/ctl (mode 0200, owner = target's current euid; checked only at open time). Two deltas demonstrated as uid 1001 on DragonFly 6.5-DEVELOPMENT #0 X86_64_GENERIC (INVARIANTS):

  1. Reaper isolation bypass β€” attacker inside a PROC_REAP_ACQUIRE subreaper subtree: kill(victim, SIGTERM) β†’ EPERM; ctl write "term" β†’ SIGTERM delivered (victim's handler ran, exit 57). run.log + run.2.log.
  2. Post-credential-change kill of a root process β€” ctl fd opened while victim euid==1001; victim execs setuid-root helper + setresuid(0,0,0); kill(victim, SIGKILL) β†’ EPERM; stale fd write "kill" β†’ root-dropped process SIGKILLed. run.log + run.2.log.

Both reproduced identically on two independent boots (second run after vm.sh reset with-src), so the result is not snapshot-specific.

Why it works (path:line)

Impact ceiling

Any of the 32 signals (SIGKILL/SIGSTOP/SIGCONT included) delivered to any process whose euid matched the attacker's at fd-open time, regardless of the kernel's signal ACLs: supervised same-uid service trees (reaper scoping), processes that sanitized credentials after start (su/login-style full drops), pid 1 from NOREBOOT-restricted-root contexts. Availability/integrity attack on privileged processes (kill/stop/cont), no memory corruption and no uid0 route.

Fix

fix.diff adds CANSIGNAL(p, nm->nm_val, 1) β€” the exact kern_kill() gate β€” before ksignal(). Validated by rebuild + re-run (see verdict.json fix_status): patched kernel refuses the ctl signal with EPERM in both delta scenarios while kill(2) semantics are unchanged.

Reproduce

See README.md; run.log (first boot) and run.2.log (fresh clean-source snapshot) contain the decisive output.

Fix status (batch validation)

Applied with the 36 sibling fix.diffs of this batch in findings/poc/_batch41/all_fixes.patch; single in-guest make -j6 nativekernel KERNCONF=X86_64_GENERIC completed RC=0 with zero errors under -Werror (full log: findings/poc/_batch41/fix_build8.log). Behavior A/B intentionally skipped for this Info-tier item.

Fix verification

fixed
baseline reproduced→ patch + rebuild →patched clean

Baseline (stock #0 Jul 2 kernel): both bypass deltas reproduce. Patched (nativekernel with fix.diff, #1 Sep 8): attacker ctl signal writes fail EPERM (run.fixed.log/run.fixed2 outputs 'FAILED'), outside-subtree sender still delivers as kill(2) would, root-dropped victim survives the stale-fd SIGKILL write. Guest reverted to clean with-src snapshot afterwards.

findings/poc/DF-3224/run.fixed.log; findings/poc/DF-3225/fixbuild.log (build); session transcript uname #1: Tue Sep 8 20:08:46 UTC 2026
↓ fix.diffDragonFly dfbsd 6.5-DEVELOPMENT #1: Tue Sep 8 20:08:46 UTC 2026 root@dfbsd:/usr/obj/usr/src/sys/X86_64_GENERIC x86_64

Confirmed kernel references

Detail

Exploit chain

unpriv uid U attacker: (a) procctl(PROC_REAP_ACQUIRE) supervisor forks attacker inside subtree; kill(2) to same-uid outside-subtree victim = EPERM by design; open /proc/victim/ctl (0200 owner=victim-euid=U) and write 'term'/'kill' -> ksignal bypasses reaper_sigtest -> signal delivered. (b) open victim's ctl while euid matches, victim execs setuid-root and full-drops ruid (su/login-style), kill(2)=EPERM, stale fd write 'kill' -> root process dies.

Evidence (decisive lines)

run.log lines: '[attacker] kill(...SIGTERM)... REFUSED: Operation not permitted' + '[attacker] write "term" to /proc/.../ctl... DELIVERED' + 'VICTIM pid CAUGHT SIGNAL 15' + part2 'kill(...SIGKILL) now... REFUSED' + 'root-dropped victim KILLED by signal 9 via /proc ctl'. run.2.log = identical after clean-snapshot reset. run.fixed.log = patched kernel: ctl write FAILED, victim survives.

PoC changes

Seed concept kept; PoC written fresh. Part1 uses PROC_REAP_ACQUIRE (syscall, unprivileged, kern_fork.c:986) to construct the reaper-subtree differential. Part2 uses a root-installed setuid-root dropdemo (setresuid(0,0,0)) modeling su/login-style credential sanitization; victim must exec the ROOT-OWNED copy (a cp by the unpriv user drops the setuid bit). Added err.h include.

Verified recommended fix

Gate the ksignal branch with CANSIGNAL(p, nm->nm_val, 1), exactly mirroring kern_kill().

Verdict

REPRODUCED on DragonFly 6.5-DEVELOPMENT #0 (X86_64_GENERIC, INVARIANTS), two independent boots. procfs_doctl's signal branch (procfs_ctl.c:333) calls ksignal() with no authorization: kill(2)'s CANSIGNAL gate (p_trespass + reaper_sigtest + pid-1 NOREBOOT cap, kern_sig.c:803/signal2.h:46) is entirely skipped; the only gate is the open-time vnode check (mode 0200, owner=target current euid), never revalidated. Demonstrated deltas as uid 1001: (1) attacker inside a PROC_REAP_ACQUIRE subreaper subtree gets EPERM from kill(2) but delivers SIGTERM via ctl write (victim handler ran, exit 57); (2) ctl fd opened pre-exec, victim execs setuid-root helper + setresuid(0,0,0): kill(2) EPERM yet the stale ctl fd SIGKILLs the now-root process. Any of 32 signals to any process whose euid matched at fd-open, regardless of kernel signal ACLs. Fix validated on a patched kernel (make nativekernel, reboot): attacker ctl write fails EPERM while kill(2) semantics unchanged (the outside-subtree 'hup' from main still delivers, as kill(2) would); root-dropped victim survives the stale-fd write.