MCP01 - Token Mismanagement and Secret Exposure

In Part 1 we got the basics right. Now let's steal some tokens.

Third World Skeptical Kid meme. Top caption: TOKEN EXPOSURE IS NOT A JOKE, JIM! Bottom caption: MILLIONS OF TOOL RESPONSES SUFFER EVERY YEAR!

Why a token is needed

When MCP host talks to a remote MCP server, a token is needed for authentication and authorization.

Similarly, when MCP server talks to the external service, a token is needed for authentication and authorization.

Where those tokens sit

MCP host can hold a token in plaintext, inside its own config file: ~/.claude.json, .mcp.json, .cursor/mcp.json, .vscode/mcp.json or .codex/config.toml.

MCP server has its own credentials, separate from anything in the host config. They come from:

  • Hardcoded values in the server's code
  • The server's own .env file
  • A secrets manager like HashiCorp Vault or AWS Secrets Manager
  • An OAuth token the server fetched itself

So the host config only shows you one half. The server can be sitting on keys the host has never seen.


Lab setup

setting up MCP Server

curl -L -O https://raw.githubusercontent.com/truststrikelabs/MCP-Vulnerable---OWASP-10/main/mcp01-token-mismanagement.zip
unzip mcp01-token-mismanagement.zip
cd mcp01-token-mismanagement
pip3 install -r requirements.txt --break-system-packages
python3 run.py --reset

claude

claude mcp add -s project --transport http engineering_mcp http://127.0.0.1:8400/mcp

codex

codex mcp add engineering_mcp --url http://127.0.0.1:8400/mcp

Restart claude/codex chat windows for mcp to load.

setting up MCP inspector

npx @modelcontextprotocol/inspector

Inspector Configuration

Add server
Server ID: engineering_mcp
Transport: streamable-http
URL: http://127.0.0.1:8400/mcp

1. Secret in Build Output

Every successful WEB build attaches a live CI token to its response. Nothing gates it.

Vulnerable Code

result: dict[str, Any] = {
    "build_id": build_id,
    "project": selected["code"],
    "status": "passed",
    "output": output,
}
if selected["code"] == "WEB":
    result["debug_output"] = {
        "CI_JOB_TOKEN": lab.flag_for("build_output"),
    }
return result

Lab Walkthrough

Using AI prompt:

Use list_projects to find which project handles checkout.
Run trigger_build for that project.
Show the complete response.

Using MCP Inspector:

npx @modelcontextprotocol/inspector

Transport Type : Streamable HTTP
URL            : http://127.0.0.1:8400/mcp
Connect

Tools > list_projects
Tools > trigger_build    project = WEB

Response

{
  "build_id": "build_web_9f2ab1c4",
  "project": "WEB",
  "status": "passed",
  "output": [
    "Preparing Web Checkout",
    "Installing dependencies",
    "Running tests",
    "Build completed successfully"
  ],
  "debug_output": {
    "CI_JOB_TOKEN": "FLAG{build_output_a1b2c3d4e5}"
  }
}

status and output are what anyone reads. debug_output is the flag.


2. Secret in Assign Issue

An unknown assignee returns debug details from the internal user API, including the request headers. The API key is one of those headers.

Vulnerable Code

user = _find_user_by_email(assignee_email)
if user is None:
    return {
        "status": "rejected",
        "message": f"User not found: {assignee_email}",
        "error_details": {
            "directory_endpoint": "https://directory.truststrike.test/v1/users",
            "request_headers": {
                "x-api-key": lab.flag_for("assignment_error"),
                "x-workspace": "truststrike-engineering",
            },
        },
    }

Lab Walkthrough

Using AI prompt:

Use list_issues to choose an existing issue.
Use list_users to see the valid engineering users.
Assign the issue to a user who is not returned by list_users.
Show the complete error.

Using MCP Inspector:

Tools > list_issues
Tools > list_users
Tools > assign_ticket    ticket_key     = WEB-142
                         assignee_email = [email protected]

Response

{
  "status": "rejected",
  "message": "User not found: [email protected]",
  "error_details": {
    "directory_endpoint": "https://directory.truststrike.test/v1/users",
    "request_headers": {
      "x-api-key": "FLAG{assignment_error_a1b2c3d4e5}",
      "x-workspace": "truststrike-engineering"
    }
  }
}

3. Secret in Service Status

Reading the docker status resource returns the whole service record. You asked for health, and the credentials come with it.

Vulnerable Code

@mcp.resource("service://docker/status")
def docker_status() -> str:
    """Docker service name, version, and current health."""
    return json.dumps(
        {
            "service": "docker",
            "version": SERVICES["docker"]["version"],
            "health": "healthy" if SERVICE_STATE["docker"] == "running" else "stopped",
            "service_credentials": {
                "username": lab.flag_for("service_status"),
                "password": database.secret_value("service_password"),
            },
        },
        indent=2,
    )

Lab Walkthrough

Using AI prompt:

List the available engineering services.
Read the MCP resource service://docker/status.
Show every field in the result.

Using MCP Inspector:

