This guide assumes you've enabled the auditing covered in the logging post, have a detection or alert in hand from the hunting post, and now need to figure out what actually happened.

Everything here is defensive incident response collection, reconstruction, and reporting on activity in your own environment.

The hunting post finds candidates. This one is what you do with them. The goal: in under an hour, go from "an alert fired on host X" to "here is the full session, here is what was touched, here is whether it succeeded, and here is a timeline I can hand to legal."

The First Five Minutes

Before you investigate anything, freeze the evidence. EventLog has a max size and Powershell can rotate it out from under you.

function Save-IRArtifact
{
    [CmdletBinding()]
    param
    (
        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)]
        [string]
        $ComputerName,

        [Parameter(Mandatory = $true)]
        [ValidateScript({ Test-Path $_ })]
        [string]
        $CaseDir
    )

    begin
    {
        Write-Verbose -Message '[Save-IRArtifact] Begin'
        $ErrorActionPreference = 'Stop'

        # 1. Snapshot the relevant event channels
        $channels = @(
            'Microsoft-Windows-PowerShell/Operational',
            'Windows PowerShell',
            'Security',
            'System',
            'Microsoft-Windows-Sysmon/Operational',
            'Microsoft-Windows-Windows Defender/Operational'
        )
    }

    process
    {
        $hostDir = Join-Path $CaseDir $ComputerName

        try
        {
            $null = New-Item -ItemType Directory -Path $hostDir -Force -ErrorAction Stop
        }
        catch
        {
            throw ('Could not create case dir {0}: {1}' -f $hostDir, $_.Exception.Message)
        }

        # 2. Collect Powershell transcripts (path comes from your GPO)
        $tx = "\\$ComputerName\C$\PSLogs\Transcripts"

        foreach ($ch in $channels)
        {
            $safe = ($ch -replace '[\\/]', '_')
            $out  = Join-Path $hostDir "$safe.evtx"

            try
            {
                wevtutil epl $ch $out /r:$ComputerName 2>$null

                if ($LASTEXITCODE -ne 0)
                {
                    Write-Warning -Message ('wevtutil exit {0} on channel {1}; continuing' -f $LASTEXITCODE, $ch)
                }
            }
            catch
            {
                Write-Warning -Message ('Failed to export channel {0} from {1}: {2}' -f $ch, $ComputerName, $_.Exception.Message)
            }
        }

        if (Test-Path $tx)
        {
            try
            {
                Copy-Item $tx (Join-Path $hostDir 'Transcripts') -Recurse -Force -ErrorAction Stop
            }
            catch [System.UnauthorizedAccessException]
            {
                Write-Warning -Message ('Transcripts share on {0} denied access; check IR account rights' -f $ComputerName)
            }
            catch
            {
                Write-Warning -Message ('Could not copy transcripts from {0}: {1}' -f $tx, $_.Exception.Message)
            }
        }

        # 3. Hash everything we collected so chain-of-custody is intact
        try
        {
            Get-ChildItem $hostDir -Recurse -File |
                ForEach-Object { Get-FileHash $_.FullName -Algorithm SHA256 } |
                Export-Csv (Join-Path $hostDir 'hashes.csv') -NoTypeInformation -ErrorAction Stop
        }
        catch
        {
            throw ('Failed to write hash manifest for {0}: {1}' -f $hostDir, $_.Exception.Message)
        }
    }

    end
    {
        Write-Verbose -Message '[Save-IRArtifact] End'
    }
}


Save-IRArtifact -ComputerName 'WIN-APP01' -CaseDir 'D:\Cases\INC-2025-0042'

Hash the artifacts immediately after collection. If the case ever goes to court or to internal legal review being able to prove the file you analyzed is the file you collected matters more than any analysis you do afterward.

Reconstructing a Session

Script-block logging captures each block separately. A 3-line script becomes 3 events. To rebuild the attacker's actual session, group by the ScriptBlockId (or by User + a small time window if blocks have unique IDs):

