// Build log 04 / Malware dev & EDR research

I rebuilt the named-pipe injection technique to learn where EDRs actually draw the line

A console named-pipe injection loader, written from scratch in C and assembly, went up against Microsoft Defender, Bitdefender Total Security, and Sophos XDR in my lab. The published version of the technique died on the two enterprise products even with a harmless Calculator payload; the final design ran clean on all three, with the payload executing inside a signed Windows binary and the process tree proving it. What the engines allowed, what they killed, and the one experiment that separated the two taught me more about detection than any course has.

// Read this first

The technique was published by TwoSevenOneT on the Zero Salarium blog, together with a proof of concept called InjectSetConsole. All credit for the original idea goes to that author, who credits earlier work from SensePost and modexp. My loader was written from scratch and only ever ran on virtual machines I own, inside an isolated network, against trial licenses exercised the way a researcher would exercise them. The target payload in every test was a harmless Calculator stage or a proof file.

This post exists for education and for the defensive side of the house, not as a verdict on any vendor. Every observation describes one product, on one build, on one day, in one configuration; engines change constantly, and that is exactly the point of the method you are about to read. Code appears in fragments, and the connective tissue that would make it a working tool is deliberately left out. The section near the end belongs to defenders.

01Why I built it

The technique was all over my feed. Every post explained the trick, almost none explained what happens after the trick meets a real engine. And I could not have answered the simplest question a red teamer should be able to answer about it. The public proof of concept works on a quiet machine. What does the full path cost when the machine is not quiet? Which exact behavior do the enterprise engines score, and can the design be changed to stop paying for it?

So I set the same kind of constraint that shaped my last project. Version one would be a faithful build of the published technique, no improvements, no cleverness. Every change after that had to be earned by an experiment that isolated one variable. If a defense killed my tool, I was not allowed to guess why. I had to make it tell me.

The feed said the trick works. The feed did not say what it costs.

02How the technique works

Do not write the payload into the target. Make the target read it in itself.

Classic injection allocates memory in another process, writes the payload into it, and forces a thread to run it. Those are VirtualAllocEx, WriteProcessMemory, and CreateRemoteThread, and every engine on earth watches all three. The published technique throws the first two away. It starts nslookup.exe, a signed Microsoft tool, with its standard input connected to a pipe the loader controls. The payload travels down that pipe, and nslookup reads it into its own memory with a normal ReadFile call. The loader never writes a payload anywhere. It only has to find where the host put it.

// the whole attack, end to end spawn the signed host → feed the payload through stdin → scan for the marker → flip the page executable → redirect the host thread

The payload travels as plain printable text. Every byte becomes two letters, so nothing in the stream can be mangled by the console input path:

wire.cc
/* every payload byte becomes two printable letters */
for (i = 0; i < len; i++) {
    wire[2 * i]     = 0x41 + (payload[i] >> 4);
    wire[2 * i + 1] = 0x41 + (payload[i] & 0x0F);
}

A random marker, generated fresh every run, is sent just before the payload. The loader scans the host memory for that marker to learn where the payload landed, makes the region executable, and redirects the host main thread into it. New run, new marker, so nobody gets to write a signature for the wire.

The host delivers the payload to itself. The loader just watches where it lands.

03Reading the public PoC

Before writing anything I read the public proof of concept line by line. It is a genuinely clean piece of work: small, readable, and it proves the core idea works on a real Windows machine. Publishing a working reference like that is what lets other people study a technique properly instead of guessing at it, and it is the reason this whole experiment was possible.

My build keeps the same idea and adds the engineering it takes to run it as a repeatable tool in my own lab. Payloads load from stdin, a file, or a URL instead of one fixed array, which makes testing different stages easy. Every payload byte travels as printable text, so the stream survives the console path intact. The marker is generated fresh on every run. The host can spawn with or without a visible window depending on what the test needs. All memory operations run through hand-built direct syscalls instead of the standard APIs. And a small assembly bootstrap lifts the payload ceiling from roughly four kilobytes to unlimited.

