LIVE PANEL

How AI agents close the loop on detections → Panther, SACR & HealthEquity. Register Now →

close

How AI agents close the loop on detections → Panther, SACR & HealthEquity. Register Now →

close

BLOG

Detecting and Hunting for Cloud Ransomware Part 3: Azure Storage

Alessandra

Rizzo

Mar 10, 2026

Introduction

This is the third in a series of reports covering ransomware detection across major cloud infrastructures. Following our examination of AWS S3 in Part 1 and Google Cloud Storage in Part 2, this report focuses on Azure Storage attack vectors.

Azure Storage has been a widely documented target for threat actors, as demonstrated by financially motivated threat actors like Storm-0501 who have evolved from traditional on-premises endpoint ransomware to cloud-native techniques. Unlike traditional ransomware that deploys malware, cloud-based ransomware leverages native Azure capabilities to rapidly exfiltrate and encrypt data, destroy backups, and render data inaccessible.

This report examines the primary attack vectors based on in-the-wild attacks reported by Microsoft here and here. We will provide detection coverage using Panther's Python detection engine and log analysis, with the corresponding detection logic. The detections referenced in this report are available in the Panther Analysis repository.

Log Source Requirements

Before deploying these detections, ensure proper Azure logging configuration:

Log Source

Log Type (Panther)

Captures

Azure Monitor Activity Logs

Azure.MonitorActivity

Control plane operations: resource management, deletions, role assignments

Azure Storage Account Logs

Azure.MonitorActivity

Data plane operations: PutBlob, DeleteBlob, GetBlobMetadata, CPK errors

Azure Key Vault Logs

Azure.MonitorActivity

Key management: KeyGet, KeyPurge, VaultDelete, secret access

Azure Audit Logs (Entra ID)

Azure.Audit

Identity operations: sign-ins, role assignments, MFA changes, federation

Enable diagnostic settings for Storage Account Logs and Key Vault Logs, sending to Log Analytics, Event Hubs, or storage accounts. We recommend that all log types should be enabled for critical resources.

Attack Prerequisites

For a successful ransomware attack, an attacker must either find resources with missing security controls or disable these controls themselves.

The table below consolidates preventive controls with their corresponding detection rules.

Control

Purpose

Objective

Panther Detection Rule

Resource Locks

CanNotDelete locks prevent deletion; ReadOnly locks prevent modification. Overrides all user permissions including Owner.

Attacker deletes storage accounts, backups, and key vaults

azure_resource_lock_deleted

Immutability Policies

Time-based retention or legal hold prevents blob modification/deletion. Once locked, cannot be removed or shortened.

Attacker modifies or deletes protected blobs

azure_storage_immutability_policy_deleted

Blob Soft Delete

Retains deleted blobs for configurable period (1-365 days).

Deleted data cannot be recovered

azure_storage_blob_soft_delete_disabled

Blob Versioning

Automatically retains previous blob versions.

Overwrites destroy data permanently

azure_storage_versioning_disabled

Recovery Services Vault Soft Delete

Default 14-day retention for deleted backup data.

Backup destruction is immediate and irreversible

azure_recovery_services_container_deleted

Key Vault Purge Protection

Enforces 7-90 day retention for deleted vaults and keys.

Encryption keys destroyed immediately

azure_keyvault_purge_protection_disabled

Diagnostic Settings

Captures activity logs for detection.

Attack activity goes undetected

azure_diagnostic_settings_deleted

Resource Lock Deletion

Storm-0501 systematically removes resource locks before destroying storage accounts and backups. Resource locks override user permissions, so their deletion is a critical pre-ransomware indicator.

{
  "time": "2025-01-27T14:23:00Z",
  "resourceId": "/subscriptions/.../resourceGroups/critical-data-rg/providers/Microsoft.Storage/storageAccounts/criticaldata001/providers/Microsoft.Authorization/locks/DoNotDelete",
  "operationName": "MICROSOFT.AUTHORIZATION/LOCKS/DELETE",
  "resultType": "Success",
  "callerIpAddress": "203.0.113.42"
}
{
  "time": "2025-01-27T14:23:00Z",
  "resourceId": "/subscriptions/.../resourceGroups/critical-data-rg/providers/Microsoft.Storage/storageAccounts/criticaldata001/providers/Microsoft.Authorization/locks/DoNotDelete",
  "operationName": "MICROSOFT.AUTHORIZATION/LOCKS/DELETE",
  "resultType": "Success",
  "callerIpAddress": "203.0.113.42"
}
{
  "time": "2025-01-27T14:23:00Z",
  "resourceId": "/subscriptions/.../resourceGroups/critical-data-rg/providers/Microsoft.Storage/storageAccounts/criticaldata001/providers/Microsoft.Authorization/locks/DoNotDelete",
  "operationName": "MICROSOFT.AUTHORIZATION/LOCKS/DELETE",
  "resultType": "Success",
  "callerIpAddress": "203.0.113.42"
}
{
  "time": "2025-01-27T14:23:00Z",
  "resourceId": "/subscriptions/.../resourceGroups/critical-data-rg/providers/Microsoft.Storage/storageAccounts/criticaldata001/providers/Microsoft.Authorization/locks/DoNotDelete",
  "operationName": "MICROSOFT.AUTHORIZATION/LOCKS/DELETE",
  "resultType": "Success",
  "callerIpAddress": "203.0.113.42"
}

We can therefore create a rule to monitor the specific operation.

LOCK_DELETE = "MICROSOFT.AUTHORIZATION/LOCKS/DELETE"

def rule(event):
    return (
        event.get("operationName", "").upper() == LOCK_DELETE
        and azure_activity_success(event)
    )

def severity(event):
    return "HIGH"

def title(event):
    resource_id = event.get("resourceId", "")
    lock_name = extract_resource_name_from_id(resource_id, "locks", default="<UNKNOWN>")
    return f"Azure resource lock [{lock_name}] deleted"
LOCK_DELETE = "MICROSOFT.AUTHORIZATION/LOCKS/DELETE"

def rule(event):
    return (
        event.get("operationName", "").upper() == LOCK_DELETE
        and azure_activity_success(event)
    )

def severity(event):
    return "HIGH"

def title(event):
    resource_id = event.get("resourceId", "")
    lock_name = extract_resource_name_from_id(resource_id, "locks", default="<UNKNOWN>")
    return f"Azure resource lock [{lock_name}] deleted"
LOCK_DELETE = "MICROSOFT.AUTHORIZATION/LOCKS/DELETE"

def rule(event):
    return (
        event.get("operationName", "").upper() == LOCK_DELETE
        and azure_activity_success(event)
    )

def severity(event):
    return "HIGH"

def title(event):
    resource_id = event.get("resourceId", "")
    lock_name = extract_resource_name_from_id(resource_id, "locks", default="<UNKNOWN>")
    return f"Azure resource lock [{lock_name}] deleted"
LOCK_DELETE = "MICROSOFT.AUTHORIZATION/LOCKS/DELETE"

def rule(event):
    return (
        event.get("operationName", "").upper() == LOCK_DELETE
        and azure_activity_success(event)
    )

def severity(event):
    return "HIGH"

def title(event):
    resource_id = event.get("resourceId", "")
    lock_name = extract_resource_name_from_id(resource_id, "locks", default="<UNKNOWN>")
    return f"Azure resource lock [{lock_name}] deleted"

Immutability Policy Deletion

Immutability policies implement Write-Once-Read-Many (WORM) protection. Storm-0501 specifically targets these for deletion before encrypting or destroying data.

{
  "resourceId": "/subscriptions/.../storageAccounts/criticaldata001/blobServices/default/containers/backups/immutabilityPolicies/default",
  "operationName": "MICROSOFT.STORAGE/STORAGEACCOUNTS/BLOBSERVICES/CONTAINERS/IMMUTABILITYPOLICIES/DELETE",
  "resultType": "Success"
}
{
  "resourceId": "/subscriptions/.../storageAccounts/criticaldata001/blobServices/default/containers/backups/immutabilityPolicies/default",
  "operationName": "MICROSOFT.STORAGE/STORAGEACCOUNTS/BLOBSERVICES/CONTAINERS/IMMUTABILITYPOLICIES/DELETE",
  "resultType": "Success"
}
{
  "resourceId": "/subscriptions/.../storageAccounts/criticaldata001/blobServices/default/containers/backups/immutabilityPolicies/default",
  "operationName": "MICROSOFT.STORAGE/STORAGEACCOUNTS/BLOBSERVICES/CONTAINERS/IMMUTABILITYPOLICIES/DELETE",
  "resultType": "Success"
}
{
  "resourceId": "/subscriptions/.../storageAccounts/criticaldata001/blobServices/default/containers/backups/immutabilityPolicies/default",
  "operationName": "MICROSOFT.STORAGE/STORAGEACCOUNTS/BLOBSERVICES/CONTAINERS/IMMUTABILITYPOLICIES/DELETE",
  "resultType": "Success"
}
IMMUTABILITY_POLICY_DELETE = (
    "MICROSOFT.STORAGE/STORAGEACCOUNTS/BLOBSERVICES/CONTAINERS/IMMUTABILITYPOLICIES/DELETE"
)

