Skip to content

Lesson 4 of 9 · 22m

Packers, Crypters, and Reading Entropy

Recognize a wrapped binary in a minute — section layout, entropy, and import shape — and know why the stub is not the malware.

Most commodity malware you triage is wrapped. That matters for two reasons. First, everything static triage told you about a packed file describes the wrapper, not the payload. Second, the stub is shared: a YARA rule or IOC written against it will match forty unrelated families plus legitimate packer-protected software. The skill isn't reversing the stub — it's recognizing it in a minute and moving past it.

The tells

  • A section with entropy above ~7.2 (out of 8.0) — compressed or encrypted content.
  • Raw size 0 but a large virtual size, or a section marked RWX — space reserved for something written at runtime.
  • Section names like UPX0/UPX1, .aspack, .themida; the entry point in the last section instead of .text.
  • A tiny import table: LoadLibraryA, GetProcAddress, VirtualAlloc, VirtualProtect, little else.
import pefile, sys
pe = pefile.PE(sys.argv[1])
ep = pe.OPTIONAL_HEADER.AddressOfEntryPoint
for s in pe.sections:
    c = s.Characteristics
    rwx = ("R" if c & 0x40000000 else "-") + ("W" if c & 0x80000000 else "-") + ("X" if c & 0x20000000 else "-")
    print(f"{s.Name.rstrip(b'\x00').decode(errors='replace'):8} raw={s.SizeOfRawData:#08x} "
          f"virt={s.Misc_VirtualSize:#08x} {rwx} H={s.get_entropy():.2f}{'  <- EP' if s.contains_rva(ep) else ''}")
UPX0     raw=0x000000 virt=0x02c000 RWX H=0.00
UPX1     raw=0x00f200 virt=0x010000 RWX H=7.93  <- EP
.rsrc    raw=0x001400 virt=0x002000 RW- H=4.12

An empty, writable, executable region waiting to be filled — and the entry point living in the compressed blob that fills it.

Entropy, demystified

// Shannon entropy: how evenly byte values are distributed, 0-8 bits/byte
function entropy(bytes) {
  const c = new Array(256).fill(0);
  for (const b of bytes) c[b]++;
  return c.reduce((h, n) => n ? h - (n / bytes.length) * Math.log2(n / bytes.length) : h, 0).toFixed(3);
}
const text = new TextEncoder().encode("powershell -nop -w hidden -c IEX (New-Object Net.WebClient)".repeat(60));
console.log({ text: entropy(text),
  xor5a: entropy(text.map(b => b ^ 0x5a)),   // identical — XOR relabels bytes, not their distribution
  aes:   entropy(crypto.getRandomValues(new Uint8Array(4096))) });

Single-byte XOR doesn't change the distribution, so it doesn't change entropy — low entropy does not mean "not obfuscated." High entropy means compressed or encrypted, not necessarily malicious.

Packer, crypter, protector

  • Packer (UPX, MPRESS): compression, usually reversible with a tool; common on legitimate software too.
  • Crypter: encrypts the payload behind a custom stub, rebuilt per customer — every SHA-256 new, the payload inside often byte-identical across builds.
  • Protector (Themida, VMProtect): anti-debugging and code virtualization. Not a day-one manual target — lean on dynamic dumping.

Field notes

  • Installers (NSIS, Inno Setup), compressed resources, and embedded PNGs are legitimately high-entropy. Entropy is a hint, not a verdict.
  • Check the overlay — data after the last section — via pe.get_overlay_data_start_offset(); droppers love it.
  • DIE saying "UPX" doesn't mean upx -d works. Modified UPX is a classic; the next lesson handles it.
  • Sometimes you do want a rule on the stub — tracking a crypter-as-a-service across customers. Just name it that way.