Active Directory & Identity

Storm-3168 Tried to Delete 100 Azure Storage Accounts in Seven Minutes. Two Old Controls Held.

Dark cyberpunk illustration of nine tall doorways receding across a glowing cyan grid at night, eight of them dark and one at the right edge burning amber, casting a long orange reflection across the floor with embers drifting above it.

Two Azure controls held while everything around them was deleted. One was a resource lock. The other was storage-account-level deletion protection. Neither of them knows what an AI agent is, and both predate the phrase "agentic ransomware" by years. They are the reason a handful of storage accounts in one compromised tenant still exist.

Microsoft Security Research published the Azure half of that intrusion on September 25. It tracks the operator as Storm-3168, the same actor Sysdig named JADEPUFFER in July and assessed as the first documented ransomware operation driven end to end by a large language model. The write-up is the first detailed view of what this operator does once it holds cloud credentials. It reads for most of a day, deletes for seven minutes, then spends half an hour collecting keys.

Most coverage will stop at the word "agentic." Read the two reports together and a more useful claim falls out: this operator never defeated a control. It used credentials somebody had already published, stayed inside role assignments it already held, and got stopped only where a lock, a deletion-protection setting, or a wrong API version happened to sit in the way.

What the activity log actually shows

Microsoft observed two compromised service principals belonging to the same tenant. In early June 2026 the first one enumerated virtual machines, subscriptions, resource groups and resources for 15 hours and 30 minutes, logging more than 300 successful read operations. That is about 19 reads an hour, roughly one every three minutes. No volume threshold tuned for humans will ever fire on that.

Ninety minutes after the first identity started reading, the second enumerated virtual machines and resource groups across two subscriptions in five seconds. Both used Storm-3168 infrastructure, the same network fingerprint, and the user agent python-requests/2.34.2. Sixteen hours later the second identity listed Azure App Service configuration stores and looked, without success, for Azure OpenSearch resources. Seventy seconds later it called ListKey against a storage account that did not exist. Less than a second after that failure, the deletions began.

The destructive sequence ran about seven minutes and included more than 100 storage-account deletion attempts. Most succeeded. A Key Vault, a Function App and an App Service plan went with them. Azure SQL database deletions ran in parallel and every one failed, because the caller used an API version the Azure SQL resource type does not support. Attempts to delete Azure Site Recovery locks and an Azure Backup protection lock failed as well. Across the window the identity attempted more than 150 destructive or credential-collection operations in 35 minutes, over five separate tokens, two of them live during the same 70-second period.

Then, roughly half an hour after the last deletion, the same identity issued more than 30 successful ListKeys calls against storage accounts, including accounts tied to Azure Site Recovery. Deleted resources come back from backup. A storage account key is a bearer credential that keeps working until somebody rotates it, so those 30 calls are the loss that outlives the incident. Full detail is in the Microsoft Security Research report.

Put the two rates side by side. Reconnaissance ran at about 19 operations an hour, destruction at about 860. One service principal changed its own throughput by a factor of roughly 45, and about seventeen and a half hours passed between its first read and its first delete. The window existed. Nobody was watching the object that held it.

Who this reaches, and who can close the tab

Close the tab if your organization has no Azure subscription and no app registrations in Entra ID. Every step here needed an Azure Resource Manager role assignment, and with no subscription there is nothing to assign.

This one is yours if you have an Azure subscription and at least one service principal, which in practice means any organization with a deployment pipeline, a Terraform state, a backup job, or a monitoring integration. Company size is not a filter. A two-person shop with a GitHub Actions workflow that publishes to Azure owns exactly the object that was abused here: a non-human identity holding a long-lived client secret and a role assignment nobody has reviewed since the pipeline first worked.

One limit, stated plainly. Microsoft could not confirm how the service principal was compromised, and observed no ransom note and no successful exfiltration. Calling this a completed ransomware case in Azure overstates the published evidence. Reading it as a documented template for automated cloud resource destruction is fair, and that is how I would brief it.

The myth: an AI-driven attack needs an AI-shaped defense

Microsoft's remediation list ends with two recommendations to deploy AI: Project Perception for agent-driven investigation, and Defender for AI Security for attack-path discovery. Both are defensible products. Neither would have changed the outcome here, because no step in this intrusion depended on AI.