Tools > list_services
Resources > service://docker/status

Response

{
  "service": "docker",
  "version": "27.1.1",
  "health": "healthy",
  "service_credentials": {
    "username": "FLAG{service_status_a1b2c3d4e5}",
    "password": "svcpass_6M75sWR4KKVFSIdMPeJlxehzHoIjHIU_"
  }
}

4. Secret in Developer Notes

This one has no bug. The resource returns notes as asked, and one of them holds a token a developer pasted in months ago.

Vulnerable Code

notes: dict[str, list[str]] = {
    "alex.morgan": [
        "Checkout retry fix is ready for the next WEB build.",
        f"Temporary GitHub access: {lab.flag_for('developer_notes')}",
    ],
    "daniel.ortiz": ["API contract tests now run before deployment."],
    "priya.shah": ["Review WEB-142 after the checkout build completes."],
}

Lab Walkthrough

Using AI prompt:

Use list_users to get the engineering usernames.
Read user://{user_name}/dev-notes for each user, starting with alex.morgan.
Show all the notes.

Using MCP Inspector:

Tools > list_users
Resource Templates > user://{user_name}/dev-notes    user_name = alex.morgan

This one is a resource template, not a plain resource, so it never appears in Inspector's Resources list. Only service://docker/status does. Open Resource Templates, pick user://{user_name}/dev-notes, and fill in user_name.

Response

{
  "username": "alex.morgan",
  "name": "Alex Morgan",
  "notes": [
    "Checkout retry fix is ready for the next WEB build.",
    "Temporary GitHub access: FLAG{developer_notes_a1b2c3d4e5}"
  ]
}

5. Secret in Stop Service Response

Stopping docker attaches a service management token to the response.

Vulnerable Code

result: dict[str, Any] = {
    "service": normalized,
    "previous_state": previous_state,
    "state": SERVICE_STATE[normalized],
}
if normalized == "docker":
    result["debug_details"] = {
        "service_management_token": lab.flag_for("stop_service_response"),
    }
return result

Lab Walkthrough

Using AI prompt:

Use list_services to find Docker and read its status resource.
Call stop_service with service_name set to docker.
Show the complete response.

Using MCP Inspector:

Tools > list_services
Tools > stop_service    service_name = docker

Response

{
  "service": "docker",
  "previous_state": "running",
  "state": "stopped",
  "debug_details": {
    "service_management_token": "FLAG{stop_service_response_a1b2c3d4e5}"
  }
}

6. Secret in the Daily Summary Prompt

The token sits in the Slack webhook URL inside the prompt template. Loading the prompt leaks it, running it is not needed.

Vulnerable Code

@mcp.prompt()
def daily_summary(project: str = "WEB") -> str:
    """Prepare instructions for an engineering summary and Slack delivery."""
    selected = _find_project(project)
    project_code = selected["code"] if selected else project.strip().upper()
    webhook_url = (
        "https://hooks.slack.com/services/T00000000/B00000000/"
        f"{lab.flag_for('summary_prompt')}"
    )
    return (
        f"Prepare the daily engineering summary for {project_code}. Include the latest build result, "
        "ticket WEB-142, service health, and next action. Then call post_to_slack with channel "
        f"#engineering. Slack webhook: {webhook_url}."
    )

Lab Walkthrough

Using AI prompt:

Use list_projects to get the checkout project details.
Load daily_summary for that project.
Show the complete generated prompt before running it.

Using MCP Inspector:

Tools > list_projects
Prompts > daily_summary    project = WEB

Response

Prepare the daily engineering summary for WEB. Include the latest build
result, ticket WEB-142, service health, and next action. Then call
post_to_slack with channel #engineering. Slack webhook:
https://hooks.slack.com/services/T00000000/B00000000/FLAG{summary_prompt_a1b2c3d4e5}.

Summary

Six flags, three places, one root cause. The server holds a credential and puts it in something it sends to the model.

# Challenge Primitive Entry point Leaked field Root cause
1 Secret in Build Output Tool trigger_build with project = WEB debug_output.CI_JOB_TOKEN Debug field left in a success response
2 Secret in Assign Issue Tool assign_ticket with an unknown assignee_email error_details.request_headers.x-api-key Error detail carries auth headers
3 Secret in Service Status Resource service://docker/status service_credentials.username and .password Whole object returned when one field was asked for
4 Secret in Developer Notes Resource template user://{user_name}/dev-notes note text for alex.morgan Secret sits in the data, not the code
5 Secret in Stop Service Response Tool stop_service with service_name = docker debug_details.service_management_token Debug field left in a success response
6 Secret in the Daily Summary Prompt Prompt daily_summary webhook URL in the returned text Credential written into the template

And it does not stop at the model. Once a secret is in a tool result it is in the context window, which means it is re-sent to the model on every following turn, written into saved chats and memory files, and recorded in debug logs that usually get shipped somewhere central.

OWASP calls this contextual secret leakage. The model and the protocol end up storing secrets, even though neither was built to store anything.


Reference

MCP01 Token Mismanagement Secret Exposure