Skip to content

Delivery Chain Analysis

Delivery chain analysis is the reconstruction of the complete path an attack took from its origin to its execution on the target: the sender infrastructure that launched the campaign, the hops the message or link traversed, the web redirect or traffic-direction system that routed the victim, the document or dropper that executed, and the payload it delivered. Where family analysis (M8, M9, M12) asks “what is this malware”, delivery chain analysis asks “how did it get here — every hop, in order, with evidence”. The discipline is fundamentally forensic-archaeological: no single log contains the chain, so the analyst joins partial views — email headers, gateway verdicts, proxy logs, DNS telemetry, endpoint process timelines — into one evidence-backed narrative. Done well, it produces root cause (which control failed at which hop), scope (which other users got the same campaign), and prevention (what to fix so the next campaign dies two hops earlier).

Every intrusion starts somewhere, and “somewhere” is a chain of controls that almost failed. Organizations invest heavily in the payload end of the chain — EDR, sandbox detonation, threat intelligence — while the delivery end is where the cheapest wins hide: an SPF/DKIM/DMARC gap, an unprotected redirect, a URL-scanning blind spot, a user-reporting workflow that nobody feeds back into gateway rules. Delivery chain analysis is how you find those gaps with your own incident data instead of a framework checklist. It also answers the questions executives and auditors actually ask after an incident — how did they get in, how long were they in, which other users received the same attack — with evidence instead of reconstruction-by-memory. For threat intelligence (M15), the chain is the campaign’s fingerprint: sender domains, TDS parameters, template hosts, and redirect patterns persist across payloads, so chain evidence clusters incidents that binary analysis alone would never link. And operationally, chain analysis is the discipline that connects the two halves of response work: the mailbox team’s headers and the IR team’s endpoint timeline are describing one event, and someone has to join them — that someone is the analyst who can read both ends of the chain.

A delivery chain decomposes into segments, and each segment has its own evidence source and its own failure modes. The email path is the dominant case:

THE EMAIL DELIVERY CHAIN AND ITS TELEMETRY
[attacker] spam/templating infra, compromised or bulletproof host
| evidence: none visible to you (out of reach)
v
[MTA hops] Received: chain in headers, delay analysis
| evidence: full headers -- forged on the first hop is common
v
[auth] SPF / DKIM / DMARC evaluation at your gateway
| evidence: Authentication-Results header, gateway log verdict
v
[gateway] AV/URL rewrite/sandbox verdict, or the miss
| evidence: mail gateway logs -- THE most under-read log
v
[mailbox] user receives; read receipt? reply? report?
| evidence: message trace, mailbox audit (Windows: none; 365: UAL)
v
[user click] URL -> redirector/TDS -> exploit/payload host
| evidence: proxy logs, DNS logs, safe-links rewrite records
v
[document] maldoc opens (M12), macro/prompt payload executes
| evidence: Sysmon EID 1 (Office lineage), 4104, Defender 1116
v
[payload] stealer/ransomware/loader stages, C2 beacon (M7/M9/M8)
evidence: EID 11/22/3, sandbox reports, EDR timeline

Reconstruction works backwards from the strongest evidence and forwards through the weakest. Start at the endpoint: the EDR process timeline gives you the exact moment of execution, the parent chain (Outlook → WINWORD → powershell), and the dropped artifacts with hashes. From the document, extract its network refs (template URLs, payload hosts) — those URLs are your pivots into proxy and DNS logs, which give you the other users who fetched the same URL (scope expansion — the single most valuable output of chain analysis, because one reported incident usually means N recipients). From the URL, walk the redirect chain (a headless fetch or manual curl through the redirects) — beware that TDS (traffic direction systems) gate by IP/UA/referrer, so server-side the chain often will not replay from your analysis host; treat non-replaying URLs as data points, not failures. Then move to the mailbox side: full RFC 5322 headers give the Received chain (read bottom-up — each server prepends), authentication results, and the mismatch cluster that phish detection lives on (display-name vs envelope-from vs Reply-To). Finally, the gateway log tells you what your controls saw and decided — the root-cause moment: a pass verdict, a URL rewritten but not detonated, a sandbox delay window the user clicked through. The joined timeline is the deliverable: one table, one row per hop, each row citing its evidence — because a chain analysis that can’t show its evidence is a story, not a finding.

