Skip to content

mcpvuln 0.3.0

Latest

Choose a tag to compare

@DINAKAR-S DINAKAR-S released this 05 Sep 12:56

The headline: fourteen new rules for the MCP vulnerability classes disclosed as CVEs
during 2025 and 2026, calibrated on 55 third-party MCP servers (840,905 lines) chosen
for being obscure rather than popular, and a second public fixture corpus that scores
them. The paper's 22-instance corpus is untouched and still scores exactly as published:
precision 1.000, recall 0.864, F1 0.927.

Detection

  • SSRF from a tool's URL argument (web.ssrf.tool_argument_url), the shape of
    CVE-2025-65513, CVE-2026-26118, CVE-2026-27826 and TRA-2025-36. The rule runs only in
    files that define MCP tools and show no private-address or allow-list check.
  • Argument injection into git, kubectl, docker, ssh and similar
    (web.argument_injection.cli_list), the shape of CVE-2025-68144 in Anthropic's
    mcp-server-git. An argument list that passes "--" before the caller's values is exempt.
  • Prefix-only path containment (web.path_traversal.startswith_containment),
    CVE-2025-66689 and CVE-2025-53109/53110: .startswith(root) in a file that never
    canonicalises a path.
  • DNS-rebinding protection explicitly disabled
    (mcp.dns_rebinding.protection_disabled), CVE-2025-66414 and CVE-2025-66416. Three of
    the 55 real servers do this. An informational sibling flags an HTTP transport with no
    transport-security settings in the file at all.
  • All-interface bind with no authentication in sight
    (mcp.network_exposure.bind_all_interfaces), CVE-2026-23744 and CVE-2025-49596.
  • One HTTP transport shared by every client (mcp.session.shared_http_transport),
    CVE-2026-25536.
  • JavaScript command injection through execSync on a template literal or a
    concatenation (web.cmd_injection.js_exec_interpolated; CVE-2025-53355, CVE-2025-53967,
    CVE-2026-0755) and spawn with shell: true (web.cmd_injection.spawn_shell_true).
  • A shell started through an argument list, ["bash", "-lc", command]
    (agent.excessive_agency.shell_argv).
  • Line jumping, cross-server tool shadowing and invisible Unicode in tool
    metadata (mcp.line_jumping.*, mcp.tool_shadowing.*, mcp.hidden_unicode.*), after
    Trail of Bits and Invariant Labs.
  • subprocess ... shell=True is now found behind nested parentheses, which a real server
    used (subprocess.run(subprocess.list2cmdline(cmd), ..., shell=True)), and
    asyncio.create_subprocess_shell is treated as the same sink. Both were misses on a
    third-party deliberately vulnerable file.
  • A rug-pull rule was written and removed. It matched await client.listTools(),
    which is how every correct client lists tools: 26 hits on the official SDK, all false.
    A rule that fires on all correct code is not a detection. docs/cve-coverage.md lists
    it and the other classes that deliberately have no rule.

Precision, measured on code nobody wrote for this tool

The first run of the previous rule set on the 55 servers reported 103 findings; adjudicated
by hand, 6 were plausible defects. Three idioms accounted for most of the rest, and each
is now excluded explicitly rather than by lowering a threshold:

  • f"... IN ({','.join('?' * n)})" and f"... FROM {TABLE_CONSTANT}" are not SQL
    injection; nor is '%' inside a quoted string (strftime('%s')) the formatting
    operator. Identifier interpolation into SET, SAVEPOINT, INSTALL and other
    statements that cannot take a bound parameter is now a separate informational rule.
  • MD5 or SHA-1 as a content fingerprint or cache key is informational; the reportable rule
    needs a password, credential or signature in the same expression.
  • A UTF-8 byte-order mark at the start of a .cs file is not a hidden instruction. The
    analyzer strips a leading BOM before matching, and the invisible-Unicode rule is anchored
    to the line so code that strips those characters is not reported for naming them.
  • A method definition called exec (protected exec(sql: string)) is not dynamic
    execution.

After the changes the same 55 servers (1,162,650 lines once the largest finished) report
30 findings, of which 22 were judged plausible defects on manual review: three
servers with DNS-rebinding protection switched off, two bound to every interface with no
authentication, a committed 64-hex API key in a docker-compose.yml, a shell-as-a-tool
server, a subprocess.run(list2cmdline(cmd), shell=True), and a short-link resolver that
follows redirects from any host that merely contains the expected domain. Plausible
means "worth a human's time", not "confirmed exploitable"; the eight remaining are HTTP
wrappers over a fixed base URL and a WeChat-mandated SHA-1.

On the 285,463-line benign corpus the raw count went from 21 to 38 (0.013 per 100
lines, under the 0.05 CI budget) and the reportable count from 1 to 3. All three are
recorded in benchmark/corpus.json: the official fetch server retrieving whatever URL the
model supplies, shell=True on an argument list in the SDK's CLI, and the SDK's own
backward-compatibility fallback that constructs TransportSecuritySettings with protection
off. Two further hits went away for the right reason: the fixed git server rejects
option-shaped targets three lines above the call the rule matches, so the rule now stands
down in any file that checks for a leading -.

Analyzer

  • Rules can carry two file-level gates: requires, a regex that must match somewhere
    in the file, and absent, one that must match nowhere. A single-site regex cannot say
    "this file defines MCP tools" or "this file never calls realpath"; a gate can.
    --self-check validates them.
  • A leading UTF-8 byte-order mark is stripped before matching.
  • A hang found by the pre-release stress pass, present since 0.1.0. The
    cross-tenant cache rule began with an unbounded \w* in front of a literal, which is
    quadratic; a 2 MB single-line file made one scan run for over ten minutes. The prefix
    is now bounded, and a test feeds every rule six hostile files (a 400 KB line, 2,000
    nested parentheses, 10,000 alternating quotes, a 10,000-placeholder f-string, 10,000
    repeated <IMPORTANT> tags, 50,000 zero-width characters) with a two-second ceiling
    each.

Benchmark

  • A second fixture corpus, demo/vulnerable-2026 and demo/safe-2026, with
    demo/ground-truth-2026.json naming the CVE each of its 16 instances mirrors.
    python benchmark/score_demo.py --corpus 2026 scores it (16 TP, 0 FP, 0 FN) and
    --corpus paper scores the frozen 22-instance set. CI runs both and requires the fixed
    halves to be silent.

Packaging

  • Dockerfile and a release workflow that publishes ghcr.io/dinakar-s/mcpvuln to
    GitHub Packages on every v* tag and attaches the sdist and wheel to the release.
  • CITATION.cff: the author's name is Dinakar, family name S.
  • 216 tests, up from 154.