Skip to main content

Network Forensics Without a Tap: Reconstructing Lateral Movement from DNS Cache, NetFlow, and Authentication Logs

· 36 min read
Inference Defense
Threat Intelligence & Detection Engineering

The attacker has been in your network for six days. You have no packet capture. You have no IDS tap on east-west traffic. Your NDR license only covers the perimeter. The EDR on the compromised host was disabled on day two. What you do have: DNS server query logs, DHCP lease records, NetFlow from your core switches, and Windows Security event logs from your domain controllers. That is enough if you know exactly what to look for, in what order, and how to correlate across sources that were never designed to talk to each other.


The Forensic Reality Most IR Teams Face

Full packet capture of internal east-west traffic is the gold standard for network forensics. It is also rarely present. The economics don't work for most organizations: capturing all internal traffic at 10Gbps generates roughly 75TB per day, and the storage, licensing, and operational overhead is prohibitive outside of the largest enterprises.

What almost every organization does have often without realizing its forensic value is a set of indirect network artifacts that, when properly correlated, can reconstruct lateral movement with surprising fidelity. These artifacts are not designed for security. They exist for operational reasons: DHCP assigns IPs, DNS resolves names, NetFlow measures bandwidth, and authentication logs track access control. But together they form a network activity record that tells the story of which machine talked to which other machine, when, using which identity, and with what volume of data.

This post covers:

  1. DNS cache forensics what survives on live endpoints, what the DNS server logs, and how to extract lateral movement indicators from both
  2. DHCP log correlation the IP-to-hostname-to-MAC mapping that is your network identity backbone
  3. NetFlow analysis reading flow records to detect internal scanning, lateral movement, and staged exfiltration
  4. Windows authentication log correlation mapping logon events to network events to build a movement timeline
  5. Cross-source correlation the JOIN operations that turn four incomplete pictures into one complete attack timeline

Every technique includes exact commands, scripts, and queries you can execute during an active investigation.


Part 1 Understanding What Evidence Each Source Preserves

Before diving into the techniques, understand what each source captures, how long it survives, and what attackers do to destroy it. This determines your evidence collection priority during the first hour of IR.

Evidence Volatility Matrix

SourceWhere StoredDefault RetentionVolatile?Attacker Can Destroy?
Client DNS cacheMemory (Windows DNS Client svc)Until reboot or TTL expiryYes highestipconfig /flushdns
DNS server query logsEVTX / flat file on DNS serverDisabled by defaultMediumClear log, disable logging
DHCP server logsC:\Windows\System32\dhcp\7 daily log filesMediumDelete log files
DHCP lease databaseC:\Windows\System32\dhcp\dhcp.mdbActive leases onlyLowRequires DHCP server access
NetFlow recordsFlow collector appliance/SIEMWeeks to monthsLowRequires collector access
Windows auth logs (4624)Security.evtx / SIEMPer log size / SIEMMediumEvent log clear (1102)
ARP table (router)Router memoryMinutes to hoursHighestVolatile by design
DNS passive records (SIEM)SIEM if collectedPer SIEM retentionLowRequires SIEM access

Collection priority: DNS cache → DHCP DB → Auth logs → NetFlow. The first two expire or get destroyed fastest. NetFlow is typically the most durable artifact.


Part 2 DNS Cache Forensics

2.1 The Client DNS Cache: A Map of Recent Activity

Every Windows host maintains a local DNS resolver cache an in-memory table of recently resolved hostnames and their IP addresses. This cache is populated every time the host communicates with any other host by name. For lateral movement forensics, this is invaluable: it records the internal hostnames the compromised machine tried to reach, even if those connections happened days ago and left no other trace.

The cache is managed by the DNS Client service (svchost.exe hosting Dnscache). It survives reboots only partially some entries are persisted in the registry for pre-population on next boot.

Live extraction from a running host:

:: Basic extraction  all cached entries
ipconfig /displaydns

:: Output format for a single entry:
:: Record Name . . . . . : DC02.corp.local
:: Record Type . . . . . : 1 <- A record (IPv4)
:: Time To Live . . . . : 1847 <- seconds remaining before expiry
:: Data Length . . . . . : 4
:: Section . . . . . . . : Answer
:: A (Host) Record . . . : 10.10.1.15

Structured extraction for analysis:

# Extract DNS cache as structured objects  far more useful than raw ipconfig output
# Run on suspect host or via Invoke-Command for remote collection

