The first 30 minutes of a Windows incident investigation set the tone for everything that follows. The goal in this phase is not deep analysis. It is answering three questions as quickly and completely as possible: what happened, roughly when, and how widely has it spread. This checklist is built around those questions in order of priority.
This is a working document. It will not cover every scenario but it covers the things that matter most consistently across real incidents and CTF forensics challenges.
Before touching anything: baseline the system state
Note the current system time and compare it against a trusted time source. Large clock skew can indicate deliberate tampering to confuse timeline analysis, or simply that the system clock drifted. Either way it affects every timestamp you are about to collect and needs to be documented.
w32tm /query /status
# Key fields:
# Stratum: 2 or 3 is normal. 16 means no sync.
# Last Successful Sync Time: should be recent
# Source: your domain controller or pool.ntp.org
# Get current time and UTC offset
Get-Date
[System.TimeZoneInfo]::Local
On systems where the case may have legal consequences, photograph the screen showing the current time and system state before any commands are run. Once you start collecting volatile data the state starts changing.
Phase 1: Volatile data collection (live system only)
These artefacts exist only in RAM and are lost when the system powers off. Collect them first, before anything else, before you even check the event logs.
// Create the output directory
New-Item -ItemType Directory -Path C:\ir -Force
// Running processes with full detail
Get-WmiObject Win32_Process | Select-Object ProcessId, ParentProcessId,
Name, ExecutablePath, CommandLine,
@{N="CreationDate";E={$_.ConvertToDateTime($_.CreationDate)}} |
Export-Csv C:\ir\processes.csv -NoTypeInformation
// Map processes to their parent names (makes the process tree readable)
$procs = Get-WmiObject Win32_Process
$procs | ForEach-Object {
$parent = $procs | Where-Object {$_.ProcessId -eq $_.ParentProcessId}
[PSCustomObject]@{
PID = $_.ProcessId
PPID = $_.ParentProcessId
Name = $_.Name
ParentName = $parent.Name
Path = $_.ExecutablePath
CmdLine = $_.CommandLine
Created = $_.ConvertToDateTime($_.CreationDate)
}
} | Export-Csv C:\ir\process_tree.csv -NoTypeInformation
// Active network connections with process correlation
Get-NetTCPConnection -State Established | ForEach-Object {
$proc = Get-Process -Id $_.OwningProcess -ErrorAction SilentlyContinue
[PSCustomObject]@{
LocalAddress = $_.LocalAddress
LocalPort = $_.LocalPort
RemoteAddress = $_.RemoteAddress
RemotePort = $_.RemotePort
State = $_.State
PID = $_.OwningProcess
ProcessName = $proc.Name
ProcessPath = $proc.Path
}
} | Export-Csv C:\ir\connections.csv -NoTypeInformation
// DNS cache (reveals domains contacted recently, even if connection is closed)
Get-DnsClientCache | Select-Object Entry, RecordName, RecordType, TimeToLive,
DataLength, Section, Data |
Export-Csv C:\ir\dns_cache.csv -NoTypeInformation
// Tip: sort by TTL to find recently resolved entries
Get-DnsClientCache | Sort-Object TimeToLive | Select-Object -First 30
// Logged in users (both local and remote sessions)
query user
Get-WmiObject Win32_LoggedOnUser | ForEach-Object {
$account = $_.Antecedent -match 'Name="(.+?)"' | Out-Null
$session = $_.Dependent -match 'LogonId="(.+?)"' | Out-Null
Write-Output "$($Matches[1])"
}
// Open file handles and shared resources
net use
net share
Get-WmiObject Win32_NetworkConnection |
Select-Object LocalName, RemoteName, Status, ConnectionState
// Services in a non-standard state
Get-WmiObject Win32_Service |
Where-Object { $_.State -ne "Stopped" -and $_.StartMode -ne "Disabled" } |
Select-Object Name, State, StartMode, PathName, StartName |
Export-Csv C:\ir\services.csv -NoTypeInformation
// New or recently modified services
Get-WinEvent -FilterHashtable @{LogName="System"; Id=7045; StartTime=(Get-Date).AddHours(-48)} |
ForEach-Object {
[xml]$xml = $_.ToXml()
[PSCustomObject]@{
Time = $_.TimeCreated
ServiceName = $xml.Event.EventData.Data[0]."#text"
ImagePath = $xml.Event.EventData.Data[2]."#text"
StartType = $xml.Event.EventData.Data[3]."#text"
Account = $xml.Event.EventData.Data[4]."#text"
}
}
// Scheduled tasks with full command detail
schtasks /query /fo CSV /v > C:\ir\schtasks.csv
// PowerShell version with anomaly highlighting
Get-ScheduledTask | Where-Object {$_.State -ne "Disabled"} | ForEach-Object {
$action = $_.Actions | Select-Object -First 1
[PSCustomObject]@{
TaskName = $_.TaskName
TaskPath = $_.TaskPath
State = $_.State
Execute = $action.Execute
Arguments = $action.Arguments
WorkingDir = $action.WorkingDirectory
Author = $_.Author
Description = $_.Description
}
} | Where-Object {
# Flag tasks pointing outside standard locations
$_.Execute -notmatch "^C:\\Windows\\" -and
$_.Execute -notmatch "^C:\\Program Files"
} | Export-Csv C:\ir\suspicious_tasks.csv -NoTypeInformation
Phase 2: Event log triage
Extract targeted events from the last 48 hours rather than pulling entire log files. Query for the events most likely to show attacker activity first.
// Authentication events: find unusual accounts, times, and source IPs
Get-WinEvent -FilterHashtable @{
LogName = "Security"
Id = 4624
StartTime = (Get-Date).AddHours(-48)
} | ForEach-Object {
$props = $_.Properties
[PSCustomObject]@{
Time = $_.TimeCreated
Account = $props[5].Value
Domain = $props[6].Value
LogonType = $props[8].Value
SourceIP = $props[18].Value
WorkStation = $props[13].Value
LogonProc = $props[9].Value
}
} | Where-Object {
# Flag non-interactive logons from external IPs or unusual accounts
$_.LogonType -in @(3, 10) -and
$_.SourceIP -ne "127.0.0.1" -and
$_.SourceIP -ne "-"
} | Export-Csv C:\ir\logons.csv -NoTypeInformation
// Failed logon attempts (possible brute force or credential stuffing)
Get-WinEvent -FilterHashtable @{LogName="Security"; Id=4625; StartTime=(Get-Date).AddHours(-48)} |
ForEach-Object {
$props = $_.Properties
[PSCustomObject]@{
Time = $_.TimeCreated
Account = $props[5].Value
SourceIP = $props[19].Value
FailureReason = $props[9].Value
}
} | Group-Object Account | Sort-Object Count -Descending | Select-Object -First 20
// New local accounts created
Get-WinEvent -FilterHashtable @{LogName="Security"; Id=4720; StartTime=(Get-Date).AddHours(-72)} |
Select-Object TimeCreated, Message
// Account added to privileged groups
Get-WinEvent -FilterHashtable @{
LogName = "Security"
Id = @(4728, 4732, 4756) // Domain Admins, Local Admins, Universal
StartTime = (Get-Date).AddHours(-72)
} | Select-Object TimeCreated, Id, Message
// Process creation with command lines (requires 4688 + cmdline logging enabled)
Get-WinEvent -FilterHashtable @{LogName="Security"; Id=4688; StartTime=(Get-Date).AddHours(-24)} |
Where-Object {
$_.Message -match "-EncodedCommand|-enc |-nop|-windowstyle hidden|iex |invoke-expression|downloadstring|downloadfile|bypass"
} |
Select-Object TimeCreated, Message | Format-List
// Suspicious parent-child relationships
Get-WinEvent -FilterHashtable @{LogName="Security"; Id=4688; StartTime=(Get-Date).AddHours(-24)} |
Where-Object {
$_.Message -match "Creator Process Name.*\\(winword|excel|powerpnt|outlook|mspub)\.exe" -and
$_.Message -match "New Process Name.*\\(cmd|powershell|wscript|cscript|mshta)\.exe"
} | Select-Object TimeCreated, Message | Format-List
// PowerShell script block logging (Event ID 4104)
// This shows decoded PowerShell content even if it was obfuscated
Get-WinEvent -FilterHashtable @{
LogName = "Microsoft-Windows-PowerShell/Operational"
Id = 4104
Level = 3 // Warning level = PowerShell flagged this as suspicious
StartTime = (Get-Date).AddHours(-48)
} | ForEach-Object {
[PSCustomObject]@{
Time = $_.TimeCreated
ScriptBlock = $_.Properties[2].Value | Select-Object -First 500
ScriptPath = $_.Properties[4].Value
}
} | Format-List
// All 4104 events (not just warnings) for full coverage
Get-WinEvent -FilterHashtable @{
LogName = "Microsoft-Windows-PowerShell/Operational"
Id = 4104
StartTime = (Get-Date).AddHours(-24)
} | Where-Object {
$_.Message -match "DownloadString|DownloadFile|WebClient|IEX|Invoke-Expression|FromBase64|EncodedCommand|Reflection\.Assembly"
} | Select-Object TimeCreated, Message | Format-List
Phase 3: Persistence locations
Check every standard persistence location. Attackers who know their tooling will clean up temporary files but frequently forget persistence mechanisms installed before they established a stable foothold.
// Registry run keys across all hives
$runkeys = @(
"HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Run",
"HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\RunOnce",
"HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\RunServices",
"HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Run",
"HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Run",
"HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\RunOnce"
)
foreach ($key in $runkeys) {
if (Test-Path $key) {
$values = Get-ItemProperty $key | Select-Object * -ExcludeProperty PS*
$values.PSObject.Properties | Where-Object {$_.Name -ne ""} |
ForEach-Object {
[PSCustomObject]@{
Key = $key
Name = $_.Name
Value = $_.Value
# Flag entries pointing outside standard directories
Suspicious = ($_.Value -notmatch "^C:\\Windows\\" -and
$_.Value -notmatch "^C:\\Program Files")
}
} | Format-Table -AutoSize
}
}
// Winlogon hijacking (a common persistence location)
Get-ItemProperty "HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon" |
Select-Object Shell, Userinit, Taskman
// Expected values:
// Shell: explorer.exe
// Userinit: C:\Windows\system32\userinit.exe,
// Taskman: (empty or legitimate path)
// AppInit_DLLs (DLL loaded into every process that loads User32.dll)
Get-ItemProperty "HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Windows" |
Select-Object AppInit_DLLs, LoadAppInit_DLLs
Get-ItemProperty "HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows NT\CurrentVersion\Windows" |
Select-Object AppInit_DLLs, LoadAppInit_DLLs
// Image File Execution Options (hijacks debugger for specific executables)
Get-ChildItem "HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Image File Execution Options" |
ForEach-Object {
$debugger = (Get-ItemProperty $_.PSPath -ErrorAction SilentlyContinue).Debugger
if ($debugger) {
Write-Output "IFEO: $($_.PSChildName) -> Debugger: $debugger"
}
}
// WMI persistence (commonly missed in manual IR checklists)
Get-WMIObject -Namespace root\subscription -Class __EventFilter |
Select-Object Name, Query, QueryLanguage | Format-List
Get-WMIObject -Namespace root\subscription -Class __EventConsumer |
Select-Object Name, CommandLineTemplate, ScriptText | Format-List
Get-WMIObject -Namespace root\subscription -Class __FilterToConsumerBinding |
Select-Object Filter, Consumer | Format-List
// If any of these have entries you did not create, you have WMI persistence
// Common attacker pattern: EventFilter watches for system events (timer, login)
// EventConsumer runs a command or script when the filter fires
// Startup folders for all users
Get-ChildItem "C:\ProgramData\Microsoft\Windows\Start Menu\Programs\Startup" -ErrorAction SilentlyContinue
Get-ChildItem "$env:APPDATA\Microsoft\Windows\Start Menu\Programs\Startup" -ErrorAction SilentlyContinue
Get-ChildItem "C:\Users\*\AppData\Roaming\Microsoft\Windows\Start Menu\Programs\Startup" -ErrorAction SilentlyContinue
Phase 4: Execution artefacts
These artefacts tell you what ran, when, and often where from, even after the attacker has deleted the tools they used.
// Prefetch: execution history with timestamps
// Each file records the last 8 run times and referenced file paths
if (Test-Path C:\Windows\Prefetch) {
Get-ChildItem C:\Windows\Prefetch\*.pf |
Select-Object Name, LastWriteTime, CreationTime, Length |
Sort-Object LastWriteTime -Descending |
Where-Object { $_.LastWriteTime -gt (Get-Date).AddHours(-48) } |
Format-Table -AutoSize
// Flag executables with suspicious names that have run recently
Get-ChildItem C:\Windows\Prefetch\*.pf |
Where-Object {
$_.Name -match "MIMIKATZ|PROCDUMP|POWERSPLOIT|METERPRETER|COBALTSTRIKE|RUBEUS|BLOODHOUND|SHARPHOUND"
} | Format-Table Name, LastWriteTime -AutoSize
}
// Parse with PECmd for full detail including referenced files
// PECmd.exe -d C:\Windows\Prefetch --csv C:\ir\ --csvf prefetch.csv
// Amcache: file execution records with SHA1 hashes
// Copy for offline analysis (cannot be read while in use by the system)
if (Test-Path C:\Windows\AppCompat\Programs\Amcache.hve) {
try {
Copy-Item C:\Windows\AppCompat\Programs\Amcache.hve C:\ir\Amcache.hve
Write-Output "Amcache copied successfully"
} catch {
Write-Output "Could not copy Amcache (system file in use)"
Write-Output "Boot from WinPE or use a forensic acquisition tool"
}
}
// Parse offline with AmcacheParser.exe:
// AmcacheParser.exe -f C:\ir\Amcache.hve --csv C:\ir\ --csvf amcache.csv
// Shimcache: file presence records
// Copy the SYSTEM hive for offline parsing
reg save HKLM\SYSTEM C:\ir\SYSTEM.hive /y
// Parse offline with AppCompatCacheParser.exe:
// AppCompatCacheParser.exe -f C:\ir\SYSTEM.hive --csv C:\ir\ --csvf shimcache.csv
// Look for executables in unusual locations within the incident time window
// Recently accessed files via LNK files (shows what the user interacted with)
Get-ChildItem "$env:APPDATA\Microsoft\Windows\Recent" -ErrorAction SilentlyContinue |
Sort-Object LastWriteTime -Descending | Select-Object Name, LastWriteTime | Select-Object -First 30
// Shell bags (folder access history, useful for showing attacker navigation)
// Requires offline parsing with ShellBagsExplorer against the ntuser.dat hive
Phase 5: Quick scope assessment
Before going deep on any single finding, assess scope. How many systems are affected? What time range? This determines whether you are dealing with a contained incident or an active widespread compromise that needs immediate containment.
// If you have Velociraptor deployed, run this hunt across all endpoints:
// SELECT Hostname, OS, ProcessId, Name, Exe, CommandLine
// FROM pslist()
// WHERE Name =~ "mimikatz|meterpreter|cobaltstrike|empire|metasploit"
// If you have Splunk, correlate the source IP from the initial logon
// across all other systems:
// index=wineventlog EventCode=4624
// | where src_ip="[suspicious_IP]"
// | stats count by ComputerName, Account_Name
// | sort -count
// If you have Elastic, use EQL correlation:
// sequence by host.name with maxspan=10m
// [process where process.name == "cmd.exe" and process.parent.name == "winword.exe"]
// [network where destination.port == 443]
The most important principle: breadth before depth
The single most common mistake in initial triage is spending 90 minutes going deep on one suspicious process before completing a broad sweep. In real incidents and in CTF challenges alike, the most obvious suspicious finding is often a decoy or a secondary tool planted after the real persistence mechanism was established.
Complete the full checklist across all five phases first. Document everything you find. Then prioritise which finding to investigate in depth based on the complete picture. The most important finding is usually not the first one you notice.