I tested a self-hosted AI platform: a chat UI in front of several LLM providers, with agents that call out to real company tools through MCP. The bug that mattered most came from a chain. One verbose error told me what the platform was running. That told me which public CVE applied. And that pointed straight at the endpoint that mattered.
3 findings · Self-hosted AI platform · Chain method · ~6 min read
01 · THE SETUP
Three layers, one trust boundary
The platform is three layers stacked on top of each other.
Chat backend. Takes the user's message and per-request options (which model, which tools) and hands them off.
LLM routing layer. Embedded as a library rather than run as its own service. It normalises the request for whichever provider is selected (OpenAI, Anthropic, Gemini, a self-hosted model).
MCP layer. Model Context Protocol servers that expose real tools (ticketing, CRM, internal APIs) to the agent, gated by credentials the platform stores on the user's behalf.
The MCP layer is where the interesting trust boundary lives. Some servers are shared org-wide. Others are configured per-user, meaning each person authenticates individually and the token that results is supposed to be theirs alone.
I tested as one standard, authenticated account. Nothing elevated. The question that mattered was simple: does "per user" actually mean per user once that token is stored?
Two paths, one account. The LLM path leaks the stack; the MCP token path leaks other users' credentials.
02 · THE CHAIN
Three findings, each one paying for the next
None of these needed exploit chaining in the traditional sense. What chained was information. Each finding pointed straight at the next request to send.
The chain: a leaked error becomes a pinned version, becomes a matching CVE, becomes the critical.
FINDING 1
A stack trace told me the stack
The promise: internal errors stay internal.
What I did
Sent a chat request with a model name the SDK's provider mapping mishandles.
Sent a second request naming a real model the deployment's cloud project wasn't provisioned for.
What happened
First request returned a full Python traceback: internal file paths, the LLM SDK's version-specific internals, the Python runtime version. The root cause was a known, public upstream bug, a response parser choking on a constant of the wrong type.
Second request returned the cloud provider's raw error, including the project identifier and inference region.
Two more variants surfaced the same way: a raw internal dictionary key in an unhandled exception, and a duplicate-tool-registration error that printed internal MCP tool names.
Why it matters
Not RCE, but free reconnaissance.
Source paths plus a pinned SDK version mean a CVE lookup instead of a guess.
A leaked cloud project ID is infrastructure fingerprinting, handed over by the app itself.
The leaked internal tool and object names became the table of contents for what to test next.
POST /chat { model: "<malformed>" } -> full stack trace, internal paths, SDK version
POST /chat { model: "<valid-but-unprovisioned>" } -> upstream error, cloud project ID leaked
FINDING 2
The version in the stack trace was a known-bad version
The promise: the components behind the platform are current and patched.
What I did
Took the SDK version and paths from Finding 1, then matched line numbers against public release history to pin an exact version.
Fingerprinted the platform's own version the same way, then cross-referenced both against public advisory databases.
What happened
The embedded LLM SDK was running well behind current. Ten CVEs exist for it, all in a proxy-server component this deployment doesn't run, so all ten were not applicable here. One non-CVE bug in that same codebase, however, matched Finding 1's crash exactly.
The platform itself was one major release behind, squarely inside the vulnerable range of a critical, publicly disclosed CVE. That CVE is Finding 3, below.
Why it matters
On its own, this is a hygiene finding.
Chained, it's the step that turns "I have a stack trace" into "I know exactly which public exploit applies."
FINDING 3
CRITICAL · CVSS 9.6
Cross-user OAuth tokens, returned to any authenticated user
The promise: a per-user credential is readable by exactly one user, the one it belongs to.
In plain terms. Onyx lets each user connect an external tool (a helpdesk, a CRM) with their own login, and that login token is meant to stay private to them. CVE-2026-71424 is the bug where asking Onyx to list the connected servers hands back another user's token: whoever connected to each tool most recently. The cause is a storage slip: each person's private token was being copied into one shared record per tool, overwriting the previous one, and every user is allowed to read that record.
What I did
As a standard, non-admin account, called the endpoint that lists an organisation's configured MCP servers.
What happened
The response listed every MCP server in the org, including each server's auth headers, even for servers I had never connected and didn't own.
For each server configured as per-user, the live OAuth Bearer token of another user, whoever had connected most recently, came back in plain text in the Authorization header field.
This is a published vulnerability, CVE-2026-71424 (CVSS 9.6, CWE-200 and CWE-863), affecting the platform before versions 3.1.10, 3.2.14, and 4.0.0.
The root cause is a storage bug plus an exposure bug. Per-user tokens get copied into a shared template row meant to hold only the server's OAuth app settings, with each new login overwriting the last. The server-listing endpoint then returns that row's headers, unmasked, to any logged-in user.
The platform's own status flags marked these tokens as active and connected.
The storage slip: each login overwrites one shared row per tool, which is then served to any authenticated user.
Why it matters
These tokens carry their owners' real permissions against a live third-party system. Here that was a helpdesk platform with well over a hundred callable tools under that scope.
Read and write access to another employee's ticketing data, via one GET request, from a low-privilege account.
03 · WHERE THIS SITS
Two frameworks, same shape
OWASP Top 10 for LLM Applications (2025)Sensitive Information Disclosure covers the app leaking internal state through errors or output (Findings 1 and 2).
OWASP Top 10 for Model Context Protocol (2025, beta)Token Mismanagement & Secret Exposure and Insufficient Authentication & Authorization both map almost directly onto Finding 3: a credential persisting somewhere it shouldn't, retrievable by an endpoint that never checks whose data it's serving.
This isn't a coincidence. Agentic platforms introduced a new trust boundary, per-user credentials held by a shared service, and it keeps breaking the same handful of ways across unrelated products. Recognising the shape early is half the audit.
04 · THE PATTERN
One promise breaks, the next one is free to find
A verbose error is reconnaissance.
Reconnaissance turns into a version, and a version turns into a CVE lookup.
The endpoint meant to enforce "your token stays yours" never checked whose token it was serving.
None of this needed a novel technique. Each step is the system telling on itself, a little more each time, to someone who kept asking the next obvious question.
The takeaway. Don't stop at the first error message. A stack trace is a free fingerprint of every component behind the request. Pin the versions, check them against public advisories, then go test the specific thing those advisories say is broken. On a platform storing other people's credentials on your behalf, that specific thing is almost always worth checking first.