Detection rules › Kusto

Linked Malicious Storage Artifacts

Status
available
Severity
medium
Time window
1d
Group by
AlertType, AttackerCountry, AttackerIP, ClientIP, DisplayName, MaliciousFileDirectory, MaliciousFileHashes, MaliciousFileName, ResourceId, TimeGenerated
Source
github.com/Azure/Azure-Sentinel

'This query identifies the additional files uploaded by the same IP address which triggered a malware alert for malicious content upload on Azure Blob or File Storage Container.'

MITRE ATT&CK coverage

TacticTechniques
Command & ControlT1071 Application Layer Protocol
ExfiltrationT1567 Exfiltration Over Web Service

Rule body kusto

id: b9e3b9f8-a406-4151-9891-e5ff1ddd8c1d
name: Linked Malicious Storage Artifacts
description: |
  'This query identifies the additional files uploaded by the same IP address which triggered a malware alert for malicious content upload on Azure Blob or File Storage Container.'
severity: Medium
status: Available
requiredDataConnectors:
  - connectorId: MicrosoftCloudAppSecurity
    dataTypes:
      - SecurityAlert
queryFrequency: 1d
queryPeriod: 1d
triggerOperator: gt
triggerThreshold: 0
tactics:
  - CommandAndControl
  - Exfiltration
relevantTechniques:
  - T1071
  - T1567
query: |
  //Collect the alert events
  let alertData = SecurityAlert
  | where DisplayName has "Potential malware uploaded to"
  | extend Entities = parse_json(Entities)
  | mv-expand Entities;
  //Parse the IP address data
  let ipData = alertData
  | where Entities['Type'] =~ "ip"
  | extend AttackerIP = tostring(Entities['Address']), AttackerCountry = tostring(Entities['Location']['CountryName']);
  //Parse the file data
  let FileData = alertData
  | where Entities['Type'] =~ "file"
  | extend MaliciousFileDirectory = tostring(Entities['Directory']), MaliciousFileName = tostring(Entities['Name']), MaliciousFileHashes = tostring(Entities['FileHashes']);
  //Combine the File and IP data together
  ipData
  | join (FileData) on VendorOriginalId
  | summarize by TimeGenerated, AttackerIP, AttackerCountry, DisplayName, ResourceId, AlertType, MaliciousFileDirectory, MaliciousFileName, MaliciousFileHashes
  //Create a type column so we can track if it was a File storage or blobl storage upload
  | extend type = iff(DisplayName has "file", "File", "Blob")
  | join (
    union
    StorageFileLogs,
    StorageBlobLogs
    //File upload operations
    | where OperationName =~ "PutBlob" or OperationName =~ "PutRange"
    //Parse out the uploader IP
    | extend ClientIP = tostring(split(CallerIpAddress, ":", 0)[0])
    //Extract the filename from the Uri
    | extend FileName = extract(@"\/([\w\-. ]+)\?", 1, Uri)
    //Base64 decode the MD5 filehash, we will encounter non-ascii hex so string operations don't work
    //We can work around this by making it an array then converting it to hex from an int
    | extend base64Char = base64_decode_toarray(ResponseMd5)
    | mv-expand base64Char
    | extend hexChar = tohex(toint(base64Char))
    | extend hexChar = iff(strlen(hexChar) < 2, strcat("0", hexChar), hexChar)
    | extend SourceTable = iff(OperationName has "range", "StorageFileLogs", "StorageBlobLogs")
    | summarize make_list(hexChar, 1000) by CorrelationId, ResponseMd5, FileName, AccountName, TimeGenerated, RequestBodySize, ClientIP, SourceTable
    | extend Md5Hash = strcat_array(list_hexChar, "")
    //Pack the file information the summarise into a ClientIP row
    | extend p = pack("FileName", FileName, "FileSize", RequestBodySize, "Md5Hash", Md5Hash, "Time", TimeGenerated, "SourceTable", SourceTable)
    | summarize UploadedFileInfo=make_list(p, 10000), FilesUploaded=count() by ClientIP
        | join kind=leftouter (
          union
          StorageFileLogs,
          StorageBlobLogs
          | where OperationName =~ "DeleteFile" or OperationName =~ "DeleteBlob"
          | extend ClientIP = tostring(split(CallerIpAddress, ":", 0)[0])
          | extend FileName = extract(@"\/([\w\-. ]+)\?", 1, Uri)
          | extend SourceTable = iff(OperationName has "range", "StorageFileLogs", "StorageBlobLogs")
          | extend p = pack("FileName", FileName, "Time", TimeGenerated, "SourceTable", SourceTable)
          | summarize DeletedFileInfo=make_list(p, 10000), FilesDeleted=count() by ClientIP
          ) on ClientIP
    ) on $left.AttackerIP == $right.ClientIP
  | mvexpand UploadedFileInfo
  | extend LinkedMaliciousFileName = tostring(UploadedFileInfo.FileName)
  | extend LinkedMaliciousFileHash = tostring(UploadedFileInfo.Md5Hash)
  | extend HashAlgorithm = "MD5"
  | project AlertTimeGenerated = TimeGenerated, LinkedMaliciousFileName, LinkedMaliciousFileHash, HashAlgorithm, AlertType, AttackerIP, AttackerCountry, MaliciousFileDirectory, MaliciousFileName, FilesUploaded, UploadedFileInfo
