The gauntlet a single command runs
Before the trick, the stack. On a hardened host, one PowerShell command crosses up to six recording layers — and the layers don't share a failure model, so defeating one buys you nothing with the others:
| Layer | What it records |
|---|---|
| Process creation 4688 / Sysmon 1 | powershell.exe being born: command line, parent, hash, signer |
| AMSI | Script content, scanned in whatever process hosts the engine — before execution |
| ETW · script blocks 4104 | Every script block as executed — deobfuscated; obfuscation dies here |
| ETW · module logging 4103 | Pipeline execution detail, per module |
| Transcription | Full session transcripts, written to disk files |
| Assembly loads | clr.dll, mscoree, System.Management.Automation arriving in a process — CLR ETW and kernel image-load callbacks both see it |
The managed runspace binary answers layers two and three and sidesteps constrained language mode — respectable. But it answers from inside a process that just triggered layer one, the CLR fired layer six at its birth (a .NET binary is managed from its first instruction), and layers four and five are never touched at all. Group-policy logging isn't ETW; transcription is files on disk. The managed answer leaves the building with two alarms off and four still ringing.
Layer 0: don't exist
The quietest bypass is the code that never runs. Phantom's first layer isn't a technique — it's a command set: 116 built-in commands and aliases (dir, cd, type, ps, whoami, the everyday operator vocabulary) executed by the agent itself, in its own process, with the token context you tasked. No child process. No CLR. Nothing for five of the six layers to even wake up for. In the console these sessions are color-coded purple, so the operator always knows which layer they're standing on — but to the host, a week of built-in-covered work is indistinguishable from the agent doing nothing.
You can't log a process that was never created — so we start there.
And the engine itself ships as a ~164 KB on-demand module (the depot model, from note N.04): until you task an interactive shell, the code for all of this isn't on the host at all.
Layer 1: a runtime that arrives late
When a command genuinely needs PowerShell, Phantom hosts a persistent runspace in-process — but lazily: the CLR is initialized on the first command that actually requires it, not when the session starts. A session that never leaves the built-ins produces zero ClassifiedModuleLoad telemetry, zero assembly-load events, zero CLR presence. Hosting happens over raw COM vtable offsets and IDispatch late binding — no Add-Type, no powershell.exe, nothing on disk, and zero bytes added to the agent, because the .NET Framework is already installed on the host you're on. Compare the managed binary, which pays this entire cost at birth: the CLR isn't something it loads, it's what it is.
Layer 2: the gate
With a runspace resident, the remaining problem is content inspection — and this is where Phantom Gate, the per-command arm/disarm mechanism, earns its name. It touches four native entry points: AmsiScanBuffer and AmsiScanString (amsi.dll), EtwEventWrite and NtTraceEvent (ntdll). The strategy is return-clean, and the details are the product:
; AmsiScanBuffer — 20 bytes, written over the entry point mov rax, [rsp+0x30] ; caller's out-param (the verdict slot) test rax, rax jz skip mov dword [rax], 0 ; AMSI_RESULT_CLEAN — through their pointer skip: xor eax, eax ; S_OK ret ; the scan "succeeded". nothing was scanned. ; EtwEventWrite / NtTraceEvent — 3 bytes xor eax, eax ; STATUS_SUCCESS ret
Why the out-param write? Because the naive bypass — return E_INVALIDARG from the hook — is a burned heuristic: engines pattern-match the error return as the signature of a tampered AMSI. A scan that succeeds and reports clean is indistinguishable from a scan that happened. The hook doesn't refuse the scan; it aces it.
Three more properties matter more than the bytes. The patch is transient: applied via indirect syscalls (NtProtectVirtualMemory + memcpy — no VirtualProtect/WriteProcessMemory API telemetry), original protection restored immediately so the modified code executes under RX, and original bytes restored on disarm. The window is the command's lifetime — typically milliseconds — not the process's. It's process-global: pipeline and job threads are covered automatically, where a hardware-breakpoint design is per-thread and Start-Job walked right past v1. And it handles the mid-window load: amsi.dll often isn't mapped until the CLR initializes — a LdrDllNotification callback patches it on arrival, closing the first-scan race that hardware breakpoints silently lost. If indirect syscalls are unavailable, the gate falls back to VEH + hardware breakpoints on the calling thread (DR0–DR3), with our own debug registers whitelisted from the agent's anti-debug checks so we never trip our own wires.
The layer the walkthroughs miss: the policy cache
Script-block logging, module logging, and transcription are governed by Group Policy — and the engine caches those settings in a static field at startup. Blinding ETW doesn't clear the cache: the engine still formats every event and attempts to emit it, and transcription still opens its files. So immediately after Runspace.Open(), Phantom zeroes the cached Enable* flags — 4104, 4103, transcription — through the same COM reflection the runspace itself is hosted with. No script is executed to do it. A script that disables logging is a script that gets logged; the bypass has to be in the host, below the engine, or it testifies against itself.
Layer 3: when you must borrow a process
Some commands can't run in-process — they need their own address space, or a different session. Phantom's last resort is WMI: the child is spawned under wmiprvse.exe — a system process doing what system processes do — and output returns over an anonymous named pipe, \\.\pipe\<GUID>, not a temp file. No disk artifacts, random pipe names, streaming reads so the 64 KB pipe-buffer deadlock can't wedge the session. Layer 3 is honest about its cost: it is a process creation. It's attributed to a binary that creates processes for a living.
When the engine fights back
A hostile or merely runaway script can kill its host — a stack overflow inside Pipeline.Invoke is precisely the class of fault that takes the whole process, and your session, with it. The runspace runs inside a crash guard: a vectored handler plus a setjmp/longjmp pair around invocation. A fault unwinds to safety, marks the runspace broken, and execution falls back to the WMI layer for the rest of the session. The operator sees an error line. The agent keeps its session — a fault returns an error, never the session, the same doctrine as the BOF runtime.
Side by side
| Managed runspace binary | Phantom Shell | |
|---|---|---|
| Process created | Itself — it is a new process | None, layers 0–2; WMI child only when unavoidable |
| CLR load | At birth, unconditionally | Lazy — first command that truly needs it |
| AMSI | Managed field flip, process-lifetime | Return-clean native stubs, per-command window |
| ETW | Managed field flip | EtwEventWrite + NtTraceEvent return success, transient |
| 4104 / 4103 / transcription | Not addressed | Policy cache zeroed via COM reflection — no script runs |
| Crash safety | Fault takes the process | VEH + longjmp; runspace marked broken, WMI fallback |
| Fallback ladder | None | Builtins → CLR runspace → WMI named-pipe |
| On-host footprint | A binary on disk | ~164 KB module, fetched on demand; keys wiped, header stomped |
The honest limits
The patch window is fail-open: if the process dies mid-command, the stubs persist until exit. The window is milliseconds and the crash would be the story anyway — but it's a real property, and scheduled integrity sweeps can catch a leftover. Layer 3 is a process creation, attributed but not invisible; lineage analytics will see wmiprvse.exe with a console child. Builtins are a finite set — the moment work gets interesting, the CLR loads, and a CLR appearing in a process with no managed business is itself a signal if anyone correlates assembly loads; lazy loading narrows the window, it doesn't erase it. And the gate blinds content inspection, not consequences: what you run still runs, and a loud script is loud even when nobody scans it. Operator discipline is not a feature we can ship.
The defender's angle
Everything above is a hunting map, and we mean that as a gift. Hash the .text of amsi.dll and ntdll in sensitive processes — the patch is transient, but a sweep that lands inside the window, or on a fail-open leftover, is a conviction. Treat absence as telemetry: a host that normally emits 4104s going quiet while interactive-looking activity continues is a correlation rule waiting to be written. Watch image loads for clr.dll/mscoree arriving in processes with no managed business. Alert on WmiPrvSE.exe spawning shells, and on GUID-named pipes. And keep the signatures for the managed field flips — the public tools that inspired this note are pattern-scannable, and those rules will catch the copycats even where they won't catch us. As always: we'd rather be caught by a good rule than believed on a bad adjective.
Next note: Mimikatz is a magnet. We use the library. — DCSync, Kerberoast, ADCS and shadow credentials through a defender-grade .NET library, hosted in-process, scrubbed on exit.