In this section

0.2 Windows PowerShell 5.1 and PowerShell 7

Module 0

Introduction

Every current Windows machine has PowerShell installed, and many have two. Windows PowerShell 5.1 is part of Windows itself. PowerShell 7 is a separate program, installed alongside it. They share most of a language and most of their commands, so it is easy to use one while reading instructions written for the other, and to be surprised when something behaves differently.

For an analyst the difference matters twice. The scripts you write should target one version deliberately. And the evidence you investigate was produced by whichever one ran on the machine, which for an attacker is usually the one that is always there. This lesson shows how to tell the two apart, where they differ in ways an analyst meets, and which one this course uses.

Windows PowerShell 5.1 and PowerShell 7.6 Windows PowerShell 5.1 powershell.exe; .NET Framework; in every Windows; fixes only PowerShell 7.6 pwsh.exe; modern .NET; Windows, Linux, macOS; developed One analyst writes for 7.6, reads evidence from both Two programs with nearly the same language, installed side by side, logging to different places.

By the end of the lesson you can identify which PowerShell is running from $PSVersionTable, explain why Windows PowerShell 5.1 is on every Windows machine and what that means for investigations, name commands removed from PowerShell 7 and their replacements, check a file's text encoding, avoid losing data to JSON's default depth, use the language features PowerShell 7 adds, and recognize a script written for one edition only.

The evidence is a record of $PSVersionTable from both shells on Northgate's analyst workstation, NGE-DFIR-WS01, a Windows 11 machine. Commands that exist only in Windows PowerShell are shown as they appear on that workstation, labeled as such; everything else runs live in PowerShell 7.6, the version this course targets.

1

Two Shells, One Machine

what $PSVersionTable says

The automatic variable $PSVersionTable describes the PowerShell it is typed into. On the analyst workstation, in each of the two shells:

PS C:\> $PSVersionTable  # in Windows PowerShell (powershell.exe)
PSVersion                 5.1.26100.3194
PSEdition                 Desktop
BuildVersion              10.0.26100.3194
CLRVersion                4.0.30319.42000
WSManStackVersion         3.0
PSRemotingProtocolVersion 2.3
SerializationVersion      1.1.0.1
PS C:\> $PSVersionTable  # in PowerShell 7 (pwsh.exe)
PSVersion                 7.6.6
PSEdition                 Core
GitCommitId               7.6.6
OS                        Microsoft Windows 10.0.26100
Platform                  Win32NT
WSManStackVersion         3.0
PSRemotingProtocolVersion 2.4
SerializationVersion      1.1.0.1

Windows PowerShell reports version 5.1.26100.3194, edition Desktop, remoting protocol 2.3, and a CLRVersion, the version of the .NET Framework runtime it is built on. PowerShell 7 reports 7.6.6, edition Core, an operating system and a platform, and no CLRVersion, because it runs on modern .NET instead. Edition is the quickest tell: Desktop means 5.1, Core means 6 or later.

Inside a script, the same check is one line: $PSVersionTable.PSEdition, or $PSVersionTable.PSVersion.Major for the version number. Scripts that must behave differently in each shell test one of these at the top. Most of this course's scripts do not need to, because they use only commands and syntax that both editions share, or declare that they need 7.

Version numbers say more than they seem to. The 26100 in Windows PowerShell's version is the Windows build, because 5.1 is serviced as part of Windows: its version changes when Windows is patched, never otherwise. PowerShell 7's version is its own, and changes when it is updated, independently of Windows. A workstation fully patched for Windows can still run an old PowerShell 7, and the reverse.

The two are also separate programs, installed in separate places, as the record shows:

PS C:\Evidence> $v = Get-Content ./versions-ws01.json -Raw | ConvertFrom-Json
PS C:\Evidence> $v.WindowsPowerShell.Path
C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe
PS C:\Evidence> $v.PowerShell7.Path
C:\Program Files\PowerShell\7\pwsh.exe
PS C:\Evidence> $v.WindowsPowerShell.PSEdition, $v.PowerShell7.PSEdition -join ' / '
Desktop / Core

In the record, powershell.exe in System32 is Windows PowerShell; pwsh.exe in Program Files is PowerShell 7. The SysWOW64 folder holds a 32-bit copy of powershell.exe too, which matters when evidence shows a 32-bit PowerShell: a 32-bit Office macro launching powershell.exe starts that copy, and it sees a different view of the registry and the file system.

