Azure Storage SAS Token Access from External IP


Description

Detects SAS token usage from external public IPs by parsing the signature parameter in storage URIs. Storm-0501 uses stolen SAS tokens from external C2 infrastructure for data exfiltration. Replicates Defender for Cloud alert 'Storage.Blob_AccountSas.InternalSasUsedExternally'.

Query · python

import ipaddress
from urllib.parse import parse_qs, urlparse


def rule(event):
    """
    Detects SAS token usage from external IP addresses.
    Replicates Defender for Cloud: Storage.Blob_AccountSas.InternalSasUsedExternally
    """
    # 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 extract_caller_ip(event):
    """Extract IP address from callerIpAddress field, removing port if present"""
    caller_ip_address = event.get("callerIpAddress", "")
    if not caller_ip_address:
        return ""
    # Split by colon to remove port if present
    return caller_ip_address.split(":")[0] if ":" in caller_ip_address else caller_ip_address


def is_private_ip(ip_address):
    """Check if IP is in private ranges (RFC1918) or localhost"""
    if not ip_address:
        return True  # Treat empty/missing IPs as private to filter them out

    try:
        ip_obj = ipaddress.ip_address(ip_address)
        return ip_obj.is_private or ip_obj.is_loopback
    except ValueError:
        # Unparseable IPs are treated as external (suspicious) to avoid missing potential threats
        return False


def is_permissive_sas(uri):
    """
    Check if SAS token has write, delete, or add permissions.
    SAS permissions in 'sp' parameter: r=read, a=add, c=create, w=write, d=delete, l=list
    """
    if not uri:
        return False  # Unknown URIs default to non-permissive (read-only assumption)

    parsed = urlparse(uri)
    params = parse_qs(parsed.query)
    permissions = params.get("sp", [""])[0]

    # Check for dangerous permissions
    return any(perm in permissions for perm in ["w", "d", "a"])


def title(event):
    caller_ip = extract_caller_ip(event) or "<UNKNOWN_IP>"
    storage_account = event.deep_get("properties", "accountName", default="<UNKNOWN_ACCOUNT>")
    operation = event.get("operationName", "<UNKNOWN_OPERATION>")

    return (
        f"Azure Storage SAS token used from external IP [{caller_ip}] "
        f"to access [{storage_account}] with operation [{operation}]"
    )


def severity(event):
    """Higher severity for write/delete operations"""
    operation = event.get("operationName", "").lower()

    # Check if this is a write/delete operation or has permissive SAS
    uri = event.get("uri", "")
    if is_permissive_sas(uri):
        return "HIGH"

    # Delete operations are always high severity
    if "delete" in operation:
        return "HIGH"

    # Write operations are medium severity
    if any(op in operation for op in ["put", "write", "create", "set"]):
        return "MEDIUM"

    # Read-only operations from external IPs are low severity
    return "LOW"


def alert_context(event):
    # Start with standard Azure activity context
    context = {
        "caller_ip": extract_caller_ip(event) or "<UNKNOWN>",
        "storage_account": event.deep_get("properties", "accountName", default="<UNKNOWN>"),
        "operation": event.get("operationName", "<UNKNOWN_OPERATION>"),
        "object_key": event.deep_get("properties", "objectKey", default="<UNKNOWN>"),
        "user_agent": event.deep_get("properties", "userAgentHeader", default="<UNKNOWN>"),
        "uri": event.get("uri", "<UNKNOWN_URI>"),
        "status_code": event.get("statusCode"),
        "category": event.get("category"),
    }

    # Extract SAS-specific parameters from URI
    uri = event.get("uri", "")
    if uri:
        parsed = urlparse(uri)
        params = parse_qs(parsed.query)
        if "sp" in params:
            context["sas_permissions"] = params["sp"][0]
        if "se" in params:
            context["sas_expiry"] = params["se"][0]

    return context


def dedup(event):
    """Group alerts by storage account and external IP"""
    caller_ip = extract_caller_ip(event) or "unknown"
    storage_account = event.deep_get("properties", "accountName", default="unknown")
    return f"{storage_account}:{caller_ip}"

Analyst notes

  1. Query Azure Monitor Activity logs for SAS token generation operations (Microsoft.Storage/storageAccounts/listAccountSas/action) by the same identity in the 24 hours before this access to identify when the token was created and by whom
  2. Find all storage operations (GetBlob, ListBlobs, DeleteBlob) from the callerIpAddress in the 6 hours before and after the alert to assess if this is isolated access or part of bulk exfiltration
  3. Check if the callerIpAddress has accessed this storage account in the past 90 days to establish if this external IP is expected
  4. Review Azure Audit logs for authentication events from the callerIpAddress in the 48 hours before the alert to identify potential account compromise or credential theft
  5. Search for other alerts with the same callerIpAddress across all storage accounts in the past 7 days to identify if this is targeted or widespread
