MCP server runs as privileged

ID

mcp-server-runs-as-privileged

Severity

high

Remediation Complexity

trivial

Remediation Risk

medium

Remediation Effort

low

OWASP LLM

LLM06:2025 (Excessive Agency)

OWASP ASI

secondary ASI03:2026 (Privilege Escalation), secondary ASI05:2026

Family

LLM06 — Excessive Agency

Red-team vectors

Privilege Escalation / Containment Failure

Tags

ai_security

Description

An MCP-server container / process manifest runs as root / privileged: no privilege-restriction declaration in its Dockerfile, docker-compose service, Kubernetes pod spec, or systemd unit. An MCP server run as root can read or write any file on the system, escalate to the host if a container breakout occurs, and inject code into peer processes — so the containment boundary around a tool-executing surface is gone.

Scoped to MCP manifests: the file must carry an MCP signal in its own content — a case-insensitive match, on any single line, of

(?i)modelcontextprotocol|\bmcp[-_]?server\b|\bmcp\b|\b(?:fastmcp|mcpo)\b

There is no dedicated label or annotation to add: a comment, an image name, a package install, an ENTRYPOINT path, a container / service name, an ENV, or a systemd Description= all qualify. An unrelated root container is left to the IaC scanner. Only files whose name matches Dockerfile*, .yml, .yaml or *.service are read at all.

One finding per manifest, flagging both the missing declaration and explicit root:

  • Dockerfile — no USER directive, or USER root / USER 0 / USER 0:0;

  • docker-compose — no user: field, or user: root / user: "0:0";

  • Kubernetes — no securityContext.runAsNonRoot: true and no runAsUser, or runAsUser: 0;

  • systemd unit — no User= directive, or User=root.

Only the user part of a user[:group] token decides, so root:root and 0:0 are root.

A Dockerfile USER root is not flagged when the same line carries an explicit justification — # justification: <text>, also accepted as justified: / reason:, with actual text after the colon:

USER root  # justification: needs CAP_NET_RAW to probe the tool network

A bare comment does not suppress it. USER root # TODO and USER root # are flagged, because silencing a high-severity privilege finding has to be a deliberate act rather than a side effect of annotating the line. The escape is Dockerfile-only: user: root, runAsUser: 0 and User=root are flagged whatever comment they carry — mute the finding instead.

Scope limits worth knowing. The signal must be in the file itself: an mcp reference in a neighbouring requirements.txt / package.json does not bring a Dockerfile into scope, and the file name is not a signal either (a Dockerfile.mcp with no mcp in its body is out of scope). The framework package names are listed one by one: fastmcp and mcpo are recognized, another framework is not until it is added to the alternation. A Containerfile, and a Kubernetes manifest written in .json, are never read. Each file is a single scope: the first USER / user: / User= found decides for the whole file — a multi-stage Dockerfile is not evaluated per stage, and a compose file whose first service is hardened clears its siblings. Likewise, in a multi-document (---) YAML a runAsNonRoot: true in one document clears the others, and pod-level versus container-level securityContext is not distinguished.

Examples

FROM ubuntu:22.04
RUN apt-get install -y mcp-server
ENTRYPOINT ["/usr/local/bin/mcp-server"]   (1)
1 No USER directive → runs as root — flagged.

A dedicated non-root user produces no finding:

RUN adduser --system mcp-user
USER mcp-user

Neither does a manifest with no MCP signal, root or not — it is out of scope:

FROM nginx:1.25
COPY site /usr/share/nginx/html

Mitigation / Fix

  • Run the MCP server as a dedicated non-root user: USER mcp-user / compose user: / securityContext.runAsNonRoot: true (+ runAsUser) / systemd User=.

  • Drop Linux capabilities and use a read-only root filesystem where possible.

© 2026 Xygeni. All rights reserved