Plugin: - exec_python / exec_idc endpoints (token-gated arbitrary IDA scripting) - function_info, lvars, segment_at, search_text (IDA 9.0 API) - Plugins -> IDA MCP opens a settings dialog (bind/port/advertise/registry/token) via ida_kernwin.Form, with a text-prompt fallback; Apply restarts the server live Server: - ida_overview (one-shot triage) and ida_batch (many calls, one round-trip) - native TLS and mutual-TLS support (MCP_TLS_CERT/KEY/CLIENT_CA), MCP_BIND_HOST - clearer warnings when running plaintext behind an HTTP tunnel Docs: - DEPLOY.md: hosting for endpoint availability + confidentiality (VPN back-channel, public TLS front door, mutual TLS, firewall, systemd), and what hosting can't do Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
224 lines
8.9 KiB
Markdown
224 lines
8.9 KiB
Markdown
# Hosting and hardening
|
|
|
|
This covers two things people usually ask for together: keeping the MCP endpoint up
|
|
even when your analysis machine reboots, and making sure nobody in the middle can read
|
|
what goes over MCP.
|
|
|
|
## Read this first: what hosting can and can't do
|
|
|
|
The MCP server is almost stateless. It is a registry of which IDA instances are alive
|
|
plus a proxy that forwards tool calls to them. **All the real capability lives in the
|
|
IDA instances**, which run on your analysis machine next to IDA.
|
|
|
|
So:
|
|
|
|
- Moving the server onto an always-on host (a small VPS) keeps the **endpoint** alive.
|
|
Clients stay connected and never have to change the URL. When your Mac reboots and IDA
|
|
comes back, the plugin re-registers on its own (the heartbeat already does this) and
|
|
tools start working again with no manual step.
|
|
- It does **not** give you functional high availability. If the machine running IDA dies,
|
|
there is nothing to serve until it comes back — `ida_list_instances` just returns empty.
|
|
Real HA would mean running IDA on more than one machine, which is a different project.
|
|
|
|
Second thing to be clear-eyed about: now that the plugin exposes `exec_python` and
|
|
`exec_idc`, the MCP channel is effectively **remote code execution** on the analysis
|
|
machine. Transport encryption and strong auth are not optional niceties anymore — anyone
|
|
who can read or reach the channel can run code on that box. Treat the token and the
|
|
private key like SSH keys.
|
|
|
|
## Why a free HTTP tunnel is not private
|
|
|
|
A plain-HTTP `ngrok` (or any HTTP tunnel) terminates TLS at the provider's edge and
|
|
forwards your traffic to the agent in the clear. The tunnel from your machine to the
|
|
provider is encrypted, but **the provider's servers see your plaintext** — the
|
|
decompiler output, the addresses, everything. If the goal is "nobody learns what goes
|
|
over MCP," an HTTP tunnel does not meet it. You need one of:
|
|
|
|
- a VPN, so the transport is encrypted and the endpoint is not public at all, or
|
|
- your own TLS endpoint, where the only machine that sees plaintext is one you control, or
|
|
- end-to-end TLS through a pure TCP/TLS passthrough tunnel (the tunnel moves bytes it
|
|
cannot read).
|
|
|
|
## Pick your shape based on who the clients are
|
|
|
|
**If your MCP client is local** (Claude Code / desktop app on your own machine): put
|
|
everything on a private network (Tailscale or WireGuard) and expose nothing publicly.
|
|
This is the strongest option — there is no public endpoint to find, and every leg is
|
|
encrypted by the VPN.
|
|
|
|
**If your MCP client is a hosted connector** (e.g. a claude.ai connector that connects
|
|
from someone else's servers): that client cannot join your VPN and usually cannot present
|
|
a client certificate, so it needs a public HTTPS endpoint with a strong bearer token. Keep
|
|
the plugin-to-server leg on the VPN so it never touches the public internet. The only
|
|
machine that sees plaintext is your own VPS.
|
|
|
|
Both shapes share the same back-channel: **the IDA plugins talk to the server over the
|
|
VPN**, never over a public plaintext port.
|
|
|
|
---
|
|
|
|
## Build A — everything on a private network (local client, strongest)
|
|
|
|
Tailscale is the low-footgun choice; raw WireGuard is the DIY equivalent.
|
|
|
|
1. Install Tailscale on the always-on host and on the analysis machine, and `tailscale up`
|
|
on both. Each gets a stable `100.x.y.z` address.
|
|
2. On the always-on host, run the server bound to its tailnet address, with a token:
|
|
|
|
```bash
|
|
MCP_BIND_HOST=100.x.y.z \
|
|
IDA_MCP_TOKEN=$(openssl rand -hex 32) \
|
|
python ida-mcp-server.py
|
|
```
|
|
|
|
3. Point the IDA plugin at the server's tailnet address, and have it listen only on the
|
|
analysis machine's own tailnet address:
|
|
|
|
- `IDA_MCP_HOST` = the analysis machine's `100.x.y.z` (plugin listens only there)
|
|
- `IDA_MCP_ADVERTISE_HOST` = the same address (what it reports to the server)
|
|
- `IDA_MCP_REGISTRY_URL` = `http://100.x.y.z:8888` (the server's tailnet address)
|
|
- `IDA_MCP_TOKEN` = the same token as the server
|
|
|
|
4. Point your local MCP client at `http://100.x.y.z:8888/sse` (the server's tailnet
|
|
address).
|
|
|
|
Nothing is published to the internet. Every hop is inside the tailnet, which is
|
|
end-to-end encrypted. Plain HTTP is fine here because the network itself is the
|
|
encryption layer — but keep the token set so a compromised tailnet device still can't
|
|
drive it.
|
|
|
|
---
|
|
|
|
## Build B — public HTTPS front door + private back-channel (hosted client)
|
|
|
|
Use this only when the client must reach you from the public internet.
|
|
|
|
Layout:
|
|
|
|
```
|
|
hosted client ──HTTPS(443)──▶ Caddy (VPS) ──▶ uvicorn :8888 (VPS, localhost)
|
|
▲
|
|
analysis machine ──WireGuard/Tailscale──────────────┘ (plugin register + proxied calls)
|
|
```
|
|
|
|
1. On the VPS, run the server with a token (bound to all interfaces; the firewall and
|
|
Caddy decide who actually reaches it):
|
|
|
|
```bash
|
|
MCP_BIND_HOST=0.0.0.0 IDA_MCP_TOKEN=$(openssl rand -hex 32) python ida-mcp-server.py
|
|
```
|
|
|
|
2. Put Caddy in front for automatic Let's Encrypt TLS, and **only expose `/sse` and the
|
|
OAuth paths** — block the instance registry from the public side (the plugin reaches
|
|
it over the VPN, not through Caddy):
|
|
|
|
```
|
|
mcp.example.com {
|
|
@registry path /instances /instances/*
|
|
respond @registry "forbidden" 403
|
|
reverse_proxy 127.0.0.1:8888
|
|
}
|
|
```
|
|
|
|
3. Firewall the VPS so the app port is never reachable directly from the internet — only
|
|
443 (Caddy) is public, and the app port is reachable from localhost and the VPN subnet:
|
|
|
|
```bash
|
|
ufw default deny incoming
|
|
ufw allow 443/tcp
|
|
ufw allow in on wg0 # or the tailscale interface
|
|
ufw allow 51820/udp # WireGuard handshake, if using raw WireGuard
|
|
ufw enable
|
|
```
|
|
|
|
4. Join the analysis machine to the VPN (Tailscale as in Build A, or WireGuard) and set
|
|
the plugin's `IDA_MCP_REGISTRY_URL` / `IDA_MCP_ADVERTISE_HOST` to the VPS's VPN
|
|
address, exactly as in Build A step 3. The plugin never uses the public hostname.
|
|
|
|
5. Point the hosted client at `https://mcp.example.com/sse` with the bearer token.
|
|
|
|
---
|
|
|
|
## Native TLS and mutual TLS (no reverse proxy)
|
|
|
|
The server can serve HTTPS itself, and can require client certificates (mutual TLS) so
|
|
that even someone who knows the URL cannot open a connection without a cert you issued.
|
|
This is ideal when your client is your own script or Claude Code (which you can give a
|
|
client cert); most hosted connectors cannot present one, so use token-over-TLS for those.
|
|
|
|
Generate a CA, a server cert, and one client cert:
|
|
|
|
```bash
|
|
# CA
|
|
openssl genrsa -out ca.key 4096
|
|
openssl req -x509 -new -nodes -key ca.key -sha256 -days 3650 -subj "/CN=ida-mcp-ca" -out ca.crt
|
|
|
|
# server cert (CN must match how the client addresses it)
|
|
openssl genrsa -out server.key 4096
|
|
openssl req -new -key server.key -subj "/CN=mcp.example.com" -out server.csr
|
|
openssl x509 -req -in server.csr -CA ca.crt -CAkey ca.key -CAcreateserial -days 825 -sha256 -out server.crt
|
|
|
|
# client cert
|
|
openssl genrsa -out client.key 4096
|
|
openssl req -new -key client.key -subj "/CN=analyst-1" -out client.csr
|
|
openssl x509 -req -in client.csr -CA ca.crt -CAkey ca.key -CAcreateserial -days 825 -sha256 -out client.crt
|
|
```
|
|
|
|
Run with TLS (add the CA line for mutual TLS):
|
|
|
|
```bash
|
|
MCP_TLS_CERT=server.crt \
|
|
MCP_TLS_KEY=server.key \
|
|
MCP_TLS_CLIENT_CA=ca.crt \
|
|
IDA_MCP_TOKEN=$(openssl rand -hex 32) \
|
|
python ida-mcp-server.py
|
|
```
|
|
|
|
With `MCP_TLS_CLIENT_CA` set, the server refuses any connection that doesn't present a
|
|
cert signed by your CA. Bundle `client.crt` + `client.key` into the client (for `curl`:
|
|
`--cert client.crt --key client.key --cacert ca.crt`).
|
|
|
|
---
|
|
|
|
## Keep it running (systemd on the VPS)
|
|
|
|
```ini
|
|
# /etc/systemd/system/ida-mcp.service
|
|
[Unit]
|
|
Description=IDA MCP server
|
|
After=network-online.target
|
|
Wants=network-online.target
|
|
|
|
[Service]
|
|
WorkingDirectory=/opt/ida-mcp-server
|
|
Environment=MCP_BIND_HOST=0.0.0.0
|
|
Environment=IDA_MCP_TOKEN=REPLACE_WITH_openssl_rand_hex_32
|
|
ExecStart=/usr/bin/python3 /opt/ida-mcp-server/ida-mcp-server.py
|
|
Restart=always
|
|
RestartSec=2
|
|
# hardening
|
|
NoNewPrivileges=true
|
|
ProtectSystem=strict
|
|
ProtectHome=true
|
|
ReadWritePaths=/opt/ida-mcp-server
|
|
PrivateTmp=true
|
|
|
|
[Install]
|
|
WantedBy=multi-user.target
|
|
```
|
|
|
|
```bash
|
|
systemctl daemon-reload && systemctl enable --now ida-mcp
|
|
```
|
|
|
|
`Restart=always` covers a server crash; the plugin's heartbeat covers an IDA restart on
|
|
your side. Together the endpoint stays up across both, without anyone reconfiguring a URL.
|
|
|
|
## Checklist
|
|
|
|
- [ ] Token set (`openssl rand -hex 32`), same value on server and every plugin.
|
|
- [ ] Plugin-to-server traffic goes over a VPN, never a public plaintext port.
|
|
- [ ] No free HTTP tunnel in the path (it can read everything). VPN, own TLS, or TLS passthrough only.
|
|
- [ ] Public exposure (if any) is 443 behind a proxy or native TLS; the app port is firewalled off the internet.
|
|
- [ ] `/instances*` is not reachable from the public side.
|
|
- [ ] Remember `exec_python`/`exec_idc` are RCE on the analysis box — the token and any private key are as sensitive as an SSH key.
|