David Rosas — research
Oct 13, 2026·WannaCry·Reverse engineering

A jump that does not jump and a ZIP missing 177 KB.

Somebody took the WannaCry worm and removed its kill switch with a hex editor: the domain URL zeroed, and the jump that checked it neutered. Two patches, and the sinkhole that stopped the May 2017 outbreak stops being useful. But in the same package, the ZIP resource holding the encryption engine is missing its last 177 KB, and with them the central directory, so the malware's own ZIP library cannot open it. The result is a worm that spreads forever and encrypts nothing.

Sample d724d8cc6420f06e8a48752f0da11c66 First seen 14 May 2017 Family WannaCry · mssecsvc.exe Target Windows · native PE32

What WannaCry was, and what the sources say

It is worth starting with the context, because it is what gives meaning to everything else. WannaCry was the ransomware that behaved like a worm: instead of waiting for somebody to open an attachment, it copied itself across the network on its own, through a flaw in SMBv1. On 12 May 2017 that was enough to stop hospitals, factories and railways over a single weekend.

What is publicly documented, with the sources alongside:

What the sources do not say. This sample is not the WannaCry of 12 May: it is a patched variant, and public sources do not establish who patched it. On 14 May 2017 Mandiant described a variant "that does not contain the domain contact killswitch functionality" and added, in as many words, that the change "may have been implemented by a third party after the malware was compiled rather than by the operators". Of the same variant it said that "the ransomware component of this variant appears corrupted and does not function in test environments". Both observations match what is in front of me.

The following day, Bitdefender argued in the press that the variants with and without the switch were run by the same group, because the ransom wallets were the same. That argument distinguishes nothing, and the binary shows why: the three Bitcoin addresses are hardcoded inside tasksche.exe, which is exactly the part the patch does not touch. Identical wallets are what you would expect whoever edits the dropper. So I leave it where the sources leave it: unknown.

A zeroed URL and a neutered jump

The file is the worm component, mssecsvc.exe: a native PE32 of 3,723,264 bytes, four sections, image base 0x400000. 93% of its size is the resource section, because it carries the ransomware installer inside it as a plain MZ.

The original behaviour, at 0x408140, is this:

mov  esi, 0x4313d0        ; "http://www.iuqerfsodp9ifjaposdfjhgosurijfaewrwergwea.com"
rep  movsd                ; copy the URL to the stack
call InternetOpenUrlA
test edi, edi
jne  0x4081bc             ; domain answers -> close handles and EXIT
call sub_408090           ; no answer -> infect

In this sample there are two changes, and both are visible directly in the file:

WhereOriginalThis sampleEffect
0x4313d0
(offset 0x313d0)
57 bytes holding the URL00 00 00 …InternetOpenUrlA("") always fails; no HTTP traffic and no DNS query
0x4081a5
(offset 0x81a5)
75 15 — jne 0x4081bc75 00 — jne +0both branches fall into the infection
ORIGINAL THIS SAMPLE mov esi, 0x4313d0 57 B holding the domain URL mov esi, 0x4313d0 57 B of zeroes InternetOpenUrlA(url) the domain answers or not InternetOpenUrlA("") always fails jne 0x4081bc if it answers: exit jne +0 (75 00) never jumps sub_408090: infect only if no answer sub_408090: infect always
Either patch would have been enough on its own. With both, restoring the URL does not bring the switch back either: the jump no longer exists.

The original exit branch is still there, as dead code right after the ret, at 0x4081bc: ff d6 57 ff d6 5f 33 c0 5e 83 c4 50 c2 10 00. Nobody deleted it, it is simply unreachable.

And the decompiler confirms it by removing the comparison entirely, because a condition whose two branches go to the same place is not a condition:

uVar1 = InternetOpenA(0,1);
InternetOpenUrlA(uVar1, &uStack_64, 0, 0, 0x84000000, 0);
InternetCloseHandle(uVar1);
InternetCloseHandle(0);
FUN_00408090();          // unconditional infection
return 0;

