# 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`):

```c
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`):

```c
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`:

```c
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`):

```c
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.

```diff
-       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.
