← Blog · · df00tech

Detecting Data Exfiltration (T1041, T1567.002, T1048): KQL and SPL Queries for Staging, Cloud Uploads, and Egress Anomalies

Detection Engineering MITRE ATT&CK KQL SPL Threat Hunting

Most detection libraries are heavily front-loaded. Teams build deep coverage for initial access, execution, and credential theft, then thin out badly by the time an adversary reaches the part of the kill chain that actually generates the breach notification: getting the data out. Exfiltration detections are unpopular because they are noisy — every legitimate business runs on file sharing, backups, and cloud sync — but they are also the last place you can intervene before an incident becomes a disclosure event.

This post covers five detections that work in production, written for both Microsoft Sentinel / Defender XDR (KQL) and Splunk (SPL). Each one maps to a specific ATT&CK technique, and each includes the tuning notes that keep it from drowning your queue.

Model exfiltration as three stages, not one

The single biggest mistake in exfil detection is writing one rule that tries to catch "data leaving." By the time bytes cross the perimeter you have lost most of your context — you see a source IP, a destination, and a volume. Instead, split the behaviour into three observable stages:

  • Collection and staging — the adversary pulls files into one place and compresses them (T1119, T1560.001, T1213). This is the highest-fidelity stage: it happens on an endpoint you have EDR on, and the artefacts are unambiguous.
  • Transfer tooling — rclone, MEGAcmd, WinSCP, curl, or a PowerShell one-liner (T1567.002, T1048).
  • Egress anomaly — volume, ratio, and destination outliers (T1041, T1030).

Stage one gives you time. Stage three gives you scope. You want both.

Detection 1: Archive staging in temp and public directories (T1560.001)

Ransomware crews and data-theft-only groups alike compress before they upload — it is faster, it defeats naive DLP content inspection, and it produces a single object to move. The signal is archive creation in a directory no user would deliberately choose, by a process that is not a user-launched archiver GUI.

DeviceFileEvents
| where Timestamp > ago(24h)
| where FileName endswith '.7z' or FileName endswith '.zip'
    or FileName endswith '.rar' or FileName endswith '.cab'
| where InitiatingProcessFileName in~ ('7z.exe','7za.exe','rar.exe','winrar.exe',
    'powershell.exe','pwsh.exe','tar.exe','makecab.exe','cmd.exe')
| where FolderPath has_any ('\\Windows\\Temp', '\\Users\\Public',
    '\\ProgramData', '\\AppData\\Local\\Temp', '$Recycle.Bin', '\\PerfLogs')
| summarize ArchiveCount = dcount(FileName),
            TotalBytes  = sum(FileSize),
            Samples     = make_set(FolderPath, 25)
    by DeviceId, DeviceName, InitiatingProcessAccountName,
       InitiatingProcessFileName, bin(Timestamp, 1h)
| where ArchiveCount > 3 or TotalBytes > 104857600
| order by TotalBytes desc

The Splunk equivalent using Sysmon Event ID 11:

index=sysmon EventCode=11
  (TargetFilename="*.7z" OR TargetFilename="*.zip" OR TargetFilename="*.rar" OR TargetFilename="*.cab")
  (TargetFilename="*\\Windows\\Temp\\*" OR TargetFilename="*\\Users\\Public\\*"
   OR TargetFilename="*\\ProgramData\\*" OR TargetFilename="*\\PerfLogs\\*")
| bin _time span=1h
| stats dc(TargetFilename) AS archive_count, values(TargetFilename) AS files
    BY _time, host, User, Image
| where archive_count > 3
| sort - archive_count

Tuning: your backup agent, your software packaging pipeline, and your crash-dump collector will all fire here. Suppress by InitiatingProcessFolderPath or signer rather than by filename — attackers drop a renamed 7za.exe into C:\ProgramData precisely because filename-based allowlists are common. A second useful pivot: archives whose size exceeds the total size of anything that user has legitimately created in the last 30 days.

Detection 2: Exfiltration tooling and renamed binaries (T1567.002)

rclone is now effectively standard tradecraft, and it is almost always renamed. Detect on the PE metadata, not the filename on disk.

DeviceProcessEvents
| where Timestamp > ago(7d)
| extend OrigName = tolower(tostring(ProcessVersionInfoOriginalFileName))
| where OrigName in ('rclone.exe','megacmd.exe','megatools.exe','winscp.exe',
                    'pscp.exe','psftp.exe','filezilla.exe')
    or ProcessCommandLine has_any ('rclone copy','rclone sync','rclone move',
                                   'rclone --config','mega-put','curl.exe -T',
                                   'Invoke-WebRequest -InFile','Invoke-RestMethod -InFile')
