Skip to content

CVE-2026-14891: Verified Reproduction

CVE-2026-14891: HashiCorp Nomad's Docker task driver can be tricked into bind-mounting host paths via a symlink containment bypass, enabling sandbox escape and host file read/write.

CVE-2026-14891 is verified against hashicorp/nomad · other. Affected versions: Nomad CE 0.4.1 through 2.0.3 (all releases prior to 2.0.4); Nomad Enterprise prior to 2.0.4 / 1.11.8 / 1.10.14. Fixed in 2.0.4. This high reproduction includes runnable sandbox proof, artifacts, and a plain-text agent view under REPRO-2026-00385.

REPRO-2026-00385 hashicorp/nomad · other Oct 9, 2026 CVE entry .txt
Severity
HIGH
Confidence
HIGH
Reproduced in
110m 14s
Tool calls
224
Spend
$5.26
01 · Overview

What Is CVE-2026-14891?

CVE-2026-14891 is a high-severity vulnerability affecting hashicorp/nomad Nomad CE 0.4.1 through 2.0.3 (all releases prior to 2.0.4); Nomad Enterprise prior to 2.0.4 / 1.11.8 / 1.10.14. Pruva has independently reproduced it and publishes a verified, runnable proof-of-concept (reproduction REPRO-2026-00385).

02 · Severity & CVSS

CVE-2026-14891 Severity

CVE-2026-14891 is rated high severity.

HIGH threat level
Weakness CWE-59 Improper Link Resolution Before File Access (Link Following)

High — serious impact or readily exploitable. Prioritize remediation.

03 · Affected Versions

Affected hashicorp/nomad Versions

hashicorp/nomad · other versions Nomad CE 0.4.1 through 2.0.3 (all releases prior to 2.0.4); Nomad Enterprise prior to 2.0.4 / 1.11.8 / 1.10.14 are affected.

How to Reproduce CVE-2026-14891

$ pruva-verify REPRO-2026-00385
or curl -O https://www.pruva.dev/api/v1/reproductions/REPRO-2026-00385/artifacts/bundle/repro/reproduction_steps.sh && chmod +x reproduction_steps.sh && ./reproduction_steps.sh
Run in a VM or disposable container. This exploits a real vulnerability.
06 · Proof of Reproduction

Proof of Reproduction for CVE-2026-14891

sandbox escape — reproduced
  • reached the target end-to-end
  • full exploit chain demonstrated
  • on the real production code path
  • high confidence
  • the upstream fix blocks the same trigger
Trigger

Docker-driver job submitted via POST /v1/jobs: prestart task plants symlink /alloc/escape-root -> / in the shared allocation directory; main task declares mounts = [{type=bind, source=../alloc/escape-root, target=/host-escape, readonly=false}] whose source is lexically contained in the allocation directory but resolve…

Attack chain
  1. Nomad HTTP API (POST /v1/jobs)
  2. scheduler
  3. client task runner
  4. drivers/docker toDockerMount()/containerBinds() lexical isParentPath containment check (volumes disabled)
  5. Docker daemon bind mount follows symlink
  6. host filesystem mounted read-write inside task container
How the agent worked 477 events · 224 tool calls · 1h 50m
1h 50mDuration
224Tool calls
94Reasoning steps
477Events
6Dead-ends
Agent activity over 1h 50m
Policy
1
Support
20
Repro
170
Judge
37
Variant
244
Verify
1
0:00110:03

Root Cause and Exploit Chain for CVE-2026-14891

Versions: Nomad Community Edition ≤ 2.0.3 and Nomad Enterprise

HashiCorp Nomad's Docker task driver (Community Edition ≤ 2.0.3) validates bind-mount sources with a purely lexical path-containment check (isParentPath) when volume bind mounts are disabled. Because the check never resolves symbolic links, a job submitter can supply a mount whose source path appears to be inside the task's allocation directory but is actually a symlink that resolves to an arbitrary host path. The Docker daemon follows the symlink at bind-mount time, mounting the host path read-write into the task container — a sandbox escape from the allocation directory that grants host filesystem read/write even though volume bind mounts are disabled (the default).

  • Package/component affected: drivers/docker plugin of HashiCorp Nomad (drivers/docker/driver.go: toDockerMount() and containerBinds()).
  • Affected versions: Nomad Community Edition ≤ 2.0.3 and Nomad Enterprise ≤ 2.0.3 / 1.11.7 / 1.10.13 (fixed in Nomad CE/EE 2.0.4, EE 1.11.8, EE 1.10.14; advisory HCSEC-2026-21).
  • Risk level: High (CVSS 3.1 8.7, CWE-59). An authenticated job submitter (or any API client when ACLs are disabled) can read and write arbitrary files on the Nomad client host filesystem from inside a container, escaping the intended allocation sandbox.

