//nefariousplan

CVE-2026-33980: adx-mcp-server's Three "Safe" Tools All Call execute. The Approval Policy Was the Tool's Name.

patterns

cve

proof of concept

adx-mcp-server is an MCP server that wraps Azure Data Explorer. It registers four tools with the model: execute_query, get_table_schema, sample_table_data, and get_table_details. MCP clients that distinguish "safe" tools from "dangerous" tools route the first one through a human approval prompt and auto-approve the other three.

All four tools terminate at the same line of code: client.execute(config.database, query). The first one passes query straight through from the caller. The other three wrap the caller's string in an f-string with a fixed prefix or suffix. KQL's line-comment token is //. The suffix is optional.

CVE-2026-33980, KQL injection in pab1it0/adx-mcp-server at or below commit 48b2933. CVSS 8.3. Patched in 0abe0ee on 2026-03-25 by adding a regex that enforces what each tool's name had been advertising.

All four tools call execute

The four tools, exactly as pab1it0/adx-mcp-server registered them at commit 48b2933:

@mcp.tool(description="Executes a Kusto Query Language (KQL) query against the configured Azure Data Explorer database and returns the results as a list of dictionaries.")
async def execute_query(query: str) -> List[Dict[str, Any]]:
    client = get_kusto_client()
    result_set = client.execute(config.database, query)
    ...

@mcp.tool(description="Retrieves the schema information for a specified table in the Azure Data Explorer database, including column names, data types, and other schema-related metadata.")
async def get_table_schema(table_name: str) -> List[Dict[str, Any]]:
    client = get_kusto_client()
    query = f"{table_name} | getschema"
    result_set = client.execute(config.database, query)
    ...

@mcp.tool(description="Retrieves a random sample of rows from the specified table in the Azure Data Explorer database. The sample_size parameter controls how many rows to return (default: 10).")
async def sample_table_data(table_name: str, sample_size: int = 10) -> List[Dict[str, Any]]:
    client = get_kusto_client()
    query = f"{table_name} | sample {sample_size}"
    result_set = client.execute(config.database, query)
    ...

@mcp.tool(description="Retrieves table details including TotalRowCount, HotExtentSize")
async def get_table_details(table_name: str) -> List[Dict[str, Any]]:
    client = get_kusto_client()
    query = f".show table {table_name} details"
    result_set = client.execute(config.database, query)
    ...

Four advertisements, four call sites. Three of them build a query string by interpolating a caller-supplied parameter. The fourth one names what is happening and accepts the parameter under its real label.

// is a KQL comment

The bug needs only one fact about KQL beyond what the f-strings already make clear: // starts a line comment, and KQL accepts it inside any pipeline.

f"{table_name} | getschema" with table_name = "sensitive_data | project Secret, Password | take 100 //" becomes:

sensitive_data | project Secret, Password | take 100 // | getschema

The | getschema is now commented out. The query runs as sensitive_data | project Secret, Password | take 100. The MCP client saw an auto-approved call to get_table_schema. The cluster received a free-form KQL query against a table the caller named themselves.

f".show table {table_name} details" with table_name = "users details\n.drop table critical_data" becomes:

.show table users details
.drop table critical_data details

KQL evaluates statements separated by newlines. The first statement returns table metadata for users. The second statement is a management command that destroys the named table. The MCP client saw an auto-approved call to get_table_details. The cluster received .drop table.

sample_table_data is the same shape with a | sample N suffix that the same // comments out.

The MCP approval policy reads the tool's name

MCP clients implement tool-call approval by reading what the server registered: tool name, tool description, and the parameters the model is about to pass. The MCP spec leaves the policy itself to the client, and the convention that has emerged is to gate by tool name. Tools whose name implies an arbitrary execution primitive (execute_query, run_command, eval) draw a per-call prompt. Tools whose name implies a constrained metadata primitive (get_*, list_*, sample_*, describe_*) get auto-approved, or get a "trust this tool forever" prompt that the operator clicks once and forgets about.

