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

procfs ATTACH lacks ptrace's tracer-cycle check: two unprivileged processes build a p_pptr cycle; ptrace(PT_ATTACH)'s ancestor walk then spins forever in-kernel holding the target's p_token β€” permanent progressive kernel livelock

Summary

ptrace PT_ATTACH refuses attaching to an ancestor when the caller is traced (sys_process.c:193-199); procfs's ctl attach has no such check, and the ctl writer becomes the victim's parent (proc_reparent at :155) - two cooperating same-uid processes attach each other, creating A.p_pptr=B / B.p_pptr=A, both P_TRACED. Any later ptrace(PT_ATTACH,x) from either never returns: the ancestor walk at sys_process.c:194 iterates the cycle forever, holding the target's p_token. VERIFIED uid 1001: fork A,B,C (SA_NOCLDSTOP armed); A attach+run B; B attach+run A => cycle; A: ptrace(PT_ATTACH,C) -> never returns; A survives SIGKILL (unkillable spinner); kill(C) blocks; guest 3-4 of 6 vCPUs pinned 100% in-kernel, ssh dead, no panic, hard reset required - permanent progressive livelock. Fix validated (mirror the ptrace ancestor check in ctl ATTACH): the cycle-creating attach returns EINVAL.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-3225 Β· 10 files
FileTypeDescriptionSize
df3225.c β€” 4.6 KB view raw
build.sh β€” 79 B view raw
run.sh β€” 136 B view raw
run.log β€” 510 B view raw
wedge_evidence.txt β€” 462 B view raw
boot.log β€” 12.9 KB view raw
VERDICT.md β€” 3.6 KB ↓ raw
README.md β€” 3.4 KB ↓ raw
fix.diff β€” 755 B view raw
verdict.json β€” 4.9 KB view raw

DF-3225 β€” procfs ATTACH lacks the ptrace tracer-cycle check β†’ p_pptr cycle β†’ unbounded kernel ancestor walk (permanent livelock)

File: sys/vfs/procfs/procfs_ctl.c:134-159 (procfs_control, PROCFS_CTL_ATTACH) Severity: Medium (unprivileged local DoS, permanent, progressive whole-system wedge) Status: REPRODUCED on DragonFly 6.5-DEVELOPMENT #0 (X86_64_GENERIC, INVARIANTS, 6 vCPU).

What the bug is

ptrace(PT_ATTACH) refuses to attach to any ancestor of the caller when the caller is itself traced β€” this prevents tracer cycles:

if (curp->p_flags & P_TRACED)
    for (pp = curp->p_pptr; pp != NULL; pp = pp->p_pptr)
        if (pp == p) { ... return (EINVAL); }   /* sys_process.c:193-199 */

procfs's equivalent attach (write("attach\n", "/proc/<pid>/ctl")) has no such check (procfs_ctl.c:134-159 checks only P_TRACED-already, self, and CHECKIO/p_trespass). The ctl writer becomes the victim's parent (proc_reparent(p, curp), procfs_ctl.c:155). Two cooperating unprivileged same-uid processes can therefore attach each other:

A --ctl "attach"--> B   : B.p_pptr = A   (B stopped, then A runs B)
B --ctl "attach"--> A   : A.p_pptr = B   => parent-cycle A <-> B

Both remain P_TRACED and runnable (run via ctl "run"; note B must arm SA_NOCLDSTOP first β€” see PoC comment β€” or the SIGCHLD emitted when A completes its stop stops B, since B is traced by A).

With the cycle in place, the very check ptrace has becomes the weapon: ptrace(PT_ATTACH, <any untraced pid C>) from A (or B) walks pp = curp->p_pptr forever β€” A.p_pptr = B, B.p_pptr = A, pp != NULL always, pp == p (C) never. The walk spins inside the kernel holding C's p_token (taken at sys_process.c:162), unkillable (SIGKILL pends, never delivered), and every subsequent kernel operation that needs C's token β€” or touches the poisoned parent topology β€” wedges another CPU: kill(C) from another process, sshd session setup, etc. No panic; a progressive, permanent livelock from a plain unprivileged user.

Reproduced evidence (run.log)

