Skip to content

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.

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.

ComponentArtifactRole
Dropper/wormmssecsvc.exeContains EternalBlue + payload DLL; spreads on 445/TCP
Kernel implantDoublePulsar-equivalent shellcodeExecutes payload in victim kernel context
Ransomwaretasksche.exe (service), @WanaDecryptor@.exeEncrypts, demands BTC ($300→$600)
Kill switchhardcoded 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.

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.

  1. 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).
  2. 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).
  3. Shellcode: the overflow lands staged shellcode into kernel context (the exploit’s multi-stage pool-grooming dance — documented exhaustively in community writeups).
  4. DoublePulsar-equivalent: the implant registers into the SMB server’s dispatch, accepting further shellcode via crafted transactions — the delivery mechanism for the ransomware DLL.
  5. 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.

Propagation leverWannaCry’s implementationDefensive counter
Vulnerability exploitEternalBlue (CVE-2017-0144-class)Patch (MS17-010), disable SMBv1
Protocol choiceSMBv1/445 — ubiquitous in 2017Egress filtering, protocol hygiene
Scan strategyRandom IPs + local subnetSegmentation, monitoring scan bursts
Implant chainDoublePulsar kernel payloadIDS on exploit patterns (ET rulesets had SIDs for MS17-010 [verify current coverage])
Payload deliveryInjected DLL via implantHost-based detection of service-context injection
Kill switchHardcoded domain checkSinkhole registration (the defensive masterstroke)
MonetizationBTC ransom + timerBackups + the “don’t reboot” memory-forensics window

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-switch
Pure stdlib. Python 3.8+. Usage examples in __main__.
"""
import argparse
import random
import 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))
  • 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.

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

  1. Arsenal — leaked exploit framework (EternalBlue family + DoublePulsar from the Shadow Brokers leak)
  2. Target surface — SMBv1 on 445 across flat, unpatched estates: the patch gap as the attack surface
  3. Fingerprint — SMBv1 probe identifies exploitable hosts at scan rate
  4. Exploit — SrvOs2FeaListSizeToNt FEA overflow → kernel pool → shellcode
  5. Implant — DoublePulsar-class kernel payload accepts the real payload
  6. Payload — ransomware DLL injected, service executes, files encrypt, BTC demand
  7. Propagation — every victim becomes a scanner: the epidemic loop, hours to saturation
  8. Kill switch — hardcoded domain check: sinkholed May 12, dropper stops, damage frozen
  9. Aftermath — attribution, the segmentation renaissance, SMBv1’s funeral

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 argparse
import 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()
  • 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.
$ 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 day120
MTTP 7d 3 2 1 1
MTTP 14d 41 12 4 2
MTTP 30d 502 188 67 31
MTTP 60d 1044 733 402 219

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

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.

  1. Defensive-response dynamics: model detection-and-patching during the epidemic (the real outbreak’s day-2 curve bent) — SIR-with-response.
  2. Per-segment exposure: model the internet-facing DMZ as the seed pool and measure lateral movement latency — the segmentation ROI calculator.
  3. Scan-strategy comparison: random-IP vs subnet-first vs target-list worms — the model’s sensitivity to adversary search strategy.
  4. Sinkhole modeling: kill-switch hour as a function of researcher discovery (earlier sinkhole = smaller epidemic) — the intel-loop’s value, quantified.
  5. Export the curve: CSV of the epidemic curve per scenario for the risk memo’s chart.
SourceID / SignalWhat firesNotes
Suricata/IDSMS17-010 SIDs + DoublePulsar fingerprints [verify current SIDs]Exploit + implant signatures on 445The disclosure-day rules
Flow analytics (E17)445-scan burstsOne host → hundreds of SMB endpointsThe worm’s loudest tell
Sysmon1mssecsvc.exe-pattern service spawns, payload injection lineageHost-side chain
Windows Security4625 bursts?SMB auth anomalies during scansSecondary signal
EDR behavioral—Kernel-pool-exploit-adjacent behaviors [verify per vendor]Post-leak signature era
WazuhMS17-010-vuln audit rules [verify IDs]Estate vulnerability postureThe pre-outbreak control
  • 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.

  1. Run wormsim.py baseline vs --no-egress vs --killswitch-hour 12; chart the three curves — the levers, felt.
  2. Run patchrace.py --grid; identify the MTTP that collapses the off-diagonal for your estate’s size assumptions; write the memo.
  3. IDS lab: load current ET Suricata ruleset in a lab Suricata (or suricata -T offline on a crafted pcap), replay an MS17-010-class probe, and note which SIDs fire — the disclosure-day detection, live.
  4. 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.
  5. Post-mortem drill: write the 5-line incident timeline (leak → patch → outbreak → sinkhole → attribution) with one defensive lesson per line — the systems view, compressed.

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.

M11 (Botnet Architecture Analysis) · M13 (Delivery Chain Analysis) · BT13 (Network Segmentation) · E17 (Network Detection)