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
4104events withMessageNumber/MessageTotalheaders. 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-Expressionstep 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-Expressionon 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:
- Practice the IR-collection script against a non-production host today.
Save-IRArtifactagainst 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. - 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.
- Maintain a one-page IR runbook that lives next to the helpers: "alert from hunting post → run
Save-IRArtifact→ runBuild-Timeline→ review last 60 minutes ofGet-PSSessionoutput → 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).


