Cloud Complete
Operation Vermillion Drift
A full Azure cloud kill chain against a self-built AzureGoat range — anonymous public URL to resource-group Contributor, external-attacker lens, no portal access.
- 01 Public blob recon T1526
- 02 App source disclosure T1530
- 03 Hardcoded secrets T1552.001
- 04 Forged admin JWT HS256
- 05 SSRF → file:// T1190
- 06 Storage key + SSH keys T1552.004
- 07 VM managed identity T1078.004
- 08 RG Contributor Impact
Scope
Operation Vermillion Drift was run against AzureGoat, an intentionally-vulnerable Azure range, which I provisioned myself in Terraform on my own Azure subscription and then attacked purely from the outside — no portal, no credentials, no insider knowledge beyond what a real external attacker could gather. The fictional target, Vermillion Dynamics, is a 60-person SaaS startup that lifted an employee-records app into Azure with no security review: a public blob “for backups,” a managed identity scoped wide “to make auth easy,” an automation account with broad rights “so deploys don’t break.” Those are not exotic bugs — they are the ordinary shortcuts real cloud teams take under deadline.
Everything below happened on infrastructure I own. The goal was a complete, repeatable cloud-pentest kill chain across the attack paths that actually matter in the wild: public web foothold → managed-identity token theft → storage and secret looting → subscription-level control.
Recon — from a hostname to the source code
The only starting point was a public application URL. Fingerprinting the page
identified it as an INE AzureGoat deployment, and the function-app hostname gave up the
naming convention for the storage account. One container was Blob-level public — I
couldn’t list it, but I could anonymously download the frontend package. Its
index.html named a second container hosting the React bundle, and the bundle named
the backend API host. Public exposure, walked one hop at a time, handed me the app’s
whole topology before I’d sent a single authenticated request.
The backend was a Python Azure Function. Between the downloadable deployment package and the project’s public GitHub template, I could read the function’s source and its settings file — which is where the real problem lived.
Finding — secrets in the package, then SSRF for the rest
The shipped package contained hardcoded secrets: the JWT signing key, a Cosmos DB primary key, and a storage-account key. The application signs its sessions with HS256 using that leaked key, and its auth check validates only the signature and expiry. With the key in hand I forged an admin JWT with PyJWT and reached every protected route — no registration, no password, no MFA in the path.
The app also exposed a “save content” endpoint that fetched an arbitrary URL
server-side and stored the response — a textbook SSRF with a built-in exfil
channel. The instinctive move, hitting the instance metadata endpoint at
169.254.169.254, returned nothing: Azure Functions has no VM metadata service,
and this function had no managed identity at all. That negative result is the lesson —
the metadata endpoint you target has to match the compute type. Instead I pointed the
SSRF at file:///proc/self/environ and dumped the function’s entire environment,
confirming the live secrets and leaking a read/write storage SAS as a bonus.
The stolen storage-account key then unlocked the whole account — containers that Blob-level public access had kept hidden. One of them held SSH private keys for the range’s virtual machines, alongside a config file salted with decoys: RFC1918 addresses and a live-but-unrelated public IP designed to waste an attacker’s time. The public GitHub template disambiguated which host was real, and the matching private key logged me straight in.
Impact — resource-group Contributor
On the virtual machine, the metadata service behaved as expected for a VM: with the
Metadata: true header it returned the system-assigned managed-identity token.
Replayed against Azure Resource Manager, that token enumerated the resource group and —
per its role assignments — carried Contributor. From an anonymous public URL, the
chain now had write control over every resource in the group.
The intended escalation route in this range runs through an automation account, but the subscription’s platform restrictions meant that component couldn’t be deployed at all. Rather than stop, I reconstructed an equivalent Contributor foothold through the leaked SSH keys and the over-privileged VM identity. That is the point worth sitting with: attackers route around missing pieces. A control that only blocks the “textbook” path, while leaving an equivalent one open, buys nothing.
Remediation — where the chain breaks
Each link has a cheap, boring fix, and any one of them breaks the chain:
- Get secrets out of build artifacts. Key Vault references plus a managed identity, and rotation the moment a key touches a package. This single change removes the forged JWT, the storage compromise, and the SSH pivot at the root.
- Treat every user-supplied URL as hostile. Allow-list outbound destinations and
strip
file:///gopher://schemes — SSRF stops being a credential-exfil primitive. - Scope managed identities to least privilege. A VM identity almost never needs Contributor over its resource group; scope it to the specific resources it uses.
- Close public storage and keep private keys out of blobs. Disable anonymous container access, prefer private endpoints and short-lived SAS, and never store SSH keys as objects.
- Verify tokens properly. Check audience and issuer, not just signature and expiry.
Building and running it — the honest part
The range fought back before the target did. The subscription enforced a
Microsoft-locked region policy that couldn’t be edited or exempted, a zero
Consumption-plan quota, and CPU-architecture conflicts across five candidate regions; a
serverless database creation was flaky enough to orphan billing resources. I worked the
Azure Resource Manager REST API directly (az rest) to route around CLI wrapper crashes,
and captured the whole blocker chain in a runbook that cut rebuild time from a
multi-hour investigation to a ~25-minute apply. Provisioning the lab was itself a real
exercise in reliable cloud tooling under constraint — the kind of friction that shows up
in every genuine engagement.
AFindings
| Severity | Finding | ATT&CK | Recommendation |
|---|---|---|---|
| critical | Hardcoded secrets in a publicly-downloadable deployment package | T1552.001 | Never ship secrets in build artifacts; use Key Vault references + managed identity, and rotate on exposure. |
| critical | SSRF with arbitrary-URL and file:// fetch | T1190 | Allow-list outbound destinations; strip file/gopher schemes; treat any user-supplied URL as hostile. |
| critical | Over-privileged VM managed identity (Contributor) | T1078.004 | Scope managed identities to least privilege per resource; never grant Contributor for convenience. |
| high | Broken JWT authentication (forgeable admin token) | T1552 | Verify audience/issuer, not just signature+expiry; keep signing keys out of shipped code. |
| high | Blob-level public storage container | T1530 | Disable anonymous access; use private endpoints and short-lived scoped SAS. |
| medium | SSH private keys stored in a readable blob | T1552.004 | Keep private keys out of object storage; rotate any key that touches a blob. |
BMITRE ATT&CK
- T1526 Cloud Service Discovery Discovery
- T1530 Data from Cloud Storage Collection
- T1552.001 Unsecured Credentials: Credentials In Files Credential Access
- T1190 Exploit Public-Facing Application (SSRF) Initial Access
- T1552.004 Unsecured Credentials: Private Keys Credential Access
- T1078.004 Valid Accounts: Cloud Accounts Persistence / Priv. Esc.
- T1552.005 Cloud Instance Metadata API Credential Access
What this demonstrates
I can carry a cloud attack from anonymous public exposure to subscription-level control end-to-end, reason about why each step works at the platform level, and improvise an equivalent path when the intended one is blocked — then translate the whole chain into concrete defenses.