<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:atom="http://www.w3.org/2005/Atom">
  <channel>
    <title>Whocan Blog</title>
    <link>https://whocan.cloud/blog</link>
    <description>Field notes on cloud access: breach breakdowns, RQL query walkthroughs, and the privilege-escalation paths a permissions graph cannot answer.</description>
    <language>en-us</language>
    <copyright>© Pragmable SAS</copyright>
    <lastBuildDate>Wed, 17 Jun 2026 09:00:00 GMT</lastBuildDate>
    <atom:link href="https://whocan.cloud/rss.xml" rel="self" type="application/rss+xml" />
    <item>
      <title>You wrote the data perimeter. Can you prove it holds?</title>
      <link>https://whocan.cloud/blog/prove-your-data-perimeter-holds</link>
      <guid isPermaLink="true">https://whocan.cloud/blog/prove-your-data-perimeter-holds</guid>
      <pubDate>Wed, 17 Jun 2026 09:00:00 GMT</pubDate>
      <dc:creator>Whocan Research</dc:creator>
      <description>A data perimeter is preventive policy — SCPs, RCPs, and VPC endpoint policies enforcing trusted identities, trusted resources, and expected networks. Writing the guardrails and proving none of them leak are different jobs. Here is how to ask the perimeter questions directly.</description>
      <category>AWS</category><category>Data perimeter</category><category>SCPs &amp; RCPs</category>
      <content:encoded><![CDATA[<p><strong>A data perimeter is one of the strongest preventive controls AWS offers: always-on guardrails that ensure only trusted identities reach your trusted resources, and only from networks you expect. You build it from service control policies, resource control policies, and VPC endpoint policies, and AWS publishes a full set of example policies to start from. You can write every one of them and attach them across your organization. None of that answers the question that matters at audit time: does the perimeter actually hold? Is there a principal, a resource, or a network path that still slips through? Writing the guardrail and proving it holds are two different jobs.</strong></p>
<h2>Three perimeters, six questions</h2>
<p>AWS frames the data perimeter as three boundaries, each enforced in two directions. The identity perimeter: only trusted identities can access your resources, and only trusted identities are allowed from your network. The resource perimeter: your identities can access only trusted resources, and only trusted resources can be reached from your network. The network perimeter: your identities can reach resources only from expected networks, and your resources can be reached only from expected networks. Six control objectives, enforced with three policy types — SCPs on the identity side, RCPs on the resource side, and endpoint policies on the network side.</p>
<p>Trusted has precise definitions. Trusted identities are the principals in your accounts, plus AWS services acting on your behalf. Trusted resources are the resources your accounts own. Expected networks are your VPCs and on-premises ranges. The example policies express those definitions with organization IDs, account lists, VPC IDs, and corporate IP ranges — the values you substitute in when you deploy them.</p>
<h2>Why writing it is not proving it</h2>
<p>A deployed perimeter is not one policy. It is an SCP at one organizational unit, an RCP at another, an endpoint policy on each VPC, a resource policy on the buckets that predate RCP support, and a web of conditions and exception tags threaded through all of them. The guardrails interact, they span every account in the organization, and several of them turn on values that exist only at request time — the source VPC, the source IP, a principal tag, a resource tag. Reading two hundred policy statements does not tell you who actually slips through the seams where they meet.</p>
<p>This is the same wall the conditional-access label runs into: a scanner that reads policies at rest sees the conditions but cannot resolve them, so the cross-cutting result comes back as conditional rather than as a list. The way through is to stop reading the policies and start asking the perimeter questions as queries — evaluating the whole chain, across accounts, with the runtime values supplied.</p>
<h2>Prove each boundary with a query</h2>
<p>Take the boundaries one at a time and pair each with the question that proves it. Start with the identity perimeter — only trusted identities should reach your resources. The proof is the empty result to its inverse: every principal that can read a sensitive bucket, filtered to those whose account is outside your organization.</p>
<p><strong>Identity perimeter: external identities that can reach your data</strong></p><p>Compute everyone who can read the buckets, then keep only the principals whose account is not in your organization. An empty result is the identity perimeter holding; any name is an external identity the resource-side controls did not stop.</p><pre><code>readers = who-can(
    action: "s3:GetObject"
    resource: var:sensitive-buckets
)

readers where self.account() not in var:org-accounts</code></pre>
<p>The resource perimeter is the mirror image of that query — your own identities reaching a resource outside the organization — proven the same way, with the populations flipped. The network perimeter is a different shape: it turns on a condition, the source network, so it becomes a parameter. Pin a source that is not one of your VPCs and ask who still gets through.</p>
<p><strong>Network perimeter: access from outside the expected network</strong></p><p>The network perimeter policies turn on aws:SourceVpc and aws:SourceIp. Supply a source that is not one of your VPCs and the chain is re-evaluated under it. Names on the list are principals the network perimeter does not actually constrain.</p><pre><code>who-can(
    action: "s3:GetObject"
    resource: var:sensitive-buckets
    env: {"aws:sourcevpc": "vpc-untrusted"}
)</code></pre>
<h2>The governance trap: who can leave the perimeter?</h2>
<p>The example policies carve out exceptions with tags — a principal tagged dp:exclude:identity is exempt from the identity perimeter, dp:exclude from all of it. That is a deliberate and necessary feature. It is also the biggest blind spot in the perimeter, because the exception is only as strong as control over the tag. A principal that can set dp:exclude on itself can walk straight out of every boundary you built. The AWS governance policy examples exist precisely to lock those tags down, which makes the audit question simple: who can write them?</p>
<p><strong>Who can tag a principal out of the perimeter</strong></p><p>The governance SCP is meant to restrict the exclusion tags to perimeter administrators. This proves it: everyone who can tag a role, minus the administrators who are supposed to. Anyone left is a way out of the perimeter.</p><pre><code>perimeter-admins = principals where self.Tags has (
    self.Key == "team" &amp; self.Value == "admin"
)

taggers = who-can(
    action: "iam:TagRole"
    resource: roles
)

taggers where self not in perimeter-admins</code></pre>
<blockquote><p><strong>Every boundary, one verdict</strong></p><p>Each boundary the example set defines becomes a query whose empty result is the proof and whose non-empty result is the exact gap: an external identity that still reads your data, one of your own roles that can push to a resource you do not own, a principal that reaches you from an unexpected network, an account that can tag itself out of the perimeter. Save each one as a monitor and the verdict is re-checked on every refresh — so the perimeter is something you can prove holds today and know the moment it stops.</p></blockquote>
<h2>What the perimeter does not cover</h2>
<p>Two honest boundaries. First, a data perimeter is a set of IAM authorization controls — AWS is explicit that it does not cover network traffic inspection or encryption, and neither does evaluating it. Proving the authorization side holds is not the same as proving a packet cannot leave; the network and data controls AWS keeps separate are still yours to run. Second, the perimeter inherits the health of your fundamentals — account structure, tagging discipline, change management. A query proves that the policies you have evaluate the way you intend; it cannot invent a guardrail you never wrote.</p>
<h2>The takeaway</h2>
<p>A data perimeter is the rare control that is genuinely preventive — it stops the access instead of alerting on it afterward. That is exactly why it is worth proving rather than assuming. You already did the hard part of writing the policies. Turn each boundary into the question it is meant to answer, evaluate it across the whole organization, and read the verdict: empty, or the precise list of what still gets through.</p>]]></content:encoded>
    </item>
    <item>
      <title>&quot;Conditional access&quot; is not an answer</title>
      <link>https://whocan.cloud/blog/conditional-access-is-not-an-answer</link>
      <guid isPermaLink="true">https://whocan.cloud/blog/conditional-access-is-not-an-answer</guid>
      <pubDate>Wed, 17 Jun 2026 09:00:00 GMT</pubDate>
      <dc:creator>Whocan Research</dc:creator>
      <description>Graph-based scanners label condition-dependent access &quot;conditional&quot; because they evaluate at ingest time — before the source IP, MFA state, or object tag exists. Evaluate the conditions as parameters instead, and the label becomes an exact list.</description>
      <category>AWS</category><category>Conditional access</category><category>Access reviews</category>
      <content:encoded><![CDATA[<p><strong>You point a scanner at a sensitive bucket and ask the obvious question: who can read this? It returns a list of principals, and next to several of them sits a label — "conditional access." Not yes. Not no. Conditional. That label is the most honest thing a graph-based scanner can say, and it is also the moment an access review turns into an investigation. The principals it cannot resolve are exactly the ones whose access depends on a condition, and conditions are where the gaps hide.</strong></p>
<h2>Why the graph stops at "conditional access"</h2>
<p>Graph-based CSPMs ingest cloud state on a schedule. At ingest time they capture the permissions of each principal and the conditions attached to them, and they label the resulting graph edge accordingly. The trouble is that conditions resolve against values that only exist at request time: the actual MFA state of the session, the source IP, the request or object tag, the encryption context. None of those exist when the graph is built, so the graph cannot say whether the condition would pass. The safest thing it can report is "access exists, conditional on X" — and leave you to work out by hand which principals actually have access in which contexts.</p>
<p>For an access review or a regulator, that is not an answer. "Conditional access" is a deferral, and under time pressure it gets rounded to "probably blocked." The cases where it would not have been blocked are precisely the ones that become incidents.</p>
<h2>Evaluate the decision, do not store it</h2>
<p>The alternative is to compute the authorization decision at query time instead of reading it off a snapshot. In that model a condition is not an ingested fact — it is a parameter you supply. Whocan does this with an env: argument: you pin the condition value you care about, the full policy chain is re-evaluated under that assumption, and the result is a definitive list of principals rather than a label.</p>
<p>Ask the question the graph deferred — who can read this bucket from outside our corporate IP range — and pin the source IP as the value to test:</p>
<p><strong>Access from an untrusted source IP</strong></p><p>The source IP is supplied as a parameter; the chain is re-evaluated as if the request came from there. An empty result means the IP condition holds. Names on the list are the gap.</p><pre><code>external-ip = "203.0.113.42"

who-can(
    action: "s3:GetObject"
    resource: var:critical-bucket
    env: {"aws:sourceip": external-ip}
)</code></pre>
<p>The same mechanism answers every other condition family — what if MFA were absent, what if the request came from outside the production VPC, what if the session carried a different principal tag. The condition is just a value you set.</p>
<h2>The tags the graph never indexed</h2>
<p>Two of the conditions a static graph handles worst are the ones modern architectures lean on hardest: resource tags and object-level tags. A reachability graph can only search what it ingested, and it does not index per-object S3 tags — a bucket can hold billions of objects, and a tagging-API call per object does not scale. So tenant isolation and data classification built on object tags collapse to "conditional access" too. The tool reads the bucket policy correctly, sees the s3:ExistingObjectTag condition, and stops there, because it cannot know the tags on any given object.</p>
<p><strong>Who can read objects of one classification</strong></p><p>The object tag is supplied as a parameter, so no per-object scan is required. Change the value to compare classifications — the difference between the two results is your access boundary.</p><pre><code>project-tag = "Secret"

who-can(
    action: "s3:GetObject"
    resource: var:sensitive-bucket
    env: {"s3:existingobjecttag/project": project-tag}
)</code></pre>
<p><strong>DynamoDB row-level isolation between tenants</strong></p><p>The textbook multi-tenant pattern. An empty result is audit-grade proof that the principals of tenant A cannot read the rows belonging to tenant B — the answer a "conditional access" label can never give.</p><pre><code>foreign-tenant = "tenant-b"

who-can(
    action: "dynamodb:GetItem"
    resource: var:multi-tenant-table
    env: {"dynamodb:leadingkeys": foreign-tenant}
)</code></pre>
<p>The shift is the whole point: a query engine does not try to know your data, it knows your authorization rules. A tag value is an input to the decision, not a fact to be scanned. The same machinery that answers the MFA question answers the object-tag question, because both are values supplied at evaluation time.</p>
<h2>The same gap, organization-wide</h2>
<p>The conditional label has a structural cousin one level up. The AWS data perimeter — the SCPs, RCPs, and endpoint policies that keep access inside trusted identities, trusted resources, and expected networks — is enforced across every account in an organization, and whether it actually holds is the same cross-account, condition-dependent question. It is large enough to deserve its own treatment, which it gets in the companion post, "You wrote the data perimeter. Can you prove it holds?"</p>
<blockquote><p><strong>What "conditional access" becomes</strong></p><p>Every condition family is a parameter, not a label: MFA presence, source IP, source VPC, principal and resource tags, S3 object tags, DynamoDB leading keys, KMS encryption context. Pin the value you care about and the answer is an exact list — empty means the control holds, a name means it does not. Save any of these as a monitor and the question is re-checked on every refresh, so the answer stays current instead of aging with the last scan.</p></blockquote>
<h2>Where a graph still wins</h2>
<p>This is a deliberate boundary, not a claim to do everything. A graph-based platform that scans workloads, network, and data gives you things a query engine does not: agentless vulnerability scanning across the fleet, end-to-end attack-path visualization spanning network, workload, and identity, data classification, and a single multi-cloud view. Whocan is an IAM authorization specialist — it answers who can actually do this, given these exact conditions, with a precision a reachability graph structurally cannot, and it is AWS-deep today rather than broad across clouds. The two are complementary: reach for the platform when the question spans the whole stack, and for the engine when the question is an authorization decision that turns on a condition.</p>
<h2>The takeaway</h2>
<p>"Conditional access" is the polite form of "we cannot tell you." For anyone who has to produce a yes or a no — an access reviewer, an auditor, a regulator — that label is the difference between an answer and an investigation. The conditions are not a footnote to the question; they are the question. Supply the value, evaluate the decision, and read the list.</p>]]></content:encoded>
    </item>
    <item>
      <title>Bedrock agents are non-human identities — can you say what yours can reach?</title>
      <link>https://whocan.cloud/blog/bedrock-agents-non-human-identities</link>
      <guid isPermaLink="true">https://whocan.cloud/blog/bedrock-agents-non-human-identities</guid>
      <pubDate>Wed, 10 Jun 2026 09:00:00 GMT</pubDate>
      <dc:creator>Whocan Research</dc:creator>
      <description>Amazon Bedrock supports no resource-based policies — whether a principal can invoke a model or rewrite an agent is decided entirely on the identity side. Three questions to ask about every agent role before it runs.</description>
      <category>AWS</category><category>Bedrock</category><category>Non-human identities</category>
      <content:encoded><![CDATA[<p><strong>Non-human identities already outnumber human ones in a typical AWS account, and Amazon Bedrock is minting more of them every week — every agent and knowledge base runs under a service role you create, each one a principal with standing permissions. Here is the part that should change how you review them: Bedrock supports no resource-based policies. There is no policy you can attach to a model or an agent to catch a bad grant, no second wall behind a mistake on the identity side. Whether a principal can invoke a model or rewrite an agent is decided entirely by the policies attached to that principal. The identity chain is not part of the control surface. It is the whole control surface.</strong></p>
<h2>Bedrock removes the safety net you rely on everywhere else</h2>
<p>On S3, an over-broad identity policy can still be stopped by a restrictive bucket policy. On KMS, the key policy has the final say. Those resource-side controls are the reason a single mistake on the identity side is usually survivable — something else gets a vote. Per the AWS IAM documentation, Bedrock has none of that: no resource-based policies, no ACLs, no resource-side control that could veto an over-broad grant. What a principal can do to a model or an agent comes down to its identity policies, the SCPs above it, and the conditions on the request, and nothing else.</p>
<p>That is an unusually clean problem for identity-side analysis to own. There is no second policy layer to reconcile, no question of whether the bucket policy or the IAM policy wins. The question of who can invoke a given model resolves from the same chain Whocan already evaluates for human users — identity policies, permission boundaries, SCPs, and conditions — to the decision AWS itself would make.</p>
<h2>Three questions to ask about every agent</h2>
<p>Bedrock agents run under customer-managed service roles: IAM roles you create and hand to the agent. AWS warns against editing them casually, because changing their permissions can break the agent — but nothing warns you about a role that was over-granted the day it was created. These three questions give every agent role a list it can be reviewed against.</p>
<p><strong>Who can invoke Bedrock models?</strong></p><p>There is no model-side policy to backstop you. This is the full set of principals, human and non-human, that can reach a model — computed from identity policies, SCPs, and conditions alone.</p><pre><code>who-can(
    action: "bedrock:InvokeModel"
)</code></pre>
<p><strong>Which agent roles have human-grade privileges?</strong></p><p>An agent role should be scoped to the few actions its task needs. Any agent that computes to admin, or holds a privilege-escalation sequence, is a non-human identity with far more reach than its job requires.</p><pre><code>agent-roles = roles where self.Tags has (
    self.Key == "workload" &amp; self.Value == "ai-agent"
)

admin-agents = agent-roles
    where self.Entitlements.Abilities includes "iam-admin"
privesc-agents = agent-roles
    where self.Entitlements.Abilities includes "iam-privilege-escalation"

admin-agents union privesc-agents</code></pre>
<p><strong>Who can both rewrite and run an agent?</strong></p><p>AWS splits the Agents API into a build-time plane and a runtime plane. A principal holding both can rewrite the action group an agent runs, then trigger it — inheriting everything that agent role can do. It is the Lambda-code-injection pattern, moved to agents.</p><pre><code>agent-builders = who-can(
    action: "bedrock:UpdateAgentActionGroup"
)

agent-invokers = who-can(
    action: "bedrock:InvokeAgent"
)

agent-builders where self in agent-invokers</code></pre>
<blockquote><p><strong>Same engine, new principals</strong></p><p>Each of these runs through the identical authorization chain Whocan uses for human IAM — every SCP, every condition, every role hop. An agent that can read a production secret, a service role that quietly computes to admin, a build-plus-runtime toxic combination: all of them are standing facts, knowable before the agent makes its first call. Save any of these as a monitor and the answer is re-checked on every refresh.</p></blockquote>
<h2>AWS tells you to build the wall. It cannot tell you if there is a door.</h2>
<p>The Bedrock hardening guidance is good and worth following. Route model and agent traffic through PrivateLink interface endpoints; scope those endpoints with a policy that allows only the actions you intend; and for model-customization jobs, lock the training-data buckets behind a Deny-unless-it-comes-from-your-VPC bucket policy keyed on aws:sourceVpc. Each of those is a wall.</p>
<p>A wall is only as good as the absence of a door around it. The question worth asking is not whether you wrote the Deny policy — it is whether any principal still reaches this bucket from outside the VPC, through a path the policy did not anticipate. That is conditional and context-dependent: it turns on the source VPC, on which role is asking, on the conditions attached at every layer in between. Source-VPC, MFA, and request context are exactly what who-can factors in, so whether the wall actually holds is something you can ask directly instead of inferring from the policy text.</p>
<h2>The takeaway</h2>
<p>An AI agent is not just a feature you ship — it is an IAM principal you create, with standing access that outlives any single request. Treat it like one. Before it makes its first call, ask what its role can reach, whether anyone can rewrite and re-run it, and whether the perimeter you drew around its data actually holds. Bedrock gives you no resource-side net to catch these later; the identity side is where the question lives, which makes it where the answer is too.</p>]]></content:encoded>
    </item>
    <item>
      <title>LexisNexis: one frontend role could read every secret in the account</title>
      <link>https://whocan.cloud/blog/lexisnexis-one-role-every-secret</link>
      <guid isPermaLink="true">https://whocan.cloud/blog/lexisnexis-one-role-every-secret</guid>
      <pubDate>Tue, 09 Jun 2026 09:00:00 GMT</pubDate>
      <dc:creator>Whocan Research</dc:creator>
      <description>A React app task role with account-wide Secrets Manager access turned one compromised container into 3.9 million leaked records. The blast radius was a standing fact you could have queried.</description>
      <category>AWS</category><category>Least privilege</category><category>Incident analysis</category>
      <content:encoded><![CDATA[<p><strong>A frontend React application does not need to read your production database password. The one at the center of the LexisNexis breach could read all of them. Its ECS task role held secretsmanager:GetSecretValue on every secret in the account, so when a single container was compromised, that role handed the attacker 53 secrets in plaintext — including the production Redshift master credential — and 3.9 million records walked out the door. The vulnerability was the spark. The blast radius was the breach.</strong></p>
<h2>What happened</h2>
<p>As reported by BleepingComputer in March 2026, the attacker exploited a React2Shell vulnerability in a frontend ECS container and pulled the task role credentials from the instance metadata endpoint. That part is routine — containers get compromised. What turned a contained incident into a 3.9 million record breach was what that role was allowed to do.</p>
<ol><li>A React2Shell vulnerability was exploited in the frontend ECS container.</li><li>Task role credentials were read from the metadata endpoint.</li><li>secretsmanager:GetSecretValue worked against every secret in the account.</li><li>53 secrets were retrieved in plaintext, including the Redshift master credential.</li><li>The attacker connected to Redshift with that master credential.</li><li>536 tables and 3.9 million records were exfiltrated — about 2 GB.</li></ol>
<p>A rendering layer was granted account-wide access to the secrets store. That is not a flaw you patch on a Tuesday — it is a permission someone granted, and it sat there waiting for any code path into that container.</p>
<h2>The blast radius was a standing fact</h2>
<p>Blast radius is the set of things a principal can reach if it is compromised. For this task role it was the entire Secrets Manager store, and through the Redshift master credential, 536 production tables. None of that required predicting React2Shell. It is computable at rest, from the permissions alone — the only question you had to ask was what each service role can touch.</p>
<p>Three questions would have put this role on a list it had no business being on.</p>
<p><strong>Who can read production secrets?</strong></p><p>The single query that would have prevented the breach.</p><pre><code>who-can(
    action: "secretsmanager:GetSecretValue"
    resource: secrets
)</code></pre>
<p><strong>Non-admin roles reading secrets</strong></p><p>A frontend app role should never appear in this result.</p><pre><code>admin-roles = roles where self.Entitlements.Abilities includes "iam-admin"

secret-readers = who-can(
    action: "secretsmanager:GetSecretValue"
    resource: secrets
)

secret-readers where self not in admin-roles</code></pre>
<p><strong>Who can reach the Redshift master credential?</strong></p><p>Only DBAs and the Redshift service role belong here.</p><pre><code>who-can(
    action: "secretsmanager:GetSecretValue"
    resource: secrets where self.Name ~ /redshift|master|prod/i
)</code></pre>
<blockquote><p><strong>What Whocan would have surfaced</strong></p><p>The frontend task role shows up in the secret-readers set and is flagged as a non-admin role reading production credentials. Its blast radius — every secret, then the Redshift master credential, then 536 tables — is computed as an entitlement before any exploit. And a continuous monitor on secrets access fires the moment any new role is granted GetSecretValue, so the over-grant is caught when it is made, not after the leak.</p></blockquote>
<h2>Least privilege is a blast-radius question</h2>
<p>The lesson is not patch faster. You should patch faster, and you should also assume the container will fall anyway. The durable fix is to shrink what a compromised role can reach: scope the task role to the one or two secrets the application actually uses, and every other secret becomes unreachable even after the container is owned. Least privilege is not a paperwork exercise — it is the difference between an incident and a breach.</p>
<ul><li>ECS task role with broad secrets access — who-can(action: "secretsmanager:GetSecretValue").</li><li>Non-admin role reading production credentials — entitlement analysis with admin exclusion.</li><li>Access to the Redshift master credential — scoped who-can on specific secrets.</li><li>Blast radius of an ECS role compromise — entitlement computation for task roles.</li><li>New secret access granted — continuous monitor on secrets access.</li></ul>
<h2>The takeaway</h2>
<p>Assume every internet-facing service will be compromised at some point. The question that decides whether that is a shrug or a headline is what its role can reach. Ask it before the attacker does: who can read production secrets, and does anything on that list have no reason to be there?</p>]]></content:encoded>
    </item>
    <item>
      <title>SCARLETEEL: 8 minutes to admin, and the path was visible the whole time</title>
      <link>https://whocan.cloud/blog/scarleteel-8-minutes-to-admin</link>
      <guid isPermaLink="true">https://whocan.cloud/blog/scarleteel-8-minutes-to-admin</guid>
      <pubDate>Mon, 08 Jun 2026 09:00:00 GMT</pubDate>
      <dc:creator>Whocan Research</dc:creator>
      <description>An AI-assisted attacker went from a leaked credential to full AWS admin in eight minutes. Every hop it used was a standing access path you could have queried the day before.</description>
      <category>AWS</category><category>Privilege escalation</category><category>Incident analysis</category>
      <content:encoded><![CDATA[<p><strong>In the SCARLETEEL intrusion documented by Sysdig, an AI-assisted attacker went from a single leaked credential to full AWS administrator in about eight minutes — then spent the next two hours moving across nineteen principals. The unsettling part is not the speed. It is that every hop the attacker used was a standing access path that existed in the account the day before, and the day before that.</strong></p>
<h2>What happened</h2>
<p>The entry point was mundane: long-lived credentials sitting in a public S3 bucket alongside RAG training data. From there the attacker did not exploit a vulnerability in the usual sense. It used permissions that were already granted.</p>
<ol><li>Found credentials in a public S3 bucket (RAG data).</li><li>Used lambda:UpdateFunctionCode to inject code into an existing Lambda function.</li><li>Called iam:CreateAccessKey to mint keys for an admin user.</li><li>Moved laterally across 19 principals — 5 users, 6 roles, 14 sessions.</li><li>Assumed the cross-account OrganizationAccountAccessRole.</li><li>Created a backdoor admin user and launched GPU instances for LLMjacking.</li></ol>
<p>Sysdig caught the activity at runtime — that is what runtime detection is for, and it worked. But by the time an alert fires, the attacker is already inside the path. The more interesting question is the one you can ask before any of this: who could have walked this route?</p>
<h2>The path was queryable before there was an attacker</h2>
<p>Each step above corresponds to a permission relationship that was true at rest. A principal that can rewrite the code of a Lambda inherits whatever that function execution role can do. A principal that can create access keys for another user inherits the privileges of that user. Chain those together and you have a privilege-escalation sequence — one that no single policy review reveals, because no single policy is wrong on its own.</p>
<p>These are exactly the questions Whocan is built to answer. Three of them would have surfaced the entire route.</p>
<p><strong>Who can modify Lambda code?</strong></p><p>The initial escalation vector — and a known, enumerable attack path.</p><pre><code>who-can(
    action: "lambda:UpdateFunctionCode"
    resource: lambdas
)</code></pre>
<p><strong>Who can create access keys for other users?</strong></p><p>Every principal that can mint new long-lived credentials for someone else.</p><pre><code>who-can(
    action: "iam:CreateAccessKey"
    resource: users
)</code></pre>
<p><strong>Full privilege-escalation chains</strong></p><p>The Lambda-plus-execution-role chain, flagged as a critical sequence rather than two unrelated permissions.</p><pre><code>users where self.Entitlements.Abilities includes "iam-privilege-escalation"
    map { Name, Arn, Sequences: self.Entitlements.Sequences }</code></pre>
<blockquote><p><strong>What Whocan would have surfaced</strong></p><p>The Lambda-to-admin escalation chain appears as a single critical sequence, before exploitation — not as five separate permissions that each look reasonable in isolation. The cross-account OrganizationAccountAccessRole assumption shows up in the transitive assume-role graph, and a new backdoor admin trips the admin-population drift monitor the moment it is created.</p></blockquote>
<h2>Detection and prevention are different jobs</h2>
<p>Whocan does not replace runtime detection — it closes the IAM gap before runtime detection ever has to fire. Mapped against the attack chain, every step was knowable as a standing access fact:</p>
<ul><li>User with Lambda write access — who-can(action: "lambda:UpdateFunctionCode").</li><li>Lambda-to-admin escalation chain — critical sequence detection.</li><li>CreateAccessKey for other users — credentials-access entitlement.</li><li>Cross-account role assumption — transitive assume-role graph.</li><li>Backdoor admin user created — admin population drift monitor.</li></ul>
<h2>The takeaway</h2>
<p>Eight minutes is not a lot of time to respond. The good news is you do not have to win that race if you have already closed the path. Audit the route, not the alert: ask who can reach admin, through which hops, before an attacker asks the same question with worse intentions.</p>]]></content:encoded>
    </item>
  </channel>
</rss>