Raw source Azure Storage SAS Token Access from External IP · Panther Python
Esc
Published by panther-labs/panther-analysis ↗, licensed under Apache 2.0 ↗. Reproduced here unmodified.
AnalysisType: rule
Filename: azure_storage_sas_external_access.py
RuleID: "Azure.MonitorActivity.Storage.SASTokenExternalAccess"
DisplayName: "Azure Storage SAS Token Access from External IP"
Enabled: true
LogTypes:
  - Azure.MonitorActivity
Severity: Low
DedupPeriodMinutes: 60
Description: >
  Detects SAS token usage from external public IPs by parsing the signature parameter in storage URIs.
  Storm-0501 uses stolen SAS tokens from external C2 infrastructure for data exfiltration.
  Replicates Defender for Cloud alert 'Storage.Blob_AccountSas.InternalSasUsedExternally'.
Reports:
  MITRE ATT&CK:
    - TA0010:T1567 # Exfiltration: Exfiltration Over Web Service
    - TA0006:T1552.001 # Credential Access: Unsecured Credentials - Credentials In Files
Reference: https://learn.microsoft.com/en-us/azure/defender-for-cloud/alerts-azure-storage
Tags:
  - Exfiltration
  - Credential Access
  - Storm-0501
  - Defender for Cloud
  - SAS Token
Runbook: |
  1. Query Azure Monitor Activity logs for SAS token generation operations (Microsoft.Storage/storageAccounts/listAccountSas/action) by the same identity in the 24 hours before this access to identify when the token was created and by whom
  2. Find all storage operations (GetBlob, ListBlobs, DeleteBlob) from the callerIpAddress in the 6 hours before and after the alert to assess if this is isolated access or part of bulk exfiltration
  3. Check if the callerIpAddress has accessed this storage account in the past 90 days to establish if this external IP is expected
  4. Review Azure Audit logs for authentication events from the callerIpAddress in the 48 hours before the alert to identify potential account compromise or credential theft
  5. Search for other alerts with the same callerIpAddress across all storage accounts in the past 7 days to identify if this is targeted or widespread
SummaryAttributes:
  - callerIpAddress
  - properties:accountName
  - operationName
Tests:
  - Name: SAS Token from External IP - Read Operation
    ExpectedResult: true
    Log:
      {
        "time": "2025-01-28T15:30:00.0000000Z",
        "category": "StorageRead",
        "operationName": "GetBlob",
        "callerIpAddress": "8.8.8.8:45123",
        "statusCode": 200,
        "uri": "https://criticaldata001.blob.core.windows.net/backups/data.zip?sv=2021-12-02&ss=b&srt=sco&sp=rl&se=2025-12-31T23:59:59Z&sig=SIGNATURE_HERE",
        "properties": {
          "accountName": "criticaldata001",
          "objectKey": "/criticaldata001/backups/data.zip",
          "userAgentHeader": "python-requests/2.31.0",
          "metricResponseType": "Success"
        },
        "p_log_type": "Azure.MonitorActivity"
      }
  - Name: SAS Token from External IP - Delete Operation
    ExpectedResult: true
    Log:
      {
        "time": "2025-01-28T16:00:00.0000000Z",
        "category": "StorageDelete",
        "operationName": "DeleteBlob",
        "callerIpAddress": "1.1.1.1:12345",
        "statusCode": 202,
        "uri": "https://proddata.blob.core.windows.net/financial/records.xlsx?sv=2021-12-02&sp=rwdl&sig=ATTACKER_SIG",
        "properties": {
          "accountName": "proddata",
          "objectKey": "/proddata/financial/records.xlsx",
          "userAgentHeader": "curl/7.88.0",
          "metricResponseType": "Success"
        },
        "p_log_type": "Azure.MonitorActivity"
      }
  - Name: SAS Token from Internal IP 
    ExpectedResult: false
    Log:
      {
        "time": "2025-01-28T16:30:00.0000000Z",
        "category": "StorageWrite",
        "operationName": "PutBlob",
        "callerIpAddress": "10.158.74.16:36683",
        "statusCode": 201,
        "uri": "https://internaldata.blob.core.windows.net/logs/app.log?sv=2021-12-02&sp=w&sig=INTERNAL_SIG",
        "properties": {
          "accountName": "internaldata",
          "objectKey": "/internaldata/logs/app.log",
          "userAgentHeader": "Azure-Storage/1.0",
          "metricResponseType": "Success"
        },
        "p_log_type": "Azure.MonitorActivity"
      }
  - Name: Storage Account Key Auth from External IP 
    ExpectedResult: false
    Log:
      {
        "time": "2025-01-28T17:00:00.0000000Z",
        "category": "StorageRead",
        "operationName": "GetBlob",
        "callerIpAddress": "9.9.9.9:55555",
        "statusCode": 200,
        "uri": "https://publicdata.blob.core.windows.net/downloads/file.pdf",
        "properties": {
          "accountName": "publicdata",
          "objectKey": "/publicdata/downloads/file.pdf",
          "userAgentHeader": "Microsoft.Azure.Storage/9.0",
          "metricResponseType": "Success"
        },
        "identity": {
          "type": "AccountKey"
        },
        "p_log_type": "Azure.MonitorActivity"
      }
  - Name: Failed SAS Token Access 
    ExpectedResult: false
    Log:
      {
        "time": "2025-01-28T17:30:00.0000000Z",
        "category": "StorageRead",
        "operationName": "GetBlob",
        "callerIpAddress": "4.4.4.4:33333",
        "statusCode": 403,
        "statusText": "AuthenticationFailed",
        "uri": "https://securedata.blob.core.windows.net/secrets/key.txt?sv=2021-12-02&sp=r&sig=EXPIRED_SIG",
        "properties": {
          "accountName": "securedata",
          "objectKey": "/securedata/secrets/key.txt",
          "metricResponseType": "AuthenticationFailed"
        },
        "p_log_type": "Azure.MonitorActivity"
      }


