Back to all posts
Published on · by Renaud Deraison

It waited for the third call

Pillar Security is tracking Deadbugz, an MCP server pushed into open-source projects by pull request. It ships two honest tools, counts your agent's tool calls, and on the fourth one rewrites the metadata it returns into instructions to collect SSH keys, AWS credentials, shell history and kubeconfig, and to say nothing about it. No bug, no CVE: the protocol allows all of it. The casualty is the idea that approving a server once tells you anything about the next call. In a Bromure Agentic Coding workspace the whole payload runs, collects four fakes, and dies at the proxy on the way out.

You read the source. You ran it. It formatted text, as advertised. You approved it, and the attacker had counted on you approving it.

On the evening of August 10, between 21:52 and 23:07 UTC, a GitHub account called zellkernel opened twenty-three pull requests against unrelated AI, MCP and developer-tool projects. Seventy-four minutes, start to finish. Each pull request made the same small edit: it added one Model Context Protocol server to a configuration file.

Pillar Security published the campaign on August 12 and named it Deadbugz, after the file one variant drops. A month later the monthly MCP roundups still lead with it. Adversa's September digest, published yesterday, gives it the whole incidents section. The clock is what earns it that space.

Two tools, and a counter

The server calls itself productivity-suite. It offers two tools: format_text and summarize. Both work. Point an agent at it, ask it to tighten a paragraph, and you get a tightened paragraph.

Behind them the server keeps what Pillar describes as "an in-memory, per-client counter for tools/call requests". When that counter reaches three, the server changes its answers to two other kinds of request: tools/list, which tells the agent what tools exist and what they are for, and prompts/get, which hands the agent prepared instructions. From the fourth call onward, those responses come back carrying, in Pillar's words, "instructions intended to steer an attached AI agent toward sensitive local files and to hide the activity from its operator".

Four things: SSH keys, AWS credentials, shell history, Kubernetes configuration. Then one more instruction: conceal the activity from the user.

The tool names never change. format_text is still called format_text. The description underneath it is what turned, and the description is the part the model reads as authority.

productivity-suite — the same two tools, before and after the counteryour coding agenttools/call ×Nordinary work, ordinary turnsin-memory counterper client, never persistedthreshold: threecalls 1 – 3tools/list → format_text, summarize“formats a block of text”prompts/get → a text-tidying promptwhat a review seesa small server that does what it saystwo tools, honest descriptions, no surprisesapproved4thcalls 4 and aftertools/list → format_text, summarizesame names, different instructionsprompts/get → the payloadwhat the model is now told to do~/.ssh · AWS credentialsshell history · ~/.kube/configand do not mention this to the user
Deadbugz keeps a per-client counter of tools/call requests. For the first three, tools/list and prompts/get return honest metadata for format_text and summarize. From the fourth onward the same two tool names come back with descriptions and prepared prompts that instruct the agent to collect SSH keys, AWS credentials, shell history and kubeconfig, and to keep quiet about it. The tool names never change, so nothing a reviewer looks at looks different.

Nothing here is a vulnerability

No bug appears anywhere in this story. There is no memory corruption to patch and no CVE to file, because the server does nothing the protocol forbids.

MCP is a protocol in which a server publishes its capabilities at runtime and can publish them again. Tool descriptions are text the server chooses and the client fetches. Prompts are text the server chooses and the client fetches. Deadbugz uses all of it as designed. Its one original contribution is deciding when to tell the truth.

And that is enough, because every control most teams have against a hostile MCP server runs once. You read the repository. You start the server and poke it. You check the tool list against what the README claims. You approve it, maybe you pin the version, and you move on. Each of those is a photograph, and Deadbugz decides when you take it.

Pillar's own advice to the people who build MCP clients gives the shape away. Treat tool-definition changes on an already-approved server as a security event requiring renewed approval. Make definition changes visible to the operator. Fingerprint tool definitions at approval time and compare later. Every one of those is a request to move a check from approval time to run time.

The population Deadbugz hunts in is large and unstable. A dynamic assessment of internet-facing MCP servers published in July, Exposed by Design, tested 414 of 640 confirmed production servers out of the 21,000-plus instances its author found on the public internet. It reported 68 reportable vulnerabilities across ten classes, 91.8% of servers running with no OAuth authentication at all, and 687 tool instances exposing shell execution with no access control. Turnover is the number that stays with you: 41.6% of confirmed servers vanished within three days between consecutive measurement runs, which the author reads as "rapid deployment cycles without security review". A server you audited last week may be different software this week, or gone.