Impact Parity

  • Disclosed/claimed maximum impact: Sandbox escape via Docker task driver — bind-mount host paths into a container despite bind mounts being disabled, allowing reading and writing files on the host filesystem.
  • Reproduced impact from this run: Full parity. On Nomad 2.0.3 with volumes.enabled = false, the Docker-driver task's bind mount (source = "../alloc/escape-root", a symlink to / planted by a prestart task in the same allocation) was accepted, and the task container received the whole Docker-daemon host filesystem mounted read-write at /host-escape. The task read the host's /etc/hostname (fc9e2e9012df, matching docker info --format '{{.Name}}') and wrote a marker file to a host path outside the allocation directory (/workspace/host-escape-zone/ESCAPE_WRITE_OK_*.txt), observable on the host filesystem. The identical job against Nomad 2.0.4 was rejected (volumes are not enabled; cannot mount host path) with no host write.
  • Parity: full.
  • Not demonstrated: Nothing material to the claim. (No arbitrary code execution on the host was claimed or needed; the escape primitive itself — arbitrary host file read/write from inside the container — was demonstrated.)

Root Cause

In Nomad 2.0.3, drivers/docker/driver.go toDockerMount() handled task-configured mounts of type bind as follows:

case "bind":
    hm.Source = expandPath(task.TaskDir().Dir, hm.Source)

    // paths inside alloc dir are always allowed as they mount within
    // a container, and treated as relative to task dir
    if !d.config.Volumes.Enabled && !isParentPath(task.AllocDir, hm.Source) {
        return nil, fmt.Errorf(
            "volumes are not enabled; cannot mount host path: %q %q",
            hm.Source, task.AllocDir)
    }

isParentPath (drivers/docker/utils.go) is purely lexical:

func isParentPath(parent, path string) bool {
    rel, err := filepath.Rel(parent, path)
    return err == nil && !strings.HasPrefix(rel, "..")
}

filepath.Rel operates on path strings only; it never consults the filesystem. Therefore a source path that is lexically inside the allocation directory — but is a symlink on the host pointing anywhere, e.g. to / — passes the containment check. The same flawed check exists in containerBinds() for the legacy volumes task attribute. The expanded source path is then handed verbatim to the Docker daemon as a bind-mount source; the daemon/kernel resolves the symlink at mount time and mounts the symlink's target, so the task container gains access to host paths outside the allocation directory.

An attacker who can submit Docker-driver jobs controls the content of the allocation directory on the client host (any task in the allocation can write to the shared alloc dir, which is bind-mounted into every task container at /alloc). A prestart task plants escape-root -> / there; the main task's mount source ../alloc/escape-root expands to <data_dir>/alloc/<alloc_id>/alloc/escape-root, which is lexically inside <data_dir>/alloc/<alloc_id> and therefore accepted by the vulnerable check.

Fix (v2.0.4, commit 5b83b133998a1f514beb81019930fb673b5ed669): the lexical isParentPath calls in toDockerMount() and containerBinds() were replaced by escapingfs.ChildEscapesParentDir() (new helper helper/escapingfs/escapes.go), which uses Go's os.OpenRoot/root.Stat to resolve symlinks inside the allocation directory and rejects any child that resolves to an absolute path outside it:

if !d.config.Volumes.Enabled {
    if err := escapingfs.ChildEscapesParentDir(task.AllocDir, hm.Source); err != nil {
        return nil, fmt.Errorf("volumes are not enabled; cannot mount host path: %q", hm.Source)
    }
}

Vulnerable checkout v2.0.3 (a2a5fee9c42d6481adcb9be865bcbccf8fd4d725) contains isParentPath and lacks the patch hunk; fixed checkout v2.0.4 (5b83b133998a1f514beb81019930fb673b5ed669) contains escapingfs.ChildEscapesParentDir (verified via git diff v2.0.3 v2.0.4 -- drivers/docker).

