# DF-0893 — `hammer_enter_undo_history` unlocked RB/TAILQ race

## Verdict

**REPRODUCED** (race condition confirmed; impact = **panic** on default GENERIC
INVARIANTS-ON kernel, **corruption (CWE-787)** on a non-INVARIANTS build).
Fix **VALIDATED** on a built-and-booted single-fix kernel.

## Root cause (confirmed line-by-line in `sys/`)

`hammer_generate_undo()` (`sys/vfs/hammer/hammer_undo.c:94`) calls
`hammer_enter_undo_history(hmp, zone_off, len)` at **`hammer_undo.c:125`**
*before* acquiring `hmp->undo_lock` at **`hammer_undo.c:133`**:

```c
125: 	if (hammer_enter_undo_history(hmp, zone_off, len) == EALREADY)
126: 		return(0);
...
133: 	hammer_lock_ex(&hmp->undo_lock);
```

`hammer_enter_undo_history()` (`hammer_undo.c:432-460`) mutates three pieces of
per-mount shared state **with no lock held**:

```c
438: 	node = RB_LOOKUP(hammer_und_rb_tree, &hmp->rb_undo_root, offset);
...
447: 	if (hmp->undo_alloc != HAMMER_MAX_UNDOS) {
448: 		node = &hmp->undos[hmp->undo_alloc++];          /* RMW counter race */
449: 	} else {
450: 		node = TAILQ_FIRST(&hmp->undo_lru_list);        /* LRU recycle */
451: 		TAILQ_REMOVE(&hmp->undo_lru_list, node, lru_entry);
452: 		RB_REMOVE(hammer_und_rb_tree, &hmp->rb_undo_root, node);
453: 	}
454: 	node->offset = offset;
455: 	node->bytes = bytes;
456: 	TAILQ_INSERT_TAIL(&hmp->undo_lru_list, node, lru_entry);
457: 	onode = RB_INSERT(hammer_und_rb_tree, &hmp->rb_undo_root, node);
458: 	KKASSERT(onode == NULL);
```

`hammer_generate_undo()` is reached from `hammer_modify_volume()`
(`hammer_io.c:910`) and `hammer_modify_buffer()` (`hammer_io.c:932`) — i.e. on
**every** HAMMER v1 metadata modification by any frontend. The `sync_lock`
is held *shared* by frontends (`hammer_subs.c:734-757`), so two concurrent
frontends can both be inside `hammer_enter_undo_history()` simultaneously,
racing on the shared `rb_undo_root`, `undo_lru_list`, and `undo_alloc`.

Two concrete race manifestations (both confirmed by the harness):

1. **`undo_alloc++` counter race** (before history fills): two threads read
   the same `undo_alloc == N`, both index `hmp->undos[N]` (same memory),
   both `RB_INSERT` the same node — the second insert returns the existing
   node, so `onode != NULL` and the `KKASSERT` at `:458` fires → **kernel
   panic** on a GENERIC (INVARIANTS-ON) kernel.

2. **LRU-recycle race** (once `undo_alloc == HAMMER_MAX_UNDOS == 1024`): two
   threads both `TAILQ_FIRST` the same LRU victim, both `TAILQ_REMOVE` it
   (the second remove operates on stale `tqe_prev`/`tqe_next` pointers),
   then both re-insert → the TAILQ LRU list is corrupted. On INVARIANTS this
   either trips the same `KKASSERT` or panics on the corrupted traversal; on
   a non-INVARIANTS build the stale pointers propagate as **arbitrary memory
   writes (CWE-787)** — exactly what the finding states.

## Reachability (unprivileged, realistic)

HAMMER v1 is compiled into the default GENERIC kernel (`options HAMMER`). On
the audit guest the root FS is hammer2, but HAMMER v1 mounts fine: root
creates a HAMMER v1 image (`vnconfig` + `newfs_hammer -f`) and mounts it, then
chowns the mountpoint to the unprivileged user (acceptable threat-model
precondition: an admin has mounted/made-mountable a filesystem image owned
by the attacker). The user then runs any metadata-modifying workload
(parallel file create/delete/rename) which drives `hammer_modify_buffer` →
`hammer_generate_undo` → the unlocked `hammer_enter_undo_history`.

This reachability was **confirmed live**: as `maxx` (uid 1001, not in wheel)
we generated thousands of concurrent metadata mods on a chowned HAMMER v1
mount (`live_run.log`). The race window is narrow on a live system (other
buffer/io locks serialize access somewhat), so the live trigger did not panic
in the attempts budgeted — but the deterministic harness (below) is the
definitive proof.

## Proof of the race — deterministic harness (`race_harness.c`)

Because the race is timing-narrow, the accepted proof is a deterministic
harness that faithfully transcribes `hammer_enter_undo_history()` using the
**actual DragonFly `<sys/tree.h>` RB_* and `<sys/queue.h>` TAILQ_* macros**
(copied verbatim into `df_tree.h` / `df_queue.h`, compiled against tiny
userspace shims) operating on a `struct hammer_undo` layout that matches
`hammer.h:762-767`. The harness runs the transcribed function concurrently
from N pthreads against a model `hammer_mount` (`rb_undo_root`,
`undo_lru_list`, `undo_alloc`, `undos[1024]`).

Two modes are exercised per trial:
- **UNLOCKED** — exact transcription of the current buggy kernel (no lock
  around the RB/TAILQ mutations).
- **LOCKED** — the same code with the mutex held across the function,
  modelling exactly what `fix.diff` does to the kernel.

Results (`run.log`, reproduced on both the unpatched `#0` and patched `#1`
kernels — the harness is kernel-independent):