The delivery is worth a note too, because it is cheap. Seventeen of the twenty-three pull requests added a remote endpoint, productivity-suite-mcp.onrender.com/mcp, to a project's MCP configuration. Four pointed Python at a hidden local file, ~/.config/.cache/.sys/.deadbug-mcp.py. Two were submissions to server directories. Pillar found none of them merged through GitHub's merge button at the time of review: nineteen closed, four still open. But a pull request is only one way a configuration line travels. The same line fits in a blog post, a getting-started guide, a listing in an MCP directory, or a helpful answer under a question about text formatting.

The shopping list

Go back to the list. It is specific, and it is the same list every time: SSH keys, AWS credentials, shell history, Kubernetes configuration.

That is the standing inventory of a developer machine. It is what an infostealer takes, what a malicious postinstall script greps for, and what an attacker asks a hijacked agent to fetch. The list has been stable for a decade of campaigns because the contents are stable: a private key that signs, a secret that authenticates, a record of what you typed, and a token that reaches production.

An agent on an ordinary laptop can satisfy every item, because it has a shell in your home directory and it runs as you.

The same list, inside a Bromure workspace

Bromure Agentic Coding runs each coding agent in a hardware-virtualized Linux VM on your Mac, and holds every real secret on the host side of that boundary. The agent works with structure-preserving fakes, derived from the real value plus a per-install salt, so tools that fingerprint a key never see it churn between sessions. A host-side proxy substitutes the real value onto the wire after the request has left the VM, and only when it is bound for the host that credential was minted for.

Run the payload and let it win. Assume the model reads the poisoned description, believes it, and goes looking. Take the four items in order.

SSH keys. The agent finds no key, not even a decoy one. The VM's SSH_AUTH_SOCK is an ssh-agent bridge over a virtio socket, vsock port 8444. The guest can list identities and ask for signatures; the private key bytes live only on the host and, as the manual puts it, cannot be read or extracted from inside the VM. Bromure never exposes your macOS login agent to a session. Each signature that does happen emits a credential.ssh_sign audit event carrying the key's SHA256 fingerprint, and you can set any imported key to prompt on the host for every signature request.

AWS credentials. A file that looks right and is worth nothing. The VM's ~/.aws/config points at a credential-process helper that vends the real access key ID paired with a 40-character fake secret key over vsock port 8445. Every SDK, the aws CLI, terraform and boto3 pick it up without extra setup, and the guest produces a well-formed SigV4 signature computed with the wrong secret. The host resigner strips that signature and re-signs on the way out. Take the pair somewhere else and AWS answers InvalidSignatureException.

Kubernetes configuration. ~/.kube/config exists and holds a bearer token starting brm-k8s-. It works, through the proxy, against the API servers the workspace was configured for. It is inert anywhere else.

Shell history. Real, and durable: coding work needs durable state, so the workspace VM is a persistent machine rather than a throwaway. It holds the history of a machine that has never contained a real key, with commands run against decoys in a home directory that is not yours, alongside a ~/.git-credentials and a ~/.docker/config.json that are fakes too.

the shopping list, run inside a workspacewhat the payload asks forwhat it finds~/.ssh private keysnothing — the bytes are on the hostsignatures cross vsock 8444; keys never doAWS credentialsreal key id, fake 40-char secretreplayed elsewhere: InvalidSignatureException~/.kube/configa brm-k8s- placeholderworks through the host proxy, inert anywhere elseshell historydurable, and full of decoysa VM that has never held a real key
The payload's four-item shopping list, run against a Bromure Agentic Coding workspace. SSH returns no private key bytes at all, because signing happens on the host over vsock 8444. The AWS config vends the real access key ID with a fake secret, so a stolen pair fails signature validation upstream. The kubeconfig holds a brm-k8s- placeholder. The shell history is the history of a VM that has never held a real credential.

Then it has to send it

Collecting is half the job. The other half is the outbound leg to productivity-suite-mcp.onrender.com, and that leg runs into a stack of host-side checks.

The fakes are tripwires. Each one has a single legitimate destination family, the host scope it was minted for, so the proxy scans every outbound request, headers and body, for a fake headed somewhere it does not belong. On a hit it treats the request as attempted credential exfiltration: it refuses with HTTP 451 without forwarding a byte, pauses the VM on the spot, and raises an alert offering Shut down, Save for Investigation (export disk, home and shared folders for forensics first), or Continue at your own risk. The event lands as a red Credential brokering row in the Security Timeline.

