You’ve landed a shell on a Windows lab box, or you’re just staring at a blue PowerShell window at work. You type ls, it works, and you assume it’s Bash with a different colour scheme. It isn’t. If you treat PowerShell like a text shell, you’ll fight it. If you treat it like an object shell, a lot of hard tasks become one-liners.

This post walks through the PowerShell commands you’ll use every day. It covers how the object pipeline works, how to find commands on your own, how to handle files, processes and services, and how to export results. It ends with a short security section on execution policy and basic triage. Every command and output below was run on Windows PowerShell 5.1 (5.1.26100) on Windows 11.

Run these examples on your own machine or a lab VM only.

Before you start: open the shell and check your version

Open Windows PowerShell from the Start menu. You don’t need admin rights for anything here except Update-Help. Check which version you’re on first, because a few behaviours differ between editions:

$PSVersionTable.PSVersion
Major  Minor  Build  Revision
-----  -----  -----  --------
5      1      26100  9444

Everything below was tested on Windows PowerShell 5.1. PowerShell 7 (pwsh) differs in a few places noted along the way, such as JSON enum handling and which aliases exist on Linux and macOS.

What PowerShell is: objects, not text

In Bash, ps aux | grep ssh passes text from one command to the next. You then cut columns out of that text with awk or cut, and the parsing breaks when the output format changes.

PowerShell passes .NET objects down the pipeline. A service isn’t a line of text. It’s a System.ServiceProcess.ServiceController object with properties like Status, Name and StartType. You filter and sort on those properties by name, with no string parsing.

(Get-Service -Name Spooler).GetType().FullName
# System.ServiceProcess.ServiceController

The table you see on screen is just how PowerShell chose to display the object. The object behind it carries much more data.

The Verb-Noun naming convention

Cmdlets follow a Verb-Noun pattern: Get-Process, Stop-Process, New-Item, Remove-Item. The verbs come from an approved list, so once you know Get, Set, New, Remove, Start and Stop, you can guess most command names.

Get-Verb                        # list the approved verbs
(Get-Verb | Measure-Object).Count   # 98 on this 5.1 machine
98

The convention makes the shell predictable. If Get-Service exists, Start-Service, Stop-Service and Set-Service almost certainly do too.

Discovery: Get-Command, Get-Help, Get-Member

You don’t need to memorise PowerShell. These three cmdlets let you find everything else.

Get-Command finds commands

Get-Command -Verb Get -Noun *Process*
Get-Command -Noun Service

Get-Help explains them

Get-Help Get-ChildItem
Get-Help Get-ChildItem -Examples
Get-Help Get-ChildItem -Online

On a fresh Windows install the full help files are not present. Get-Help shows only partial help and tells you to run Update-Help:

Get-Help shows partial help until Update-Help is run

In Windows PowerShell 5.1, Update-Help needs an elevated (Run as administrator) session to update help for the core modules:

Update-Help

If you can’t elevate, -Online opens the same documentation in your browser.

Get-Member shows what an object really is

This is the most useful cmdlet for beginners. Pipe anything to Get-Member to see its type, properties and methods.

Get-Service -Name Spooler | Get-Member -MemberType Property

Get-Command and Get-Member output

Once you know the property names, you know what you can filter, sort and select on.

The file cmdlets cover the same ground as pwd, cd, ls, cp, mv, rm and cat:

Get-Location                                   # where am I?
# Work in a dedicated lab folder instead of the shared TEMP root
New-Item -Path $env:TEMP\ps-lab -ItemType Directory -Force
Set-Location -Path $env:TEMP\ps-lab            # change directory
Get-ChildItem                                  # list items
Get-ChildItem -Recurse -Filter *.log           # recursive, filtered

New-Item -Path .\notes.txt -ItemType File
New-Item -Path .\backup -ItemType Directory

Set-Content -Path .\notes.txt -Value "target: 10.10.10.10", "port: 445"
Add-Content -Path .\notes.txt -Value "status: open"
Get-Content -Path .\notes.txt

Copy-Item -Path .\notes.txt -Destination .\backup\notes-copy.txt
Move-Item -Path .\backup\notes-copy.txt -Destination .\backup\old-notes.txt
Remove-Item -Path .\backup -Recurse -WhatIf    # preview first
Remove-Item -Path .\backup -Recurse

