<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Yael Palacios</title>
    <description>The latest articles on DEV Community by Yael Palacios (@yayopalacios).</description>
    <link>https://dev.to/yayopalacios</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4103444%2Fb9e9ac88-ae05-4f0f-bbcf-25caf40af870.jpg</url>
      <title>DEV Community: Yael Palacios</title>
      <link>https://dev.to/yayopalacios</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/yayopalacios"/>
    <language>en</language>
    <item>
      <title>Two AWS IAM privesc tools, one path finder</title>
      <dc:creator>Yael Palacios</dc:creator>
      <pubDate>Tue, 01 Sep 2026 01:25:24 +0000</pubDate>
      <link>https://dev.to/yayopalacios/two-aws-iam-privesc-tools-26pb</link>
      <guid>https://dev.to/yayopalacios/two-aws-iam-privesc-tools-26pb</guid>
      <description>&lt;p&gt;&lt;em&gt;Four things that surprised me while grading PMapper and cloudfox against an AWS IAM privilege-escalation lab.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Over two weekends I deployed Bishop Fox's &lt;code&gt;iam-vulnerable&lt;/code&gt; in a sandbox AWS account and ran two open-source IAM privilege-escalation tools against it: cloudfox 2.0.5 and PMapper 1.1.5. I graded every scenario by hand using grading rules I finalized and saved in git before I deployed anything.&lt;/p&gt;

&lt;p&gt;I expected a scoreboard. I got four findings, none of them about which tool wins.&lt;/p&gt;




&lt;h2&gt;
  
  
  1. A region I don't use killed the run and took the map with it
&lt;/h2&gt;

&lt;p&gt;PMapper builds a map of every identity in an account and then answers questions over it. Everything starts with &lt;code&gt;pmapper graph create&lt;/code&gt;, so I ran that as admin against the fresh lab. It ran for five minutes fifty-two seconds and exited with an error.&lt;/p&gt;

&lt;p&gt;The first four minutes look fine. It pulls users, roles, groups and policies, works out who's already an admin, then starts checking for links, starting with EC2 Auto Scaling, which means checking every region. My account has 17 regions on and AWS has many more, so most of those calls hit regions I can't use. PMapper is supposed to handle those errors and continue:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;19:23:21 | Unable to search region af-south-1 for launch configs. The region may be
           disabled, or the error may be caused by an authorization issue. Continuing.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Fifteen regions, fifteen "Continuing." Then the sixteenth:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;botocore.exceptions.ConnectTimeoutError: Connect timeout on endpoint URL:
"https://autoscaling.me-south-1.amazonaws.com/"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;and the whole thing dies at &lt;code&gt;autoscaling_edges.py:60&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;It comes down to one line. The code that catches "region didn't work, keep going" only catches a region &lt;em&gt;refusing&lt;/em&gt; you (&lt;code&gt;ClientError&lt;/code&gt;). The other fifteen refused, so they got caught. &lt;code&gt;me-south-1&lt;/code&gt; didn't refuse, it went silent and timed out. That's a different kind of error, so the catch doesn't handle it and the program dies.&lt;/p&gt;

&lt;p&gt;The catch isn't missing. Someone wrote it for exactly this situation. They just aimed it at the wrong kind of error.&lt;/p&gt;

&lt;p&gt;A small miss becomes a total one because of how PMapper is built. Auto Scaling is the first of nine checks, and nothing is saved until all nine finish. The four minutes of good data it already had, the whole map, was in memory when it crashed. It's gone.&lt;/p&gt;

&lt;p&gt;Everything after this needs that file. I ran the other seven commands anyway, six died the same way:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ValueError: Did not find file at: /Users/.../com.nccgroup.principalmapper/000000000000
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The seventh, &lt;code&gt;graph list&lt;/code&gt;, exited cleanly and printed &lt;code&gt;Account IDs:&lt;/code&gt; and nothing under it. The one command that worked is the one whose only job is to tell you there's nothing there.&lt;/p&gt;

&lt;p&gt;This happened once, so I can't say how common it is. But the bug isn't about my setup: any unreachable endpoint in that loop triggers it, that loop is the only thing that builds the map, and the line hasn't changed on main since 2022-02-03.&lt;/p&gt;

&lt;p&gt;Getting to the crash took longer than the crash did. PMapper won't import on Python 3.10+, &lt;code&gt;from collections import Mapping&lt;/code&gt;, removed in 3.10, while its setup file claims "Python 3.5+," so pip installs it happily and you find out when you run it. It actually only works up to Python 3.9, which reached end of life in October 2025. I didn't patch the import, a patched copy isn't the published tool. cloudfox, for contrast, took under a minute: download the binary, check the hash, run it.&lt;/p&gt;




