Jun 28 2024 | Daniel Heinsen

An AWS Administrator Identity Crisis: Part 1

Share

BLUF: Every attack path needs a destination. This is a formalized way of describing destinations in AWS. In cloud providers where we only have data plane access, we divert our focus from an arbitrary definition of administrator to resources we care about.

How many administrators are in your AWS environment? Does it even matter?

This is seemingly a simple and frequent question, but tends to be very difficult to answer particularly in large AWS environments. To be able to answer this question, we need to first understand “What is an administrator?”. Off the cuff, you may say that an administrator is any principal with the AdministratorAccess policy attached, or even more specifically, a principal that has:

{
“Effect”: “Allow”,
“Action”: “*”,
“Resource”: “*”
}

in one of their policies. For the AWS uninitiated, this statement says that any action is permitted (out of 16982 defined AWS actions) on any resource in the account. This is a logical place to start but ultimately a far too simplistic and limiting definition. If we were to modify the policy as such, it would not fit our current definition of administrator:

[
{
“Effect”: “Allow”,
“Action”: “*”,
“Resource”: “*”
},
{
“Effect”: “Deny”,
“Action”:“elastictranscoder:readpipeline”,
“Resource”: "*"
}
]

The only modification here is that we can no longer call one of the 16982 actions, leaving only 16981 at our disposal. I think most would intuit that we are indeed still considered an administrator, even though it doesn’t fit our first strict definition. In this case, how much privilege can we whittle away before we are no longer considered an administrator? In this context, I think it is helpful to separate principals into two groups:

  1. Principals that are currently an “administrator”. We will call these direct permissions.
  2. Principals that can modify some aspect of the environment to become an administrator. We will call these indirect permissions.

The key difference between the two is that the latter requires some sort of write operation to obtain the necessary privileges. The rest of this post is going to focus on the direct permissions, and I will discuss graph modification in a later post. In broad strokes, we can define an administrator as a principal that has no limitations as to what they can do within their account.

One possible route is to flag specific actions as high value “administrator” actions. This can include actions such as iam:updaterolepolicy, iam:passrole, iam:createuserkeys, etc. I don’t like this approach because of the flexible nature of AWS policy definitions. For example, I can specify that a user can update role policies for all principals but themselves. That’s kind of like an admin, but doesn’t let the principal auto elevate. It certainly is not as powerful as it could be, but is still very powerful. In addition, this requires the definition to have a more concrete awareness of AWS and the actions that it provides, so the definition won’t scale well to other cloud platforms. It may be helpful to identify “administrator actions” for auditing, but I want concrete definitions on which to build a foundation. Lastly, it mixes in indirect and direct permissions, which should be treated differently.

In that vein, it may be sufficient to say “An administrator can perform all applicable actions on all resources in the account.” This is different from the first AdministratorAccess policy which translates to “Can perform all actions on all resources that can possibly exist ‘’. In plain English, this is a meaningful distinction because most (if not all) AWS accounts don’t utilize every service AWS has to offer. For example, most AWS accounts probably aren’t using the elastictranscoder service.

If the ability to perform all elastictranscoder actions on all elastictranscoder resources (even if they don’t exist) is a requirement for Administrator consideration, then that definition is far too constrained.

The consequence of this is omitting principals that should be considered an administrator and overlooking the scrutiny that should come with them.

Let’s take a step back for a minute and consider the following statement:

{
“Effect”: “Allow”,
“Action”: “ec2:stopinstances”,
“Resource”: “arn:aws:ec2:us-west-2:123456789012:instance/i-0abcd1234efgh5678”
}

This statement is unique because it allows only one action on a single resource. It creates an action-to-resource pairing of ec2:stopinstances to arn:aws:ec2:us-west-2:123456789012:instance/i-0abcd1234efgh5678. This action-to-resource mapping is the atomic unit of our set processing and represents a successful API call that a caller can make. To extrapolate this out, consider the modified statement:

{
“Effect”: “Allow”,
“Action”: “ec2:*”,
“Resource”: “arn:aws:ec2:us-west-2:123456789012:instance/i-0abcd1234efgh5678”
}

This statement creates a set of action-to-resource entries. Because not every ec2 action can act on an ec2 instance, we may only include the actions that can act on the resource type. Our resulting table looks like this, and we’ll call it a permission matrix, which is simply a Cartesian product of the actions and the resources.

Conversely, if we put a wildcard in the resource instead of the action, we populate the table with all ec2 instances that exist in the account. Once again, ec2:stopinstances can only act on ec2:instance resources, so we may omit all other types of resources.

{
“Effect”: “Allow”,
“Action”: “ec2:stopinstances”,
“Resource”: “*”
}

Results in the following permission matrix:

And when we use wildcards in both, our matrix gets really big, but the idea stays the same. Using these action-to-resource sets, we can define our administrator as follows:

for resource in (allResourcesInTheAccount):
actions = getAllActions(resource) // Getting all the actions that can act on that resource
addEntryToPermissionMatrix(action, resource)

In this pseudocode, we enumerate every resource in the account, determine which actions can be performed on that resource, and add that action/resource pair to a permission matrix which we will call the “Ridiculous Administrator Matrix” (RAM). If we reconsider the AdministratorAccess policy, the permission matrix is defined as this:

AdministratorAccess = CartesianProduct(Every Possible ARN, Every Possible Action)

At this point, we can say that the RAM is a subset of the permission matrix derived from the AdministratorAccess policy. It becomes clear that if we want to identify administrators, we should define it as any principal that has a resulting set of policy (RSOP) permission matrix that is a superset of the RAM. The RSOP is calculated as follows and is an implementation of the policy evaluation logic provided by AWS:

# These statements represent every statement that can affect the principal
statements = getAllStatementsAttachedToPrincipal(principal, context)

