Skip to content

Threat Intelligence and Campaign Clustering

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.

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.

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 time

Pivoting 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.

TechniquePivot onStabilityToolingNotes
Hash pivotingFile hashBuild-levelVT, internal corpusFast start, fast dead-end
imphash / tlsh / ssdeepImport set / fuzzy hashBuild-familyVT, ssdeepGroups recompiles [verify tlsh scope]
Mutex/registry clusteringHost artifactsFamily-levelSandbox reports, M5 toolsSurvives rebuilds
PDB path patternBuild metadataOperator habitPE parse (M5)Links across families
Protocol fingerprintingURI set, headers, timingFamily-levelM7 toolingStrongest technical link
Cert fingerprint reuseTLS cert bytes/subjectOperator habitCT logs, zeek tls.logKits reuse certs broadly
Passive DNS / WHOIS historyInfrastructureWeeks-monthspDNS providersRegistration habits
Sinkhole censusSinkhole check-insFleet-levelM11 workflowVictimology + counts
YARA retrohuntsRule hitsFamily-levelVT/ corpora (M6)Cluster auto-absorption
Delivery-chain overlapTemplate/TDS paramsCampaign-levelM13 toolingLinks campaigns sharing infra
Victimology overlayTarget profileCampaign-levelCluster recordsMotivation inference
Time-series analysisArtifact timestamps—Cluster recordsOperational rhythm, timezone hints

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, datetime
from collections import defaultdict
from 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()
  • 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. The reasons list 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.

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.

  1. Intake — incident sample arrives; M5 extraction produces feature record (mutex, PDB, protocol template)
  2. Lookup — feature record queried against cluster store; protocol template matches cluster KX-2026-03
  3. Absorb — sample joins cluster: victimology updated, new infra recorded with decay-weighted confidence
  4. Retrohunt — M6 rule from cluster stable strings run over archive: 14 prior samples absorbed retroactively
  5. Cross-link — delivery chains (M13) of prior samples share template host with new incident: campaign confirmed active
  6. Prediction — DGA seed from cluster predicts next week’s domains; blocklist pre-staged
  7. Detection — protocol-fingerprint hunt (M7) deployed; first new-beacon alert fires within 48h
  8. Production — cluster exported to sharing platform; partner confirms same infra in their estate: scope multiplied

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.

  • 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 links table 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.
#!/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.sqlite
stdlib 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()
  • The schema (lines 14-30) encodes the module’s data model: samples carry per-class artifact columns (indexed for the find workflow), links carry score/classes/reason/created — the audit trail — and raw keeps 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 the links table 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, old created, dead infra) get challenged.

$ 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

$ python3 campaigndb.py cluster bench.sqlite a1b2c3 $ python3 campaigndb.py view bench.sqlite

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.

  1. Automate absorption: run samplelink weekly against the DB and queue above-threshold links for analyst approval instead of manual linking.
  2. Add victimology fields (sector, geo, count) and a per-cluster victimology view — the targeting picture drives leadership reporting.
  3. Export adapters: MISP event JSON and STIX 2.1 objects from cluster views [verify current spec details].
  4. Time-series view: per-cluster artifact timeline (first/last seen per class) exposing operational rhythm and gaps.
  5. Decay automation: nightly job re-scores links against time_decay and flags those fallen below threshold for human review.
Intelligence productConsumerDeploymentNotes
Cluster stable-string YARAMail/EDR/file scanningM6 pipelineAuto-refresh via retrohunt
Protocol-fingerprint huntsProxy/zeek/NDRM7 stats queriesSurvives infra rotation
DGA prediction blocklistsResolver/gatewayM11 slices, pre-stagedPredictive posture
Chain fingerprintsMail gateway rulesM13 template hostsCampaign-level blocking
Artifact enrichment lookupsSIEM/WazuhDB feedsLive context on alerts
Victimology briefsLeadership/IRReportsTargeting vs incidental
Sharing exports (MISP/STIX)CommunityPlatform adaptersStrip victim detail
Sinkhole census feedsSOC dashboardsM11 workflowFleet infection counts
Infra rules with expirySuricatacustom/local sid:1000001+Decay or become folklore
Wazuh custom rules from clustersHIDS[verify rule IDs per deployment]Map per environment

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.

  1. 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.
  2. 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 view review — the audit habit in miniature.
  3. Take a public campaign write-up with published artifacts (mutexes, infra, protocol details), build the feature record, and check whether your database’s find would have matched it against your prior lab records — grading your schema against reality.
  4. 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.
  5. 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.
  • 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?

M5 (String and Artifact Extraction) · M6 (YARA Rule Engineering) · M7 (C2 Protocol Analysis) · M11 (Botnet Architecture Analysis) · M13 (Delivery Chain Analysis)