def rule(event):
    return (
        event.get("operationName", "").upper() == IMMUTABILITY_POLICY_DELETE
        and azure_activity_success(event)
    )

def severity(event):
    return "CRITICAL"

def title(event):
    resource_id = event.get("resourceId", "")
    container = extract_resource_name_from_id(resource_id, "containers", default="<UNKNOWN>")
    storage_account = extract_resource_name_from_id(resource_id, "storageAccounts", default="<UNKNOWN>")
    return f"Immutability policy deleted on container [{container}] in [{storage_account}]"
IMMUTABILITY_POLICY_DELETE = (
    "MICROSOFT.STORAGE/STORAGEACCOUNTS/BLOBSERVICES/CONTAINERS/IMMUTABILITYPOLICIES/DELETE"
)

def rule(event):
    return (
        event.get("operationName", "").upper() == IMMUTABILITY_POLICY_DELETE
        and azure_activity_success(event)
    )

def severity(event):
    return "CRITICAL"

def title(event):
    resource_id = event.get("resourceId", "")
    container = extract_resource_name_from_id(resource_id, "containers", default="<UNKNOWN>")
    storage_account = extract_resource_name_from_id(resource_id, "storageAccounts", default="<UNKNOWN>")
    return f"Immutability policy deleted on container [{container}] in [{storage_account}]"
IMMUTABILITY_POLICY_DELETE = (
    "MICROSOFT.STORAGE/STORAGEACCOUNTS/BLOBSERVICES/CONTAINERS/IMMUTABILITYPOLICIES/DELETE"
)

def rule(event):
    return (
        event.get("operationName", "").upper() == IMMUTABILITY_POLICY_DELETE
        and azure_activity_success(event)
    )

def severity(event):
    return "CRITICAL"

def title(event):
    resource_id = event.get("resourceId", "")
    container = extract_resource_name_from_id(resource_id, "containers", default="<UNKNOWN>")
    storage_account = extract_resource_name_from_id(resource_id, "storageAccounts", default="<UNKNOWN>")
    return f"Immutability policy deleted on container [{container}] in [{storage_account}]"
IMMUTABILITY_POLICY_DELETE = (
    "MICROSOFT.STORAGE/STORAGEACCOUNTS/BLOBSERVICES/CONTAINERS/IMMUTABILITYPOLICIES/DELETE"
)

def rule(event):
    return (
        event.get("operationName", "").upper() == IMMUTABILITY_POLICY_DELETE
        and azure_activity_success(event)
    )

def severity(event):
    return "CRITICAL"

def title(event):
    resource_id = event.get("resourceId", "")
    container = extract_resource_name_from_id(resource_id, "containers", default="<UNKNOWN>")
    storage_account = extract_resource_name_from_id(resource_id, "storageAccounts", default="<UNKNOWN>")
    return f"Immutability policy deleted on container [{container}] in [{storage_account}]"

Recovery Services Protection Container Deletion

Storm-0501 also deleted protection containers to destroy all backup data and recovery points. This is the most destructive backup operation.

{
  "resourceId": "/subscriptions/.../providers/Microsoft.RecoveryServices/vaults/ProductionBackupVault/backupFabrics/Azure/protectionContainers/IaasVMContainer;iaasvmcontainerv2;prodrg;prod-vm-001",
  "operationName": "MICROSOFT.RECOVERYSERVICES/VAULTS/BACKUPFABRICS/PROTECTIONCONTAINERS/DELETE",
  "resultType": "Success"
}
{
  "resourceId": "/subscriptions/.../providers/Microsoft.RecoveryServices/vaults/ProductionBackupVault/backupFabrics/Azure/protectionContainers/IaasVMContainer;iaasvmcontainerv2;prodrg;prod-vm-001",
  "operationName": "MICROSOFT.RECOVERYSERVICES/VAULTS/BACKUPFABRICS/PROTECTIONCONTAINERS/DELETE",
  "resultType": "Success"
}
{
  "resourceId": "/subscriptions/.../providers/Microsoft.RecoveryServices/vaults/ProductionBackupVault/backupFabrics/Azure/protectionContainers/IaasVMContainer;iaasvmcontainerv2;prodrg;prod-vm-001",
  "operationName": "MICROSOFT.RECOVERYSERVICES/VAULTS/BACKUPFABRICS/PROTECTIONCONTAINERS/DELETE",
  "resultType": "Success"
}
{
  "resourceId": "/subscriptions/.../providers/Microsoft.RecoveryServices/vaults/ProductionBackupVault/backupFabrics/Azure/protectionContainers/IaasVMContainer;iaasvmcontainerv2;prodrg;prod-vm-001",
  "operationName": "MICROSOFT.RECOVERYSERVICES/VAULTS/BACKUPFABRICS/PROTECTIONCONTAINERS/DELETE",
  "resultType": "Success"
}
PROTECTION_CONTAINER_DELETE = (
    "MICROSOFT.RECOVERYSERVICES/VAULTS/BACKUPFABRICS/PROTECTIONCONTAINERS/DELETE"
)

def rule(event):
    return (
        event.get("operationName", "").upper() == PROTECTION_CONTAINER_DELETE
        and azure_activity_success(event)
    )

def severity(event):
    return "CRITICAL"

def title(event):
    resource_id = event.get("resourceId", "")
    vault = extract_resource_name_from_id(resource_id, "vaults", default="<UNKNOWN>")
    container = extract_resource_name_from_id(resource_id, "protectionContainers", default="<UNKNOWN>")
    return f"Protection container [{container}] deleted from vault [{vault}]"
PROTECTION_CONTAINER_DELETE = (
    "MICROSOFT.RECOVERYSERVICES/VAULTS/BACKUPFABRICS/PROTECTIONCONTAINERS/DELETE"
)

def rule(event):
    return (
        event.get("operationName", "").upper() == PROTECTION_CONTAINER_DELETE
        and azure_activity_success(event)
    )

def severity(event):
    return "CRITICAL"

def title(event):
    resource_id = event.get("resourceId", "")
    vault = extract_resource_name_from_id(resource_id, "vaults", default="<UNKNOWN>")
    container = extract_resource_name_from_id(resource_id, "protectionContainers", default="<UNKNOWN>")
    return f"Protection container [{container}] deleted from vault [{vault}]"
PROTECTION_CONTAINER_DELETE = (
    "MICROSOFT.RECOVERYSERVICES/VAULTS/BACKUPFABRICS/PROTECTIONCONTAINERS/DELETE"
)

def rule(event):
    return (
        event.get("operationName", "").upper() == PROTECTION_CONTAINER_DELETE
        and azure_activity_success(event)
    )

def severity(event):
    return "CRITICAL"

def title(event):
    resource_id = event.get("resourceId", "")
    vault = extract_resource_name_from_id(resource_id, "vaults", default="<UNKNOWN>")
    container = extract_resource_name_from_id(resource_id, "protectionContainers", default="<UNKNOWN>")
    return f"Protection container [{container}] deleted from vault [{vault}]"
PROTECTION_CONTAINER_DELETE = (
    "MICROSOFT.RECOVERYSERVICES/VAULTS/BACKUPFABRICS/PROTECTIONCONTAINERS/DELETE"
)

def rule(event):
    return (
        event.get("operationName", "").upper() == PROTECTION_CONTAINER_DELETE
        and azure_activity_success(event)
    )

def severity(event):
    return "CRITICAL"

def title(event):
    resource_id = event.get("resourceId", "")
    vault = extract_resource_name_from_id(resource_id, "vaults", default="<UNKNOWN>")
    container = extract_resource_name_from_id(resource_id, "protectionContainers", default="<UNKNOWN>")
    return f"Protection container [{container}] deleted from vault [{vault}]"

Soft Delete and Versioning Disabled

Attackers disable recovery features before destruction operations to ensure complete data loss in the victim environment. We can track these suspicious operations with the following rules:

def rule(event):
    if event.get("operationName", "").upper() != "MICROSOFT.STORAGE/STORAGEACCOUNTS/BLOBSERVICES/WRITE":
        return False
    
    request_body = azure_parse_json_string(
        event.deep_get("properties", "requestbody", default=None)
    )
    
    enabled = request_body.deep_get("properties", "deleteRetentionPolicy", "enabled", default=None)
    return enabled is False and azure_activity_success(event)
