My Windows DFIR Checklist for Initial Triage

23 September 2025 | 9 min read | justruss.tech

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.