Typing powershell in a command prompt always starts 5.1, whatever else is installed, and typing pwsh starts 7. Process evidence names one or the other, which tells you which shell an attacker used without any other context.

Both programs also accept the same basic command-line switches, which attackers rely on: -NoProfile, -WindowStyle Hidden, -EncodedCommand and the rest. Module 7 decodes them from the case's evidence. Because those switches look the same for powershell.exe and pwsh.exe, the program's path in a process record is often the only way to tell which shell ran the command.

2

Why 5.1 Is Everywhere

and what that means for evidence

Windows PowerShell 5.1 ships with Windows 10, Windows 11 and Windows Server, and it is part of the operating system: it cannot be uninstalled and is updated only with Windows. Microsoft no longer adds features to it. PowerShell 7 is installed separately, updated on its own schedule, and runs on Windows, Linux and macOS. This course's own build runs it on Linux:

PS C:\Evidence> $PSVersionTable.PSVersion.ToString()
7.6.6
PS C:\Evidence> $PSVersionTable.PSEdition
Core
PS C:\Evidence> $IsWindows, $IsLinux -join ' / '
False / True

PowerShell 7.6.6, edition Core, on a machine that is not Windows. Everything in this course that does not need Windows itself runs here, live, as you will see in every single lesson.

The automatic variables $IsWindows and $IsLinux, and $IsMacOS with them, exist only in PowerShell 6 and later, which is itself a clue to the edition, so a script can test the platform it is on before calling something that only Windows has. In Windows PowerShell they are simply undefined, which a script can also test for. This course's tools use them where a command is Windows-only.

For investigations, the consequence is simple: attackers use powershell.exe, because it is on every target. They cannot count on PowerShell 7 being installed, and installing it would be noisy and unnecessary. The intrusion this course follows started with Word launching powershell.exe, not pwsh.exe. Module 7's logging, Module 8's hunts and most real-world detections are therefore about Windows PowerShell 5.1, even when the analyst's own work is done in 7.

The two also log to different places. Windows PowerShell writes its operational log to Microsoft-Windows-PowerShell/Operational; PowerShell 7 writes to its own channel, PowerShellCore/Operational, once its event provider is registered. An analyst who checks only one channel can miss the other shell's activity entirely, which Module 7 shows on the case's evidence.

There is also a security history here. Windows PowerShell 2.0, an older engine without modern logging or AMSI scanning, was for years installable alongside 5.1, and attackers used powershell.exe -Version 2 to slip under those defenses. Microsoft has removed the 2.0 engine from current Windows releases, and Module 7 shows how to check whether a machine still has it.

3

Commands That Are Gone

and their replacements

PowerShell 7 dropped commands that depended on parts of .NET Framework it does not have. Microsoft's differences page lists them; the ones an analyst meets most are the *-EventLog cmdlets and the WMI version 1 cmdlets such as Get-WmiObject. Get-EventLog in each shell on the workstation:

PS C:\> Get-Command Get-EventLog | Select-Object Name, Version, Source  # Windows PowerShell 5.1
Name         Version Source
----         ------- ------
Get-EventLog 3.1.0.0  Microsoft.PowerShell.Management
PS C:\> Get-Command Get-EventLog  # PowerShell 7.6
Get-Command: The term 'Get-EventLog' is not recognized as a name of a cmdlet, function, script file, or executable program.

In Windows PowerShell, Get-EventLog is a cmdlet in Microsoft.PowerShell.Management. In PowerShell 7 it does not exist. The error is the same one PowerShell gives for a mistyped name, which is why the removal surprises people: nothing says the command used to exist.

The replacements already work in both: Get-WinEvent for event logs, which Module 6 teaches, and Get-CimInstance for WMI data, which Module 4 uses for processes. Both are also faster and more capable than what they replaced: Get-WinEvent reads the modern event channels such as Sysmon's and PowerShell's own, which Get-EventLog never could, and Get-CimInstance works over the same remoting as everything else in Module 9.

That matters because years of security blogs, forum answers, vendor knowledge-base articles and AI-generated scripts use Get-EventLog and Get-WmiObject. A script built on them runs in 5.1 and fails in 7 with "not recognized", and the fix is not to switch shells but to switch to the command that works in both. Lesson 11.8 shows how to check a borrowed script for exactly this.