| extend Renamed = iff(tolower(FileName) != OrigName and isnotempty(OrigName), true, false)
| project Timestamp, DeviceName, AccountName, FileName, OrigName, Renamed,
          FolderPath, ProcessCommandLine, InitiatingProcessFileName
| order by Renamed desc, Timestamp desc
index=sysmon EventCode=1 earliest=-7d
| eval orig=lower(coalesce(OriginalFileName,"none")), img=lower(Image)
| where match(orig, "^(rclone|megacmd|megatools|winscp|pscp|psftp)\\.exe$")
    OR match(CommandLine, "(?i)(rclone\\s+(copy|sync|move)|mega-put|curl(\\.exe)?\\s+-T)")
| eval renamed=if(isnotnull(orig) AND NOT match(img, orig."$"), "yes", "no")
| table _time host User Image OriginalFileName renamed CommandLine ParentImage
| sort - renamed, - _time

Tuning: the Renamed flag is your severity dial. An rclone.exe at C:\Program Files\rclone\ run by an engineer is a low-priority informational hit; an svchost.exe in C:\Users\Public whose original filename is rclone.exe is a page-someone alert. Also watch for the config file — rclone invoked with --config pointing at a temp path means the operator brought their own remote definition.

Detection 3: Cloud upload volume outliers with a per-user baseline (T1567.002)

Static thresholds fail here — 5 GB is nothing for a video team and enormous for a payroll clerk. Baseline each identity against itself.

let lookback = 30d;
let baseline =
    CloudAppEvents
    | where Timestamp between (ago(lookback) .. ago(1d))
    | where ActionType has 'Upload' or ActionType in ('FileUploaded','FileSyncUploadedFull')
    | summarize DailyBytes = sum(todouble(RawEventData.ObjectSize))
        by AccountObjectId, bin(Timestamp, 1d)
    | summarize AvgBytes = avg(DailyBytes), SdBytes = stdev(DailyBytes)
        by AccountObjectId;
CloudAppEvents
| where Timestamp > ago(1d)
| where ActionType has 'Upload' or ActionType in ('FileUploaded','FileSyncUploadedFull')
| summarize TodayBytes = sum(todouble(RawEventData.ObjectSize)),
            Apps  = make_set(Application, 10),
            Files = dcount(ObjectName)
    by AccountObjectId, AccountDisplayName, IPAddress
| join kind=inner baseline on AccountObjectId
| where TodayBytes > AvgBytes + (3 * SdBytes) and TodayBytes > 52428800
| project AccountDisplayName, IPAddress, TodayBytes, AvgBytes, Files, Apps
| order by TodayBytes desc

For proxy or secure-web-gateway logs in Splunk, using a lookup of known cloud-storage domains:

index=proxy earliest=-30d
| lookup cloud_storage_domains domain AS url_domain OUTPUT is_storage
| where is_storage="true"
| bin _time span=1d
| stats sum(bytes_out) AS daily_bytes BY _time, user, url_domain
| eventstats avg(daily_bytes) AS avg_bytes, stdev(daily_bytes) AS sd_bytes BY user
| where _time >= relative_time(now(), "-1d@d")
    AND daily_bytes > (avg_bytes + 3 * sd_bytes)
    AND daily_bytes > 52428800
| eval mb_out = round(daily_bytes/1024/1024, 1)
| table _time user url_domain mb_out avg_bytes

Tuning: exclude service principals and sync accounts by object ID, not display name. Require a minimum of ~14 days of baseline history per user or new joiners will alert on their first real working day. Split the rule by sanctioned versus unsanctioned destination — an upload spike to your corporate tenant is an investigation, the same spike to a personal MEGA or Dropbox account is an incident.

Detection 4: Mailbox forwarding rules to external domains (T1114.003)

Email-based exfiltration is quiet, persistent, and survives endpoint reimaging. It also pairs directly with the identity attacks covered under T1078.004.

let InternalDomains = dynamic(['contoso.com','contoso.onmicrosoft.com']);
CloudAppEvents
| where Timestamp > ago(7d)
| where ActionType in ('New-InboxRule','Set-InboxRule','UpdateInboxRules','Set-Mailbox')
| mv-expand Param = RawEventData.Parameters
| where tostring(Param.Name) in ('ForwardTo','ForwardAsAttachmentTo',
                                 'RedirectTo','ForwardingSmtpAddress')
| extend Target = tolower(tostring(Param.Value))
| extend TargetDomain = tostring(split(split(Target, '@')[1], ';')[0])
| where isnotempty(TargetDomain) and TargetDomain !in (InternalDomains)
| project Timestamp, AccountDisplayName, ActionType, TargetDomain, Target,
          IPAddress, UserAgent
