Skip to main content
Glama
README.md
# mcp-testbed: Detecting Malicious MCP Server Behavior

[![CI](https://github.com/TexasSlammer/mcp-testbed/actions/workflows/ci.yml/badge.svg)](https://github.com/TexasSlammer/mcp-testbed/actions/workflows/ci.yml)
![Python](https://img.shields.io/badge/python-3.10%2B-blue)
![Detections](https://img.shields.io/badge/detections-Sigma%20%7C%20Wazuh%20%7C%20Sysmon-purple)
![License](https://img.shields.io/badge/license-MIT-green)

AI agents such as Claude Desktop, Cursor and VS Code load **MCP (Model Context Protocol) servers** as local child processes running with the user's full privileges. A malicious or compromised server, for example a typosquatted package or a poisoned update, can run commands and read secrets, and to most endpoint tooling it looks like an ordinary `python.exe` or `node.exe`.

This project builds a **harmless test server** that reproduces those behaviors, then answers two questions:
1. What does default Windows / Wazuh / Sysmon logging actually see?
2. What logging changes and detections are needed to catch it?

> **Safety by design.** Commands come from a fixed allowlist of read-only built-ins (`whoami`, `hostname`, `ipconfig`, `tasklist`); no input reaches a shell. File reads are limited to fake decoy files the project creates, guarded by a marker file and a path check. There is no network code.

## Threat model

```mermaid
flowchart LR
    U[User] --> C[AI client<br/>Claude Desktop / Cursor]
    C -->|stdio| S[MCP server<br/>python.exe / node.exe]
    S -->|T1059| SH[cmd.exe / powershell.exe]
    S -->|T1552| F[~/.aws/credentials<br/>~/.ssh/id_rsa<br/>.env]
    SH -.-> SYS[Sysmon Event 1 ✔]
    F -.-> GAP[Sysmon: no read event ✘<br/>needs SACL → 4663]
```

## Simulated behaviors

| Tool | Imitates | ATT&CK | Telemetry needed |
|---|---|---|---|
| `system_diagnostics` | Server spawning a shell | T1059.003 | Sysmon Event 1 |
| `encoded_powershell_check` | Obfuscated PowerShell | T1059.001, T1027 | Sysmon Event 1, PowerShell 4104 |
| `load_project_config` | Reading cloud keys, SSH keys, `.env`, git creds | T1552.001, T1552.004 | Security 4663 (SACL) |

## Detections

| Detection | Format | Logic |
|---|---|---|
| MCP interpreter spawns shell | [Sigma](detections/sigma/proc_mcp_interpreter_spawns_shell.yml) / Wazuh 100700 | parent python/node/uv with MCP in cmdline → child shell |
| Interpreter spawns encoded PowerShell | [Sigma](detections/sigma/proc_mcp_encoded_powershell.yml) / Wazuh 100701 | parent interpreter → `powershell -enc` |
| Decoy credential read | [Sigma](detections/sigma/file_access_credential_decoy.yml) / Wazuh 100702 | 4663 on decoy path |
| AI client launches interpreter | [Sigma](detections/sigma/proc_ai_client_launches_interpreter.yml) / Wazuh 100703 | Claude/Cursor/Windsurf → python/node/uv (correlation context) |
| Sysmon config additions | [XML](detections/sysmon/sysmon-mcp-additions.xml) | ensures interpreter children and credential-path writes are logged |

## Design decisions

- **Decoys, not real secrets.** Decoys sit at paths that mimic real ones (`...\mcp-testbed-decoys\.aws\credentials`), so path-based detections built here carry over to real `~/.aws` with no changes to the matching logic.
- **Why SACLs.** Sysmon has no file-read event; Event 11 fires only on create/overwrite. The only native Windows signal for a read is object-access auditing (4663) on files with an audit ACE.
- **Parent command line as context.** A Sysmon event shows the parent but not the grandparent, so the AI client itself isn't in the event. The parent command line contains the server path, which is the most reliable single-event signal; deeper lineage needs a ProcessGuid pivot.
- **Real client in the loop.** `client/trigger.py` uses the official MCP SDK over stdio, which reproduces the true process tree instead of a direct script call.

## Detection validation

Sigma and Wazuh rules are unit-tested against the same labeled synthetic events. The first run found an `-ec` evasion, 3 false positives from Defender and the Windows Search indexer, and no way to tell which processes are MCP servers. All are fixed (one fix caused a regression, which the tests caught), taking detection from **8/12 with 3 false positives to 11/12 with 0**. The remaining miss is documented: it needs process-lineage correlation, not a single-event rule. Details: [docs/detection-validation.md](docs/detection-validation.md).

![Detection validation](docs/screenshots/detection-validation.png)

## Safety tests

The safety guarantees above are enforced by tests, not just described: unlisted commands rejected, reads blocked until decoys exist, path escapes refused, setup never touches real credential folders, no network imports.

```bash
python -m unittest discover tests -v
```
![Safety tests](docs/screenshots/safety-tests.png)

## Run it

Windows 11 lab VM with Sysmon and a Wazuh agent (see [detection-range](https://github.com/TexasSlammer/detection-range)):

```powershell
git clone https://github.com/TexasSlammer/mcp-testbed; cd mcp-testbed
py -m venv .venv; .\.venv\Scripts\Activate.ps1
pip install -r requirements.txt
python server\setup_decoys.py        # create fake credential files
.\scripts\Set-DecoyAudit.ps1         # (admin) enable 4663 on decoys
python client\trigger.py             # run all behaviors, log UTC windows to runs.csv
```

Executed already? Committed evidence from a real Linux run lives in [`runs/`](runs/README.md)
(8/8 behaviors, real outputs, process lineage). The Windows run over Wazuh is the last
measurement this project needs - [docs/evidence-plan.md](docs/evidence-plan.md) step M2.

Test it under a real AI client too (`claude_desktop_config.json`):
```json
{ "mcpServers": { "testbed-utils": {
    "command": "C:\\path\\to\\mcp-testbed\\.venv\\Scripts\\python.exe",
    "args": ["C:\\path\\to\\mcp-testbed\\server\\testbed_server.py"] } } }
```

## Findings

> Execution evidence is committed under [`runs/`](runs/README.md): the testbed was executed for real (8/8 behaviors through a real MCP stdio session, real process lineage, real decoy reads). Windows default-logging behavior and its verification checklist are in [FINDINGS.md](FINDINGS.md). Rule-logic results are in [docs/detection-validation.md](docs/detection-validation.md).

| Behavior | Default logging (documented) | After changes |
|---|---|---|
| Shell child process | 4688 off by default; Sysmon channel not collected by the agent → invisible | Sysmon Event 1 → rule 100700 |
| Encoded PowerShell | needs script block logging for the decoded script | 4104 → rule 100701 |
| Credential file read | Sysmon cannot log reads - no event | SACL + 4663 → rule 100702 |
| Process lineage to AI client | single event shows parent only | ParentProcessGuid pivot + rule 100703 context |

<!-- SCREENSHOTS: uncomment once captured (see docs/screenshots/README.md)
![Process tree](docs/screenshots/process-tree.png)
![Wazuh MCP alerts](docs/screenshots/wazuh-mcp-alerts.png)
-->

## Author
**Mark Lambert**, M.S. Information Security & Privacy candidate, The University of Texas at Austin
[GitHub](https://github.com/TexasSlammer) · [LinkedIn](https://www.linkedin.com/)

MIT License