def rule(event):
    if event.get("operationName", "").upper() != "MICROSOFT.STORAGE/STORAGEACCOUNTS/BLOBSERVICES/WRITE":
        return False
    
    request_body = azure_parse_json_string(
        event.deep_get("properties", "requestbody", default=None)
    )
    
    enabled = request_body.deep_get("properties", "deleteRetentionPolicy", "enabled", default=None)
    return enabled is False and azure_activity_success(event)
def rule(event):
    if event.get("operationName", "").upper() != "MICROSOFT.STORAGE/STORAGEACCOUNTS/BLOBSERVICES/WRITE":
        return False
    
    request_body = azure_parse_json_string(
        event.deep_get("properties", "requestbody", default=None)
    )
    
    enabled = request_body.deep_get("properties", "deleteRetentionPolicy", "enabled", default=None)
    return enabled is False and azure_activity_success(event)
def rule(event):
    if event.get("operationName", "").upper() != "MICROSOFT.STORAGE/STORAGEACCOUNTS/BLOBSERVICES/WRITE":
        return False
    
    request_body = azure_parse_json_string(
        event.deep_get("properties", "requestbody", default=None)
    )
    
    enabled = request_body.deep_get("properties", "deleteRetentionPolicy", "enabled", default=None)
    return enabled is False and azure_activity_success(event)
def rule(event):
    if event.get("operationName", "").upper() != "MICROSOFT.STORAGE/STORAGEACCOUNTS/BLOBSERVICES/WRITE":
        return False
    
    request_body = azure_parse_json_string(
        event.deep_get("properties", "requestbody", default=None)
    )
    
    enabled = request_body.deep_get("properties", "isVersioningEnabled", default=None)
    return enabled is False and azure_activity_success(event)
def rule(event):
    if event.get("operationName", "").upper() != "MICROSOFT.STORAGE/STORAGEACCOUNTS/BLOBSERVICES/WRITE":
        return False
    
    request_body = azure_parse_json_string(
        event.deep_get("properties", "requestbody", default=None)
    )
    
    enabled = request_body.deep_get("properties", "isVersioningEnabled", default=None)
    return enabled is False and azure_activity_success(event)
def rule(event):
    if event.get("operationName", "").upper() != "MICROSOFT.STORAGE/STORAGEACCOUNTS/BLOBSERVICES/WRITE":
        return False
    
    request_body = azure_parse_json_string(
        event.deep_get("properties", "requestbody", default=None)
    )
    
    enabled = request_body.deep_get("properties", "isVersioningEnabled", default=None)
    return enabled is False and azure_activity_success(event)
def rule(event):
    if event.get("operationName", "").upper() != "MICROSOFT.STORAGE/STORAGEACCOUNTS/BLOBSERVICES/WRITE":
        return False
    
    request_body = azure_parse_json_string(
        event.deep_get("properties", "requestbody", default=None)
    )
    
    enabled = request_body.deep_get("properties", "isVersioningEnabled", default=None)
    return enabled is False and azure_activity_success(event)

Diagnostic Settings Deleted

Attackers can also delete diagnostic settings to disable logging and hide their activity.

DIAGNOSTIC_SETTINGS_DELETE = "MICROSOFT.INSIGHTS/DIAGNOSTICSETTINGS/DELETE"

def rule(event):
    return (
        event.get("operationName", "").upper() == DIAGNOSTIC_SETTINGS_DELETE
        and azure_activity_success(event)
    )
 
def title(event):
    resource_id = event.get("resourceId", "")
    return f"Diagnostic settings deleted on [{resource_id}]"
DIAGNOSTIC_SETTINGS_DELETE = "MICROSOFT.INSIGHTS/DIAGNOSTICSETTINGS/DELETE"

def rule(event):
    return (
        event.get("operationName", "").upper() == DIAGNOSTIC_SETTINGS_DELETE
        and azure_activity_success(event)
    )
 
def title(event):
    resource_id = event.get("resourceId", "")
    return f"Diagnostic settings deleted on [{resource_id}]"
DIAGNOSTIC_SETTINGS_DELETE = "MICROSOFT.INSIGHTS/DIAGNOSTICSETTINGS/DELETE"

def rule(event):
    return (
        event.get("operationName", "").upper() == DIAGNOSTIC_SETTINGS_DELETE
        and azure_activity_success(event)
    )
 
def title(event):
    resource_id = event.get("resourceId", "")
    return f"Diagnostic settings deleted on [{resource_id}]"
DIAGNOSTIC_SETTINGS_DELETE = "MICROSOFT.INSIGHTS/DIAGNOSTICSETTINGS/DELETE"

def rule(event):
    return (
        event.get("operationName", "").upper() == DIAGNOSTIC_SETTINGS_DELETE
        and azure_activity_success(event)
    )
 
def title(event):
    resource_id = event.get("resourceId", "")
    return f"Diagnostic settings deleted on [{resource_id}]"

Privilege Escalation Prerequisites

Before attackers can execute ransomware operations against Azure Storage, they typically need to escalate privileges. The following detections identify common privilege escalation techniques that precede storage attacks.

Elevate Access

The Microsoft.Authorization/elevateAccess/action operation grants the caller User Access Administrator role at the root scope (/), providing full control over all Azure subscriptions in the tenant. Storm-0501 leveraged this operation after compromising privileged accounts.

{
  "operationName": {
    "value": "Microsoft.Authorization/elevateAccess/action",
    "localizedValue": "Assigns the caller to User Access Administrator role"
  },
  "status": {
    "value": "Succeeded"
  },
  "resourceId": "/providers/Microsoft.Authorization",
  "callerIpAddress": "203.0.113.42"
}
{
  "operationName": {
    "value": "Microsoft.Authorization/elevateAccess/action",
    "localizedValue": "Assigns the caller to User Access Administrator role"
  },
  "status": {
    "value": "Succeeded"
  },
  "resourceId": "/providers/Microsoft.Authorization",
  "callerIpAddress": "203.0.113.42"
}
{
  "operationName": {
    "value": "Microsoft.Authorization/elevateAccess/action",
    "localizedValue": "Assigns the caller to User Access Administrator role"
  },
  "status": {
    "value": "Succeeded"
  },
  "resourceId": "/providers/Microsoft.Authorization",
  "callerIpAddress": "203.0.113.42"
}
{
  "operationName": {
    "value": "Microsoft.Authorization/elevateAccess/action",
    "localizedValue": "Assigns the caller to User Access Administrator role"
  },
  "status": {
    "value": "Succeeded"
  },
  "resourceId": "/providers/Microsoft.Authorization",
  "callerIpAddress": "203.0.113.42"
}

To detect this operation:

ELEVATE_ACCESS_ACTION = "MICROSOFT.AUTHORIZATION/ELEVATEACCESS/ACTION"

def rule(event):
    operation_name = event.deep_get("operationName", "value", default="").upper()
    return (
        operation_name == ELEVATE_ACCESS_ACTION
        and event.deep_get("status", "value") == "Succeeded"
    )

def severity(event):
    return "CRITICAL"
ELEVATE_ACCESS_ACTION = "MICROSOFT.AUTHORIZATION/ELEVATEACCESS/ACTION"

def rule(event):
    operation_name = event.deep_get("operationName", "value", default="").upper()
    return (
        operation_name == ELEVATE_ACCESS_ACTION
        and event.deep_get("status", "value") == "Succeeded"
    )

def severity(event):
    return "CRITICAL"
ELEVATE_ACCESS_ACTION = "MICROSOFT.AUTHORIZATION/ELEVATEACCESS/ACTION"

def rule(event):
    operation_name = event.deep_get("operationName", "value", default="").upper()
    return (
        operation_name == ELEVATE_ACCESS_ACTION
        and event.deep_get("status", "value") == "Succeeded"
    )

def severity(event):
    return "CRITICAL"
ELEVATE_ACCESS_ACTION = "MICROSOFT.AUTHORIZATION/ELEVATEACCESS/ACTION"

def rule(event):
    operation_name = event.deep_get("operationName", "value", default="").upper()
    return (
        operation_name == ELEVATE_ACCESS_ACTION
        and event.deep_get("status", "value") == "Succeeded"
    )

def severity(event):
    return "CRITICAL"

Privileged Role Assignment

Attackers could also try to assign privileged roles such as Owner, Contributor, or User Access Administrator to compromised accounts or service principals.

ROLE_ASSIGNMENT_WRITE = "MICROSOFT.AUTHORIZATION/ROLEASSIGNMENTS/WRITE"

PRIVILEGED_ROLES = {
    "8e3af657-a8ff-443c-a75c-2fe8c4bcb635": "Owner",
    "b24988ac-6180-42a0-ab88-20f7382dd24c": "Contributor",
    "18d7d88d-d35e-4fb5-a5c3-7773c20a72d9": "User Access Administrator",
}

