# DF-0823: hammer2_xop_helper cleanup/create mismatch — UAF on multi-chain PFS unmount

## Verdict: REPRODUCED (live kernel panic) — FIX VALIDATED

**Severity:** High (kernel UAF → panic on default GENERIC with INVARIANTS)
**Impact:** `panic` — confirmed by live reproduction on DragonFly 6.5-DEVELOPMENT #0
**CWE:** CWE-416 (Use-After-Free)

## Root cause (TWO bugs working together)

### Bug 1: `hammer2_xop_helper_create` double-allocates `xop_groups` (memory leak + thread orphan)

`hammer2_xop_helper_create` (hammer2_admin.c:426) unconditionally allocates `pmp->xop_groups`:

```c
pmp->xop_groups = kmalloc(hammer2_xop_nthreads * sizeof(hammer2_xop_group_t), ...);
```

It does NOT check if `xop_groups` is already allocated. This function is called from
THREE sites:
1. `hammer2_mount_helper` (vfsops.c:1708) — during mount, when `nchains` may be 1
2. `hammer2_xop_start_except` (admin.c:492) — lazily, guarded by `has_xop_threads == 0`
3. `hammer2_pfsalloc` (vfsops.c:589) — **unconditionally** when `pmp->mp || nchains >= 2`

When a multi-chain PFS forms (MASTER on device A, SLAVE chain joins from device B):
- Call #1 (at mount, nchains=1): allocates xop_groups_A, creates thread[0]
- Call #3 (when SLAVE joins, nchains=2): **overwrites** `pmp->xop_groups` with
  xop_groups_B (new allocation), creates thread[0]+thread[1] in xop_groups_B

The old xop_groups_A is **leaked**, and old thread[0] running in xop_groups_A is
**orphaned** — it continues running, accessing memory the kernel no longer tracks.

### Bug 2: `hammer2_xop_helper_cleanup` stops only MASTER-indexed threads

`hammer2_xop_helper_cleanup` (hammer2_admin.c:461) iterates `pfs_nmasters`:

```c
for (i = 0; i < pmp->pfs_nmasters; ++i) {   // only MASTER chains
```

`pfs_nmasters` counts only `HAMMER2_PFSTYPE_MASTER` chains (vfsops.c:536-542), while
the CREATE loop uses `cluster.nchains` (all chain types). For 1 MASTER + 1 SLAVE:
`nchains=2`, `pfs_nmasters=1`. Cleanup stops thread[0] (MASTER), then `kfree(xop_groups)`
(admin.c:468) frees xop_groups_B while thread[1] (SLAVE) is still running.

### Combined effect

After umount of a multi-chain PFS:
1. Cleanup stops only MASTER threads in xop_groups_B → SLAVE thread[1] orphaned
2. `kfree(xop_groups_B)` frees backing memory under surviving thread[1]
3. Orphan thread[1] continues in `hammer2_primary_xops_thread` (admin.c:1148):
   - Reads `thr->flags` from freed heap (line 1159)
   - `atomic_cmpset_int(&thr->flags, ...)` writes freed heap (lines 1213, 1243)
   - `xop->desc->storage_func(...)` dereferences function pointer via freed data (line 1220)
4. Additionally, old thread[0] in leaked xop_groups_A is still running and accesses
   the PMP structure which may also be freed → secondary UAF

## Live reproduction

The trigger (`trigger_v2.sh`) creates two vn-backed hammer2 devices, creates a MASTER
PFS on device A and a matching SLAVE PFS on device B, mounts both so the SLAVE chain
joins the MASTER's PMP (nchains=2), does I/O, then unmounts.

### Panic signature (serial console, unpatched kernel #0)

```
Fatal trap 12: page fault while in kernel mode
fault virtual address    = 0xfffff8011a1d5558
fault code               = supervisor write data, page not present
instruction pointer      = 0x8:0xffffffff8095da89
current process          = Idle
current thread           = pri 12 (CRIT)

Stopped at      hammer2_primary_xops_thread+0x2d9:      lock xaddl      %edx,0x81558(%rsi)
```

The `lock xaddl %edx,0x81558(%rsi)` is an atomic operation within the orphaned
`hammer2_primary_xops_thread`. The page at the target address was freed by
`kfree(xop_groups)` and unmapped, causing the page-not-present fault.

### Trigger sequence

1. Create two vn-backed hammer2 images, `newfs_hammer2` each
2. Mount vn0@DATA → create MASTER PFS "testpfs" on vn0
3. Mount vn0@testpfs → PMP created (nchains=1), `xop_helper_create` call #1
4. Mount vn1@DATA → `hammer2_update_pmps` finds SLAVE testpfs with matching clid →
   adds chain to PMP (nchains=2) → `xop_helper_create` call #3 (overwrites xop_groups!)
5. Create SLAVE PFS on vn1 with matching clid
6. I/O on /mnt/test to spin up XOP threads
7. `umount /mnt/h2b` (vn1@DATA) — `mount_count > 0` → `pfsfree_scan` skipped → SLAVE chain stays
8. `umount /mnt/test` (vn0@testpfs) — `xop_helper_cleanup`: stops thread[0] only
   (pfs_nmasters=1), `kfree(xop_groups_B)` → orphan threads survive
9. `umount /mnt/h2a` (vn0@DATA) — device cleanup triggers `pfsfree_scan` which
   frees the PMP → old orphan threads access freed PMP/xop_groups → **PANIC**

## Fix (two changes, both required)

### Change 1: Guard xop_groups allocation in `hammer2_xop_helper_create`

```diff
-	pmp->xop_groups = kmalloc(hammer2_xop_nthreads *
-				  sizeof(hammer2_xop_group_t),
-				  M_HAMMER2, M_WAITOK | M_ZERO);
+	if (pmp->xop_groups == NULL) {
+		pmp->xop_groups = kmalloc(hammer2_xop_nthreads *
+					  sizeof(hammer2_xop_group_t),
+					  M_HAMMER2, M_WAITOK | M_ZERO);
+	}
```

This prevents the double allocation: on the second call, existing xop_groups is
reused, and only missing threads (for new chain indices) are created.

### Change 2: Fix cleanup loop to match create loop's range

```diff
-	for (i = 0; i < pmp->pfs_nmasters; ++i) {
+	for (i = 0; i < pmp->iroot->cluster.nchains; ++i) {
```

This ensures ALL thread columns (MASTER, SLAVE, etc.) are stopped before kfree.

**Both changes are required.** Change 1 alone still leaves the cleanup loop mismatch
(thread[1] created but not stopped). Change 2 alone doesn't prevent the double
allocation leak (old threads still orphaned).

## Fix validation

| Kernel | Version | Result |
|--------|---------|--------|
| Unpatched baseline | `#0` 2026-07-02 | **PANIC**: `hammer2_primary_xops_thread+0x2d9` within seconds of umount |
| Single-fix (cleanup only) | `#1` 2026-07-06 04:26 | **PANIC**: same signature (cleanup fix alone insufficient — double alloc still orphans old threads) |
| Two-fix (create + cleanup) | `#1` 2026-07-06 05:07 | **CLEAN**: trigger completes, guest survives 45s+ post-umount, no panic |

The two-fix kernel (`sha256=e5fd2095...`) was validated with `trigger_v2.sh`: all
three unmounts completed, the 20s wait completed, and the guest remained alive and
healthy 45+ seconds post-umount (well past the 30s tsleep interval). The serial
console shows no panic, trap, or DDB prompt.