function Get-PSSession
{
    [CmdletBinding()]
    [OutputType([pscustomobject[]])]
    param
    (
        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)]
        [string]
        $EvtxPath,

        [Parameter(Mandatory = $true)]
        [datetime]
        $Start,

        [Parameter(Mandatory = $true)]
        [datetime]
        $End
    )

    begin
    {
        Write-Verbose -Message '[Get-PSSession] Begin'
    }

    process
    {
        try
        {
            $events = Get-WinEvent -Path $EvtxPath -FilterXPath "*[System[EventID=4104]]" -ErrorAction Stop
        }
        catch [System.Exception]
        {
            Write-Error -Message ('Could not read events from {0}: {1}' -f $EvtxPath, $_.Exception.Message)
            return
        }

        $events |
            Where-Object { $_.TimeCreated -ge $Start -and $_.TimeCreated -le $End } |
            ForEach-Object {
                [pscustomobject] @{
                    Time      = $_.TimeCreated
                    User      = $_.UserId.Value
                    ProcessId = $_.ProcessId
                    ScriptId  = $_.Properties[3].Value
                    Order     = $_.Properties[0].Value     # MessageNumber
                    Total     = $_.Properties[1].Value     # MessageTotal
                    Script    = $_.Properties[2].Value
                }
            } |
            Group-Object ScriptId |
            ForEach-Object {
                $ordered = $_.Group | Sort-Object Order
                [pscustomobject] @{
                    ScriptId  = $_.Name
                    StartTime = ($ordered | Select-Object -First 1).Time
                    User      = ($ordered | Select-Object -First 1).User
                    Pid       = ($ordered | Select-Object -First 1).ProcessId
                    FullText  = ($ordered.Script -join '')
                    Length    = ($ordered.Script -join '').Length
                }
            } |
            Sort-Object StartTime
    }

    end
    {
        Write-Verbose -Message '[Get-PSSession] End'
    }
}

A single logical script block over 20 KB gets split across multiple 4104 events with MessageNumber/MessageTotal headers. Always reassemble before analysis half a payload is worse than none.

Building a Timeline

A timeline is the single most valuable artifact in any IR. It's the thing that turns "lots of events" into "this is the story". Powershell makes this trivial:

function Build-Timeline
{
    [CmdletBinding()]
    [OutputType([pscustomobject[]])]
    param
    (
        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)]
        [string]
        $CaseDir
    )

    begin
    {
        Write-Verbose -Message '[Build-Timeline] Begin'
        $rows = New-Object -TypeName 'System.Collections.Generic.List[object]'
    }

    process
    {
        # Powershell script blocks
        try
        {
            Get-ChildItem $CaseDir -Recurse -Filter '*PowerShell_Operational.evtx' -ErrorAction Stop |
                ForEach-Object { Get-WinEvent -Path $_.FullName -FilterXPath '*[System[EventID=4104]]' -ErrorAction SilentlyContinue } |
                ForEach-Object {
                    $rows.Add([pscustomobject] @{
                        Time   = $_.TimeCreated
                        Host   = $_.MachineName
                        Source = 'PS-4104'
                        User   = $_.UserId
                        Detail = ($_.Properties[2].Value).Substring(0, [Math]::Min(200, $_.Properties[2].Value.Length))
                    })
                }
        }
        catch
        {
            Write-Warning -Message ('PowerShell channel read failed for {0}: {1}' -f $CaseDir, $_.Exception.Message)
        }

        # Sysmon process creates
        try
        {
            Get-ChildItem $CaseDir -Recurse -Filter '*Sysmon_Operational.evtx' -ErrorAction Stop |
                ForEach-Object { Get-WinEvent -Path $_.FullName -FilterXPath '*[System[EventID=1]]' -ErrorAction SilentlyContinue } |
                ForEach-Object {
                    $xml = [xml]$_.ToXml()
                    $cmd = ($xml.Event.EventData.Data | Where-Object Name -eq 'CommandLine').'#text'
                    $rows.Add([pscustomobject] @{
                        Time   = $_.TimeCreated
                        Host   = $_.MachineName
                        Source = 'Sysmon-1'
                        User   = ($xml.Event.EventData.Data | Where-Object Name -eq 'User').'#text'
                        Detail = $cmd
                    })
                }
        }
        catch
        {
            Write-Warning -Message ('Sysmon-1 read failed for {0}: {1}' -f $CaseDir, $_.Exception.Message)
        }

        # Sysmon network
        try
        {
            Get-ChildItem $CaseDir -Recurse -Filter '*Sysmon_Operational.evtx' -ErrorAction Stop |
                ForEach-Object { Get-WinEvent -Path $_.FullName -FilterXPath '*[System[EventID=3]]' -ErrorAction SilentlyContinue } |
                ForEach-Object {
                    $xml = [xml]$_.ToXml()
                    $dst = ($xml.Event.EventData.Data | Where-Object Name -eq 'DestinationIp').'#text'
                    $prt = ($xml.Event.EventData.Data | Where-Object Name -eq 'DestinationPort').'#text'
                    $rows.Add([pscustomobject] @{
                        Time   = $_.TimeCreated
                        Host   = $_.MachineName
                        Source = 'Sysmon-3'
                        User   = ($xml.Event.EventData.Data | Where-Object Name -eq 'User').'#text'
                        Detail = "${dst}:${prt}"
                    })
                }
        }
        catch
        {
            Write-Warning -Message ('Sysmon-3 read failed for {0}: {1}' -f $CaseDir, $_.Exception.Message)
        }

        # Defender
        try
        {
            Get-ChildItem $CaseDir -Recurse -Filter '*Windows_Defender_Operational.evtx' -ErrorAction Stop |
                ForEach-Object { Get-WinEvent -Path $_.FullName -FilterXPath '*[System[EventID=1116 or EventID=1117]]' -ErrorAction SilentlyContinue } |
                ForEach-Object {
                    $rows.Add([pscustomobject] @{
                        Time   = $_.TimeCreated
                        Host   = $_.MachineName
                        Source = "Defender-$($_.Id)"
                        User   = $_.UserId
                        Detail = ($_.Message -split "`n" | Select-Object -First 1)
                    })
                }
        }
        catch
        {
            Write-Warning -Message ('Defender channel read failed for {0}: {1}' -f $CaseDir, $_.Exception.Message)
        }
    }

    end
    {
        Write-Verbose -Message '[Build-Timeline] End'
        $rows | Sort-Object Time
    }
}

