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

procfs ATTACH skips saving p_oppid for already-owned children; DETACH reparents victim to proc0 leaking permanent zombies

Summary

procfs_ctl.c:153-156 p_oppid save and reparent both inside if(p->p_pptr!=curp). Fork+trace pattern: child already owned by curp so p_oppid NOT saved stays 0 (fork initial). DETACH:211 p_oppid!=p_pptr->p_pid => 0!=curp_pid true. :214 pfs_pfind(0) returns &proc0 (procfs_subr.c:285-287 special-cased). :216 proc_reparent to proc0. proc0 is kernel swapper never wait4()s. Victim exits = zombie never reaped pid never recycled. Each fork+attach+detach+exit leaks one pid slot. ~kern.maxproc iterations = PID exhaustion system-wide DoS. ptrace(2) PT_ATTACH saves p_oppid unconditionally (sys_process.c:314). Fix: move p_oppid save outside if.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-0911 Β· 14 files
FileTypeDescriptionSize
df0911.c trigger-source fork + procfs /proc/<pid>/ctl attach+detach -> observe orphaning 4.0 KB view raw
ptrace_control.c control-source same scenario via ptrace(2) -- proves bug is procfs-specific 1.6 KB view raw
build.sh build-script cc -o df0911 df0911.c 156 B view raw
run.sh run-script ./df0911 291 B view raw
build.log build-log trigger build, full output 13 B view raw
run.log run-log decisive baseline (#0) run: BUG REPRODUCED 663 B view raw
fix_run.log fix-run-log patched (#1) kernel run: not reproduced 626 B view raw
fix_build.log fix-build-log full make -j6 nativekernel output (rc=0) 5.6 MB ↓ download
fix.diff suggested-fix git-apply-able one-line fix: save p_oppid unconditionally 749 B view raw
env.txt environment uname, cc, procfs mount, uid 280 B view raw
README.md readme summary + how to reproduce 2.7 KB ↓ raw
VERDICT.md verdict full narrative analysis with path:line citations 5.8 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 summary + how to reproduce
↓ download raw

DF-0911 β€” procfs ATTACH skips saving p_oppid for already-owned children; DETACH orphans

What the bug is

procfs_control() in sys/vfs/procfs/procfs_ctl.c is the procfs equivalent of ptrace(2) for the /proc/<pid>/ctl file (write "attach", "detach", etc). The ATTACH path looks like this:

p->p_flags |= P_TRACED;
p->p_xstat = 0;
if (p->p_pptr != curp) {        /* procfs_ctl.c:153 */
    p->p_oppid = p->p_pptr->p_pid;
    proc_reparent(p, curp);
}
proc_stop(p, SSTOP);

When the tracer is already the real parent of the target (p->p_pptr == curp β€” the normal case for a tracer that fork()s the child and then attaches), the body of the if is skipped, so p_oppid is never saved and stays at its default 0.

On the matching DETACH:

if (p->p_oppid != p->p_pptr->p_pid) {   /* procfs_ctl.c:211 */
    pp = pfs_pfind(p->p_oppid);         /* pfs_pfind(0) -> &proc0 */
    if (pp) {
        proc_reparent(p, pp);           /* child is handed to proc0 */
        pfs_pdone(pp);
    }
}
p->p_oppid = 0;

pfs_pfind(0) returns &proc0 (the kernel swapper), so the child is reparented to proc0. The original parent can no longer wait(2) on the child (ECHILD), and the child's getppid() returns 0.

Compare to the ptrace(2) syscall path in sys/kern/sys_process.c which always saves p_oppid (lines 306 for PT_TRACE_ME, 314 for PT_ATTACH). The procfs path is inconsistent with the syscall path.

How to reproduce (unprivileged)

maxx (uid 1001) forks a child, ATTACHes and DETACHes via /proc/<child>/ctl:

./build.sh
./run.sh

Expected output on the buggy kernel:

[parent] before attach: child ppid=<P>
[parent] wrote 'attach' (6 bytes) to /proc/<C>/ctl
[parent] wrote 'detach' (6 bytes) to /proc/<C>/ctl
[parent] after detach:  child ppid=0
RESULT: BUG REPRODUCED -- child orphaned to proc0 (ppid=0) after procfs attach/detach
waitpid(<C>)=-1 errno=10 (ECHILD)

On the fixed kernel:

[parent] after detach:  child ppid=<P>           # parent unchanged
RESULT: not reproduced -- child ppid after detach is non-zero
waitpid(<C>)=0 errno=0

Files

  • df0911.c β€” minimal trigger (fork, attach via procfs ctl, detach, observe ppid/waitpid)
  • ptrace_control.c β€” control test using ptrace(2) PT_ATTACH/PT_DETACH (correct path)
  • build.sh / run.sh β€” exact build & run
  • build.log β€” trigger build output
  • run.log β€” baseline (#0) run output (buggy)
  • fix_run.log β€” patched (#1) kernel run output (fixed)
  • fix_build.log β€” full make nativekernel log
  • fix.diff β€” git-apply-able one-line-fix diff
  • env.txt β€” guest environment
  • VERDICT.md β€” full narrative analysis
  • manifest.json β€” artifact catalog
VERDICT.md verdict full narrative analysis with path:line citations
↓ download raw

DF-0911 β€” VERDICT

Verdict: REPRODUCED β€” procfs logic bug, fix VALIDATED.

Class: Privilege/parenting logic bug (NOT memory corruption β€” no escalation chain).

Mechanism

/proc/<pid>/ctl accepts the strings "attach" / "detach" / "step" / "run" / "wait". procfs_doctl() (sys/vfs/procfs/procfs_ctl.c:286) parses the string and dispatches to procfs_control() (procfs_ctl.c:106).

In the ATTACH case (procfs_ctl.c:134):

p->p_flags |= P_TRACED;
p->p_xstat = 0;
if (p->p_pptr != curp) {              /* line 153 */
    p->p_oppid = p->p_pptr->p_pid;    /* line 154 */
    proc_reparent(p, curp);           /* line 155 */
}
proc_stop(p, SSTOP);

When the tracer is already the actual parent of the target β€” the normal case for any debugger that fork()s the child and then attaches β€” p->p_pptr == curp and the whole if body is skipped. p_oppid is never written and stays at its default value of 0.

In the DETACH case (procfs_ctl.c:197-225):

if (p->p_oppid != p->p_pptr->p_pid) {     /* line 211: 0 != parent_pid  -> TRUE */
    pp = pfs_pfind(p->p_oppid);           /* line 214: pfs_pfind(0) */
    if (pp) {
        proc_reparent(p, pp);             /* line 216: child reparented to &proc0 */
        pfs_pdone(pp);
    }
}
p->p_oppid = 0;                           /* line 221 */

pfs_pfind() (sys/vfs/procfs/procfs_subr.c:281) special-cases pid 0:

if (pfs_pid == 0) {
    p = &proc0;          /* line 286 β€” the kernel swapper */
    PHOLD(p);
}

So proc_reparent(p, &proc0) (sys/kern/kern_exit.c:1468) hands the child to proc0. The original parent's getppid() for the child now returns 0 and waitpid(child) returns ECHILD (the child is no longer in the parent's children list).

Why this is procfs-specific

The ptrace(2) syscall path in sys/kern/sys_process.c does not have this bug. It unconditionally saves p_oppid for both PT_TRACE_ME (sys_process.c:306) and PT_ATTACH (sys_process.c:314):

case PT_ATTACH:
    p->p_flags |= P_TRACED;
    p->p_oppid = p->p_pptr->p_pid;   /* ALWAYS saved */
    proc_reparent(p, curp);
    ...

A control test (ptrace_control.c) using ptrace(PT_ATTACH/PT_DETACH) against a forked child leaves the child owned by the parent (waitpid returns 0) β€” confirming the bug is procfs-only.

Reproduction (unprivileged, default GENERIC kernel)

Run as maxx (uid 1001, not in wheel):

[parent] before attach: child ppid=945
[parent] wrote 'attach' (6 bytes) to /proc/947/ctl
[parent] wrote 'detach' (6 bytes) to /proc/947/ctl
[parent] after detach:  child ppid=0
RESULT: BUG REPRODUCED -- child orphaned to proc0 (ppid=0) after procfs attach/detach
waitpid(947)=-1 errno=10 (ECHILD)

Reproduced deterministically across 4 runs. Requires only procfs mounted on /proc (default on the DragonFlyBSD DEV ISO β€” verified) and same-uid permission (CHECKIO/p_trespass checks pass for self-forked children).

Impact

This is a logic bug in process parenting β€” not memory corruption, so there is no escalation chain. The realistic impact ceiling is:

  • A local user (or any program using /proc/<pid>/ctl to debug its own children β€” e.g. legacy tracers, in-tree regression tests) can detach a child from itself, orphaning the child to proc0 (the kernel swapper, not init).
  • The child can no longer be wait(2)ed by its real parent (ECHILD), so the parent loses child-exit status, resource accounting, and signal delivery for that child.
  • The orphaned child is parented by a kernel thread (proc0) rather than init (pid 1), which can confuse userland reaping logic and leave permanent zombies / lost signals (consistent with the finding title: "leaking permanent zombies").
  • No memory primitive is gained; no privilege boundary is crossed (the tracer already had permission to debug the child). Severity Medium is appropriate.

Exploit chain

none β€” this is a logic bug, not memory corruption. There is no path to uid=0 from this primitive; the security boundary that ptrace protects (only-same-uid-or-root may trace) is intact. The damage is limited to parenting corruption / zombie leakage for processes the attacker could already debug.

PoC changes

  • Wrote df0911.c from scratch β€” the original PoC folder was empty. The trigger: parent forks child, writes "attach" then "detach" to /proc/<child>/ctl, observes getppid()==0 in the child and waitpid()==ECHILD in the parent.
  • Wrote ptrace_control.c as a control: the same scenario via ptrace(2) does not orphan the child (proves the bug is procfs-specific, matching the source).
  • Wrote fix.diff β€” the one-line fix.

Fix

fix.diff: save p_oppid unconditionally on ATTACH, matching the ptrace(2) path. The proc_reparent() call is still guarded by p->p_pptr != curp (no need to reparent when the tracer is already the parent). After the fix, DETACH sees p_oppid == p_pptr->p_pid and skips the bogus reparent.

-       if (p->p_pptr != curp) {
-           p->p_oppid = p->p_pptr->p_pid;
+       p->p_oppid = p->p_pptr->p_pid;
+       if (p->p_pptr != curp)
            proc_reparent(p, curp);
-       }

This supersedes any pre-verification proposal: it is the minimal change that brings procfs into agreement with ptrace(2) and closes the orphan-on-detach behavior.

Fix validation (Phase 8)

  • Baseline (#0, unpatched audit-source): bug reproduces β€” child ppid=0, waitpid=ECHILD.
  • Patched (#1, single-fix kernel built with make -j6 nativekernel, kern.version = "DragonFly 6.5-DEVELOPMENT #1: Sun Jul 12 03:17:43 UTC 2026", sha256(/boot/kernel/kernel) = 20d74b6560e50682c57166d1c81136ced15b5d06b3f037c53d85ed6d8cb5323f): bug is gone β€” child ppid=<parent> after detach, waitpid=0. Confirmed deterministic over 4 runs.

Clean before/after. The fix closes the bug.

Fix verification

fixed
baseline reproduced→ patch + rebuild →patched clean

VALIDATED. df0911 on the unpatched #0 baseline produced child ppid=0 and waitpid=ECHILD (bug present). After applying only this finding's fix.diff (save p_oppid unconditionally on ATTACH in procfs_ctl.c) and booting the single-fix #1 kernel (make -j6 nativekernel, rc=0), the SAME PoC produced child ppid= and waitpid=0 (bug gone). Clean before/after, deterministic over 4 runs each.

baseline #0:  [parent] after detach: child ppid=0 | RESULT: BUG REPRODUCED | waitpid(947)=-1 errno=10 (ECHILD)
patched #1:  [parent] after detach: child ppid=819 | RESULT: not reproduced | waitpid(820)=0 errno=0
↓ fix.diffDragonFly 6.5-DEVELOPMENT #1: Sun Jul 12 03:17:43 UTC 2026 root@dfbsd:/usr/obj/usr/src/sys/X86_64_GENERIC x86_64 (sha256 /boot/kernel/kernel = 20d74b6560e50682c57166d1c81136ced15b5d06b3f037c53d85ed6d8cb5323f)

Confirmed kernel references

Detail

Exploit chain

none. This is a parenting/logic bug, not memory corruption -- no slab primitive, no function-pointer overwrite, no privilege boundary is crossed (the attacker already had ptrace permission on the target). The realistic impact ceiling is: orphaning a debuggable child to proc0, breaking the parent's wait()/resource accounting and leaking zombies. No escalation to uid=0 is derivable from this primitive; the security boundary ptrace enforces (same-uid-or-root) is intact.

Evidence (decisive lines)

baseline (#0 unpatched): [parent] after detach: child ppid=0 / RESULT: BUG REPRODUCED / waitpid(947)=-1 errno=10 (ECHILD). Reproduced 4x deterministically. patched (#1): [parent] after detach: child ppid=819 / RESULT: not reproduced / waitpid(820)=0 errno=0. Reproduced 4x deterministically.

PoC changes

findings/poc/DF-0911/ was empty; wrote df0911.c (fork + /proc//ctl attach+detach trigger observing ppid==0/ECHILD), ptrace_control.c (control proving the bug is procfs-specific by exercising the ptrace(2) path which behaves correctly), build.sh/run.sh, fix.diff (one-line fix), and full evidence pack (VERDICT.md, manifest.json, env.txt, build.log, run.log, fix_run.log, fix_build.log).

Verified recommended fix

In sys/vfs/procfs/procfs_ctl.c ATTACH case, save p_oppid unconditionally (move p->p_oppid = p->p_pptr->p_pid; out of the if (p->p_pptr != curp) block) so DETACH sees p_oppid==p_pptr->p_pid and skips the bogus pfs_pfind(0)->proc0 reparent. Keep proc_reparent() guarded by p->p_pptr != curp. This brings procfs into agreement with ptrace(2) at sys/kern/sys_process.c:306,314. Supersedes the finding proposal (the markdown folder was empty; this fix was authored post-verification with line-accurate confirmation). The full git-apply-able diff lives in findings/poc/DF-0911/fix.diff.

Verdict

REPRODUCED (logic bug, not memory corruption). The procfs ATTACH path at sys/vfs/procfs/procfs_ctl.c:153-156 only saves p_oppid inside if (p->p_pptr != curp). When the tracer is already the real parent of the target (the normal fork-then-attach case), p_oppid stays at its default 0. On DETACH (procfs_ctl.c:211) the check p_oppid != p_pptr->p_pid is then TRUE, so pfs_pfind(0) is called, which returns &proc0 (procfs_subr.c:285-287), and proc_reparent(p, &proc0) hands the child to the kernel swapper. Confirmed by df0911.c: child getppid() returns 0 after procfs attach+detach, parent waitpid() returns ECHILD. ptrace(2) does NOT have this bug (sys_process.c:306,314 always save p_oppid) -- ptrace_control.c proves this. Fix VALIDATED: the single-fix #1 kernel leaves the child with its real parent.