Start with where the credentials came from. The compromised service principal's client ID, client secret and tenant ID had been posted in plaintext in a public GitHub issue by an employee of the victim organization. The issue was later edited to remove the secret. The secret stayed readable in the issue's public edit history. Microsoft says it could not confirm whether that specific secret drove this activity, and the caveat matters, but the lesson stands on its own: editing a secret out of a public page does not revoke it, and the edit history is a page too.

Then look at what the identity was permitted to do. Every operation followed a role assignment it already held. A group-granted Storage Account Contributor role authorized the storage deletions. Direct Contributor covered the Key Vault, Function App and App Service plan. Direct SQL DB Contributor covered the database attempts. No privilege escalation appears in the timeline, because none was required.

Finally, look at what stopped anything. Resource locks. Storage-account deletion protection. An unsupported API version. Two configuration settings and one accident. Automation changed how long the defender had. It did not change which controls mattered.

Langflow reached the busiest five percent of the KEV catalog in fifteen months

The entry point in Sysdig's half of the story generalizes, so it deserves its own numbers. JADEPUFFER got in through an internet-facing Langflow instance using CVE-2025-3248, a missing-authentication flaw in the /api/v1/validate/code endpoint that hands an unauthenticated caller arbitrary Python. Microsoft separately observed Storm-3168 infrastructure probing that endpoint across several customers' Azure App Service deployments. The reports share a hard indicator too: 45.131.66[.]106 is the beacon destination in the crontab Sysdig recovered and the source of malicious ARM requests in Microsoft's IOC table.

CVE-2025-3248 has been in CISA's Known Exploited Vulnerabilities catalog since May 5, 2025, flagged for known ransomware use, with a federal remediation deadline of May 26, 2025. It has company. Count the catalog yourself:

curl -s https://www.cisa.gov/sites/default/files/feeds/known_exploited_vulnerabilities.json \
  -o kev.json

python3 - <<'PY'
import collections, json

kev = json.load(open("kev.json"))
rows = kev["vulnerabilities"]
per_product = collections.Counter(
    (v["vendorProject"].strip(), v["product"].strip()) for v in rows
)

langflow = {k: n for k, n in per_product.items() if "Langflow" in k[1]}
total = sum(langflow.values())
busier = sum(1 for n in per_product.values() if n >= total)

print(f"catalog {kev['catalogVersion']}: {len(rows)} entries, "
      f"{len(per_product)} distinct products")
print(f"Langflow entries: {total} -> {langflow}")
print(f"products with {total}+ entries: {busier} of {len(per_product)}")

for v in sorted((v for v in rows if "Langflow" in v["product"]),
                key=lambda v: v["dateAdded"]):
    print(v["dateAdded"], v["cveID"],
          "ransomware:", v["knownRansomwareCampaignUse"])
PY

The September 25 release holds 1,726 entries across 719 distinct vendor and product pairs. Langflow accounts for six of them, five filed under Langflow and one under IBM after the acquisition, spanning May 2025 to August 2026. Only 41 of those 719 products carry six or more entries, and the rest of that list reads like a firewall inventory: Windows, Exchange Server, PAN-OS, FortiOS, vCenter Server, Chromium V8. Those products have been exposed to the internet for a decade or more. Langflow got there in fifteen months.

Widen the count to the rest of the AI development stack and n8n, Ray and MLflow each add an entry, for nine in total, all added after May 2025. These frameworks hold provider API keys and cloud credentials in their environment by design, and developers stand them up fast, without the network controls a production service would get. An LLM tooling host on your perimeter is a credential store on your perimeter.

Find the identities that can delete your environment

Nothing below needs a license you do not already have. Inventory first: every workload identity in the tenant, the roles it holds, the age of its secret.

# Every role assignment held by a workload identity, across all subscriptions.
az role assignment list --all --include-inherited \
  --query "[?principalType=='ServicePrincipal'].{sp:principalName, role:roleDefinitionName, scope:scope}" \
  -o tsv | sort -u

# Narrow to the four roles that authorised the Storm-3168 operations.
az role assignment list --all --include-inherited \
  --query "[?principalType=='ServicePrincipal' && (roleDefinitionName=='Owner' \
    || roleDefinitionName=='Contributor' \
    || roleDefinitionName=='Storage Account Contributor' \
    || roleDefinitionName=='SQL DB Contributor')] \
    .{sp:principalName, role:roleDefinitionName, scope:scope}" \
  -o table

