PowerShell threat hunting on a Windows endpoint sits between two extremes: the built-in Get-Process tells you almost nothing useful for triage, while a full EDR product does the job but is not always installed on the box in front of you. The middle ground — a handful of Get-CimInstance, Get-NetTCPConnection, and Get-WinEvent queries — is what I reach for first when something looks off and I need to decide whether to escalate. This guide covers both halves of that work: the fast one-liners for the first five minutes, and the deeper process investigation for when one of them turns up a lead. Everything here is read-only and intended for authorised triage on systems you administer.
Key Takeaways
- PowerShell threat hunting for triage comes down to four signals: who started a process, with what arguments, signed by whom, and what it is talking to on the network.
- One-liners are for quick orientation; the deeper
Get-CimInstance Win32_Processqueries are what you run once a one-liner flags something. Get-Processalone is not enough — the command line, parent PID, and creation time live onWin32_ProcessviaGet-CimInstance.- Parent-child relationships are the fastest way to spot living-off-the-land abuse, like Office or Outlook spawning
powershell.exe. - These techniques are for authorised triage and baselining, not continuous monitoring — for that, use Microsoft Defender for Endpoint, Sysmon plus a SIEM, or an equivalent agent.
Environment
- Windows 10 22H2 / Windows 11 23H2 endpoints, or Windows Server 2019/2022.
- Windows PowerShell 5.1 or PowerShell 7.4 — every snippet works on both unless flagged.
- Local administrator rights. Command lines, the Security log, and signature paths silently truncate output without elevation.
- Authorised access to the host. These commands are read-only but should still only run with permission.
The Problem
Most blog posts about PowerShell threat hunting stop at Get-Process | Sort-Object CPU -Descending, which is fine for finding the browser tab eating your battery but tells you nothing about an attacker. The information a defender actually wants — who started this, with what arguments, signed by whom, talking to which IP — is split across at least four sources: the WMI/CIM Win32_Process class, Authenticode signature data, the network stack via Get-NetTCPConnection, and registry-based startup locations.
So triage has two speeds. The first is orientation: a pocket card of read-only one-liners you can paste from memory in the first five minutes on a possibly-compromised host. The second is investigation: wiring those four sources together to decide whether a specific process is benign or worth paging an analyst over. This guide covers both, in that order.
Fast Orientation — The One-Liner Card
These are the equivalent of a paramedic's pocket card. Not where the bulk of the work happens, but exactly what you reach for when something looks wrong and you need an answer right now. Pick the four or five that map to the questions you ask most, drop them in a profile module, and they become reflexes.
Top CPU consumers right now
Get-Process | Sort-Object CPU -Descending | Select-Object -First 10 ProcessName, Id, CPU, @{N='MB';E={[math]::Round($_.WorkingSet64/1MB,1)}}
Quick orientation. Anything sitting at 80–100% CPU that you do not recognise is your first lead — particularly common with coin miners and unpacked loaders.
Services running from temporary or user-writable paths
Get-CimInstance Win32_Service | Where-Object { $_.PathName -match 'temp|appdata|programdata|users\\public' } | Select-Object Name,DisplayName,PathName,StartName,State
Legitimate services almost never live in user-writable paths. Anything matching here deserves a closer look at the binary signature. A companion check is services set to auto-start but currently stopped (Get-Service | Where-Object { $_.StartType -eq 'Automatic' -and $_.Status -eq 'Stopped' }), since attackers sometimes disable Defender or Windows Update without unsetting auto-start.
Autorun entries from the four classic Run keys
'HKLM:\Software\Microsoft\Windows\CurrentVersion\Run','HKLM:\Software\Microsoft\Windows\CurrentVersion\RunOnce','HKCU:\Software\Microsoft\Windows\CurrentVersion\Run','HKCU:\Software\Microsoft\Windows\CurrentVersion\RunOnce' | ForEach-Object { Get-ItemProperty $_ -ErrorAction SilentlyContinue | Select-Object PSPath,* -ExcludeProperty PS* }
The simplest persistence anchor. -ErrorAction SilentlyContinue is essential here — without it the output is buried in access-denied errors when a key does not exist. For broader coverage use Sysinternals Autoruns; this catches the most common locations.
Recently created executables outside Program Files
Get-ChildItem -Path C:\ -Include *.exe,*.dll,*.ps1,*.bat,*.cmd -Recurse -ErrorAction SilentlyContinue | Where-Object { $_.CreationTime -gt (Get-Date).AddHours(-24) -and $_.FullName -notmatch 'Program Files|Windows\\(System32|SysWOW64|WinSxS|servicing|assembly)' } | Select-Object FullName,CreationTime,Length
A filesystem-level new-arrival check. Tune the time window and exclusion list to match what your environment legitimately drops. On a server with millions of files this walk can take minutes — scope it tighter when in doubt.
Local admins, scheduled tasks, and failed logons
Three more orientation checks worth keeping on the card — local administrators (including nested domain group members), recently-active scheduled tasks, and a failed-logon roll-up:
# Anyone in Administrators who is not your expected admin is worth a question
Get-LocalGroupMember -Group 'Administrators' | Select-Object Name,PrincipalSource,ObjectClass
# Scheduled tasks that ran recently or are due — a top persistence mechanism
Get-ScheduledTask | Get-ScheduledTaskInfo | Where-Object { $_.LastRunTime -gt (Get-Date).AddDays(-7) -or $_.NextRunTime -gt (Get-Date) } | Select-Object TaskName,LastRunTime,NextRunTime,LastTaskResult
# Failed logons in the last hour, grouped by user and source IP
Get-WinEvent -FilterHashtable @{LogName='Security';Id=4625;StartTime=(Get-Date).AddHours(-1)} -ErrorAction SilentlyContinue | Select-Object @{N='User';E={$_.Properties[5].Value}},@{N='IP';E={$_.Properties[19].Value}} | Group-Object User,IP | Sort-Object Count -Descending | Select-Object Count,Name -First 20
The failed-logon roll-up is the fastest way to confirm or rule out an active password attack. The querying side of event logs — building efficient FilterHashtable filters and decoding the Properties array — is a topic in itself, which I cover in PowerShell event log management for Windows security.
Deep Process Investigation — Beyond Get-Process
Once a one-liner flags something, the next step is a proper look at the process. This is where Get-Process falls down — it returns a .NET System.Diagnostics.Process object that does not include the command line or parent PID. Get-CimInstance Win32_Process does.
Step 1 — Get the data that Get-Process hides
Get-CimInstance Win32_Process |
Select-Object ProcessId,
ParentProcessId,
Name,
CommandLine,
CreationDate,
@{Name='Owner';Expression={ ($_ | Invoke-CimMethod -MethodName GetOwner).User }} |
Sort-Object CreationDate -Descending
CommandLine is $null for processes you do not have rights to read — usually services running as another user when you are not elevated. If you see a lot of empty command-line columns, re-run the console as administrator.
Step 2 — Map the parent-child tree
Attackers very often live off the land. winword.exe spawning cmd.exe spawning powershell.exe is the textbook macro-execution chain, and it is invisible until you draw the tree:
$procs = Get-CimInstance Win32_Process
$procs |
Select-Object ProcessId,
Name,
CommandLine,
@{Name='Parent';Expression={
($procs | Where-Object ProcessId -eq $_.ParentProcessId).Name
}} |
Where-Object Parent -in 'winword.exe','excel.exe','outlook.exe','powerpnt.exe' |
Format-Table -AutoSize
An Office product is not supposed to be the parent of cmd.exe, powershell.exe, wscript.exe, mshta.exe, or regsvr32.exe. If the table is not empty, that is the lead you investigate first.
Step 3 — Inspect command lines for the obvious red flags
PowerShell encoded commands, -WindowStyle Hidden, -ExecutionPolicy Bypass, and outbound download calls show up in command-line strings even when the script body never touches disk:
$flags = @(
'-enc', '-encodedcommand', 'frombase64string', 'iex',
'downloadstring', 'downloadfile', '-w hidden',
'-windowstyle hidden', '-nop', '-noprofile', '-executionpolicy bypass'
)
Get-CimInstance Win32_Process |
Where-Object CommandLine |
ForEach-Object {
$cl = $_.CommandLine.ToLower()
foreach ($f in $flags) {
if ($cl -like "*$f*") {
[PSCustomObject]@{
Pid = $_.ProcessId
Parent = $_.ParentProcessId
Name = $_.Name
Trigger = $f
Command = $_.CommandLine
}
break
}
}
}
False positives exist — your own deployment tooling may legitimately call -NoProfile -ExecutionPolicy Bypass. Baseline a clean system, list the expected hits, and treat anything outside that list as worth a second look. The logging counterpart to this — capturing the full deobfuscated script even when the command line is encoded — is PowerShell script block logging with Event ID 4104.
Step 4 — Verify Authenticode signatures
Unsigned binaries living in %TEMP%, %APPDATA%, or %PROGRAMDATA% are statistically far more interesting than signed Microsoft binaries in C:\Windows\System32:
Get-Process |
Where-Object Path |
Select-Object Name, Id, Path,
@{Name='Signer';Expression={ (Get-AuthenticodeSignature $_.Path).SignerCertificate.Subject }},
@{Name='Status';Expression={ (Get-AuthenticodeSignature $_.Path).Status }} |
Where-Object {
$_.Path -like "$env:TEMP*" -or
$_.Path -like "$env:APPDATA*" -or
$_.Status -ne 'Valid'
}
Combine the signature check with the parent-child tree from Step 2 and you have a short list of the processes worth investigating first.
Step 5 — Tie processes to network connections
A process running with no network activity is rarely interesting. A process you have never heard of with an established connection to a hosting-provider IP is worth a closer look:
Get-NetTCPConnection -State Established |
Where-Object { $_.RemoteAddress -notmatch '^10\.|^172\.(1[6-9]|2[0-9]|3[01])\.|^192\.168\.|^127\.|^::1' } |
Select-Object LocalPort, RemoteAddress, RemotePort,
@{Name='Process';Expression={ (Get-Process -Id $_.OwningProcess -ErrorAction SilentlyContinue).Name }},
@{Name='Path';Expression={ (Get-Process -Id $_.OwningProcess -ErrorAction SilentlyContinue).Path }} |
Sort-Object RemoteAddress
The regex filters RFC 1918 ranges plus IPv4 and IPv6 loopback, leaving only externally routable destinations. Legitimate update services and telemetry endpoints will show up here, so baseline before you alert.
Step 6 — Check the persistence surface
If a suspicious process keeps coming back after a reboot, it has a persistence anchor somewhere. Win32_StartupCommand is a one-line first pass over the legacy startup folders and basic Run keys:
Get-CimInstance Win32_StartupCommand | Select-Object Name, Command, Location, User
Scheduled tasks and WMI event subscriptions are the next places to look — the scheduled-task angle is covered in detecting malicious scheduled tasks on Windows.
From Triage to a Repeatable Process
Two habits turn these snippets from ad-hoc commands into a workflow. First, save the ones you use most into a profile module so they are always a tab-complete away. Second, baseline a known-clean machine of each role you run — workstation, member server, Domain Controller — so that when you run the parent-child tree or the external-connection check on a suspect host, you already know what "normal" looks like and the anomalies jump out.
When a triage query turns up something worth sharing, pipe it to Export-Csv -NoTypeInformation -Encoding UTF8BOM so it survives Excel, and run the whole set across a fleet with Invoke-Command -ComputerName $list -ScriptBlock { ... } rather than one host at a time. Both of those — clean CSV export and fleet-wide remoting — are worth doing properly, which is why I treat them as their own build-an-audit-pipeline topic rather than an afterthought here.
Frequently Asked Questions
Why does CommandLine come back empty for some processes?
Reading another user's process command line requires either local administrator rights or the same token as the process owner. Empty values almost always mean an unelevated console. Re-run as administrator.
Is Get-WmiObject still safe to use?
It works on 5.1 and produces the same data, but it is deprecated and absent from PowerShell 7. Use Get-CimInstance for new code — it talks WinRM rather than DCOM, plays better with firewalls, and is the supported path going forward.
Do I need to be a local administrator for endpoint triage?
For most of it, yes. CommandLine on Win32_Process, the Security event log, and most registry hives under HKLM require elevation. Without it, the same query silently returns fewer rows, which is misleading. Always run from an elevated shell.
Is this a replacement for an EDR?
No. These queries are point-in-time snapshots; an EDR captures the full process tree over time, correlates with file and registry activity, and stores the result for retrospective hunting. Use these scripts for triage on machines you administer or to learn what the signals look like. For continuous monitoring, deploy Microsoft Defender for Endpoint, Sysmon plus a SIEM, or an equivalent agent.
Are these queries safe to run on a live production server?
Yes — they are read-only and add negligible CPU. The only risks are operational: the filesystem walk on C:\ can take minutes on a host with millions of files, and Get-CimInstance Win32_Process over a slow link can be sluggish when a host has thousands of processes. Scope with -Filter and tighter paths when needed.
Conclusion
Most useful endpoint triage comes from four signals: who started the process, what arguments it has, who signed the binary, and what it is talking to. The one-liner card gets you oriented in the first five minutes; the deeper Get-CimInstance investigation tells you whether a specific lead is benign or worth escalating. Neither is a substitute for proper endpoint telemetry, but on a system you administer they are enough to decide whether something deserves a closer look or a clean bill of health.
Save the snippets as a profile module, baseline a known-clean machine, and the next time something feels off you will get to a useful answer in minutes rather than hours.
Related Posts
- PowerShell Event Log Management for Windows Security — the querying side, once a triage check points you at the logs.
- Windows Security: Detecting Malicious Scheduled Tasks — the persistence layer that usually accompanies a suspicious process.
- PowerShell Script Block Logging with Event ID 4104 — capturing the deobfuscated script behind an encoded command line.
Authoritative reference for the WMI class most of these queries lean on: Win32_Process on Microsoft Learn.
Editorial note: posts on this blog are drafted with AI assistance and then reviewed, edited, and tested against a real environment before publishing. Commands, output, and screenshots come from systems I actually ran the work on.
0 comments:
Post a Comment