Executable Packing Analysis
Definition
Section titled “Definition”Executable packing analysis studies packing as a technology: how packer engines transform a normal PE into a compressed/encrypted, self-extracting package — the stub construction, section mangling, import-table compression, and entry-point preservation that make the trick work — and how packed output is detected, classified, and measured at estate scale. Where M4 covers the analyst’s workflow of recovering an unpacked payload from a suspicious sample, this module covers the machinery itself: what UPX actually does byte-by-byte, why IAT compression is the hard part of packer engineering, how packer families develop recognizable structural fingerprints, and why entropy alone is a weak detector that honest detection engineering must outgrow. The skill set is dual-use by nature — the same knowledge that builds a packer builds packer detection, and reading commercial protectors (VMProtect-class) requires exactly this foundation.
Why It Matters
Section titled “Why It Matters”Packing is the most common transformation in the malware economy and one of the most common in legitimate software distribution — installers, game launchers, and licensed binaries pack for size and anti-tamper. That duality is precisely why naive “packed = bad” detection produces unusable false-positive rates and why detection engineering must classify which packer, not just that packed. For the analyst, understanding packer internals converts mystery into checklist: every packer must solve the same four problems (compress, rebuild imports, find its way to the OEP, hide itself), and each solution family has characteristic static artifacts you can fingerprint at scale. For the detection engineer, packing is the canonical precision/recall exercise: entropy thresholds tuned on real corpora, import-sparsity heuristics, section-structure outliers, and packer-family YARA all trade against thousands of legitimate packed binaries in any enterprise. For the malware economist (M15), packer fingerprints are identity: commodity operators reuse packer builds across campaigns the way legitimate developers reuse frameworks, and those fingerprints link samples that no payload string would. Finally, this module is the conceptual bridge to E1’s AV internals — because AV emulators and scan engines make their packed-vs-unpacked decisions exactly here, and understanding those decisions explains most of what malware does to survive them.
Detailed Explanation
Section titled “Detailed Explanation”A packer engine executes a fixed pipeline at pack time, and every stage leaves structural evidence:
PACK-TIME PIPELINE RUNTIME (the stub) 1. parse original PE 1. allocate RWX buffer 2. select sections to compress 2. decompress sections (code/data/rsrc; keep headers) into their virtual addrs 3. compress (LZMA/aPLib/zlib) 3. REBUILD IAT: 4. mangle section table - packed imports = table of - rename/wipe names (dll_name, fn_name) pairs - set raw size 0 for virtual-only - stub walks it, calling 5. build/append stub section LoadLibrary+GetProcAddress - decompressor + IAT rebuilder (or emulates the whole thing) 6. fix entry point: 4. restore original EP value - AddressOfEntryPoint -> stub 5. jump to OEP - ORIGINAL EP stored inside stub (that's the tail jump) 7. handle TLS callbacks, relocations (or declare them unsupported)
DETECTION SPACE (why entropy alone fails) entropy high ...but installers/compressed assets too import count low ...but legitimate small utilities too section table weird ...stronger: names wiped, raw=0, EP-in-last-section, single RX section fingerprint strongest: stub byte patterns, packer-version strings, allocator+API call ORDERThe import table is the packer engineer’s central problem. A compressed payload cannot keep a normal IAT (the thunks would point at unresolved addresses), so packers either resolve at load time — the stub contains a table of (dll, function) name pairs and calls LoadLibraryA/GetProcAddress in a loop, which is why packed files import exactly those few APIs — or emulate resolution: the stub contains hand-written loader code per API (moving mov eax, <SSN>-style thunks or direct jmp [address] trampolines), producing zero real imports, which is both harder to build and a fingerprint of its own. Entry-point preservation is the other half: the stub must jump to the original entry point after unwrapping, so the original EP value lives somewhere in the stub — frequently visible as the tail-jump target M4’s tooling hunts for; protector-grade packers obscure this with multiple stages, control-flow obfuscation in the stub itself, and OEP bouncing through intermediate jmp chains. Section-table mangling ranges from lazy (UPX’s honest UPX0/UPX1 names) to hostile (wiped names, fake characteristics, raw-size-zero sections that exist only in memory, overlapping SizeOfRawData values that break naive parsers). Modern protectors add virtualization (native code → bytecode for a custom VM), mutation (polymorphic stub regeneration per build), and anti-dump (wiping the PE header in memory after unwrap, so analyst dumps come out headerless). Detection engineering reads all of this as a feature space: entropy, import profile, section geometry, entry-point placement, stub byte signatures — and the honest detector is a scored combination, because every single feature has a benign population that defeats it alone.
Common Techniques
Section titled “Common Techniques”| Technique | Mechanism | Static artifact | Detection use |
|---|---|---|---|
| Section compression (aPLib/LZMA) | Shrink payload, stub decompresses | High-entropy section, raw≈0 source | Entropy + geometry scoring |
| Import compression | (dll,fn) table + runtime resolution | 3-8 imports, resolver set only | Import-sparsity heuristic |
| Emulated import stubs | Hand-written trampolines per API | Zero imports; jmp-chains in stub | Strong protector fingerprint |
| EP relocation to stub | AddressOfEntryPoint → stub section | EP in last exec section | Classic packer indicator |
| Section name wiping | Rename to random/empty | Non-standard names, garbage bytes | Geometry outlier |
| Virtual-only sections | raw=0, virtual>0 | UPX0-style layout | Struct indicator |
| TLS callback execution | Code before EP via TLS dir | TLS directory + callbacks | Pre-EP execution check |
| Overlay payload abuse | Data after last section | Overlay > threshold | Dropper/packed-data triage |
| Polymorphic stubs | Per-build regenerated code | No stable stub bytes | Defeats byte-signature rules |
| Anti-dump (header wipe) | Zero PE header in memory | Headerless dumps | Forensics friction, not detection-proof |
| VM/virtualization | Native → custom bytecode | .vmp sections, VM handlers | Protector classification |
| Rich header stripping | Remove compiler provenance | Missing/richless header | Attribution friction |
Full Working Code
Section titled “Full Working Code”packstat.py — packer statistics at scale: batch-scans a directory of PEs, extracts the structural feature vector (entropy profile, import count/resolver-only flag, section geometry anomalies, entry-point placement, overlay presence), clusters files sharing the same packer trait-fingerprint, and emits a prevalence report — the estate-scale view that single-file triage tools (M4) cannot give you. stdlib + pefile.
#!/usr/bin/env python3"""packstat.py — packer feature extraction + trait clustering at scale.Usage: python packstat.py <dir> [--out report.md] [--min-cluster 2]Deps: pefile."""import argparse, hashlib, math, os, sysfrom collections import Counter, defaultdictimport pefile
RESOLVER = {"LoadLibraryA", "LoadLibraryW", "LoadLibraryExA", "LoadLibraryExW", "GetProcAddress", "VirtualAlloc", "VirtualProtect", "VirtualFree", "ExitProcess"}
def entropy(data): if not data: return 0.0 freq = Counter(data) n = len(data) return -sum((c / n) * math.log2(c / n) for c in freq.values())
def features(path): """Extract the packer-relevant feature vector from one PE.""" f = {"file": os.path.basename(path), "sha16": "", "verdict": None, "max_entropy": 0.0, "ep_section": None, "sections": [], "imports": None, "resolver_only": False, "zero_raw_exec": 0, "nonstd_names": 0, "overlay": 0, "traits": set()} try: data = open(path, "rb").read() except OSError: f["verdict"] = "unreadable" return f if not data.startswith(b"MZ"): f["verdict"] = "not-pe" return f f["sha16"] = hashlib.sha256(data).hexdigest()[:16] try: pe = pefile.PE(path, fast_load=False) except pefile.PEFormatError: f["verdict"] = "parse-error" return f ep_rva = pe.OPTIONAL_HEADER.AddressOfEntryPoint max_ent = 0.0 for s in pe.sections: name = s.Name.rstrip(b"\x00").decode(errors="replace") raw = s.get_data() ent = entropy(raw) max_ent = max(max_ent, ent) exe = bool(s.Characteristics & 0x20000000) f["sections"].append(name) if exe and len(raw) == 0 and s.Misc_VirtualSize > 0: f["zero_raw_exec"] += 1 f["traits"].add("virtual-only-exec-section") if name == "" or not name.isascii() or \ not re.match(r"^[\.A-Za-z0-9_$/]{1,8}$", name): f["nonstd_names"] += 1 f["traits"].add("wiped-section-names") if exe and s.VirtualAddress <= ep_rva < \ s.VirtualAddress + max(s.Misc_VirtualSize, 1): f["ep_section"] = name f["max_entropy"] = round(max_ent, 2) # imports try: names = [i.name.decode() for e in pe.DIRECTORY_ENTRY_IMPORT for i in e.imports if i.name] except AttributeError: names = [] f["imports"] = len(names) f["resolver_only"] = bool(names) and set(names) <= RESOLVER if f["resolver_only"]: f["traits"].add("resolver-only-imports") if f["imports"] is not None and f["imports"] <= 8: f["traits"].add("sparse-imports") if f["max_entropy"] > 7.2: f["traits"].add("high-entropy") # entry point in last exec section (classic stub placement) exec_secs = [s.Name.rstrip(b"\x00").decode(errors="replace") for s in pe.sections if s.Characteristics & 0x20000000] if exec_secs and f["ep_section"] == exec_secs[-1]: f["traits"].add("ep-in-last-exec-section") # overlay: data beyond last section's raw extent try: last = max(pe.sections, key=lambda s: s.PointerToRawData + s.SizeOfRawData) end = last.PointerToRawData + last.SizeOfRawData f["overlay"] = max(0, os.path.getsize(path) - end) if f["overlay"] > 65536: f["traits"].add("large-overlay") except ValueError: pass packed_traits = {"virtual-only-exec-section", "wiped-section-names", "resolver-only-imports", "high-entropy", "ep-in-last-exec-section"} f["verdict"] = "packed-traits" if f["traits"] & packed_traits \ else "clean-shape" return f
def main(): ap = argparse.ArgumentParser() ap.add_argument("root") ap.add_argument("--out") ap.add_argument("--min-cluster", type=int, default=2) args = ap.parse_args() recs, failed = [], 0 for root, _, files in os.walk(args.root): for n in sorted(files): f = features(os.path.join(root, n)) if f["verdict"] in ("unreadable", "not-pe", "parse-error"): failed += 1 continue recs.append(f) if not recs: sys.exit("no PE files found") packed = [r for r in recs if r["verdict"] == "packed-traits"] print(f"[+] {len(recs)} PE files, {len(packed)} " f"packed-traits, {failed} skipped") # trait clustering: identical trait-set = one packer profile clusters = defaultdict(list) for r in packed: clusters[tuple(sorted(r["traits"]))].append(r) print(f"\n== packer trait clusters ({len(clusters)}) ==") ranked = sorted(clusters.items(), key=lambda kv: -len(kv[1])) for traits, members in ranked: if len(members) < args.min_cluster: continue print(f"\n[{len(members)} files] traits: " f"{', '.join(traits)}") samples = Counter(m["sections"][0] if m["sections"] else "?" for m in members).most_common(3) print(f" first-section names: {samples}") for m in members[:5]: print(f" {m['sha16']} {m['file']} " f"ent={m['max_entropy']} imp={m['imports']}") if args.out: with open(args.out, "w") as fh: fh.write(f"# Packstat report — {len(recs)} PEs\n\n") fh.write(f"packed-traits: {len(packed)}, " f"clusters: {len(clusters)}\n\n") fh.write("| sha256-16 | file | entropy | imports | " "ep_section | traits |\n|---|---|---|---|---|---|\n") for r in sorted(recs, key=lambda r: r["verdict"]): fh.write(f"| `{r['sha16']}` | {r['file']} " f"| {r['max_entropy']} | {r['imports']} " f"| {r['ep_section']} " f"| {', '.join(sorted(r['traits'])) or '-'} |\n") print(f"\n[+] wrote {args.out}")
if __name__ == "__main__": main()Code Walkthrough
Section titled “Code Walkthrough”features()(lines 25-96) is the detector’s feature extractor, deliberately built from structural signals rather than one magic number: per-section entropy with the maximum tracked (lines 45-52), zero-raw executable sections (lines 53-56 — the UPX0 shape), wiped/non-standard names (lines 57-61), entry-point placement resolved to its containing section (lines 66-69) and compared against the last executable section (lines 89-94 — the classic stub position), import sparsity and resolver-only composition (lines 71-87), and overlay size (lines 96-103). Each signal independently has a benign population; the set is the classifier.- The trait set (a Python set of strings) is the report’s unit of clustering: files sharing the same trait-set get grouped (lines 122-125) — a crude but effective packer-profile hash that needs no signatures, only structure.
- The verdict split (lines 105-108) is intentionally conservative:
packed-traitsfires on ANY structural trait,clean-shapeis a claim only about structure — never about intent. The report’s honesty lives there; prevalence numbers derived from intent claims rot fast. - The markdown report (lines 144-158) is sorted by verdict so the packed-traits block reads as the actionable half — the shape a SOC review expects.
How It Reaches The Target
Section titled “How It Reaches The Target”Packing reaches the target as the last build step of almost every commodity payload: the operator compiles the loader, drops it into a crypter or packer (increasingly a purchased service, same as hosting), and ships the wrapped output through their delivery chain (M13). The wrapper is what every static control first meets: AV scan engines hit the high-entropy section (E1), mail gateways hash the wrapper (which rotates per build by design), and sandboxes detonate it — the one control that sees through packing by definition, since the stub unpacks in memory. On the estate, packed-traits files appear wherever the payload lands and persists, which makes packer detection a population discipline: thousands of files, mostly legitimate installers, some malicious — and the packstat-style structural clustering is how you tell the populations apart at that scale. Defenders who track their estate’s packer fingerprint distribution gain a low-noise tripwire: a new cluster of files with wiped-section-names + resolver-only-imports + ep-in-last-exec-section appearing in user-writable directories is a campaign-shaped event regardless of payload. For the analyst, the same fingerprint reasoning works backwards: the trait cluster of today’s incident samples identifies the packer build, links prior campaigns (M15), and tells you which unpacking workflow (M4) applies before you burn bench time on the wrong tooling.
Exploitation Chain
Section titled “Exploitation Chain”- Build — operator compiles loader; original PE has 84 imports, entropy 5.9
- Pack — crypter service wraps: sections compressed (entropy 7.96), imports reduced to 6 resolver APIs, section names wiped, EP moved to stub
- Ship — wrapped binary distributed via M13 chain; static scanners see only stub
- Detonate — sandbox executes; stub decompresses, rebuilds IAT, jumps OEP
- Land — payload persists on host; packed file sits in user-writable dir
- Census — packstat.py estate sweep: new trait cluster (5 files) in user dirs, no benign precedent
- Triage — M4 workflow unpacks one cluster member; payload = stealer (M9)
- Link — trait cluster + stub fingerprint matches 3 prior campaigns (M15); operator build identified
Building Your Own Tool
Section titled “Building Your Own Tool”packgen.py — a detector-calibration corpus generator: applies controlled packing-shaped transformations to clean PEs (section name wiping, overlay junk, EP-adjacent section flag changes, resolver-only import emulation is left as an exercise — see notes) and emits a labeled corpus of clean-vs-mutated files. Its purpose is honest and specific: you cannot tune packer detectors without a corpus that contains both real packed files and benign files that look packed, and this tool manufactures the second population — the false-positive stress test most detection projects skip.
Design decisions
Section titled “Design decisions”- Transform, don’t fake. The tool mutates real clean PEs with real, reversible structural edits (pefile in, PE out) — not synthetic random files — so downstream detectors process genuinely parseable binaries.
- Label everything. Output filenames encode the transformation set (
orig_sha16__wipe+overlay.bin) so corpus ground truth survives any directory reshuffling. - One knob per transformation. Each mutation is independently togglable — the tuning workflow is “which trait causes my FP rate” and per-traction toggles answer it directly.
- No runnable-payload claims. The tool explicitly does NOT produce an executable packed binary (that requires a native stub); it produces detection-test shapes. The docstring says so; honesty in tooling is a design decision.
Full source
Section titled “Full source”#!/usr/bin/env python3"""packgen.py — packer-detector calibration corpus generator.Creates labeled structural variants of clean PEs for tuningpacker detectors. NOT an executable packer (no runtime stub).Usage: python packgen.py clean_dir out_dir --wipe-names --overlay 131072Deps: pefile."""import argparse, hashlib, os, sysimport pefile
NAME_POOL = [b"\x01\x9d\xfa", b".\x02\x03\x04", b"ZZZZZZZ", b"\x7f\x7f\x7f\x7f", b"data?", b"!@#$%^&"]
def wipe_names(pe): """Rewrite section names with non-standard byte junk.""" for i, s in enumerate(pe.sections): junk = NAME_POOL[i % len(NAME_POOL)] s.Name = junk.ljust(8, b"\x00")
def add_overlay(src_bytes, size, seed=0x41): """Append `size` bytes of pseudo-random (LCG) junk overlay.""" out = bytearray(src_bytes) x = seed for _ in range(size): x = (x * 1103515245 + 12345) & 0xFFFFFFFF out.append((x >> 16) & 0xFF) return bytes(out)
def bump_timestamps(pe): """Shift TimeDateStamp by a random delta (build-time noise).""" import random pe.FILE_HEADER.TimeDateStamp = ( pe.FILE_HEADER.TimeDateStamp + random.randint(1, 10**6) ) & 0xFFFFFFFF
TRANSFORMS = { "wipe": lambda pe, args: wipe_names(pe), "ts": lambda pe, args: bump_timestamps(pe),}
def main(): ap = argparse.ArgumentParser() ap.add_argument("src", help="dir of clean PE files") ap.add_argument("dst", help="output corpus dir") ap.add_argument("--overlay", type=int, default=0, help="overlay junk size in bytes") ap.add_argument("--wipe", action="store_true") ap.add_argument("--ts", action="store_true") ap.add_argument("--rich", action="store_true") args = ap.parse_args() if not os.path.isdir(args.src): sys.exit(f"not a dir: {args.src}") os.makedirs(args.dst, exist_ok=True) made = 0 for root, _, files in os.walk(args.src): for n in sorted(files): p = os.path.join(root, n) try: pe = pefile.PE(p, fast_load=True) except pefile.PEFormatError: continue data = open(p, "rb").read() active = [] for key, fn in TRANSFORMS.items(): if getattr(args, key): fn(pe, args) active.append(key) if active: pe.write(os.path.join(args.dst, "tmp_mut.bin")) data = open(os.path.join(args.dst, "tmp_mut.bin"), "rb").read() os.remove(os.path.join(args.dst, "tmp_mut.bin")) if args.rich: # rich header requires raw rewrite; do it directly off = data.find(b"Rich") if off != -1: end = data.find(b"\x00\x00\x00\x00", off) if end == -1 or end - off > 4096: end = off + 16 data = (data[:off] + b"\x00" * (end - off) + data[end:]) active.append("rich") if args.overlay > 0: data = add_overlay(data, args.overlay) active.append(f"overlay{args.overlay}") if not active: continue tag = "+".join(active) sha = hashlib.sha256(data).hexdigest()[:16] out = os.path.join(args.dst, f"{sha}__{tag}__{n}.bin") with open(out, "wb") as fh: fh.write(data) made += 1 print(f"[+] {n} -> {os.path.basename(out)}") print(f"\n[+] {made} calibration variants in {args.dst}") print("next: run your detector on src vs dst and measure " "the FP delta per transform tag")
if __name__ == "__main__": main()Walkthrough
Section titled “Walkthrough”wipe_names()(lines 18-21) writes non-standard byte junk into section-name fields — the exact trait the packstat extractor keys on — so a detector tuned only on entropy gets a chance to fail on names and learn.add_overlay()(lines 32-39) appends LCG junk past the last section’s raw extent, exercising the “large overlay” detector path with controlled sizes (--overlay 131072produces the >64 KB trait).- The Rich-header wipe (lines 78-88) is implemented as a direct raw-bytes edit —
pefiledoesn’t model the Rich region as writable, so the tool locates theRichmarker and zeroes to the next alignment boundary; attribution traits disappear while structure stays parseable. - The transform pipeline (lines 96-110) applies each enabled mutation to the in-memory PE, writes through a temp file (pefile’s
write()needs a path), then continues with raw transforms — the active-tags list becomes the output filename so every corpus file is self-describing ground truth. - The honest limitation (docstring lines 1-6, closing print lines 113-114): this is a detector-tuning corpus, not packed malware — the missing trait (resolver-only imports) requires a real import-table rebuild that this tool refuses to fake, and the report’s next-step line directs the measured FP delta per tag, which is the entire point.
Compilation and test procedure
Section titled “Compilation and test procedure”install deps and build the corpus from clean system binaries
Section titled “install deps and build the corpus from clean system binaries”$ pip install pefile $ mkdir -p clean corpus $ cp /mnt/win/C/Windows/System32/notepad.exe clean/ $ cp /mnt/win/C/Windows/System32/mspaint.exe clean/
generate variants: names wiped + 128KB overlay + rich wiped
Section titled “generate variants: names wiped + 128KB overlay + rich wiped”$ python3 packgen.py clean corpus —wipe —overlay 131072 —rich [+] notepad.exe -> a1b2…__overlay131072+rich+wipe__notepad.exe.bin [+] mspaint.exe -> c3d4…__overlay131072+rich+wipe__mspaint.exe.bin [+] 2 calibration variants in corpus
validate against the packer detector: the variants must trip
Section titled “validate against the packer detector: the variants must trip”the structural traits (that’s the FP stress test working)
Section titled “the structural traits (that’s the FP stress test working)”$ python3 ../m4/packed_hunter.py corpus/a1b2…__*.bin VERDICT : PACKED
and packstat must place originals and variants in clusters apart
Section titled “and packstat must place originals and variants in clusters apart”$ python3 packstat.py corpus —min-cluster 1
Detection considerations
Section titled “Detection considerations”The corpus workflow is the tool’s real contribution: most packer-detection projects tune on malware corpora only and discover their FP rate in production — generate the benign-looks-packed population first, then tune. Estate-scale detection combines the structural traits (packstat’s vector) with telemetry context: packed-traits files in C:\Windows\Temp, user AppData, or programdata-writable paths are categorically more interesting than the same traits in C:\Program Files, and YARA rules on packer stub fragments (M6) plus path context produce low-noise family-level alerts. Your own tooling hygiene: packstat/packgen are pure file readers — no execution, no network, invisible on endpoints. On the response side, remember the sandbox’s structural advantage: it observes post-unpack state, so sandbox-derived detections (M4’s chains) are packing-proof by construction, while static rules need the trait-vector treatment to survive wrapper rotation. Wazuh/Suricata have no layer here — packing is file-and-endpoint territory; EID 6/1/11 (driver, process, file-write) carry the telemetry around it [verify mapping per deployment].
Improvements to add
Section titled “Improvements to add”- Implement import-table reduction for real: write a minimal import rebuild (drop all but resolver set, add stub-thunk names) to complete the trait set — with the explicit caveat that output stays non-executable until a native stub exists.
- Add section-geometry transforms: raw-size-zero executable sections (the UPX0 shape) via pefile section table edits.
- Corpus packaging: emit a manifest JSON with per-file trait labels for automated detector benchmarking.
- Integration mode: pipe packstat clusters directly into YARA rule drafts (M6) for the top clusters.
- Add TLS-callback detection to the packstat vector (pre-EP execution trait) — data is already parsed by pefile, only the trait rule is missing.
Detection — Defender View
Section titled “Detection — Defender View”| Signal | Source | ID | Notes |
|---|---|---|---|
| Packed-traits file in writable path | packstat sweep / EDR | EID 11 + path context | Population triage anchor |
| Packer-family stub signature | YARA (M6) | file scan / EDR custom sig | Rotates; keep family rules fresh |
| Sandbox post-unpack behavior | Sandbox/EDR | vendor events | Packing-proof by construction |
| Driver-load by packed installer | Sysmon | EID 6 | Installers are the benign mass |
| Service install from packed binary | Windows Security | 7045 | Persistence via packed dropper |
| Execution from non-image memory | Sysmon/ETW | EID 10 anomalies [verify] | Stub unpack in private memory |
| Known-bad packer hash sets | TI feeds | file hash | Per-build; low shelf life |
| Estate trait-cluster delta | packstat scheduled run | internal report | New cluster = campaign-shaped |
| Wazuh file-integrity on dirs | HIDS | [verify rule IDs per deployment] | Dirs where payloads persist |
| Suricata executable downloads | NDR | custom/local sid:1000001+ | Wrapper delivery over HTTP only |
Evasion Techniques
Section titled “Evasion Techniques”Packer evolution is a co-evolution with the detection feature space, read best as responses: against entropy scoring, protectors keep compressed sections but pad with low-entropy sections or compress only part of the payload, pulling max-entropy under thresholds while retaining size wins; against import-sparsity heuristics, they ship a full-looking fake IAT of commonly imported functions resolved for real but unused, restoring the import profile; against section-geometry rules, they use legitimate-looking names (PAGE, .text) and normal raw sizes, moving the deception into content rather than structure; against stub signatures, polymorphic/mutating stubs regenerate the decompressor per build (byte-signature rules die weekly); against entry-point heuristics, multi-stage stubs bounce the OEP through relocated trampolines, or move execution into TLS callbacks before the EP, which EP-based tools never watch; against memory scanning, anti-dump wipes and re-encryption of dormant code pages (per-page XOR with rolling keys) reduce clean-code exposure windows. The defender’s counter-trend is structural: every evasion degrades something the attacker needs — fake IATs cost build complexity, partial compression costs size, mutation costs development velocity — and sandbox-plus-telemetry (post-unpack behavior) is immune to all static tricks by construction, which is why modern detection architecture puts static traits as fast pre-filters and behavioral telemetry as the decision layer rather than pretending static detection can carry the weight alone.
⚠️ What caught me: I built a packed-file detector on entropy + import count, validated it on malware corpora, and shipped it to the SOC feeling thorough — then the first week produced 400 alerts, all game-launchers and a designer’s encrypted asset pack, because legitimate packing is everywhere and my benign corpus was a fantasy. Rebuilding with packgen’s philosophy — manufacture the benign-looks-packed population first — cut FPs by 95% in an afternoon. The rule I keep since: detection thresholds are not derived from malware samples; they’re derived from the benign population you’re willing to disturb, and that corpus has to be real.
Lab Practice
Section titled “Lab Practice”- Pack a clean utility with UPX (
--best --lzma), then run both packstat and M4’s packed_hunter on original and packed versions; diff the feature vectors line by line and identify exactly which traits each tool keyed on. - Build the calibration corpus with packgen (wipe + overlay + rich), run your detector on it, and record the FP delta per transformation tag — then fix the weakest feature and re-measure.
- Reverse one UPX stub by hand in x64dbg: find the decompression loop, the IAT rebuild loop, and the final tail jump to the OEP; annotate each with its packstat trait — the packer pipeline embodied.
- Compare two different packers’ outputs (UPX vs a commercial protector trial, or two open-source packers) with packstat; observe which traits are shared (structural family) and which are unique (build fingerprint) — the M15 clustering logic in miniature.
- Schedule the estate sweep: run packstat over a realistic directory set (Program Files + user-writable paths), record the trait-cluster baseline, and write the delta-alert runbook — the population tripwire that survives wrapper rotation.
Interview Questions
Section titled “Interview Questions”- Technical: Walk through a packer’s pack-time pipeline end to end. Which stage produces the resolver-only import profile, and why is import handling the hard engineering problem?
- Technical: Why is max-section entropy alone a weak packed-file detector, and name three benign populations that defeat it.
- Scenario: packstat surfaces a new trait cluster of 12 files in user-writable directories. What do you check first, and what distinguishes campaign from noise?
- Conceptual: Explain the trade-off space between compression ratio, import emulation, and stub complexity in packer design.
- Conceptual: Why is sandbox-derived detection immune to packing while static detection is not, and what does the sandbox still miss?
- Deep-dive: Design an estate-scale packer fingerprint pipeline: extraction, clustering, baselining, and delta alerting — what refresh cadence keeps it honest?
- Practical: Your entropy-based detector generates 400 FPs/week from legitimate software. Rebuild the feature vector: which traits replace entropy as primary, and how do you validate the new threshold?
Related Modules
Section titled “Related Modules”M1 (Malware Classification) · M4 (Unpacking and Deobfuscation) · M6 (YARA Rule Engineering) · M15 (Threat Intelligence and Campaign Clustering) · E1 (AV Engine Internals)