$dnsCache = Get-DnsClientCache | Select-Object `
Entry, # The queried hostname
RecordName, # Actual DNS record name (may differ CNAME targets)
RecordType, # 1=A, 28=AAAA, 5=CNAME, 12=PTR, 15=MX
Status, # Success, NotExist, etc.
Section, # Answer, Authority, Additional
TimeToLive, # Remaining TTL in seconds
DataLength,
Data # The resolved IP address

# Filter for internal IP ranges lateral movement candidates
$internalRanges = @('10\.', '172\.(1[6-9]|2\d|3[01])\.', '192\.168\.')
$lateralCandidates = $dnsCache | Where-Object {
$ip = $_.Data
$isInternal = $internalRanges | Where-Object { $ip -match $_ }
$isInternal -and $_.RecordType -eq 1 # A records only
}

$lateralCandidates | Sort-Object Entry | Format-Table -AutoSize

# Export for comparison across multiple hosts
$lateralCandidates | Export-Csv "dns_cache_$(hostname)_$(Get-Date -Format 'yyyyMMddHHmm').csv" -NoTypeInformation

Remote collection across all suspect hosts:

# Bulk DNS cache collection  run from IR workstation with admin rights
$suspectHosts = @("WORKSTATION01", "WORKSTATION02", "SERVER01")
$allCacheEntries = @()

foreach ($computer in $suspectHosts) {
try {
$entries = Invoke-Command -ComputerName $computer -ScriptBlock {
Get-DnsClientCache | Select-Object Entry, RecordType, TimeToLive, Data,
@{N='SourceHost'; E={$env:COMPUTERNAME}}
} -ErrorAction Stop
$allCacheEntries += $entries
Write-Host "[+] Collected from $computer : $($entries.Count) entries"
} catch {
Write-Warning "[-] Failed on $computer : $_"
}
}

# Find hosts that queried the same internal target lateral movement breadcrumb
$allCacheEntries |
Where-Object { $_.Data -match '^10\.' -or $_.Data -match '^172\.' } |
Group-Object Data |
Where-Object Count -gt 1 | # IP seen in cache on multiple hosts
ForEach-Object {
Write-Host "Shared target: $($_.Name)" -ForegroundColor Yellow
$_.Group | Select-Object SourceHost, Entry, TimeToLive | Format-Table
}

2.2 What the DNS Cache Reveals About Attack Techniques

Different lateral movement techniques leave distinct DNS cache signatures:

BloodHound collection is particularly distinctive in DNS cache:

# Detect BloodHound-style mass internal resolution burst in DNS cache
# Key indicator: >50 unique internal hostnames resolved in a single cache snapshot

$internalEntries = Get-DnsClientCache |
Where-Object { $_.Data -match '^10\.' -and $_.RecordType -eq 1 }

# Group by subnet to see spread pattern
$subnetSpread = $internalEntries | ForEach-Object {
$ip = $_.Data
$octets = $ip.Split('.')
"$($octets[0]).$($octets[1]).$($octets[2]).0/24"
} | Group-Object | Sort-Object Count -Descending

Write-Host "Unique subnets contacted: $($subnetSpread.Count)"
Write-Host "Unique internal hosts resolved: $($internalEntries.Count)"

if ($internalEntries.Count -gt 50) {
Write-Warning "INDICATOR: High internal hostname resolution count possible AD enumeration"
}
if ($subnetSpread.Count -gt 5) {
Write-Warning "INDICATOR: Resolutions span >5 subnets possible network discovery"
}

2.3 DNS Server Query Logs: The Persistent Record

The client cache is volatile. The DNS server query log is persistent if enabled. Microsoft DNS Server on Windows Server can log all DNS queries received, but this is disabled by default and must be enabled explicitly.

Enable DNS debug logging (Windows DNS Server):

# Enable DNS analytical logging  captures all queries
# Run on DNS server (typically a DC)

# Method 1: Via DNS Management PowerShell module
Set-DnsServerDiagnostics -All $true -ComputerName "DNS01.corp.local"

# Method 2: Specific settings balance between detail and volume
Set-DnsServerDiagnostics `
-Queries $true ` # Log all incoming queries
-Answers $true ` # Log responses
-SendPackets $true ` # Log sent packets
-ReceivePackets $true ` # Log received packets
-LogFilePath "C:\DNSDebugLog\dns.log" `
-MaxMBFileSize 500 ` # 500MB before rotation
-ComputerName "DNS01.corp.local"

# Verify logging is active:
Get-DnsServerDiagnostics -ComputerName "DNS01.corp.local" |
Select-Object Queries, Answers, LogFilePath, MaxMBFileSize

DNS debug log format and parsing:

# Raw DNS debug log entry format:
# Date Time Thread Context Internal(I)/External(E) Response/Send/Receive QueryType RecordType Data
#
# Example entries from a lateral movement scenario:
2025-11-15 02:47:33 0D4 PACKET 000000AA3F012345 UDP Rcv 10.10.5.42 6D43 R Q [8081 DR NOERROR] A (6)TARGET(4)corp(5)local(0)
2025-11-15 02:47:33 0D4 PACKET 000000AA3F012346 UDP Snd 10.10.5.42 6D43 R Q [8081 DR NOERROR] A 10.10.1.55

# This shows: host 10.10.5.42 queried for TARGET.corp.local at 02:47:33
# The DNS server responded with 10.10.1.55

Parse the DNS debug log for lateral movement indicators:

#!/usr/bin/env python3
"""
Parse Windows DNS Server debug log for lateral movement indicators.
Looks for: internal IP clients resolving many internal hostnames (discovery pattern),
clients resolving hostnames they've never queried before, burst query patterns.
"""

import re
import sys
from collections import defaultdict
from datetime import datetime

def parse_dns_debug_log(log_path):
"""Parse Windows DNS debug log and extract client->hostname query pairs."""

# Pattern for DNS debug log query lines
query_pattern = re.compile(
r'(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}).*?UDP Rcv\s+([\d\.]+)\s+\w+\s+Q\s+\[\w+\s+\w+\s+\w+\]\s+(\w+)\s+(.+)'
)

queries = defaultdict(list) # client_ip -> [(timestamp, hostname, query_type)]

with open(log_path, 'r', encoding='utf-8', errors='replace') as f:
for line in f:
m = query_pattern.search(line)
if not m:
continue

ts_str, client_ip, query_type, raw_hostname = m.groups()

# Only interested in internal clients querying internal names
if not client_ip.startswith(('10.', '172.', '192.168.')):
continue

# Clean up the hostname encoding (DNS wire format in debug log)
hostname = raw_hostname.replace('(', '').replace(')', '.').strip('.')

try:
ts = datetime.strptime(ts_str, '%Y-%m-%d %H:%M:%S')
except ValueError:
continue

queries[client_ip].append((ts, hostname, query_type))

return queries


def detect_lateral_movement_patterns(queries, internal_prefix=('10.', '172.', '192.168.')):
"""Analyze query patterns for lateral movement indicators."""

findings = []

for client_ip, query_list in queries.items():

# Sort by timestamp
query_list.sort(key=lambda x: x[0])

# Find bursts: >30 unique internal hostnames resolved in 10 minutes
internal_queries = [
(ts, host) for ts, host, qt in query_list
if any(host.endswith(s) for s in ['.corp.local', '.internal', '.lan'])
]

if len(internal_queries) < 10:
continue

# Sliding 10-minute window
for i, (start_ts, _) in enumerate(internal_queries):
window = [
host for ts, host in internal_queries
if 0 <= (ts - start_ts).total_seconds() <= 600 # 10 min window
]
unique_hosts_in_window = len(set(window))

if unique_hosts_in_window > 30:
findings.append({
'client_ip': client_ip,
'indicator': 'MASS_INTERNAL_RESOLUTION',
'detail': f'{unique_hosts_in_window} unique internal hostnames in 10-minute window starting {start_ts}',
'severity': 'HIGH'
})
break

# Detect unusual timing: queries at off-hours (midnight - 5am)
off_hours_queries = [
(ts, host) for ts, host, qt in query_list
if 0 <= ts.hour < 5
]
if len(off_hours_queries) > 20:
findings.append({
'client_ip': client_ip,
'indicator': 'OFF_HOURS_ACTIVITY',
'detail': f'{len(off_hours_queries)} queries between midnight and 5AM',
'severity': 'MEDIUM'
})