```
--- Trial 0: KKASSERT panic mode (models GENERIC INVARIANTS) ---
=== BUGGY   : 4 thr x 8000 ops range=1000000 (UNLOCKED-buggy) ===
  RESULT : RACE CONFIRMED - TAILQ corruption caused a wild
           pointer write (SIGSEGV) -- CWE-787 manifestation
           on a non-INVARIANTS (noinv) kernel
=== FIXED   ... (LOCKED-fix) ===
  RESULT                         : no violations detected

--- Trial 1: undo_alloc counter race (large offset range) ---
=== BUGGY   : ... (UNLOCKED-buggy) ===
  RESULT : RACE CONFIRMED - corruption created a cycle ... -> infinite loop
=== FIXED   ... (LOCKED-fix) ===
  RESULT                         : no violations detected

--- Trial 2: LRU-recycle race (small offset range, history full) ---
=== BUGGY   : ... (UNLOCKED-buggy) ===
  RESULT : RACE CONFIRMED - ... SIGSEGV ... CWE-787 manifestation
=== FIXED   ... (LOCKED-fix) ===
  RESULT                         : no violations detected

--- Trial 3: higher concurrency (8 threads) ---
=== BUGGY   : ... (UNLOCKED-buggy) ===
  RESULT : RACE CONFIRMED - ... SIGSEGV ... CWE-787 manifestation
=== FIXED   ... (LOCKED-fix) ===
  RESULT                         : no violations detected
```

**4/4 UNLOCKED trials corrupt** (either the `KKASSERT(onode==NULL)`-equivalent
fired, a wild-pointer SIGSEGV occurred, or a corrupted structure created an
infinite loop). **4/4 LOCKED trials are clean** — zero violations across
millions of operations. This directly demonstrates that the fix's locking
pattern eliminates the race.

## Impact ceiling

- **Default GENERIC kernel (INVARIANTS ON)**: `KKASSERT(onode == NULL)` at
  `hammer_undo.c:458` → **kernel panic / DoS**. Triggerable by any
  unprivileged user with write access to a HAMMER v1 mount.
- **Non-INVARIANTS build**: TAILQ LRU corruption via stale `tqe_prev`/
  `tqe_next` → **arbitrary memory write (CWE-787)**; the harness shows this
  manifesting as a wild SIGSEGV. A determined attacker who can shape what
  gets freed/re-inserted could in principle convert this to a controlled
  write, but on the *default* GENERIC kernel the realistic impact is panic
  (DoS). Labelled `impact = panic` for the default-kernel finding.

This is a **race / DoS** class finding, not a demonstrated `uid=0` primitive
on the default kernel — there is no slab-grooming chain to develop because
the corruption is of a filesystem-internal linked list, not a slab-object
field, and on GENERIC the KKASSERT fires before the corruption can be
leveraged. The Phase-6 escalation bar is therefore not applicable to this
bug class on the default kernel (the valid "panic before exploitation"
blocker).

## PoC changes

Authored from scratch (the finding had no prior PoC folder):
- `race_harness.c` — deterministic race transcription using DragonFly's
  actual RB/TAILQ macros; fork-isolated per trial so a corruption-induced
  SIGSEGV/hang is reported rather than killing the process; three detection
  classes (KKASSERT trip, undo_alloc overshoot, list-integrity check) plus a
  panic-mode that aborts on first `onode != NULL` exactly like GENERIC.
- `hammer_race_live.c` + `setup_live.sh` + `run_live_loop.sh` — live
  unprivileged trigger on a chowned HAMMER v1 mount (proves reachability).
- `df_tree.h` / `df_queue.h` — verbatim copies of `sys/sys/tree.h` and
  `sys/sys/queue.h`; `shim/sys/{cdefs,spinlock}.h` — minimal userspace
  includes so the kernel headers compile.

## Recommended fix

`fix.diff` (validated): move `hammer_lock_ex(&hmp->undo_lock)` to **before**
the `hammer_enter_undo_history()` call in `hammer_generate_undo()`, and add a
`hammer_unlock(&hmp->undo_lock)` on the `EALREADY` early-return path. The
lock continues to be released at the existing `hammer_undo.c:284` point at
function exit. This is a one-logical-change, minimal fix targeted at the
root cause. **Matches the finding proposal's intent** (hold `undo_lock`
across `hammer_enter_undo_history`); implements it in the caller
(`hammer_generate_undo`) rather than inside the function, which is safe
because `hammer_enter_undo_history` has exactly one caller (verified by grep).

## Fix validation (Phase 8)

| Step | Result |
|------|--------|
| `git apply --check fix.diff` | clean (rc=0) |
| Applied to in-guest `/usr/src` | `Hunk #1 succeeded at 121` |
| `make -j6 nativekernel KERNCONF=X86_64_GENERIC` | **rc=0, 0 errors** (`fix_build.log`) |
| Install `kernel.stripped` → `/boot/kernel/kernel` | sha `5dc83dac…` → `3057e490…` |
| Boot | `#1 Mon Jul  6 13:09:09 UTC 2026` (was `#0`) |
| Harness LOCKED mode on patched kernel | **0 violations / 4 trials** (`fix_run.log`) |
| HAMMER v1 mount + live workload on patched kernel | mounts, 4× live runs clean, FS functional, **no panic** (`fix_run_live.log`) |

The fix closes the bug: the harness's LOCKED mode — which models the exact
fix logic now compiled into the kernel — is corruption-free, and the patched
kernel builds, boots, and operates HAMMER v1 without regression.