TechniqueWhereWhat it revealsNotes
Received-chain parsingEmail headersTrue path, hop delays, forgeriesRead bottom-up; top entries are attacker’s
SPF/DKIM/DMARC readingAuthentication-ResultsAuth failures and alignment gapsd= vs From: domain alignment matters
Reply-To / Return-Path diffHeadersReply hijack setupsDisplay-name impersonation won’t show here
Message-trace correlationGateway/365 UALAll recipients of the campaignScope expansion in minutes
Redirect-chain walkProxy/browserTDS → landing → payload hostTDS gates by referrer/IP — non-replay is normal
URL parameter analysisRedirect URLsCampaign IDs, sub-affiliate tagsIDs persist across payloads (M15)
Attachment pivotingMaldoc analysisTemplate/payload hostsM12 tooling feeds this module
Sandbox verdict reviewGateway/sandboxWhat controls saw and missedRoot-cause anchor
Beacon backtrackingEDR/DNS (M7)C2 domains → campaign infraLinks endpoint to delivery infra
Campaign replay attemptCurl/headlessWhether infra still liveThreat-informed defense validation

chainmail.py — an email delivery-chain analyzer: parses full RFC 5322 headers, reconstructs the Received chain bottom-up with hop delays, extracts and evaluates SPF/DKIM/DMARC results, flags the classic impersonation mismatches (display-name vs envelope-from vs Reply-To), and pulls URLs for pivot. stdlib email module only.