Reproduction Steps

  1. Script: bundle/repro/reproduction_steps.sh (run from the bundle root: bash bundle/repro/reproduction_steps.sh; exit 0 = confirmed).
  2. What the script does, end-to-end against the real product:
    • Verifies the Docker daemon is reachable and that /workspace is the same filesystem the daemon bind-mounts from (shared-host precondition), pulls alpine:3.19.
    • Downloads the official Nomad release binaries 2.0.3 (vulnerable) and 2.0.4 (fixed) from releases.hashicorp.com, verifying versions and recording SHA-256 digests.
    • For each of two vulnerable (2.0.3) and two fixed (2.0.4) attempts: starts a real single-node Nomad agent (server + client) with the Docker driver configured with volumes { enabled = false }, waits for /v1/agent/health (client+server ok) and for the docker driver to be detected on the node. Submits an identical batch job through the real HTTP API (POST /v1/jobs): a prestart Docker task plants ln -sfn / /alloc/escape-root in the shared allocation directory, and the main escape Docker task declares mounts = [{ type = "bind", source = "../alloc/escape-root", target = "/host-escape", readonly = false }] and then reads /host-escape/etc/hostname and writes /host-escape/workspace/host-escape-zone/<unique-marker>.txt. The script then captures allocation state, task events, task stdout, the host-side marker file, and the agent log; stops the agent; and cleans up its data dir.
    • Writes bundle/repro/runtime_manifest.json with per-attempt proof artifacts and SHA-256 digests.
  3. Expected evidence of reproduction (both observed in two consecutive runs):
    • Vulnerable (2.0.3): the escape task starts and exits 0; stdout shows HOST_HOSTNAME=fc9e2e9012df (the Docker daemon host's /etc/hostname) plus a full listing of the host root at /host-escape; the marker file appears on the host filesystem outside the allocation directory.
    • Fixed (2.0.4): the identical job fails closed with Driver Failure: ... volumes are not enabled; cannot mount host path: "<data_dir>/alloc/<alloc_id>/alloc/escape-root" / Not Restarting: Error was unrecoverable, and no marker is written.

Evidence

  • Per-attempt evidence: bundle/repro/artifacts/{vuln1,vuln2,fixed1,fixed2}/
    • job_request.json, job_submit_response.json — real API request/response for job submission.
    • alloc_detail.json, escape_task_events.txt — task states/events.
    • escape_task_stdout.log, prep_task_stdout.log — task logs via the Nomad logs API.
    • host_marker.txt — copy of the file the task wrote on the host through the escaped bind mount (vulnerable attempts only).
    • agent_attempt.log, attempt_summary.txt.
  • Driver log: bundle/logs/reproduction_steps.log.
  • Key excerpts (vulnerable, vuln1/escape_task_stdout.log):
    HOST_HOSTNAME=fc9e2e9012df
    ESCAPE_WRITE_OK_vuln1-1791556358
    bin bundle certs dev etc home lib media mnt opt proc pruva root run sbin ...
    
    (fixed, fixed1/escape_task_events.txt):
    Driver Failure: Failed to create container configuration for image "alpine:3.19" (...): volumes are not enabled; cannot mount host path: "/workspace/nomad-repro/data-fixed1-1791556392/alloc/<id>/alloc/escape-root"
    Not Restarting: Error was unreverable [sic] / Error was unrecoverable
    
  • Environment: Ubuntu 26.04 sandbox (linux/x86_64), rootless Docker daemon (Server 27.5.1) reachable at unix:///run/user/1000/docker.sock, sharing /workspace with the Nomad client; Nomad agents run as non-root user with the Docker driver only; volume bind mounts disabled (volumes.enabled = false, the plugin default). Target identity: Nomad 2.0.3 official release binary (revision a2a5fee9c42d6481adcb9be865bcbccf8fd4d725, binary SHA-256 7f1d3e1e49566ed6c6239c16d3fe1d29af362904408b4d01c3f64fc630022156); negative control: Nomad 2.0.4 (5b83b133998a1f514beb81019930fb673b5ed669, SHA-256 f22cf977f100f938e0056cef4b4717288b5da1ecb0bb8d69cc2f563f82e63f43).

Recommendations / Next Steps

  • Upgrade to Nomad Community Edition 2.0.4+ (or Enterprise 2.0.4, 1.11.8, 1.10.14), which rejects symlink-resolved mount sources via escapingfs.ChildEscapesParentDir (os.OpenRoot).
  • Operators who cannot upgrade immediately should enable ACLs so that only authorized principals can submit Docker-driver jobs, and should treat job submitters as having potential host-filesystem access on clients.
  • Consider defense-in-depth: audit the Docker daemon's view of client filesystems, and restrict task-configured mounts/volumes via policy where possible.
  • Testing: regression test should submit a Docker-driver job whose mount source is a symlink inside the allocation directory pointing to an absolute host path, and assert rejection when volumes.enabled = false (the driver test suite in 2.0.4 contains such cases).

Additional Notes

  • Idempotency confirmation: reproduction_steps.sh was executed twice consecutively in the same environment; both runs ended with results: vuln1=escaped vuln2=escaped fixed1=blocked fixed2=blocked, exit code 0, and a valid runtime_manifest.json. Job IDs and marker tokens are timestamp-unique so re-runs do not collide; each attempt uses a fresh agent data dir, and stale agents are killed before attempts start.
  • Limitations / environment notes: the "host" whose filesystem is escaped is the Docker-daemon host filesystem as seen by the Nomad client (in this sandbox, the rootless daemon container sharing /workspace). This is exactly the production model — Nomad client and Docker daemon on the same host. The mount is performed by the real Docker daemon following the real symlink planted through the real allocation-directory bind mount. In a production cluster the escape would apply to the Nomad client host's filesystem. The write was additionally verified from outside the container by observing the marker file appear on the host filesystem.
  • The docker driver logs a warning that running non-root disables NUMA/core scheduling; this does not affect the reproduction. The escape requires no privileged container, no caps, and works with the driver's default volumes.enabled = false.

CVE-2026-14891 Reproduction Transcript

The agent's step-by-step process — every tool call, every handoff, the moment the exploit fired.

Full session Replay every step — scrub the timeline or play it back.

Event 1/40
0:002:16
0:00
session startedaccounts/fireworks/models/glm-5p3 · CVE-2026-14891 · REPRO-20
0:05
0:07
web search
0:08
0:13
0:17
web search
0:18
0:37
0:39
web search
0:42
web search
0:58
1:00
web search
1:48
1:48
extract_facts
no facts extracted
1:50
1:50
supportclaim_contract
1:55
1:55
1:55
1:59
1:59
2:00
2:03
2:03
2:03
2:03
2:09
$ls -la /pruva/project-cache/repo-mirrors/ && cat /pruva/project-cache/.pruva/cache_manifest.json && ls /pruva/project-cache/repo 2>/dev/null; echo "---"; cat /pruva/project-cache/repo-mirrors/*/HEAD 2>/dev/null; ls -la /pruva/project-cache/repo-mirrors/*/ 2>/dev/null | head -30
0.5s✓
total 0
drwx------ 2 vscode vscode 40 Oct  9 14:09 .
drwx------ 4 vscode vscode 80 Oct  9 14:09 ..
{
  "schema_version": 1,
  "entries": [
    {
      "path": "repo-mirrors",
2:16

Artifacts and Evidence for CVE-2026-14891

Scripts, logs, diffs, and output captured during the reproduction.

bundle/repro/artifacts/fixed1/agent_attempt.log18.2 KB
bundle/repro/artifacts/fixed1/alloc_detail.json15.8 KB
bundle/repro/artifacts/fixed1/attempt_summary.txt0.3 KB
bundle/repro/artifacts/fixed1/escape_task_events.txt0.7 KB
bundle/repro/artifacts/fixed1/escape_task_stdout.log0.1 KB
bundle/repro/artifacts/fixed1/job_request.json1.6 KB
bundle/repro/artifacts/fixed1/job_submit_response.json0.2 KB
bundle/repro/artifacts/fixed1/prep_task_stdout.log0.4 KB
bundle/repro/artifacts/fixed2/agent_attempt.log18.1 KB
bundle/repro/artifacts/fixed2/alloc_detail.json15.8 KB
bundle/repro/artifacts/fixed2/attempt_summary.txt0.3 KB
bundle/repro/artifacts/fixed2/escape_task_events.txt0.7 KB
bundle/repro/artifacts/fixed2/escape_task_stdout.log0.1 KB
bundle/repro/artifacts/fixed2/job_request.json1.6 KB
bundle/repro/artifacts/fixed2/job_submit_response.json0.2 KB
bundle/repro/artifacts/fixed2/prep_task_stdout.log0.4 KB
bundle/repro/artifacts/vuln1/agent_attempt.log11.0 KB
bundle/repro/artifacts/vuln1/alloc_detail.json8.2 KB
bundle/repro/artifacts/vuln1/attempt_summary.txt0.2 KB
bundle/repro/artifacts/vuln1/escape_task_events.txt0.3 KB
bundle/repro/artifacts/vuln1/escape_task_stdout.log0.2 KB
bundle/repro/artifacts/vuln1/host_marker.txt0.0 KB
bundle/repro/artifacts/vuln1/job_request.json1.6 KB
bundle/repro/artifacts/vuln1/job_submit_response.json0.2 KB
bundle/repro/artifacts/vuln1/prep_task_stdout.log0.4 KB
bundle/repro/artifacts/vuln2/agent_attempt.log11.1 KB
bundle/repro/artifacts/vuln2/alloc_detail.json8.2 KB
bundle/repro/artifacts/vuln2/attempt_summary.txt0.2 KB
bundle/repro/artifacts/vuln2/escape_task_events.txt0.3 KB
bundle/repro/artifacts/vuln2/escape_task_stdout.log0.2 KB
bundle/repro/artifacts/vuln2/host_marker.txt0.0 KB
bundle/repro/artifacts/vuln2/job_request.json1.6 KB
bundle/repro/artifacts/vuln2/job_submit_response.json0.2 KB
bundle/repro/artifacts/vuln2/prep_task_stdout.log0.4 KB
bundle/repro/rca_report.md11.6 KB
bundle/repro/reproduction_steps.sh17.1 KB
bundle/repro/runtime_manifest.json7.2 KB
bundle/repro/validation_verdict.json1.6 KB
08 · How to Fix

How to Fix CVE-2026-14891

Upgrade hashicorp/nomad · other to 2.0.4 or later.

Coming soon

Step-by-step mitigation and hardening guidance for CVE-2026-14891 — configuration checks, workarounds where no patch exists, and how to verify you're protected — is on the way.

10 · FAQ

FAQ: CVE-2026-14891

Is CVE-2026-14891 exploitable?

Yes. Pruva independently reproduced CVE-2026-14891 in hashicorp/nomad and verified the exploit fires end-to-end in a sandboxed environment. A runnable proof-of-concept script and the full agent transcript are on this page (reproduction REPRO-2026-00385).

How severe is CVE-2026-14891?

CVE-2026-14891 is rated high severity.

What type of vulnerability is CVE-2026-14891?

CVE-2026-14891 is classified as CWE-59 Improper Link Resolution Before File Access (Link Following).

Which versions of hashicorp/nomad are affected by CVE-2026-14891?

hashicorp/nomad Nomad CE 0.4.1 through 2.0.3 (all releases prior to 2.0.4); Nomad Enterprise prior to 2.0.4 / 1.11.8 / 1.10.14 is affected by CVE-2026-14891.

Is there a fix for CVE-2026-14891?

Yes. CVE-2026-14891 is fixed in hashicorp/nomad 2.0.4. Upgrading to the fixed version remediates the issue.

How can I reproduce CVE-2026-14891?

Pruva provides a verified reproduction script on this page. Download it and run it inside an isolated environment such as a container or virtual machine — never against production. The reproduction was confirmed end-to-end by Pruva's automated agents.

Is the CVE-2026-14891 reproduction verified?

Yes. Pruva reproduced CVE-2026-14891 with high confidence in a sandboxed environment, capturing the full agent transcript and artifacts as evidence.
11 · References

References for CVE-2026-14891

Authoritative sources for CVE-2026-14891 — official vulnerability databases and the upstream advisory. Pruva's reproduction verifies the issue firsthand; these are the primary records to corroborate it.