Research note N.09 · 2026-10-16

The shell that was never spawned.

A recent public walkthrough shows the managed answer to PowerShell's defense stack: flip two internal fields over reflection, host your own runspace, enjoy Full Language Mode. It's a clean exposition — and its bill is clean too. You are a new process. The CLR loads at your birth. And the fields you flip are now signatures themselves; the walkthrough's own defensive section lists them as things to hunt. Phantom Shell, the in-process shell inside the AXIS iShell module, is our answer to the same stack. It goes several layers deeper — and most of the time, it does nothing at all. This note walks the whole design, layer by layer, ending with what defenders should copy.

READ ≈ 8 MIN AUTHOR THE AXIS TEAM

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:

LayerWhat it records
Process creation 4688 / Sysmon 1powershell.exe being born: command line, parent, hash, signer
AMSIScript content, scanned in whatever process hosts the engine — before execution
ETW · script blocks 4104Every script block as executed — deobfuscated; obfuscation dies here
ETW · module logging 4103Pipeline execution detail, per module
TranscriptionFull session transcripts, written to disk files
Assembly loadsclr.dll, mscoree, System.Management.Automation arriving in a process — CLR ETW and kernel image-load callbacks both see it
Fig. A — the stackSix layers, six independent failure models

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:

RETURN-CLEAN STUBS — x64, AS BUILTper command
; 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
Fig. B — the stubsDisassembly-verified; x86 variants differ by stack slot

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 binaryPhantom Shell
Process createdItself — it is a new processNone, layers 0–2; WMI child only when unavoidable
CLR loadAt birth, unconditionallyLazy — first command that truly needs it
AMSIManaged field flip, process-lifetimeReturn-clean native stubs, per-command window
ETWManaged field flipEtwEventWrite + NtTraceEvent return success, transient
4104 / 4103 / transcriptionNot addressedPolicy cache zeroed via COM reflection — no script runs
Crash safetyFault takes the processVEH + longjmp; runspace marked broken, WMI fallback
Fallback ladderNoneBuiltins → CLR runspace → WMI named-pipe
On-host footprintA binary on disk~164 KB module, fetched on demand; keys wiped, header stomped
Fig. C — the bill, comparedBoth answer the stack; one answers all six layers

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.