def rule(event):
    operation_name = event.get("operationName", "").upper()
    
    if operation_name != ROLE_ASSIGNMENT_WRITE:
        return False
    
    role_id = extract_role_id_from_event(event)
    return role_id in PRIVILEGED_ROLES and azure_activity_success(event)
ROLE_ASSIGNMENT_WRITE = "MICROSOFT.AUTHORIZATION/ROLEASSIGNMENTS/WRITE"

PRIVILEGED_ROLES = {
    "8e3af657-a8ff-443c-a75c-2fe8c4bcb635": "Owner",
    "b24988ac-6180-42a0-ab88-20f7382dd24c": "Contributor",
    "18d7d88d-d35e-4fb5-a5c3-7773c20a72d9": "User Access Administrator",
}

def rule(event):
    operation_name = event.get("operationName", "").upper()
    
    if operation_name != ROLE_ASSIGNMENT_WRITE:
        return False
    
    role_id = extract_role_id_from_event(event)
    return role_id in PRIVILEGED_ROLES and azure_activity_success(event)
ROLE_ASSIGNMENT_WRITE = "MICROSOFT.AUTHORIZATION/ROLEASSIGNMENTS/WRITE"

PRIVILEGED_ROLES = {
    "8e3af657-a8ff-443c-a75c-2fe8c4bcb635": "Owner",
    "b24988ac-6180-42a0-ab88-20f7382dd24c": "Contributor",
    "18d7d88d-d35e-4fb5-a5c3-7773c20a72d9": "User Access Administrator",
}

def rule(event):
    operation_name = event.get("operationName", "").upper()
    
    if operation_name != ROLE_ASSIGNMENT_WRITE:
        return False
    
    role_id = extract_role_id_from_event(event)
    return role_id in PRIVILEGED_ROLES and azure_activity_success(event)
ROLE_ASSIGNMENT_WRITE = "MICROSOFT.AUTHORIZATION/ROLEASSIGNMENTS/WRITE"

PRIVILEGED_ROLES = {
    "8e3af657-a8ff-443c-a75c-2fe8c4bcb635": "Owner",
    "b24988ac-6180-42a0-ab88-20f7382dd24c": "Contributor",
    "18d7d88d-d35e-4fb5-a5c3-7773c20a72d9": "User Access Administrator",
}

def rule(event):
    operation_name = event.get("operationName", "").upper()
    
    if operation_name != ROLE_ASSIGNMENT_WRITE:
        return False
    
    role_id = extract_role_id_from_event(event)
    return role_id in PRIVILEGED_ROLES and azure_activity_success(event)

Azure Storage Ransomware Scenarios

The following section examines ransomware techniques documented in Storm-0501 campaigns and other Azure-targeting threat actors.

Customer-Provided Key (CPK) Encryption

In a technique mirroring the SSE-C Encryption attack documented for AWS S3 in the Codefinger campaign, an attacker generates an AES-256 encryption key locally and provides it to Azure Storage during blob upload or copy operations. Azure uses the key to encrypt the blob, then discards it. Azure only logs a hash of the key for request verification, but the hash cannot be used to recover the original key or decrypt the data.

This attack has the lowest barrier to entry among the encryption-based scenarios. It requires no Key Vault permissions and no attacker-side Azure infrastructure. The attacker only needs write access to the target storage account and a locally generated key. This makes CPK encryption particularly dangerous in environments where storage permissions are overly permissive or where compromised service principals have write access to production storage accounts.

From the logs generated by uploading a blob with CPK encryption:

{
  "operationName": "PutBlob",
  "uri": "<https://storageaccount.blob.core.windows.net/container/document.txt.ENCRYPTED>",
  "callerIpAddress": "203.0.113.42",
  "statusCode": 201
}
{
  "operationName": "PutBlob",
  "uri": "<https://storageaccount.blob.core.windows.net/container/document.txt.ENCRYPTED>",
  "callerIpAddress": "203.0.113.42",
  "statusCode": 201
}
{
  "operationName": "PutBlob",
  "uri": "<https://storageaccount.blob.core.windows.net/container/document.txt.ENCRYPTED>",
  "callerIpAddress": "203.0.113.42",
  "statusCode": 201
}
{
  "operationName": "PutBlob",
  "uri": "<https://storageaccount.blob.core.windows.net/container/document.txt.ENCRYPTED>",
  "callerIpAddress": "203.0.113.42",
  "statusCode": 201
}

Note that unlike AWS S3, Azure Storage logs do not explicitly indicate when CPK encryption is used during upload. The encryption is transparent in the write operation logs.

However, when victims attempt to access CPK-encrypted blobs without the key, they receive a BlobUsesCustomerSpecifiedEncryption error:

{
  "operationName": "GetBlobMetadata",
  "uri": "<https://storageaccount.blob.core.windows.net/container/document.txt.ENCRYPTED>",
  "statusCode": 409,
  "statusText": "BlobUsesCustomerSpecifiedEncryption"
}
{
  "operationName": "GetBlobMetadata",
  "uri": "<https://storageaccount.blob.core.windows.net/container/document.txt.ENCRYPTED>",
  "statusCode": 409,
  "statusText": "BlobUsesCustomerSpecifiedEncryption"
}
{
  "operationName": "GetBlobMetadata",
  "uri": "<https://storageaccount.blob.core.windows.net/container/document.txt.ENCRYPTED>",
  "statusCode": 409,
  "statusText": "BlobUsesCustomerSpecifiedEncryption"
}
{
  "operationName": "GetBlobMetadata",
  "uri": "<https://storageaccount.blob.core.windows.net/container/document.txt.ENCRYPTED>",
  "statusCode": 409,
  "statusText": "BlobUsesCustomerSpecifiedEncryption"
}

To detect this scenario, we monitor for these access errors which indicate data has been encrypted with an unknown CPK:

STORAGE_READ_CATEGORY = "STORAGEREAD"
CPK_ERROR_STATUS = "BLOBUSESCUSTOMERSPECIFIEDENCRYPTION"

def rule(event):
    # Detect when users try to access CPK-encrypted blobs without the key
    return (
        event.get("category", "").upper() == STORAGE_READ_CATEGORY
        and event.get("statusCode") == 409
        and event.get("statusText", "").upper() == CPK_ERROR_STATUS
        and azure_resource_logs_failure(event)
    )

def title(event):
    resource_id = event.get("resourceId", "<UNKNOWN_STORAGE_ACCOUNT>")
    storage_account = extract_resource_name_from_id(
        resource_id, "storageAccounts", default="<UNKNOWN_STORAGE_ACCOUNT>"
    )
    blob_path = event.deep_get("properties", "objectKey", default="<UNKNOWN_BLOB>")

    return (
        f"Access denied returned in storage account [{storage_account}] "
        f"for CPK-encrypted blob [{blob_path}]"
    )
STORAGE_READ_CATEGORY = "STORAGEREAD"
CPK_ERROR_STATUS = "BLOBUSESCUSTOMERSPECIFIEDENCRYPTION"

def rule(event):
    # Detect when users try to access CPK-encrypted blobs without the key
    return (
        event.get("category", "").upper() == STORAGE_READ_CATEGORY
        and event.get("statusCode") == 409
        and event.get("statusText", "").upper() == CPK_ERROR_STATUS
        and azure_resource_logs_failure(event)
    )

def title(event):
    resource_id = event.get("resourceId", "<UNKNOWN_STORAGE_ACCOUNT>")
    storage_account = extract_resource_name_from_id(
        resource_id, "storageAccounts", default="<UNKNOWN_STORAGE_ACCOUNT>"
    )
    blob_path = event.deep_get("properties", "objectKey", default="<UNKNOWN_BLOB>")

    return (
        f"Access denied returned in storage account [{storage_account}] "
        f"for CPK-encrypted blob [{blob_path}]"
    )
STORAGE_READ_CATEGORY = "STORAGEREAD"
CPK_ERROR_STATUS = "BLOBUSESCUSTOMERSPECIFIEDENCRYPTION"

def rule(event):
    # Detect when users try to access CPK-encrypted blobs without the key
    return (
        event.get("category", "").upper() == STORAGE_READ_CATEGORY
        and event.get("statusCode") == 409
        and event.get("statusText", "").upper() == CPK_ERROR_STATUS
        and azure_resource_logs_failure(event)
    )

def title(event):
    resource_id = event.get("resourceId", "<UNKNOWN_STORAGE_ACCOUNT>")
    storage_account = extract_resource_name_from_id(
        resource_id, "storageAccounts", default="<UNKNOWN_STORAGE_ACCOUNT>"
    )
    blob_path = event.deep_get("properties", "objectKey", default="<UNKNOWN_BLOB>")

    return (
        f"Access denied returned in storage account [{storage_account}] "
        f"for CPK-encrypted blob [{blob_path}]"
    )