[main] A=856 B=855 C=854
[A 856] attaching B (855)
[A] running B
[B 855] attaching A (856) back -> cycle
[B] running A again
[main] B reported cycle complete
[main] go marker set; A enters ptrace walk
[A 856] ptrace(PT_ATTACH, 854): if vulnerable, never returns
[main] after 5s: kill -9 A (856)
[main] A SURVIVED SIGKILL -> stuck in kernel spin
[main] now kill -9 C (854): needs C.p_token held by spinning A -- expect HANG
<no further output β€” kill(C) never returned>
  • final line [main] kill(C) returned never printed (runner hung in kill(2));
  • guest ssh became unavailable (banner timeouts) while the console showed no panic (wedge_evidence.txt);
  • host-side qemu CPU pinned at 300-393% (3-4 of 6 vCPUs spinning in-kernel) with the guest otherwise idle β€” classic livelock, not load;
  • guest required vm.sh reset β€” unkillable, unrecoverable.

Files

  • df3225.c β€” PoC (unprivileged; builds the cycle, triggers the walk, proves unkillability + wedge spread)
  • run.log β€” decisive run (direct output until ssh died)
  • wedge_evidence.txt β€” host-side qemu CPU + ssh probe transcripts
  • boot.log β€” serial console (no panic; guest alive-but-dead)
  • fix.diff β€” verified fix (ancestor check mirroring sys_process.c:193-199)
  • verdict.json, manifest.json

Build & run

cc -O -o df3225 df3225.c
./df3225          # WARNING: wedges the machine; expect to hard-reset after
VERDICT.md
↓ download raw

DF-3225 VERDICT β€” REPRODUCED (permanent unprivileged kernel livelock / local DoS)

Bottom line

procfs_control(ATTACH) (sys/vfs/procfs/procfs_ctl.c:134-159) omits the tracer-cycle (ancestor) check that ptrace(PT_ATTACH) performs at sys/kern/sys_process.c:193-199. Because the ctl writer becomes the victim's parent (proc_reparent(p, curp), procfs_ctl.c:155), two cooperating unprivileged same-uid processes attach each other and create a p_pptr cycle. The ptrace ancestor walk

for (pp = curp->p_pptr; pp != NULL; pp = pp->p_pptr)   /* sys_process.c:194 */

then never terminates for any subsequent ptrace(PT_ATTACH, x) from either of them: the loop spins in-kernel holding the target's p_token (acquired at sys_process.c:162), the thread cannot be killed (SIGKILL pends on a thread that never returns to user mode), and the wedge spreads β€” each later kernel operation needing that token or touching the cyclic parent topology (kill(2) of the token-holder's target, sshd session setup) blocks or spins another CPU.

What was observed (run.log, 2026-09-08, DF 6.5-DEVELOPMENT #0)

  1. Cycle built with three unpriv (uid 1001) forks + four ctl writes β€” all markers printed up to [B] running A again / [main] B reported cycle complete.
  2. A ptrace(PT_ATTACH, C) never returned ([A] ... if vulnerable, never returns is the last A line).
  3. kill -9 A had no effect (A SURVIVED SIGKILL -> stuck in kernel spin) β€” unkillable in-kernel thread.
  4. kill -9 C from the runner never returned (the expected [main] kill(C) returned line is absent) β€” C's p_token is held forever by the spinner (kern_sig.c:802 blocks in lwkt_gettoken_shared).
  5. Guest sshd stopped accepting connections (banner timeouts, multiple probes) while the serial console showed no panic β€” the system was alive-but-dead; host-side qemu burned 300-393% CPU (3-4 vCPUs pinned in-kernel) with the guest idle.
  6. Only a hard reset recovered the machine.

Orchestration note: the PoC arms SA_NOCLDSTOP in B before attaching back β€” without it, the SIGCHLD emitted when A completes its attach-stop (kern_sig.c:1637 ksignal(p->p_pptr, SIGCHLD)) stops B (B is traced by A) and the two merely freeze each other (mutual-stop, observed in the first attempt, run.log.first-attempt not kept) without reaching the livelock.

Exploit chain (DoS)

fork A,B,C (unpriv) β†’ A: ctl attach+run B β†’ B: ctl attach+run A (cycle) β†’ A: ptrace(PT_ATTACH, C) β†’ kernel spins forever on one CPU holding C.p_token β†’ any further activity touching C or the A/B parent topology wedges more CPUs β†’ system unusable; no recovery without reboot. 100% reliable on the test guest.

Fix

fix.diff adds the missing ancestor check to procfs ATTACH, mirroring sys_process.c:193-199. Validated by patched-kernel rebuild + re-run (verdict.json fix_status): the attach-back is refused with EINVAL, the cycle never forms, ptrace(PT_ATTACH, C) returns normally, the runner completes, no wedge.

kernel_refs

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): full wedge (run.log + wedge_evidence.txt). Patched (nativekernel with fix.diff): the cycle-creating attach-back is refused EINVAL (run.fixed.log, run.fixed2: 'df3225: B attach A: Invalid argument'), the cycle never forms, ptrace(PT_ATTACH, C) returns normally, guest stays responsive throughout. The runner's post-refusal hang in run.fixed.log is PoC orchestration (B stopped-in-exit while traced keeps the sync pipe open), not kernel behavior; the guest remained fully responsive.