#!/usr/bin/env python3
"""chainmail.py — email delivery-chain analyzer (headers → verdict).
Usage: python chainmail.py headers.eml [more.eml ...]
or: python chainmail.py - (stdin, raw headers)
stdlib only (email module).
"""
import argparse, sys
from email import policy
from email.parser import BytesParser
from email.utils import parseaddr, parsedate_to_datetime
import re
URL_RE = re.compile(rb"https?://[^\s<>\"')\]]+")
def read_message(path_or_dash):
if path_or_dash == "-":
return BytesParser(policy=policy.default).parsebytes(
sys.stdin.buffer.read())
return BytesParser(policy=policy.default).parse(
open(path_or_dash, "rb"))
def received_chain(msg):
"""Reconstruct hop list bottom-up with timestamps."""
hops = []
for hop in msg.get_all("Received", []):
hop = str(hop)
ts = None
m = re.search(r";\s*(.+)$", hop)
if m:
try:
ts = parsedate_to_datetime(m.group(1).strip())
except (TypeError, ValueError):
pass
hops.append({"raw": " ".join(hop.split())[:120], "ts": ts})
return list(reversed(hops)) # bottom-up = closest to origin first
def hop_delays(hops):
"""Compute inter-hop delays where both timestamps parse."""
out = []
for a, b in zip(hops, hops[1:]):
if a["ts"] and b["ts"]:
out.append(round((b["ts"] - a["ts"]).total_seconds(), 1))
else:
out.append(None)
return out
def auth_results(msg):
"""Extract SPF/DKIM/DMARC verdicts from headers."""
results = {"spf": None, "dkim": None, "dmarc": None}
for ar in msg.get_all("Authentication-Results", []):
ar = str(ar).lower()
for key in results:
m = re.search(rf"{key}\s*=\s*(pass|fail|softfail"
r"|neutral|none|temperror|permerror)", ar)
if m and results[key] is None:
results[key] = m.group(1)
# fallback: legacy per-header auth results
if results["spf"] is None:
spf = str(msg.get("Received-SPF", "")).lower()
results["spf"] = spf.split()[0] if spf else None
return results
def identity_cluster(msg):
"""Display-name / envelope-from / Reply-To comparison."""
from_hdr = msg.get("From", "")
disp_name, from_addr = parseaddr(from_hdr)
reply = msg.get_all("Reply-To", [])
reply_addrs = [parseaddr(r)[1] for r in reply]
return_path = parseaddr(msg.get("Return-Path", ""))[1]
return {"display_name": disp_name, "from_addr": from_addr,
"reply_to": reply_addrs, "return_path": return_path}
def flags(msg, ids, auth):
"""Heuristic impersonation/anti-analysis flags."""
f = []
idn = identity_cluster(msg)
if idn["reply_to"]:
dom = lambda a: a.split("@")[-1].lower() if "@" in a else a
if any(dom(r) != dom(idn["from_addr"])
for r in idn["reply_to"]):
f.append("REPLY-TO domain differs From: domain "
f"({idn['reply_to']})")
if auth.get("dmarc") == "fail":
f.append("DMARC fail")
if auth.get("spf") in ("fail", "softfail"):
f.append(f"SPF {auth['spf']}")
subject = str(msg.get("Subject", ""))
if re.match(r"^(re|fw|fwd):\s*$", subject, re.I):
f.append("empty reply/fwd prefix (thread-hijack shape)")
if msg.get("X-Mailer") and "PHP" in str(msg.get("X-Mailer")):
f.append("PHP mailer X-Mailer (spam-kit shape)")
return f
def extract_urls(msg):
"""URLs from body and from text/html parts (rewritten forms)."""
urls = []
if msg.is_multipart():
parts = msg.walk()
else:
parts = [msg]
for part in parts:
ct = part.get_content_type()
if ct not in ("text/plain", "text/html"):
continue
try:
body = part.get_payload(decode=True) or b""
except Exception:
continue
for m in URL_RE.finditer(body):
urls.append(m.group().decode(errors="replace"))
# safe-links / gateway rewrites surface in headers too
for h in ("X-Safelinks", "X-Microsoft-Antispam"):
if msg.get(h):
for m in URL_RE.finditer(str(msg.get(h)).encode()):
urls.append(m.group().decode(errors="replace"))
return list(dict.fromkeys(urls)) # dedupe, keep order
def main():
ap = argparse.ArgumentParser()
ap.add_argument("files", nargs="+")
args = ap.parse_args()
for path in args.files:
msg = read_message(path)
name = path if path != "-" else "<stdin>"
print(f"== {name} ==")
ids = identity_cluster(msg)
auth = auth_results(msg)
hops = received_chain(msg)
delays = hop_delays(hops)
print(f"from: {ids['display_name']!r} <{ids['from_addr']}>")
if ids["reply_to"]:
print(f"reply-to: {ids['reply_to']}")
print(f"return-path: {ids['return_path']}")
print(f"auth: spf={auth['spf']} dkim={auth['dkim']} "
f"dmarc={auth['dmarc']}")
print(f"received chain ({len(hops)} hops, bottom-up):")
for i, h in enumerate(hops):
d = f" +{delays[i]}s" if i < len(delays) and \
delays[i] is not None else ""
print(f" {i+1:>2}. {h['raw']}{d}")
fl = flags(msg, ids, auth)
print("flags:")
if fl:
for x in fl:
print(f" [!] {x}")
else:
print(" none")
urls = extract_urls(msg)
print(f"urls ({len(urls)}):")
for u in urls[:12]:
print(f" {u}")
print()
if __name__ == "__main__":
main()
  • received_chain() (lines 22-36) collects every Received header and reverses the list: the email standard prepends each hop, so header order is reverse-path order and the bottom-most Received is the closest to the true origin. The timestamp is pulled from the semicolon-suffixed date clause and parsed defensively — malformed dates appear as None rather than crashing the chain.
  • hop_delays() (lines 39-46) computes inter-hop durations. Large delays at a specific hop (minutes between the attacker’s MTA and the next) are a reputation signal: spam-emitting infrastructure often queuing delays, and multi-hour gaps with later catch-up indicate throttled or greylisted paths.
  • auth_results() (lines 49-62) reads Authentication-Results for all three mechanisms with the full verdict vocabulary (pass/fail/softfail/neutral/none/temperror/permerror), then falls back to legacy Received-SPF. The results[key] is None guard keeps the first verdict when multiple ARS headers exist (forwarding chains add them).
  • identity_cluster() + flags() (lines 65-97) implement the impersonation heuristics: Reply-To on a different domain than From (the reply-hijack classic), DMARC/SPF failures, the empty Re: prefix that thread-hijack campaigns prepend to fake context, and PHP-mailer X-Mailer strings common to spam kits. Each flag names its evidence — the analyst verifies, the tool points.
  • extract_urls() (lines 100-115) walks MIME parts, decoding only text parts, and additionally scans safelinks-style rewrite headers — because gateway URL rewriting frequently buries the original URL inside a wrapper parameter, and the wrapper is where the original hides.
  • The main loop (lines 118-145) prints the full one-screen triage. Multi-file mode exists deliberately: paste in the whole campaign’s .eml set and the output becomes a per-message comparison table for spotting infrastructure reuse.

