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)
PoC verification
Evidence pack
findings/poc/DF-0911 Β· 14 files| File | Type | Description | Size | |
|---|---|---|---|---|
| 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 |
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 usingptrace(2)PT_ATTACH/PT_DETACH (correct path)build.sh/run.shβ exact build & runbuild.logβ trigger build outputrun.logβ baseline (#0) run output (buggy)fix_run.logβ patched (#1) kernel run output (fixed)fix_build.logβ fullmake nativekernellogfix.diffβ git-apply-able one-line-fix diffenv.txtβ guest environmentVERDICT.mdβ full narrative analysismanifest.jsonβ artifact catalog
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>/ctlto debug its own children β e.g. legacy tracers, in-tree regression tests) can detach a child from itself, orphaning the child toproc0(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 thaninit(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.cfrom scratch β the original PoC folder was empty. The trigger: parent forks child, writes"attach"then"detach"to/proc/<child>/ctl, observesgetppid()==0in the child andwaitpid()==ECHILDin the parent. - Wrote
ptrace_control.cas a control: the same scenario viaptrace(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 β childppid=0,waitpid=ECHILD. - Patched (
#1, single-fix kernel built withmake -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 β childppid=<parent>after detach,waitpid=0. Confirmed deterministic over 4 runs.
Clean before/after. The fix closes the bug.
Fix verification
fixedVALIDATED. 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=
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
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/
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.
No comments yet.