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 through System.Management.Automation.dll without using powershell.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 an ErrorRecord

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:

  1. Persistence through $PROFILE: for a script's functions to remain in the session, it must be dot-sourced.
  2. In-memory module loading: A script that defines exports must be dot-sourced so they are available in the session.
  3. 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.exe is 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:

  1. Whether powershell.exe is executed or not — the boundary between methods inside PowerShell and unmanaged hosting
  2. Whether AMSI is active in the process context — depends on the host
  3. Whether ScriptBlock Logging is enabled — depends on PowerShell configuration and ETW
  4. Scope — & vs dot sourcing
  5. Return type — .Invoke() vs .InvokeReturnAsIs()
  6. 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.