Build-Timeline -CaseDir 'D:\Cases\INC-2025-0042' |
Export-Csv 'D:\Cases\INC-2025-0042\timeline.csv' -NoTypeInformation

Open the resulting CSV in Excel. Sort by time. The story emerges in minutes.

De-Obfuscating What You Found

Most attacker payloads are nested at least one or two layers deep. A small recursive decoder handles the common cases:

function Resolve-Payload
{
    [CmdletBinding()]
    [OutputType([string])]
    param
    (
        [Parameter(Mandatory = $true, ValueFromPipeline = $true)]
        [string]
        $Script,

        [Parameter()]
        [int]
        $Depth = 0,

        [Parameter()]
        [int]
        $MaxDepth = 5
    )

    begin
    {
        Write-Verbose -Message ('[Resolve-Payload] Begin depth={0}/{1}' -f $Depth, $MaxDepth)
    }

    process
    {
        if ($Depth -ge $MaxDepth)
        {
            return $Script
        }

        # Layer 1: -EncodedCommand <base64>
        if ($Script -match '-(?:e|en|enc|enco|encod|encode|encoded|encodedc|encodedco|encodedcom|encodedcomm|encodedcomma|encodedcomman|encodedcommand)\s+([A-Za-z0-9+/=]{20,})')
        {
            try
            {
                $decoded = [Text.Encoding]::Unicode.GetString([Convert]::FromBase64String($matches[1]))
                return Resolve-Payload $decoded -Depth ($Depth + 1) -MaxDepth $MaxDepth
            }
            catch [System.FormatException]
            {
                Write-Verbose -Message '[Resolve-Payload] base64 layer rejected (bad padding); skipping'
            }
            catch
            {
                Write-Verbose -Message ('[Resolve-Payload] base64 layer unexpected error: {0}' -f $_.Exception.Message)
            }
        }

        # Layer 2: gzip + base64 ([IO.Compression.GzipStream]...)
        if ($Script -match "FromBase64String\(['""]([A-Za-z0-9+/=]+)['""]\)" -and $Script -match 'GzipStream')
        {
            try
            {
                $bytes   = [Convert]::FromBase64String($matches[1])
                $ms      = [IO.MemoryStream]::new($bytes)
                $gz      = [IO.Compression.GzipStream]::new($ms, [IO.Compression.CompressionMode]::Decompress)
                $reader  = [IO.StreamReader]::new($gz)
                $decoded = $reader.ReadToEnd()
                return Resolve-Payload $decoded -Depth ($Depth + 1) -MaxDepth $MaxDepth
            }
            catch [System.IO.InvalidDataException]
            {
                Write-Verbose -Message '[Resolve-Payload] gzip stream malformed; skipping'
            }
            catch
            {
                Write-Verbose -Message ('[Resolve-Payload] gzip layer unexpected error: {0}' -f $_.Exception.Message)
            }
        }

        # Layer 3: format-string ('{2}{0}{1}' -f 'a','b','c')
        if ($Script -match "['""]\{[0-9]+\}.*?-f\s+(['""].*?['""].*?(?:,\s*['""].*?['""])+)")
        {
            try
            {
                $resolved = Invoke-Expression "&{`$ErrorActionPreference='SilentlyContinue'; $($matches[0])}"

                if ($resolved -and $resolved -ne $Script)
                {
                    return Resolve-Payload $resolved -Depth ($Depth + 1) -MaxDepth $MaxDepth
                }
            }
            catch
            {
                Write-Verbose -Message ('[Resolve-Payload] format-string layer rejected: {0}' -f $_.Exception.Message)
            }
        }
    }

    end
    {
        Write-Verbose -Message '[Resolve-Payload] End'
        return $Script
    }
}

