{"repro_id":"REPRO-2026-00185","version":8,"title":"HashiCorp Nomad: path traversal in host volume plugin loader → client-host RCE","repro_type":"security","status":"published","severity":"high","description":"Nomad's dynamic host volume feature lets operators create volumes on client nodes via external plugins (small executables in a configured plugin directory). When a volume is created, the server RPC `HostVolume.Create` forwards a `ClientHostVolume.Create` RPC to the target client. The client's `HostVolumeManager.getPlugin` passes the attacker-supplied `PluginID` directly into `NewHostVolumePluginExternal` in `client/hostvolumemanager/host_volume_plugin.go` (around line 229 in `v2.0.0`). There, the code does:\n\n```go\nexecutable := filepath.Join(pluginDir, filename)\nf, err := os.Stat(executable)\n```\n\nIf `filename` (the `PluginID`) contains path-traversal sequences such as `../../../bin/sh`, `filepath.Join` resolves outside the intended plugin directory. The code then checks whether that arbitrary path exists and is executable; if so, it stores the escaped path in `HostVolumePluginExternal.Executable`. That executable is later invoked via `exec.CommandContext` during `Fingerprint`, `Create`, and `Delete` operations, yielding **arbitrary code execution as the Nomad client user**.\n\nServer-side validation is bypassed when the attacker provides an explicit `NodeID` in the create request: the server's `placeHostVolume` shortcut skips the feasibility check that would otherwise verify the plugin exists on the node (via the `${attr.plugins.host_volume.<plugin>.version}` node attribute). This means a user with `host-volume-create` ACL capability in a namespace can target any client node and run any binary on its filesystem.","root_cause":"# RCA Report: CVE-2026-7474\n\n## Summary\n\nCVE-2026-7474 is a path traversal vulnerability in HashiCorp Nomad's dynamic host volume plugin loader that allows arbitrary command execution on Nomad client nodes. When an authenticated user with `host-volume-create` ACL capability submits a `HostVolume.Create` request with a malicious `PluginID` containing directory traversal sequences (e.g., `../../../../bin/ls`) and an explicit `NodeID`, the server bypasses plugin feasibility checks and forwards the request to the target client. The client's `NewHostVolumePluginExternal` function uses `filepath.Join(pluginDir, filename)` followed by `os.Stat`, which resolves the traversal to an arbitrary executable outside the configured plugin directory. That executable is later invoked via `exec.CommandContext` during volume `Create`, `Fingerprint`, or `Delete` operations.\n\n## Impact\n\n- **Package/component affected:** `github.com/hashicorp/nomad/client/hostvolumemanager` (`host_volume_plugin.go`), `github.com/hashicorp/nomad/nomad` (`host_volume_endpoint.go`)\n- **Affected versions:** Prior to `v2.0.1` (confirmed vulnerable in `v2.0.0`, `v1.10.5`, `v1.11.2`)\n- **Fixed versions:** `v2.0.1`\n- **Risk level:** High (CVSS 3.1: 8.8)\n- **Consequences:** Authenticated attackers with namespace-level `host-volume-create` ACLs can force any Nomad client node to execute arbitrary binaries from the host filesystem as the Nomad client user, leading to full client-node compromise.\n\n## Root Cause\n\nThe vulnerability stems from two missing validations in the host volume create workflow:\n\n1. **Client-side path traversal (CWE-22):** `NewHostVolumePluginExternal` in `client/hostvolumemanager/host_volume_plugin.go` (v2.0.0, ~line 224) constructs the plugin executable path with `filepath.Join(pluginDir, filename)` and then calls `os.Stat(executable)`. Because `filepath.Join` does not reject `../` sequences, a malicious `PluginID` resolves to any path on the filesystem. If the target file exists and is executable, the client stores the escaped path and later invokes it via `exec.CommandContext`.\n\n2. **Server-side feasibility bypass:** In `nomad/host_volume_endpoint.go`, the `placeHostVolume` function skips the plugin feasibility constraint (`${attr.plugins.host_volume.<plugin>.version} is_set`) when the user explicitly provides a `NodeID`. This was intended as an optimization, but it allows an attacker to force placement onto a node that does not legitimately advertise the plugin, bypassing the only server-side guard that could block the malicious request.\n\nThe fix commit (`cd7240c4099ad33eda279924fb3a9459b162d120`, released in `v2.0.1`) addresses both issues:\n- **Client-side:** Replaces `filepath.Join` + `os.Stat` with `os.OpenRoot(pluginDir)` and `root.Stat(filename)`. On Go 1.24+, `os.Root.Stat` rejects paths that escape the root directory, returning an error that is mapped to `ErrPluginNotExists`.\n- **Server-side:** Moves the plugin feasibility constraint so it is checked **even when the request already specifies an explicit `NodeID`**, preventing traversal payloads from being forwarded to the client.\n\n## Reproduction Steps\n\nThe reproduction script is `repro/reproduction_steps.sh`. It performs the following steps:\n\n1. Clones the HashiCorp Nomad repository (if not already present) and checks out both `v2.0.0` (vulnerable) and `v2.0.1` (fixed).\n2. Builds the `nomad` binary for each tag (`bin/nomad-vuln` and `bin/nomad-fixed`).\n3. Writes a minimal `nomad agent -dev` configuration that enables both server and client roles, binds to `127.0.0.1`, and sets a `host_volume_plugin_dir`.\n4. Starts the vulnerable agent, waits for it to be healthy, and queries its NodeID via `/v1/nodes`.\n5. Sends a malicious `PUT /v1/volume/host/create` request with:\n   - `PluginID: \"../../../../bin/ls\"`\n   - Explicit `NodeID`\n   - Minimal capacity constraints\n6. Captures the HTTP response body and code into `logs/vulnerable_api.txt`.\n7. Stops the vulnerable agent, frees ports, and repeats steps 4–6 with the fixed binary.\n8. Captures the fixed response into `logs/fixed_api.txt`.\n9. Writes `repro/runtime_manifest.json` with both results.\n10. Verifies that the vulnerable run shows evidence of `/bin/ls` execution (`exit status 2`) while the fixed run shows a server-side placement rejection (`node ... is not feasible for volume`).\n\n### Expected evidence\n\n- **Vulnerable (`v2.0.0`):** HTTP 500 response containing:\n  `HostVolume.Create error: error creating volume \"<id>\" with plugin \"../../../../bin/ls\": exit status 2`\n  This proves the server forwarded the request, the client resolved the traversal to `/bin/ls`, and executed `/bin/ls create` (which exits with code 2 because `ls` does not understand the `create` subcommand).\n\n- **Fixed (`v2.0.1`):** HTTP 500 response containing:\n  `could not place volume \"cve-test-vol\": node <id> is not feasible for volume`\n  This proves the server now enforces the plugin feasibility constraint even with an explicit `NodeID`, rejecting the request before it ever reaches the client.\n\n## Evidence\n\n- `logs/vulnerable_api.txt` — API response from the vulnerable agent run.\n- `logs/fixed_api.txt` — API response from the fixed agent run.\n- `repro/runtime_manifest.json` — Structured manifest with payload, endpoint, and captured HTTP codes for both versions.\n- `external/nomad/bin/nomad-vuln` and `external/nomad/bin/nomad-fixed` — Compiled Nomad binaries used during reproduction.\n\n### Key excerpts\n\nVulnerable response (`logs/vulnerable_api.txt`):\n```\nHostVolume.Create error: error creating volume \"f09840bb-a5d8-0b96-39c6-77deafd35c19\" with plugin \"../../../../bin/ls\": exit status 2\nHTTP_CODE:500\n```\n\nFixed response (`logs/fixed_api.txt`):\n```\ncould not place volume \"cve-test-vol\": node 33225465-afa9-a5c1-2acf-02c0de00879a is not feasible for volume\nHTTP_CODE:500\n```\n\n## Recommendations / Next Steps\n\n1. **Upgrade immediately:** All Nomad deployments should be upgraded to `v2.0.1` or later. The fix includes both client-side `os.OpenRoot` containment and server-side feasibility enforcement.\n2. **ACL hardening:** Restrict `host-volume-create` capabilities to the smallest set of namespaces and users that genuinely require it. Even with the fix, the capability is powerful.\n3. **Network segmentation:** Place Nomad client agents on isolated networks so that even a compromised client cannot easily pivot to other infrastructure.\n4. **Monitoring:** Alert on host volume create requests that reference unusual `PluginID` values (e.g., containing `..`, `/`, or absolute paths) as a defense-in-depth measure.\n5. **Testing:** Add regression tests that verify `os.Root.Stat` rejects traversal sequences and that `placeHostVolume` always checks plugin feasibility regardless of whether `NodeID` is explicit.\n\n## Additional Notes\n\n- **Idempotency:** The reproduction script was executed twice consecutively and produced identical differential behavior on both runs (vulnerable → `exit status 2`, fixed → `not feasible`).\n- **Environment:** Reproduction performed in an Ubuntu container with Go 1.24.7. A cgroup workaround (`mount -t tmpfs` over `/sys/fs/cgroup/cpuset`) was required because the container lacked the `cpuset.mems` file expected by Nomad's cgroup initialization.\n- **Limitations:** The reproduction demonstrates the traversal through the actual HTTP API and real agent binaries. The `exit status 2` artifact proves command execution of the escaped path. A fully successful volume creation would require a compliant plugin executable; using `/bin/ls` intentionally produces a failure that still proves the vulnerability.\n","ghsa_id":"GHSA-hx53-77qj-8663","cve_id":"CVE-2026-7474","source_url":"https://github.com/hashicorp/nomad","package":{"name":"nomad","ecosystem":"go","affected_versions":"prior to v2.0.1 (confirmed at v2.0.0, v1.10.5, v1.11.2)","fixed_version":"v2.0.1"},"reproduced_at":"2026-05-28T19:26:49.817932+00:00","duration_secs":2889.6104748249054,"tool_calls":337,"turns":306,"handoffs":2,"total_cost_usd":3.4800842900000006,"agent_costs":{"repro":1.90534092,"support":0.02492035,"vuln_variant":1.54982302},"cost_breakdown":{"repro":{"accounts/fireworks/models/kimi-k2p6":1.90534092},"support":{"accounts/fireworks/models/kimi-k2p6":0.02492035},"vuln_variant":{"accounts/fireworks/models/kimi-k2p6":1.54982302}},"quality":{"confidence":"high","idempotent_verified":false,"community_verifications":0},"published_at":"2026-05-28T19:26:52.151398+00:00","retracted":false,"artifacts":[{"path":"bundle/repro/rca_report.md","filename":"rca_report.md","size":7599,"category":"analysis"},{"path":"bundle/repro/reproduction_steps.sh","filename":"reproduction_steps.sh","size":6240,"category":"reproduction_script"},{"path":"bundle/vuln_variant/rca_report.md","filename":"rca_report.md","size":6623,"category":"analysis"},{"path":"bundle/vuln_variant/reproduction_steps.sh","filename":"reproduction_steps.sh","size":3280,"category":"reproduction_script"},{"path":"bundle/context.json","filename":"context.json","size":3466,"category":"other"},{"path":"bundle/metadata.json","filename":"metadata.json","size":865,"category":"other"},{"path":"bundle/ticket.md","filename":"ticket.md","size":7258,"category":"ticket"},{"path":"bundle/repro/runtime_manifest.json","filename":"runtime_manifest.json","size":435,"category":"other"},{"path":"bundle/repro/validation_verdict.json","filename":"validation_verdict.json","size":3320,"category":"other"},{"path":"bundle/repro/cve_repro.go","filename":"cve_repro.go","size":804,"category":"other"},{"path":"bundle/vuln_variant/variant_test.go","filename":"variant_test.go","size":1800,"category":"other"},{"path":"bundle/vuln_variant/root_cause_equivalence.json","filename":"root_cause_equivalence.json","size":1282,"category":"other"},{"path":"bundle/vuln_variant/patch_analysis.md","filename":"patch_analysis.md","size":4801,"category":"documentation"},{"path":"bundle/vuln_variant/variant_manifest.json","filename":"variant_manifest.json","size":2705,"category":"other"},{"path":"bundle/vuln_variant/validation_verdict.json","filename":"validation_verdict.json","size":2933,"category":"other"},{"path":"bundle/vuln_variant/source_identity.json","filename":"source_identity.json","size":647,"category":"other"},{"path":"bundle/logs/vulnerable_api.txt","filename":"vulnerable_api.txt","size":148,"category":"other"},{"path":"bundle/logs/nomad_v2.0.1_stderr.txt","filename":"nomad_v2.0.1_stderr.txt","size":291,"category":"other"},{"path":"bundle/logs/nomad-fixed_stderr.txt","filename":"nomad-fixed_stderr.txt","size":291,"category":"other"},{"path":"bundle/logs/nomad-vuln_stderr.txt","filename":"nomad-vuln_stderr.txt","size":291,"category":"other"},{"path":"bundle/logs/nomad-fixed_stdout.txt","filename":"nomad-fixed_stdout.txt","size":6857,"category":"other"},{"path":"bundle/logs/vuln_variant/variant_fixed.txt","filename":"variant_fixed.txt","size":421,"category":"other"},{"path":"bundle/logs/vuln_variant/variant_vuln.txt","filename":"variant_vuln.txt","size":363,"category":"other"},{"path":"bundle/logs/retry_run.txt","filename":"retry_run.txt","size":1071,"category":"other"},{"path":"bundle/logs/variant_fixed.txt","filename":"variant_fixed.txt","size":421,"category":"other"},{"path":"bundle/logs/nomad_v2.0.0_stderr.txt","filename":"nomad_v2.0.0_stderr.txt","size":291,"category":"other"},{"path":"bundle/logs/fixed_api.txt","filename":"fixed_api.txt","size":122,"category":"other"},{"path":"bundle/logs/nomad_config.hcl","filename":"nomad_config.hcl","size":329,"category":"other"},{"path":"bundle/logs/fixed_run.txt","filename":"fixed_run.txt","size":46,"category":"other"},{"path":"bundle/logs/variant_vuln.txt","filename":"variant_vuln.txt","size":363,"category":"other"},{"path":"bundle/logs/nomad_v2.0.0_stdout.txt","filename":"nomad_v2.0.0_stdout.txt","size":6373,"category":"other"},{"path":"bundle/logs/vulnerable_run.txt","filename":"vulnerable_run.txt","size":158,"category":"other"},{"path":"bundle/logs/nomad-vuln_stdout.txt","filename":"nomad-vuln_stdout.txt","size":6373,"category":"other"},{"path":"bundle/logs/nomad_v2.0.1_stdout.txt","filename":"nomad_v2.0.1_stdout.txt","size":6130,"category":"other"}]}