#!/usr/bin/env python3 """Packer and protector detector for PE files. Identifies common packers/protectors by signature matching and entropy analysis. Uses both a signature database and heuristic detection methods. Usage: python3 packer_detector.py --file sample.exe python3 packer_detector.py --file sample.exe --output results.json python3 packer_detector.py --file sample.exe --verbose """ from __future__ import annotations import argparse import json import math import os import struct import sys from collections import OrderedDict from pathlib import Path # --------------------------------------------------------------------------- # Packer signature database # Each entry: (name, offset_type, offset, signature_bytes) # offset_type: "ep" = relative to entry point, "abs" = absolute file offset, # "section_name" = match section name, "overlay" = in overlay data # --------------------------------------------------------------------------- PACKER_SIGNATURES = [ # UPX ("UPX", "section_name", 0, b"UPX0"), ("UPX", "section_name", 0, b"UPX1"), ("UPX", "section_name", 0, b"UPX2"), ("UPX", "ep", 0, b"\x60\xBE"), # pushad; mov esi, ... ("UPX", "overlay", -0x20, b"UPX!"), # ASPack ("ASPack", "section_name", 0, b".aspack"), ("ASPack", "section_name", 0, b".adata"), ("ASPack", "ep", 0, b"\x60\xE8\x03\x00\x00\x00\xE9\xEB"), # Themida / WinLicense ("Themida", "section_name", 0, b".themida"), ("Themida", "section_name", 0, b".winlice"), ("Themida", "section_name", 0, b" "), # 8 spaces # VMProtect ("VMProtect", "section_name", 0, b".vmp0"), ("VMProtect", "section_name", 0, b".vmp1"), ("VMProtect", "section_name", 0, b".vmp2"), # PECompact ("PECompact", "section_name", 0, b"pec1"), ("PECompact", "section_name", 0, b"pec2"), ("PECompact", "section_name", 0, b"PEC2"), ("PECompact", "ep", 0, b"\xB8\x00\x00\x00\x00\x50\x64\xFF\x35"), # MPRESS ("MPRESS", "section_name", 0, b".MPRESS1"), ("MPRESS", "section_name", 0, b".MPRESS2"), # Enigma Protector ("Enigma Protector", "section_name", 0, b".enigma1"), ("Enigma Protector", "section_name", 0, b".enigma2"), # Obsidium ("Obsidium", "section_name", 0, b".obsidiu"), # Armadillo ("Armadillo", "abs", 0, b"\x60\xE8\x00\x00\x00\x00\x5D\x50\x51"), # MEW ("MEW", "section_name", 0, b"MEW"), ("MEW", "ep", 0, b"\xE9\x00\x00\x00\x00\x00\x00\x00\x00"), # NSPack ("NSPack", "section_name", 0, b".nsp0"), ("NSPack", "section_name", 0, b".nsp1"), ("NSPack", "section_name", 0, b".nsp2"), # PEtite ("PEtite", "section_name", 0, b".petite"), # FSG ("FSG", "ep", 0, b"\xBE\x00\x00\x00\x00\xAD\x93\xAD"), ("FSG", "ep", 0, b"\xBB\xD0\x01\x40\x00\xBF\x00\x10"), # Confuser / ConfuserEx (.NET) ("ConfuserEx", "section_name", 0, b"Confuser"), # .NET Reactor (".NET Reactor", "section_name", 0, b".reacto"), # Smart Assembly (.NET) ("SmartAssembly", "section_name", 0, b".sadata"), ] # String patterns to search for in the binary PACKER_STRINGS = [ ("UPX", [b"UPX!", b"$Info: This file is packed with the UPX"]), ("Themida", [b"Themida", b"WinLicense", b"Oreans Technologies"]), ("VMProtect", [b"VMProtect", b".vmp"]), ("ASPack", [b"ASPack"]), ("PECompact", [b"PECompact2", b"PECompact"]), ("Enigma Protector", [b"Enigma Protector", b"enigmaprotector"]), ("Armadillo", [b"Armadillo", b"Silicon Realms"]), ("Obsidium", [b"Obsidium"]), ("MPRESS", [b"MPRESS"]), ("NSPack", [b"NSPack", b"North Star"]), ("PEtite", [b"PEtite"]), ("Confuser", [b"Confuser", b"ConfuserEx"]), (".NET Reactor", [b".NET Reactor", b"Eziriz"]), ("SmartAssembly", [b"SmartAssembly", b"RedGate"]), ("Babel Obfuscator", [b"Babel"]), ("Dotfuscator", [b"Dotfuscator", b"PreEmptive"]), ] def calculate_entropy(data: bytes) -> float: """Calculate Shannon entropy of a byte sequence.""" if not data: return 0.0 byte_counts = [0] * 256 for byte in data: byte_counts[byte] += 1 length = len(data) entropy = 0.0 for count in byte_counts: if count > 0: p = count / length entropy -= p * math.log2(p) return entropy def read_pe_headers(filepath: str) -> dict: """Parse minimal PE headers without external dependencies. Returns a dict with keys: sections, entry_point, ep_file_offset, import_dir_rva, is_64bit, image_base, characteristics. Returns None if not a valid PE. """ result = {} try: with open(filepath, "rb") as f: # Check MZ signature mz = f.read(2) if mz != b"MZ": return None # Get PE header offset f.seek(0x3C) pe_offset = struct.unpack(" 0 and result["import_dir_size"] > 0: # Find which section contains the import directory for sec in sections: va = sec["virtual_address"] vs = max(sec["virtual_size"], sec["raw_size"]) if va <= result["import_dir_rva"] < va + vs: imp_file_off = sec["raw_offset"] + ( result["import_dir_rva"] - va) f.seek(imp_file_off) # Each import descriptor is 20 bytes, terminated by null while True: desc = f.read(20) if len(desc) < 20 or desc == b"\x00" * 20: break import_count += 1 break result["import_count"] = import_count return result except (OSError, struct.error, OverflowError): return None def check_section_name_signatures(sections: list) -> list: """Check section names against packer signature database.""" detections = [] for sec in sections: sec_name = sec["name"] for packer_name, sig_type, _, sig_bytes in PACKER_SIGNATURES: if sig_type != "section_name": continue # Section names are 8 bytes, null-padded padded_sig = sig_bytes.ljust(8, b"\x00") trimmed_name = sec_name.rstrip(b"\x00") trimmed_sig = sig_bytes.rstrip(b"\x00") if trimmed_name == trimmed_sig or sec_name == padded_sig: if packer_name not in detections: detections.append(packer_name) return detections def check_ep_signatures(filepath: str, ep_file_offset: int) -> list: """Check entry point bytes against packer signatures.""" detections = [] if ep_file_offset is None: return detections try: with open(filepath, "rb") as f: f.seek(ep_file_offset) ep_bytes = f.read(64) for packer_name, sig_type, offset, sig_bytes in PACKER_SIGNATURES: if sig_type != "ep": continue start = offset end = start + len(sig_bytes) if end <= len(ep_bytes) and ep_bytes[start:end] == sig_bytes: if packer_name not in detections: detections.append(packer_name) except OSError: pass return detections def check_string_signatures(filepath: str) -> list: """Scan file for packer-related string patterns.""" detections = [] try: with open(filepath, "rb") as f: data = f.read() for packer_name, patterns in PACKER_STRINGS: for pattern in patterns: if pattern in data: if packer_name not in detections: detections.append(packer_name) break except OSError: pass return detections def check_overlay_signatures(filepath: str) -> list: """Check overlay data for packer signatures.""" detections = [] try: file_size = os.path.getsize(filepath) with open(filepath, "rb") as f: # Read last 256 bytes for overlay checks read_size = min(256, file_size) f.seek(file_size - read_size) tail = f.read(read_size) for packer_name, sig_type, offset, sig_bytes in PACKER_SIGNATURES: if sig_type != "overlay": continue # offset is negative, relative to end pos = len(tail) + offset if 0 <= pos <= len(tail) - len(sig_bytes): if tail[pos:pos + len(sig_bytes)] == sig_bytes: if packer_name not in detections: detections.append(packer_name) except OSError: pass return detections def analyze_sections_entropy(filepath: str, sections: list) -> list: """Calculate per-section entropy.""" section_info = [] try: with open(filepath, "rb") as f: for sec in sections: name = sec["name"].rstrip(b"\x00").decode("ascii", errors="replace") if sec["raw_size"] > 0: f.seek(sec["raw_offset"]) data = f.read(sec["raw_size"]) entropy = calculate_entropy(data) else: entropy = 0.0 section_info.append({ "name": name, "entropy": round(entropy, 4), "raw_size": sec["raw_size"], "virtual_size": sec["virtual_size"], "characteristics": f"0x{sec['characteristics']:08X}", }) except OSError: pass return section_info def heuristic_analysis(filepath: str, pe_info: dict, section_info: list) -> list: """Apply heuristic rules to detect packing indicators.""" indicators = [] file_size = os.path.getsize(filepath) # Check overall entropy with open(filepath, "rb") as f: overall_data = f.read() overall_entropy = calculate_entropy(overall_data) if overall_entropy > 7.0: indicators.append(f"High overall entropy ({overall_entropy:.2f}) suggests packing/encryption") elif overall_entropy > 6.8: indicators.append(f"Elevated overall entropy ({overall_entropy:.2f}) may indicate partial packing") # Check for sections with very high entropy for sec in section_info: if sec["entropy"] > 7.5 and sec["raw_size"] > 1024: indicators.append( f"Section '{sec['name']}' has very high entropy ({sec['entropy']:.2f})") # Check for empty/zeroed sections (common in packed binaries) for sec in section_info: if sec["raw_size"] == 0 and sec["virtual_size"] > 0: indicators.append( f"Section '{sec['name']}' has zero raw size but virtual size " f"{sec['virtual_size']} (unpacking target)") # Check import count import_count = pe_info.get("import_count", 0) if import_count <= 3 and import_count > 0: indicators.append(f"Very few imports ({import_count}) - typical of packed binaries") elif import_count == 0: indicators.append("No imports found - binary may be packed or use dynamic resolution") # Check for unusual section names standard_names = {".text", ".rdata", ".data", ".rsrc", ".reloc", ".bss", ".idata", ".edata", ".pdata", ".tls", ".CRT", "CODE", "DATA", ".code", ".data"} for sec in section_info: name = sec["name"].strip() if name and name not in standard_names: indicators.append(f"Non-standard section name: '{name}'") # Check for section with both write and execute permissions for sec_raw, sec_analyzed in zip(pe_info["sections"], section_info): chars = sec_raw["characteristics"] writable = bool(chars & 0x80000000) executable = bool(chars & 0x20000000) if writable and executable: indicators.append( f"Section '{sec_analyzed['name']}' is both writable and " f"executable (W+X) - common in packed binaries") # Check ratio of code to file size code_size = sum(s["raw_size"] for s in section_info if s["name"] in (".text", "CODE", ".code")) if file_size > 0 and code_size > 0: ratio = code_size / file_size if ratio < 0.1: indicators.append( f"Small code-to-file ratio ({ratio:.1%}) suggests packed content") return indicators, overall_entropy def detect_packer(filepath: str, verbose: bool = False) -> dict: """Run full packer detection analysis on a file.""" result = OrderedDict() result["file"] = os.path.basename(filepath) result["file_size"] = os.path.getsize(filepath) pe_info = read_pe_headers(filepath) if pe_info is None: # Not a PE file - do basic entropy analysis only with open(filepath, "rb") as f: data = f.read() overall_entropy = calculate_entropy(data) result["overall_entropy"] = round(overall_entropy, 4) result["likely_packed"] = overall_entropy > 7.0 result["pe_file"] = False result["packer_signatures"] = check_string_signatures(filepath) result["suspicious_indicators"] = [] if overall_entropy > 7.0: result["suspicious_indicators"].append( f"High entropy ({overall_entropy:.2f})") return result result["pe_file"] = True result["is_64bit"] = pe_info["is_64bit"] result["entry_point"] = f"0x{pe_info['entry_point']:08X}" result["import_count"] = pe_info["import_count"] # Section analysis section_info = analyze_sections_entropy(filepath, pe_info["sections"]) result["sections"] = section_info # Signature-based detection all_detections = set() sec_detections = check_section_name_signatures(pe_info["sections"]) all_detections.update(sec_detections) ep_detections = check_ep_signatures(filepath, pe_info.get("ep_file_offset")) all_detections.update(ep_detections) string_detections = check_string_signatures(filepath) all_detections.update(string_detections) overlay_detections = check_overlay_signatures(filepath) all_detections.update(overlay_detections) result["packer_signatures"] = sorted(all_detections) # Heuristic analysis indicators, overall_entropy = heuristic_analysis(filepath, pe_info, section_info) result["overall_entropy"] = round(overall_entropy, 4) result["suspicious_indicators"] = indicators # Overall verdict result["likely_packed"] = bool( all_detections or overall_entropy > 7.0 or len(indicators) >= 3 ) if verbose: result["detection_details"] = { "section_name_matches": sec_detections, "entry_point_matches": ep_detections, "string_matches": string_detections, "overlay_matches": overlay_detections, } return result def main() -> None: parser = argparse.ArgumentParser( description="Detect packers and protectors in executable files", formatter_class=argparse.RawDescriptionHelpFormatter, epilog=""" Examples: %(prog)s --file sample.exe %(prog)s --file sample.exe --output results.json %(prog)s --file sample.exe --verbose """, ) parser.add_argument("--input", "--file", "-f", dest="file", required=True, help="Path to the file to analyze") parser.add_argument("--output", "-o", help="Output file path (JSON format)") parser.add_argument("--verbose", "-v", action="store_true", help="Include detailed detection information") parser.add_argument("--json", action="store_true", help="Output raw JSON (no formatting)") parser.add_argument("--format", default="json", choices=["json", "text", "csv"], help="Output format (default: json)") args = parser.parse_args() if not os.path.isfile(args.file): print(f"Error: File not found: {args.file}", file=sys.stderr) sys.exit(1) result = detect_packer(args.file, verbose=args.verbose) if args.json: output = json.dumps(result) else: output = json.dumps(result, indent=2) if args.output: with open(args.output, "w") as f: f.write(output + "\n") print(f"Results written to {args.output}") else: print(output) # Exit code: 0 = not packed, 1 = likely packed sys.exit(1 if result.get("likely_packed") else 0) if __name__ == "__main__": main()