A working reference is what lets an idea travel.

04First test results

Version one was faithful on purpose. Pipe delivery, marker scan, protection flip, thread hijack, exactly as published, wrapped in enough engineering to be testable: payloads from stdin, file, or URL, verification probes, and proper exit codes instead of print statements and hope.

On Windows Defender, fully updated with real-time protection and behavior monitoring on, version one ran completely clean. The Calculator stage executed. A live command and control callback was established through the same pipeline and stayed connected. Nothing written to disk, no alerts at any stage. That was the baseline.

Then the same build went to Bitdefender Total Security with Advanced Threat Defense on, and to Sophos with the Intercept X engine. Both blocked it. The hosts died within seconds, even when the payload was a harmless Calculator. A benign payload died exactly like a real one, which was the first clue that the engines were not looking at my code at all. They were looking at what my code did.

Two Spider-Men pointing at each other
// a harmless Calculator and a real implant, dying the same death. the engines were not reading bytes.

One build, two verdicts. That split became the whole project.

05The real engineering problems

With the baseline established, my build got its upgrades one at a time, and each change earned its place by fixing something specific.

The four kilobyte ceiling was the biggest. The host only keeps a small window of the pipe data in its input buffer, so a large payload's tail never exists in memory and the run crashes. The fix was a 391 byte bootstrap written in assembly: the wire carries only the bootstrap, the bootstrap receives a duplicated pipe handle, and it streams the real payload in from that pipe in full, into memory it allocates itself. Payload size went from roughly four kilobytes to unlimited.

The direct syscalls came next. The hooked Windows APIs are rewired by security products, so every memory operation in the loader runs through hand built syscall stubs instead:

stub.cc
/* a 16 byte stub with the syscall number baked in.
   the numbers come from the kernel's own clean copy of ntdll,
   mapped from the KnownDlls section object, untouched since boot. */
stb[0] = 0x4C; stb[1] = 0x8B; stb[2] = 0xD1;   /* mov r10, rcx */
stb[3] = 0xB8;                                  /* mov eax, SSN */
*(unsigned int *)(stb + 4) = ssn;
stb[8] = 0x0F; stb[9] = 0x05;                   /* syscall      */
stb[10] = 0xC3;                                 /* ret          */

One discovery along the way was pure Sophos. An APC entry into a region that Control Flow Guard does not recognize makes a CFG enabled host fail fast with status 0xC0000409, and nslookup is compiled with CFG on. The fix is a creation-time mitigation policy attribute that turns CFG off for the child at spawn. Another was quieter: closing the pipe write end before resuming the hijacked thread lets the pending read complete, and the kernel rewrites the saved thread context while it is suspended. The pipe stays open until after the resume. Small orderings like that decide whether the technique works at all.

Every fix above was earned by a crash, a probe, or a vendor document.

06Finding the exact trigger

Still blocked on both enterprise products. Guessing is useless when a product kills you silently, so the loader grew probe modes: flip the protections and do nothing else, write data and read it back, deliver only, execute only, and so on. Between every run the machine rolled back to a clean snapshot, so each run started from an identical state. And before any of it counted, the positive controls had to fire: a known test file caught by the signature layer, a known malicious chain killed by the behavior layer. A clean result from a sleeping engine means nothing.

The experiments converged on one rule. Writing data into the host heap passed. Changing page protection passed. The pipe delivery passed. Every single way of moving execution into memory that was not part of a loaded module died. Private executable memory, killed. Section mapped memory, killed. An APC pointing at either, killed. On Sophos the block even came with a name: an event called APCViolation, from a policy called APCMitigation, raised by the engine's own DLL sitting inside my processes.

Then the experiment that mattered most: the same APC call with only the destination changed. Routine in my section memory, blocked. Routine at LoadLibraryA, a function inside the host itself, passed silently. Same API, same call, one variable.

