MCP04 - Software Supply Chain Attacks & Dependency Tampering

MCP01 took the tokens. MCP02 took the permissions. MCP03 poisoned the tools. Now let's take the code nobody wrote.

Dwight Schrute meme. Top caption: IDENTITY THEFT IS NOT A JOKE, JIM! MILLIONS OF FAMILIES SUFFER EVERY YEAR! Bottom caption: DEPENDENCY INSIDE DEPENDENCY.

What a dependency is

A dependency is code somebody else wrote that you use inside your product.

Every dependency arrives with four things. Here they are in one install:

pip install requests==2.31.0
Part What it is In that command
Name What you typed requests
Source Which registry answered pypi.org, your default, because you never said
Version What actually landed 2.31.0
Bill of materials What is inside it urllib3, certifi, idna, charset-normalizer

What a supply chain attack is

A supply chain attack is when the code you install is not the code you meant to install.

Example: you install one package. Its resolver pulls in eleven transitive dependencies you never named, and one of them is attacker controlled. It runs with the same privileges as the code you wrote.


Lab setup

setting up MCP Server

git clone https://github.com/truststrikelabs/BrokenMCP.git
cd BrokenMCP
pip3 install -r requirements.txt --break-system-packages

Start Web UI and leave it running:

python3 gui/run.py

Open http://127.0.0.1:8410, pick MCP04 from the sidebar, and press Start lab. That serves:

Server ID:    registry_mcp
MCP endpoint: http://127.0.0.1:8403/mcp

setting up MCP client

npx @modelcontextprotocol/inspector

Inspector Configuration

Add server
Server ID: registry_mcp
Transport: streamable-http
URL: http://127.0.0.1:8403/mcp

claude

claude mcp add -s project --transport http registry_mcp http://127.0.0.1:8403/mcp

codex

codex mcp add registry_mcp --url http://127.0.0.1:8403/mcp

Restart claude/codex chat windows for mcp to load.


Lab background

The registry is brokenmcp corp's app store for code. Developers search it, install what they need, and it ships with the product.

Some packages come from an internal mirror the company runs. Most come from the public registry, written by people nobody at BrokenMCP has met.

registry_mcp is where packages are searched, installed, updated, and signed off.

Who you are

You are registry-assistant, acting as dozy.

Tools > my_workspace
{
  "agent": "registry-assistant",
  "account": "dozy",
  "role": "Platform Engineer",
  "task": "supply-chain sign-off for payments-api 5.0",
  "installed_count": 4,
  "mirror": "configured, not enforced",
  "signatures": "checked on install, warn only",
  "note": "Both of those settings are recorded here and acted on nowhere."
}

Why you are here

payments-api 5.0 ships next week. It is blocked on one item: somebody has to say the packages it depends on are safe.

Tools > release_requirements
{
  "project": "payments-api",
  "release": "5.0",
  "ships": "2026-08-09",
  "required": ["date-fmt", "log-format", "iac-lint", "crypto-utils", "sbom-gen", "brokenmcp-audit-lib"],
  "installed": ["date-fmt", "log-format", "iac-lint", "crypto-utils"],
  "missing": ["sbom-gen", "brokenmcp-audit-lib"],
  "awaiting_signoff": ["crypto-utils", "iac-lint"]
}

1. The Typosquat

Search for package name date-fmt and install the latest one.

Vulnerable Code

    near = [
        row["name"]
        for row in database.all_installed()
        if row["name"] != entry["name"]
        and _one_edit_apart(row["name"], entry["name"])
        and entry["downloads"] < catalog.PACKAGES[row["name"]]["downloads"]
    ]

Code can detect a suspicious lookalike package after install, but installer does not run that check before installing

Lab Walkthrough

Using MCP Inspector:

Tools > search_registry    query = date-fmt
Tools > install_package    name = date-fmts

Using AI prompt:

ShowHide
Search the registry for date-fmt and show me everything that comes back.
Compare the results by publisher, age, and downloads.

Response

search_registry returns two packages:

Tools > search_registry    query = date-fmt
{
  "query": "date-fmt",
  "count": 2,
  "results": [
    {
      "name": "date-fmt",
      "publisher": "dateworks",
      "first_published": "2022-03-14",
      "downloads": 1284000
    },
    {
      "name": "date-fmts",
      "publisher": "d4teworks",
      "first_published": "2026-07-29",
      "downloads": 2
    }
  ]
}
  • One is the trusted package: date-fmt by dateworks, published in 2022, with 1.28 million downloads.
  • The other is suspicious: date-fmts by d4teworks, published days ago, with 2 downloads.
  • The name and publisher are both lookalikes/typosquat.

The suspicious package installs without warning.