Run this in an isolated VM, not on a production host. The Invoke-Expression step is bounded but still executes attacker-controlled code in &{}. The right place to do this is a sandbox.

Pivoting From a Single Event

A typical IR ask: "We saw script X on host A. What else did this user run? Did they touch any other hosts?"

function Find-RelatedActivity
{
    [CmdletBinding()]
    [OutputType([pscustomobject[]])]
    param
    (
        [Parameter(Mandatory = $true)]
        [string]
        $User,

        [Parameter(Mandatory = $true)]
        [datetime]
        $Anchor,

        [Parameter()]
        [int]
        $WindowMinutes = 60,

        [Parameter(Mandatory = $true)]
        [string[]]
        $Hosts
    )

    begin
    {
        Write-Verbose -Message '[Find-RelatedActivity] Begin'
    }

    process
    {
        $start = $Anchor.AddMinutes(-$WindowMinutes)
        $end   = $Anchor.AddMinutes( $WindowMinutes)

        foreach ($h in $Hosts)
        {
            try
            {
                Get-WinEvent -ComputerName $h -FilterHashtable @{
                    LogName   = 'Microsoft-Windows-PowerShell/Operational'
                    Id        = 4104
                    StartTime = $start
                    EndTime   = $end
                } -ErrorAction Stop |
                    Where-Object { $_.UserId -like "*$User*" } |
                    Select-Object @{ N = 'Host'; E = { $h } }, TimeCreated, Id,
                        @{ N = 'Snippet'; E = { $_.Properties[2].Value.Substring(0, [Math]::Min(200, $_.Properties[2].Value.Length)) } }
            }
            catch [System.Diagnostics.Eventing.Reader.EventLogException]
            {
                # No matching events on this host, or RPC unreachable - record and move on.
                Write-Warning -Message ('Event-log query against {0} failed: {1}' -f $h, $_.Exception.Message)
            }
            catch
            {
                Write-Warning -Message ('Unexpected error querying {0}: {1}' -f $h, $_.Exception.Message)
            }
        }
    }

    end
    {
        Write-Verbose -Message '[Find-RelatedActivity] End'
    }
}

$pivot = @{
    User          = 'S-1-5-21-...-1104'
    Anchor        = '2025-07-22T14:33:00'
    WindowMinutes = 120
    Hosts         = @('WIN-APP01','WIN-APP02','WIN-DC01')
}
Find-RelatedActivity @pivot

The same shape of script (anchor + window + identity) is what every good IR pivot looks like. Build it once and reuse it for every case.

Indicators to Extract

Once you've reconstructed the session, mine it for the artifacts the rest of the org needs:

function Get-IOC
{
    [CmdletBinding()]
    [OutputType([pscustomobject])]
    param
    (
        [Parameter(Mandatory = $true, ValueFromPipeline = $true)]
        [string]
        $Script
    )

    begin
    {
        Write-Verbose -Message '[Get-IOC] Begin'
    }

    process
    {
        $iocs = [ordered] @{
            Urls          = ([regex]::Matches($Script, 'https?://[^\s''""<>)]+')                       | Select-Object -Expand Value -Unique)
            Ipv4          = ([regex]::Matches($Script, '\b(?:\d{1,3}\.){3}\d{1,3}\b')                  | Select-Object -Expand Value -Unique)
            Domains       = ([regex]::Matches($Script, '\b[a-z0-9-]+\.[a-z]{2,}(?:\.[a-z]{2})?\b')     | Select-Object -Expand Value -Unique)
            Sha256        = ([regex]::Matches($Script, '\b[a-fA-F0-9]{64}\b')                          | Select-Object -Expand Value -Unique)
            FilePaths     = ([regex]::Matches($Script, '[a-zA-Z]:\\[^\s''""]+')                        | Select-Object -Expand Value -Unique)
            RegKeys       = ([regex]::Matches($Script, 'HK(LM|CU|U|CR)\\[^\s''""]+')                   | Select-Object -Expand Value -Unique)
            ScheduledTask = ([regex]::Matches($Script, 'New-ScheduledTask|Register-ScheduledTask').Count -gt 0)
            ServicesNew   = ([regex]::Matches($Script, 'New-Service\b').Count -gt 0)
        }

        [pscustomobject] $iocs
    }

    end
    {
        Write-Verbose -Message '[Get-IOC] End'
    }
}

