Research note N.08 · 2026-10-15

What convicts an injector? Six baits, two controls, one answer.

Ask ten engineers what makes an EDR kill an injection and you get ten confident folklores. We needed the real answer for GhostCrypt — our polymorphic in-memory crypter — so we did the unfashionable thing: a controlled experiment. Six bait programs, two controls, a hardened SentinelOne + Defender box, every row reproduced. The verdict inverts the folklore.

READING TIME ≈ 7 MIN AUTHOR THE AXIS TEAM

GhostCrypt is one of the build-time tools in the AXIS toolchain: feed it any x64 native Windows executable and it emits a unique-per-build binary that carries the payload encrypted, decrypts it in memory, and runs it there — the file never touches disk in the clear. Every build differs: a fresh ChaCha-20 key split into three-to-six random XOR shares, a fresh seed for the API-name hashes (so hash constants can't become IOCs), fresh junk functions, a fresh section name, a benign version resource. The static layer of this problem is honestly close to solved — unique bytes, no payload on disk, an import table that reads like a calculator. The hard question was behavioral: what does a modern EDR actually punish?

First, cheat the emulator

Before any behavioral question matters, the sample has to survive the static-and-emulation layer — and emulation is where most packers die. Defender and similar engines execute unknown binaries in a synthetic CPU, waiting for the decrypted payload to materialize in emulated memory where the scanner can see it. GhostCrypt's answer is the piece of this tool we're proudest of, because it turns the emulator's budget into a weapon against itself: the final decryption key does not exist anywhere in the binary. It is recomputed at startup by folding a proof-of-work into the key schedule.

THE POW KEY FOLD — CATEGORY LEVELper build
key_eff = key(from 3–6 XOR shares) XOR sha256-chain(seed, iters)

# iters: random per build, 500,000–1,200,000
# real CPU:  ~0.2–0.5 s, once, at startup
# emulator:  instruction budget exhausts long before
#            the chain completes — so plaintext never
#            exists inside the emulated machine at all
Fig. A — the foldBuilder recomputes the chain and cross-checks byte-for-byte

The economics are brutal and asymmetric. Half a second of real CPU work is invisible to a user; an emulator's instruction budget is orders of magnitude too small to finish the chain, so emulation aborts before a single plaintext byte exists anywhere its scanner can look. What remains is the static scan — which a polymorphic stub with benign imports and low-entropy storage is built to pass. The builder recomputes the identical chain and compares it byte-for-byte with what the stub will compute, so a build either folds correctly or doesn't ship.

The experiment: one variable per bait

With the static layer handled, we built a bait series to interrogate the behavioral layer. Each bait program is benign — it writes a proof file and exits — but each carries exactly one injection pattern, staged so the series isolates variables instead of confounding them. Two more samples served as controls. All of it ran live on Windows 11 24H2 with SentinelOne and Defender hardened to maximum (cloud block high, samples kept off the vendor cloud), with Defender's detection events diffed per run and the S1 console checked for management-side convictions. Every row was reproduced multiple times.

SampleWhat it isolatesVerdict
Self-injection baitPacked payload in its own process — even with RWX + CreateThreadCLEAN
v1 — classic chainRWX + CreateRemoteThread into a suspended child, maximum ETW-TI surfaceCONVICTED
v2 — early-bird APCW^X discipline, no RWX, no CreateRemoteThread — but still an APC into the childCONVICTED
v3 — shared sectionZero WriteProcessMemory, zero VirtualProtectEx — local RW map + remote RX map, APC againCONVICTED
v4 — hollowing-styleRedirect the child's own thread via Get/SetThreadContext — no CRT, no APCCONVICTED
v5 — silent childRedirected thread runs a pure byte-copy loop: zero syscalls, everCONVICTED
v6 — stompCode written over the child's own signed-image .text, entry redirectedCLEAN
control 1Staging identical to v2 (allocate, write, flip to RX in the child) — but never executesCLEAN
control 2CreateRemoteThread running signed LoadLibraryA on legitimate user32.dll — no foreign code at allCONVICTED
Fig. B — the boardWin11 24H2 · SentinelOne + Defender (hardened) · every row reproduced

Three conclusions, none of them folklore

One: conviction is execution-based, not behavior-based. The silent child is the row that should end arguments — a redirected thread executing a byte-copy loop with literally zero syscalls still burned. Whatever the engine inspected, it was not the code's behavior. Meanwhile control one, which performs every suspicious staging step but never executes, walked free.

The sensor doesn't care what your code does. It cares where your code lives.

Two: the scan keys on private unsigned pages. Every convicted variant executed from freshly allocated private memory. The one survivor — v6 — executed from the child's own signed-image .text, bytes stomped in place over copy-on-write image pages. Code running from an image-backed region, even a modified one, was simply not in the scanned set.

Three: staging is free; only execution convicts. Allocate, write, change protections — none of it registered. Even control two is instructive in reverse: a CreateRemoteThread call died while running Microsoft's own signed library code, with zero foreign bytes involved. The API pattern itself — a remote thread start — is what the sensor punishes, regardless of what the thread runs.

What survived — the shape of it

The surviving recipe, at category level: spawn the child suspended, locate its image in memory, write your code over its own .text, point the already-existing thread at it with SetThreadContext, resume. Zero new pages, zero remote allocations, zero protection changes, zero thread-creation calls. Afterwards: restore the original bytes, keep the child's visible behavior minimal, terminate cleanly. One production note the hard way — at CREATE_SUSPENDED only the EXE and ntdll are mapped, so the main module is the only guaranteed stomp target; your favorite DLL does not exist yet.

Fig. C — what livedEvery convicted step removed, one at a time, then verified

The honest limits

The doctrine we ship with the tool says it plainly, because our lab's rule applies to every claim on this page: results are pinned to the engines and the day we tested, and the payload's own behavior after execution — network, registry, persistence — is outside the loader's control and is what advanced EDRs score next. GhostCrypt refuses .NET assemblies at build time (CLR hosting is milestone two), and its OPSEC section leads with the two rules that matter most: never upload a sample to a multi-scanner — submissions burn a build into signatures within days — and never reuse one packed binary across targets. Reuse is what gets builds signatured; per-target uniqueness is the whole point of the tool.

The defender's angle

This experiment is a gift to the other side, and we publish it knowing that. If conviction keys on execution from private unsigned pages, then VAD auditing — not API sequence matching — is the high-fidelity signal: alert when a thread's instruction pointer lands in image memory that has gone copy-on-write, or in any executable private region. The surviving pattern still has seams: a process created suspended that does nothing visible, a SetThreadContext that moves a child's RIP into its own .text, image pages that flipped COW. Each is quiet; correlated, they're a story. And the PoW key fold carries a lesson for emulator design: a fixed instruction budget is a wall an attacker can simply walk around — budget on time and syscalls, not instructions, or accept that emulation only sees the artifacts that want to be seen. We'd rather you build that rule than read that we said "undetectable." We never said it.

Next note: The shell that was never spawned — the Phantom Shell, layer by layer: builtins that create nothing, a lazy runspace, return-clean stubs with a per-command lifetime, and the policy cache nobody mentions.