Zenity Labs showed one prompt to a public agent returning credentials for a role scoped to every agent in the account. The model made one HTTP request. Everything after that was IAM, and IAM is code you can check today.
Your public agent answers questions all day. A user asks it to fetch a URL. It fetches the URL. That is the job you gave it.
Now picture that URL returning the temporary credentials of the machine the agent runs on, and picture those credentials belonging not to that one agent, but to every agent in the same account and region. Your quoting agent just became a key to your finance agent, its conversations, its secrets, and its memory. Nobody jailbroke anything. Nobody found a clever prompt. The request worked because the role said it could.
Start With the Disclosure, Not the Metaphor
On October 8, 2026, at the SecTor conference in Toronto, Zenity Labs disclosed a chain it called AgentCorruption in Amazon Bedrock AgentCore, the managed platform AWS offers for deploying and operating AI agents. The research had been reported to AWS starting December 25, 2025. According to the disclosure summary, a single prompt to one public-facing agent could reach the instance metadata service at 169.254.169.254, return the temporary credentials assigned to that workload, and then use a default execution role whose permissions were not scoped to that one agent.
From there, the reported chain moved in directions that should sound familiar to anyone who has reviewed an IAM policy. Discovery across the account. Invocation of other agents in the region. Reading conversations across agents and users. Pulling container images and reading source code. Reading API keys and tokens from a secrets store. Writing memories that persisted after the session ended and redirected later conversations.
AWS moved AgentCore to IMDSv2 for newly deployed agents by February 14, 2026, and the summary reports that the wider default-role permissions were removed by September 29, 2026, 278 days after the first report. AWS described the original behavior as documented and expected, and pointed customers to a practice you have heard for years: grant an execution role only the permissions its workload needs.
You can argue about the timeline. You can argue about what a managed platform should ship by default. Both arguments miss the part you control this afternoon. If you copied a default role, accepted a generated role, or widened a role until the demo stopped failing, your blast radius is a policy document sitting in your own account. You do not need a research team to read it. You need an hour and a refusal to skim.
The blast radius was a role property, not a model property.
That is the gap this piece fills. Most write-ups stop at the chain: prompt, metadata service, credentials, takeover. The useful work starts one step later, when you turn the chain into five concrete checks against your own role policy and run them before your agent meets the public.
Understand What a Default Role Actually Grants
An execution role is the set of actions a workload can perform, on the resources named in the policy, for as long as the credentials are valid. An agent holding that role does not need to persuade an authorization system. It presents signed credentials, and the cloud checks the signature and the policy, not the intention behind the request and not whether a human or a model typed the prompt.
That distinction explains the whole incident. A prompt injection is a content problem. It becomes an account problem only when the content can reach a tool, the tool can reach credentials, and the credentials can reach resources the agent has no business touching. Break any one of those three links and the chain ends as an embarrassing chat transcript. Leave all three in place and a customer service agent is a lateral movement tool.
Notice what the dangerous permissions have in common. None of them looks dramatic on its own:
- An invoke permission scoped to all agent runtimes in a region, not to a named list of agents this agent may call.
- A list or describe permission that enumerates every agent, log group, or resource in the account, turning one compromise into an inventory.
- A read permission on conversations or events across agents and users, dissolving a privacy boundary the product page implied was there.
- A secrets read permission scoped broadly enough to reach credentials the platform was supposed to keep away from the agent itself.
- A memory write permission that crosses agents or users, so the compromise survives the session that created it.
Each line is a normal permission with an abnormal scope. That is why reviews miss them. A reviewer scanning action names sees familiar verbs: invoke, list, get, create. The damage lives in the resource field, in the star where a specific identifier should be, and stars are easy to scroll past at the end of a long policy.
Run the Five Requests Against Your Own Role
Do not start by reading your policy and nodding. Start by asking it questions, the same questions an attacker would ask with your credentials in hand. The script below encodes two policies for the same public quoting agent. The first mirrors the pattern in the disclosure: useful actions, all scoped to a wildcard. The second is the same agent rewritten to itself only: its own secret prefix, its own memory, its own log group, and no permission to invoke, read, or enumerate any other agent at all.
import fnmatch
BROAD = {
"Statement": [
{"Effect": "Allow", "Action": ["bedrock-agentcore:InvokeAgentRuntime"], "Resource": "*"},
{"Effect": "Allow", "Action": ["bedrock-agentcore:ListEvents"], "Resource": "*"},
{"Effect": "Allow", "Action": ["bedrock-agentcore:GetResourceApiKey",
"secretsmanager:GetSecretValue"], "Resource": "*"},
{"Effect": "Allow", "Action": ["bedrock-agentcore:CreateEvent"], "Resource": "*"},
{"Effect": "Allow", "Action": ["logs:DescribeLogGroups"], "Resource": "*"},
]
}
OWN_SECRET = "arn:aws:secretsmanager:us-east-1:111122223333:secret:quoting/*"
OWN_MEMORY = "arn:aws:bedrock-agentcore:us-east-1:111122223333:memory/quoting-memory"
SCOPED = {
"Statement": [
{"Effect": "Allow", "Action": ["secretsmanager:GetSecretValue"], "Resource": OWN_SECRET},
{"Effect": "Allow", "Action": ["bedrock-agentcore:CreateEvent"], "Resource": OWN_MEMORY},
{"Effect": "Allow", "Action": ["logs:CreateLogStream", "logs:PutLogEvents"],
"Resource": "arn:aws:logs:us-east-1:111122223333:log-group:/agents/quoting:*"},
]
}
FINANCE = "arn:aws:bedrock-agentcore:us-east-1:111122223333:runtime/finance-agent"
FINANCE_SECRET = "arn:aws:secretsmanager:us-east-1:111122223333:secret:finance/payroll"
FINANCE_MEMORY = "arn:aws:bedrock-agentcore:us-east-1:111122223333:memory/finance-memory"
def allows(policy, action, resource):
for st in policy["Statement"]:
actions = st["Action"] if isinstance(st["Action"], list) else [st["Action"]]
resources = st["Resource"] if isinstance(st["Resource"], list) else [st["Resource"]]
if any(fnmatch.fnmatchcase(action, a) for a in actions) and \
any(fnmatch.fnmatchcase(resource, r) for r in resources):
return True
return False
CHECKS = [
("invoke the finance agent", "bedrock-agentcore:InvokeAgentRuntime", FINANCE),
("read conversations on the finance agent", "bedrock-agentcore:ListEvents", FINANCE),
("read the finance payroll secret", "secretsmanager:GetSecretValue", FINANCE_SECRET),
("plant a memory on the finance agent", "bedrock-agentcore:CreateEvent", FINANCE_MEMORY),
("enumerate agents via log groups", "logs:DescribeLogGroups", "*"),
("read its OWN quoting secret (legit work)", "secretsmanager:GetSecretValue",
"arn:aws:secretsmanager:us-east-1:111122223333:secret:quoting/catalog-key"),
("write its OWN memory (legit work)", "bedrock-agentcore:CreateEvent", OWN_MEMORY),
]
for name, policy in [("BROAD default role", BROAD), ("SCOPED role", SCOPED)]:
print(f"\n{name}")
allowed = 0
for label, action, resource in CHECKS:
ok = allows(policy, action, resource)
allowed += ok
print(f" {'ALLOW' if ok else 'DENY '} {label}")
print(f" -> {allowed}/{len(CHECKS)} requests allowed")
Run it with a plain interpreter, no packages to install:
python3 check_role.py
On my run, the broad role allowed 7 of 7 requests. The scoped role allowed 2 of 7, and the two it allowed were the agent's own legitimate work: reading its own catalog secret and writing its own memory. Every cross-agent request flipped to deny, and nothing the agent actually needs broke. That last point matters, because the usual objection to scoping is that the agent will stop working. Here it does not. The work survives. The takeover does not.
A few things are worth noting about this example. First, the evaluator is deliberately small: wildcard matching on action and resource, which is close enough to a first-pass policy review to catch the failure mode in the disclosure. Second, the test set is the product. Writing the seven checks forced the design question a policy read never does: should a quoting agent ever invoke a finance agent, in any circumstance, for any user? If the answer is no, the policy should say no, not merely hope the prompt does. Third, keep the two legitimate checks in the suite forever. A scoping change that passes the attack checks and fails the work checks will get reverted at the next incident, usually by someone widening the role back to a star at 2 a.m.
Adapt the checks to your platform in this order:
[public agent] --prompt--> [tool: fetch URL] --reaches--> [metadata service]
|
v
[other agents] <--invoke-- [credentials of the DEFAULT ROLE] --read--> [secrets]
^ |
| v
[future sessions] <--planted-- [memory write across agents] <--read-- [conversations]
Read the diagram as a checklist, not a picture. Can a tool your agent holds reach a metadata address? Should outbound fetch be limited to an allowlist of hosts, with link-local addresses denied outright? If credentials leak anyway, and you should design as if they will, which of the five arrows still works? A good scoped role leaves the attacker holding valid credentials to almost nothing.
Check the Real Role With Real Commands
The script tests a policy you paste into it. Your account needs the same treatment against the role that is actually attached. Pull it, do not rely on the console summary you remember:
aws iam get-role --role-name YOUR_AGENT_ROLE
aws iam list-attached-role-policies --role-name YOUR_AGENT_ROLE
aws iam get-role-policy --role-name YOUR_AGENT_ROLE --policy-name YOUR_POLICY_NAME
aws iam simulate-principal-policy \
--policy-source-arn arn:aws:iam::111122223333:role/YOUR_AGENT_ROLE \
--action-names bedrock-agentcore:InvokeAgentRuntime secretsmanager:GetSecretValue \
--resource-arns arn:aws:bedrock-agentcore:us-east-1:111122223333:runtime/finance-agent
The simulation call is the one teams skip, and it is the one that answers the only question that matters: given this exact role, this exact action, and that exact other agent, does the cloud say yes? Run it for each of the five dangerous pairs above, once per sensitive agent in the account. Record the answers in the pull request that scopes the role. A reviewer can argue with an opinion about least privilege. A simulation table with five allows on cross-agent actions is harder to wave away.
While you are there, treat memory as part of the compromised state, not as harmless context. If a role could write memory across agents or users, your incident plan needs a step that inspects and clears memories written outside their own session, after credentials rotate. Rotating the key and restarting the agent leaves a planted memory exactly where the attacker put it, ready for the next user who trusts the agent's name.
Where This Breaks
- This evaluator is a screening test, not an authorization engine. It ignores deny statements, permission boundaries, session policies, and condition keys, all of which can narrow or widen a real policy. Use it to find stars, then confirm with the provider's own simulation.
- Scoping the role does not fix the first link. An agent that can fetch arbitrary URLs can still be steered to internal addresses that need no credentials at all. Deny link-local and private ranges at the fetch tool, separately.
- Some platforms genuinely need one agent to call another. Scope that call to the named target, for the named action, and log every invocation with the calling agent's identity. A shared role for a fleet of cooperating agents recreates the problem with extra steps.
- Secrets scoping fails quietly when teams share one secret for convenience. If the quoting agent and the finance agent read the same secret entry, no resource policy can separate them. Split the secret first.
- A hardened default today says nothing about next quarter. Platforms add permissions during upgrades and incident fixes. Re-run the checks in CI against the exported policy, or the star comes back without anyone choosing it.
Decide Whether to Do This Work
Build it if:
- Any agent in your account is reachable by people you do not employ, and any other agent in that account touches money, health data, internal documents, or credentials.
- You cannot produce, right now, the policy document for the role your production agent runs under.
- Two or more agents share a role, a secret, or a memory store.
Skip it if:
- You run one agent, in its own account, with a role you wrote line by line and a simulation test in CI. You have already done this work. Spend the hour on the fetch-tool allowlist instead.
The minimal viable version fits in an afternoon. Export the role policy. Write the seven checks for your most public agent and your most sensitive agent. Run them. Replace every star on a dangerous action with a named resource, split any shared secret the checks expose, and add the test file to CI so a future widening fails the build instead of shipping quietly.
Scope the Role Before You Tune the Prompt
You will be tempted to spend next week on prompt defenses, because prompts feel like the new part and IAM feels like homework. Resist that ordering. A prompt defense reduces how often the agent tries something it should not. A scoped role decides whether trying works. One of those is a probability. The other is a boundary.
Export one production agent role today, run the five cross-agent checks against it, and fix the first star you find before you touch another system prompt.
When you pulled your agent's actual role policy for the first time, which permission surprised you most, and who had added it?
Resources
- The Default Role, Not the Model: AgentCorruption analysis - a step-by-step summary of the Zenity Labs disclosure, the five permissions in the chain, and the 278-day timeline.
- Your Agent Should Not Borrow Your API Key - scoped, expiring credentials per agent, with a runnable gate.
- Your Agent Platform Fails Open - the companion failure mode: no identity provider configured should mean no access, not full admin.
- Your Agent Forgot What It Did: Build an Audit Trail It Cannot Edit - the witness layer: a record of cross-agent calls that the agent itself cannot rewrite.
Top comments (0)