Hand the result to your SIEM team they'll immediately want to query for Urls and Ipv4 across the entire fleet to scope the blast radius.

Generating the Report

A consistent report format saves your future self every single time:

function New-IRReport
{
    [CmdletBinding()]
    param
    (
        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)]
        [string]
        $CaseDir
    )

    begin
    {
        Write-Verbose -Message '[New-IRReport] Begin'
    }

    process
    {
        try
        {
            $timeline = Import-Csv (Join-Path $CaseDir 'timeline.csv') -ErrorAction Stop
        }
        catch [System.IO.FileNotFoundException]
        {
            throw ('No timeline.csv in {0}; run Build-Timeline first' -f $CaseDir)
        }
        catch
        {
            throw ('Could not read timeline.csv from {0}: {1}' -f $CaseDir, $_.Exception.Message)
        }

        $iocs  = $timeline | Where-Object Source -eq 'PS-4104' | ForEach-Object { Get-IOC $_.Detail }
        $hosts = $timeline.Host | Sort-Object -Unique
        $users = $timeline.User | Sort-Object -Unique

        $reportPath = Join-Path $CaseDir 'report.md'

        try
        {
            @"
# Incident Report - $(Split-Path $CaseDir -Leaf)

## Summary
- Time window: $($timeline[0].Time) -> $($timeline[-1].Time)
- Hosts touched: $($hosts -join ', ')
- Users involved: $($users -join ', ')

## Indicators of Compromise
- URLs: $(($iocs.Urls | Sort-Object -Unique) -join ', ')
- IPs:  $(($iocs.Ipv4 | Sort-Object -Unique) -join ', ')
- File paths: $(($iocs.FilePaths | Sort-Object -Unique) -join ', ')
- Registry keys: $(($iocs.RegKeys | Sort-Object -Unique) -join ', ')

## Timeline (top 50)
$(($timeline | Select-Object -First 50 | Format-Table | Out-String))
"@ | Set-Content -Path $reportPath -ErrorAction Stop
        }
        catch
        {
            throw ('Failed to write IR report {0}: {1}' -f $reportPath, $_.Exception.Message)
        }
    }

    end
    {
        Write-Verbose -Message '[New-IRReport] End'
    }
}

A markdown report renders in any ticketing system, diffs cleanly in git, and converts to PDF without losing structure.

What Not To Do

  • Don't log into the suspect host with a domain admin account. You're handing the attacker a much better credential than they had.
  • Don't delete events to "tidy up". The first thing forensics will check is whether the gap was the attacker or the responder.
  • Don't run Invoke-Expression on attacker payloads in the same shell you're investigating from. Always sandbox.
  • Don't rely on transcripts alone. Anyone with admin can disable transcription mid-session.
  • Don't skip the hashing step. A case without chain-of-custody is a case you can't escalate.

What to Do Next

Most "PowerShell forensics" comes down to four things: collect the right artifacts before they age out, reassemble the script blocks into real sessions, build a timeline that fuses PowerShell + Sysmon + Defender events, and extract IOCs the rest of the org can actually use.

Three concrete moves before the next incident finds you unprepared:

  1. Practice the IR-collection script against a non-production host today. Save-IRArtifact against your own laptop, just to walk through the prompts. The first time you run it should not be the morning of an incident; the muscle memory of "where do my artifacts land, what do they look like" is what saves you the first hour during a real event.
  2. Build the timeline against a known event (a Defender block from last month, a successful audit-flagged login). The timeline reconstruction is the part with the highest skill-floor; build it once on data with a known outcome so you trust the output the first time the data is novel.
  3. Maintain a one-page IR runbook that lives next to the helpers: "alert from hunting post → run Save-IRArtifact → run Build-Timeline → review last 60 minutes of Get-PSSession output → escalate if any of these patterns appear." The runbook stops you from re-deriving the workflow at 2 AM.

This post is the last in the logging → hunting → forensics trilogy. Pairs naturally with the JEA post (every JEA session emits transcripts that feed straight into this collection) and with the secret-management post (the credentials your IR scripts use to reach across the fleet should themselves be fetched from a vault, not hardcoded into the runbook).