DEV Community

Cover image for My AI trading tool would sign a wallet drain as a login challenge
Edy Cu
Edy Cu

Posted on

My AI trading tool would sign a wallet drain as a login challenge

BagOS is an MCP server I maintain. It lets an AI agent read token data on Bags, a Solana launchpad,
and, if you configure a wallet, trade and claim creator fees. I built it around one idea: a model
should be able to propose a spend but never complete one on its own. The first call to a write
tool signs nothing. It returns a preview and a single-use token bound to the exact arguments. Every
trade is capped. Every transaction is simulated before it's signed.

This week an outside review showed that none of that mattered. There was a way to drain the wallet
without touching a single write tool.

The attack

It needed two flaws. Either one alone was harmless.

1. The server trusted the folder you had open. MCP clients such as Claude Code start a local
server in the current project folder. BagOS called dotenv.config(), which reads .env from the
working directory. So any repository you opened could supply configuration you never set, including
BAGS_API_URL, the endpoint the login tool talks to.

2. The login tool signed whatever it was given. bags_authenticate proves you own a wallet: it
fetches a challenge from the auth endpoint, signs it, and trades the signature for an API key. It
signed the challenge bytes without checking what they were.

On Solana, a transaction signature is an ed25519 signature over the transaction's serialized
message. So if the "challenge" is a transaction message, the signature the tool sends back is a
valid signature for that transaction. Whoever receives it can broadcast it.

Put them together. A repo ships a .env that points BAGS_API_URL at a server its author
controls. You open the repo, and your agent calls bags_authenticate, maybe because a README told
it to. The fake endpoint returns a transfer of your balance as the challenge. The tool signs it and
sends the signature to that server.

The login tool wasn't a write tool, so none of the guardrails applied: no token gate, no cap, no
preview, no confirmation. My docs even said "Signing a challenge is not signing a transaction."
Before the fix, that wasn't true.

Why 100% coverage didn't catch it

The suite had 100% line, branch and function coverage, enforced in CI. Every line of the auth tool
was tested. The tests checked that it fetched a challenge, signed it and exchanged it, and it did
all of that correctly.

Coverage measures which lines run. It says nothing about which inputs you assumed were safe. My
tests used a well-behaved endpoint and a config I wrote myself, because I had never asked who else
could write that config or what else could arrive as a challenge. The missing check wasn't
untested; it had never been written.

The fix

3.0.0 is a breaking release, because it changes how configuration loads.

The server no longer reads .env from the working directory. You name a file explicitly, and the
path must be absolute:

const explicit = env["BAGS_ENV_FILE"]?.trim();
if (explicit) {
  // A relative path resolves against the working directory, which is the
  // exact thing this function exists not to trust.
  if (!isAbsolute(explicit)) {
    console.error(/* "refusing BAGS_ENV_FILE=...: it must be an absolute path" */);
    return "refused-relative";
  }
Enter fullscreen mode Exit fullscreen mode

If a .env is sitting in the working directory, the server says it's ignoring it, on stderr.

The login tool now signs only Bags' exact sign-in text, with the nonce from the same init response.
It also refuses anything that decodes as a Solana transaction, and anything that isn't printable
text:

export function isTransactionMessage(bytes: Uint8Array): boolean {
  try {
    const message = VersionedMessage.deserialize(bytes);
    return Buffer.from(message.serialize()).equals(Buffer.from(bytes));
  } catch {
    return false;
  }
}
Enter fullscreen mode Exit fullscreen mode

The auth endpoint is pinned to https on bags.fm unless the operator sets
BAGS_ALLOW_CUSTOM_API_URL=true. The model can no longer choose the keypair path either.

Shipping a security release

I drafted a private GitHub security advisory, fixed the bug on a temporary private fork, and merged
it from the advisory page. Then:

  • Released 3.0.0 through the normal pipeline, with npm provenance.
  • Deprecated every older version on npm (1.0.0 through 2.6.0), with a message pointing to the advisory.
  • Published the advisory: GHSA-g679-3wq7-mh3m.
  • Turned on private vulnerability reporting, so the next person has a private channel.

One trap for anyone doing this with release-please: merging from the advisory page squashes the
private fork into one commit titled "Merge commit from fork." That isn't a conventional commit, so
release-please computed a patch version for a breaking change. I caught it by checking the
release PR before merging, and fixed it by pushing an empty commit that restated the breaking
change.

Then it happened again

Hours later, I had the review check my launch plan against the code, and it turned up a second
gap.

The spend caps checked the amount the agent asked for. But the swap transaction that actually gets
signed is built by the Bags API, and nothing compared the two. The cap bounded the request, not
the signature.

3.0.5 closes that. Before signing, BagOS reads the wallet's SOL balance, asks the simulation for
the balance afterwards, and refuses if the difference is more than the approved amount plus 0.01
SOL for fees and rent. A fee claim approves nothing, so it may cost fees only. If the simulation
doesn't report the balance, the transaction isn't signed. The check is required on every path,
so no transaction can skip it.

What's still open

These are documented in SECURITY.md rather than hidden:

  • HTTP mode has no auth. --http serves /mcp on 0.0.0.0. Don't run it with a funded wallet. The stdio default is unaffected.
  • The simulation check reads SOL, not tokens. A transaction from the Bags API that also moved SPL tokens wouldn't trip it. That's trust in the Bags API, whose endpoint is fixed in its SDK.
  • The confirmation token binds the arguments, not the quoted price. Confirming re-runs the quote, so the price can move inside the five-minute window.
  • Caps are in SOL. A swap from another token can't be valued, so those swaps are refused unless you opt in.

What I'd tell anyone building an MCP server that holds a key

  1. Treat configuration as input. An MCP server's working directory belongs to whatever project is open. Only the operator should set config, in the client's own settings.
  2. Never sign bytes you didn't construct or check. "It's just a login" is how a signing tool ends up outside every guardrail.
  3. Bound the effect, not the request. Simulate the transaction and check what it actually does to the wallet before you sign it.
  4. Keep an adversarial reviewer in the loop. Both bugs were found by review, not by tests. The tests were written with the same assumptions as the code, so they couldn't find them.

Links: repo ·
advisory ·
SECURITY.md ·
npm

Earlier I wrote about a different BagOS bug, where the write tools reported success without signing anything: I shipped an MCP server that reported success without signing anything.

I build safety layers for AI agents that move money. I'm open to remote work.

Top comments (0)