AWS Builder Center
RufRoot Was a Warning Shot: Hardening MCP-Based AI Agents on AWS

RufRoot Was a Warning Shot: Hardening MCP-Based AI Agents on AWS

A field guide for locking down agentic AI infrastructure, written from the trenches with a real, this-week CVE as the case study.

On August 5, 2026, F5 Labs published details on CVE-2026-59726, nicknamed "RufRoot" a maximum-severity (CVSS 10.0) vulnerability in Ruflo, an open-source AI agent meta-harness. The root cause is almost embarrassingly simple: the default docker-compose.yml bound the Model Context Protocol (MCP) control port to 0.0.0.0, exposing an unauthenticated bridge that let anyone on the network invoke a terminal_execute tool and get arbitrary code execution inside the container. From there, attackers could exfiltrate LLM provider API keys, plant persistent backdoors, and the part that should really worry you rewrite entries in the agent's own memory store to bias its future outputs.
I want to use this vulnerability as a working example rather than just another CVE writeup, because the failure pattern it represents a fast-moving AI framework shipping an insecure-by-default network binding is not a Ruflo problem. It's a category of problem, and if you're running Bedrock Agents, Bedrock AgentCore, LangGraph, AutoGen, or any self-hosted MCP server on AWS, you have almost certainly made at least one of the same three decisions that turned RufRoot from a bug into a CVSS 10.0 incident. This post walks through what happened, why it happened, and the concrete AWS architecture and code changes that close the gap plus what those changes cost you in practice.

Why this matters beyond Ruflo

Three structural facts made RufRoot a maximum-severity bug instead of a minor one, and all three generalize:
  1. MCP servers are, by design, tool-execution surfaces. A well-behaved MCP server exposes exactly the tools an agent needs but "terminal execute" is a common convenience tool during development that regularly survives into production images.
  2. Container defaults optimize for a five-minute quickstart, not a production deployment. Binding to 0.0.0.0 instead of 127.0.0.1 is the difference between "works out of the box on my laptop" and "reachable from the internet the moment someone runs the same compose file on a cloud VM with an open security group."
  3. Agent memory is a new kind of write target. Traditional RCE gets you data theft and persistence. RCE against an agent with a learning-store (AgentDB, a vector DB, a fine-tuning feedback loop) also gets you output manipulation the compromise keeps paying out long after the initial access is patched, because the poisoned memory persists.
If you're building or operating agentic systems, that third point deserves to sit in your threat model right next to "SQL injection." It's new enough that most teams haven't written a runbook for it yet.

The three decisions that need to change

Before the AWS-specific implementation, here's the shape of the fix in plain terms this is the part a security engineer will ask about, and a developer advocate should be able to explain without hand-waving:
Vulnerable defaultHardened equivalentWhy
MCP port bound to 0.0.0.0 inside the containerBound to 127.0.0.1, reached only through an authenticated sidecar proxyRemoves the network path entirely; auth failures fail closed instead of failing open
Tools like terminal_execute shipped enabled by defaultTool allow-list is explicit, per-agent, and defaults to emptyLeast privilege applied to capabilities, not just to IAM
API keys and DB credentials in environment variables / compose filesPulled from AWS Secrets Manager at runtime via task IAM role, rotated on a scheduleEnv vars are readable by anything with container access; a secrets manager call requires a live, scoped credential
Everything below is one way to implement that table on AWS. I'm going to show working Terraform, a working Python MCP server, a working IAM policy, and a working detection rule not screenshots of a console.

Step-by-step: deploying a hardened MCP server on ECS Fargate

Step 1: Network isolation first, before any application code

The single highest-leverage fix in the whole RufRoot story is the network binding. On AWS, don't rely on the application to get this right enforce it at the security group and subnet level too, so a future misconfiguration inside the container still can't reach the internet.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
# vpc.tf — private subnet for the agent workload, no public route
resource "aws_subnet" "agent_private" {
vpc_id = aws_vpc.main.id
cidr_block = "10.0.10.0/24"
availability_zone = "us-east-1a"
tags = { Name = "mcp-agent-private" }
}

