How a small Rust loader sidestepped Windows Defender
My first version was well built, encrypted, and completely fileless. Defender flagged it anyway. The detection name it returned taught me more about how antivirus actually works than everything that came before, and the fix turned out to be one character long.
Everything described here ran against virtual machines I own, on an isolated home lab network, with no third-party systems involved and nothing distributed. Code appears in small fragments, and the parts that would make this turnkey are left out on purpose. Attackers should know what defenders see, and defenders should know what quiet looks like.
01The idea
A file on disk is evidence. It has a hash, a signature, a timestamp, and it waits patiently for something to scan it. My loader exists to break that relationship: the only file is a small, boring program, and the actual payload arrives over the network and lives entirely in memory, from the moment it arrives to the moment the process dies.
The job breaks down into five moves, and every later section of this post is just one of them wearing a story:
Simple to say. The first attempt did all five steps correctly and still got caught, which is where the interesting part starts.
02The first run got flagged
Picture the first run. A fully updated Windows 10 machine, Defender real-time protection
on. The loader starts, sleeps, fetches an encrypted blob from my attack box over plain
HTTP, decodes it in memory, decrypts it, and executes. Nothing was ever written to disk.
Defender flagged it anyway, almost immediately, with a signature I can still recite:
Behavior:Win32/Meterpreter.gen!D.
I want you to notice one word in that name, because I missed it for an hour.
Behavior. Not Trojan:Win32/Something, which is
what a file signature looks like. Behavior. Defender was not matching bytes. My loader
binary was clean, and the payload on the wire was opaque encrypted noise. What got matched
was the way the whole thing danced.
Here's what was actually happening. My shellcode was a staged payload, generated with a
slash in its name: windows/x64/meterpreter/reverse_https. A
staged payload ships a tiny stub first, and that stub connects back and pulls the real,
much larger stage from the C2. Two connections, in a fixed order, with a recognizable size
and rhythm. That two-step handshake has been documented, taught, and written into detection
rules for over a decade. It didn't matter that both connections carried encrypted content.
The choreography itself was the signature.
The fix was to switch to a stageless payload:
windows/x64/meterpreter_reverse_https, with an underscore. A
stageless payload carries the entire agent inside the single blob my loader already
downloads. One connection, no second fetch, no handshake. The next run was clean.
You can encrypt content into noise. Choreography has to actually happen, in order, every time.
That distinction has reorganized how I think about every assessment since. Static analysis reads what something is. Behavioral analysis reads what something does. You can disguise the first almost for free. The second takes real design, because malware has to perform its function to be malware, and performing a function is a pattern.
03Disguising the payload
With the handshake problem gone, one risk remained: raw Meterpreter shellcode contains byte patterns that exist in every signature database on earth. If anything ever inspects that buffer, in a file, in a proxy, in a memory dump, it gets recognized. So before the payload travels, it goes through two cheap transformations.
The first is XOR with a short rotating key. The packer and the loader share four bytes, and every payload byte gets flipped against the key, with the key position cycling as you go:
key = bytes([0x13, 0x57, 0x29, 0x84])
enc = bytes([b ^ key[i % 4] for i, b in enumerate(data)])
This is not encryption and I won't insult the word by using it. Anyone who captures the payload and suspects XOR can break four bytes in milliseconds. The goal is much narrower: the bytes sitting in transit and in memory should not match any known run of shellcode. Signatures need stable byte sequences to match, and a rotating key destroys every stable sequence. Where the same plaintext byte used to map to the same ciphertext byte over and over, making frequency analysis trivial, rotation scatters it.
The second transformation is Base64, and it exists for a boring reason: HTTP moves text reliably and binary badly, so the encrypted blob gets encoded as text before it's served. The pleasant surprise was the decoder. I wrote the Base64 decoding by hand instead of importing a library, half as an exercise and half out of caution, and the caution turned out to be the useful half. Every crate you compile into a binary leaves fingerprints: imported functions, version strings, bytes that other loaders using the same crate also carry. A hand-rolled decoder is a few dozen lines, and no one else's binary shares them.
04The memory ritual
Now the payload exists in the loader's memory as decrypted bytes. The last step is making those bytes executable, and this is where most malware gets greedy. The obvious move is to allocate one region that is readable, writable, and executable, copy the shellcode in, and jump. One call, done.
The obvious move is also the loudest thing a process can do. Memory that is writable and executable at the same time is rare in legitimate software, and when it exists it usually has a name, like a JIT compiler. An anonymous RWX region in some random utility process is the single most reliable malware tell in memory forensics. So the loader does it in two polite steps instead:
// ask for writable memory, never mentioning execution
let addr = VirtualAlloc(null, size, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);
copy the decrypted shellcode in;
// only then, quietly request execute permission
let mut old = 0;
VirtualProtect(addr, size, PAGE_EXECUTE_READ, &mut old);
CreateThread(starting at addr);
The sequence matters more than the calls. A page that becomes executable after being written tells a coherent story: something was prepared, then something was run. A page that is born with all three permissions tells a different story, and every EDR knows that story by heart.
05Sleep and a boring identity
Neither of these involved a single clever line of code, and both earned their place.
Sleep before doing anything. Automated analysis is a budget. A sandbox runs your program for a fixed window, watches for interesting behavior, and writes a verdict. A process that does nothing for two minutes has usually already been labeled harmless before its first network packet. A randomized delay before the fetch, long enough to outlast the machine's patience and short enough not to insult mine, quietly removed an entire category of dynamic detection.
Look boring on the outside. The binary got a believable name, an icon, a
version resource, the full costume of a background utility. On my lab build it answered to
audiotek.exe. Engines weigh what a file claims to be against
what it does, and a confident, consistent, unremarkable identity is friction for the static
side. It costs nothing, and nothing is exactly the right price.
The clever part got it running. The boring part kept it invisible.
06The run
Final configuration: stageless shellcode, XOR'd and Base64'd on the attack box, served as a
plain text file over HTTP. On the target, the loader slept a random interval, fetched,
decoded, decrypted, flipped one page to executable, and started a thread. A full Defender
scan ran before the test and another after. Zero alerts, and the Meterpreter session
arrived on my handler and stayed.
I don't read that as Defender failing. The engine made a reasonable bet: most malware is industrial, recycled, and eventually signatured, so it hunts recycled things. A small, custom, single-purpose program that fetches one opaque blob is a different animal, and betting against its existence is, most days, a winning bet.
Quiet is not invisible. From the defensive chair, this chain leaves exactly four traces I'd alert on:
- The RW to RX flip. In a process that isn't a JIT runtime, a page becoming executable after being written is worth an alert by itself. It is rare enough to survive a tiny false-positive rate.
- Threads born nowhere. A thread whose start address lives in memory with no backing file is the fingerprint of precisely this technique.
- Text over HTTP with no meaning. My payload was a Base64 text file on port 8080, followed by a call home on 443. High-entropy text responses with no content type are worth profiling, even when the bytes match nothing.
- Sleep, then burst. Random delays beat fixed sandbox timeouts, but a process that idles for minutes and then opens a socket still has a shape. Long-window behavioral rules can catch what short detonation windows miss.
07What stuck
Three things. Read the detection name before touching anything, because "Behavior" and "Trojan" point at opposite ends of your chain. Respect the boring details, because sleep and a convincing file identity bought as much evasion as the memory tricks and cost nothing to try. And when something gets caught, be glad: the detection is a free lesson in exactly which of your assumptions was wrong.
The next project got bigger. I built a full remote access tool with its own control server, pointed it at an enterprise EDR, and learned that catching one behavior is enough.
← All build logs