PowerShell 7 can also load many Windows-only modules through its Windows PowerShell compatibility feature, which runs them in a hidden 5.1 process behind the scenes. It is useful for an administration module that has no PowerShell 7 version. It is not a reason to keep using removed cmdlets for which a direct replacement exists.

Other differences are smaller but worth knowing when they appear: Windows PowerShell's web cmdlets parse HTML through Internet Explorer's engine unless told not to, PowerShell 7 does not; some error messages and default output formats differ; and a few cmdlets gained parameters in 7 that 5.1 lacks. Each later lesson flags the difference that touches its subject.

4

Text Encoding

what bytes a file gets

When PowerShell writes text to a file it has to choose an encoding, and the two editions choose differently. PowerShell 7 writes UTF-8 without a byte-order mark by default.

Windows PowerShell's Out-File, and the > redirection operator, write UTF-16 little-endian, called Unicode in its parameters, which starts with the bytes FF FE and uses two bytes per ordinary character. In PowerShell 7, the default and the 5.1 behavior side by side:

PS C:\Evidence> 'x' | Out-File ./enc.txt -NoNewline
PS C:\Evidence> ([IO.File]::ReadAllBytes((Resolve-Path ./enc.txt)) | ForEach-Object { '{0:X2}' -f $_ }) -join ' '
78
PS C:\Evidence> 'x' | Out-File ./enc16.txt -NoNewline -Encoding unicode
PS C:\Evidence> ([IO.File]::ReadAllBytes((Resolve-Path ./enc16.txt)) | ForEach-Object { '{0:X2}' -f $_ }) -join ' '
FF FE 78 00
PS C:\Evidence> Remove-Item ./enc.txt, ./enc16.txt

One byte for the letter x by default; four bytes, FF FE 78 00, with -Encoding unicode, which is what Out-File writes in Windows PowerShell without being asked.

The FF FE at the start is the byte-order mark, which tells a reader that the file is UTF-16 with the low byte first; the 78 00 is the letter x in two bytes. Both are perfectly correct; they are just different, and tools that expect one misread the other.

Get-Content in PowerShell 7 recognizes the byte-order mark and reads either file correctly, which is one reason to do evidence work in PowerShell rather than in a tool that assumes one encoding.

For analysts this appears in two places. Evidence files written by an attacker's script in 5.1, or by a collection script in 5.1, may be UTF-16 and look like text with spaces between every letter in tools that expect UTF-8. And a script you write in 7 and run in 5.1 may produce files your other tools cannot read. Setting -Encoding explicitly whenever a file leaves PowerShell avoids both.

Windows PowerShell is not even consistent with itself: Out-File writes UTF-16, while Set-Content and Add-Content write the system's legacy code page, usually Windows-1252 in English locales. PowerShell 7 made every text cmdlet default to UTF-8 without a byte-order mark. That consistency is one of the quieter reasons to prefer 7 for anything that writes files.

5

JSON Depth

a default that loses evidence

ConvertTo-Json turns objects into JSON, as Lesson 0.1 did. By default it stops at a depth of two levels and writes anything deeper as the name of its type. A three-level process chain, converted both ways:

First trydefault depth
PS> $o | ConvertTo-Json -Compress
The grandparent becomes the text System.Collections.Specialized.OrderedDictionary.
ConvertTo-Json stops at depth 2 by default and writes anything deeper as its type name. PowerShell 7 warns when it does; Windows PowerShell 5.1 truncates silently, which is how process trees and nested API results lose their deepest, most interesting level.
Refineddepth set explicitly
PS> $o | ConvertTo-Json -Depth 5 -Compress
The whole chain, down to WINWORD.EXE.

Run live, in this course's own PowerShell 7.6, with the same three-level chain built first so both conversions see identical data:

PS C:\Evidence> $o = [ordered]@{ Host = 'NE-SHARMA-LT'; Process = [ordered]@{ Name = 'rundll32.exe'; Parent = [ordered]@{ Name = 'powershell.exe'; Parent = [ordered]@{ Name = 'WINWORD.EXE' } } } }
PS C:\Evidence> $o | ConvertTo-Json -Compress
WARNING: Resulting JSON is truncated as serialization has exceeded the set depth of 2.
{"Host":"NE-SHARMA-LT","Process":{"Name":"rundll32.exe","Parent":{"Name":"powershell.exe","Parent":"System.Collections.Specialized.OrderedDictionary"}}}
PS C:\Evidence> $o | ConvertTo-Json -Depth 5 -Compress
{"Host":"NE-SHARMA-LT","Process":{"Name":"rundll32.exe","Parent":{"Name":"powershell.exe","Parent":{"Name":"WINWORD.EXE"}}}}