# Security group: the MCP bridge is NEVER a listed ingress rule.
# Only the sidecar proxy port is reachable, and only from the ALB security group.
resource "aws_security_group" "mcp_task" {
name = "mcp-task-sg"
vpc_id = aws_vpc.main.id

ingress {
description = "Sidecar proxy only — never the raw MCP port"
from_port = 8443
to_port = 8443
protocol = "tcp"
security_groups = [aws_security_group.alb.id]
}

egress {
description = "Egress restricted to Bedrock, Secrets Manager, and DynamoDB VPC endpoints only"
from_port = 443
to_port = 443
protocol = "tcp"
prefix_list_ids = [
aws_vpc_endpoint.secretsmanager.prefix_list_id,
aws_vpc_endpoint.dynamodb.prefix_list_id,
aws_vpc_endpoint.bedrock_runtime.prefix_list_id,
]
}
}
Architecture decision why VPC endpoints instead of a NAT gateway: a NAT gateway gives the task a path to the open internet, which means a compromised container can still exfiltrate data or call out to a C2 server even with tight security groups. Routing egress through VPC endpoints for the specific AWS services the agent needs (Secrets Manager, DynamoDB, Bedrock runtime) means there is no internet route to lose control of in the first place. The tradeoff is real: every new AWS service the agent needs becomes a Terraform change and an endpoint to pay for (~$0.01/hour per AZ per endpoint), and third-party APIs the agent legitimately needs to call (search, weather, whatever) now need an explicit egress proxy instead of "just working." For a general-purpose coding agent that needs broad web access, this pattern is too restrictive you'd use a controlled egress proxy with domain allow-listing instead. For a purpose-built internal agent, it's the right default.

Step 2: Bind the MCP server to loopback, put a sidecar in front of it

This is the actual RufRoot fix, expressed as an ECS task definition. The MCP server itself never gets a routable address; a small auth sidecar does.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
{
"family": "mcp-agent-task",
"networkMode": "awsvpc",
"requiresCompatibilities": ["FARGATE"],
"cpu": "512",
"memory": "1024",
"taskRoleArn": "arn:aws:iam::123456789012:role/mcp-agent-task-role",
"containers": [
{
"name": "mcp-server",
"image": "123456789012.dkr.ecr.us-east-1.amazonaws.com/mcp-server:3.16.3",
"portMappings": [],
"readonlyRootFilesystem": true,
"user": "10001:10001",
"environment": [
{ "name": "MCP_BIND_ADDRESS", "value": "127.0.0.1" },
{ "name": "MCP_BIND_PORT", "value": "3001" },
{ "name": "MCP_ENABLED_TOOLS", "value": "read_file,search_docs" }
],
"linuxParameters": {
"capabilities": { "drop": ["ALL"] }
}
},
{
"name": "auth-sidecar",
"image": "123456789012.dkr.ecr.us-east-1.amazonaws.com/mcp-auth-proxy:1.2.0",
"portMappings": [{ "containerPort": 8443, "protocol": "tcp" }],
"environment": [
{ "name": "UPSTREAM", "value": "http://127.0.0.1:3001" },
{ "name": "REQUIRE_OAUTH", "value": "true" }
]
}
]
}
Three things doing real work here that are easy to skip under deadline pressure:
"portMappings": [] on the MCP server container it has no reachable network identity at all, even inside the task's own ENI, outside of loopback.
"readonlyRootFilesystem": true and dropped Linux capabilities even with code execution inside the container, an attacker can't write a persistent backdoor to disk or escalate capabilities. This directly closes the "backdoors written to /app" persistence path from the original CVE.
MCP_ENABLED_TOOLS as an explicit allow-list, shipped empty by default in your base image terminal_execute-style tools require someone to consciously add them, not consciously remove them.