The chain reaches the target one controlled hop at a time, and every hop is someone’s detection opportunity that either fired or didn’t. The attacker’s MTA delivers through compromised or bulletproof-relay infrastructure; your edge evaluates authentication — the majority of commodity phish fails SPF or DMARC and should die here; what passes lands in the mailbox, where the user becomes the next control: they either report (the best outcome — feeds the gateway and the chain analysis) or interact. A click traverses URL rewrites and safe-links inspection, then the redirector — increasingly a commercial TDS with per-IP/per-referrer gating designed to show scanners a blank page. The payload host serves the document; the document executes per M12’s chain; the payload per M8/M9. The defender’s leverage concentrates at three hops: authentication (cheap, structural, fixes are config-level), gateway verdict review (the most under-read log in security operations — every miss is a free detection-engineering backlog item), and the endpoint lineage (Office → script host → network, the near-zero-FP alert). Chain analysis closes the loop between all three: it tells you which hop actually failed for this campaign, which other users the same chain reached (scope), and what the upstream infrastructure looks like so the next campaign from the same operator dies before your user’s mailbox. That last part — feeding chain findings back into controls — is what turns incident response into defense improvement, and it is the reason delivery chain analysis sits in the analyst’s core skill set rather than the email team’s silo.

  1. Launch — campaign sent from compromised SMB MTA; 4,200 recipients across 3 orgs
  2. Auth — 91% fail DMARC and die at gateway; 380 messages pass on aligned custom domain
  3. Delivery — 37 users open; 9 enable content on “invoice” maldoc (M12 chain fires)
  4. Fetch — remote template fetched from template host; 4 payloads detonate
  5. Payload — RedLine stealer beacons; credentials exfil (M9)
  6. Report — user 7 reports; chain analysis begins from that mailbox
  7. Scope — message trace + proxy logs: 37 openers, 9 executors identified in 40 minutes
  8. Hardening — gateway rule blocks template host + sender domain; DMARC enforcement gap fixed at failing hop

chainjoin.py — the timeline joiner: ingests the partial evidence CSVs a chain analysis accumulates (email trace, proxy log, DNS log, EDR process events) and emits one merged, time-ordered delivery-chain timeline per host, with cross-source correlation highlighted — the tool that turns four exports into the single evidence table an incident report needs.

  • Normalize at the edges, join in the middle. Each source has its own timestamp format, hostname column, and URL encoding; per-source adapters normalize into one schema (time, host, source, event, detail) before any joining — normalization bugs stay at the edge where they’re testable.
  • Window correlation, not exact matches. Proxy fetches and process spawns never share timestamps; the joiner correlates events on the same host within a configurable window (default 120 s) and marks joins explicitly.
  • Evidence discipline. Every output row cites its source and raw line — the tool’s output must survive being pasted into an incident report and challenged line by line.
  • Human-sized output. A joined timeline for one host should fit one screen; the tool caps per-host detail and writes the full version to a file.