STORAGE_READ_CATEGORY = "STORAGEREAD"
CPK_ERROR_STATUS = "BLOBUSESCUSTOMERSPECIFIEDENCRYPTION"

def rule(event):
    # Detect when users try to access CPK-encrypted blobs without the key
    return (
        event.get("category", "").upper() == STORAGE_READ_CATEGORY
        and event.get("statusCode") == 409
        and event.get("statusText", "").upper() == CPK_ERROR_STATUS
        and azure_resource_logs_failure(event)
    )

def title(event):
    resource_id = event.get("resourceId", "<UNKNOWN_STORAGE_ACCOUNT>")
    storage_account = extract_resource_name_from_id(
        resource_id, "storageAccounts", default="<UNKNOWN_STORAGE_ACCOUNT>"
    )
    blob_path = event.deep_get("properties", "objectKey", default="<UNKNOWN_BLOB>")

    return (
        f"Access denied returned in storage account [{storage_account}] "
        f"for CPK-encrypted blob [{blob_path}]"
    )

To create a higher-fidelity detection, we can correlate blob uploads with subsequent CPK errors on the same blob path:

Detection:
    - Sequence:
        - ID: Blob Upload
          RuleID: Azure.MonitorActivity.Storage.Blob.Uploaded
        - ID: CPK Access Denied
          RuleID: Azure.MonitorActivity.Storage.Blob.CPKEncryptionDetected
      Transitions:
        - ID: Upload to CPK Error on Same Blob
          From: Blob Upload
          To: CPK Access Denied
          WithinTimeFrameMinutes: 60
          Match:
            - On: p_alert_context.blob_path
      Schedule:
        RateMinutes: 60
        TimeoutMinutes: 5
      LookbackWindowMinutes: 120
Detection:
    - Sequence:
        - ID: Blob Upload
          RuleID: Azure.MonitorActivity.Storage.Blob.Uploaded
        - ID: CPK Access Denied
          RuleID: Azure.MonitorActivity.Storage.Blob.CPKEncryptionDetected
      Transitions:
        - ID: Upload to CPK Error on Same Blob
          From: Blob Upload
          To: CPK Access Denied
          WithinTimeFrameMinutes: 60
          Match:
            - On: p_alert_context.blob_path
      Schedule:
        RateMinutes: 60
        TimeoutMinutes: 5
      LookbackWindowMinutes: 120
Detection:
    - Sequence:
        - ID: Blob Upload
          RuleID: Azure.MonitorActivity.Storage.Blob.Uploaded
        - ID: CPK Access Denied
          RuleID: Azure.MonitorActivity.Storage.Blob.CPKEncryptionDetected
      Transitions:
        - ID: Upload to CPK Error on Same Blob
          From: Blob Upload
          To: CPK Access Denied
          WithinTimeFrameMinutes: 60
          Match:
            - On: p_alert_context.blob_path
      Schedule:
        RateMinutes: 60
        TimeoutMinutes: 5
      LookbackWindowMinutes: 120
Detection:
    - Sequence:
        - ID: Blob Upload
          RuleID: Azure.MonitorActivity.Storage.Blob.Uploaded
        - ID: CPK Access Denied
          RuleID: Azure.MonitorActivity.Storage.Blob.CPKEncryptionDetected
      Transitions:
        - ID: Upload to CPK Error on Same Blob
          From: Blob Upload
          To: CPK Access Denied
          WithinTimeFrameMinutes: 60
          Match:
            - On: p_alert_context.blob_path
      Schedule:
        RateMinutes: 60
        TimeoutMinutes: 5
      LookbackWindowMinutes: 120

Exfiltration via SAS Token

In this scenario, an attacker steals storage account keys or generates SAS tokens, then uses them to exfiltrate data to external storage before deletion. This mirrors the Exfiltration and Deletion technique documented by Palo Alto Unit 42 for AWS S3.

Storage Account Key Theft

Before generating SAS tokens for data exfiltration, attackers must obtain storage account access keys. The listKeys operation retrieves both primary and secondary keys, providing full account access equivalent to root credentials. Unlike Azure AD authentication, access keys enable persistent access that persists even after the attacker's compromised identity is revoked.

Storm-0501 systematically lists storage account keys after obtaining elevated privileges, then uses these keys to generate SAS tokens with arbitrary permissions and expiration dates from external infrastructure.

{
  "time": "2025-01-27T15:42:18Z",
  "operationName": "MICROSOFT.STORAGE/STORAGEACCOUNTS/LISTKEYS/ACTION",
  "resourceId": "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/prod-data-rg/providers/Microsoft.Storage/storageAccounts/criticaldata001",
  "resultType": "Success",
  "callerIpAddress": "203.0.113.42",
  "identity": {
    "claims": {
      "name": "compromised-admin@victim.onmicrosoft.com",
      "appid": "00000000-0000-0000-0000-000000000000"
    }
  }
}
{
  "time": "2025-01-27T15:42:18Z",
  "operationName": "MICROSOFT.STORAGE/STORAGEACCOUNTS/LISTKEYS/ACTION",
  "resourceId": "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/prod-data-rg/providers/Microsoft.Storage/storageAccounts/criticaldata001",
  "resultType": "Success",
  "callerIpAddress": "203.0.113.42",
  "identity": {
    "claims": {
      "name": "compromised-admin@victim.onmicrosoft.com",
      "appid": "00000000-0000-0000-0000-000000000000"
    }
  }
}
{
  "time": "2025-01-27T15:42:18Z",
  "operationName": "MICROSOFT.STORAGE/STORAGEACCOUNTS/LISTKEYS/ACTION",
  "resourceId": "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/prod-data-rg/providers/Microsoft.Storage/storageAccounts/criticaldata001",
  "resultType": "Success",
  "callerIpAddress": "203.0.113.42",
  "identity": {
    "claims": {
      "name": "compromised-admin@victim.onmicrosoft.com",
      "appid": "00000000-0000-0000-0000-000000000000"
    }
  }
}
{
  "time": "2025-01-27T15:42:18Z",
  "operationName": "MICROSOFT.STORAGE/STORAGEACCOUNTS/LISTKEYS/ACTION",
  "resourceId": "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/prod-data-rg/providers/Microsoft.Storage/storageAccounts/criticaldata001",
  "resultType": "Success",
  "callerIpAddress": "203.0.113.42",
  "identity": {
    "claims": {
      "name": "compromised-admin@victim.onmicrosoft.com",
      "appid": "00000000-0000-0000-0000-000000000000"
    }
  }
}

To detect storage account key enumeration:

LISTKEYS_ACTION = "MICROSOFT.STORAGE/STORAGEACCOUNTS/LISTKEYS/ACTION"

def rule(event):
    return (
        event.get("operationName", "").upper() == LISTKEYS_ACTION
        and azure_activity_success(event)
    )
LISTKEYS_ACTION = "MICROSOFT.STORAGE/STORAGEACCOUNTS/LISTKEYS/ACTION"

def rule(event):
    return (
        event.get("operationName", "").upper() == LISTKEYS_ACTION
        and azure_activity_success(event)
    )
LISTKEYS_ACTION = "MICROSOFT.STORAGE/STORAGEACCOUNTS/LISTKEYS/ACTION"

def rule(event):
    return (
        event.get("operationName", "").upper() == LISTKEYS_ACTION
        and azure_activity_success(event)
    )
LISTKEYS_ACTION = "MICROSOFT.STORAGE/STORAGEACCOUNTS/LISTKEYS/ACTION"

def rule(event):
    return (
        event.get("operationName", "").upper() == LISTKEYS_ACTION
        and azure_activity_success(event)
    )

When an attacker uses a SAS token from an external IP address, we can see the following in storage logs:

{
  "category": "StorageRead",
  "operationName": "GetBlob",
  "uri": "<https://storageaccount.blob.core.windows.net/data/sensitive.docx?sv=2021-06-08&ss=b&srt=sco&sp=rwdlacup&se=2025-12-31&sig=>...",
  "callerIpAddress": "203.0.113.42",
  "authenticationtype": "SAS",
  "statusCode": 200
}
{
  "category": "StorageRead",
  "operationName": "GetBlob",
  "uri": "<https://storageaccount.blob.core.windows.net/data/sensitive.docx?sv=2021-06-08&ss=b&srt=sco&sp=rwdlacup&se=2025-12-31&sig=>...",
  "callerIpAddress": "203.0.113.42",
  "authenticationtype": "SAS",
  "statusCode": 200
}
{
  "category": "StorageRead",
  "operationName": "GetBlob",
  "uri": "<https://storageaccount.blob.core.windows.net/data/sensitive.docx?sv=2021-06-08&ss=b&srt=sco&sp=rwdlacup&se=2025-12-31&sig=>...",
  "callerIpAddress": "203.0.113.42",
  "authenticationtype": "SAS",
  "statusCode": 200
}
{
  "category": "StorageRead",
  "operationName": "GetBlob",
  "uri": "<https://storageaccount.blob.core.windows.net/data/sensitive.docx?sv=2021-06-08&ss=b&srt=sco&sp=rwdlacup&se=2025-12-31&sig=>...",
  "callerIpAddress": "203.0.113.42",
  "authenticationtype": "SAS",
  "statusCode": 200
}

