· 7 min read
Key Vault does what it says: it stores secrets, encrypts them at rest, and controls who reads them. The control plane is the weak point, not the vault. If an ordinary user account holds Key Vault Secrets User, then a phished or leaked password for that account is a password for every secret in the vault.
We ran two separate campaigns against the same vault, ext-contractors in the megabigtech.com Pwned Labs tenant. The first extracted the secrets. The second re-confirmed them and then used one, which is where the campaign stops being a configuration review.
Campaign one: the extraction
| Severity | Count |
|---|---|
| Critical | 2 (both exploited) |
| High | 6 (4 exploited) |
| Medium | 8 (confirmed) |
| Low | 0 |
| Total | 16 |
The report table lists 16 rows. The executive summary of the same report counts 8 distinct vulnerabilities, because each finding was recorded twice by parallel sub-agents. Both numbers are in the document and we are quoting both.
Access started with the same pattern as the other Azure runs: ROPC with a leaked password, one token per audience, no MFA challenge on any of them.
# four token requests, four scopes, all HTTP 200
scope=https://management.azure.com/.default -> Bearer, expires_in 4974
scope=https://graph.microsoft.com/.default -> Bearer, expires_in 3803
scope=https://vault.azure.net/.default -> Bearer, expires_in 3909
scope=https://storage.azure.com/.default -> Bearer, expires_in 5163Then the vault, through its own REST API:
curl -s -H "Authorization: Bearer $VAULT_TOKEN" \
"https://ext-contractors.vault.azure.net/secrets?api-version=7.4"
-> 200 OK, three secrets: josh-harvey, alissa-suarez, ryan-garcia
curl -s -H "Authorization: Bearer $VAULT_TOKEN" \
"https://ext-contractors.vault.azure.net/secrets/josh-harvey?api-version=7.4"
-> 200 OK, {"value":"<redacted>", "attributes":{"created":1698069293}}Three plaintext passwords, one per secret, each created on the same day in 2023, each with a single version and no expiry set. The secret names match tenant user names, which is the detail that turns a secrets dump into an attack plan: the vault is telling you which account each password belongs to.
The rest of the campaign mapped the blast radius around that access:
- 17 custom RBAC roles, several of which grant
Microsoft.Compute/virtualMachines/runCommand/action. That single action is remote code execution on a VM through the management plane. Another grantsMicrosoft.App/containerApps/listSecrets/action. - An application credential expiring in 2124, a 98 year secret, alongside others with more sensible dates.
- Six Global Administrators, enumerated by name from the directory role members endpoint.
- Dynamic groups with exploitable membership rules, including one whose rule is
(user.userPrincipalName -contains "admin"). Anyone able to influence a UPN can join a group described as restricted. - Full directory enumeration through the Graph token: 90 or more users, 15 or more groups, 50 or more applications, 30 directory roles.
Campaign two: using one of the passwords
| Severity | Count |
|---|---|
| Critical | 3 (all exploited) |
| High | 2 (1 exploited) |
| Medium | 2 (confirmed) |
| Low | 0 |
| Total | 7 |
The second campaign confirmed the three secrets, then took the next step. It authenticated as the contractor whose password came out of the vault:
curl -s -X POST "https://login.microsoftonline.com/<tenant-id>/oauth2/v2.0/token" \
-d "client_id=04b07795-8ddb-461a-bbee-02f9e1bf7b46" -d "grant_type=password" \
-d "username=ext.josh.harvey@megabigtech.com" \
--data-urlencode "password=<the key vault secret>" \
-d "scope=https://management.azure.com/.default"
-> token minted; management and storage scopes both succeed
-> subscription visible, 20+ resources enumeratedThat identity held a custom role called Customer Database Access, whose data action is Microsoft.Storage/storageAccounts/tableServices/tables/entities/read. The agent used it:
curl -s -H "Authorization: Bearer $TOKEN" -H "x-ms-version: 2020-12-06" \
"https://custdatabase.table.core.windows.net/customers()?$format=json"
-> 11 rows returned: 10 customer records plus the lab flag
-> each record carries customer_name, card_number, cvv, card_expiryTen customer records with full payment card numbers, CVVs and expiry dates, reached from a password that was sitting in a secrets vault. The report classifies it as a PCI DSS breach and records the chain: password grant with no MFA, vault read, identity pivot, table storage read.
Three places the agent was precise instead of loud
The vault firewall does work, from the wrong direction. A direct curl to the vault returned HTTP 403 ForbiddenByFirewall. The same operation through the Azure CLI returned 200 and the secret values. The vault network ACL is defaultAction=Deny with bypass=AzureServices, and the tester IP was not on the allowlist. The agent recorded that difference as a medium severity finding about trusted service bypass rather than pretending the firewall was absent.
Only one of the three passwords led anywhere. It tried the other two contractor identities and got AADSTS50034, the account does not exist in this directory, for both. The report says so instead of implying three pivots.
The VM runCommand roles were not used. Roles granting remote code execution on virtual machines are recorded as high severity and confirmed. No command was run on any VM.
Remediation
- Do not store user passwords in Key Vault. It is a secret store for application secrets, and a password sitting there is a password with an API in front of it.
- Scope
Key Vault Secrets Userto workload identities, not to people, and review who currently holds it. - Set expiry on every secret. All three here had none, so nothing would ever have forced a rotation.
- Block ROPC. Every step of both campaigns began with a password grant that never met a second factor.
- Review the custom roles:
runCommandis remote code execution, andlistSecretsis a secret read, whatever the role is called. - Get cardholder data out of Table Storage, or encrypt and tokenise it, and never retain CVVs. Fix the dynamic group rules that key on a substring of a user principal name.
What this proves about autonomous pentesting
Campaign one is what a good configuration review produces: the secrets are readable, here they are. Campaign two answers the question a reviewer usually cannot, which is what those secrets actually unlock. The pivot took one token request and one table query, and it converted an access control finding into a payment card exposure. That distance is the whole argument for testing rather than auditing.
Darkmoon is GPL-3.0 and self hosted: repository, documentation.