/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)
PoC verification
Evidence pack
findings/poc/DF-3224 Β· 11 files| File | Type | Description | Size | |
|---|---|---|---|---|
| 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 == 1NOREBOOT 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)
- Reaper-subtree bypass (fully unprivileged). A same-uid attacker inside a
PROC_REAP_ACQUIREsubreaper subtree cannotkill(2)a same-uid process outside it (reaper_sigtestβ EPERM, enforcement working as designed), yetwrite("term\n")to/proc/<victim>/ctldelivers SIGTERM. This defeats the reaper isolation boundary exactly where it is designed to protect (supervised/sandboxed same-uid service trees). - 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 inp_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.
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):
- Reaper isolation bypass β attacker inside a
PROC_REAP_ACQUIREsubreaper subtree:kill(victim, SIGTERM)βEPERM; ctl write"term"β SIGTERM delivered (victim's handler ran, exit 57). run.log + run.2.log. - 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)
procfs_doctlsignal branch: sys/vfs/procfs/procfs_ctl.c:319-337 βelse { ksignal(p, nm->nm_val); } error = 0;β no cred/reaper/cap check.ksignalis the raw primitive: sys/kern/kern_sig.c:1118-1121.- kill(2) gate that is skipped: sys/kern/kern_sig.c:801-809 (
CANSIGNAL), macro at sys/sys/signal2.h:46-49 βp_trespass(sys/kern/kern_prot.c:1023) reaper_sigtest(sys/kern/kern_fork.c:1285-1312) + pid-1 NOREBOOT cap (kern_sig.c:786).- The only gate ever applied is open-time vnode perms: ctl mode 0200 (sys/vfs/procfs/procfs_subr.c:213-217), owner = target current euid (sys/vfs/procfs/procfs_vnops.c:607), enforced by VOP_ACCESS at open only; P_SUGID bit-stripping (procfs_vnops.c:584-588) affects only new opens β a pre-opened fd keeps working after the target goes setuid-root.
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
fixedBaseline (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
Confirmed kernel references
- sys/vfs/procfs/procfs_ctl.c:319
- sys/vfs/procfs/procfs_ctl.c:333
- sys/kern/kern_sig.c:803
- sys/kern/kern_sig.c:786
- sys/sys/signal2.h:46
- sys/kern/kern_prot.c:1023
- sys/kern/kern_fork.c:1285
- sys/vfs/procfs/procfs_subr.c:216
- sys/vfs/procfs/procfs_vnops.c:584
- sys/vfs/procfs/procfs_vnops.c:607
- sys/kern/kern_sig.c:1118
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.
No comments yet.