Step 3: Secrets from Secrets Manager, never from the environment

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# secrets.py — runs inside the MCP server container, called once at startup
import boto3
import json
from functools import lru_cache

_client = boto3.client("secretsmanager", region_name="us-east-1")

@lru_cache(maxsize=1)
def get_llm_credentials() -> dict:
"""
Pulled at runtime via the task's IAM role — never baked into the image,
never in a compose file, never in a shell history.
"""

response = _client.get_secret_value(SecretId="mcp-agent/llm-provider-keys")
return json.loads(response["SecretString"])

# Usage in the agent's model-calling code:
creds = get_llm_credentials()
anthropic_key = creds["anthropic_api_key"]
The lru_cache matters for a reason beyond convenience: it means a compromised process that achieves RCE after this call still can't force a fresh get_secret_value call to exfiltrate a rotated key later in the process lifetime unless it can also read process memory which the read-only filesystem and dropped capabilities from Step 2 make meaningfully harder.
Honest tradeoff: this adds a network round-trip (typically 20-40ms) to cold start, and it means your MCP server now has a hard runtime dependency on Secrets Manager availability if that call fails, your agent fails to start rather than starting with a stale cached key. In practice this pushed us toward a short-lived local cache with a background refresh thread rather than a pure "call every time" model, which adds code complexity in exchange for resilience. There's no free lunch here you're trading a small amount of latency and operational surface for eliminating an entire class of credential-theft path.

Step 4: Least-privilege IAM for the task role