PowerShell 7 printed a warning that the output was truncated, then wrote the grandparent as System.Collections.Specialized.OrderedDictionary. With -Depth 5 the whole chain survived. Windows PowerShell 5.1 has the same default but no warning, so a 5.1 script exporting nested evidence can lose data with nothing on screen to say so.

Collection scripts are where this bites, because they often run in 5.1 on the host and the truncated JSON is only opened later, on the analyst's workstation, when it is too late to collect again.

The lesson generalizes well beyond JSON: defaults are choices someone else made for a different purpose, and some of them quietly lose information. Process trees, event data and API responses are all nested, and this course sets -Depth explicitly on every ConvertTo-Json that matters. Module 2 returns to JSON in detail.

ConvertFrom-Json, going the other way, has a much higher default depth, so reading deep JSON is rarely a problem; writing it is where data disappears. The asymmetry catches people who round-trip evidence: an export written with the default depth reads back without complaint, and the missing levels are simply absent, as if they had never been collected.

6

What 7 Adds

language features you will see

PowerShell 7 added language features that make scripts shorter and clearer, and this course uses some of them. The ternary operator, the null-coalescing operator, pipeline chain operators, and parallel ForEach-Object:

PS C:\Evidence> $n = 2; $n -gt 1 ? 'two or more' : 'one'
two or more
PS C:\Evidence> $owner = $null; $owner ?? 'no owner recorded'
no owner recorded
PS C:\Evidence> Get-Item ./versions-ws01.json | Out-Null && 'found'
found
PS C:\Evidence> Get-Item ./nothere.json 2>$null || 'missing'
missing
PS C:\Evidence> 1..4 | ForEach-Object -Parallel { "host $_ checked" } -ThrottleLimit 2 | Sort-Object
host 1 checked
host 2 checked
host 3 checked
host 4 checked

The ternary operator chose between two strings by a condition, in one expression, where 5.1 needs a whole if statement. ?? supplied a value where the variable was empty, a common need with evidence where a field may be missing. && ran the second command only because the first succeeded, and || ran its second only because the first failed. -Parallel ran four iterations, two at a time, on separate threads.

Sort-Object put the results back in order, because parallel work finishes in whatever order it finishes; anything that depends on order has to restore it. Module 9 uses -Parallel to check many hosts at once, with the same caution.

None of these work in Windows PowerShell 5.1, where each is a syntax error. Each has a longer 5.1 equivalent, an if statement for the ternary operator, a test for $null for ??, and a check of $? for the chain operators, so nothing is impossible in 5.1; it is just more to write and more to read.

That is the practical cost of writing for 7: a script that uses them will not run on a machine that has only 5.1. For tools you run from your own workstation, that is rarely a problem. For scripts that must run on every host in a fleet, Module 9 shows how to keep the part that runs remotely compatible with 5.1.

When reading scripts from elsewhere, these operators are also a quick way to date them. A script using ?? or && was written for PowerShell 7 and will fail in 5.1 with a parser error at the first such line, before anything runs. Lesson 11.8's review process checks this along with removed cmdlets.

Comparing versions is itself a small trap. As strings, "10.0" sorts before "7.6", because the comparison goes character by character. Cast to [version], each part is compared as a number, which is what the exercise relied on. The same applies to build numbers in evidence, such as Windows builds and Defender platform versions.

7

Scripts for One Edition

saying so in the script

A script that depends on one edition should say so, and PowerShell enforces it with a #Requires statement at the top. A script that requires Windows PowerShell, run in PowerShell 7:

PS C:\Evidence> Set-Content ./need51.ps1 "#Requires -PSEdition Desktop`n'ran'"
PS C:\Evidence> ./need51.ps1
./need51.ps1: The script 'need51.ps1' cannot be run because it contained a "#requires" statement for PowerShell editions 'Desktop'. The edition of PowerShell that is required by the script does not match the currently running PowerShell Core edition.
PS C:\Evidence> Remove-Item ./need51.ps1