index=o365 sourcetype="o365:management:activity"
  Operation IN ("New-InboxRule","Set-InboxRule","UpdateInboxRules","Set-Mailbox")
| spath path=Parameters{} output=params
| mvexpand params
| spath input=params
| search Name IN ("ForwardTo","ForwardAsAttachmentTo","RedirectTo","ForwardingSmtpAddress")
| rex field=Value "@(?<target_domain>[a-z0-9.\\-]+)"
| search NOT target_domain IN ("contoso.com","contoso.onmicrosoft.com")
| table _time UserId Operation target_domain Value ClientIP

Tuning: this one is close to zero-false-positive in most environments once you allowlist your legitimate partner domains and ticketing systems. Rules that also contain a delete or mark-as-read action, or that filter on keywords like invoice, payment, or wire, should escalate automatically — that pattern is business email compromise, not casual forwarding.

Detection 5: Upload/download ratio inversion at the egress point (T1041, T1030)

Normal client traffic is asymmetric in the download direction. Sustained heavy upload from a workstation to a single external host is one of the few genuinely generic exfil signals worth alerting on.

CommonSecurityLog
| where TimeGenerated > ago(24h)
| where isnotempty(DestinationIP) and not(ipv4_is_private(DestinationIP))
| summarize Sent = sum(todouble(SentBytes)),
            Recv = sum(todouble(ReceivedBytes)),
            Sessions = count()
    by SourceIP, DestinationIP, DestinationPort, bin(TimeGenerated, 1h)
| extend Ratio = Sent / (Recv + 1)
| where Sent > 209715200 and Ratio > 5
| extend SentMB = round(Sent / 1048576, 1)
| project TimeGenerated, SourceIP, DestinationIP, DestinationPort, SentMB, Ratio, Sessions
| order by SentMB desc
index=network sourcetype="bro:conn:json" earliest=-24h
| where NOT cidrmatch("10.0.0.0/8", dest_ip)
    AND NOT cidrmatch("172.16.0.0/12", dest_ip)
    AND NOT cidrmatch("192.168.0.0/16", dest_ip)
| bin _time span=1h
| stats sum(orig_bytes) AS sent, sum(resp_bytes) AS recv, count AS sessions
    BY _time, src_ip, dest_ip, dest_port
| eval ratio = round(sent / (recv + 1), 2), sent_mb = round(sent/1048576, 1)
| where sent > 209715200 AND ratio > 5
| sort - sent_mb

Tuning: exclude your backup targets, CI/CD artifact stores, log shippers, and video conferencing ranges by destination ASN or CIDR — not by IP, which churns. Servers need a separate, much higher threshold than workstations; run two copies of this rule with different asset scopes rather than one compromise threshold that catches neither.

Triage: what to do when one of these fires

These five rules are designed to corroborate each other. A single hit on any one is a lead; two correlated hits on the same host or identity within a few hours is an incident. A practical triage order:

  1. Establish the actor. Pivot from device to account and check for recent authentication anomalies — impossible travel, new device registration, or token replay.
  2. Find the staging directory. If Detection 1 fired, enumerate what went into the archive. That list is your scope of impact, and it is far more reliable than reasoning backwards from byte counts.
  3. Confirm the destination. Resolve the external IP or domain and check whether it appeared anywhere in your environment before this event. First-seen destinations carrying hundreds of megabytes are rarely benign.
  4. Check for the precursor. Exfiltration is a late-stage technique. Look backwards for discovery, lateral movement, and credential access in the preceding days. If you find none, you may be looking at an insider or a policy violation rather than an intrusion — a different response path entirely.
  5. Contain before you finish investigating. Unlike most techniques, every additional minute of an active exfil alert costs you data. Block the destination and isolate the host first; complete the timeline afterwards.

Coverage checklist

If you are building this out as a coherent block in your detection library, the minimum viable set is: archive staging (T1560.001), automated collection from shares (T1119), transfer tooling execution (T1567.002), alternative-protocol transfer such as FTP or SMTP (T1048), email forwarding (T1114.003), chunked transfer (T1030), and exfiltration over the C2 channel itself (T1041). Seven rules covers the realistic exfil surface for most enterprises.

Write them with per-entity baselines rather than global thresholds, put the suppression logic in lookups you can update without editing the rule, and accept that these detections will always run hotter than your execution detections. That is the correct trade: a false positive on an exfil rule costs an analyst ten minutes, and a false negative costs you the breach.

Get new detections in your inbox

New ATT&CK coverage plus CISA KEV / CVE detection rules, roughly weekly. No spam, unsubscribe anytime.