# App registrations carrying a client secret, and when that secret was issued.
az ad app list --all \
  --query "[?passwordCredentials[0]].{app:displayName, appId:appId, \
    issued:passwordCredentials[0].startDateTime, \
    expires:passwordCredentials[0].endDateTime}" \
  -o table

Read the output for two things. Any service principal holding Owner or Contributor at subscription scope has a delete-everything grant, and a pipeline that publishes a web app does not need one. Any client secret older than your rotation interval is a standing key, and for most small teams that interval is "never." Microsoft's Workload ID documentation is blunt about the fix: move these identities onto federated credentials so there is no secret to leak, and read the Azure RBAC guidance before you re-grant anything.

Then apply the control that actually worked. Storage accounts with no lock are the ones that disappeared:

# List every storage account that has no management lock on it.
az storage account list --query "[].{n:name, g:resourceGroup}" -o tsv |
while read -r name rg; do
  locks=$(az lock list --resource-group "$rg" --resource-name "$name" \
            --resource-type Microsoft.Storage/storageAccounts \
            --query "length(@)" -o tsv)
  [ "$locks" = "0" ] && echo "no lock: $rg/$name"
done

# Add the lock that blocked deletion during this intrusion.
az lock create --name protect-account --lock-type CanNotDelete \
  --resource-group "$rg" --resource-name "$name" \
  --resource-type Microsoft.Storage/storageAccounts

# Soft delete for blobs and containers, so a wipe inside the account is recoverable.
az storage account blob-service-properties update \
  --account-name "$name" --resource-group "$rg" \
  --enable-delete-retention true --delete-retention-days 30 \
  --enable-container-delete-retention true --container-delete-retention-days 30

A CanNotDelete lock holds against a caller with Owner rights, which is what made it useful here. Microsoft documents the pattern in Apply an Azure Resource Manager lock to a storage account. Treat Recovery Services vaults and Backup protection locks the same way; they were targeted before the data was.

The detection is a rate change, not an indicator

There is nothing durable to match on. The user agent was python-requests, and the three IP addresses in Microsoft's table are already burned. The shape holds: one workload identity reading quietly for hours, then writing at a rate it has never reached before. Azure Activity logs already carry that baseline.

// Workload identities whose ARM operation rate spikes far above their own normal.
// Storm-3168 went from ~19 reads an hour to ~860 write operations an hour.
AzureActivity
| where TimeGenerated > ago(14d)
| where Caller matches regex @"^[0-9a-f]{8}-[0-9a-f]{4}-"   // object IDs, not user UPNs
| summarize ops = count() by Caller, bin(TimeGenerated, 1h)
| summarize typical = percentile(ops, 50), peak = max(ops), active_hours = count()
    by Caller
| extend burst = round(todouble(peak) / todouble(max_of(typical, 1)), 1)
| where burst >= 20 and peak >= 50
| sort by burst desc

// Storage keys are bearer credentials. A listKeys burst is a credential theft
// even when nothing was deleted, and it is the part backups do not fix.
AzureActivity
| where TimeGenerated > ago(30d)
| where OperationNameValue =~ "MICROSOFT.STORAGE/STORAGEACCOUNTS/LISTKEYS/ACTION"
| where ActivityStatusValue == "Success"
| summarize accounts = dcount(_ResourceId), calls = count()
    by Caller, CallerIpAddress, bin(TimeGenerated, 30m)
| where accounts >= 5
| sort by accounts desc

Tune the second query down, not up. Thirty accounts in half an hour is the published case; five is already a pipeline behaving unlike itself. When it fires, rotate both keys on every account named with az storage account keys renew before anything else.

Lock the storage accounts you cannot rebuild

Pick the storage accounts, vaults and databases you could not reconstruct from source this week, and put a CanNotDelete lock on each one today. One command per resource, no cost, and it is the only control in this incident with a documented record of stopping a caller who already held administrative rights. Then list your service principals, delete the role assignments that made sense in 2024, and search your own public repositories and issue histories for clientSecret, AZURE_CLIENT_SECRET and tenantId. Anything you find there is already spent, whether or not the page still shows it.

Workload identities are the part of an identity program nobody owns, because they belong to whoever wrote the pipeline. That is the review we run for clients with an Azure tenant and no full-time security team: enumerate the non-human identities, cut each role assignment down to what the code actually calls, and replace long-lived secrets with federated credentials.

Need help hardening your identity infrastructure?

We assess Active Directory and Entra ID environments, including the workload identities nobody owns, for the misconfigurations attackers actually exploit. Book a session to discuss your environment.