return findings


if __name__ == '__main__':
log_file = sys.argv[1] if len(sys.argv) > 1 else r'C:\DNSDebugLog\dns.log'

print(f"[*] Parsing DNS debug log: {log_file}")
queries = parse_dns_debug_log(log_file)
print(f"[*] Found {len(queries)} unique client IPs")

findings = detect_lateral_movement_patterns(queries)

if not findings:
print("[+] No lateral movement indicators detected")
else:
print(f"\n[!] {len(findings)} INDICATORS DETECTED:\n")
for f in sorted(findings, key=lambda x: x['severity']):
print(f" [{f['severity']}] {f['client_ip']}: {f['indicator']}")
print(f" {f['detail']}\n")

Part 3 DHCP Log Forensics: The IP Identity Backbone

3.1 Why DHCP Logs Are Essential for Network IR

In any active investigation, you will frequently encounter IP addresses in NetFlow, DNS query logs, and authentication logs with no hostname context. Without DHCP correlation, 10.10.5.42 is meaningless. With DHCP logs, 10.10.5.42 becomes LAPTOP-JSMITH with MAC address 00:1A:2B:3C:4D:5E immediately correlating to a specific user and device in your asset inventory.

DHCP logs are the IP-to-identity translation layer that makes all other network forensic data actionable.

3.2 Windows DHCP Server Log Format

Windows DHCP Server maintains daily rotating log files at:

C:\Windows\System32\dhcp\DhcpSrvLog-Mon.log
C:\Windows\System32\dhcp\DhcpSrvLog-Tue.log
...
C:\Windows\System32\dhcp\DhcpSrvLog-Sun.log

Each line is CSV-formatted:

ID,Date,Time,Description,IP Address,Host Name,MAC Address,User Name,...

10,11/15/25,02:31:04,Assign,10.10.5.42,LAPTOP-JSMITH,00-1A-2B-3C-4D-5E,,0...
11,11/15/25,02:31:04,Renew,10.10.5.42,LAPTOP-JSMITH,00-1A-2B-3C-4D-5E,,0...
12,11/15/25,10:44:17,Release,10.10.5.42,LAPTOP-JSMITH,00-1A-2B-3C-4D-5E,,0...

Key Event IDs in DHCP logs:

IDDescriptionForensic Significance
10AssignNew lease device appeared on network at this time
11RenewLease renewal device still active
12ReleaseClient gracefully released IP clean shutdown
13DNS UpdateDHCP registered DNS A record on behalf of client
14DNS Update FailedDNS dynamic update failed may indicate DNS manipulation
15Lease ExpiredClient dropped off without releasing crash, abrupt disconnect
24IP Address in UseConflict potentially unauthorized static IP or spoofed MAC
25IP Address DeletedLease manually deleted by admin
50-59IPv6 equivalentsSame semantics, IPv6 addresses

3.3 Parsing DHCP Logs for IP-to-Host Correlation

#!/usr/bin/env python3
"""
Parse all Windows DHCP server log files in a directory.
Builds a time-aware IP-to-hostname mapping for correlation with
other forensic artifacts during incident response.
"""

import os
import csv
import glob
from datetime import datetime
from collections import defaultdict

DHCP_EVENT_TYPES = {
'10': 'Assign',
'11': 'Renew',
'12': 'Release',
'13': 'DNS_Update',
'14': 'DNS_Update_Failed',
'15': 'Lease_Expired',
'24': 'IP_Conflict',
'25': 'Lease_Deleted'
}

def parse_dhcp_logs(log_dir):
"""
Parse all DhcpSrvLog-*.log files in directory.
Returns list of lease events sorted by timestamp.
"""
events = []
log_files = glob.glob(os.path.join(log_dir, 'DhcpSrvLog-*.log'))

for log_file in log_files:
with open(log_file, 'r', encoding='utf-8', errors='replace') as f:
for line in f:
line = line.strip()
# Skip header lines and comments
if not line or line.startswith('ID') or line.startswith('Microsoft') or \
line.startswith('Start') or line.startswith('Date'):
continue

parts = line.split(',')
if len(parts) < 7:
continue

event_id = parts[0].strip()
if event_id not in DHCP_EVENT_TYPES:
continue

try:
date_str = parts[1].strip()
time_str = parts[2].strip()
timestamp = datetime.strptime(f"{date_str} {time_str}", "%m/%d/%y %H:%M:%S")
except (ValueError, IndexError):
continue

events.append({
'timestamp': timestamp,
'event_type': DHCP_EVENT_TYPES[event_id],
'ip_address': parts[4].strip(),
'hostname': parts[5].strip(),
'mac_address': parts[6].strip().replace('-', ':').upper(),
'source_file': os.path.basename(log_file)
})

return sorted(events, key=lambda x: x['timestamp'])


def build_ip_timeline(events):
"""
Build a timeline of which hostname held which IP at what time.
Essential for correlating IP addresses seen in other log sources.
"""
ip_timeline = defaultdict(list) # ip -> [(start_time, end_time, hostname, mac)]
active_leases = {} # ip -> (start_time, hostname, mac)

for event in events:
ip = event['ip_address']
hostname = event['hostname']
mac = event['mac_address']
ts = event['timestamp']

if event['event_type'] in ('Assign',):
# New lease assigned record start
if ip in active_leases:
# Previous lease ended without explicit release
prev_start, prev_host, prev_mac = active_leases[ip]
ip_timeline[ip].append((prev_start, ts, prev_host, prev_mac))
active_leases[ip] = (ts, hostname, mac)

elif event['event_type'] in ('Release', 'Lease_Expired'):
# Lease ended
if ip in active_leases:
start_ts, prev_host, prev_mac = active_leases.pop(ip)
ip_timeline[ip].append((start_ts, ts, prev_host, prev_mac))

# Close any still-active leases
for ip, (start_ts, hostname, mac) in active_leases.items():
ip_timeline[ip].append((start_ts, None, hostname, mac)) # None = still active

return ip_timeline


def resolve_ip_at_time(ip_timeline, ip_address, query_time):
"""
Given an IP address and a timestamp, return what hostname held that IP.
The critical function for correlating network events to hostnames.
"""
if ip_address not in ip_timeline:
return None

for start_ts, end_ts, hostname, mac in ip_timeline[ip_address]:
if start_ts <= query_time:
if end_ts is None or query_time <= end_ts:
return {
'hostname': hostname,
'mac': mac,
'lease_start': start_ts,
'lease_end': end_ts
}
return None


