# 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
`<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:

```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/<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`.