# ------ paired body: azure_storage_sas_external_access.py ------

import ipaddress
from urllib.parse import parse_qs, urlparse


def rule(event):
    """
    Detects SAS token usage from external IP addresses.
    Replicates Defender for Cloud: Storage.Blob_AccountSas.InternalSasUsedExternally
    """
    # 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 extract_caller_ip(event):
    """Extract IP address from callerIpAddress field, removing port if present"""
    caller_ip_address = event.get("callerIpAddress", "")
    if not caller_ip_address:
        return ""
    # Split by colon to remove port if present
    return caller_ip_address.split(":")[0] if ":" in caller_ip_address else caller_ip_address


def is_private_ip(ip_address):
    """Check if IP is in private ranges (RFC1918) or localhost"""
    if not ip_address:
        return True  # Treat empty/missing IPs as private to filter them out

    try:
        ip_obj = ipaddress.ip_address(ip_address)
        return ip_obj.is_private or ip_obj.is_loopback
    except ValueError:
        # Unparseable IPs are treated as external (suspicious) to avoid missing potential threats
        return False


def is_permissive_sas(uri):
    """
    Check if SAS token has write, delete, or add permissions.
    SAS permissions in 'sp' parameter: r=read, a=add, c=create, w=write, d=delete, l=list
    """
    if not uri:
        return False  # Unknown URIs default to non-permissive (read-only assumption)

    parsed = urlparse(uri)
    params = parse_qs(parsed.query)
    permissions = params.get("sp", [""])[0]

    # Check for dangerous permissions
    return any(perm in permissions for perm in ["w", "d", "a"])


def title(event):
    caller_ip = extract_caller_ip(event) or "<UNKNOWN_IP>"
    storage_account = event.deep_get("properties", "accountName", default="<UNKNOWN_ACCOUNT>")
    operation = event.get("operationName", "<UNKNOWN_OPERATION>")

    return (
        f"Azure Storage SAS token used from external IP [{caller_ip}] "
        f"to access [{storage_account}] with operation [{operation}]"
    )


def severity(event):
    """Higher severity for write/delete operations"""
    operation = event.get("operationName", "").lower()

    # Check if this is a write/delete operation or has permissive SAS
    uri = event.get("uri", "")
    if is_permissive_sas(uri):
        return "HIGH"

    # Delete operations are always high severity
    if "delete" in operation:
        return "HIGH"

    # Write operations are medium severity
    if any(op in operation for op in ["put", "write", "create", "set"]):
        return "MEDIUM"

    # Read-only operations from external IPs are low severity
    return "LOW"


def alert_context(event):
    # Start with standard Azure activity context
    context = {
        "caller_ip": extract_caller_ip(event) or "<UNKNOWN>",
        "storage_account": event.deep_get("properties", "accountName", default="<UNKNOWN>"),
        "operation": event.get("operationName", "<UNKNOWN_OPERATION>"),
        "object_key": event.deep_get("properties", "objectKey", default="<UNKNOWN>"),
        "user_agent": event.deep_get("properties", "userAgentHeader", default="<UNKNOWN>"),
        "uri": event.get("uri", "<UNKNOWN_URI>"),
        "status_code": event.get("statusCode"),
        "category": event.get("category"),
    }

    # Extract SAS-specific parameters from URI
    uri = event.get("uri", "")
    if uri:
        parsed = urlparse(uri)
        params = parse_qs(parsed.query)
        if "sp" in params:
            context["sas_permissions"] = params["sp"][0]
        if "se" in params:
            context["sas_expiry"] = params["se"][0]

    return context


def dedup(event):
    """Group alerts by storage account and external IP"""
    caller_ip = extract_caller_ip(event) or "unknown"
    storage_account = event.deep_get("properties", "accountName", default="unknown")
    return f"{storage_account}:{caller_ip}"

Detection rules belong to the projects that publish them and remain under their own licenses. This site indexes and links to them; it claims no rights in them.