Metadata
PowerShell hosting
YAML
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
payload | string | no | — | Name of the payload in build.payloads to load. |
mode | string (run | interactive) | no | run | How the engine is driven (run or interactive). |
command | string | no | Invoke-AllChecks | The **default** invocation, run after the script is loaded. |
command_mode | string (override | join) | no | override | How the run-time arguments combine with command. |
technique: powershell_hostingparams: mode: run command: Invoke-AllChecks command_mode: overrideRuns a .ps1 payload in the loader’s own process by hosting Windows PowerShell 5.1 on the CLR, so the script never touches disk and never spawns powershell.exe.
What it does
Section titled “What it does”An Execution technique. It runs a format: powershell payload — a .ps1
script — in the loader’s own process, without a child powershell.exe, without
-EncodedCommand and without a temporary file. This is the powerpick shape:
the process hosts the engine, not the other way round.
A .ps1 is text, so it cannot be mapped as code. The packer closes the gap at
build time: it compiles a small managed launcher around the script
(src/engine/managed.rs) and the technique hosts that managed assembly on
the CLR, exactly as dotnet_hosting hosts a format: dotnet payload. The
script text only ever exists inside the encrypted launcher image, in memory.
How it works
Section titled “How it works”flowchart TD
A["build: read the .ps1"] --> B["render a C# launcher around it"]
B --> C["csc -> managed assembly (bytes)"]
C --> D["embed + encrypt like any payload"]
D --> E["run: patch_amsi"]
E --> F["decrypt the launcher in memory"]
F --> G["start the CLR, _AppDomain.Load(byte[])"]
G --> H["load System.Management.Automation"]
H --> I["RunspaceFactory.CreateRunspace"]
I --> J["PowerShell.AddScript(script)"]
J --> K{"mode + invocation"}
K -- run --> L["AddScript(command) then Invoke"]
K -- interactive --> M["read line -> AddScript -> print"]
L --> N["return control to the loader"]
M --> N
1. Build time — the script becomes a managed assembly
Section titled “1. Build time — the script becomes a managed assembly”- The packer reads the
.ps1as UTF-8 text and validates it is non-empty. - It renders a C# launcher that embeds the script as a verbatim string, hosts
System.Management.Automation, and drives it withPowerShell.Create()/AddScript()/Invoke()in a privateRunspace. - It compiles the launcher with the .NET Framework compiler (
csc.exe) against Windows PowerShell’sSystem.Management.Automation.dll. - The resulting managed assembly bytes are embedded and encrypted exactly
like any other payload; the
.ps1never reaches the stub in cleartext.
2. Run time — the CLR hosts the engine
Section titled “2. Run time — the CLR hosts the engine”- The stub decrypts the launcher and starts the CLR in its own process
(
CLRCreateInstance→ICorRuntimeHost→_AppDomain.Load(byte[])), the same pathdotnet_hostinguses. Loading frombyte[]keeps the launcher in memory. - The launcher opens a runspace, runs the script (defining its functions) and
then runs the configured invocation in the same runspace, so a function
the script defines is callable (
command: Invoke-AllChecks). mode: interactiveleaves a prompt instead of running one invocation.
3. The invocation is a run-time value
Section titled “3. The invocation is a run-time value”command is only a baked default. The operator’s own command-line arguments
override it (command_mode: override, the default) or append to it
(command_mode: join), so one artifact runs whatever is typed:
powerup.picaro.exe "Get-DomainUser -Identity admin" # overridepowerup.picaro.exe -Verbose # join -> "Invoke-AllChecks -Verbose"powerup.picaro.exe # no args -> the baked defaultIntegration
Section titled “Integration”An Execution step, so it belongs after the Preparations that shape the process.
patch_amsi is required in practice: the engine scans AddScript content
through AmsiScanBuffer, so the script would be detected without it (the step
patches the native exports and bakes the managed AmsiUtils bypass into the
launcher). patch_etw is recommended (script block logging travels over ETW).
The step returns control to the loader (it is not terminal), so a step such
as self_deletion may follow it. It accepts only format: powershell payloads.
What it evades
Section titled “What it evades”- No
powershell.exeand no command line. There is no child process and no Base64 blob on a command line; the engine runs in the loader’s process. - No script on disk. The
.ps1is embedded encrypted and only ever exists decrypted in memory;AddScriptis handed the text directly. - Signature scanning of the file. A disk signature on
PowerUp.ps1never applies: there is no file to scan. AMSI (which scans the in-memory text) is neutralised bypatch_amsi, and under the common script-block-logging policy (invocation logging off) a block is only logged when AMSI flags it — so the patch also suppresses the 4104.
What detects it
Section titled “What detects it”- The engine loaded outside
powershell.exe. Module-load telemetry flags a process that loadedSystem.Management.Automation.dlland is not PowerShell. This is the signature trade ofpowerpick-style hosting (see the technique design note); injecting into a realpowershell.exeis the fix and is out of scope for now. - Script block logging with invocation logging on. Every block is logged
over ETW regardless of AMSI;
patch_etwblunts that path but must be measured against a policy-configured host. - The .NET runtime loading at all. Starting the CLR from a small native
loader is itself an anomaly (the same one
dotnet_hostingcarries).
Measurements
Section titled “Measurements”- See
docs/measurements.md.
Implementation notes
Section titled “Implementation notes”- The technique reuses
dotnet_hosting’s stub fragment (include_str!), so the two cannot drift. The difference is entirely at build time: aformat: powershellpayload is compiled into a managed launcher before it is embedded. csc.exeandSystem.Management.Automation.dllare located at build time (PICARO_CSC/PICARO_PSASSEMBLYoverride the search). Neither is bundled with the repository. On a non-Windows build host the lookup falls back to Mono’smcsand the PowerShell 5.1 SDK reference assembly (Microsoft.PowerShell.5.ReferenceAssemblies); the Docker image ships both, so a container build works out of the box.- Interactive output uses each result’s
ToString(); rich objects are not formatted withOut-Stringyet. - The launcher patches nothing native itself: the native AMSI patch is the
patch_amsistep’s job, in the loader’s process, before the CLR starts. When that step is present the launcher does carry the managed bypass — it setsSystem.Management.Automation.AmsiUtils.amsiInitFailed(the name is XOR-scrambled in the assembly) so PowerShell’s own scan entry returns before it calls intoamsi.dll. - Host sensitivity. With Defender’s real-time protection enabled, the AMSI
patch is intermittently interfered with, so the hosted engine may not run on
every launch; a Defender-off lab host is stable (see
docs/measurements.md). Output written to a redirected stdout after the engine starts can also be lost, so a script that needs a deterministic result should write a file.
References
Section titled “References”System.Management.Automation.PowerShellhosting API — Microsoft Learn.- Cobalt Strike
powerpick— the in-process PowerShell shape this models.