Set-Content overwrites a file and Add-Content appends to it. -WhatIf is your safety net: it prints what would happen without doing it.

File cmdlets run in a lab folder, including -WhatIf

The pipeline: filter, shape, sort, loop, count

These five cmdlets do most of the work in real PowerShell:

Cmdlet Job Example
Where-Object Keep objects that match a condition Where-Object Status -eq "Running"
Select-Object Pick properties or the first/last N Select-Object -First 5 Name, Id
Sort-Object Order by a property Sort-Object WorkingSet64 -Descending
ForEach-Object Run a script block per object ForEach-Object { $_ * 2 }
Measure-Object Count, sum, average, min, max Measure-Object -Property Length -Sum

Inside a script block, $_ is the current object. Chain the cmdlets together:

# Top 5 processes by memory, with a calculated MB column
Get-Process |
    Sort-Object -Property WorkingSet64 -Descending |
    Select-Object -First 5 -Property Name, Id,
        @{Name="MemMB"; Expression={[math]::Round($_.WorkingSet64 / 1MB)}}

# How many services are running?
Get-Service | Where-Object Status -eq "Running" | Measure-Object

# Total size of files in the current folder
Get-ChildItem -File | Measure-Object -Property Length -Sum

Pipeline examples with real output

Filter as early as you can. Where-Object near the start means fewer objects flowing through the rest of the pipeline.

Processes and services

1234 below is a placeholder PID. Replace it with a real one from Get-Process, or safer still, start a throwaway process and capture its PID so you only ever stop that one:

Get-Process                          # all processes
Get-Process -Name explorer           # by name
Get-Process -Id 1234                 # by PID (replace 1234 with a real PID)

# Start a disposable process and keep its PID
$p = Start-Process -FilePath ping.exe -ArgumentList "-n 60 127.0.0.1" -WindowStyle Hidden -PassThru
Get-Process -Id $p.Id | Select-Object Name, Id
Stop-Process -Id $p.Id -WhatIf       # preview
Stop-Process -Id $p.Id               # kill by PID

Get-Service                          # all services
Get-Service -Name Spooler, WinRM | Select-Object Name, Status, StartType

Prefer Stop-Process -Id over -Name. Killing by name stops every process with that name, including ones you didn’t mean to touch. In the screenshot below, a throwaway ping.exe is started, inspected and stopped by its exact PID:

Starting, inspecting and stopping a process by PID, then exporting service data

Output and export

Because the data is objects, exporting it is one cmdlet:

# Plain text, exactly as shown on screen
Get-Process | Select-Object -First 3 Name, Id | Out-File -FilePath .\procs.txt

# CSV for Excel or a report
Get-Service | Where-Object Status -eq "Running" |
    Select-Object Name, DisplayName |
    Export-Csv -Path .\running-services.csv -NoTypeInformation

# JSON for APIs and tools
Get-Service -Name Spooler | Select-Object Name, Status | ConvertTo-Json

One gotcha in Windows PowerShell 5.1: ConvertTo-Json writes enums as numbers. The command above produced "Status": 4, not "Running". Convert the value to a string first if a human or another tool will read it:

Get-Service -Name Spooler |
    Select-Object Name, @{Name="Status"; Expression={$_.Status.ToString()}} |
    ConvertTo-Json
{
    "Name":  "Spooler",
    "Status":  "Running"
}

Variables, if and loops

Variables start with $ and can hold anything, including a whole collection of objects:

$services = Get-Service -Name Spooler, WinRM, EventLog

foreach ($svc in $services) {
    if ($svc.Status -eq "Running") {
        "$($svc.Name) is running"
    } else {
        "$($svc.Name) is $($svc.Status)"
    }
}
EventLog is running
Spooler is running
WinRM is Stopped

Comparison operators are words, not symbols: -eq, -ne, -gt, -lt, -like, -match. > is redirection, not "greater than". Use $( ) inside double quotes to expand a property, as in "$($svc.Name)".

Aliases: familiar names, real cmdlets