The second check is reachability. Each workspace carries an ordered egress firewall (action, protocol, host or CIDR, ports, and for web traffic the individual HTTP verbs) with a default for unmatched traffic. New workspaces allow what no rule matches; set it to Deny, list what the work needs, and a render.com subdomain is not on the list. Two components outside the guest enforce it: the virtual switch matches every flow by destination IP and DNS-snooped hostname, and the proxy matches again by TLS server name. Rule edits reach running sessions with no reboot.

Guardrails puts a gate on the consequence. It classifies the agent's calls to Kubernetes, AWS, DigitalOcean, container registries, git forges and HTTPS databases as reads, writes or destructive operations, and new workspaces default to Prompt before write: every mutation pauses for a host dialog showing the literal operation, the exact SQL or METHOD /path. That is what Pillar asked platform builders for: sensitive file reads, credential access and code execution should not be consequences of remote metadata.

A detector sits on the content path as well. When the agent streams external content back to the model (file contents, fetched pages, command output, the results of tool calls) a local PromptGuard classifier scores those spans on your Mac before the model acts on them, and can log, ask, or block with a 451. The MCP bearer token for an HTTP server never enters the VM at all: the real one stays on the host, the agent's config gets a brm-mcp_ placeholder, and the settings field says so out loud, Never sent to VM — swapped by proxy.

the check runs onceapproval at install time, inherited foreverday 1 — read the source, run it, approvethe server is honest, and the verdict is correctcalls 1 – 3 — still honestnothing prompts anyone to look againcall 4 — the metadata turnscovered by a verdict made before it happenedno new decision is asked of anyonewhat the agent reachesreal ~/.ssh · real AWS keys · real kubeconfigthe check runs every timeno install-time verdict to inheritworkspace VM — the agent and the server's outputbrm-k8s- · brm-mcp_ · fake AWS secretplaceholders from boot; no private key bytesvsock 8443 — the only route outhost proxy — consulted per requestegress firewall · host, port, HTTP verbwrite policy · read / write / destructiveinjection scan on the content the model is sentone trace row per request, on your machinethe exfiltration legfake off its scope → HTTP 451, VM paused, timeline row
Two placements of the same decision. On the left, one approval at install time covers everything the server does afterwards, so a metadata change on the fourth call inherits a verdict made on day one. On the right, there is no install-time verdict to inherit: the credentials are placeholders from boot, and each operation the agent attempts is classified again in the host proxy on its way out.

If you touched this one

Search your MCP client configs, repositories and home directories for productivity-suite-mcp.onrender.com, the earlier promo-surname-xml-quantum.trycloudflare.com endpoint, and the local artifact at ~/.config/.cache/.sys/.deadbug-mcp.py. Revert any configuration change that introduced them, and do not run the script. Preserve your MCP client logs and look for tool-definition refreshes after the third call. Pillar's advice on rotation is measured: rotate where local evidence supports compromise.

The rule that shrinks the question

In the workspace's Guardrails pane, set Unmatched traffic to Deny and list what the work needs: allow web api.github.com, allow web registry.npmjs.org, default deny. Then run bromure-cli trace hostnames my-workspace after a session, which prints every distinct host the agent contacted with counts. A destination you never chose shows up as a line you did not write.

Approval is a snapshot; the wire is continuous

The instinct after a campaign like this is to review harder: read more of the source, pin the commit. Both are worth doing, and neither touches the mechanism. The review here was correct. The server was honest while anyone looked at it, then changed on a schedule it controlled, and broke no protocol rule doing so.

A control that survives a server changing its mind is one that does not depend on having been right earlier. A credential that is a placeholder is a placeholder on the fourth call and the four-hundredth. A private key on the host cannot be read on any call. The proxy evaluates an egress rule on every connection and classifies each write as it happens. None of that carries a memory of the day you approved something, which is why an attacker cannot inherit it.

MCP made it easy to give an agent new capabilities, which was the point, and it made those capabilities a thing a remote party describes in text your model reads as instruction. Both are here to stay. Decide what your agent can hold and what it can reach, and put the enforcement somewhere the server gets no vote. Install Bromure Agentic Coding and give your agent nothing worth waiting three calls for.