Building Cloud Control-Plane Detections with Chokepoints

Building Cloud Control-Plane Detections with Chokepoints

Cloud intrusions have quietly become an identity-and-API problem more than a malware problem. CrowdStrike's 2025 Global Threat Report put valid-account abuse as the top initial-access method in cloud incidents, and access-broker advertising for stolen credentials keeps climbing year over year. The attacker doesn't need an implant. They need a credential and an API call. Once they're in, the question that matters isn't "what tool are they running." It's "what does the platform force them to do next."

That's the part I find reassuring as a defender. The tooling rotates constantly, but the control-plane moves don't. Whether someone is versioning an IAM policy in AWS, granting themselves roles/owner in GCP, or regenerating a storage key in Azure, they keep landing on the same tiny set of API calls, because the platform gives them no other path to the outcome.

A while back I wrote about the building blocks of cloud identity detections. This is the other half of that coin: the control plane. I first came across the framing for it in iimp0ster's detection-chokepoints repo, which does this for endpoint, under a tagline that stuck with me: "TTPs evolve. Chokepoints don't." I ported the idea to the cloud and turned it into a catalog, cloud-detection-chokepoints.

This post walks through what a chokepoint actually is, what I built, and where it's honest about its own limits.

What is a chokepoint?

A chokepoint is a technical prerequisite an attacker can't design around. It's the step that survives tool rotation.

The way I decide whether something qualifies is a single question:

If the attacker switches tools tomorrow, does the detection still fire?

If the detection keys on a user agent, a source IP, a session name, or a tool signature, all things the attacker controls, then it isn't a chokepoint, and it'll rot the moment the attacker recompiles. But if it keys on the invariant, like "a new default IAM policy version that grants Action:*," it fires no matter what wrote it. Every entry in the catalog is built by pulling on that thread: separate what the attacker controls from the one thing they can't, and anchor to the second.

Kaspersky did a version of this analysis on eight ransomware operations and found they converged on the same core kill chain despite using completely different tools. The same convergence shows up in cloud. Ransomware crews, cryptominers, and hands-on-keyboard operators all funnel through SetIamPolicyroleAssignments/writeCreateServiceAccountKeyStopLogging, and a dozen others. Those are the chokepoints.

What I built

Right now it's 58 chokepoints across three clouds and ten ATT&CK tactics: AWS (CloudTrail), GCP (Cloud Audit Logs), and Azure (Activity Log). One YAML file per chokepoint, all on a single schema so an AWS entry reads the same as a GCP or Azure one.

Each entry carries the same building blocks:

  • the invariant analysis: what the technique is, what the attacker controls, and the one thing they don't;
  • the observable operation (eventName / methodName / operationName) and where the discriminating signal actually lives;
  • three tiers of detection logic, borrowed straight from the upstream maturity model: Research (every occurrence, high FP), then Hunt (add the narrowing invariant), then Analyst (tightest logic plus allowlists, low FP);
  • and a portable Sigma rule for the ones you can build today.

Deliberately out of scope: identity itself (sign-ins, OAuth, conditional access). That's a different log source, and it's the part most SOCs already cover. It's what my earlier identity post was about. This project is the resource and control-plane layer that tends to get less love.

The honesty tax

This is the part I care about most, because it's the part most detection repos skip.

It would be easy to slap "detects APT activity" on all 58 and call it authoritative. I wouldn't trust a repo like that, so I didn't build one. Every entry carries a validation_tier:

  • INCIDENT: a named actor was actually documented doing this, in this cloud.
  • AGGREGATE: vendor telemetry says the behavior class is prevalent.
  • CAPABILITY: demonstrated feasible, but not yet tied to a public incident.
  • TRANSLATABLE: seen in another cloud, inferred here.

When I finished tagging everything, only 14 of the 58 came out INCIDENT. Most are CAPABILITY. I think that's the honest result, and I don't read it as a weakness. A CAPABILITY chokepoint is one you get to catch before it lands in someone's incident report.

It also forced me to kill a myth I'd repeated myself. The "Scattered Spider added a SAML provider in AWS" story gets passed around a lot, but read the CISA advisory and that federation abuse is at the Okta/Entra tenant level, not an AWS CreateSAMLProvider call. So that entry is CAPABILITY with a note, instead of pretending we've seen it.

The read-log gap (same story on all three clouds)

If there's one thing to take away, it's this: on every cloud, the write operations are logged by default and the reads are not.

  • AWS: S3 GetObject data events are off by default.
  • GCP: Data Access logs are off by default (BigQuery reads being the lone exception).
  • Azure: Key Vault secret reads and storage blob reads are data-plane, off by default.

Which means secret reads, bulk object-read exfiltration, service-account impersonation, and permission recon are all blind until you turn the logging on. Rather than quietly drop those chokepoints, I kept them in and flagged them [blind]. They're real, and detectable the moment you enable the read logging. Just know going in that reads are the expensive volume, so it's a scoping-and-cost conversation, not a checkbox.

A few things that tripped me up

Because the useful part of any writeup is the stuff that didn't work the first time:

  • "Policy contains" vs "delta added." My first GCP privileged-grant rule matched roles/owner in the SetIamPolicy payload, and fired on basically everything, because every policy contains roles/owner. You have to match the ADD binding delta, not the presence of the role. Learned that the noisy way.
  • The field-to-field comparison footgun. A rule like callerAccount != resourceAccount looks obvious, but a lot of query languages read fieldA != fieldB as "compare fieldA to the literal string fieldB," so it matches everything. Always validate the result count on real data before you trust the logic.
  • Azure roleAssignments/write doesn't log the granted role or principal cleanly. You get the caller's authorizing role and the target GUID, but the role being assigned lives in a request body that often isn't captured. Worth knowing before you promise anyone a "who just got made Owner" alert.

Using it

If you're doing detection engineering, lift the Sigma or just the invariant analysis, but baseline the allowlists on your own data, because the ones in the repo are deliberate placeholders. If you're hunting, the Research-tier logic is your starting query. And if you spot a chokepoint I missed (I know I missed some), there's a schema and a quick-add template in the repo to make adding one painless.

It's here: github.com/tresscross/cloud-detection-chokepoints.

The thing that keeps me coming back to this framing, from both sides of the keyboard, is that it ages well. Anchor a detection to the tool and you're rewriting it next quarter. Anchor it to the thing the attacker can't avoid, and it just keeps working. The tools will keep changing. These won't.

Resources