Many Unix and CMD habits work because PowerShell ships aliases for them. Get-Alias shows what each one maps to:

Get-Alias -Name ls, cd, cat, gc, pwd, cp, mv, rm, ps, kill
Get-Alias -Definition Get-ChildItem    # dir, gci, ls
ls -la                                 # Unix flags don't carry over
ls -la : A parameter cannot be found that matches parameter name 'la'.
Alias Cmdlet Note
ls, dir, gci Get-ChildItem ls -la fails: the alias doesn’t accept Unix flags
cd Set-Location
pwd Get-Location
cat, gc Get-Content
cp / mv / rm Copy-Item / Move-Item / Remove-Item
ps Get-Process
kill Stop-Process

Variables, a foreach/if loop, and alias mappings

Use aliases at the prompt and full cmdlet names in scripts. Full names are easier to read and review. Aliases also differ by platform: on PowerShell 7 for Linux and macOS, ls, cat, cp, mv, rm and ps are not aliases, so they run the native tools.

Security angle: execution policy and quick triage

Execution policy is not a security boundary

Get-ExecutionPolicy          # effective policy
Get-ExecutionPolicy -List    # policy per scope, in precedence order

If no scope sets a policy on a Windows client, the effective policy is Restricted, which blocks script files. It’s easy to assume that makes the box safe from scripts. It doesn’t. Microsoft’s own documentation says execution policy isn’t a security boundary. It exists to stop users from running scripts by accident.

In the lab, create a one-line script, then watch it get blocked under Restricted but run fine when its contents are piped to a new PowerShell process:

Set-Content -Path .\hello.ps1 -Value 'Write-Output "hello from a script"'

powershell.exe -NoProfile -ExecutionPolicy Restricted -File .\hello.ps1
# ...cannot be loaded because running scripts is disabled on this system.

Get-Content -Path .\hello.ps1 | powershell.exe -NoProfile -ExecutionPolicy Restricted -Command -
# hello from a script

Execution policy blocks the file but not the piped content; listening ports mapped to processes

Detecting the bypass

Execution policy won’t catch that trick, but logging will. Script Block Logging records the actual code PowerShell runs, including code piped to -Command -, whatever the execution policy says. Each logged block is written as Event ID 4104 in the Microsoft-Windows-PowerShell/Operational log. On this machine the log was already enabled, and the piped hello from a script block showed up as a 4104 event:

Get-WinEvent -FilterHashtable @{
    LogName = 'Microsoft-Windows-PowerShell/Operational'; Id = 4104
} -MaxEvents 20 | Select-Object TimeCreated, Id, LevelDisplayName

Script Block Logging records the code as Event ID 4104

Windows auto-logs some suspicious blocks, but for full coverage you turn Script Block Logging on through Group Policy (Administrative Templates → Windows Components → Windows PowerShell → Turn on PowerShell Script Block Logging) or the matching registry value. Check whether it’s on first, rather than changing the box for no reason:

Get-WinEvent -ListLog Microsoft-Windows-PowerShell/Operational |
    Select-Object LogName, IsEnabled, RecordCount

For defenders, the real controls come from elsewhere: application control (WDAC/AppLocker), Script Block and module logging, and least privilege. Don’t rely on execution policy alone.

Basic triage with Get-Process and Get-NetTCPConnection

Two quick questions during triage are "what’s running?" and "what’s listening?":

# Listening TCP ports, mapped to the owning process name.
# Drop -LocalPort to see every listener; here we focus on 135 and 445.
Get-NetTCPConnection -State Listen -LocalPort 135, 445 |
    Select-Object LocalAddress, LocalPort, OwningProcess,
        @{Name="Process"; Expression={(Get-Process -Id $_.OwningProcess).Name}}

# Processes running from outside the usual install folders
Get-Process |
    Where-Object { $_.Path -and $_.Path -notlike "C:\Windows\*" -and $_.Path -notlike "C:\Program Files*" } |
    Select-Object Name, Id, Path

The second query will list legitimate per-user apps too, and processes you don’t have rights to inspect have an empty Path. Treat its output as a list of leads to check, not a verdict. Export either result with Export-Csv to compare against a known-good baseline later.

Key takeaways

References