A number that does not add up. The technical press of May 2017 reported that the "patched" variant differed from the original by only 2 bytes. In this sample the changed bytes number 58: one in the jump and 57 in the URL slot. Both can be true at once, because several variants circulated and the jump patch alone is already sufficient — in fact it is the minimum possible. What I cannot do is treat this sample as described by a figure that is not its own.

Where the switch actually sat

The usual way of telling the story is "the switch prevented encryption". That is imprecise, and this sample demonstrates it by construction.

The check lives in the worm's WinMain, before the service is created and before the installer is written to disk. What sat behind the jump was not the encryption: it was everything. Creating the mssecsvc2.0 service, starting the SMB propagation, and dropping tasksche.exe into C:\WINDOWS to run it with /i. If the domain answered, none of that happened. That is why Microsoft talks about the dropper rather than the encryptor.

Neutering the jump unblocks the whole chain: the machine installs itself, spreads, and drops the payload every time. That the encryption never happens, in this sample, has nothing to do with the switch — it is a separate accident, and it is section 05.

Two more things the patch puts on display. The first is what the check was probably for: an anti-sandbox brake. Many analysis environments answer any domain so that no traffic leaves the box, so inside a sandbox the domain "resolves" and the malware sits still. The sinkhole effect of 12 May was, in that reading, the same mechanism used in reverse. The second is more uncomfortable: that global brake had no integrity protection whatsoever. No checksum over its own code, no check on the URL length, no second check anywhere else. One byte.

One thread for the LAN and 128 for the internet

With the brake removed, what stays operational is the part that actually did the damage in 2017. It starts at 0x407bd0 and splits the work two ways: one thread for the local network and 128 for the internet, launched one at a time every two seconds.

The internal target list is built by sub_409160 using GetAdaptersInfo and GetPerAdapterInfo, and for each adapter it adds three things:

It then sorts, deduplicates, and attacks with at most 10 concurrent threads and 50 ms between launches. That gateway-and-DNS detail is what explains why a small mask contained nothing: through the gateway and the DNS it reaches other VLANs even when the local segment is tiny.

The 128 internet threads are simpler and noisier. They generate a random IPv4 and only discard a first octet of 127 and anything ≥ 224. They do not exclude RFC1918, so they also aim at private ranges that happen to be routed over VPN or MPLS. If 445 answers, they sweep the entire /24, from .1 to .254. Each exploitation attempt gets an hour of grace — 0x36ee80 ms — and then a TerminateThread.

The volume that produces is measurable, and it is on public record for this very hash: 18,506 distinct IP addresses contacted across the sandbox runs aggregated by VirusTotal, with Suricata and Snort signatures for SMB sweeping and for EternalBlue. It is not stealthy and does not try to be.

About the exploitation itself I will say what I can: the buffers handed to each host with 445 open are prepared at 0x407a20 and are an x86 DLL of 0x4060 bytes, an x64 one of 0xc8a4, and a copy of the executable itself. The strings __TREEID__PLACEHOLDER__ and __USERID__PLACEHOLDER__ are the slots filled in the SMB template. I did not decompile the inside of the exploitation threads: that the chain is EternalBlue → DoublePulsar → remote execution is documented behaviour of the family, not something verified instruction by instruction here.

The ZIP missing 177 KB

Here is the other half of the story, and it is the half that turns this sample into a curiosity rather than an emergency.

The first thing to undo is a common confusion: tasksche.exe does not encrypt. It is an installer and a loader. The encryption lives in t.wnry, an encrypted DLL that tasksche decrypts in memory and whose TaskStart export does the work. And t.wnry is not in the binary: it travels inside a ZIP resource — type XIA, ID 2058 — protected with the password WNcry@2ol7, fixed and public since 2017.

That ZIP is broken. Not "suspiciously odd": broken, and in a specific way.