&lt;h2&gt;
  
  
  2. There was only ever one path-finding engine
&lt;/h2&gt;

&lt;p&gt;cloudfox ran without a hitch. One command produced fifteen CSVs and their JSON versions: 94 principals, 6,008 permission grants, 44 trust edges. Nothing crashed, nothing needed a flag.&lt;/p&gt;

&lt;p&gt;One of its columns is &lt;code&gt;CanPrivEscToAdmin?&lt;/code&gt;. It appears 158 times, and in this run every one reads:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="nl"&gt;"CanPrivEscToAdmin?"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Skipping, no pmapper data"&lt;/span&gt;&lt;span class="err"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;cloudfox has no path finder. It hands that job to PMapper, and it's upfront about it in three places: its wiki, the console mid-run ("we suggest running the pmapper commands in the loot file"), and a file it writes for you, &lt;code&gt;loot/iam-simulator-pmapper-commands.txt&lt;/code&gt;, sixteen lines, every one a &lt;code&gt;pmapper&lt;/code&gt; command.&lt;/p&gt;

&lt;p&gt;The tool that ran successfully wrote me a to-do list for the tool that didn't.&lt;/p&gt;

&lt;p&gt;Because that's documented, I graded cloudfox N/A on path-finding rather than counting it as a miss, and graded everything it works out itself normally. It does that part well. In the lab's three-role chain, its output has one row saying &lt;code&gt;ending-role&lt;/code&gt; trusts &lt;code&gt;intermediate-role&lt;/code&gt; and is admin, and one row below it, &lt;code&gt;intermediate-role&lt;/code&gt; trusts &lt;code&gt;starting-role&lt;/code&gt;. Every step of the chain is there. The chain isn't.&lt;/p&gt;

&lt;p&gt;The broader thing: I set out to compare two tools on path-finding and found one engine and one tool that displays that engine's output. If PMapper's map had been on disk when cloudfox ran, cloudfox would have displayed PMapper's answer, and I'd have graded the same engine twice thinking I had two data points. That only didn't happen because I ran cloudfox first, on a machine where PMapper had never run.&lt;/p&gt;

&lt;p&gt;Two tools aren't enough to judge the whole field. But of those two, the maintained one doesn't find paths and points you at the other, and the other hasn't been touched since February 2022 and throws its whole map away on a timeout.&lt;/p&gt;




&lt;h2&gt;
  
  
  3. My answer key was wrong and the tool was right
&lt;/h2&gt;

&lt;p&gt;Six of the lab's privilege-escalation methods each have two versions: one on a user and one on a role, both with the same permission. PMapper reported one version of each but not the other. I scored the missing six as misses and wrote down "PMapper being inconsistent."&lt;/p&gt;

&lt;p&gt;It isn't inconsistent. It's right, and I was wrong. I checked all six by hand, and in every case the version PMapper skipped can't escalate itself:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The permissions that edit &lt;strong&gt;users&lt;/strong&gt;: a role holding one can hand a user admin, but a role can't log in as that user.&lt;/li&gt;
&lt;li&gt;The permissions that edit &lt;strong&gt;groups&lt;/strong&gt;: a role can make a group admin, but AWS doesn't let a role be &lt;em&gt;in&lt;/em&gt; a group.&lt;/li&gt;
&lt;li&gt;The permissions that edit &lt;strong&gt;roles&lt;/strong&gt;: a user holding one can make any role admin, but can't assume it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Every time, the version PMapper &lt;em&gt;did&lt;/em&gt; report is the one that can turn the permission on itself. Its own data marks the difference, the six it reported are &lt;code&gt;is_admin: true&lt;/code&gt;, the six it skipped are &lt;code&gt;is_admin: false&lt;/code&gt;. The rule, once you see it: PMapper reports a principal that can escalate &lt;em&gt;itself&lt;/em&gt;, not one that can escalate someone else.&lt;/p&gt;

&lt;p&gt;My answer key couldn't make that distinction because it only recorded the permissions assigned to each principal. That works as an inventory, but it doesn't show whether the principal has a path it can actually use.&lt;/p&gt;

&lt;p&gt;PMapper's detections didn't move when I fixed this. It found 44 rows before and 44 after. Only the number of expected findings changed. The important part is this: &lt;strong&gt;knowing who has each permission does not tell you what each principal can actually reach.&lt;/strong&gt; If you grade a path finder against that kind of list, you can count things the tool was right to skip as misses. A less precise tool that flagged all twelve versions would have scored better against my broken list, even though it was less accurate.&lt;/p&gt;