# Example usage during IR:
if __name__ == '__main__':
DHCP_LOG_DIR = r'C:\Windows\System32\dhcp'

print("[*] Parsing DHCP logs...")
events = parse_dhcp_logs(DHCP_LOG_DIR)
print(f"[*] Parsed {len(events)} DHCP events")

ip_timeline = build_ip_timeline(events)
print(f"[*] Built timeline for {len(ip_timeline)} unique IP addresses")

# Example: resolve IP seen in NetFlow at a specific time
investigation_ip = '10.10.5.42'
investigation_time = datetime(2025, 11, 15, 2, 47, 33) # From DNS/NetFlow log

result = resolve_ip_at_time(ip_timeline, investigation_ip, investigation_time)
if result:
print(f"\n[+] At {investigation_time}, {investigation_ip} was held by:")
print(f" Hostname: {result['hostname']}")
print(f" MAC: {result['mac']}")
print(f" Lease: {result['lease_start']}{result['lease_end'] or 'Active'}")
else:
print(f"[-] No DHCP record for {investigation_ip} at {investigation_time}")
print(" Possible: static IP, rogue device, or DHCP logs pre-date the event")

3.4 Detecting Rogue Devices and MAC Spoofing in DHCP Logs

A common attacker technique is bringing a rogue device onto the network or spoofing a MAC address. DHCP logs expose both:

# Detect MAC addresses seen with multiple different hostnames (MAC reuse or spoofing)
$dhcpLogDir = "C:\Windows\System32\dhcp"
$assignEvents = @()

Get-ChildItem "$dhcpLogDir\DhcpSrvLog-*.log" | ForEach-Object {
Get-Content $_.FullName | Where-Object { $_ -match '^10,' } | # Event ID 10 = Assign
ForEach-Object {
$parts = $_ -split ','
if ($parts.Count -ge 7 -and $parts[6] -ne '') {
$assignEvents += [PSCustomObject]@{
Timestamp = "$($parts[1]) $($parts[2])"
IP = $parts[4]
Hostname = $parts[5]
MAC = $parts[6]
}
}
}
}

# MAC with multiple hostnames = suspicious
$assignEvents |
Group-Object MAC |
Where-Object { ($_.Group.Hostname | Sort-Object -Unique).Count -gt 1 } |
ForEach-Object {
$hostnames = ($_.Group.Hostname | Sort-Object -Unique) -join ', '
Write-Warning "MAC $($_.Name) seen with multiple hostnames: $hostnames"
$_.Group | Sort-Object Timestamp | Select-Object Timestamp, IP, Hostname, MAC |
Format-Table -AutoSize
}

Part 4 NetFlow Analysis: Reading East-West Traffic Without a Tap

4.1 What NetFlow Records Contain

NetFlow (Cisco's original protocol) and its successors IPFIX and sFlow record connection metadata not packet content. For each network flow (defined as packets sharing the same 5-tuple: source IP, destination IP, source port, destination port, protocol), NetFlow records:

NetFlow v9 / IPFIX Record Fields:
──────────────────────────────────────────────────────────────────
Field Type Forensic Value
──────────────────────────────────────────────────────────────────
src_addr IPv4/6 Source IP address
dst_addr IPv4/6 Destination IP address
src_port uint16 Source port (ephemeral for clients)
dst_port uint16 Destination port (service identifier)
protocol uint8 6=TCP, 17=UDP, 1=ICMP
flow_start datetime When the flow began
flow_end datetime When the flow ended
in_bytes uint64 Bytes from src to dst
out_bytes uint64 Bytes from dst to src (bidirectional flows)
tcp_flags uint8 SYN, ACK, RST, FIN combinations
input_snmp uint32 Router interface index (ingress)
output_snmp uint32 Router interface index (egress)
──────────────────────────────────────────────────────────────────

What NetFlow does NOT contain: packet payload, request/response content, authentication details, or process names. It tells you that a connection happened, when, for how long, and how much data moved. Combined with DHCP and auth logs, this is sufficient to reconstruct lateral movement.

4.2 Enabling NetFlow on Common Platforms

If NetFlow is not already configured, enabling it retroactively gives you forward coverage. It does not recover historical data.

# Cisco IOS  enable NetFlow on internal switch interfaces
ip flow-export destination 10.10.1.100 9995 ! SIEM / flow collector IP and port
ip flow-export version 9
ip flow-export source GigabitEthernet0/0

interface GigabitEthernet0/1 ! Repeat for each internal-facing interface
ip flow ingress
ip flow egress

! Verify:
show ip flow export
show ip cache flow

# Cisco NX-OS (datacenter switches):
feature netflow

flow record SECURITY-RECORD
match ipv4 source address
match ipv4 destination address
match transport source-port
match transport destination-port
match ip protocol
collect counter bytes
collect counter packets
collect transport tcp flags
collect timestamp sys-uptime first
collect timestamp sys-uptime last

flow exporter SIEM-EXPORT
destination 10.10.1.100
transport udp 9995
version 9

flow monitor SECURITY-MONITOR
record SECURITY-RECORD
exporter SIEM-EXPORT
cache timeout active 60

interface Ethernet1/1
ip flow monitor SECURITY-MONITOR input
ip flow monitor SECURITY-MONITOR output

4.3 NetFlow Queries for Lateral Movement Detection

Most enterprises store NetFlow in a collector (SolarWinds NTA, Elastic with Logstash, Splunk stream, open-source ntopng/nfdump). The following queries work with nfdump (open-source command-line NetFlow analyzer):

# nfdump is installed on most Linux-based flow collectors
# NetFlow files typically stored in: /var/cache/nfdump/ or /opt/nfdump/data/

# ─── SCENARIO 1: Find all connections FROM a known compromised host ───
# Replace 10.10.5.42 with the source IP you're investigating
nfdump -R /var/cache/nfdump/2025/11/15/ \
-t "2025-11-15 00:00:00-2025-11-15 23:59:59" \
-o "fmt:%ts %te %sa %da %dp %pr %byt %pkt %flg" \
"src ip 10.10.5.42 and not dst ip 10.10.5.42" | \
sort -k4 # Sort by destination IP to group lateral targets

# ─── SCENARIO 2: Detect internal port scanning ───
# High number of unique destinations on the same port = scanning
nfdump -R /var/cache/nfdump/2025/11/15/ \
-o "fmt:%sa %da %dp %pr %flg" \
"src net 10.10.0.0/16 and dst net 10.10.0.0/16 and \
(dst port 445 or dst port 135 or dst port 3389 or dst port 5985)" | \
awk '{print $1" "$3}' | \ # Source IP + Destination Port
sort | uniq -c | sort -rn | head -30
# Output: count src_ip dst_port
# High counts on port 445 from single source = SMB scanning = BloodHound or lateral prep

# ─── SCENARIO 3: Find SMB connections (port 445) between workstations ───
# Workstation-to-workstation SMB is almost never legitimate in modern environments
# Adjust subnet ranges for your network
nfdump -R /var/cache/nfdump/2025/11/15/ \
-o "fmt:%ts %sa %da %dp %byt %flg" \
"dst port 445 and src net 10.10.0.0/24 and dst net 10.10.0.0/24" | \
grep -v "10.10.0.10" # Exclude file server if one exists in that subnet

# ─── SCENARIO 4: Detect RDP lateral movement ───
nfdump -R /var/cache/nfdump/2025/11/15/ \
-o "fmt:%ts %te %sa %da %byt" \
"dst port 3389 and src net 10.10.0.0/16 and dst net 10.10.0.0/16" | \
awk '{ bytes=$5; src=$3; dst=$4
if (bytes > 0) print src " -> " dst " bytes=" bytes }' | \
sort | uniq -c | sort -rn

