{"repro_id":"REPRO-2026-00385","version":6,"title":"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.","repro_type":"security","status":"published","severity":"high","description":"HashiCorp Nomad and Nomad Enterprise are vulnerable to a sandbox escape in the Docker task driver. A job submitter can potentially bind-mount a host path into a container even when volume bind mounts are disabled, which may allow reading and writing files on the host filesystem.","root_cause":"# Root Cause Analysis — CVE-2026-14891\n\n## Summary\n\nHashiCorp Nomad's Docker task driver (Community Edition ≤ 2.0.3) validates\nbind-mount sources with a purely **lexical** path-containment check\n(`isParentPath`) when volume bind mounts are disabled. Because the check never\nresolves symbolic links, a job submitter can supply a mount whose source path\n*appears* to be inside the task's allocation directory but is actually a\nsymlink that resolves to an arbitrary host path. The Docker daemon follows the\nsymlink at bind-mount time, mounting the host path read-write into the task\ncontainer — a sandbox escape from the allocation directory that grants host\nfilesystem read/write even though volume bind mounts are disabled (the\ndefault).\n\n## Impact\n\n- **Package/component affected:** `drivers/docker` plugin of HashiCorp Nomad\n  (`drivers/docker/driver.go`: `toDockerMount()` and `containerBinds()`).\n- **Affected versions:** Nomad Community Edition ≤ 2.0.3 and Nomad Enterprise\n  ≤ 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;\n  advisory HCSEC-2026-21).\n- **Risk level:** High (CVSS 3.1 8.7, CWE-59). An authenticated job submitter\n  (or any API client when ACLs are disabled) can read and write arbitrary\n  files on the Nomad client host filesystem from inside a container, escaping\n  the intended allocation sandbox.\n\n## Impact Parity\n\n- **Disclosed/claimed maximum impact:** Sandbox escape via Docker task driver —\n  bind-mount host paths into a container despite bind mounts being disabled,\n  allowing reading and writing files on the host filesystem.\n- **Reproduced impact from this run:** Full parity. On Nomad 2.0.3 with\n  `volumes.enabled = false`, the Docker-driver task's bind mount\n  (`source = \"../alloc/escape-root\"`, a symlink to `/` planted by a prestart\n  task in the same allocation) was accepted, and the task container received\n  the whole Docker-daemon host filesystem mounted read-write at\n  `/host-escape`. The task **read** the host's `/etc/hostname`\n  (`fc9e2e9012df`, matching `docker info --format '{{.Name}}'`) and **wrote** a\n  marker file to a host path outside the allocation directory\n  (`/workspace/host-escape-zone/ESCAPE_WRITE_OK_*.txt`), observable on the\n  host filesystem. The identical job against Nomad 2.0.4 was rejected\n  (`volumes are not enabled; cannot mount host path`) with no host write.\n- **Parity:** `full`.\n- **Not demonstrated:** Nothing material to the claim. (No arbitrary *code*\n  execution on the host was claimed or needed; the escape primitive itself —\n  arbitrary host file read/write from inside the container — was demonstrated.)\n\n## Root Cause\n\nIn Nomad 2.0.3, `drivers/docker/driver.go` `toDockerMount()` handled\ntask-configured `mounts` of type `bind` as follows:\n\n```go\ncase \"bind\":\n    hm.Source = expandPath(task.TaskDir().Dir, hm.Source)\n\n    // paths inside alloc dir are always allowed as they mount within\n    // a container, and treated as relative to task dir\n    if !d.config.Volumes.Enabled && !isParentPath(task.AllocDir, hm.Source) {\n        return nil, fmt.Errorf(\n            \"volumes are not enabled; cannot mount host path: %q %q\",\n            hm.Source, task.AllocDir)\n    }\n```\n\n`isParentPath` (`drivers/docker/utils.go`) is purely lexical:\n\n```go\nfunc isParentPath(parent, path string) bool {\n    rel, err := filepath.Rel(parent, path)\n    return err == nil && !strings.HasPrefix(rel, \"..\")\n}\n```\n\n`filepath.Rel` operates on path *strings* only; it never consults the\nfilesystem. Therefore a source path that is lexically inside the allocation\ndirectory — but is a symlink on the host pointing anywhere, e.g. to `/` —\npasses the containment check. The same flawed check exists in\n`containerBinds()` for the legacy `volumes` task attribute. The expanded\nsource path is then handed verbatim to the Docker daemon as a bind-mount\nsource; the daemon/kernel resolves the symlink at mount time and mounts the\nsymlink's *target*, so the task container gains access to host paths outside\nthe allocation directory.\n\nAn attacker who can submit Docker-driver jobs controls the content of the\nallocation directory on the client host (any task in the allocation can write\nto the shared alloc dir, which is bind-mounted into every task container at\n`/alloc`). A prestart task plants `escape-root -> /` there; the main task's\nmount source `../alloc/escape-root` expands to\n`<data_dir>/alloc/<alloc_id>/alloc/escape-root`, which is lexically inside\n`<data_dir>/alloc/<alloc_id>` and therefore accepted by the vulnerable check.\n\n**Fix (v2.0.4, commit `5b83b133998a1f514beb81019930fb673b5ed669`):** the\nlexical `isParentPath` calls in `toDockerMount()` and `containerBinds()` were\nreplaced by `escapingfs.ChildEscapesParentDir()` (new helper\n`helper/escapingfs/escapes.go`), which uses Go's `os.OpenRoot`/`root.Stat` to\nresolve symlinks inside the allocation directory and rejects any child that\nresolves to an absolute path outside it:\n\n```go\nif !d.config.Volumes.Enabled {\n    if err := escapingfs.ChildEscapesParentDir(task.AllocDir, hm.Source); err != nil {\n        return nil, fmt.Errorf(\"volumes are not enabled; cannot mount host path: %q\", hm.Source)\n    }\n}\n```\n\nVulnerable checkout `v2.0.3` (`a2a5fee9c42d6481adcb9be865bcbccf8fd4d725`)\ncontains `isParentPath` and lacks the patch hunk; fixed checkout `v2.0.4`\n(`5b83b133998a1f514beb81019930fb673b5ed669`) contains\n`escapingfs.ChildEscapesParentDir` (verified via `git diff v2.0.3 v2.0.4 --\ndrivers/docker`).\n\n## Reproduction Steps\n\n1. Script: `bundle/repro/reproduction_steps.sh` (run from the bundle root:\n   `bash bundle/repro/reproduction_steps.sh`; exit 0 = confirmed).\n2. What the script does, end-to-end against the **real product**:\n   - Verifies the Docker daemon is reachable and that `/workspace` is the same\n     filesystem the daemon bind-mounts from (shared-host precondition), pulls\n     `alpine:3.19`.\n   - Downloads the official Nomad release binaries `2.0.3` (vulnerable) and\n     `2.0.4` (fixed) from `releases.hashicorp.com`, verifying versions and\n     recording SHA-256 digests.\n   - For each of **two vulnerable (2.0.3) and two fixed (2.0.4) attempts**:\n     starts a real single-node Nomad agent (server + client) with the Docker\n     driver configured with `volumes { enabled = false }`, waits for\n     `/v1/agent/health` (client+server ok) and for the `docker` driver to be\n     detected on the node.\n     Submits an identical batch job through the real HTTP API\n     (`POST /v1/jobs`): a `prestart` Docker task plants\n     `ln -sfn / /alloc/escape-root` in the shared allocation directory, and\n     the main `escape` Docker task declares\n     `mounts = [{ type = \"bind\", source = \"../alloc/escape-root\", target =\n     \"/host-escape\", readonly = false }]` and then reads\n     `/host-escape/etc/hostname` and writes\n     `/host-escape/workspace/host-escape-zone/<unique-marker>.txt`.\n     The script then captures allocation state, task events, task stdout, the\n     host-side marker file, and the agent log; stops the agent; and cleans up\n     its data dir.\n   - Writes `bundle/repro/runtime_manifest.json` with per-attempt proof\n     artifacts and SHA-256 digests.\n3. Expected evidence of reproduction (both observed in two consecutive runs):\n   - **Vulnerable (2.0.3):** the `escape` task starts and exits 0; stdout\n     shows `HOST_HOSTNAME=fc9e2e9012df` (the Docker daemon host's\n     `/etc/hostname`) plus a full listing of the host root at `/host-escape`;\n     the marker file appears on the host filesystem outside the allocation\n     directory.\n   - **Fixed (2.0.4):** the identical job fails closed with\n     `Driver Failure: ... volumes are not enabled; cannot mount host path:\n     \"<data_dir>/alloc/<alloc_id>/alloc/escape-root\"` /\n     `Not Restarting: Error was unrecoverable`, and no marker is written.\n\n## Evidence\n\n- Per-attempt evidence: `bundle/repro/artifacts/{vuln1,vuln2,fixed1,fixed2}/`\n  - `job_request.json`, `job_submit_response.json` — real API\n    request/response for job submission.\n  - `alloc_detail.json`, `escape_task_events.txt` — task states/events.\n  - `escape_task_stdout.log`, `prep_task_stdout.log` — task logs via the\n    Nomad logs API.\n  - `host_marker.txt` — copy of the file the task wrote on the host through\n    the escaped bind mount (vulnerable attempts only).\n  - `agent_attempt.log`, `attempt_summary.txt`.\n- Driver log: `bundle/logs/reproduction_steps.log`.\n- Key excerpts (vulnerable, `vuln1/escape_task_stdout.log`):\n  ```\n  HOST_HOSTNAME=fc9e2e9012df\n  ESCAPE_WRITE_OK_vuln1-1791556358\n  bin bundle certs dev etc home lib media mnt opt proc pruva root run sbin ...\n  ```\n  (fixed, `fixed1/escape_task_events.txt`):\n  ```\n  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\"\n  Not Restarting: Error was unreverable [sic] / Error was unrecoverable\n  ```\n- Environment: Ubuntu 26.04 sandbox (linux/x86_64), rootless Docker daemon\n  (Server 27.5.1) reachable at `unix:///run/user/1000/docker.sock`, sharing\n  `/workspace` with the Nomad client; Nomad agents run as non-root user with\n  the Docker driver only; volume bind mounts disabled (`volumes.enabled =\n  false`, the plugin default). Target identity: Nomad 2.0.3 official release\n  binary (revision `a2a5fee9c42d6481adcb9be865bcbccf8fd4d725`, binary SHA-256\n  `7f1d3e1e49566ed6c6239c16d3fe1d29af362904408b4d01c3f64fc630022156`);\n  negative control: Nomad 2.0.4 (`5b83b133998a1f514beb81019930fb673b5ed669`,\n  SHA-256 `f22cf977f100f938e0056cef4b4717288b5da1ecb0bb8d69cc2f563f82e63f43`).\n\n## Recommendations / Next Steps\n\n- **Upgrade** to Nomad Community Edition 2.0.4+ (or Enterprise 2.0.4, 1.11.8,\n  1.10.14), which rejects symlink-resolved mount sources via\n  `escapingfs.ChildEscapesParentDir` (`os.OpenRoot`).\n- Operators who cannot upgrade immediately should enable ACLs so that only\n  authorized principals can submit Docker-driver jobs, and should treat\n  job submitters as having potential host-filesystem access on clients.\n- Consider defense-in-depth: audit the Docker daemon's view of client\n  filesystems, and restrict task-configured `mounts`/`volumes` via policy\n  where possible.\n- Testing: regression test should submit a Docker-driver job whose mount\n  source is a symlink inside the allocation directory pointing to an\n  absolute host path, and assert rejection when `volumes.enabled = false`\n  (the driver test suite in 2.0.4 contains such cases).\n\n## Additional Notes\n\n- **Idempotency confirmation:** `reproduction_steps.sh` was executed twice\n  consecutively in the same environment; both runs ended with\n  `results: vuln1=escaped vuln2=escaped fixed1=blocked fixed2=blocked`,\n  exit code 0, and a valid `runtime_manifest.json`. Job IDs and marker tokens\n  are timestamp-unique so re-runs do not collide; each attempt uses a fresh\n  agent data dir, and stale agents are killed before attempts start.\n- **Limitations / environment notes:** the \"host\" whose filesystem is\n  escaped is the Docker-daemon host filesystem as seen by the Nomad client\n  (in this sandbox, the rootless daemon container sharing `/workspace`).\n  This is exactly the production model — Nomad client and Docker daemon on\n  the same host. The mount is performed by the real Docker daemon following\n  the real symlink planted through the real allocation-directory bind mount.\n  In a production cluster the escape would apply to the Nomad *client* host's\n  filesystem. The write was additionally verified from outside the container\n  by observing the marker file appear on the host filesystem.\n- The `docker` driver logs a warning that running non-root disables\n  NUMA/core scheduling; this does not affect the reproduction. The escape\n  requires no privileged container, no caps, and works with the driver's\n  default `volumes.enabled = false`.\n","cve_id":"CVE-2026-14891","cwe_id":"CWE-59 Improper Link Resolution Before File Access (Link Following)","source_url":"https://nvd.nist.gov/vuln/detail/CVE-2026-14891","package":{"name":"hashicorp/nomad","ecosystem":"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_version":"2.0.4"},"reproduced_at":"2026-10-09T18:02:55.583687+00:00","duration_secs":6614.0,"tool_calls":224,"handoffs":2,"total_cost_usd":5.262551,"agent_costs":{"claim_matcher":0.023392,"judge":0.613757,"learning_policy":0.010778,"repro":1.57217,"support":0.066563,"vuln_variant":2.975891},"cost_breakdown":{"claim_matcher":{"gpt-5.4-mini-2026-03-17":0.023392},"judge":{"gpt-5.6-sol":0.613757},"learning_policy":{"gpt-5.4-mini-2026-03-17":0.010778},"repro":{"accounts/fireworks/models/glm-5p3":1.57217},"support":{"accounts/fireworks/models/glm-5p3":0.066563},"vuln_variant":{"accounts/fireworks/models/glm-5p3":2.975891}},"vulnerable_version_variant_outcome":"unknown","fix_bypass_outcome":"unknown","variant_disclosure_state":"unknown","quality":{"confidence":"high","idempotent_verified":false,"community_verifications":0},"evidence":{"workflow":{"profile":"known_vulnerability","schema_version":2,"stages":["support","claim_contract","repro","judge","vuln_variant"]}},"environment":{"sandbox_image":"ghcr.io/n3mes1s/pruva-sandbox@sha256:8096b2518d6022e13d68f885c3b8ded6b4fe607098b1a1ccbfb99abc004d1dc1"},"published_at":"2026-10-09T18:02:56.860686+00:00","retracted":false,"artifacts":[{"path":"bundle/repro/rca_report.md","filename":"rca_report.md","size":11913,"category":"analysis"},{"path":"bundle/repro/reproduction_steps.sh","filename":"reproduction_steps.sh","size":17481,"category":"reproduction_script"},{"path":"bundle/repro/artifacts/fixed1/agent_attempt.log","filename":"agent_attempt.log","size":18625,"category":"log"},{"path":"bundle/repro/artifacts/fixed1/alloc_detail.json","filename":"alloc_detail.json","size":16216,"category":"other"},{"path":"bundle/repro/artifacts/fixed1/attempt_summary.txt","filename":"attempt_summary.txt","size":256,"category":"other"},{"path":"bundle/repro/artifacts/fixed1/escape_task_events.txt","filename":"escape_task_events.txt","size":718,"category":"other"},{"path":"bundle/repro/artifacts/fixed1/escape_task_stdout.log","filename":"escape_task_stdout.log","size":129,"category":"log"},{"path":"bundle/repro/artifacts/fixed1/job_request.json","filename":"job_request.json","size":1663,"category":"other"},{"path":"bundle/repro/artifacts/fixed1/job_submit_response.json","filename":"job_submit_response.json","size":166,"category":"other"},{"path":"bundle/repro/artifacts/fixed1/prep_task_stdout.log","filename":"prep_task_stdout.log","size":386,"category":"log"},{"path":"bundle/repro/artifacts/fixed2/agent_attempt.log","filename":"agent_attempt.log","size":18557,"category":"log"},{"path":"bundle/repro/artifacts/fixed2/alloc_detail.json","filename":"alloc_detail.json","size":16215,"category":"other"},{"path":"bundle/repro/artifacts/fixed2/attempt_summary.txt","filename":"attempt_summary.txt","size":256,"category":"other"},{"path":"bundle/repro/artifacts/fixed2/escape_task_events.txt","filename":"escape_task_events.txt","size":718,"category":"other"},{"path":"bundle/repro/artifacts/fixed2/escape_task_stdout.log","filename":"escape_task_stdout.log","size":129,"category":"log"},{"path":"bundle/repro/artifacts/fixed2/job_request.json","filename":"job_request.json","size":1663,"category":"other"},{"path":"bundle/repro/artifacts/fixed2/job_submit_response.json","filename":"job_submit_response.json","size":166,"category":"other"},{"path":"bundle/repro/artifacts/fixed2/prep_task_stdout.log","filename":"prep_task_stdout.log","size":386,"category":"log"},{"path":"bundle/repro/artifacts/vuln1/agent_attempt.log","filename":"agent_attempt.log","size":11292,"category":"log"},{"path":"bundle/repro/artifacts/vuln1/alloc_detail.json","filename":"alloc_detail.json","size":8350,"category":"other"},{"path":"bundle/repro/artifacts/vuln1/attempt_summary.txt","filename":"attempt_summary.txt","size":253,"category":"other"},{"path":"bundle/repro/artifacts/vuln1/escape_task_events.txt","filename":"escape_task_events.txt","size":275,"category":"other"},{"path":"bundle/repro/artifacts/vuln1/escape_task_stdout.log","filename":"escape_task_stdout.log","size":163,"category":"log"},{"path":"bundle/repro/artifacts/vuln1/host_marker.txt","filename":"host_marker.txt","size":33,"category":"other"},{"path":"bundle/repro/artifacts/vuln1/job_request.json","filename":"job_request.json","size":1658,"category":"other"},{"path":"bundle/repro/artifacts/vuln1/job_submit_response.json","filename":"job_submit_response.json","size":166,"category":"other"},{"path":"bundle/repro/artifacts/vuln1/prep_task_stdout.log","filename":"prep_task_stdout.log","size":386,"category":"log"},{"path":"bundle/repro/artifacts/vuln2/agent_attempt.log","filename":"agent_attempt.log","size":11364,"category":"log"},{"path":"bundle/repro/artifacts/vuln2/alloc_detail.json","filename":"alloc_detail.json","size":8350,"category":"other"},{"path":"bundle/repro/artifacts/vuln2/attempt_summary.txt","filename":"attempt_summary.txt","size":253,"category":"other"},{"path":"bundle/repro/artifacts/vuln2/escape_task_events.txt","filename":"escape_task_events.txt","size":275,"category":"other"},{"path":"bundle/repro/artifacts/vuln2/escape_task_stdout.log","filename":"escape_task_stdout.log","size":163,"category":"log"},{"path":"bundle/repro/artifacts/vuln2/host_marker.txt","filename":"host_marker.txt","size":33,"category":"other"},{"path":"bundle/repro/artifacts/vuln2/job_request.json","filename":"job_request.json","size":1658,"category":"other"},{"path":"bundle/repro/artifacts/vuln2/job_submit_response.json","filename":"job_submit_response.json","size":166,"category":"other"},{"path":"bundle/repro/artifacts/vuln2/prep_task_stdout.log","filename":"prep_task_stdout.log","size":386,"category":"log"},{"path":"bundle/repro/runtime_manifest.json","filename":"runtime_manifest.json","size":7396,"category":"other"},{"path":"bundle/repro/validation_verdict.json","filename":"validation_verdict.json","size":1628,"category":"other"}]}