Introducing the Corelight SSH Inference package
The SSH Inference package installs on sensors with a few clicks and provides network traffic analysis (NTA) inferences on live SSH traffic.
In my last post I used an AI agent to work out the blast radius of a compromise, and the agent found something I had not been looking for: other AI agents inside the blast radius. On an administrator's workstation, AI coding assistants were running Model Context Protocol (MCP) servers that the administrator had set up, and those servers held privileged credentials for security tools and other administrative resources. Nothing on that workstation was compromised, but it raised a question: where does the code in those servers come from, and how does it get updated?
Here's what we (me and the agent) found. One of those MCP servers was configured to run one specific release, built from one specific commit. Over five weeks it ran with 15 different sets of dependencies. Nobody had changed its configuration. The way it was launched resolved its dependencies again from the Python Package Index (PyPI) whenever it rebuilt its environment, so 23 of its 69 packages changed versions underneath a release that looked pinned.
That's the main point of this post: pinning a package's version doesn't necessarily pin the packages it depends on. And this is an issue that isn't only specific to MCP. Any software installed or launched this way behaves in the same fashion. It matters more for MCP servers because of what they're given. An MCP server acts for a user, so it's often set up with that user's credentials and, as this workstation showed, an administrator's credentials reach a lot.
This post shows:
The workstation's AI coding assistants started their MCP servers with a one-step launcher, uvx, which downloads a Python package and runs it from a single line of configuration. One of them, a community MCP server for the network gateway, was configured like this:
uvx --from git+https://github.com/<owner>/<server>@<release-tag> <server>
The others named an exact PyPI version (uvx --from <package>==<version> <package>) or an exact Git commit. Either way, each one looked like the content would not change.
The community server's author had done the hard part. The project pinned its seven direct dependencies exactly, and it published a hash-pinned lock file. But the launcher was installed from the package's declared dependencies, not from the lock file, so the roughly 60 packages underneath were resolved fresh from PyPI, within whatever version ranges the dependency tree allowed.
The launcher didn’t download everything with every launch. It kept a cached environment and reused it when nothing had changed. Here's what we saw:
It wasn't one server. Every MCP server which was launched in the same way on that workstation showed the same pattern, whether it was pinned to a release tag, an exact PyPI version, or an exact Git commit. One of the servers that was pinned to a commit had 19 builds and 17 distinct environments, and the MCP framework it was built on was running two major versions ahead of the version in the project's own lock file. In fact, one security vendor's official MCP server, added later with broad read and write access to its platform, declared its direct dependencies with no upper version limit.
It isn't limited to Python, either. Several vendors offer hosted MCP servers, and AI clients that can't add an authentication header themselves connect through a small open-source proxy that runs on the user's machine and attaches the user's API token to each request. In several vendors' official setup instructions we reviewed, that proxy is started with npm's one-step runner and a "latest" tag (npx <proxy>@latest <url>), so the client asks the npm registry for the newest version each time it starts. At one point, the proxy released six versions in one recent month, four of them within 14 hours.
In an example from another vendor, the setup also turned off TLS certificate checking for the whole proxy process. In 2025, researchers found a critical command-injection flaw in a widely used MCP proxy (CVSS 9.6) that a malicious or intercepted server could trigger on the user's machine (JFrog). Following "latest" got the fix to users quickly. However, it would deliver a hijacked release just as quickly.
None of this is a flaw in MCP, and MCP servers don't have to run this way. Each case was a choice in the setup instructions or the configuration, and some of the servers we reviewed avoid it entirely (see the note for maintainers below). Of the 35 MCP servers we reviewed, from two vendor catalogs and our own lab, 23 had a recommended install that would have resolved dependencies fresh from a public registry, either with every launch or with each install or image build.
A software supply-chain attack doesn't break into your systems directly. It compromises something you already install, and lets your own update process deliver it.
Software that fetches updates and holds credentials is normal. Your EDR agent, your browser, and your operating system all do both. What makes that acceptable isn't a signature by itself. It's that the update comes from a controlled build, is reviewed and tested before release, and you can see which version is running. An unfrozen environment has none of that:
| A managed software update | An MCP server through a one-step launcher | |
|---|---|---|
| Who can change what runs | A publisher, through a build and release process you've chosen to trust | The maintainers of any package in the dependency tree. In our example, about 70 packages, each with its own accounts and release process. |
| How changes are controlled | Built from a defined set of inputs, then reviewed and tested before release | Whatever the registry serves, within the allowed version ranges, when the environment is rebuilt |
| When it changes | When the publisher releases, often in stages | When a rebuild happens. In our example, every couple of days, during ordinary assistant sessions. |
| How you'd know | A version number and release notes | You wouldn't. The MCP server's own version number doesn't change. |
| What new code can reach | What the software was designed to do | Everything the MCP server's credentials can reach |
Managed updates aren't immune. Signed updates are compromised as soon as attackers get into the build process itself. For example, the SolarWinds vulnerability in 2020 involved a trojanized update that was signed with the company's own certificate (CISA). Then in 2023 3CX happened because of a signed installer from another software vendor (Mandiant). The difference between these examples and the ones I’m diving into in this post is that a controlled build has defined inputs and someone accountable for each change; but an environment that re-resolves its dependencies at launch has neither.
There are two properties of MCP servers that make this noteworthy:
Every dependency runs with the MCP server's access. When an MCP server starts, it loads its dependencies into the same process. Code in any of them, including packages that no one chose deliberately, runs with whatever the server has: the API tokens in its environment, the user's files, and the network paths from that workstation. A malicious package doesn't have to be called. Some of the payloads described below ran as soon as Python started or the package loaded.
MCP servers are started automatically. The administrator adds the configuration line once. After that, the assistant starts the server at the beginning of each session, so the launcher runs far more often than anyone realizes, and no one runs an install command.
Packages do get hijacked. Attackers target whichever dependency is easiest—a maintainer account with a reused password or a phished token, a release pipeline with a weak secret, or a project handed to a new maintainer. A malicious version then looks like a normal update to anything that accepts new versions automatically. Here are some recent examples of this:
Two things about all of these examples stand out. Attackers now look for AI coding assistants and the keys they hold. And a malicious release may be available for only minutes or hours, so whether you're affected comes down to whether anything is rebuilt during that window. A launcher that rebuilds every couple of days, started automatically, has more chances to land in one.
This is why the typical mitigation advice in the community follows one of two paths: pin specific versions of dependencies and only change them periodically, or set your preferences to only download packages once they have been available for a certain amount of time, such as a few days. This gives people a chance to catch malicious packages and have them removed from the repositories before you download. Unfortunately, this automatic dependency installation behavior threatens to undermine these best practices.
The evidence comes in three layers, and it helps to keep them separate. The network tells you which hosts contact code registries and whether their activity looks automated. This provides security teams with a short list of hosts to look at. The endpoint tells you which process made those connections and what started it. Only a look at what's installed (the launcher's cache, or the endpoint's file events) tells you whether the code actually changed. Contacting a registry isn't evidence of drift on its own; a cached launcher may only be checking a tag.
Step 1: Start with DNS. Corelight's DNS logs record each lookup and the internal host that made it, even when that host's internet traffic leaves through network address translation (NAT). We searched seven days of lookups for the domains that serve code and packages: GitHub's download domains, PyPI, and the npm registry. Our internal DNS resolvers topped the list, because they forward everyone else's lookups, so we set those forwarded queries aside. That left three hosts: a development server, an administrator's workstation and a jump host.
Step 2: Fill the gaps with TLS. Corelight doesn't just record that a connection happened. Zeek® extracts metadata from each transaction, across dozens of protocols, and ties each protocol record to the connection behind it. Corelight's SSL logs record the server name and timing of each TLS session. Because the server name is sent in the TLS handshake, the SSL logs identify the destination even when the matching DNS lookup never crosses the network: an answer cached on the host, a resolver inside a container host, or encrypted DNS. That added a fourth host, a container host that reached GitHub with no DNS lookup on the wire. One caveat: this depends on the server name being visible. Encrypted ClientHello (ECH) hides it, so as ECH adoption grows, hunting by server name will see less. In our lab, all of these sessions carried a readable server name.
The SSL logs can also help separate people from tools. Here's what we saw over 80 minutes on the administrator's workstation. These are observations from our lab, not general rules:
| A person in a browser | The launcher starting an MCP server | |
|---|---|---|
| Domains | GitHub plus its supporting services: page assets, avatars, the API, telemetry and a live-update channel | Only code and package hosts: GitHub, its raw-file host, PyPI, and PyPI's file host |
| Session length | About 30 seconds, kept open for reuse; the live-update channel stayed open for 5 to 23 minutes | 0.1 to 1.5 seconds |
| Rhythm | Clusters on page loads, then API polling every one to four minutes while the tab was open | Two to five sessions within about three seconds of each launch, then nothing |
| Data | Varied with the page; up to 4 MB of assets | About 24 KB for each tag check against GitHub, and more only when new package versions downloaded |
To label each connection with the process behind it, we matched Corelight's records to the EDR used in this lab’s by source port, which our gateway preserved through NAT. We also hoped the TLS client fingerprint would identify the tool, but JA3 didn't. This client randomized its TLS handshake, so one burst produced about a hundred different JA3 hashes. JA4 handles that, and enabling it in this lab is on our list.
Step 3: Rank by what the host can reach. A developer's machine pulling code is normal. An administrator's workstation or a server doing it on a schedule nobody chose is worth a closer look, because of the credentials it holds. That put the workstation first.
Step 4: Attribute the connections. The EDR now had four hosts to look at instead of the whole network. For each one, we asked which process made the connections to GitHub and PyPI, using each process's network events. In the EDR records we had, Windows DNS events were attributed to the system DNS service rather than to the program that asked, so network events were the reliable way to tie a connection to a process. Three hosts had an expected explanation:
| Host | What fetched code | Started by | GitHub | PyPI | Verdict |
|---|---|---|---|---|---|
| Development server | Corelight's content updater, plus Git, the GitHub CLI, and test builds | System service and the developer | 1,700 | 0 | Expected |
| Jump host | Corelight Fleet Manager | System service | 16 | 0 | Expected |
| Container host | A threat-intelligence connector | Container | 4 | 0 | Expected |
| Administrator's workstation | The one-step launcher, plus Git | Two AI coding assistants, each starting its configured MCP servers | 203 | 136 | Investigate |
GitHub and PyPI columns: connections seen by the EDR over one week. Browser and code-editor traffic on the workstation (607 GitHub connections) is excluded as normal.
Volume wasn't the signal: the development server pulled far more from GitHub. The workstation stood out because it was the only host installing packages from PyPI, the installs happened when an AI coding assistant started its MCP servers rather than on a service's schedule, and the processes doing it held management-plane credentials.
Step 5: Follow the process. The EDR’s process lineage showed the same chain on each launch. The AI coding assistant started the launcher, the launcher checked GitHub and sometimes PyPI, then it started the MCP server. The same telemetry showed where those servers connected next; more than 1,600 connections in a week to 11 management systems, including the firewall, the SIEM and sensor management. That's the combination worth acting on: a process whose code can change without review, holding credentials to the management plane.
The network and process records tell you where to look. To know whether the code changed, look at what was installed. Each rebuild ran the server from a new cache folder named in its command line, so counting those folders in the EDR’s process events counted rebuilds: 17 to 21 per MCP server over 30 days. The EDR's file events also recorded the version-numbered package folders the launcher created during each rebuild. We confirmed the build and version counts from the launcher's cache on disk, and the endpoint record matched.
The fix is to freeze the environment once, not just pin the release, and stop rebuilding it at launch:
We froze five MCP servers this way and checked three things:
| Launch | Corelight (TLS to GitHub and PyPI) | EDR (process connections) |
|---|---|---|
| Original method | A burst within seconds of each launch | Git to GitHub and the launcher to PyPI, even when the cached environment was reused |
| Frozen install | None | None, once the update check was off |
Since then, the EDR has recorded no connections to GitHub or PyPI from the frozen servers, apart from the one-time install. Quiet network logs alone don't prove a fix, though. A cached, unfrozen install can also be quiet between rebuilds. It’s important to use the network comparison alongside the version check, not instead of it.
The frozen servers were also started in about 2 seconds instead of 4 to 11.
Freezing stops the drift. It doesn't decide who updates the code, or how much that code can do.
If you have an AI agent connected to your Corelight data (and optionally your EDR) through an MCP, you can run this hunt yourself. Copy the prompt below, and adjust the time range and domain list to suit your environment.
Using Corelight DNS and SSL logs from the last 7 days, find the internal hosts that download code from public repositories: github.com, codeload.github.com, raw.githubusercontent.com, objects.githubusercontent.com, pypi.org, files.pythonhosted.org, and registry.npmjs.org.
Treat registry contact as a lead, not proof that code changed. Keep what you observed separate from what you inferred, and finish with a short, prioritized list of hosts to review.
Expect developer machines in the results. What matters is everything else: servers, jump hosts and admin workstations that pull code on a schedule nobody chose, especially when the process behind it holds credentials. For each one, the follow-up is to check what's installed and whether it has changed.
Most of this is fixable at the source, and some vendors already get it right. One official MCP server we reviewed ships a hash-locked lock file, builds its container image strictly from that lock, and stops the runtime from re-syncing at startup; this is the frozen pattern described above. Another installs through its lock file from a cloned repository. The community server we looked at had done the hard part too, with exact pins and a hash-pinned lock file. What was missing was an install path that used the lock file. A short addition to an MCP server's README would close the gap:
Install the [server name] MCP server version [X] as a frozen build. Download the hash-pinned requirements file published with that release, create a dedicated environment in [path], install the dependencies with hash checking enforced, then install the server itself without resolving dependencies. Configure this assistant to launch the installed program directly, not through a one-step launcher, and turn off any startup update checks. Before changing my configuration, show me the installed versions and the exact config change, and tell me how to repeat this when I upgrade.
Users can paste the same prompt today for any MCP server that publishes a lock file. If a server doesn't publish one, that's worth knowing before you give it your credentials.
AI agents are increasingly acting as privileged users on the network. Treat each one like any other admin account: know what it can reach, what it runs, and where its code comes from.
Want to follow more insights like this? Subscribe to our newsletter in the footer of our website below.
The SSH Inference package installs on sensors with a few clicks and provides network traffic analysis (NTA) inferences on live SSH traffic.
At Black Hat USA 2026, our NOC team found third-party apps leaking photos, phone calls, and PII in the clear. Don’t trust. Verify.
While I have used log collection and SIEM platforms to review Zeek transaction logs, it is not necessary to wait for a SIEM before collecting...