&lt;p&gt;PMapper's four false alarms in this run are the same mistake in reverse. It reported, at High severity, that a user can call &lt;code&gt;ssm:SendCommand&lt;/code&gt; to reach an EC2 instance with a privileged role attached. There are no EC2 instances in that account, in any of the 17 regions. It reasoned from "there's an instance profile" to "there must be an instance." Permissions on paper again, once in the tool, once in me grading it.&lt;/p&gt;




&lt;h2&gt;
  
  
  4. AWS's GitHub OIDC guardrail checks how a rule is written, not what it allows
&lt;/h2&gt;

&lt;p&gt;The lab ships no GitHub OIDC scenario, so I built one after the matrix was frozen. Two chains, two hops each. Hop 1 is a GitHub Actions role you log into from outside AWS, allowed to do exactly one thing: become hop 2. Hop 2 is full admin and trusts only hop 1. Hop 2's trust is narrow, and it doesn't matter, because the way in is one step back.&lt;/p&gt;

&lt;p&gt;Scenario A was meant to be the obvious bad case: a rule trusting every repo on GitHub. AWS refused to create the role:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;MalformedPolicyDocument: Trust policy with trusted principal
...token.actions.githubusercontent.com must evaluate, using StringEquals,
StringLike or StringEqualsIgnoreCase, ...:sub or ...:job_workflow_ref
which is not scoped to all.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I wasn't expecting that. AWS ships a guardrail against exactly the mistake I was trying to make. So I poked at where it sits:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;repo restriction&lt;/th&gt;
&lt;th&gt;result&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;em&gt;(none)&lt;/em&gt;&lt;/td&gt;
&lt;td&gt;REJECTED&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;*&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;REJECTED&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;repo:*&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;ACCEPTED&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;repo:*/*&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;ACCEPTED&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Every GitHub Actions login starts with &lt;code&gt;repo:&lt;/code&gt;. So &lt;code&gt;repo:*&lt;/code&gt; matches every repo on GitHub, the exact thing AWS just refused as &lt;code&gt;*&lt;/code&gt;. Same meaning, different spelling. (An org can customise its login format so it doesn't start with &lt;code&gt;repo:&lt;/code&gt;, in which case &lt;code&gt;repo:*&lt;/code&gt; matches nothing, so the more accurate way to say it is "every default-format login.")&lt;/p&gt;

&lt;p&gt;The rejection message is specific and helpful. An engineer who sees it is likely to try another value and use the first one AWS accepts.&lt;/p&gt;

&lt;p&gt;Scenario B is the realistic one:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="nl"&gt;"sub"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"repo:iam-tool-benchmark-lab/*"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There's a repo restriction. It names an org. It passes a quick review. And it allows every repo in that org, every branch, every environment, and every repo anyone creates in it later. What was meant was &lt;code&gt;repo:iam-tool-benchmark-lab/deploy:ref:refs/heads/main&lt;/code&gt;. One asterisk apart. AWS catches the obvious version of A. B looks reasonable in a quick review.&lt;/p&gt;

&lt;p&gt;Two limits before I make this sound bigger than it is. As full admin I tried to assume all four roles and got &lt;code&gt;AccessDenied&lt;/code&gt; on every one, so the only way in is a GitHub login from outside. And I did not prove the chain end to end, that needs a live repo running a workflow, and I didn't run one.&lt;/p&gt;




&lt;h2&gt;
  
  
  What ties them together
&lt;/h2&gt;

&lt;p&gt;PMapper's error handler checks &lt;em&gt;that&lt;/em&gt; a region failed, not &lt;em&gt;how&lt;/em&gt;. cloudfox has a privesc column without its own engine behind it, and elsewhere prints &lt;code&gt;Condition=Yes&lt;/code&gt; for both an always-true condition and a never-true one, because the column says a condition is present, not what it does. My answer key checked that a permission was granted, not what it actually let the principal reach. AWS checks that a &lt;code&gt;sub&lt;/code&gt; restriction is present and doesn't look like a wildcard, not what it matches.&lt;/p&gt;

&lt;p&gt;Checking that something is there is easy. What it actually allows is the part that matters, and that's what tools, reviewers, and even the cloud provider writing the guardrail keep missing.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The grading rules, the full grade file, the manual checks with exact commands, and all raw tool output are in &lt;a href="https://github.com/YayoPalacios/iam-privesc-benchmark" rel="noopener noreferrer"&gt;the repo&lt;/a&gt;. I only changed one published script to replace the account ID and access keys with placeholders.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>aws</category>
      <category>cybersecurity</category>
      <category>security</category>
      <category>opensource</category>
    </item>
  </channel>
</rss>
