Hunting Shadow Admins in AWS

A tour of the AWS privilege escalation techniques modelled by PMapper and awspx, where each of them falls short, and how I rebuilt Snotra's shadow administrator detection to cover everything they do - and a good deal they do not.

When conducting AWS Configuration Reviews, something I keep coming back to is the shadow administrator problem. A principal looks harmless - a deploy role here, a service account there - but through some combination of permissions it can quietly promote itself to full administrator. Attackers love these paths because they hide in plain sight, and they make excellent persistence: park a shadow admin somewhere unremarkable and you have a way back in long after the initial foothold is burned.

The two tools everyone reaches for here are NCC Group's PMapper and WithSecure's awspx. Both build a graph of your IAM principals and work out who can reach administrator. They are excellent, and I have used both for years. But they havent been updated in years and are showing their age. I have been using and developing Snotra for many years also and it is the base of all my Cloud configuration reviews, Its old shadow admin check was a naive one - it scanned individual policy documents for a fixed list of dangerous actions and flagged them, ignoring resource scoping entirely. It was noisy and it missed the interesting paths. So I rebuilt it as a proper graph engine, and used PMapper and awspx as the bar to clear. This post is a tour of what those two tools model, where each of them stops, and how Snotra now goes past both.

How the graph tools work

The core idea in both tools is the same. Treat every IAM user and role as a node. Draw an edge from A to B whenever A can obtain the privileges of B - by assuming it, by passing it to a service that runs attacker-controlled code, by resetting its credentials, and so on. Mark the nodes that are already administrator. A principal is a shadow admin if there is a path from it to an admin node.

PMapper computes this reachability directly and reports the path. awspx loads the same sort of graph into Neo4j and lets you query attack paths in Cypher. The difference is mostly in the techniques each one knows how to draw an edge for.

What PMapper models

PMapper's strength is breadth of services. It draws edges for:

  • sts:AssumeRole, honouring the target's trust policy and evaluating the path both with and without MFA. It also handles cross-account assumption.
  • iam:UpdateAssumeRolePolicy - rewrite a role's trust to trust yourself, then assume it.
  • iam:CreateAccessKey, iam:CreateLoginProfile and iam:UpdateLoginProfile against another user.
  • iam:PassRole into a service that will run your code as the role: EC2 (with an instance profile), Auto Scaling, Lambda (create a new function or edit an existing one), CloudFormation (create or update a stack, or a change set), CodeBuild and SageMaker.
  • ssm:SendCommand and ssm:StartSession against an instance that already carries a role.

On top of the paths it also reports the usual suspects: instance profiles, Lambda roles and CloudFormation roles that hold administrator, roles with unsafe SSM permissions, principals in a circular access relationship, and administrators without MFA.

What it does not model is in-place self escalation. Because every PMapper edge points from one principal to another principal, a role that can simply attach AdministratorAccess to itself, or publish a new default version of a policy it is already using, has nowhere to point. It becomes administrator without ever leaving its own identity, and the graph never draws the edge. That is a real and common path, and it is exactly the class awspx and Snotra catch.

What awspx models

awspx comes at it from the other direction. Its strength is composing primitive IAM actions rather than hard-coding combinations. It models:

  • The whole in-place toolkit: iam:CreatePolicyVersion, iam:CreatePolicy, iam:Put{User,Role,Group}Policy, iam:Attach{User,Role,Group}Policy, iam:AddUserToGroup.
  • Creating fresh principals to pivot through: iam:Create{User,Role,Group,InstanceProfile}, iam:AddRoleToInstanceProfile, ec2:AssociateIamInstanceProfile.
  • iam:CreateAccessKey, iam:CreateLoginProfile and iam:UpdateLoginProfile, with a nice touch - it knows CreateAccessKey only works if the target has fewer than two keys (or you can delete one), and CreateLoginProfile only if no profile exists yet.
  • iam:UpdateAssumeRolePolicy, sts:AssumeRole, ec2:RunInstances.

It is condition-aware and resource-aware, and it models resource-based policies. Where it stops is the rest of the service catalogue: awspx does not draw iam:PassRole edges for Lambda, CloudFormation, Glue, SageMaker, CodeBuild or SSM the way PMapper does, and it has no cross-account story. It is also a heavyweight to run - it wants Docker and a Neo4j container and a full ingest before you can ask it anything.

Where they each fall short

Put plainly:

  • PMapper misses in-place self escalation (attach or write an admin policy to yourself, bump a policy version), because its edges only ever point at another principal.
  • awspx misses the non-IAM, non-EC2 iam:PassRole services and cross-account paths, and is operationally awkward to stand up.
  • Both treat the union of the published technique lists as the finish line. Neither of them models the control-plane and trust-fabric techniques that have been written up over the last couple of years - and that is where most of the interesting modern escalation lives.

Whichever you pick, you are also running a second tool alongside whatever you already use for configuration review, and reconciling two sets of output. I wanted this living inside Snotra, as ordinary findings, in pure Python with no extra moving parts.

What Snotra does now