The engines allow the data. They kill execution that leaves loaded modules.

07The engine injects itself into every process

The hardest bug of the project looked exactly like antivirus interference and turned out to be mine. The loader resolves functions by walking the process module list, and the classic walk assumes the third entry is always kernel32. On the Sophos machine that assumption is false: the engine injects its own DLL, SophosED.dll, into every process before kernel32 ever appears. My walk found the antivirus's export table and happily resolved functions from it.

The fix was to stop assuming positions and match the module by name while walking the list. That fixed the walk, and the tool still crashed. The second bug took a vectored exception handler, a register dump at the fault, and a re-read of the exact table entry to find: the resolver loaded an export table entry as a 64 bit value when those entries are 32 bit values. For most entries the upper bytes happened to be zero. For the last entry in the table, the upper bytes came from the neighboring table, and the pointer came out corrupted in a way that looked like nothing I had written.

resolve.Sasm
; before: a 64 bit load. the upper bytes came from the next table.
mov rdi, [r8 + rbx*4 - 4]
; after: a 32 bit load, because RVAs are dwords.
mov edi, dword ptr [r8 + rbx*4 - 4]

One register width, one byte of intent, days of clarity. This is also the part no course teaches: when a security product injects itself into every process on the machine, the assumptions in every public shellcode tutorial quietly stop holding.

Scooby-Doo unmasking reveal
// the mask comes off. the third module was never kernel32. it was the engine, riding in every process the whole time.

My bug looked like their block. The register dump said otherwise.

08The two modes that passed

The rule from section six says execution must stay inside a loaded module. Two designs satisfy it, and both became modes of the loader.

The first, apcexec, moves no code at all. The loader writes a short command string into the host heap, which is just data. It then queues a special APC whose routine is the system function that already exists inside the host C library, with the string address as the argument. A two byte nudge finishes the host pending input wait, the APC fires, and nslookup runs its own function with my text. From the engine point of view there is no executable memory, no thread creation, no image modification. The payload is an argument.

apcexec.cc
/* the routine is a function the host already loaded.
   the argument is our string. one argument, because that is
   all an APC routine receives. */
QueueUserAPC2((void *)system_addr, host_thread,
              (ULONG_PTR)cmd_string_addr,
              QUEUE_USER_APC_FLAGS_SPECIAL_USER_APC);

Choosing that routine took proof, not vibes. An APC routine receives exactly one argument, which disqualifies functions people commonly reach for, like WinExec, whose second parameter would arrive as garbage. The one argument rule is also why the pair that worked is so convenient: system takes one string, and so does LoadLibraryA.

That limitation is what the second mode solves. apcdll stages a normal DLL and writes only its path into the host heap. The APC routine this time is LoadLibraryA inside the host, the argument is the path, and the nudge makes the host map the DLL into itself. DllMain runs on a thread of nslookup.exe, the code lives in a mapped module exactly like every legitimate library in the process, and the payload is now a real payload:

payload.cc
/* the DLL follows the normal convention: spawn a worker,
   return immediately, never block inside DllMain */
if (reason == DLL_PROCESS_ATTACH) {
    DisableThreadLibraryCalls(hinst);
    t = CreateThread(NULL, 0, worker, NULL, 0, NULL);
    if (t) CloseHandle(t);
}

Both modes passed clean on Bitdefender Total Security and Sophos XDR. Calculator popped, a proof file recorded the host PID it ran inside, and a keep-alive option holds the host open so the process tree stays visible for as long as you want to look at it: the loader, nslookup.exe under it, and the Calculator under that. For network payloads, netstat shows the callback connection owned by the host PID. The proof is not a claim, it is a recording.

