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,sshand 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
execSyncon a template literal or a
concatenation (web.cmd_injection.js_exec_interpolated; CVE-2025-53355, CVE-2025-53967,
CVE-2026-0755) andspawnwithshell: 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=Trueis now found behind nested parentheses, which a real server
used (subprocess.run(subprocess.list2cmdline(cmd), ..., shell=True)), and
asyncio.create_subprocess_shellis 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.mdlists
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)})"andf"... FROM {TABLE_CONSTANT}"are not SQL
injection; nor is'%'inside a quoted string (strftime('%s')) the formatting
operator. Identifier interpolation intoSET,SAVEPOINT,INSTALLand 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
.csfile 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, andabsent, 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-checkvalidates 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-2026anddemo/safe-2026, with
demo/ground-truth-2026.jsonnaming the CVE each of its 16 instances mirrors.
python benchmark/score_demo.py --corpus 2026scores it (16 TP, 0 FP, 0 FN) and
--corpus paperscores the frozen 22-instance set. CI runs both and requires the fixed
halves to be silent.
Packaging
Dockerfileand a release workflow that publishesghcr.io/dinakar-s/mcpvulnto
GitHub Packages on everyv*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.