#!/usr/bin/env python3
"""chainjoin.py — delivery-chain timeline joiner.
Usage:
python chainjoin.py --proxy proxy.csv --dns dns.csv \
--edr edr.csv --host 10.1.2.33 \
[--window 120] [--out timeline.md]
CSV schemas (headers required):
proxy.csv : time,host,url
dns.csv : time,host,query
edr.csv : time,host,image,cmdline,parent
All times ISO-8601. stdlib only.
"""
import argparse, csv, os, sys
from datetime import datetime, timedelta
def parse_time(s):
s = s.strip().replace("Z", "+00:00")
return datetime.fromisoformat(s)
def load(path, source, mapper):
"""Yield normalized events from one source CSV."""
if not path or not os.path.exists(path):
return
with open(path, encoding="utf-8", errors="replace") as fh:
for i, row in enumerate(csv.DictReader(fh), 1):
try:
ev = mapper(row, source, i)
if ev:
yield ev
except (KeyError, ValueError):
continue
def map_proxy(row, source, line):
return {"time": parse_time(row["time"]),
"host": row["host"], "source": source,
"event": "HTTP fetch",
"detail": row["url"], "line": line}
def map_dns(row, source, line):
return {"time": parse_time(row["time"]),
"host": row["host"], "source": source,
"event": "DNS query",
"detail": row["query"], "line": line}
def map_edr(row, source, line):
return {"time": parse_time(row["time"]),
"host": row["host"], "source": source,
"event": "process",
"detail": (f"{row['parent']} -> {row['image']}"
f" | {row['cmdline'][:90]}"),
"line": line}
def correlate(events, window):
"""Group events per host into time-ordered timeline with joins."""
by_host = {}
for e in events:
by_host.setdefault(e["host"], []).append(e)
timelines = {}
for host, evs in by_host.items():
evs.sort(key=lambda e: e["time"])
joined = []
for i, e in enumerate(evs):
joins = []
for j in range(i + 1, len(evs)):
if evs[j]["time"] - e["time"] > \
timedelta(seconds=window):
break
if evs[j]["source"] != e["source"]:
joins.append(evs[j])
e = dict(e)
e["joins"] = joins
joined.append(e)
timelines[host] = joined
return timelines
def render(host, timeline, full_fh=None):
lines = [f"## host {host} — {len(timeline)} events", "",
"| time | source | event | detail | joins |",
"|---|---|---|---|---|"]
for e in timeline:
join_txt = "; ".join(
f"{j['source']}:{j['event']}" for j in e["joins"]) or "-"
lines.append(
f"| {e['time'].isoformat(timespec='seconds')} "
f"| {e['source']} | {e['event']} "
f"| {e['detail'][:70]} | {join_txt} |")
lines.append("")
if full_fh:
for e in timeline:
full_fh.write(
f"{e['time'].isoformat()} [{e['source']}] "
f"{e['event']} :: {e['detail']} "
f"(line {e['line']})\n")
return lines
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--proxy"); ap.add_argument("--dns")
ap.add_argument("--edr")
ap.add_argument("--host", help="limit to one host")
ap.add_argument("--window", type=int, default=120)
ap.add_argument("--out")
args = ap.parse_args()
events = []
events += list(load(args.proxy, "proxy", map_proxy))
events += list(load(args.dns, "dns", map_dns))
events += list(load(args.edr, "edr", map_edr))
if not events:
sys.exit("no events loaded — check CSV paths/schemas")
timelines = correlate(events, args.window)
hosts = [args.host] if args.host else \
sorted(timelines, key=lambda h: -len(timelines[h]))
full_fh = open(args.out, "w") if args.out else None
for h in hosts[:5]:
for line in render(h, timelines.get(h, []), full_fh):
print(line)
if full_fh:
full_fh.close()
print(f"[+] full timeline: {args.out}")
if __name__ == "__main__":
main()
  • The per-source mappers (lines 30-47) are the tool’s whole portability story: adding NetFlow or mail-trace sources later means writing one mapper function with the same six-field output shape — the join logic never changes.
  • correlate() (lines 50-73) implements the window join: per host, events sorted by time, and each event annotated with other-source events inside the window (the inner loop breaks at the window edge — events are sorted, so the scan is linear). The joins list is rendered in the report so cross-source confirmation is visible, e.g., a process row (EDR) joined with the HTTP fetch (proxy) that preceded it by 4 seconds.
  • The empty-joins rows stay in the output — uncorrelated events are where missed deliveries hide, and a timeline that hides them biases the analysis toward what already fits.
  • render() (lines 76-96) emits the markdown table plus, with --out, the full-fidelity line-cited version for the incident record — the two-audience split (screen summary vs. evidence file) mirrors how chain analyses are actually consumed.

fabricate a coherent mini-incident across three sources (same host)

