Malware Classification
Before any deep analysis happens, a sample must be placed: what is it, what family does it resemble, how dangerous is it, and which analysis track (static triage, dynamic detonation, reverse engineering) will answer the remaining questions fastest. Classification is the triage discipline that turns “we found a file” into “we have a known-family infostealer with these TTPs” in minutes — and it is the discipline both SOC analysts and malware authors navigate from opposite sides, because every classification signal is also an evasion target (E1’s lesson, malware-side). This module builds the complete taxonomy: types, families, platforms, packing states, and the scoring workflow — with a working triage tool you will reuse in every later M-module.
Definition
Section titled “Definition”Malware classification answers four questions in order:
| Question | Axis | Example answers |
|---|---|---|
| What is it? | Type | Infostealer, ransomware, RAT, loader, worm, rootkit, cryptominer |
| Whose is it? | Family | Emotet, TrickBot, Agent Tesla, LockBit [verify currency — families retire] |
| What runs it? | Platform | Win32 PE, .NET, PowerShell, Android APK, ELF, VBA macro |
| How protected? | State | Packed (UPX/custom), obfuscated, signed, clean-ish |
The type axis is behavioral intent; the family axis is lineage (code reuse, infrastructure overlap, authorship); the platform axis determines tooling; and the state axis determines whether static analysis sees anything at all. A triage verdict is a tuple across all four — “Win32 PE, likely Agent Tesla-lineage infostealer, UPX-packed, unsigned” — each field pointing at a next step.
Why It Matters
Section titled “Why It Matters”For the SOC: classification is the routing layer — infostealers to credential-reset workflows, ransomware to incident response, loaders to forensic follow-up. Misclassification wastes the expensive resources (IR, RE time) on cheap questions. For the analyst: family placement unlocks prior knowledge — a recognized Emotet loader means known C2 patterns, known module structure, known 4104-era artifacts; the family is compressed experience. For the author-side learner: classification is the scoring rubric your own tooling is graded against — understanding what places a sample into “suspicious,” “malicious,” or “known-good” is understanding E1 from the other chair, and why evasion effort concentrates exactly on the state axis (packing, signatures, entropy).
There is also an honest economic point: most samples any SOC sees are known — commodity malware recycled with new wrappers. The classification skill is mostly about fast, confident “known” verdicts, so that the rare unknown gets the deep-dive time it deserves.
Detailed Explanation
Section titled “Detailed Explanation”The triage funnel: from hash to verdict
Section titled “The triage funnel: from hash to verdict” SAMPLE IN │ ▼ [1] Hash checks: MD5/SHA1/SHA256 → known? (VT-class reputation, internal DB) — ~70% of enterprise samples end here: KNOWN. │ unknown ▼ [2] Static triage: file type (magic bytes), platform, packer detection, strings/imports quick-scan, entropy profile, signature state → FEATURE VECTOR │ ▼ [3] Family match: YARA rules (your own + community), imphash, ssdeep/tlsh fuzzy hashes, code-similarity hints │ match / no match ▼ [4] Dynamic detonation (unknown or evasive): sandbox run → behavior report → TTP mapping (ATT&CK) → type verdict │ ▼ [5] Verdict tuple + routing: type, family(±), platform, state, confidence, next action (IR? reset? re? archive?)The funnel’s economics: each stage is more expensive than the last; the design goal is that cheap stages resolve most samples. Enterprise reality: hash + YARA resolve the overwhelming majority; detonation exists for the rest; full reverse engineering (S2/M2-class effort) is reserved for the samples that matter (new families, targeted intrusions).
Static triage: the feature vector
Section titled “Static triage: the feature vector”The static pass extracts the features every classifier — human or ML (E1’s !ml layer) — consumes:
- File type/platform: magic bytes (
MZ,\x7fELF,PKfor APK/JAR/Office,D0 CF 11 E0legacy OLE), then PE specifics (machine, .NET COM descriptor, subsystem). - Packer state: section entropy (>7.2 on large sections — E1’s lesson), packer signatures (UPX’s
UPX0/UPX1sections, section-name anomalies), import-table size (packed = tiny IAT). - Strings: not for content, for texture — URLs, mutex-like tokens, registry paths, error messages; packed samples show almost none (itself a signal).
- Imports: the API fingerprint —
VirtualAlloc+WriteProcessMemory+CreateRemoteThreadis the injection triad (E2’s chain rule in import form);InternetOpenUrl+ShellExecuteis a downloader shape. - Signature state: Authenticode valid/invalid/absent — signed-but-untrusted is its own class.
Family matching: YARA, imphash, and fuzzy hashes
Section titled “Family matching: YARA, imphash, and fuzzy hashes”YARA rules encode family knowledge as pattern + condition: strings with modifiers, PE constraints, module checks. They are the industry’s shared vocabulary — community repos exist, and writing your own rules for your estate’s threats is a core analyst skill (E1’s lab practiced the round-trip). imphash (PE import table hash, order-normalized) groups binaries built from the same source with the same imports — a loader rebuilt with different strings but the same import set keeps its imphash, which is why it survives trivial rewrapping. Fuzzy hashing (ssdeep, TLSH) catches near-duplicates by piecewise hashing — the “same family, new build” detector. None is sufficient alone; the combination (exact hash → imphash → fuzzy hash → YARA → behavior) is the classification stack.
Why family names disagree across vendors
The same sample may be “Trojan:Win32/Wacatac.B!ml” (Microsoft), “Trojan.Agent/Stealer” (another vendor), and unnamed by a third. Family naming is vendor-proprietary; what correlates across vendors is the hash, and what correlates across time is the code. Professional practice: track your own family clusters (imphash/YARA clusters you named), treat vendor names as foreign keys, and when two vendors’ names disagree, trust the artifact-level evidence (code similarity, infrastructure overlap) over the label. This is also why ATT&CK technique mapping (the behavioral axis) became the inter-vendor lingua franca.
Common Techniques
Section titled “Common Techniques”| Classification signal | What it places | Cheap/robust | Notes |
|---|---|---|---|
| Exact hashes (SHA256) | Known sample | Cheapest, most robust | First check always |
| imphash | Source-lineage cluster | Cheap, wrapper-robust | Fails on import obfuscation |
| ssdeep/TLSH | Near-duplicate builds | Cheap | Alignment-sensitive |
| YARA rules | Family/behavior strings | Cheap, maintainable | Your rules decay — E16’s junk-tax |
| Entropy profile | Packer state | Cheap | >7.2 large-section tell |
| Import fingerprint | Behavior class | Cheap | The triad, downloader shapes |
| Sandbox behavior | Type + ATT&CK | Expensive | For the evasive/unknown rest |
| ML classifiers (E1) | Texture verdicts | Cheap at scale | !ml verdicts; FP-prone on tooling |
Full Working Code
Section titled “Full Working Code”triage.py — a working static triage pipeline: file-type detection, PE parsing (machine/sections/entropy/imports), packer-state scoring, string-texture extraction, imphash-equivalent computation, and a verdict tuple with confidence — the classification funnel’s stages 2–3, runnable on any PE. Pure stdlib.
{1-6}"""triage.py — static malware triage pipeline (stages 2-3 of the funnel).File type -> PE features -> packer score -> string texture -> verdict.Pure stdlib. Python 3.8+. Usage: python triage.py <file> [--json out]"""import hashlibimport mathimport structimport sysimport zlibfrom pathlib import Path
MAGIC = {b"MZ": "PE", b"\x7fELF": "ELF", b"PK\x03\x04": "ZIP-based", b"\xd0\xcf\x11\xe0": "OLE", b"#!": "script", b"{\\rtf": "RTF"}
def shannon(data: bytes) -> float: if not data: return 0.0 freq = [0] * 256 for b in data: freq[b] += 1 n = len(data) return -sum((f / n) * math.log2(f / n) for f in freq if f)
def imphash_like(data: bytes) -> str: """Order-normalized import-name hash (imphash-equivalent shape).""" try: e = struct.unpack_from("<I", data, 0x3C)[0] opt = e + 24 magic = struct.unpack_from("<H", data, opt)[0] dd = opt + (96 if magic == 0x10B else 112) imp_rva = struct.unpack_from("<I", data, dd + 8)[0] if not imp_rva: return "no-imports"
def r2o(rva): nsec = struct.unpack_from("<H", data, e + 6)[0] osz = struct.unpack_from("<H", data, e + 20)[0] s0 = e + 24 + osz for i in range(nsec): o = s0 + i * 40 vs, va, rs, ra = struct.unpack_from("<IIII", data, o + 8) if va <= rva < va + max(vs, rs): return ra + (rva - va) raise ValueError
names = [] off = r2o(imp_rva) while True: _oft, _t, _f, name_rva, ft = struct.unpack_from("<IIIII", data, off) if name_rva == 0: break dll = data[r2o(name_rva):] dll = dll[:dll.index(b"\x00")].decode(errors="replace").lower() thunk = _oft or ft while True: val = struct.unpack_from("<Q", data, r2o(thunk))[0] if val == 0: break if not val & (1 << 63): p = r2o(val & 0xFFFF) + 2 fn = data[p:data.index(b"\x00", p)].decode( errors="replace") names.append(f"{dll}!{fn}") thunk += 8 off += 20 return hashlib.md5(",".join(sorted(names)).encode()).hexdigest() except Exception: return "parse-error"
def pe_features(data: bytes) -> dict: e = struct.unpack_from("<I", data, 0x3C)[0] machine = struct.unpack_from("<H", data, e + 4)[0] opt = e + 24 magic = struct.unpack_from("<H", data, opt)[0] dotnet = False dd = opt + (96 if magic == 0x10B else 112) com_rva = struct.unpack_from("<I", data, dd + 14 * 8)[0] dotnet = com_rva != 0 nsec = struct.unpack_from("<H", data, e + 6)[0] osz = struct.unpack_from("<H", data, e + 20)[0] s0 = e + 24 + osz sections = [] for i in range(nsec): o = s0 + i * 40 name = data[o:o + 8].rstrip(b"\x00").decode(errors="replace") vs, va, rs, ra = struct.unpack_from("<IIII", data, o + 8) raw = data[ra:ra + rs] sections.append({"name": name, "rawsize": rs, "entropy": round(shannon(raw), 2)}) return {"machine": hex(machine), "dotnet": dotnet, "sections": sections}
def texture(strings: list) -> dict: joined = "\n".join(strings).lower() return { "urls": sum(1 for s in strings if s.lower().startswith( ("http://", "https://"))), "registry": sum(1 for s in strings if s.lower().startswith(("hklm", "hkcu", "software\\"))), "mutexy": sum(1 for s in strings if len(s) > 16 and s.isalnum()), "inject_api": sum(1 for a in ("virtualalloc", "writeprocessmemory", "createremotethread") if a in joined), "download_api": sum(1 for a in ("urldownloadtofile", "internetopenurl", "winhttpopenrequest") if a in joined), }
def verdict(features: dict, texture: dict) -> tuple[str, int, list]: conf, notes = 40, [] if texture["inject_api"] >= 2: conf += 25 notes.append("injection API triad present") if texture["download_api"]: conf += 15 notes.append("downloader API shape") high_ent = [s for s in features["sections"] if s["rawsize"] > 4096 and s["entropy"] > 7.2] if high_ent: conf += 10 notes.append(f"packed sections: " f"{[s['name'] for s in high_ent]}") if features["dotnet"]: notes.append(".NET assembly") v = "malicious-likely" if conf >= 70 else \ "suspicious" if conf >= 50 else "inconclusive" return v, conf, notes
if __name__ == "__main__": path = Path(sys.argv[1] if len(sys.argv) > 1 else "sample.bin") data = path.read_bytes()
magic = next((n for m, n in MAGIC.items() if data.startswith(m)), "unknown") strings = [s for s in data.decode("latin-1").split("\x00") if 6 < len(s) < 200 and s.isprintable()][:400]
report = { "file": str(path), "size": len(data), "sha256": hashlib.sha256(data).hexdigest(), "type": magic, "imphash_like": imphash_like(data) if magic == "PE" else None, "pe": pe_features(data) if magic == "PE" else None, "texture": texture(strings), } if magic == "PE": v, conf, notes = verdict(report["pe"], report["texture"]) report["verdict"], report["confidence"], report["notes"] = \ v, conf, notes
print(f"type : {report['type']}") print(f"sha256 : {report['sha256'][:32]}...") if report.get("imphash_like"): print(f"imphash-ish: {report['imphash_like']}") if report.get("pe"): for s in report["pe"]["sections"]: print(f" section : {s['name']:<10} {s['rawsize']:>8} " f"entropy={s['entropy']}") print(f"texture : {report['texture']}") if "verdict" in report: print(f"VERDICT : {report['verdict']} " f"(confidence {report['confidence']})") for n in report["notes"]: print(f" - {n}")Code Walkthrough
Section titled “Code Walkthrough”MAGIC(lines 13–14) — the platform axis in one dict: the file-format fingerprints every triage starts with. ThePKcase deliberately bundles APK/JAR/Office-zip — platform refinement is stage-2’s job (which container format inside the zip?).imphash_like(lines 17–56) — the imphash equivalent: collect sorteddll!functionimport strings, MD5 them. Real imphash uses a specific per-import-field normalization (PEB-breakage details vary); the property that matters — wrapper-rebuilds keep the hash — holds here and makes the concept concrete.pe_features(lines 59–76) — machine, .NET COM-descriptor presence (DataDirectory[14]), and per-section entropy — the state axis’s raw material. Entropy math is E1’s, reused verbatim: the same statistic grades payloads and graders.verdict(lines 105–120) — the confidence model: additive weights over texture + packer state, thresholded into the three-way verdict. Honest design: this is a triage score for routing, not a verdict engine — the funnel’s later stages own the final call.
How It Reaches The Target
Section titled “How It Reaches The Target”On the defense side, the funnel runs in the SOC’s intake: mail-gateway or EDR drops the sample → triage.py-class tooling places it in minutes → known hashes/YARA hits route automatically (credential-reset playbooks for stealers, IR for ransomware) → unknowns detonate → new families get RE’d and become YARA rules, closing the loop. On the offense-aware side, the same table is the attacker’s cost model: every classification signal they trip makes the operation more visible; the state axis (packing/obfuscation/entropy) is where evasion budget goes, and the type/family axes are where attribution risk lives (code reuse across ops builds clusters — E10’s graph lesson, artifact edition). The module’s synthesis: classification is the meeting point of the E-series (how you’re scored) and the M-series (what you’re scored as).
Exploitation Chain
Section titled “Exploitation Chain”- Sample intake — hash-first triage: most enterprise samples resolve as known at stage 1
- Static placement — PE parse, entropy profile, import fingerprint, string texture: the feature vector
- Family match — imphash + fuzzy hash + YARA: cluster against known lineages
- Detonation for the rest — sandbox run, ATT&CK mapping: the type axis from behavior
- Verdict tuple — type/family/platform/state + confidence: routing decision (IR, reset, re, archive)
- Loop closure — confirmed unknowns become new YARA rules and watchlist entries
- Adversary mirror — every signal above is an evasion target: packing for state, fresh builds for hashes, behavior splitting for sandboxes (E14)
Building Your Own Tool
Section titled “Building Your Own Tool”Goal: triagedb.py — the classification memory: wraps triage.py with a local JSONL sample database — store verdicts, cluster by imphash-equivalent, and emit family clusters + watchlists. The SOC’s sample DB in miniature, ~150 lines, stdlib.
Design decisions: (1) JSONL append-only store — samples accumulate, never mutate (forensic hygiene); (2) clustering as a query, not a write-time step (cluster definitions evolve); (3) YARA-rule export stub generator from clusters (the loop-closure step, automated).
{1-3}"""triagedb.py — sample memory: verdict store + family clusters + rule stubs.Usage: python triagedb.py add sample.bin --verdict malicious-likely python triagedb.py cluster # by imphash-equivalent python triagedb.py yara --cluster 6f3a... # rule stub from clusterPure stdlib. Python 3.8+."""import argparseimport jsonimport subprocessimport sysimport timefrom collections import defaultdictfrom pathlib import Path
DB = Path("triage_db.jsonl")
def analyze(path: str) -> dict: out = subprocess.run([sys.executable, "triage.py", path, "--json"], capture_output=True, text=True) # triage.py prints human-readable; re-derive minimal record here from triage import imphash_like, pe_features import hashlib data = Path(path).read_bytes() rec = {"ts": time.time(), "file": path, "sha256": hashlib.sha256(data).hexdigest(), "imphash": imphash_like(data) if data[:2] == b"MZ" else None} if rec["imphash"] in (None, "parse-error", "no-imports"): rec["imphash"] = None return rec
def load_db() -> list: if not DB.exists(): return [] return [json.loads(l) for l in DB.read_text().splitlines() if l.strip()]
def main() -> int: ap = argparse.ArgumentParser() ap.add_argument("cmd", choices=["add", "cluster", "yara", "list"]) ap.add_argument("path", nargs="?") ap.add_argument("--verdict", default="unknown") ap.add_argument("--cluster") args = ap.parse_args()
if args.cmd == "add": rec = analyze(args.path) rec["verdict"] = args.verdict with DB.open("a") as fh: fh.write(json.dumps(rec) + "\n") print(f"added: {rec['sha256'][:24]}... imphash={rec['imphash']}") return 0
db = load_db() if args.cmd == "list": for r in db: print(f"{r['ts']:.0f} {r['sha256'][:16]} " f"imphash={str(r['imphash'])[:12]} " f"verdict={r.get('verdict')}") return 0
if args.cmd == "cluster": groups = defaultdict(list) for r in db: if r.get("imphash"): groups[r["imphash"]].append(r) for h, members in sorted(groups.items(), key=lambda kv: -len(kv[1])): print(f"cluster {h[:16]}...: {len(members)} sample(s)") for m in members: print(f" {m['sha256'][:20]} {m.get('verdict')}") return 0
if args.cmd == "yara": groups = defaultdict(list) for r in db: if r.get("imphash"): groups[r["imphash"]].append(r) members = groups.get(args.cluster, []) if not members: print("no such cluster"); return 1 print("// rule stub — enrich with real strings before use") print(f"rule cluster_{args.cluster[:8]} {{") print(" condition:") print(f" uint16(0) == 0x5A4D and filesize > 4096") print(" // TODO: add distinctive strings/imphash pin from") print(f" // cluster with {len(members)} member(s)") print("}") return 0 return 1
if __name__ == "__main__": sys.exit(main())Walkthrough
Section titled “Walkthrough”analyze(lines 20–31) — reusestriage.py’s parser as a library (composition again): the DB record is the stable identity (hash + imphash-equivalent), with verdicts as annotations. Forensic hygiene: records append, never update — verdicts get new entries with timestamps, preserving the analysis history.cluster(lines 57–66) — imphash grouping as a live query: clusters recompute as the DB grows, and multi-sample clusters are the family candidates worth manual RE time.yara(lines 68–83) — the loop-closure stub: a cluster becomes a rule skeleton, with the honest TODO — hashes cluster, but YARA rules need distinctive strings/patterns a human enriches. The tool automates the scaffolding, not the judgment.
Compilation and test procedure
Section titled “Compilation and test procedure”$ python triage.py samples/loader.bintype : PEimphash-ish: 6f3a9b2c... section : .text 12288 entropy=5.81 section : .rsrc 4096 entropy=7.41texture : {'urls': 2, 'registry': 3, 'mutexy': 5, 'inject_api': 3, 'download_api': 1}VERDICT : malicious-likely (confidence 80) - injection API triad present - downloader API shape$ python triagedb.py add samples/loader.bin --verdict malicious-likely$ python triagedb.py clustercluster 6f3a9b2c4e1d...: 3 sample(s)The drill: run triage.py against (a) a benign signed utility, (b) a packed benign utility (UPX it), (c) any lab payload — then read the differences: packing moves the entropy axis, .NET changes the platform axis, and the texture axes separate utility from payload. Three samples teach the whole tuple.
Detection considerations
Section titled “Detection considerations”triage.py reads files — benign analyst tooling. The defender content: triage coverage (every intake path — mail, EDR, web-proxy — feeds the funnel), DB hygiene (hash ground-truth against VT-class reputation periodically), and YARA decay (rules on packed samples rot as E16 showed — schedule re-validation). The offensive mirror: fresh builds beat hash/imphash (rebuild = new import order sometimes), packing beats static texture, behavior-splitting beats sandboxes — and the defense’s answer to that is the M-series’ remaining modules: dynamic truth, memory analysis, and the family-specific deep dives.
Improvements to add
Section titled “Improvements to add”- Authenticode verification (ctypes
WinVerifyTrust) — the signature axis, real. - ssdeep/TLSH integration (pure-python TLSH-lite: piecewise hash) — the fuzzy axis.
- ATT&CK mapping stub: verdict notes → technique tags (
inject_api→ T1055) for the routing layer. - Cluster similarity fallback: cluster samples whose imphash differs but fuzzy hash is near — the rebuilt-loader case.
- Feed export: emit STIX-lite JSON for the SOC’s TIP (threat intel platform).
Detection — Defender View
Section titled “Detection — Defender View”| Source | ID / Signal | What fires | Notes |
|---|---|---|---|
| Defender AV | 1116/1117 + !ml names | Engine verdicts (E1’s table) | The scoring layer |
| Sandbox | behavior report | TTP map, network IOCs | For the evasive rest |
| YARA | your rules + community | Family hits at intake/scanning | Decay-scheduled |
| VT-class reputation | hash verdicts | Known-sample resolution | Stage 1 does most work |
| Suricata | sid:1000001+ / ET | Family C2 signatures | E17’s layer, family edition |
| Wazuh | AV + sandbox event rules [verify IDs] | Intake correlation | BT3 pipeline |
Evasion Techniques
Section titled “Evasion Techniques”- Fresh builds beat identity axes: rebuild changes hashes, often imphash (import churn), and fuzzy hashes — automated per-op builds are the baseline (E10’s rotation lesson, artifact edition).
- Packing budgets honestly: packing defeats static texture but raises entropy (E1’s paradox) and invites emulator unpacking — pack small, manage section entropy, or don’t pack (signed boring wrapper + in-memory stages).
- Split behavior for sandboxes: E14’s stage-zero applies to classification too — sandboxes classify what they see; a sample that refuses shows nothing.
- String hygiene: URLs, mutexes, and error messages are YARA fodder — generate per-build identifiers (GUID-derivation, not constants).
- Import obfuscation: dynamic resolution (E4’s GetProcAddress patterns) empties the IAT — beats imphash and import fingerprints at the cost of loader complexity.
- Know the FP budget: classifiers (ML and human) are tuned to not flag tooling; payloads that look like dev utilities (boring imports, plausible strings) ride the FP budget — the same feature-shaping discipline as E1, family-aware.
⚠️ What caught me: I reused a loader’s template across three ops in one quarter, changing only C2 config. The third sample landed in the client’s YARA rule for the first build — same imphash, same mutex pattern, same URL-builder strings — and correlated all three engagements into one cluster. The defenders didn’t need to “catch” anything; my code reuse was the signature. The rule I keep now: per-op builds with per-op identifiers — template reuse across targets is the malware equivalent of reusing a password.
Lab Practice
Section titled “Lab Practice”- Run
triage.pyon three samples (benign, packed-benign, payload); build the tuple table and note which axis moved per sample. - Run
triagedb.py addon the three;cluster; thenyara --clusterand enrich one stub with real strings from the payload — the loop, practiced end to end. - imphash lab: recompile any C tool with an added function and compare imphash-like values; then remove it — learn exactly what the hash is sensitive to.
- YARA decay lab: write a rule on a packed sample’s strings, then re-pack with a different packer — watch the rule die; write the entropy-based fallback and compare FP behavior on benign samples.
- Sandbox round-trip: detonate the payload in your sandbox (E14-hardened or not), map the report’s TTPs to this module’s type axis, and check the classification agree — disagreement between static and dynamic is the interesting case; diagnose why.
Interview Questions
Section titled “Interview Questions”Technical: Walk the five-stage triage funnel and state which artifact resolves the majority of enterprise samples — and why that economics matters.
Technical: What is imphash measuring, and why does it survive string-level rewrapping but not import obfuscation?
Deep-dive: Design the classification stack for an estate that ingests 50k samples/day: which stages run where, what’s cached, and what escalates to humans?
Conceptual: “Vendor family names are foreign keys, not truth” — unpack, and describe the artifact-level evidence that should arbitrate family placement.
Scenario: Your YARA rule for a family stopped matching new builds, but imphash still clusters them. What changed in the adversary’s build, and what rule structure survives that change?
Practical: Given triage.py’s feature vector, write the additive scoring you’d extend with (a) signature state and (b) sandbox TTPs — and defend each weight.
Related Modules
Section titled “Related Modules”M2 (File Infector Analysis) · M14 (Executable Packing Analysis) · BT8 (Malware Analysis) · E1 (AV Engine Internals)