The convention works when the tool's body is constrained to the primitive its name advertises. Here it is not. get_table_schema advertises "schema retrieval for a named table" and implements "any KQL query the caller writes ending in | getschema, with the suffix removable via //." The advertisement is a docstring. The body is forty-three characters of client.execute(config.database, query).

A user who routes an assistant through this server, lets the model pick tools, and clicks "always allow get_table_schema" once because they assume schema retrieval is read-only metadata, has authorized arbitrary KQL against their cluster for the rest of the conversation. An auto-approve mode keyed on a get_* name pattern does the same thing on every call. A whitelist of safe tools, configured by the operator on first use, does the same thing.

The patch is the regex the tool name had been promising

Commit 0abe0ee, "Add input validation for table_name and sample_size parameters to prevent KQL injection attacks":

+_TABLE_NAME_PATTERN = re.compile(r'^[a-zA-Z_][a-zA-Z0-9_]*(\.[a-zA-Z_][a-zA-Z0-9_]*)*$')
+
+def validate_table_name(table_name: str) -> str:
+    if not table_name or not table_name.strip():
+        raise ValueError("Table name cannot be empty")
+    table_name = table_name.strip()
+    if not _TABLE_NAME_PATTERN.match(table_name):
+        raise ValueError(...)
+    return table_name

 async def get_table_schema(table_name: str) -> List[Dict[str, Any]]:
+    table_name = validate_table_name(table_name)
     ...

 async def sample_table_data(table_name: str, sample_size: int = 10) -> List[Dict[str, Any]]:
+    table_name = validate_table_name(table_name)
+    sample_size = validate_sample_size(sample_size)
     ...

 async def get_table_details(table_name: str) -> List[Dict[str, Any]]:
+    table_name = validate_table_name(table_name)
     ...

The regex says: a KQL identifier, optionally dot-qualified. No pipes, no whitespace, no backslashes, no slashes, no newlines, no semicolons. That regex is what get_table_schema had been advertising in its description string for the entire lifetime of the project. The patch is the first time the body agreed.

Internal-only by convention, applied to MCP tool surfaces

We have named this shape before. A header documented as IPC-internal, read with security-relevant authority on every inbound request: Next.js's x-middleware-subrequest (CVE-2025-29927). A property name documented as a framework brand, queried with 'in' on attacker-controlled JSON: MikroORM's __raw (CVE-2026-34220). A header GitHub's gitrpcd treats as the source of authority for rails_env, derived from a string that also carries push options (CVE-2026-3854). A flat key=value session file where the Basic auth password handler is one of the writers (CVE-2026-41940). The pattern is internal-only-by-convention: a contract held by documentation, read by enforcement, broken at the input boundary.

The MCP version puts the boundary at the tool registry. The client trusts that the tool name maps to the tool body. The server's @mcp.tool(description=...) decorator is the contract. Nothing in the MCP spec, in the FastMCP framework, or in any MCP client's approval logic verifies that the body matches the description. The agreement is between the server author and a downstream operator they will never meet, mediated by a string that whoever wrote the decorator typed.

The awesome-mcp-servers list, which this PoC's author searched first, contains hundreds of servers wrapping cloud platforms, internal databases, code-execution sandboxes, and credential stores. Every one of them registers tools with the same name + description + body shape. The auto-approval policies operators rely on every day are reading the name. The body is whatever the author wrote that week.

Prompt injection turns the suffix into a remote primitive

The PoC's framing leans on AI-agent threat models, and the framing earns it. The MCP layer is where prompt injection meets infrastructure. The model reads a document, an email, a Jira ticket, a row from the same ADX cluster the server is wrapping. The text in that source instructs the model to call get_table_schema(table_name="...") with a payload. The MCP client, having been told once that get_table_schema is safe, auto-approves the call. The cluster runs the query.

This is content-is-command at the agent boundary, and the payload's destination is the same client.execute the operator never authorized. The interpreter that reads attacker content and emits commands is the model. The interpreter that reads those commands and runs them is KQL. Between them is a server whose "safe" tool names were the only thing standing in.

PoC: romain-deperne/CVE-2026-33980

The patch is a regex. Before the patch, the policy was a docstring.