I kept my second brain off the cloud. In exchange, I left an unauthenticated port open around the clock

When I wrote about designing my second brain so it isn’t locked into any one AI, one of the reasons I listed for being able to switch was that the embeddings for semantic search are generated by a local Ollama. The contents of my vault never go to the cloud. That was the choice.

In late August, an article came through about a vulnerability targeting exactly that Ollama. Open one malicious web page, and an attacker can permanently plant instructions inside the model running on your machine. The note where I keep a map of my environment has a single line about it: “homebrew.mxcl.ollama, always running.” Always running means it’s listening on some port for something. I had written that line down and never once looked at what it was.

What the vulnerability actually was

Strictly speaking, it wasn’t a bug in Ollama itself. What NVIDIA’s security bulletin published as CVE-2026-65105 concerns NVIDIA NemoClaw, an agent runtime that uses Ollama as its local inference backend. The Cyera Research write-up of the research by Oasis Security explains how it works.

Ollama’s API has no authentication. Instead, two layers stand between it and a hostile browser. The first checks the Origin header: it passes if the origin is on an allowlist, or if it matches the Host header. The second validates the Host header itself, rejecting anything that isn’t a local name such as localhost or the machine’s own hostname.

NemoClaw started Ollama on 0.0.0.0:11434 so that its sandboxed containers could reach the host. If you read Ollama’s source, the Host validation is skipped entirely when the bind address isn’t loopback. That leaves only the Origin check, and DNS rebinding gets past it. The attacker points their domain at their own server first, serves the page, then flips the DNS record to 127.0.0.1. The browser sees the same hostname, treats it as same-origin, and every subsequent request lands on the victim’s local Ollama. Origin and Host are both the attacker’s domain, so they match and the first layer passes. The second layer was already skipped. Straight through.

Ollama's two defensive layers. Bound to 0.0.0.0, the Host check is skipped and DNS rebinding makes the Origin check pass as same-origin, so requests go straight through. Bound to 127.0.0.1, the Host check runs and rejects the attacker's domain with 403.

What can you do once you’re in? The part of the report I liked most was that the obvious trick, planting a system prompt in the model, doesn’t work against an agent. The agent sends its own system prompt with every request, which overrides whatever is baked into the model. So they rewrote the chat template instead. That sits in the generation path of every conversation, and nothing overrides it.

Looking at my own machine

Only after finishing the article did I look at my own setup.

$ lsof -nP -iTCP:11434 -sTCP:LISTEN
ollama  1023 nobu666  3u  IPv4  TCP 127.0.0.1:11434 (LISTEN)

It was 127.0.0.1, not 0.0.0.0. The Homebrew launchd config doesn’t set OLLAMA_HOST, so Ollama was listening on loopback, its default.

That should mean the second layer, the Host check, is alive. I sent the same shape of request a browser would send during the attack, with both Origin and Host set to the attacker’s domain:

$ curl -s -o /dev/null -w "%{http_code}\n" \
    -H "Origin: http://evil.example:11434" \
    -H "Host: evil.example:11434" \
    http://127.0.0.1:11434/api/tags
403

Rejected. The first layer passes because Origin and Host match, then the second layer decides evil.example isn’t a local name and stops it. Even if DNS rebinding got a browser this far, it ends here.

This Host check went in when Ollama closed the same kind of hole back in 2024. I didn’t know that. I was just running the default, and the default was what had been protecting me all along.

What “no authentication” still leaves open

The browser route was closed. The API still has no authentication, though, and any process on the same machine can call it freely. I tried it against my own models to see what that actually allows.

Starting from bge-m3, the model my semantic search CLI (vault-search) uses for embeddings, I built a test model with /api/create, following the report’s technique: instructions planted in both the system prompt and the chat template. No auth, and it went through on the first try. /api/show on the new model displays the planted text in both system and template.

It had no effect on embeddings, though. Embedding the same sentence with the original model and the poisoned one, the cosine similarity was 1.0. Comparing the vectors element by element, they were identical. The embedding path doesn’t go through the system prompt or the chat template. Since embeddings are the only thing vault-search asks Ollama for, the report’s technique, carried over as-is, doesn’t touch my use case.

What would touch it is a different move. /api/create can rebuild a model under an existing name from a different base. When I created a test model from bge-m3 and then recreated it from gemma3, the model’s family changed from bert to gemma3. Do that to the real bge-m3 and the foundation of my search gets swapped out.

If that failed silently, it would be frightening. When I tried it, asking gemma3 for an embedding got a 501 from Ollama, because it doesn’t support embeddings at all. And if it were swapped for a different embedding model, vault-search has a check that stops when the vector dimensions of the index and the query disagree. Within what I tested, it breaks loudly rather than quietly. One caveat: a replacement with the same dimensions would sail through that check, and I haven’t looked at that.

What I found was a different hole

While I was at it, I checked the version, and something I hadn’t expected came up.

$ ollama --version
ollama version is 0.33.0
Warning: client version is 0.33.3

The running server was 0.33.0. The binary on disk was 0.33.3. The process had started on September 5, brew had updated Ollama since then, and the resident process just kept running the old build. The patched binary was sitting right there, and the thing actually running was the one from before the update. That version gap has nothing to do with this vulnerability, but leave it in place and next time it will.

The appeal of launchd is that it starts things on its own and keeps them running on its own. The flip side is that an update doesn’t swap them out on its own either. I restarted it and confirmed 0.33.3 was running. In the end, that was the only thing I fixed.

All I had protected was where the data sits

What I inventoried in the lock-in article was where my memory is stored. Markdown on my own disk, not on Anthropic’s servers, embeddings generated locally. Every item was about location.

I hadn’t looked at the path the memory travels when I retrieve it. A resident process holds one listening socket, and that socket has no authentication. That has been true since the day I built the semantic search, and neither that article nor the lock-in one mentions it. The reason is simple: I hadn’t looked.

It resembles the time I leaked a GCP key. Back then I was scanning for secrets right before they left the machine, and the key came in from a location I had forgotten to add to the scan. This time I was checking whether anything went to the cloud, and the listening port wasn’t on the checklist. The guard was there. There was just a spot outside the area I thought I was guarding, and I wasn’t looking at it.

Judged by the outcome, I was better protected than I expected. Loopback binding, a working Host check, embeddings immune to injection, and, within what I tested, a swap that fails loudly. In the lock-in article, my own past decisions turned out to be doing the work. This time it was someone else’s default. Until I checked, I was running that process without knowing whether any of it held. If you keep a local LLM running for privacy or to avoid lock-in, you’ve probably done what I did and checked where the data sits many times over. Running lsof once to see what’s listening on which address? I suspect far fewer people have gotten that far.

comments powered by Disqus