Delivery Chain Analysis
Definition
Section titled “Definition”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).
Why It Matters
Section titled “Why It Matters”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.
Detailed Explanation
Section titled “Detailed Explanation”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 timelineReconstruction 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.
Common Techniques
Section titled “Common Techniques”| Technique | Where | What it reveals | Notes |
|---|---|---|---|
| Received-chain parsing | Email headers | True path, hop delays, forgeries | Read bottom-up; top entries are attacker’s |
| SPF/DKIM/DMARC reading | Authentication-Results | Auth failures and alignment gaps | d= vs From: domain alignment matters |
| Reply-To / Return-Path diff | Headers | Reply hijack setups | Display-name impersonation won’t show here |
| Message-trace correlation | Gateway/365 UAL | All recipients of the campaign | Scope expansion in minutes |
| Redirect-chain walk | Proxy/browser | TDS → landing → payload host | TDS gates by referrer/IP — non-replay is normal |
| URL parameter analysis | Redirect URLs | Campaign IDs, sub-affiliate tags | IDs persist across payloads (M15) |
| Attachment pivoting | Maldoc analysis | Template/payload hosts | M12 tooling feeds this module |
| Sandbox verdict review | Gateway/sandbox | What controls saw and missed | Root-cause anchor |
| Beacon backtracking | EDR/DNS (M7) | C2 domains → campaign infra | Links endpoint to delivery infra |
| Campaign replay attempt | Curl/headless | Whether infra still live | Threat-informed defense validation |
Full Working Code
Section titled “Full Working Code”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, sysfrom email import policyfrom email.parser import BytesParserfrom email.utils import parseaddr, parsedate_to_datetimeimport 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()Code Walkthrough
Section titled “Code Walkthrough”received_chain()(lines 22-36) collects everyReceivedheader 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 asNonerather 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) readsAuthentication-Resultsfor all three mechanisms with the full verdict vocabulary (pass/fail/softfail/neutral/none/temperror/permerror), then falls back to legacyReceived-SPF. Theresults[key] is Noneguard 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 emptyRe: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
.emlset and the output becomes a per-message comparison table for spotting infrastructure reuse.
How It Reaches The Target
Section titled “How It Reaches The Target”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.
Exploitation Chain
Section titled “Exploitation Chain”- Launch — campaign sent from compromised SMB MTA; 4,200 recipients across 3 orgs
- Auth — 91% fail DMARC and die at gateway; 380 messages pass on aligned custom domain
- Delivery — 37 users open; 9 enable content on “invoice” maldoc (M12 chain fires)
- Fetch — remote template fetched from template host; 4 payloads detonate
- Payload — RedLine stealer beacons; credentials exfil (M9)
- Report — user 7 reports; chain analysis begins from that mailbox
- Scope — message trace + proxy logs: 37 openers, 9 executors identified in 40 minutes
- Hardening — gateway rule blocks template host + sender domain; DMARC enforcement gap fixed at failing hop
Building Your Own Tool
Section titled “Building Your Own Tool”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.
Design decisions
Section titled “Design decisions”- 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.
Full source
Section titled “Full source”#!/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,parentAll times ISO-8601. stdlib only."""import argparse, csv, os, sysfrom 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()Walkthrough
Section titled “Walkthrough”- 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). Thejoinslist is rendered in the report so cross-source confirmation is visible, e.g., aprocessrow (EDR) joined with theHTTP 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.
Compilation and test procedure
Section titled “Compilation and test procedure”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
host 10.1.2.33 — 3 events
Section titled “host 10.1.2.33 — 3 events”| time | source | event | detail | joins |
|---|---|---|---|---|
| 2026-10-14T09:41:03+00:00 | edr | process | OUTLOOK.EXE -> WINWORD.EXE | - |
| 2026-10-14T09:41:17+00:00 | dns | DNS query | cdn-check.top | proxy:HTTP fetch |
| 2026-10-14T09:41:21+00:00 | edr | process | WINWORD.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”
Detection considerations
Section titled “Detection considerations”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.
Improvements to add
Section titled “Improvements to add”- Add a mail-trace adapter (365 UAL / message-trace CSV) as a fourth source so delivery verdicts sit inline in the timeline.
- Fuzzy-join URLs to DNS queries (registrable-domain extraction) so the fetch→resolve join survives CDN CNAME chains.
- Emit a per-chain campaign fingerprint (sender domain + template host + payload hash) feeding M15 clustering directly.
- Add gap detection: flag timeline spans where a host went silent mid-chain (host offline? sensor gap?) — the missing-evidence prompt.
- Render an ASCII chain diagram per host for the report figure, from the joined timeline automatically.
Detection — Defender View
Section titled “Detection — Defender View”| Signal | Source | ID | Notes |
|---|---|---|---|
| DMARC/SPF fail on inbound | Mail gateway | header/log verdict | Structural kill, config-level fix |
| Campaign-wide recipient match | Message trace | gateway query | Scope expansion in minutes |
| URL rewrite + detonation verdict | Safe-links / sandbox | gateway log | Review misses as backlog |
| Office → script host lineage | Sysmon | EID 1 parent chain | Near-zero FP (M12) |
| Payload fetch to odd domain | Sysmon + proxy | EID 22/3 + proxy log | Template/payload host pivot |
| Script-block content | PowerShell | 4104 | Decoded stage visibility |
| Defender detection on attachment | Defender | 1116/1117 | Gateway-miss confirmation |
| Stealer/ransomware stage behavior | EDR/EDR+Sysmon | EID 11/10/13 | Payload-end anchors (M8/M9) |
| Wazuh correlation | HIDS/SIEM | [verify rule IDs per deployment] | Join endpoint+mail+proxy per host |
| Chain replay validation | Threat-informed test | local exercises | Prove the fix works (T-series bridge) |
Evasion Techniques
Section titled “Evasion Techniques”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.
Lab Practice
Section titled “Lab Practice”- 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.pyon both and identify every marker that separates them. - 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?
- 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.
- 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.
- 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.
Interview Questions
Section titled “Interview Questions”- 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?
Related Modules
Section titled “Related Modules”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)