Different Ways to Execute Commands in PowerShell
A Red Team & Detection Engineering Reference
PowerShell provides several different ways to execute code and commands. But the real value of understanding these differences is not simply knowing "how to execute" — it is understanding what effect each method has on telemetry, AMSI, and ScriptBlock Logging.
This article divides the methods into five thematic groups, making them easier to learn while also clarifying how each method relates to the others.
Detection Reference Table
Before looking at the details, here is a comparison table from a detection perspective:
| Method | AMSI Triggered? | ScriptBlock Log (4104)? | Spawns powershell.exe? |
Notes |
|---|---|---|---|---|
IEX / Invoke-Expression |
✅ Yes | ✅ Yes | - | The best-known; most EDRs have rules for it |
Invoke-Command |
✅ Yes | ✅ Yes | - | ScriptBlock content is logged |
[ScriptBlock]::Create() |
✅ Yes | ✅ Yes | - | Passes through the same PowerShell parser |
.Invoke() / .InvokeReturnAsIs() |
✅ Yes | ✅ Yes | - | |
.InvokeWithContext() |
✅ Yes | ✅ Yes | - | |
& Call Operator |
✅ Yes | ✅ Yes | - | |
| Dot Sourcing | ✅ Yes | ✅ Yes | - | |
Start-Job |
✅ Yes | ✅ Yes | ⚠️ PowerShell worker | background job; separate from the parent process |
ForEach-Object / Where-Object |
✅ Yes | ✅ Yes | - | No difference from IEX from a detection perspective |
$ExecutionContext methods |
✅ Yes | ✅ Yes | - | Useful for obfuscation; not as a distinct execution path |
[powershell]::Create() (in PowerShell) |
✅ Yes | ✅ Yes | - | |
[powershell]::Create() (from C# / unmanaged host) |
⚠️ Depends on the host | ⚠️ Depends on ETW | ❌ No | The most important scenario; explained separately |
| Runspace (from PowerShell) | ✅ Yes | ✅ Yes | - | |
| Runspace (from C# / unmanaged host) | ⚠️ Depends on the host | ⚠️ Depends on ETW | ❌ No | |
Thread.Start with ScriptBlock |
✅ Yes | ✅ Yes | - |
Important note: AMSI and ScriptBlock Logging operate at the PowerShell runtime level. If you directly host PowerShell throughSystem.Management.Automation.dllwithout usingpowershell.exe, these mechanisms are not enabled by default — unless the host itself implements them.
Group 1 — String-Based Execution
These methods interpret and execute a text string as PowerShell code.
A) Invoke-Expression / IEX
Invoke-Expression It takes a string and executes its contents as PowerShell code.
Invoke-Expression "Get-Date"
IEX "Get-Date"
The command can also be built dynamically:
$x = 'whoami'
IEX $x
Additional parameters can also be passed to it:
IEX -ErrorVariable $e -InformationAction Ignore "whoami"
Why it matters: IEX is the most common way to execute an in-memory payload (for example, retrieving a script from the internet and executing it directly without writing it to disk). That is why most EDR/AV products have well-known rules for it.
Summary: string → PowerShell parser → execution
B) [ScriptBlock]::Create()
Using the .NET API, a string can be converted into a ScriptBlock:
$sb = [ScriptBlock]::Create("Get-Date")
$sb.Invoke()
# One-liner
([ScriptBlock]::Create("Get-Date")).Invoke()
Difference from IEX: Here, a ScriptBlock object is created first and then invoked. However, both pass through the same PowerShell parser — so there is no difference from an AMSI or logging perspective.
Summary: string → ScriptBlock object → .Invoke()
C) $ExecutionContext.InvokeCommand.ExpandString()
This method uses string interpolation to execute code:
$ExecutionContext.InvokeCommand.ExpandString('$(calc)')
How it works: PowerShell evaluates the contents of $() as an expression — the same mechanism used in double-quoted strings. Therefore, '$(whoami)' is executed as an expression.
Limitations:
- Only one expression can be used in each
$() - For multi-statement execution, you need
;inside$()or chaining - Error handling differs from
IEX— an exception is returned instead of anErrorRecord
From an evasion perspective: Neither AMSI nor ScriptBlock Logging is bypassed. Its value lies in specific string-obfuscation techniques, not in serving as an independent execution path.
Summary: string interpolation → expression evaluation → execution
Group 2 — ScriptBlock-Based Execution
These methods take an existing ScriptBlock and execute it.
D) Invoke-Command / icm
Invoke-Command -ScriptBlock { Get-Date }
icm -ScriptBlock { Get-Date }
It can also be used for remote execution:
Invoke-Command -ComputerName TARGET -ScriptBlock { whoami }
Invoke-Command -Session $session -ScriptBlock { Get-Process }
Important difference from &: Invoke-Command is designed for remote execution. For local execution, & is usually enough. However, Invoke-Command provides a more explicit way to pass variables through -ArgumentList.
Summary: ScriptBlock → Invoke-Command → execution
E) .Invoke() Directly on ScriptBlock
If you have a ScriptBlock, you can call .Invoke() directly:
$sb = { Get-Date }
$sb.Invoke()
{ Get-Date }.Invoke()
# with GetNewClosure() to capture environment variables
{ Get-Date }.GetNewClosure().Invoke()
About GetNewClosure(): This method creates a copy of the ScriptBlock with a closure that captures the variables in the current scope. It is useful when you pass a ScriptBlock somewhere else and want it to retain access to the current variables.
Summary: ScriptBlock → .Invoke() → Collection<PSObject>
F) .InvokeReturnAsIs()
{ Get-Date }.InvokeReturnAsIs()
([ScriptBlock]::Create("Get-Date")).InvokeReturnAsIs()
Real difference from .Invoke():
# .Invoke() wraps the result in Collection<PSObject>
$result1 = { 42 }.Invoke()
$result1.GetType().FullName
# Output: System.Collections.ObjectModel.Collection`1[[System.Management.Automation.PSObject, ...]]
# .InvokeReturnAsIs() returns the result as-is
$result2 = { 42 }.InvokeReturnAsIs()
$result2.GetType().FullName
# Output: System.Int32
Practical use: When working with .NET reflection or interop and you need the result's actual type (rather than the PSObject wrapper), .InvokeReturnAsIs() is essential.
Summary: ScriptBlock → .InvokeReturnAsIs() → result with its actual type (without wrapping)
G) .InvokeWithContext()
This method allows a ScriptBlock to be executed with additional variables and functions injected into its context:
# The simplest form
{ calc }.InvokeWithContext($null, $null)
# Injecting a custom variable
$vars = [System.Collections.Generic.List[System.Management.Automation.PSVariable]]::new()
$vars.Add([System.Management.Automation.PSVariable]::new('myVar', 'hello'))
{ Write-Host $myVar }.InvokeWithContext($null, $vars)
# Injecting a custom function
$funcs = @{ 'myFunc' = { param($x) $x * 2 } }
{ myFunc 21 }.InvokeWithContext($funcs, $null)
# Using the call operator inside a ScriptBlock
{& ("ca"+"lc")}.InvokeWithContext($null, $null)
Method signature:
InvokeWithContext(
IDictionary functionsToDefine,
List<PSVariable> variablesToDefine,
[object[] args]
)
Summary: ScriptBlock + context → execution with a controlled execution environment
H) Call Operator &
One of the most common ways to invoke a command, script, executable, or ScriptBlock:
# ScriptBlock
& { Get-Date }
# Script
& ".\test.ps1"
& $scriptPath
# Executable
& notepad.exe
& "C:\Windows\System32\calc.exe"
# Variable that holds the command name
$cmd = "Get-Process"
& $cmd
Call operator and obfuscation:
# Building the command name dynamically
$parts = "Get-", "Pr", "oce", "ss"
& ($parts -join '')
# Through an array index
& ([string[]]@('notepad','calc'))[1]
Summary: & → invoke command/script/executable/ScriptBlock
I) Dot Sourcing
Like &, but it executes in the current scope:
. { Get-Date }
. ".\test.ps1"
. notepad.exe
Main difference from & (code example):
# With & — the function is defined in the child scope and disappears after execution
& { function Foo { "bar" } }
Foo # Error: 'Foo' is not recognized
# With dot sourcing — the function remains in the current scope
. { function Foo { "bar" } }
Foo # Output: bar
Important uses:
- Persistence through
$PROFILE: for a script's functions to remain in the session, it must be dot-sourced. - In-memory module loading: A script that defines exports must be dot-sourced so they are available in the session.
- Difference in lateral movement: When executing setup or loader scripts on a target, using
&instead of dot sourcing can cause the defined functions to be unavailable.
Summary: dot sourcing → execution in current scope → definitions remain available
Group 3 — Pipeline-Based Execution
These methods use the pipeline mechanism to execute a ScriptBlock.
J) ForEach-Object / %
1 | ForEach-Object { Get-Date }
1 | % { Get-Date }
# with an actual payload
$payload = { whoami }
1 | % $payload
Why it is on the list ForEach-Object is not usually used as an independent "execution method." Its value comes from combining it with other techniques:
# Combining it with a variable that holds a ScriptBlock
$sb = [ScriptBlock]::Create($encodedPayload)
1..1 | % { & $sb }
From the perspective of AMSI and Event 4104, this is no different from IEX $payload.
Summary: pipeline input → ForEach-Object → ScriptBlock execution (per item)
K) Where-Object / ?
1 | Where-Object { calc; $true }
1 | ? { calc; $true }
Important note: Where-Object is designed for filtering. The ScriptBlock must return a truthy or falsy value so the item is either passed through or dropped from the pipeline. For execution only, you must ensure that the ScriptBlock returns $true (otherwise the item is removed).
From a detection perspective: Like ForEach-Object, it does not change the telemetry.
Summary: pipeline input → filter ScriptBlock execution → pass or drop
Group 4 — Host/Runtime-Based Execution
These methods interact directly with PowerShell's execution infrastructure through the .NET API.
L) $ExecutionContext Methods
PowerShell makes an execution context available to scripts:
# InvokeScript with a string
$ExecutionContext.InvokeCommand.InvokeScript("Get-Date")
# InvokeScript with a ScriptBlock
$ExecutionContext.InvokeCommand.InvokeScript(
$false,
[scriptblock]::Create("calc"),
$null,
$null
)
# Dynamically constructing the method name
$ExecutionContext.InvokeCommand.('Inv'+'okeS'+'cript')("cal"+"c")
Complete InvokeScript signature:
InvokeScript(
bool useLocalScope,
ScriptBlock scriptBlock,
object input,
object[] args
)
Real value: The real value is obfuscating method names, not bypassing detection. AMSI and ScriptBlock Logging remain active.
Summary: PowerShell execution context → invocation method → execution
M) [System.Management.Automation.PowerShell] — The most important section
This API programmatically creates and executes a PowerShell pipeline.
Usage in PowerShell:
# Simple
[powershell]::Create().AddScript("Get-Date").Invoke()
# With AddCommand
[powershell]::Create().AddCommand("Get-Date").Invoke()
# With AddStatement (multiple statements)
$ps = [powershell]::Create()
$ps.AddScript("whoami").AddStatement().AddScript("hostname").Invoke()
# with PSCommand
$ps = [powershell]::Create()
$ps.Commands = [System.Management.Automation.PSCommand]::new().AddScript('Get-Date')
$ps.Invoke()
# Building the method name dynamically
[powershell]::("Cre"+"ate")().("AddS"+"cript")("calc").("Invo"+"ke")()
Important scenario — Unmanaged Hosting (from C# or other .NET languages):
// In C# — without executing powershell.exe
using System.Management.Automation;
using System.Management.Automation.Runspaces;
var rs = RunspaceFactory.CreateRunspace();
rs.Open();
var ps = PowerShell.Create();
ps.Runspace = rs;
ps.AddScript("whoami");
var results = ps.Invoke();
foreach (var result in results)
Console.WriteLine(result.ToString());
rs.Close();
Why this scenario matters:
powershell.exeis not executed — process-based detection is bypassed- AMSI is applied in the host process context — if AMSI has not been loaded in that process, it is not triggered
- ScriptBlock Logging depends on ETW and the host configuration — it may not be enabled by default
- This is the foundation of many C2 loaders
Summary: .NET PowerShell object → pipeline → invoke → results
N) Runspace
A Runspace is a separate execution environment for PowerShell.
# Basic
$rs = [runspacefactory]::CreateRunspace()
$rs.Open()
$ps = [powershell]::Create()
$ps.Runspace = $rs
$ps.AddScript("Get-Date")
$ps.Invoke()
$rs.Close()
# One-liner
$ps=[powershell]::Create();$ps.Runspace=[runspacefactory]::CreateRunspace().Open();$ps.AddScript('Get-Date').Invoke()
InitialSessionState — an important missing section:
# Creating a runspace with a custom session state
$iss = [System.Management.Automation.Runspaces.InitialSessionState]::CreateDefault()
# Restricting available cmdlets (for constrained execution)
$iss.Commands.Remove("Invoke-Expression", $null)
# Adding a custom cmdlet
$rs = [runspacefactory]::CreateRunspace($iss)
$rs.Open()
Important differences in InitialSessionState:
| Type | Description | Use case |
|---|---|---|
CreateDefault() |
All standard cmdlets | Normal environments |
CreateDefault2() |
Smaller subset | More restricted environments |
CreateRestricted(SessionCapabilities) |
Minimal session | ConstrainedLanguageMode bypass research |
Async Invocation:
$ps = [powershell]::Create()
$ps.Runspace = [runspacefactory]::CreateRunspace().Open()
# Starting async execution
$handle = $ps.AddScript('Start-Sleep 5; Get-Date').BeginInvoke()
# Do other work...
# Getting the result
$result = $ps.EndInvoke($handle)
Summary: One of the most isolated execution environments — useful for multi-threading, sandboxing, and unmanaged hosting
Group 5 — Async / Background Execution
O) Start-Job
# Simple
Start-Job { Get-Date }
# Full example with result retrieval
$job = Start-Job { whoami; hostname }
Wait-Job $job
Receive-Job $job
Remove-Job $job
# One-liner
Start-Job { Get-Date } | Wait-Job | Receive-Job
Difference from the other methods: A separate worker process is created. From a detection perspective:
- A child PowerShell process is created with the current process as its parent
- Each job runs in a separate context
- Output is transferred in serialized form through a pipe
Summary: ScriptBlock → background job (child process) → Wait-Job → Receive-Job
P) Thread.Start with ScriptBlock
# Directly
$thread = [System.Threading.Thread]::new([System.Threading.ThreadStart]{ calc })
$thread.Start()
# With a parameter
$paramThread = [System.Threading.Thread]::new(
[System.Threading.ParameterizedThreadStart]{ param($x) Write-Host "Value: $x" }
)
$paramThread.Start("hello")
# Waiting for completion
$thread.Join()
difference with Start-Job:
- The Thread runs in the same process (not a child process)
- has less overhead
- shares memory with the main thread
Summary: ScriptBlock → Thread.Start() → execution in the same process
Summary: Thematic grouping
┌─────────────────────────────────────────────────────────┐
│ PowerShell Execution │
├───────────────────┬─────────────────────────────────────┤
│ String-Based │ IEX, ScriptBlock::Create() │
│ │ ExpandString() │
├───────────────────┼─────────────────────────────────────┤
│ ScriptBlock- │ Invoke-Command, .Invoke() │
│ Based │ .InvokeReturnAsIs() │
│ │ .InvokeWithContext() │
│ │ & (Call Operator) │
│ │ Dot Sourcing │
├───────────────────┼─────────────────────────────────────┤
│ Pipeline-Based │ ForEach-Object / % │
│ │ Where-Object / ? │
├───────────────────┼─────────────────────────────────────┤
│ Host/Runtime- │ $ExecutionContext methods │
│ Based │ [powershell]::Create() │
│ │ Runspace │
├───────────────────┼─────────────────────────────────────┤
│ Async/Background │ Start-Job │
│ │ Thread.Start │
└───────────────────┴─────────────────────────────────────┘
Final note: They all lead to the same infrastructure
Most of these methods ultimately pass through the same PowerShell parser and runtime. The main differences are the following:
- Whether
powershell.exeis executed or not — the boundary between methods inside PowerShell and unmanaged hosting - Whether AMSI is active in the process context — depends on the host
- Whether ScriptBlock Logging is enabled — depends on PowerShell configuration and ETW
- Scope —
&vs dot sourcing - Return type —
.Invoke()vs.InvokeReturnAsIs() - Async/sync — background job vs thread vs synchronous
The most important execution path from an evasion perspective is unmanaged hosting through System.Management.Automation.dll — not obfuscation of methods inside PowerShell.