The authenticationtype field indicates SAS token authentication. The callerIpAddress field shows the source IP. The sp parameter in the URI indicates permissions (r=read, w=write, d=delete, l=list) and the sig parameter the presence of a SAS token. In Python, we can take advantage of the ipaddress library to determine whether the ip address that made the request is within a private range and to detect an external IP address performing storage operations using a SAS token:

def rule(event):
    # Must be storage operation
    if event.get("category") not in ["StorageRead", "StorageWrite", "StorageDelete"]:
        return False

    # Must be successful
    status_code = event.get("statusCode")
    if status_code not in [200, 201, 202, 204]:
        return False

    # Check if SAS token was used (look for 'sig=' in URI)
    uri = event.get("uri", "")
    if not uri or "sig=" not in uri:
        return False

    # Check if IP is external (not private/RFC1918)
    caller_ip = extract_caller_ip(event)
    if not caller_ip or is_private_ip(caller_ip):
        return False

    return True
def rule(event):
    # Must be storage operation
    if event.get("category") not in ["StorageRead", "StorageWrite", "StorageDelete"]:
        return False

    # Must be successful
    status_code = event.get("statusCode")
    if status_code not in [200, 201, 202, 204]:
        return False

    # Check if SAS token was used (look for 'sig=' in URI)
    uri = event.get("uri", "")
    if not uri or "sig=" not in uri:
        return False

    # Check if IP is external (not private/RFC1918)
    caller_ip = extract_caller_ip(event)
    if not caller_ip or is_private_ip(caller_ip):
        return False

    return True
def rule(event):
    # Must be storage operation
    if event.get("category") not in ["StorageRead", "StorageWrite", "StorageDelete"]:
        return False

    # Must be successful
    status_code = event.get("statusCode")
    if status_code not in [200, 201, 202, 204]:
        return False

    # Check if SAS token was used (look for 'sig=' in URI)
    uri = event.get("uri", "")
    if not uri or "sig=" not in uri:
        return False

    # Check if IP is external (not private/RFC1918)
    caller_ip = extract_caller_ip(event)
    if not caller_ip or is_private_ip(caller_ip):
        return False

    return True
def rule(event):
    # Must be storage operation
    if event.get("category") not in ["StorageRead", "StorageWrite", "StorageDelete"]:
        return False

    # Must be successful
    status_code = event.get("statusCode")
    if status_code not in [200, 201, 202, 204]:
        return False

    # Check if SAS token was used (look for 'sig=' in URI)
    uri = event.get("uri", "")
    if not uri or "sig=" not in uri:
        return False

    # Check if IP is external (not private/RFC1918)
    caller_ip = extract_caller_ip(event)
    if not caller_ip or is_private_ip(caller_ip):
        return False

    return True

Bulk Data Extraction

Mass blob read operations may indicate data staging for exfiltration. This threshold-based detection identifies unusual read volume from a single source. To detect bulk extraction, we monitor for high-volume read operations (50 operations within 15 minutes):

def rule(event):
    # Must be GetBlob operation (actual data retrieval)
    operation = event.get("operationName", "").upper()
    if operation != "GETBLOB":
        return False

    # Must be successful
    return azure_resource_logs_success(event)
def rule(event):
    # Must be GetBlob operation (actual data retrieval)
    operation = event.get("operationName", "").upper()
    if operation != "GETBLOB":
        return False

    # Must be successful
    return azure_resource_logs_success(event)
def rule(event):
    # Must be GetBlob operation (actual data retrieval)
    operation = event.get("operationName", "").upper()
    if operation != "GETBLOB":
        return False

    # Must be successful
    return azure_resource_logs_success(event)
def rule(event):
    # Must be GetBlob operation (actual data retrieval)
    operation = event.get("operationName", "").upper()
    if operation != "GETBLOB":
        return False

    # Must be successful
    return azure_resource_logs_success(event)

Bulk Data Deletion

After exfiltrating data, attackers execute mass blob deletion to maximize pressure on victims.

Unlike encryption-based ransomware, deletion is immediate and irreversible if soft delete is disabled. This makes bulk deletion particularly destructive when combined with the defense evasion techniques documented earlier.

{
  "time": "2025-01-27T16:15:42Z",
  "category": "StorageDelete",
  "operationName": "DeleteBlob",
  "uri": "<https://criticaldata001.blob.core.windows.net/production-data/customer-records-2025-01.parquet>",
  "callerIpAddress": "203.0.113.42",
  "statusCode": 202,
  "statusText": "Accepted",
  "properties": {
    "objectKey": "production-data/customer-records-2025-01.parquet",
    "etag": "0x8DC1234567890AB"
  }
}
{
  "time": "2025-01-27T16:15:42Z",
  "category": "StorageDelete",
  "operationName": "DeleteBlob",
  "uri": "<https://criticaldata001.blob.core.windows.net/production-data/customer-records-2025-01.parquet>",
  "callerIpAddress": "203.0.113.42",
  "statusCode": 202,
  "statusText": "Accepted",
  "properties": {
    "objectKey": "production-data/customer-records-2025-01.parquet",
    "etag": "0x8DC1234567890AB"
  }
}
{
  "time": "2025-01-27T16:15:42Z",
  "category": "StorageDelete",
  "operationName": "DeleteBlob",
  "uri": "<https://criticaldata001.blob.core.windows.net/production-data/customer-records-2025-01.parquet>",
  "callerIpAddress": "203.0.113.42",
  "statusCode": 202,
  "statusText": "Accepted",
  "properties": {
    "objectKey": "production-data/customer-records-2025-01.parquet",
    "etag": "0x8DC1234567890AB"
  }
}
{
  "time": "2025-01-27T16:15:42Z",
  "category": "StorageDelete",
  "operationName": "DeleteBlob",
  "uri": "<https://criticaldata001.blob.core.windows.net/production-data/customer-records-2025-01.parquet>",
  "callerIpAddress": "203.0.113.42",
  "statusCode": 202,
  "statusText": "Accepted",
  "properties": {
    "objectKey": "production-data/customer-records-2025-01.parquet",
    "etag": "0x8DC1234567890AB"
  }
}

Mass deletion operations generate distinctive patterns: several DeleteBlob operations in rapid succession, often from a single source.

def rule(event):
    return event.get("operationName", "").upper() == "DELETEBLOB" and azure_resource_logs_success(
        event
    )
def rule(event):
    return event.get("operationName", "").upper() == "DELETEBLOB" and azure_resource_logs_success(
        event
    )
def rule(event):
    return event.get("operationName", "").upper() == "DELETEBLOB" and azure_resource_logs_success(
        event
    )
def rule(event):
    return event.get("operationName", "").upper() == "DELETEBLOB" and azure_resource_logs_success(
        event
    )

Key Vault Destruction

After encrypting or destroying storage data, ransomware operators target Azure Key Vaults to eliminate recovery options and maximize impact. Key Vaults store encryption keys used for Azure Storage Service Encryption with customer-managed keys (SSE-CMK), equivalent to AWS KMS-based encryption. Destroying these keys renders encrypted data permanently inaccessible, even if backups exist.

Storm-0501 systematically targets Key Vaults in the final stages of attacks to:

  • Prevent victims from decrypting SSE-CMK encrypted storage accounts

  • Destroy cryptographic material needed for recovery operations

  • Eliminate forensic evidence stored as secrets (e.g., service principal credentials)

Azure implements a two-stage deletion model: soft delete (recoverable) followed by purge (irreversible). Attackers must wait out the soft delete retention period (7-90 days) or exploit vaults without purge protection enabled.

Key Vault Deletion

Soft-deleted Key Vaults can be recovered within the configured retention period (default: 90 days). Vault deletion initiates this countdown and immediately prevents access to all keys, secrets, and certificates.

