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)] 
        [string]$ComputerName,
        
        [Parameter(Mandatory)] 
        [ValidateScript({ Test-Path $_ })]
        [string]$CaseDir
    )
    
    begin
    {
        $hostDir = Join-Path $CaseDir $ComputerName
        $null = New-Item -ItemType Directory -Path $hostDir -Force
        # 1. Snapshot the relevant event channels
        $channels = @(
            'Microsoft-Windows-PowerShell/Operational',
            'Windows PowerShell',
            'Security',
            'System',
            'Microsoft-Windows-Sysmon/Operational',
            'Microsoft-Windows-Windows Defender/Operational'
        )
        # 2. Collect Powershell transcripts (path comes from your GPO)
        $tx = "\\$ComputerName\C$\PSLogs\Transcripts"
    }

    process
    {
        foreach ($ch in $channels)
        {
            $safe = ($ch -replace '[\\/]', '_')
            $out = Join-Path $hostDir "$safe.evtx"
            wevtutil epl $ch $out /r:$ComputerName 2>$null
        }
        if (Test-Path $tx)
        {
            Copy-Item $tx (Join-Path $hostDir 'Transcripts') -Recurse -Force
        }

        # 3. Hash everything we collected so chain-of-custody is intact
        Get-ChildItem $hostDir -Recurse -File |
        ForEach-Object { Get-FileHash $_.FullName -Algorithm SHA256 } |
        Export-Csv (Join-Path $hostDir 'hashes.csv') -NoTypeInformation
    }

    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()]
    param(
        [string]$EvtxPath,
        [datetime]$Start,
        [datetime]$End
    )
    process
    {
        Get-WinEvent -Path $EvtxPath -FilterXPath "*[System[EventID=4104]]" |
        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
    }
}

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()]
    param([string]$CaseDir)
    process
    {
        $rows = New-Object System.Collections.Generic.List[object]

        # Powershell script blocks
        Get-ChildItem $CaseDir -Recurse -Filter '*PowerShell_Operational.evtx' |
        ForEach-Object { Get-WinEvent -Path $_.FullName -FilterXPath '*[System[EventID=4104]]' } |
        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))
                })
        }

        # Sysmon process creates
        Get-ChildItem $CaseDir -Recurse -Filter '*Sysmon_Operational.evtx' |
        ForEach-Object { Get-WinEvent -Path $_.FullName -FilterXPath '*[System[EventID=1]]' } |
        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
                })
        }

        # Sysmon network
        Get-ChildItem $CaseDir -Recurse -Filter '*Sysmon_Operational.evtx' |
        ForEach-Object { Get-WinEvent -Path $_.FullName -FilterXPath '*[System[EventID=3]]' } |
        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}"
                })
        }

        # Defender
        Get-ChildItem $CaseDir -Recurse -Filter '*Windows_Defender_Operational.evtx' |
        ForEach-Object { Get-WinEvent -Path $_.FullName -FilterXPath '*[System[EventID=1116 or EventID=1117]]' } |
        ForEach-Object {
            $rows.Add([pscustomobject]@{
                    Time   = $_.TimeCreated
                    Host   = $_.MachineName
                    Source = "Defender-$($_.Id)"
                    User   = $_.UserId
                    Detail = ($_.Message -split "`n" | Select-Object -First 1)
                })
        }

        $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()]
    param([string]$Script, [int]$Depth = 0, [int]$MaxDepth = 5)

    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 { }
        }

        # 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 { }
        }

        # 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 { }
        }
    }
    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()]
    param(
        [Parameter(Mandatory)] [string]$User,
        [Parameter(Mandatory)] [datetime]$Anchor,
        [int]$WindowMinutes = 60,
        [string[]]$Hosts
    )
    process
    {
        $start = $Anchor.AddMinutes(-$WindowMinutes)
        $end = $Anchor.AddMinutes( $WindowMinutes)

        foreach ($h in $Hosts)
        {
            Get-WinEvent -ComputerName $h -FilterHashtable @{
                LogName   = 'Microsoft-Windows-PowerShell/Operational'
                Id        = 4104
                StartTime = $start
                EndTime   = $end
            } -ErrorAction SilentlyContinue |
            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)) } }
        }
    }
}

$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
{
    param([string]$Script)

    $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
}

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
{
    param([string]$CaseDir)

    process
    {
        $timeline = Import-Csv (Join-Path $CaseDir 'timeline.csv')
        $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

        @"
# 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 (Join-Path $CaseDir 'report.md')
    }
}

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).