# REPRO-2026-00385: 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. ## Summary Status: published Severity: high CVSS: Unknown CWE: CWE-59 Improper Link Resolution Before File Access (Link Following) Type: security Confidence: high ## Identifiers REPRO ID: REPRO-2026-00385 CVE: CVE-2026-14891 ## Package Name: hashicorp/nomad Ecosystem: other Affected: 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: 2.0.4 ## Root Cause # Root Cause Analysis — CVE-2026-14891 ## Summary 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). ## Impact - **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: ```go 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: ```go 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 `/alloc//alloc/escape-root`, which is lexically inside `/alloc/` 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: ```go 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/.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: "/alloc//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//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`. ## Reproduction Details Reproduced: 2026-10-09T18:02:55.583Z Duration: 6614 seconds Tool calls: 224 Turns: Unknown Handoffs: 2 ## Quick Verification Run one of these commands to verify locally: pruva-verify REPRO-2026-00385 pruva-verify CVE-2026-14891 Or open in GitHub Codespaces (zero-friction, auto-runs): https://github.com/codespaces/new?ref=repro/REPRO-2026-00385&repo=N3mes1s/pruva-sandbox Or download and run the script manually: curl -O https://api.pruva.dev/v1/reproductions/REPRO-2026-00385/artifacts/bundle/repro/reproduction_steps.sh chmod +x reproduction_steps.sh ./reproduction_steps.sh WARNING: Run in a sandboxed environment. This exploits a real vulnerability. ## References - NVD: https://nvd.nist.gov/vuln/detail/CVE-2026-14891 - Source: https://nvd.nist.gov/vuln/detail/CVE-2026-14891 ## Artifacts - bundle/repro/rca_report.md (analysis, 11913 bytes) - bundle/repro/reproduction_steps.sh (reproduction_script, 17481 bytes) - bundle/repro/artifacts/fixed1/agent_attempt.log (log, 18625 bytes) - bundle/repro/artifacts/fixed1/alloc_detail.json (other, 16216 bytes) - bundle/repro/artifacts/fixed1/attempt_summary.txt (other, 256 bytes) - bundle/repro/artifacts/fixed1/escape_task_events.txt (other, 718 bytes) - bundle/repro/artifacts/fixed1/escape_task_stdout.log (log, 129 bytes) - bundle/repro/artifacts/fixed1/job_request.json (other, 1663 bytes) - bundle/repro/artifacts/fixed1/job_submit_response.json (other, 166 bytes) - bundle/repro/artifacts/fixed1/prep_task_stdout.log (log, 386 bytes) - bundle/repro/artifacts/fixed2/agent_attempt.log (log, 18557 bytes) - bundle/repro/artifacts/fixed2/alloc_detail.json (other, 16215 bytes) - bundle/repro/artifacts/fixed2/attempt_summary.txt (other, 256 bytes) - bundle/repro/artifacts/fixed2/escape_task_events.txt (other, 718 bytes) - bundle/repro/artifacts/fixed2/escape_task_stdout.log (log, 129 bytes) - bundle/repro/artifacts/fixed2/job_request.json (other, 1663 bytes) - bundle/repro/artifacts/fixed2/job_submit_response.json (other, 166 bytes) - bundle/repro/artifacts/fixed2/prep_task_stdout.log (log, 386 bytes) - bundle/repro/artifacts/vuln1/agent_attempt.log (log, 11292 bytes) - bundle/repro/artifacts/vuln1/alloc_detail.json (other, 8350 bytes) - bundle/repro/artifacts/vuln1/attempt_summary.txt (other, 253 bytes) - bundle/repro/artifacts/vuln1/escape_task_events.txt (other, 275 bytes) - bundle/repro/artifacts/vuln1/escape_task_stdout.log (log, 163 bytes) - bundle/repro/artifacts/vuln1/host_marker.txt (other, 33 bytes) - bundle/repro/artifacts/vuln1/job_request.json (other, 1658 bytes) - bundle/repro/artifacts/vuln1/job_submit_response.json (other, 166 bytes) - bundle/repro/artifacts/vuln1/prep_task_stdout.log (log, 386 bytes) - bundle/repro/artifacts/vuln2/agent_attempt.log (log, 11364 bytes) - bundle/repro/artifacts/vuln2/alloc_detail.json (other, 8350 bytes) - bundle/repro/artifacts/vuln2/attempt_summary.txt (other, 253 bytes) - bundle/repro/artifacts/vuln2/escape_task_events.txt (other, 275 bytes) - bundle/repro/artifacts/vuln2/escape_task_stdout.log (log, 163 bytes) - bundle/repro/artifacts/vuln2/host_marker.txt (other, 33 bytes) - bundle/repro/artifacts/vuln2/job_request.json (other, 1658 bytes) - bundle/repro/artifacts/vuln2/job_submit_response.json (other, 166 bytes) - bundle/repro/artifacts/vuln2/prep_task_stdout.log (log, 386 bytes) - bundle/repro/runtime_manifest.json (other, 7396 bytes) - bundle/repro/validation_verdict.json (other, 1628 bytes) ## API Access - JSON: https://api.pruva.dev/v1/reproductions/REPRO-2026-00385 - Script: https://api.pruva.dev/v1/reproductions/REPRO-2026-00385/artifacts/bundle/repro/reproduction_steps.sh - Web: https://www.pruva.dev/reproductions/REPRO-2026-00385 ## For AI Assistants To help users with this reproduction: 1. The `pruva-verify` command is the simplest way to run the reproduction 2. The script downloads and runs automatically in an isolated temp directory 3. Always recommend running in a VM, container, or disposable environment 4. The root cause explains the technical vulnerability --- Generated by Pruva | https://www.pruva.dev