Snotra builds the graph in memory from a single iam:GetAccountAuthorizationDetails call - which returns every principal with its inline, attached and group policies and role trust documents in one go - plus a few regional sweeps for instance profiles, running instances and existing role-bearing resources. It evaluates permissions locally, with proper wildcard expansion, explicit Deny precedence and resource-ARN matching, so a iam:PutRolePolicy scoped to arn:aws:iam::*:role/build-* is no longer mistaken for a path to administrator. Conditioned statements are reported as conditional rather than silently treated as wide open.

The result is the union of everything PMapper and awspx model - sts:AssumeRole, iam:UpdateAssumeRolePolicy, the full in-place self-escalation toolkit, credential creation, SSM, and iam:PassRole into EC2, Lambda, CloudFormation, CodeBuild, SageMaker, Glue and Auto Scaling - reported as a single finding (iam_37) that lists each non-administrative principal and the concrete path it takes to administrator.

Going Further

The genuinely interesting part is the set of techniques that neither PMapper nor awspx draw an edge for. These are not about a single permission on a single principal; they are about manipulating the fabric that decides who is allowed to assume what. Snotra now models all of them:

  • Identity provider manipulation. A principal with iam:UpdateSAMLProvider, iam:UpdateOpenIDConnectProviderThumbprint or iam:AddClientIDToOpenIDConnectProvider over a SAML or OIDC provider can swap in an identity source it controls and federate in as any role that trusts that provider. It is the trust-fabric equivalent of iam:UpdateAssumeRolePolicy, and it is how a lot of real persistence is built. See the excellent write-ups on RogueOIDC, Silver SAML and persistence via a SAML identity provider.
  • IAM Roles Anywhere. rolesanywhere:CreateProfile together with iam:PassRole lets an attacker map an arbitrary role into a profile and assume it with a certificate from a trust anchor they control - a tidy escalation and a durable backdoor, covered well by this post.
  • EKS access entries. eks:CreateAccessEntry plus eks:AssociateAccessPolicy can associate the cluster-admin access policy to yourself and hand you Kubernetes cluster-administrator straight from IAM, no RBAC change required. Wiz and Datadog both dug into this. Snotra reports it as its own finding, since cluster-admin is a Kubernetes privilege rather than AWS account administrator - and it pairs nicely with Snotra Kubernetes.
  • AWS Identity Center. In an Identity Center management or delegated-admin account, a principal that can edit and provision a permission set, create an account assignment, or add itself to a group can grant itself an administrative permission set across the organisation - completely invisibly to classic IAM analysis. CloudQuery has a good breakdown.

On top of those, Snotra fills in the long tail that the older tools never enumerated: EC2 Instance Connect (ec2-instance-connect:SendSSHPublicKey onto a running, role-bearing instance), editing existing Glue jobs and dev endpoints, lambda:UpdateFunctionConfiguration, and iam:PassRole into ECS, AWS Batch, DataPipeline, Bedrock AgentCore, Step Functions, EMR, MWAA, App Runner, EKS node groups and CodePipeline. There is also a check for the subtle case PMapper's reliance on Deny would otherwise miss: a principal that is held back by an explicit Deny but can delete or detach the very policy imposing it (iam:DeleteRolePolicy, iam:DetachRolePolicy and friends) - shed the restriction first, escalate second.

Where the techniques came from

None of this is invented. The baseline comes from the published research that the community has been maintaining for years, and I worked through all of it:

The newer, fabric-level techniques came from the individual posts linked above, plus Elastic's look at sts:AssumeRoot.

What Snotra deliberately does not do

Honesty matters more than a feature tick-box, so to be clear about the edges of the model:

  • Cross-account path-joining is out of scope. PMapper will stitch graphs across an organisation; Snotra evaluates one account at a time. A role that trusts an external account is still reported as a starting point, but Snotra will not tell you which principal in the other account can actually walk through it.
  • Service Control Policies and permission boundaries are not evaluated. The engine reasons over identity policies only. The practical consequence is that it may over-report a path that an SCP or boundary would in fact block - it will not under-report one. For a review tool I would much rather err that way round.
  • sts:AssumeRoot is noted but not modelled, because it is fundamentally a management-account-to-member-account move and belongs with the cross-account work I have parked.

Scanning for it

All of this ships in Snotra today. The shadow admin paths are iam_37; the separable categories - instance-profile admin, service-role admin, circular access, administrators without MFA, EKS cluster-admin, Identity Center escalation and removable restrictions - are iam_41 through iam_47. Run the IAM service on its own with:

snotra --results-dir ./results/ --service iam

The engine needs iam:GetAccountAuthorizationDetails, which is granted by both the ReadOnlyAccess and SecurityAudit managed policies. Note that ViewOnlyAccess deliberately withholds it, so run with one of the other two for full coverage.

About the author

Shaun is a Penetration Tester and the author of Snotra, an open-source cloud and Kubernetes security auditing tool.

Shaun

About the author

Shaun is a Penetration Tester and Bitcoiner, with over a decade in the Security Industry, specialising in Cloud and Infrastructure Security and regularly completing assessments for all manner of companies from global corporations to small charities and non profits.