# ─── SCENARIO 5: WinRM lateral movement (port 5985) ───
# PowerShell remoting rarely legitimate between workstations
nfdump -R /var/cache/nfdump/2025/11/15/ \
-o "fmt:%ts %sa %da %byt" \
"dst port 5985 and src net 10.10.0.0/16"

# ─── SCENARIO 6: Data staging large internal transfers ───
# Before exfiltration, attackers stage data on a single host
# Look for unusually large transfers TO a single internal host
nfdump -R /var/cache/nfdump/2025/11/15/ \
-o "fmt:%sa %da %byt" \
"src net 10.10.0.0/16 and dst net 10.10.0.0/16" | \
awk '{bytes[$2] += $3} END {for (dst in bytes) print bytes[dst], dst}' | \
sort -rn | head -20 | \
awk '{gb=$1/1073741824; printf "%-15s received %.2f GB\n", $2, gb}'

Splunk SPL equivalent for organizations storing NetFlow in Splunk:

| tstats count, sum(bytes) as total_bytes, dc(dest_ip) as unique_dests
WHERE index=netflow earliest=-24h
BY src_ip, dest_port
| where dest_port IN (445, 135, 3389, 5985, 5986, 22)
AND src_ip LIKE "10.10.%"
| eval is_internal_src = if(match(src_ip, "^10\.10\."), 1, 0)
| where is_internal_src=1
| where unique_dests > 5 /* scanning: one source hitting many destinations */
| eval total_GB = round(total_bytes/1073741824, 3)
| sort -unique_dests
| table src_ip, dest_port, unique_dests, count, total_GB

4.4 Reading TCP Flags for Attack Technique Fingerprinting

TCP flags in NetFlow records reveal the nature of a connection without needing packet content. This is particularly useful for distinguishing scanning from actual sessions:

TCP Flags in NetFlow (hex byte):
─────────────────────────────────────────────────────────────────
Flag Hex Meaning in NetFlow Attacker Significance
─────────────────────────────────────────────────────────────────
SYN 0x02 Connection attempt Scanning: many SYN with no SYN-ACK response
SYN-ACK 0x12 Connection accepted Normal connection establishment
RST 0x04 Connection refused/reset Port closed target not listening
FIN-ACK 0x11 Clean session termination Full session completed
SYN-RST 0x06 SYN followed immediately by RST Stealth scan (half-open)
PSH-ACK 0x18 Data transfer in progress Active session with data movement
─────────────────────────────────────────────────────────────────
# Find SYN-only flows (scanning  connections never completed)
# High ratio of SYN-only to SYN-ACK on internal scanning traffic
nfdump -R /var/cache/nfdump/2025/11/15/ \
-o "fmt:%ts %sa %da %dp %flg %pkt" \
"src net 10.10.0.0/16 and dst port 445" | \
awk '
/\.S\.\.\.\./ { syn_only[$3]++ } # SYN flag only = unresponded
/\.SA\.\.\.\./ { syn_ack[$3]++ } # SYN-ACK = completed handshake
END {
for (dst in syn_only) {
ratio = (syn_ack[dst] > 0) ? syn_only[dst]/syn_ack[dst] : 999
if (ratio > 10) { # 10x more SYN than SYN-ACK = scanning
printf "SCAN detected toward %s: %d SYN, %d SYN-ACK, ratio=%.1f\n",
dst, syn_only[dst], syn_ack[dst], ratio
}
}
}
'

Part 5 Windows Authentication Log Correlation

5.1 The Authentication Events That Matter for Network Forensics

Windows Security event logs on Domain Controllers capture every network authentication attempt in the domain. These events are the identity layer they tell you which account was used for which network connection, from which source machine.

The key event IDs for network forensics correlation:

Event IDLog LocationWhat It RecordsLateral Movement Significance
4624Security (target host)Successful logonType 3 = network logon; maps network connection to identity
4625Security (target host)Failed logonBrute force, pass-the-hash failures, scanning
4648Security (source host)Explicit credentials usedAttacker using alternate credentials from a host
4672Security (target host)Special privileges assignedAdmin-equivalent access on target
4769Security (DC)Kerberos TGS requestWhich service ticket was requested from which host
4776Security (DC)NTLM credential validationNTLM auth includes source workstation and account
4768Security (DC)Kerberos TGT requestInitial Kerberos auth includes source IP
4771Security (DC)Kerberos pre-auth failureFailed Kerberos password spray, enumeration

5.2 Extracting Authentication-Based Movement Chains

The most powerful query in Windows auth log forensics: find every machine that jsmith@corp.local authenticated to, in chronological order. This is the lateral movement chain.

# Build authentication chain for a specific account across all DCs
# Run against your SIEM or directly against DC Security logs

$targetAccount = "jsmith"
$startTime = [DateTime]::Parse("2025-11-15 00:00:00")
$endTime = [DateTime]::Parse("2025-11-16 00:00:00")

