<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>Privilege Escalation on Matt Goodrich</title><link>https://mattgoodrich.com/tags/privilege-escalation/</link><description>Recent content in Privilege Escalation on Matt Goodrich</description><generator>Hugo -- gohugo.io</generator><language>en-us</language><lastBuildDate>Mon, 08 Jun 2026 12:00:00 -0700</lastBuildDate><atom:link href="https://mattgoodrich.com/tags/privilege-escalation/index.xml" rel="self" type="application/rss+xml"/><item><title>You Can Reach More Than You Were Granted</title><link>https://mattgoodrich.com/posts/you-can-reach-more-than-you-were-granted/</link><pubDate>Mon, 08 Jun 2026 12:00:00 -0700</pubDate><guid>https://mattgoodrich.com/posts/you-can-reach-more-than-you-were-granted/</guid><description>&lt;img src="https://mattgoodrich.com/posts/you-can-reach-more-than-you-were-granted/header.png" alt="Featured image of post You Can Reach More Than You Were Granted" />&lt;p>A developer has permission to assume one role. That role can start an EC2 instance. The instance comes up with an instance profile attached, which is another role. That second role can read a specific S3 bucket, write to a queue, and call an internal deploy API. The grant on the developer&amp;rsquo;s account says one thing: assume this role. What the developer can actually reach is everything at the far end of that chain, and no one wrote that part down.&lt;/p>
&lt;p>This is the gap that makes least privilege so much harder than it sounds. The permission you grant and the access you create are not the same thing, and the distance between them is where the risk hides.&lt;/p>
&lt;h2 id="granted-versus-reachable">Granted Versus Reachable
&lt;/h2>&lt;p>It is worth naming the two things cleanly, because most conversations blur them.&lt;/p>
&lt;p>A &lt;strong>granted&lt;/strong> permission is what shows up in the policy. User X can assume role Y. Service account Z can read table T. These are the statements you write, review, and attest to. They are legible.&lt;/p>
&lt;p>A &lt;strong>reachable&lt;/strong> permission is everything an identity can get to by following grants through other grants. Assume a role that can launch compute, and you reach whatever that compute can do. Assume a role that can pass another role to a service, and you reach that role&amp;rsquo;s permissions too. Reachable access is the transitive closure of every grant, and it is almost never written down anywhere.&lt;/p>
&lt;p>Granted is a list. Reachable is a graph. The list is what your access review looks at. The graph is what an attacker explores.&lt;/p>
&lt;p>&lt;img src="https://mattgoodrich.com/posts/you-can-reach-more-than-you-were-granted/diagram-transitive-access.png"
width="1568"
height="464"
srcset="https://mattgoodrich.com/posts/you-can-reach-more-than-you-were-granted/diagram-transitive-access_hu7646740289056073774.png 480w, https://mattgoodrich.com/posts/you-can-reach-more-than-you-were-granted/diagram-transitive-access_hu8594616541289807415.png 1024w"
loading="lazy"
alt="One Granted Edge, a Whole Reachable Subgraph: the Developers Assume-Role Grant Reaches an EC2 Instance, Its Instance Role, and Everything That Role Can Touch"
class="gallery-image"
data-flex-grow="337"
data-flex-basis="811px"
>&lt;/p>
&lt;h2 id="why-the-chain-is-invisible">Why the Chain Is Invisible
&lt;/h2>&lt;p>This stays hidden because each link looks reasonable on its own.&lt;/p>
&lt;p>The developer needs to assume a role to do their job. The role needs to launch instances. The instance needs a profile so the code on it can reach its database. Every grant in the chain passes review individually, because every grant in the chain is individually defensible. The danger appears only when you compose them, and nothing in the normal review process composes them.&lt;/p>
&lt;p>Cloud platforms sharpen this, because the building blocks are designed to chain into each other. AWS lets a role pass another role to a service (&lt;code>iam:PassRole&lt;/code>), assume roles across accounts, and chain assumptions one into the next. Each is a useful feature. Together they mean a modest-looking grant can sit two or three hops from something you would never have approved directly. The classic case is an over-broad &lt;code>iam:PassRole&lt;/code> that lets a low-privilege principal hand a high-privilege role to a service it controls. Now the low-privilege principal has the high-privilege role&amp;rsquo;s reach, and the policy on its own account still looks tame.&lt;/p>
&lt;h2 id="where-reviews-miss-it">Where Reviews Miss It
&lt;/h2>&lt;p>A quarterly access review reads memberships and policies. It can tell you a user is in the developers group and the group can assume role Y. It cannot tell you, in the thirty seconds a reviewer spends per line, that role Y reaches the customer database three hops later. Even &lt;a class="link" href="https://mattgoodrich.com/posts/telemetry-driven-access-reviews/" >telemetry-driven review&lt;/a>, which is a large improvement over the quarterly ritual, mostly sees direct use: who authenticated, what they called. It sees the hops that happened, not the hops that could happen. The unused transitive path is the most dangerous kind, because it is reachable and invisible at the same time.&lt;/p>
&lt;h2 id="what-actually-helps">What Actually Helps
&lt;/h2>&lt;p>You cannot eyeball a permission graph, and this is one of the few places in identity where tooling is genuinely worth running.&lt;/p>
&lt;p>The category is cloud infrastructure entitlement management, and the useful capability under the marketing is effective-access analysis: computing the transitive closure and answering &amp;ldquo;what can this identity actually reach?&amp;rdquo; AWS IAM Access Analyzer does a slice of this natively, covering external access, unused access, and policy validation. Wiz, Sonrai, and Tenable (through the Ermetic acquisition) build the fuller graph across accounts and clouds. The common thread is that they treat access as a graph and compute reachability, which is the thing humans cannot do by hand.&lt;/p>
&lt;p>The other half is &lt;code>iam:PassRole&lt;/code> discipline. Scope pass-role permissions to specific roles, never a wildcard. Most dangerous chains route through a sloppy PassRole grant, and tightening it cuts a lot of reachable access without touching anything anyone uses.&lt;/p>
&lt;p>And where you can, prefer short-lived, narrowly scoped credentials over standing roles, so even a reachable path is only reachable for a bounded window. This is the same argument as &lt;a class="link" href="https://mattgoodrich.com/posts/agents-need-capabilities-not-roles/" >capabilities over roles&lt;/a>: the narrower the grant, the smaller the graph it sits in.&lt;/p>
&lt;h2 id="building-the-graph-without-buying-one">Building the Graph Without Buying One
&lt;/h2>&lt;p>The commercial tools build the graph for you. If you want to build it yourself, the data is all queryable, and a couple of open-source tools already do the hard part.&lt;/p>
&lt;p>The raw material is your account&amp;rsquo;s authorization details. &lt;code>aws iam get-account-authorization-details&lt;/code> dumps every user, role, policy, and trust relationship as JSON, which is the graph&amp;rsquo;s nodes and edges. The work is computing the paths through it, and you do not have to write that yourself.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># every identity, policy, and trust relationship in the account&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">aws iam get-account-authorization-details &amp;gt; auth-details.json
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># or let PMapper build the privilege graph and ask it directly&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">pmapper graph create
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">pmapper query &lt;span class="s1">&amp;#39;preset privesc *&amp;#39;&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;a class="link" href="https://github.com/nccgroup/PMapper" target="_blank" rel="noopener"
>PMapper&lt;/a> (Principal Mapper, from NCC Group) builds the privilege graph and answers reachability questions directly: which principals can reach admin, which can escalate, what a given role can get to. &lt;a class="link" href="https://github.com/cartography-cncf/cartography" target="_blank" rel="noopener"
>Cartography&lt;/a> (originally from Lyft) ingests AWS and other clouds into a Neo4j graph so you can write your own queries against the whole estate. AWS IAM Access Analyzer covers the managed slice, external and unused access, without standing anything up.&lt;/p>
&lt;p>Reach for PMapper when you want answers this afternoon, and Cartography when you want a graph you can keep querying as the estate changes. Either way the shift is the same: you stop reviewing policies one at a time and start asking the graph a question.&lt;/p>
&lt;p>None of this is AWS-only. Cartography ingests GCP and Azure too, so one graph can span all three clouds. GCP has Policy Analyzer to ask who can reach a resource and IAM Recommender to surface unused access, both native. On Azure the open-source pair is &lt;a class="link" href="https://github.com/SpecterOps/AzureHound" target="_blank" rel="noopener"
>AzureHound&lt;/a> and &lt;a class="link" href="https://github.com/SpecterOps/BloodHound" target="_blank" rel="noopener"
>BloodHound&lt;/a>, which collect the environment and render privilege-escalation paths directly, the closest analog to PMapper, while Microsoft Defender for Cloud bundles a CIEM that does the managed version across all three. The tools differ by cloud; the question you ask the graph does not.&lt;/p>
&lt;h2 id="the-role-hides-who-used-it">The Role Hides Who Used It
&lt;/h2>&lt;p>Finding a reachable edge tells you the access exists. It does not tell you who used it, and on a transitive edge that second question gets genuinely hard.&lt;/p>
&lt;p>When code on that instance writes to the S3 bucket, CloudTrail records the action under the instance&amp;rsquo;s role, not under a person. The log says role X wrote to the bucket. It does not say whether a human connected to the instance and ran a command, or the application did exactly what it was built to do on its own schedule. Both look identical in the trail, because by the time the write happens, both are wearing the same role.&lt;/p>
&lt;p>That ambiguity matters. &amp;ldquo;A human reached production through this chain&amp;rdquo; and &amp;ldquo;the workload reached production because we designed it to&amp;rdquo; are completely different findings, and the default audit trail cannot tell them apart.&lt;/p>
&lt;p>AWS gives you a way to keep them apart for one of those cases, but you have to opt in. &lt;code>sts:SourceIdentity&lt;/code> carries the original human identity through a chain of role assumptions. You add &lt;code>sts:SetSourceIdentity&lt;/code> to the role&amp;rsquo;s trust policy, alongside &lt;code>sts:AssumeRole&lt;/code>, with a condition that requires it so nobody can omit it, and the caller (or your IdP, mapping an email or username) sets &lt;code>--source-identity jane@example.com&lt;/code> at assume time. From then on the value is immutable and rides along on every downstream CloudTrail event, so the log can say &amp;ldquo;Jane, operating through role X, wrote to the bucket&amp;rdquo; instead of just &amp;ldquo;role X.&amp;rdquo;&lt;/p>
&lt;p>The catch is which case that covers. SourceIdentity works for role chaining, a human assuming a role that assumes another role. It does nothing for the instance-profile hop in the example above, because the instance&amp;rsquo;s role is handed to it by the EC2 service, not by a human&amp;rsquo;s assume-role call. A person who connects to that instance and uses its credentials leaves no source identity at all, and CloudTrail shows only the instance role. To attribute that, you are correlating SSM Session Manager or SSH logs against CloudTrail by timestamp, which is exactly as fragile as it sounds. The hardest hop to trace is the one this post opened with.&lt;/p>
&lt;p>This is the deeper thing the chain exposes, and it is worth naming on its own. The hop that erases attribution is the one where a human assumes a role and lands on a workload that has its own identity. From that point the person is operating as the workload, and their actions are indistinguishable from the workload&amp;rsquo;s own. User identity crossing into workload identity is where accountability goes to disappear, and it is a big enough problem to deserve its own post. I will come back to it.&lt;/p>
&lt;p>The other clouds land in the same place, with one twist. GCP actually logs the full impersonation chain by default: when one service account impersonates another, Cloud Audit Logs records every principal in &lt;code>serviceAccountDelegationInfo&lt;/code>, including the human who started it, no opt-in required. Azure leans on Microsoft Graph activity logs, which only reached general availability in 2024 and which most tenants still have not turned on. But all three share the same blind spot you cannot configure away: a workload with an attached identity, an EC2 instance profile, a GCP service account on a VM, an Azure managed identity, logs its actions as that identity, and a human operating through it vanishes into it.&lt;/p>
&lt;h2 id="the-map-is-never-complete">The Map Is Never Complete
&lt;/h2>&lt;p>Here is the part the tooling vendors undersell. The reachable graph is never fully knowable, because some hops are not in IAM at all.&lt;/p>
&lt;p>An application with database write access can change data another system trusts. A CI job that can edit its own pipeline can grant itself more. A service that writes to a config store another service reads can influence that service&amp;rsquo;s behavior. These are reachability paths too, and no entitlement tool sees them, because they run through application logic, not access policy. Effective-access analysis shrinks the invisible part. It does not erase it.&lt;/p>
&lt;p>&lt;img src="https://mattgoodrich.com/posts/you-can-reach-more-than-you-were-granted/diagram-invisible-paths.png"
width="1568"
height="514"
srcset="https://mattgoodrich.com/posts/you-can-reach-more-than-you-were-granted/diagram-invisible-paths_hu5327378083913166235.png 480w, https://mattgoodrich.com/posts/you-can-reach-more-than-you-were-granted/diagram-invisible-paths_hu4461095185103185143.png 1024w"
loading="lazy"
alt="Three Reachability Paths That Arent in IAM: an Apps Data Write That Another System Trusts, a CI Job Editing Its Own Pipeline to Escalate, and a Service Writing Config That Another Service Obeys. The Dashed Edges Are Invisible to Entitlement Tools"
class="gallery-image"
data-flex-grow="305"
data-flex-basis="732px"
>&lt;/p>
&lt;p>So the honest goal is a smaller gap between granted and reachable, plus the awareness that the gap is never zero. A complete map was never on offer. You shrink it by scoping grants tightly, killing wildcard pass-role, shortening credential lifetimes, and running a tool that computes the closure for the parts that live in IAM. The rest you handle by assuming any identity can eventually reach a little further than its policy claims.&lt;/p>
&lt;h2 id="you-are-reviewing-the-wrong-object">You Are Reviewing the Wrong Object
&lt;/h2>&lt;p>The thing to internalize is that your access review is reviewing the wrong object. It reviews grants, a list. The risk lives in reachability, a graph. Until you can see the graph, you are approving the links one at a time and hoping nobody walks the chain. Someone always walks the chain.&lt;/p></description></item></channel></rss>