# Variant RCA Report: CVE-2026-7474

## Summary

CVE-2026-7474 is a path-traversal vulnerability in HashiCorp Nomad's dynamic host volume plugin loader. The original reproduction showed that a malicious `PluginID` containing `../` sequences could be passed through `HostVolume.Create` to the client, where `filepath.Join(pluginDir, filename)` resolved outside the plugin directory and allowed arbitrary code execution.

Variant analysis examined three distinct attack hypotheses against the fixed version (v2.0.1):
1. **Symlink bypass:** A symlink inside the plugin directory pointing to an arbitrary executable outside.
2. **Register → Delete chain:** Updating an existing volume's `PluginID` via `HostVolume.Register`, then deleting it to trigger execution.
3. **Path traversal with `..`:** The original payload tested directly against `NewHostVolumePluginExternal`.

All three direct traversal variants were **blocked** on v2.0.1 by the client-side `os.OpenRoot` containment in `NewHostVolumePluginExternal`. No bypass of the patched code path was found. The Register endpoint remains a latent variant path because it does not validate `PluginID`, but it cannot be exploited because the client-side sink is hardened.

## Fix Coverage / Assumptions

The fix (`cd7240c4099ad33eda279924fb3a9459b162d120`) relies on two invariants:

1. **Client-side `os.OpenRoot` is unbypassable.** `root.Stat(filename)` uses Linux `openat2` with `RESOLVE_IN_ROOT`, which prevents `..` resolution, absolute paths, and symlink escapes from leaving the opened directory. Empirical testing with Go 1.24+ confirmed this blocks `../`, `../../bin/ls`, absolute paths, and symlinks pointing outside the root.
2. **Server-side feasibility check blocks malicious requests before forwarding.** Even when an explicit `NodeID` is provided, `placeHostVolume` now enforces the `${attr.plugins.host_volume.<plugin>.version} is_set` constraint, preventing traversal payloads from reaching the client via the normal `HostVolume.Create` path.

The fix does **not** cover:
- The `HostVolume.Register` endpoint, which does not validate `PluginID` for path traversal and does not enforce plugin feasibility.
- Agent restart via `restoreFromState`, which loads `PluginID` from local disk state.

## Variant / Alternate Trigger

### Variant 1: Symlink escape via `os.OpenRoot`
- **Hypothesis:** A symlink placed inside the plugin directory could point to `/bin/ls`. On v2.0.0, `os.Stat` followed the symlink and found the target executable. On v2.0.1, `root.Stat` with `RESOLVE_IN_ROOT` blocks symlinks that escape the root.
- **Entry point:** Same as original (`HostVolume.Create` with `PluginID = "symlink_plugin"`).
- **Sink:** `NewHostVolumePluginExternal` → `root.Stat` → blocked.

### Variant 2: Register → Delete chain
- **Hypothesis:** `HostVolume.Register` allows updating an existing volume's `PluginID` because `ValidateUpdate` does not check `PluginID` changes and Register does not call `placeHostVolume`. After updating the `PluginID` to a traversal payload, `HostVolume.Delete` forwards the stored `PluginID` to the client.
- **Entry point:** `HostVolume.Register` followed by `HostVolume.Delete`.
- **Sink:** `HostVolumeManager.Delete` → `getPlugin` → `NewHostVolumePluginExternal` → blocked on v2.0.1 by `os.OpenRoot`.

### Variant 3: Direct traversal payload
- **Hypothesis:** Directly calling `NewHostVolumePluginExternal` with `PluginID = "../../../../bin/ls"`.
- **Entry point:** Any RPC path that reaches `getPlugin` (Create, Delete, restoreFromState).
- **Sink:** `NewHostVolumePluginExternal` → blocked on v2.0.1.

## Impact

- **Package/component:** `github.com/hashicorp/nomad/client/hostvolumemanager`
- **Affected versions:** v2.0.0 and earlier (vulnerable to variants); v2.0.1 (blocked)
- **Risk level on vulnerable version:** High — arbitrary command execution as Nomad client user
- **Risk level on fixed version:** None — variants are blocked at the client sink

## Root Cause

The underlying root cause is the same as the original CVE: `NewHostVolumePluginExternal` resolves a user-supplied filename against a filesystem directory without constraining the resolution to that directory. The fix replaces `filepath.Join` + `os.Stat` with `os.OpenRoot` + `root.Stat`, which enforces the containment invariant at the kernel level via `RESOLVE_IN_ROOT`.

The Register endpoint is a **latent variant path** because it lacks the same input validation on `PluginID`, but it cannot be exploited on the fixed version because the client sink is hardened.

## Reproduction Steps

1. Run `bash vuln_variant/reproduction_steps.sh`
2. The script:
   - Creates Git worktrees for `v2.0.0` (vulnerable) and `v2.0.1` (fixed)
   - Copies a Go test (`variant_test_test.go`) into both worktrees
   - Runs `go test -run TestVariantAttempts` on both
   - Compares results
3. Expected evidence:
   - On v2.0.0: `Variant 1 (traversal): err = <nil>` and `Variant 2 (symlink): err = <nil>`
   - On v2.0.1: `Variant 1 (traversal): err = no such plugin` and `Variant 2 (symlink): err = no such plugin`

## Evidence

- `logs/vuln_variant/variant_vuln.txt` — test output from v2.0.0 showing traversal and symlink variants succeed
- `logs/vuln_variant/variant_fixed.txt` — test output from v2.0.1 showing both variants blocked by `os.OpenRoot`
- `vuln_variant/patch_analysis.md` — detailed diff analysis of the fix

## Recommendations / Next Steps

1. **Harden the Register endpoint:** Add `PluginID` validation to `HostVolume.ValidateUpdate` or `HostVolume.Register` to reject path traversal characters (`..`, `/`, `\`). This provides defense-in-depth in case the client-side `os.OpenRoot` is ever bypassed or not available (e.g., on older kernels without `openat2`).
2. **Normalize/sanitize PluginID early:** Apply a basename-only validation (e.g., `filepath.Base(pluginID) == pluginID && !strings.Contains(pluginID, "..")`) at the API boundary before any storage or RPC forwarding.
3. **Audit other filepath.Join usages:** Search the codebase for other places where user input is joined with a configured directory path to ensure consistent containment.

## Additional Notes

- **Idempotency:** The reproduction script was run twice consecutively and produced identical results.
- **Go version:** Tests run with Go 1.24.7 on Linux amd64. `os.OpenRoot` requires kernel ≥5.6 for `openat2` with `RESOLVE_IN_ROOT`.
- **Trust boundary:** All tested variants require authenticated access (host-volume-create or host-volume-register ACL). The trust boundary is crossed when the attacker submits an API request that reaches the client RPC.