# Query all DCs for logon events involving the account
$domainControllers = (Get-ADDomainController -Filter *).Name
$authEvents = @()

foreach ($dc in $domainControllers) {
Write-Host "Querying $dc..."

# Event 4624 (logon) and 4648 (explicit creds) and 4769 (Kerberos TGS)
$filter = @{
LogName = 'Security'
Id = @(4624, 4648, 4769, 4776)
StartTime = $startTime
EndTime = $endTime
}

try {
$events = Get-WinEvent -ComputerName $dc -FilterHashtable $filter `
-ErrorAction Stop
foreach ($event in $events) {
$xml = [xml]$event.ToXml()
$data = $xml.Event.EventData.Data

# Extract relevant fields based on event ID
$entry = [PSCustomObject]@{
Timestamp = $event.TimeCreated
EventID = $event.Id
DC = $dc
AccountName = ($data | Where-Object Name -eq 'TargetUserName').'#text'
AccountDomain = ($data | Where-Object Name -eq 'TargetDomainName').'#text'
SourceIP = ($data | Where-Object Name -eq 'IpAddress').'#text'
Workstation = ($data | Where-Object Name -eq 'WorkstationName').'#text'
LogonType = ($data | Where-Object Name -eq 'LogonType').'#text'
AuthPackage = ($data | Where-Object Name -eq 'AuthenticationPackageName').'#text'
ServiceName = ($data | Where-Object Name -eq 'ServiceName').'#text'
LogonID = ($data | Where-Object Name -eq 'TargetLogonId').'#text'
}

# Filter for our target account
if ($entry.AccountName -like "*$targetAccount*") {
$authEvents += $entry
}
}
} catch {
Write-Warning "Failed on $dc : $_"
}
}

# Sort and display the movement chain
$authEvents | Sort-Object Timestamp | Format-Table Timestamp, EventID, SourceIP, Workstation, ServiceName, LogonType, AuthPackage -AutoSize

# Export for correlation with NetFlow and DNS data
$authEvents | Export-Csv "auth_chain_${targetAccount}.csv" -NoTypeInformation

5.3 Detecting Pass-the-Hash vs. Kerberos vs. Legitimate Auth

The authentication package field in Event 4624 reveals the technique:

Event 4624 field analysis for lateral movement technique identification:

LogonType=3 (Network) + AuthPackage=NTLM + Source workstation mismatch = Pass-the-Hash
LogonType=3 (Network) + AuthPackage=Kerberos + Normal hours = Likely legitimate
LogonType=3 (Network) + AuthPackage=Kerberos + Off hours + no prior logon type 2 on that host = Suspicious
LogonType=9 (NewCredentials) + AuthPackage=NTLM = Explicit alternate credentials (runas /netonly or Invoke-Command with PSCredential)
LogonType=10 (RemoteInteractive) = RDP session

Pass-the-Hash specific indicator in Event 4624:
KeyLength: 0 ← This field being 0 in a Type 3 NTLM logon
indicates no session key negotiated = pass-the-hash
# Detect pass-the-hash by finding Type 3 NTLM logons with KeyLength=0
# and where the workstation doesn't match the source IP DHCP assignment

$pthIndicators = Get-WinEvent -ComputerName $dc -FilterHashtable @{
LogName = 'Security'; Id = 4624; StartTime = $startTime; EndTime = $endTime
} | ForEach-Object {
$xml = [xml]$_.ToXml()
$data = $xml.Event.EventData.Data

$logonType = ($data | Where-Object Name -eq 'LogonType').'#text'
$authPkg = ($data | Where-Object Name -eq 'AuthenticationPackageName').'#text'
$keyLength = ($data | Where-Object Name -eq 'KeyLength').'#text'
$sourceIP = ($data | Where-Object Name -eq 'IpAddress').'#text'
$workstation = ($data | Where-Object Name -eq 'WorkstationName').'#text'
$account = ($data | Where-Object Name -eq 'TargetUserName').'#text'

# Pass-the-hash indicators:
# Type 3 network logon + NTLM + KeyLength 0
if ($logonType -eq '3' -and $authPkg -eq 'NTLM' -and $keyLength -eq '0') {
[PSCustomObject]@{
Timestamp = $_.TimeCreated
Account = $account
SourceIP = $sourceIP
Workstation = $workstation
KeyLength = $keyLength
Indicator = 'POSSIBLE_PASS_THE_HASH'
}
}
} | Where-Object { $_ -ne $null }

$pthIndicators | Sort-Object Timestamp | Format-Table -AutoSize

Part 6 Cross-Source Correlation: Building the Attack Timeline

This is where the forensic picture comes together. Each source tells a partial story. The JOIN across all four sources builds the complete lateral movement timeline.

6.1 The Correlation Script: Joining All Four Sources

#!/usr/bin/env python3
"""
Cross-source correlation engine for network forensics.
Joins NetFlow, DHCP, DNS cache, and Windows auth logs
to reconstruct lateral movement timelines.

Input files:
- netflow.csv: ts, src_ip, dst_ip, dst_port, bytes, flags
- dhcp.csv: timestamp, event_type, ip, hostname, mac
- auth.csv: timestamp, event_id, source_ip, account, logon_type, auth_pkg, key_length
- dns_cache.csv: source_host, resolved_hostname, resolved_ip, ttl

Output:
Lateral movement events with full context enrichment.
"""

import csv
import json
from datetime import datetime, timedelta
from collections import defaultdict


class NetworkForensicsCorrelator:

def __init__(self):
self.ip_timeline = {} # ip -> [(start, end, hostname, mac)]
self.auth_events = [] # list of auth log records
self.netflow_events = [] # list of flow records
self.dns_observations = {} # source_host -> [resolved_ips]

def load_dhcp(self, csv_path):
"""Load and build IP timeline from DHCP logs."""
events = []
with open(csv_path) as f:
for row in csv.DictReader(f):
try:
events.append({
'ts': datetime.fromisoformat(row['timestamp']),
'type': row['event_type'],
'ip': row['ip'],
'hostname': row['hostname'],
'mac': row['mac']
})
except (ValueError, KeyError):
continue

# Build timeline (simplified)
active = {}
timeline = defaultdict(list)

for ev in sorted(events, key=lambda x: x['ts']):
ip = ev['ip']
if ev['type'] == 'Assign':
if ip in active:
old = active[ip]
timeline[ip].append((old['ts'], ev['ts'], old['hostname'], old['mac']))
active[ip] = ev
elif ev['type'] in ('Release', 'Lease_Expired') and ip in active:
old = active.pop(ip)
timeline[ip].append((old['ts'], ev['ts'], old['hostname'], old['mac']))

for ip, ev in active.items():
timeline[ip].append((ev['ts'], None, ev['hostname'], ev['mac']))

self.ip_timeline = dict(timeline)
print(f"[+] DHCP: loaded timeline for {len(self.ip_timeline)} IPs")

def resolve_ip(self, ip, query_time):
"""Resolve IP address to hostname at a given time using DHCP timeline."""
for start, end, hostname, mac in self.ip_timeline.get(ip, []):
if start <= query_time and (end is None or query_time <= end):
return hostname, mac
return ip, 'unknown' # Fallback to IP if no DHCP record

def load_netflow(self, csv_path, lateral_ports=None):
"""Load NetFlow records, focusing on lateral movement ports."""
if lateral_ports is None:
lateral_ports = {445, 135, 3389, 5985, 5986, 22, 23, 139}

with open(csv_path) as f:
for row in csv.DictReader(f):
try:
dst_port = int(row.get('dst_port', 0))
if dst_port not in lateral_ports:
continue

self.netflow_events.append({
'ts': datetime.fromisoformat(row['ts']),
'src_ip': row['src_ip'],
'dst_ip': row['dst_ip'],
'dst_port': dst_port,
'bytes': int(row.get('bytes', 0)),
'flags': row.get('flags', '')
})
except (ValueError, KeyError):
continue

print(f"[+] NetFlow: loaded {len(self.netflow_events)} lateral-movement-port records")

def load_auth_logs(self, csv_path):
"""Load Windows authentication events."""
with open(csv_path) as f:
for row in csv.DictReader(f):
try:
self.auth_events.append({
'ts': datetime.fromisoformat(row['timestamp']),
'event_id': int(row['event_id']),
'source_ip': row['source_ip'],
'account': row['account'],
'logon_type': row.get('logon_type', ''),
'auth_pkg': row.get('auth_pkg', ''),
'key_length': row.get('key_length', ''),
'service': row.get('service', '')
})
except (ValueError, KeyError):
continue

print(f"[+] Auth: loaded {len(self.auth_events)} authentication events")

def correlate(self, time_window_seconds=30):
"""
Main correlation: for each NetFlow lateral movement record,
find the corresponding auth event within the time window.
Enrich both with DHCP hostname resolution.
"""
timeline = []

for flow in sorted(self.netflow_events, key=lambda x: x['ts']):
flow_ts = flow['ts']
src_ip = flow['src_ip']
dst_ip = flow['dst_ip']

# Resolve IPs to hostnames using DHCP timeline
src_host, src_mac = self.resolve_ip(src_ip, flow_ts)
dst_host, dst_mac = self.resolve_ip(dst_ip, flow_ts)

# Find matching auth event within time window
matching_auth = None
window = timedelta(seconds=time_window_seconds)

for auth in self.auth_events:
if abs((auth['ts'] - flow_ts).total_seconds()) <= time_window_seconds:
if auth['source_ip'] == src_ip and auth['logon_type'] in ('3', '10'):
matching_auth = auth
break

# Determine if this is suspicious
suspicion_flags = []

if matching_auth:
# Pass-the-hash indicator
if (matching_auth['auth_pkg'] == 'NTLM' and
matching_auth['key_length'] == '0'):
suspicion_flags.append('PASS_THE_HASH')

# Off-hours activity (midnight to 5am)
if 0 <= flow_ts.hour < 5:
suspicion_flags.append('OFF_HOURS')

# Workstation-to-workstation SMB (no server name pattern)
if (flow['dst_port'] == 445 and
'srv' not in dst_host.lower() and
'server' not in dst_host.lower() and
'dc' not in dst_host.lower()):
suspicion_flags.append('WORKSTATION_TO_WORKSTATION_SMB')

event = {
'timestamp': flow_ts.isoformat(),
'src_ip': src_ip,
'src_hostname': src_host,
'src_mac': src_mac,
'dst_ip': dst_ip,
'dst_hostname': dst_host,
'dst_mac': dst_mac,
'dst_port': flow['dst_port'],
'protocol': 'TCP',
'bytes_transferred': flow['bytes'],
'tcp_flags': flow['flags'],
'auth_account': matching_auth['account'] if matching_auth else 'UNKNOWN',
'auth_type': matching_auth['auth_pkg'] if matching_auth else 'UNKNOWN',
'logon_type': matching_auth['logon_type'] if matching_auth else 'UNKNOWN',
'suspicion_flags': suspicion_flags,
'severity': 'HIGH' if suspicion_flags else 'INFO'
}

timeline.append(event)

return sorted(timeline, key=lambda x: x['timestamp'])

def print_timeline(self, timeline, high_only=True):
"""Print a readable attack timeline."""
print("\n" + "="*80)
print("LATERAL MOVEMENT TIMELINE")
print("="*80)

port_names = {445: 'SMB', 135: 'RPC', 3389: 'RDP', 5985: 'WinRM', 22: 'SSH'}

for event in timeline:
if high_only and event['severity'] != 'HIGH':
continue

port_str = port_names.get(event['dst_port'], str(event['dst_port']))
mb = round(event['bytes_transferred'] / 1048576, 2)
flags_str = ', '.join(event['suspicion_flags']) if event['suspicion_flags'] else 'none'

print(f"\n[{event['timestamp']}] {event['severity']}")
print(f" MOVEMENT: {event['src_hostname']} ({event['src_ip']})")
print(f" --> {event['dst_hostname']} ({event['dst_ip']}) via {port_str}")
print(f" IDENTITY: {event['auth_account']} [{event['auth_type']}, Type {event['logon_type']}]")
print(f" VOLUME: {mb} MB transferred")
print(f" FLAGS: {flags_str}")


# Usage during incident response:
if __name__ == '__main__':
correlator = NetworkForensicsCorrelator()
correlator.load_dhcp('dhcp_export.csv')
correlator.load_netflow('netflow_internal.csv')
correlator.load_auth_logs('dc_auth_events.csv')

timeline = correlator.correlate(time_window_seconds=60)
correlator.print_timeline(timeline, high_only=True)

# Export full timeline for SIEM ingestion or report
with open('lateral_movement_timeline.json', 'w') as f:
json.dump(timeline, f, indent=2, default=str)

print(f"\n[*] Full timeline exported to lateral_movement_timeline.json")
print(f"[*] Total events: {len(timeline)}")
print(f"[*] HIGH severity: {sum(1 for e in timeline if e['severity'] == 'HIGH')}")

Part 7 The Investigation Workflow: A Decision Tree for IR Teams

7.1 Quick Reference: IR Commands by Phase

Phase 1 Evidence Collection (first 30 minutes)

# On suspected source host  collect before reboot or shutdown
ipconfig /displaydns > dns_cache_$(hostname).txt
Get-DnsClientCache | Export-Csv dns_cache_structured_$(hostname).csv -NoTypeInformation

# On DHCP server export current leases and logs
Copy-Item "C:\Windows\System32\dhcp\DhcpSrvLog-*.log" "C:\IR\dhcp_logs\"
Get-DhcpServerv4Lease -ScopeId 10.10.0.0 -AllLeases |
Select-Object IPAddress, ClientId, HostName, AddressState, LeaseExpiryTime |
Export-Csv "C:\IR\dhcp_active_leases.csv" -NoTypeInformation

# On Domain Controllers export auth events for investigation window
$filter = @{LogName='Security'; Id=@(4624,4625,4648,4769,4776,4768,4771,4672);
StartTime=(Get-Date).AddDays(-7); EndTime=(Get-Date)}
Get-WinEvent -FilterHashtable $filter |
ForEach-Object { $_.ToXml() } |
Out-File "C:\IR\dc_auth_events_raw.xml"

Phase 2 NetFlow Queries (first 2 hours)

# On NetFlow collector  identify all east-west connections from suspected host
# Replace 10.10.5.42 with your compromised host IP
SUSPECT="10.10.5.42"
START="2025-11-14 00:00:00"
END="2025-11-15 23:59:59"

nfdump -R /var/cache/nfdump/ -t "${START}-${END}" \
-o "fmt:%ts,%te,%sa,%da,%dp,%pr,%byt,%pkt,%flg" \
"src ip ${SUSPECT} and dst net 10.0.0.0/8" > suspected_host_flows.csv

# Find all unique internal destinations
awk -F',' 'NR>1 {print $4}' suspected_host_flows.csv | sort -u > unique_destinations.txt
echo "Unique internal targets: $(wc -l < unique_destinations.txt)"

Phase 3 Correlation and Timeline (hours 2-4)

# Quick IP lookup against DHCP logs (command-line usage)
python3 dhcp_correlator.py \
--dhcp-dir /path/to/dhcp/logs \
--ip 10.10.5.42 \
--time "2025-11-15 02:47:33"

# Output: 10.10.5.42 at 2025-11-15 02:47:33 was LAPTOP-JSMITH (MAC: 00:1A:2B:...)

Part 8 The Artifacts That Survive Attacker Cleanup

Sophisticated attackers attempt to remove evidence. Understanding what survives cleanup determines whether your investigation can proceed after the attacker has tried to cover tracks.

Attacker ActionWhat Is DestroyedWhat Survives
ipconfig /flushdns on sourceLocal DNS cacheDNS server query logs, DHCP records, NetFlow
wevtutil cl Security on targetTarget's Security event logDC's 4769 records showing service ticket to target, NetFlow showing the connection
Delete DHCP log filesDHCP daily logsActive lease database (dhcp.mdb), SIEM if logs were ingested
Spoof MAC addressCorrect MAC in DHCP logsAnomalous MAC not in asset inventory, IP conflict events (DHCP ID 24)
VPN / proxy through another internal hostDirect source IP in NetFlowIntermediate host shows elevated connection count, DHCP shows presence of intermediate
Disable NetFlow on switchFuture NetFlow dataHistorical NetFlow from before disable event
Rename computer before lateral moveHostname in DNSMAC address correlation still possible, old DNS PTR records

The most resilient artifact: NetFlow from core switch

The attacker would need administrative access to your core switching infrastructure to retroactively destroy NetFlow. In most organizations, this is a separate administrative domain from Windows servers. Even if the attacker cleans every Windows log, the flow records showing the connections remain on the collector.

The second most resilient: DNS server query logs (if enabled)

The attacker cleaning logs on workstations and servers does not affect DNS query logs on the DNS server. These are particularly valuable because they capture every hostname the attacker resolved, including reconnaissance against hosts they never successfully connected to.


Summary: The Correlation Matrix

When you have an IR scenario and need to know which sources answer which questions, use this reference:

QuestionPrimary SourceSecondary SourceCommand
What hosts did X talk to?NetFlowDNS cachenfdump "src ip X and dst net internal"
What hostname owned IP Y at time T?DHCP logsDNS PTR recordsresolve_ip(Y, T) from dhcp_correlator.py
What account was used for connection?DC Security 4624/4769Target 4624Get-WinEvent ... Id 4624 -FilterXPath
Was pass-the-hash used?DC/Target 4624 KeyLength=0NetFlow NTLM port patternKeyLength field in 4624 XML
When did attacker first appear?DHCP first Assign eventNetFlow earliest recordnfdump earliest + DHCP first seen
Which hosts were scanned but not compromised?NetFlow SYN-only flowsDNS cache of sourceTCP flags analysis in nfdump
What data was staged/exfiltrated?NetFlow bytes, off-hours large transfersDNS cache for staging hostnfdump "dst net internal and byt > 100MB"
Did the attacker modify logs?Event 1102, 4719 on DCsSIEM volume anomalyGet-WinEvent ... Id 1102, 4719

References

  • Microsoft Docs: DHCP Server Log Event IDs complete event ID reference
  • nfdump documentation: nfdump.sourceforge.io complete query syntax
  • NSA: "Detect and Prevent Web Shell Malware" NetFlow analysis methodology
  • SANS: "Network Forensics Analysis" course materials flow analysis techniques
  • MITRE ATT&CK T1021.002 (SMB/Windows Admin Shares) lateral movement documentation
  • MITRE ATT&CK T1550.002 (Pass the Hash) detection guidance
  • Cisco: NetFlow Configuration Guide enabling NetFlow on Cisco infrastructure
  • Microsoft Security: "Token Theft Playbook" auth log correlation methodology

Further Reading


All commands and techniques described in this post are standard incident response and forensic analysis procedures. They operate against infrastructure and logs that the analyst has administrative access to as part of an authorized investigation.