{
  "time": "2025-01-27T16:45:12Z",
  "operationName": "MICROSOFT.KEYVAULT/VAULTS/DELETE",
  "resourceId": "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/prod-security-rg/providers/Microsoft.KeyVault/vaults/prod-encryption-vault",
  "resultType": "Success",
  "callerIpAddress": "203.0.113.42",
  "identity": {
    "claims": {
      "name": "compromised-admin@victim.onmicrosoft.com"
    }
  },
  "properties": {
    "enableSoftDelete": true,
    "enablePurgeProtection": false
  }
}
{
  "time": "2025-01-27T16:45:12Z",
  "operationName": "MICROSOFT.KEYVAULT/VAULTS/DELETE",
  "resourceId": "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/prod-security-rg/providers/Microsoft.KeyVault/vaults/prod-encryption-vault",
  "resultType": "Success",
  "callerIpAddress": "203.0.113.42",
  "identity": {
    "claims": {
      "name": "compromised-admin@victim.onmicrosoft.com"
    }
  },
  "properties": {
    "enableSoftDelete": true,
    "enablePurgeProtection": false
  }
}
{
  "time": "2025-01-27T16:45:12Z",
  "operationName": "MICROSOFT.KEYVAULT/VAULTS/DELETE",
  "resourceId": "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/prod-security-rg/providers/Microsoft.KeyVault/vaults/prod-encryption-vault",
  "resultType": "Success",
  "callerIpAddress": "203.0.113.42",
  "identity": {
    "claims": {
      "name": "compromised-admin@victim.onmicrosoft.com"
    }
  },
  "properties": {
    "enableSoftDelete": true,
    "enablePurgeProtection": false
  }
}
{
  "time": "2025-01-27T16:45:12Z",
  "operationName": "MICROSOFT.KEYVAULT/VAULTS/DELETE",
  "resourceId": "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/prod-security-rg/providers/Microsoft.KeyVault/vaults/prod-encryption-vault",
  "resultType": "Success",
  "callerIpAddress": "203.0.113.42",
  "identity": {
    "claims": {
      "name": "compromised-admin@victim.onmicrosoft.com"
    }
  },
  "properties": {
    "enableSoftDelete": true,
    "enablePurgeProtection": false
  }
}

The enablePurgeProtection property in the event indicates whether the vault can be immediately purged after deletion. Vaults without purge protection are at higher risk, as attackers can permanently destroy them without waiting for the soft delete period. We can monitor the general vault delete operation like so:

KEYVAULT_DELETE = "MICROSOFT.KEYVAULT/VAULTS/DELETE"

def rule(event):
    return (
        event.get("operationName", "").upper() == KEYVAULT_DELETE
        and azure_activity_success(event)
    )
KEYVAULT_DELETE = "MICROSOFT.KEYVAULT/VAULTS/DELETE"

def rule(event):
    return (
        event.get("operationName", "").upper() == KEYVAULT_DELETE
        and azure_activity_success(event)
    )
KEYVAULT_DELETE = "MICROSOFT.KEYVAULT/VAULTS/DELETE"

def rule(event):
    return (
        event.get("operationName", "").upper() == KEYVAULT_DELETE
        and azure_activity_success(event)
    )
KEYVAULT_DELETE = "MICROSOFT.KEYVAULT/VAULTS/DELETE"

def rule(event):
    return (
        event.get("operationName", "").upper() == KEYVAULT_DELETE
        and azure_activity_success(event)
    )

If the deleted vault contains encryption keys for storage accounts, those accounts become inaccessible immediately. Azure Storage returns 403 Forbidden errors when attempting to access the encrypted data.

Key Vault Purged

Purging permanently destroys the vault and all cryptographic material, keys, secrets, and certificates. This operation is irreversible and cannot be undone. Purging requires the vault to be in a soft-deleted state first.

{
  "time": "2025-01-27T16:50:33Z",
  "operationName": "MICROSOFT.KEYVAULT/LOCATIONS/DELETEDVAULTS/PURGE/ACTION",
  "resourceId": "/subscriptions/00000000-0000-0000-0000-000000000000/providers/Microsoft.KeyVault/locations/eastus/deletedVaults/prod-encryption-vault",
  "resultType": "Success",
  "callerIpAddress": "203.0.113.42",
  "properties": {
    "scheduledPurgeDate": "2025-04-27T16:45:12Z",
    "deletionDate": "2025-01-27T16:45:12Z"
  }
}
{
  "time": "2025-01-27T16:50:33Z",
  "operationName": "MICROSOFT.KEYVAULT/LOCATIONS/DELETEDVAULTS/PURGE/ACTION",
  "resourceId": "/subscriptions/00000000-0000-0000-0000-000000000000/providers/Microsoft.KeyVault/locations/eastus/deletedVaults/prod-encryption-vault",
  "resultType": "Success",
  "callerIpAddress": "203.0.113.42",
  "properties": {
    "scheduledPurgeDate": "2025-04-27T16:45:12Z",
    "deletionDate": "2025-01-27T16:45:12Z"
  }
}
{
  "time": "2025-01-27T16:50:33Z",
  "operationName": "MICROSOFT.KEYVAULT/LOCATIONS/DELETEDVAULTS/PURGE/ACTION",
  "resourceId": "/subscriptions/00000000-0000-0000-0000-000000000000/providers/Microsoft.KeyVault/locations/eastus/deletedVaults/prod-encryption-vault",
  "resultType": "Success",
  "callerIpAddress": "203.0.113.42",
  "properties": {
    "scheduledPurgeDate": "2025-04-27T16:45:12Z",
    "deletionDate": "2025-01-27T16:45:12Z"
  }
}
{
  "time": "2025-01-27T16:50:33Z",
  "operationName": "MICROSOFT.KEYVAULT/LOCATIONS/DELETEDVAULTS/PURGE/ACTION",
  "resourceId": "/subscriptions/00000000-0000-0000-0000-000000000000/providers/Microsoft.KeyVault/locations/eastus/deletedVaults/prod-encryption-vault",
  "resultType": "Success",
  "callerIpAddress": "203.0.113.42",
  "properties": {
    "scheduledPurgeDate": "2025-04-27T16:45:12Z",
    "deletionDate": "2025-01-27T16:45:12Z"
  }
}

The scheduledPurgeDate shows when automatic purge would have occurred. Manual purge operations override this schedule, indicating intentional destruction rather than policy-based cleanup. To detect Key Vault purge operations:

KEYVAULT_PURGE = "MICROSOFT.KEYVAULT/LOCATIONS/DELETEDVAULTS/PURGE/ACTION"

def rule(event):
    return event.get("operationName", "").upper() == KEYVAULT_PURGE and azure_activity_success(
        event
    )
KEYVAULT_PURGE = "MICROSOFT.KEYVAULT/LOCATIONS/DELETEDVAULTS/PURGE/ACTION"

def rule(event):
    return event.get("operationName", "").upper() == KEYVAULT_PURGE and azure_activity_success(
        event
    )
KEYVAULT_PURGE = "MICROSOFT.KEYVAULT/LOCATIONS/DELETEDVAULTS/PURGE/ACTION"

def rule(event):
    return event.get("operationName", "").upper() == KEYVAULT_PURGE and azure_activity_success(
        event
    )
KEYVAULT_PURGE = "MICROSOFT.KEYVAULT/LOCATIONS/DELETEDVAULTS/PURGE/ACTION"

def rule(event):
    return event.get("operationName", "").upper() == KEYVAULT_PURGE and azure_activity_success(
        event
    )

Cryptographic Key Purged

Attackers can also purge individual cryptographic keys while leaving the vault intact. This approach destroys specific encryption keys used for SSE-CMK encrypted storage accounts without raising alarms about vault deletion.

{
  "time": "2025-01-27T16:55:01Z",
  "resourceId": "/subscriptions/00000000-0000-0000-0000-000000000000/providers/Microsoft.KeyVault/vaults/prod-vault/keys/storage-encryption-key-2025",
  "operationName": "MICROSOFT.KEYVAULT/VAULTS/KEYS/PURGE/ACTION",
  "resultType": "Success",
  "callerIpAddress": "203.0.113.42",
  "properties": {
    "keyType": "RSA",
    "keySize": 4096,
    "deletionDate": "2025-01-27T16:50:00Z"
  }
}
{
  "time": "2025-01-27T16:55:01Z",
  "resourceId": "/subscriptions/00000000-0000-0000-0000-000000000000/providers/Microsoft.KeyVault/vaults/prod-vault/keys/storage-encryption-key-2025",
  "operationName": "MICROSOFT.KEYVAULT/VAULTS/KEYS/PURGE/ACTION",
  "resultType": "Success",
  "callerIpAddress": "203.0.113.42",
  "properties": {
    "keyType": "RSA",
    "keySize": 4096,
    "deletionDate": "2025-01-27T16:50:00Z"
  }
}
{
  "time": "2025-01-27T16:55:01Z",
  "resourceId": "/subscriptions/00000000-0000-0000-0000-000000000000/providers/Microsoft.KeyVault/vaults/prod-vault/keys/storage-encryption-key-2025",
  "operationName": "MICROSOFT.KEYVAULT/VAULTS/KEYS/PURGE/ACTION",
  "resultType": "Success",
  "callerIpAddress": "203.0.113.42",
  "properties": {
    "keyType": "RSA",
    "keySize": 4096,
    "deletionDate": "2025-01-27T16:50:00Z"
  }
}
{
  "time": "2025-01-27T16:55:01Z",
  "resourceId": "/subscriptions/00000000-0000-0000-0000-000000000000/providers/Microsoft.KeyVault/vaults/prod-vault/keys/storage-encryption-key-2025",
  "operationName": "MICROSOFT.KEYVAULT/VAULTS/KEYS/PURGE/ACTION",
  "resultType": "Success",
  "callerIpAddress": "203.0.113.42",
  "properties": {
    "keyType": "RSA",
    "keySize": 4096,
    "deletionDate": "2025-01-27T16:50:00Z"
  }
}