Tools > install_package    name = date-fmts
{
  "package": "date-fmts",
  "status": "installed",
  "checks": {
    "name_similarity_checked": false,
    "publisher_verified": false,
    "signature_enforced": false
  },
  "exposure": [
    {
      "control": "package selection",
      "reason": "date-fmts is one character from date-fmt, which is already installed. It was first published on 2026-07-29 and has 2 downloads.",
      "impact": "An attacker controlled malicious package published by d4teworks now ships with the release, declaring fs:read, fs:write, network:egress collect.d4teworks.example.",
      "audit_reference": "FLAG{typosquat_a1b2c3d4e5}"
    }
  ]
}

The installer did not check:

  • name similarity
  • publisher identity
  • signature

2. Dependency Confusion

Rule: A brokenmcp- name resolves from the internal mirror only.

brokenmcp-audit-lib is on the missing list and it is BrokenMCP's own package. Read policy://registry/supply-chain-rules first, then resolve and install it.

Vulnerable Code

    # Ranked by version number across every configured source. Nothing here knows that
    # brokenmcp-* is a private namespace.
    winner = max(candidates, key=lambda item: catalog.version_key(item["version"]))
  1. policy://registry/supply-chain-rules
    The company rule: brokenmcp- packages come from the mirror only.
  2. resolve_package
    Decides where the package comes from. It answers the public registry, not the mirror, so the rule is already broken.
  3. install_package
    The public copy gets installed instead of the mirror's, because its version number is higher.

Lab Walkthrough

Using MCP Inspector:

Resources > policy://registry/supply-chain-rules
Tools > resolve_package    name = brokenmcp-audit-lib
Tools > install_package    name = brokenmcp-audit-lib

Using AI prompt:

ShowHide
Read policy://registry/supply-chain-rules and tell me the rule for brokenmcp- names.
Then resolve brokenmcp-audit-lib and say which source answered and whether that
is allowed. Then install it and show what the installed package can do.

Response

Tools > resolve_package    name = brokenmcp-audit-lib
{
  "name": "brokenmcp-audit-lib",
  "resolved_to": {
    "source": "public-registry",
    "version": "9.0.1",
    "publisher": "unverified"
  },
  "candidates": [
    { "source": "internal-mirror", "version": "1.4.0", "publisher": "brokenmcp-platform" },
    { "source": "public-registry", "version": "9.0.1", "publisher": "unverified" }
  ],
  "exposure": [
    {
      "control": "source scoping",
      "reason": "An internal name resolved to public-registry, because 9.0.1 outranks the mirror's copy.",
      "impact": "Anything installing brokenmcp-audit-lib gets a package published by unverified instead of the one on the mirror.",
      "audit_reference": "FLAG{dependency_confusion_a1b2c3d4e5}"
    }
  ]
}

The mirror has 1.4.0 from brokenmcp-platform. The public registry has 9.0.1 from unverified. As public version 9.0.1 > mirror version 1.4.0, install_package installs public version.

Tools > install_package    name = brokenmcp-audit-lib
{
  "package": "brokenmcp-audit-lib",
  "version": "9.0.1",
  "source": "public-registry",
  "publisher": "unverified",
  "status": "installed",
  "exposure": [
    {
      "control": "source scoping",
      "reason": "brokenmcp-audit-lib is published on the internal mirror by brokenmcp-platform at 1.4.0. This install took 9.0.1 from public-registry, published by unverified, because the public version number is higher.",
      "impact": "An attacker controlled malicious package now ships with the release in place of the internal one, declaring network:egress audit-sync.unverified.example that brokenmcp-platform's copy does not.",
      "audit_reference": "FLAG{dependency_confusion_a1b2c3d4e5}"
    }
  ]
}

3. The Trojanized Update

List versions available for package log-format and update it. Once done, check diff between old version and new version.

Vulnerable Code

    before = entry["versions"][row["version"]]["declares"]
    after = entry["versions"][latest]["declares"]
    added = [item for item in after if item not in before]
    database.record_install(row["name"], latest, row["source"])

Lab Walkthrough

Using MCP Inspector:

Tools > list_versions    package = log-format
Tools > update_package   package = log-format
Tools > diff_versions    package = log-format
                         from_version = 3.0.4
                         to_version = 3.1.0

Using AI prompt:

ShowHide
List the versions available for log-format and update it to the newest one.
Then diff the version I was on against the one I am now on, and tell me
what changed.

Response

The update applies:

Tools > update_package   package = log-format
{
  "package": "log-format",
  "from": "3.0.4",
  "to": "3.1.0",
  "status": "updated",
  "declared_changes": { "added": ["network:egress attacker.corp"], "removed": [] },
  "review": {
    "diff_run_before_update": false,
    "capability_change_reviewed": false
  },
  "egress": {
    "active": true,
    "reads": ["every log line the formatter is handed"],
    "sends_to": "https://attacker.corp/v1/ingest",
    "described_as": "anonymous usage telemetry"
  },
  "exposure": [
    {
      "control": "update review",
      "reason": "log-format 3.1.0 declares 1 capability it did not have at 3.0.4, and the update applied without comparing them.",
      "impact": "every log line the formatter is handed now goes to https://attacker.corp/v1/ingest. log-format is a log formatter, so that is every line your services log, including anything a log line happens to carry.",
      "audit_reference": "FLAG{trojan_update_a1b2c3d4e5}"
    }
  ]
}

diff_run_before_update: false. Nothing required you to look first.

Now run the diff and read what you already let in:

Tools > diff_versions    package = log-format
                         from_version = 3.0.4
                         to_version = 3.1.0
{
  "package": "log-format",
  "from": "3.0.4",
  "to": "3.1.0",
  "declares_added": ["network:egress attacker.corp"],
  "declares_removed": [],
  "unchanged": false
}

One line. The tool that catches this existed the whole time, and the update did not require it.

The package calls it anonymous usage telemetry. The destination is not.


4. The Postinstall Script

payments-api project has release requirement to install package sbom-gen, install package to fullfil requirement.

Vulnerable Code

    postinstall = detail.get("postinstall")
    if postinstall:
        result["postinstall"] = {
            "step": postinstall["step"],
            "read": postinstall["reads"],
            "sent_to": postinstall["sends_to"],
        }

Lab Walkthrough

Using MCP Inspector:

Tools > release_requirements project = payments-api

Tools > install_package    name = sbom-gen

Using AI prompt:

ShowHide
Call release_requirements and install whatever is missing that the sign-off needs.
Show me the complete response.

Response

release_requirements names what is missing: "sbom-gen"

Tools > release_requirements    project = payments-api
{
  "project": "payments-api",
  "release": "5.0",
  "ships": "2026-08-09",
  "required": ["date-fmt", "log-format", "iac-lint", "crypto-utils", "sbom-gen", "brokenmcp-audit-lib"],
  "installed": ["date-fmt", "log-format", "iac-lint", "crypto-utils"],
  "missing": ["sbom-gen", "brokenmcp-audit-lib"],
  "awaiting_signoff": ["crypto-utils", "iac-lint"]
}

Install sbom-gen package:

Tools > install_package    name = sbom-gen
{
  "package": "sbom-gen",
  "version": "2.3.1",
  "publisher": "spdx-tools",
  "status": "installed",
  "postinstall": {
    "step": "python -m sbom_gen.setup --link-config",
    "read": ["~/.brokenmcp/registry.toml", "~/.brokenmcp/credentials"],
    "sent_to": "https://attacker.corp/telemetry"
  },
  "exposure": [
    {
      "control": "install-time execution",
      "reason": "The package declared a postinstall step and the installer ran it.",
      "impact": "Your credential store was read and sent out before a single line of this package was used.",
      "audit_reference": "FLAG{postinstall_exec_a1b2c3d4e5}"
    }
  ]
}

postinstall comes from the package. Nothing checks what it does.

Here it runs python -m sbom_gen.setup --link-config, which opens ~/.brokenmcp/credentials and posts it to attacker.corp.


5. The Transitive One

List iac-lint package dependencies, see if you find any hidden dependecies also.

Vulnerable Code

There is none. list_dependencies says direct dependencies and returns direct dependencies:

    direct = entry["versions"][version]["dependencies"]
    return {
        "package": entry["name"],
        "version": version,
        "depth": 1,
        "direct_dependencies": [{"name": name, "version": pinned} for name, pinned in direct],
        "note": "Direct dependencies only. Call list_hidden_dependencies for the full tree.",
    }

The tool is honest. It reports its own depth and names the tool that goes deeper. The reviewer stopped where the default stopped.

Lab Walkthrough

Using MCP Inspector:

Tools > list_dependencies           package = iac-lint
Tools > list_hidden_dependencies    package = iac-lint

Using AI prompt:

ShowHide
Review the dependencies of iac-lint. Then walk its full tree rather than
its direct dependencies, and check every node for advisories.

Response

The review most people run:

Tools > list_dependencies           package = iac-lint
{
  "package": "iac-lint",
  "depth": 1,
  "direct_dependencies": [{"name": "yaml-lite", "version": "0.9.2"}],
  "note": "Direct dependencies only. Call list_hidden_dependencies for the full tree."
}

List Hidden Dependencies:

Tools > list_hidden_dependencies    package = iac-lint
{
  "package": "iac-lint",
  "node_count": 3,
  "max_depth": 2,
  "tree": [
    { "name": "iac-lint",     "version": "2.1.0", "depth": 0, "publisher": "iacworks",    "advisories": [] },
    { "name": "yaml-lite",    "version": "0.9.2", "depth": 1, "publisher": "yamlworks",   "advisories": [] },
    { "name": "stream-utils", "version": "1.0.7", "depth": 2, "publisher": "streamworks", "advisories": ["ADV-2026-0117"] }
  ],
  "with_advisories": ["stream-utils"],
  "runtime": {
    "package": "stream-utils",
    "trigger": "the first time a manifest is parsed",
    "connects_to": "attacker.corp:4444",
    "gives": "an interactive shell on the host running payments-api"
  },
  "exposure": [
    {
      "control": "dependency scanning",
      "reason": "stream-utils 1.0.7 sits 2 levels below iac-lint and carries ADV-2026-0117, severity critical.",
      "impact": "Nobody installed stream-utils. It arrived under iac-lint, and the first time a manifest is parsed it opens attacker.corp:4444 and gives an interactive shell on the host running payments-api.",
      "audit_reference": "FLAG{transitive_dep_a1b2c3d4e5}"
    }
  ]
}

You installed iac-lint. iac-lint pulled yaml-lite, and yaml-lite pulled stream-utils.

stream-utils declares process:spawn and network:egress attacker.corp. Nothing happens at install. It happens at first time payments-api reads a config file, gives reverse shell to attacker.corp:4444


6. The Name Changed Hands

Something in the release is exfiltrating. You know it is one of the six packages. Find it.

Vulnerable Code

    account_created = entry["publisher_account_created"]
    if account_created <= entry["first_published"]:
        return result

A package cannot be older than the account that publishes it, and nothing on this registry ever compares the two.

Lab Walkthrough

Using MCP Inspector:

Tools > list_installed
Tools > publisher_history    package = crypto-utils
Tools > approve_release       package = crypto-utils

Using AI prompt:

ShowHide
For every installed package, call publisher_history and compare when the package
was first published against when its publisher account was created.
Tell me which one does not add up.

Response

Run publisher_history across the installed packages and five of six look like this:

Tools > publisher_history    package = stream-utils
{
  "package": "stream-utils",
  "publisher": "streamworks",
  "package_first_published": "2019-11-08",
  "publisher_account_created": "2019-11-08",
  "ownership": null
}

One does not:

Tools > publisher_history    package = crypto-utils
{
  "package": "crypto-utils",
  "publisher": "cryptoworks-oss",
  "repo_url": "https://github.example/cryptoworks-oss/crypto-utils",
  "package_first_published": "2021-02-23",
  "publisher_account_created": "2026-06-18",
  "ownership": {
    "account_deleted": "2026-05-30",
    "name_re_registered": "2026-06-18",
    "publisher_name_changed": false,
    "repo_url_changed": false,
    "announced": false
  },
  "versions": [
    { "version": "5.1.4", "released": "2026-02-02" },
    { "version": "5.2.0", "released": "2026-07-02" }
  ]
}

crypto-utils has shipped since 2021. The account publishing it is two months old. A package cannot be older than the account that publishes it.

Read the three false values. The publisher is still cryptoworks-oss. The repo url is the one it has always been. Nothing was announced.

That is what repojacking is. The owner deleted the account on 30 May, somebody registered the same username on 18 June, and that URL has worked ever since. Your lockfile, your dependency list and your config are all byte for byte what they were in 2021.

Sign it off, and the flag lands in the exposure block:

Tools > approve_release    package = crypto-utils
{
  "package": "crypto-utils",
  "version": "5.2.0",
  "status": "approved",
  "approved_by": "dozy",
  "checks": {
    "publisher_account_age_checked": false,
    "ownership_change_checked": false
  },
  "publisher": {
    "account": "cryptoworks-oss",
    "repo_url": "https://github.example/cryptoworks-oss/crypto-utils",
    "account_created": "2026-06-18",
    "package_first_published": "2021-02-23",
    "publisher_name_changed": false,
    "repo_url_changed": false,
    "announced": false
  },
  "exposure": [
    {
      "control": "publisher identity",
      "reason": "crypto-utils has been published since 2021-02-23. The account publishing it was created on 2026-06-18. A package cannot be older than the account that publishes it.",
      "impact": "The account was deleted on 2026-05-30 and the same username was registered again on 2026-06-18 by somebody else. The publisher name and the repo url are unchanged, so nothing in your lockfile or your dependency list looks different. crypto-utils 5.2.0 copies the signing key to attacker.corp on every signature crypto-utils generates.",
      "audit_reference": "FLAG{repojacked_a1b2c3d4e5}"
    }
  ]
}

Reference

MCP04 Supply Chain Dependency Tampering