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)
PoC verification
Evidence pack
findings/poc/DF-3225 Β· 10 files| File | Type | Description | Size | |
|---|---|---|---|---|
| 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) returnednever 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 transcriptsboot.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
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)
- 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. A ptrace(PT_ATTACH, C)never returned ([A] ... if vulnerable, never returnsis the last A line).kill -9 Ahad no effect (A SURVIVED SIGKILL -> stuck in kernel spin) β unkillable in-kernel thread.kill -9 Cfrom the runner never returned (the expected[main] kill(C) returnedline is absent) β C'sp_tokenis held forever by the spinner (kern_sig.c:802 blocks inlwkt_gettoken_shared).- 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.
- 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
- sys/vfs/procfs/procfs_ctl.c:134-159 (attach, no cycle check)
- sys/vfs/procfs/procfs_ctl.c:155 (proc_reparent(p, curp) β writer becomes parent)
- sys/kern/sys_process.c:162 (target p_token held during request)
- sys/kern/sys_process.c:193-199 (the unbounded walk)
- sys/kern/kern_sig.c:802 (kill blocks on p_token β wedge spread)
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): 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
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.
No comments yet.