PowerShell 7 refused to run it, with a clear message naming both the edition the script required and the edition actually running, before executing a single line. The refusal happens when the script is loaded, so even a script whose first line would delete something never reaches it.

That is the difference between a declared requirement and a check inside the script, which runs only once the script has already started. #Requires -PSEdition Core does the reverse, and #Requires -Version 7.4 sets a minimum version. Lesson 11.6 uses the same idea in a module's manifest.

For scripts you share, this is cheap insurance. A colleague who runs your script in the wrong shell gets a clear message at once, instead of a failure halfway through a collection, after some of the evidence has been gathered and some has not.

#Requires can also demand modules and administrator rights. #Requires -Modules Microsoft.Graph.Authentication refuses to run if the module is missing, and #Requires -RunAsAdministrator refuses to run without elevation. Collection scripts in Module 9 use the second, because reading the Security log needs it, and a non-elevated run would otherwise collect a partial package.

8

Which One to Use

and five checks

This course writes for PowerShell 7.6, the current long-term version, on Windows, Linux or macOS. PowerShell 7.6 is a long-term-support release, which Microsoft supports for longer than the releases in between, and that is why the course targets it rather than whatever is newest on the day you read this.

Analysis runs anywhere 7.6 does, as every lesson shows. Collection from Windows hosts uses commands that work in both editions, because the hosts may have only 5.1. And the evidence is read knowing which shell produced it. The facts of this lesson, checked:

PS C:\Evidence> $v = Get-Content ./versions-ws01.json -Raw | ConvertFrom-Json
PS C:\Evidence> @(
>>     $v.WindowsPowerShell.PSEdition -eq "Desktop"
>>     $v.PowerShell7.PSEdition -eq "Core"
>>     [version]$v.PowerShell7.PSVersion -gt [version]$v.WindowsPowerShell.PSVersion
>>     $PSVersionTable.PSEdition -eq "Core"
>>     -not (Get-Command Get-EventLog -ErrorAction SilentlyContinue)
>> )
True
True
True
True
True

Five True: the workstation's 5.1 is Desktop, its 7 is Core and newer, this build runs Core, and Get-EventLog is absent here.

Checks like these are worth keeping at the top of any script that matters. A collection script that asserts the edition and version it was written for, and stops otherwise, cannot quietly produce a different kind of evidence on a machine where the other shell answered the call.

Module 9's collector does exactly that before it reads a single log. Comparing versions with [version] rather than as strings keeps 7.6.6 correctly above 5.1.26100.3194.

When a difference between the two editions matters in a later lesson, that lesson says so where you meet it: an encoding when a file is written, a cmdlet that is missing, a default that changed. Microsoft's page of differences between Windows PowerShell 5.1 and PowerShell 7 is the reference behind each one.

Installing PowerShell 7 does not replace or change Windows PowerShell; both stay, side by side, and each keeps its own modules folder and its own profile scripts. Lesson 0.8 installs 7.6 on an analyst workstation and sets it up for this course. Until then, any PowerShell 7.4 or later runs the lessons' commands as shown.

Treating the two shells as one
assume powershell.exe means PowerShell 7
check $PSVersionTable: Desktop is 5.1, Core is 7two programs, two version lines
Get-EventLog because an old blog used it
Get-WinEvent, which works in bothremoved from PowerShell 7
ConvertTo-Json with its default depth
-Depth set every timedeeper levels become type names
Write for 7.6, set depths and encodings explicitly, and remember that evidence comes from both.

Knowing which shell you are in, and which shell produced the evidence in front of you, is the first habit of this course. The next lesson looks inside PowerShell itself: what it is built on, and why everything it hands you is an object.

Practice

Do this Know which PowerShell you are in
  1. Check. Read $PSVersionTable: Desktop is Windows PowerShell 5.1, Core is PowerShell 7.
  2. Replace. Use Get-WinEvent and Get-CimInstance, which work in both, instead of removed cmdlets.
  3. Be explicit. Set -Encoding and -Depth whenever data leaves PowerShell.
  4. Declare. Put #Requires in scripts that need one edition or a minimum version.
What you should end up with: scripts that behave the same wherever they run, and evidence read with its source in mind.

Then, on a Windows machine of your own, open both powershell.exe and pwsh.exe, compare $PSVersionTable, and run Get-Command Get-EventLog in each.

Next: how PowerShell is built underneath, and why everything it hands you is an object.