entityMappings:
  - entityType: IP
    fieldMappings:
      - identifier: Address
        columnName: AttackerIP
  - entityType: FileHash
    fieldMappings:
      - identifier: Algorithm
        columnName: HashAlgorithm
      - identifier: Value
        columnName: LinkedMaliciousFileHash
version: 1.0.3
kind: Scheduled

Stages and Predicates

Let binding: FileData

let FileData = alertData
| where Entities['Type'] =~ "file"
| extend MaliciousFileDirectory = tostring(Entities['Directory']), MaliciousFileName = tostring(Entities['Name']), MaliciousFileHashes = tostring(Entities['FileHashes']);

Derived from alertData.

The stages below define let ipData (the rule's main pipeline source).

Stage 1: source

SecurityAlert

Stage 2: where

| where DisplayName has "Potential malware uploaded to"

Stage 3: extend

| extend Entities = parse_json(Entities)

Stage 4: mv-expand

| mv-expand Entities

Stage 5: where

| where Entities['Type'] =~ "ip"

Stage 6: extend

| extend AttackerIP = tostring(Entities['Address']), AttackerCountry = tostring(Entities['Location']['CountryName'])

The stages below run on ipData (the outer pipeline).

Stage 7: join

ipData
| join (FileData) on VendorOriginalId

Stage 8: summarize

| summarize by TimeGenerated, AttackerIP, AttackerCountry, DisplayName, ResourceId, AlertType, MaliciousFileDirectory, MaliciousFileName, MaliciousFileHashes

Stage 9: extend

| extend type = iff(DisplayName has "file", "File", "Blob")
type =
ifDisplayName has "file""File"
else"Blob"

Stage 10: join

| join (
  union
  StorageFileLogs,
  StorageBlobLogs
  | where OperationName =~ "PutBlob" or OperationName =~ "PutRange"
  | extend ClientIP = tostring(split(CallerIpAddress, ":", 0)[0])
  | extend FileName = extract(@"\/([\w\-. ]+)\?", 1, Uri)
  | extend base64Char = base64_decode_toarray(ResponseMd5)
  | mv-expand base64Char
  | extend hexChar = tohex(toint(base64Char))
  | extend hexChar = iff(strlen(hexChar) < 2, strcat("0", hexChar), hexChar)
  | extend SourceTable = iff(OperationName has "range", "StorageFileLogs", "StorageBlobLogs")
  | summarize make_list(hexChar, 1000) by CorrelationId, ResponseMd5, FileName, AccountName, TimeGenerated, RequestBodySize, ClientIP, SourceTable
  | extend Md5Hash = strcat_array(list_hexChar, "")
  | extend p = pack("FileName", FileName, "FileSize", RequestBodySize, "Md5Hash", Md5Hash, "Time", TimeGenerated, "SourceTable", SourceTable)
  | summarize UploadedFileInfo=make_list(p, 10000), FilesUploaded=count() by ClientIP
      | join kind=leftouter (
        union
        StorageFileLogs,
        StorageBlobLogs
        | where OperationName =~ "DeleteFile" or OperationName =~ "DeleteBlob"
        | extend ClientIP = tostring(split(CallerIpAddress, ":", 0)[0])
        | extend FileName = extract(@"\/([\w\-. ]+)\?", 1, Uri)
        | extend SourceTable = iff(OperationName has "range", "StorageFileLogs", "StorageBlobLogs")
        | extend p = pack("FileName", FileName, "Time", TimeGenerated, "SourceTable", SourceTable)
        | summarize DeletedFileInfo=make_list(p, 10000), FilesDeleted=count() by ClientIP
        ) on ClientIP
  ) on $left.AttackerIP == $right.ClientIP

Stage 11: mv-expand

| mvexpand UploadedFileInfo

Stage 12: extend (3 consecutive steps)

| extend LinkedMaliciousFileName = tostring(UploadedFileInfo.FileName)
| extend LinkedMaliciousFileHash = tostring(UploadedFileInfo.Md5Hash)
| extend HashAlgorithm = "MD5"

Stage 13: project

| project AlertTimeGenerated = TimeGenerated, LinkedMaliciousFileName, LinkedMaliciousFileHash, HashAlgorithm, AlertType, AttackerIP, AttackerCountry, MaliciousFileDirectory, MaliciousFileName, FilesUploaded, UploadedFileInfo

Indicators

Each row is a field, operator, and value that the rule matches. The corpus column counts how many other rules in the catalog look for the same combination: high numbers point to widely-used, community-vetted indicators. Blank or 1 shows that the indicator is specific to this rule.

FieldKindValues
DisplayNamematch
  • Potential malware uploaded to transforms: term
OperationNameeq
  • DeleteBlob
  • DeleteFile
  • PutBlob
  • PutRange
Typeeq
  • file
  • ip

Output fields

Fields the rule emits when it matches. Chronicle authors list these in the outcome block; they appear on the detection and $risk_score drives alerting. Sentinel / Defender XDR rules build them up through project / summarize / extend stages. Sentinel maps these into alert fields via entityMappings and customDetails; Defender XDR custom detections surface them as alert fields directly.

FieldSource
AlertTimeGeneratedproject
AlertTypeproject
AttackerCountryproject
AttackerIPproject
FilesUploadedproject
HashAlgorithmproject
LinkedMaliciousFileHashproject
LinkedMaliciousFileNameproject
MaliciousFileDirectoryproject
MaliciousFileNameproject
UploadedFileInfoproject