TestResult
Local headers PK 03 0432, all of them before the damage
Central directory PK 01 020 occurrences
End of directory PK 05 060 occurrences
Start of the damageoffset 0x31e2b7 (3,269,303), 94.9% into the file
Damaged tail177,022 bytes, entropy 7.9986 bits/byte, not a single header
Password WNcry@2ol7correct: 31 entries decrypt with a valid CRC
t.wnry, u.wnry, taskdl.exe, taskse.exedo not exist in the file
ZIP XIA/2058 — 3,446,325 B intact up to 0x31e2b7 177,022 B · entropy 7.9986 · no headers Recoverable, header by header b.wnry · c.wnry · r.wnry msg/*.wnry ×28 s.wnry up to 99% Not in the file at all t.wnry · u.wnry taskdl.exe · taskse.exe central directory and EOCD minizip looks for PK 05 06 in the last 64 KB. It does not find it: unzOpen returns NULL. Zero files extracted, not even the notes. tasksche carries on, and fails to open t.wnry.
Almost all of the recoverable content is there. What is missing are the four executable pieces and the table the malware's own library needs in order to find them.

The chain of consequences is short and mechanical. The function that opens the resource calls minizip — the string "unzip 0.15 Copyright 1998 Gilles Vollant" is in the binary — and minizip starts with SearchCentralDir, at 0x405edf, which looks for 'P' 'K' 0x05 0x06 in the last 64 KB of the file. Not finding it, it returns -1, unzOpen returns NULL, and not one file is extracted, not even the ransom notes. The WinMain of tasksche ignores that return value and carries on quite happily until it fails to open t.wnry, at which point the function returns NULL and the call to TaskStart never happens.

The detail I like most in the whole analysis is this one: my own extractors do recover 31 files, because they walk the local headers one by one and need no index. The malware cannot, because it depends on the central directory. The difference between a ransomware and a nuisance, in this sample, is a design decision in a library from 1998.

What static analysis cannot determine is the cause of the damage. That it is a continuous block of maximum-entropy data running to the end of the file points to an overwrite rather than a truncation, but that is an inference about the shape, not proof of the motive. An accident while patching, an accident in transfer, or deliberate neutralisation are the three hypotheses, and the file does not separate them.

Two caveats that matter before writing the sample off as harmless. First: there is an edge case. If the working directory already held a valid t.wnry from an earlier infection by a working variant, tasksche would load it and encrypt. It would still be unable to show the ransom screen, because u.wnry is not extracted either: the victim would end up with encrypted files and no instructions. Second: the real risk of this sample is not the encryption, it is the exploit. EternalBlue against an unpatched machine can take it down with a blue screen, and 128 threads hunting for port 445 across the internet are still doing exactly that.

Public runs of the same hash agree point by point: the mssecsvc2.0 service is created, C:\WINDOWS\tasksche.exe /i is launched as a child of the sample, the SMB-sweep alert fires, and tasksche leaves almost no trace — no .WNCRY file, no @WanaDecryptor@.exe, no taskdl.exe, no taskse.exe, and no changed wallpaper.

One key per file, one wallet for everyone

Even though this variant does not encrypt, the cryptographic scheme is still readable, because tasksche uses it to protect itself. That is the only part verified here, and it is worth separating from what is family documentation.

Verified in this sample. The function that reads t.wnry, at 0x4014a6, imports an embedded RSA key with CryptImportKey, uses it to decrypt a 256-byte block — the size of RSA-2048 — holding a 16-byte AES key, and with that key decrypts the DLL. The container has this shape:

WANACRY!   0x100   AES key encrypted with RSA   type   plain size   encrypted DLL
  8 B      4 B               256 B              4 B      8 B          AES-128

A textbook hybrid scheme: AES because it is fast, RSA so that only the holder of the private key can recover the AES key. What is interesting is that the malware applies to itself the same treatment it applies to victims, and that the format of .WNCRY files is this same container, WANACRY! header included. The authors reused their own format.

Public documentation, not verified here — because t.wnry is missing — the encryption of the victim's files chains three levels.

Attacker RSA-2048 same for every victim Victim RSA pair .pky and .eky · per machine AES-128 per file different in every file file.ext.WNCRY WANACRY! | key | type | size Payment ignores the scheme Three BTC wallets, hardcoded inside tasksche.exe, shared by every victim. No per-machine identifier: nobody could tie a payment to a given .eky.
The cryptography is per machine and per file. The till is a single one, for everybody. The two halves of the business do not fit together.

Four consequences follow, and they explain the story of 2017 rather well:

And then there is the contrast that says most about the whole thing. The cryptography is designed per machine; the collection is not. The three Bitcoin addresses are hardcoded in tasksche.exe, at offsets 0xf440, 0xf464 and 0xf488, one after another with a 36-byte stride, and the note references them through a %s that the decryptor fills in at runtime. There is no per-victim identifier anywhere. The operator had no reliable way of knowing which payment matched which .eky, which explains both why paying almost never returned the files and why the total stayed in the $109,000–$140,000 range against more than 200,000 infected machines.

In this variant, of course, none of this runs: with no t.wnry there is no key generation and no encryption.

A vaccine that is a service doing nothing

There is a detail in the installation that can be turned against the worm. At 0x407c40, the malware only starts the spreading service if CreateServiceA succeeds:

eax = CreateServiceA(scm, "mssecsvc2.0", "Microsoft Security Center (2.0) Service",
                     0xf01ff, 0x10, 0x2, 0x1, "<path> -m security", …);
if (eax != 0) {
    StartServiceA(eax, 0, 0);     // only this way does it enter service mode -> spreading
    CloseServiceHandle(eax);
}

If a service with that name already exists, CreateServiceA fails with ERROR_SERVICE_EXISTS, the malware does not start it, and that machine does not spread. The service branch is only reached when the service control manager launches it with -m security, and that does not happen if the service was never created. So occupying the name with a disabled decoy service is enough:

sc create mssecsvc2.0 binPath= "C:\Windows\System32\svchost.exe -k vaccine_placeholder" start= disabled DisplayName= "WannaCry vaccine placeholder"
sc description mssecsvc2.0 "Defensive placeholder: blocks creation of the WannaCry service (mssecsvc2.0). Do not remove."

And now the limitations, which matter more than the trick.

It does not protect against exploitation. The machine remains vulnerable to EternalBlue and the exploit can cause a blue screen. The only thing it prevents is that machine infecting others.

The tasksche.exe drop still happens, because it is on another branch. In this variant it is harmless; in a working one, it is not.

It is specific to this family and this service name: a variant that changes the name sidesteps it effortlessly. And it is a temporary, complementary measure, never a substitute for applying MS17-010 and disabling SMBv1.

It is reasoned from the decompilation, not proven at runtime. Before deploying it anywhere it must be confirmed in an isolated environment: create the service, run the sample, and verify that neither the SMB-sweep alert nor the mass connections to port 445 appear.

Origin: three dates and a 2010 compiler

These are two different questions, and mixing them is the easy mistake: who wrote the kit and who patched this build. The public context in section 01 speaks to the first. On the second there is nothing published, and the sources that touch it say explicitly that it is not known.

What the file does contribute is three dates that fall into a sequence:

Compiled
2010-11-20 09:03:08 UTC — the original build's value, untouched
Inner payload
tasksche.exe first seen publicly on 12 May 2017
This dropper
first seen on 14 May 2017, 08:05 UTC

The compile stamp is the piece that settles the method: it did not change. Whoever touched this recompiled nothing, they edited bytes over an already-built binary — and the 2010 stamp is itself almost certainly faked in the original, so it says nothing about when the code was written. The installer inside is from the day of the outbreak; the patched dropper appears two days later, on the same 14 May that Europol gave the 200,000-victim figure.

On the origin of the kit, what stands out is how the clues are distributed:

Where an origin clue usually sitsWhat is here
PE resource languageAll 0x0409, the default value. Uninformative
Office language in the ransom notes28 of 28 carry \deflangfe1042 and \themelangfe1042: Korean as Word's East Asian language
Fonts declared in the notes28 of 28 declare Korean-charset fonts (fcharset129), with the name written as CP949 bytes: 맑은 고딕 (Malgun Gothic) in 28 of 28, 굴림체 in 5 and 바탕 (Batang) in 3
Quality of the English templateNon-native speaker errors: "But you have not so enough time."
Chinese notesNative quality; the rest machine-translated from English (Flashpoint, 2017)

That is consistent with an Office environment configured in Korean, and with the official attribution of December 2017. But two limits apply, and both matter.

First: language artefacts are trivial to plant. A \deflangfe block and a font name in CP949 are two strings in an RTF file. Anyone wanting to misdirect puts them there in ten minutes. They work as cumulative indication, never as proof on their own.

Second, and more concretely: this evidence does not speak about whoever patched the sample. The 28 notes live inside the ZIP, which is upstream of the patch — it is content inherited from the original build. Of the author of the two patches the binary carries not one linguistic trace: no new strings, no added resources, no new section. Just 58 different bytes and a 177 KB tail that is no longer good for anything.

Quality as a piece of software

It is worth judging it as what it is, because that explains both why it stopped half the world over a weekend and why it collected less than a corner shop.

What is done wellWhere the seams show
Self-contained propagation: it needs no C2 to spread; the worm's only HTTP call is the brake (0x408140)That brake is a single point of failure with no integrity protection at all: one byte at 0x4081a5 changes the global behaviour
Clean component split: worm, installer, encryption engine and ransom interface, each in its own pieceNo obfuscation and no packing: the payload travels as a plain MZ in resource R/1831
Target selection that crosses segments through the gateway's and the DNS server's /24, not just the local mask (0x409160)Fixed, cleartext ZIP password: WNcry@2ol7, the same one for everybody
Concurrency control and timeouts in the scan: at most 10 LAN threads, 50 ms between launches, one hour per exploitation attemptThe unzip return value is never checked. unzOpen returns NULL and WinMain carries on as if nothing happened, until it crashes into a file that does not exist
The encryption engine, encrypted with RSA-2048 + AES-128 and only ever loaded in memory (0x4014a6): the dropper never shows encryption code staticallyThree shared, hardcoded wallets (0xf440 in tasksche), with no per-victim identifier: the collection layer, which is the product's whole purpose, was never built
Fallback chain for the working directory: ProgramData, then Intel, then the Windows directory, then temp (0x401b5f)Extremely noisy scanning: 128 threads and full /24 sweeps light up any IDS within seconds
Two ways of starting itself, a randomly named service and, failing that, CreateProcessA, each with its own wait (0x401ce8, 0x401eff)Those waits are 60 s + 60 s on a mutex that in this variant cannot exist: there is no error detection, only elapsed time
It reuses its own WANACRY! container for the engine and for victim files, instead of inventing two formatsCleartext IOCs: mssecsvc2.0, "Microsoft Security Center (2.0) Service", C:\%s\qeriuwjhrf, tasksche.exe

The verdict: a state-grade exploit with half a business built on top of it. The part that really worked — the propagation — is borrowed: EternalBlue, leaked a month earlier. What is its own is uneven: there are well-considered network engineering decisions, such as reaching other VLANs through the gateway, sitting next to an error handler that amounts to waiting a minute to see what happens. And the collection layer, which is a ransomware's reason to exist, was never designed: you cannot invoice without knowing who paid.

About this particular build there is little to praise and one thing to point out: a third party's patch left the worm working and the extortion broken, which is the worst possible combination for a defender. There is nothing to pay, there is no note to warn you, and it still scans port 445 across half the internet.

Detection and containment

First, because it is this variant's particular trap: do not rely on the kill-switch sinkhole. This sample makes no DNS query and no HTTP request to the domain. A detection anchored on that domain does not see it at all.

Containment:

Hunting ideas:

Indicators. Everything below comes from the binary or its resources:

TypeValue
Dropper SHA-25607c44729e2c570b37db695323249474831f5861d45318bf49ccf5d2f5c8ea1cd
tasksche.exe SHA-2562584e1521065e45ec3c17767c065429038fc6291c091097ea8b22c8a502c41dd
Servicemssecsvc2.0 — "Microsoft Security Center (2.0) Service", ImagePath = <path> -m security
ServiceRandom name, ImagePath = cmd.exe /c "<dir>\tasksche.exe"
FilesC:\WINDOWS\tasksche.exe, C:\WINDOWS\qeriuwjhrf
DirectoryC:\ProgramData\<random>\ or …\Intel\<random>\, hidden and with Everyone:F permissions
Command linestasksche.exe /i, attrib +h ., icacls . /grant Everyone:F /T /C /Q
MutexGlobal\MsWinZonesCacheCounterMutexA0 — created by t.wnry, so it never appears with this variant
PE resourcesDropper: type R, ID 1831. tasksche: type XIA, ID 2058
NetworkHigh-volume outbound TCP/445; sequential sweep of the local subnet and of the gateway's and DNS server's /24; Suricata SMB-sweep signature
Tor hidden services (from c.wnry)gx7ekbenv2riucmf.onion, 57g7spgrzlojinas.onion, xxlvbrloxvriy2c5.onion, 76jdd2ir2embyv47.onion, cwwnhwhlz52maqm7.onion
Tor download (from c.wnry)https://dist.torproject.org/torbrowser/6.5.1/tor-win32-0.2.9.10.zip
BTC wallets (in tasksche)115p7UMMngoj1pMvkpHijcRdfJNXj6LrLn, 12t9YDPgwueZ9NyMgw519p7AA8isjr6SMw, 13AM4VW2dhxYgXeQepoHkHSQuy6NgaEb94

What cannot be claimed from here

The whole analysis is static: nothing was executed, and the extracted artefacts were left read-only. This is what holds up each claim, and what does not:

ClaimConfidenceBasis
The switch is neutralised by two patchesHighFile bytes and matching decompilation in two independent tools
Service installation, tasksche drop and scanning logicHighDecompilation, and agreement with public runs of the same hash
The ZIP is damaged, minizip cannot open it, and t.wnry is absentHighByte-by-byte verification of the resource plus decompilation of the library
This variant does not encryptHighThe above, plus the absence of encryption across several public runs
Hybrid RSA-2048 + AES-128 scheme protecting t.wnryHighDecompilation of 0x4014a6
The decoy-service vaccine blocks propagation from the hostMedium-highDecompilation of 0x407c40; dynamic test still pending
Key scheme used to encrypt the victim's filesMediumPublic documentation of the family. Not verifiable here: t.wnry is missing
Inside of the exploitation threads (EternalBlue, DoublePulsar)MediumKnown behaviour of the family; not decompiled in this work
Who applied the patchesLowNothing in the binary. Public sources say it is not clear
Cause of the ZIP damage: accident or neutralisationLowThe shape of the damage suggests an overwrite, but the motive is not statically determinable

One loose end I preferred to leave open: the internet thread has 20- and 40-minute timers — 0x124f80 and 0x249f00 — that appear to control the renewal of the random prefix. It fits that role, but I did not verify it instruction by instruction, so I describe it and leave it unlabelled.

Is a dynamic pass worth it? Yes, but narrow, and for two concrete things. One: to test the vaccine, because deploying into production a measure reasoned only from the decompilation is exactly what should not be done. Two: to watch tasksche with every process visible, not only the "important" ones, in order to pin the exact step where it dies — whether it gets as far as launching cmd.exe, attrib and icacls before crashing into t.wnry, or whether it stalls in the two 60-second waits. For understanding the encryption, by contrast, a dynamic pass adds nothing: there is no encryption here to run.

Public-context sources