The task role is scoped to exactly the secret, exactly the table, and exactly the Bedrock model the agent needs nothing wider.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "ReadOnlyOwnSecret",
"Effect": "Allow",
"Action": "secretsmanager:GetSecretValue",
"Resource": "arn:aws:secretsmanager:us-east-1:123456789012:secret:mcp-agent/llm-provider-keys-??????"
},
{
"Sid": "AgentMemoryReadWrite",
"Effect": "Allow",
"Action": ["dynamodb:GetItem", "dynamodb:PutItem", "dynamodb:Query"],
"Resource": "arn:aws:dynamodb:us-east-1:123456789012:table/agent-memory-store",
"Condition": {
"ForAllValues:StringEquals": { "dynamodb:Attributes": ["pk", "sk", "content", "source_ref"] }
}
},
{
"Sid": "InvokeApprovedModelsOnly",
"Effect": "Allow",
"Action": "bedrock:InvokeModel",
"Resource": "arn:aws:bedrock:us-east-1::foundation-model/anthropic.claude-sonnet-5*"
}
]
}
The Condition block on the DynamoDB statement is the detail that usually gets skipped: it restricts which attributes can be written, not just which table. This is what stands between a compromised agent writing a normal memory entry and it writing arbitrary fields that downstream code might trust implicitly (a verified: true flag, a source_ref pointing somewhere it shouldn't). It's a small addition that closes a surprising amount of the memory-poisoning risk from the original CVE.

Detecting the attack pattern, not just the CVE

Patching Ruflo to 3.16.3 fixes that specific bug. It does nothing for the next framework that ships an insecure default. What generalizes is detection on the behavior pattern: an unexpected inbound connection to an agent's tool-execution port, or an unexpected write to the memory store from outside the normal application path.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
# lambda_handler.py — triggered by EventBridge on GuardDuty findings
# tagged with resource type ECS and finding category Backdoor/UnauthorizedAccess

import boto3

ecs = boto3.client("ecs")
iam = boto3.client("iam")
sns = boto3.client("sns")

def handler(event, context):
detail = event["detail"]
finding_type = detail.get("type", "")

if "UnauthorizedAccess" in finding_type or "Backdoor" in finding_type:
task_arn = detail["resource"]["ecsClusterDetails"]["taskDetails"]["arn"]
cluster_arn = detail["resource"]["ecsClusterDetails"]["arn"]

# Isolate first, investigate second — pull the task off the network
ecs.update_service(
cluster=cluster_arn,
service=detail["resource"]["ecsClusterDetails"]["taskDetails"]["taskDefinitionArn"],
desiredCount=0,
)

# Revoke any active session credentials tied to the task role
role_name = task_arn.split("/")[-1]
iam.put_role_policy(
RoleName=role_name,
PolicyName="emergency-deny-all",
PolicyDocument='{"Version":"2012-10-17","Statement":[{"Effect":"Deny","Action":"*","Resource":"*"}]}',
)

sns.publish(
TopicArn="arn:aws:sns:us-east-1:123456789012:agent-security-alerts",
Subject=f"Agent task isolated: {finding_type}",
Message=f"Task {task_arn} auto-isolated on GuardDuty finding. IAM role denied. Manual review required before restart.",
)
Practical lesson from running this in production: the first version of this Lambda auto-isolated on any GuardDuty finding above Low severity, and we paged ourselves awake twice in the first week on findings that turned out to be a load-testing script hitting the agent from an unexpected internal IP. Tune the EventBridge rule to specific finding types (Backdoor:*, UnauthorizedAccess:*, CryptoCurrency:*) before wiring this to auto-isolate rather than auto-page false positives that page a human are an annoyance; false positives that kill a production task are an incident of their own. We now run isolate-on-Critical-only and page-on-Medium-and-above, which took about three weeks of tuning to get right.

Where AWS-native tooling fits (and where it doesn't yet)

At the June 2026 AWS Summit in New York, AWS previewed Continuum, an AI-native security service that continuously discovers, prioritizes, and helps remediate risks like this one, alongside threat-modeling support built on the STRIDE framework for agentic applications. If you have access to the preview, it's worth pointing at your MCP-serving accounts specifically this is exactly the class of misconfiguration (insecure default network binding, over-broad tool exposure) it's designed to catch before a researcher or attacker finds it for you.
Worth being direct about the current limits: Continuum for code vulnerabilities was in gated preview as of the summit announcement, so it isn't a universal substitute for the manual controls above you should build the Terraform, IAM, and detection layers in this post regardless of whether Continuum is available in your account, and treat it as an additional layer once it is.

What I'd tell a team starting this today

If you're standing up MCP-based agents on AWS right now, in priority order:
  1. Bind to loopback and put an authenticated proxy in front, always this alone would have prevented RufRoot entirely, and it's a one-line config change plus a sidecar container, not a redesign.
  2. Ship your tool allow-list empty by default. Make enabling terminal_execute or equivalent an explicit, reviewed decision, not an inherited default from a quickstart template.
  3. Move secrets out of the environment before you move anything else. It's the highest-value, lowest-effort change on this list.
  4. Instrument the memory store, not just the network boundary. RCE detection catches the entry point; write-auditing on your agent's memory catches the part of the attack that keeps paying out after the entry point is closed.
  5. Treat AWS Continuum, GuardDuty, and Bedrock AgentCore Identity as complements to the architecture above, not replacements for it they reduce how long a misconfiguration survives before someone notices, they don't remove the need to configure things correctly in the first place.
None of this is exotic. It's the same least-privilege, defense-in-depth thinking that's applied to every other class of service for the last decade, pointed at a newer kind of workload. The uncomfortable truth in the RufRoot writeup is that the fix bind to loopback, don't ship a shell tool by default, don't put secrets in env vars was never hard. It just wasn't anyone's default, and defaults are what ship to production under deadline pressure. Making the secure path the easy path, in your own base images and Terraform modules, is the actual work.
Sources: F5 Labs Weekly Threat Bulletin, August 5, 2026 (CVE-2026-59726 technical details); The Hacker News CISA KEV coverage, August 5, 2026; AWS Summit New York 2026 announcements on AWS Continuum and Bedrock AgentCore.
Any opinions in this article are those of the individual author and may not reflect the opinions of AWS.
Enjoyed reading this content? Let the author know!

Your likes, comments, shares, and saves help creators reach more builders.

Loading recommendations

Loading article