Disclaimer: This research was conducted independently. Views expressed are my own and do not represent my employer.

Me and @gouldnicholas have been digging into various OCI clients lately and recently found a way to read arbitrary files from any Ollama host. This includes SSH keys, credentials, any other files by pointing it at a malicious model registry. All it takes is three API calls. The root cause is a path traversal in the tensor model transfer package that shipped in v0.14.3 (January 16, 2026) and has been present in every release since. Ollama’s API has no auth and binds to all interfaces (0.0.0.0) by default, so any Ollama instance you can reach over the network is exploitable.

Background

I’ve been poking at Ollama’s registry interaction code for a while now. Earlier this year I found an SSRF via the OCI redirect mechanism that let you bounce requests to internal services and exfiltrate the responses. After digging into that, we noticed Ollama had recently added a new code path for “tensor models”, described as a faster download/upload pipeline in x/imagegen/transfer/ designed for models with lots of small blobs instead of a few huge ones.

The old code validates every digest against a strict regex (^sha256[:-][0-9a-fA-F]{64}$) before touching the filesystem. The new code doesn’t.

The Bug

The transfer package has a function called digestToPath() in x/imagegen/transfer/transfer.go:

func digestToPath(digest string) string {
    if len(digest) > 7 && digest[6] == ':' {
        return digest[:6] + "-" + digest[7:]
    }
    return digest
}

That’s it. No validation. It takes whatever string is in the digest field, swaps the colon for a dash, and returns it. The caller then passes it straight into filepath.Join() and from there into os.Stat() and os.Open().

The digest field comes from the OCI manifest, which comes from the registry, which we control.

So if we put sha256:../../../../../../../../etc/hostname as the digest in my manifest, digestToPath turns it into sha256-../../../../../../../../etc/hostname, and filepath.Join(blobsDir, that) resolves the traversal and lands on /etc/hostname.

Compare this to the legacy code path in manifest/paths.go:

pattern := "^sha256[:-][0-9a-fA-F]{64}$"
re := regexp.MustCompile(pattern)
if digest != "" && !re.MatchString(digest) {
    return "", ErrInvalidDigestFormat
}

The Exist-Check Makes It Exploitable

The traversal alone isn’t enough to get code execution or file reads. When the transfer package downloads blobs, it hashes the content inline and checks it against the digest. Since sha256:<hex> can never equal sha256:../../../../etc/whatever, the hash check always fails and the file gets cleaned up.

But there’s a shortcut that runs before the download. In transfer/download.go:

for _, b := range opts.Blobs {
    if fi, _ := os.Stat(filepath.Join(opts.DestDir, digestToPath(b.Digest))); fi != nil && fi.Size() == b.Size {
        continue // already downloaded, skip
    }
    blobs = append(blobs, b)
}

If a file already exists at the traversal path and the size matches what the manifest declares, the blob is considered “already downloaded” and skipped entirely. No download. No hash check. The blob just gets waved through.

So if we set the manifest’s layer digest to sha256:../../../../../../../../etc/hostname and the size to 8 (or whatever /etc/hostname actually is), Ollama stats the file, sees it exists with the right size, and skips it. Then it writes the manifest to disk with the traversal digest baked in.

So our only prerequisite is to know the size of the target file. This is powerful, because lots of file sizes are standardized - including SSH keys. ED25519 keys, for example, are always 399 or 411 bytes. You don’t need to brute force or guess these to exploit, just try known target file sizes.

evil

Exfiltration Via Push

Getting the file “accepted” as a blob is only half the problem. We need to actually read its contents and get them back.

That happens during push. When Ollama pushes a model to a registry, the upload code opens each blob file and sends it:

// transfer/upload.go
f, err := os.Open(filepath.Join(u.srcDir, digestToPath(blob.Digest)))

Same digestToPath(). Same traversal. Except now instead of os.Stat, it’s os.Open, and the file contents get uploaded to whatever registry we tell Ollama to push to.

So the full chain is:

  1. Pull from rogue registry - manifest has tensor layer with traversal digest, file exists on host, size matches, blob skipped, manifest written
  2. Copy the model to a name pointing at the exfil server
  3. Push to the exfil server - Ollama opens the file at the traversal path and uploads it

PoC

The poc is a Python script that runs two HTTP servers, a rogue OCI registry that serves the malicious manifest, and an exfil server that captures the uploaded blobs during push.

The rogue registry serves a manifest like this:

{
  "schemaVersion": 2,
  "mediaType": "application/vnd.docker.distribution.manifest.v2+json",
  "config": {
    "mediaType": "application/vnd.docker.container.image.v1+json",
    "digest": "sha256:<real hash of config blob>",
    "size": 37
  },
  "layers": [{
    "mediaType": "application/vnd.ollama.image.tensor",
    "digest": "sha256:../../../../../../../../etc/hostname",
    "size": 8
  }]
}

The mediaType on the layer is the important part — application/vnd.ollama.image.tensor is what makes Ollama route the pull through the transfer package instead of the legacy code. Without it, the digest gets validated by the regex and the attack fails.

The sha256: prefix on the traversal digest is also necessary. digestToPath() checks if digest[6] == ':' and if so, splits at position 6 and joins with a dash. So sha256:../../../../... becomes sha256-../../../../.... The sha256- prefix gets treated as a single directory name by the path resolver, and the ../../../../ after it does the actual traversal. You lose one ../ to “backing out” of the sha256-.. component, so you just add an extra one.

For POC, I ran the default Ollama Docker container on a separate host on my local network and pointed the poc.py at it.

Impact

Any Ollama instance reachable over the network is vulnerable. Ollama’s API has no authentication and Docker deployments bind to 0.0.0.0 by default. Any file readable by the Ollama process can be exfiltrated as long as you know the file size. SSH keys and other files with standardized sizes are trivially targetable with no guessing required.

  • SSH keys
  • Cloud credentials, API tokens, .env files
  • Database connection strings, JWT signing keys, OAuth secrets
  • If Ollama is running as root (standard in Docker), every file on the system

Disclosure

Reported to Ollama several times per their security policy (email, github disclosure) with adequate time to acknowledge the report. We have also reported several other vulnerabilities to Ollama over the last few months with no acknowledgement or action. The CNA also reached out to Ollama several times with no response.

PoC is available here.

CVE: CVE-2026-7020