for each statement in statements:
# Gather all the statements that provide permissions
allowActions = getAllowPermissionMatrix()

# Gather all the statements that prohibit
denyActions = getDenyPermissionMatrix()

conditonalAllowActions = getConditionalAllowPermissionMatrix()
conditionalDenyActions = getConditionalDenyPermissionMatrix()

# Resolve every conditional statement based on things we can deduce,
# and use a pre-populated context for things we cannot deduce
for conditionalDeny in conditionalDenyActions:
denyActions += resolveCondition(conditionalDeny, principal, context)
for conditionalAllow in conditionalAllowActions:
allowActions += resolveCondition(conditionalAllow, principal, context)

# Ensure that session policies and Service Control Policies are accounted for
processSessionPolicy(principal, allowStatements, denyStatement)
processSCPs(principal, allowStatements, denyStatements)

return relativeComplement(allowActions, denyActions)
The relative complement is the area in green

This gives us the following pseudo definition of an administrator:

IsAdministrator(principal, context) = (RAM) ⊆ RSOP(principal, context)
Visual set logic of an Administrator

Whenever we discuss RSOP going forward, it is understood that this is the resultant set of policy and that all SCPs, conditions, permission boundaries, and session policies have considered when producing the permission matrix.

It stands to reason that if a role is an administrator, then a principal that can assume that role is also an administrator. We will identify sts:AssumeRole as a type of “IdentityTransform”. We can introduce a new term, “Transitive RSOP” to include all permission matrices of all roles that our role can assume. For example, If:

Then, we can identify TransitiveRSOP as follows:

TransitiveRSOP(A, context) = RSOP(a, context) ∪ RSOP(b, context) ∪ RSOP(C, context)

Which means, the TransitiveRSOP is the collection of all the things each principal can perform in a given context. Therefore, we can define our administrator as follows:

IsAdministrator(principal, context) = (RAM) ⊆ TransitiveRSOP(principal, context)

In this definition, it’s possible that the RSOP of neither Role A, B, or C contain the RAM subset individually, but in sum, they do. This reflects the true nature of Role A being an “administrator” or whatever set of permissions we care about.

At first glance, this comes off as a needlessly complicated set of rules for defining an administrator. To me, this seems like an exercise in being overly academic for something that is very intuitive to most. I personally hate this definition of an Administrator because it’s not even remotely practical, the administrator definition is simply too strict.

After all this pedantic theorizing about permissions, one must ask themselves

Does defining an administrator even matter?

What are we doing here? For example, what does it matter if the Administrator can call TagResource on our dynamodb table if we don’t use ABAC?

Let’s forget about administrators. What if, instead, we identify the resources we care about (and yes, principals are resources) and explicitly state why we care about them?

Here’s an example.

Let’s say we have a domain controller running as an EC2 instance and it has the ARN of:

arn:aws:ec2:us-west-2:1234567891012:instance/i-domaincontroller

Because it is a high value asset and considered “Tier 0/Super Duper Important”, we care who has access to it. According to AWS documentation, the following actions are allowed to be performed on an EC2 instance:

Some of these are potentially benign, some are not. Let’s say we want to identify all principals that can perform all of these actions on the domain controller. This is easy using our formula above, we simply replace the definition of “Administrator” to something we actually care about, like “who can mess with the domain controller.” We will call this our Permission Matrix 0, and define it as:

PM0 = CartesianProduct(All Actions above, arn:aws:ec2:us-west-2:1234567891012:instance/i-domaincontroller)

We can now identify those principals, same as above:

isPrincipalWeActuallyCareAbout(prinipal) = PM0 ⊆ TransitiveRSOP(principal) 

Applying this formula to every principal in an AWS environment provides us a list of all the principals, including identity transformations, that have all of the permissions above which can compromise the integrity of our domain controller.

Of course, this is an extremely narrow definition because the definition requires that a principal must have all of these actions, when realistically, only a handful may suffice to compromise the domain controller. This is an easy fix, we can define each action, or maybe a handful, as their own permission matrices. For example, we can define ec2:stopinstances and ec2:detachvolume as their own permission matrix. This is to account for a specific attack path of stopping an instance and detaching the volume so it can be exfiltrated out of the account.

permissionMatrix = CartesianProduct([ec2:stopinstances, ec2:detachvolume], "arn:aws:ec2:us-west-2:1234567891012:instance/i-domaincontroller")
def isPrincipalWeActionCareAbout(principal):
if permissionMatrix ⊆ TransitiveRSOP(principal):
return true
return false

We now have a concrete and testable definition, and we can produce a very real answer to “Who can compromise this resource”, based on our definition of compromise. Resources can be anything in the AWS account, including principals. So this definition can work just as well on a privileged principal that one would deem an “Administrator”. And there’s no limitation on how many sets we can define. Unlike traditional attack path management where identity or infrastructure is the destination, our permission sets are the destination.

If it’s not already apparent, all of this is about building attack paths in AWS. Up until now, it has been based on intuition, known knowns, and approximations, like the AdministratorAccess policy. Every path needs a destination, and all of this effort is to identify the resources defenders care about so we can identify the paths to them, and ensure those paths are protected and more importantly, intended. My goal with this rant is to clarify how we discuss IAM attack paths and introduce language with which we can be precise when discussing IAM attack paths.

What do we do about this? How does this help? I am introducing these concepts to make sense of the implementation, which comes in the form of a tool that I will showcase at Blackhat Arsenal in August. In the next post, we’ll talk about identifying indirect privileges, so that we can expand our definition of “who can perform action X on resource Y?”.


An AWS Administrator Identity Crisis: Part 1 was originally published in Posts By SpecterOps Team Members on Medium, where people are continuing the conversation by highlighting and responding to this story.