The key must be in a soft-deleted state before purging. Attackers typically delete the key, then immediately purge it to minimize recovery windows. To detect cryptographic key purge operations:

KEY_PURGE = "MICROSOFT.KEYVAULT/VAULTS/KEYS/PURGE/ACTION"

def rule(event):
    return (
        event.get("operationName", "").upper() == KEY_PURGE
        and azure_activity_success(event)
    )
KEY_PURGE = "MICROSOFT.KEYVAULT/VAULTS/KEYS/PURGE/ACTION"

def rule(event):
    return (
        event.get("operationName", "").upper() == KEY_PURGE
        and azure_activity_success(event)
    )
KEY_PURGE = "MICROSOFT.KEYVAULT/VAULTS/KEYS/PURGE/ACTION"

def rule(event):
    return (
        event.get("operationName", "").upper() == KEY_PURGE
        and azure_activity_success(event)
    )
KEY_PURGE = "MICROSOFT.KEYVAULT/VAULTS/KEYS/PURGE/ACTION"

def rule(event):
    return (
        event.get("operationName", "").upper() == KEY_PURGE
        and azure_activity_success(event)
    )

Attack Matrix

Attack Stage

Detection Rule

Required Azure Permissions

Additional Requirements

Recommended Preventive Controls

Severity

MITRE ATT&CK

Rule ID

Privilege Escalation

Elevate Access

Microsoft.Authorization/elevateAccess/action

Global Administrator role

PIM for Azure Resources, Conditional Access policies

Critical

T1098

Azure.MonitorActivity.Authorization.ElevateAccess

Privilege Escalation

Privileged Role Assignment

Microsoft.Authorization/roleAssignments/write

Access to privileged roles

PIM with approval workflows, role assignment alerts

High

T1098

Azure.MonitorActivity.Authorization.PrivilegedRoleAssignment

Defense Evasion

Resource Lock Deleted

Microsoft.Authorization/locks/delete

None

Azure Policy to enforce locks, RBAC restrictions

High

T1562, T1490

Azure.MonitorActivity.Authorization.ResourceLockDeleted

Defense Evasion

Immutability Policy Deleted

Microsoft.Storage/storageAccounts/blobServices/containers/immutabilityPolicies/delete

None

Lock immutability policies, restrict delete permissions

Critical

T1562.001, T1490, T1485

Azure.MonitorActivity.Storage.ImmutabilityPolicyDeleted

Defense Evasion

Blob Soft Delete Disabled

Microsoft.Storage/storageAccounts/write

None

Azure Policy to enforce soft delete, deny policy modifications

High

T1485, T1490

Azure.MonitorActivity.StorageAccount.BlobSoftDeleteDisabled

Defense Evasion

Blob Versioning Disabled

Microsoft.Storage/storageAccounts/write

None

Azure Policy to enforce versioning

High

T1485

Azure.MonitorActivity.StorageAccount.VersioningDisabled

Defense Evasion

Diagnostic Settings Deleted

Microsoft.Insights/diagnosticSettings/delete

None

Resource locks on diagnostic settings, Azure Policy

Medium

T1562.008

Azure.MonitorActivity.Insights.DiagnosticSettingsDeleted

Credential Access

Storage Account Keys Listed

Microsoft.Storage/storageAccounts/listKeys/action

None

Disable key access, use Azure AD authentication, key rotation

Medium

T1552, T1530

Azure.MonitorActivity.StorageAccount.KeysListed

Collection

Bulk Data Extraction

Microsoft.Storage/storageAccounts/blobServices/containers/blobs/read

Network access to storage

Private endpoints, firewall rules, data exfiltration policies

Medium

T1567, T1530

Azure.MonitorActivity.Storage.Blob.BulkExtraction

Exfiltration

SAS Token External Access

Ability to generate SAS tokens

Valid SAS token

Stored access policies, short token expiry, IP restrictions

Low

T1567, T1552.001

Azure.MonitorActivity.Storage.SASTokenExternalAccess

Impact - Backup Destruction

Recovery Services Container Deleted

Microsoft.RecoveryServices/vaults/backupFabrics/protectionContainers/delete

None

Soft delete for backups, MUA for critical operations, RBAC

Critical

T1490, T1485

Azure.MonitorActivity.RecoveryServices.ProtectionContainerDeleted

Impact - Data Encryption

CPK Encryption Detected

Microsoft.Storage/storageAccounts/blobServices/containers/blobs/write

Customer-provided encryption key

Restrict CPK usage via policy, monitor for key changes

High

T1486, T1490

Azure.MonitorActivity.Storage.Blob.CPKEncryptionDetected

Impact - Data Destruction

Bulk Blob Deletion

Microsoft.Storage/storageAccounts/blobServices/containers/blobs/delete

None

Soft delete, versioning, immutability policies, retention locks

Medium

T1485, T1490

Azure.MonitorActivity.Storage.Blob.BulkDeletion

Impact - Key Destruction

Key Vault Deleted

Microsoft.KeyVault/vaults/delete

None

Soft delete enabled, purge protection, resource locks

High

T1485, T1490

Azure.MonitorActivity.KeyVault.Deleted

Impact - Key Destruction

Key Vault Purged

Microsoft.KeyVault/vaults/purge

Soft delete period expired or purge permission

Enable purge protection (mandatory), restrict purge permissions

Critical

T1485, T1490

Azure.MonitorActivity.KeyVault.Purged

Impact - Key Destruction

Cryptographic Key Purged

Microsoft.KeyVault/vaults/keys/purge

Soft delete period expired or purge permission

Enable purge protection, HSM-backed keys, restrict purge

High

T1485, T1490

Azure.MonitorActivity.KeyVault.KeyPurged

Tuning Guidance

Several detection rules in this section identify external access or unusual patterns that may trigger during legitimate operations. To reduce false positives, you can apply inline filters.

For SAS token access from known partner IP ranges, you can apply this filter to the rule "Azure Storage SAS External Access":

callerIpAddress does not match <ALLOWED_IP_PATTERN>

For storage accounts intended for external access (e.g., CDN backends, public data), you can exclude them:

uri does not contain <ALLOWED_STORAGE_ACCOUNT>

To identify a baseline of legitimate operations, query your logs to extract which Key Vaults are used for encryption operations and which IP ranges access storage via SAS tokens.

Conclusion

Azure ransomware attacks follow predictable patterns: escalating privileges through compromised identities, systematically disabling recovery mechanisms (resource locks, immutability policies, soft delete), exfiltrating data via SAS tokens, and either encrypting data with customer-provided keys or destroying it entirely alongside backups and encryption keys. Storm-0501 and similar threat actors demonstrate that cloud-native ransomware only requires sufficient permissions and knowledge of native Azure capabilities.

The detection rules in this report provide comprehensive visibility across all seven attack stages, from initial privilege escalation through recovery prevention. However, Azure's logging model presents challenges: Customer-Provided Key (CPK) encryption is transparent in write operations and only becomes visible when victims attempt access, storage account key theft is logged but the keys themselves are not, and data plane operations require diagnostic settings enabled per storage account. Organizations without comprehensive logging enabled may detect ransomware only after irreversible destruction has occurred.

The most effective defense against cloud ransomware remains making the attack prerequisites impossible to achieve: if attackers cannot disable recovery mechanisms, cannot access data plane operations from external infrastructure, and cannot destroy encryption keys, ransomware impact is limited to the permissions granted to compromised identities.

Read more of Panther's latest threat research here.

Share:

Ready for less noise
and more control?

See Panther in action. Book a demo today.

Get product updates, webinars, and news

By submitting this form, you acknowledge and agree that Panther will process your personal information in accordance with the Privacy Policy.

Get product updates, webinars, and news

By submitting this form, you acknowledge and agree that Panther will process your personal information in accordance with the Privacy Policy.

Product
Resources
Support
Company

Get product updates, webinars, and news

By submitting this form, you acknowledge and agree that Panther will process your personal information in accordance with the Privacy Policy.