My AI trading tool would sign a wallet drain as a login challenge
BagOS Security Review: Wallet Drain via Login Challenge
BagOS is an MCP server I maintain. It lets an AI agent read token data on Bags, a Solana launchpad, and, if configured with a wallet, trade and claim creator fees. The core design principle is that 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, and every transaction is simulated before it's signed.
The Attack
The vulnerability required two flaws. Either one alone was harmless. The attack chain works as follows:
Flaw 1 - Trusting the open folder.
MCP clients such as Claude Code start a local server in the current project folder. BagOS calls dotenv.config() which reads .env from the working directory. Consequently, any repository you open could supply configuration you never set, including BAGS_API_URL, the endpoint the login tool talks to.
Flaw 2 - The login tool signs arbitrary challenges.
The bags_authenticate function 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. Anyone receiving it can broadcast it.
Put them together. A repository ships a .env that points BAGS_API_URL at a server its author controls. You open the repo, and your agent calls bags_authenticate - perhaps 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 was not a write tool, so none of the guardrails applied: no token gate, no cap, no preview, no confirmation. My docs even stated "Signing a challenge is not signing a transaction." Before the fix, that statement was not true.
Why 100% Test 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 verified that it fetched a challenge, signed it, and exchanged it correctly. However, coverage measures which lines run. It says nothing about which inputs were assumed safe. The 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 was not untested; it had never been written.
The Fix (Version 3.0.0)
Version 3.0.0 is a breaking release because it changes how configuration loads. The server no longer reads .env from the working directory. You must 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";
}
}
If a .env sits in the working directory, the server rejects it with an error on stderr. The login tool now signs only Bags' exact sign-in text, paired 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;
}
}
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.
Additional Issues (Fixed in 3.0.5)
Version 3.0.5 closed a second gap. The spend caps previously checked the amount the agent requested, but the actual swap transaction that gets signed is built by the Bags API, and nothing compared the two requests. The cap bounded the request, not the signature. The fix adds a balance verification step before signing:
Before signing, BagOS reads the wallet's SOL balance, asks the simulation for the balance afterwards, and refuses if the difference exceeds 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. This check is required on every path, so no transaction can skip it.
What's Still Open
These items are documented in SECURITY.md rather than hidden:
- HTTP mode has no auth. The
--httpmode serves/mcpon0.0.0.0. Do not 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 moves SPL tokens wouldn't trigger the check. This is 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, allowing the price to shift within the five-minute window.
- Caps are in SOL. A swap from another token cannot be valued, so those swaps are refused unless you opt in.
Security Guidance for MCP Servers Holding Keys
Treat configuration as input. An MCP server's working directory belongs to whichever project is currently open. Only the operator should set configuration in the client's own settings. Never sign bytes you didn't construct or verify. As the authors observed, "It's just a login" is how a signing tool ends up outside every guardrail.
Bound the effect, not the request. Always simulate the transaction and check what it actually does to the wallet before signing it. Keep an adversarial reviewer in the loop. Both vulnerabilities were found by manual review, not by automated testing - the tests were written with the same assumptions as the code, so they could not detect these issues.
Links:
- Repository
- Advisory
-
SECURITY.md - npm
Earlier I wrote about a separate BagOS bug where write tools reported success without signing anything. I ship safety layers for AI agents that move money. I'm open to remote work.
Comments
No comments yet. Start the discussion.