Section titled “fabricate a coherent mini-incident across three sources (same host)”

$ python3 -c ” import csv w=csv.writer(open(‘edr.csv’,‘w’)); w.writerow([‘time’,‘host’,‘image’,‘cmdline’,‘parent’]) w.writerow([‘2026-10-14T09:41:03+00:00’,‘10.1.2.33’,‘WINWORD.EXE’,‘/x invoice.docm’,‘OUTLOOK.EXE’]) w.writerow([‘2026-10-14T09:41:21+00:00’,‘10.1.2.33’,‘powershell.exe’,‘-enc AAAA…’,‘WINWORD.EXE’]) p=csv.writer(open(‘proxy.csv’,‘w’)); p.writerow([‘time’,‘host’,‘url’]) p.writerow([‘2026-10-14T09:41:18+00:00’,‘10.1.2.33’,‘http://cdn-check.top/a.xll’]) d=csv.writer(open(‘dns.csv’,‘w’)); d.writerow([‘time’,‘host’,‘query’]) d.writerow([‘2026-10-14T09:41:17+00:00’,‘10.1.2.33’,‘cdn-check.top’]) ” $ python3 chainjoin.py —edr edr.csv —proxy proxy.csv —dns dns.csv
—host 10.1.2.33

timesourceeventdetailjoins
2026-10-14T09:41:03+00:00edrprocessOUTLOOK.EXE -> WINWORD.EXE-
2026-10-14T09:41:17+00:00dnsDNS querycdn-check.topproxy:HTTP fetch
2026-10-14T09:41:21+00:00edrprocessWINWORD.EXE -> powershell.exe-

widen the window and confirm the fetch->spawn join appears

Section titled “widen the window and confirm the fetch->spawn join appears”

$ python3 chainjoin.py —edr edr.csv —proxy proxy.csv —window 300 | grep -c “joins”

The joiner’s value compounds with telemetry quality: the same three sources (proxy, DNS, EDR process) that feed it are the ones your detection engineering should baseline, and every gap you discover while joining (a proxy blind spot, an EDR host reporting late) is a control finding worth its own ticket. Your own tooling hygiene: pure log parsing, zero endpoint footprint. The defensive anchor points from the chain itself, ranked by cost-effectiveness: DMARC enforcement at p=reject for your sending domains (kills lookalike delivery structurally), gateway verdict review with a miss-ledger (every sandbox pass that later detonates becomes a rule), and the Office-lineage endpoint alert from M12. Wazuh/SIEM mapping of the joined timeline is deployment-specific [verify]; Suricata contributes only where chain traffic is plaintext — treat network evidence in TLS-heavy chains as corroboration, and endpoint+gateway evidence as primary. The scope-expansion output (other recipients/hosts on the same chain) deserves its own detection: a campaign-hash (M15) scheduled hunt across your mail trace and proxy history turns one report into a fleet-wide sweep.

  1. Add a mail-trace adapter (365 UAL / message-trace CSV) as a fourth source so delivery verdicts sit inline in the timeline.
  2. Fuzzy-join URLs to DNS queries (registrable-domain extraction) so the fetch→resolve join survives CDN CNAME chains.
  3. Emit a per-chain campaign fingerprint (sender domain + template host + payload hash) feeding M15 clustering directly.
  4. Add gap detection: flag timeline spans where a host went silent mid-chain (host offline? sensor gap?) — the missing-evidence prompt.
  5. Render an ASCII chain diagram per host for the report figure, from the joined timeline automatically.
SignalSourceIDNotes
DMARC/SPF fail on inboundMail gatewayheader/log verdictStructural kill, config-level fix
Campaign-wide recipient matchMessage tracegateway queryScope expansion in minutes
URL rewrite + detonation verdictSafe-links / sandboxgateway logReview misses as backlog
Office → script host lineageSysmonEID 1 parent chainNear-zero FP (M12)
Payload fetch to odd domainSysmon + proxyEID 22/3 + proxy logTemplate/payload host pivot
Script-block contentPowerShell4104Decoded stage visibility
Defender detection on attachmentDefender1116/1117Gateway-miss confirmation
Stealer/ransomware stage behaviorEDR/EDR+SysmonEID 11/10/13Payload-end anchors (M8/M9)
Wazuh correlationHIDS/SIEM[verify rule IDs per deployment]Join endpoint+mail+proxy per host
Chain replay validationThreat-informed testlocal exercisesProve the fix works (T-series bridge)

Delivery-chain evasion attacks the weakest telemetry hop, and attackers probe for it systematically. Header forgery — fake Received lines, spoofed display names, aligned lookalike domains — defeats header-based triage that reads top-down (the first Received is the attacker’s own server); counters read bottom-up and trust Authentication-Results added by your edge, not headers the sender supplied. Authentication alignment abuse — sending from a legitimately SPF/DKIM-valid domain they control, or abusing a compromised mailbox at a real org — defeats auth-only filtering; counters weigh reputation and content, not just pass/fail. TDS gating shows scanners a benign page based on IP/UA/referrer and only the real victim’s environment reaches the payload — counters analyze from victim-side telemetry (what the user’s session actually fetched), not from replay attempts, and treat non-replaying URLs as signals. Attachment format roulette (macro → XLM → DDE → LNK → OneNote → ISO container waves) defeats specific content rules — counters anchor on the execution lineage, which no container trick changes. Chain splitting across victims’ legitimate services (payload on file-sharing, redirects via URL shorteners, exfil via cloud drives) hides each hop inside a trusted service; counters correlate across services — exactly what the joiner automates. The meta-pattern: every evasion adds a hop or a gate, and each hop is one more place where the chain leaves evidence in someone’s logs — the defender’s structural advantage is that the attacker must be right at every hop while the analyst only needs the one hop where evidence survived.

⚠️ What caught me: early in my chain-analysis work I wrote up an incident with the redirect chain I captured from my analysis VM — clean, complete, plausible. The victim’s proxy log told a different story: the TDS served my VM a decoy page and the real payload from a different host entirely, and I’d built my narrative on the attacker’s stage-play. I now treat every manually replayed URL as the scanner’s view, and I build the chain from victim-side telemetry first — replay attempts are for validating controls later, never for establishing what happened.

  1. Build a lab mailbox: send yourself two messages — one from a properly configured personal domain (SPF/DKIM valid) and one spoofing your own display name from a free account — then run chainmail.py on both and identify every marker that separates them.
  2. Forge a full Received chain in a test message and confirm the analyzer reads bottom-up: which hop does it treat as origin, and why is the attacker-forged top hop irrelevant?
  3. Generate the three CSVs from the joiner’s test block, then break them (shift one timestamp by 10 minutes, change one hostname) and watch the joins disappear — calibration for the window parameter and sensitivity to dirty data.
  4. Take one real-world public maldoc campaign write-up, and rebuild its delivery chain using only the artifacts the write-up publishes (headers, URLs, hashes) — grading your chain reconstruction against a known answer is the fastest skill check.
  5. Replay-validate a control: pick one blocked URL from a past incident (lab-safe), replay it from your analysis host, and document the difference between your view and the victim’s recorded view — the TDS-gating lesson embodied.
  • Technical: Order the email header fields an analyst trusts when reconstructing origin, and justify why Received lines are read bottom-up.
  • Technical: What exactly does DMARC evaluate beyond SPF and DKIM results, and why does a technically-valid SPF pass still fail DMARC?
  • Scenario: One user reports a phish; gateway shows no detection. List your first five queries across the chain and what each answers.
  • Conceptual: Why does TDS gating make replay-based URL analysis structurally unreliable, and what telemetry replaces it?
  • Conceptual: How does scope expansion (same-campaign recipients) change the priority of a single-user phish report?
  • Deep-dive: Design the gap-detection logic for a joined delivery timeline — what patterns distinguish sensor blindness from genuine attacker pause?
  • Practical: Your gateway passed 380 of 4,200 campaign messages and your EDR caught 4 of 9 executions. What do you fix first, and how do you prove the fix works next campaign?

M3 (Propagation Analysis (WannaCry CVE-2017-0144)) · M9 (Infostealer Analysis) · M12 (Script and Macro Malware Analysis) · M15 (Threat Intelligence and Campaign Clustering) · E17 (Network Detection)