findings/poc/DF-3225/run.fixed.log; findings/poc/DF-3225/fixbuild.log
↓ 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

fork A,B,C (unpriv) -> A: ctl 'attach'+'run' on B (B.p_pptr=A, B traced) -> B (SA_NOCLDSTOP armed): ctl 'attach'+'run' on A (A.p_pptr=B) => parent cycle -> A: ptrace(PT_ATTACH, C) -> kernel spins in-kernel forever holding C.p_token, thread unkillable -> every later operation needing C's token or touching the cyclic parent topology wedges another CPU -> progressive permanent system livelock from a plain unprivileged user.

Evidence (decisive lines)

run.log: markers through '[B 855] attaching A (856) back -> cycle', '[A 856] ptrace(PT_ATTACH, 854): if vulnerable, never returns', '[main] A SURVIVED SIGKILL -> stuck in kernel spin', and the ABSENT final line '[main] kill(C) returned' (runner hung in kill(2)). wedge_evidence.txt: ssh banner timeouts + host qemu 300-393% CPU. boot.log: serial console shows no panic. run.fixed.log: patched kernel 'B attach A: Invalid argument'.

PoC changes

PoC written fresh. Two orchestration fixes were required to reach the livelock: (1) the ctl WRITER becomes the new parent, so A and B themselves must issue their own attach/run writes (not an orchestrator); (2) B must sigaction(SIGCHLD, SA_NOCLDSTOP) before attaching back, else the SIGCHLD emitted when A's attach-stop completes (kern_sig.c:1637 ksignal(p->p_pptr, SIGCHLD)) stops B (B is traced by A) at syscall exit and the two merely freeze each other (mutual-stop, observed in the first attempt) without triggering the walk.

Verified recommended fix

Add the ptrace-style ancestor-cycle check to procfs ATTACH (walk curp's p_pptr chain, EINVAL if it contains the target).

Verdict

REPRODUCED on DragonFly 6.5-DEVELOPMENT #0 (X86_64_GENERIC, INVARIANTS, 6 vCPU). procfs_control(ATTACH) (procfs_ctl.c:134-159) lacks the tracer-cycle/ancestor check that ptrace PT_ATTACH has (sys_process.c:193-199); because the ctl writer becomes the victim's parent (proc_reparent(p, curp), :155), two cooperating unprivileged same-uid processes attach each other and build a p_pptr cycle (A.p_pptr=B, B.p_pptr=A, both P_TRACED). A subsequent ptrace(PT_ATTACH, C) from either then never returns from the kernel: the ancestor walk at sys_process.c:194 ('for (pp = curp->p_pptr; pp != NULL; pp = pp->p_pptr)') spins forever while holding C's p_token (acquired :162). Observed: ptrace call never returned; kill -9 had no effect on the spinner; kill(C) from another process blocked forever (kern_sig.c:802 needs C.p_token); guest sshd stopped accepting sessions (banner timeouts, no panic on console); host qemu burned 300-393% CPU (3-4 vCPUs pinned in-kernel) with the guest idle; only a hard reset recovered. Fix validated: patched nativekernel refuses the cycle-creating attach-back with EINVAL; ptrace(PT_ATTACH) returns normally; no wedge.