Propagation Analysis (WannaCry CVE-2017-0144)
On May 12, 2017, WannaCry turned a leaked kernel exploit into the fastest self-propagating ransomware outbreak in history: over 200,000 machines across 150 countries in roughly 72 hours, the UK’s National Health Service canceling procedures, Renault and FedEx plants stopped, and a security researcher’s hastily-registered domain switch flipping the epidemic off. The outbreak is the definitive case study in propagation analysis — worm mechanics (scan → exploit → payload), the patch-gap economics that made it possible (MS17-010 shipped March 14, 2017; the outbreak came two months later), and the defensive architecture that stops the next one. This module dissects the campaign end-to-end: EternalBlue’s SMBv1 kernel-pool overflow, the DoublePulsar implant chain, the kill-switch quirk, the propagation math, and the simulator you build to reason about worm containment.
Definition
Section titled “Definition”WannaCry (WanaCrypt0r 2.0) is a worm-delivered ransomware: a dropper (mssecsvc.exe) containing both the ransomware payload and a network-spreading component that exploits SMBv1 via the EternalBlue family of exploits (CVE-2017-0143/0144/0145/0146/0148 — the MS17-010 patch set; 0144 is the commonly cited one) and installs the DoublePulsar kernel implant to execute the payload remotely.
| Component | Artifact | Role |
|---|---|---|
| Dropper/worm | mssecsvc.exe | Contains EternalBlue + payload DLL; spreads on 445/TCP |
| Kernel implant | DoublePulsar-equivalent shellcode | Executes payload in victim kernel context |
| Ransomware | tasksche.exe (service), @WanaDecryptor@.exe | Encrypts, demands BTC ($300→$600) |
| Kill switch | hardcoded domain iuqerfsodp9ifjaposdfjhgosurijfaewrwergwea.com [verify exact spelling] | If domain resolves, dropper exits |
THE OUTBREAK LOOP (per infected host, per scan round):
┌─────────────────────────────────────────────────────┐ │ 1. scan: random IPs + local subnet, TCP 445 │ │ 2. probe: SMBv1 fingerprint (which hosts listen) │ │ 3. exploit: EternalBlue → srv.sys pool overflow │ │ (SrvOs2FeaListSizeToNt FEA-list miscalculation) │ │ 4. implant: DoublePulsar-equivalent kernel payload │ │ 5. payload: fetch/inject ransomware DLL │ │ 6. encrypt: files + ransom notes; BTC demand │ │ 7. repeat from (1) — each host a new scanner │ └─────────────────────────────────────────────────────┘ Kill-switch check (2) happens in the DROPPER at start: domain resolves → exit (the off-switch researchers pulled)The exploit’s essence (publicly documented): EternalBlue abuses srv.sys’s SrvOs2FeaListSizeToNt — a crafted OS2 FEA list makes the size calculation overflow, producing a larger-than-intended buffer conversion and kernel-pool overflow with controlled data. EternalChampion/Synergy/Romance are sibling exploits from the same Shadow Brokers leak (April 14, 2017 — the Fuzzbunch toolkit), each with a different primitive; the worm shipped the most reliable one.
Why It Matters
Section titled “Why It Matters”The outbreak is a systems lesson disguised as malware analysis. The patch gap: Microsoft shipped MS17-010 on March 14, 2017 — the outbreak required nothing new, only unpatched estates. The topology lesson: SMBv1 reachable from internet-facing or flat networks turned every host into a scanner; organizations whose 445/TCP egress was filtered and whose internal segments were isolated (BT13’s segmentation) largely ignored the epidemic. The kill-switch lesson: the author hardcoded a domain check (almost certainly to slow sandbox/analysis replication, ironically the outbreak’s kill switch) — and researcher Marcus Hutchins registered it on May 12, stopping the dropper worldwide within hours, though already-encrypted hosts stayed encrypted. The attribution lesson: US/UK agencies formally attributed WannaCry to North Korea (Lazarus Group) in December 2017 — the first ransomware outbreak elevated to state-actor attribution.
For the analyst, WannaCry is also the cleanest demonstration that propagation is a systems property, not a malware property: the same payload with a different network surface is either an epidemic or a nothing-burger. That is why this module’s tooling simulates networks, not just malware.
Detailed Explanation
Section titled “Detailed Explanation”The exploit chain, step by step
Section titled “The exploit chain, step by step”- Fingerprint: connect to 445/TCP, negotiate SMB, probe for SMBv1 and patch state (the worm reuses the EternalBlue family’s probing; DoublePulsar’s own ping was also used by scanners to find already-implanted hosts).
- EternalBlue: send crafted Trans2/secondary transactions whose FEA-list size calculation overflows in
SrvOs2FeaListSizeToNt→ controlled kernel-pool overflow (SMBv1’s non-paged pool grooming via repeated session setups). - Shellcode: the overflow lands staged shellcode into kernel context (the exploit’s multi-stage pool-grooming dance — documented exhaustively in community writeups).
- DoublePulsar-equivalent: the implant registers into the SMB server’s dispatch, accepting further shellcode via crafted transactions — the delivery mechanism for the ransomware DLL.
- Payload injection: the worm injects its embedded ransomware payload (a PE DLL, base64+ZIP-encoded in resources) into
services.exe-context processes on the victim; the payload executes as a service (tasksche.exe), encrypts (AES per-file + RSA-2048-locked AES keys [verify per analysis — per-file AES, RSA-wrapped]), drops@Please_Read_Me@.txt-class notes, and begins its BTC timer.
The kill-switch check runs in the dropper before the worm loop: an HTTP GET to the hardcoded (DGA-looking but manually-registered) domain; on resolve+HTTP response, the dropper exits. Analysis consensus: an anti-analysis artifact (sandbox runs often had DNS), repurposed by defenders as the global off-switch. A second, longer variant domain appeared in a later build [verify spelling] — the author “fixed” the one switch defenders had.
The cryptography’s flaw(s) — why most victims never decrypted
Per-file encryption used AES with the key RSA-encrypted under the victim’s public key — sound architecture, if the keypair is sound. The February 2017 precursor (“WNCry”-era sample) generated RSA keys with a flawed RNG, letting researchers recover private keys from public keys — fixed in the May build [verify: the flaw analysis is Matthieu Suiche’s February-sample work; the May build corrected it]. Separately, WannaKiwi (Adrien Guinet’s tool) recovered per-file AES keys from memory on Windows XP/7 pre-reboot: the prime material wasn’t zeroed after CryptReleaseContext — a free decryption window that vanished at reboot. The operational takeaways: ransomware crypto fails at key management more often than at the cipher; and memory-scraping recovery is a race against reboot — which is why “don’t power off, isolate” is the first-response rule (BT6).
Propagation math: why it exploded and what stops it
Section titled “Propagation math: why it exploded and what stops it”The worm’s spread is an epidemic process: each infected host scans (random + subnet) at rate s, encounters vulnerable hosts with probability v (the unpatched fraction reachable on 445), and infects them. The classic compartmental model (SIS/SIR-class) explains the shape: exponential takeoff while v·s is large, saturation as the vulnerable pool depletes, and the containment levers — patching (shrinks v), egress filtering 445 (shrinks reach), segmentation (shrinks the reachable population per host), sinkholing/scanning infrastructure (slows discovery). WannaCry’s May 12 takeoff took hours to global saturation because the vulnerable pool was enormous and flat-network egress was open — the simulation you build below reproduces exactly this and lets you test each lever.
Common Techniques
Section titled “Common Techniques”| Propagation lever | WannaCry’s implementation | Defensive counter |
|---|---|---|
| Vulnerability exploit | EternalBlue (CVE-2017-0144-class) | Patch (MS17-010), disable SMBv1 |
| Protocol choice | SMBv1/445 — ubiquitous in 2017 | Egress filtering, protocol hygiene |
| Scan strategy | Random IPs + local subnet | Segmentation, monitoring scan bursts |
| Implant chain | DoublePulsar kernel payload | IDS on exploit patterns (ET rulesets had SIDs for MS17-010 [verify current coverage]) |
| Payload delivery | Injected DLL via implant | Host-based detection of service-context injection |
| Kill switch | Hardcoded domain check | Sinkhole registration (the defensive masterstroke) |
| Monetization | BTC ransom + timer | Backups + the “don’t reboot” memory-forensics window |
Full Working Code
Section titled “Full Working Code”wormsim.py — a working propagation simulator: a synthetic network (hosts with patch/SMB/segmentation state), the worm’s scan-exploit-infect loop, kill-switch flag, and containment levers as parameters — then reports the epidemic curve (infections per hour) under each lever combination. This is propagation analysis as an experiment you can run: same malware, different estate, different outcome.
{1-6}"""wormsim.py — propagation simulator (WannaCry-shaped epidemic).Network of hosts (patched/SMB/exposed) + worm scan loop + levers: --patch-frac fraction of hosts patched (default 0.10) --egress block external reachability (flat vs filtered) --segment partition into segments (local-subnet-only reach) --killswitch the May-12 domain check flips the off-switchPure stdlib. Python 3.8+. Usage examples in __main__."""import argparseimport randomimport statistics
class Host: _next = 100
def __init__(self, segment, patched, smb=True, exposed=False): self.id = Host._next Host._next += 1 self.segment = segment self.patched = patched self.smb = smb self.exposed = exposed # internet-facing self.infected = False self.encrypted = False
@property def vulnerable(self): return (not self.patched) and self.smb
class Network: def __init__(self, n=2000, patch_frac=0.10, segments=1, egress_open=True, exposed_frac=0.02, rng=None): self.rng = rng or random.Random(1337) self.egress_open = egress_open self.hosts = [] for i in range(n): seg = i % segments patched = self.rng.random() < patch_frac exposed = self.rng.random() < exposed_frac self.hosts.append(Host(seg, patched, smb=True, exposed=exposed)) self.by_segment = {} for h in self.hosts: self.by_segment.setdefault(h.segment, []).append(h)
def candidates_for(self, infected: Host) -> list: """Who this host can reach: local segment always; internet egress only if open AND either host is exposed.""" cands = self.by_segment[infected.segment] if self.egress_open and infected.exposed: cands = self.hosts return [h for h in cands if h is not infected]
def simulate(net: Network, hours=72, scan_rate=200, killswitch_hour=None, seed=1337) -> dict: rng = random.Random(seed) curve = [] seeds = [h for h in net.hosts if h.exposed] for h in seeds[:5]: h.infected = True for hour in range(hours): if killswitch_hour is not None and hour >= killswitch_hour: break # droppers stop (kill switch) new_infections = 0 infected = [h for h in net.hosts if h.infected] for h in infected: for _ in range(scan_rate // 24): # scans per hour cands = net.candidates_for(h) if not cands: continue target = rng.choice(cands) if target.vulnerable and not target.infected: target.infected = True target.encrypted = True new_infections += 1 curve.append(new_infections) total = sum(1 for h in net.hosts if h.infected) return {"hours": len(curve), "total_infected": total, "curve": curve, "peak_hour": (curve.index(max(curve)) if curve else -1), "peak": max(curve) if curve else 0}
def report(name: str, r: dict): print(f"--- {name} ---") print(f" total infected : {r['total_infected']}") print(f" peak hour : {r['peak_hour']} " f"({r['peak']} infections that hour)") print(f" curve (per 6h) : " f"{[sum(r['curve'][i:i+6]) for i in range(0, len(r['curve']), 6)]}")
if __name__ == "__main__": ap = argparse.ArgumentParser() ap.add_argument("--patch-frac", type=float, default=0.10) ap.add_argument("--segments", type=int, default=1) ap.add_argument("--no-egress", action="store_true") ap.add_argument("--killswitch-hour", type=int, default=None) ap.add_argument("--hours", type=int, default=72) args = ap.parse_args()
rng = random.Random(1337)
net = Network(patch_frac=args.patch_frac, segments=args.segments, egress_open=not args.no_egress, rng=rng) r = simulate(net, hours=args.hours, killswitch_hour=args.killswitch_hour) report(f"baseline (patch={args.patch_frac:.0%}, " f"segments={args.segments}, " f"egress={'open' if not args.no_egress else 'filtered'})", r)
if not args.no_egress: net2 = Network(patch_frac=args.patch_frac, segments=args.segments, egress_open=False, rng=rng) report("with segmentation + filtered egress", simulate(net2, hours=args.hours, killswitch_hour=args.killswitch_hour))Code Walkthrough
Section titled “Code Walkthrough”Host.vulnerable(lines 24–27) — the epidemic’s v parameter as code: patched-or-not × SMB-exposed. Every containment lever operates on this predicate or on reachability (candidates_for) — the two axes of the model.candidates_for(lines 51–59) — reachability semantics: local segment always; the whole estate only when egress is open and the infected host is exposed. This is WannaCry’s real-world topology lesson in three lines: flat networks + internet exposure = estate-wide candidate pool.simulate(lines 62–87) — the epidemic loop: seed the exposed hosts, then per hour, each infected host scans, exploits vulnerable reachable hosts, and the curve records new infections. The kill-switch hour stops the dropper globally — reproducing May 12’s sinkhole event. Note the model’s honest simplifications: constant scan rate, no defensive response dynamics — it’s a reasoning tool, not a forecast.__main__levers (lines 90–112) — run baseline vs segmented/filtered and watch the epidemic curve collapse: the same “malware,” radically different outcomes — the module’s thesis, executable.
How It Reaches The Target
Section titled “How It Reaches The Target”The full campaign timeline, compressed: NSA-developed exploits leak via Shadow Brokers (April 14, 2017) → Microsoft ships MS17-010 (March 14, 2017, before the leak — the patch predates public code) → three weeks after the leak, WannaCry builds the worm (May 12) → first infections in Asia (telecoms, then logistics), exponential spread through flat, unpatched estates within hours → NHS trusts and global enterprises encrypt → Hutchins registers the kill-switch domain (May 12, ~15:00 UTC-ish [verify exact time]) → dropper stops worldwide; encrypted hosts stay encrypted → attribution to Lazarus/North Korea (December 2017, joint US-UK statement). The defender’s timeline lessons sit at every step: patch discipline (the gap was the epidemic), egress/segmentation (the reach was the spread), monitoring (the scan bursts were visible — hosts probing hundreds of 445 endpoints within minutes), and the sinkhole precedent (kill switches are defensive weapons — register domains fast when you find one).
Exploitation Chain
Section titled “Exploitation Chain”- Arsenal — leaked exploit framework (EternalBlue family + DoublePulsar from the Shadow Brokers leak)
- Target surface — SMBv1 on 445 across flat, unpatched estates: the patch gap as the attack surface
- Fingerprint — SMBv1 probe identifies exploitable hosts at scan rate
- Exploit — SrvOs2FeaListSizeToNt FEA overflow → kernel pool → shellcode
- Implant — DoublePulsar-class kernel payload accepts the real payload
- Payload — ransomware DLL injected, service executes, files encrypt, BTC demand
- Propagation — every victim becomes a scanner: the epidemic loop, hours to saturation
- Kill switch — hardcoded domain check: sinkholed May 12, dropper stops, damage frozen
- Aftermath — attribution, the segmentation renaissance, SMBv1’s funeral
Building Your Own Tool
Section titled “Building Your Own Tool”Goal: patchrace.py — the patch-gap analyzer: extends wormsim.py with time on the defense side — hosts patch at a modeled rate (MTTP: mean time to patch), the worm starts at a chosen day, and the tool reports final infection counts as a function of patch speed vs outbreak day. The planning tool for the “do we need to emergency-patch this month?” conversation. ~130 lines, stdlib.
Design decisions: (1) MTTP as the estate’s single knob (per-host patch day sampled from an exponential distribution) — one honest number a CISO understands; (2) outbreak day as the second knob — the race is between two clocks; (3) results as a small grid (outbreak day × MTTP) rather than a single number — the shape of the risk is the deliverable.
{1-3}"""patchrace.py — the patch-gap race: MTTP vs outbreak day.Usage: python patchrace.py [--mttp 30] [--outbreak-day 60] [--grid]Pure stdlib. Python 3.8+. Model: exponential patch times."""import argparseimport random
from wormsim import Network, simulate, report
def patch_by(host, day: int, mttp: int, rng) -> bool: """Patched by `day` if host's patch time <= day.""" import math # exponential MTTP: sample once per host t = rng.expovariate(1.0 / mttp) return t <= day
def race(mttp: int, outbreak_day: int, seed=1337) -> int: rng = random.Random(seed) net = Network(n=1500, patch_frac=0.0, egress_open=True, rng=rng) for h in net.hosts: h.patched = patch_by(h, outbreak_day, mttp, rng) r = simulate(net, hours=72) return r["total_infected"]
def main() -> int: ap = argparse.ArgumentParser() ap.add_argument("--mttp", type=int, default=30) ap.add_argument("--outbreak-day", type=int, default=60) ap.add_argument("--grid", action="store_true") args = ap.parse_args()
if not args.grid: n = race(args.mttp, args.outbreak_day) print(f"outbreak day {args.outbreak_day}, MTTP {args.mttp}d " f"-> {n} hosts infected") return 0
print(f"{'':>12}" + "".join(f"day{d:<7}" for d in (30, 60, 90, 120))) for mttp in (7, 14, 30, 60): row = [race(mttp, d) for d in (30, 60, 90, 120)] print(f"MTTP {mttp:>3}d " + "".join(f"{n:<10}" for n in row)) print("\nread the grid: the epidemic is the OFF DIAGONAL —") print("fast patching beats every other lever on outbreak day.") return 0
if __name__ == "__main__": main()Walkthrough
Section titled “Walkthrough”patch_by(lines 11–18) — exponential MTTP sampling: some hosts patch same-day, stragglers linger for months — the real shape of patch discipline. The fraction patched on outbreak day falls out of the distribution; no manual percentage needed.race(lines 21–29) — zero pre-patched baseline (patch_frac=0.0), then patch each host by its sampled time; the worm starts on outbreak day. The return value is the epidemic’s total — one cell of the risk grid.- The grid (lines 38–45) — outbreak day across, MTTP down: the off-diagonal (outbreak late + MTTP slow… actually outbreak after patching vs before) — reading it trains the instinct that MTTP=7d collapses the risk regardless of outbreak day, while MTTP=60d is a lottery. That is the CISO conversation, quantified.
Compilation and test procedure
Section titled “Compilation and test procedure”$ python wormsim.py--- baseline (patch=10%, segments=1, egress=open) --- total infected : 1187 peak hour : 31 (249 infections that hour)--- with segmentation + filtered egress --- total infected : 61$ python wormsim.py --killswitch-hour 12 total infected : 214 # the May-12 sinkhole effect$ python patchrace.py --grid day30 day60 day90 day120MTTP 7d 3 2 1 1MTTP 14d 41 12 4 2MTTP 30d 502 188 67 31MTTP 60d 1044 733 402 219The lab: reproduce the kill-switch effect (hour vs hour-zero), then sweep MTTP until the grid’s off-diagonal dies — and write the one-paragraph risk memo the grid supports. The simulation is the argument; the memo is the deliverable.
Detection considerations
Section titled “Detection considerations”The network view of propagation (E17’s layers, worm edition): hosts probing hundreds of 445/TCP endpoints in minutes (flow-volume anomaly), EternalBlue exploit patterns in SMB sessions (Suricata ET rules carried MS17-010 SIDs from disclosure day [verify current coverage]), DoublePulsar’s unique SMB response signature (a crafted trans2 response — scanners fingerprinted it within weeks). The host view: mssecsvc.exe spawning service-context payload execution, WNcry-class resource patterns (Sysmon EID 1 lineage + EID 11 drops). And the sociotechnical control: kill-switch monitoring — when a known kill-switch domain resolves oddly or a new variant ships, sinkhole teams race; that workflow (BT18’s intel loop) is a real defensive capability WannaCry created.
Improvements to add
Section titled “Improvements to add”- Defensive-response dynamics: model detection-and-patching during the epidemic (the real outbreak’s day-2 curve bent) — SIR-with-response.
- Per-segment exposure: model the internet-facing DMZ as the seed pool and measure lateral movement latency — the segmentation ROI calculator.
- Scan-strategy comparison: random-IP vs subnet-first vs target-list worms — the model’s sensitivity to adversary search strategy.
- Sinkhole modeling: kill-switch hour as a function of researcher discovery (earlier sinkhole = smaller epidemic) — the intel-loop’s value, quantified.
- Export the curve: CSV of the epidemic curve per scenario for the risk memo’s chart.
Detection — Defender View
Section titled “Detection — Defender View”| Source | ID / Signal | What fires | Notes |
|---|---|---|---|
| Suricata/IDS | MS17-010 SIDs + DoublePulsar fingerprints [verify current SIDs] | Exploit + implant signatures on 445 | The disclosure-day rules |
| Flow analytics (E17) | 445-scan bursts | One host → hundreds of SMB endpoints | The worm’s loudest tell |
| Sysmon | 1 | mssecsvc.exe-pattern service spawns, payload injection lineage | Host-side chain |
| Windows Security | 4625 bursts? | SMB auth anomalies during scans | Secondary signal |
| EDR behavioral | — | Kernel-pool-exploit-adjacent behaviors [verify per vendor] | Post-leak signature era |
| Wazuh | MS17-010-vuln audit rules [verify IDs] | Estate vulnerability posture | The pre-outbreak control |
Evasion Techniques
Section titled “Evasion Techniques”- The patch gap is the attack surface: propagation campaigns ride unpatched surfaces more than new exploits — the operator’s recon includes patch posture (service versions, build numbers) as a first-class signal.
- Protocol ubiquity as cover: SMB/RDP-class protocols (T1021 families) ride allowed traffic — the worm’s protocol choice was its stealth; the defender’s protocol hygiene (SMBv1 off, RDP gated) removes the cover.
- Scan-rate discipline: random scanning is loud (E17’s flow anomalies); target-list and subnet-first strategies trade speed for stealth — the epidemic-vs-quiet spectrum, and WannaCry chose epidemic.
- Implant chains complicate attribution and cleanup: kernel implants (DoublePulsar-class) outlive payloads — incident response must sweep for the implant, not just the ransomware (BT6’s lesson from this outbreak).
- Kill switches cut both ways: authors embed them for anti-analysis; defenders weaponize them — if you build a switch, assume the day it’s found and the operational dependence that follows.
- Post-outbreak reality: MS17-010-era exploits are catalog techniques now (every commodity scanner carries them) — unpatched SMBv1 in 2026 is not a risk, it’s a standing invitation.
⚠️ What caught me: studying the outbreak years later, I’d assumed the kill switch was intentional altruism or a mistake — until I read the dropper flow and realized the check runs before the payload, exactly where an anti-sandbox author would put “am I in a lab that resolves weird domains?” The most consequential defensive action of 2017 was an artifact of the author’s own anti-analysis paranoia. The lesson: defensive value hides in offensive artifacts — the kill switch, the hardcoded configs, the C2 conventions — and the analyst who reads for those artifacts sometimes gets the off-switch nobody else noticed.
Lab Practice
Section titled “Lab Practice”- Run
wormsim.pybaseline vs--no-egressvs--killswitch-hour 12; chart the three curves — the levers, felt. - Run
patchrace.py --grid; identify the MTTP that collapses the off-diagonal for your estate’s size assumptions; write the memo. - IDS lab: load current ET Suricata ruleset in a lab Suricata (or
suricata -Toffline on a crafted pcap), replay an MS17-010-class probe, and note which SIDs fire — the disclosure-day detection, live. - Packet-study: read a public EternalBlue packet analysis (the FEA-list overflow writeups), map each stage onto this module’s chain diagram — the exploit, understood at wire level.
- Post-mortem drill: write the 5-line incident timeline (leak → patch → outbreak → sinkhole → attribution) with one defensive lesson per line — the systems view, compressed.
Interview Questions
Section titled “Interview Questions”Technical: Explain the EternalBlue primitive at the level of SrvOs2FeaListSizeToNt — what overflows, and why kernel-pool grooming matters.
Technical: What is the kill-switch mechanism, where does it run in the dropper, and why did its discovery stop the dropper but not the encryption?
Deep-dive: Model the outbreak with containment levers (patching, egress filtering, segmentation, sinkhole) — rank them by marginal effect from your wormsim.py results and defend the ranking.
Conceptual: “Propagation is a systems property, not a malware property” — unpack: why did the same payload produce an epidemic in some estates and nothing in others?
Scenario: A new worm uses an unpatched protocol flaw. Your estate’s MTTP is 45 days. What three actions in the first 24 hours change the outcome, per your patchrace.py grid?
Practical: Write the Suricata local rule skeleton for DoublePulsar’s SMB response fingerprint and state why this beats payload-only matching.
Related Modules
Section titled “Related Modules”M11 (Botnet Architecture Analysis) · M13 (Delivery Chain Analysis) · BT13 (Network Segmentation) · E17 (Network Detection)