readinject-sophos-bypass.mp4PoC recording
// full PoC under Sophos: readinject feeds nslookup.exe the DLL path over the pipe, the host maps it into itself, calculator pops, and the console reports the device protected the whole time
A character from The Office celebrating at a party
// no executable memory, no remote thread. calculator popping inside a signed microsoft binary, on both enterprise engines.

Version two was an argument. Version three was a module. Neither was code.

09The host process has to fit the payload

Two Sophos specific findings came out of the same one-variable method, and both are about fit rather than injection. A cmd.exe child spawned by the injected host was killed within seconds even when it was sitting idle waiting on Calculator. A stream socket opened by nslookup attracted a delayed kill minutes later, because nslookup's entire legitimate life is UDP DNS and a TCP connection is a profile violation. Meanwhile a benign DLL that only wrote a file ran past one hundred seconds without a whisper.

The lesson generalizes past this technique. The host process is not just a disguise for the payload's identity, it is a contract about behavior. A DNS tool that spawns interpreters or opens connections is a DNS tool wearing someone else's clothes, and the engines are measuring exactly that. Either the payload fits the host, or you pick a different host.

The engines do not ask if the action is bad. They ask if it is in character.

10What the engines actually score

Sit with what got scored. Writing a string into a heap page is done by every application on earth. Flipping a page protection is what JIT compilers do routinely. Loading a DLL is what every program does at startup. Individually, none of it is damning. What carried score, on both enterprise products, was the sequence: an unsigned loader spawning a signed console host, execution arriving from memory no module backs, an interpreter or a socket that does not belong in that process.

That is the model modern detection runs on. No single behavior is damning, the product scores combinations, and the combinations differ per vendor. Bitdefender drew its line at execution transfers. Sophos drew the same line and added profile checks on top. Defender, in this configuration, mostly watched the file layer. You only learn which chains carry score for the product in front of you by testing it in a lab you own, and the testing has to be disciplined enough that you know which change produced which verdict.

As a defender I find the model genuinely encouraging. My Calculator died for the same reason a real implant would have, and the product reached that verdict without ever recognizing a byte of my code.

The engine never saw my payload. It saw my sequence.

11What the blue team sees

I know every artifact this chain leaves, because I put them there. In priority order:

// artifacts worth alerting on
  • Run-key-free but not alert-free. Even without persistence there is a spawn: an unsigned binary launching a signed console utility with redirected standard input. That parent child shape, plus a pipe owned by the parent, is the entry signature of this entire technique class.
  • APC destination checks. The special user APC is a normal mechanism, but its routine address should be validated against the loaded module ranges of the target. Every blocked run in this project was an APC pointing outside them.
  • Unsigned modules in signed processes. Sysmon logs the image load. An unsigned DLL mapped into nslookup.exe from a user writable folder is worth an alert on any hardened machine.
  • Host profile deviations. A DNS tool with children, with raw TCP, or with an interpreter in its tree is out of character by definition. Baseline what your console binaries normally do and alert on the delta.
  • CFG policy overrides. A creation-time attribute that disables Control Flow Guard for a child is unusual on any managed estate, and this technique needs it.
  • The full chain. Unsigned loader, signed host, unexpected child or connection. Correlated, it is unambiguous. Separately, it is noise.

12What I learned

The biggest surprise was how much of the final design is subtraction. No code movement. No command interpreter. No raw sockets. No hardcoded anything. The version that passed on every engine is the version that does the least, because every loud thing it dropped was a thing the engines were scoring. The most valuable debugging skill of the project was not assembly trickery, it was building a harness that could prove where the fault really was, twice, once when the antivirus was innocent and once when I was.

And the rule is worth repeating because it compresses the entire campaign. The engines allow the data. They allow the delivery. They allow protection changes. They kill execution that does not come from a loaded module, and they check whether the process is behaving like itself. Every design decision in the final tool falls out of those two sentences.

The next build log steps away from injection entirely, into the delivery layer: making the payload's arrival and its traffic look so ordinary that the network sensors have nothing to hold on to either.

← All build logs