Threat Intelligence and Campaign Clustering
Definition
Section titled “Definition”Threat intelligence is the discipline of converting individual analysis findings — one sample, one incident, one indicator — into reusable, linked knowledge that improves every future decision faster than attackers rotate their infrastructure. Campaign clustering is its core analytic method: grouping samples, incidents, and infrastructure into coherent operations (campaigns) using features that are stable across builds rather than features that rotate with every compile. The craft has three layers: pivoting (following an artifact — a hash, a string, a certificate, a protocol quirk — to everything else that shares it), clustering (weighting and joining artifacts into groups with quantified confidence), and production (packaging the result so machines and analysts can consume it — feeds, platforms, retrohunt queries, detection rules). Intelligence that stays in one analyst’s head is a diary; intelligence in a linked, queryable structure is force multiplication.
Why It Matters
Section titled “Why It Matters”Analysis at the single-sample level does not scale against adversaries who ship recompiled builds daily — every hash-based IOC is dead on arrival, and the analyst who chases hashes chases rotation forever. Clustering escapes that treadmill by anchoring on what persists: the mutex the family always creates, the protocol template the implant always speaks, the PDB path pattern, the DGA seed, the certificate the operator keeps reusing. Once samples cluster, the analyst inherits everything known about the cluster — prior analyses, victimology, infrastructure overlaps, hunt rules — instead of starting from zero; and the defender inherits detections that survive rotation because they anchor on the cluster’s stable features, not any build’s bytes. For the organization, intelligence maturity shows up as speed with memory: the next incident is triaged in minutes because the cluster is already characterized, and the report writes itself from the linked record. For the community, well-structured intelligence is what makes sharing real — indicator feeds full of rotating hashes have near-zero marginal value, while a characterized cluster (behavior, infrastructure pattern, stable detections) is actionable the moment a partner sees one new sample. This module is where the entire M-series converges: every extraction tool (M5), every protocol decode (M7), every architecture map (M11), every delivery chain (M13) produces exactly the artifacts this module joins.
Detailed Explanation
Section titled “Detailed Explanation”Artifacts differ enormously in stability, and the whole discipline is anchored on that hierarchy:
ARTIFACT STABILITY HIERARCHY (lifetime of an IOC class) hash (MD5/SHA) ~1 build seconds-days IP address days-weeks rotation by design domain weeks rotation by design URI path / UA string weeks-months protocol template mutex / event names months-years family trait PDB path patterns months-years build habit DGA algorithm + seed months-years family trait protocol fingerprints years hardest to change TTPs / tradecraft years last thing operators drop
CLUSTERING MECHANICS sample A ──(imphash)── sample B ──(mutex)── sample C | | (cert fingerprint) (URI template + timing) | | infra D ←──────── campaign cluster ────→ infra E confidence = Σ weights(feature matches), decayed by timePivoting is breadth-first search over an artifact graph: start at one sample, extract every artifact, look up each artifact in your corpora (sample collections, passive DNS, certificate transparency, sinkhole logs, retrohunt results), collect the neighbors, and recurse — with the stability hierarchy as your steering wheel, because pivoting on a hash gives you the same build and pivoting on a mutex gives you the family. Clustering is where discipline enters: features are weighted (protocol fingerprint > mutex > PDB pattern > imphash > infrastructure), matches are summed into a similarity score, and links below a confidence floor are not drawn — the most common clustering failure is over-linking, where shared third-party hosting or a popular library welds unrelated activity into one fake mega-cluster. The classic traps: shared IP on cloud hosting means nothing; a common packer build means a tool, not a team; a reused certificate often means a purchased kit. The counter-practices: require multiple independent feature classes for a link (one infra + one behavior beats four infra), record the reason for every link so a reviewer can audit it, and time-decay link confidence since infrastructure reuse weakens as months pass. Production closes the loop: clusters become structured records (STIX/MISP events with galaxies [verify current MISP feature naming]), retrohunt queries (M6) keep the cluster absorbing new samples automatically, and every new member re-validates or amends the cluster’s stable-feature list. The mature endgame is predictive: the cluster’s DGA seed predicts next week’s domains, the protocol template predicts what the next build will look like, and the detection set is waiting before the campaign arrives — intelligence as anticipation rather than description.
Common Techniques
Section titled “Common Techniques”| Technique | Pivot on | Stability | Tooling | Notes |
|---|---|---|---|---|
| Hash pivoting | File hash | Build-level | VT, internal corpus | Fast start, fast dead-end |
| imphash / tlsh / ssdeep | Import set / fuzzy hash | Build-family | VT, ssdeep | Groups recompiles [verify tlsh scope] |
| Mutex/registry clustering | Host artifacts | Family-level | Sandbox reports, M5 tools | Survives rebuilds |
| PDB path pattern | Build metadata | Operator habit | PE parse (M5) | Links across families |
| Protocol fingerprinting | URI set, headers, timing | Family-level | M7 tooling | Strongest technical link |
| Cert fingerprint reuse | TLS cert bytes/subject | Operator habit | CT logs, zeek tls.log | Kits reuse certs broadly |
| Passive DNS / WHOIS history | Infrastructure | Weeks-months | pDNS providers | Registration habits |
| Sinkhole census | Sinkhole check-ins | Fleet-level | M11 workflow | Victimology + counts |
| YARA retrohunts | Rule hits | Family-level | VT/ corpora (M6) | Cluster auto-absorption |
| Delivery-chain overlap | Template/TDS params | Campaign-level | M13 tooling | Links campaigns sharing infra |
| Victimology overlay | Target profile | Campaign-level | Cluster records | Motivation inference |
| Time-series analysis | Artifact timestamps | — | Cluster records | Operational rhythm, timezone hints |
Full Working Code
Section titled “Full Working Code”samplelink.py — a weighted sample-linking engine: ingests feature records in a simple JSON schema (one file per sample — output of the M5/M7/M11 tools, or hand-written), computes pairwise similarity across weighted feature classes, applies the multi-class-link rule, and emits clusters with per-link reasons. stdlib only.
#!/usr/bin/env python3"""samplelink.py — weighted campaign clustering over sample features.Usage: python samplelink.py features_dir/ [--threshold 3.0] [--min-classes 2]Feature record schema (JSON, one per sample):{ "sample": "a1b2c3", "mutexes": ["Global\\\\SyncNetTask"], "pdb": ["D:\\\\proj\\\\loader\\\\Release\\\\loader.pdb"], "protocol": ["uri:/px.gif|ua:M/*|cookie:cfu"], "infra": ["185.220.101.4", "cdn-check.top"], "imphash": ["f34d5f2d..."], "cert_sha": ["ab12..."], "when": "2026-09-01"}Weights encode the stability hierarchy. stdlib only."""import argparse, json, os, sys, datetimefrom collections import defaultdictfrom itertools import combinations
WEIGHTS = { "protocol": 4.0, # hardest to change (M7 fingerprints) "cert_sha": 3.0, # operator habit "mutexes": 3.0, # family trait (M5 verified artifacts) "pdb": 2.5, # build habit "imphash": 1.5, # build-family only "infra": 1.0, # rotates by design}CLASSES = list(WEIGHTS)
def load_records(d): recs = [] for n in sorted(os.listdir(d)): if not n.endswith(".json"): continue try: r = json.load(open(os.path.join(d, n), encoding="utf-8")) except json.JSONDecodeError as e: print(f"[!] skip {n}: {e}", file=sys.stderr) continue if "sample" not in r: continue recs.append(r) return recs
def feature_set(rec, cls): """Normalized set for one feature class of one record.""" vals = rec.get(cls) or [] if cls == "pdb": # basename of pdb = the pattern that persists vals = [v.split("\\")[-1].split("/")[-1].lower() for v in vals] if cls == "infra": # lowercase everything; strip scheme vals = [v.lower().replace("https://", "") .replace("http://", "").rstrip("/") for v in vals] return set(v.lower() for v in vals if v)
def time_decay(rec_a, rec_b, halflife_days=180): """0.5-1.0 multiplier; links decay as samples age apart.""" try: a = datetime.date.fromisoformat(rec_a.get("when") or "1970-01-01") b = datetime.date.fromisoformat(rec_b.get("when") or "1970-01-01") gap = abs((a - b).days) return 0.5 ** (gap / halflife_days) except (ValueError, TypeError): return 0.75
def link_score(a, b): """Weighted score + matched classes + per-class reasons.""" score, classes, reasons = 0.0, 0, [] for cls in CLASSES: fa, fb = feature_set(a, cls), feature_set(b, cls) if not fa or not fb: continue shared = fa & fb if not shared: continue classes += 1 gained = WEIGHTS[cls] * min(len(shared), 2) / 2 score += gained reasons.append(f"{cls}:{sorted(shared)[:2]}") if classes: score *= time_decay(a, b) return round(score, 2), classes, reasons
def main(): ap = argparse.ArgumentParser() ap.add_argument("dir") ap.add_argument("--threshold", type=float, default=3.0) ap.add_argument("--min-classes", type=int, default=2) args = ap.parse_args() recs = load_records(args.dir) if len(recs) < 2: sys.exit("need >= 2 feature records") # pairwise links above threshold with min-class rule adj = defaultdict(set) edges = [] for a, b in combinations(recs, 2): sc, ncls, reasons = link_score(a, b) if sc >= args.threshold and ncls >= args.min_classes: adj[a["sample"]].add(b["sample"]) adj[b["sample"]].add(a["sample"]) edges.append((sc, a["sample"], b["sample"], reasons)) edges.sort(reverse=True) # connected components = clusters seen, clusters = set(), [] for r in recs: if r["sample"] in seen: continue stack, comp = [r["sample"]], set() while stack: cur = stack.pop() if cur in comp: continue comp.add(cur) stack += list(adj[cur]) seen |= comp clusters.append(sorted(comp)) clusters.sort(key=len, reverse=True) print(f"== clusters: {len(clusters)} " f"({sum(1 for c in clusters if len(c)>1)} multi-member) ==") for c in clusters: tag = "CAMPAIGN" if len(c) > 1 else "single" print(f"\n[{tag}] {len(c)} samples: {', '.join(c[:8])}" f"{' ...' if len(c) > 8 else ''}") print(f"\n== top links (score, pair, reasons) ==") for sc, a, b, reasons in edges[:10]: print(f" {sc:>5} {a} <-> {b} {reasons}")
if __name__ == "__main__": main()Code Walkthrough
Section titled “Code Walkthrough”- The weights table (lines 19-26) is the module’s theory in code: protocol fingerprints weigh 4.0 (hardest to change — M7’s lesson), certificates and mutexes 3.0 (operator/family habits), PDB patterns 2.5 (build habit), imphash 1.5 (build-family), infrastructure 1.0 (rotates by design, so it corroborates but never carries a link alone).
feature_set()(lines 38-53) normalizes per class — the PDB class keeps only the basename (the pattern that persists; full paths differ per build machine) and infra strips schemes — because un-normalized sets silently zero out matches that humans would instantly see.time_decay()(lines 56-66) applies a half-life of 180 days to any linked pair’s score: two samples sharing a cert three years apart score half of the same link observed a month apart. Infra reuse means less as time passes — encoding that intuition beats documenting it.link_score()(lines 69-83) sums weighted matches with a saturation cap (min(len(shared), 2)— the second matching mutex adds value, the twentieth doesn’t), counts matched classes, and applies the decay. Thereasonslist records every matched feature with its values — the audit trail that keeps clustering reviewable.- The multi-class gate (line 126) —
ncls >= args.min_classes— is the over-linking guard: one shared feature never creates a link regardless of weight, because shared hosting and shared kits are too common. Two independent classes (e.g., protocol + mutex) is the minimum that means “same operator”. - The connected-components loop (lines 129-138) turns the link graph into clusters. Note the deliberate output discipline: every link above threshold prints with its reasons (lines 142-144), so a reviewer can challenge the weakest edge holding a mega-cluster together.
How It Reaches The Target
Section titled “How It Reaches The Target”Intelligence reaches the target organization as pre-decision: the cluster record arrives before the campaign does — via feed, partner sharing, or your own retrohunt — and its stable-feature detections (YARA on decoder stubs, protocol-fingerprint hunts, DGA predictions) are already deployed when the first sample lands. On execution day, the flow reverses: the incident produces artifacts (M13’s chain, M12’s decoded dropper), the analyst checks them against the cluster store, and either the sample joins an existing campaign — inheriting all its context instantly — or founds a new cluster whose record starts filling from minute one. The clustering process itself reaches across teams: IR consumes the victimology (are we targeted or incidental?), SOC consumes the detections (what’s already deployed against this cluster?), leadership consumes the scope story (how long has this operator been active, what’s their rhythm?), and the sharing community consumes the structured record (what’s safe and useful to publish?). The failure modes are as instructive as the wins: over-linked clusters poison everything downstream (one false mega-cluster misattributes dozens of incidents and burns community trust when published), under-linked clusters waste the analyst’s time re-analyzing the same family, and stale links (no decay) harden into folklore that outlives the infrastructure they described. The craft’s maturity is measured in exactly one metric: when the next sample arrives, how long until the analyst knows everything the organization already knows about it — minutes, not days.
Exploitation Chain
Section titled “Exploitation Chain”- Intake — incident sample arrives; M5 extraction produces feature record (mutex, PDB, protocol template)
- Lookup — feature record queried against cluster store; protocol template matches cluster KX-2026-03
- Absorb — sample joins cluster: victimology updated, new infra recorded with decay-weighted confidence
- Retrohunt — M6 rule from cluster stable strings run over archive: 14 prior samples absorbed retroactively
- Cross-link — delivery chains (M13) of prior samples share template host with new incident: campaign confirmed active
- Prediction — DGA seed from cluster predicts next week’s domains; blocklist pre-staged
- Detection — protocol-fingerprint hunt (M7) deployed; first new-beacon alert fires within 48h
- Production — cluster exported to sharing platform; partner confirms same infra in their estate: scope multiplied
Building Your Own Tool
Section titled “Building Your Own Tool”campaigndb.py — a lightweight campaign database: SQLite-backed storage for samples, artifacts, links, and cluster records with confidence scores; queryable by any artifact type; emits campaign views. Single-file, stdlib only (sqlite3), designed to sit on an analyst bench where bigger platforms (MISP et al.) are overkill — the “memory” of a small team, structured so it can be migrated upward later.
Design decisions
Section titled “Design decisions”- SQLite, not a service. The database is one file an analyst can copy, version, and air-gap; zero deployment friction beats platform features at bench scale, and the schema maps cleanly to STIX/MISP objects for later migration [verify mapping per platform version].
- Links carry their reasons. The
linkstable stores score, matched classes, and the reason list — the samplelink output becomes durable data, so every cluster claim stays auditable years later. - Artifact-type tables, not a blob. Separate columns per feature class (mutex, pdb, protocol, infra, imphash, cert) keep queries index-able and the stability hierarchy queryable (“show everything sharing this protocol template”).
- Cluster views are generated, not stored. The campaign view is computed from links at query time, so amending a link instantly changes the view — no derived-state drift.
Full source
Section titled “Full source”#!/usr/bin/env python3"""campaigndb.py — SQLite campaign memory for small teams.Usage: python campaigndb.py init db.sqlite python campaigndb.py add db.sqlite sample.json python campaigndb.py link db.sqlite A B --score 4.5 \\ --classes protocol,mutex --reason "uri template + mutex" python campaigndb.py cluster db.sqlite A python campaigndb.py find db.sqlite --protocol "uri:/px.gif" python campaigndb.py view db.sqlitestdlib only (sqlite3)."""import argparse, json, os, sqlite3, sys
SCHEMA = """CREATE TABLE IF NOT EXISTS samples ( sample TEXT PRIMARY KEY, when_ TEXT, mutex TEXT, pdb TEXT, protocol TEXT, infra TEXT, imphash TEXT, cert_sha TEXT, raw TEXT);CREATE TABLE IF NOT EXISTS links ( a TEXT, b TEXT, score REAL, classes TEXT, reason TEXT, created TEXT, PRIMARY KEY (a, b));CREATE INDEX IF NOT EXISTS idx_proto ON samples(protocol);CREATE INDEX IF NOT EXISTS idx_mutex ON samples(mutex);CREATE INDEX IF NOT EXISTS idx_infra ON samples(infra);"""
def init(db): con = sqlite3.connect(db) con.executescript(SCHEMA) con.commit() print(f"[+] schema ready: {db}")
def add(db, json_path): r = json.load(open(json_path, encoding="utf-8")) con = sqlite3.connect(db) con.execute( "INSERT OR REPLACE INTO samples VALUES (?,?,?,?,?,?,?,?,?)", (r.get("sample"), r.get("when"), json.dumps(r.get("mutexes") or []), json.dumps(r.get("pdb") or []), json.dumps(r.get("protocol") or []), json.dumps(r.get("infra") or []), json.dumps(r.get("imphash") or []), json.dumps(r.get("cert_sha") or []), json.dumps(r))) con.commit() print(f"[+] stored {r.get('sample')}")
def link(db, a, b, score, classes, reason): con = sqlite3.connect(db) import datetime con.execute( "INSERT OR REPLACE INTO links VALUES (?,?,?,?,?,?)", (a, b, score, classes, reason, datetime.date.today().isoformat())) con.commit() print(f"[+] linked {a} <-> {b} score={score}")
def cluster(db, sample, depth=3): """BFS over links from a sample; return the cluster.""" con = sqlite3.connect(db) seen, frontier = {sample}, [sample] out = {sample: _row(con, sample)} for _ in range(depth): nxt = [] for cur in frontier: for nb in _neighbors(con, cur): if nb not in seen: seen.add(nb) nxt.append(nb) out[nb] = _row(con, nb) frontier = nxt if not frontier: break return out
def _neighbors(con, sample): rows = con.execute( "SELECT a,b FROM links WHERE a=? OR b=?", (sample, sample)).fetchall() return [b if a == sample else a for a, b in rows]
def _row(con, sample): r = con.execute("SELECT sample,when_,protocol,infra " "FROM samples WHERE sample=?", (sample,)).fetchone() return {"sample": r[0], "when": r[1], "protocol": json.loads(r[2]), "infra": json.loads(r[3])} if r else {}
def cmd_cluster(db, sample): comp = cluster(db, sample) print(f"== cluster of {sample}: {len(comp)} samples ==") for s, r in comp.items(): print(f" {s} when={r.get('when','-')} " f"infra={r.get('infra')[:3]}")
def cmd_find(db, args): con = sqlite3.connect(db) col = {"protocol": "protocol", "mutex": "mutex", "infra": "infra", "imphash": "imphash", "pdb": "pdb", "cert": "cert_sha"}[args.field] hits = [] for (sample, val) in con.execute( f"SELECT sample,{col} FROM samples"): if args.value.lower() in (val or "").lower(): hits.append(sample) print(f"== {args.field} ~ '{args.value}': {len(hits)} hits ==") for h in hits[:20]: print(f" {h}")
def cmd_view(db): con = sqlite3.connect(db) n_s = con.execute("SELECT COUNT(*) FROM samples").fetchone()[0] n_l = con.execute("SELECT COUNT(*) FROM links").fetchone()[0] print(f"== campaigndb: {n_s} samples, {n_l} links ==") for a, b, sc, reason in con.execute( "SELECT a,b,score,reason FROM links " "ORDER BY score DESC LIMIT 10"): print(f" {sc:>5} {a} <-> {b} {reason}")
def main(): ap = argparse.ArgumentParser() sub = ap.add_subparsers(dest="cmd", required=True) s1 = sub.add_parser("init"); s1.add_argument("db") s2 = sub.add_parser("add") s2.add_argument("db"); s2.add_argument("json") s3 = sub.add_parser("link") s3.add_argument("db"); s3.add_argument("a") s3.add_argument("b"); s3.add_argument("--score", type=float, required=True) s3.add_argument("--classes", default="") s3.add_argument("--reason", default="") s4 = sub.add_parser("cluster") s4.add_argument("db"); s4.add_argument("sample") s5 = sub.add_parser("find") s5.add_argument("db"); s5.add_argument("--field", required=True, choices=["protocol","mutex","infra","imphash","pdb","cert"]) s5.add_argument("--value", required=True) s6 = sub.add_parser("view"); s6.add_argument("db") args = ap.parse_args() if args.cmd == "init": init(args.db) elif args.cmd == "add": add(args.db, args.json) elif args.cmd == "link": link(args.db, args.a, args.b, args.score, args.classes, args.reason) elif args.cmd == "cluster": cmd_cluster(args.db, args.sample) elif args.cmd == "find": cmd_find(args.db, args) elif args.cmd == "view": cmd_view(args.db)
if __name__ == "__main__": main()Walkthrough
Section titled “Walkthrough”- The schema (lines 14-30) encodes the module’s data model: samples carry per-class artifact columns (indexed for the
findworkflow), links carry score/classes/reason/created — the audit trail — andrawkeeps the original JSON so schema evolution never loses source data. add()(lines 38-53) stores each feature class as a JSON string in its own column — arrays in TEXT columns are denormalized, but this is bench-scale data (thousands of rows, not millions) where queryability per class matters more than normalization purity.cluster()(lines 63-79) is breadth-first over thelinkstable with a depth cap (default 3) — deep crawls on over-linked databases merge everything, so the cap is a safety rail, and the docstring of the workflow (raise depth only when the link graph is audited) belongs in your runbook.cmd_find()(lines 88-101) is the daily-driver query: any artifact substring across any class, lowercased both sides. “Has anything ever spoken this URI template?” is the question this one command answers, and it is the single highest-frequency query in campaign work.cmd_view()(lines 104-112) prints the top-scored links with reasons — the database’s self-summary for the weekly review, and the first place stale links (high score, oldcreated, dead infra) get challenged.
Compilation and test procedure
Section titled “Compilation and test procedure”full lifecycle on the bench
Section titled “full lifecycle on the bench”$ python3 campaigndb.py init bench.sqlite [+] schema ready: bench.sqlite
ingest feature records (output shape from M5/M7/M11 tools)
Section titled “ingest feature records (output shape from M5/M7/M11 tools)”$ python3 campaigndb.py add bench.sqlite rec_a1b2.json $ python3 campaigndb.py add bench.sqlite rec_c3d4.json [+] stored a1b2c3 [+] stored c3d4e5
link with reason (two independent classes = trustworthy link)
Section titled “link with reason (two independent classes = trustworthy link)”$ python3 campaigndb.py link bench.sqlite a1b2c3 c3d4e5
—score 7.0 —classes protocol,mutex
—reason “uri:/px.gif template + Global\\SyncNetTask”
[+] linked a1b2c3 <-> c3d4e5 score=7.0
the daily question: has anything spoken this template before?
Section titled “the daily question: has anything spoken this template before?”$ python3 campaigndb.py find bench.sqlite —field protocol
—value “uri:/px.gif”
== protocol ~ ‘uri:/px.gif’: 2 hits ==
a1b2c3
c3d4e5
cluster view + weekly self-summary
Section titled “cluster view + weekly self-summary”$ python3 campaigndb.py cluster bench.sqlite a1b2c3 $ python3 campaigndb.py view bench.sqlite
Detection considerations
Section titled “Detection considerations”The database is the feeder for every detection in the M-series: stable strings become YARA (M6), protocol templates become hunt queries (M7), predicted DGA slices become blocklists (M11), and chain fingerprints become gateway rules (M13) — the intelligence layer’s value is measured by what it arms. Your own tooling hygiene: a bench SQLite file is invisible to endpoints; the discipline to maintain is input hygiene — every link’s reason recorded at creation time, because recreating the reasoning later is the work that never happens. Operational risks to design against: link rot (infra dies, protocols rotate — review links quarterly against the decay logic), single-analyst bias (the person who built the cluster challenges it least — schedule a peer review), and publishing discipline (shared records must strip victim-identifying detail; the community value and the opsec risk live in the same fields). SIEM integration is the final mile: Wazuh/Splunk lookups against the artifact tables turn the database from a bench tool into a live enrichment layer [verify per platform]; Suricata gets the infra slices as local sid:1000001+ rules with expiration dates, because infra rules without decay become the folklore problem in packet form.
Improvements to add
Section titled “Improvements to add”- Automate absorption: run samplelink weekly against the DB and queue above-threshold links for analyst approval instead of manual linking.
- Add victimology fields (sector, geo, count) and a per-cluster victimology view — the targeting picture drives leadership reporting.
- Export adapters: MISP event JSON and STIX 2.1 objects from cluster views [verify current spec details].
- Time-series view: per-cluster artifact timeline (first/last seen per class) exposing operational rhythm and gaps.
- Decay automation: nightly job re-scores links against
time_decayand flags those fallen below threshold for human review.
Detection — Defender View
Section titled “Detection — Defender View”| Intelligence product | Consumer | Deployment | Notes |
|---|---|---|---|
| Cluster stable-string YARA | Mail/EDR/file scanning | M6 pipeline | Auto-refresh via retrohunt |
| Protocol-fingerprint hunts | Proxy/zeek/NDR | M7 stats queries | Survives infra rotation |
| DGA prediction blocklists | Resolver/gateway | M11 slices, pre-staged | Predictive posture |
| Chain fingerprints | Mail gateway rules | M13 template hosts | Campaign-level blocking |
| Artifact enrichment lookups | SIEM/Wazuh | DB feeds | Live context on alerts |
| Victimology briefs | Leadership/IR | Reports | Targeting vs incidental |
| Sharing exports (MISP/STIX) | Community | Platform adapters | Strip victim detail |
| Sinkhole census feeds | SOC dashboards | M11 workflow | Fleet infection counts |
| Infra rules with expiry | Suricata | custom/local sid:1000001+ | Decay or become folklore |
| Wazuh custom rules from clusters | HIDS | [verify rule IDs per deployment] | Map per environment |
Evasion Techniques
Section titled “Evasion Techniques”Attackers attack the linkage, not the artifacts. Feature rotation hygiene — rotating every rotating-class artifact on schedule (domains, IPs, certs) — is table stakes; the counter is that mature clusters barely weigh those classes. Trait poisoning: deliberately shuffling protocol templates or mutexes per campaign to split a family into fake sub-clusters — countered by weighting on behavioral classes the operator cannot cheaply vary (timing profiles, injection technique, TTP order) and by accepting split clusters over welded ones. False-flag infrastructure: reusing a rival’s certs, packer builds, or PDB patterns to misattribute — countered by multi-class gating (a cert match alone links nothing), provenance tracking (who shared this artifact, when), and humility about attribution claims in published records. Noise injection: flooding feeds with junk samples sharing one artifact class to pollute retrohunts — countered by class-count gating and source reputation in the corpus. Shared-services camouflage: hiding real C2 inside legitimate platforms (cloud functions, CDN workers, reputable hosting) so infra pivots die — countered by shifting pivot weight to protocol and behavior, which the platform cannot disguise. The meta-pattern mirrors M11’s: every anti-linkage measure raises the operator’s build cost, and the intelligence craft’s job is to keep the stable classes cheap to observe and expensive to fake — a target that moves as operators adapt, which is why this discipline is a practice, not a product.
⚠️ What caught me: I once linked two campaigns confidently on a matching code-signing certificate, published the attribution, and a peer tore it apart in one reply: the cert belonged to a purchased builder kit used by dozens of crews, and my two “linked” campaigns were strangers who shopped at the same store. The cluster had to be withdrawn publicly — a small reputational cost that bought a permanent rule: infrastructure and tooling artifacts link candidates, only behavior links operators, and nothing gets published until at least two independent behavioral classes agree. My database’s reason column exists because of that reply.
Lab Practice
Section titled “Lab Practice”- Build feature records for three samples you already analyzed in M4/M5 labs (one real family + two UPX variants of a clean binary) and run
samplelink.py— verify the clean binaries do NOT cluster with the malware despite shared packing traits, and that the malware self-cluster holds. - Exercise the full campaigndb lifecycle from the terminal block; then deliberately create one over-confident link (single class, high weight) and challenge it in a
viewreview — the audit habit in miniature. - Take a public campaign write-up with published artifacts (mutexes, infra, protocol details), build the feature record, and check whether your database’s
findwould have matched it against your prior lab records — grading your schema against reality. - Implement the time-decay re-scoring job (improvement #5) and observe which synthetic links fall below threshold over a simulated two-year gap — decay intuition made mechanical.
- Write the one-page cluster brief for your strongest lab cluster (what’s stable, what rotates, what’s deployed, what’s predicted) and have a peer attack its weakest claim — the peer-review loop that keeps clusters honest.
Interview Questions
Section titled “Interview Questions”- Technical: Order the artifact classes by stability for clustering, and justify each weight in a scoring model.
- Technical: How does time-decay change link scoring, and which artifact classes decay fastest in practice?
- Scenario: A single shared certificate links two otherwise-dissimilar campaigns. What do you do before treating them as one operation?
- Conceptual: Why do over-linked clusters cause more operational damage than under-linked ones, and what design gates prevent them?
- Conceptual: How does trait poisoning by an adaptive adversary change which feature classes you trust for linkage?
- Deep-dive: Design the absorption pipeline: new sample → feature extraction → cluster lookup → human-in-the-loop linking → detection deployment. Where are the latency ceilings and the quality gates?
- Practical: Your org wants “threat intel” with one analyst and no platform budget. What from this module do you deploy in week one, and what do you explicitly defer?
Related Modules
Section titled “Related Modules”M5 (String and Artifact Extraction) · M6 (YARA Rule Engineering) · M7 (C2 Protocol Analysis) · M11 (Botnet Architecture Analysis) · M13 (Delivery Chain Analysis)